diff --git a/CLAUDE.md b/CLAUDE.md index 034962b9..8bc412e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,6 +85,131 @@ The backend prompts now ask for inline emphasis on the words an answer turns on, Each `LiveSuggestion` still carries the `mode` it was *generated* under, and the panel keys off that rather than the current setting, so toggling mid-interview leaves cards already on screen alone. What the mode selects is the presentation around the Markdown: professional promotes the headline line, normal keeps the 🪄 marker in a column of its own - prepending it to the content instead would swallow whatever structure the answer opens with. +### Assistant lifecycle + +`RunningState` is what every control on the bar is gated on, and `Starting` and `Stopping` disable +all of them - Stop included. So the one invariant `useAssistantService` has to hold is that the +state always lands back on a terminal value, whatever went wrong on the way. `stopAssistant` +returns to `Idle` in a `finally`, and tears the four services down through `Promise.allSettled` +rather than `Promise.all`: `all` rejects on the first one that throws and abandons the other three, +so a single failing teardown used to leave the rest running *and* strand the app in `Stopping` +with no reachable control - unrecoverable without restarting the app, mid-interview. A partial +failure is now a toast rather than a throw, because there is nothing left for a caller to do about +it and the session is over either way. + +The failed-start path is the mirror of that, and it belongs in exactly one place. `startAssistant` +already tears both services down and returns to `Idle` in its own `catch`, so `doStart` in +[control-panel/index.tsx](src/renderer/components/custom/control-panel/index.tsx) reports the error +and stops there. Calling `stopAssistant()` after it, as it used to, walked the button through a +three-second `Stopping` for a session that never started, and that call's own failure landed +outside the `try` as an unhandled rejection. + +`useMediaDevices` reports `ready` alongside the device list because an empty list means two +different things - `enumerateDevices()` has not answered yet, and this machine has none - and the +control panel renders a destructive badge and refuses Start on the second. Reading them as one +put a red `!` on a working microphone for the first frames after every launch, and refused a Start +pressed quickly with a message naming a device that was there all along. An unset +`audioInputDeviceName` is a third state again, and also not "missing": `AudioGroup` is choosing +the default at that moment, in an effect - never in the render body, where the store write +re-enters React mid-commit and a failed IPC call rolls the value back into the same condition that +triggered it, one write per frame. + +### Interview language + +One setting decides three things: which speech model transcribes the call, what language suggestions come back in, and the language of the exported report. `Language` is mirrored across the processes the way `SuggestionMode` is - [src/main/types/language.ts](src/main/types/language.ts) for the request bodies, [src/renderer/types/language.ts](src/renderer/types/language.ts) for the same enum plus the display metadata the picker needs. 28 languages, which is exactly what the backend's Deepgram Nova-3 ASR streams: offering one the ASR cannot hear would not degrade, it would answer a question that was never asked. + +A code this build knows but an older backend does not is resolved back to English there rather than faked, so a client ahead of its backend degrades one session instead of breaking it. `test/language.test.mjs` pins the two mirrors staying in step, which is the failure the widening made likely: an enum member with no picker entry renders a blank trigger, and a picker entry with no enum member resolves straight back to English when picked. The menu is capped and scrolls, because it opens upward from the bottom-most control into an overflow-hidden `main` - an uncapped 28-item list runs off the top of the window rather than flipping. + +**English is the absence of the feature.** `buildStreamingUrl` sends no `language` parameter at all for English rather than `language=en`, and the backend defaults the request field, so a session that never touches the picker produces exactly the traffic it produced before this existed. + +`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. + +Two guards in `AudioWsStream` make that safe, and both 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. + +Two consequences of that first guard. `setLanguage` has to report `channelDisconnected` itself rather than leaving it to `onclose`: `new WebSocket` assigns `this.ws` synchronously, so the old socket's close event always arrives after the replacement exists and is correctly ignored. And `setLanguage` keys its own no-op check on `this.ws` rather than on `active`, which `start()` only sets *after* its first connect returns - in that window a socket exists on the old language and an `active` check would skip it. + +The setting is persisted *before* the reconnect and never rolled back on failure: a failed reconnect that reverted the setting would leave the user with no route to the language they picked, whereas leaving it set means stopping and starting the assistant recovers. + +The trigger shows the code (`EN`, `ES`) next to the icon for the same reason the tooltip names the language - the one question this control has to answer at a glance is what it is currently set to. + +The app's own chrome is **not** localised, deliberately: an English button on a Spanish interview is an inconvenience, an English transcript of Spanish speech is a wrong answer read out loud. + +### Audio input device + +**The microphone can be changed mid-interview**, and for the same reason the language can: the case +it exists for only shows up once the session is running. A headset that dies, is unplugged, or was +the wrong device to begin with is noticed when the interviewer says they cannot hear you, and the +control used to be locked at exactly that moment - the only fix was stopping the assistant, which +drops the transcript and the suggestion history with it. + +It is cheaper than the language switch, and the difference is worth keeping straight. The device is +only what feeds the worklet; it is **not** a connection parameter. So `AudioWsStream.setStream()` +replaces the `MediaStreamAudioSourceNode` while the socket, the provider session and any utterance +in flight all survive. Nothing reconnects, there is no gap in the transcript, and the dialog +therefore promises the opposite of what the language menu warns about. Reaching for `setLanguage`'s +machinery here would reintroduce the gap this avoids. + +Two things `liveTranscriptionService.setAudioInputDevice()` has to hold, both pinned by +`test/audio-device-switch.test.mjs`. **The replacement stream is acquired before anything is torn +down**, and the previous one stopped only after the swap succeeds, so a device that is unplugged, +held by another app, or refused by permissions leaves the interview on the microphone it already +had. Releasing first reads as the obvious cleanup order and works every time the new device is +present; on the one path that matters it leaves the session with no microphone at all, mid-answer. +And a stream that finishes opening *after* the session stopped is released rather than left holding +the device with its indicator light on, since nothing else keeps a reference to it. + +`setStream` reuses the existing `AudioContext` rather than building one. Its `sampleRate` is fixed +at construction and `convertTo16kPcm` reads it, so a fresh context would resample every frame +against the wrong rate - quietly, and only for users whose second device runs at a different rate +than their first. + +Its bail-out tests the context **alone**, never also the worklet node, and that is not tidiness. +`start()` creates `source` from `this.stream` and only assigns `workletNode` after +`await addModule()`, so there is a real window where a context and a source exist and the node does +not. An early return covering that window leaves `source` bound to the stream the caller is about +to stop, and `start()` then wires that dead source into the graph: socket up, channel relaying +silence for the rest of the session, nothing reporting it. The worklet connect is guarded instead, +because `start()` has its own `source.connect(workletNode)` and reads `this.source` - which is the +replacement by then. + +Only `ch_1` moves. `ch_0` is loopback audio captured from the call and has no device to change. + +The setting is persisted before the swap and never rolled back on failure, the same as the language +picker: a failed swap leaves the audio running, so reverting would only remove the user's route to +the device they picked. + +The tests are source-level, unusually for this directory - every other one loads a built +main-process module, and this is renderer code with no runtime harness. They are worth the +awkwardness because the ordering above is what a later tidy-up breaks, with no symptom a type +checker or a linter can see. + +### Navigation and external links + +The panels render Markdown that came from a language model, and `remark-gfm` autolinks bare URLs, +so an anchor in this app is not necessarily one a person wrote. `installNavigationGuard()` +([src/main/navigation-guard.ts](src/main/navigation-guard.ts)) is installed before the window's +first load and closes the two routes that follow from that, neither of which announced itself. + +`setWindowOpenHandler` denies **every** new window. A `target="_blank"` anchor - which is what +`SafeMarkdown` renders - asks Electron for one, and with no handler installed the default is to +make it: a chromeless BrowserWindow with no address bar showing a page the user did not choose. +A web URL is handed to the real browser instead, through `setImmediate` as Electron's own +guidance requires. + +`will-navigate` pins the window to the app's own document. An anchor without a target navigates +the frame it is in, and that frame is the app - preload runs on whatever document loads next, so +a remote page would inherit `window.electronAPI`, and with it the session token through +`config.get()` and the candidate's CV through `account.get()`. `file:` origins serialize to +`"null"`, so the packaged build is matched on its exact document URL rather than on an origin +comparison that could never hold. + +Both routes and the `external:open` IPC handler go through the same `openExternally()`, which +allows `http:`, `https:` and `mailto:` only. `shell.openExternal` delegates to the OS protocol +handler, so `file:` launches whatever the path points at and a registered custom scheme runs +whatever claimed it. `test/navigation-guard.test.mjs` pins all three. + ### Routing Hash-based router (required for Electron `file://` protocol). Routes: `/` (index, redirects based on login state) -> `/auth/login`, `/auth/signup`, or `/auth/forgot-password` -> `/main` (interview UI) -> `/payment`. diff --git a/README.md b/README.md index b5194d4e..b4b2963b 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ pnpm test:main # main-process checks ### Configuration - Set profile (CV, job description) -- Select microphone +- Select microphone (changeable mid-interview, without interrupting transcription) - Start assistant ## Use Cases diff --git a/src/main/index.ts b/src/main/index.ts index 8dad9914..7775ee4b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,6 @@ import { app, BrowserWindow, Menu } from 'electron'; import path from 'path'; -import { fileURLToPath } from 'url'; +import { fileURLToPath, pathToFileURL } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -22,6 +22,7 @@ import { registerLiveSuggestionHandlers } from './ipc/suggestion-live.js'; import { registerToolsHandlers } from './ipc/tools.js'; import { initializeAudioLoopback, registerTranscriptHandlers } from './ipc/transcript.js'; import { registerWindowHandlers } from './ipc/window.js'; +import { installNavigationGuard } from './navigation-guard.js'; import { autoUpdaterService } from './services/auto-updater.service.js'; import { healthCheckService } from './services/health-check.service.js'; import { transcriptService } from './services/transcript.service.js'; @@ -172,14 +173,20 @@ async function createWindow() { // Clear cache before loading await win.webContents.session.clearCache(); + // Installed before the load, so the guard is in place for the app's very first document. + // Idempotent, because this function runs again when the single-instance lock recovers a + // destroyed window and `web-contents-created` is an app-level event. if (EnvUtil.isDev()) { - win.loadURL('http://localhost:15173'); + const devUrl = 'http://localhost:15173'; + installNavigationGuard(devUrl); + win.loadURL(devUrl); win.webContents.openDevTools(); } else { // Use app.getAppPath() for conventional path resolution // This works correctly whether the app is packaged or not const distPath = path.join(app.getAppPath(), 'dist', 'index.html'); console.log('Loading from:', distPath); + installNavigationGuard(pathToFileURL(distPath).href); win.loadFile(distPath); } } diff --git a/src/main/ipc/external.ts b/src/main/ipc/external.ts index 93c1783b..ce7f97bb 100644 --- a/src/main/ipc/external.ts +++ b/src/main/ipc/external.ts @@ -1,16 +1,12 @@ import { ipcMain, shell } from 'electron'; +import { openExternally } from '../navigation-guard.js'; + export function registerExternalHandlers(): void { - ipcMain.handle('external:open', async (_event, url: string) => { - try { - if (!url || typeof url !== 'string') return { success: false, error: 'invalid-url' }; - await shell.openExternal(url); - return { success: true }; - } catch (err: unknown) { - console.warn('[ExternalHandlers] external:open error:', err); - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - }); + // Shared with the window-open handler rather than calling shell.openExternal directly, so a + // link takes the same route and the same scheme check whichever way it arrives. openExternal + // hands the URL to the OS protocol handler, so `file:` launches what the path points at. + ipcMain.handle('external:open', async (_event, url: string) => openExternally(url)); ipcMain.handle('external:open-file', async (_event, filePath: string) => { const err = await shell.openPath(filePath); diff --git a/src/main/navigation-guard.ts b/src/main/navigation-guard.ts new file mode 100644 index 00000000..64c9d395 --- /dev/null +++ b/src/main/navigation-guard.ts @@ -0,0 +1,109 @@ +import { app, shell } from 'electron'; + +/** + * Schemes `shell.openExternal` is allowed to hand to the operating system. + * + * openExternal delegates to the OS protocol handler, so `file:` launches whatever the path points + * at and a registered custom scheme runs whatever claimed it. Only the three that mean "show this + * to the user in their own application" are permitted. + */ +const OPENABLE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']); + +export function isOpenableExternally(url: string): boolean { + try { + return OPENABLE_PROTOCOLS.has(new URL(url).protocol); + } catch { + return false; + } +} + +/** + * Open a URL in the user's own browser, or refuse it. + * + * Shared by the `external:open` IPC handler and the window-open handler below, so a link takes + * the same route whether the renderer asked for it explicitly or a `target="_blank"` anchor did. + */ +export async function openExternally(url: string): Promise<{ success: boolean; error?: string }> { + if (!url || typeof url !== 'string') return { success: false, error: 'invalid-url' }; + if (!isOpenableExternally(url)) { + console.warn('[NavigationGuard] Refused to open a non-web URL:', url); + return { success: false, error: 'unsupported-scheme' }; + } + + try { + await shell.openExternal(url); + return { success: true }; + } catch (err: unknown) { + console.warn('[NavigationGuard] openExternal error:', err); + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * Keep the app's own web contents on the app. + * + * The panels render Markdown that came from a language model, and `remark-gfm` autolinks bare + * URLs, so an anchor in this app is not necessarily one anybody wrote. Two things follow from + * that, and neither was covered before. + * + * A `target="_blank"` anchor asks Electron for a new window, and with no handler installed the + * default is to make one: a chromeless BrowserWindow, no address bar, showing a page the user did + * not choose. Every one of those is denied and handed to the real browser instead, which is both + * safer and what the user expected from a link. + * + * An anchor without a target navigates the frame it is in, and that frame is the app - carrying + * the preload bridge with it, since preload runs on whatever document loads next. A remote page + * inheriting `window.electronAPI` would have the session token through `config.get()` and the + * candidate's CV through `account.get()`. `will-navigate` pins the window to the app's own + * document; the dev server and the packaged `file://` bundle are the only origins it may hold. + */ +let installed = false; + +export function installNavigationGuard(appUrl: string): void { + // `createWindow()` runs again when the single-instance lock recovers a destroyed window, and + // `web-contents-created` is an app-level event: without this the second call would stack a + // duplicate will-navigate listener on every web contents for the rest of the process. + if (installed) return; + installed = true; + + let appOrigin: string; + try { + appOrigin = new URL(appUrl).origin; + } catch { + appOrigin = ''; + } + + app.on('web-contents-created', (_event, contents) => { + contents.setWindowOpenHandler(({ url }) => { + // setImmediate, per Electron's own guidance: openExternal must not run inside the handler. + if (isOpenableExternally(url)) { + setImmediate(() => void openExternally(url)); + } else { + console.warn('[NavigationGuard] Blocked a window for:', url); + } + return { action: 'deny' }; + }); + + contents.on('will-navigate', (event, navigationUrl) => { + let target: URL; + try { + target = new URL(navigationUrl); + } catch { + event.preventDefault(); + return; + } + + // `file:` origins serialize to "null", so the packaged build is matched on the document it + // is already showing rather than on an origin comparison that can never hold. + const sameDocument = + target.href === appUrl || (appOrigin !== '' && target.origin === appOrigin); + if (sameDocument) return; + + event.preventDefault(); + console.warn('[NavigationGuard] Blocked navigation to:', navigationUrl); + if (isOpenableExternally(navigationUrl)) { + setImmediate(() => void openExternally(navigationUrl)); + } + }); + }); +} diff --git a/src/main/services/health-check.service.ts b/src/main/services/health-check.service.ts index 51196770..e12d700c 100644 --- a/src/main/services/health-check.service.ts +++ b/src/main/services/health-check.service.ts @@ -13,6 +13,19 @@ import { pushNotificationService } from './push-notification.service.js'; const SUCCESS_INTERVAL = 5 * 1000; // 5 seconds const FAILURE_INTERVAL = 1 * 1000; // 1 second +// A backend that is down is usually down for longer than a second, and the first retry is the +// only one that benefits from being immediate. Without a ceiling the loop below polls at 1 Hz +// for as long as the app is open - a laptop left overnight on a dropped connection makes tens of +// thousands of failing requests, and every installed client comes back at the same rate the +// moment a real outage ends. Backoff is capped rather than unbounded so recovery is still +// noticed within half a minute, which is what the reconnect notice in the UI is waiting on. +const MAX_FAILURE_INTERVAL = 30 * 1000; +const FAILURE_BACKOFF_FACTOR = 2; + +function nextFailureInterval(current: number): number { + return Math.min(current * FAILURE_BACKOFF_FACTOR, MAX_FAILURE_INTERVAL); +} + export class HealthCheckService { private running = false; private client = new HealthCheckApi(); @@ -64,6 +77,8 @@ export class HealthCheckService { /** Backend ping loop */ private startBackendLoop(): void { (async () => { + let failureInterval = FAILURE_INTERVAL; + while (this.running) { let backendLive = false; try { @@ -74,13 +89,17 @@ export class HealthCheckService { } if (!backendLive) { - console.log('[HealthCheckService] Backend not live'); + console.log(`[HealthCheckService] Backend not live, next check in ${failureInterval}ms`); } // Update app state appStateService.updateState({ isBackendLive: backendLive }); - const next = backendLive ? SUCCESS_INTERVAL : FAILURE_INTERVAL; + // Reset on the way back up, so one blip does not leave the app checking slowly for the + // rest of the session. + const next = backendLive ? SUCCESS_INTERVAL : failureInterval; + failureInterval = backendLive ? FAILURE_INTERVAL : nextFailureInterval(failureInterval); + await safeSleep(next); } })(); @@ -89,12 +108,17 @@ export class HealthCheckService { /** Client ping loop */ private startClientLoop(): void { (async () => { + let failureInterval = FAILURE_INTERVAL; + while (this.running) { const state = appStateService.getState(); - // skip if not logged in + // skip if not logged in. Kept at FAILURE_INTERVAL: it makes no request, so it costs a + // timer wake-up rather than traffic, and it is what decides how soon after a sign-in the + // credits and role reach the UI. if (!state.isLoggedIn) { await safeSleep(FAILURE_INTERVAL); + failureInterval = FAILURE_INTERVAL; continue; } @@ -117,9 +141,11 @@ export class HealthCheckService { userRole: res.data?.user_role, }); } + failureInterval = FAILURE_INTERVAL; } catch (error) { console.error('[HealthCheckService] Client ping error:', error); - nextInterval = FAILURE_INTERVAL; + nextInterval = failureInterval; + failureInterval = nextFailureInterval(failureInterval); } await safeSleep(nextInterval); diff --git a/src/main/services/suggestion-action.service.ts b/src/main/services/suggestion-action.service.ts index a6ef0977..182bdce5 100644 --- a/src/main/services/suggestion-action.service.ts +++ b/src/main/services/suggestion-action.service.ts @@ -218,6 +218,7 @@ export class ActionSuggestionService { transcripts: transcripts.slice(-TRANSCRIPT_UPLOAD_LIMIT), image_names: [...this.uploadedImageNames], mode: conf.professionalMode ? SuggestionMode.Professional : SuggestionMode.Normal, + language: conf.language, }; const lastQuestion = this.getLastInterviewerQuestion(transcripts); diff --git a/src/main/services/suggestion-live.service.ts b/src/main/services/suggestion-live.service.ts index d7a0b6da..aa23f553 100644 --- a/src/main/services/suggestion-live.service.ts +++ b/src/main/services/suggestion-live.service.ts @@ -125,6 +125,7 @@ class LiveSuggestionService { transcripts: transcripts.slice(-TRANSCRIPT_UPLOAD_LIMIT), mode, turn_verdict: turnVerdict, + language: conf.language, }; armStallTimer(LIVE_SUGGESTION_TTFB_MS); diff --git a/src/main/services/tools.service.ts b/src/main/services/tools.service.ts index bf37643b..741729f1 100644 --- a/src/main/services/tools.service.ts +++ b/src/main/services/tools.service.ts @@ -21,11 +21,23 @@ class ToolsService { const transcripts = appStateService.getState().transcripts; const suggestions = appStateService.getState().liveSuggestions; + // Checked before the request, not after. Summarizing an empty interview is a billed model + // call whose only possible output is invented, and it lands in a document the candidate is + // told is a record of their interview. The export button is live whenever the assistant is + // idle, which includes every launch before the first session. + if (transcripts.length === 0 && suggestions.length === 0) { + throw new Error('There is nothing to export yet. Run an interview first.'); + } + // Call the API to generate the summary text + const conf = configStore.getConfig(); const response = await this.llmApi.generateSummary({ - config: configStore.getConfig().llmConf, + config: conf.llmConf, username, transcripts, + // The exported report is written in the interview's language too. A Spanish interview + // summarised in English is a document the candidate cannot hand to anyone involved in it. + language: conf.language, } as GenerateSummarizeRequest); if (response.error) { throw new Error(response.error.message); diff --git a/src/main/services/transcript.service.ts b/src/main/services/transcript.service.ts index 5de7505f..1e13cc43 100644 --- a/src/main/services/transcript.service.ts +++ b/src/main/services/transcript.service.ts @@ -4,9 +4,11 @@ import { SELF_PARTIAL_STALE_MS, TRANSCRIPT_INTER_TRANSCRIPT_GAP_MS, } from '../consts.js'; +import { configStore } from '../store/config.store.js'; import { Speaker, Transcript } from '../types/app-state.js'; import { RequestTurnVerdict } from '../types/llm.js'; import { classifyInterviewerTurn, TurnVerdict } from '../utils/interviewer-turn.js'; +import { transcriptSeparator } from '../utils/transcript-join.js'; import { appStateService } from './app-state.service.js'; import { liveSuggestionService } from './suggestion-live.service.js'; @@ -86,6 +88,11 @@ class TranscriptService { if (this.otherPartialTranscript) allTranscripts.push(this.otherPartialTranscript); allTranscripts = allTranscripts.filter(Boolean).sort((a, b) => a.timestamp - b.timestamp); + // Read once per ingest rather than per merge. `getConfig()` reads an in-memory store, and + // this function already rebuilds `cleaned` from scratch on every partial, so the cost is + // noise next to the loop below. + const separator = transcriptSeparator(configStore.getConfig().language); + const cleaned: Transcript[] = []; for (const t of allTranscripts) { const lastIndex = cleaned.length - 1; @@ -99,7 +106,7 @@ class TranscriptService { lastCleaned.speaker === t.speaker && t.timestamp - lastCleaned.endTimestamp <= TRANSCRIPT_INTER_TRANSCRIPT_GAP_MS ) { - lastCleaned.text += ' ' + t.text; + lastCleaned.text += separator + t.text; lastCleaned.endTimestamp = t.endTimestamp; } else { cleaned.push({ ...t }); diff --git a/src/main/store/config.store.ts b/src/main/store/config.store.ts index 64930300..92816bac 100644 --- a/src/main/store/config.store.ts +++ b/src/main/store/config.store.ts @@ -6,11 +6,13 @@ 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 { - language: string; + /** Interview language: what the ASR transcribes and what suggestions come back in. */ + language: Language; sessionToken: string; rememberMe: boolean; email: string; @@ -36,7 +38,7 @@ export interface RuntimeConfig { // Default runtime configuration const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { - language: 'en', + language: DEFAULT_LANGUAGE, sessionToken: '', rememberMe: true, email: '', @@ -102,7 +104,15 @@ class ConfigStore { getConfig(): RuntimeConfig { const stored: StoredRuntime = { ...this.store.get('runtime', DEFAULT_RUNTIME_CONFIG) }; delete stored.interviewConf; - return { ...DEFAULT_RUNTIME_CONFIG, ...stored } as RuntimeConfig; + const config = { ...DEFAULT_RUNTIME_CONFIG, ...stored } as RuntimeConfig; + + // Resolved on the way out, not on the way in. The disk holds whatever some build wrote - + // a language a later release dropped, or one an older one never knew - and every consumer + // reads through here, so this is the one place that can stop an unknown code reaching the + // ASR URL and the request bodies. + config.language = resolveLanguage(config.language); + + return config; } /** diff --git a/src/main/types/language.ts b/src/main/types/language.ts new file mode 100644 index 00000000..b6e404dc --- /dev/null +++ b/src/main/types/language.ts @@ -0,0 +1,63 @@ +/** + * The language an interview runs in: what the ASR transcribes and what suggestions come back in. + * + * ISO 639-1 codes, mirroring `Language` in the backend's `app/schemas/language.py`, which is + * exactly what its Deepgram Nova-3 ASR streams. Deliberately no wider than that: offering a + * language the transcription cannot deliver produces confident answers to a question that was + * never asked, which is worse than not offering it. + * + * A code this build knows but an older backend does not is resolved back to English there rather + * than faked, so a client ahead of its backend degrades one session instead of breaking it. + * + * `src/renderer/types/language.ts` carries the same enum plus the display metadata the picker + * needs, the way `SuggestionMode` is mirrored across the two processes. + */ +export enum Language { + English = 'en', + Spanish = 'es', + German = 'de', + French = 'fr', + Portuguese = 'pt', + Italian = 'it', + Dutch = 'nl', + Polish = 'pl', + Russian = 'ru', + Ukrainian = 'uk', + Czech = 'cs', + Romanian = 'ro', + Greek = 'el', + Hungarian = 'hu', + Swedish = 'sv', + Danish = 'da', + Norwegian = 'no', + Finnish = 'fi', + Turkish = 'tr', + Hindi = 'hi', + Japanese = 'ja', + Korean = 'ko', + Chinese = 'zh', + Vietnamese = 'vi', + Thai = 'th', + Indonesian = 'id', + Arabic = 'ar', + Hebrew = 'he', +} + +export const DEFAULT_LANGUAGE = Language.English; + +const LANGUAGE_CODES = new Set(Object.values(Language)); + +/** + * Map a stored or incoming value onto the enum, falling back to English. + * + * The config store holds whatever was written to disk, which may be a language a later build + * removed or an older build never knew. Sending that through unchecked puts an unknown code on + * the ASR URL and in every request body, where the backend can only fall back anyway - so it + * resolves here, once, at the point the value leaves the store. + */ +export function resolveLanguage(raw: string | null | undefined): Language { + if (!raw) return DEFAULT_LANGUAGE; + + const normalized = raw.trim().toLowerCase(); + return LANGUAGE_CODES.has(normalized) ? (normalized as Language) : DEFAULT_LANGUAGE; +} diff --git a/src/main/types/llm.ts b/src/main/types/llm.ts index 0ec83eb8..8513e254 100644 --- a/src/main/types/llm.ts +++ b/src/main/types/llm.ts @@ -1,4 +1,5 @@ import { Transcript } from './app-state.js'; +import { Language } from './language.js'; export enum LLMProvider { OPENAI = 'openai', @@ -48,6 +49,12 @@ export interface LLMConfigValidationResult { 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. + */ + language?: Language; } /** diff --git a/src/main/utils/transcript-join.ts b/src/main/utils/transcript-join.ts new file mode 100644 index 00000000..b8ffaef7 --- /dev/null +++ b/src/main/utils/transcript-join.ts @@ -0,0 +1,27 @@ +import { Language } from '../types/language.js'; + +/** + * Languages written without spaces between words. + * + * Kept in step with the backend's `_UNSPACED_LANGUAGES` in `app/services/asr_service.py`, which + * applies the same rule when it rejoins the segments of a single utterance. This one covers the + * other half: transcripts that arrived as separate finals and are merged here because they fell + * inside `TRANSCRIPT_INTER_TRANSCRIPT_GAP_MS` of each other. + */ +const UNSPACED_LANGUAGES: ReadonlySet = new Set([ + Language.Japanese, + Language.Chinese, + Language.Thai, +]); + +/** + * What to put between two transcript blocks being merged into one. + * + * A space is a word boundary in English and a visible defect in Japanese, and it does not stop at + * the panel: `cleaned` is what the suggestion request carries, so the model is asked to answer a + * question with breaks nobody spoke. The backend already avoids inserting them inside an + * utterance; joining with a space here would put them back at every merge. + */ +export function transcriptSeparator(language: Language): string { + return UNSPACED_LANGUAGES.has(language) ? '' : ' '; +} diff --git a/src/renderer/components/custom/change-password-dialog.tsx b/src/renderer/components/custom/change-password-dialog.tsx index 740026c5..854fa57b 100644 --- a/src/renderer/components/custom/change-password-dialog.tsx +++ b/src/renderer/components/custom/change-password-dialog.tsx @@ -54,6 +54,8 @@ export function ChangePasswordDialog({ onOpenChange(newOpen); }; + const passwordsMismatch = confirmPassword !== '' && newPassword !== confirmPassword; + return ( @@ -69,6 +71,8 @@ export function ChangePasswordDialog({
setCurrentPassword(e.target.value)} placeholder="Enter current password" @@ -83,6 +87,8 @@ export function ChangePasswordDialog({
setNewPassword(e.target.value)} placeholder="Enter new password" @@ -97,6 +103,8 @@ export function ChangePasswordDialog({
setConfirmPassword(e.target.value)} placeholder="Confirm new password" @@ -128,7 +136,20 @@ export function ChangePasswordDialog({ {loading ? 'Changing...' : 'Change Password'} - {error &&
{error}
} + {/* Says why the button is dead. A mismatch is the one condition above that the user + cannot see from the fields themselves - both are masked - so without this the + dialog silently refuses to submit and gives no reason. Held back until the confirm + field has something in it, so it is not an error for a half-typed entry. */} + {passwordsMismatch && ( +
+ The new passwords do not match. +
+ )} + {error && ( +
+ {error} +
+ )}
); diff --git a/src/renderer/components/custom/configuration-dialog.tsx b/src/renderer/components/custom/configuration-dialog.tsx index 1b706913..4fb376fd 100644 --- a/src/renderer/components/custom/configuration-dialog.tsx +++ b/src/renderer/components/custom/configuration-dialog.tsx @@ -20,6 +20,34 @@ const MAX_FIELD_LENGTH = 128_000; // Kept in sync with the backend's MAX_USERNAME_LENGTH (app/cfg/llm.py) const MAX_NAME_LENGTH = 1_000; +/** + * How much of a long field's budget is left, once it is close enough to matter. + * + * `maxLength` on a textarea truncates a paste silently, which for these two fields means a CV or + * a job description arriving 2,000 characters shorter than the one the user copied, with nothing + * on screen having said so. Hidden below the threshold: a counter over an empty box is noise, + * and the limit is generous enough that most sessions never approach it. + */ +const LIMIT_NOTICE_RATIO = 0.9; + +function FieldLimitNotice({ value, max }: { value: string; max: number }) { + if (value.length < max * LIMIT_NOTICE_RATIO) return null; + + const atLimit = value.length >= max; + return ( +

+ {atLimit + ? `Character limit reached (${max.toLocaleString()}). Extra text was not added.` + : `${(max - value.length).toLocaleString()} characters left`} +

+ ); +} + interface ConfigurationDialogProps { isOpen: boolean; onOpenChange: (open: boolean) => void; @@ -77,7 +105,11 @@ export default function ConfigurationDialog({ isOpen, onOpenChange }: Configurat throw new Error('Electron API not available'); } - const result = await electron.account.update(name, profileData, context); + // Trimmed on the way out, not just validated. The Save button is already gated on the + // trimmed name being non-empty, so a name of pure whitespace could never be saved - but a + // name with a trailing space could, and it is the string the prompts address the candidate + // by. The same goes for a CV pasted with a leading blank line. + const result = await electron.account.update(name.trim(), profileData.trim(), context.trim()); if (!result.success) { throw new Error(result.error || 'Failed to save configuration'); } @@ -106,10 +138,18 @@ export default function ConfigurationDialog({ isOpen, onOpenChange }: Configurat
-
- +
+ + +