@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,239 @@
1
+ import type { ReactNode } from "react";
2
+ import type { MediaBlock, MediaGeneration } from "../core";
3
+
4
+ /**
5
+ * The media registry: one event type, eleven kinds.
6
+ *
7
+ * WHY A REGISTRY AND NOT ELEVEN PROPS. Eleven kinds behind one wire event is
8
+ * exactly the shape the O(1) rule exists for — a twelfth adds an entry here and
9
+ * a renderer, never a prop on `<Chat>`.
10
+ *
11
+ * WHY THE RENDERERS ARE THIN. Measured against both references:
12
+ * djangocfg's `VideoBlock` is 21 lines and `AudioBlock` 26, because each wraps
13
+ * an existing player; Vercel's `audio-player.tsx` is 231 lines because it builds
14
+ * one. Vercel's `image.tsx` could not be used at all — it is typed against
15
+ * `Experimental_GeneratedImage` from the `ai` package and renders base64 only,
16
+ * which is not our model.
17
+ *
18
+ * So: the chat owns the FRAME (aspect, fallback, click-to-zoom); the element or
19
+ * the host's player owns playback. A renderer past ~60 lines is a signal it is
20
+ * re-implementing something that already exists.
21
+ *
22
+ * The `src` gate is NOT here. It runs once at dispatch in
23
+ * `core/media/readMediaBlock`, the split the archived chat states outright:
24
+ * validated centrally "before this renderer runs, so we can trust it here".
25
+ * Per-renderer checking is how one of eleven quietly ends up without one.
26
+ */
27
+
28
+ export interface MediaRendererProps {
29
+ readonly block: MediaBlock;
30
+ /** Textual kinds hand their content to the host's markdown/code renderer. */
31
+ readonly renderContent?: (block: MediaBlock) => ReactNode;
32
+ /** Kinds needing a real player (audio, video, map) may be supplied by a host. */
33
+ readonly renderPlayer?: (block: MediaBlock) => ReactNode;
34
+ /**
35
+ * A picture, drawn by the host's viewer so it opens like every other image in
36
+ * the app. Absent ⇒ a plain `<img>`, which the frame comment above calls
37
+ * out as the one thing in a transcript that looks openable and is not.
38
+ *
39
+ * `generation` rides along because the host owns the picture's chrome and is
40
+ * therefore the only layer that can offer "what made this". It is passed
41
+ * rather than fetched: the block already carries it, and this package cannot
42
+ * import the feature that would render it.
43
+ */
44
+ readonly renderImage?: (image: {
45
+ readonly src: string;
46
+ readonly title: string;
47
+ readonly generation?: MediaGeneration;
48
+ }) => ReactNode;
49
+ }
50
+
51
+ export function MediaWidget(props: MediaRendererProps) {
52
+ // Wrapped once at the entry rather than by a class on each of the eight
53
+ // renderers below: the rhythm belongs to "a media block in a transcript", and
54
+ // a per-renderer class is a rule the ninth kind joins by remembering to. Two
55
+ // of the branches also delegate to a HOST element whose root this package does
56
+ // not own, so there is no class list to put it on there at all.
57
+ return <div className="chat-block">{mediaOf(props)}</div>;
58
+ }
59
+
60
+ function mediaOf({ block, renderContent, renderPlayer, renderImage }: MediaRendererProps) {
61
+ switch (block.kind) {
62
+ case "image":
63
+ return <MediaImage block={block} renderImage={renderImage} />;
64
+ case "gallery":
65
+ return <MediaGallery block={block} renderImage={renderImage} />;
66
+ case "audio":
67
+ case "video":
68
+ case "map":
69
+ // A host-supplied player wins; the native element is the fallback so a
70
+ // bare consumer still plays media instead of rendering nothing.
71
+ return renderPlayer?.(block) ?? <MediaPlayable block={block} />;
72
+ case "json":
73
+ case "code":
74
+ case "markdown":
75
+ case "text":
76
+ case "mermaid":
77
+ // Routed to the CONTENT renderer rather than a second markdown path —
78
+ // this is the seam where widgets/ and content/ meet on one vocabulary.
79
+ return renderContent?.(block) ?? <MediaPlainText block={block} />;
80
+ case "link":
81
+ return <MediaLink block={block} />;
82
+ }
83
+ }
84
+
85
+ function MediaImage({
86
+ block,
87
+ renderImage,
88
+ }: {
89
+ readonly block: MediaBlock;
90
+ readonly renderImage?: MediaRendererProps["renderImage"];
91
+ }) {
92
+ if (block.src === undefined) return null;
93
+ // `title` before `alt`: the title is what the agent chose to call the picture
94
+ // and the viewer shows it as the overlay's name, while `alt` is often empty on
95
+ // a decorative image, which would leave that overlay unlabelled.
96
+ const viewed = renderImage?.({
97
+ src: block.src,
98
+ title: block.title ?? block.alt ?? "",
99
+ generation: block.generation,
100
+ });
101
+ if (viewed !== undefined && viewed !== null) return viewed;
102
+ return (
103
+ <img
104
+ src={block.src}
105
+ alt={block.alt ?? ""}
106
+ loading="lazy"
107
+ // `referrerPolicy` matters on model-authored URLs: without it the request
108
+ // carries the transcript's URL to whatever host the model named.
109
+ referrerPolicy="no-referrer"
110
+ className="max-h-96 w-full rounded-md object-cover"
111
+ data-slot="media-image"
112
+ />
113
+ );
114
+ }
115
+
116
+ function MediaGallery({
117
+ block,
118
+ renderImage,
119
+ }: {
120
+ readonly block: MediaBlock;
121
+ readonly renderImage?: MediaRendererProps["renderImage"];
122
+ }) {
123
+ const items = block.items ?? [];
124
+ if (items.length === 0) return null;
125
+
126
+ return (
127
+ <ul className="grid grid-cols-3 gap-1" data-slot="media-gallery">
128
+ {/* Index, not `item.src`: a gallery may legitimately carry the same URL
129
+ twice — a before/after of one file, or a repeated placeholder — and a
130
+ duplicate key makes React drop or duplicate rows. The list is
131
+ append-only within a block and never reorders, so the index is stable
132
+ for its lifetime. Caught by the Gallery story, which showed one pixel
133
+ twice and turned the warning into a story-test failure. */}
134
+ {items.map((item, index) => (
135
+ <li key={index}>
136
+ <GalleryTile item={item} renderImage={renderImage} />
137
+ </li>
138
+ ))}
139
+ </ul>
140
+ );
141
+ }
142
+
143
+ /**
144
+ * One picture in a gallery.
145
+ *
146
+ * Its own component because the tile makes a decision the grid does not: the
147
+ * grid shows a THUMBNAIL, the viewer must open the full-size source, so the two
148
+ * srcs are different values and choosing between them inside the `map` is the
149
+ * render algorithm the JSX boundary keeps out of markup.
150
+ */
151
+ function GalleryTile({
152
+ item,
153
+ renderImage,
154
+ }: {
155
+ readonly item: NonNullable<MediaBlock["items"]>[number];
156
+ readonly renderImage?: MediaRendererProps["renderImage"];
157
+ }) {
158
+ const viewed = renderImage?.({ src: item.src, title: item.alt ?? "" });
159
+ if (viewed !== undefined && viewed !== null) return viewed;
160
+ return (
161
+ <img
162
+ src={item.thumbnail ?? item.src}
163
+ alt={item.alt ?? ""}
164
+ loading="lazy"
165
+ referrerPolicy="no-referrer"
166
+ className="aspect-square w-full rounded object-cover"
167
+ />
168
+ );
169
+ }
170
+
171
+ function MediaPlayable({ block }: { readonly block: MediaBlock }) {
172
+ if (block.src === undefined) return null;
173
+
174
+ if (block.kind === "audio") {
175
+ return (
176
+ // No caption track: agent-produced audio arrives without one.
177
+ <audio src={block.src} controls preload="metadata" className="w-full" data-slot="media-audio" />
178
+ );
179
+ }
180
+
181
+ return (
182
+ // No caption track: agent-produced video arrives without one.
183
+ <video
184
+ src={block.src}
185
+ poster={block.cover}
186
+ controls
187
+ preload="metadata"
188
+ className="w-full rounded-md"
189
+ style={{ aspectRatio: block.aspectRatio ?? 16 / 9 }}
190
+ data-slot="media-video"
191
+ />
192
+ );
193
+ }
194
+
195
+ function MediaPlainText({ block }: { readonly block: MediaBlock }) {
196
+ if (block.content === undefined) return null;
197
+ return (
198
+ <pre className="overflow-x-auto rounded-md p-3 text-xs" data-slot="media-text">
199
+ {block.content}
200
+ </pre>
201
+ );
202
+ }
203
+
204
+ function MediaLink({ block }: { readonly block: MediaBlock }) {
205
+ if (block.url === undefined) return null;
206
+ const preview = block.link;
207
+
208
+ return (
209
+ <a
210
+ href={block.url}
211
+ target="_blank"
212
+ // `noopener` is required with target=_blank; `noreferrer` keeps the
213
+ // transcript URL from reaching a destination the model chose.
214
+ rel="noopener noreferrer"
215
+ className="flex gap-3 rounded-md border p-3 no-underline"
216
+ data-slot="media-link"
217
+ >
218
+ {preview?.image === undefined ? null : (
219
+ <img
220
+ src={preview.image}
221
+ alt=""
222
+ loading="lazy"
223
+ referrerPolicy="no-referrer"
224
+ className="size-16 shrink-0 rounded object-cover"
225
+ />
226
+ )}
227
+ <span className="min-w-0">
228
+ {/* Falls back to the URL: the server's contract is that a preview with
229
+ no title still renders as a bare URL card, not as nothing. */}
230
+ <span className="block truncate text-sm font-medium">
231
+ {preview?.title ?? block.url}
232
+ </span>
233
+ {preview?.description === undefined ? null : (
234
+ <span className="line-clamp-2 text-xs opacity-70">{preview.description}</span>
235
+ )}
236
+ </span>
237
+ </a>
238
+ );
239
+ }