@kpritam/grimoire-output-docusaurus 0.1.8

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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +25 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.js +1 -0
  6. package/dist/internal/assets.d.ts +9 -0
  7. package/dist/internal/assets.js +50 -0
  8. package/dist/internal/docusaurusConfig.d.ts +9 -0
  9. package/dist/internal/docusaurusConfig.js +259 -0
  10. package/dist/internal/spellbookAssets.d.ts +39 -0
  11. package/dist/internal/spellbookAssets.js +68 -0
  12. package/dist/layer.d.ts +3 -0
  13. package/dist/layer.js +6 -0
  14. package/dist/shared.d.ts +10 -0
  15. package/dist/shared.js +36 -0
  16. package/dist/upstream.d.ts +6 -0
  17. package/dist/upstream.js +84 -0
  18. package/package.json +59 -0
  19. package/src/index.ts +1 -0
  20. package/src/internal/assets.ts +66 -0
  21. package/src/internal/docusaurusConfig.ts +281 -0
  22. package/src/internal/spellbookAssets.ts +80 -0
  23. package/src/layer.ts +12 -0
  24. package/src/shared.ts +43 -0
  25. package/src/upstream.ts +119 -0
  26. package/templates/spellbook/spellbookPlugin.ts +156 -0
  27. package/templates/spellbook/src/components/SpellbookChat/ChatEngine.ts +79 -0
  28. package/templates/spellbook/src/components/SpellbookChat/ChatErrorBoundary.tsx +65 -0
  29. package/templates/spellbook/src/components/SpellbookChat/Markdown.tsx +259 -0
  30. package/templates/spellbook/src/components/SpellbookChat/README.md +111 -0
  31. package/templates/spellbook/src/components/SpellbookChat/SettingsPanel.tsx +376 -0
  32. package/templates/spellbook/src/components/SpellbookChat/VoiceMode.tsx +867 -0
  33. package/templates/spellbook/src/components/SpellbookChat/index.tsx +744 -0
  34. package/templates/spellbook/src/components/SpellbookChat/markdown.module.css +343 -0
  35. package/templates/spellbook/src/components/SpellbookChat/secretStore.ts +106 -0
  36. package/templates/spellbook/src/components/SpellbookChat/streamProviders/anthropic.ts +36 -0
  37. package/templates/spellbook/src/components/SpellbookChat/streamProviders/createCloudProvider.ts +112 -0
  38. package/templates/spellbook/src/components/SpellbookChat/streamProviders/google.ts +33 -0
  39. package/templates/spellbook/src/components/SpellbookChat/streamProviders/index.ts +32 -0
  40. package/templates/spellbook/src/components/SpellbookChat/streamProviders/mapFinishReason.ts +23 -0
  41. package/templates/spellbook/src/components/SpellbookChat/streamProviders/ollama.ts +44 -0
  42. package/templates/spellbook/src/components/SpellbookChat/streamProviders/openai.ts +34 -0
  43. package/templates/spellbook/src/components/SpellbookChat/streamProviders/openaiRealtime.ts +320 -0
  44. package/templates/spellbook/src/components/SpellbookChat/streamProviders/types.ts +172 -0
  45. package/templates/spellbook/src/components/SpellbookChat/streamProviders/webllm.ts +214 -0
  46. package/templates/spellbook/src/components/SpellbookChat/styles.module.css +852 -0
  47. package/templates/spellbook/src/components/SpellbookChat/systemPrompt.ts +107 -0
  48. package/templates/spellbook/src/components/SpellbookChat/transformers-ssr-stub.ts +16 -0
  49. package/templates/spellbook/src/components/SpellbookChat/types.ts +52 -0
  50. package/templates/spellbook/src/components/SpellbookChat/useBundleLoader.ts +46 -0
  51. package/templates/spellbook/src/components/SpellbookChat/useChatEngine.ts +524 -0
  52. package/templates/spellbook/src/components/SpellbookChat/useEmbeddings.ts +147 -0
  53. package/templates/spellbook/src/components/SpellbookChat/useRetrieval.ts +377 -0
  54. package/templates/spellbook/src/components/SpellbookChat/useSileroVAD.ts +236 -0
  55. package/templates/spellbook/src/components/SpellbookChat/useSpeechRecognition.ts +271 -0
  56. package/templates/spellbook/src/components/SpellbookChat/useSpeechSynthesis.ts +229 -0
  57. package/templates/spellbook/src/components/SpellbookChat/useUnifiedSTT.ts +134 -0
  58. package/templates/spellbook/src/components/SpellbookChat/useWhisperSTT.ts +411 -0
  59. package/templates/spellbook/src/components/SpellbookChat/vad-ssr-stub.ts +25 -0
  60. package/templates/spellbook/src/components/SpellbookChat/voiceDebug.ts +60 -0
  61. package/templates/spellbook/src/components/SpellbookChat/voiceFsm.ts +196 -0
  62. package/templates/spellbook/src/components/SpellbookChat/voiceStyles.module.css +334 -0
  63. package/templates/spellbook/src/components/SpellbookChat/webllm-ssr-stub.ts +8 -0
  64. package/templates/spellbook/src/components/SpellbookChatDisabled.tsx +20 -0
  65. package/templates/spellbook/src/theme/Root.tsx +29 -0
