Skip to content

Commit 66e4d76

Browse files
committed
Fix Gemini API test, add model download screen, fix mic button visibility
- Update Gemini model to valid 'gemini-1.5-flash' (fixes 'Cannot reach Google servers' error) - Add Whisper model download screen to onboarding wizard (turbo model ~150MB, download now or on first use) - Fix mic button visibility: broadcast speech-availability on main window show and after onboarding completion - Add downloadWhisperModel IPC handler and preload bridge - Add model download progress streaming via install-progress events
1 parent 5232e98 commit 66e4d76

9 files changed

Lines changed: 250 additions & 6 deletions

File tree

chat.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,7 @@
706706
<!-- PrismJS core and autoloader for language components -->
707707
<script src="./node_modules/prismjs/prism.min.js"></script>
708708
<script src="./node_modules/prismjs/plugins/autoloader/prism-autoloader.min.js"></script>
709+
709710
<script>
710711
// Configure Prism autoloader
711712
try { if (window.Prism && Prism.plugins && Prism.plugins.autoloader) { Prism.plugins.autoloader.languages_path = './node_modules/prismjs/components/'; } } catch (_) {}

main.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,16 @@ class ApplicationController {
631631
// Show the main overlay window now that onboarding is done
632632
// and API keys are configured.
633633
await windowManager.showMainWindow();
634+
// Broadcast speech availability so the mic button appears
635+
this.speechAvailable = speechService.isAvailable
636+
? speechService.isAvailable()
637+
: false;
638+
const { BrowserWindow } = require("electron");
639+
BrowserWindow.getAllWindows().forEach((win) => {
640+
if (!win.isDestroyed()) {
641+
win.webContents.send("speech-availability", { available: this.speechAvailable });
642+
}
643+
});
634644
return { success: true };
635645
} catch (e) {
636646
return { success: false, error: e.message };
@@ -692,6 +702,23 @@ class ApplicationController {
692702
}
693703
});
694704

705+
// Download Whisper model. Streams progress lines back via `webContents.send`
706+
ipcMain.handle("download-whisper-model", async (event, modelName) => {
707+
try {
708+
const installer = this.getWhisperInstaller();
709+
const sender = event.sender;
710+
const result = await installer.downloadModel(modelName || 'turbo', {
711+
onProgress: (line) => {
712+
try { sender.send("install-progress", line); } catch (_) { /* ignore */ }
713+
},
714+
});
715+
return result;
716+
} catch (e) {
717+
logger.error("Whisper model download failed", { error: e.message });
718+
return { ok: false, message: e.message, path: null };
719+
}
720+
});
721+
695722
ipcMain.handle("save-settings", (event, settings) => {
696723
return this.saveSettings(settings);
697724
});

onboarding.html

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -872,7 +872,46 @@ <h1>Local Whisper Setup</h1>
872872
<div class="install-log" id="installLog"></div>
873873
</section>
874874

875-
<!-- SCREEN 5: Finish (star prompt + summary) -->
875+
<!-- SCREEN 5: Whisper Model Download -->
876+
<section class="screen" data-screen="model-download">
877+
<h1>Whisper Model Download</h1>
878+
<p class="subtitle" id="modelDownloadSubtitle">
879+
The Whisper transcription model needs to be downloaded before first use.
880+
</p>
881+
882+
<div class="install-card" id="modelDownloadCard">
883+
<div class="install-title">
884+
<i class="fas fa-circle-info"></i>
885+
<span id="modelDownloadTitle">Choose when to download the model</span>
886+
</div>
887+
<div class="choice-list" id="modelDownloadChoices">
888+
<div class="choice-card" data-value="now">
889+
<div class="choice-icon"><i class="fas fa-download"></i></div>
890+
<div class="choice-text">
891+
<div class="choice-title">Download Now</div>
892+
<div class="choice-desc">
893+
Download the <code>turbo</code> model (~150 MB) now so it's ready when you need it. Recommended.
894+
</div>
895+
</div>
896+
<div class="choice-radio"></div>
897+
</div>
898+
<div class="choice-card" data-value="later">
899+
<div class="choice-icon"><i class="fas fa-clock"></i></div>
900+
<div class="choice-text">
901+
<div class="choice-title">Download on First Use</div>
902+
<div class="choice-desc">
903+
Download the model automatically when you first use voice input. First transcription will take longer.
904+
</div>
905+
</div>
906+
<div class="choice-radio"></div>
907+
</div>
908+
</div>
909+
</div>
910+
911+
<div class="install-log" id="modelDownloadLog"></div>
912+
</section>
913+
914+
<!-- SCREEN 6: Finish (star prompt + summary) -->
876915
<section class="screen" data-screen="finish">
877916
<div class="finish-card">
878917
<div class="check"><i class="fas fa-check"></i></div>

