Stats history with year filter, folding and XLSX/CSV export; snippets, read-only links, commit suggestions, weekly review, offline copies, server speech-to-text, planning model, job reports; fal.ai health fix
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c08c6c7436
commit
388ca96419
31 files changed
+1518
-82
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getLang, useT } from "../i18n";
|
||||
import { toast } from "../dialogs";
|
||||
import { get } from "../api";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
/* Web Speech API (Chrome/Edge/Safari; Firefox has none). */
|
||||
@@ -18,10 +19,86 @@ const Impl: (new () => Recognition) | undefined = (window as any).SpeechRecognit
|
||||
|
||||
export const voiceSupported = () => !!Impl;
|
||||
|
||||
/** Server-side transcription is on (server.transcribe_model) – for browsers without dictation. */
|
||||
function useServerSpeech(): boolean {
|
||||
const [on, setOn] = useState(false);
|
||||
useEffect(() => {
|
||||
if (Impl) return;
|
||||
get<{ transcribe?: boolean }>("/config")
|
||||
.then((c) => setOn(!!c.transcribe))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
return on;
|
||||
}
|
||||
|
||||
/** Record with the microphone; the audio is transcribed by the server. */
|
||||
function RecorderButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) {
|
||||
const t = useT();
|
||||
const [state, setState] = useState<"idle" | "recording" | "sending">("idle");
|
||||
const rec = useRef<MediaRecorder | null>(null);
|
||||
useEffect(() => () => rec.current?.stream.getTracks().forEach((x) => x.stop()), []);
|
||||
|
||||
const toggle = async () => {
|
||||
if (state === "recording") {
|
||||
rec.current?.stop();
|
||||
return;
|
||||
}
|
||||
if (state === "sending") return;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const r = new MediaRecorder(stream);
|
||||
const chunks: Blob[] = [];
|
||||
r.ondataavailable = (e) => e.data.size && chunks.push(e.data);
|
||||
r.onstop = async () => {
|
||||
stream.getTracks().forEach((x) => x.stop());
|
||||
setState("sending");
|
||||
onInterim(t("voice.transcribing"));
|
||||
try {
|
||||
const blob = new Blob(chunks, { type: r.mimeType || "audio/webm" });
|
||||
const res = await fetch(`/api/transcribe?lang=${getLang()}`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": blob.type }, body: blob });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error ?? data.message ?? `HTTP ${res.status}`);
|
||||
if (data.text) onText(String(data.text));
|
||||
} catch (e: any) {
|
||||
toast(`${t("voice.error")}: ${e.message}`);
|
||||
} finally {
|
||||
onInterim("");
|
||||
setState("idle");
|
||||
}
|
||||
};
|
||||
rec.current = r;
|
||||
r.start();
|
||||
setState("recording");
|
||||
} catch {
|
||||
toast(t("voice.denied"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`ghost small voice-btn ${state === "recording" ? "on" : ""}`}
|
||||
disabled={state === "sending"}
|
||||
onClick={() => void toggle()}
|
||||
title={t(state === "recording" ? "voice.stop" : "voice.start")}
|
||||
aria-label={t(state === "recording" ? "voice.stop" : "voice.start")}
|
||||
aria-pressed={state === "recording"}
|
||||
>
|
||||
<Icon name="mic" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const LOCALES: Record<string, string> = { de: "de-DE", en: "en-US" };
|
||||
|
||||
/** Microphone button: dictated text is appended to the message. */
|
||||
export function VoiceButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) {
|
||||
export function VoiceButton(props: { onText: (text: string) => void; onInterim: (text: string) => void }) {
|
||||
const server = useServerSpeech();
|
||||
if (!Impl) return server ? <RecorderButton {...props} /> : null;
|
||||
return <BrowserVoiceButton {...props} />;
|
||||
}
|
||||
|
||||
function BrowserVoiceButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) {
|
||||
const t = useT();
|
||||
const [listening, setListening] = useState(false);
|
||||
const rec = useRef<Recognition | null>(null);
|
||||
|
||||
Reference in new issue
Block a user