@runtypelabs/persona 3.19.0 → 3.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts CHANGED
@@ -19,7 +19,8 @@ import {
19
19
  ClientChatRequest,
20
20
  ClientFeedbackRequest,
21
21
  ClientFeedbackType,
22
- PersonaArtifactKind
22
+ PersonaArtifactKind,
23
+ ContentPart
23
24
  } from "./types";
24
25
  import {
25
26
  extractTextFromJson,
@@ -44,6 +45,36 @@ type SSEHandler = (event: AgentWidgetEvent) => void;
44
45
  const DEFAULT_ENDPOINT = "https://api.runtype.com/v1/dispatch";
45
46
  const DEFAULT_CLIENT_API_BASE = "https://api.runtype.com";
46
47
 
48
+ /**
49
+ * Derive a download filename for `agent_media` parts that are delivered
50
+ * without one. Maps a few well-known MIME types to friendly extensions and
51
+ * falls back to `attachment.<subtype>` (or just `attachment` for opaque
52
+ * types like `application/octet-stream`).
53
+ */
54
+ function filenameFromMediaType(mediaType: string): string {
55
+ // MIME types are case-insensitive (RFC 7231); compare against a lowercased
56
+ // copy so callers that pass mixed casing still hit the friendly extensions.
57
+ const lower = mediaType.toLowerCase();
58
+ const knownExtensions: Record<string, string> = {
59
+ "application/pdf": "pdf",
60
+ "application/json": "json",
61
+ "application/zip": "zip",
62
+ "text/plain": "txt",
63
+ "text/csv": "csv",
64
+ "text/markdown": "md"
65
+ };
66
+ const ext = knownExtensions[lower];
67
+ if (ext) return `attachment.${ext}`;
68
+ const slash = lower.indexOf("/");
69
+ if (slash > 0) {
70
+ const subtype = lower.slice(slash + 1).split(";")[0]?.trim() ?? "";
71
+ if (subtype && subtype !== "octet-stream" && /^[a-z0-9.+-]+$/i.test(subtype)) {
72
+ return `attachment.${subtype}`;
73
+ }
74
+ }
75
+ return "attachment";
76
+ }
77
+
47
78
  /**
48
79
  * Check if a message has valid (non-empty) content for sending to the API.
49
80
  * Filters out messages with empty content that would cause validation errors.
@@ -2563,6 +2594,124 @@ export class AgentWidgetClient {
2563
2594
  }
2564
2595
  }
2565
2596
  }
2597
+ } else if (payloadType === "agent_media") {
2598
+ // A tool produced media (image / audio / video / file). Render it
2599
+ // as a synthetic assistant message inserted at the point the tool
2600
+ // completed — between the tool bubble and the next text turn.
2601
+ //
2602
+ // Wire format is the AI SDK–aligned `MediaContentPart` from
2603
+ // @runtypelabs/shared:
2604
+ // { type: 'media', data, mediaType } // AI SDK v6: base64
2605
+ // { type: 'image-url', url, mediaType? } // AI SDK v3/v4
2606
+ // { type: 'file-url', url, mediaType } // AI SDK v3/v4
2607
+ const rawMedia = Array.isArray(payload.media) ? payload.media : [];
2608
+ const mediaContentParts: ContentPart[] = [];
2609
+ for (const part of rawMedia) {
2610
+ if (!part || typeof part !== "object") continue;
2611
+ const rec = part as Record<string, unknown>;
2612
+ const partType = typeof rec.type === "string" ? rec.type : undefined;
2613
+
2614
+ // Resolve `(src, mediaType)` for the part.
2615
+ // RFC 7231 says MIME types are case-insensitive, so we canonicalize
2616
+ // to lowercase once here. That makes the `startsWith("image/")` /
2617
+ // `"audio/"` / `"video/"` bucket checks robust to upstream tools
2618
+ // that emit non-canonical casing like `Image/PNG`.
2619
+ const rawMediaType =
2620
+ typeof rec.mediaType === "string" ? rec.mediaType.toLowerCase() : "";
2621
+ let src: string | null = null;
2622
+ let mediaType = "";
2623
+ if (partType === "media") {
2624
+ const data = typeof rec.data === "string" ? rec.data : undefined;
2625
+ if (!data) continue;
2626
+ // Empty/missing mediaType yields `data:;base64,...` which RFC 2397
2627
+ // resolves to `text/plain` — stamp a default so the data URI is
2628
+ // well-formed and the part lands in the file bucket.
2629
+ mediaType = rawMediaType.length > 0 ? rawMediaType : "application/octet-stream";
2630
+ src = `data:${mediaType};base64,${data}`;
2631
+ } else if (partType === "image-url") {
2632
+ const url = typeof rec.url === "string" ? rec.url : undefined;
2633
+ if (!url) continue;
2634
+ mediaType = rawMediaType;
2635
+ src = url;
2636
+ } else if (partType === "file-url") {
2637
+ const url = typeof rec.url === "string" ? rec.url : undefined;
2638
+ if (!url) continue;
2639
+ mediaType = rawMediaType;
2640
+ src = url;
2641
+ } else {
2642
+ continue;
2643
+ }
2644
+ if (!src) continue;
2645
+
2646
+ // Pick the right rendering bucket based on mediaType.
2647
+ if (partType === "image-url" || mediaType.startsWith("image/")) {
2648
+ mediaContentParts.push({
2649
+ type: "image",
2650
+ image: src,
2651
+ ...(mediaType ? { mimeType: mediaType } : {}),
2652
+ });
2653
+ } else if (mediaType.startsWith("audio/")) {
2654
+ mediaContentParts.push({
2655
+ type: "audio",
2656
+ audio: src,
2657
+ mimeType: mediaType,
2658
+ });
2659
+ } else if (mediaType.startsWith("video/")) {
2660
+ mediaContentParts.push({
2661
+ type: "video",
2662
+ video: src,
2663
+ mimeType: mediaType,
2664
+ });
2665
+ } else {
2666
+ const resolvedMediaType = mediaType || "application/octet-stream";
2667
+ mediaContentParts.push({
2668
+ type: "file",
2669
+ data: src,
2670
+ mimeType: resolvedMediaType,
2671
+ filename: filenameFromMediaType(resolvedMediaType),
2672
+ });
2673
+ }
2674
+ }
2675
+
2676
+ if (mediaContentParts.length > 0) {
2677
+ // Uniquify per emission. A tool may emit multiple `agent_media`
2678
+ // events for the same `toolCallId` (e.g. streamed/batched media);
2679
+ // sharing an id would let `emitMessage` merge them by id and
2680
+ // overwrite the prior `contentParts`.
2681
+ const seq = nextSequence();
2682
+ const toolCallIdRaw = payload.toolCallId;
2683
+ const mediaIdSuffix =
2684
+ typeof toolCallIdRaw === "string" && toolCallIdRaw.length > 0
2685
+ ? `${toolCallIdRaw}-${seq}`
2686
+ : String(seq);
2687
+ const mediaMessage: AgentWidgetMessage = {
2688
+ id: `agent-media-${mediaIdSuffix}`,
2689
+ role: "assistant",
2690
+ content: "",
2691
+ contentParts: mediaContentParts,
2692
+ createdAt: new Date().toISOString(),
2693
+ streaming: false,
2694
+ sequence: seq,
2695
+ agentMetadata: {
2696
+ executionId: payload.executionId,
2697
+ iteration: payload.iteration,
2698
+ },
2699
+ };
2700
+ emitMessage(mediaMessage);
2701
+
2702
+ // Seal any in-flight assistant text bubble before splitting the
2703
+ // stream. Without this, an orphan bubble retains `streaming: true`
2704
+ // forever — `agent_complete` only finalizes the latest
2705
+ // `assistantMessage`, so the typing/caret indicator would stay on
2706
+ // the prior bubble even though no more deltas will arrive.
2707
+ const prevAssistant = assistantMessage as AgentWidgetMessage | null;
2708
+ if (prevAssistant) {
2709
+ prevAssistant.streaming = false;
2710
+ emitMessage(prevAssistant);
2711
+ }
2712
+ assistantMessage = null;
2713
+ assistantMessageRef.current = null;
2714
+ }
2566
2715
  } else if (payloadType === "agent_iteration_complete") {
2567
2716
  // Iteration complete - no special handling needed
2568
2717
  // In 'separate' mode, message finalization happens at next iteration_start
@@ -3,6 +3,7 @@ import { describe, it, expect } from "vitest";
3
3
  import {
4
4
  createStandardBubble,
5
5
  isSafeImageSrc,
6
+ isSafeMediaSrc,
6
7
  resolveStopReasonNoticeText,
7
8
  getDefaultStopReasonNoticeCopy,
8
9
  } from "./message-bubble";
@@ -274,3 +275,194 @@ describe("createStandardBubble — stopReason notice", () => {
274
275
  expect(bubble.querySelector(".persona-message-stop-reason")).toBeNull();
275
276
  });
276
277
  });
278
+
279
+ describe("isSafeMediaSrc", () => {
280
+ it("allows https URLs", () => {
281
+ expect(isSafeMediaSrc("https://example.com/audio.mp3")).toBe(true);
282
+ });
283
+
284
+ it("allows http URLs", () => {
285
+ expect(isSafeMediaSrc("http://example.com/audio.mp3")).toBe(true);
286
+ });
287
+
288
+ it("allows blob URLs", () => {
289
+ expect(isSafeMediaSrc("blob:http://example.com/abc-123")).toBe(true);
290
+ });
291
+
292
+ it("allows audio data URIs", () => {
293
+ expect(isSafeMediaSrc("data:audio/mpeg;base64,AAAA")).toBe(true);
294
+ });
295
+
296
+ it("allows video data URIs", () => {
297
+ expect(isSafeMediaSrc("data:video/mp4;base64,AAAA")).toBe(true);
298
+ });
299
+
300
+ it("allows binary file data URIs", () => {
301
+ expect(isSafeMediaSrc("data:application/pdf;base64,AAAA")).toBe(true);
302
+ });
303
+
304
+ it("blocks javascript: URIs", () => {
305
+ expect(isSafeMediaSrc("javascript:alert(1)")).toBe(false);
306
+ });
307
+
308
+ it("blocks data:text/html URIs", () => {
309
+ expect(isSafeMediaSrc("data:text/html,<script>alert(1)</script>")).toBe(false);
310
+ expect(isSafeMediaSrc("data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==")).toBe(false);
311
+ });
312
+
313
+ it("blocks other executable data: types", () => {
314
+ expect(isSafeMediaSrc("data:text/javascript,alert(1)")).toBe(false);
315
+ expect(isSafeMediaSrc("data:text/xml,<svg onload=alert(1)/>")).toBe(false);
316
+ expect(isSafeMediaSrc("data:application/xhtml+xml,<html>")).toBe(false);
317
+ });
318
+
319
+ it("blocks data:image/svg+xml URIs (XSS via right-click open in new tab)", () => {
320
+ expect(isSafeMediaSrc("data:image/svg+xml,<svg onload=alert(1)>")).toBe(false);
321
+ expect(isSafeMediaSrc("data:image/svg+xml;base64,PHN2Zz4=")).toBe(false);
322
+ expect(isSafeMediaSrc("data:image/SVG+XML;base64,PHN2Zz4=")).toBe(false);
323
+ });
324
+
325
+ it("allows inert data:text/* payloads (plain, csv, markdown)", () => {
326
+ expect(isSafeMediaSrc("data:text/plain;base64,SGVsbG8=")).toBe(true);
327
+ expect(isSafeMediaSrc("data:text/csv;base64,YSxiCjEsMg==")).toBe(true);
328
+ expect(isSafeMediaSrc("data:text/markdown;base64,IyBIaQ==")).toBe(true);
329
+ });
330
+
331
+ it("allows relative paths", () => {
332
+ expect(isSafeMediaSrc("relative/file.mp3")).toBe(true);
333
+ });
334
+ });
335
+
336
+ describe("createStandardBubble — audio/video/file content parts", () => {
337
+ it("renders an <audio> element with controls for an audio content part", () => {
338
+ const bubble = createStandardBubble(
339
+ makeMessage({
340
+ contentParts: [
341
+ {
342
+ type: "audio",
343
+ audio: "data:audio/mpeg;base64,AAAA",
344
+ mimeType: "audio/mpeg",
345
+ },
346
+ ],
347
+ }),
348
+ ({ text }) => text
349
+ );
350
+
351
+ const container = bubble.querySelector('[data-message-attachments="audio"]');
352
+ expect(container).not.toBeNull();
353
+ const audio = container?.querySelector("audio") as HTMLAudioElement | null;
354
+ expect(audio).not.toBeNull();
355
+ expect(audio?.controls).toBe(true);
356
+ expect(audio?.getAttribute("src")).toBe("data:audio/mpeg;base64,AAAA");
357
+ });
358
+
359
+ it("renders an <audio> element for a URL-based audio part", () => {
360
+ const bubble = createStandardBubble(
361
+ makeMessage({
362
+ contentParts: [
363
+ {
364
+ type: "audio",
365
+ audio: "https://example.com/clip.mp3",
366
+ mimeType: "audio/mpeg",
367
+ },
368
+ ],
369
+ }),
370
+ ({ text }) => text
371
+ );
372
+
373
+ const audio = bubble.querySelector(
374
+ '[data-message-attachments="audio"] audio'
375
+ ) as HTMLAudioElement | null;
376
+ expect(audio?.getAttribute("src")).toBe("https://example.com/clip.mp3");
377
+ });
378
+
379
+ it("skips audio parts with unsafe schemes", () => {
380
+ const bubble = createStandardBubble(
381
+ makeMessage({
382
+ contentParts: [
383
+ {
384
+ type: "audio",
385
+ audio: "javascript:alert(1)",
386
+ mimeType: "audio/mpeg",
387
+ },
388
+ ],
389
+ }),
390
+ ({ text }) => text
391
+ );
392
+
393
+ expect(
394
+ bubble.querySelector('[data-message-attachments="audio"]')
395
+ ).toBeNull();
396
+ });
397
+
398
+ it("renders a <video> element with controls for a video content part", () => {
399
+ const bubble = createStandardBubble(
400
+ makeMessage({
401
+ contentParts: [
402
+ {
403
+ type: "video",
404
+ video: "https://example.com/clip.mp4",
405
+ mimeType: "video/mp4",
406
+ },
407
+ ],
408
+ }),
409
+ ({ text }) => text
410
+ );
411
+
412
+ const video = bubble.querySelector(
413
+ '[data-message-attachments="video"] video'
414
+ ) as HTMLVideoElement | null;
415
+ expect(video).not.toBeNull();
416
+ expect(video?.controls).toBe(true);
417
+ expect(video?.getAttribute("src")).toBe("https://example.com/clip.mp4");
418
+ });
419
+
420
+ it("renders a download link for a file content part", () => {
421
+ const bubble = createStandardBubble(
422
+ makeMessage({
423
+ contentParts: [
424
+ {
425
+ type: "file",
426
+ data: "data:application/pdf;base64,AAAA",
427
+ mimeType: "application/pdf",
428
+ filename: "report.pdf",
429
+ },
430
+ ],
431
+ }),
432
+ ({ text }) => text
433
+ );
434
+
435
+ const link = bubble.querySelector(
436
+ '[data-message-attachments="files"] a'
437
+ ) as HTMLAnchorElement | null;
438
+ expect(link).not.toBeNull();
439
+ expect(link?.getAttribute("href")).toBe("data:application/pdf;base64,AAAA");
440
+ expect(link?.getAttribute("download")).toBe("report.pdf");
441
+ expect(link?.textContent).toBe("report.pdf");
442
+ // Cross-origin URLs ignore `download`, so we open in a new tab to avoid
443
+ // navigating the chat page away from the conversation.
444
+ expect(link?.getAttribute("target")).toBe("_blank");
445
+ expect(link?.getAttribute("rel")).toBe("noopener noreferrer");
446
+ });
447
+
448
+ it("renders a download link for a URL-hosted file", () => {
449
+ const bubble = createStandardBubble(
450
+ makeMessage({
451
+ contentParts: [
452
+ {
453
+ type: "file",
454
+ data: "https://example.com/report.pdf",
455
+ mimeType: "application/pdf",
456
+ filename: "report.pdf",
457
+ },
458
+ ],
459
+ }),
460
+ ({ text }) => text
461
+ );
462
+
463
+ const link = bubble.querySelector(
464
+ '[data-message-attachments="files"] a'
465
+ ) as HTMLAnchorElement | null;
466
+ expect(link?.getAttribute("href")).toBe("https://example.com/report.pdf");
467
+ });
468
+ });
@@ -8,6 +8,9 @@ import {
8
8
  AgentWidgetMessageFeedback,
9
9
  LoadingIndicatorRenderContext,
10
10
  ImageContentPart,
11
+ AudioContentPart,
12
+ VideoContentPart,
13
+ FileContentPart,
11
14
  StopReasonKind
12
15
  } from "../types";
13
16
  import { createIconButton } from "../utils/buttons";
@@ -98,6 +101,31 @@ export const isSafeImageSrc = (src: string): boolean => {
98
101
  return false;
99
102
  };
100
103
 
104
+ /**
105
+ * Validate that a media src URL (audio/video/file) uses a safe scheme.
106
+ * Allows http(s), blob:, and inert data: URIs. Blocks `javascript:` and the
107
+ * executable data: types whose payloads a browser would render or run when a
108
+ * user right-clicks the link → "Open in new tab" (bypassing `download`):
109
+ * `data:text/html`, `data:text/javascript`, `data:text/xml`,
110
+ * `data:application/xhtml`, and `data:image/svg+xml`. Inert text payloads
111
+ * (`text/plain`, `text/csv`, `text/markdown`) remain allowed so attachments
112
+ * with those mime types render as download links instead of vanishing.
113
+ */
114
+ export const isSafeMediaSrc = (src: string): boolean => {
115
+ const lower = src.toLowerCase();
116
+ if (lower.startsWith("javascript:")) return false;
117
+ if (lower.startsWith("data:text/html")) return false;
118
+ if (lower.startsWith("data:text/javascript")) return false;
119
+ if (lower.startsWith("data:text/xml")) return false;
120
+ if (lower.startsWith("data:application/xhtml")) return false;
121
+ if (lower.startsWith("data:image/svg+xml")) return false;
122
+ if (/^(?:https?|blob):/i.test(src)) return true;
123
+ if (lower.startsWith("data:")) return true;
124
+ // Relative URLs are safe
125
+ if (!src.includes(":")) return true;
126
+ return false;
127
+ };
128
+
101
129
  export type LoadingIndicatorRenderer = (context: LoadingIndicatorRenderContext) => HTMLElement | null;
102
130
 
103
131
  export type MessageTransform = (context: {
@@ -128,6 +156,36 @@ const getMessageImageParts = (message: AgentWidgetMessage): ImageContentPart[] =
128
156
  );
129
157
  };
130
158
 
159
+ const getMessageAudioParts = (message: AgentWidgetMessage): AudioContentPart[] => {
160
+ if (!message.contentParts || message.contentParts.length === 0) return [];
161
+ return message.contentParts.filter(
162
+ (part): part is AudioContentPart =>
163
+ part.type === "audio" &&
164
+ typeof part.audio === "string" &&
165
+ part.audio.trim().length > 0
166
+ );
167
+ };
168
+
169
+ const getMessageVideoParts = (message: AgentWidgetMessage): VideoContentPart[] => {
170
+ if (!message.contentParts || message.contentParts.length === 0) return [];
171
+ return message.contentParts.filter(
172
+ (part): part is VideoContentPart =>
173
+ part.type === "video" &&
174
+ typeof part.video === "string" &&
175
+ part.video.trim().length > 0
176
+ );
177
+ };
178
+
179
+ const getMessageFileParts = (message: AgentWidgetMessage): FileContentPart[] => {
180
+ if (!message.contentParts || message.contentParts.length === 0) return [];
181
+ return message.contentParts.filter(
182
+ (part): part is FileContentPart =>
183
+ part.type === "file" &&
184
+ typeof part.data === "string" &&
185
+ part.data.trim().length > 0
186
+ );
187
+ };
188
+
131
189
  const createMessageImagePreviews = (
132
190
  imageParts: ImageContentPart[],
133
191
  hasVisibleText: boolean,
@@ -209,6 +267,124 @@ const createMessageImagePreviews = (
209
267
  }
210
268
  };
211
269
 
270
+ const createMessageAudioPreviews = (
271
+ audioParts: AudioContentPart[]
272
+ ): HTMLElement | null => {
273
+ if (audioParts.length === 0) return null;
274
+ try {
275
+ const container = createElement(
276
+ "div",
277
+ "persona-flex persona-flex-col persona-gap-2"
278
+ );
279
+ container.setAttribute("data-message-attachments", "audio");
280
+ let visible = 0;
281
+ audioParts.forEach((part) => {
282
+ if (!isSafeMediaSrc(part.audio)) return;
283
+ const audioElement = createElement("audio") as HTMLAudioElement;
284
+ audioElement.controls = true;
285
+ audioElement.preload = "metadata";
286
+ audioElement.src = part.audio;
287
+ audioElement.style.display = "block";
288
+ audioElement.style.width = "100%";
289
+ audioElement.style.maxWidth = `${MESSAGE_IMAGE_PREVIEW_MAX_WIDTH_PX}px`;
290
+ container.appendChild(audioElement);
291
+ visible += 1;
292
+ });
293
+ if (visible === 0) {
294
+ container.remove();
295
+ return null;
296
+ }
297
+ return container;
298
+ } catch {
299
+ return null;
300
+ }
301
+ };
302
+
303
+ const createMessageVideoPreviews = (
304
+ videoParts: VideoContentPart[]
305
+ ): HTMLElement | null => {
306
+ if (videoParts.length === 0) return null;
307
+ try {
308
+ const container = createElement(
309
+ "div",
310
+ "persona-flex persona-flex-col persona-gap-2"
311
+ );
312
+ container.setAttribute("data-message-attachments", "video");
313
+ let visible = 0;
314
+ videoParts.forEach((part) => {
315
+ if (!isSafeMediaSrc(part.video)) return;
316
+ const videoElement = createElement("video") as HTMLVideoElement;
317
+ videoElement.controls = true;
318
+ videoElement.preload = "metadata";
319
+ videoElement.src = part.video;
320
+ videoElement.style.display = "block";
321
+ videoElement.style.width = "100%";
322
+ videoElement.style.maxWidth = `${MESSAGE_IMAGE_PREVIEW_MAX_WIDTH_PX}px`;
323
+ videoElement.style.maxHeight = `${MESSAGE_IMAGE_PREVIEW_MAX_HEIGHT_PX}px`;
324
+ videoElement.style.borderRadius = "10px";
325
+ videoElement.style.backgroundColor =
326
+ "var(--persona-attachment-image-bg, var(--persona-container, #f3f4f6))";
327
+ container.appendChild(videoElement);
328
+ visible += 1;
329
+ });
330
+ if (visible === 0) {
331
+ container.remove();
332
+ return null;
333
+ }
334
+ return container;
335
+ } catch {
336
+ return null;
337
+ }
338
+ };
339
+
340
+ const createMessageFilePreviews = (
341
+ fileParts: FileContentPart[]
342
+ ): HTMLElement | null => {
343
+ if (fileParts.length === 0) return null;
344
+ try {
345
+ const container = createElement(
346
+ "div",
347
+ "persona-flex persona-flex-col persona-gap-2"
348
+ );
349
+ container.setAttribute("data-message-attachments", "files");
350
+ let visible = 0;
351
+ fileParts.forEach((part) => {
352
+ if (!isSafeMediaSrc(part.data)) return;
353
+ const link = createElement("a") as HTMLAnchorElement;
354
+ link.href = part.data;
355
+ link.download = part.filename;
356
+ // Cross-origin URLs ignore the `download` attribute, so without
357
+ // `target=_blank` the link would navigate the chat page to the file.
358
+ // Pair with `rel="noopener noreferrer"` to prevent reverse tabnabbing.
359
+ link.target = "_blank";
360
+ link.rel = "noopener noreferrer";
361
+ link.textContent = part.filename;
362
+ link.className = "persona-message-file-attachment";
363
+ link.style.display = "inline-flex";
364
+ link.style.alignItems = "center";
365
+ link.style.gap = "6px";
366
+ link.style.padding = "6px 10px";
367
+ link.style.borderRadius = "8px";
368
+ link.style.fontSize = "0.875rem";
369
+ link.style.textDecoration = "underline";
370
+ link.style.backgroundColor =
371
+ "var(--persona-attachment-file-bg, var(--persona-container, #f3f4f6))";
372
+ link.style.border =
373
+ "1px solid var(--persona-attachment-file-border, var(--persona-border, #e5e7eb))";
374
+ link.style.color = "inherit";
375
+ container.appendChild(link);
376
+ visible += 1;
377
+ });
378
+ if (visible === 0) {
379
+ container.remove();
380
+ return null;
381
+ }
382
+ return container;
383
+ } catch {
384
+ return null;
385
+ }
386
+ };
387
+
212
388
  // Create typing indicator element
213
389
  export const createTypingIndicator = (): HTMLElement => {
214
390
  const container = document.createElement("div");
@@ -710,6 +886,30 @@ export const createStandardBubble = (
710
886
  }
711
887
  }
712
888
 
889
+ const audioParts = getMessageAudioParts(message);
890
+ if (audioParts.length > 0) {
891
+ const audioPreviews = createMessageAudioPreviews(audioParts);
892
+ if (audioPreviews) {
893
+ bubble.appendChild(audioPreviews);
894
+ }
895
+ }
896
+
897
+ const videoParts = getMessageVideoParts(message);
898
+ if (videoParts.length > 0) {
899
+ const videoPreviews = createMessageVideoPreviews(videoParts);
900
+ if (videoPreviews) {
901
+ bubble.appendChild(videoPreviews);
902
+ }
903
+ }
904
+
905
+ const fileParts = getMessageFileParts(message);
906
+ if (fileParts.length > 0) {
907
+ const filePreviews = createMessageFilePreviews(fileParts);
908
+ if (filePreviews) {
909
+ bubble.appendChild(filePreviews);
910
+ }
911
+ }
912
+
713
913
  bubble.appendChild(contentDiv);
714
914
 
715
915
  // Add timestamp below if configured
package/src/index.ts CHANGED
@@ -61,6 +61,7 @@ export type {
61
61
  InjectAssistantMessageOptions,
62
62
  InjectUserMessageOptions,
63
63
  InjectSystemMessageOptions,
64
+ InjectComponentDirectiveOptions,
64
65
  // Loading indicator types
65
66
  LoadingIndicatorRenderContext,
66
67
  AgentWidgetLoadingIndicatorConfig,