Add project-scoped voice dictation controls with observable local model management. - Add the Voice Input settings section with opt-in enablement and runtime-safe status handling. - Add Parakeet v3 model download, removal, progress polling, search, documentation, and regression coverage. - Add a minor release changeset for Voice Input settings. Files changed: .changeset/fn-8572-voice-input-settings.md | 7 ++ docs/dashboard-guide.md | 4 + docs/settings-reference.md | 1 + .../dashboard/app/components/SettingsModal.tsx | 7 +- .../VoiceInputSection.modal-visibility.test.tsx | 19 +++ .../settings/__tests__/VoiceInputSection.test.tsx | 137 +++++++++++++++++++++ .../settings/__tests__/section-keys.test.ts | 1 + .../search/__tests__/settings-search-index.test.ts | 1 + .../app/components/settings/search/entries.ts | 2 + .../app/components/settings/section-keys.ts | 2 + .../settings/sections/VoiceInputSection.css | 48 ++++++++ .../settings/sections/VoiceInputSection.search.ts | 14 +++ .../settings/sections/VoiceInputSection.tsx | 128 ++++++++++++++++++++ 13 files changed, 370 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-8572 Fusion-Task-Lineage: 2d2ad0df-3765-4d19-a6f3-7c2b5ca5df95 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
129 lines
6.8 KiB
TypeScript
129 lines
6.8 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { api } from "../../../api/legacy";
|
|
import { SettingsFieldRow } from "../SettingsFieldRow";
|
|
import { SettingsToggleRow } from "../SettingsToggleRow";
|
|
import type { SectionBaseProps } from "./context";
|
|
import "./VoiceInputSection.css";
|
|
|
|
export const VOICE_STATUS_POLL_INTERVAL_MS = 1_000;
|
|
type ModelStatus = "not-installed" | "queued" | "downloading" | "installed" | "error";
|
|
type VoiceStatus = {
|
|
model: { status: ModelStatus; progress?: number; errorReason?: string; errorMessage?: string };
|
|
runtime: { status: "available" | "unavailable"; unavailableReason?: string };
|
|
};
|
|
|
|
function isVoiceStatus(value: unknown): value is VoiceStatus {
|
|
if (!value || typeof value !== "object") return false;
|
|
const response = value as { model?: { status?: unknown }; runtime?: { status?: unknown } };
|
|
return ["not-installed", "queued", "downloading", "installed", "error"].includes(String(response.model?.status))
|
|
&& ["available", "unavailable"].includes(String(response.runtime?.status));
|
|
}
|
|
|
|
/**
|
|
* FNXC:VoiceInput 2026-07-28-12:00:
|
|
* Voice input is opt-in and the Parakeet v3 download remains operator-managed in
|
|
* Settings. Status polling runs only while a download is active, so progress is
|
|
* live without leaving a background timer after the model reaches a terminal state.
|
|
*
|
|
* Missing sherpa-onnx runtime and indeterminate status fail closed: the stored
|
|
* preference is preserved, but the effective toggle is disabled rather than
|
|
* silently rewriting a preference that may become usable after runtime recovery.
|
|
* Model controls stay in SettingsFieldRow slots to retain the shared settings-row
|
|
* contract instead of introducing a parallel panel or row variant.
|
|
*/
|
|
export function VoiceInputSection({ form, setForm }: SectionBaseProps) {
|
|
const { t } = useTranslation("app");
|
|
const [status, setStatus] = useState<VoiceStatus | null>(null);
|
|
const [statusUnavailable, setStatusUnavailable] = useState(false);
|
|
const mounted = useRef(true);
|
|
|
|
const loadStatus = useCallback(async () => {
|
|
try {
|
|
const response = await api<unknown>("/voice/status");
|
|
if (!isVoiceStatus(response)) throw new Error("invalid voice status");
|
|
if (mounted.current) {
|
|
setStatus(response);
|
|
setStatusUnavailable(false);
|
|
}
|
|
} catch {
|
|
if (mounted.current) {
|
|
setStatus(null);
|
|
setStatusUnavailable(true);
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
mounted.current = true;
|
|
void loadStatus();
|
|
return () => { mounted.current = false; };
|
|
}, [loadStatus]);
|
|
|
|
const modelStatus = status?.model.status;
|
|
useEffect(() => {
|
|
if (modelStatus !== "downloading" && modelStatus !== "queued") return;
|
|
const interval = window.setInterval(() => { void loadStatus(); }, VOICE_STATUS_POLL_INTERVAL_MS);
|
|
return () => window.clearInterval(interval);
|
|
}, [loadStatus, modelStatus]);
|
|
|
|
const runtimeUnavailable = status?.runtime.status === "unavailable";
|
|
const unavailable = statusUnavailable || runtimeUnavailable;
|
|
const storedEnabled = form.voiceInput?.enabled === true;
|
|
const effectiveEnabled = storedEnabled && !unavailable;
|
|
const unavailableMessage = statusUnavailable
|
|
? t("settings.voiceInput.statusUnavailable", "Voice runtime status could not be determined; voice mode stays disabled.")
|
|
: t("settings.voiceInput.runtimeUnavailable", storedEnabled
|
|
? "Voice mode is inactive because the sherpa-onnx runtime is unavailable. Your saved preference remains on."
|
|
: "The sherpa-onnx runtime is unavailable, so voice mode stays disabled.");
|
|
|
|
const performModelAction = async (path: string, method: "POST" | "DELETE") => {
|
|
try { await api(path, { method }); } finally { await loadStatus(); }
|
|
};
|
|
const downloading = modelStatus === "downloading" || modelStatus === "queued";
|
|
const progress = status?.model.progress;
|
|
|
|
return <section className="voice-input-section" data-testid="voice-input-section">
|
|
<h4 className="settings-section-heading">{t("settings.voiceInput.title", "Voice Input")}</h4>
|
|
<div data-effective-enabled={effectiveEnabled ? "true" : "false"}>
|
|
<SettingsToggleRow
|
|
descriptor={{
|
|
key: "voiceInput.enabled",
|
|
label: t("settings.voiceInput.enable", "Enable voice input"),
|
|
help: t("settings.voiceInput.enableHelp", "Default: off. Voice dictation uses the operator-managed Parakeet v3 model."),
|
|
scope: "project",
|
|
disabled: unavailable,
|
|
}}
|
|
value={effectiveEnabled}
|
|
onChange={(enabled) => setForm((current) => ({ ...current, voiceInput: { ...(current.voiceInput ?? {}), enabled: enabled === true } }))}
|
|
/>
|
|
</div>
|
|
{unavailable && <p className="voice-input-section__message" role="alert" data-testid={statusUnavailable ? "voice-input-status-unavailable" : "voice-input-runtime-unavailable"}>{unavailableMessage}</p>}
|
|
<SettingsFieldRow
|
|
label={t("settings.voiceInput.modelStatus", "Parakeet v3 model status")}
|
|
help={t("settings.voiceInput.modelStatusHelp", "The speech model is installed and managed locally on this device.")}
|
|
scope="project"
|
|
>
|
|
<span className={`voice-input-section__status voice-input-section__status--${modelStatus ?? "unknown"}`} data-testid="voice-input-model-status">
|
|
{statusUnavailable ? t("settings.voiceInput.unknown", "Status unavailable")
|
|
: downloading ? (typeof progress === "number" ? t("settings.voiceInput.downloadingProgress", "Downloading: {{progress}}%", { progress: Math.round(progress * 100) }) : t("settings.voiceInput.downloading", "Downloading model…"))
|
|
: modelStatus === "installed" ? t("settings.voiceInput.installed", "Installed")
|
|
: modelStatus === "error" ? t("settings.voiceInput.error", "Model error: {{message}}", { message: status?.model.errorMessage ?? status?.model.errorReason ?? "Unknown error" })
|
|
: t("settings.voiceInput.notInstalled", "Not installed")}
|
|
</span>
|
|
</SettingsFieldRow>
|
|
<SettingsFieldRow
|
|
label={t("settings.voiceInput.modelActions", "Model management")}
|
|
help={t("settings.voiceInput.modelActionsHelp", "Download or remove the Parakeet v3 speech model.")}
|
|
scope="project"
|
|
>
|
|
<div className="voice-input-section__actions" data-testid="voice-input-model-actions">
|
|
{!statusUnavailable && (modelStatus === "not-installed" || modelStatus === "error") && <button type="button" className="btn btn-secondary" onClick={() => void performModelAction("/voice/model/download", "POST")}>{t("settings.voiceInput.download", "Download")}</button>}
|
|
{!statusUnavailable && modelStatus === "installed" && <button type="button" className="btn btn-secondary" onClick={() => void performModelAction("/voice/model", "DELETE")}>{t("settings.voiceInput.remove", "Remove")}</button>}
|
|
</div>
|
|
</SettingsFieldRow>
|
|
</section>;
|
|
}
|
|
|
|
export default VoiceInputSection;
|