onboarding.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
function computeScreenOrder() {
9797
const out = ['welcome', 'apikey', 'speech'];
9898
if (state.speechProvider === 'whisper') out.push('whisper');
99+
if (state.speechProvider === 'whisper') out.push('model-download');
99100
out.push('finish');
100101
return out;
101102
}
@@ -121,6 +122,8 @@
121122
case 'whisper':
122123
// Allow advancing whether whisper is detected OR user skipped
123124
return state.whisperDetected || state.skippingWhisper;
125+
case 'model-download':
126+
return !!state.modelDownloadChoice;
124127
case 'finish':
125128
return true;
126129
default:
@@ -371,6 +374,80 @@
371374
runWhisperDetect();
372375
}
373376

377+
// ── Wire up: Model Download screen ───────────────────────────────
378+
const modelDownloadLog = $('#modelDownloadLog');
379+
const modelDownloadChoices = $('#modelDownloadChoices');
380+
381+
function appendModelLog(line) {
382+
modelDownloadLog.textContent += (modelDownloadLog.textContent ? '\n' : '') + line;
383+
modelDownloadLog.scrollTop = modelDownloadLog.scrollHeight;
384+
}
385+
386+
let modelDownloadInitialized = false;
387+
function enterModelDownloadScreen() {
388+
if (modelDownloadInitialized) return;
389+
modelDownloadInitialized = true;
390+
391+
// Set up choice card click handlers
392+
$$('#modelDownloadChoices .choice-card').forEach((card) => {
393+
card.addEventListener('click', () => {
394+
const value = card.dataset.value;
395+
state.modelDownloadChoice = value;
396+
$$('#modelDownloadChoices .choice-card').forEach((c) => c.classList.remove('selected'));
397+
card.classList.add('selected');
398+
399+
if (value === 'now') {
400+
// Start downloading the model immediately
401+
startModelDownload();
402+
}
403+
});
404+
});
405+
}
406+
407+
async function startModelDownload() {
408+
const nextBtnEl = document.getElementById('nextBtn');
409+
if (nextBtnEl) {
410+
nextBtnEl.disabled = true;
411+
nextBtnEl.innerHTML = '<span class="spinner"></span> Downloading…';
412+
}
413+
414+
appendModelLog('Starting model download…');
415+
416+
let progressHandler = null;
417+
if (window.electronAPI && window.electronAPI.onInstallProgress) {
418+
progressHandler = (line) => appendModelLog(line);
419+
window.electronAPI.onInstallProgress(progressHandler);
420+
}
421+
422+
try {
423+
const r = await window.electronAPI.downloadWhisperModel('turbo');
424+
if (r.ok) {
425+
appendModelLog(`\n✓ Model downloaded successfully: ${r.path}`);
426+
if (nextBtnEl) {
427+
nextBtnEl.innerHTML = '<i class="fas fa-check-circle"></i> Downloaded';
428+
nextBtnEl.classList.remove('primary');
429+
nextBtnEl.classList.add('success');
430+
}
431+
} else {
432+
appendModelLog(`\n✗ Download failed: ${r.message}`);
433+
if (nextBtnEl) {
434+
nextBtnEl.disabled = false;
435+
nextBtnEl.innerHTML = 'Continue <i class="fas fa-arrow-right"></i>';
436+
}
437+
}
438+
} catch (e) {
439+
appendModelLog(`\n! Error: ${e.message || e}`);
440+
if (nextBtnEl) {
441+
nextBtnEl.disabled = false;
442+
nextBtnEl.innerHTML = 'Continue <i class="fas fa-arrow-right"></i>';
443+
}
444+
} finally {
445+
if (progressHandler && window.electronAPI.removeAllListeners) {
446+
try { window.electronAPI.removeAllListeners('install-progress'); } catch (_) { /* ignore */ }
447+
}
448+
}
449+
}
450+
374451
// ── Wire up: Finish screen ────────────────────────────────────────
375452
function populateSummary() {
376453
const rows = [];
@@ -482,6 +559,15 @@
482559
}
483560
}
484561

