1import {
2  ChangeDetectionStrategy,
3  Component,
4  computed,
5  input,
6} from "@angular/core";
7import { registerFrontendTool } from "@copilotkit/angular";
8import type { AngularToolCall } from "@copilotkit/angular";
9import { z } from "zod";
10
11import { FeatureHeaderComponent } from "../feature-header.component";
12import { HeadlessChatController } from "./headless-chat";
13import type { ShowcaseMessage } from "./headless-chat.types";
14import { messageText, toolArguments } from "./headless-message-utils";
15
16@Component({
17  selector: "showcase-headless-weather-card",
18  changeDetection: ChangeDetectionStrategy.OnPush,
19  host: { class: "headless-tool-card", "data-testid": "headless-weather-card" },
20  template: `
21    <span>Weather</span><strong>{{ location() }}</strong>
22    <p>22°C · Partly cloudy</p>
23  `,
24})
25export class HeadlessWeatherCard {
26  readonly toolCall =
27    input.required<AngularToolCall<{ location?: string; city?: string }>>();
28  protected readonly location = computed(
29    () => this.toolCall().args.location ?? this.toolCall().args.city ?? "Tokyo",
30  );
31}
32
33@Component({
34  selector: "showcase-headless-stock-card",
35  changeDetection: ChangeDetectionStrategy.OnPush,
36  host: { class: "headless-tool-card", "data-testid": "headless-stock-card" },
37  template: `
38    <span>Stock price</span><strong>{{ toolCall().args.ticker ?? "AAPL" }}</strong>
39    <p>$189.42 · +1.27%</p>
40  `,
41})
42export class HeadlessStockCard {
43  readonly toolCall = input.required<AngularToolCall<{ ticker?: string }>>();
44}
45
46@Component({
47  selector: "showcase-headless-highlight-card",
48  changeDetection: ChangeDetectionStrategy.OnPush,
49  host: {
50    class: "headless-tool-card highlight",
51    "data-testid": "headless-highlight-card",
52  },
53  template: `
54    <span>Highlighted note</span
55    ><strong>{{ toolCall().args.text ?? "Note" }}</strong>
56  `,
57})
58export class HeadlessHighlightCard {
59  readonly toolCall =
60    input.required<AngularToolCall<{ text?: string; color?: string }>>();
61}
62
63@Component({
64  selector: "showcase-headless-revenue-card",
65  changeDetection: ChangeDetectionStrategy.OnPush,
66  host: {
67    class: "headless-tool-card",
68    "data-testid": "headless-revenue-chart",
69  },
70  template: `
71    <span>Revenue</span><strong>Six-month revenue</strong>
72    <div class="mini-bars" aria-label="Revenue increased over six months">
73      @for (height of bars; track $index) {
74        <i [style.height.%]="height"></i>
75      }
76    </div>
77  `,
78})
79export class HeadlessRevenueCard {
80  readonly toolCall = input.required<AngularToolCall>();
81  protected readonly bars = [32, 45, 51, 63, 74, 92];
82}
83
84@Component({
85  selector: "showcase-headless-complete-feature",
86  imports: [
87    FeatureHeaderComponent,
88    HeadlessWeatherCard,
89    HeadlessStockCard,
90    HeadlessHighlightCard,
91    HeadlessRevenueCard,
92  ],
93  changeDetection: ChangeDetectionStrategy.OnPush,
94  host: { class: "feature-page" },
95  template: `
96    <showcase-feature-header />
97    <main class="headless-page" aria-label="Complete headless chat">
98      <section class="headless-panel">
99        <h2>Headless Chat (Complete)</h2>
100        <div class="headless-messages" aria-live="polite">
101          @for (message of messages(); track message.id) {
102            @if (message.role === "user") {
103              <div class="headless-user-message" data-message-role="user">
104                {{ messageText(message) }}
105              </div>
106            } @else if (message.role === "assistant") {
107              <div
108                class="headless-assistant-message"
109                data-testid="headless-message-assistant"
110                data-message-role="assistant"
111              >
112                @if (messageText(message); as content) {
113                  <p>{{ content }}</p>
114                }
115                @for (call of message.toolCalls ?? []; track call.id) {
116                  @switch (call.function.name) {
117                    @case ("get_weather") {
118                      <showcase-headless-weather-card [toolCall]="toolCall(call)" />
119                    }
120                    @case ("get_stock_price") {
121                      <showcase-headless-stock-card [toolCall]="toolCall(call)" />
122                    }
123                    @case ("highlight_note") {
124                      <showcase-headless-highlight-card
125                        [toolCall]="toolCall(call)"
126                      />
127                    }
128                    @case ("get_revenue_chart") {
129                      <showcase-headless-revenue-card [toolCall]="toolCall(call)" />
130                    }
131                  }
132                }
133              </div>
134            }
135          }
136        </div>
137        <div class="headless-suggestions">
138          @for (suggestion of suggestions; track suggestion.label) {
139            <button
140              type="button"
141              [disabled]="isRunning()"
142              (click)="send(suggestion.prompt)"
143            >
144              {{ suggestion.label }}
145            </button>
146          }
147        </div>
148        @if (error()) {
149          <p class="headless-error" role="alert">{{ error() }}</p>
150        }
151        <div class="headless-composer">
152          <textarea
153            rows="2"
154            aria-label="Message"
155            [value]="inputValue()"
156            (input)="updateInput($event)"
157            (keydown)="handleComposerKeydown($event)"
158          ></textarea>
159          <button
160            type="button"
161            [disabled]="isRunning() || !inputValue().trim()"
162            (click)="send()"
163          >
164            Send
165          </button>
166        </div>
167      </section>
168    </main>
169  `,
170})
171export class HeadlessCompleteFeatureComponent extends HeadlessChatController {
172  protected readonly messageText = messageText;
173  protected readonly suggestions = [
174    { label: "Weather", prompt: "What's the weather in Tokyo?" },
175    { label: "Stock price", prompt: "What's the price of AAPL right now?" },
176    {
177      label: "Highlight a note",
178      prompt: "Highlight this note for me: 'ship the demo on Friday'.",
179    },
180    {
181      label: "Revenue chart",
182      prompt: "Show me a chart of revenue over the last six months.",
183    },
184  ] as const;
185
186  constructor() {
187    super("headless-complete");
188    registerFrontendTool({
189      name: "highlight_note",
190      description: "Highlight a short note or phrase in the chat.",
191      parameters: z.object({
192        text: z.string(),
193        color: z.string().optional(),
194      }),
195      handler: async ({ text, color }) => ({ text, color: color ?? "yellow" }),
196    });
197  }
198
199  protected toolCall(
200    call: NonNullable<ShowcaseMessage["toolCalls"]>[number],
201  ): AngularToolCall {
202    return {
203      name: call.function.name,
204      args: toolArguments(call.function.arguments),
205      status: "executing",
206      result: undefined,
207    };
208  }
209}
210
4a65196ee
CopilotKit Showcase