@runtypelabs/persona 3.20.0 → 3.21.1
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/README.md +7 -1
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-cwY5HaFD.d.cts → types-CWPIj66R.d.cts} +19 -1
- package/dist/animations/{types-cwY5HaFD.d.ts → types-CWPIj66R.d.ts} +19 -1
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/index.cjs +46 -46
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +20 -1
- package/dist/index.d.ts +20 -1
- package/dist/index.global.js +78 -78
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +46 -46
- package/dist/index.js.map +1 -1
- package/dist/theme-editor.cjs +314 -26
- package/dist/theme-editor.d.cts +19 -1
- package/dist/theme-editor.d.ts +19 -1
- package/dist/theme-editor.js +314 -26
- package/package.json +1 -1
- package/src/client.test.ts +521 -0
- package/src/client.ts +150 -1
- package/src/components/message-bubble.test.ts +192 -0
- package/src/components/message-bubble.ts +200 -0
- package/src/session.test.ts +151 -0
- package/src/session.ts +78 -21
- package/src/types.ts +27 -2
- package/src/ui.ask-user-question-plugin.test.ts +42 -0
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
|