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,7 @@
|
||||
/**
|
||||
* className 合并工具
|
||||
*
|
||||
* @see standard/开发规范.md — 第一铁律
|
||||
*/
|
||||
|
||||
export { default as cn } from 'clsx';
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 常量定义
|
||||
*
|
||||
* 快捷键映射、状态颜色、消息类型等全局常量。
|
||||
*/
|
||||
|
||||
// ===== 布局尺寸 =====
|
||||
|
||||
export const LAYOUT = {
|
||||
HEADER_HEIGHT: 40,
|
||||
STATUS_BAR_HEIGHT: 24,
|
||||
SIDEBAR_WIDTH: 260,
|
||||
DETAIL_WIDTH: 320,
|
||||
CHAT_MAX_WIDTH: 768,
|
||||
} as const;
|
||||
|
||||
// ===== Agent 状态颜色 =====
|
||||
|
||||
export const AGENT_STATUS_COLORS = {
|
||||
idle: 'var(--green)',
|
||||
thinking: 'var(--amber)',
|
||||
executing: 'var(--purple)',
|
||||
error: 'var(--red)',
|
||||
} as const;
|
||||
|
||||
export const AGENT_STATUS_LABELS = {
|
||||
idle: 'Agent Ready',
|
||||
thinking: '思考中...',
|
||||
executing: '执行中...',
|
||||
error: '错误',
|
||||
} as const;
|
||||
|
||||
// ===== Trace 步骤状态 =====
|
||||
|
||||
export const TRACE_STATE_COLORS: Record<string, string> = {
|
||||
INIT: 'var(--cyan)',
|
||||
THINKING: 'var(--amber)',
|
||||
PARSING: 'var(--orange)',
|
||||
EXECUTING: 'var(--purple)',
|
||||
OBSERVING: 'var(--cyan)',
|
||||
REFLECTING: 'var(--purple)',
|
||||
COMPRESSING: 'var(--amber)',
|
||||
TERMINATED: 'var(--green)',
|
||||
};
|
||||
|
||||
export const TRACE_STATE_LABELS: Record<string, string> = {
|
||||
INIT: '初始化',
|
||||
THINKING: '思考',
|
||||
PARSING: '解析',
|
||||
EXECUTING: '执行',
|
||||
OBSERVING: '观察',
|
||||
REFLECTING: '反思',
|
||||
COMPRESSING: '压缩',
|
||||
TERMINATED: '终止',
|
||||
};
|
||||
|
||||
// ===== 工具调用状态 =====
|
||||
|
||||
export const TOOL_CALL_STATUS_COLORS = {
|
||||
pending: 'var(--amber)',
|
||||
executing: 'var(--purple)',
|
||||
success: 'var(--green)',
|
||||
error: 'var(--red)',
|
||||
blocked: 'var(--orange)',
|
||||
} as const;
|
||||
|
||||
// ===== 快捷键定义 =====
|
||||
|
||||
export const SHORTCUTS = {
|
||||
NEW_SESSION: { key: 'n', ctrl: true },
|
||||
QUICK_SEARCH: { key: 'k', ctrl: true },
|
||||
SEND_MESSAGE: { key: 'Enter', ctrl: true },
|
||||
NEW_LINE: { key: 'Enter', ctrl: true, shift: true },
|
||||
ABORT: { key: '.', ctrl: true },
|
||||
FOCUS_MODE: { key: 'f', ctrl: true, shift: true },
|
||||
TOGGLE_SIDEBAR: { key: 'b', ctrl: true },
|
||||
TOGGLE_DETAIL: { key: 'j', ctrl: true },
|
||||
OPEN_SETTINGS: { key: ',', ctrl: true },
|
||||
PREV_SESSION: { key: '[', ctrl: true },
|
||||
NEXT_SESSION: { key: ']', ctrl: true },
|
||||
CLOSE_POPUP: { key: 'Escape' },
|
||||
FOCUS_INPUT: { key: 'l', ctrl: true },
|
||||
COPY_LAST_REPLY: { key: 'c', ctrl: true, shift: true },
|
||||
TOGGLE_THEME: { key: 'd', ctrl: true },
|
||||
} as const;
|
||||
|
||||
// ===== Provider 信息 =====
|
||||
|
||||
export const PROVIDER_LABELS: Record<string, string> = {
|
||||
deepseek: 'DeepSeek',
|
||||
agnes: 'Agnes AI',
|
||||
ollama: 'Ollama',
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 格式化工具函数
|
||||
*
|
||||
* 日期格式化使用 date-fns(成熟第三方库,禁止自写)。
|
||||
* Token 数、文件大小等简单格式化为 < 20 行纯函数,允许自写。
|
||||
*
|
||||
* @see standard/开发规范.md — 第一铁律
|
||||
*/
|
||||
|
||||
import { formatDistanceToNow, format } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
|
||||
// ===== 日期格式化(使用 date-fns)=====
|
||||
|
||||
/** 相对时间(如 "3 分钟前"、"昨天") */
|
||||
export function formatRelativeTime(timestamp: number): string {
|
||||
return formatDistanceToNow(new Date(timestamp), { addSuffix: true, locale: zhCN });
|
||||
}
|
||||
|
||||
/** 短时间格式(如 "14:30") */
|
||||
export function formatTime(timestamp: number): string {
|
||||
return format(new Date(timestamp), 'HH:mm');
|
||||
}
|
||||
|
||||
// ===== Token 格式化(< 20 行纯函数,允许自写)=====
|
||||
|
||||
export function formatTokens(count: number): string {
|
||||
if (count < 1000) return String(count);
|
||||
if (count < 10000) return `${(count / 1000).toFixed(1)}K`;
|
||||
if (count < 1000000) return `${Math.round(count / 1000)}K`;
|
||||
return `${(count / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
|
||||
// ===== 文件大小格式化(< 20 行纯函数,允许自写)=====
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
// ===== 耗时格式化(< 20 行纯函数,允许自写)=====
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = Math.round((ms % 60000) / 1000);
|
||||
return `${minutes}m${seconds}s`;
|
||||
}
|
||||
|
||||
// ===== 截断文本(< 20 行纯函数,允许自写)=====
|
||||
|
||||
export function truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength - 1) + '…';
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* IPC Client — 渲染进程 IPC 封装
|
||||
*
|
||||
* 为渲染进程提供类型安全的 IPC 调用封装。
|
||||
* 所有调用通过 window.metona 桥接层(由 preload.ts contextBridge 暴露)。
|
||||
*
|
||||
* @see electron/preload.ts — contextBridge 安全暴露
|
||||
* @see src/types/global.d.ts — 类型声明
|
||||
*/
|
||||
|
||||
import type { MetonaBridge, MetonaSessionInfo, MetonaStreamEventData, MetonaMCPServerStatus, MetonaMemorySearchResult } from '@renderer/types/global';
|
||||
|
||||
// ===== 桥接层访问 =====
|
||||
|
||||
function getBridge(): MetonaBridge {
|
||||
if (!window.metona) {
|
||||
throw new Error('Metona bridge not available. Ensure preload script is loaded.');
|
||||
}
|
||||
return window.metona;
|
||||
}
|
||||
|
||||
// ===== Agent API =====
|
||||
|
||||
export const agentAPI = {
|
||||
/**
|
||||
* 发送用户消息到 Agent
|
||||
*/
|
||||
sendMessage(message: { role: 'user'; content: string; timestamp: number }, sessionId: string): Promise<{ success: boolean }> {
|
||||
return getBridge().agent.sendMessage(message, sessionId);
|
||||
},
|
||||
|
||||
/**
|
||||
* 监听流式事件
|
||||
* @returns 取消监听函数
|
||||
*/
|
||||
onStreamEvent(callback: (event: MetonaStreamEventData) => void): () => void {
|
||||
return getBridge().agent.onStreamEvent(callback);
|
||||
},
|
||||
|
||||
/**
|
||||
* 监听状态变化
|
||||
* @returns 取消监听函数
|
||||
*/
|
||||
onStateChange(callback: (state: { sessionId: string; state: string }) => void): () => void {
|
||||
return getBridge().agent.onStateChange(callback);
|
||||
},
|
||||
|
||||
/**
|
||||
* 中断当前会话
|
||||
*/
|
||||
abortSession(sessionId: string): Promise<{ success: boolean }> {
|
||||
return getBridge().agent.abortSession(sessionId);
|
||||
},
|
||||
|
||||
/**
|
||||
* 监听 Provider 切换通知
|
||||
* @returns 取消监听函数
|
||||
*/
|
||||
onProviderSwitched(callback: (data: { from?: string; to?: string; reason?: string }) => void): () => void {
|
||||
return getBridge().agent.onProviderSwitched(callback);
|
||||
},
|
||||
};
|
||||
|
||||
// ===== Sessions API =====
|
||||
|
||||
export const sessionsAPI = {
|
||||
list(): Promise<MetonaSessionInfo[]> {
|
||||
return getBridge().sessions.list();
|
||||
},
|
||||
|
||||
create(title?: string): Promise<MetonaSessionInfo> {
|
||||
return getBridge().sessions.create(title);
|
||||
},
|
||||
|
||||
rename(sessionId: string, title: string): Promise<{ success: boolean }> {
|
||||
return getBridge().sessions.rename(sessionId, title);
|
||||
},
|
||||
|
||||
delete(sessionId: string): Promise<{ success: boolean }> {
|
||||
return getBridge().sessions.delete(sessionId);
|
||||
},
|
||||
|
||||
getMessages(sessionId: string): Promise<Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
reasoningContent?: string;
|
||||
toolCalls?: unknown[];
|
||||
timestamp: number;
|
||||
}>> {
|
||||
return getBridge().sessions.getMessages(sessionId);
|
||||
},
|
||||
|
||||
pin(sessionId: string, pinned: boolean): Promise<{ success: boolean }> {
|
||||
return getBridge().sessions.pin(sessionId, pinned);
|
||||
},
|
||||
};
|
||||
|
||||
// ===== MCP API =====
|
||||
|
||||
export const mcpAPI = {
|
||||
listServers(): Promise<MetonaMCPServerStatus[]> {
|
||||
return getBridge().mcp.listServers();
|
||||
},
|
||||
|
||||
addServer(config: { name: string; transport: 'stdio' | 'sse'; command?: string; args?: string[]; url?: string }): Promise<{ success: boolean }> {
|
||||
return getBridge().mcp.addServer(config);
|
||||
},
|
||||
|
||||
removeServer(name: string): Promise<{ success: boolean }> {
|
||||
return getBridge().mcp.removeServer(name);
|
||||
},
|
||||
|
||||
toggleServer(name: string, enabled: boolean): Promise<{ success: boolean }> {
|
||||
return getBridge().mcp.toggleServer(name, enabled);
|
||||
},
|
||||
};
|
||||
|
||||
// ===== Memory API =====
|
||||
|
||||
export const memoryAPI = {
|
||||
search(query: string, options?: {
|
||||
topK?: number;
|
||||
type?: 'episodic' | 'semantic' | 'working';
|
||||
minImportance?: number;
|
||||
}): Promise<MetonaMemorySearchResult[]> {
|
||||
return getBridge().memory.search(query, options);
|
||||
},
|
||||
};
|
||||
|
||||
// ===== Config API =====
|
||||
|
||||
export const configAPI = {
|
||||
get<T = unknown>(key: string): Promise<T | null> {
|
||||
return getBridge().config.get(key) as Promise<T | null>;
|
||||
},
|
||||
|
||||
set(key: string, value: unknown): Promise<{ success: boolean }> {
|
||||
return getBridge().config.set(key, value);
|
||||
},
|
||||
};
|
||||
|
||||
// ===== App API =====
|
||||
|
||||
export const appAPI = {
|
||||
getVersion(): Promise<string> {
|
||||
return getBridge().app.getVersion();
|
||||
},
|
||||
|
||||
getAppDataPath(): Promise<string> {
|
||||
return getBridge().app.getAppDataPath();
|
||||
},
|
||||
|
||||
openExternal(url: string): Promise<void> {
|
||||
return getBridge().app.openExternal(url);
|
||||
},
|
||||
|
||||
showItemInFolder(path: string): void {
|
||||
getBridge().app.showItemInFolder(path);
|
||||
},
|
||||
};
|
||||
|
||||
// ===== Toast API =====
|
||||
|
||||
export const toastAPI = {
|
||||
/**
|
||||
* 监听主进程 Toast 通知桥接
|
||||
* @returns 取消监听函数
|
||||
*/
|
||||
onShow(callback: (data: {
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
options?: Record<string, unknown>;
|
||||
}) => void): () => void {
|
||||
return getBridge().toast.onShow(callback);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* MUI 主题配置
|
||||
*
|
||||
* 匹配 Metona 暗色/亮色配色方案。
|
||||
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 主题系统
|
||||
*/
|
||||
|
||||
import { createTheme, type ThemeOptions } from '@mui/material/styles';
|
||||
|
||||
const darkTheme: ThemeOptions = {
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: { main: '#818cf8' },
|
||||
secondary: { main: '#252836' },
|
||||
error: { main: '#f87171' },
|
||||
warning: { main: '#fbbf24' },
|
||||
info: { main: '#22d3ee' },
|
||||
success: { main: '#34d399' },
|
||||
background: {
|
||||
default: '#0f1117',
|
||||
paper: '#1a1d27',
|
||||
},
|
||||
text: {
|
||||
primary: '#e1e4ed',
|
||||
secondary: '#64748b',
|
||||
disabled: '#8b8fa7',
|
||||
},
|
||||
divider: '#2a2d3a',
|
||||
},
|
||||
typography: {
|
||||
fontFamily: `-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif`,
|
||||
fontSize: 13,
|
||||
h6: { fontSize: 14, fontWeight: 600 },
|
||||
body2: { fontSize: 12, color: '#8b8fa7' },
|
||||
caption: { fontSize: 11, color: '#8b8fa7' },
|
||||
},
|
||||
shape: { borderRadius: 10 },
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: { textTransform: 'none' as const, fontWeight: 500, fontSize: 12, borderRadius: 8 },
|
||||
sizeSmall: { height: 28, padding: '0 12px' },
|
||||
},
|
||||
},
|
||||
MuiTextField: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
fontSize: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#252836',
|
||||
'& fieldset': { borderColor: '#2a2d3a' },
|
||||
'&:hover fieldset': { borderColor: '#818cf8' },
|
||||
'&.Mui-focused fieldset': { borderColor: '#818cf8' },
|
||||
},
|
||||
'& .MuiInputLabel-root': { fontSize: 12 },
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDialog: {
|
||||
styleOverrides: {
|
||||
paper: { backgroundImage: 'none', backgroundColor: '#1a1d27', border: '1px solid #2a2d3a' },
|
||||
},
|
||||
},
|
||||
MuiSelect: {
|
||||
styleOverrides: {
|
||||
root: { fontSize: 12, borderRadius: 8, backgroundColor: '#252836' },
|
||||
},
|
||||
},
|
||||
MuiTabs: {
|
||||
styleOverrides: {
|
||||
root: { minHeight: 36 },
|
||||
indicator: { height: 2 },
|
||||
},
|
||||
},
|
||||
MuiTab: {
|
||||
styleOverrides: {
|
||||
root: { textTransform: 'none' as const, fontSize: 12, minHeight: 36 },
|
||||
},
|
||||
},
|
||||
MuiTooltip: {
|
||||
styleOverrides: { tooltip: { fontSize: 11, backgroundColor: '#252836', border: '1px solid #2a2d3a' } },
|
||||
},
|
||||
MuiMenu: {
|
||||
styleOverrides: { paper: { backgroundImage: 'none', backgroundColor: '#1a1d27', border: '1px solid #2a2d3a' } },
|
||||
},
|
||||
MuiMenuItem: {
|
||||
styleOverrides: { root: { fontSize: 12 } },
|
||||
},
|
||||
MuiBadge: {
|
||||
styleOverrides: { standard: { fontSize: 10 } },
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: { root: { backgroundImage: 'none', backgroundColor: '#1a1d27', border: '1px solid #2a2d3a' } },
|
||||
},
|
||||
MuiSlider: {
|
||||
styleOverrides: { root: { color: '#818cf8' } },
|
||||
},
|
||||
MuiSwitch: {
|
||||
styleOverrides: { root: { '& .MuiSwitch-switchBase.Mui-checked': { color: '#818cf8' } } },
|
||||
},
|
||||
MuiCheckbox: {
|
||||
styleOverrides: { root: { color: '#8b8fa7', '&.Mui-checked': { color: '#818cf8' } } },
|
||||
},
|
||||
MuiDivider: {
|
||||
styleOverrides: { root: { borderColor: '#2a2d3a' } },
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: { root: { backgroundImage: 'none' } },
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: { root: { fontSize: 10 } },
|
||||
},
|
||||
MuiIconButton: {
|
||||
styleOverrides: { root: { borderRadius: 8 } },
|
||||
},
|
||||
MuiAvatar: {
|
||||
styleOverrides: { root: { width: 32, height: 32, fontSize: 14 } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const lightTheme: ThemeOptions = {
|
||||
...darkTheme,
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: { main: '#6366f1' },
|
||||
secondary: { main: '#f1f5f9' },
|
||||
error: { main: '#dc2626' },
|
||||
warning: { main: '#d97706' },
|
||||
info: { main: '#0891b2' },
|
||||
success: { main: '#059669' },
|
||||
background: { default: '#ffffff', paper: '#f8fafc' },
|
||||
text: { primary: '#0f172a', secondary: '#334155', disabled: '#64748b' },
|
||||
divider: '#e2e8f0',
|
||||
},
|
||||
components: {
|
||||
...darkTheme.components,
|
||||
MuiTextField: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
fontSize: 12, borderRadius: 8, backgroundColor: '#f1f5f9',
|
||||
'& fieldset': { borderColor: '#e2e8f0' },
|
||||
'&:hover fieldset': { borderColor: '#6366f1' },
|
||||
'&.Mui-focused fieldset': { borderColor: '#6366f1' },
|
||||
},
|
||||
'& .MuiInputLabel-root': { fontSize: 12 },
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDialog: {
|
||||
styleOverrides: { paper: { backgroundImage: 'none', backgroundColor: '#f8fafc', border: '1px solid #e2e8f0' } },
|
||||
},
|
||||
MuiMenu: {
|
||||
styleOverrides: { paper: { backgroundImage: 'none', backgroundColor: '#f8fafc', border: '1px solid #e2e8f0' } },
|
||||
},
|
||||
MuiTooltip: {
|
||||
styleOverrides: { tooltip: { fontSize: 11, backgroundColor: '#f1f5f9', border: '1px solid #e2e8f0', color: '#0f172a' } },
|
||||
},
|
||||
MuiSelect: {
|
||||
styleOverrides: { root: { fontSize: 12, borderRadius: 8, backgroundColor: '#f1f5f9' } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const metonaDarkTheme = createTheme(darkTheme);
|
||||
export const metonaLightTheme = createTheme(lightTheme);
|
||||
Reference in New Issue
Block a user