@@ -0,0 +1,411 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { transcriptDebug, voiceLog } from "./voiceDebug";
3
+
4
+ /**
5
+ * Whisper-based STT fallback for browsers without the native Web Speech API
6
+ * (Firefox, some Chromium derivatives). Single-shot: tap to record, tap
7
+ * again to stop, transcript arrives after a Whisper pass. Uses the same
8
+ * `@huggingface/transformers` runtime as the embedder. The model (~40 MB
9
+ * quantized) downloads once and is browser-cached thereafter.
10
+ */
11
+ export type WhisperLoadStatus = "idle" | "loading" | "ready" | "error";
12
+
13
+ export interface UseWhisperSTTResult {
14
+ readonly supported: boolean;
15
+ readonly listening: boolean;
16
+ readonly interimTranscript: string;
17
+ readonly finalTranscript: string;
18
+ readonly error: string | null;
19
+ readonly start: (opts?: { lang?: string }) => void;
20
+ readonly stop: () => void;
21
+ readonly reset: () => void;
22
+ /** Lifecycle of the on-disk Whisper pipeline. */
23
+ readonly modelLoadStatus: WhisperLoadStatus;
24
+ /** 0 → 1 progress for the initial model download; meaningless after `ready`. */
25
+ readonly modelLoadProgress: number;
26
+ /** True while Whisper is post-processing the captured audio. */
27
+ readonly transcribing: boolean;
28
+ }
29
+
30
+ interface AnyTransformersPipeline {
31
+ (input: Float32Array, options?: Record<string, unknown>): Promise<{
32
+ text?: string;
33
+ }>;
34
+ }
35
+
36
+ let whisperPipelinePromise: Promise<AnyTransformersPipeline> | null = null;
37
+
38
+ /** Memoized at module scope so `start()` calls and other hook instances share one pipeline. */
39
+ async function loadWhisperPipeline(
40
+ onProgress: (progress: number) => void,
41
+ ): Promise<AnyTransformersPipeline> {
42
+ if (whisperPipelinePromise) {
43
+ return whisperPipelinePromise;
44
+ }
45
+ whisperPipelinePromise = (async () => {
46
+ const transformers = await import("@huggingface/transformers");
47
+ const pipelineFn = transformers.pipeline as unknown as (
48
+ task: string,
49
+ model: string,
50
+ opts?: { progress_callback?: (info: unknown) => void },
51
+ ) => Promise<AnyTransformersPipeline>;
52
+ return pipelineFn("automatic-speech-recognition", "Xenova/whisper-tiny.en", {
53
+ progress_callback: (info: unknown) => {
54
+ if (!info || typeof info !== "object") return;
55
+ const o = info as { status?: string; progress?: number };
56
+ if (
57
+ (o.status === "progress" || o.status === "progress_total") &&
58
+ typeof o.progress === "number"
59
+ ) {
60
+ onProgress(Math.max(0, Math.min(1, o.progress / 100)));
61
+ }
62
+ },
63
+ });
64
+ })();
65
+ whisperPipelinePromise.catch(() => {
66
+ whisperPipelinePromise = null;
67
+ });
68
+ return whisperPipelinePromise;
69
+ }
70
+
71
+ /** Decode the recording into mono 16 kHz Float32, as Whisper expects. */
72
+ async function decodeAndResample(blob: Blob): Promise<Float32Array> {
73
+ const arrayBuffer = await blob.arrayBuffer();
74
+ const Ctx =
75
+ window.AudioContext ??
76
+ (window as unknown as { webkitAudioContext?: typeof AudioContext })
77
+ .webkitAudioContext;
78
+ if (!Ctx) {
79
+ throw new Error("AudioContext is not supported in this browser.");
80
+ }
81
+ const ctx = new Ctx();
82
+ try {
83
+ const audioBuffer = await ctx.decodeAudioData(arrayBuffer.slice(0));
84
+ if (audioBuffer.sampleRate === 16000 && audioBuffer.numberOfChannels === 1) {
85
+ return new Float32Array(audioBuffer.getChannelData(0));
86
+ }
87
+ const offline = new OfflineAudioContext(
88
+ 1,
89
+ Math.max(1, Math.ceil(audioBuffer.duration * 16000)),
90
+ 16000,
91
+ );
92
+ const src = offline.createBufferSource();
93
+ src.buffer = audioBuffer;
94
+ src.connect(offline.destination);
95
+ src.start(0);
96
+ const resampled = await offline.startRendering();
97
+ return new Float32Array(resampled.getChannelData(0));
98
+ } finally {
99
+ ctx.close().catch(() => {
100
+ /* ignore */
101
+ });
102
+ }
103
+ }
104
+
105
+ function detectSupported(): boolean {
106
+ if (typeof window === "undefined") return false;
107
+ if (typeof MediaRecorder === "undefined") return false;
108
+ if (!navigator?.mediaDevices?.getUserMedia) return false;
109
+ if (
110
+ typeof window.AudioContext === "undefined" &&
111
+ typeof (window as unknown as { webkitAudioContext?: unknown })
112
+ .webkitAudioContext === "undefined"
113
+ ) {
114
+ return false;
115
+ }
116
+ return true;
117
+ }
118
+
119
+ function pickRecorderMimeType(): string | undefined {
120
+ if (typeof MediaRecorder === "undefined") return undefined;
121
+ const candidates = [
122
+ "audio/webm;codecs=opus",
123
+ "audio/webm",
124
+ "audio/mp4",
125
+ "audio/ogg;codecs=opus",
126
+ ];
127
+ for (const m of candidates) {
128
+ try {
129
+ if (MediaRecorder.isTypeSupported(m)) return m;
130
+ } catch {
131
+ /* ignore */
132
+ }
133
+ }
134
+ return undefined;
135
+ }
136
+
137
+ export function useWhisperSTT(): UseWhisperSTTResult {
138
+ // Lazy initial state — same rationale as `useSpeechRecognition`. Without
139
+ // this, the first render reports `supported: false` and the unified hook
140
+ // briefly shows the "not supported" caption before flipping to true on
141
+ // the next render, which the user can race past with a fast click.
142
+ const [supported, setSupported] = useState<boolean>(() => detectSupported());
143
+ const [listening, setListening] = useState(false);
144
+ const [finalTranscript, setFinalTranscript] = useState("");
145
+ const [error, setError] = useState<string | null>(null);
146
+ const [modelLoadStatus, setModelLoadStatus] =
147
+ useState<WhisperLoadStatus>("idle");
148
+ const [modelLoadProgress, setModelLoadProgress] = useState(0);
149
+ const [transcribing, setTranscribing] = useState(false);
150
+
151
+ const recorderRef = useRef<MediaRecorder | null>(null);
152
+ const streamRef = useRef<MediaStream | null>(null);
153
+ const chunksRef = useRef<Blob[]>([]);
154
+ const startSeqRef = useRef(0);
155
+
156
+ useEffect(() => {
157
+ const next = detectSupported();
158
+ setSupported((prev) => (prev === next ? prev : next));
159
+ }, []);
160
+
161
+ const cleanupTracks = useCallback(() => {
162
+ streamRef.current?.getTracks().forEach((t) => {
163
+ try {
164
+ t.stop();
165
+ } catch {
166
+ /* ignore */
167
+ }
168
+ });
169
+ streamRef.current = null;
170
+ }, []);
171
+
172
+ const cleanupRecorder = useCallback(() => {
173
+ const recorder = recorderRef.current;
174
+ if (recorder && recorder.state !== "inactive") {
175
+ try {
176
+ recorder.stop();
177
+ } catch {
178
+ /* ignore */
179
+ }
180
+ }
181
+ recorderRef.current = null;
182
+ }, []);
183
+
184
+ useEffect(() => {
185
+ return () => {
186
+ cleanupRecorder();
187
+ cleanupTracks();
188
+ chunksRef.current = [];
189
+ };
190
+ }, [cleanupRecorder, cleanupTracks]);
191
+
192
+ const stop = useCallback(() => {
193
+ const recorder = recorderRef.current;
194
+ voiceLog("whisper.stop", { state: recorder?.state ?? "none" });
195
+ if (!recorder) {
196
+ startSeqRef.current += 1;
197
+ }
198
+ if (recorder && recorder.state !== "inactive") {
199
+ try {
200
+ recorder.stop();
201
+ } catch {
202
+ /* ignore */
203
+ }
204
+ }
205
+ setListening(false);
206
+ }, []);
207
+
208
+ const reset = useCallback(() => {
209
+ voiceLog("whisper.reset");
210
+ startSeqRef.current += 1;
211
+ cleanupRecorder();
212
+ cleanupTracks();
213
+ chunksRef.current = [];
214
+ setFinalTranscript("");
215
+ setError(null);
216
+ setListening(false);
217
+ setTranscribing(false);
218
+ }, [cleanupRecorder, cleanupTracks]);
219
+
220
+ const start = useCallback(
221
+ async (_opts?: { lang?: string }) => {
222
+ if (!supported) {
223
+ voiceLog("whisper.unsupported");
224
+ setError("Voice input is not supported in this browser.");
225
+ return;
226
+ }
227
+
228
+ voiceLog("whisper.start", { modelLoadStatus });
229
+ setError(null);
230
+ setFinalTranscript("");
231
+ chunksRef.current = [];
232
+ const seq = ++startSeqRef.current;
233
+
234
+ if (modelLoadStatus !== "ready") {
235
+ voiceLog("whisper.model.loading");
236
+ setModelLoadStatus("loading");
237
+ setModelLoadProgress(0);
238
+ try {
239
+ await loadWhisperPipeline((p) => {
240
+ if (startSeqRef.current === seq) {
241
+ setModelLoadProgress(p);
242
+ }
243
+ });
244
+ if (startSeqRef.current !== seq) return;
245
+ voiceLog("whisper.model.ready");
246
+ setModelLoadStatus("ready");
247
+ setModelLoadProgress(1);
248
+ } catch (e) {
249
+ voiceLog("whisper.model.error", {
250
+ message: e instanceof Error ? e.message : String(e),
251
+ });
252
+ setModelLoadStatus("error");
253
+ setError(
254
+ `Could not load voice model: ${
255
+ e instanceof Error ? e.message : String(e)
256
+ }`,
257
+ );
258
+ return;
259
+ }
260
+ }
261
+
262
+ let stream: MediaStream;
263
+ try {
264
+ voiceLog("whisper.media.request");
265
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
266
+ } catch (e) {
267
+ const name = (e as DOMException)?.name;
268
+ voiceLog("whisper.media.error", {
269
+ name,
270
+ message: e instanceof Error ? e.message : String(e),
271
+ });
272
+ setError(
273
+ name === "NotAllowedError" || name === "SecurityError"
274
+ ? "Microphone permission denied"
275
+ : `Could not access microphone: ${
276
+ e instanceof Error ? e.message : String(e)
277
+ }`,
278
+ );
279
+ return;
280
+ }
281
+
282
+ if (startSeqRef.current !== seq) {
283
+ stream.getTracks().forEach((t) => t.stop());
284
+ return;
285
+ }
286
+ streamRef.current = stream;
287
+
288
+ const mimeType = pickRecorderMimeType();
289
+ let recorder: MediaRecorder;
290
+ try {
291
+ recorder = mimeType
292
+ ? new MediaRecorder(stream, { mimeType })
293
+ : new MediaRecorder(stream);
294
+ } catch (e) {
295
+ cleanupTracks();
296
+ voiceLog("whisper.recorder.error", {
297
+ message: e instanceof Error ? e.message : String(e),
298
+ });
299
+ setError(
300
+ `Could not start recorder: ${
301
+ e instanceof Error ? e.message : String(e)
302
+ }`,
303
+ );
304
+ return;
305
+ }
306
+
307
+ recorderRef.current = recorder;
308
+ recorder.ondataavailable = (ev: BlobEvent) => {
309
+ if (ev.data && ev.data.size > 0) {
310
+ voiceLog("whisper.recorder.data", { size: ev.data.size });
311
+ chunksRef.current.push(ev.data);
312
+ }
313
+ };
314
+
315
+ recorder.onstop = async () => {
316
+ const chunks = chunksRef.current;
317
+ chunksRef.current = [];
318
+ cleanupTracks();
319
+ recorderRef.current = null;
320
+ setListening(false);
321
+
322
+ if (chunks.length === 0 || startSeqRef.current !== seq) {
323
+ voiceLog("whisper.stop.empty", { chunks: chunks.length });
324
+ return;
325
+ }
326
+ const blobType = chunks[0]?.type || mimeType || "audio/webm";
327
+ const blob = new Blob(chunks, { type: blobType });
328
+ voiceLog("whisper.stop.blob", {
329
+ size: blob.size,
330
+ type: blob.type,
331
+ chunks: chunks.length,
332
+ });
333
+ if (blob.size < 1024) {
334
+ voiceLog("whisper.stop.too-small", { size: blob.size });
335
+ return;
336
+ }
337
+
338
+ setTranscribing(true);
339
+ try {
340
+ voiceLog("whisper.transcribe.start");
341
+ const pipe = await loadWhisperPipeline(() => {});
342
+ if (startSeqRef.current !== seq) return;
343
+ const pcm = await decodeAndResample(blob);
344
+ if (startSeqRef.current !== seq) return;
345
+ const result = await pipe(pcm, {
346
+ language: "english",
347
+ task: "transcribe",
348
+ });
349
+ if (startSeqRef.current !== seq) return;
350
+ const text = (result?.text ?? "").trim();
351
+ voiceLog("whisper.transcribe.done", transcriptDebug(text));
352
+ if (text) {
353
+ setFinalTranscript(text);
354
+ }
355
+ } catch (e) {
356
+ voiceLog("whisper.transcribe.error", {
357
+ message: e instanceof Error ? e.message : String(e),
358
+ });
359
+ setError(
360
+ `Could not transcribe audio: ${
361
+ e instanceof Error ? e.message : String(e)
362
+ }`,
363
+ );
364
+ } finally {
365
+ if (startSeqRef.current === seq) {
366
+ setTranscribing(false);
367
+ }
368
+ }
369
+ };
370
+
371
+ recorder.onerror = () => {
372
+ voiceLog("whisper.recorder.onerror");
373
+ setError("Recorder error.");
374
+ cleanupTracks();
375
+ recorderRef.current = null;
376
+ setListening(false);
377
+ };
378
+
379
+ try {
380
+ recorder.start();
381
+ voiceLog("whisper.recorder.started", { mimeType: recorder.mimeType });
382
+ setListening(true);
383
+ } catch (e) {
384
+ cleanupTracks();
385
+ voiceLog("whisper.recorder.start-error", {
386
+ message: e instanceof Error ? e.message : String(e),
387
+ });
388
+ setError(
389
+ `Could not start recording: ${
390
+ e instanceof Error ? e.message : String(e)
391
+ }`,
392
+ );
393
+ }
394
+ },
395
+ [supported, modelLoadStatus, cleanupTracks],
396
+ );
397
+
398
+ return {
399
+ supported,
400
+ listening,
401
+ interimTranscript: "",
402
+ finalTranscript,
403
+ error,
404
+ start,
405
+ stop,
406
+ reset,
407
+ modelLoadStatus,
408
+ modelLoadProgress,
409
+ transcribing,
410
+ };
411
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * SSR / server webpack target: the real `@ricky0123/vad-web` pulls
3
+ * `onnxruntime-web` + an AudioWorklet asset path that Webpack must not
4
+ * try to parse on the server. The chat only loads this module on the
5
+ * client (lazy import inside `useSileroVAD`).
6
+ */
7
+
8
+ export class MicVAD {
9
+ static async new(): Promise<MicVAD> {
10
+ throw new Error("@ricky0123/vad-web is client-only");
11
+ }
12
+ start = async (): Promise<void> => {
13
+ throw new Error("@ricky0123/vad-web is client-only");
14
+ };
15
+ pause = async (): Promise<void> => {
16
+ /* noop */
17
+ };
18
+ destroy = async (): Promise<void> => {
19
+ /* noop */
20
+ };
21
+ listening = false;
22
+ errored: string | null = null;
23
+ }
24
+
25
+ export const DEFAULT_MODEL = "legacy" as const;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Opt-in debug logging for the voice pipeline.
3
+ *
4
+ * The voice state machine is notoriously hard to reason about (phase
5
+ * transitions, STT modes, TTS queueing, abort timing), so the dev
6
+ * surface has a firehose of `voiceLog` calls. In production those
7
+ * logs would:
8
+ * - spam the end user's console
9
+ * - expose transcript previews to any browser extension that can
10
+ * read `console.debug` output
11
+ *
12
+ * Both are undesirable, so logging is silent by default and only lights
13
+ * up when the consumer opts in, either per-tab via the DevTools:
14
+ *
15
+ * localStorage.setItem("grimoire.chat.debug", "1")
16
+ * location.reload()
17
+ *
18
+ * …or per-session by setting `window.__grimoireChatDebug = true` before
19
+ * the panel mounts. Both checks are read once on module load; flip and
20
+ * reload to toggle.
21
+ */
22
+
23
+ const PREFIX = "[chat:voice]";
24
+
25
+ const debugEnabled = ((): boolean => {
26
+ if (typeof window === "undefined") return false;
27
+ try {
28
+ const viaWindow = (window as unknown as { __grimoireChatDebug?: boolean })
29
+ .__grimoireChatDebug;
30
+ if (viaWindow === true) return true;
31
+ return window.localStorage?.getItem("grimoire.chat.debug") === "1";
32
+ } catch {
33
+ // SSR, storage-disabled iframes, or sandboxed contexts — silent.
34
+ return false;
35
+ }
36
+ })();
37
+
38
+ export function voiceLog(
39
+ event: string,
40
+ details?: Record<string, unknown>,
41
+ ): void {
42
+ if (!debugEnabled) return;
43
+ console.debug(PREFIX, event, details ?? {});
44
+ }
45
+
46
+ /**
47
+ * Collapse a transcript into `{ length, preview }` for the log sink. We
48
+ * never log the full transcript; at most the first 80 characters, and
49
+ * only when debug is on (see `voiceLog`).
50
+ */
51
+ export function transcriptDebug(text: string): {
52
+ readonly length: number;
53
+ readonly preview: string;
54
+ } {
55
+ const trimmed = text.trim();
56
+ return {
57
+ length: trimmed.length,
58
+ preview: trimmed.length > 80 ? `${trimmed.slice(0, 80)}...` : trimmed,
59
+ };
60
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Voice mode finite-state machine.
3
+ *
4
+ * The four UI phases (`idle`, `listening`, `thinking`, `speaking`) are the
5
+ * states the user can observe; the machine here makes the *transitions*
6
+ * between them explicit and disallows illegal jumps. Side effects
7
+ * (timers, AbortControllers, mic streams) are NOT modelled here — those
8
+ * are owned by VoiceMode and reacted to via the `effects` array each
9
+ * transition emits. Keeping effects out of the reducer keeps it pure and
10
+ * testable without a real DOM.
11
+ *
12
+ * Transition table:
13
+ *
14
+ * idle ──MIC_PRESS──> listening (start STT + VAD)
15
+ * listening ──VAD_END / PHRASE_END / MIC_PRESS / NATIVE_FINAL──> thinking
16
+ * listening ──STT_ERROR──> idle (with error)
17
+ * thinking ──TOKEN──> speaking (first sentence enqueued)
18
+ * thinking ──ASK_DONE──> idle (no audible reply)
19
+ * thinking ──ASK_ERROR──> idle (with error)
20
+ * speaking ──TTS_DRAIN──> idle
21
+ * * ──CANCEL──> idle (hard reset; runs cleanup effects)
22
+ *
23
+ * `engineBlocked` and `pendingTranscript` live on the FSM only because
24
+ * they gate transitions. Anything else (assistant text, citations, mic
25
+ * permission state) belongs to the component.
26
+ */
27
+
28
+ export type VoiceUiPhase = "idle" | "listening" | "thinking" | "speaking";
29
+
30
+ export interface VoiceFsmState {
31
+ readonly phase: VoiceUiPhase;
32
+ readonly error: string | null;
33
+ }
34
+
35
+ export type VoiceFsmEvent =
36
+ | { readonly type: "MIC_PRESS" }
37
+ | { readonly type: "VAD_END" }
38
+ | { readonly type: "PHRASE_END" }
39
+ | { readonly type: "NATIVE_FINAL_RECEIVED" }
40
+ | { readonly type: "STT_ERROR"; readonly message: string }
41
+ | { readonly type: "TOKEN_RECEIVED" }
42
+ | { readonly type: "ASK_DONE"; readonly hadAudibleReply: boolean }
43
+ | { readonly type: "ASK_ERROR"; readonly message: string }
44
+ | { readonly type: "TTS_DRAIN" }
45
+ | { readonly type: "CANCEL" };
46
+
47
+ export type VoiceFsmEffect =
48
+ | "start-stt"
49
+ | "stop-stt-graceful"
50
+ | "stop-stt-hard"
51
+ | "start-vad"
52
+ | "stop-vad"
53
+ | "abort-ask"
54
+ | "cancel-tts"
55
+ | "clear-timers"
56
+ | "reset-transcripts";
57
+
58
+ export interface VoiceFsmTransition {
59
+ readonly state: VoiceFsmState;
60
+ readonly effects: readonly VoiceFsmEffect[];
61
+ }
62
+
63
+ export const initialVoiceState: VoiceFsmState = {
64
+ phase: "idle",
65
+ error: null,
66
+ };
67
+
68
+ const into = (
69
+ phase: VoiceUiPhase,
70
+ effects: readonly VoiceFsmEffect[] = [],
71
+ error: string | null = null,
72
+ ): VoiceFsmTransition => ({ state: { phase, error }, effects });
73
+
74
+ /**
75
+ * Pure reducer. Returns the next state plus a list of effects the
76
+ * VoiceMode component should fire after committing the state. The
77
+ * effect names are deliberately coarse — VoiceMode owns the actual
78
+ * function calls (so we don't have to mock timers or AudioContext to
79
+ * test the FSM).
80
+ */
81
+ export function voiceFsmReduce(
82
+ state: VoiceFsmState,
83
+ ev: VoiceFsmEvent,
84
+ ): VoiceFsmTransition {
85
+ // CANCEL is the universal escape hatch — it always resets.
86
+ if (ev.type === "CANCEL") {
87
+ return into("idle", [
88
+ "abort-ask",
89
+ "cancel-tts",
90
+ "stop-stt-hard",
91
+ "stop-vad",
92
+ "clear-timers",
93
+ "reset-transcripts",
94
+ ]);
95
+ }
96
+
97
+ switch (state.phase) {
98
+ case "idle": {
99
+ if (ev.type === "MIC_PRESS") {
100
+ return into("listening", [
101
+ "reset-transcripts",
102
+ "start-stt",
103
+ "start-vad",
104
+ ]);
105
+ }
106
+ return { state, effects: [] };
107
+ }
108
+
109
+ case "listening": {
110
+ switch (ev.type) {
111
+ case "VAD_END":
112
+ case "PHRASE_END":
113
+ case "MIC_PRESS":
114
+ return into("thinking", [
115
+ "stop-stt-graceful",
116
+ "stop-vad",
117
+ "clear-timers",
118
+ ]);
119
+ case "NATIVE_FINAL_RECEIVED":
120
+ // Native API streams a final without us asking; treat as
121
+ // implicit phrase end + speech end.
122
+ return into("thinking", [
123
+ "stop-stt-graceful",
124
+ "stop-vad",
125
+ "clear-timers",
126
+ ]);
127
+ case "STT_ERROR":
128
+ return into(
129
+ "idle",
130
+ ["stop-vad", "clear-timers", "reset-transcripts"],
131
+ ev.message,
132
+ );
133
+ default:
134
+ return { state, effects: [] };
135
+ }
136
+ }
137
+
138
+ case "thinking": {
139
+ switch (ev.type) {
140
+ case "TOKEN_RECEIVED":
141
+ return into("speaking");
142
+ case "ASK_DONE":
143
+ // No audible reply means TTS never started; jump straight to idle.
144
+ if (!ev.hadAudibleReply) {
145
+ return into("idle");
146
+ }
147
+ // Otherwise stay in thinking and wait for first token; this branch
148
+ // is mostly defensive since `TOKEN_RECEIVED` arrives first.
149
+ return { state, effects: [] };
150
+ case "ASK_ERROR":
151
+ return into("idle", ["abort-ask"], ev.message);
152
+ case "MIC_PRESS":
153
+ // Interrupt: user wants to start over while the model is thinking.
154
+ return into("listening", [
155
+ "abort-ask",
156
+ "cancel-tts",
157
+ "reset-transcripts",
158
+ "start-stt",
159
+ "start-vad",
160
+ ]);
161
+ default:
162
+ return { state, effects: [] };
163
+ }
164
+ }
165
+
166
+ case "speaking": {
167
+ switch (ev.type) {
168
+ case "TTS_DRAIN":
169
+ return into("idle");
170
+ case "ASK_ERROR":
171
+ return into("idle", ["abort-ask", "cancel-tts"], ev.message);
172
+ case "MIC_PRESS":
173
+ // Barge-in: user starts a new turn while the assistant is talking.
174
+ return into("listening", [
175
+ "abort-ask",
176
+ "cancel-tts",
177
+ "reset-transcripts",
178
+ "start-stt",
179
+ "start-vad",
180
+ ]);
181
+ case "TOKEN_RECEIVED":
182
+ // Still streaming additional sentences mid-speak; nothing to do
183
+ // (the component flushes them to TTS, not the FSM).
184
+ return { state, effects: [] };
185
+ default:
186
+ return { state, effects: [] };
187
+ }
188
+ }
189
+
190
+ default: {
191
+ // Exhaustiveness: TS will flag if we add a phase without handling it.
192
+ const _exhaustive: never = state.phase;
193
+ return { state, effects: [] };
194
+ }
195
+ }
196
+ }