feat: MetonaAI Desktop 初始项目
- 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 导入导致黑屏
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user