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
99 class="headless-panel"
100 data-testid="copilot-chat"
101 [attr.data-copilot-running]="isRunning()"
102 >
103 <h2>Headless Chat (Complete)</h2>
104 <div class="headless-messages" aria-live="polite">
105 @for (message of messages(); track message.id) {
106 @if (message.role === "user") {
107 <div class="headless-user-message" data-message-role="user">
108 {{ messageText(message) }}
109 </div>
110 } @else if (message.role === "assistant") {
111 <div
112 class="headless-assistant-message"
113 data-testid="headless-message-assistant"
114 data-message-role="assistant"
115 >
116 @if (messageText(message); as content) {
117 <p>{{ content }}</p>
118 }
119 @for (call of message.toolCalls ?? []; track call.id) {
120 @switch (call.function.name) {
121 @case ("get_weather") {
122 <showcase-headless-weather-card [toolCall]="toolCall(call)" />
123 }
124 @case ("get_stock_price") {
125 <showcase-headless-stock-card [toolCall]="toolCall(call)" />
126 }
127 @case ("highlight_note") {
128 <showcase-headless-highlight-card
129 [toolCall]="toolCall(call)"
130 />
131 }
132 @case ("get_revenue_chart") {
133 <showcase-headless-revenue-card [toolCall]="toolCall(call)" />
134 }
135 }
136 }
137 </div>
138 }
139 }
140 </div>
141 <div class="headless-suggestions">
142 @for (suggestion of suggestions; track suggestion.label) {
143 <button
144 type="button"
145 [disabled]="isRunning()"
146 (click)="send(suggestion.prompt)"
147 >
148 {{ suggestion.label }}
149 </button>
150 }
151 </div>
152 @if (error()) {
153 <p class="headless-error" role="alert">{{ error() }}</p>
154 }
155 <div class="headless-composer">
156 <textarea
157 rows="2"
158 aria-label="Message"
159 [value]="inputValue()"
160 (input)="updateInput($event)"
161 (keydown)="handleComposerKeydown($event)"
162 ></textarea>
163 <button
164 type="button"
165 [disabled]="isRunning() || !inputValue().trim()"
166 (click)="send()"
167 >
168 Send
169 </button>
170 </div>
171 </section>
172 </main>
173 `,
174})
175export class HeadlessCompleteFeatureComponent extends HeadlessChatController {
176 protected readonly messageText = messageText;
177 protected readonly suggestions = [
178 { label: "Weather", prompt: "What's the weather in Tokyo?" },
179 { label: "Stock price", prompt: "What's the price of AAPL right now?" },
180 {
181 label: "Highlight a note",
182 prompt: "Highlight this note for me: 'ship the demo on Friday'.",
183 },
184 {
185 label: "Revenue chart",
186 prompt: "Show me a chart of revenue over the last six months.",
187 },
188 ] as const;
189
190 constructor() {
191 super("headless-complete");
192 registerFrontendTool({
193 name: "highlight_note",
194 description: "Highlight a short note or phrase in the chat.",
195 parameters: z.object({
196 text: z.string(),
197 color: z.string().optional(),
198 }),
199 followUp: false,
200 handler: async ({ text, color }) => ({ text, color: color ?? "yellow" }),
201 });
202 }
203
204 protected toolCall(
205 call: NonNullable<ShowcaseMessage["toolCalls"]>[number],
206 ): AngularToolCall {
207 return {
208 name: call.function.name,
209 args: toolArguments(call.function.arguments),
210 status: "executing",
211 result: undefined,
212 };
213 }
214}
215