@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,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
|
+
};
|