CI / verify (push) Successful in 1m2s
修复: - main.ts 退出释放模型显存改用 getSetting(serverUrl),不再硬编码 127.0.0.1:11434(避免非默认地址时释放请求打到错误端口) - 备份导出/导入并入 localStorage 持久化状态(会话摘要、度量历史、轨迹降级缓存、主题),版本升级到 v2,实现完整备份 - 工具数量改为 getEnabledToolDefinitions().length 动态计算,删除写死"32 个"的硬编码 - 记忆日志区分操作来源:memory:write 透传 reason,标注"新增记忆/替换/删除/清空/TTL 衰减清理/访问统计写回(无新条目)",避免"写了但看不到新记忆"的困惑 可维护性: - 上下文压力逻辑收敛到统一 calculateContextStats,删除 getContextPressureLevel / getTrendAwareCompressThreshold 的重复实现 - 消除 validateToolArgs 同名碰撞(agent-engine 本地版改名 validateToolArgsQuick) - 子代理工具集改用 getEnabledToolDefinitions() 基线,跟随全局启用开关与 Plan 模式 - 抽取 html-utils.ts 纯函数模块(实体解码/HTML→文本/HTML→Markdown/拦截页检测/相关性评分),tool-handlers-system 净减约 190 行重复代码 - 统一静态导入(savePlanTracker/setPlanModeActive/collectDiagnostics/addWrittenFile) - console.* 使用处补充豁免说明(启动/退出/刷盘阶段无渲染进程可推送日志) - run_command 工具描述改为反映可配置执行模式 测试: - 新增 7 个测试文件 + 扩展 2 个,共 273 个测试(原 34 → 273) - 覆盖 agent-engine / agent-safety / context-manager / tool-registry / result-formatter / tool-parsing / memory-service / crypto / build-context / html-utils / utils / tool-handlers-fs - 全部通过 npm run typecheck && npm test && npm run build
274 lines
9.7 KiB
TypeScript
274 lines
9.7 KiB
TypeScript
/**
|
|
* Metona Ollama Desktop - 主进程入口
|
|
*/
|
|
|
|
import { app, BrowserWindow, dialog, session } from 'electron';
|
|
import * as path from 'path';
|
|
import * as fs from 'fs';
|
|
import { setupIPC } from './ipc.js';
|
|
import { createTray } from './tray.js';
|
|
import { createMenu } from './menu.js';
|
|
import { showNotification } from './utils.js';
|
|
import { ensureWorkspaceDir, killAllProcesses } from './workspace.js';
|
|
import { browserClose } from './browser.js';
|
|
import { stopAllServers } from './mcp-manager.js';
|
|
import { getSetting, flushDatabase } from './db/sqlite.js';
|
|
|
|
// ── 全局错误处理:写入文件 + 弹窗提示 ──
|
|
const ERROR_LOG = path.join(app.getPath('userData'), 'startup-error.log');
|
|
|
|
function logStartupError(phase: string, err: unknown): void {
|
|
const msg = `[${new Date().toISOString()}] ${phase}: ${err instanceof Error ? err.stack || err.message : String(err)}\n`;
|
|
try { fs.appendFileSync(ERROR_LOG, msg); } catch { /* ignore */ }
|
|
// 豁免:进程启动阶段的未捕获错误发生在渲染进程与日志面板就绪之前,只能落到 stderr
|
|
console.error(msg);
|
|
}
|
|
|
|
process.on('uncaughtException', (err) => {
|
|
logStartupError('uncaughtException', err);
|
|
});
|
|
|
|
process.on('unhandledRejection', (err) => {
|
|
logStartupError('unhandledRejection', err);
|
|
});
|
|
|
|
const APP_NAME = 'Metona Ollama';
|
|
const ICON_PATH = path.join(__dirname, '..', '..', 'assets', 'icons', 'llama.png');
|
|
const ICO_PATH = path.join(__dirname, '..', '..', 'assets', 'icons', 'llama.ico');
|
|
const IS_DEV = !app.isPackaged;
|
|
|
|
export let mainWindow: BrowserWindow | null = null;
|
|
export let isQuitting = false;
|
|
|
|
export function setQuitting(): void {
|
|
isQuitting = true;
|
|
}
|
|
|
|
export function getIconPath(): string {
|
|
return process.platform === 'win32' ? ICO_PATH : ICON_PATH;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// CORS 允许清单:为主窗口(file:// 源)访问 Ollama HTTP API 注入
|
|
// Access-Control-Allow-Origin 响应头。这是开启 webSecurity 前提下
|
|
// 本地 API 可达的正确做法,取代此前全局禁用同源策略的方式。
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
const corsListener = (
|
|
details: { responseHeaders?: Record<string, string[]> },
|
|
callback: (response: { responseHeaders?: Record<string, string[]> }) => void,
|
|
): void => {
|
|
const headers = details.responseHeaders ?? {};
|
|
headers['Access-Control-Allow-Origin'] = ['*'];
|
|
callback({ responseHeaders: headers });
|
|
};
|
|
|
|
/** 更新 CORS 允许清单(仅放行 Ollama 服务地址) */
|
|
export function updateCorsAllowlist(rawUrl: string): void {
|
|
try {
|
|
const u = new URL(rawUrl);
|
|
const pattern = [`${u.protocol}//${u.host}/*`];
|
|
const sess = session.defaultSession;
|
|
// Electron webRequest API:传 null 清除既有监听后重新注册
|
|
sess.webRequest.onHeadersReceived(null);
|
|
sess.webRequest.onHeadersReceived({ urls: pattern }, corsListener);
|
|
} catch {
|
|
// 无效 URL 时忽略
|
|
}
|
|
}
|
|
|
|
function createMainWindow(): BrowserWindow {
|
|
const userDataPath = app.getPath('userData');
|
|
const configPath = path.join(userDataPath, 'window-state.json');
|
|
let windowState = { width: 1200, height: 800, x: undefined as number | undefined, y: undefined as number | undefined, maximized: false };
|
|
|
|
try {
|
|
if (fs.existsSync(configPath)) {
|
|
windowState = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
}
|
|
} catch { /* use defaults */ }
|
|
|
|
mainWindow = new BrowserWindow({
|
|
width: windowState.width || 1200,
|
|
height: windowState.height || 800,
|
|
x: windowState.x,
|
|
y: windowState.y,
|
|
minWidth: 800,
|
|
minHeight: 600,
|
|
icon: process.platform === 'win32' ? ICO_PATH : ICON_PATH,
|
|
title: APP_NAME,
|
|
backgroundColor: '#FAF7F2',
|
|
show: false,
|
|
autoHideMenuBar: true,
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.js'),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
// sandbox 保持关闭:preload 需要读取 os 信息(homeDir/username 等)。
|
|
// 开启 sandbox 需将 sys 信息改为 IPC 异步获取,列入后续迭代。
|
|
sandbox: false,
|
|
// webSecurity 必须开启(同源策略)。file:// 页面访问 Ollama HTTP API
|
|
// 的 CORS 问题通过上方 webRequest 允许清单精确放行,而非全局禁用安全策略。
|
|
webSecurity: true,
|
|
allowRunningInsecureContent: false
|
|
}
|
|
});
|
|
|
|
// 开发模式:从 src/renderer/ 加载;生产模式:从 dist/renderer/ 加载
|
|
const isDev = !app.isPackaged;
|
|
if (isDev) {
|
|
mainWindow.loadFile(path.join(__dirname, '..', '..', 'src', 'renderer', 'index.html'));
|
|
} else {
|
|
mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'));
|
|
}
|
|
mainWindow.setMenuBarVisibility(false);
|
|
|
|
mainWindow.once('ready-to-show', () => {
|
|
if (windowState.maximized) {
|
|
mainWindow!.maximize();
|
|
}
|
|
mainWindow!.show();
|
|
});
|
|
|
|
const saveWindowState = (): void => {
|
|
if (!mainWindow || mainWindow.isMaximized() || mainWindow.isMinimized()) return;
|
|
const bounds = mainWindow.getBounds();
|
|
try {
|
|
fs.writeFileSync(configPath, JSON.stringify(bounds));
|
|
} catch { /* ignore */ }
|
|
};
|
|
|
|
mainWindow.on('resize', saveWindowState);
|
|
mainWindow.on('move', saveWindowState);
|
|
|
|
mainWindow.on('maximize', () => {
|
|
try {
|
|
const state = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
state.maximized = true;
|
|
fs.writeFileSync(configPath, JSON.stringify(state));
|
|
} catch { /* ignore */ }
|
|
});
|
|
|
|
mainWindow.on('unmaximize', () => {
|
|
try {
|
|
const state = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
state.maximized = false;
|
|
fs.writeFileSync(configPath, JSON.stringify(state));
|
|
} catch { /* ignore */ }
|
|
});
|
|
|
|
mainWindow.on('close', (e) => {
|
|
if (!isQuitting) {
|
|
e.preventDefault();
|
|
mainWindow!.hide();
|
|
if (!(global as Record<string, unknown>)._trayHintShown) {
|
|
(global as Record<string, unknown>)._trayHintShown = true;
|
|
showNotification('Metona 已最小化到系统托盘', '点击托盘图标可重新打开');
|
|
}
|
|
}
|
|
});
|
|
|
|
mainWindow.on('closed', () => {
|
|
mainWindow = null;
|
|
});
|
|
|
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
const { shell } = require('electron');
|
|
shell.openExternal(url);
|
|
return { action: 'deny' as const };
|
|
}
|
|
return { action: 'allow' as const };
|
|
});
|
|
|
|
return mainWindow;
|
|
}
|
|
|
|
// ── 单实例锁 ──
|
|
const gotTheLock = app.requestSingleInstanceLock();
|
|
if (!gotTheLock) {
|
|
app.quit();
|
|
} else {
|
|
app.on('second-instance', () => {
|
|
if (mainWindow) {
|
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
mainWindow.focus();
|
|
}
|
|
});
|
|
}
|
|
|
|
app.whenReady().then(async () => {
|
|
ensureWorkspaceDir();
|
|
await setupIPC();
|
|
// 恢复 Ollama 服务地址的 CORS 允许清单(设置面板保存地址时会动态更新)
|
|
try {
|
|
const serverUrl = getSetting<string>('serverUrl', 'http://127.0.0.1:11434');
|
|
updateCorsAllowlist(serverUrl || 'http://127.0.0.1:11434');
|
|
} catch { /* 数据库未就绪时使用默认地址 */ }
|
|
createMainWindow();
|
|
createTray();
|
|
createMenu();
|
|
|
|
app.on('activate', () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
createMainWindow();
|
|
} else if (mainWindow) {
|
|
mainWindow.show();
|
|
}
|
|
});
|
|
}).catch((err) => {
|
|
logStartupError('app.whenReady', err);
|
|
// 弹出错误对话框,让用户知道发生了什么
|
|
try {
|
|
dialog.showErrorBox('Metona Ollama 启动失败',
|
|
`应用初始化出错,请检查以下信息:\n\n${err instanceof Error ? err.message : String(err)}\n\n错误日志:${ERROR_LOG}`);
|
|
} catch {
|
|
// dialog 也可能失败(app 未完全初始化)
|
|
}
|
|
app.quit();
|
|
});
|
|
|
|
app.on('window-all-closed', () => {
|
|
if (process.platform !== 'darwin') {
|
|
// keep running with tray
|
|
}
|
|
});
|
|
|
|
app.on('before-quit', async () => {
|
|
isQuitting = true;
|
|
// 强制刷盘:防抖持久化模式下确保最近 300ms 内的写入不丢失
|
|
try { flushDatabase(); } catch { /* 刷盘失败不阻塞退出 */ }
|
|
// 清理浏览器
|
|
browserClose().catch(() => {});
|
|
// 清理 MCP 服务器
|
|
stopAllServers();
|
|
// 清理所有工作空间进程
|
|
killAllProcesses();
|
|
// 通知渲染进程释放显存
|
|
mainWindow?.webContents.send('app-quit');
|
|
// 主进程直接调用 Ollama API 释放显存(更可靠,不依赖渲染进程)
|
|
// 地址从设置读取(与启动时 CORS 清单逻辑保持一致),避免使用非默认地址时释放请求打到错误端口
|
|
try {
|
|
const serverUrl = getSetting<string>('serverUrl', 'http://127.0.0.1:11434');
|
|
const ollamaUrl = (serverUrl || 'http://127.0.0.1:11434').replace(/\/+$/, '');
|
|
const psResp = await fetch(`${ollamaUrl}/api/ps`);
|
|
if (psResp.ok) {
|
|
const psData = await psResp.json() as { models?: Array<{ name: string }> };
|
|
const models = psData.models || [];
|
|
for (const m of models) {
|
|
try {
|
|
await fetch(`${ollamaUrl}/api/generate`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ model: m.name, keep_alive: 0 }),
|
|
});
|
|
} catch { /* 忽略单个模型释放失败 */ }
|
|
}
|
|
if (models.length > 0) {
|
|
// 豁免:before-quit 阶段渲染进程已进入关闭流程,释放显存结果仅记录到主进程 stderr
|
|
console.log(`[before-quit] 已释放 ${models.length} 个模型显存`);
|
|
}
|
|
}
|
|
} catch { /* 释放显存失败不影响退出 */ }
|
|
});
|