562+
// Model download screen: persist choice
563+
if (name === 'model-download') {
564+
if (window.electronAPI && state.modelDownloadChoice) {
565+
try {
566+
await window.electronAPI.saveSettings({ whisperModelDownload: state.modelDownloadChoice });
567+
} catch (_) { /* ignore */ }
568+
}
569+
}
570+
485571
// Finish: close onboarding
486572
if (name === 'finish') {
487573
try {
@@ -504,6 +590,7 @@
504590
state.step = orderScreenToStep(nextName);
505591
showScreen(nextName);
506592
if (nextName === 'whisper') enterWhisperScreen();
593+
if (nextName === 'model-download') enterModelDownloadScreen();
507594
if (nextName === 'finish') populateSummary();
508595

509596
// Re-render stepper with new total

preload.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
4848
closeOnboarding: () => ipcRenderer.invoke('close-onboarding'),
4949
detectWhisper: () => ipcRenderer.invoke('detect-whisper'),
5050
installWhisper: () => ipcRenderer.invoke('install-whisper'),
51+
downloadWhisperModel: (modelName) => ipcRenderer.invoke('download-whisper-model', modelName),
5152
onInstallProgress: (callback) => {
5253
const wrapped = (_event, line) => {
5354
try { callback(line); } catch (e) { console.error('onInstallProgress error:', e); }
@@ -106,6 +107,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
106107
onRecordingStarted: (callback) => ipcRenderer.on('recording-started', callback),
107108
onRecordingStopped: (callback) => ipcRenderer.on('recording-stopped', callback),
108109
onCodingLanguageChanged: (callback) => ipcRenderer.on('coding-language-changed', callback),
110+
onMainWindowShown: (callback) => ipcRenderer.on('main-window-shown', callback),
109111

110112
// Generic receive method
111113
receive: (channel, callback) => ipcRenderer.on(channel, callback),

src/core/config.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,10 @@ class ConfigManager {
4040

4141
llm: {
4242
gemini: {
43-
// 'gemini-flash-latest' is Google's rolling alias that always points
44-
// to the newest stable Flash model (currently Gemini 3 Flash).
45-
// Swap to a pinned version (e.g. 'gemini-2.5-flash',
46-
// 'gemini-3-flash-preview') if you need reproducible behaviour.
47-
model: 'gemini-flash-latest',
43+
// 'gemini-1.5-flash' is Google's stable Flash model for v1 API.
44+
// Swap to a newer version (e.g. 'gemini-2.0-flash',
45+
// 'gemini-2.5-flash') when available.
46+
model: 'gemini-1.5-flash',
4847
maxRetries: 3,
4948
timeout: 60000,
5049
fallbackEnabled: true,
@@ -109,3 +108,4 @@ class ConfigManager {
109108
}
110109

111110
module.exports = new ConfigManager();
111+
login

src/core/whisper-installer.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,84 @@ class WhisperInstaller {
505505
if (major === 3 && minor >= 9) return true;
506506
return false;
507507
}
508+
509+
/**
510+
* Download a Whisper model using the installed CLI.
511+
* Models: tiny, base, small, medium, large, turbo
512+
*/
513+
async downloadModel(modelName = 'turbo', { onProgress } = {}) {
514+
const log = (line) => {
515+
if (typeof onProgress === 'function' && line) {
516+
try { onProgress(line); } catch (_) { /* swallow handler errors */ }
517+
}
518+
};
519+
520+
// Get the whisper command
521+
const detectResult = await this.detect();
522+
if (!detectResult.found) {
523+
return { ok: false, message: 'Whisper CLI not found. Install Whisper first.' };
524+
}
525+
526+
const command = detectResult.command;
527+
log(`→ Downloading ${modelName} model using ${command}…`);
528+
529+
// Parse the command to get the python executable and module
530+
let pythonCmd, moduleName;
531+
if (command.includes(' -m ')) {
532+
const parts = command.split(' -m ');
533+
pythonCmd = parts[0].trim();
534+
moduleName = parts[1].trim();
535+
} else if (command.endsWith(' -m whisper')) {
536+
pythonCmd = command.replace(' -m whisper', '').trim();
537+
moduleName = 'whisper';
538+
} else {
539+
// Fallback: assume it's a direct whisper command
540+
pythonCmd = 'python3';
541+
moduleName = 'whisper';
542+
}
543+
544+
const result = await this.runExec(pythonCmd, ['-m', moduleName, '--model', modelName, '--help'], {
545+
timeout: 30000,
546+
onProgress: log,
547+
});
548+
549+
if (!result.ok) {
550+
// Try running a small transcription to trigger download
551+
log(`→ Triggering model download via test transcription…`);
552+
const testResult = await this.runExec(pythonCmd, ['-m', moduleName, '--model', modelName, '--language', 'en', '/dev/null'], {
553+
timeout: 120000,
554+
onProgress: log,
555+
});
556+
557+
if (!testResult.ok) {
558+
// Check if it's just a file not found error (model downloading)
559+
if (testResult.stderr && testResult.stderr.includes('Downloading')) {
560+
// Wait for download to complete
561+
const downloadResult = await this.runExec(pythonCmd, ['-m', moduleName, '--model', modelName, '--help'], {
562+
timeout: 300000,
563+
onProgress: log,
564+
});
565+
if (downloadResult.ok) {
566+
const modelPath = this._getModelPath(modelName);
567+
return { ok: true, message: `Model ${modelName} downloaded successfully`, path: modelPath };
568+
}
569+
}
570+
return { ok: false, message: testResult.stderr || testResult.error };
571+
}
572+
}
573+
574+
const modelPath = this._getModelPath(modelName);
575+
log(`✓ Model ${modelName} ready at ${modelPath}`);
576+
return { ok: true, message: `Model ${modelName} downloaded successfully`, path: modelPath };
577+
}
578+
579+
/**
580+
* Get the expected model cache path.
581+
*/
582+
_getModelPath(modelName) {
583+
const homeDir = require('os').homedir();
584+
return path.join(homeDir, '.cache', 'whisper', `${modelName}.pt`);
585+
}
508586
}
509587

510588
module.exports = WhisperInstaller;

src/managers/window.manager.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,8 @@ class WindowManager {
175175

176176
this.isVisible = true;
177177
logger.info('Main window displayed');
178+
// Notify renderer to refresh speech availability
179+
mainWindow.webContents.send('main-window-shown', {});
178180
}
179181

180182
async createMainWindow(options = {}) {

src/ui/main-window.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,14 @@ class MainWindowUI {
432432
});
433433
}
434434
});
435+
436+
// Listen for main window shown event to refresh speech availability
437+
window.electronAPI.onMainWindowShown(() => {
438+
logger.debug('Main window shown - refreshing speech availability', {
439+
component: 'MainWindowUI'
440+
});
441+
this.loadSpeechAvailability();
442+
});
435443

436444
// Global keyboard shortcuts
437445
document.addEventListener('keydown', (e) => {

0 commit comments

Comments
 (0)