Files
metona-ai-desktop/electron/services/tray-manager.service.ts
T
thzxx 1d185db6b3 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 导入导致黑屏
2026-06-27 21:33:27 +08:00

212 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 {
// 优先使用 icoWindows),其次 pngmacOS/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);
}
}