- Electron + React + TypeScript 架构 - 三栏布局: Sidebar | ChatPanel | DetailPanel - 9 个内置工具 (文件系统/网络/记忆/命令) - SQLite 持久化 (better-sqlite3) - MUI 暗色/亮色主题系统 - Agent Loop ReAct 状态机引擎 - DeepSeek / Agnes AI / Ollama Provider 适配器 - MCP 协议集成 - 系统托盘 + 全局快捷键 - Tailwind CSS v4 + Tailwind Merge - 修复: Sidebar 缺失 TextField 导入导致黑屏
212 lines
5.0 KiB
TypeScript
212 lines
5.0 KiB
TypeScript
/**
|
||
* Tray Manager — 系统托盘管理
|
||
*
|
||
* 职责:
|
||
* 1. 创建系统托盘图标
|
||
* 2. 托盘右键菜单(新建会话、显示窗口、退出)
|
||
* 3. 托盘图标状态指示(空闲/思考中/执行中)
|
||
* 4. 点击托盘图标显示/隐藏窗口
|
||
*
|
||
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 窗口管理
|
||
*/
|
||
|
||
import { Tray, Menu, BrowserWindow, app, nativeImage, Notification } from 'electron';
|
||
import { join } from 'path';
|
||
import { existsSync } from 'fs';
|
||
import log from 'electron-log';
|
||
|
||
export type TrayStatus = 'idle' | 'thinking' | 'executing' | 'error';
|
||
|
||
export class TrayManager {
|
||
private tray: Tray | null = null;
|
||
private mainWindow: BrowserWindow | null = null;
|
||
private currentStatus: TrayStatus = 'idle';
|
||
private static isQuitting = false;
|
||
|
||
constructor(private resourcesPath: string) {}
|
||
|
||
/**
|
||
* 初始化系统托盘
|
||
*/
|
||
initialize(mainWindow: BrowserWindow): void {
|
||
this.mainWindow = mainWindow;
|
||
|
||
// 创建托盘图标
|
||
const iconPath = this.getTrayIconPath();
|
||
if (!iconPath) {
|
||
log.warn('Tray icon not found, skipping tray initialization');
|
||
return;
|
||
}
|
||
|
||
this.tray = new Tray(iconPath);
|
||
this.tray.setToolTip('MetonaAI Desktop');
|
||
|
||
// 构建右键菜单
|
||
this.updateContextMenu();
|
||
|
||
// 点击托盘图标:显示/隐藏窗口
|
||
this.tray.on('click', () => {
|
||
if (this.mainWindow) {
|
||
if (this.mainWindow.isVisible()) {
|
||
this.mainWindow.hide();
|
||
} else {
|
||
this.mainWindow.show();
|
||
this.mainWindow.focus();
|
||
}
|
||
}
|
||
});
|
||
|
||
// 窗口关闭时隐藏到托盘(不退出)
|
||
mainWindow.on('close', (event) => {
|
||
if (!TrayManager.isQuitting) {
|
||
event.preventDefault();
|
||
mainWindow.hide();
|
||
}
|
||
});
|
||
|
||
log.info('System tray initialized');
|
||
}
|
||
|
||
/**
|
||
* 更新托盘状态
|
||
*/
|
||
setStatus(status: TrayStatus): void {
|
||
if (!this.tray) return;
|
||
this.currentStatus = status;
|
||
|
||
const statusLabels: Record<TrayStatus, string> = {
|
||
idle: 'MetonaAI — 空闲',
|
||
thinking: 'MetonaAI — 思考中...',
|
||
executing: 'MetonaAI — 执行中...',
|
||
error: 'MetonaAI — 错误',
|
||
};
|
||
|
||
this.tray.setToolTip(statusLabels[status]);
|
||
this.updateContextMenu();
|
||
|
||
// 更新图标(如果有多状态图标)
|
||
this.updateTrayIcon(status);
|
||
}
|
||
|
||
/**
|
||
* 发送系统通知
|
||
*/
|
||
sendNotification(title: string, body: string, onClick?: () => void): void {
|
||
if (!Notification.isSupported()) return;
|
||
|
||
const notification = new Notification({
|
||
title,
|
||
body,
|
||
icon: this.getTrayIconPath() ?? undefined,
|
||
});
|
||
|
||
if (onClick) {
|
||
notification.on('click', onClick);
|
||
}
|
||
|
||
notification.show();
|
||
}
|
||
|
||
/**
|
||
* 销毁托盘
|
||
*/
|
||
destroy(): void {
|
||
if (this.tray) {
|
||
this.tray.destroy();
|
||
this.tray = null;
|
||
}
|
||
}
|
||
|
||
// ===== 私有方法 =====
|
||
|
||
/**
|
||
* 获取托盘图标路径
|
||
*/
|
||
private getTrayIconPath(): string | null {
|
||
// 优先使用 ico(Windows),其次 png(macOS/Linux)
|
||
const candidates = [
|
||
join(this.resourcesPath, 'logo.ico'),
|
||
join(this.resourcesPath, 'logo.png'),
|
||
join(this.resourcesPath, 'icon.ico'),
|
||
join(this.resourcesPath, 'icon.png'),
|
||
];
|
||
|
||
for (const candidate of candidates) {
|
||
if (existsSync(candidate)) return candidate;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 更新托盘图标(根据状态)
|
||
*/
|
||
private updateTrayIcon(status: TrayStatus): void {
|
||
if (!this.tray) return;
|
||
|
||
// 基础图标
|
||
const iconPath = this.getTrayIconPath();
|
||
if (!iconPath) return;
|
||
|
||
// 对于 thinking/executing 状态,可以使用 overlay 图标
|
||
// 当前实现使用基础图标,后续可扩展
|
||
const image = nativeImage.createFromPath(iconPath);
|
||
this.tray.setImage(image.resize({ width: 16, height: 16 }));
|
||
}
|
||
|
||
/**
|
||
* 更新右键菜单
|
||
*/
|
||
private updateContextMenu(): void {
|
||
if (!this.tray) return;
|
||
|
||
const statusIcons: Record<TrayStatus, string> = {
|
||
idle: '⚪',
|
||
thinking: '🔵',
|
||
executing: '🟢',
|
||
error: '🔴',
|
||
};
|
||
|
||
const contextMenu = Menu.buildFromTemplate([
|
||
{
|
||
label: `MetonaAI Desktop`,
|
||
enabled: false,
|
||
},
|
||
{
|
||
label: `状态: ${statusIcons[this.currentStatus]} ${this.currentStatus}`,
|
||
enabled: false,
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: '显示窗口',
|
||
click: () => {
|
||
if (this.mainWindow) {
|
||
this.mainWindow.show();
|
||
this.mainWindow.focus();
|
||
}
|
||
},
|
||
},
|
||
{
|
||
label: '新建会话',
|
||
click: () => {
|
||
if (this.mainWindow) {
|
||
this.mainWindow.show();
|
||
this.mainWindow.focus();
|
||
this.mainWindow.webContents.send('tray:newSession');
|
||
}
|
||
},
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: '退出',
|
||
click: () => {
|
||
TrayManager.isQuitting = true;
|
||
app.quit();
|
||
},
|
||
},
|
||
]);
|
||
|
||
this.tray.setContextMenu(contextMenu);
|
||
}
|
||
}
|