核心引擎修复: - CE-1: 上下文压缩摘要 role 从 system 改为 user,避免被 adapter 过滤 - CE-2: 工具失败时优先使用 error 字段(engine/openai-format/ollama 三处) - P0-1: DeadLoopError 终止时正确传 error 参数,前端可见 ERROR 事件 - MT-1: 新增 waitForAbort 方法,abortSession 等待 run 结束再返回 - MT-2: TERMINATED 状态到达时标记步骤完成,避免 Trace Viewer 转圈 - MT-3: 压缩边界检测孤立 tool 消息,避免 API 400 错误 - isRetryableError 与 catch 分支统一 toLowerCase IPC 与主进程修复: - P0-2: createAdapter 配置缺失返回 null,FALLBACK_ADAPTER 兜底 - P0-3: reloadAdapter 失败返回 success:false 通知前端 - P1-5: 校验失败发 ERROR+DONE 流事件,防止 isStreaming 卡死 - P1-6: configLoaded 标志,配置加载前禁用发送按钮 - P1-7: MCP initialize 移到 agentLoop 后,完成后同步工具 - P2-11: beforeLoad 在 loadURL 前注册 IPC handler - P2-12: provider 切换竞态保护 前端状态同步修复: - clearSessions 后同步清空前端会话与消息状态 - clearMemories 通过 memoryVersion 触发 MemoryViewer 重新加载 - ContextMenu 4 个 session 操作补全 IPC 调用与 try/catch - useConfig 配置保存失败回滚 UI 并提示 - handleToggle 工具切换失败回滚单个工具状态 错误处理全量补全: - 所有 await window.metona 调用补全 try/catch 与 toast 反馈 - MCP addServer/toggleServer/removeServer 检查返回值 - showItemInFolder 检查返回值(handleOpen/handleOpenInFolder) - sse-stream/ollama NDJSON 解析失败改为 log.warn - adapter throwHttpError 带 status 属性供 isRetryableError 判断
231 lines
5.7 KiB
TypeScript
231 lines
5.7 KiB
TypeScript
/**
|
||
* Window Manager — 多窗口管理
|
||
*
|
||
* 职责:
|
||
* 1. 创建和管理多个窗口(每个工作空间可独立开窗口)
|
||
* 2. 窗口状态持久化(位置、大小)
|
||
* 3. 全局快捷键注册(Cmd+Shift+M 切换到 Metona)
|
||
* 4. 关闭到托盘行为
|
||
*
|
||
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 窗口管理
|
||
*/
|
||
|
||
import { BrowserWindow, globalShortcut, app, shell } from 'electron';
|
||
import { join } from 'path';
|
||
import { existsSync } from 'fs';
|
||
import { is } from '@electron-toolkit/utils';
|
||
import log from 'electron-log';
|
||
|
||
export interface WindowState {
|
||
x?: number;
|
||
y?: number;
|
||
width: number;
|
||
height: number;
|
||
isMaximized?: boolean;
|
||
}
|
||
|
||
export class WindowManager {
|
||
private windows = new Map<string, BrowserWindow>();
|
||
private activeWindowId: string | null = null;
|
||
|
||
/**
|
||
* 创建新窗口
|
||
*/
|
||
createWindow(options: {
|
||
id?: string;
|
||
workspacePath?: string;
|
||
title?: string;
|
||
state?: WindowState;
|
||
beforeLoad?: (win: BrowserWindow) => void;
|
||
}): BrowserWindow {
|
||
const id = options.id ?? `window_${Date.now()}`;
|
||
const state = options.state ?? { width: 1440, height: 900 };
|
||
|
||
const win = new BrowserWindow({
|
||
width: state.width,
|
||
height: state.height,
|
||
x: state.x,
|
||
y: state.y,
|
||
minWidth: 960,
|
||
minHeight: 600,
|
||
icon: this.getIconPath(),
|
||
show: false,
|
||
titleBarStyle: 'hiddenInset',
|
||
title: options.title ?? 'MetonaAI Desktop',
|
||
webPreferences: {
|
||
preload: join(__dirname, '../preload/preload.mjs'),
|
||
sandbox: false,
|
||
contextIsolation: true,
|
||
nodeIntegration: false,
|
||
},
|
||
});
|
||
|
||
// P2-11 修复: 在 loadURL 之前执行回调,确保 IPC handler 在渲染进程加载前注册
|
||
if (options.beforeLoad) options.beforeLoad(win);
|
||
|
||
// 加载页面
|
||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||
win.loadURL(process.env['ELECTRON_RENDERER_URL']);
|
||
} else {
|
||
win.loadFile(join(__dirname, '../../dist/index.html'));
|
||
}
|
||
|
||
// 窗口事件
|
||
win.on('ready-to-show', () => {
|
||
if (state.isMaximized) {
|
||
win.maximize();
|
||
}
|
||
win.show();
|
||
});
|
||
|
||
win.on('closed', () => {
|
||
this.windows.delete(id);
|
||
if (this.activeWindowId === id) {
|
||
this.activeWindowId = this.windows.size > 0 ? this.windows.keys().next().value ?? null : null;
|
||
}
|
||
});
|
||
|
||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||
// C-8 修复: 校验 URL 协议,只允许 http/https,防止 javascript:/file: 等协议执行代码
|
||
try {
|
||
const parsed = new URL(url);
|
||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||
shell.openExternal(url);
|
||
} else {
|
||
log.warn(`[WindowManager] Blocked window.open with unsafe protocol: ${parsed.protocol}`);
|
||
}
|
||
} catch {
|
||
log.warn(`[WindowManager] Blocked window.open with invalid URL: ${url.slice(0, 100)}`);
|
||
}
|
||
return { action: 'deny' };
|
||
});
|
||
|
||
this.windows.set(id, win);
|
||
this.activeWindowId = id;
|
||
|
||
log.info(`Window created: ${id} (${options.title ?? 'MetonaAI Desktop'})`);
|
||
|
||
return win;
|
||
}
|
||
|
||
/**
|
||
* 获取窗口
|
||
*/
|
||
getWindow(id: string): BrowserWindow | undefined {
|
||
return this.windows.get(id);
|
||
}
|
||
|
||
/**
|
||
* 获取活动窗口
|
||
*/
|
||
getActiveWindow(): BrowserWindow | null {
|
||
if (this.activeWindowId) {
|
||
return this.windows.get(this.activeWindowId) ?? null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 获取所有窗口
|
||
*/
|
||
getAllWindows(): BrowserWindow[] {
|
||
return Array.from(this.windows.values());
|
||
}
|
||
|
||
/**
|
||
* 聚焦到窗口(从任意应用切换)
|
||
*/
|
||
focusWindow(): void {
|
||
const win = this.getActiveWindow();
|
||
if (win) {
|
||
if (win.isMinimized()) {
|
||
win.restore();
|
||
}
|
||
win.show();
|
||
win.focus();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 注册全局快捷键
|
||
*/
|
||
registerGlobalShortcuts(): void {
|
||
// Cmd/Ctrl+Shift+M — 从任意应用切换到 Metona
|
||
const registered = globalShortcut.register('CommandOrControl+Shift+M', () => {
|
||
this.focusWindow();
|
||
log.info('Global shortcut: Switch to Metona');
|
||
});
|
||
|
||
if (!registered) {
|
||
log.warn('Failed to register global shortcut: Cmd+Shift+M');
|
||
} else {
|
||
log.info('Global shortcut registered: Cmd+Shift+M');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 注销全局快捷键
|
||
*/
|
||
unregisterGlobalShortcuts(): void {
|
||
globalShortcut.unregisterAll();
|
||
log.info('Global shortcuts unregistered');
|
||
}
|
||
|
||
/**
|
||
* 获取窗口状态(用于持久化)
|
||
*/
|
||
getWindowState(id: string): WindowState | null {
|
||
const win = this.windows.get(id);
|
||
if (!win) return null;
|
||
|
||
const bounds = win.getBounds();
|
||
return {
|
||
x: bounds.x,
|
||
y: bounds.y,
|
||
width: bounds.width,
|
||
height: bounds.height,
|
||
isMaximized: win.isMaximized(),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 关闭所有窗口
|
||
*/
|
||
closeAll(): void {
|
||
for (const [id, win] of this.windows) {
|
||
try {
|
||
win.destroy();
|
||
} catch {
|
||
log.warn(`Failed to close window: ${id}`);
|
||
}
|
||
}
|
||
this.windows.clear();
|
||
this.activeWindowId = null;
|
||
}
|
||
|
||
/**
|
||
* 获取窗口数量
|
||
*/
|
||
get count(): number {
|
||
return this.windows.size;
|
||
}
|
||
|
||
/**
|
||
* 获取应用图标路径
|
||
*/
|
||
private getIconPath(): string | undefined {
|
||
const candidates = [
|
||
join(__dirname, '../../assets/logo.ico'),
|
||
join(__dirname, '../../assets/logo.png'),
|
||
join(process.resourcesPath || '', 'app.asar.unpacked', 'assets/logo.ico'),
|
||
join(process.resourcesPath || '', 'app.asar.unpacked', 'assets/logo.png'),
|
||
join(process.resourcesPath || '', 'assets/logo.ico'),
|
||
join(process.resourcesPath || '', 'assets/logo.png'),
|
||
];
|
||
for (const p of candidates) {
|
||
if (existsSync(p)) return p;
|
||
}
|
||
return undefined;
|
||
}
|
||
}
|