@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,196 @@
1
+ import type { VoiceCapability } from "../capabilities";
2
+ import { safeEarcon, type PlayEarcon } from "./earcons";
3
+
4
+ /**
5
+ * Dictation as a state machine — no React, no Web Speech API.
6
+ *
7
+ * The host owns recognition; this owns the two rules that are easy to get
8
+ * wrong and invisible when they are:
9
+ *
10
+ * 1. **An interim result REPLACES the previous interim, never appends.** The
11
+ * engine re-emits its whole current guess as it refines — "hello wor",
12
+ * "hello world" — so appending duplicates the sentence in front of the user.
13
+ * 2. **A final result COMMITS**, and the next interim starts from empty.
14
+ *
15
+ * The draft therefore always reads `committed + interim`, and only `committed`
16
+ * survives a stop.
17
+ */
18
+
19
+ export interface VoiceSnapshot {
20
+ readonly recording: boolean;
21
+ /** Text the engine has committed. */
22
+ readonly committed: string;
23
+ /** The current guess. Replaced wholesale on each interim result. */
24
+ readonly interim: string;
25
+ /** What the composer should show: `committed + interim`. */
26
+ readonly text: string;
27
+ readonly error?: string;
28
+ }
29
+
30
+ export interface VoiceSessionOptions {
31
+ readonly capability: VoiceCapability;
32
+ readonly onChange: (snapshot: VoiceSnapshot) => void;
33
+ /**
34
+ * Play a start/stop tone. Absent ⇒ dictation is silent, which is a legitimate
35
+ * configuration: the package ships no audio and a host that supplies none
36
+ * gets working dictation without it.
37
+ */
38
+ readonly playEarcon?: PlayEarcon;
39
+ }
40
+
41
+ const IDLE: VoiceSnapshot = {
42
+ recording: false,
43
+ committed: "",
44
+ interim: "",
45
+ text: "",
46
+ };
47
+
48
+ export class VoiceSession {
49
+ private snapshot: VoiceSnapshot = IDLE;
50
+ private controller: AbortController | null = null;
51
+ /** Wrapped once here so neither call site needs a null check or a try. */
52
+ private readonly earcon: PlayEarcon;
53
+
54
+ constructor(private readonly options: VoiceSessionOptions) {
55
+ this.earcon = safeEarcon(options.playEarcon);
56
+ }
57
+
58
+ get state(): VoiceSnapshot {
59
+ return this.snapshot;
60
+ }
61
+
62
+ /** Begin, or do nothing if already recording. */
63
+ start(): void {
64
+ if (this.snapshot.recording) return;
65
+
66
+ const controller = new AbortController();
67
+ this.controller = controller;
68
+ this.emit({ recording: true, committed: "", interim: "", error: undefined });
69
+
70
+ // Before `capability.start`: after it the microphone is live and the tone
71
+ // is transcribed as a word.
72
+ this.earcon("start");
73
+
74
+ this.options.capability
75
+ .start((text, final) => {
76
+ // A result arriving after stop() belongs to a session the user ended.
77
+ // Accepting it would append words to a draft they already sent.
78
+ if (this.controller !== controller) return;
79
+ this.receive(text, final);
80
+ }, controller.signal)
81
+ .then(() => {
82
+ if (this.controller !== controller) return;
83
+ // The engine ended on its own (a pause, a time limit). Commit whatever
84
+ // interim was in flight rather than discarding the last words.
85
+ this.commitAndStop();
86
+ })
87
+ .catch((cause: unknown) => {
88
+ if (this.controller !== controller) return;
89
+ this.controller = null;
90
+ // Closes the pair. The start tone has already played — it must, before
91
+ // the microphone opens — so silence here leaves a session the user heard
92
+ // begin and never end.
93
+ this.earcon("stop");
94
+ // Failures settle on the snapshot; throwing here would surface as an
95
+ // unhandled rejection while the user is mid-sentence.
96
+ this.emit({
97
+ recording: false,
98
+ interim: "",
99
+ error: cause instanceof Error ? cause.message : String(cause),
100
+ });
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Stop and commit the in-flight interim.
106
+ *
107
+ * Safe to call synchronously from a send: the capability's `stop` is invoked
108
+ * first, so no further words can arrive.
109
+ */
110
+ stop(): void {
111
+ if (!this.snapshot.recording) return;
112
+ this.options.capability.stop();
113
+ this.controller?.abort();
114
+ this.controller = null;
115
+ this.commitAndStop();
116
+ }
117
+
118
+ /**
119
+ * Stop and DISCARD everything — for a send.
120
+ *
121
+ * Distinct from `stop()` on purpose: after a send the draft is cleared, so
122
+ * committing dictated text back into it would resurrect the message the user
123
+ * just sent.
124
+ *
125
+ * SILENT: the caller is a send, which plays its own chime. A stop tone under
126
+ * it is two sounds for one action, and the user pressed send, not stop.
127
+ */
128
+ cancel(): void {
129
+ this.options.capability.stop();
130
+ this.controller?.abort();
131
+ this.controller = null;
132
+ // Through emit(), like every other transition. Assigning `this.snapshot`
133
+ // directly is the one way to leave `text` stale, since emit() is the sole
134
+ // place that recomputes it — a mutant doing exactly that survived a test
135
+ // asserting only `text`, because the stale value happened to be correct.
136
+ this.emit({ recording: false, committed: "", interim: "", error: undefined });
137
+ }
138
+
139
+ /** Take the dictated text and reset, for handing to the draft. */
140
+ take(): string {
141
+ const text = this.snapshot.text;
142
+ this.cancel();
143
+ return text;
144
+ }
145
+
146
+ dispose(): void {
147
+ this.options.capability.stop();
148
+ this.controller?.abort();
149
+ this.controller = null;
150
+ }
151
+
152
+ private receive(text: string, final: boolean): void {
153
+ if (final) {
154
+ // Committed text accumulates with a separating space; the engine emits
155
+ // phrases, not a continuous stream, so joining without one runs the
156
+ // sentences together.
157
+ const committed = this.snapshot.committed === ""
158
+ ? text
159
+ : `${this.snapshot.committed} ${text}`;
160
+ this.emit({ committed, interim: "" });
161
+ return;
162
+ }
163
+ // REPLACE, never append — the engine re-sends its whole current guess.
164
+ this.emit({ interim: text });
165
+ }
166
+
167
+ /**
168
+ * Both routes here — an explicit `stop()` and an engine ending on its own —
169
+ * are moments the user must hear, so the stop tone lives here. The microphone
170
+ * is already shut on both. `cancel()` does not pass through.
171
+ */
172
+ private commitAndStop(): void {
173
+ this.earcon("stop");
174
+ const { committed, interim } = this.snapshot;
175
+ const merged = interim === ""
176
+ ? committed
177
+ : committed === ""
178
+ ? interim
179
+ : `${committed} ${interim}`;
180
+ this.controller = null;
181
+ this.emit({ recording: false, committed: merged, interim: "" });
182
+ }
183
+
184
+ private emit(patch: Partial<Omit<VoiceSnapshot, "text">>): void {
185
+ const next = { ...this.snapshot, ...patch };
186
+ // `text` is DERIVED, never assigned: two writers for one value is how the
187
+ // draft and the snapshot drift apart.
188
+ const text = next.interim === ""
189
+ ? next.committed
190
+ : next.committed === ""
191
+ ? next.interim
192
+ : `${next.committed} ${next.interim}`;
193
+ this.snapshot = { ...next, text };
194
+ this.options.onChange(this.snapshot);
195
+ }
196
+ }
@@ -0,0 +1,170 @@
1
+ import type { VoiceCapability } from "../capabilities";
2
+
3
+ /**
4
+ * The browser's own dictation, as a `VoiceCapability`.
5
+ *
6
+ * This is the package's DEFAULT engine, and the reason the capability seam
7
+ * still exists: audio never leaves the device here, but a host with a server
8
+ * transcriber, Whisper, or a native bridge supplies its own capability and this
9
+ * steps aside. Neither available means no microphone button at all — a greyed
10
+ * out mic is a promise the product cannot keep.
11
+ *
12
+ * Everything racy — an interim replacing its predecessor, a result arriving
13
+ * after stop, an engine that ends on its own — is `VoiceSession`'s, not this
14
+ * file's. This is the adapter and nothing more.
15
+ */
16
+
17
+ /**
18
+ * The two constructor spellings, without pulling in DOM lib types the package
19
+ * does not depend on.
20
+ *
21
+ * Typed structurally rather than via `SpeechRecognition` from `lib.dom`: that
22
+ * type is not in every consumer's TS configuration, and a package that fails to
23
+ * compile in a host missing one DOM lib is worse than one local interface.
24
+ */
25
+ interface RecognitionLike {
26
+ continuous: boolean;
27
+ interimResults: boolean;
28
+ lang: string;
29
+ start: () => void;
30
+ stop: () => void;
31
+ abort: () => void;
32
+ onresult: ((event: RecognitionEventLike) => void) | null;
33
+ onerror: ((event: { readonly error?: string }) => void) | null;
34
+ onend: (() => void) | null;
35
+ }
36
+
37
+ interface RecognitionResultLike {
38
+ readonly isFinal: boolean;
39
+ readonly length: number;
40
+ readonly [index: number]: { readonly transcript: string } | undefined;
41
+ }
42
+
43
+ interface RecognitionEventLike {
44
+ readonly resultIndex: number;
45
+ readonly results: {
46
+ readonly length: number;
47
+ readonly [index: number]: RecognitionResultLike | undefined;
48
+ };
49
+ }
50
+
51
+ type RecognitionConstructor = new () => RecognitionLike;
52
+
53
+ function constructorFor(): RecognitionConstructor | undefined {
54
+ if (typeof window === "undefined") return undefined;
55
+ const scope = window as unknown as {
56
+ SpeechRecognition?: RecognitionConstructor;
57
+ webkitSpeechRecognition?: RecognitionConstructor;
58
+ };
59
+ return scope.SpeechRecognition ?? scope.webkitSpeechRecognition;
60
+ }
61
+
62
+ /**
63
+ * Whether the browser can dictate at all.
64
+ *
65
+ * Exported so a host can decide before rendering. MediaRecorder is deliberately
66
+ * NOT treated as support: it only produces audio, and turning that into text
67
+ * needs a transcription endpoint — audio leaving the device, which is exactly
68
+ * the decision the capability seam leaves to the host.
69
+ */
70
+ export function isWebSpeechAvailable(): boolean {
71
+ return constructorFor() !== undefined;
72
+ }
73
+
74
+ export interface WebSpeechOptions {
75
+ /** BCP-47 tag. Read at START, so a change applies to the next dictation. */
76
+ readonly language: () => string;
77
+ }
78
+
79
+ /**
80
+ * Build the default capability, or `undefined` when the browser has no engine.
81
+ *
82
+ * `language` is a FUNCTION, not a value: the capability object outlives any one
83
+ * dictation, and reading the tag at start is what lets a language change take
84
+ * effect without rebuilding the session mid-sentence.
85
+ */
86
+ export function createWebSpeechCapability(
87
+ options: WebSpeechOptions,
88
+ ): VoiceCapability | undefined {
89
+ const Recognition = constructorFor();
90
+ if (Recognition === undefined) return undefined;
91
+
92
+ let active: RecognitionLike | null = null;
93
+
94
+ return {
95
+ start: (onText, signal) =>
96
+ new Promise<void>((resolve, reject) => {
97
+ if (signal.aborted) {
98
+ resolve();
99
+ return;
100
+ }
101
+ const recognition = new Recognition();
102
+ active = recognition;
103
+ // `continuous` so a pause between sentences does not end the session,
104
+ // and `interimResults` so words appear while they are being said. The
105
+ // interim half is what `VoiceSession`'s replace-never-append rule is
106
+ // for; without it dictation is silent until a phrase completes.
107
+ recognition.continuous = true;
108
+ recognition.interimResults = true;
109
+ recognition.lang = options.language();
110
+
111
+ const finish = (): void => {
112
+ if (active === recognition) active = null;
113
+ signal.removeEventListener("abort", onAbort);
114
+ };
115
+
116
+ const onAbort = (): void => {
117
+ recognition.abort();
118
+ finish();
119
+ resolve();
120
+ };
121
+ signal.addEventListener("abort", onAbort, { once: true });
122
+
123
+ recognition.onresult = (event) => {
124
+ // From `resultIndex`, not from zero: the engine re-sends the whole
125
+ // results list, and replaying it from the start re-delivers phrases
126
+ // already committed — the sentence duplicates itself.
127
+ for (let i = event.resultIndex; i < event.results.length; i += 1) {
128
+ const result = event.results[i];
129
+ const transcript = result?.[0]?.transcript;
130
+ if (result === undefined || transcript === undefined) continue;
131
+ onText(transcript, result.isFinal);
132
+ }
133
+ };
134
+
135
+ recognition.onerror = (event) => {
136
+ finish();
137
+ // `no-speech` and `aborted` are ordinary endings, not faults: the
138
+ // first is a silent room and the second is the user pressing stop.
139
+ // Surfacing either as an error puts a red message under a composer
140
+ // for doing nothing wrong.
141
+ if (event.error === "no-speech" || event.error === "aborted") {
142
+ resolve();
143
+ return;
144
+ }
145
+ reject(new Error(event.error ?? "speech recognition failed"));
146
+ };
147
+
148
+ recognition.onend = () => {
149
+ finish();
150
+ resolve();
151
+ };
152
+
153
+ try {
154
+ recognition.start();
155
+ } catch (cause) {
156
+ // `start()` throws synchronously when one is already running — a
157
+ // double-press. Treat it as a no-op rather than a failure.
158
+ finish();
159
+ resolve();
160
+ void cause;
161
+ }
162
+ }),
163
+ stop: () => {
164
+ // `stop()` rather than `abort()`: it lets the engine deliver the final
165
+ // result for what was already said, which is the difference between
166
+ // pressing stop mid-sentence and losing that sentence.
167
+ active?.stop();
168
+ },
169
+ };
170
+ }
@@ -0,0 +1,87 @@
1
+ import { useMemo } from "react";
2
+ import { useLocaleOptional } from "@djangocfg/i18n";
3
+
4
+ import { ar } from "./locales/ar";
5
+ import { da } from "./locales/da";
6
+ import { de } from "./locales/de";
7
+ import { en } from "./locales/en";
8
+ import { es } from "./locales/es";
9
+ import { fr } from "./locales/fr";
10
+ import { it } from "./locales/it";
11
+ import { ja } from "./locales/ja";
12
+ import { ko } from "./locales/ko";
13
+ import { nl } from "./locales/nl";
14
+ import { no } from "./locales/no";
15
+ import { pl } from "./locales/pl";
16
+ import { ptBR } from "./locales/ptBR";
17
+ import { ru } from "./locales/ru";
18
+ import { sv } from "./locales/sv";
19
+ import { tr } from "./locales/tr";
20
+ import { zh } from "./locales/zh";
21
+ import type { ChatCopy } from "./types";
22
+
23
+ export type {
24
+ ChatCopy,
25
+ ComposerCopy,
26
+ MessageCopy,
27
+ PluralCopy,
28
+ QueueCopy,
29
+ ToolCopy,
30
+ TranscriptCopy,
31
+ VoiceCopy,
32
+ } from "./types";
33
+ export { fill, plural } from "./plural";
34
+
35
+ /**
36
+ * Every bundled dictionary, keyed by the language code the host reports.
37
+ *
38
+ * Exported so the registry test asserts against THIS object rather than its own
39
+ * copy of the list: a second list is a second thing to forget, and what it
40
+ * hides is a locale that silently never loads.
41
+ */
42
+ export const CHAT_DICTIONARIES: Readonly<Record<string, ChatCopy>> = {
43
+ en, ru, de, fr, es, it, nl, pl, sv, no, da, tr, ja, ko, zh, ar,
44
+ // The KEY is the language code the app reports (`pt-BR`), which is not a
45
+ // valid JS identifier — so the binding is `ptBR` and the key is quoted. Get
46
+ // this wrong and the lookup misses: every Brazilian user falls back to
47
+ // English with no error anywhere.
48
+ "pt-BR": ptBR,
49
+ };
50
+
51
+ /**
52
+ * The chat dictionary for the active language, English for anything not yet
53
+ * translated.
54
+ *
55
+ * The package owns its copy rather than taking it as props. Labels-in
56
+ * looked right — it kept this package free of i18next — but it made adding one
57
+ * string a four-file change and let a host assemble a translated string into a
58
+ * DOM selector, which broke the composer's caret in a single locale with types
59
+ * and tests green.
60
+ *
61
+ * The whole typed object, not a `t(key)` resolver: a dot-path string
62
+ * reintroduces exactly the runtime miss that typing the schema removes.
63
+ * `@djangocfg/i18n` types a CLOSED resources map, so a `chat` namespace does
64
+ * not typecheck there, and opening one would point the dependency arrow from
65
+ * `i18n` at this widget.
66
+ *
67
+ * `useLocaleOptional` returns null with no provider mounted, which is what lets
68
+ * every surface render in Storybook and in a test without one.
69
+ */
70
+ export function useChatCopy(): ChatCopy {
71
+ const locale = useLocaleOptional();
72
+ return useMemo(() => (locale ? CHAT_DICTIONARIES[locale] ?? en : en), [locale]);
73
+ }
74
+
75
+ /**
76
+ * The active language tag, for `Intl.PluralRules`.
77
+ *
78
+ * Separate from the dictionary because the two can disagree: an untranslated
79
+ * language falls back to the English STRINGS while its own plural CATEGORIES
80
+ * still apply, and selecting `few` from a dictionary that has none is what the
81
+ * `other` fallback in `plural()` exists to absorb.
82
+ */
83
+ export function useChatLanguage(): string {
84
+ // English rather than the empty string with no provider: `Intl.PluralRules`
85
+ // throws on an invalid tag, and a plural category is needed on every render.
86
+ return useLocaleOptional() ?? "en";
87
+ }
@@ -0,0 +1,97 @@
1
+ import type { ChatCopy } from "../types";
2
+
3
+ export const ar: ChatCopy = {
4
+ transcript: {
5
+ empty: "لا توجد رسائل بعد.",
6
+ suggestionsLabel: "اقتراحات للبدء",
7
+ log: "المحادثة",
8
+ announceStreaming: "المساعد يجيب",
9
+ announceComplete: "اكتمل الرد",
10
+ jumpToLatest: "الانتقال إلى الأحدث",
11
+ thinking: "يفكر",
12
+ thinkingElapsed: "{{seconds}} ث",
13
+ today: "اليوم",
14
+ yesterday: "أمس",
15
+ },
16
+ message: {
17
+ copy: "نسخ",
18
+ reply: "رد",
19
+ copyLink: "نسخ الرابط",
20
+ copiedLink: "تم نسخ الرابط",
21
+ jumpToQuoted: "الانتقال إلى الرسالة المقتبسة",
22
+ copied: "تم النسخ",
23
+ reasoning: "الاستدلال",
24
+ reasoningLive: "جارٍ التفكير…",
25
+ reasoningTokens: {
26
+ other: "فكّر خلال {{count}} رمزًا",
27
+ },
28
+ showMore: "عرض المزيد",
29
+ showLess: "عرض أقل",
30
+ },
31
+ tool: {
32
+ inputStreaming: "قيد التحضير",
33
+ inputAvailable: "قيد التشغيل",
34
+ outputAvailable: "تم",
35
+ outputError: "فشل",
36
+ collapse: "طيّ استدعاء الأداة",
37
+ expand: "توسيع استدعاء الأداة",
38
+ },
39
+ composer: {
40
+ replyClear: "إلغاء الرد",
41
+ input: "رسالة",
42
+ placeholder: "اسأل أي شيء",
43
+ send: "إرسال",
44
+ sendShortcut: "‏Enter للإرسال",
45
+ stop: "إيقاف",
46
+ toolsMenu: "الأدوات",
47
+ toolsSearch: "ابحث عن أداة…",
48
+ toolsEmpty: "لا توجد أداة بهذا الاسم",
49
+ toolsRecent: "الأخيرة",
50
+ toolsForget: "إزالة من الأخيرة",
51
+ toolRemove: "إزالة الأداة",
52
+ needsFile: "أرفق الملف",
53
+ needsSkill: "حدّد المهارة",
54
+ toolsPicked: "الأدوات المختارة",
55
+ thinkOn: "تفكير أطول (أبطأ، تكلفة أعلى)",
56
+ thinkOff: "إيقاف التفكير الأطول",
57
+ },
58
+ queue: {
59
+ prompts: {
60
+ zero: "{{count}} رسالة في قائمة الانتظار",
61
+ one: "{{count}} رسالة في قائمة الانتظار",
62
+ two: "{{count}} رسالتان في قائمة الانتظار",
63
+ few: "{{count}} رسائل في قائمة الانتظار",
64
+ many: "{{count}} رسالة في قائمة الانتظار",
65
+ other: "{{count}} رسالة في قائمة الانتظار",
66
+ },
67
+ more: {
68
+ zero: "{{count}} إضافية",
69
+ one: "{{count}} إضافية",
70
+ two: "{{count}} إضافيتان",
71
+ few: "{{count}} إضافية",
72
+ many: "{{count}} إضافية",
73
+ other: "{{count}} إضافية",
74
+ },
75
+ showLess: "عرض أقل",
76
+ remove: "إزالة من قائمة الانتظار",
77
+ show: "عرض قائمة الانتظار",
78
+ edit: "تحرير هذا الطلب",
79
+ send: "الإرسال الآن",
80
+ sendNow: "الإرسال الآن مع إيقاف الرد الحالي",
81
+ sendNowConfirmTitle: "إرسال هذه الرسالة الآن؟",
82
+ sendNowConfirmMessage:
83
+ "سيتوقف الرد الحالي وسيُفقد العمل غير المكتمل. تحتفظ بقية الرسائل بترتيبها في قائمة الانتظار.",
84
+ replaceDraftTitle: "استبدال ما كتبته؟",
85
+ replaceDraftMessage:
86
+ "مسودتك وهذه الرسالة مختلفتان. اختر ما تريد الاحتفاظ به.",
87
+ },
88
+ voice: {
89
+ start: "إملاء",
90
+ stop: "إيقاف الإملاء",
91
+ language: "لغة الإملاء",
92
+ languageMenu: "لغة الإملاء",
93
+ languageSearch: "البحث عن اللغات",
94
+ languageEmpty: "لم يتم العثور على لغة",
95
+ languageBack: "رجوع",
96
+ },
97
+ };
@@ -0,0 +1,90 @@
1
+ import type { ChatCopy } from "../types";
2
+
3
+ export const da: ChatCopy = {
4
+ transcript: {
5
+ empty: "Ingen beskeder endnu.",
6
+ suggestionsLabel: "Foreslåede prompts",
7
+ log: "Samtale",
8
+ announceStreaming: "Assistenten svarer",
9
+ announceComplete: "Svaret er færdigt",
10
+ jumpToLatest: "Gå til nyeste",
11
+ thinking: "Tænker",
12
+ thinkingElapsed: "{{seconds}} s",
13
+ today: "I dag",
14
+ yesterday: "I går",
15
+ },
16
+ message: {
17
+ copy: "Kopiér",
18
+ reply: "Svar",
19
+ copyLink: "Kopiér link",
20
+ copiedLink: "Link kopieret",
21
+ jumpToQuoted: "Gå til citeret besked",
22
+ copied: "Kopieret",
23
+ reasoning: "Ræsonnement",
24
+ reasoningLive: "Ræsonnerer…",
25
+ reasoningTokens: {
26
+ one: "Tænkte i {{count}} token",
27
+ other: "Tænkte i {{count}} tokens",
28
+ },
29
+ showMore: "Vis mere",
30
+ showLess: "Vis mindre",
31
+ },
32
+ tool: {
33
+ inputStreaming: "Forbereder",
34
+ inputAvailable: "Kører",
35
+ outputAvailable: "Færdig",
36
+ outputError: "Mislykkedes",
37
+ collapse: "Skjul værktøjskaldet",
38
+ expand: "Vis værktøjskaldet",
39
+ },
40
+ composer: {
41
+ replyClear: "Annullér svar",
42
+ input: "Besked",
43
+ placeholder: "Spørg om hvad som helst",
44
+ send: "Send",
45
+ sendShortcut: "Enter for at sende",
46
+ stop: "Stop",
47
+ toolsMenu: "Værktøjer",
48
+ toolsSearch: "Find et værktøj…",
49
+ toolsEmpty: "Intet sådant værktøj",
50
+ toolsRecent: "Seneste",
51
+ toolsForget: "Fjern fra seneste",
52
+ toolRemove: "Fjern værktøj",
53
+ needsFile: "Vedhæft filen",
54
+ needsSkill: "Nævn din skill",
55
+ toolsPicked: "Valgte værktøjer",
56
+ thinkOn: "Tænk længere (langsommere, koster mere)",
57
+ thinkOff: "Stop længere tænkning",
58
+ },
59
+ queue: {
60
+ prompts: {
61
+ one: "{{count}} besked i kø",
62
+ other: "{{count}} beskeder i kø",
63
+ },
64
+ more: {
65
+ one: "{{count}} mere",
66
+ other: "{{count}} mere",
67
+ },
68
+ showLess: "Vis mindre",
69
+ remove: "Fjern fra køen",
70
+ show: "Vis køen",
71
+ edit: "Rediger anmodningen",
72
+ send: "Send nu",
73
+ sendNow: "Send nu, og afbryd det igangværende svar",
74
+ sendNowConfirmTitle: "Send denne besked nu?",
75
+ sendNowConfirmMessage:
76
+ "Det igangværende svar afbrydes, og dets ufærdige arbejde går tabt. De øvrige beskeder beholder deres rækkefølge i køen.",
77
+ replaceDraftTitle: "Erstat det, du har skrevet?",
78
+ replaceDraftMessage:
79
+ "Dit udkast og denne besked er forskellige. Vælg, hvad der bevares.",
80
+ },
81
+ voice: {
82
+ start: "Diktér",
83
+ stop: "Stop diktering",
84
+ language: "Dikteringssprog",
85
+ languageMenu: "Dikteringssprog",
86
+ languageSearch: "Søg sprog",
87
+ languageEmpty: "Intet sprog fundet",
88
+ languageBack: "Tilbage",
89
+ },
90
+ };