FN-8572: add voice input settings and model controls

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>
This commit is contained in:
gsxdsm
2026-07-25 02:51:28 -07:00
parent 9a2aea6120
commit 11db36187f
13 changed files with 370 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add Voice Input settings for opt-in dictation and Parakeet v3 model management.
category: feature
dev: Adds project-scoped voiceInput.enabled UI with polled model lifecycle controls.

View File

@@ -38,6 +38,10 @@ Every user-editable setting's help text (the `.settings-description`/`<small>` h
<!-- FNXC:SettingsAutoSaveDocs 2026-08-02-20:55: FN-8395 removes the ambiguous Settings Save affordance. Operators need the persistence timing and close guarantee documented where Settings behavior is introduced. -->
Settings form changes save automatically after a short pause. The footer no longer includes a **Save** button and closing Settings does not ask about unsaved changes: Close, Escape, and clicking outside the modal first flush any pending edit. The footer shows quiet **Saving…**, **Saved**, or save-failure status; correct the value and retry after a failure.
## Voice Input
**Settings → Voice Input** is visible in both Basic and Advanced settings. Voice mode is off by default; enabling it is an explicit project preference. The same section shows the locally managed Parakeet v3 model and lets an operator download or remove it. Download progress is polled only while the model is downloading. When sherpa-onnx is unavailable, Settings preserves any saved enabled preference but presents voice mode as backend-enforced disabled with an explanation. If status cannot be determined, the section fails closed: voice mode stays disabled and model actions are not shown until status is available.
## Reset Settings
<!-- FNXC:SettingsResetDocs 2026-07-04-00:00: Reset Settings is a DESTRUCTIVE action. Document both choices, the scope-precision guarantee, and which sections are excluded so operators understand exactly what a reset does and does not touch before they click it.

View File

@@ -774,6 +774,7 @@ Database backups work with both external PostgreSQL and Fusion's default embedde
| `chatRoomCompactionFetchLimit` | `number` | `200` | Upper bound on room messages fetched for transcript compaction per responder turn (raised to support larger retained context windows). |
| `chatRoomSummaryMaxChars` | `number` | `3000` | Hard cap for the synthesized “Earlier room context” summary block (about 2× the prior summary budget). |
| `researchSettings` | `ResearchProjectSettings` | `{ enabled: true, searchProvider: undefined, synthesisProvider: undefined, synthesisModelId: undefined, enabledSources: { webSearch: true, pageFetch: true, github: false, localDocs: true, llmSynthesis: true }, limits: { maxConcurrentRuns: 3, maxSourcesPerRun: 20, maxDurationMs: 300000, requestTimeoutMs: 30000 } }` | Project-specific Research enablement/overrides. Resolved together with `researchGlobalDefaults` via `resolveResearchSettings()`. |
| `voiceInput` | `VoiceInputSettings` | `{ enabled: false }` | Opt-in voice dictation preference. **Settings → Voice Input** manages the local Parakeet v3 model separately: status is polled while downloading, Download/Remove are operator actions, and the model is never downloaded implicitly. If the sherpa-onnx runtime is unavailable, a saved enabled preference is preserved but reported as effectively disabled. If runtime/model status cannot be determined, the UI fails closed: voice remains disabled and model actions are hidden until status can be read. |
| `researchEnabled` | `boolean` | `undefined` | Enable or disable research for this project. **Deprecated:** prefer `researchSettings.enabled`. |
| `researchMaxConcurrentRuns` | `number` | `undefined` | Project-level max concurrent research runs. |
| `researchDefaultTimeout` | `number` | `undefined` | Project-level default run timeout in milliseconds. |

View File

