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:
thzxx
2026-06-27 21:33:27 +08:00
commit 1d185db6b3
109 changed files with 39155 additions and 0 deletions
+220
View File
@@ -0,0 +1,220 @@
/**
* useAgentStream — Agent 流式事件监听 Hook
*
* 监听 window.metona.agent.onStreamEvent,将事件分发到 Zustand Store。
* 负责流式文本追加、工具调用状态更新、思考内容处理。
*/
import { useEffect, useRef } from 'react';
import { useAgentStore, type ToolCallInfo } from '@renderer/stores/agent-store';
import { useSessionStore } from '@renderer/stores/session-store';
/**
* Agent 流式事件监听 Hook
*
* 在 App 根组件调用一次,自动监听当前会话的流式事件。
*/
export function useAgentStream(): void {
const cleanupRef = useRef<(() => void) | null>(null);
useEffect(() => {
// 检查 IPC 桥是否可用
if (!window.metona?.agent?.onStreamEvent) return;
const unsubscribe = window.metona.agent.onStreamEvent((event: unknown) => {
const data = event as {
type?: string;
requestId?: string;
sessionId?: string;
iteration?: number;
delta?: string;
content?: string;
toolCall?: { id: string; name: string; args: Record<string, unknown> };
toolResult?: { toolCallId: string; success: boolean; result?: unknown; error?: string; durationMs?: number };
usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number };
error?: { code: string; message: string };
state?: string;
};
const store = useAgentStore.getState();
switch (data.type) {
// 思考开始
case 'thinking_start':
store.setAgentStatus('thinking');
store.addTraceStep({
iteration: data.iteration ?? store.currentIteration + 1,
state: 'THINKING',
startedAt: Date.now(),
});
break;
// 推理内容增量
case 'reasoning_delta':
if (data.delta) {
const messages = store.messages;
const lastMsg = messages[messages.length - 1];
if (lastMsg?.role === 'assistant') {
// 追加到最后一条 assistant 消息
store.updateMessage(lastMsg.id, {
reasoningContent: (lastMsg.reasoningContent ?? '') + data.delta,
});
} else {
// 还没有 assistant 消息,先创建一条(仅含思考内容)
store.addMessage({
id: `msg_${Date.now()}_assistant`,
role: 'assistant',
content: '',
reasoningContent: data.delta,
timestamp: Date.now(),
});
}
}
break;
// 思考结束
case 'thinking_end':
if (data.iteration) {
store.updateTraceStep(data.iteration, { completedAt: Date.now() });
}
break;
// 文本增量
case 'text_delta':
if (data.delta) {
store.setStreaming(true);
store.updateLastAssistantMessage(data.delta);
}
break;
// 工具调用完成
case 'tool_call_complete':
if (data.toolCall) {
const tc: ToolCallInfo = {
id: data.toolCall.id,
name: data.toolCall.name,
args: data.toolCall.args,
status: 'pending',
};
// 追加到当前 assistant 消息
const messages = store.messages;
const lastMsg = messages[messages.length - 1];
if (lastMsg?.role === 'assistant') {
store.updateMessage(lastMsg.id, {
toolCalls: [...(lastMsg.toolCalls ?? []), tc],
});
}
store.setAgentStatus('executing');
store.addTraceStep({
iteration: data.iteration ?? store.currentIteration,
state: 'EXECUTING',
startedAt: Date.now(),
toolCalls: [tc],
});
}
break;
// 工具执行结果
case 'tool_result': {
if (data.toolResult) {
const messages = store.messages;
const lastMsg = messages[messages.length - 1];
if (lastMsg?.toolCalls) {
const updatedToolCalls = lastMsg.toolCalls.map((tc) =>
tc.id === data.toolResult!.toolCallId
? {
...tc,
status: data.toolResult!.success ? 'success' as const : 'error' as const,
result: data.toolResult!.result,
error: data.toolResult!.error,
durationMs: data.toolResult!.durationMs,
}
: tc,
);
store.updateMessage(lastMsg.id, { toolCalls: updatedToolCalls });
}
}
break;
}
// Token 使用统计
case 'usage':
if (data.usage) {
store.updateTokenUsage({
inputTokens: data.usage.inputTokens ?? 0,
outputTokens: data.usage.outputTokens ?? 0,
totalTokens: data.usage.totalTokens ?? 0,
});
}
break;
// 流结束
case 'done':
store.setStreaming(false);
store.setAgentStatus('idle');
// 保存 trace 数据到数据库
store.saveTraceData();
break;
// 错误
case 'error':
store.setStreaming(false);
store.setAgentStatus('error');
store.addMessage({
id: `msg_${Date.now()}_error`,
role: 'system',
content: `错误: ${data.error?.message ?? '未知错误'}`,
timestamp: Date.now(),
});
break;
// 状态变化
case 'state_change':
if (data.state) {
store.addTraceStep({
iteration: data.iteration ?? store.currentIteration,
state: data.state,
startedAt: Date.now(),
});
}
break;
}
});
cleanupRef.current = unsubscribe;
return () => {
cleanupRef.current?.();
};
}, []);
// 监听状态变化事件
useEffect(() => {
if (!window.metona?.agent?.onStateChange) return;
const unsubscribe = window.metona.agent.onStateChange((state: unknown) => {
// 状态变化由主进程推送,UI 已通过 streamEvent 处理
});
return () => unsubscribe();
}, []);
// 监听 Provider 切换通知
useEffect(() => {
if (!window.metona?.agent?.onProviderSwitched) return;
const unsubscribe = window.metona.agent.onProviderSwitched((data: unknown) => {
const { from, to, reason } = data as { from?: string; to?: string; reason?: string };
useAgentStore.getState().addMessage({
id: `msg_${Date.now()}_system`,
role: 'system',
content: `Provider 已切换: ${from ?? '未知'}${to ?? '未知'}${reason ? ` (${reason})` : ''}`,
timestamp: Date.now(),
});
});
return () => unsubscribe();
}, []);
}
+136
View File
@@ -0,0 +1,136 @@
/**
* useKeyboardShortcuts — 全局快捷键 Hook
*
* 注册全局键盘快捷键,处理 Ctrl/Cmd 修饰键。
*/
import { useEffect } from 'react';
import { useUIStore } from '@renderer/stores/ui-store';
import { useAgentStore } from '@renderer/stores/agent-store';
import { useSessionStore } from '@renderer/stores/session-store';
import { toggleTheme } from './useTheme';
/**
* 检测是否为 macOS
*/
function isMac(): boolean {
return navigator.platform.toUpperCase().indexOf('MAC') >= 0;
}
/**
* 检查修饰键是否匹配
*/
function matchModifier(event: KeyboardEvent, ctrl?: boolean, shift?: boolean): boolean {
const modKey = isMac() ? event.metaKey : event.ctrlKey;
if (ctrl && !modKey) return false;
if (!ctrl && modKey) return false;
if (shift && !event.shiftKey) return false;
if (!shift && event.shiftKey) return false;
return true;
}
/**
* 全局快捷键 Hook
*
* 在 App 根组件调用一次即可。
*/
export function useKeyboardShortcuts(): void {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
const key = e.key.toLowerCase();
// Esc — 关闭弹窗
if (key === 'escape') {
const { settingsOpen, closeSettings } = useUIStore.getState();
if (settingsOpen) {
closeSettings();
e.preventDefault();
return;
}
}
// Ctrl+N — 新建会话
if (key === 'n' && matchModifier(e, true)) {
if (window.metona?.sessions?.create) {
window.metona.sessions.create().then((result: any) => {
useSessionStore.getState().addSession(result);
useSessionStore.getState().setCurrentSession(result.id);
}).catch(() => {});
}
e.preventDefault();
return;
}
// Ctrl+L — 聚焦输入框
if (key === 'l' && matchModifier(e, true)) {
const input = document.querySelector<HTMLTextAreaElement>('[data-chat-input]');
input?.focus();
e.preventDefault();
return;
}
// Ctrl+D — 切换主题
if (key === 'd' && matchModifier(e, true)) {
toggleTheme();
e.preventDefault();
return;
}
// Ctrl+, — 打开设置
if (key === ',' && matchModifier(e, true)) {
useUIStore.getState().openSettings();
e.preventDefault();
return;
}
// Ctrl+. — 中断 Agent
if (key === '.' && matchModifier(e, true)) {
useAgentStore.getState().abort();
e.preventDefault();
return;
}
// Ctrl+K — 快速搜索(CommandPalette 自身监听此快捷键)
if (key === 'k' && matchModifier(e, true)) {
e.preventDefault();
return;
}
// Ctrl+[ — 上一个会话
if (key === '[' && matchModifier(e, true)) {
const { sessions, currentSessionId, setCurrentSession } = useSessionStore.getState();
if (sessions.length > 0 && currentSessionId) {
const idx = sessions.findIndex((s) => s.id === currentSessionId);
if (idx > 0) setCurrentSession(sessions[idx - 1].id);
}
e.preventDefault();
return;
}
// Ctrl+] — 下一个会话
if (key === ']' && matchModifier(e, true)) {
const { sessions, currentSessionId, setCurrentSession } = useSessionStore.getState();
if (sessions.length > 0 && currentSessionId) {
const idx = sessions.findIndex((s) => s.id === currentSessionId);
if (idx < sessions.length - 1) setCurrentSession(sessions[idx + 1].id);
}
e.preventDefault();
return;
}
// Ctrl+Shift+C — 复制最后一条 Agent 回复
if (key === 'c' && matchModifier(e, true, true)) {
const { messages } = useAgentStore.getState();
const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
if (lastAssistant?.content) {
navigator.clipboard.writeText(lastAssistant.content).catch(() => {});
}
e.preventDefault();
return;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
}
+73
View File
@@ -0,0 +1,73 @@
/**
* useTheme — 主题切换 Hook
*
* 管理主题切换逻辑:监听系统 prefers-color-scheme,同步 data-theme 属性。
* 支持 dark / light / auto 三种模式。
*/
import { useEffect } from 'react';
import { useUIStore, type ThemeMode } from '@renderer/stores/ui-store';
/**
* 应用主题到 DOM
*/
function applyTheme(resolved: 'dark' | 'light'): void {
document.documentElement.setAttribute('data-theme', resolved);
}
/**
* 解析系统主题
*/
function getSystemTheme(): 'dark' | 'light' {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
/**
* 主题切换 Hook
*
* 在 App 根组件调用一次即可。
*/
export function useTheme(): void {
const theme = useUIStore((s) => s.theme);
const setResolvedTheme = useUIStore((s) => s.setResolvedTheme);
// 初始化:从 localStorage 恢复主题偏好
useEffect(() => {
const saved = localStorage.getItem('metona-theme') as ThemeMode | null;
if (saved && ['dark', 'light', 'auto'].includes(saved)) {
useUIStore.getState().setTheme(saved);
}
}, []);
// 监听系统主题变化
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (): void => {
if (theme === 'auto') {
const resolved = getSystemTheme();
applyTheme(resolved);
setResolvedTheme(resolved);
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme, setResolvedTheme]);
// 主题变化时应用
useEffect(() => {
const resolved = theme === 'auto' ? getSystemTheme() : theme;
applyTheme(resolved);
setResolvedTheme(resolved);
localStorage.setItem('metona-theme', theme);
}, [theme, setResolvedTheme]);
}
/**
* 切换主题(dark ↔ light
*/
export function toggleTheme(): void {
const { resolvedTheme, setTheme } = useUIStore.getState();
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
}