Merge upstream v0.4.0 into Gitea fork

This commit is contained in:
2026-10-31 13:45:06 +03:00
286 changed files with 24403 additions and 2546 deletions
+75 -56
View File
@@ -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}