fix(speech): keep provider secrets server-side
This commit is contained in:
@@ -3,6 +3,7 @@ import { z } from "zod"
|
|||||||
import { probeBinaryVersion } from "../../workspaces/runtime"
|
import { probeBinaryVersion } from "../../workspaces/runtime"
|
||||||
import type { SettingsService } from "../../settings/service"
|
import type { SettingsService } from "../../settings/service"
|
||||||
import type { Logger } from "../../logger"
|
import type { Logger } from "../../logger"
|
||||||
|
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
settings: SettingsService
|
settings: SettingsService
|
||||||
@@ -20,10 +21,10 @@ function validateBinaryPath(binaryPath: string): { valid: boolean; version?: str
|
|||||||
|
|
||||||
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
|
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
// Full-document access
|
// 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) => {
|
app.patch("/api/storage/config", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
return deps.settings.mergePatchDoc("config", request.body ?? {})
|
return sanitizeConfigDoc(deps.settings.mergePatchDoc("config", request.body ?? {}))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reply.code(400)
|
reply.code(400)
|
||||||
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
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) => {
|
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) => {
|
app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
reply.code(400)
|
reply.code(400)
|
||||||
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ const SynthesizeBodySchema = z.object({
|
|||||||
format: z.enum(["mp3", "wav", "opus"]).optional(),
|
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) {
|
export function registerSpeechRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
app.get("/api/speech/capabilities", async () => deps.speechService.getCapabilities())
|
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)
|
return await deps.speechService.transcribe(body)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
request.log.error({ err: error }, "Failed to transcribe audio")
|
request.log.error({ err: error }, "Failed to transcribe audio")
|
||||||
reply.code(400)
|
reply.code(getSpeechErrorStatus(error))
|
||||||
return { error: error instanceof Error ? error.message : "Failed to transcribe audio" }
|
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)
|
return await deps.speechService.synthesize(body)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
request.log.error({ err: error }, "Failed to synthesize audio")
|
request.log.error({ err: error }, "Failed to synthesize audio")
|
||||||
reply.code(400)
|
reply.code(getSpeechErrorStatus(error))
|
||||||
return { error: error instanceof Error ? error.message : "Failed to synthesize audio" }
|
return { error: getSpeechErrorMessage(error, "Failed to synthesize audio") }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
40
packages/server/src/settings/public-config.ts
Normal file
40
packages/server/src/settings/public-config.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { SettingsDoc } from "./yaml-doc-store"
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { ConfigLocation } from "../config/location"
|
|||||||
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
||||||
import { migrateSettingsLayout } from "./migrate"
|
import { migrateSettingsLayout } from "./migrate"
|
||||||
import type { WorkspaceEventPayload } from "../api-types"
|
import type { WorkspaceEventPayload } from "../api-types"
|
||||||
|
import { sanitizeConfigOwner } from "./public-config"
|
||||||
|
|
||||||
export type DocKind = "config" | "state"
|
export type DocKind = "config" | "state"
|
||||||
|
|
||||||
@@ -45,10 +46,11 @@ export class SettingsService {
|
|||||||
private publish(kind: DocKind, owner: string, value?: SettingsDoc) {
|
private publish(kind: DocKind, owner: string, value?: SettingsDoc) {
|
||||||
if (!this.eventBus) return
|
if (!this.eventBus) return
|
||||||
const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged"
|
const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged"
|
||||||
|
const nextValue = value ?? this.getOwner(kind, owner)
|
||||||
const payload: WorkspaceEventPayload = {
|
const payload: WorkspaceEventPayload = {
|
||||||
type,
|
type,
|
||||||
owner,
|
owner,
|
||||||
value: value ?? this.getOwner(kind, owner),
|
value: kind === "config" ? sanitizeConfigOwner(owner, nextValue) : nextValue,
|
||||||
} as any
|
} as any
|
||||||
this.eventBus.publish(payload)
|
this.eventBus.publish(payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Mic, Volume2 } from "lucide-solid"
|
||||||
import { useConfig, type SpeechSettings } from "../../stores/preferences"
|
import { useConfig, type SpeechSettings } from "../../stores/preferences"
|
||||||
import { useI18n } from "../../lib/i18n"
|
import { useI18n } from "../../lib/i18n"
|
||||||
@@ -17,7 +17,7 @@ type DraftFields = {
|
|||||||
|
|
||||||
function createDraftFields(speech: SpeechSettings): DraftFields {
|
function createDraftFields(speech: SpeechSettings): DraftFields {
|
||||||
return {
|
return {
|
||||||
apiKey: speech.apiKey ?? "",
|
apiKey: "",
|
||||||
baseUrl: speech.baseUrl ?? "",
|
baseUrl: speech.baseUrl ?? "",
|
||||||
sttModel: speech.sttModel,
|
sttModel: speech.sttModel,
|
||||||
ttsModel: speech.ttsModel,
|
ttsModel: speech.ttsModel,
|
||||||
@@ -36,6 +36,8 @@ export const SpeechSettingsCard: Component = () => {
|
|||||||
const [isSaving, setIsSaving] = createSignal(false)
|
const [isSaving, setIsSaving] = createSignal(false)
|
||||||
const [saveStatus, setSaveStatus] = createSignal<"idle" | "saved" | "error">("saved")
|
const [saveStatus, setSaveStatus] = createSignal<"idle" | "saved" | "error">("saved")
|
||||||
const [drafts, setDrafts] = createSignal<DraftFields>(initialDrafts)
|
const [drafts, setDrafts] = createSignal<DraftFields>(initialDrafts)
|
||||||
|
const [apiKeyTouched, setApiKeyTouched] = createSignal(false)
|
||||||
|
const [clearStoredApiKey, setClearStoredApiKey] = createSignal(false)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const speech = serverSettings().speech
|
const speech = serverSettings().speech
|
||||||
@@ -44,6 +46,12 @@ export const SpeechSettingsCard: Component = () => {
|
|||||||
if (!isDraftEqual(drafts(), nextDrafts)) {
|
if (!isDraftEqual(drafts(), nextDrafts)) {
|
||||||
setDrafts(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) => {
|
const updateDraft = (key: keyof DraftFields, value: string) => {
|
||||||
setSaveStatus("idle")
|
setSaveStatus("idle")
|
||||||
|
if (key === "apiKey") {
|
||||||
|
setApiKeyTouched(true)
|
||||||
|
setClearStoredApiKey(false)
|
||||||
|
}
|
||||||
setDrafts((current) => ({ ...current, [key]: value }))
|
setDrafts((current) => ({ ...current, [key]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const apiKeyDirty = createMemo(() => clearStoredApiKey() || drafts().apiKey.trim().length > 0)
|
||||||
|
|
||||||
const isDirty = createMemo(() => {
|
const isDirty = createMemo(() => {
|
||||||
const speech = serverSettings().speech
|
const speech = serverSettings().speech
|
||||||
const current = drafts()
|
const current = drafts()
|
||||||
return (
|
return (
|
||||||
(current.apiKey || "") !== (speech.apiKey || "") ||
|
apiKeyDirty() ||
|
||||||
(current.baseUrl || "") !== (speech.baseUrl || "") ||
|
(current.baseUrl || "") !== (speech.baseUrl || "") ||
|
||||||
current.sttModel !== speech.sttModel ||
|
current.sttModel !== speech.sttModel ||
|
||||||
current.ttsModel !== speech.ttsModel ||
|
current.ttsModel !== speech.ttsModel ||
|
||||||
@@ -87,8 +101,9 @@ export const SpeechSettingsCard: Component = () => {
|
|||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
setSaveStatus("idle")
|
setSaveStatus("idle")
|
||||||
try {
|
try {
|
||||||
|
const trimmedApiKey = current.apiKey.trim()
|
||||||
await updateSpeechSettings({
|
await updateSpeechSettings({
|
||||||
apiKey: current.apiKey.trim() || undefined,
|
...(clearStoredApiKey() ? { apiKey: null } : trimmedApiKey ? { apiKey: trimmedApiKey } : {}),
|
||||||
baseUrl: current.baseUrl.trim() || undefined,
|
baseUrl: current.baseUrl.trim() || undefined,
|
||||||
sttModel: current.sttModel.trim() || undefined,
|
sttModel: current.sttModel.trim() || undefined,
|
||||||
ttsModel: current.ttsModel.trim() || undefined,
|
ttsModel: current.ttsModel.trim() || undefined,
|
||||||
@@ -96,12 +111,14 @@ export const SpeechSettingsCard: Component = () => {
|
|||||||
})
|
})
|
||||||
await loadSpeechCapabilities(true)
|
await loadSpeechCapabilities(true)
|
||||||
setDrafts({
|
setDrafts({
|
||||||
apiKey: current.apiKey.trim(),
|
apiKey: "",
|
||||||
baseUrl: current.baseUrl.trim(),
|
baseUrl: current.baseUrl.trim(),
|
||||||
sttModel: current.sttModel.trim() || serverSettings().speech.sttModel,
|
sttModel: current.sttModel.trim() || serverSettings().speech.sttModel,
|
||||||
ttsModel: current.ttsModel.trim() || serverSettings().speech.ttsModel,
|
ttsModel: current.ttsModel.trim() || serverSettings().speech.ttsModel,
|
||||||
ttsVoice: current.ttsVoice.trim() || serverSettings().speech.ttsVoice,
|
ttsVoice: current.ttsVoice.trim() || serverSettings().speech.ttsVoice,
|
||||||
})
|
})
|
||||||
|
setApiKeyTouched(false)
|
||||||
|
setClearStoredApiKey(false)
|
||||||
setSaveStatus("saved")
|
setSaveStatus("saved")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to save speech settings", error)
|
log.error("Failed to save speech settings", error)
|
||||||
@@ -151,7 +168,25 @@ export const SpeechSettingsCard: Component = () => {
|
|||||||
value={drafts().apiKey}
|
value={drafts().apiKey}
|
||||||
onInput={(value) => updateDraft("apiKey", value)}
|
onInput={(value) => updateDraft("apiKey", value)}
|
||||||
type="password"
|
type="password"
|
||||||
|
placeholder={serverSettings().speech.hasApiKey ? t("settings.speech.apiKey.placeholder") : undefined}
|
||||||
/>
|
/>
|
||||||
|
<Show when={serverSettings().speech.hasApiKey && !apiKeyTouched() && drafts().apiKey.length === 0}>
|
||||||
|
<div class="settings-inline-note">
|
||||||
|
{clearStoredApiKey() ? t("settings.speech.apiKey.clearPending") : t("settings.speech.apiKey.storedNote")}{" "}
|
||||||
|
<Show when={!clearStoredApiKey()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary w-auto whitespace-nowrap"
|
||||||
|
onClick={() => {
|
||||||
|
setClearStoredApiKey(true)
|
||||||
|
setSaveStatus("idle")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("settings.speech.apiKey.clearAction")}
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<Field
|
<Field
|
||||||
label={t("settings.speech.baseUrl.title")}
|
label={t("settings.speech.baseUrl.title")}
|
||||||
caption={t("settings.speech.baseUrl.subtitle")}
|
caption={t("settings.speech.baseUrl.subtitle")}
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ export const settingsMessages = {
|
|||||||
"settings.speech.status.error": "Speech service unavailable",
|
"settings.speech.status.error": "Speech service unavailable",
|
||||||
"settings.speech.apiKey.title": "API key",
|
"settings.speech.apiKey.title": "API key",
|
||||||
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
|
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
|
||||||
|
"settings.speech.apiKey.placeholder": "Enter a new API key",
|
||||||
|
"settings.speech.apiKey.storedNote": "A saved API key is hidden. Enter a new value to replace it, or leave the field blank to keep it.",
|
||||||
|
"settings.speech.apiKey.clearAction": "Clear saved key",
|
||||||
|
"settings.speech.apiKey.clearPending": "The saved API key will be removed when you save.",
|
||||||
"settings.speech.baseUrl.title": "Base URL",
|
"settings.speech.baseUrl.title": "Base URL",
|
||||||
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
|
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
|
||||||
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ export const settingsMessages = {
|
|||||||
"settings.speech.status.error": "Speech service unavailable",
|
"settings.speech.status.error": "Speech service unavailable",
|
||||||
"settings.speech.apiKey.title": "API key",
|
"settings.speech.apiKey.title": "API key",
|
||||||
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
|
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
|
||||||
|
"settings.speech.apiKey.placeholder": "Enter a new API key",
|
||||||
|
"settings.speech.apiKey.storedNote": "A saved API key is hidden. Enter a new value to replace it, or leave the field blank to keep it.",
|
||||||
|
"settings.speech.apiKey.clearAction": "Clear saved key",
|
||||||
|
"settings.speech.apiKey.clearPending": "The saved API key will be removed when you save.",
|
||||||
"settings.speech.baseUrl.title": "Base URL",
|
"settings.speech.baseUrl.title": "Base URL",
|
||||||
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
|
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
|
||||||
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ export const settingsMessages = {
|
|||||||
"settings.speech.status.error": "Speech service unavailable",
|
"settings.speech.status.error": "Speech service unavailable",
|
||||||
"settings.speech.apiKey.title": "API key",
|
"settings.speech.apiKey.title": "API key",
|
||||||
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
|
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
|
||||||
|
"settings.speech.apiKey.placeholder": "Enter a new API key",
|
||||||
|
"settings.speech.apiKey.storedNote": "A saved API key is hidden. Enter a new value to replace it, or leave the field blank to keep it.",
|
||||||
|
"settings.speech.apiKey.clearAction": "Clear saved key",
|
||||||
|
"settings.speech.apiKey.clearPending": "The saved API key will be removed when you save.",
|
||||||
"settings.speech.baseUrl.title": "Base URL",
|
"settings.speech.baseUrl.title": "Base URL",
|
||||||
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
|
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
|
||||||
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ export const settingsMessages = {
|
|||||||
"settings.speech.status.error": "Speech service unavailable",
|
"settings.speech.status.error": "Speech service unavailable",
|
||||||
"settings.speech.apiKey.title": "API key",
|
"settings.speech.apiKey.title": "API key",
|
||||||
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
|
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
|
||||||
|
"settings.speech.apiKey.placeholder": "Enter a new API key",
|
||||||
|
"settings.speech.apiKey.storedNote": "A saved API key is hidden. Enter a new value to replace it, or leave the field blank to keep it.",
|
||||||
|
"settings.speech.apiKey.clearAction": "Clear saved key",
|
||||||
|
"settings.speech.apiKey.clearPending": "The saved API key will be removed when you save.",
|
||||||
"settings.speech.baseUrl.title": "Base URL",
|
"settings.speech.baseUrl.title": "Base URL",
|
||||||
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
|
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
|
||||||
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ export const settingsMessages = {
|
|||||||
"settings.speech.status.error": "Speech service unavailable",
|
"settings.speech.status.error": "Speech service unavailable",
|
||||||
"settings.speech.apiKey.title": "API key",
|
"settings.speech.apiKey.title": "API key",
|
||||||
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
|
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
|
||||||
|
"settings.speech.apiKey.placeholder": "Enter a new API key",
|
||||||
|
"settings.speech.apiKey.storedNote": "A saved API key is hidden. Enter a new value to replace it, or leave the field blank to keep it.",
|
||||||
|
"settings.speech.apiKey.clearAction": "Clear saved key",
|
||||||
|
"settings.speech.apiKey.clearPending": "The saved API key will be removed when you save.",
|
||||||
"settings.speech.baseUrl.title": "Base URL",
|
"settings.speech.baseUrl.title": "Base URL",
|
||||||
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
|
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
|
||||||
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ export const settingsMessages = {
|
|||||||
"settings.speech.status.error": "Speech service unavailable",
|
"settings.speech.status.error": "Speech service unavailable",
|
||||||
"settings.speech.apiKey.title": "API key",
|
"settings.speech.apiKey.title": "API key",
|
||||||
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
|
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
|
||||||
|
"settings.speech.apiKey.placeholder": "Enter a new API key",
|
||||||
|
"settings.speech.apiKey.storedNote": "A saved API key is hidden. Enter a new value to replace it, or leave the field blank to keep it.",
|
||||||
|
"settings.speech.apiKey.clearAction": "Clear saved key",
|
||||||
|
"settings.speech.apiKey.clearPending": "The saved API key will be removed when you save.",
|
||||||
"settings.speech.baseUrl.title": "Base URL",
|
"settings.speech.baseUrl.title": "Base URL",
|
||||||
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
|
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
|
||||||
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
||||||
|
|||||||
@@ -33,12 +33,17 @@ export type SpeechProviderPreference = "openai-compatible"
|
|||||||
export interface SpeechSettings {
|
export interface SpeechSettings {
|
||||||
provider: SpeechProviderPreference
|
provider: SpeechProviderPreference
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
|
hasApiKey: boolean
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
sttModel: string
|
sttModel: string
|
||||||
ttsModel: string
|
ttsModel: string
|
||||||
ttsVoice: string
|
ttsVoice: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SpeechSettingsUpdate = Partial<Omit<SpeechSettings, "apiKey">> & {
|
||||||
|
apiKey?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface UiSettings {
|
export interface UiSettings {
|
||||||
showThinkingBlocks: boolean
|
showThinkingBlocks: boolean
|
||||||
showKeyboardShortcutHints: boolean
|
showKeyboardShortcutHints: boolean
|
||||||
@@ -136,6 +141,7 @@ const defaultUiSettings: UiSettings = {
|
|||||||
|
|
||||||
const defaultSpeechSettings: SpeechSettings = {
|
const defaultSpeechSettings: SpeechSettings = {
|
||||||
provider: "openai-compatible",
|
provider: "openai-compatible",
|
||||||
|
hasApiKey: false,
|
||||||
sttModel: "gpt-4o-mini-transcribe",
|
sttModel: "gpt-4o-mini-transcribe",
|
||||||
ttsModel: "gpt-4o-mini-tts",
|
ttsModel: "gpt-4o-mini-tts",
|
||||||
ttsVoice: "alloy",
|
ttsVoice: "alloy",
|
||||||
@@ -183,6 +189,7 @@ function normalizeSpeechSettings(input?: Partial<SpeechSettings> | null): Speech
|
|||||||
return {
|
return {
|
||||||
provider: sanitized.provider === "openai-compatible" ? sanitized.provider : defaultSpeechSettings.provider,
|
provider: sanitized.provider === "openai-compatible" ? sanitized.provider : defaultSpeechSettings.provider,
|
||||||
apiKey: typeof sanitized.apiKey === "string" && sanitized.apiKey.trim() ? sanitized.apiKey.trim() : undefined,
|
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,
|
baseUrl: typeof sanitized.baseUrl === "string" && sanitized.baseUrl.trim() ? sanitized.baseUrl.trim() : undefined,
|
||||||
sttModel:
|
sttModel:
|
||||||
typeof sanitized.sttModel === "string" && sanitized.sttModel.trim()
|
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))
|
void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to update binary list", error))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateSpeechSettings(updates: Partial<SpeechSettings>): Promise<void> {
|
async function updateSpeechSettings(updates: SpeechSettingsUpdate): Promise<void> {
|
||||||
const next = normalizeSpeechSettings({ ...serverSettings().speech, ...updates })
|
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 {
|
try {
|
||||||
await patchConfigOwner("server", { speech: next })
|
await patchConfigOwner("server", { speech: patch })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to update speech settings", error)
|
log.error("Failed to update speech settings", error)
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
Reference in New Issue
Block a user