diff --git a/app/diagnostics-filter.js b/app/diagnostics-filter.js index 480b4b6c..84c212e8 100644 --- a/app/diagnostics-filter.js +++ b/app/diagnostics-filter.js @@ -75,6 +75,7 @@ const ARGS_ECHO_REDACTORS = { 'set-remote-ollama-url': redactRest, 'test-remote-ollama': redactRest, 'set-cloud-api-url': redactRest, + 'set-openai-asr-config': redactRest, // device_id + a user-assigned device label (e.g. "Valentin's AirPods") — // same PII class as set-user-name. 'set-microphone': redactRest, diff --git a/app/main.js b/app/main.js index bacca241..4fe735b9 100644 --- a/app/main.js +++ b/app/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, ipcMain, dialog, shell, systemPreferences, globalShortcut, Tray, Menu, nativeImage, powerMonitor, net, session } = require('electron'); +const { app, BrowserWindow, ipcMain, dialog, shell, systemPreferences, globalShortcut, Tray, Menu, nativeImage, powerMonitor, net, session, desktopCapturer } = require('electron'); // safeStorage is accessed lazily via getSafeStorage(), NOT destructured from the // require above. On macOS, merely retrieving the safeStorage binding at load @@ -47,6 +47,7 @@ if (process.platform !== 'darwin') { process.on('unhandledRejection', (reason) => { _logStartupCrash('unhandledRejection', reason); }); } + const path = require('path'); // Backend CLI seam (spawn wrapper, process-tree kill, bundled-backend paths, // runPythonScript), the debug-log sink, and the quit teardown registry are @@ -5007,7 +5008,8 @@ function loadTranscriptionEngine() { if (!fs.existsSync(cfgPath)) return 'parakeet'; const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')); const engine = cfg.transcription_engine; - return engine === 'whisper' ? 'whisper' : 'parakeet'; + if (engine === 'whisper' || engine === 'openai-asr') return engine; + return 'parakeet'; } catch (_) { return 'parakeet'; } @@ -5024,12 +5026,19 @@ function loadTranscriptionContext() { return { engine: 'parakeet', model: 'parakeet', language: 'auto' }; } const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')); - const engine = cfg.transcription_engine === 'whisper' ? 'whisper' : 'parakeet'; + const rawEngine = cfg.transcription_engine; + const engine = (rawEngine === 'whisper' || rawEngine === 'openai-asr') ? rawEngine : 'parakeet'; + let model; + if (engine === 'whisper') { + model = sanitizeModelForAnalytics(cfg.whisper_model); + } else if (engine === 'openai-asr') { + model = sanitizeModelForAnalytics(cfg.openai_asr_model) || 'whisper-1'; + } else { + model = 'parakeet'; + } return { engine, - // Parakeet has no separate user-selectable model today (single bundled - // default) -- report the engine name rather than guess a variant id. - model: engine === 'whisper' ? sanitizeModelForAnalytics(cfg.whisper_model) : 'parakeet', + model, language: cfg.language || 'auto', }; } catch (_) { @@ -7927,6 +7936,33 @@ ipcMain.handle('set-transcription-engine', async (event, engine) => { } catch (e) { return { success: false, error: e.message }; } }); +ipcMain.handle('get-openai-asr-config', async () => { + try { + const extraEnv = {}; + const oaiKey = loadOpenAiAsrApiKey(); + if (oaiKey) { extraEnv.STENOAI_OAI_API_KEY = oaiKey; } + const result = await runPythonScript('simple_recorder.py', ['get-openai-asr-config'], true, extraEnv); + return JSON.parse(result.trim()); + } catch (e) { return { success: false, error: e.message }; } +}); + +ipcMain.handle('set-openai-asr-config', async (_event, cfg) => { + try { + if (cfg.api_key !== undefined) { + const saved = saveOpenAiAsrApiKey(cfg.api_key); + if (!saved) return { success: false, error: 'Failed to save OpenAI ASR API key' }; + } + const args = ['set-openai-asr-config']; + if (cfg.api_url !== undefined) { args.push('--api-url', cfg.api_url); } + if (cfg.model !== undefined) { args.push('--model', cfg.model); } + const extraEnv = {}; + const oaiKey = loadOpenAiAsrApiKey(); + if (oaiKey) { extraEnv.STENOAI_OAI_API_KEY = oaiKey; } + const result = await runPythonScript('simple_recorder.py', args, false, extraEnv); + return JSON.parse(result.trim()); + } catch (e) { return { success: false, error: e.message }; } +}); + ipcMain.handle('list-parakeet-models', async () => { try { const result = await runPythonScript('simple_recorder.py', ['list-parakeet-models'], true); @@ -8736,6 +8772,55 @@ function hasCloudApiKey() { return fs.existsSync(getCloudKeyPath()); } +function getOpenAiAsrKeyPath() { + return path.join(getUserDataDir(), '.openai-asr-api-key'); +} + +function saveOpenAiAsrApiKey(key) { + try { + const keyPath = getOpenAiAsrKeyPath(); + if (!key || !key.trim()) { + if (fs.existsSync(keyPath)) { + fs.unlinkSync(keyPath); + } + return true; + } + const keyDir = path.dirname(keyPath); + if (!fs.existsSync(keyDir)) { + fs.mkdirSync(keyDir, { recursive: true }); + } + const safe = getSafeStorage(); + if (!safe || !safe.isEncryptionAvailable()) { + console.error('safeStorage is not available to encrypt OpenAI ASR key'); + return false; + } + const encrypted = safe.encryptString(key.trim()); + fs.writeFileSync(keyPath, encrypted); + return true; + } catch (error) { + console.error('Failed to save OpenAI ASR API key:', error.message); + return false; + } +} + +function loadOpenAiAsrApiKey() { + try { + const keyPath = getOpenAiAsrKeyPath(); + if (!fs.existsSync(keyPath)) return null; + const safe = getSafeStorage(); + if (!safe || !safe.isEncryptionAvailable()) return null; + const encrypted = fs.readFileSync(keyPath); + return safe.decryptString(encrypted); + } catch (error) { + console.error('Failed to load OpenAI ASR API key:', error.message); + return null; + } +} + +function hasOpenAiAsrApiKey() { + return fs.existsSync(getOpenAiAsrKeyPath()); +} + // Build the env additions a Python AI-driven subprocess needs. Merges // the encrypted-on-disk cloud key (decrypted only here, never written // to the env if absent) AND the org adapter URL+JWT when a session @@ -8746,6 +8831,8 @@ function getAiEnv() { const env = {}; const cloudKey = loadCloudApiKey(); if (cloudKey) env.STENOAI_CLOUD_API_KEY = cloudKey; + const oaiAsrKey = loadOpenAiAsrApiKey(); + if (oaiAsrKey) env.STENOAI_OAI_API_KEY = oaiAsrKey; const session = loadOrgSession(); if (session && session.adapterUrl && session.token && !isJwtExpired(session.token)) { env.STENOAI_ADAPTER_URL = session.adapterUrl; @@ -11466,18 +11553,17 @@ async function firePreMeetingNotification(event) { notif.payload.attendees = event.attendees ? event.attendees.map((a) => a.name || a.email).join(', ') : ''; - // Only count a PASSIVE dismiss here (15s auto-close or being superseded). An - // active click/X-dismiss is tracked by the renderer, which also flags - // _analyticsInteracted via close-notification-window — so skip it here to - // avoid double-counting. This is the same split the old createNotificationWindow - // used, preserved verbatim. - // - // Read THIS notif's own window (notif._window), never the module-level - // `notificationWindow`: when this toast is superseded, the successor reassigns - // `notificationWindow` (and resets its `_analyticsInteracted` to false) before - // this 'close' fires, so reading the module-level var would attribute this - // toast's dismissal to the NEXT toast's interaction flag — dropping or - // duplicating the dismiss. The per-instance window keeps the flag correct. + + notif.on('click', () => { + if (event.meeting_url) { + shell.openExternal(event.meeting_url); + } + if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } + }); + notif.on('close', () => { if (!notif._window || !notif._window._analyticsInteracted) { trackEvent('notification_dismissed', { type: 'premeeting' }); @@ -11486,8 +11572,6 @@ async function firePreMeetingNotification(event) { notif.show(); trackEvent('notification_shown', { type: 'premeeting' }); - // Mark fired only after we've actually shown it, so an unshowable notif - // (no OS support) isn't permanently skipped by the scheduler's dedupe. premeetingFiredIds.add(event.id); return true; } diff --git a/app/preload.js b/app/preload.js index 8496fc39..a33ebcab 100644 --- a/app/preload.js +++ b/app/preload.js @@ -230,6 +230,12 @@ const stenoai = { set: (engine) => invoke('set-transcription-engine', engine), }, + openaiAsr: { + getConfig: () => invoke('get-openai-asr-config'), + // cfg may include any subset of { api_url, api_key, model } + setConfig: (cfg) => invoke('set-openai-asr-config', cfg), + }, + settings: { getNotifications: () => invoke('get-notifications'), setNotifications: (v) => invoke('set-notifications', v), diff --git a/app/renderer/src/components/CommandPalette.tsx b/app/renderer/src/components/CommandPalette.tsx index 6eab7dc5..886ee936 100644 --- a/app/renderer/src/components/CommandPalette.tsx +++ b/app/renderer/src/components/CommandPalette.tsx @@ -75,7 +75,7 @@ const SETTINGS_INDEX: SettingsEntry[] = [ }, { id: 'general-dock', tab: 'general', title: 'Hide dock icon', sub: 'Menu bar / tray icon only', macOnly: true }, { id: 'ai-language', tab: 'ai', title: 'Language', sub: 'Transcription and summary language' }, - { id: 'ai-transcription', tab: 'ai', title: 'Transcription model', sub: 'Parakeet or Whisper' }, + { id: 'ai-transcription', tab: 'ai', title: 'Transcription model', sub: 'Parakeet, Whisper, or OpenAI-compatible ASR' }, { id: 'ai-save-recordings', tab: 'ai', title: 'Save recordings', sub: 'Keep the audio files after transcription' }, { id: 'ai-autonotes', tab: 'ai', title: 'Generate notes automatically', sub: 'Summarise after transcription' }, { id: 'ai-provider', tab: 'ai', title: 'AI provider', sub: 'Local, private server, cloud, or organisation' }, diff --git a/app/renderer/src/components/Sidebar.tsx b/app/renderer/src/components/Sidebar.tsx index 4eb51019..a6556503 100644 --- a/app/renderer/src/components/Sidebar.tsx +++ b/app/renderer/src/components/Sidebar.tsx @@ -167,24 +167,20 @@ export function Sidebar({ const [dragOverAllMeetings, setDragOverAllMeetings] = React.useState(false); const isDraggingRef = React.useRef(false); const [iconPicker, setIconPicker] = React.useState<{ id: string; anchorRect: DOMRect } | null>(null); + + const updateIcon = useUpdateFolderIcon(); const isHomeActive = currentRoute === '/' || currentRoute === ''; const isAllMeetingsActive = currentRoute === '/meetings'; - // Match /chat as well as any /chat/ conversation route — the same Chat - // tab item should stay highlighted when drilling into a session. const isChatActive = currentRoute === '/chat' || currentRoute.startsWith('/chat/'); const isOrgSharedActive = currentRoute.startsWith('/org/'); const orgSession = useOrgSession(); const orgLogout = useOrgLogout(); const orgSignedIn = orgSession.data?.signedIn ?? false; - // Enterprise can hide the Shared notes feature (tab + cross-folder chat). - // `enabled` stays false until policy resolves, so the tab doesn't flash in - // then vanish for an org that has the feature turned off. const sharedNotes = useSharedNotesGate(orgSignedIn); - // Malformed % escapes throw URIError. Guard so a bad route can't crash - // the entire sidebar render. + const activeFolderId = React.useMemo(() => { if (!currentRoute.startsWith('/folders/')) return null; const raw = currentRoute.slice('/folders/'.length); @@ -235,21 +231,15 @@ export function Sidebar({ [width, onWidthChange], ); - return (