import { Show, createEffect, createMemo, createSignal, type Component } from "solid-js" import { Mic, Volume2 } from "lucide-solid" import { useConfig, type SpeechSettings } from "../../stores/preferences" import { useI18n } from "../../lib/i18n" import { loadSpeechCapabilities, speechCapabilities, speechCapabilitiesError, speechCapabilitiesLoading } from "../../stores/speech" import { getLogger } from "../../lib/logger" const log = getLogger("actions") type DraftFields = { apiKey: string baseUrl: string sttModel: string ttsModel: string ttsVoice: string } function createDraftFields(speech: SpeechSettings): DraftFields { return { apiKey: "", baseUrl: speech.baseUrl ?? "", sttModel: speech.sttModel, ttsModel: speech.ttsModel, ttsVoice: speech.ttsVoice, } } function isDraftEqual(a: DraftFields, b: DraftFields): boolean { return a.apiKey === b.apiKey && a.baseUrl === b.baseUrl && a.sttModel === b.sttModel && a.ttsModel === b.ttsModel && a.ttsVoice === b.ttsVoice } export const SpeechSettingsCard: Component = () => { const { t } = useI18n() const { serverSettings, updateSpeechSettings } = useConfig() const initialDrafts = createDraftFields(serverSettings().speech) const [isSaving, setIsSaving] = createSignal(false) const [saveStatus, setSaveStatus] = createSignal<"idle" | "saved" | "error">("saved") const [drafts, setDrafts] = createSignal(initialDrafts) const [apiKeyTouched, setApiKeyTouched] = createSignal(false) const [clearStoredApiKey, setClearStoredApiKey] = createSignal(false) createEffect(() => { const speech = serverSettings().speech const nextDrafts = createDraftFields(speech) if (!isSaving() && !isDirty()) { if (!isDraftEqual(drafts(), nextDrafts)) { setDrafts(nextDrafts) } if (apiKeyTouched()) { setApiKeyTouched(false) } if (clearStoredApiKey()) { setClearStoredApiKey(false) } } }) createEffect(() => { void loadSpeechCapabilities() }) const capabilityLabel = () => { if (speechCapabilitiesLoading()) return t("settings.speech.status.loading") if (speechCapabilitiesError()) return t("settings.speech.status.error") return speechCapabilities()?.configured ? t("settings.speech.status.configured") : t("settings.speech.status.missing") } const updateDraft = (key: keyof DraftFields, value: string) => { setSaveStatus("idle") if (key === "apiKey") { setApiKeyTouched(true) setClearStoredApiKey(false) } setDrafts((current) => ({ ...current, [key]: value })) } const apiKeyDirty = createMemo(() => clearStoredApiKey() || drafts().apiKey.trim().length > 0) const isDirty = createMemo(() => { const speech = serverSettings().speech const current = drafts() return ( apiKeyDirty() || (current.baseUrl || "") !== (speech.baseUrl || "") || current.sttModel !== speech.sttModel || current.ttsModel !== speech.ttsModel || current.ttsVoice !== speech.ttsVoice ) }) const saveStatusLabel = () => { if (isSaving()) return t("settings.speech.save.saving") if (saveStatus() === "saved") return t("settings.speech.save.saved") if (saveStatus() === "error") return t("settings.speech.save.error") return t("settings.speech.save.unsaved") } async function handleSave() { if (!isDirty() || isSaving()) return const current = drafts() setIsSaving(true) setSaveStatus("idle") try { const trimmedApiKey = current.apiKey.trim() await updateSpeechSettings({ ...(clearStoredApiKey() ? { apiKey: null } : trimmedApiKey ? { apiKey: trimmedApiKey } : {}), baseUrl: current.baseUrl.trim() || undefined, sttModel: current.sttModel.trim() || undefined, ttsModel: current.ttsModel.trim() || undefined, ttsVoice: current.ttsVoice.trim() || undefined, }) await loadSpeechCapabilities(true) setDrafts({ apiKey: "", baseUrl: current.baseUrl.trim(), sttModel: current.sttModel.trim() || serverSettings().speech.sttModel, ttsModel: current.ttsModel.trim() || serverSettings().speech.ttsModel, ttsVoice: current.ttsVoice.trim() || serverSettings().speech.ttsVoice, }) setApiKeyTouched(false) setClearStoredApiKey(false) setSaveStatus("saved") } catch (error) { log.error("Failed to save speech settings", error) setSaveStatus("error") } finally { setIsSaving(false) } } return (

{t("settings.speech.title")}

{t("settings.speech.subtitle")}

{t("settings.scope.server")}
{t("settings.speech.provider.title")}
{t("settings.speech.provider.subtitle")}
{t("settings.speech.provider.openaiCompatible")} {capabilityLabel()} {saveStatusLabel()}
updateDraft("apiKey", value)} type="password" placeholder={serverSettings().speech.hasApiKey ? t("settings.speech.apiKey.placeholder") : undefined} />
{clearStoredApiKey() ? t("settings.speech.apiKey.clearPending") : t("settings.speech.apiKey.storedNote")}{" "}
updateDraft("baseUrl", value)} placeholder={t("settings.speech.baseUrl.placeholder")} /> updateDraft("sttModel", value)} /> updateDraft("ttsModel", value)} /> updateDraft("ttsVoice", value)} icon={} />
{t("settings.speech.help")}
) } const Field: Component<{ label: string caption: string value: string type?: string placeholder?: string onInput: (value: string) => void icon?: any }> = (props) => { return (
{props.label}
{props.caption}
{props.icon} props.onInput(event.currentTarget.value)} class="selector-input w-full" placeholder={props.placeholder} />
) } export default SpeechSettingsCard