Skip to content

Commit 942cd34

Browse files
author
Brendan Gray
committed
v1.8.12: Object-disposed crash fix, continuation fence dedup, code block truncation fix, sysReserve guard, model auto-load, set-as-default, active state UI
1 parent 50817a4 commit 942cd34

8 files changed

Lines changed: 189 additions & 28 deletions

File tree

electron-main.js

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -445,22 +445,54 @@ async function initializeServices() {
445445
mainWindow.webContents.send('memory-stats', memoryStore.getStats());
446446
mainWindow.webContents.send('mcp-tools-available', mcpToolServer.getToolDefinitions());
447447

448-
// Skip auto-loading — let user pick a model manually.
449-
// This avoids blocking the UI for 1-5 minutes on startup.
450-
const defaultModel = modelManager.getDefaultModel();
451-
if (defaultModel) {
452-
console.log(`[IDE] Default model available: ${defaultModel.name} (not auto-loading)`);
448+
// Auto-load last used model if persisted, otherwise show available model
449+
const fs = require('fs');
450+
const settingsPath = require('path').join(userDataPath, 'settings.json');
451+
let lastUsedModel = null;
452+
try {
453+
const config = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
454+
if (config.lastUsedModel && fs.existsSync(config.lastUsedModel)) {
455+
lastUsedModel = config.lastUsedModel;
456+
}
457+
} catch {}
458+
459+
if (lastUsedModel) {
460+
const modelName = require('path').basename(lastUsedModel).replace(/\.gguf$/i, '');
461+
console.log(`[IDE] Auto-loading last used model: ${modelName}`);
453462
mainWindow.webContents.send('llm-status', {
454-
state: 'idle',
455-
message: `Model ready: ${defaultModel.name}. Click to load.`,
463+
state: 'loading',
464+
message: `Loading ${modelName}...`,
456465
});
457-
} else {
458-
// No local GGUF model — normal on first install. Cloud AI (Cerebras/Groq)
459-
// is active by default so the user can start immediately.
460-
mainWindow.webContents.send('llm-status', {
461-
state: 'idle',
462-
message: 'Cloud AI active (Cerebras/Groq). Download a .gguf model to enable local GPU inference.',
466+
// Non-blocking auto-load — UI is usable while model loads
467+
llmEngine.initialize(lastUsedModel).then((modelInfo) => {
468+
console.log(`[IDE] Auto-loaded model: ${modelName}`);
469+
if (mainWindow && !mainWindow.isDestroyed()) {
470+
mainWindow.webContents.send('llm-status', { state: 'ready', message: `Model loaded: ${modelName}` });
471+
if (modelInfo?.contextSize) {
472+
mainWindow.webContents.send('context-usage', { used: 0, total: modelInfo.contextSize });
473+
}
474+
mainWindow.webContents.send('model-auto-loaded', { path: lastUsedModel, name: modelName });
475+
}
476+
}).catch((err) => {
477+
console.warn(`[IDE] Auto-load failed: ${err.message}`);
478+
if (mainWindow && !mainWindow.isDestroyed()) {
479+
mainWindow.webContents.send('llm-status', { state: 'idle', message: `Auto-load failed. Click a model to load.` });
480+
}
463481
});
482+
} else {
483+
const defaultModel = modelManager.getDefaultModel();
484+
if (defaultModel) {
485+
console.log(`[IDE] Default model available: ${defaultModel.name} (not auto-loading)`);
486+
mainWindow.webContents.send('llm-status', {
487+
state: 'idle',
488+
message: `Model ready: ${defaultModel.name}. Click to load.`,
489+
});
490+
} else {
491+
mainWindow.webContents.send('llm-status', {
492+
state: 'idle',
493+
message: 'Cloud AI active (Cerebras/Groq). Download a .gguf model to enable local GPU inference.',
494+
});
495+
}
464496
}
465497
// Non-blocking: detect NVIDIA GPU and download CUDA backends in the background.
466498
// App is fully usable via cloud AI while this runs (or if no GPU is found).

main/agenticChat.js

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,30 @@ function register(ctx) {
524524
const sysPromptReserve = estimateTokens(actualSystemPrompt) + 50 + toolSchemaTokenEstimate;
525525
console.log(`[AI Chat] Profile: ${modelProfile._meta.profileSource} | ctx=${totalCtx} (hw=${hwContextSize}) | sysReserve=${sysPromptReserve}`);
526526

527+
// Guard: if system prompt + tool schemas exceed available context, fall back to compact preamble
528+
let usedCompactFallback = false;
529+
if (sysPromptReserve >= totalCtx * 0.9) {
530+
const compactPrompt = llmEngine._getCompactSystemPrompt();
531+
const compactReserve = estimateTokens(compactPrompt) + 50 + toolSchemaTokenEstimate;
532+
if (compactReserve < totalCtx * 0.9) {
533+
console.log(`[AI Chat] sysReserve (${sysPromptReserve}) exceeds ctx (${totalCtx}), switching to compact preamble (reserve=${compactReserve})`);
534+
// Reset session with compact prompt
535+
try { await llmEngine.resetSession(true); } catch (_) {}
536+
usedCompactFallback = true;
537+
} else {
538+
// Even compact preamble doesn't fit — inform user
539+
console.error(`[AI Chat] FATAL: Even compact preamble (${compactReserve} tokens) exceeds context (${totalCtx}). Cannot generate.`);
540+
if (mainWindow && !mainWindow.isDestroyed()) {
541+
mainWindow.webContents.send('llm-response-chunk', {
542+
text: `\n\n**Error:** This model's context window (${totalCtx} tokens) is too small for tool-assisted generation. The system prompt alone requires ~${compactReserve} tokens. Please load a model with a larger context window, or use Cloud AI.`,
543+
done: true,
544+
});
545+
}
546+
return;
547+
}
548+
}
549+
console.log(`[AI Chat] Model: ${modelTier.family} (${modelTier.paramLabel} ${modelTier.family}) \u2014 tools=${modelProfile.generation?.maxToolsPerTurn ?? 0}, grammar=${modelProfile.generation?.grammarConstrained ? 'strict' : 'limited'}`);
550+
527551
const maxResponseTokens = Math.min(
528552
Math.floor(totalCtx * modelProfile.context.responseReservePct),
529553
modelProfile.context.maxResponseTokens
@@ -883,7 +907,7 @@ function register(ctx) {
883907
}
884908
if (_tStart !== -1 && _tName && mainWindow && !mainWindow.isDestroyed()) {
885909
const raw = _tb.slice(_tStart);
886-
const paramsText = raw.length > 4000 ? raw.slice(-4000) : raw;
910+
const paramsText = raw.length > 4000 ? raw.slice(0, 4000) : raw;
887911
mainWindow.webContents.send('llm-tool-generating', {
888912
callIndex: _tIdx, functionName: _tName, paramsText, done: false,
889913
});
@@ -1090,6 +1114,21 @@ function register(ctx) {
10901114
if (responseText.startsWith(suffix)) { overlap = len; break; }
10911115
}
10921116
_stitchedForMcp = _pendingPartialBlock + responseText.slice(overlap);
1117+
1118+
// Fence-aware cleanup: if stitching produced duplicate ```json fences,
1119+
// keep only the LAST complete one (the continuation's fresh attempt)
1120+
const fencePattern = /```(?:json|tool_call|tool)\b/g;
1121+
const fencePositions = [];
1122+
let fm;
1123+
while ((fm = fencePattern.exec(_stitchedForMcp)) !== null) fencePositions.push(fm.index);
1124+
if (fencePositions.length >= 2) {
1125+
// Multiple fence opens — the first is from the truncated pass, the second from continuation
1126+
// Keep from the last fence open onward (it has the complete JSON)
1127+
const lastFenceStart = fencePositions[fencePositions.length - 1];
1128+
const textBeforeFences = _stitchedForMcp.slice(0, fencePositions[0]);
1129+
_stitchedForMcp = textBeforeFences + _stitchedForMcp.slice(lastFenceStart);
1130+
console.log(`[AI Chat] Fence dedup: removed ${fencePositions.length - 1} duplicate fence(s)`);
1131+
}
10931132
} else {
10941133
_stitchedForMcp = responseText;
10951134
}

main/ipc/llmHandlers.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,21 @@ function register(ctx) {
6060
await new Promise(r => setTimeout(r, 100));
6161
}
6262
const modelInfo = await ctx.llmEngine.initialize(modelPath);
63+
// Persist as last-used model for auto-load on next startup
64+
try {
65+
const { ipcMain: _ipc } = require('electron');
66+
// Write directly to settings file to avoid IPC roundtrip
67+
const fs = require('fs');
68+
const path = require('path');
69+
const { app } = require('electron');
70+
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
71+
let config = {};
72+
try { config = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
73+
config.lastUsedModel = modelPath;
74+
fs.writeFileSync(settingsPath + '.tmp', JSON.stringify(config, null, 2));
75+
fs.renameSync(settingsPath + '.tmp', settingsPath);
76+
console.log(`[LLM] Persisted lastUsedModel: ${path.basename(modelPath)}`);
77+
} catch (e) { console.warn('[LLM] Failed to persist lastUsedModel:', e.message); }
6378
const win = ctx.getMainWindow();
6479
if (win && modelInfo?.contextSize) {
6580
win.webContents.send('context-usage', { used: 0, total: modelInfo.contextSize });

main/llmEngine.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1074,12 +1074,13 @@ class LLMEngine extends EventEmitter {
10741074
// Reuse existing sequence — just clear KV cache
10751075
if (this.sequence && !this.sequence._disposed) {
10761076
try {
1077-
this.sequence.eraseContextTokenRanges([{ start: 0, end: this.sequence.nTokens }]);
1077+
// Await the erase to prevent race with pending async operations
1078+
await this.sequence.eraseContextTokenRanges([{ start: 0, end: this.sequence.nTokens }]);
10781079
} catch {
1079-
// If erase fails, get a new sequence
1080-
this.sequence = this.context.getSequence();
1080+
// If erase fails (e.g. sequence disposed mid-flight), get a new sequence
1081+
try { this.sequence = this.context.getSequence(); } catch { /* context may also be gone */ }
10811082
}
1082-
} else {
1083+
} else if (this.context) {
10831084
this.sequence = this.context.getSequence();
10841085
}
10851086

main/settingsManager.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,33 @@ function registerSettingsHandlers(ctx) {
6868
ipcMain.handle('save-settings', (_evt, settings) => _writeConfig(settings));
6969
ipcMain.handle('load-settings', () => _readConfig());
7070

71+
// ── Model persistence ──
72+
ipcMain.handle('set-last-used-model', (_evt, modelPath) => {
73+
try {
74+
const config = _readConfig();
75+
config.lastUsedModel = modelPath || null;
76+
return _writeConfig(config);
77+
} catch (e) { return { success: false, error: e.message }; }
78+
});
79+
80+
ipcMain.handle('get-last-used-model', () => {
81+
const config = _readConfig();
82+
return config.lastUsedModel || null;
83+
});
84+
85+
ipcMain.handle('set-default-model', (_evt, modelPath) => {
86+
try {
87+
const config = _readConfig();
88+
config.defaultModelPath = modelPath || null;
89+
return _writeConfig(config);
90+
} catch (e) { return { success: false, error: e.message }; }
91+
});
92+
93+
ipcMain.handle('get-default-model', () => {
94+
const config = _readConfig();
95+
return config.defaultModelPath || null;
96+
});
97+
7198
ipcMain.handle('get-system-prompt-preview', (_evt, opts) => {
7299
// Return the effective system prompt that would be sent to the model
73100
const { DEFAULT_SYSTEM_PREAMBLE, DEFAULT_COMPACT_PREAMBLE } = require('./constants');

preload.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
102102
modelsAdd: () => ipcRenderer.invoke('models-add'),
103103
modelsRemove: (modelPath) => ipcRenderer.invoke('models-remove', modelPath),
104104
onModelsAvailable: (callback) => _on('models-available', callback),
105+
onModelAutoLoaded: (callback) => _on('model-auto-loaded', callback),
106+
setDefaultModel: (modelPath) => ipcRenderer.invoke('set-default-model', modelPath),
107+
getDefaultModelPath: () => ipcRenderer.invoke('get-default-model'),
105108

106109
// ── Hardware & Model Recommendations ──
107110
getHardwareInfo: () => ipcRenderer.invoke('get-hardware-info'),

src/components/Layout/WelcomeScreen.tsx

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
22
import {
33
FolderOpen, Plus, Clock, ChevronRight, ArrowRight,
44
Download, CheckCircle, Loader2, Zap, Code2, Brain, Package,
5-
Cloud, LogOut, UserCircle,
5+
Cloud, LogOut, UserCircle, Star,
66
} from 'lucide-react';
77
import type { LicenseStatus } from '@/types/electron';
88

@@ -39,6 +39,10 @@ export const WelcomeScreen: React.FC<WelcomeScreenProps> = ({ onOpenFolder, onNe
3939
const [licenseStatus, setLicenseStatus] = useState<LicenseStatus | null>(null);
4040
const [licenseLoading, setLicenseLoading] = useState(false);
4141
const [cloudAILoading, setCloudAILoading] = useState(false);
42+
// Track which model is currently active (loaded)
43+
const [activeModel, setActiveModel] = useState<string | null>(null);
44+
// Track which model is set as default
45+
const [defaultModelPath, setDefaultModelPath] = useState<string | null>(null);
4246

4347
useEffect(() => {
4448
try {
@@ -68,6 +72,13 @@ export const WelcomeScreen: React.FC<WelcomeScreenProps> = ({ onOpenFolder, onNe
6872
// Load license status for sign-in strip
6973
useEffect(() => {
7074
window.electronAPI?.licenseGetStatus?.().then(s => { if (s) setLicenseStatus(s); }).catch(() => {});
75+
// Load default model path from settings
76+
window.electronAPI?.getDefaultModelPath?.().then(p => { if (p) setDefaultModelPath(p); }).catch(() => {});
77+
// Listen for auto-loaded model on startup
78+
const cleanup = window.electronAPI?.onModelAutoLoaded?.((data: { path: string; name: string }) => {
79+
setActiveModel(data.path);
80+
});
81+
return () => { if (typeof cleanup === 'function') cleanup(); };
7182
}, []);
7283

7384
const openRecent = (path: string) => {
@@ -88,11 +99,19 @@ export const WelcomeScreen: React.FC<WelcomeScreenProps> = ({ onOpenFolder, onNe
8899
await window.electronAPI?.llmLoadModel?.(modelPath);
89100
// Switch app to local model — clear cloud provider preference so ChatPanel defaults to local
90101
try { localStorage.removeItem('guide-cloud-provider'); } catch {}
102+
setActiveModel(modelPath);
91103
} finally {
92104
setLoadingModel(null);
93105
}
94106
};
95107

108+
const setAsDefault = async (modelPath: string) => {
109+
try {
110+
await window.electronAPI?.setDefaultModel?.(modelPath);
111+
setDefaultModelPath(modelPath);
112+
} catch {}
113+
};
114+
96115
const useCloudAI = () => {
97116
setCloudAILoading(true);
98117
try {
@@ -309,25 +328,47 @@ export const WelcomeScreen: React.FC<WelcomeScreenProps> = ({ onOpenFolder, onNe
309328
<div className="flex flex-col gap-1">
310329
{installedModels.slice(0, 4).map((model) => {
311330
const label = (model.name || (model.path || '').split(/[/\\]/).pop() || 'Unknown').replace(/\.gguf$/i, '');
331+
const mp = model.path || model.name;
332+
const isActive = activeModel === mp;
333+
const isDefault = defaultModelPath === mp;
334+
const isLoading = loadingModel === mp;
312335
return (
313336
<div
314-
key={model.path || model.name}
337+
key={mp}
315338
className="flex items-center gap-2 px-3 py-1.5 rounded-lg"
316-
style={{ backgroundColor: 'var(--theme-bg-secondary)', border: '1px solid var(--theme-border)' }}
339+
style={{
340+
backgroundColor: isActive ? 'color-mix(in srgb, var(--theme-accent) 10%, var(--theme-bg-secondary))' : 'var(--theme-bg-secondary)',
341+
border: isActive ? '1px solid var(--theme-accent)' : '1px solid var(--theme-border)',
342+
}}
317343
>
344+
{/* Set as default star */}
345+
<button
346+
onClick={() => setAsDefault(mp)}
347+
className="flex-shrink-0 transition-colors"
348+
style={{ color: isDefault ? 'var(--theme-accent)' : 'var(--theme-foreground-subtle)', cursor: 'pointer' }}
349+
title={isDefault ? 'Default model' : 'Set as default'}
350+
>
351+
<Star size={12} fill={isDefault ? 'currentColor' : 'none'} />
352+
</button>
318353
<span className="flex-1 min-w-0 text-[12px] truncate" style={{ color: 'var(--theme-foreground)' }} title={label}>
319354
{label}
320355
</span>
321356
<button
322-
onClick={() => useModel(model.path || model.name)}
323-
disabled={loadingModel === (model.path || model.name)}
357+
onClick={() => !isActive && useModel(mp)}
358+
disabled={isLoading || isActive}
324359
className="flex-shrink-0 text-[11px] px-2 py-0.5 rounded font-medium flex items-center justify-center gap-1 transition-opacity"
325-
style={{ backgroundColor: 'var(--theme-accent)', color: 'var(--theme-bg)', minWidth: 36, opacity: loadingModel === (model.path || model.name) ? 0.7 : 1 }}
326-
onMouseEnter={(e) => { if (loadingModel !== (model.path || model.name)) (e.currentTarget as HTMLElement).style.opacity = '0.8'; }}
327-
onMouseLeave={(e) => { if (loadingModel !== (model.path || model.name)) (e.currentTarget as HTMLElement).style.opacity = '1'; }}
328-
title={loadingModel === (model.path || model.name) ? 'Loading...' : `Load ${label}`}
360+
style={{
361+
backgroundColor: isActive ? '#89d185' : 'var(--theme-accent)',
362+
color: 'var(--theme-bg)',
363+
minWidth: 46,
364+
opacity: isLoading ? 0.7 : 1,
365+
cursor: isActive ? 'default' : 'pointer',
366+
}}
367+
onMouseEnter={(e) => { if (!isLoading && !isActive) (e.currentTarget as HTMLElement).style.opacity = '0.8'; }}
368+
onMouseLeave={(e) => { if (!isLoading && !isActive) (e.currentTarget as HTMLElement).style.opacity = '1'; }}
369+
title={isActive ? 'Model is active' : isLoading ? 'Loading...' : `Load ${label}`}
329370
>
330-
{loadingModel === (model.path || model.name) ? <Loader2 size={10} className="animate-spin" /> : 'Use'}
371+
{isLoading ? <Loader2 size={10} className="animate-spin" /> : isActive ? 'Active' : 'Use'}
331372
</button>
332373
</div>
333374
);

src/types/electron.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ export interface ElectronAPI {
123123
modelsAdd(): Promise<{ success: boolean; models: AvailableModel[] }>;
124124
modelsRemove(modelPath: string): Promise<{ success: boolean }>;
125125
onModelsAvailable(callback: (models: AvailableModel[]) => void): (() => void) | void;
126+
onModelAutoLoaded(callback: (data: { path: string; name: string }) => void): (() => void) | void;
127+
setDefaultModel(modelPath: string): Promise<{ success: boolean }>;
128+
getDefaultModelPath(): Promise<string | null>;
126129

127130
// Hardware & Model Recommendations
128131
getHardwareInfo(): Promise<{ vramGB: number; gpuName: string; totalRAM: number; freeRAM: number; cpuModel: string; cpuCores: number }>;

0 commit comments

Comments
 (0)