@djangocfg/widget-chat 0.1.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/LICENSE +21 -0
- package/README.md +124 -0
- package/package.json +95 -0
- package/src/composer/composer.tsx +100 -0
- package/src/composer/context.ts +33 -0
- package/src/composer/controls/button.tsx +104 -0
- package/src/composer/controls/language-menu.tsx +123 -0
- package/src/composer/controls/send.tsx +83 -0
- package/src/composer/controls/think.tsx +50 -0
- package/src/composer/controls/tools.tsx +130 -0
- package/src/composer/controls/tooltip.tsx +10 -0
- package/src/composer/controls/voice.tsx +266 -0
- package/src/composer/field/field.tsx +140 -0
- package/src/composer/field/input.tsx +214 -0
- package/src/composer/field/keys.ts +44 -0
- package/src/composer/field/use-autosize.ts +134 -0
- package/src/composer/focus-field.ts +27 -0
- package/src/composer/index.ts +86 -0
- package/src/composer/stories/field.tsx +112 -0
- package/src/composer/stories/harness.tsx +127 -0
- package/src/composer/tools/menu.tsx +129 -0
- package/src/composer/tools/rows.tsx +180 -0
- package/src/composer/tools/use-menu-nav.ts +66 -0
- package/src/composer/tools/use-recent-tools.ts +30 -0
- package/src/composer/tray/banner.tsx +157 -0
- package/src/composer/tray/command-progress.tsx +116 -0
- package/src/composer/tray/picked-tools.tsx +104 -0
- package/src/composer/tray/queue.tsx +202 -0
- package/src/composer/tray/use-replace-text.ts +58 -0
- package/src/composer/use-draft-persistence.ts +79 -0
- package/src/content/index.ts +16 -0
- package/src/content/media-content.tsx +38 -0
- package/src/content/response.tsx +221 -0
- package/src/core/adapter.ts +29 -0
- package/src/core/capabilities.ts +286 -0
- package/src/core/chunks.ts +66 -0
- package/src/core/content/incomplete-math.ts +118 -0
- package/src/core/content/index.ts +9 -0
- package/src/core/draft-storage.ts +62 -0
- package/src/core/events.ts +88 -0
- package/src/core/focus/index.ts +10 -0
- package/src/core/focus/roving.ts +56 -0
- package/src/core/group-parts.ts +80 -0
- package/src/core/index.ts +34 -0
- package/src/core/links/index.ts +33 -0
- package/src/core/links/prose.ts +191 -0
- package/src/core/links/scheme.ts +98 -0
- package/src/core/links/tool-id.ts +26 -0
- package/src/core/media/allowed-src.ts +138 -0
- package/src/core/media/block.ts +254 -0
- package/src/core/media/index.ts +22 -0
- package/src/core/media/kinds.ts +85 -0
- package/src/core/outbound.ts +86 -0
- package/src/core/parts.ts +169 -0
- package/src/core/queue/index.ts +15 -0
- package/src/core/queue/queue.ts +100 -0
- package/src/core/recall/boundary.ts +77 -0
- package/src/core/recall/from-transcript.ts +47 -0
- package/src/core/recall/index.ts +23 -0
- package/src/core/recall/ladder.ts +171 -0
- package/src/core/reload-decision.ts +36 -0
- package/src/core/scroll/index.ts +17 -0
- package/src/core/scroll/missed.ts +106 -0
- package/src/core/scroll/sent.ts +54 -0
- package/src/core/text-replace.ts +20 -0
- package/src/core/tool-name.ts +31 -0
- package/src/core/tool-picks-set.ts +48 -0
- package/src/core/tool-picks.ts +142 -0
- package/src/core/tool-prompt.ts +47 -0
- package/src/core/tools/capability.ts +34 -0
- package/src/core/tools/catalogue.ts +109 -0
- package/src/core/tools/index.ts +17 -0
- package/src/core/tools/layout.ts +44 -0
- package/src/core/tools/nav.ts +32 -0
- package/src/core/tools/rank.ts +60 -0
- package/src/core/tools/recent.ts +39 -0
- package/src/core/transcript.ts +301 -0
- package/src/core/voice/earcons.ts +58 -0
- package/src/core/voice/index.ts +26 -0
- package/src/core/voice/language-search.ts +62 -0
- package/src/core/voice/languages.ts +267 -0
- package/src/core/voice/resolve-language.ts +93 -0
- package/src/core/voice/session.ts +196 -0
- package/src/core/voice/web-speech.ts +170 -0
- package/src/i18n/index.ts +87 -0
- package/src/i18n/locales/ar.ts +97 -0
- package/src/i18n/locales/da.ts +90 -0
- package/src/i18n/locales/de.ts +90 -0
- package/src/i18n/locales/en.ts +90 -0
- package/src/i18n/locales/es.ts +90 -0
- package/src/i18n/locales/fr.ts +90 -0
- package/src/i18n/locales/it.ts +90 -0
- package/src/i18n/locales/ja.ts +83 -0
- package/src/i18n/locales/ko.ts +83 -0
- package/src/i18n/locales/nl.ts +90 -0
- package/src/i18n/locales/no.ts +90 -0
- package/src/i18n/locales/pl.ts +96 -0
- package/src/i18n/locales/ptBR.ts +90 -0
- package/src/i18n/locales/ru.ts +96 -0
- package/src/i18n/locales/sv.ts +90 -0
- package/src/i18n/locales/tr.ts +89 -0
- package/src/i18n/locales/zh.ts +83 -0
- package/src/i18n/plural.ts +62 -0
- package/src/i18n/types.ts +257 -0
- package/src/index.ts +18 -0
- package/src/primitives/index.ts +43 -0
- package/src/primitives/message.tsx +85 -0
- package/src/primitives/part-registry.tsx +44 -0
- package/src/primitives/stories-harness.tsx +118 -0
- package/src/primitives/suggestions.tsx +112 -0
- package/src/primitives/tool-call.tsx +145 -0
- package/src/primitives/tooltip.tsx +48 -0
- package/src/primitives/transcript.tsx +285 -0
- package/src/primitives/use-dismiss.ts +100 -0
- package/src/primitives/use-missed-count.ts +30 -0
- package/src/primitives/use-overflows.ts +69 -0
- package/src/primitives/use-return-on-send.ts +29 -0
- package/src/primitives/use-roving-focus.ts +69 -0
- package/src/primitives/use-select-all-transcript.ts +111 -0
- package/src/primitives/use-stick-to-bottom.ts +83 -0
- package/src/primitives/use-turn-activity.ts +25 -0
- package/src/store/context.ts +28 -0
- package/src/store/hooks.ts +215 -0
- package/src/store/index.ts +31 -0
- package/src/store/provider.tsx +97 -0
- package/src/store/store.ts +394 -0
- package/src/store/subscribers.ts +55 -0
- package/src/store/surfaces.ts +174 -0
- package/src/store/use-draft.ts +168 -0
- package/src/store/use-queue.ts +61 -0
- package/src/store/use-recall.ts +264 -0
- package/src/store/use-reload-on-revision.ts +67 -0
- package/src/store/use-speech-language.ts +84 -0
- package/src/store/use-voice.ts +116 -0
- package/src/styles/block.css +128 -0
- package/src/styles/composer.css +449 -0
- package/src/styles/index.css +47 -0
- package/src/styles/markdown.css +368 -0
- package/src/styles/surface.css +102 -0
- package/src/styles/tool.css +128 -0
- package/src/styles/turn-mark.css +97 -0
- package/src/testing/index.ts +15 -0
- package/src/testing/mock-adapter.ts +69 -0
- package/src/testing/mock-tools.ts +101 -0
- package/src/testing/mock-voice.ts +81 -0
- package/src/ui/chat-composer.tsx +286 -0
- package/src/ui/chat-reply-chip.tsx +42 -0
- package/src/ui/chat-suggestions.tsx +87 -0
- package/src/ui/chat-transcript.tsx +157 -0
- package/src/ui/chat.tsx +197 -0
- package/src/ui/collapsible-body.tsx +180 -0
- package/src/ui/collapsible-row.tsx +81 -0
- package/src/ui/day-divider.tsx +49 -0
- package/src/ui/index.ts +54 -0
- package/src/ui/jump-to-latest.tsx +60 -0
- package/src/ui/link-chip.tsx +73 -0
- package/src/ui/message-actions.tsx +163 -0
- package/src/ui/message-quote.tsx +47 -0
- package/src/ui/message.tsx +295 -0
- package/src/ui/pending.tsx +85 -0
- package/src/ui/renderers/data.tsx +63 -0
- package/src/ui/renderers/reasoning.tsx +73 -0
- package/src/ui/renderers/tool.tsx +176 -0
- package/src/ui/turn-mark.tsx +31 -0
- package/src/utils/day-heading.ts +46 -0
- package/src/utils/error-message.ts +10 -0
- package/src/utils/index.ts +12 -0
- package/src/utils/markdown-text.ts +175 -0
- package/src/widgets/index.ts +14 -0
- package/src/widgets/media.tsx +239 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mention URL scheme: how a chip in a message names a thing outside it.
|
|
3
|
+
*
|
|
4
|
+
* A mention has to survive a round trip through markdown — a chip is written as
|
|
5
|
+
* a link, sent as text, parsed back, and re-drawn as a chip. That needs a URL
|
|
6
|
+
* shape both halves agree on, and a plain `https:` link cannot carry it: the
|
|
7
|
+
* host must be able to tell "open this thing I know about" from "follow this
|
|
8
|
+
* link", and only a distinct scheme says so.
|
|
9
|
+
*
|
|
10
|
+
* ## Why the scheme is configurable and the shapes are not
|
|
11
|
+
*
|
|
12
|
+
* The SCHEME is the host's — it is their product's name, and it is what their
|
|
13
|
+
* desktop shell or deep-link handler registers. The SHAPES are ours: the chat
|
|
14
|
+
* writes them and the chat parses them, so a host redefining them would break
|
|
15
|
+
* its own round trip.
|
|
16
|
+
*
|
|
17
|
+
* ```
|
|
18
|
+
* <scheme>://tool/<id> a tool the user picked
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* One shape, because only one thing here is the chat's own. A host that mentions
|
|
22
|
+
* users, documents or tickets addresses them with its own scheme and resolves
|
|
23
|
+
* them itself; this module would only be guessing at their grammar. The other
|
|
24
|
+
* shapes that used to live here were one product's nouns and left with it.
|
|
25
|
+
*
|
|
26
|
+
* Nothing here reaches a network. These are addresses the host resolves when a
|
|
27
|
+
* chip is clicked; this module only writes and reads them.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** The default scheme. Override with {@link configureMentionScheme}. */
|
|
31
|
+
const DEFAULT_SCHEME = "app";
|
|
32
|
+
|
|
33
|
+
let scheme = DEFAULT_SCHEME;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Set the URL scheme mentions are written with.
|
|
37
|
+
*
|
|
38
|
+
* Call once at startup, before any message is rendered. Changing it later
|
|
39
|
+
* leaves already-written messages addressing the old scheme, which then parse
|
|
40
|
+
* as plain links — which is the honest outcome, but not one to arrive at by
|
|
41
|
+
* accident.
|
|
42
|
+
*/
|
|
43
|
+
export function configureMentionScheme(next: string): void {
|
|
44
|
+
scheme = next.replace(/:$/, "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The scheme in force. Exposed so a link-hardening allowlist can name it. */
|
|
48
|
+
export function mentionScheme(): string {
|
|
49
|
+
return scheme;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const HOST_TOOL = "tool";
|
|
53
|
+
|
|
54
|
+
// --- Writers ---------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `<scheme>://tool/<id>`.
|
|
58
|
+
*
|
|
59
|
+
* An empty id returns the bare host, which parses but names no tool; a renderer
|
|
60
|
+
* leaves that as plain text rather than minting a chip that stands for nothing.
|
|
61
|
+
*/
|
|
62
|
+
export function toolURL(id: string): string {
|
|
63
|
+
return id ? `${scheme}://${HOST_TOOL}/${encodeURIComponent(id)}` : `${scheme}://${HOST_TOOL}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Reader ----------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
export type ParsedMention = { readonly kind: "tool"; readonly toolID: string };
|
|
69
|
+
|
|
70
|
+
export type ParseResult =
|
|
71
|
+
| { readonly ok: true; readonly parsed: ParsedMention }
|
|
72
|
+
| { readonly ok: false; readonly error: "malformed" | "not-mention-url" | "missing-id" };
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read a mention URL back into its parts.
|
|
76
|
+
*
|
|
77
|
+
* A result rather than a throw: an ordinary link in a message reaches here on
|
|
78
|
+
* every render, and "this is not one of ours" is the common case, not a fault.
|
|
79
|
+
*/
|
|
80
|
+
export function parse(href: string): ParseResult {
|
|
81
|
+
let url: URL;
|
|
82
|
+
try {
|
|
83
|
+
url = new URL(href);
|
|
84
|
+
} catch {
|
|
85
|
+
return { ok: false, error: "malformed" };
|
|
86
|
+
}
|
|
87
|
+
// `URL.protocol` carries the trailing colon.
|
|
88
|
+
if (url.protocol !== `${scheme}:`) return { ok: false, error: "not-mention-url" };
|
|
89
|
+
|
|
90
|
+
// `URL.pathname` opens with "/", so the first split entry is always empty.
|
|
91
|
+
const segments = url.pathname.split("/").filter((segment) => segment.length > 0);
|
|
92
|
+
|
|
93
|
+
if (url.hostname !== HOST_TOOL) return { ok: false, error: "not-mention-url" };
|
|
94
|
+
|
|
95
|
+
const [rawID] = segments;
|
|
96
|
+
if (rawID === undefined) return { ok: false, error: "missing-id" };
|
|
97
|
+
return { ok: true, parsed: { kind: "tool", toolID: decodeURIComponent(rawID) } };
|
|
98
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { parse as parseMentionUrl } from "./scheme";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `app://tool/<id>` — the link a picked tool becomes in the outbound text.
|
|
5
|
+
*
|
|
6
|
+
* Unlike a named correspondent there is no `@token` preprocess step here: the
|
|
7
|
+
* COMPOSER writes the link itself, so the id never has to be recovered from
|
|
8
|
+
* prose. This module owns only the reading half.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extract the tool id from a `app://tool/<id>` href, or null for any other
|
|
13
|
+
* shape. Uses the single-sourced `core/links` parser so the scheme stays in
|
|
14
|
+
* lock-step with Go.
|
|
15
|
+
*
|
|
16
|
+
* A bare `app://tool` parses but names no tool, and returns null here — the
|
|
17
|
+
* caller must treat "no id" and "not a tool link" the same way, since neither
|
|
18
|
+
* identifies anything a chip could stand for.
|
|
19
|
+
*/
|
|
20
|
+
export function extractToolId(href: string | undefined): string | null {
|
|
21
|
+
if (!href) return null;
|
|
22
|
+
const r = parseMentionUrl(href);
|
|
23
|
+
if (!r.ok) return null;
|
|
24
|
+
if (r.parsed.kind !== "tool") return null;
|
|
25
|
+
return r.parsed.toolID || null;
|
|
26
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The media `src` allowlist — a security boundary, mirrored from the server.
|
|
3
|
+
*
|
|
4
|
+
* SOURCE OF TRUTH: the server's own `src` allowlist and same-origin rule. This
|
|
5
|
+
* is a deliberate second implementation, not a duplicated helper: the server
|
|
6
|
+
* gates what a TOOL may emit, this side gates what a BROWSER will load, and a
|
|
7
|
+
* block can reach the DOM through a stored transcript no live server process
|
|
8
|
+
* re-validated.
|
|
9
|
+
*
|
|
10
|
+
* The stake, plainly: an unvalidated preview image is an exfil vector. A model-authored `<img src>` is an outbound GET the user never
|
|
11
|
+
* asked for, carrying the page's referrer and cookies to whoever the model
|
|
12
|
+
* names.
|
|
13
|
+
*
|
|
14
|
+
* The server also fixes the rendering contract, and it holds here too: blocks are
|
|
15
|
+
* RENDER-ONLY. Nothing in this package may interpret a block as an action.
|
|
16
|
+
*
|
|
17
|
+
* Keep in lock-step with whatever the server enforces. Divergence in either
|
|
18
|
+
* direction is a real defect — laxer here loads what the server rejected;
|
|
19
|
+
* stricter here silently blanks media that is legitimately allowed.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// The app's own scheme, read from the mention-link module rather than repeated:
|
|
23
|
+
// a second copy of the scheme is a second thing to change, and what it hides is
|
|
24
|
+
// media that silently blanks after a host reconfigures it.
|
|
25
|
+
import { mentionScheme } from "../links";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Same-origin path prefixes a src may start with.
|
|
29
|
+
*
|
|
30
|
+
* A PARAMETER rather than a baked-in constant: the list belongs to whoever
|
|
31
|
+
* serves the transcript, and hardcoding a copy here would create a second
|
|
32
|
+
* source of truth that drifts silently the day the server adds a route.
|
|
33
|
+
*/
|
|
34
|
+
export type SameOriginPrefixes = readonly string[];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Is this `src` safe to put in front of a browser?
|
|
38
|
+
*
|
|
39
|
+
* Accepts `https:`, an allowlisted same-origin absolute path, the app's own
|
|
40
|
+
* mention scheme, and `data:image/`. Everything else is rejected — including
|
|
41
|
+
* `javascript:`, non-image `data:`, plain `http:`, `file:` and `vbscript:`.
|
|
42
|
+
*/
|
|
43
|
+
export function isAllowedSrc(src: string, sameOriginPrefixes: SameOriginPrefixes): boolean {
|
|
44
|
+
const trimmed = src.trim();
|
|
45
|
+
if (trimmed === "") return false;
|
|
46
|
+
|
|
47
|
+
const low = trimmed.toLowerCase();
|
|
48
|
+
if (low.startsWith("https://")) return true;
|
|
49
|
+
if (low.startsWith(`${mentionScheme()}://`)) return true;
|
|
50
|
+
// `data:image/` only. A bare `data:` allows text/html, which is script.
|
|
51
|
+
if (low.startsWith("data:image/")) return true;
|
|
52
|
+
if (trimmed.startsWith("/")) return isAllowedSameOrigin(trimmed, sameOriginPrefixes);
|
|
53
|
+
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A content address: lowercase hex sha256. Mirrored rather than parameterised
|
|
59
|
+
* because it is a property of sha256, not a route a server is free to change.
|
|
60
|
+
*/
|
|
61
|
+
const CONTENT_ADDRESS = /^[0-9a-f]{64}$/;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The attachment a src IS, or nothing.
|
|
65
|
+
*
|
|
66
|
+
* The server serves hosted bytes under a content address, so the last path
|
|
67
|
+
* segment IS the id — this reads a key the src already carries rather than
|
|
68
|
+
* minting one. The ROUTE is not spelled here: it arrives as a same-origin
|
|
69
|
+
* prefix for the same reason `isAllowedSrc` takes one, since baking a server's
|
|
70
|
+
* copy in would be a second source of truth that drifts the day it moves the
|
|
71
|
+
* route.
|
|
72
|
+
*
|
|
73
|
+
* A segment that is not a full hash belongs to some other route, and claiming
|
|
74
|
+
* it as an id would name a blob that does not exist.
|
|
75
|
+
*/
|
|
76
|
+
export function attachmentIdOf(
|
|
77
|
+
src: string | undefined,
|
|
78
|
+
sameOriginPrefixes: SameOriginPrefixes,
|
|
79
|
+
): string | undefined {
|
|
80
|
+
if (src === undefined) return undefined;
|
|
81
|
+
const path = src.split(/[?#]/, 1)[0] ?? "";
|
|
82
|
+
const prefix = sameOriginPrefixes.find((candidate) => path.startsWith(candidate));
|
|
83
|
+
if (prefix === undefined) return undefined;
|
|
84
|
+
const id = path.slice(prefix.length);
|
|
85
|
+
return CONTENT_ADDRESS.test(id) ? id : undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Gate an absolute-path src.
|
|
90
|
+
*
|
|
91
|
+
* Unlike a scheme, a path can be walked out of its prefix
|
|
92
|
+
* (`/api/admin/../../etc`) or hijacked into another origin, so a plain
|
|
93
|
+
* `startsWith` is NOT sufficient. Two escapes the server calls out by name:
|
|
94
|
+
* `//evil.com/x` is protocol-relative, and `/\evil.com` is treated as such by
|
|
95
|
+
* some browsers.
|
|
96
|
+
*/
|
|
97
|
+
function isAllowedSameOrigin(src: string, prefixes: SameOriginPrefixes): boolean {
|
|
98
|
+
if (src.startsWith("//") || src.startsWith("/\\")) return false;
|
|
99
|
+
|
|
100
|
+
// A query or fragment may legitimately follow; the gate applies to the path.
|
|
101
|
+
const cut = src.search(/[?#]/);
|
|
102
|
+
const path = cut >= 0 ? src.slice(0, cut) : src;
|
|
103
|
+
|
|
104
|
+
// REJECT rather than normalise. A src that needs cleaning is not one we
|
|
105
|
+
// minted, and normalising first is how a traversal becomes an allowed path.
|
|
106
|
+
if (path !== cleanPath(path)) return false;
|
|
107
|
+
|
|
108
|
+
return prefixes.some((prefix) => path.startsWith(prefix));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The subset of Go's `path.Clean` this gate needs.
|
|
113
|
+
*
|
|
114
|
+
* Only used to answer "is this path already clean?", never to sanitise a path
|
|
115
|
+
* for use — a caller that cleans and then trusts the result has re-introduced
|
|
116
|
+
* exactly the bug the reject-don't-normalise rule prevents.
|
|
117
|
+
*/
|
|
118
|
+
export function cleanPath(path: string): string {
|
|
119
|
+
if (path === "") return ".";
|
|
120
|
+
|
|
121
|
+
const rooted = path.startsWith("/");
|
|
122
|
+
const out: string[] = [];
|
|
123
|
+
|
|
124
|
+
for (const segment of path.split("/")) {
|
|
125
|
+
if (segment === "" || segment === ".") continue;
|
|
126
|
+
if (segment === "..") {
|
|
127
|
+
// At the root, `..` has nowhere to go and Go's Clean drops it.
|
|
128
|
+
if (out.length > 0 && out[out.length - 1] !== "..") out.pop();
|
|
129
|
+
else if (!rooted) out.push("..");
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
out.push(segment);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const joined = out.join("/");
|
|
136
|
+
if (rooted) return `/${joined}`;
|
|
137
|
+
return joined === "" ? "." : joined;
|
|
138
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { attachmentIdOf, isAllowedSrc, type SameOriginPrefixes } from "./allowed-src";
|
|
2
|
+
import { isMediaKind, SRC_KINDS, type MediaKind } from "./kinds";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reading a media block, and gating it.
|
|
6
|
+
*
|
|
7
|
+
* VALIDATION HAPPENS ONCE, HERE — not in each renderer. That split is taken
|
|
8
|
+
* from the archived chat, whose LinkBlock says outright that the payload "is
|
|
9
|
+
* validated centrally at the registry dispatch point before this renderer runs,
|
|
10
|
+
* so we can trust it here". Per-renderer checking is how one of eleven quietly
|
|
11
|
+
* ends up missing its own.
|
|
12
|
+
*
|
|
13
|
+
* A rejected URL does not throw and does not render a broken element: the field
|
|
14
|
+
* is dropped, so a block with an unsafe cover still plays its audio, and a block
|
|
15
|
+
* with an unsafe `src` renders nothing at all.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface MediaBlock {
|
|
19
|
+
readonly kind: MediaKind;
|
|
20
|
+
readonly src?: string;
|
|
21
|
+
readonly cover?: string;
|
|
22
|
+
readonly title?: string;
|
|
23
|
+
readonly alt?: string;
|
|
24
|
+
/** Textual kinds (json/code/markdown/text/mermaid) carry content, not a src. */
|
|
25
|
+
readonly content?: string;
|
|
26
|
+
/** `code` only. */
|
|
27
|
+
readonly language?: string;
|
|
28
|
+
readonly items?: readonly MediaGalleryItem[];
|
|
29
|
+
/** `link` only — the destination and its pre-resolved unfurl. */
|
|
30
|
+
readonly url?: string;
|
|
31
|
+
readonly link?: MediaLinkPreview;
|
|
32
|
+
readonly aspectRatio?: number;
|
|
33
|
+
/** Present only when a tool produced the picture rather than found it. */
|
|
34
|
+
readonly generation?: MediaGeneration;
|
|
35
|
+
/**
|
|
36
|
+
* The server-hosted blob these bytes ARE, when the src names one.
|
|
37
|
+
*
|
|
38
|
+
* Derived from the src, never authored: the server serves hosted bytes at
|
|
39
|
+
* `/api/admin/attachments/<sha256>` and that sha256 IS the attachment id, so
|
|
40
|
+
* the block already states its own key. Absent for a picture hosted
|
|
41
|
+
* elsewhere — those bytes never reached this server and nothing here has
|
|
42
|
+
* measured them.
|
|
43
|
+
*
|
|
44
|
+
* An IDENTITY, not a copy of the facts. The turn's file parts already carry
|
|
45
|
+
* width, size and duration; duplicating them onto the block would give one
|
|
46
|
+
* fact two homes and let them disagree.
|
|
47
|
+
*/
|
|
48
|
+
readonly attachmentId?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* How a picture came to exist: the request, and the model that answered it.
|
|
53
|
+
*
|
|
54
|
+
* `size` is a preference the provider may ignore and `quality` is an intent, so
|
|
55
|
+
* a card must present both as the request they are. `model` is the one field
|
|
56
|
+
* describing the RESULT — the router reports which model its ladder reached,
|
|
57
|
+
* which the intent alone cannot say. Absent when the router did not report one.
|
|
58
|
+
*/
|
|
59
|
+
export interface MediaGeneration {
|
|
60
|
+
readonly prompt: string;
|
|
61
|
+
readonly size?: string;
|
|
62
|
+
readonly quality?: string;
|
|
63
|
+
readonly model?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Where these bytes also live on the open internet. Carried here because the
|
|
66
|
+
* server rewrites `src` when it hosts the bytes, so a card reading one finds
|
|
67
|
+
* an internal path. Absent when the producer hosted nothing.
|
|
68
|
+
*/
|
|
69
|
+
readonly publicUrl?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface MediaGalleryItem {
|
|
73
|
+
readonly src: string;
|
|
74
|
+
readonly thumbnail?: string;
|
|
75
|
+
readonly alt?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface MediaLinkPreview {
|
|
79
|
+
readonly title?: string;
|
|
80
|
+
readonly description?: string;
|
|
81
|
+
readonly image?: string;
|
|
82
|
+
readonly favicon?: string;
|
|
83
|
+
readonly siteName?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const str = (value: unknown): string | undefined =>
|
|
87
|
+
typeof value === "string" && value !== "" ? value : undefined;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read and GATE a media block.
|
|
91
|
+
*
|
|
92
|
+
* Returns null when the block cannot be rendered honestly — an unknown kind, a
|
|
93
|
+
* src-bearing kind whose src is rejected, or a textual kind with no content.
|
|
94
|
+
*/
|
|
95
|
+
/**
|
|
96
|
+
* The wire field a textual kind carries its payload in.
|
|
97
|
+
*
|
|
98
|
+
* The server does NOT send one `content` field. Each textual kind names its own
|
|
99
|
+
* — a mermaid block sends `chart`, a markdown block `markdown`, a code block
|
|
100
|
+
* `code` — while this model keeps a single `content`, because a renderer that
|
|
101
|
+
* has already switched on `kind` gains nothing from a second name for "the
|
|
102
|
+
* text". Translating at the boundary is what keeps that true.
|
|
103
|
+
*
|
|
104
|
+
* Reading only `content` is what made every one of these blocks vanish: the
|
|
105
|
+
* field was always undefined, so the check below rejected the block and no
|
|
106
|
+
* renderer ever ran. Silent, because a dropped block looks exactly like a block
|
|
107
|
+
* the agent never sent.
|
|
108
|
+
*/
|
|
109
|
+
const CONTENT_FIELD: Partial<Record<MediaKind, string>> = {
|
|
110
|
+
code: "code",
|
|
111
|
+
markdown: "markdown",
|
|
112
|
+
text: "text",
|
|
113
|
+
mermaid: "chart",
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A textual kind's payload as a string, or undefined.
|
|
118
|
+
*
|
|
119
|
+
* `json` is the one kind whose payload is not text on the wire — the server
|
|
120
|
+
* sends the parsed VALUE. It is serialised here so the block model can keep one
|
|
121
|
+
* string field, and because a renderer showing JSON has to print it anyway.
|
|
122
|
+
*/
|
|
123
|
+
function readContent(row: Record<string, unknown>, kind: MediaKind): string | undefined {
|
|
124
|
+
if (kind === "json") {
|
|
125
|
+
if (row.data === undefined) return undefined;
|
|
126
|
+
try {
|
|
127
|
+
return JSON.stringify(row.data, null, 2);
|
|
128
|
+
} catch {
|
|
129
|
+
// A cycle cannot survive the wire, so this is unreachable through the
|
|
130
|
+
// server — it guards a hand-built block in a story or a test.
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const field = CONTENT_FIELD[kind] ?? "content";
|
|
136
|
+
const value = row[field];
|
|
137
|
+
// Not `str()`: that treats "" as absent, and an empty code block is a
|
|
138
|
+
// legitimate thing for an agent to show.
|
|
139
|
+
if (typeof value === "string") return value;
|
|
140
|
+
// The generic name is accepted as a fallback for every kind, so a hand-built
|
|
141
|
+
// block and a story keep working against the model's own vocabulary.
|
|
142
|
+
return typeof row.content === "string" ? row.content : undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function readMediaBlock(
|
|
146
|
+
data: unknown,
|
|
147
|
+
prefixes: SameOriginPrefixes,
|
|
148
|
+
): MediaBlock | null {
|
|
149
|
+
if (typeof data !== "object" || data === null) return null;
|
|
150
|
+
const row = data as Record<string, unknown>;
|
|
151
|
+
if (!isMediaKind(row.kind)) return null;
|
|
152
|
+
|
|
153
|
+
const kind = row.kind;
|
|
154
|
+
const safe = (value: unknown): string | undefined => {
|
|
155
|
+
const url = str(value);
|
|
156
|
+
if (url === undefined) return undefined;
|
|
157
|
+
return isAllowedSrc(url, prefixes) ? url : undefined;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const block: MediaBlock = {
|
|
161
|
+
kind,
|
|
162
|
+
src: safe(row.src),
|
|
163
|
+
// Secondary URLs are gated exactly like the primary one. They are the ones
|
|
164
|
+
// a renderer forgets, and each is an outbound request made on behalf of a
|
|
165
|
+
// model-authored block.
|
|
166
|
+
cover: safe(row.cover),
|
|
167
|
+
title: str(row.title),
|
|
168
|
+
alt: str(row.alt),
|
|
169
|
+
content: readContent(row, kind),
|
|
170
|
+
language: str(row.language),
|
|
171
|
+
items: readItems(row.items, safe),
|
|
172
|
+
url: safe(row.url),
|
|
173
|
+
link: readLink(row.link, safe),
|
|
174
|
+
aspectRatio: typeof row.aspectRatio === "number" && row.aspectRatio > 0
|
|
175
|
+
? row.aspectRatio
|
|
176
|
+
: undefined,
|
|
177
|
+
generation: readGeneration(row.generation, safe),
|
|
178
|
+
attachmentId: attachmentIdOf(safe(row.src), prefixes),
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// A src-bearing kind with no surviving src has nothing to show. Rendering an
|
|
182
|
+
// empty frame would claim media exists that the gate just refused.
|
|
183
|
+
if (SRC_KINDS.has(kind)) {
|
|
184
|
+
const hasSource =
|
|
185
|
+
block.src !== undefined ||
|
|
186
|
+
block.url !== undefined ||
|
|
187
|
+
(block.items?.length ?? 0) > 0;
|
|
188
|
+
if (!hasSource) return null;
|
|
189
|
+
} else if (kind !== "map" && block.content === undefined) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return block;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function readItems(
|
|
197
|
+
raw: unknown,
|
|
198
|
+
safe: (value: unknown) => string | undefined,
|
|
199
|
+
): readonly MediaGalleryItem[] | undefined {
|
|
200
|
+
if (!Array.isArray(raw)) return undefined;
|
|
201
|
+
const items: MediaGalleryItem[] = [];
|
|
202
|
+
for (const entry of raw) {
|
|
203
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
204
|
+
const row = entry as Record<string, unknown>;
|
|
205
|
+
const src = safe(row.src);
|
|
206
|
+
// An item whose src was rejected is dropped rather than shown as a gap:
|
|
207
|
+
// the gallery stays honest about what it can actually display.
|
|
208
|
+
if (src === undefined) continue;
|
|
209
|
+
items.push({ src, thumbnail: safe(row.thumbnail), alt: str(row.alt) });
|
|
210
|
+
}
|
|
211
|
+
return items.length > 0 ? items : undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* A generation is its prompt. Without one there is nothing a card could show,
|
|
216
|
+
* so the field is dropped rather than kept as an empty shell a renderer would
|
|
217
|
+
* then have to decide how to draw.
|
|
218
|
+
*/
|
|
219
|
+
function readGeneration(
|
|
220
|
+
raw: unknown,
|
|
221
|
+
safe: (value: unknown) => string | undefined,
|
|
222
|
+
): MediaGeneration | undefined {
|
|
223
|
+
if (typeof raw !== "object" || raw === null) return undefined;
|
|
224
|
+
const row = raw as Record<string, unknown>;
|
|
225
|
+
const prompt = str(row.prompt);
|
|
226
|
+
if (prompt === undefined) return undefined;
|
|
227
|
+
return {
|
|
228
|
+
prompt,
|
|
229
|
+
size: str(row.size),
|
|
230
|
+
quality: str(row.quality),
|
|
231
|
+
model: str(row.model),
|
|
232
|
+
// Gated like every other src on this block: a provenance field is written
|
|
233
|
+
// by a tool, so an unvalidated one would be an address a model chose.
|
|
234
|
+
publicUrl: safe(row.publicUrl),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function readLink(
|
|
239
|
+
raw: unknown,
|
|
240
|
+
safe: (value: unknown) => string | undefined,
|
|
241
|
+
): MediaLinkPreview | undefined {
|
|
242
|
+
if (typeof raw !== "object" || raw === null) return undefined;
|
|
243
|
+
const row = raw as Record<string, unknown>;
|
|
244
|
+
const preview: MediaLinkPreview = {
|
|
245
|
+
title: str(row.title),
|
|
246
|
+
description: str(row.description),
|
|
247
|
+
image: safe(row.image),
|
|
248
|
+
favicon: safe(row.favicon),
|
|
249
|
+
siteName: str(row.siteName),
|
|
250
|
+
};
|
|
251
|
+
// The server's own note: a preview with no title/description/image still
|
|
252
|
+
// renders as a bare URL card, so an all-empty preview is not an error.
|
|
253
|
+
return preview;
|
|
254
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Media blocks: the eleven-kind vocabulary and the `src` allowlist.
|
|
2
|
+
//
|
|
3
|
+
// Pure TypeScript, no React — the allowlist is a SECURITY BOUNDARY and belongs
|
|
4
|
+
// where it can be tested exhaustively without a renderer. It is a deliberate
|
|
5
|
+
// mirror of the server's media vocabulary and allowlist, not a shared helper:
|
|
6
|
+
// the server gates what a tool may emit, this gates what a browser will load,
|
|
7
|
+
// and a stored transcript reaches the DOM with no live server process
|
|
8
|
+
// re-checking it.
|
|
9
|
+
|
|
10
|
+
export { attachmentIdOf, isAllowedSrc, cleanPath } from "./allowed-src";
|
|
11
|
+
export type { SameOriginPrefixes } from "./allowed-src";
|
|
12
|
+
|
|
13
|
+
export { MEDIA_KINDS, isMediaKind, TEXTUAL_KINDS, SRC_KINDS, URL_FIELDS } from "./kinds";
|
|
14
|
+
export type { MediaKind } from "./kinds";
|
|
15
|
+
|
|
16
|
+
export { readMediaBlock } from "./block";
|
|
17
|
+
export type {
|
|
18
|
+
MediaBlock,
|
|
19
|
+
MediaGalleryItem,
|
|
20
|
+
MediaGeneration,
|
|
21
|
+
MediaLinkPreview,
|
|
22
|
+
} from "./block";
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The media block vocabulary — one event type (8), eleven kinds.
|
|
3
|
+
*
|
|
4
|
+
* SOURCE OF TRUTH: the server's `Kind` vocabulary and its supported-kinds set.
|
|
5
|
+
* Mirrored here so the discriminant lives in ONE place on this
|
|
6
|
+
* side too, instead of being re-spelled inside each renderer.
|
|
7
|
+
*
|
|
8
|
+
* This is why media is a REGISTRY rather than a widget: eleven kinds behind one
|
|
9
|
+
* event type is exactly the shape the O(1) rule exists for. A twelfth kind adds
|
|
10
|
+
* a registry entry and a renderer — never a prop on `<Chat>`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const MEDIA_KINDS = [
|
|
14
|
+
"image",
|
|
15
|
+
"audio",
|
|
16
|
+
"video",
|
|
17
|
+
"map",
|
|
18
|
+
"json",
|
|
19
|
+
"code",
|
|
20
|
+
"markdown",
|
|
21
|
+
"text",
|
|
22
|
+
"mermaid",
|
|
23
|
+
"gallery",
|
|
24
|
+
"link",
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export type MediaKind = (typeof MEDIA_KINDS)[number];
|
|
28
|
+
|
|
29
|
+
const SUPPORTED = new Set<string>(MEDIA_KINDS);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Is this a kind this build knows how to render?
|
|
33
|
+
*
|
|
34
|
+
* An unknown kind is not an error: the server's vocabulary grows, and a
|
|
35
|
+
* transcript stored by a newer agent may reach an older client. The caller
|
|
36
|
+
* renders nothing rather than guessing — the same degrade the server applies
|
|
37
|
+
* ("an unsupported kind is rejected by Validate").
|
|
38
|
+
*/
|
|
39
|
+
export function isMediaKind(value: unknown): value is MediaKind {
|
|
40
|
+
return typeof value === "string" && SUPPORTED.has(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Kinds whose payload is TEXT, not a `src`.
|
|
45
|
+
*
|
|
46
|
+
* They are routed to the content renderer instead of a media element, which is
|
|
47
|
+
* the seam where `widgets/` and `content/` meet. Getting this list wrong grows a
|
|
48
|
+
* second markdown path — the exact duplication the registry exists to prevent.
|
|
49
|
+
*/
|
|
50
|
+
export const TEXTUAL_KINDS = new Set<MediaKind>([
|
|
51
|
+
"json",
|
|
52
|
+
"code",
|
|
53
|
+
"markdown",
|
|
54
|
+
"text",
|
|
55
|
+
"mermaid",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
/** Kinds that load a URL, and therefore MUST pass the allowlist. */
|
|
59
|
+
export const SRC_KINDS = new Set<MediaKind>(["image", "audio", "video", "gallery", "link"]);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every URL-bearing field per kind — NOT just `src`.
|
|
63
|
+
*
|
|
64
|
+
* Checked against the server's own validation, and that is the reason to write
|
|
65
|
+
* it out: the secondary URLs are the ones a renderer forgets. The server gates
|
|
66
|
+
* `cover` on audio/video, `thumbnail` on each gallery item, and BOTH
|
|
67
|
+
* `image` and `favicon` inside a link preview — every one of them is an
|
|
68
|
+
* outbound request the browser makes on behalf of a model-authored block.
|
|
69
|
+
*
|
|
70
|
+
* A renderer that validates `src` and then drops an unvalidated `favicon` into
|
|
71
|
+
* an `<img>` has left the exfil vector open while looking careful.
|
|
72
|
+
*/
|
|
73
|
+
export const URL_FIELDS: Readonly<Record<MediaKind, readonly string[]>> = {
|
|
74
|
+
image: ["src"],
|
|
75
|
+
audio: ["src", "cover"],
|
|
76
|
+
video: ["src", "cover"],
|
|
77
|
+
gallery: ["items[].src", "items[].thumbnail"],
|
|
78
|
+
link: ["url", "link.image", "link.favicon"],
|
|
79
|
+
map: [],
|
|
80
|
+
json: [],
|
|
81
|
+
code: [],
|
|
82
|
+
markdown: [],
|
|
83
|
+
text: [],
|
|
84
|
+
mermaid: [],
|
|
85
|
+
};
|