Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions src/main/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,13 @@ export class ApiClient {
return this.request<T>('POST', url, body, timeoutMs);
}

async postStream(path: string, body?: unknown): Promise<ReadableStream<Uint8Array> | null> {
async postStream(
path: string,
body?: unknown,
signal?: AbortSignal
): Promise<ReadableStream<Uint8Array> | null> {
const url = this.buildUrl(path);
return this.requestStream('POST', url, body);
return this.requestStream('POST', url, body, signal);
}

async put<T>(path: string, body?: unknown): Promise<ApiResponse<T>> {
Expand Down Expand Up @@ -184,10 +188,14 @@ export class ApiClient {
}
}

// No `timeoutMs` here on purpose: AbortSignal.timeout is a total wall-clock deadline, which
// would truncate a long-but-healthy generation mid-stream. Callers pass a signal driven by a
// stall timer that resets on every chunk instead.
async requestStream(
method: string,
url: string,
body?: unknown
body?: unknown,
signal?: AbortSignal
): Promise<ReadableStream<Uint8Array> | null> {
try {
const sessionToken = configStore.getConfig().sessionToken;
Expand All @@ -199,6 +207,7 @@ export class ApiClient {
method,
headers: this.headers,
body: body ? JSON.stringify(body) : undefined,
signal,
});
if (!response.ok) {
const responseContent = await response.text().catch(() => '');
Expand Down Expand Up @@ -228,6 +237,16 @@ export class ApiClient {
throw error;
}

// A supersede or stall abort is deliberate. Let it through untouched so callers can
// read `signal.reason` to tell the two apart, and do not log it as a failure.
//
// Keyed on the signal, not the error name: an abort rejects with the *reason*, so a
// stall abort surfaces as `TimeoutError` rather than `AbortError` and a name check
// would miss it and wrap it as a network failure.
if (signal?.aborted) {
throw error;
}

console.error('[ApiClient] Streaming request error:', { method, url, error });
throw new ApiRequestError(
error instanceof Error ? error.message : 'Network request failed',
Expand Down
10 changes: 6 additions & 4 deletions src/main/api/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,10 @@ export class LLMApi extends ApiClient {
* Generate Live Suggestions
*/
async generateLiveSuggestions(
data: GenerateLiveSuggestionRequest
data: GenerateLiveSuggestionRequest,
signal?: AbortSignal
): Promise<ReadableStream<Uint8Array> | null> {
return this.postStream('/api/llm/live-suggestion', data);
return this.postStream('/api/llm/live-suggestion', data, signal);
}

/**
Expand All @@ -48,9 +49,10 @@ export class LLMApi extends ApiClient {
* Generate Action Suggestion
*/
async generateActionSuggestionStream(
payload: GenerateActionSuggestionRequest
payload: GenerateActionSuggestionRequest,
signal?: AbortSignal
): Promise<ReadableStream<Uint8Array> | null> {
return this.postStream('api/llm/action-suggestion', payload);
return this.postStream('api/llm/action-suggestion', payload, signal);
}

/**
Expand Down
41 changes: 41 additions & 0 deletions src/main/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,53 @@ export const DEFAULT_HEIGHT = 768;
// Transcript constants
export const TRANSCRIPT_INTER_TRANSCRIPT_GAP_MS = 5000;

// An in-flight mic partial gates live suggestions, and it is only cleared by a matching final.
// An ASR websocket that drops mid-utterance never sends that final, so the partial is orphaned
// and suppresses suggestions until the candidate next finishes speaking - which can span
// several interviewer questions if they stay quiet. Generous on purpose: the gate exists to
// stop suggestions firing over someone mid-answer, so a short value would regress that.
export const SELF_PARTIAL_STALE_MS = 15_000;

// Most recent transcript entries sent with a suggestion request. The backend already slices to
// its own MAX_TRANSCRIPTS_NUM before building the prompt, so everything beyond this was upload
// cost for no effect - and it grew for the whole interview.
//
// Deliberately larger than the backend's window, so a change there does not silently starve the
// prompt. This does NOT bound retained history: the end-of-interview summary and .docx export
// read the full transcript from app state.
export const TRANSCRIPT_UPLOAD_LIMIT = 60;

// Suggestion constants
export const LIVE_SUGGESTION_GAP_MS = 2000;
export const LIVE_SUGGESTION_NO_SUGGESTION = 'NO_SUGGESTION_NEEDED';
export const ACTION_SUGGESTION_MAX_CAPTURES = 4;
export const ACTION_TIMEOUT_MS = 30_000; // 30 seconds

// Longest edge a screenshot is captured at. Capturing at full physical resolution meant
// NativeImage.toPNG() - which is synchronous, on the main process - ran on a 4K bitmap and
// stalled the event loop for hundreds of milliseconds per capture, blocking IPC, transcript
// ingest and any in-flight suggestion stream. It also drove the request payload, which the
// backend then base64-inflates by a third.
//
// Not lower than this: the model has to read code and stack traces off these screenshots, and
// the failure mode of over-shrinking is silent - a confident answer about a blurry image.
export const CAPTURE_MAX_EDGE_PX = 1920;

// Time to first byte. Separate budgets: an action request uploads up to four screenshots and
// the backend base64-encodes them before the provider emits a token, so it starts far slower
// than a live suggestion. These bound a request that never starts, not total generation time.
export const LIVE_SUGGESTION_TTFB_MS = 20_000;
export const ACTION_SUGGESTION_TTFB_MS = 45_000;

// Gap between chunks once a stream is flowing. Reset on every chunk, so this never caps a
// long-but-healthy generation.
export const SUGGESTION_STALL_MS = 15_000;

// Backstop only. The action lock is released explicitly on every path; this exists so that a
// bug in a future caller cannot brick Ctrl+Shift+F9/F11/F12 for the rest of a session. Well
// above the longest legitimate action suggestion.
export const ACTION_LOCK_MAX_HOLD_MS = 180_000;

// Stealth mode opacity levels (cycles on each toggle; default = second highest)
export const OPACITY_LEVELS = [0.2, 0.5, 0.73, 0.9] as const;
export const OPACITY_DEFAULT = OPACITY_LEVELS[OPACITY_LEVELS.length - 2]; // 0.73
Expand Down
3 changes: 3 additions & 0 deletions src/main/ipc/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export function registerTranscriptHandlers(): void {
ipcMain.handle('transcription:ingest', async (_event, payload) => {
await transcriptService.ingest(payload?.channel, payload?.type, payload?.text);
});
ipcMain.handle('transcription:channel-disconnected', async (_event, channel: string) => {
transcriptService.handleChannelDisconnected(channel);
});
ipcMain.handle('transcription:set-session-token', async (_event, sessionToken: string) => {
if (!sessionToken) return;
const url = BACKEND_BASE_URL.replace(/^ws/i, 'http');
Expand Down
2 changes: 2 additions & 0 deletions src/main/preload.cts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ const electronApi = {
ipcRenderer.invoke('transcription:ingest', payload),
setSessionToken: (token: string) =>
ipcRenderer.invoke('transcription:set-session-token', token),
channelDisconnected: (channel: 'ch_0' | 'ch_1') =>
ipcRenderer.invoke('transcription:channel-disconnected', channel),
// Channel names set by the electron-audio-loopback package — cannot be renamed
enableLoopbackAudio: () => ipcRenderer.invoke('enable-loopback-audio'),
disableLoopbackAudio: () => ipcRenderer.invoke('disable-loopback-audio'),
Expand Down
27 changes: 25 additions & 2 deletions src/main/services/action-lock.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Manages blocking of long-running action suggestion actions (screenshot capture, suggestion generation)
*/

import { ACTION_LOCK_MAX_HOLD_MS } from '../consts.js';
import { pushNotificationService } from './push-notification.service.js';

export enum ActionType {
Expand All @@ -12,6 +13,7 @@ export enum ActionType {

class ActionLockService {
private currentAction: ActionType | null = null;
private holdTimer: NodeJS.Timeout | null = null;

/**
* Try to acquire lock for an action
Expand All @@ -23,15 +25,36 @@ class ActionLockService {
return false;
}
this.currentAction = action;

// A held lock blocks all three action hotkeys with no recovery short of restarting the
// app, so never let one outlive its holder. Callers still release explicitly; this only
// fires if that fails.
this.holdTimer = setTimeout(() => {
console.error(
`[ActionLockService] Lock held by ${this.currentAction} for more than ` +
`${ACTION_LOCK_MAX_HOLD_MS}ms, force-releasing. This indicates a leaked lock.`
);
this.currentAction = null;
this.holdTimer = null;
}, ACTION_LOCK_MAX_HOLD_MS);

return true;
}

/**
* Release the lock
*/
release(action: ActionType): void {
if (this.currentAction === action) {
this.currentAction = null;
// Only the holder may release. Clearing the timer on a mismatched call would strip the
// backstop from a lock that is still held by someone else.
if (this.currentAction !== action) {
return;
}

this.currentAction = null;
if (this.holdTimer) {
clearTimeout(this.holdTimer);
this.holdTimer = null;
}
}

Expand Down
28 changes: 28 additions & 0 deletions src/main/services/app-state.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,15 @@ const DEFAULT_STATE: AppState = {
interviewConfigLoaded: false,
};

// Every broadcast structured-clones the whole renderer state, and they fire on each streamed
// token and each ASR partial - roughly 20/second across two channels, against a transcript array
// that grows for the whole interview. Coalescing bounds that cost per unit time instead of per
// event. Short enough that streaming still reads as streaming.
const BROADCAST_COALESCE_MS = 50;

export class AppStateService {
private state: AppState;
private broadcastTimer: ReturnType<typeof setTimeout> | null = null;

constructor() {
this.state = { ...DEFAULT_STATE };
Expand Down Expand Up @@ -116,6 +123,27 @@ export class AppStateService {
}

private notifyRenderer(): void {
if (this.broadcastTimer) return;

this.broadcastTimer = setTimeout(() => {
this.broadcastTimer = null;
this.flushRenderer();
}, BROADCAST_COALESCE_MS);
}

/**
* Send a pending broadcast immediately, if one is scheduled.
*
* Coalescing means a caller that needs the renderer to have the current state right now - a
* test, or a shutdown path - cannot simply wait. Deliberately a no-op when nothing is
* pending, so flushing cannot manufacture a broadcast that the change detection suppressed.
*/
flushRenderer(): void {
if (!this.broadcastTimer) return;

clearTimeout(this.broadcastTimer);
this.broadcastTimer = null;

try {
const win = getWindowReference();
if (win && !win.isDestroyed()) {
Expand Down
Loading