diff --git a/packages/server/src/server/routes/settings.ts b/packages/server/src/server/routes/settings.ts index e5673275..1d6fe13a 100644 --- a/packages/server/src/server/routes/settings.ts +++ b/packages/server/src/server/routes/settings.ts @@ -3,6 +3,7 @@ import { z } from "zod" import { probeBinaryVersion } from "../../workspaces/runtime" import type { SettingsService } from "../../settings/service" import type { Logger } from "../../logger" +import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config" interface RouteDeps { settings: SettingsService @@ -20,10 +21,10 @@ function validateBinaryPath(binaryPath: string): { valid: boolean; version?: str export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) { // Full-document access - app.get("/api/storage/config", async () => deps.settings.getDoc("config")) + app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config"))) app.patch("/api/storage/config", async (request, reply) => { try { - return deps.settings.mergePatchDoc("config", request.body ?? {}) + return sanitizeConfigDoc(deps.settings.mergePatchDoc("config", request.body ?? {})) } catch (error) { reply.code(400) return { error: error instanceof Error ? error.message : "Invalid patch" } @@ -31,12 +32,15 @@ export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) { }) app.get<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request) => { - return deps.settings.getOwner("config", request.params.owner) + return sanitizeConfigOwner(request.params.owner, deps.settings.getOwner("config", request.params.owner)) }) app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => { try { - return deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}) + return sanitizeConfigOwner( + request.params.owner, + deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}), + ) } catch (error) { reply.code(400) return { error: error instanceof Error ? error.message : "Invalid patch" } diff --git a/packages/server/src/server/routes/speech.ts b/packages/server/src/server/routes/speech.ts index 3eab4ad6..3fdce9f9 100644 --- a/packages/server/src/server/routes/speech.ts +++ b/packages/server/src/server/routes/speech.ts @@ -19,6 +19,20 @@ const SynthesizeBodySchema = z.object({ format: z.enum(["mp3", "wav", "opus"]).optional(), }) +function getSpeechErrorStatus(error: unknown): number { + if (error instanceof z.ZodError) { + return 400 + } + if (error instanceof Error && /not configured/i.test(error.message)) { + return 503 + } + return 502 +} + +function getSpeechErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback +} + export function registerSpeechRoutes(app: FastifyInstance, deps: RouteDeps) { app.get("/api/speech/capabilities", async () => deps.speechService.getCapabilities()) @@ -28,8 +42,8 @@ export function registerSpeechRoutes(app: FastifyInstance, deps: RouteDeps) { return await deps.speechService.transcribe(body) } catch (error) { request.log.error({ err: error }, "Failed to transcribe audio") - reply.code(400) - return { error: error instanceof Error ? error.message : "Failed to transcribe audio" } + reply.code(getSpeechErrorStatus(error)) + return { error: getSpeechErrorMessage(error, "Failed to transcribe audio") } } }) @@ -39,8 +53,8 @@ export function registerSpeechRoutes(app: FastifyInstance, deps: RouteDeps) { return await deps.speechService.synthesize(body) } catch (error) { request.log.error({ err: error }, "Failed to synthesize audio") - reply.code(400) - return { error: error instanceof Error ? error.message : "Failed to synthesize audio" } + reply.code(getSpeechErrorStatus(error)) + return { error: getSpeechErrorMessage(error, "Failed to synthesize audio") } } }) } diff --git a/packages/server/src/settings/public-config.ts b/packages/server/src/settings/public-config.ts new file mode 100644 index 00000000..a79a0222 --- /dev/null +++ b/packages/server/src/settings/public-config.ts @@ -0,0 +1,40 @@ +import type { SettingsDoc } from "./yaml-doc-store" + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function sanitizeServerOwner(value: SettingsDoc): SettingsDoc { + const next: SettingsDoc = { ...value } + const speech = isPlainObject(next.speech) ? { ...next.speech } : null + + if (!speech) { + return next + } + + const rawApiKey = typeof speech.apiKey === "string" ? speech.apiKey.trim() : "" + if (rawApiKey) { + delete speech.apiKey + speech.hasApiKey = true + } else if (!("hasApiKey" in speech)) { + speech.hasApiKey = false + } + + next.speech = speech + return next +} + +export function sanitizeConfigOwner(owner: string, value: SettingsDoc): SettingsDoc { + if (owner !== "server") { + return value + } + return sanitizeServerOwner(value) +} + +export function sanitizeConfigDoc(value: SettingsDoc): SettingsDoc { + const next: SettingsDoc = { ...value } + if (isPlainObject(next.server)) { + next.server = sanitizeServerOwner(next.server) + } + return next +} diff --git a/packages/server/src/settings/service.ts b/packages/server/src/settings/service.ts index 02a18422..45924076 100644 --- a/packages/server/src/settings/service.ts +++ b/packages/server/src/settings/service.ts @@ -4,6 +4,7 @@ import type { ConfigLocation } from "../config/location" import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store" import { migrateSettingsLayout } from "./migrate" import type { WorkspaceEventPayload } from "../api-types" +import { sanitizeConfigOwner } from "./public-config" export type DocKind = "config" | "state" @@ -45,10 +46,11 @@ export class SettingsService { private publish(kind: DocKind, owner: string, value?: SettingsDoc) { if (!this.eventBus) return const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged" + const nextValue = value ?? this.getOwner(kind, owner) const payload: WorkspaceEventPayload = { type, owner, - value: value ?? this.getOwner(kind, owner), + value: kind === "config" ? sanitizeConfigOwner(owner, nextValue) : nextValue, } as any this.eventBus.publish(payload) } diff --git a/packages/ui/src/components/settings/speech-settings-card.tsx b/packages/ui/src/components/settings/speech-settings-card.tsx index 464fb4ba..09d83969 100644 --- a/packages/ui/src/components/settings/speech-settings-card.tsx +++ b/packages/ui/src/components/settings/speech-settings-card.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, type Component } from "solid-js" +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" @@ -17,7 +17,7 @@ type DraftFields = { function createDraftFields(speech: SpeechSettings): DraftFields { return { - apiKey: speech.apiKey ?? "", + apiKey: "", baseUrl: speech.baseUrl ?? "", sttModel: speech.sttModel, ttsModel: speech.ttsModel, @@ -36,6 +36,8 @@ export const SpeechSettingsCard: Component = () => { 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 @@ -44,6 +46,12 @@ export const SpeechSettingsCard: Component = () => { if (!isDraftEqual(drafts(), nextDrafts)) { setDrafts(nextDrafts) } + if (apiKeyTouched()) { + setApiKeyTouched(false) + } + if (clearStoredApiKey()) { + setClearStoredApiKey(false) + } } }) @@ -59,14 +67,20 @@ export const SpeechSettingsCard: Component = () => { 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 ( - (current.apiKey || "") !== (speech.apiKey || "") || + apiKeyDirty() || (current.baseUrl || "") !== (speech.baseUrl || "") || current.sttModel !== speech.sttModel || current.ttsModel !== speech.ttsModel || @@ -87,8 +101,9 @@ export const SpeechSettingsCard: Component = () => { setIsSaving(true) setSaveStatus("idle") try { + const trimmedApiKey = current.apiKey.trim() await updateSpeechSettings({ - apiKey: current.apiKey.trim() || undefined, + ...(clearStoredApiKey() ? { apiKey: null } : trimmedApiKey ? { apiKey: trimmedApiKey } : {}), baseUrl: current.baseUrl.trim() || undefined, sttModel: current.sttModel.trim() || undefined, ttsModel: current.ttsModel.trim() || undefined, @@ -96,12 +111,14 @@ export const SpeechSettingsCard: Component = () => { }) await loadSpeechCapabilities(true) setDrafts({ - apiKey: current.apiKey.trim(), + 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) @@ -151,7 +168,25 @@ export const SpeechSettingsCard: Component = () => { value={drafts().apiKey} onInput={(value) => 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")}{" "} + + + +
+
> & { + apiKey?: string | null +} + export interface UiSettings { showThinkingBlocks: boolean showKeyboardShortcutHints: boolean @@ -136,6 +141,7 @@ const defaultUiSettings: UiSettings = { const defaultSpeechSettings: SpeechSettings = { provider: "openai-compatible", + hasApiKey: false, sttModel: "gpt-4o-mini-transcribe", ttsModel: "gpt-4o-mini-tts", ttsVoice: "alloy", @@ -183,6 +189,7 @@ function normalizeSpeechSettings(input?: Partial | null): Speech return { provider: sanitized.provider === "openai-compatible" ? sanitized.provider : defaultSpeechSettings.provider, apiKey: typeof sanitized.apiKey === "string" && sanitized.apiKey.trim() ? sanitized.apiKey.trim() : undefined, + hasApiKey: sanitized.hasApiKey === true || (typeof sanitized.apiKey === "string" && sanitized.apiKey.trim().length > 0), baseUrl: typeof sanitized.baseUrl === "string" && sanitized.baseUrl.trim() ? sanitized.baseUrl.trim() : undefined, sttModel: typeof sanitized.sttModel === "string" && sanitized.sttModel.trim() @@ -388,10 +395,21 @@ function updateLastUsedBinary(path: string): void { void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to update binary list", error)) } -async function updateSpeechSettings(updates: Partial): Promise { - const next = normalizeSpeechSettings({ ...serverSettings().speech, ...updates }) +async function updateSpeechSettings(updates: SpeechSettingsUpdate): Promise { + const apiKeyPatch = updates.apiKey + const { apiKey: _apiKey, ...restUpdates } = updates + const next = normalizeSpeechSettings({ + ...serverSettings().speech, + ...restUpdates, + ...(apiKeyPatch === null ? {} : { apiKey: apiKeyPatch }), + }) + const { hasApiKey: _hasApiKey, ...persistedSpeech } = next + const patch = { + ...persistedSpeech, + ...(apiKeyPatch === null ? { apiKey: null } : {}), + } try { - await patchConfigOwner("server", { speech: next }) + await patchConfigOwner("server", { speech: patch }) } catch (error) { log.error("Failed to update speech settings", error) throw error