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