From f58680aa6bb28282741885df848ecd69dc42a2ca Mon Sep 17 00:00:00 2001 From: alpha Date: Fri, 4 Sep 2026 15:17:53 -0400 Subject: [PATCH 1/2] feat(llm): remove the user-provided API key (BYOK) feature Deletes the "use my own API key" control-bar dialog and its entire supporting path, so the app always uses the platform's own default LLM (already what happened whenever a user had never turned BYOK on): - llm-group.tsx (the dialog itself - provider select, API key input, model picker, debounced validate-as-you-type) and its control-bar entry - main/ipc/llm.ts (llm:list-models / llm:validate handlers) and their preload/electron-api.d.ts bridge - LLMApi.validate()/listModels() (main/api/llm.ts) - the rest of the class (generate/upload endpoints) stays, those aren't BYOK-specific - LLMConfig/LLMModelInfo/LLMConfigValidationResult/LLMProvider from both renderer and main type files; SuggestionMode (unrelated to BYOK) stays - llmConf from Config/RuntimeConfig, and the `config` field the three generation services (live/action suggestion, summarize) sent on every request - the backend no longer accepts it at all Security cleanup: `llmConf` could hold a real provider API key in plaintext in the local electron-store file. Removing it from the type doesn't erase it from an existing install's disk - config.store.ts now actively scrubs a leftover `llmConf` key on first load after upgrade, the same way the app already handles other retired store keys. Companion backend PR removes the corresponding /llm/validate and /llm/models endpoints and the request-schema field. --- src/main/api/llm.ts | 17 - src/main/index.ts | 2 - src/main/ipc/llm.ts | 38 -- src/main/preload.cts | 6 - .../services/suggestion-action.service.ts | 1 - src/main/services/suggestion-live.service.ts | 1 - src/main/services/tools.service.ts | 1 - src/main/store/config.store.ts | 17 +- src/main/types/llm.ts | 48 --- .../components/custom/control-panel/index.tsx | 2 - .../custom/control-panel/llm-group.tsx | 356 ------------------ src/renderer/components/custom/titlebar.tsx | 4 +- src/renderer/pages/main/index.tsx | 2 +- src/renderer/types/config.ts | 3 - src/renderer/types/electron-api.d.ts | 9 - src/renderer/types/llm.ts | 37 -- 16 files changed, 14 insertions(+), 530 deletions(-) delete mode 100644 src/main/ipc/llm.ts delete mode 100644 src/renderer/components/custom/control-panel/llm-group.tsx diff --git a/src/main/api/llm.ts b/src/main/api/llm.ts index 60020c95..bffbd7fc 100644 --- a/src/main/api/llm.ts +++ b/src/main/api/llm.ts @@ -7,27 +7,10 @@ import { GenerateActionSuggestionRequest, GenerateLiveSuggestionRequest, GenerateSummarizeRequest, - LLMConfigValidationResult, - LLMModelInfo, - LLMRequest, } from '../types/llm.js'; import { ApiClient, ApiResponse } from './client.js'; export class LLMApi extends ApiClient { - /** - * Validate LLM Config - */ - async validate(request: LLMRequest): Promise> { - return this.post('/api/llm/validate', request); - } - - /** - * List Supported Models - */ - async listModels(): Promise> { - return this.get('/api/llm/models'); - } - /** * Generate Live Suggestions */ diff --git a/src/main/index.ts b/src/main/index.ts index 5f5a1bec..7e5112ae 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -14,7 +14,6 @@ import { registerAuthHandlers } from './ipc/auth.js'; import { registerAutoUpdaterHandlers } from './ipc/auto-updater.js'; import { registerConfigHandlers } from './ipc/config.js'; import { registerExternalHandlers } from './ipc/external.js'; -import { registerLLMHandlers } from './ipc/llm.js'; import { registerPaymentHandlers } from './ipc/payment.js'; import { registerPermissionHandlers } from './ipc/permissions.js'; import { registerActionSuggestionHandlers } from './ipc/suggestion-action.js'; @@ -206,7 +205,6 @@ app.whenReady().then(async () => { registerAuthHandlers(); registerAccountHandlers(); registerPaymentHandlers(); - registerLLMHandlers(); registerPermissionHandlers(); registerTranscriptHandlers(); registerLiveSuggestionHandlers(); diff --git a/src/main/ipc/llm.ts b/src/main/ipc/llm.ts deleted file mode 100644 index e0cd021c..00000000 --- a/src/main/ipc/llm.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ipcMain } from 'electron'; - -import { LLMApi } from '../api/llm.js'; -import { LLMConfig } from '../types/llm.js'; - -const llmApi = new LLMApi(); - -export function registerLLMHandlers(): void { - ipcMain.handle('llm:list-models', async () => { - try { - const response = await llmApi.listModels(); - if (response.error) { - return { success: false, error: response.error.message }; - } - return { success: true, data: response.data ?? [] }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to list models', - }; - } - }); - - ipcMain.handle('llm:validate', async (_event, config: LLMConfig | null) => { - try { - const response = await llmApi.validate({ config }); - if (response.error) { - return { success: false, error: response.error.message }; - } - return { success: true, data: response.data }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to validate llm config', - }; - } - }); -} diff --git a/src/main/preload.cts b/src/main/preload.cts index b9b320f1..c6696f96 100644 --- a/src/main/preload.cts +++ b/src/main/preload.cts @@ -89,12 +89,6 @@ const electronApi = { getCredits: () => ipcRenderer.invoke('payment:get-credits'), }, - llm: { - listModels: () => ipcRenderer.invoke('llm:list-models'), - validate: (config: Record | null) => - ipcRenderer.invoke('llm:validate', config), - }, - appState: { get: () => ipcRenderer.invoke('app:get-state'), update: (updates: Record) => ipcRenderer.invoke('app:update-state', updates), diff --git a/src/main/services/suggestion-action.service.ts b/src/main/services/suggestion-action.service.ts index 182bdce5..e03520d1 100644 --- a/src/main/services/suggestion-action.service.ts +++ b/src/main/services/suggestion-action.service.ts @@ -212,7 +212,6 @@ export class ActionSuggestionService { const interviewConfig = appStateService.getState().interviewConfig; const payload: GenerateActionSuggestionRequest = { - config: conf.llmConf, profile_data: interviewConfig.profileData, context: interviewConfig.context, transcripts: transcripts.slice(-TRANSCRIPT_UPLOAD_LIMIT), diff --git a/src/main/services/suggestion-live.service.ts b/src/main/services/suggestion-live.service.ts index aa23f553..cb012f39 100644 --- a/src/main/services/suggestion-live.service.ts +++ b/src/main/services/suggestion-live.service.ts @@ -119,7 +119,6 @@ class LiveSuggestionService { try { const interviewConfig = appStateService.getState().interviewConfig; const requestBody: GenerateLiveSuggestionRequest = { - config: conf.llmConf, profile_data: interviewConfig.profileData, context: interviewConfig.context, transcripts: transcripts.slice(-TRANSCRIPT_UPLOAD_LIMIT), diff --git a/src/main/services/tools.service.ts b/src/main/services/tools.service.ts index 42d9ca67..471fb4ba 100644 --- a/src/main/services/tools.service.ts +++ b/src/main/services/tools.service.ts @@ -37,7 +37,6 @@ class ToolsService { // Call the API to generate the summary text const conf = configStore.getConfig(); const response = await this.llmApi.generateSummary({ - config: conf.llmConf, username, transcripts, // The exported report is written in the interview's language too. A Spanish interview diff --git a/src/main/store/config.store.ts b/src/main/store/config.store.ts index 10c2ccc6..f60255f9 100644 --- a/src/main/store/config.store.ts +++ b/src/main/store/config.store.ts @@ -7,7 +7,6 @@ import ElectronStore from 'electron-store'; import { OPACITY_DEFAULT } from '../consts.js'; import { DEFAULT_LANGUAGE, Language, resolveLanguage } from '../types/language.js'; -import { LLMConfig } from '../types/llm.js'; // Runtime configuration (matches Config type in frontend) export interface RuntimeConfig { @@ -19,8 +18,6 @@ export interface RuntimeConfig { password: string; audioInputDeviceName: string; - llmConf: LLMConfig | null; - // panel auto-scroll preferences autoScrollLiveSuggestions: boolean; autoScrollActionSuggestions: boolean; @@ -54,8 +51,6 @@ const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { password: '', audioInputDeviceName: '', - llmConf: null, - // default autoscroll preferences are enabled autoScrollLiveSuggestions: true, autoScrollActionSuggestions: true, @@ -275,6 +270,18 @@ export const configStore = new ConfigStore(); } })(); // migration block +// One-time cleanup: `llmConf` backed the removed bring-your-own-API-key feature and could hold a +// real provider key in plaintext on disk. `RuntimeConfig` no longer declares it, but every read +// and write here spreads the raw stored object through - nothing strips a key TypeScript no +// longer knows about - so a leftover value would otherwise survive on an upgraded install forever. +(() => { + const raw = configStore.getStoredRuntime() as (StoredRuntime & { llmConf?: unknown }) | undefined; + if (raw && 'llmConf' in raw) { + delete raw.llmConf; + configStore.setStoredRuntime(raw); + } +})(); // llmConf scrub + // Read (but do not yet delete) any leftover local copy, so AccountService can migrate it // onto the account. Deleting here unconditionally would destroy the only copy whenever the // first launch after upgrade happens to be offline. diff --git a/src/main/types/llm.ts b/src/main/types/llm.ts index 8513e254..b8c82547 100644 --- a/src/main/types/llm.ts +++ b/src/main/types/llm.ts @@ -1,55 +1,7 @@ import { Transcript } from './app-state.js'; import { Language } from './language.js'; -export enum LLMProvider { - OPENAI = 'openai', - ANTHROPIC = 'anthropic', - GROQ = 'groq', - GOOGLE = 'google', -} - -export enum LLMModality { - TEXT_INPUT = 'text_input', - IMAGE_INPUT = 'image_input', - TEXT_OUTPUT = 'text_output', - IMAGE_OUTPUT = 'image_output', - AUDIO_INPUT = 'audio_input', - AUDIO_OUTPUT = 'audio_output', -} - -export interface LLMModelInfo { - id: string; - provider: LLMProvider; - name: string; - description: string; - modalities: LLMModality[]; - vision_capable: boolean; - context_window: number; - max_output_tokens: number; - pricing_input: number; - pricing_output: number; - supports_streaming: boolean; - supports_function_calling: boolean; - supports_json_mode: boolean; - release_date: string | null; -} - -export interface LLMConfig { - provider: LLMProvider; - apikey: string; - model: string; -} - -export interface LLMConfigValidationResult { - provider_ok: boolean; - apikey_ok: boolean; - model_ok: boolean; - error: string; -} - export interface LLMRequest { - config: LLMConfig | null; - /** * Interview language. Carried on the shared base so the three request kinds cannot drift, and * defaulted server-side, so omitting it against an older deployment still means English. diff --git a/src/renderer/components/custom/control-panel/index.tsx b/src/renderer/components/custom/control-panel/index.tsx index cea314b1..527ed496 100644 --- a/src/renderer/components/custom/control-panel/index.tsx +++ b/src/renderer/components/custom/control-panel/index.tsx @@ -18,7 +18,6 @@ import PermissionGateDialog from '../permission-gate-dialog'; import ZoomControl from '../zoom-control'; import { AudioGroup } from './audio-group'; import { LanguageGroup } from './language-group'; -import { LLMGroup } from './llm-group'; import { MainGroup } from './main-group'; import { ProfessionalModeGroup } from './professional-mode-group'; import { ToolsGroup } from './tools-group'; @@ -215,7 +214,6 @@ export default function ControlPanel() { getDisabled={getDisabled} /> - {/* What the interview produces: how suggestions read, and what to do with the session */} diff --git a/src/renderer/components/custom/control-panel/llm-group.tsx b/src/renderer/components/custom/control-panel/llm-group.tsx deleted file mode 100644 index cd52a6f1..00000000 --- a/src/renderer/components/custom/control-panel/llm-group.tsx +++ /dev/null @@ -1,356 +0,0 @@ -import { Brain } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; -import { toast } from 'sonner'; - -import { Button } from '@/components/ui/button'; -import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { useAppState } from '@/hooks/use-app-state'; -import { useConfigStore } from '@/hooks/use-config-store'; -import { cn, getElectron } from '@/lib/utils'; -import { RunningState } from '@/types/app-state'; -import type { LLMConfigValidationResult, LLMModelInfo } from '@/types/llm'; -import { LLMProvider } from '@/types/llm'; - -import { BAR_GHOST, BAR_ICON_BUTTON } from './bar'; - -interface LLMGroupProps { - getDisabled: (state: RunningState, disableOnRunning?: boolean) => boolean; -} - -const PROVIDERS = Object.values(LLMProvider); - -const PROVIDER_LABELS: Record = { - [LLMProvider.OPENAI]: 'OpenAI', - [LLMProvider.ANTHROPIC]: 'Anthropic', - [LLMProvider.GROQ]: 'Groq', - [LLMProvider.GOOGLE]: 'Google', -}; - -export function LLMGroup({ getDisabled }: LLMGroupProps) { - const [open, setOpen] = useState(false); - const [useOwnApiKey, setUseOwnApiKey] = useState(false); - const [provider, setProvider] = useState(LLMProvider.OPENAI); - const [apiKey, setApiKey] = useState(''); - const [model, setModel] = useState(''); - const [models, setModels] = useState([]); - const [isLoadingModels, setIsLoadingModels] = useState(false); - const [validation, setValidation] = useState(null); - const [validationMessage, setValidationMessage] = useState('Enter API key to validate'); - const [isValidating, setIsValidating] = useState(false); - const [isSaving, setIsSaving] = useState(false); - - const { runningState } = useAppState(); - const { config, updateConfig } = useConfigStore(); - - const availableModels = useMemo( - () => models.filter((item) => item.provider === provider).map((item) => item.id), - [models, provider] - ); - const providerValid = PROVIDERS.includes(provider); - const modelValid = availableModels.includes(model); - const apikeyValid = validation?.apikey_ok === true; - const formValid = providerValid && modelValid && apikeyValid; - const canSave = !useOwnApiKey || formValid; - - useEffect(() => { - if (!open) return; - const electron = getElectron(); - if (!electron?.llm) return; - - setIsLoadingModels(true); - electron.llm - .listModels() - .then((response) => { - if (!response.success) { - toast.error(response.error ?? 'Failed to fetch LLM models'); - return; - } - setModels(response.data ?? []); - }) - .catch((error) => { - console.error('Failed to fetch llm models:', error); - toast.error('Failed to fetch LLM models'); - }) - .finally(() => setIsLoadingModels(false)); - }, [open]); - - useEffect(() => { - if (!open) return; - - const conf = config?.llmConf; - const nextProvider = conf?.provider ?? LLMProvider.OPENAI; - setUseOwnApiKey(conf !== null && conf !== undefined); - setProvider(nextProvider); - setApiKey(conf?.apikey ?? ''); - setModel(conf?.model ?? ''); - setValidation(conf ? { provider_ok: true, apikey_ok: true, model_ok: true, error: '' } : null); - setValidationMessage(conf ? 'API key validated' : 'Enter API key to validate'); - }, [open, config?.llmConf]); - - useEffect(() => { - // Keep persisted model while models are still loading on first open. - if (!availableModels.length) return; - if (!availableModels.includes(model)) { - setModel(availableModels[0]); - } - }, [availableModels, model]); - - const handleProviderChange = (value: string) => { - const nextProvider = value as LLMProvider; - setProvider(nextProvider); - setValidation(null); - setValidationMessage('Provider changed. Re-validate API key.'); - }; - - useEffect(() => { - if (!open || !useOwnApiKey) return; - const trimmed = apiKey.trim(); - if (!trimmed || !provider || !model) { - setValidation(null); - setValidationMessage('Provider, API key and model are required'); - return; - } - - const electron = getElectron(); - if (!electron?.llm) return; - - const timer = setTimeout(async () => { - setIsValidating(true); - try { - const result = await electron.llm.validate({ - provider, - apikey: trimmed, - model, - }); - if (!result.success || !result.data) { - setValidation(null); - setValidationMessage(result.error ?? 'Validation failed'); - return; - } - - setValidation(result.data); - setValidationMessage( - result.data.provider_ok && result.data.apikey_ok && result.data.model_ok - ? 'All details are vaild.' - : result.data.error - ); - } catch (error) { - console.error('Failed to validate llm config:', error); - setValidation(null); - setValidationMessage('Validation request failed'); - } finally { - setIsValidating(false); - } - }, 350); - - return () => clearTimeout(timer); - }, [open, useOwnApiKey, provider, apiKey, model]); - - const validationOk = validation?.provider_ok && validation?.apikey_ok && validation?.model_ok; - const validationText = isValidating ? 'Validating...' : validationMessage; - const validationClass = validationOk - ? 'text-green-700 dark:text-green-500 bg-green-500/10 border-green-500/20' - : 'text-destructive bg-destructive/10 border-destructive/20'; - - const saveDisabled = - isSaving || isLoadingModels || isValidating || (useOwnApiKey && (!canSave || !validationOk)); - - const providerOptions = useMemo( - () => PROVIDERS.filter((item) => models.some((modelInfo) => modelInfo.provider === item)), - [models] - ); - - const currentProviders = providerOptions.length > 0 ? providerOptions : PROVIDERS; - - const modelPlaceholder = isLoadingModels ? 'Loading models...' : 'Select model'; - - const providerPlaceholder = isLoadingModels ? 'Loading providers...' : 'Select provider'; - - const isProviderDisabled = !useOwnApiKey || isLoadingModels; - const isModelDisabled = !useOwnApiKey || isLoadingModels; - - const currentProvider = currentProviders.includes(provider) - ? provider - : (currentProviders[0] ?? provider); - - useEffect(() => { - if (currentProvider !== provider) { - setProvider(currentProvider); - } - }, [currentProvider, provider]); - - useEffect(() => { - if (!open || !useOwnApiKey) return; - if (availableModels.length > 0 && !model) { - setModel(availableModels[0]); - } - }, [open, useOwnApiKey, availableModels, model]); - - const canShowValidation = useOwnApiKey; - - const providerLabel = PROVIDER_LABELS[currentProvider] ?? currentProvider; - void providerLabel; - - const hasModelsForProvider = availableModels.length > 0; - const effectiveModel = hasModelsForProvider ? model : ''; - const effectiveCanSave = !useOwnApiKey || (canSave && hasModelsForProvider && !!effectiveModel); - - const onModelChange = (value: string) => { - setModel(value); - setValidation(null); - setValidationMessage('Model changed. Re-validate API key.'); - }; - - const handleSave = async () => { - if (!effectiveCanSave) return; - - setIsSaving(true); - try { - await updateConfig({ - llmConf: useOwnApiKey - ? { - provider: currentProvider, - apikey: apiKey.trim(), - model: effectiveModel, - } - : null, - }); - toast.success('LLM configuration saved'); - setOpen(false); - } catch (error) { - console.error('Failed to save llm configuration:', error); - toast.error('Failed to save LLM configuration'); - } finally { - setIsSaving(false); - } - }; - - return ( -
- - - - - -

LLM options

-
-
- - - - LLM Options - -

Connect your own LLM provider and API key for full control.

-

- If you prefer to use our hosted models, we’ll automatically provide them based on your - available credits: SOTA model is active while you have a balance, - switching to free tier model once credits are exhausted. -

-
- -
-

Use my own API key

- -
- -
- {/* A Radix Select trigger is a button, not a form control, so htmlFor does not - reach it. id + aria-labelledby is what associates the two. */} - - -
- -
- - setApiKey(e.target.value)} - placeholder="Enter API key" - className="h-8 text-xs" - disabled={!useOwnApiKey} - maxLength={512} - /> -
- -
- - -
- - {canShowValidation && ( -
- {validationText} -
- )} - -
- -
-
-
-
- ); -} diff --git a/src/renderer/components/custom/titlebar.tsx b/src/renderer/components/custom/titlebar.tsx index 77d5b795..d579cd28 100644 --- a/src/renderer/components/custom/titlebar.tsx +++ b/src/renderer/components/custom/titlebar.tsx @@ -5,7 +5,6 @@ import CreditsDisplay from '@/components/custom/credits-display'; import TitlebarMenu from '@/components/custom/titlebar-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useAppState } from '@/hooks/use-app-state'; -import { useConfigStore } from '@/hooks/use-config-store'; import useIsStealthMode from '@/hooks/use-is-stealth-mode'; import { APP_NAME, isMac } from '@/lib/consts'; import { getElectron } from '@/lib/utils'; @@ -42,7 +41,6 @@ export default function Titlebar() { const handleClose = () => window.electronAPI?.close(); const { appState } = useAppState(); - const { config } = useConfigStore(); if (isStealth) return null; @@ -67,7 +65,7 @@ export default function Titlebar() { {appState?.isLoggedIn && appState?.credits !== undefined && ( diff --git a/src/renderer/pages/main/index.tsx b/src/renderer/pages/main/index.tsx index 7daee020..f67a022a 100644 --- a/src/renderer/pages/main/index.tsx +++ b/src/renderer/pages/main/index.tsx @@ -365,7 +365,7 @@ export default function MainPage() { )} diff --git a/src/renderer/types/config.ts b/src/renderer/types/config.ts index 64c05e00..4da5ba1f 100644 --- a/src/renderer/types/config.ts +++ b/src/renderer/types/config.ts @@ -1,5 +1,4 @@ import type { Language } from './language'; -import type { LLMConfig } from './llm'; export type { Language }; @@ -16,8 +15,6 @@ export interface Config { // Transcription options audioInputDeviceName: string; - llmConf: LLMConfig | null; - // Panel auto-scroll preferences (persisted between sessions) autoScrollLiveSuggestions: boolean; autoScrollActionSuggestions: boolean; diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 07240c02..2803a033 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -1,7 +1,6 @@ import type { AppState } from './app-state'; import type { Config } from './config'; import type { ExportFormat } from './export'; -import type { LLMConfig, LLMConfigValidationResult, LLMModelInfo } from './llm'; import type { AvailableCurrency, CreatePaymentRequest, @@ -101,14 +100,6 @@ declare global { getCredits: () => Promise<{ success: boolean; credits?: number; error?: string }>; }; - // LLM management - llm: { - listModels: () => Promise<{ success: boolean; data?: LLMModelInfo[]; error?: string }>; - validate: ( - config: LLMConfig | null - ) => Promise<{ success: boolean; data?: LLMConfigValidationResult; error?: string }>; - }; - // App state management appState: { get: () => Promise; diff --git a/src/renderer/types/llm.ts b/src/renderer/types/llm.ts index 4caf8af8..ca18a29c 100644 --- a/src/renderer/types/llm.ts +++ b/src/renderer/types/llm.ts @@ -1,16 +1,3 @@ -export enum LLMProvider { - OPENAI = 'openai', - ANTHROPIC = 'anthropic', - GROQ = 'groq', - GOOGLE = 'google', -} - -export interface LLMConfig { - provider: LLMProvider; - apikey: string; - model: string; -} - /** * How much prose a suggestion carries. Mirrors `SuggestionMode` in src/main/types/llm.ts. */ @@ -18,27 +5,3 @@ export enum SuggestionMode { Normal = 'normal', Professional = 'professional', } - -export interface LLMModelInfo { - id: string; - provider: LLMProvider; - name: string; - description: string; - modalities: string[]; - vision_capable: boolean; - context_window: number; - max_output_tokens: number; - pricing_input: number; - pricing_output: number; - supports_streaming: boolean; - supports_function_calling: boolean; - supports_json_mode: boolean; - release_date: string | null; -} - -export interface LLMConfigValidationResult { - provider_ok: boolean; - apikey_ok: boolean; - model_ok: boolean; - error: string; -} From 55b404b45bfb3a73ea791df4f53f5f5ffe76426f Mon Sep 17 00:00:00 2001 From: alpha Date: Fri, 4 Sep 2026 15:32:04 -0400 Subject: [PATCH 2/2] fix(llm): address review findings on the BYOK removal - Update the audio/language group comment (control-panel/index.tsx) and the matching line in CLAUDE.md - both still said "unlike Model" about a control this PR deleted. - Add test coverage for the llmConf disk scrub in config-store.test.mjs: seeds a leftover llmConf with a fake API key the way a pre-upgrade install would have it, then asserts the scrub actually removes it - both from the in-memory store and from the written-back file - rather than only asserting the type no longer declares the field. --- CLAUDE.md | 2 +- .../components/custom/control-panel/index.tsx | 10 +++++----- test/config-store.test.mjs | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 570891f9..8ccfd2bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -206,7 +206,7 @@ A code this build knows but an older backend does not is resolved back to Englis `configStore.getConfig()` resolves the language on the way *out*, not on the way in. The disk holds whatever some build wrote - a code a later release dropped, or one an older release never knew - and every consumer reads through `getConfig`, so that is the single place an unknown code can be stopped before it reaches the ASR URL and three request bodies. `test/language.test.mjs` pins it. -**The picker stays live mid-interview**, unlike Model, because an interview that switches language is the case it exists for and not one the candidate can prepare for by restarting. The two halves of the setting move at different speeds and `useInterviewLanguage` is where that is reconciled. Suggestions need nothing: every request reads the config store as it is built, so the next one already follows. The ASR carries its language as a *connection* parameter, so `liveTranscriptionService.setLanguage()` tears both sockets down and re-opens them - a second or two of gap, and whatever utterance was mid-flight is orphaned, which is why the button shows a spinner rather than pretending the change was instant and why the menu says so before the user commits. +**The picker stays live mid-interview**, because an interview that switches language is the case it exists for and not one the candidate can prepare for by restarting. The two halves of the setting move at different speeds and `useInterviewLanguage` is where that is reconciled. Suggestions need nothing: every request reads the config store as it is built, so the next one already follows. The ASR carries its language as a *connection* parameter, so `liveTranscriptionService.setLanguage()` tears both sockets down and re-opens them - a second or two of gap, and whatever utterance was mid-flight is orphaned, which is why the button shows a spinner rather than pretending the change was instant and why the menu says so before the user commits. Three guards in `AudioWsStream` make that safe, and all three protect against the same failure - two sockets on one channel, one of them orphaned and still relaying audio into a dead session. `ws.onclose` ignores a close from a socket that is no longer `this.ws`, since that is the tail of a replacement rather than a disconnect; and the `switching` flag suppresses the ordinary backoff reconnect for the close `setLanguage` causes itself, which it then handles immediately instead of after `WS_RETRY_BASE_DELAY_MS`. `connectWebSocket` rebuilds the URL per attempt rather than capturing it, which is what lets a reconnect pick up the new language at all. diff --git a/src/renderer/components/custom/control-panel/index.tsx b/src/renderer/components/custom/control-panel/index.tsx index 527ed496..d28ae7ed 100644 --- a/src/renderer/components/custom/control-panel/index.tsx +++ b/src/renderer/components/custom/control-panel/index.tsx @@ -202,11 +202,11 @@ export default function ControlPanel() {