Merge upstream v0.4.0 into Gitea fork
This commit is contained in:
+75
-56
@@ -6,6 +6,8 @@ import { useSettingsDialog } from "@/components/settings/settings-dialog";
|
||||
import { DEFAULT_STT_MODEL, OPENROUTER_STT_MODEL, STT_MAX_AUDIO_BYTES, WAV_HEADER_BYTES, dictationUrl, dictationWav, type SttModel, type SttLanguage } from "@/src/core";
|
||||
import { LanguageSelect, useI18n } from "@/components/i18n";
|
||||
import { Hint } from "@/components/ui/hint";
|
||||
import { setDictationActive } from "@/components/dictation-state";
|
||||
export { isDictationActive } from "@/components/dictation-state";
|
||||
|
||||
/**
|
||||
* Voice dictation — direct Deepgram streaming or whole-recording OpenRouter STT.
|
||||
@@ -201,15 +203,16 @@ export function rmsToMeter(rms: number): number {
|
||||
* window, one pending question, one target at a time.
|
||||
*/
|
||||
let questionTarget: ((text: string) => void) | null = null;
|
||||
let composerTarget: ((text: string) => void) | null = null;
|
||||
|
||||
/* Is a dictation run live on this page? The Esc chain (keyboard wave A,
|
||||
* components/keyboard-shortcuts.tsx) needs a SYNCHRONOUS answer — the
|
||||
* stop handler can't report back through a CustomEvent. Set at start(),
|
||||
* cleared in cleanup() (every terminal path runs through cleanup). */
|
||||
let dictationActiveFlag = false;
|
||||
|
||||
export function isDictationActive(): boolean {
|
||||
return dictationActiveFlag;
|
||||
/** File references use the very same default destination as dictation,
|
||||
* including claims whose dock is currently collapsed. A focused editor can
|
||||
* insert at its caret instead; callers use this only as the fallback. */
|
||||
export function appendToPrimaryInput(text: string): boolean {
|
||||
const target = questionTarget ?? composerTarget;
|
||||
if (!target) return false;
|
||||
target(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* The finisher registry: submit paths FAR from the hook's owner (the
|
||||
@@ -295,7 +298,13 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
const upload = useRef<AbortController | null>(null);
|
||||
const drain = useRef<(() => void) | null>(null);
|
||||
const stopping = useRef(false);
|
||||
const [phase, setPhase] = useState<DictationPhase>("idle");
|
||||
const captureDrained = useRef(false);
|
||||
const [phase, setPhaseState] = useState<DictationPhase>("idle");
|
||||
const phaseRef = useRef<DictationPhase>("idle");
|
||||
const setPhase = useCallback((next: DictationPhase) => {
|
||||
phaseRef.current = next;
|
||||
setPhaseState(next);
|
||||
}, []);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */
|
||||
const levelRef = useRef(0);
|
||||
@@ -303,6 +312,13 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
* retarget (composer↔question) is honored on the very next final. */
|
||||
const appendRef = useRef(append);
|
||||
appendRef.current = append;
|
||||
useEffect(() => {
|
||||
const target = (text: string) => appendRef.current(text);
|
||||
composerTarget = target;
|
||||
return () => {
|
||||
if (composerTarget === target) composerTarget = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Live machinery (non-render values). The MediaStream tracks stop and
|
||||
// the per-run nodes disconnect on teardown; the socket is recreated per
|
||||
@@ -337,7 +353,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
}, []);
|
||||
|
||||
/** Full teardown of one run's machinery. Idempotent; generation-guarded by callers. */
|
||||
const cleanup = useCallback(() => {
|
||||
const cleanup = useCallback((warmNext = true) => {
|
||||
genRef.current++;
|
||||
upload.current?.abort();
|
||||
upload.current = null;
|
||||
@@ -346,7 +362,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
audioFrames.current = [];
|
||||
audioBytes.current = 0;
|
||||
stopping.current = false;
|
||||
dictationActiveFlag = false;
|
||||
setDictationActive(false);
|
||||
setDictating(false);
|
||||
flush(); // the deferred transcript appears when the run ends (owner round 2)
|
||||
if (safetyRef.current) {
|
||||
@@ -375,7 +391,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
for (const w of waitersRef.current.splice(0)) w();
|
||||
// Pre-mint for the next run (mitigation D) — best-effort; a keyless
|
||||
// page just doesn't warm.
|
||||
if (selected.current.model === DEFAULT_STT_MODEL) warmTokens.refresh();
|
||||
if (warmNext && selected.current.model === DEFAULT_STT_MODEL) warmTokens.refresh();
|
||||
}, []);
|
||||
|
||||
/** Open the Deepgram socket and wire its messages. `gen` guards staleness. */
|
||||
@@ -394,7 +410,11 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
// faster-than-realtime sends.
|
||||
for (const frame of pendingRef.current) ws.send(frame);
|
||||
pendingRef.current = [];
|
||||
setPhase("recording");
|
||||
if (!stopping.current) setPhase("recording");
|
||||
else if (captureDrained.current) {
|
||||
try { ws.send(JSON.stringify({ type: "CloseStream" })); }
|
||||
catch { cleanup(); }
|
||||
}
|
||||
});
|
||||
ws.addEventListener("message", (ev) => {
|
||||
if (gen !== genRef.current) return;
|
||||
@@ -444,6 +464,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (phaseRef.current !== "idle") return;
|
||||
// Pin the complete configuration once; reconnects must retain both language and hints.
|
||||
recordingUrl.current = selectedUrl.current;
|
||||
recordingConfig.current = { ...selected.current };
|
||||
@@ -451,8 +472,9 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
audioFrames.current = [];
|
||||
audioBytes.current = 0;
|
||||
stopping.current = false;
|
||||
captureDrained.current = false;
|
||||
setDictating(true);
|
||||
dictationActiveFlag = true;
|
||||
setDictationActive(true);
|
||||
setError(null);
|
||||
setPhase("connecting");
|
||||
reconnectsRef.current = 0;
|
||||
@@ -567,7 +589,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
|
||||
const stop = useCallback(() => {
|
||||
const gen = genRef.current;
|
||||
if (phase !== "recording" && phase !== "connecting") return;
|
||||
if (phaseRef.current !== "recording" && phaseRef.current !== "connecting") return;
|
||||
if (stopping.current) return;
|
||||
stopping.current = true;
|
||||
setPhase("stopping");
|
||||
@@ -615,47 +637,54 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
})();
|
||||
return;
|
||||
}
|
||||
workletRef.current?.disconnect();
|
||||
workletRef.current = null;
|
||||
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
const worklet = workletRef.current;
|
||||
if (!worklet) { cleanup(); return; }
|
||||
// Bound Stop even if a token/handshake never arrives. While connecting,
|
||||
// the socket's open handler sends queued PCM before CloseStream.
|
||||
safetyRef.current = setTimeout(() => {
|
||||
if (gen === genRef.current) cleanup();
|
||||
}, 3000);
|
||||
void (async () => {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: "CloseStream" }));
|
||||
} catch {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => { drain.current = null; reject(new Error("Could not finish microphone capture")); }, 2000);
|
||||
drain.current = () => { clearTimeout(timer); resolve(); };
|
||||
worklet.port.postMessage({ type: "flush" });
|
||||
});
|
||||
if (gen !== genRef.current) return;
|
||||
worklet.disconnect();
|
||||
workletRef.current = null;
|
||||
captureDrained.current = true;
|
||||
const ws = wsRef.current;
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "CloseStream" }));
|
||||
} catch (error) {
|
||||
if (gen !== genRef.current) return;
|
||||
setError(error instanceof Error ? error.message : "Could not finish microphone capture");
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
// Deepgram flushes remaining finals then closes; if it never
|
||||
// does, the safety net ends the run anyway (dictate pattern).
|
||||
safetyRef.current = setTimeout(() => {
|
||||
if (gen === genRef.current) cleanup();
|
||||
}, 3000);
|
||||
} else {
|
||||
cleanup();
|
||||
}
|
||||
}, [phase, cleanup]);
|
||||
})();
|
||||
}, [cleanup]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (phase === "idle") return;
|
||||
if (phaseRef.current === "idle") return;
|
||||
// Discard first, teardown second — cleanup() flushes, and the now-empty
|
||||
// buffer makes that flush a no-op. Also kills a "stopping" run: the
|
||||
// flush already underway is abandoned (its timer is cleared inside).
|
||||
bufferRef.current = [];
|
||||
cleanup();
|
||||
}, [phase, cleanup]);
|
||||
}, [cleanup]);
|
||||
|
||||
const finish = useCallback((): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
if (phase === "idle") {
|
||||
if (phaseRef.current === "idle") {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
waitersRef.current.push(resolve);
|
||||
// "stopping" is already ending — the waiter just rides to cleanup.
|
||||
if (phase === "recording" || phase === "connecting") stop();
|
||||
if (phaseRef.current === "recording" || phaseRef.current === "connecting") stop();
|
||||
});
|
||||
}, [phase, stop]);
|
||||
}, [stop]);
|
||||
|
||||
// Publish finish() for the sibling subtrees (the pending question's
|
||||
// dock) — registered for as long as this page owns the hook.
|
||||
@@ -667,10 +696,10 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
}, [finish]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (phase === "idle") void start();
|
||||
else if (phase === "recording" || phase === "connecting") stop();
|
||||
if (phaseRef.current === "idle") void start();
|
||||
else if (phaseRef.current === "recording" || phaseRef.current === "connecting") stop();
|
||||
// "stopping" presses are ignored — the flush is in flight.
|
||||
}, [phase, start, stop]);
|
||||
}, [start, stop]);
|
||||
|
||||
// Shortcut seams (keyboard wave A): ⌘D toggles from anywhere on the
|
||||
// page; the Esc chain lands here as a CANCEL (owner, round 3). Each
|
||||
@@ -680,9 +709,9 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
if ((general.sttModel ?? DEFAULT_STT_MODEL) === DEFAULT_STT_MODEL) warmTokens.refresh();
|
||||
}, [general.sttModel]);
|
||||
useEffect(() => () => {
|
||||
// Navigation cancels a pending upload and releases the microphone without inserting text.
|
||||
// Navigation cancels every pending await/upload without inserting into another page.
|
||||
bufferRef.current = [];
|
||||
cleanup();
|
||||
cleanup(false);
|
||||
}, [cleanup]);
|
||||
useEffect(() => {
|
||||
window.addEventListener("lh:kbd-toggle-dictation", toggle);
|
||||
@@ -772,10 +801,7 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
||||
return (
|
||||
<div className="flex w-full min-w-0 items-center gap-1.5">
|
||||
<Hint
|
||||
// Errored: the button span truncates the line — the tooltip is its
|
||||
// full reveal (the keyless line KEEPS "Deepgram" — owner overrule:
|
||||
// it's the Keys row label, the truth the user needs). Otherwise the
|
||||
// quiet label + the live ⌘D binding.
|
||||
// The error line truncates in the button; its tooltip reveals it.
|
||||
label={error ? t(error) : t("Dictate")}
|
||||
action={error ? undefined : "toggle-dictation"}
|
||||
side="top"
|
||||
@@ -792,10 +818,8 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
||||
}}
|
||||
aria-label={error ? t("Dictation failed: {v0}", { v0: t(error) }) : t("Start dictation")}
|
||||
data-testid="dictation-toggle"
|
||||
// Same blurring as the floating chrome (owner, fix wave round 3),
|
||||
// but lower opacity — the mic stays quieter than the model pills.
|
||||
className="flex h-7 w-full items-center justify-center gap-1.5 rounded-md border border-border/50 bg-card/50 text-xs text-muted-foreground/70 shadow-lg backdrop-blur transition-colors hover:border-border hover:text-muted-foreground"
|
||||
>
|
||||
className="flex h-7 w-full items-center justify-center gap-1.5 rounded-md border border-border/50 bg-card/50 text-xs text-muted-foreground/70 shadow-lg backdrop-blur transition-colors hover:border-border hover:text-muted-foreground"
|
||||
>
|
||||
<MicIcon className="size-3.5" />
|
||||
<span className="max-w-full truncate">{error ? t(error) : t("Dictate")}</span>
|
||||
</button>
|
||||
@@ -811,12 +835,7 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
||||
|
||||
return (
|
||||
<div className="flex w-full min-w-0 items-center gap-2" data-testid="dictation-bar">
|
||||
<Hint
|
||||
// The owner flagged this copy good ("Stop dictation (Esc)") —
|
||||
// kept verbatim; only the machinery moved to the styled tooltip.
|
||||
label={t("Stop dictation (Esc)")}
|
||||
side="top"
|
||||
>
|
||||
<Hint label={t("Stop dictation (Esc)")} side="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
|
||||
Reference in New Issue
Block a user