@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,221 @@
|
|
|
1
|
+
import { math } from "@streamdown/math";
|
|
2
|
+
import { code } from "@streamdown/code";
|
|
3
|
+
import { DiagramSource, diagramPlugin } from "@djangocfg/widget-media/diagram";
|
|
4
|
+
import { memo, type ComponentProps } from "react";
|
|
5
|
+
import { harden } from "rehype-harden";
|
|
6
|
+
import { Streamdown } from "streamdown";
|
|
7
|
+
|
|
8
|
+
import { maskIncompleteMath } from "../core/content";
|
|
9
|
+
import { mentionScheme } from "../core/links";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The ONE place a model's markdown becomes DOM.
|
|
13
|
+
*
|
|
14
|
+
* Everything below is a decision that was measured rather than assumed, because
|
|
15
|
+
* each one is invisible when wrong: a half-arrived fence that throws takes the
|
|
16
|
+
* transcript row down, and a formula typeset from a prefix renders a DIFFERENT
|
|
17
|
+
* expression than the one being written.
|
|
18
|
+
*
|
|
19
|
+
* ## Why the whole string is one block
|
|
20
|
+
*
|
|
21
|
+
* `parseMarkdownIntoBlocksFn={(s) => [s]}` disables streamdown's own splitting.
|
|
22
|
+
* Splitting on blank lines is what breaks reference definitions — `see [x][1]`
|
|
23
|
+
* and `[1]: https://…` land in different parses, so the link never resolves and
|
|
24
|
+
* the reader sees a literal `[1]`. Our previous splitter had the same defect
|
|
25
|
+
* plus a second one (loose ordered lists renumbering from 1), and both follow
|
|
26
|
+
* from splitting AT ALL, not from how it was done.
|
|
27
|
+
*
|
|
28
|
+
* The cost is real and accepted: a token now re-renders the whole message
|
|
29
|
+
* instead of its last block. `memo` below keeps that to the one message that
|
|
30
|
+
* changed, and a message is bounded — a transcript is not.
|
|
31
|
+
*
|
|
32
|
+
* ## Why these two plugins and not the other two
|
|
33
|
+
*
|
|
34
|
+
* streamdown's core carries no math, no highlighting, no diagrams; each is an
|
|
35
|
+
* opt-in package — streamdown does NOT drag KaTeX in on its own.
|
|
36
|
+
*
|
|
37
|
+
* - `math` (katex, ~1 dep tree) — IN. Agent answers carry complexity bounds and
|
|
38
|
+
* formulas; rendering them as `$O(n \log n)$` is a worse answer.
|
|
39
|
+
* - `code` (shiki) — IN. A transcript of an agent that edits files is mostly
|
|
40
|
+
* code; unhighlighted code is the single most-read thing here.
|
|
41
|
+
* - `mermaid` — IN, on OUR renderer rather than the upstream plugin. The cost
|
|
42
|
+
* objection stands against `@streamdown/mermaid`, which pulls an 84 MB
|
|
43
|
+
* runtime needing a DOM; `@djangocfg/widget-media` draws the same diagrams from a
|
|
44
|
+
* 2.5 MB parser needing none. The `show_media` tool declares `mermaid` among
|
|
45
|
+
* its kinds, so diagrams are produced whether or not this renders them — the
|
|
46
|
+
* alternative was not "no diagrams", it was a code block where one belongs.
|
|
47
|
+
* - `cjk` — OUT until a CJK locale ships. Its job is strikethrough/emphasis
|
|
48
|
+
* boundaries in CJK text; nothing today reads those.
|
|
49
|
+
*
|
|
50
|
+
* ## Inline math needs `$$`, and that default is KEPT
|
|
51
|
+
*
|
|
52
|
+
* `@streamdown/math` ships `singleDollarTextMath: false`. That reads like a
|
|
53
|
+
* missing feature and is not: a transcript from an agent that runs shells and
|
|
54
|
+
* quotes prices is FULL of lone dollars — `$PATH`, `$HOME`, `$50/month`, `cd
|
|
55
|
+
* $DIR` — and enabling single-dollar math turns the text between any two of
|
|
56
|
+
* them into a formula. The failure is silent and it corrupts the answer, which
|
|
57
|
+
* is strictly worse than `$x$` staying literal.
|
|
58
|
+
*
|
|
59
|
+
* So inline math is written `$$E = mc^2$$` and block math is a fenced `$$` on
|
|
60
|
+
* its own lines. Pinned by a test, because "turn it on" is a one-word edit that
|
|
61
|
+
* looks like an improvement.
|
|
62
|
+
*
|
|
63
|
+
* ## KaTeX needs its stylesheet
|
|
64
|
+
*
|
|
65
|
+
* The plugin emits `<span class="katex">`; without `katex/dist/katex.min.css`
|
|
66
|
+
* that renders as unstyled fallback glyphs. `math.getStyles()` returns the path
|
|
67
|
+
* — the HOST imports it, because this package ships no CSS of its own.
|
|
68
|
+
*
|
|
69
|
+
* ## Completion stays ON, and math is exempted from it
|
|
70
|
+
*
|
|
71
|
+
* streamdown completes half-arrived markdown, which is what makes a bold run
|
|
72
|
+
* style itself as it is typed. For math that same behaviour typesets a PREFIX —
|
|
73
|
+
* `$$O(n \log` renders a finished-looking `O(n log` that becomes a different
|
|
74
|
+
* formula on the next token:
|
|
75
|
+
*
|
|
76
|
+
* | unclosed | completion on | off |
|
|
77
|
+
* |---|---|---|
|
|
78
|
+
* | `**important` | bold | plain |
|
|
79
|
+
* | `` `foo( `` | code | plain |
|
|
80
|
+
* | `[docs](` | link | plain |
|
|
81
|
+
* | `$$O(n \log` | KATEX ← the defect | plain |
|
|
82
|
+
*
|
|
83
|
+
* `parseIncompleteMarkdown: false` fixes the last row by breaking the other
|
|
84
|
+
* three, so the flag stays on and the one harmful case is neutralised on the
|
|
85
|
+
* INPUT by `maskIncompleteMath` — see `core/content/incomplete-math.ts`.
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
const PLUGINS = { math, code, mermaid: diagramPlugin };
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What a fenced diagram shows before it can be drawn.
|
|
92
|
+
*
|
|
93
|
+
* Streamdown recomputes the mermaid branch on every token and does NOT pass it
|
|
94
|
+
* the `isIncomplete` flag it hands the code-block branch, so a diagram arriving
|
|
95
|
+
* a token at a time reaches the renderer dozens of times unfinished. The plugin
|
|
96
|
+
* rejects those, and this is what a rejection looks like: the source, not
|
|
97
|
+
* streamdown's default red error box, which would otherwise flash through the
|
|
98
|
+
* whole of a streamed diagram before the picture replaced it.
|
|
99
|
+
*/
|
|
100
|
+
const MERMAID_OPTIONS = { errorComponent: DiagramSource } as const;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Link hardening, re-declared so an app scheme survives it.
|
|
104
|
+
*
|
|
105
|
+
* The renderer hardens links through `rehype-harden`, whose `allowedProtocols`
|
|
106
|
+
* defaults to EMPTY. A scheme it does not recognise loses its `href` and the
|
|
107
|
+
* anchor is replaced by grey text reading `<label> [blocked]`, carrying
|
|
108
|
+
* `title="Blocked URL: undefined"`. Correct for `javascript:`; wrong for an app
|
|
109
|
+
* scheme the HOST assigns meaning to — a `<scheme>://user/<id>` mention
|
|
110
|
+
* rendered as `name [blocked]` mid-sentence, which is what shipped.
|
|
111
|
+
*
|
|
112
|
+
* `urlTransform` does NOT fix it: that runs earlier, and harden re-checks the
|
|
113
|
+
* href afterwards. The plugin is the only place the decision is made.
|
|
114
|
+
*
|
|
115
|
+
* Declared here rather than passed by the host because `components` is already
|
|
116
|
+
* the seam for "this href means something to me" — a host that supplies a link
|
|
117
|
+
* renderer must not also have to know that a plugin would strip the href before
|
|
118
|
+
* its renderer ever sees it.
|
|
119
|
+
*/
|
|
120
|
+
/**
|
|
121
|
+
* Built on FIRST RENDER, not at module load, and cached per scheme.
|
|
122
|
+
*
|
|
123
|
+
* A host configures the scheme at startup, which may run after this module is
|
|
124
|
+
* evaluated. Baking the protocol list in at load time would therefore block
|
|
125
|
+
* every one of that host's own mentions, with `[blocked]` in the transcript as
|
|
126
|
+
* the only symptom.
|
|
127
|
+
*/
|
|
128
|
+
let cachedScheme: string | undefined;
|
|
129
|
+
let cachedPlugins: readonly unknown[] | undefined;
|
|
130
|
+
|
|
131
|
+
function rehypePlugins(): readonly unknown[] {
|
|
132
|
+
const scheme = mentionScheme();
|
|
133
|
+
if (cachedPlugins === undefined || cachedScheme !== scheme) {
|
|
134
|
+
cachedScheme = scheme;
|
|
135
|
+
cachedPlugins = [
|
|
136
|
+
[
|
|
137
|
+
harden,
|
|
138
|
+
{
|
|
139
|
+
// `*` for prefixes keeps the default reach; the PROTOCOL list is the
|
|
140
|
+
// part that was empty, and it is what blocked the app scheme.
|
|
141
|
+
allowedLinkPrefixes: ["*"],
|
|
142
|
+
allowedImagePrefixes: ["*"],
|
|
143
|
+
allowedProtocols: ["http:", "https:", "mailto:", "tel:", `${scheme}:`],
|
|
144
|
+
allowDataImages: true,
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
];
|
|
148
|
+
}
|
|
149
|
+
return cachedPlugins;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Links are anchors, not confirm buttons.
|
|
154
|
+
*
|
|
155
|
+
* `linkSafety` defaults to ON, and an enabled one renders every link as a
|
|
156
|
+
* `<button>` carrying no `href` — clicking opens an "are you sure" modal first.
|
|
157
|
+
* That is built for a chat where a STRANGER writes the link. Here the author is
|
|
158
|
+
* the agent the host put behind this room, and the two threats the modal
|
|
159
|
+
* guards are already answered upstream: a dangerous scheme cannot survive
|
|
160
|
+
* the hardened protocol list, and content the agent did not author is
|
|
161
|
+
* datamarked before it ever reaches a model. What was left was a second click
|
|
162
|
+
* on every citation.
|
|
163
|
+
*
|
|
164
|
+
* Deliberately NOT a domain allowlist of "our" hosts: that is a second source
|
|
165
|
+
* of truth about which addresses are ours, needing an edit for every new one,
|
|
166
|
+
* and it is the same thing `allowedLinkPrefixes: ["*"]` above already declined.
|
|
167
|
+
* The protocol is the class of threat; the domain is not.
|
|
168
|
+
*/
|
|
169
|
+
const LINK_SAFETY = { enabled: false } as const;
|
|
170
|
+
|
|
171
|
+
/** One block: see the reference-definition rationale above. */
|
|
172
|
+
const wholeString = (source: string) => [source];
|
|
173
|
+
|
|
174
|
+
export interface ResponseProps {
|
|
175
|
+
/** Markdown, possibly mid-stream. */
|
|
176
|
+
readonly children: string;
|
|
177
|
+
readonly className?: string;
|
|
178
|
+
/**
|
|
179
|
+
* Element overrides, passed through to the markdown renderer.
|
|
180
|
+
*
|
|
181
|
+
* The seam exists for links whose HREF the host owns a meaning for.
|
|
182
|
+
* `[@Dana Reyes](<scheme>://user/<id>)` is a MENTION here, and this
|
|
183
|
+
* package must not learn what a mentionable thing is — so the host overrides `a`,
|
|
184
|
+
* recognises its own scheme, and renders a chip. An unrecognised href falls
|
|
185
|
+
* through to the default anchor.
|
|
186
|
+
*
|
|
187
|
+
* Without it a mention paints as an ordinary link to a scheme no browser can
|
|
188
|
+
* follow: visibly a link, inert on click.
|
|
189
|
+
*
|
|
190
|
+
* MUST be referentially stable — an inline object is a new reference on every
|
|
191
|
+
* render, and the comparator below would then re-parse the markdown of every
|
|
192
|
+
* message on every parent render, which is the cost that comparator exists to
|
|
193
|
+
* avoid.
|
|
194
|
+
*/
|
|
195
|
+
readonly components?: ComponentProps<typeof Streamdown>["components"];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export const Response = memo(
|
|
199
|
+
({ children, className, components }: ResponseProps) => (
|
|
200
|
+
<Streamdown
|
|
201
|
+
className={className}
|
|
202
|
+
parseMarkdownIntoBlocksFn={wholeString}
|
|
203
|
+
plugins={PLUGINS}
|
|
204
|
+
mermaid={MERMAID_OPTIONS}
|
|
205
|
+
components={components}
|
|
206
|
+
linkSafety={LINK_SAFETY}
|
|
207
|
+
rehypePlugins={rehypePlugins() as never}
|
|
208
|
+
>
|
|
209
|
+
{maskIncompleteMath(children)}
|
|
210
|
+
</Streamdown>
|
|
211
|
+
),
|
|
212
|
+
// Vercel's own comparator, and for the same reason: `Streamdown` is the most
|
|
213
|
+
// expensive node in the transcript, and a parent re-rendering for an unrelated
|
|
214
|
+
// reason (a scroll flag, a new sibling message) must not re-parse markdown.
|
|
215
|
+
(prev, next) =>
|
|
216
|
+
prev.children === next.children &&
|
|
217
|
+
prev.className === next.className &&
|
|
218
|
+
prev.components === next.components,
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
Response.displayName = "Response";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The backend contract. ONE required method.
|
|
3
|
+
*
|
|
4
|
+
* Everything optional an adapter might do — mentions, retry — is a
|
|
5
|
+
* separate capability object, not a field here. That split is what keeps the
|
|
6
|
+
* root component at seven props: a capability that is absent is a feature that
|
|
7
|
+
* is off, expressed once, instead of a boolean plus its four companion props.
|
|
8
|
+
*
|
|
9
|
+
* A `ReadableStream` rather than a callback bag because backpressure and
|
|
10
|
+
* cancellation come with it: `AbortSignal` stops the stream and the reducer
|
|
11
|
+
* needs no "am I still the current turn?" bookkeeping.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { StreamChunk } from "./chunks";
|
|
15
|
+
import type { ChatMessage } from "./parts";
|
|
16
|
+
|
|
17
|
+
export interface SendRequest {
|
|
18
|
+
/** The full outbound text, tool markers already appended. */
|
|
19
|
+
readonly text: string;
|
|
20
|
+
/** Prior turns, oldest first. The adapter decides how much it forwards. */
|
|
21
|
+
readonly history: readonly ChatMessage[];
|
|
22
|
+
/** The message this turn answers, or absent. Opaque — the adapter forwards it. */
|
|
23
|
+
readonly replyTo?: string;
|
|
24
|
+
readonly signal: AbortSignal;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ChatAdapter {
|
|
28
|
+
sendMessage(request: SendRequest): Promise<ReadableStream<StreamChunk>>;
|
|
29
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capabilities — the answer to 83 props.
|
|
3
|
+
*
|
|
4
|
+
* Every optional feature is ONE object. Present means the feature exists;
|
|
5
|
+
* absent means it does not, and no UI for it renders. There is no boolean to
|
|
6
|
+
* contradict the object, and no companion props to keep in sync with it — those
|
|
7
|
+
* two failure modes are what produced a configuration space nobody could
|
|
8
|
+
* enumerate (`@rules/architecture.md`).
|
|
9
|
+
*
|
|
10
|
+
* The rule that keeps it honest: a capability owns the WHOLE feature — its
|
|
11
|
+
* behaviour, its limits, its callbacks. If adding one still requires a prop on
|
|
12
|
+
* `<Chat>`, the capability is drawn wrong.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { MediaBlock, MediaGeneration } from "./media/block";
|
|
16
|
+
import type { ToolsCapability } from "./tools/capability";
|
|
17
|
+
import type { VoiceSoundsCapability } from "./voice/earcons";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Re-running a turn.
|
|
21
|
+
*
|
|
22
|
+
* Separate from the adapter because not every backend can: a replayed history
|
|
23
|
+
* transcript has no live turn to retry, and the button must be absent there
|
|
24
|
+
* rather than present-and-broken.
|
|
25
|
+
*/
|
|
26
|
+
export interface RetryCapability {
|
|
27
|
+
readonly retry: (messageId: string) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* End the turn on the AGENT, not just the local stream.
|
|
32
|
+
*
|
|
33
|
+
* Aborting the stream only closes this tab's reader: the run is deliberately
|
|
34
|
+
* detached from its caller, so the agent keeps working and the button silences
|
|
35
|
+
* the tab. This carries the explicit stop the host sends instead.
|
|
36
|
+
*
|
|
37
|
+
* Absent ⇒ Stop stays local-only, which is correct for a host with no transport
|
|
38
|
+
* behind it — a replayed transcript has no run to end.
|
|
39
|
+
*/
|
|
40
|
+
export interface CancelRunCapability {
|
|
41
|
+
/** Rejects on a refusal the host chose to surface; the abort happens either way. */
|
|
42
|
+
readonly cancel: () => Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Dictation.
|
|
47
|
+
*
|
|
48
|
+
* The contract is the smallest one that works: start, stop, and a stream of
|
|
49
|
+
* text. Which engine, which language and whether audio leaves the device are
|
|
50
|
+
* product and privacy decisions, and this seam is what keeps them the host's.
|
|
51
|
+
*
|
|
52
|
+
* **Absent does NOT mean no microphone here**, unlike every other capability.
|
|
53
|
+
* The package ships a Web Speech default (`core/voice/web-speech.ts`), so an
|
|
54
|
+
* absent capability falls back to the browser and only a browser without an
|
|
55
|
+
* engine yields no button. A host supplying one overrides the default outright.
|
|
56
|
+
* The exception is deliberate: without it every consumer re-implements the same
|
|
57
|
+
* engine, which is how a host ends up depending on an external toolkit for a
|
|
58
|
+
* seam the package could have owned.
|
|
59
|
+
*
|
|
60
|
+
* `false` is how a host says NO — the one thing absence cannot express here,
|
|
61
|
+
* since absence already means "use the default". A phone keyboard has dictation
|
|
62
|
+
* of its own, so a second microphone in the composer is a duplicate control
|
|
63
|
+
* spending a tile in the row a thumb reaches.
|
|
64
|
+
*/
|
|
65
|
+
export interface VoiceCapability {
|
|
66
|
+
/**
|
|
67
|
+
* Begin dictation.
|
|
68
|
+
*
|
|
69
|
+
* `onText` may be called many times. `final: false` is an interim result that
|
|
70
|
+
* REPLACES the previous interim; `final: true` commits. Without that
|
|
71
|
+
* distinction an interim "hello wor" and its correction both land in the
|
|
72
|
+
* draft and the user watches their sentence duplicate itself.
|
|
73
|
+
*/
|
|
74
|
+
readonly start: (
|
|
75
|
+
onText: (text: string, final: boolean) => void,
|
|
76
|
+
signal: AbortSignal,
|
|
77
|
+
) => Promise<void>;
|
|
78
|
+
/**
|
|
79
|
+
* Stop and discard any uncommitted interim.
|
|
80
|
+
*
|
|
81
|
+
* MUST be safe to call synchronously from a send. The archived chat notes
|
|
82
|
+
* why: on submit, "voice dictation stops recording instead of talking into a
|
|
83
|
+
* sent message" — a listener that settles asynchronously lets the next words
|
|
84
|
+
* land in a draft that has already been cleared.
|
|
85
|
+
*/
|
|
86
|
+
readonly stop: () => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Rendering the agent's media blocks.
|
|
91
|
+
*
|
|
92
|
+
* Absent ⇒ media blocks render NOTHING. That is the safe default rather than a
|
|
93
|
+
* degraded one: a media src is a URL the MODEL chose, and displaying it is an
|
|
94
|
+
* outbound request made on the user's behalf. A host that cannot say which
|
|
95
|
+
* same-origin paths are legitimately its own cannot gate that request, so it
|
|
96
|
+
* must not make it.
|
|
97
|
+
*
|
|
98
|
+
* The prefixes are the capability's whole content because they are the one part
|
|
99
|
+
* a package cannot know. `core/media/allowed-src.ts` gates schemes on its own —
|
|
100
|
+
* `https:`, `app://`, `data:image/` are the same everywhere — but an absolute
|
|
101
|
+
* path is only safe relative to whoever serves the transcript. Baking one
|
|
102
|
+
* transport's routes into this package would be the second source of truth its
|
|
103
|
+
* own header refuses.
|
|
104
|
+
*/
|
|
105
|
+
export interface MediaCapability {
|
|
106
|
+
/**
|
|
107
|
+
* Absolute-path prefixes this host serves media from, e.g.
|
|
108
|
+
* `/api/admin/attachments/`. Case-sensitive, and matched only after the path
|
|
109
|
+
* is proven already-clean — see `isAllowedSrc`.
|
|
110
|
+
*
|
|
111
|
+
* Empty ⇒ only the allowlisted SCHEMES render; no same-origin path does.
|
|
112
|
+
*/
|
|
113
|
+
readonly sameOriginPrefixes: readonly string[];
|
|
114
|
+
/**
|
|
115
|
+
* Draw a playable block (audio, video, map) with the host's own player.
|
|
116
|
+
*
|
|
117
|
+
* Absent ⇒ the native `<audio controls>` / `<video controls>`, which cannot
|
|
118
|
+
* join the shell's player, show embedded artwork, or rewrite a video src
|
|
119
|
+
* onto a streaming route.
|
|
120
|
+
*
|
|
121
|
+
* On MediaCapability rather than its own: a host that can render media is the
|
|
122
|
+
* same host that decides what a player is. Returning undefined for one kind
|
|
123
|
+
* falls back per kind.
|
|
124
|
+
*
|
|
125
|
+
* `unknown`, not `ReactNode`: `src/core` imports no React. The renderer that
|
|
126
|
+
* consumes this narrows it.
|
|
127
|
+
*/
|
|
128
|
+
readonly renderPlayer?: (block: MediaBlock) => unknown;
|
|
129
|
+
/**
|
|
130
|
+
* Draw a picture with the host's own viewer.
|
|
131
|
+
*
|
|
132
|
+
* Absent ⇒ a plain `<img>`, which is the one thing in a transcript that looks
|
|
133
|
+
* openable and is not. The seam exists for the same reason `renderPlayer`
|
|
134
|
+
* does: a lightbox is a heavy dependency belonging to the host's design
|
|
135
|
+
* system, and a package that shipped one would carry it into every mount.
|
|
136
|
+
*
|
|
137
|
+
* Format is never part of the decision — whatever the browser can render,
|
|
138
|
+
* the host is handed the same src the `<img>` would have used.
|
|
139
|
+
* `unknown`, not `ReactNode`: `src/core` imports no React.
|
|
140
|
+
*/
|
|
141
|
+
readonly renderImage?: (image: {
|
|
142
|
+
readonly src: string;
|
|
143
|
+
/** The filename, which is also the picture's only description. */
|
|
144
|
+
readonly title: string;
|
|
145
|
+
/** What produced it, when a tool authored the picture rather than found it. */
|
|
146
|
+
readonly generation?: MediaGeneration;
|
|
147
|
+
}) => unknown;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Starter prompts for an empty room.
|
|
152
|
+
*
|
|
153
|
+
* Absent ⇒ an empty transcript shows its one line of text and nothing else, the
|
|
154
|
+
* same absent-not-stubbed rule as voice. A read-only replay has no agent to
|
|
155
|
+
* start, so the affordance must be absent rather than inert.
|
|
156
|
+
*
|
|
157
|
+
* The CATALOGUE is the host's, and that is load-bearing rather than tidy:
|
|
158
|
+
* choosing the set needs facts this package cannot hold — which of the agent's
|
|
159
|
+
* tool families exist on the server behind the transport, and that different
|
|
160
|
+
* agent engines reach different subsets of them. A list declared here would be
|
|
161
|
+
* a second source of truth against the one the host generates.
|
|
162
|
+
*
|
|
163
|
+
* The stakes are higher than for a menu of verbs the transport can refuse. An
|
|
164
|
+
* unsupported verb comes back as an error; a suggestion is ordinary prose, so
|
|
165
|
+
* nothing refuses it — the model answers, improvising about a tool it does not
|
|
166
|
+
* have. There is no server-side gate behind this list at all.
|
|
167
|
+
*/
|
|
168
|
+
export interface SuggestionsCapability {
|
|
169
|
+
/** The list to offer, already filtered by the host, in display order. */
|
|
170
|
+
readonly items: readonly ChatSuggestion[];
|
|
171
|
+
/**
|
|
172
|
+
* The language to answer in, named in ENGLISH ("Russian"), appended to the
|
|
173
|
+
* prompt as one sentence.
|
|
174
|
+
*
|
|
175
|
+
* Prompts stay English because they are model-facing; the reply is what the
|
|
176
|
+
* user reads. The host supplies it: no locale reaches the agent on the ask
|
|
177
|
+
* path, so this cannot be decided further down.
|
|
178
|
+
*
|
|
179
|
+
* Absent ⇒ nothing appended. An English-language host needs no "Reply in
|
|
180
|
+
* English."
|
|
181
|
+
*/
|
|
182
|
+
readonly replyLanguage?: string;
|
|
183
|
+
/**
|
|
184
|
+
* Observe a pick. The prompt is ALREADY SENT when this runs — this is a
|
|
185
|
+
* notification, not the send itself.
|
|
186
|
+
*
|
|
187
|
+
* The package sends it down the same path the composer uses (`store.send`),
|
|
188
|
+
* which is what makes a picked row indistinguishable from a typed message:
|
|
189
|
+
* it queues behind a turn in flight, it gets the same optimistic row, and it
|
|
190
|
+
* obeys the same guards. A host performing the send itself would be a second
|
|
191
|
+
* path to one behaviour, and the two would drift on exactly those cases.
|
|
192
|
+
*/
|
|
193
|
+
readonly onPick?: (suggestion: ChatSuggestion) => void;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* One starter row.
|
|
198
|
+
*
|
|
199
|
+
* Two texts, deliberately, because two audiences read them: `title` is shown
|
|
200
|
+
* and translated, `prompt` is sent to the model and is not. One string used as
|
|
201
|
+
* both — the shape Vercel's primitive takes — forces a choice between a row too
|
|
202
|
+
* long to scan and a prompt too terse to act on.
|
|
203
|
+
*/
|
|
204
|
+
export interface ChatSuggestion {
|
|
205
|
+
readonly id: string;
|
|
206
|
+
/** What the row READS. Short enough for a narrow pane. */
|
|
207
|
+
readonly title: string;
|
|
208
|
+
/** What is SENT. May be much longer than the title. */
|
|
209
|
+
readonly prompt: string;
|
|
210
|
+
/**
|
|
211
|
+
* Glyph KEY, never a node — the catalogue crosses the transport as data. An
|
|
212
|
+
* unmapped key draws no glyph rather than failing, so a row may ship before
|
|
213
|
+
* its icon.
|
|
214
|
+
*/
|
|
215
|
+
readonly icon?: string;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Reading back a mention the composer wrote as an opaque token.
|
|
220
|
+
*
|
|
221
|
+
* Absent ⇒ every mention already reads as itself, which is true wherever the
|
|
222
|
+
* written token IS the name. A host whose correspondents are named in free text
|
|
223
|
+
* writes an id instead — that is what keeps a mention resolvable and durable
|
|
224
|
+
* across a rename — and supplies this so the reader still sees the name.
|
|
225
|
+
*
|
|
226
|
+
* An unknown token resolves to nothing and stays as written. It names someone
|
|
227
|
+
* who has left or has not loaded, and a substituted guess would put a wrong
|
|
228
|
+
* name into a message already sent.
|
|
229
|
+
*/
|
|
230
|
+
export interface MentionNamesCapability {
|
|
231
|
+
readonly nameFor: (token: string) => string | undefined;
|
|
232
|
+
/**
|
|
233
|
+
* The whole correspondent behind a token, for a host that draws more than a
|
|
234
|
+
* name — a face, a role, a card.
|
|
235
|
+
*
|
|
236
|
+
* `unknown`, not a typed row: `src/core` imports no React and knows nothing
|
|
237
|
+
* about what a correspondent IS. The host that supplies this is the same one
|
|
238
|
+
* that renders it, so the type is checked where both ends meet rather than
|
|
239
|
+
* described here. Absent ⇒ the name alone.
|
|
240
|
+
*/
|
|
241
|
+
readonly contactFor?: (token: string) => unknown;
|
|
242
|
+
/**
|
|
243
|
+
* How to WRITE a mention of this address, already serialized.
|
|
244
|
+
*
|
|
245
|
+
* The finished text rather than its parts, because the spelling belongs to
|
|
246
|
+
* the host that owns how a mention is written — returning the parts would let
|
|
247
|
+
* a second caller compose them differently. Absent ⇒ no way to write one, and
|
|
248
|
+
* the affordance is omitted.
|
|
249
|
+
*/
|
|
250
|
+
readonly mentionFor?: (token: string) => string | undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* A longer reasoning pass before the answer — the composer's "Think" toggle.
|
|
255
|
+
*
|
|
256
|
+
* Absent ⇒ no button renders. That is the required shape here, not a
|
|
257
|
+
* preference: only a third of the catalogue accepts the `reasoning_effort`
|
|
258
|
+
* request field (measured 2026-08-29 — 133 of 396 models, a strict subset of
|
|
259
|
+
* the 270 that reason), so a toggle shown unconditionally would be pressable
|
|
260
|
+
* and inert on models like Claude Opus and DeepSeek R1. A control that silently
|
|
261
|
+
* does nothing is worse than one that is not there.
|
|
262
|
+
*
|
|
263
|
+
* The host owns the decision because only it can see which model is active —
|
|
264
|
+
* the model catalogue it fetches is what carries `reasoningEffort` per model.
|
|
265
|
+
*/
|
|
266
|
+
export interface ThinkCapability {
|
|
267
|
+
/**
|
|
268
|
+
* Whether the toggle is currently on. STICKY across turns, like ChatGPT's:
|
|
269
|
+
* it lives with the host, not in the draft, because the draft is cleared on
|
|
270
|
+
* every send and a value stored there would silently switch itself off.
|
|
271
|
+
*/
|
|
272
|
+
readonly enabled: boolean;
|
|
273
|
+
readonly setEnabled: (enabled: boolean) => void;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface ChatCapabilities {
|
|
277
|
+
readonly suggestions?: SuggestionsCapability;
|
|
278
|
+
readonly tools?: ToolsCapability;
|
|
279
|
+
readonly retry?: RetryCapability;
|
|
280
|
+
readonly cancelRun?: CancelRunCapability;
|
|
281
|
+
readonly voice?: VoiceCapability | false;
|
|
282
|
+
readonly voiceSounds?: VoiceSoundsCapability;
|
|
283
|
+
readonly think?: ThinkCapability;
|
|
284
|
+
readonly media?: MediaCapability;
|
|
285
|
+
readonly mentionNames?: MentionNamesCapability;
|
|
286
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire vocabulary between an adapter and this package.
|
|
3
|
+
*
|
|
4
|
+
* An adapter's ONLY obligation is to emit these. It does not build messages,
|
|
5
|
+
* hold state, or know about React — so a new backend is one file, and the
|
|
6
|
+
* reducer stays the single owner of how a transcript is assembled.
|
|
7
|
+
*
|
|
8
|
+
* Chunks name PART ids explicitly (`partId`) rather than implying "the last
|
|
9
|
+
* part". Implicit targeting is what makes interleaved output — text, then a
|
|
10
|
+
* tool, then more text — collapse into the wrong part; naming it costs the
|
|
11
|
+
* adapter nothing since it already knows which stream it is reading.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { DataPart } from "./parts";
|
|
15
|
+
|
|
16
|
+
export type StreamChunk =
|
|
17
|
+
/**
|
|
18
|
+
* A new assistant message begins. Everything after targets this message.
|
|
19
|
+
*
|
|
20
|
+
* `createdAt` comes from the adapter because the core reads no clock: a
|
|
21
|
+
* reducer that called `Date.now()` would make every transcript test
|
|
22
|
+
* time-dependent, and history replay would stamp old turns with now.
|
|
23
|
+
*/
|
|
24
|
+
| { readonly type: "message-start"; readonly messageId: string; readonly createdAt: number }
|
|
25
|
+
| { readonly type: "text-start"; readonly partId: string }
|
|
26
|
+
| { readonly type: "text-delta"; readonly partId: string; readonly delta: string }
|
|
27
|
+
| { readonly type: "reasoning-start"; readonly partId: string }
|
|
28
|
+
| { readonly type: "reasoning-delta"; readonly partId: string; readonly delta: string }
|
|
29
|
+
/**
|
|
30
|
+
* The thought is finished — the only chunk that can say so.
|
|
31
|
+
*
|
|
32
|
+
* Without it a block is sealed by the NEXT part opening, which leaves the
|
|
33
|
+
* last thought of a turn streaming forever when the model answers with a
|
|
34
|
+
* tool call, or with nothing at all. `elapsedMs` and `tokens` carry whatever
|
|
35
|
+
* the engine measured; both absent is ordinary.
|
|
36
|
+
*/
|
|
37
|
+
| {
|
|
38
|
+
readonly type: "reasoning-end";
|
|
39
|
+
readonly partId: string;
|
|
40
|
+
readonly elapsedMs?: number;
|
|
41
|
+
readonly tokens?: number;
|
|
42
|
+
}
|
|
43
|
+
/** Arguments begin streaming; the part appears immediately, in `input-streaming`. */
|
|
44
|
+
| { readonly type: "tool-input-start"; readonly toolCallId: string; readonly toolName: string }
|
|
45
|
+
| { readonly type: "tool-input-delta"; readonly toolCallId: string; readonly delta: string }
|
|
46
|
+
| { readonly type: "tool-input-available"; readonly toolCallId: string; readonly input: unknown }
|
|
47
|
+
| { readonly type: "tool-output"; readonly toolCallId: string; readonly output: unknown }
|
|
48
|
+
| { readonly type: "tool-error"; readonly toolCallId: string; readonly error: string }
|
|
49
|
+
| { readonly type: "data"; readonly part: DataPart }
|
|
50
|
+
/**
|
|
51
|
+
* Discard everything accumulated for the current message and start it over —
|
|
52
|
+
* the agent is re-issuing the turn after a mid-stream cut.
|
|
53
|
+
*
|
|
54
|
+
* An INSTRUCTION, not a notice. Ignoring it renders the abandoned fragment
|
|
55
|
+
* followed by the real one, which reads as the agent repeating itself.
|
|
56
|
+
*/
|
|
57
|
+
| { readonly type: "reset" }
|
|
58
|
+
| { readonly type: "finish" }
|
|
59
|
+
/** The server reported the turn as failed. Terminal, and durable server-side. */
|
|
60
|
+
| { readonly type: "error"; readonly message: string }
|
|
61
|
+
/**
|
|
62
|
+
* The transport died without a terminal frame. NOT a failure verdict: the
|
|
63
|
+
* reply may or may not have been persisted, so the host reconciles on
|
|
64
|
+
* reconnect rather than showing a permanent error.
|
|
65
|
+
*/
|
|
66
|
+
| { readonly type: "interrupted" };
|