1import {
2 ChangeDetectionStrategy,
3 Component,
4 inject,
5 signal,
6} from "@angular/core";
7import type { Type } from "@angular/core";
8import { ActivatedRoute } from "@angular/router";
9import {
10 registerFrontendTool,
11 registerHumanInTheLoop,
12 registerRenderToolCall,
13} from "@copilotkit/angular";
14import type {
15 FrontendToolConfig,
16 HumanInTheLoopConfig,
17 RenderToolCallConfig,
18} from "@copilotkit/angular";
19import { z } from "zod";
20
21import { FeatureHeaderComponent } from "../feature-header.component";
22import { ShowcaseChatHostComponent } from "../showcase-chat-host.component";
23import { ApprovalDialog, TimePickerCard } from "./hitl-cards";
24import { createBackgroundTool } from "./tool-feature-model";
25import {
26 FlightToolCard,
27 HaikuToolCard,
28 NotesToolCard,
29 PieChartToolCard,
30 ReasoningCatchallToolCard,
31 ShowcaseWildcardToolCard,
32 ThreadToolCard,
33 WeatherToolCard,
34} from "./tool-cards";
35
36type ToolArgs = Record<string, unknown>;
37
38@Component({
39 selector: "showcase-tool-feature",
40 imports: [FeatureHeaderComponent, ShowcaseChatHostComponent],
41 changeDetection: ChangeDetectionStrategy.OnPush,
42 host: { class: "feature-page" },
43 template: `
44 <showcase-feature-header />
45 <main
46 class="tool-feature-page"
47 data-testid="frontend-tools-background"
48 [style.background]="background()"
49 [attr.data-background-value]="background()"
50 >
51 <section class="chat-surface" aria-label="Angular tool demonstration">
52 <showcase-chat-host />
53 </section>
54 </main>
55 `,
56})
57export class ToolFeatureComponent {
58 private readonly route = inject(ActivatedRoute);
59 protected readonly background = signal(
60 "linear-gradient(135deg, #f8fafc, #eef2f7)",
61 );
62 private readonly feature =
63 (this.route.snapshot.data["feature"] as string | undefined) ?? "unknown";
64
65 constructor() {
66 this.registerFeature(this.feature);
67 }
68
69 private registerFeature(feature: string): void {
70 switch (feature) {
71 case "gen-ui-tool-based":
72 this.registerGeneratedUiTools();
73 break;
74 case "tool-rendering-custom-catchall":
75 this.registerRenderer(
76 "*",
77 z.record(z.unknown()),
78 ShowcaseWildcardToolCard,
79 );
80 break;
81 case "tool-rendering":
82 this.registerWeatherRenderers();
83 break;
84 case "tool-rendering-reasoning-chain":
85 this.registerWeatherRenderers();
86 this.registerRenderer(
87 "search_flights",
88 z.record(z.unknown()),
89 FlightToolCard,
90 );
91 this.registerRenderer(
92 "*",
93 z.record(z.unknown()),
94 ReasoningCatchallToolCard,
95 );
96 break;
97 case "frontend-tools":
98 this.registerBackgroundTool();
99 break;
100 case "frontend-tools-async":
101 this.registerNotesTool();
102 break;
103 case "threadid-frontend-tool-roundtrip":
104 this.registerThreadTool();
105 break;
106 case "hitl-in-chat":
107 this.registerTimePicker("book_call");
108 this.registerTimePicker("schedule_meeting");
109 break;
110 case "hitl-in-app":
111 this.registerApproval();
112 break;
113 case "tool-rendering-default-catchall":
114 break;
115 }
116 }
117
118 private registerGeneratedUiTools(): void {
119 this.registerFrontendRenderer(
120 "render_pie_chart",
121 "Render a pie chart from labeled values.",
122 PieChartToolCard,
123 );
124 this.registerFrontendRenderer(
125 "render_bar_chart",
126 "Render a bar chart from labeled values.",
127 PieChartToolCard,
128 );
129 this.registerFrontendRenderer(
130 "generate_haiku",
131 "Render a generated haiku.",
132 HaikuToolCard,
133 );
134 }
135
136 private registerWeatherRenderers(): void {
137 this.registerRenderer(
138 "get_weather",
139 z.record(z.unknown()),
140 WeatherToolCard,
141 );
142 this.registerRenderer(
143 "get-weather",
144 z.record(z.unknown()),
145 WeatherToolCard,
146 );
147 }
148
149 private registerBackgroundTool(): void {
150 registerFrontendTool(createBackgroundTool(this.background));
151 }
152
153 private registerNotesTool(): void {
154 registerFrontendTool({
155 name: "query_notes",
156 description: "Search the user's local notes.",
157 parameters: z.object({ query: z.string() }),
158 component: asFrontendRenderer(NotesToolCard),
159 handler: async ({ query }, context) => {
160 await abortableDelay(350, context.signal);
161 const normalized = typeof query === "string" ? query.toLowerCase() : "";
162 return NOTES.filter((note) =>
163 `${note.title} ${note.body} ${note.tags.join(" ")}`
164 .toLowerCase()
165 .includes(normalized),
166 );
167 },
168 });
169 }
170
171 private registerThreadTool(): void {
172 registerFrontendTool({
173 name: "testFrontendToolCalling",
174 description:
175 "Return the supplied label to verify a frontend tool round trip.",
176 parameters: z.object({ label: z.string() }),
177 component: asFrontendRenderer(ThreadToolCard),
178 handler: async ({ label }) => `handled ${label}`,
179 });
180 }
181
182 private registerTimePicker(name: string): void {
183 const config: HumanInTheLoopConfig<ToolArgs> = {
184 name,
185 description: "Ask the user to select a meeting time.",
186 parameters: z.record(z.unknown()),
187 component: asHitlRenderer(TimePickerCard),
188 };
189 registerHumanInTheLoop(config);
190 }
191
192 private registerApproval(): void {
193 const config: HumanInTheLoopConfig<ToolArgs> = {
194 name: "request_user_approval",
195 description: "Ask the user to approve or reject a consequential action.",
196 parameters: z.record(z.unknown()),
197 component: asHitlRenderer(ApprovalDialog),
198 };
199 registerHumanInTheLoop(config);
200 }
201
202 private registerFrontendRenderer(
203 name: string,
204 description: string,
205 component: Type<unknown>,
206 ): void {
207 const config: FrontendToolConfig<ToolArgs> = {
208 name,
209 description,
210 parameters: z.record(z.unknown()),
211 component: asFrontendRenderer(component),
212 handler: async (args) => args,
213 };
214 registerFrontendTool(config);
215 }
216
217 private registerRenderer(
218 name: string,
219 args: RenderToolCallConfig<ToolArgs>["args"],
220 component: Type<unknown>,
221 ): void {
222 registerRenderToolCall({
223 name,
224 args,
225 component: asRenderer(component),
226 });
227 }
228}
229
230const NOTES = [
231 {
232 id: "project-planning",
233 title: "Project planning checklist",
234 body: "Scope, milestones, launch criteria, and owners.",
235 tags: ["project", "planning"],
236 },
237 {
238 id: "auth-review",
239 title: "Authentication review",
240 body: "Audit session expiry and refresh behavior.",
241 tags: ["auth", "security"],
242 },
243 {
244 id: "reading-list",
245 title: "Reading list",
246 body: "Articles to revisit after launch.",
247 tags: ["reading"],
248 },
249] as const;
250
251function abortableDelay(
252 durationMs: number,
253 abortSignal?: AbortSignal,
254): Promise<void> {
255 return new Promise((resolve, reject) => {
256 if (abortSignal?.aborted) {
257 reject(abortSignal.reason);
258 return;
259 }
260 const timeout = setTimeout(resolve, durationMs);
261 abortSignal?.addEventListener(
262 "abort",
263 () => {
264 clearTimeout(timeout);
265 reject(abortSignal.reason);
266 },
267 { once: true },
268 );
269 });
270}
271
272
273function asRenderer(
274 component: Type<unknown>,
275): RenderToolCallConfig<ToolArgs>["component"] {
276 return component as unknown as RenderToolCallConfig<ToolArgs>["component"];
277}
278
279function asFrontendRenderer(
280 component: Type<unknown>,
281): NonNullable<FrontendToolConfig<ToolArgs>["component"]> {
282 return component as unknown as NonNullable<
283 FrontendToolConfig<ToolArgs>["component"]
284 >;
285}
286
287function asHitlRenderer(
288 component: Type<unknown>,
289): HumanInTheLoopConfig<ToolArgs>["component"] {
290 return component as unknown as HumanInTheLoopConfig<ToolArgs>["component"];
291}
292