@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.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/package.json +95 -0
  4. package/src/composer/composer.tsx +100 -0
  5. package/src/composer/context.ts +33 -0
  6. package/src/composer/controls/button.tsx +104 -0
  7. package/src/composer/controls/language-menu.tsx +123 -0
  8. package/src/composer/controls/send.tsx +83 -0
  9. package/src/composer/controls/think.tsx +50 -0
  10. package/src/composer/controls/tools.tsx +130 -0
  11. package/src/composer/controls/tooltip.tsx +10 -0
  12. package/src/composer/controls/voice.tsx +266 -0
  13. package/src/composer/field/field.tsx +140 -0
  14. package/src/composer/field/input.tsx +214 -0
  15. package/src/composer/field/keys.ts +44 -0
  16. package/src/composer/field/use-autosize.ts +134 -0
  17. package/src/composer/focus-field.ts +27 -0
  18. package/src/composer/index.ts +86 -0
  19. package/src/composer/stories/field.tsx +112 -0
  20. package/src/composer/stories/harness.tsx +127 -0
  21. package/src/composer/tools/menu.tsx +129 -0
  22. package/src/composer/tools/rows.tsx +180 -0
  23. package/src/composer/tools/use-menu-nav.ts +66 -0
  24. package/src/composer/tools/use-recent-tools.ts +30 -0
  25. package/src/composer/tray/banner.tsx +157 -0
  26. package/src/composer/tray/command-progress.tsx +116 -0
  27. package/src/composer/tray/picked-tools.tsx +104 -0
  28. package/src/composer/tray/queue.tsx +202 -0
  29. package/src/composer/tray/use-replace-text.ts +58 -0
  30. package/src/composer/use-draft-persistence.ts +79 -0
  31. package/src/content/index.ts +16 -0
  32. package/src/content/media-content.tsx +38 -0
  33. package/src/content/response.tsx +221 -0
  34. package/src/core/adapter.ts +29 -0
  35. package/src/core/capabilities.ts +286 -0
  36. package/src/core/chunks.ts +66 -0
  37. package/src/core/content/incomplete-math.ts +118 -0
  38. package/src/core/content/index.ts +9 -0
  39. package/src/core/draft-storage.ts +62 -0
  40. package/src/core/events.ts +88 -0
  41. package/src/core/focus/index.ts +10 -0
  42. package/src/core/focus/roving.ts +56 -0
  43. package/src/core/group-parts.ts +80 -0
  44. package/src/core/index.ts +34 -0
  45. package/src/core/links/index.ts +33 -0
  46. package/src/core/links/prose.ts +191 -0
  47. package/src/core/links/scheme.ts +98 -0
  48. package/src/core/links/tool-id.ts +26 -0
  49. package/src/core/media/allowed-src.ts +138 -0
  50. package/src/core/media/block.ts +254 -0
  51. package/src/core/media/index.ts +22 -0
  52. package/src/core/media/kinds.ts +85 -0
  53. package/src/core/outbound.ts +86 -0
  54. package/src/core/parts.ts +169 -0
  55. package/src/core/queue/index.ts +15 -0
  56. package/src/core/queue/queue.ts +100 -0
  57. package/src/core/recall/boundary.ts +77 -0
  58. package/src/core/recall/from-transcript.ts +47 -0
  59. package/src/core/recall/index.ts +23 -0
  60. package/src/core/recall/ladder.ts +171 -0
  61. package/src/core/reload-decision.ts +36 -0
  62. package/src/core/scroll/index.ts +17 -0
  63. package/src/core/scroll/missed.ts +106 -0
  64. package/src/core/scroll/sent.ts +54 -0
  65. package/src/core/text-replace.ts +20 -0
  66. package/src/core/tool-name.ts +31 -0
  67. package/src/core/tool-picks-set.ts +48 -0
  68. package/src/core/tool-picks.ts +142 -0
  69. package/src/core/tool-prompt.ts +47 -0
  70. package/src/core/tools/capability.ts +34 -0
  71. package/src/core/tools/catalogue.ts +109 -0
  72. package/src/core/tools/index.ts +17 -0
  73. package/src/core/tools/layout.ts +44 -0
  74. package/src/core/tools/nav.ts +32 -0
  75. package/src/core/tools/rank.ts +60 -0
  76. package/src/core/tools/recent.ts +39 -0
  77. package/src/core/transcript.ts +301 -0
  78. package/src/core/voice/earcons.ts +58 -0
  79. package/src/core/voice/index.ts +26 -0
  80. package/src/core/voice/language-search.ts +62 -0
  81. package/src/core/voice/languages.ts +267 -0
  82. package/src/core/voice/resolve-language.ts +93 -0
  83. package/src/core/voice/session.ts +196 -0
  84. package/src/core/voice/web-speech.ts +170 -0
  85. package/src/i18n/index.ts +87 -0
  86. package/src/i18n/locales/ar.ts +97 -0
  87. package/src/i18n/locales/da.ts +90 -0
  88. package/src/i18n/locales/de.ts +90 -0
  89. package/src/i18n/locales/en.ts +90 -0
  90. package/src/i18n/locales/es.ts +90 -0
  91. package/src/i18n/locales/fr.ts +90 -0
  92. package/src/i18n/locales/it.ts +90 -0
  93. package/src/i18n/locales/ja.ts +83 -0
  94. package/src/i18n/locales/ko.ts +83 -0
  95. package/src/i18n/locales/nl.ts +90 -0
  96. package/src/i18n/locales/no.ts +90 -0
  97. package/src/i18n/locales/pl.ts +96 -0
  98. package/src/i18n/locales/ptBR.ts +90 -0
  99. package/src/i18n/locales/ru.ts +96 -0
  100. package/src/i18n/locales/sv.ts +90 -0
  101. package/src/i18n/locales/tr.ts +89 -0
  102. package/src/i18n/locales/zh.ts +83 -0
  103. package/src/i18n/plural.ts +62 -0
  104. package/src/i18n/types.ts +257 -0
  105. package/src/index.ts +18 -0
  106. package/src/primitives/index.ts +43 -0
  107. package/src/primitives/message.tsx +85 -0
  108. package/src/primitives/part-registry.tsx +44 -0
  109. package/src/primitives/stories-harness.tsx +118 -0
  110. package/src/primitives/suggestions.tsx +112 -0
  111. package/src/primitives/tool-call.tsx +145 -0
  112. package/src/primitives/tooltip.tsx +48 -0
  113. package/src/primitives/transcript.tsx +285 -0
  114. package/src/primitives/use-dismiss.ts +100 -0
  115. package/src/primitives/use-missed-count.ts +30 -0
  116. package/src/primitives/use-overflows.ts +69 -0
  117. package/src/primitives/use-return-on-send.ts +29 -0
  118. package/src/primitives/use-roving-focus.ts +69 -0
  119. package/src/primitives/use-select-all-transcript.ts +111 -0
  120. package/src/primitives/use-stick-to-bottom.ts +83 -0
  121. package/src/primitives/use-turn-activity.ts +25 -0
  122. package/src/store/context.ts +28 -0
  123. package/src/store/hooks.ts +215 -0
  124. package/src/store/index.ts +31 -0
  125. package/src/store/provider.tsx +97 -0
  126. package/src/store/store.ts +394 -0
  127. package/src/store/subscribers.ts +55 -0
  128. package/src/store/surfaces.ts +174 -0
  129. package/src/store/use-draft.ts +168 -0
  130. package/src/store/use-queue.ts +61 -0
  131. package/src/store/use-recall.ts +264 -0
  132. package/src/store/use-reload-on-revision.ts +67 -0
  133. package/src/store/use-speech-language.ts +84 -0
  134. package/src/store/use-voice.ts +116 -0
  135. package/src/styles/block.css +128 -0
  136. package/src/styles/composer.css +449 -0
  137. package/src/styles/index.css +47 -0
  138. package/src/styles/markdown.css +368 -0
  139. package/src/styles/surface.css +102 -0
  140. package/src/styles/tool.css +128 -0
  141. package/src/styles/turn-mark.css +97 -0
  142. package/src/testing/index.ts +15 -0
  143. package/src/testing/mock-adapter.ts +69 -0
  144. package/src/testing/mock-tools.ts +101 -0
  145. package/src/testing/mock-voice.ts +81 -0
  146. package/src/ui/chat-composer.tsx +286 -0
  147. package/src/ui/chat-reply-chip.tsx +42 -0
  148. package/src/ui/chat-suggestions.tsx +87 -0
  149. package/src/ui/chat-transcript.tsx +157 -0
  150. package/src/ui/chat.tsx +197 -0
  151. package/src/ui/collapsible-body.tsx +180 -0
  152. package/src/ui/collapsible-row.tsx +81 -0
  153. package/src/ui/day-divider.tsx +49 -0
  154. package/src/ui/index.ts +54 -0
  155. package/src/ui/jump-to-latest.tsx +60 -0
  156. package/src/ui/link-chip.tsx +73 -0
  157. package/src/ui/message-actions.tsx +163 -0
  158. package/src/ui/message-quote.tsx +47 -0
  159. package/src/ui/message.tsx +295 -0
  160. package/src/ui/pending.tsx +85 -0
  161. package/src/ui/renderers/data.tsx +63 -0
  162. package/src/ui/renderers/reasoning.tsx +73 -0
  163. package/src/ui/renderers/tool.tsx +176 -0
  164. package/src/ui/turn-mark.tsx +31 -0
  165. package/src/utils/day-heading.ts +46 -0
  166. package/src/utils/error-message.ts +10 -0
  167. package/src/utils/index.ts +12 -0
  168. package/src/utils/markdown-text.ts +175 -0
  169. package/src/widgets/index.ts +14 -0
  170. 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" };