Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2f045ee
feat: add support for OpenAI-compatible ASR engines with configurable…
Vassista Jul 18, 2026
5805d98
feat: improve settings UI layout and navigation
Vassista Jul 18, 2026
9a77975
feat: resolve CI lint errors and add privacy confirmation dialog for …
Vassista Jul 18, 2026
ea68e23
refactor: sync config query state without useEffect to satisfy lint r…
Vassista Jul 18, 2026
90cde03
Update app/renderer/src/components/Sidebar.tsx
Vassista Jul 18, 2026
7ebac09
Update app/renderer/src/components/Sidebar.tsx
Vassista Jul 18, 2026
796e016
fix: resolve reported issues across UI, auth, and transcription backend
Vassista Jul 18, 2026
61bd165
chore: redact set-openai-asr-config in diagnostics logs
Vassista Jul 18, 2026
19eae53
fix: read STENOAI_OAI_API_KEY from environment in simple_recorder.py
Vassista Jul 18, 2026
e4dd888
fix: remove Azure support, fix redirect leak, fix whisper engine swit…
Vassista Jul 18, 2026
7ce9ed5
Update src/transcriber.py
Vassista Jul 18, 2026
8fb59ba
fix: allow UI to clear API key, add secure --prompt-api-key CLI flag
Vassista Jul 18, 2026
d4adce8
fix: preserve POST body on same-origin redirect
Vassista Jul 18, 2026
eb9341c
Update app/renderer/src/routes/settings/TranscriptionTab.tsx
Vassista Jul 18, 2026
1252360
remove azure from UI hint
Vassista Jul 18, 2026
ccb85d6
Refine notification toast styling and icons
Vassista Jul 19, 2026
c3fd3ec
Refine notification watermark visibility and update note icon to lett…
Vassista Jul 19, 2026
51432c5
feat: migrate all notifications to custom React UI
Vassista Jul 19, 2026
cb6bb52
fix: resolve notification closure bug
Vassista Jul 19, 2026
1599d8c
fix: resolve all review findings from #349 (ASR key encryption, audio…
Vassista Jul 31, 2026
cfef61f
Merge upstream/main into ui-enhancemen and re-apply OpenAI ASR to AiTab
Vassista Aug 4, 2026
dbe1b46
fix(main): clean up duplicate Notification class and leftover conflic…
Vassista Aug 4, 2026
8159297
fix: resolve review findings for cloud ASR, safeStorage, config fallb…
Vassista Aug 4, 2026
d4a1fba
fix(transcriber, sidebar): define chunk_dur in _transcribe_single_chu…
Vassista Aug 4, 2026
9dfa101
Update src/transcriber.py
Vassista Aug 4, 2026
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
1 change: 1 addition & 0 deletions app/diagnostics-filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
124 changes: 104 additions & 20 deletions app/main.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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';
}
Expand All @@ -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 (_) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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() {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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
Expand All @@ -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;
Expand Down Expand Up @@ -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' });
Expand All @@ -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;
}
Expand Down
6 changes: 6 additions & 0 deletions app/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion app/renderer/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Loading
Loading