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/** Isolate the source-workspace Angular-major type brand from packed consumers. */
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
4a65196ee
CopilotKit Showcase