@@ -58,6 +58,7 @@ import { SourceControlGlobalSection } from "./settings/sections/SourceControlGlo
import { AgentPermissionsSection } from "./settings/sections/AgentPermissionsSection";
import { MemorySection } from "./settings/sections/MemorySection";
import { ResearchProjectSection } from "./settings/sections/ResearchProjectSection";
import { VoiceInputSection } from "./settings/sections/VoiceInputSection";
import { ProjectMcpSection } from "./settings/sections/ProjectMcpSection";
import { BackupsSection } from "./settings/sections/BackupsSection";
import { ConfigurationVersionsSection } from "./settings/sections/ConfigurationVersionsSection";
@@ -313,7 +314,8 @@ const RUNTIME_PLUGIN_SECTION_IDS: ReadonlyMap<string, string> = new Map([
const RUNTIME_SETTINGS_SECTION_IDS = new Set(RUNTIME_PLUGIN_SECTION_IDS.values());
const ADVANCED_SETTINGS_SECTION_IDS = new Set([
/* FNXC:VoiceInput 2026-07-28-12:00: Voice Input is an opt-in end-user feature, so it stays visible in Basic Settings rather than joining this advanced-only set. */
export const ADVANCED_SETTINGS_SECTION_IDS = new Set([
"node-sync",
"global-mcp",
"cli-agents",
@@ -631,6 +633,7 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
{ id: "memory", label: "Memory", labelKey: "settings.nav.memory", scope: "project", searchableText: ["memory backend", "Dreams", "long-term memory", "qmd", "memory file", "retrieval"] },
{ id: "research-global", label: "Research · Global", labelKey: "settings.nav.researchGlobal", scope: "global", searchableText: ["research providers", "external search providers", "fetch limits", "global research defaults", "citations"] },
{ id: "research-project", label: "Research · Project", labelKey: "settings.nav.researchProject", scope: "project", searchableText: ["project research", "research runs", "citations", "search limits", "fetch synthesis"] },
{ id: "voice-input", label: "Voice Input", labelKey: "settings.nav.voiceInput", scope: "project", searchableText: ["voice", "dictation", "microphone", "speech to text", "parakeet", "transcription"] },
{ id: "__automation_header", label: "Automation", labelKey: "settings.nav.automationHeader", scope: undefined, isGroupHeader: true },
/*
@@ -4421,6 +4424,8 @@ export function SettingsModal({
researchLimitError={researchLimitError}
/>
);
case "voice-input":
return <VoiceInputSection form={form} setForm={setForm} />;
case "cli-binary":
return <CliBinarySection />;
case "experimental":

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { ADVANCED_SETTINGS_SECTION_IDS } from "../../SettingsModal";
/**
* The Voice Input nav entry must remain a Basic-mode setting. The modal's
* visibility filter is driven solely by ADVANCED_SETTINGS_SECTION_IDS, while
* this source-level contract also protects the matching render-switch case.
*/
describe("Voice Input SettingsModal visibility", () => {
it("keeps Voice Input visible outside Advanced settings and wires its render case", () => {
expect(ADVANCED_SETTINGS_SECTION_IDS.has("voice-input")).toBe(false);
const modalSource = readFileSync(resolve(__dirname, "../../SettingsModal.tsx"), "utf8");
expect(modalSource).toContain('id: "voice-input", label: "Voice Input"');
expect(modalSource).toContain('case "voice-input":');
expect(modalSource).toContain("<VoiceInputSection form={form} setForm={setForm} />");
});
});

View File

@@ -0,0 +1,137 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { Settings } from "@fusion/core";
import { VoiceInputSection, VOICE_STATUS_POLL_INTERVAL_MS } from "../sections/VoiceInputSection";
import type { SettingsFormState } from "../sections/context";
import { settingsSearchEntriesForSection } from "../search/entries";
import { rankSettingsSearchResults } from "../search/match";
const response = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
const available = (status: string, extras: Record<string, unknown> = {}) => ({ model: { status, ...extras }, runtime: { status: "available" } });
function renderSection(status: unknown, formOverrides: Partial<Settings> = {}) {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(response(status)));
vi.stubGlobal("fetch", fetchMock);
let form = { ...formOverrides } as SettingsFormState;
const setForm = vi.fn((updater: SettingsFormState | ((previous: SettingsFormState) => SettingsFormState)) => { form = typeof updater === "function" ? updater(form) : updater; });
const view = render(<VoiceInputSection form={form} setForm={setForm} />);
return { ...view, fetchMock, setForm, getForm: () => form };
}
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); });
describe("VoiceInputSection", () => {
it("persists the opt-in toggle through the Settings form", async () => {
const { setForm, getForm } = renderSection(available("not-installed"));
await screen.findByText("Not installed");
fireEvent.click(screen.getByLabelText("Enable voice input"));
expect(setForm).toHaveBeenCalledOnce();
expect(getForm().voiceInput?.enabled).toBe(true);
});
it.each([
["not-installed", "Download", undefined],
["installed", "Remove", undefined],
["error", "Download", "network"],
])("renders %s model affordances", async (modelStatus, action, errorReason) => {
renderSection(available(modelStatus, errorReason ? { errorReason } : {}));
await screen.findByTestId("voice-input-model-status");
expect(screen.getByRole("button", { name: action })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: action === "Download" ? "Remove" : "Download" })).not.toBeInTheDocument();
});
it("renders downloading progress without action shells", async () => {
renderSection(available("downloading", { progress: 0.6 }));
expect(await screen.findByText("Downloading: 60%")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Download|Remove/ })).not.toBeInTheDocument();
});
it("fails closed for unavailable runtime without rewriting a persisted preference", async () => {
const { setForm } = renderSection({ model: { status: "not-installed" }, runtime: { status: "unavailable" } }, { voiceInput: { enabled: true } });
const toggle = await screen.findByLabelText("Enable voice input");
expect(toggle).toBeDisabled();
expect(toggle).not.toBeChecked();
expect(toggle.closest("div[data-effective-enabled]")).toHaveAttribute("data-effective-enabled", "false");
expect(screen.getByTestId("voice-input-runtime-unavailable")).toHaveTextContent("saved preference remains on");
expect(setForm).not.toHaveBeenCalled();
});
it.each([undefined, { nonsense: true }])("fails closed when status cannot be parsed", async (body) => {
const { setForm } = renderSection(body);
const toggle = await screen.findByLabelText("Enable voice input");
expect(toggle).toBeDisabled();
expect(toggle.closest("div[data-effective-enabled]")).toHaveAttribute("data-effective-enabled", "false");
expect(setForm).not.toHaveBeenCalled();
expect(screen.getByTestId("voice-input-status-unavailable")).toHaveTextContent("status could not be determined");
expect(screen.queryByRole("button", { name: /Download|Remove/ })).not.toBeInTheDocument();
});
it("fails closed when the status request rejects", async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error("network unavailable"));
vi.stubGlobal("fetch", fetchMock);
const setForm = vi.fn();
render(<VoiceInputSection form={{ voiceInput: { enabled: true } } as SettingsFormState} setForm={setForm} />);
const toggle = await screen.findByLabelText("Enable voice input");
expect(toggle).toBeDisabled();
expect(toggle.closest("div[data-effective-enabled]")).toHaveAttribute("data-effective-enabled", "false");
expect(setForm).not.toHaveBeenCalled();
expect(screen.getByTestId("voice-input-status-unavailable")).toHaveTextContent("status could not be determined");
expect(screen.queryByRole("button", { name: /Download|Remove/ })).not.toBeInTheDocument();
});
it("renders an indeterminate download without action shells", async () => {
renderSection(available("downloading"));
expect(await screen.findByText("Downloading model…")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Download|Remove/ })).not.toBeInTheDocument();
});
it("polls active downloads and stops after the installed transition", async () => {
vi.useFakeTimers();
const fetchMock = vi.fn()
.mockResolvedValueOnce(response(available("downloading", { progress: 0.1 })))
.mockResolvedValueOnce(response(available("downloading", { progress: 0.6 })))
.mockResolvedValueOnce(response(available("installed")));
vi.stubGlobal("fetch", fetchMock);
render(<VoiceInputSection form={{} as SettingsFormState} setForm={vi.fn()} />);
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
expect(screen.getByText("Downloading: 10%")).toBeInTheDocument();
await act(async () => { await vi.advanceTimersByTimeAsync(VOICE_STATUS_POLL_INTERVAL_MS); });
expect(screen.getByText("Downloading: 60%")).toBeInTheDocument();
await act(async () => { await vi.advanceTimersByTimeAsync(VOICE_STATUS_POLL_INTERVAL_MS); });
expect(screen.getByRole("button", { name: "Remove" })).toBeInTheDocument();
await act(async () => { await vi.advanceTimersByTimeAsync(VOICE_STATUS_POLL_INTERVAL_MS * 2); });
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("keeps voice and model terms reachable through the settings search index", () => {
for (const query of ["voice", "dictation", "microphone", "parakeet", "speech to text"]) {
expect(rankSettingsSearchResults(settingsSearchEntriesForSection("voice-input"), query, (_key, fallback) => fallback).some((entry) => entry.sectionId === "voice-input")).toBe(true);
}
});
it("calls model management endpoints from SettingsFieldRow control slots", async () => {
const { fetchMock, unmount } = renderSection(available("not-installed"));
await screen.findByRole("button", { name: "Download" });
fireEvent.click(screen.getByRole("button", { name: "Download" }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/voice/model/download", expect.objectContaining({ method: "POST" })));
expect(screen.getByTestId("voice-input-model-actions").closest(".settings-field-row")).not.toBeNull();
unmount();
const installed = renderSection(available("installed"));
await screen.findByRole("button", { name: "Remove" });
fireEvent.click(screen.getByRole("button", { name: "Remove" }));
await waitFor(() => expect(installed.fetchMock).toHaveBeenCalledWith("/api/voice/model", expect.objectContaining({ method: "DELETE" })));
});
it("cleans up download polling after unmount", async () => {
vi.useFakeTimers();
const fetchMock = vi.fn().mockResolvedValue(response(available("downloading", { progress: 0.1 })));
vi.stubGlobal("fetch", fetchMock);
const view = render(<VoiceInputSection form={{} as SettingsFormState} setForm={vi.fn()} />);
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
expect(fetchMock).toHaveBeenCalledTimes(1);
view.unmount();
await act(async () => { await vi.advanceTimersByTimeAsync(VOICE_STATUS_POLL_INTERVAL_MS * 3); });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -42,6 +42,7 @@ const EXPECTED_KEY_OWNING_SECTIONS: Record<string, "global" | "project"> = {
"agent-permissions": "project",
backups: "project",
"research-project": "project",
"voice-input": "project",
"project-models": "project",
};

View File

@@ -73,6 +73,7 @@ const SECTION_FILE_TO_ID: Record<string, string> = {
"SourceControlGlobalSection.tsx": "source-control-global",
"SourceControlSection.tsx": "source-control",
"WorktreesSection.tsx": "worktrees",
"VoiceInputSection.tsx": "voice-input",
};
describe("settings search index", () => {

View File

@@ -30,6 +30,7 @@ import { schedulingSearchEntries } from "../sections/SchedulingSection.search";
import { sourceControlGlobalSearchEntries } from "../sections/SourceControlGlobalSection.search";
import { sourceControlSearchEntries } from "../sections/SourceControlSection.search";
import { worktreesSearchEntries } from "../sections/WorktreesSection.search";
import { voiceInputSearchEntries } from "../sections/VoiceInputSection.search";
/**
* Flat index of every searchable setting. Order is not significant — results
@@ -59,6 +60,7 @@ export const SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] = [
...sourceControlGlobalSearchEntries,
...sourceControlSearchEntries,
...worktreesSearchEntries,
...voiceInputSearchEntries,
];
/** Entries owned by one section id. */

View File

@@ -178,6 +178,8 @@ const PROJECT_SECTION_KEYS: Record<string, readonly string[]> = {
"memoryBackupScope",
],
"research-project": ["researchSettings"],
/* FNXC:VoiceInput 2026-07-28-12:00: Voice input is a project preference; reset restores the inherited opt-in default without affecting the local model lifecycle. */
"voice-input": ["voiceInput"],
"project-models": [
"autoSelectModelPreset",
"autoSummarizeTitles",

View File

@@ -0,0 +1,48 @@
/*
FNXC:VoiceInput 2026-07-28-12:00:
Voice model controls use the existing SettingsFieldRow slots. These styles only
arrange status and standard buttons within those slots, preserving shared settings
spacing and responsive touch targets without creating a parallel panel.
*/
.voice-input-section {
display: grid;
gap: var(--space-xs);
}
.voice-input-section__message {
margin: 0;
color: var(--color-warning);
font-size: var(--font-size-xs);
}
.voice-input-section__status {
color: var(--text-muted);
font-size: var(--font-size-sm);
}
.voice-input-section__status--installed {
color: var(--color-success);
}
.voice-input-section__status--error,
.voice-input-section__status--unknown {
color: var(--color-error);
}
.voice-input-section__status--downloading,
.voice-input-section__status--queued {
color: var(--color-warning);
}
.voice-input-section__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
@media (max-width: 768px) {
.voice-input-section__actions .btn {
inline-size: 100%;
justify-content: center;
}
}

View File

@@ -0,0 +1,14 @@
import type { SettingsSearchEntry } from "../search/types";
/** Voice Input's one persisted descriptor row, kept adjacent to its section. */
export const voiceInputSearchEntries: SettingsSearchEntry[] = [
{
sectionId: "voice-input",
key: "voiceInput.enabled",
labelKey: "settings.voiceInput.enable",
labelFallback: "Enable voice input",
helpKey: "settings.voiceInput.enableHelp",
helpFallback: "Default: off. Voice dictation uses the operator-managed Parakeet v3 model.",
keywords: ["voice", "dictation", "microphone", "speech to text", "parakeet", "transcription"],
},
];

View File

@@ -0,0 +1,128 @@
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;