1import type {
2  AttachmentsConfig,
3  RenderToolCallConfig,
4} from "@copilotkit/angular";
5import {
6  ChangeDetectionStrategy,
7  Component,
8  effect,
9  inject,
10  signal,
11  viewChild,
12} from "@angular/core";
13import { ActivatedRoute } from "@angular/router";
14import {
15  CopilotKit,
16  injectAgentStore,
17  registerRenderToolCall,
18} from "@copilotkit/angular";
19import { z } from "zod";
20
21import { agentIdForRoute } from "../../feature-agent";
22import { FeatureHeaderComponent } from "../feature-header.component";
23import { ShowcaseChatHostComponent } from "../showcase-chat-host.component";
24import { WeatherToolCard } from "../tools/tool-cards";
25import {
26  createMultimodalMessage,
27  dedupeUserMessageMedia,
28  rewriteMessagesForLegacyConverter,
29  validateSampleBytes,
30  VOICE_WEATHER_TOOL_NAMES,
31} from "./media-model";
32import type { MediaAgentMessage, SampleSpec } from "./media-model";
33
34const VOICE_SAMPLE_TEXT = "What is the weather in Tokyo?";
35
36type VoiceWeatherArgs = Record<string, unknown>;
37
38/** Exact backend weather tools rendered by the voice demo. */
39export const voiceWeatherRendererConfigs: readonly RenderToolCallConfig<VoiceWeatherArgs>[] =
40  VOICE_WEATHER_TOOL_NAMES.map((name) => ({
41    name,
42    args: z.record(z.unknown()),
43    component:
44      WeatherToolCard as unknown as RenderToolCallConfig<VoiceWeatherArgs>["component"],
45  }));
46
47const SAMPLES: readonly SampleSpec[] = [
48  {
49    buttonLabel: "Try with sample image",
50    filename: "sample.png",
51    mimeType: "image/png",
52    testId: "multimodal-sample-image-button",
53    fetchUrl: "/demo-files/sample.png",
54    autoPrompt: "can you tell me what is in this demo image I just attached",
55  },
56  {
57    buttonLabel: "Try with sample PDF",
58    filename: "sample.pdf",
59    mimeType: "application/pdf",
60    testId: "multimodal-sample-pdf-button",
61    fetchUrl: "/demo-files/sample.pdf",
62    autoPrompt: "can you tell me what is in this demo pdf I just attached",
63  },
64];
65
66const MULTIMODAL_ATTACHMENTS: AttachmentsConfig = {
67  enabled: true,
68  accept: "image/*,application/pdf",
69  maxSize: 10 * 1024 * 1024,
70};
71
72@Component({
73  selector: "showcase-media-feature",
74  imports: [FeatureHeaderComponent, ShowcaseChatHostComponent],
75  changeDetection: ChangeDetectionStrategy.OnPush,
76  host: { class: "feature-page" },
77  template: `
78    <showcase-feature-header />
79    <main class="media-page">
80      <section class="sample-panel" aria-label="Bundled media samples">
81        @if (feature === "voice") {
82          <div class="sample-copy">
83            <strong>Voice input</strong>
84            <span>Use the microphone, or insert a deterministic sample.</span>
85          </div>
86          <button
87            type="button"
88            data-testid="voice-sample-audio-button"
89            [title]="'Inserts: &quot;' + voiceSampleText + '&quot;'"
90            (click)="insertVoiceSample()"
91          >
92            <span aria-hidden="true">πŸŽ™</span>
93            <span>Try a sample audio</span>
94          </button>
95        } @else {
96          <div class="sample-copy">
97            <strong>Bundled samples</strong>
98            <span>Send a real image or PDF through the agent runtime.</span>
99          </div>
100          <div class="sample-actions">
101            @for (sample of samples; track sample.testId) {
102              <button
103                type="button"
104                [attr.data-testid]="sample.testId"
105                [disabled]="loading() !== null"
106                (click)="sendSample(sample)"
107              >
108                {{ loading() === sample.testId ? "Sending…" : sample.buttonLabel }}
109              </button>
110            }
111          </div>
112        }
113        @if (error()) {
114          <p class="sample-error" role="alert">{{ error() }}</p>
115        }
116      </section>
117      <section class="chat-surface" aria-label="CopilotKit assistant">
118        <showcase-chat-host
119          [agentId]="agentId"
120          [attachments]="
121            feature === 'multimodal' ? multimodalAttachments : undefined
122          "
123        />
124      </section>
125    </main>
126  `,
127  styles: `
128    .media-page {
129      display: grid;
130      min-height: 0;
131      grid-template-rows: auto minmax(0, 1fr);
132      gap: 0.75rem;
133      padding: 0.75rem;
134      background: #eef3f7;
135    }
136    .sample-panel {
137      display: flex;
138      flex-wrap: wrap;
139      align-items: center;
140      gap: 0.75rem 1rem;
141      padding: 0.75rem 1rem;
142      border: 1px solid #d8e0ea;
143      border-radius: 0.9rem;
144      background: #fff;
145    }
146    .sample-copy {
147      display: grid;
148      flex: 1 1 16rem;
149      gap: 0.15rem;
150      color: #14213d;
151    }
152    .sample-copy span {
153      color: #52637a;
154      font-size: 0.82rem;
155    }
156    .sample-actions {
157      display: flex;
158      flex-wrap: wrap;
159      gap: 0.5rem;
160    }
161    button {
162      display: inline-flex;
163      align-items: center;
164      gap: 0.4rem;
165      padding: 0.55rem 0.8rem;
166      border: 1px solid #c7d2e0;
167      border-radius: 0.65rem;
168      color: #20324d;
169      background: #fff;
170      font: inherit;
171      font-size: 0.84rem;
172      font-weight: 650;
173      cursor: pointer;
174    }
175    button:hover:not(:disabled) {
176      border-color: #4263eb;
177      background: #f5f7ff;
178    }
179    button:focus-visible {
180      outline: 3px solid #91a7ff;
181      outline-offset: 2px;
182    }
183    button:disabled {
184      cursor: wait;
185      opacity: 0.62;
186    }
187    .sample-error {
188      flex-basis: 100%;
189      margin: 0;
190      color: #991b1b;
191      font-size: 0.84rem;
192    }
193    .chat-surface {
194      min-height: 0;
195      overflow: hidden;
196      border: 1px solid #d8e0ea;
197      border-radius: 1rem;
198      background: #fff;
199    }
200  `,
201})
202export class MediaFeatureComponent {
203  private readonly route = inject(ActivatedRoute);
204  private readonly copilotKit = inject(CopilotKit);
205  private readonly chatHost = viewChild.required(ShowcaseChatHostComponent);
206  protected readonly feature =
207    (this.route.snapshot.data["feature"] as string | undefined) ?? "voice";
208  protected readonly agentId = agentIdForRoute(this.feature, this.route);
209  private readonly agentStore = injectAgentStore(this.agentId);
210  protected readonly voiceSampleText = VOICE_SAMPLE_TEXT;
211  protected readonly samples = SAMPLES;
212  protected readonly multimodalAttachments = MULTIMODAL_ATTACHMENTS;
213  protected readonly loading = signal<string | null>(null);
214  protected readonly error = signal<string | null>(null);
215
216  constructor() {
217    if (this.feature === "voice") {
218      for (const config of voiceWeatherRendererConfigs) {
219        registerRenderToolCall(config);
220      }
221    }
222    if (this.feature === "multimodal") {
223      effect((onCleanup) => {
224        const handle = installLegacyConverterShim(this.agentStore().agent);
225        onCleanup(() => handle.unsubscribe());
226      });
227    }
228  }
229
230  /** Insert the deterministic voice sample into the actual chat composer. */
231  protected insertVoiceSample(): void {
232    if (!this.chatHost().populateComposer(VOICE_SAMPLE_TEXT)) {
233      this.error.set("The chat composer is not ready yet. Try again.");
234      return;
235    }
236    this.error.set(null);
237  }
238
239  /** Fetch, validate, attach, and dispatch one canonical media sample. */
240  protected async sendSample(spec: SampleSpec): Promise<void> {
241    if (this.loading() !== null) return;
242    this.loading.set(spec.testId);
243    this.error.set(null);
244    try {
245      const response = await fetch(spec.fetchUrl);
246      if (!response.ok) {
247        throw new Error(
248          `Could not fetch sample "${spec.filename}" (HTTP ${response.status}).`,
249        );
250      }
251      const buffer = await response.arrayBuffer();
252      const bytes = new Uint8Array(buffer);
253      validateSampleBytes(bytes, spec.mimeType, spec.filename);
254      const base64 = await bufferToBase64(buffer, spec.mimeType);
255      const agent = this.agentStore().agent;
256      const message = createMultimodalMessage(
257        spec,
258        base64,
259        buffer.byteLength,
260        createMessageId(),
261      );
262      agent.addMessage(message as Parameters<typeof agent.addMessage>[0]);
263      await this.copilotKit.core.runAgent({ agent });
264    } catch (error: unknown) {
265      console.error("[showcase-angular:multimodal] Sample send failed", error);
266      this.error.set(
267        error instanceof Error ? error.message : "The sample send failed.",
268      );
269    } finally {
270      this.loading.set(null);
271    }
272  }
273}
274
275interface SubscribableAgent {
276  subscribe: (subscriber: unknown) => { unsubscribe: () => void };
277}
278
279/** Install the converter compatibility subscribers on one active agent. */
280function installLegacyConverterShim(agent: object): {
281  unsubscribe: () => void;
282} {
283  const rewrite = ({
284    messages,
285  }: {
286    messages: ReadonlyArray<Readonly<MediaAgentMessage>>;
287  }) => {
288    const rewritten = rewriteMessagesForLegacyConverter(messages);
289    return rewritten ? { messages: rewritten } : undefined;
290  };
291  const dedupe = ({
292    messages,
293  }: {
294    messages: ReadonlyArray<Readonly<MediaAgentMessage>>;
295  }) => {
296    const deduped = dedupeUserMessageMedia(messages);
297    return deduped ? { messages: deduped } : undefined;
298  };
299  return (agent as SubscribableAgent).subscribe({
300    onRunInitialized: rewrite,
301    onMessagesSnapshotEvent: dedupe,
302    onRunFinalized: dedupe,
303  });
304}
305
306/** Convert browser bytes to the base64 payload used by AG-UI data sources. */
307function bufferToBase64(
308  buffer: ArrayBuffer,
309  mimeType: string,
310): Promise<string> {
311  return new Promise((resolve, reject) => {
312    const reader = new FileReader();
313    reader.addEventListener(
314      "error",
315      () =>
316        reject(
317          reader.error ?? new Error("The selected media could not be read."),
318        ),
319      { once: true },
320    );
321    reader.addEventListener(
322      "load",
323      () => {
324        if (typeof reader.result !== "string") {
325          reject(new Error("The selected media returned an invalid result."));
326          return;
327        }
328        const comma = reader.result.indexOf(",");
329        resolve(comma >= 0 ? reader.result.slice(comma + 1) : reader.result);
330      },
331      { once: true },
332    );
333    reader.readAsDataURL(new Blob([buffer], { type: mimeType }));
334  });
335}
336
337/** Create a collision-resistant client message identifier. */
338function createMessageId(): string {
339  return (
340    globalThis.crypto?.randomUUID?.() ??
341    `angular-${Date.now()}-${Math.random().toString(16).slice(2)}`
342  );
343}
344
4a65196ee
CopilotKit Showcase