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:
+72
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* MetonaAI Desktop — 根组件
|
||||
*
|
||||
* 三栏布局:Header + [Sidebar | Chat | DetailPanel] + StatusBar
|
||||
* + ToastContainer + SettingsModal + OnboardingWizard
|
||||
*
|
||||
* 连接所有 Store,注册快捷键,初始化主题。
|
||||
*/
|
||||
|
||||
import { ThemeProvider, CssBaseline } from '@mui/material';
|
||||
import { useEffect } from 'react';
|
||||
import { Header } from '@renderer/components/layout/Header';
|
||||
import { Sidebar } from '@renderer/components/layout/Sidebar';
|
||||
import { DetailPanel } from '@renderer/components/layout/DetailPanel';
|
||||
import { StatusBar } from '@renderer/components/layout/StatusBar';
|
||||
import { ChatPanel } from '@renderer/components/chat/ChatPanel';
|
||||
import { SettingsModal } from '@renderer/components/settings/SettingsModal';
|
||||
import { OnboardingWizard } from '@renderer/components/onboarding/OnboardingWizard';
|
||||
import { CommandPalette } from '@renderer/components/CommandPalette';
|
||||
import { ToastContainer } from '@renderer/components/ToastContainer';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts';
|
||||
import { useAgentStream } from '@renderer/hooks/useAgentStream';
|
||||
import { useUIStore } from '@renderer/stores/ui-store';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { metonaDarkTheme, metonaLightTheme } from '@renderer/lib/theme';
|
||||
|
||||
export default function App(): React.JSX.Element {
|
||||
useTheme();
|
||||
useKeyboardShortcuts();
|
||||
useAgentStream();
|
||||
|
||||
const resolvedTheme = useUIStore((s) => s.resolvedTheme);
|
||||
const muiTheme = resolvedTheme === 'dark' ? metonaDarkTheme : metonaLightTheme;
|
||||
|
||||
// 启动时从数据库恢复状态
|
||||
useEffect(() => {
|
||||
if (window.metona?.config?.get) {
|
||||
window.metona.config.get('onboarding.completed').then((v) => {
|
||||
if (v === true) useUIStore.getState().setOnboardingCompleted(true);
|
||||
}).catch(() => {});
|
||||
Promise.all([
|
||||
window.metona.config.get('llm.provider'),
|
||||
window.metona.config.get('llm.model'),
|
||||
]).then(([provider, model]) => {
|
||||
if (provider || model) useAgentStore.getState().setProvider(provider ?? '', model ?? '');
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={muiTheme}>
|
||||
<CssBaseline />
|
||||
<div
|
||||
className="h-screen w-screen flex flex-col overflow-hidden select-none"
|
||||
style={{ background: muiTheme.palette.background.default, color: muiTheme.palette.text.primary }}
|
||||
>
|
||||
<Header />
|
||||
<div className="flex-1 flex overflow-hidden min-h-0">
|
||||
<Sidebar />
|
||||
<ChatPanel />
|
||||
<DetailPanel />
|
||||
</div>
|
||||
<StatusBar />
|
||||
<SettingsModal />
|
||||
<OnboardingWizard />
|
||||
<CommandPalette />
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* CommandPalette — 快速搜索面板 (Cmd+K)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Dialog, DialogContent, InputBase, List, ListItemButton, ListItemIcon, ListItemText, Typography, Box, Divider } from '@mui/material';
|
||||
import { Search, MessageSquare, Settings, Plus, Trash2 } from 'lucide-react';
|
||||
import { useUIStore } from '@renderer/stores/ui-store';
|
||||
import { useSessionStore } from '@renderer/stores/session-store';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
|
||||
interface CommandItem { id: string; icon: typeof Search; label: string; description?: string; action: () => void; }
|
||||
|
||||
export function CommandPalette(): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'k' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); setOpen((p) => !p); setQuery(''); setSelectedIndex(0); } };
|
||||
window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if (open) setTimeout(() => inputRef.current?.focus(), 50); }, [open]);
|
||||
|
||||
const getCommands = useCallback((): CommandItem[] => {
|
||||
const cmds: CommandItem[] = [
|
||||
{ id: 'new-session', icon: Plus, label: '新建会话', description: '创建一个新的对话会话', action: async () => { if (window.metona?.sessions?.create) { const r = await window.metona.sessions.create() as any; useSessionStore.getState().addSession(r); useSessionStore.getState().setCurrentSession(r.id); useAgentStore.getState().setCurrentSession(r.id); } setOpen(false); } },
|
||||
{ id: 'clear', icon: Trash2, label: '清空当前会话', action: () => { useAgentStore.getState().clearMessages(); setOpen(false); } },
|
||||
{ id: 'settings', icon: Settings, label: '打开设置', action: () => { useUIStore.getState().openSettings(); setOpen(false); } },
|
||||
];
|
||||
if (query) {
|
||||
const filtered = useSessionStore.getState().sessions.filter((s) => s.title.toLowerCase().includes(query.toLowerCase()));
|
||||
for (const s of filtered.slice(0, 5)) cmds.push({ id: `s-${s.id}`, icon: MessageSquare, label: s.title, description: `${s.messageCount} 条消息`, action: () => { useSessionStore.getState().setCurrentSession(s.id); useAgentStore.getState().setCurrentSession(s.id); setOpen(false); } });
|
||||
}
|
||||
return cmds;
|
||||
}, [query]);
|
||||
|
||||
const commands = getCommands();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth PaperProps={{ sx: { mt: '20vh', maxWidth: 520, borderRadius: 3, overflow: 'hidden' } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, px: 2, py: 1.5, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Search size={16} style={{ color: '#8b8fa7', flexShrink: 0 }} />
|
||||
<InputBase inputRef={inputRef} value={query} onChange={(e) => { setQuery(e.target.value); setSelectedIndex(0); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex((p) => Math.min(p + 1, commands.length - 1)); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex((p) => Math.max(p - 1, 0)); }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); commands[selectedIndex]?.action(); }
|
||||
}}
|
||||
placeholder="搜索会话、命令..." sx={{ flex: 1, fontSize: 13 }}
|
||||
/>
|
||||
</Box>
|
||||
<List sx={{ maxHeight: 300, overflowY: 'auto', py: 0.5 }}>
|
||||
{commands.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ textAlign: 'center', py: 3, display: 'block', color: 'text.secondary' }}>无匹配结果</Typography>
|
||||
) : commands.map((cmd, i) => {
|
||||
const Icon = cmd.icon;
|
||||
return (
|
||||
<ListItemButton key={cmd.id} selected={i === selectedIndex} onClick={cmd.action} sx={{ px: 2, py: 1.25 }}>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}><Icon size={14} style={{ color: '#818cf8' }} /></ListItemIcon>
|
||||
<ListItemText primary={<Typography variant="body2" sx={{ fontSize: 12 }}>{cmd.label}</Typography>} secondary={cmd.description ? <Typography variant="caption">{cmd.description}</Typography> : undefined} />
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
<Divider />
|
||||
<Box sx={{ display: 'flex', gap: 2, px: 2, py: 1, fontSize: 10, color: 'text.disabled' }}>
|
||||
<span>↑↓ 导航</span><span>↵ 选择</span><span>Esc 关闭</span>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* ContextMenu — 右键菜单组件
|
||||
*
|
||||
* 支持 5 种对象的右键菜单:消息、工具调用卡片、会话项、代码块、Trace 步骤。
|
||||
*
|
||||
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 右键菜单设计
|
||||
*/
|
||||
|
||||
import { Menu, MenuItem, ListItemIcon, ListItemText } from '@mui/material';
|
||||
import { Copy, Quote, Edit, Trash2, RotateCcw, Eye, Code, ExternalLink, Pin, Archive, FileDown } from 'lucide-react';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { useSessionStore } from '@renderer/stores/session-store';
|
||||
|
||||
export type ContextMenuType = 'message' | 'tool-call' | 'session' | 'code-block' | 'trace-step';
|
||||
|
||||
interface ContextMenuItem {
|
||||
id: string;
|
||||
icon: typeof Copy;
|
||||
label: string;
|
||||
action: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
type: ContextMenuType;
|
||||
x: number;
|
||||
y: number;
|
||||
onClose: () => void;
|
||||
items: ContextMenuItem[];
|
||||
}
|
||||
|
||||
export function ContextMenu({ x, y, onClose, items }: ContextMenuProps): React.JSX.Element {
|
||||
return (
|
||||
<Menu open onClose={onClose} anchorReference="anchorPosition" anchorPosition={{ top: y, left: x }} PaperProps={{ sx: { minWidth: 160 } }}>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<MenuItem key={item.id} onClick={() => { if (!item.disabled) { item.action(); onClose(); } }} disabled={item.disabled} dense>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}><Icon size={12} /></ListItemIcon>
|
||||
<ListItemText primaryTypographyProps={{ fontSize: 12 }}>{item.label}</ListItemText>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建右键菜单项的工厂函数
|
||||
*/
|
||||
export function createContextMenuItems(type: ContextMenuType, data?: unknown): ContextMenuItem[] {
|
||||
switch (type) {
|
||||
case 'message': {
|
||||
const content = (data as { content?: string })?.content ?? '';
|
||||
return [
|
||||
{ id: 'copy', icon: Copy, label: '复制', action: () => navigator.clipboard.writeText(content).catch(() => {}) },
|
||||
{ id: 'quote', icon: Quote, label: '引用回复', action: () => {
|
||||
const input = document.querySelector<HTMLTextAreaElement>('[data-chat-input]');
|
||||
if (input) { input.value = content.split('\n').map((l: string) => `> ${l}`).join('\n') + '\n\n'; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); }
|
||||
}},
|
||||
];
|
||||
}
|
||||
|
||||
case 'tool-call': {
|
||||
const tc = data as { args?: Record<string, unknown>; result?: unknown } | undefined;
|
||||
return [
|
||||
{ id: 'view-params', icon: Eye, label: '查看参数', action: () => { if (tc?.args) navigator.clipboard.writeText(JSON.stringify(tc.args, null, 2)).catch(() => {}); }},
|
||||
{ id: 'view-result', icon: Eye, label: '查看完整结果', action: () => { if (tc?.result) navigator.clipboard.writeText(JSON.stringify(tc.result, null, 2)).catch(() => {}); }},
|
||||
{ id: 'copy-result', icon: Copy, label: '复制结果', action: () => { if (tc?.result) navigator.clipboard.writeText(typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result)).catch(() => {}); }},
|
||||
{ id: 're-execute', icon: RotateCcw, label: '重新执行', action: () => {
|
||||
const m = [...useAgentStore.getState().messages].reverse().find((m) => m.role === 'user');
|
||||
if (m) useAgentStore.getState().sendMessage(m.content);
|
||||
}},
|
||||
];
|
||||
}
|
||||
|
||||
case 'session': {
|
||||
const sid = (data as { sessionId?: string })?.sessionId;
|
||||
return [
|
||||
{ id: 'rename', icon: Edit, label: '重命名', action: () => {
|
||||
if (sid) { const t = prompt('新会话名称:'); if (t?.trim()) { window.metona?.sessions.rename(sid, t.trim()); useSessionStore.getState().updateSession(sid, { title: t.trim() }); } }
|
||||
}},
|
||||
{ id: 'pin', icon: Pin, label: '置顶', action: () => {
|
||||
if (sid) { const s = useSessionStore.getState().sessions.find((x) => x.id === sid); if (s) { window.metona?.sessions.pin(sid, !s.pinned); useSessionStore.getState().pinSession(sid, !s.pinned); } }
|
||||
}},
|
||||
{ id: 'archive', icon: Archive, label: '归档', action: () => { if (sid) useSessionStore.getState().archiveSession(sid, true); }},
|
||||
{ id: 'export', icon: FileDown, label: '导出', action: () => {
|
||||
if (sid) window.metona?.sessions.getMessages(sid).then((msgs) => {
|
||||
const b = new Blob([JSON.stringify(msgs, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `session-${sid}.json`; a.click();
|
||||
}).catch(() => {});
|
||||
}},
|
||||
{ id: 'delete', icon: Trash2, label: '删除', action: () => {
|
||||
if (sid && confirm('确定删除此会话?')) { window.metona?.sessions.delete(sid); useSessionStore.getState().removeSession(sid); }
|
||||
}},
|
||||
];
|
||||
}
|
||||
|
||||
case 'code-block': {
|
||||
const code = (data as { code?: string })?.code;
|
||||
return [
|
||||
{ id: 'copy-code', icon: Copy, label: '复制代码', action: () => { if (code) navigator.clipboard.writeText(code).catch(() => {}); }},
|
||||
{ id: 'open-editor', icon: Code, label: '在编辑器中打开', action: () => { if (code) window.open(URL.createObjectURL(new Blob([code], { type: 'text/plain' })), '_blank'); }},
|
||||
];
|
||||
}
|
||||
|
||||
case 'trace-step': {
|
||||
const step = data as { thought?: string; toolCalls?: Array<{ name: string; args: Record<string, unknown> }> } | undefined;
|
||||
return [
|
||||
{ id: 'copy-thought', icon: Copy, label: '复制 Thought', action: () => { if (step?.thought) navigator.clipboard.writeText(step.thought).catch(() => {}); }},
|
||||
{ id: 'copy-params', icon: Copy, label: '复制工具参数', action: () => {
|
||||
if (step?.toolCalls) navigator.clipboard.writeText(step.toolCalls.map((tc) => `${tc.name}: ${JSON.stringify(tc.args, null, 2)}`).join('\n')).catch(() => {});
|
||||
}},
|
||||
{ id: 'export', icon: ExternalLink, label: '导出步骤详情', action: () => {
|
||||
if (step) { const b = new Blob([JSON.stringify(step, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `trace-step-${Date.now()}.json`; a.click(); }
|
||||
}},
|
||||
];
|
||||
}
|
||||
|
||||
default: return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* ToastContainer — Toast 通知容器
|
||||
*
|
||||
* 配置 metona-toast 并监听主进程通知桥接。
|
||||
*
|
||||
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — MetonaToast 集成方案
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Toast 容器组件
|
||||
*
|
||||
* 在 App 根组件中渲染,负责:
|
||||
* 1. 配置 MeToast 全局参数
|
||||
* 2. 监听主进程 toast:show 事件
|
||||
* 3. 桥接到 MeToast 显示
|
||||
*/
|
||||
export function ToastContainer(): null {
|
||||
useEffect(() => {
|
||||
// 动态导入 metona-toast 并配置
|
||||
import('metona-toast').then((mod) => {
|
||||
const MeToast = mod.default;
|
||||
|
||||
// 全局配置(设计规范指定的参数)
|
||||
MeToast.configure({
|
||||
position: 'top-right',
|
||||
duration: 4000,
|
||||
max: 6,
|
||||
theme: 'auto',
|
||||
animation: 'slide',
|
||||
pauseOnHover: true,
|
||||
closeOnClick: true,
|
||||
showProgress: true,
|
||||
draggable: true,
|
||||
locale: 'zh-CN',
|
||||
});
|
||||
|
||||
// 安装插件
|
||||
try {
|
||||
MeToast.use('keyboard'); // ESC 关闭所有
|
||||
MeToast.use('persistence'); // 配置持久化
|
||||
MeToast.use('accessibility'); // 屏幕阅读器
|
||||
} catch {
|
||||
// 插件可能已安装
|
||||
}
|
||||
}).catch(() => {
|
||||
// metona-toast 未安装时静默
|
||||
});
|
||||
|
||||
// 监听主进程通知桥接
|
||||
if (window.metona?.toast?.onShow) {
|
||||
const unsubscribe = window.metona.toast.onShow(async (data) => {
|
||||
try {
|
||||
const MeToast = (await import('metona-toast')).default;
|
||||
const { type, message, options } = data;
|
||||
switch (type) {
|
||||
case 'success': MeToast.success(message, options); break;
|
||||
case 'error': MeToast.error(message, options); break;
|
||||
case 'warning': MeToast.warning(message, options); break;
|
||||
case 'info': MeToast.info(message, options); break;
|
||||
default: MeToast.info(message, options);
|
||||
}
|
||||
} catch {
|
||||
// 静默
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* AssistantMessage — Agent 回复消息
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Box, Typography, Avatar, Stack, IconButton, Tooltip } from '@mui/material';
|
||||
import { Bot, Copy, Check } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import type { ChatMessage } from '@renderer/stores/agent-store';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { ThoughtBlock } from './ThoughtBlock';
|
||||
import { ToolCallCard } from './ToolCallCard';
|
||||
import { formatTime } from '@renderer/lib/formatters';
|
||||
import { ContextMenu, createContextMenuItems } from '@renderer/components/ContextMenu';
|
||||
|
||||
interface AssistantMessageProps { message: ChatMessage; isStreaming?: boolean; streamContent?: string; }
|
||||
|
||||
export function AssistantMessage({ message, isStreaming, streamContent }: AssistantMessageProps): React.JSX.Element {
|
||||
const content = isStreaming ? streamContent ?? '' : message.content;
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const sendMessage = useAgentStore((s) => s.sendMessage);
|
||||
|
||||
const handleRegenerate = useCallback(() => {
|
||||
const lastUserMsg = [...useAgentStore.getState().messages].reverse().find((m) => m.role === 'user');
|
||||
if (lastUserMsg) sendMessage(lastUserMsg.content);
|
||||
}, [sendMessage]);
|
||||
|
||||
const contextMenuItems = createContextMenuItems('message', { content }).map((item) => item.id === 'regenerate' ? { ...item, action: handleRegenerate } : item);
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={1.5} sx={{ animation: 'fadeInUp 300ms ease-out' }} onContextMenu={(e: React.MouseEvent) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }}>
|
||||
<Avatar sx={{ width: 32, height: 32, bgcolor: 'secondary.main', color: 'primary.main', flexShrink: 0 }}><Bot size={16} /></Avatar>
|
||||
<Box sx={{ flex: 1, minWidth: 0, maxWidth: 768, borderRadius: 3, px: 2, py: 1.5, bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider' }}>
|
||||
{message.reasoningContent && <ThoughtBlock content={message.reasoningContent} />}
|
||||
{message.toolCalls?.map((tc) => <ToolCallCard key={tc.id} toolCall={tc} />)}
|
||||
{content && (
|
||||
<Box className="prose-metona">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight, rehypeRaw]} components={{
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className ?? '');
|
||||
const codeStr = String(children).replace(/\n$/, '');
|
||||
if (!match) return <code className={className} {...props}>{children}</code>;
|
||||
return <CodeBlock language={match[1]} code={codeStr} />;
|
||||
},
|
||||
}}>{content}</ReactMarkdown>
|
||||
</Box>
|
||||
)}
|
||||
{isStreaming && !streamContent && <Box component="span" sx={{ display: 'inline-block', width: 8, height: 16, ml: 0.5, bgcolor: 'primary.main', animation: 'blink 1s step-end infinite' }} />}
|
||||
{!isStreaming && <Typography variant="caption" sx={{ mt: 0.5, display: 'block', color: 'text.disabled' }}>{formatTime(message.timestamp)}</Typography>}
|
||||
</Box>
|
||||
{contextMenu && <ContextMenu type="message" x={contextMenu.x} y={contextMenu.y} items={contextMenuItems} onClose={() => setContextMenu(null)} />}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ language, code }: { language: string; code: string }): React.JSX.Element {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = useCallback(async () => {
|
||||
try { await navigator.clipboard.writeText(code); } catch { const ta = document.createElement('textarea'); ta.value = code; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); }
|
||||
setCopied(true); setTimeout(() => setCopied(false), 2000);
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative', my: 1, '&:hover .copy-btn': { opacity: 1 } }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ px: 1.5, py: 0.75, borderTopLeftRadius: 8, borderTopRightRadius: 8, borderBottom: '1px solid', borderColor: 'divider', bgcolor: 'background.default', fontSize: 11, color: 'text.secondary' }}>
|
||||
<span>{language}</span>
|
||||
<Tooltip title={copied ? '已复制' : '复制'}>
|
||||
<IconButton className="copy-btn" size="small" onClick={handleCopy} sx={{ opacity: 0, transition: 'opacity 150ms', color: 'text.secondary' }}>
|
||||
{copied ? <Check size={10} /> : <Copy size={10} />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Box component="pre" sx={{ m: 0, bgcolor: 'secondary.main', border: '1px solid', borderColor: 'divider', borderTop: 'none', borderBottomLeftRadius: 8, borderBottomRightRadius: 8, p: 1.5, overflowX: 'auto', fontSize: 13, lineHeight: 1.6 }}>
|
||||
<code className={language ? `language-${language}` : ''}>{code}</code>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* ChatInput — 聊天输入框
|
||||
*
|
||||
* 附件系统:
|
||||
* - 图片(png/jpg/gif/webp):缩略图预览,发送时转 base64 通过 images 字段传给 LLM
|
||||
* - 文本文件(txt/md/json/csv/ts/js/py等):读取内容注入消息上下文
|
||||
* - 其他文件:显示文件名+大小卡片,提示 LLM 文件信息
|
||||
*
|
||||
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 输入框智能特性
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Box, Typography, IconButton, Tooltip, Stack, Button, Paper } from '@mui/material';
|
||||
import { Send, Paperclip, Square, X, FileText, Image as ImageIcon } from 'lucide-react';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { useSessionStore } from '@renderer/stores/session-store';
|
||||
import { formatTokens, formatFileSize } from '@renderer/lib/formatters';
|
||||
|
||||
const SLASH_COMMANDS = [
|
||||
{ id: 'tool', label: '/tool', description: '选择工具' },
|
||||
{ id: 'memory', label: '/memory', description: '搜索记忆' },
|
||||
{ id: 'clear', label: '/clear', description: '清空会话' },
|
||||
{ id: 'export', label: '/export', description: '导出会话' },
|
||||
];
|
||||
|
||||
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||
const TEXT_EXTENSIONS = ['txt', 'md', 'json', 'csv', 'ts', 'tsx', 'js', 'jsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'css', 'html', 'xml', 'yaml', 'yml', 'toml', 'ini', 'sh', 'bash', 'zsh', 'fish', 'sql', 'env', 'gitignore', 'dockerfile', 'makefile', 'log'];
|
||||
|
||||
interface Attachment {
|
||||
id: string;
|
||||
file: File;
|
||||
type: 'image' | 'text' | 'other';
|
||||
preview?: string; // 图片 base64 data URL
|
||||
textContent?: string; // 文本文件内容
|
||||
}
|
||||
|
||||
export function ChatInput(): React.JSX.Element {
|
||||
const [input, setInput] = useState('');
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [showSlashMenu, setShowSlashMenu] = useState(false);
|
||||
const [slashFilter, setSlashFilter] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const sendMessage = useAgentStore((s) => s.sendMessage);
|
||||
const abort = useAgentStore((s) => s.abort);
|
||||
const isStreaming = useAgentStore((s) => s.isStreaming);
|
||||
const tokenUsage = useAgentStore((s) => s.tokenUsage);
|
||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||
|
||||
// 草稿自动保存
|
||||
useEffect(() => { if (currentSessionId) { const d = sessionStorage.getItem(`draft-${currentSessionId}`); setInput(d ?? ''); } }, [currentSessionId]);
|
||||
useEffect(() => { if (currentSessionId && input) sessionStorage.setItem(`draft-${currentSessionId}`, input); }, [input, currentSessionId]);
|
||||
|
||||
// ===== 附件处理 =====
|
||||
|
||||
const classifyFile = useCallback((file: File): Attachment['type'] => {
|
||||
if (IMAGE_TYPES.includes(file.type)) return 'image';
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
|
||||
if (TEXT_EXTENSIONS.includes(ext)) return 'text';
|
||||
return 'other';
|
||||
}, []);
|
||||
|
||||
const processFile = useCallback(async (file: File): Promise<Attachment> => {
|
||||
const type = classifyFile(file);
|
||||
const attachment: Attachment = { id: `att_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, file, type };
|
||||
|
||||
if (type === 'image') {
|
||||
// 图片转 base64 data URL
|
||||
attachment.preview = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
} else if (type === 'text') {
|
||||
// 文本文件读取内容
|
||||
attachment.textContent = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
return attachment;
|
||||
}, [classifyFile]);
|
||||
|
||||
const addFiles = useCallback(async (files: FileList | File[]) => {
|
||||
const fileArray = Array.from(files).slice(0, 5); // 最多 5 个附件
|
||||
const newAttachments = await Promise.all(fileArray.map(processFile));
|
||||
setAttachments((prev) => [...prev, ...newAttachments]);
|
||||
}, [processFile]);
|
||||
|
||||
const removeAttachment = useCallback((id: string) => {
|
||||
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
}, []);
|
||||
|
||||
// 文件选择
|
||||
const handleFileSelect = useCallback(() => { fileInputRef.current?.click(); }, []);
|
||||
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) addFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}, [addFiles]);
|
||||
|
||||
// 拖拽
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); }, []);
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
|
||||
}, [addFiles]);
|
||||
|
||||
// 粘贴
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const items = Array.from(e.clipboardData.items);
|
||||
const files: File[] = [];
|
||||
for (const item of items) {
|
||||
if (item.kind === 'file') {
|
||||
const f = item.getAsFile();
|
||||
if (f) files.push(f);
|
||||
}
|
||||
}
|
||||
if (files.length) { e.preventDefault(); addFiles(files); }
|
||||
}, [addFiles]);
|
||||
|
||||
// ===== 发送消息 =====
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed && attachments.length === 0) return;
|
||||
if (isStreaming) return;
|
||||
|
||||
// 处理 / 命令
|
||||
if (trimmed.startsWith('/')) {
|
||||
const cmd = trimmed.split(' ')[0].toLowerCase();
|
||||
if (cmd === '/clear') { useAgentStore.getState().clearMessages(); setInput(''); setAttachments([]); return; }
|
||||
if (cmd === '/export') {
|
||||
const blob = new Blob([JSON.stringify(useAgentStore.getState().messages, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `session-${Date.now()}.json`; a.click(); setInput(''); return;
|
||||
}
|
||||
}
|
||||
|
||||
// 构建用户可见内容(纯文本 + 附件描述隐藏)
|
||||
let messageContent = trimmed;
|
||||
const images: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }> = [];
|
||||
|
||||
// 附件元数据(用于 UI 渲染)
|
||||
const attachmentInfos = attachments.map((att) => ({
|
||||
id: att.id,
|
||||
name: att.file.name,
|
||||
type: att.type,
|
||||
size: att.file.size,
|
||||
preview: att.preview,
|
||||
textContent: att.type === 'text' ? att.textContent : undefined,
|
||||
}));
|
||||
|
||||
// 图片加入 images 数组
|
||||
for (const att of attachments) {
|
||||
if (att.type === 'image' && att.preview) {
|
||||
images.push({ url: att.preview, detail: 'auto' });
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(messageContent, images.length > 0 ? images : undefined, attachmentInfos.length > 0 ? attachmentInfos : undefined);
|
||||
setInput('');
|
||||
setAttachments([]);
|
||||
setShowSlashMenu(false);
|
||||
if (currentSessionId) sessionStorage.removeItem(`draft-${currentSessionId}`);
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
||||
}, [input, attachments, isStreaming, sendMessage, currentSessionId]);
|
||||
|
||||
const handleAbort = useCallback(() => { abort(); }, [abort]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
// Ctrl+Enter — 换行
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
const t = e.currentTarget as HTMLTextAreaElement;
|
||||
const s = t.selectionStart;
|
||||
const en = t.selectionEnd;
|
||||
setInput((p) => p.slice(0, s) + '\n' + p.slice(en));
|
||||
requestAnimationFrame(() => { t.selectionStart = t.selectionEnd = s + 1; });
|
||||
return;
|
||||
}
|
||||
// Enter — 发送
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); return; }
|
||||
if (e.key === 'Escape' && showSlashMenu) { setShowSlashMenu(false); return; }
|
||||
}, [handleSend, showSlashMenu]);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const v = e.target.value; setInput(v);
|
||||
if (v === '/') { setShowSlashMenu(true); setSlashFilter(''); }
|
||||
else if (v.startsWith('/') && !v.includes(' ')) { setShowSlashMenu(true); setSlashFilter(v.slice(1).toLowerCase()); }
|
||||
else { setShowSlashMenu(false); }
|
||||
}, []);
|
||||
|
||||
const filteredCommands = SLASH_COMMANDS.filter((c) => c.label.toLowerCase().includes(`/${slashFilter}`));
|
||||
|
||||
return (
|
||||
<Box sx={{ flexShrink: 0, px: 2, pb: 1.5 }}>
|
||||
<Paper sx={{ maxWidth: 768, mx: 'auto', borderRadius: 3, p: 1.5, bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider', position: 'relative' }}
|
||||
onDragOver={handleDragOver} onDrop={handleDrop}
|
||||
>
|
||||
{/* 附件预览区 */}
|
||||
{attachments.length > 0 && (
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 1, flexWrap: 'wrap', gap: 1 }}>
|
||||
{attachments.map((att) => (
|
||||
<AttachmentPreview key={att.id} attachment={att} onRemove={() => removeAttachment(att.id)} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* / 命令菜单 */}
|
||||
{showSlashMenu && filteredCommands.length > 0 && (
|
||||
<div style={{ position: 'absolute', bottom: '100%', left: 0, right: 0, marginBottom: 4, padding: '4px 0', background: 'var(--bg-secondary, #1a1d27)', border: '1px solid var(--border-color, #2a2d3a)', borderRadius: 8, boxShadow: '0 -4px 12px rgba(0,0,0,0.2)', zIndex: 10 }}>
|
||||
{filteredCommands.map((cmd) => (
|
||||
<div key={cmd.id} onClick={() => { setInput(cmd.label + ' '); setShowSlashMenu(false); textareaRef.current?.focus(); }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 12px', fontSize: 12, cursor: 'pointer', color: 'var(--text-primary, #e1e4ed)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.05)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<span style={{ color: '#818cf8', fontSize: 14, fontWeight: 700, flexShrink: 0 }}>/</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{cmd.label}</span>
|
||||
<span style={{ color: 'var(--text-secondary, #64748b)', fontSize: 11 }}>{cmd.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input ref={fileInputRef} type="file" multiple accept="image/*,.txt,.md,.json,.csv,.ts,.tsx,.js,.jsx,.py,.rb,.go,.rs,.java,.c,.cpp,.h,.css,.html,.xml,.yaml,.yml,.toml,.ini,.sh,.sql,.log,.pdf" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
|
||||
<textarea
|
||||
ref={textareaRef} data-chat-input value={input} onChange={handleChange} onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder="输入消息... (Enter 发送, Ctrl+Enter 换行, / 命令)" disabled={isStreaming}
|
||||
rows={1}
|
||||
style={{ width: '100%', background: 'transparent', border: 'none', outline: 'none', color: 'inherit', fontSize: 13, lineHeight: '24px', resize: 'none', fontFamily: 'inherit', minHeight: 24, maxHeight: 144 }}
|
||||
/>
|
||||
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mt: 1, minHeight: 32 }}>
|
||||
{/* 左侧:附件按钮 */}
|
||||
<Tooltip title="附加文件(图片/文本/代码)">
|
||||
<IconButton size="small" sx={{ color: 'text.secondary' }} onClick={handleFileSelect}>
|
||||
<Paperclip size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{/* 右侧:发送按钮 */}
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
{isStreaming ? (
|
||||
<Button variant="contained" color="error" size="small" onClick={handleAbort} sx={{ height: 28, fontSize: 12 }}>
|
||||
<Square size={12} style={{ marginRight: 6 }} /> 中断
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="contained" size="small" onClick={handleSend} disabled={!input.trim() && attachments.length === 0} sx={{ height: 28, fontSize: 12, opacity: (input.trim() || attachments.length > 0) ? 1 : 0.5 }}>
|
||||
<Send size={12} style={{ marginRight: 6 }} /> 发送
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 附件预览组件 =====
|
||||
|
||||
function AttachmentPreview({ attachment, onRemove }: { attachment: Attachment; onRemove: () => void }) {
|
||||
const { type, file, preview } = attachment;
|
||||
|
||||
if (type === 'image' && preview) {
|
||||
return (
|
||||
<Box sx={{ position: 'relative', width: 64, height: 64, borderRadius: 1.5, overflow: 'hidden', border: '1px solid', borderColor: 'divider', flexShrink: 0 }}>
|
||||
<Box component="img" src={preview} alt={file.name} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
<IconButton size="small" onClick={onRemove} sx={{ position: 'absolute', top: 0, right: 0, width: 20, height: 20, bgcolor: 'rgba(0,0,0,0.6)', '&:hover': { bgcolor: 'rgba(0,0,0,0.8)' }, color: '#fff' }}>
|
||||
<X size={12} />
|
||||
</IconButton>
|
||||
<Typography variant="caption" sx={{ position: 'absolute', bottom: 0, left: 0, right: 0, bgcolor: 'rgba(0,0,0,0.6)', color: '#fff', fontSize: 9, textAlign: 'center', py: 0.25, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', px: 0.5 }}>
|
||||
{file.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 文本文件 / 其他文件
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, borderRadius: 1.5, bgcolor: 'secondary.main', border: '1px solid', borderColor: 'divider', maxWidth: 200, flexShrink: 0 }}>
|
||||
{type === 'text' ? <FileText size={16} style={{ color: '#22d3ee', flexShrink: 0 }} /> : <ImageIcon size={16} style={{ color: '#8b8fa7', flexShrink: 0 }} />}
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 11, color: 'text.primary' }}>{file.name}</Typography>
|
||||
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.secondary' }}>{formatFileSize(file.size)}</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={onRemove} sx={{ width: 20, height: 20, flexShrink: 0, color: 'text.secondary' }}>
|
||||
<X size={12} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* ChatPanel — 聊天主面板
|
||||
*
|
||||
* 组装 MessageList + ChatInput。
|
||||
* 占据中间弹性区域。
|
||||
*/
|
||||
|
||||
import { MessageList } from './MessageList';
|
||||
import { ChatInput } from './ChatInput';
|
||||
|
||||
export function ChatPanel(): React.JSX.Element {
|
||||
return (
|
||||
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<MessageList />
|
||||
<ChatInput />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* MessageItem — 单条消息路由组件
|
||||
*
|
||||
* 根据消息 role 路由到对应的子组件渲染。
|
||||
*/
|
||||
|
||||
import type { ChatMessage } from '@renderer/stores/agent-store';
|
||||
import { UserMessage } from './UserMessage';
|
||||
import { AssistantMessage } from './AssistantMessage';
|
||||
import { SystemMessage } from './SystemMessage';
|
||||
|
||||
interface MessageItemProps {
|
||||
message: ChatMessage;
|
||||
isLast?: boolean;
|
||||
isStreaming?: boolean;
|
||||
streamContent?: string;
|
||||
}
|
||||
|
||||
export function MessageItem({ message, isLast, isStreaming, streamContent }: MessageItemProps): React.JSX.Element {
|
||||
switch (message.role) {
|
||||
case 'user':
|
||||
return <UserMessage message={message} />;
|
||||
|
||||
case 'assistant':
|
||||
return (
|
||||
<AssistantMessage
|
||||
message={message}
|
||||
isStreaming={isLast && isStreaming}
|
||||
streamContent={streamContent}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'tool':
|
||||
// 工具消息渲染为系统消息样式
|
||||
return <SystemMessage message={message} />;
|
||||
|
||||
case 'system':
|
||||
default:
|
||||
return <SystemMessage message={message} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* MessageList — 消息列表容器
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { MessageItem } from './MessageItem';
|
||||
import { StreamingIndicator } from './StreamingIndicator';
|
||||
|
||||
export function MessageList(): React.JSX.Element {
|
||||
const messages = useAgentStore((s) => s.messages);
|
||||
const isStreaming = useAgentStore((s) => s.isStreaming);
|
||||
const streamingContent = useAgentStore((s) => s.streamingContent);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, streamingContent]);
|
||||
|
||||
if (messages.length === 0 && !isStreaming) {
|
||||
return (
|
||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Box sx={{ textAlign: 'center', animation: 'fadeIn 200ms ease-out' }}>
|
||||
<Box component="img" src="./assets/logo.png" alt="Metona" sx={{ width: 64, height: 64, mx: 'auto', mb: 2, borderRadius: 2 }} />
|
||||
<Typography variant="h6" sx={{ color: 'text.primary', mb: 0.5 }}>MetonaAI Desktop</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>生产级通用 AI Agent 智能体桌面应用</Typography>
|
||||
<Typography variant="caption" sx={{ mt: 2, display: 'block', opacity: 0.6 }}>Agent 就绪 · 选择一个 Provider 开始对话</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 2, py: 3 }}>
|
||||
<Box sx={{ maxWidth: 768, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{messages.map((msg, i) => (
|
||||
<MessageItem key={msg.id} message={msg} isLast={i === messages.length - 1} isStreaming={isStreaming} streamContent={streamingContent} />
|
||||
))}
|
||||
<StreamingIndicator />
|
||||
<div ref={messagesEndRef} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* StreamingIndicator — 流式加载指示器
|
||||
*/
|
||||
|
||||
import { Box, Typography, CircularProgress } from '@mui/material';
|
||||
import { Brain, Loader2 } from 'lucide-react';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
|
||||
export function StreamingIndicator(): React.JSX.Element | null {
|
||||
const isStreaming = useAgentStore((s) => s.isStreaming);
|
||||
const agentStatus = useAgentStore((s) => s.agentStatus);
|
||||
if (!isStreaming) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1.5, animation: 'fadeIn 200ms ease-out' }}>
|
||||
<Box sx={{ width: 32, height: 32, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'secondary.main', color: 'primary.main' }}>
|
||||
<Brain size={16} style={{ animation: 'pulse 2s infinite' }} />
|
||||
</Box>
|
||||
<Loader2 size={14} style={{ animation: 'spin 1s linear infinite', color: '#818cf8' }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{agentStatus === 'thinking' ? '思考中...' : agentStatus === 'executing' ? '执行中...' : '处理中...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* SystemMessage — 系统通知消息
|
||||
*/
|
||||
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import type { ChatMessage } from '@renderer/stores/agent-store';
|
||||
import { formatTime } from '@renderer/lib/formatters';
|
||||
|
||||
interface SystemMessageProps { message: ChatMessage; }
|
||||
|
||||
export function SystemMessage({ message }: SystemMessageProps): React.JSX.Element {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 1, animation: 'fadeIn 200ms ease-out' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ px: 1.5, py: 0.5, borderRadius: 99, color: 'text.disabled', bgcolor: 'secondary.main', fontSize: 11 }}
|
||||
>
|
||||
{message.content}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* ThoughtBlock — 思考过程展示
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Box, Typography, IconButton, Collapse } from '@mui/material';
|
||||
import { ChevronDown, ChevronRight, Brain } from 'lucide-react';
|
||||
|
||||
interface ThoughtBlockProps { content: string; defaultExpanded?: boolean; }
|
||||
|
||||
export function ThoughtBlock({ content, defaultExpanded = false }: ThoughtBlockProps): React.JSX.Element {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
if (!content) return <></>;
|
||||
|
||||
return (
|
||||
<Box sx={{ borderRadius: 1.5, border: '1px dashed', borderColor: 'divider', bgcolor: 'secondary.main', mb: expanded ? 1.5 : 0.5, transition: 'all 200ms' }}>
|
||||
<IconButton size="small" onClick={() => setExpanded(!expanded)} sx={{ width: '100%', justifyContent: 'flex-start', gap: 1, px: 1.5, py: 1, borderRadius: '10px 10px 0 0', color: 'text.secondary', fontSize: 12 }}>
|
||||
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
<Brain size={12} style={{ color: '#fbbf24' }} />
|
||||
<span>思考过程</span>
|
||||
</IconButton>
|
||||
<Collapse in={expanded}>
|
||||
<Box sx={{ px: 1.5, pb: 1.5, fontFamily: "'SF Mono','Fira Code',monospace", fontSize: 12, lineHeight: 1.6, color: 'text.secondary', whiteSpace: 'pre-wrap' }}>
|
||||
{content}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* ToolCallCard — 工具调用卡片
|
||||
*/
|
||||
|
||||
import { Box, Typography, Stack, Chip } from '@mui/material';
|
||||
import { Wrench, Clock, CheckCircle, XCircle, Ban, Loader2 } from 'lucide-react';
|
||||
import type { ToolCallInfo } from '@renderer/stores/agent-store';
|
||||
import { formatDuration } from '@renderer/lib/formatters';
|
||||
|
||||
interface ToolCallCardProps { toolCall: ToolCallInfo; }
|
||||
|
||||
const STATUS_ICONS = { pending: Clock, executing: Loader2, success: CheckCircle, error: XCircle, blocked: Ban };
|
||||
const STATUS_LABELS = { pending: '等待中', executing: '执行中', success: '成功', error: '失败', blocked: '已阻止' };
|
||||
const STATUS_COLORS: Record<string, string> = { pending: '#fbbf24', executing: '#a855f7', success: '#34d399', error: '#f87171', blocked: '#fb923c' };
|
||||
const CHIP_VARIANTS: Record<string, 'outlined' | 'filled'> = { pending: 'outlined', executing: 'filled', success: 'filled', error: 'filled', blocked: 'outlined' };
|
||||
|
||||
export function ToolCallCard({ toolCall }: ToolCallCardProps): React.JSX.Element {
|
||||
const Icon = STATUS_ICONS[toolCall.status];
|
||||
const color = STATUS_COLORS[toolCall.status];
|
||||
const label = STATUS_LABELS[toolCall.status];
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 1.5, border: '1px solid', borderColor: 'divider', borderLeft: '3px solid', borderLeftColor: color,
|
||||
bgcolor: 'secondary.main', px: 1.5, py: 1.25, mb: 1,
|
||||
animation: toolCall.status === 'executing' ? 'pulse 2s infinite' : 'fadeInUp 300ms ease-out',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.75 }}>
|
||||
<Wrench size={12} style={{ color: '#a855f7' }} />
|
||||
<Typography sx={{ fontSize: 12, fontWeight: 600, fontFamily: 'monospace', color: 'text.primary' }}>{toolCall.name}</Typography>
|
||||
<Chip
|
||||
icon={<Icon size={10} style={{ animation: toolCall.status === 'executing' ? 'spin 1s linear infinite' : 'none' }} />}
|
||||
label={label}
|
||||
size="small"
|
||||
variant={CHIP_VARIANTS[toolCall.status]}
|
||||
sx={{ height: 18, fontSize: 10, color, borderColor: color, '& .MuiChip-icon': { color } }}
|
||||
/>
|
||||
{toolCall.durationMs != null && (
|
||||
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.secondary' }}>{formatDuration(toolCall.durationMs)}</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{Object.keys(toolCall.args).length > 0 && (
|
||||
<Box component="pre" sx={{ fontSize: 11, borderRadius: 1, px: 1, py: 0.75, overflowX: 'auto', bgcolor: 'background.default', color: 'text.secondary', fontFamily: "'SF Mono',monospace", maxHeight: 80, m: 0 }}>
|
||||
{JSON.stringify(toolCall.args, null, 2)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{toolCall.status === 'error' && toolCall.error && (
|
||||
<Typography variant="caption" sx={{ mt: 0.75, display: 'block', color: 'error.main' }}>{toolCall.error}</Typography>
|
||||
)}
|
||||
|
||||
{toolCall.status === 'success' && toolCall.result != null && (
|
||||
<Typography variant="caption" sx={{ mt: 0.75, display: 'block', color: 'text.secondary', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{typeof toolCall.result === 'string' ? toolCall.result.slice(0, 200) : JSON.stringify(toolCall.result).slice(0, 200)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* ToolResultBlock — 工具结果块
|
||||
*/
|
||||
|
||||
import { Box, Typography, Stack } from '@mui/material';
|
||||
import { FileText } from 'lucide-react';
|
||||
import type { ToolCallInfo } from '@renderer/stores/agent-store';
|
||||
import { formatDuration } from '@renderer/lib/formatters';
|
||||
|
||||
interface ToolResultBlockProps { toolCall: ToolCallInfo; }
|
||||
|
||||
export function ToolResultBlock({ toolCall }: ToolResultBlockProps): React.JSX.Element {
|
||||
if (toolCall.status !== 'success' || toolCall.result == null) return <></>;
|
||||
const resultStr = typeof toolCall.result === 'string' ? toolCall.result : JSON.stringify(toolCall.result, null, 2);
|
||||
|
||||
return (
|
||||
<Box sx={{ borderRadius: 1.5, border: '1px solid', borderColor: 'divider', borderLeft: '3px solid', borderLeftColor: 'info.main', bgcolor: 'secondary.main', px: 1.5, py: 1, mb: 1, animation: 'fadeInUp 300ms ease-out' }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
|
||||
<FileText size={12} style={{ color: '#22d3ee' }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 500, color: 'text.primary' }}>{toolCall.name} 结果</Typography>
|
||||
{toolCall.durationMs != null && <Typography variant="caption" sx={{ ml: 'auto', color: 'text.secondary' }}>{formatDuration(toolCall.durationMs)}</Typography>}
|
||||
</Stack>
|
||||
<Box component="pre" sx={{ fontSize: 11, borderRadius: 1, px: 1, py: 0.75, overflowX: 'auto', bgcolor: 'background.default', color: 'text.secondary', fontFamily: "'SF Mono',monospace", maxHeight: 120, m: 0 }}>
|
||||
{resultStr.length > 500 ? resultStr.slice(0, 500) + '\n... [截断]' : resultStr}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* UserMessage — 用户消息卡片
|
||||
*
|
||||
* 全宽卡片流布局,左侧头像。
|
||||
* 支持附件可视化:图片缩略图、文件卡片。
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Box, Typography, Avatar, Stack, TextareaAutosize } from '@mui/material';
|
||||
import { User, FileText, Image as ImageIcon, X } from 'lucide-react';
|
||||
import type { ChatMessage, AttachmentInfo } from '@renderer/stores/agent-store';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { formatTime, formatFileSize } from '@renderer/lib/formatters';
|
||||
import { ContextMenu, createContextMenuItems } from '@renderer/components/ContextMenu';
|
||||
|
||||
interface UserMessageProps { message: ChatMessage; }
|
||||
|
||||
export function UserMessage({ message }: UserMessageProps): React.JSX.Element {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editContent, setEditContent] = useState(message.content);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const updateMessage = useAgentStore((s) => s.updateMessage);
|
||||
|
||||
const handleDoubleClick = useCallback(() => { setEditing(true); setEditContent(message.content); }, [message.content]);
|
||||
const handleEditSave = useCallback(() => {
|
||||
if (editContent.trim() && editContent !== message.content) updateMessage(message.id, { content: editContent.trim() });
|
||||
setEditing(false);
|
||||
}, [editContent, message.content, message.id, updateMessage]);
|
||||
const handleEditKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { setEditing(false); setEditContent(message.content); }
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleEditSave();
|
||||
}, [handleEditSave, message.content]);
|
||||
useEffect(() => { if (editing) textareaRef.current?.focus(); }, [editing]);
|
||||
|
||||
const hasAttachments = message.attachments && message.attachments.length > 0;
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={1.5} sx={{ animation: 'fadeInUp 300ms ease-out' }}>
|
||||
<Avatar sx={{ width: 32, height: 32, bgcolor: 'action.hover', color: 'primary.main', flexShrink: 0 }}><User size={16} /></Avatar>
|
||||
<Box
|
||||
sx={{ maxWidth: 768, borderRadius: 3, px: 2, py: 1.5, cursor: 'default', bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider' }}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenu={(e: React.MouseEvent) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }}
|
||||
>
|
||||
{/* 附件预览区 */}
|
||||
{hasAttachments && (
|
||||
<Stack direction="row" spacing={1} sx={{ mb: message.content ? 1.5 : 0, flexWrap: 'wrap', gap: 1 }}>
|
||||
{message.attachments!.map((att) => (
|
||||
<AttachmentPreview key={att.id} attachment={att} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* 文本内容 */}
|
||||
{editing ? (
|
||||
<TextareaAutosize ref={textareaRef} value={editContent} onChange={(e) => setEditContent(e.target.value)} onKeyDown={handleEditKeyDown} onBlur={handleEditSave} minRows={3} style={{ width: '100%', background: 'transparent', border: 'none', outline: 'none', color: 'inherit', fontSize: 13, lineHeight: 1.7, resize: 'none', fontFamily: 'inherit' }} />
|
||||
) : message.content ? (
|
||||
<Typography sx={{ whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.7, color: 'text.primary' }}>{message.content}</Typography>
|
||||
) : null}
|
||||
|
||||
<Typography variant="caption" sx={{ mt: 0.75, display: 'block', color: 'text.disabled' }}>{formatTime(message.timestamp)}</Typography>
|
||||
</Box>
|
||||
{contextMenu && <ContextMenu type="message" x={contextMenu.x} y={contextMenu.y} items={createContextMenuItems('message', { content: message.content })} onClose={() => setContextMenu(null)} />}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 附件预览组件 =====
|
||||
|
||||
function AttachmentPreview({ attachment }: { attachment: AttachmentInfo }) {
|
||||
const { type, name, size, preview } = attachment;
|
||||
|
||||
if (type === 'image' && preview) {
|
||||
return (
|
||||
<Box sx={{ position: 'relative', width: 120, height: 120, borderRadius: 2, overflow: 'hidden', border: '1px solid', borderColor: 'divider', flexShrink: 0 }}>
|
||||
<Box component="img" src={preview} alt={name} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
<Box sx={{ position: 'absolute', bottom: 0, left: 0, right: 0, bgcolor: 'rgba(0,0,0,0.6)', px: 1, py: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#fff', fontSize: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
|
||||
{name}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 文本文件
|
||||
if (type === 'text') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, borderRadius: 1.5, bgcolor: 'action.hover', border: '1px solid', borderColor: 'divider', maxWidth: 200, flexShrink: 0 }}>
|
||||
<FileText size={20} style={{ color: '#22d3ee', flexShrink: 0 }} />
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 11, color: 'text.primary' }}>{name}</Typography>
|
||||
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.secondary' }}>{formatFileSize(size)}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 其他文件
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, borderRadius: 1.5, bgcolor: 'action.hover', border: '1px solid', borderColor: 'divider', maxWidth: 200, flexShrink: 0 }}>
|
||||
<ImageIcon size={20} style={{ color: '#8b8fa7', flexShrink: 0 }} />
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 11, color: 'text.primary' }}>{name}</Typography>
|
||||
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.secondary' }}>{formatFileSize(size)}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* AgentMonitor — Agent 状态指示器
|
||||
*/
|
||||
|
||||
import { Box, Typography, Stack } from '@mui/material';
|
||||
import { Activity, Cpu, Clock } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { useAgentStore, type AgentStatus } from '@renderer/stores/agent-store';
|
||||
import { AGENT_STATUS_COLORS, AGENT_STATUS_LABELS, PROVIDER_LABELS } from '@renderer/lib/constants';
|
||||
import { formatDuration } from '@renderer/lib/formatters';
|
||||
|
||||
const STATUS_ICONS: Record<AgentStatus, typeof Activity> = { idle: Activity, thinking: Cpu, executing: Cpu, error: Activity };
|
||||
|
||||
export function AgentMonitor(): React.JSX.Element {
|
||||
const agentStatus = useAgentStore((s) => s.agentStatus);
|
||||
const provider = useAgentStore((s) => s.provider);
|
||||
const model = useAgentStore((s) => s.model);
|
||||
const currentIteration = useAgentStore((s) => s.currentIteration);
|
||||
const maxIterations = useAgentStore((s) => s.maxIterations);
|
||||
const traceSteps = useAgentStore((s) => s.traceSteps);
|
||||
const setMaxIterations = useAgentStore((s) => s.setMaxIterations);
|
||||
|
||||
// 启动时从数据库读取 maxIterations
|
||||
useEffect(() => {
|
||||
if (window.metona?.config?.get) {
|
||||
window.metona.config.get('agent.maxIterations').then((v) => {
|
||||
if (typeof v === 'number' && v > 0) setMaxIterations(v);
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, [setMaxIterations]);
|
||||
|
||||
// 监听配置变更
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (window.metona?.config?.get) {
|
||||
window.metona.config.get('agent.maxIterations').then((v) => {
|
||||
if (typeof v === 'number' && v > 0) setMaxIterations(v);
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [setMaxIterations]);
|
||||
const StatusIcon = STATUS_ICONS[agentStatus];
|
||||
const statusColor = AGENT_STATUS_COLORS[agentStatus];
|
||||
const statusLabel = AGENT_STATUS_LABELS[agentStatus];
|
||||
const totalDuration = traceSteps.reduce((sum, s) => sum + (s.completedAt ? s.completedAt - s.startedAt : 0), 0);
|
||||
|
||||
const InfoRow = ({ icon: Icon, label, value }: { icon: typeof Activity; label: string; value: string }) => (
|
||||
<Stack direction="row" alignItems="center" sx={{ gap: 1 }}>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ color: 'text.secondary', flexShrink: 0, minWidth: 60 }}>
|
||||
<Icon size={10} /><Typography variant="caption">{label}</Typography>
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: 'text.primary', flex: 1, textAlign: 'right', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{value}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider' }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
|
||||
<Cpu size={14} style={{ color: '#818cf8' }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>Agent 状态</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ px: 1.5, py: 1, borderRadius: 1.5, mb: 1, bgcolor: 'background.default' }}>
|
||||
<StatusIcon size={14} style={{ color: statusColor, animation: (agentStatus === 'thinking' || agentStatus === 'executing') ? 'pulse 2s infinite' : 'none' }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 500, color: statusColor }}>{statusLabel}</Typography>
|
||||
</Stack>
|
||||
<Stack spacing={0.75}>
|
||||
<InfoRow icon={Activity} label="Provider" value={PROVIDER_LABELS[provider] ?? provider} />
|
||||
<InfoRow icon={Cpu} label="模型" value={model} />
|
||||
<InfoRow icon={Activity} label="迭代" value={`${currentIteration} / ${maxIterations}`} />
|
||||
{totalDuration > 0 && <InfoRow icon={Clock} label="总耗时" value={formatDuration(totalDuration)} />}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* DetailPanel — 右侧详情面板
|
||||
*
|
||||
* 始终显示,包含 TraceViewer、TokenUsage、AgentMonitor。
|
||||
*/
|
||||
|
||||
import { Box } from '@mui/material';
|
||||
import { TraceViewer } from '@renderer/components/trace/TraceViewer';
|
||||
import { TokenUsage } from '@renderer/components/trace/TokenUsage';
|
||||
import { AgentMonitor } from '@renderer/components/layout/AgentMonitor';
|
||||
import { LAYOUT } from '@renderer/lib/constants';
|
||||
|
||||
export function DetailPanel(): React.JSX.Element {
|
||||
return (
|
||||
<Box component="aside" sx={{ flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: 'background.paper', borderLeft: 1, borderColor: 'divider', width: LAYOUT.DETAIL_WIDTH }}>
|
||||
<Box sx={{ p: 1.5, flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<TraceViewer />
|
||||
<TokenUsage />
|
||||
<AgentMonitor />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Header — 标题栏
|
||||
*
|
||||
* Logo + 会话标题 + 设置按钮。
|
||||
*/
|
||||
|
||||
import { Box, IconButton, Tooltip, Stack, Typography } from '@mui/material';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { useUIStore } from '@renderer/stores/ui-store';
|
||||
import { useSessionStore } from '@renderer/stores/session-store';
|
||||
|
||||
type WebkitCSS = React.CSSProperties & { WebkitAppRegion?: string };
|
||||
|
||||
export function Header(): React.JSX.Element {
|
||||
const openSettings = useUIStore((s) => s.openSettings);
|
||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||
const sessions = useSessionStore((s) => s.sessions);
|
||||
const currentSession = sessions.find((s) => s.id === currentSessionId);
|
||||
|
||||
return (
|
||||
<header
|
||||
style={{
|
||||
flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '0 16px', height: 40, userSelect: 'none',
|
||||
background: 'var(--bg-secondary)', borderBottom: '1px solid var(--border-color)',
|
||||
WebkitAppRegion: 'drag',
|
||||
} as WebkitCSS}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ minWidth: 0 }}>
|
||||
<Box component="img" src="./assets/logo.png" alt="Metona" sx={{ width: 20, height: 20, borderRadius: 0.5 }} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'primary.main', fontSize: 13 }}>MetonaAI</Typography>
|
||||
{currentSession && (
|
||||
<>
|
||||
<Typography variant="body2" sx={{ color: 'divider' }}>·</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{currentSession.title}</Typography>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={0.5} sx={{ WebkitAppRegion: 'no-drag' } as WebkitCSS}>
|
||||
<Tooltip title="设置 (Ctrl+,)"><IconButton size="small" onClick={openSettings} sx={{ color: 'text.secondary' }}><Settings size={14} /></IconButton></Tooltip>
|
||||
</Stack>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Sidebar — 左侧边栏
|
||||
*
|
||||
* 始终显示,包含会话列表、新建会话按钮、搜索。
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Typography, Button, IconButton, InputBase, Stack, Collapse, List, ListItemButton, ListItemIcon, ListItemText, Badge, Divider, Dialog, DialogTitle, DialogContent, DialogActions, TextField } from '@mui/material';
|
||||
import Fuse from 'fuse.js';
|
||||
import { Plus, Search, MessageSquare, Pin, Wrench, Database, ChevronDown, ChevronRight, Trash2 } from 'lucide-react';
|
||||
import { useSessionStore, type Session } from '@renderer/stores/session-store';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { formatRelativeTime } from '@renderer/lib/formatters';
|
||||
import { LAYOUT } from '@renderer/lib/constants';
|
||||
|
||||
export function Sidebar(): React.JSX.Element {
|
||||
const sessions = useSessionStore((s) => s.sessions);
|
||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||
const setCurrentSession = useSessionStore((s) => s.setCurrentSession);
|
||||
const loadSessionMessages = useAgentStore((s) => s.setCurrentSession);
|
||||
const searchQuery = useSessionStore((s) => s.searchQuery);
|
||||
const setSearchQuery = useSessionStore((s) => s.setSearchQuery);
|
||||
const agentStatus = useAgentStore((s) => s.agentStatus);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.metona?.sessions?.list) window.metona.sessions.list().then((list) => useSessionStore.getState().setSessions((list as any[]).map((s) => ({ id: s.id, title: s.title, createdAt: s.createdAt, updatedAt: s.updatedAt, messageCount: s.messageCount, pinned: s.pinned, archived: s.archived })))).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const filteredSessions = (() => {
|
||||
let list = sessions.filter((s) => !s.archived);
|
||||
if (searchQuery) { const fuse = new Fuse(list, { keys: ['title'], threshold: 0.4, ignoreLocation: true }); list = fuse.search(searchQuery).map((r) => r.item); }
|
||||
return list.sort((a, b) => a.pinned === b.pinned ? b.updatedAt - a.updatedAt : a.pinned ? -1 : 1);
|
||||
})();
|
||||
|
||||
const handleNewSession = async () => {
|
||||
if (window.metona?.sessions?.create) {
|
||||
try {
|
||||
const r = await window.metona.sessions.create() as { id: string; title: string; createdAt: number; updatedAt: number; messageCount: number; pinned: boolean; archived: boolean };
|
||||
useSessionStore.getState().addSession({ id: r.id, title: r.title, createdAt: r.createdAt, updatedAt: r.updatedAt, messageCount: r.messageCount, pinned: r.pinned, archived: r.archived });
|
||||
setCurrentSession(r.id);
|
||||
loadSessionMessages(r.id);
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
const newSession: Session = { id: `s_${Date.now()}`, title: '新会话', createdAt: Date.now(), updatedAt: Date.now(), messageCount: 0, pinned: false, archived: false };
|
||||
useSessionStore.getState().addSession(newSession);
|
||||
setCurrentSession(newSession.id);
|
||||
loadSessionMessages(newSession.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box component="aside" sx={{ flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: 'background.paper', borderRight: 1, borderColor: 'divider', width: LAYOUT.SIDEBAR_WIDTH }}>
|
||||
<Box sx={{ p: 1.5, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<Button variant="outlined" fullWidth size="small" onClick={handleNewSession} sx={{ mb: 1.5, fontSize: 12 }}>
|
||||
<Plus size={14} style={{ marginRight: 8 }} /> 新建会话
|
||||
</Button>
|
||||
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ px: 1.5, py: 0.75, borderRadius: 1.5, mb: 1.5, bgcolor: 'secondary.main', border: '1px solid', borderColor: 'divider' }}>
|
||||
<Search size={12} style={{ color: '#8b8fa7', flexShrink: 0 }} />
|
||||
<InputBase value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="搜索会话..." sx={{ flex: 1, fontSize: 12 }} />
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
{filteredSessions.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.secondary' }}>{searchQuery ? '无匹配结果' : '暂无会话'}</Typography>
|
||||
) : filteredSessions.map((session) => (
|
||||
<SessionItem key={session.id} session={session} isActive={session.id === currentSessionId} isAgentActive={session.id === currentSessionId && (agentStatus === 'thinking' || agentStatus === 'executing')} onClick={() => { setCurrentSession(session.id); loadSessionMessages(session.id); }} />
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mt: 'auto', mb: 1 }} />
|
||||
<ToolManagerPanel />
|
||||
<MemorySearchPanel />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionItem({ session, isActive, isAgentActive, onClick }: { session: Session; isActive: boolean; isAgentActive: boolean; onClick: () => void }) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
window.metona?.sessions.delete(session.id).catch(() => {});
|
||||
useSessionStore.getState().removeSession(session.id);
|
||||
setShowDeleteDialog(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItemButton onClick={onClick} selected={isActive} dense sx={{ borderRadius: 1.5, mb: 0.25, px: 1.5, py: 0.75, borderRight: isActive ? '2px solid' : '2px solid transparent', borderColor: isActive ? 'primary.main' : 'transparent', '&:hover .delete-btn': { opacity: 1 } }}>
|
||||
<ListItemIcon sx={{ minWidth: 24 }}>
|
||||
{session.pinned ? <Pin size={10} style={{ color: '#818cf8' }} /> : isAgentActive ? <Badge color="success" variant="dot" sx={{ '& .MuiBadge-dot': { animation: 'pulse 2s infinite', width: 8, height: 8 } }}><MessageSquare size={12} style={{ color: '#8b8fa7' }} /></Badge> : <MessageSquare size={12} style={{ color: '#8b8fa7' }} />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={<Typography variant="body2" sx={{ fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: isActive ? 'text.primary' : 'text.secondary' }}>{session.title}</Typography>}
|
||||
secondary={<Typography variant="caption" sx={{ fontSize: 10 }}>{formatRelativeTime(session.updatedAt)}{session.messageCount > 0 ? ` · ${session.messageCount} 条` : ''}</Typography>}
|
||||
/>
|
||||
<IconButton
|
||||
className="delete-btn"
|
||||
size="small"
|
||||
onClick={handleDelete}
|
||||
sx={{ opacity: 0, transition: 'opacity 150ms', color: 'text.disabled', '&:hover': { color: 'error.main' }, width: 20, height: 20 }}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</IconButton>
|
||||
</ListItemButton>
|
||||
|
||||
<Dialog open={showDeleteDialog} onClose={() => setShowDeleteDialog(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ fontSize: 14 }}>删除会话</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
确定删除会话「{session.title}」?此操作不可恢复。
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button size="small" onClick={() => setShowDeleteDialog(false)} sx={{ color: 'text.secondary' }}>取消</Button>
|
||||
<Button size="small" color="error" variant="contained" onClick={confirmDelete}>删除</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolManagerPanel() {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const tools = [
|
||||
{ name: 'read_file', label: '读取文件', risk: 'SAFE' }, { name: 'write_file', label: '写入文件', risk: 'MEDIUM' },
|
||||
{ name: 'list_directory', label: '列出目录', risk: 'SAFE' }, { name: 'search_files', label: '搜索文件', risk: 'SAFE' },
|
||||
{ name: 'web_search', label: '网络搜索', risk: 'LOW' }, { name: 'web_extract', label: '网页抓取', risk: 'LOW' },
|
||||
{ name: 'memory_store', label: '存储记忆', risk: 'MEDIUM' }, { name: 'memory_search', label: '搜索记忆', risk: 'SAFE' },
|
||||
{ name: 'run_command', label: '执行命令', risk: 'HIGH' },
|
||||
];
|
||||
const riskColors: Record<string, string> = { SAFE: 'success.main', LOW: 'info.main', MEDIUM: 'warning.main', HIGH: 'error.main' };
|
||||
return (
|
||||
<Box>
|
||||
<ListItemButton onClick={() => setExpanded(!expanded)} dense sx={{ borderRadius: 1, px: 1.5, py: 0.75 }}>
|
||||
<ListItemIcon sx={{ minWidth: 24 }}>{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}<Wrench size={12} style={{ marginLeft: 4 }} /></ListItemIcon>
|
||||
<ListItemText primary={<Typography variant="body2" sx={{ fontSize: 12 }}>工具管理</Typography>} />
|
||||
<Typography variant="caption" sx={{ color: 'success.main', fontSize: 10 }}>9 就绪</Typography>
|
||||
</ListItemButton>
|
||||
<Collapse in={expanded}>
|
||||
<List dense disablePadding sx={{ pl: 3 }}>
|
||||
{tools.map((t) => (
|
||||
<ListItemButton key={t.name} dense sx={{ py: 0.25, px: 1, borderRadius: 1 }}>
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: riskColors[t.risk], mr: 1, flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ flex: 1, fontSize: 11, color: 'text.secondary' }}>{t.label}</Typography>
|
||||
<Typography variant="caption" sx={{ fontSize: 9, color: riskColors[t.risk] }}>{t.risk}</Typography>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MemorySearchPanel() {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<Array<{ id: string; type: string; content: string; importance: number }>>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const handleSearch = async () => { if (!query.trim() || !window.metona?.memory?.search) return; setSearching(true); try { setResults((await window.metona.memory.search(query, { topK: 5 })) as any); } catch { setResults([]); } setSearching(false); };
|
||||
return (
|
||||
<Box>
|
||||
<ListItemButton onClick={() => setExpanded(!expanded)} dense sx={{ borderRadius: 1, px: 1.5, py: 0.75 }}>
|
||||
<ListItemIcon sx={{ minWidth: 24 }}>{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}<Database size={12} style={{ marginLeft: 4 }} /></ListItemIcon>
|
||||
<ListItemText primary={<Typography variant="body2" sx={{ fontSize: 12 }}>记忆搜索</Typography>} />
|
||||
</ListItemButton>
|
||||
<Collapse in={expanded}>
|
||||
<Stack spacing={1} sx={{ pl: 3, pr: 1, pb: 1 }}>
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<TextField size="small" value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }} placeholder="搜索记忆..." sx={{ flex: 1, '& .MuiInputBase-root': { fontSize: 11, height: 28 } }} />
|
||||
<Button variant="outlined" size="small" onClick={handleSearch} disabled={searching} sx={{ minWidth: 40, height: 28, fontSize: 10 }}>{searching ? '...' : '搜索'}</Button>
|
||||
</Stack>
|
||||
{results.length > 0 && (
|
||||
<Box sx={{ maxHeight: 200, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{results.map((r) => (
|
||||
<Box key={r.id} sx={{ px: 1, py: 0.75, borderRadius: 1, bgcolor: 'background.default', fontSize: 10, color: 'text.secondary' }}>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mb: 0.25 }}>
|
||||
<Box sx={{ px: 0.5, borderRadius: 0.5, bgcolor: 'action.hover', color: 'primary.main', fontSize: 9 }}>{r.type}</Box>
|
||||
<Typography variant="caption" sx={{ fontSize: 9 }}>重要度: {r.importance.toFixed(1)}</Typography>
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ fontSize: 10, display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.content.slice(0, 100)}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* StatusBar — 底部状态栏
|
||||
*
|
||||
* 显示 Agent 状态指示灯、Provider 名称、Token 统计、版本号。
|
||||
*/
|
||||
|
||||
import { Box, Typography, Stack, Chip } from '@mui/material';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { AGENT_STATUS_COLORS, AGENT_STATUS_LABELS, PROVIDER_LABELS } from '@renderer/lib/constants';
|
||||
import { formatTokens } from '@renderer/lib/formatters';
|
||||
|
||||
export function StatusBar(): React.JSX.Element {
|
||||
const agentStatus = useAgentStore((s) => s.agentStatus);
|
||||
const provider = useAgentStore((s) => s.provider);
|
||||
const model = useAgentStore((s) => s.model);
|
||||
const tokenUsage = useAgentStore((s) => s.tokenUsage);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
px: 2, height: 24, bgcolor: 'background.paper', borderTop: 1, borderColor: 'divider', userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||
<Box
|
||||
sx={{
|
||||
width: 8, height: 8, borderRadius: '50%', bgcolor: AGENT_STATUS_COLORS[agentStatus],
|
||||
animation: (agentStatus === 'thinking' || agentStatus === 'executing') ? 'pulse 2s infinite' : 'none',
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 11 }}>
|
||||
{AGENT_STATUS_LABELS[agentStatus]}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ color: 'divider' }}>|</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 11 }}>
|
||||
{PROVIDER_LABELS[provider] ?? provider} {model}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
{tokenUsage.totalTokens > 0 && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 11 }}>
|
||||
Tokens: {formatTokens(tokenUsage.inputTokens)}↓ {formatTokens(tokenUsage.outputTokens)}↑
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 11 }}>MetonaAI v0.1.0</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* OnboardingWizard — 首次使用引导向导
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, Button, TextField, Select, MenuItem, Stepper, Step, StepLabel, Box, Typography, Stack, FormControl, InputLabel, IconButton, InputAdornment } from '@mui/material';
|
||||
import { ArrowRight, ArrowLeft, FolderOpen, Play, CheckCircle, Eye, EyeOff } from 'lucide-react';
|
||||
import { useUIStore } from '@renderer/stores/ui-store';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
|
||||
const STEPS = ['欢迎', '配置 LLM', '自定义 Agent', '工作空间', '开始使用'];
|
||||
|
||||
export function OnboardingWizard(): React.JSX.Element | null {
|
||||
const onboardingCompleted = useUIStore((s) => s.onboardingCompleted);
|
||||
const setOnboardingCompleted = useUIStore((s) => s.setOnboardingCompleted);
|
||||
const [step, setStep] = useState(0);
|
||||
const [provider, setProvider] = useState('');
|
||||
const [baseURL, setBaseURL] = useState('');
|
||||
const [model, setModel] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [workspacePath, setWorkspacePath] = useState('');
|
||||
|
||||
if (onboardingCompleted) return null;
|
||||
|
||||
const handleNext = () => {
|
||||
if (step < STEPS.length - 1) { setStep(step + 1); return; }
|
||||
if (window.metona?.config) {
|
||||
if (provider.trim()) window.metona.config.set('llm.provider', provider.trim());
|
||||
if (baseURL.trim()) window.metona.config.set('llm.baseURL', baseURL.trim());
|
||||
if (model.trim()) window.metona.config.set('llm.model', model.trim());
|
||||
if (apiKey.trim()) window.metona.config.set('llm.apiKey', apiKey.trim());
|
||||
if (workspacePath.trim()) window.metona.config.set('workspace.path', workspacePath.trim());
|
||||
useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || '');
|
||||
}
|
||||
window.metona?.config?.set('onboarding.completed', true); setOnboardingCompleted(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open maxWidth="sm" PaperProps={{ sx: { borderRadius: 3, overflow: 'hidden' } }}>
|
||||
<Box sx={{ px: 3, pt: 2, pb: 0 }}>
|
||||
<Stepper activeStep={step} alternativeLabel sx={{ '& .MuiStepLabel-label': { fontSize: 11 } }}>
|
||||
{STEPS.map((s) => <Step key={s}><StepLabel>{s}</StepLabel></Step>)}
|
||||
</Stepper>
|
||||
</Box>
|
||||
<DialogContent sx={{ minHeight: 280, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{step === 0 && (
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Box component="img" src="./assets/logo.png" alt="Metona" sx={{ width: 64, height: 64, mx: 'auto', mb: 2, borderRadius: 2 }} />
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>欢迎使用 MetonaAI Desktop</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>生产级通用 AI Agent 智能体桌面应用,支持多轮对话、工具调用、记忆系统和 MCP 协议集成。</Typography>
|
||||
<Typography variant="caption">让我们花 1 分钟完成初始配置。</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>配置 LLM Provider</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>选择 Provider 并填写 API 信息。Base URL 和模型名称支持任意输入。</Typography>
|
||||
<Stack spacing={2}>
|
||||
<FormControl size="small"><InputLabel>Provider</InputLabel>
|
||||
<Select value={provider} label="Provider" onChange={(e) => setProvider(e.target.value)}>
|
||||
<MenuItem value="deepseek">DeepSeek</MenuItem>
|
||||
<MenuItem value="agnes">Agnes AI</MenuItem>
|
||||
<MenuItem value="ollama">Ollama (本地)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField size="small" label="API Base URL" value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder="如 https://api.deepseek.com" />
|
||||
<TextField size="small" label="模型名称" value={model} onChange={(e) => setModel(e.target.value)} placeholder="如 deepseek-v4-pro、qwen3:latest" />
|
||||
<TextField size="small" label="API Key" type={showKey ? 'text' : 'password'} value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-...(本地模型可留空)"
|
||||
slotProps={{ input: { endAdornment: <InputAdornment position="end"><IconButton size="small" onClick={() => setShowKey(!showKey)}>{showKey ? <EyeOff size={14} /> : <Eye size={14} />}</IconButton></InputAdornment> } }}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>自定义 Agent</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>编辑工作空间中的 SOUL.md 和 USERS.md 文件来定义 Agent 的身份和你的偏好。</Typography>
|
||||
<Box sx={{ px: 2, py: 1.5, borderRadius: 2, bgcolor: 'action.hover', border: '1px solid', borderColor: 'divider', fontSize: 12, color: 'text.secondary' }}>
|
||||
<div><strong style={{ color: '#e1e4ed' }}>SOUL.md</strong> — 定义 Agent 的身份、性格、核心价值观</div>
|
||||
<div><strong style={{ color: '#e1e4ed' }}>AGENTS.md</strong> — 定义行为规则、工具使用规范</div>
|
||||
<div><strong style={{ color: '#e1e4ed' }}>USERS.md</strong> — 描述你的技术栈、偏好、目标</div>
|
||||
<div style={{ marginTop: 8, fontSize: 11, opacity: 0.7 }}>此步骤可稍后在工作空间目录中完成。</div>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>工作空间</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>选择工作空间目录,或使用默认路径。</Typography>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||
<TextField size="small" value={workspacePath} onChange={(e) => setWorkspacePath(e.target.value)} placeholder="~/MetonaWorkspaces/default/" sx={{ flex: 1 }} />
|
||||
<Button variant="outlined" size="small" onClick={async () => {
|
||||
if (window.metona?.app?.selectFolder) { const r = await window.metona.app.selectFolder(workspacePath || undefined); if (!r.canceled && r.path) setWorkspacePath(r.path); }
|
||||
}}>选择文件夹</Button>
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>包含 SOUL.md、AGENTS.md、MEMORY.md、USERS.md 四个必需文件,首次打开时自动创建。</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<CheckCircle size={48} style={{ color: '#34d399', margin: '0 auto 16px' }} />
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>配置完成!</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}>MetonaAI Desktop 已准备就绪。开始与你的 AI Agent 对话吧!</Typography>
|
||||
<Typography variant="caption">按 Ctrl+Enter 发送消息,输入 / 查看命令列表</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', px: 3, py: 1.5, borderTop: 1, borderColor: 'divider' }}>
|
||||
<Button startIcon={<ArrowLeft size={12} />} onClick={() => setStep(step - 1)} disabled={step === 0} size="small" sx={{ color: 'text.secondary' }}>上一步</Button>
|
||||
<Button variant="contained" endIcon={step < STEPS.length - 1 ? <ArrowRight size={12} /> : undefined} onClick={handleNext} size="small">
|
||||
{step === STEPS.length - 1 ? '开始使用' : '下一步'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* SettingsModal — 设置弹窗
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Dialog, DialogContent, DialogTitle, Button, TextField, Select, MenuItem, Tabs, Tab, Slider, Checkbox, Box, Typography, Stack, Divider, FormControlLabel, InputLabel, FormControl, IconButton, Chip } from '@mui/material';
|
||||
import { X, Settings, Bot, Wrench, Server, Palette, FileText, Eye, EyeOff, FolderOpen } from 'lucide-react';
|
||||
import { useUIStore, type ThemeMode } from '@renderer/stores/ui-store';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
|
||||
type SettingsTab = 'workspace' | 'llm' | 'agent' | 'tools' | 'mcp' | 'appearance' | 'logs';
|
||||
const TABS: { id: SettingsTab; label: string; icon: typeof Settings }[] = [
|
||||
{ id: 'workspace', label: '工作空间', icon: FolderOpen },
|
||||
{ id: 'llm', label: 'LLM 配置', icon: Bot }, { id: 'agent', label: 'Agent 配置', icon: Settings },
|
||||
{ id: 'tools', label: '工具管理', icon: Wrench }, { id: 'mcp', label: 'MCP 服务', icon: Server },
|
||||
{ id: 'appearance', label: '外观', icon: Palette }, { id: 'logs', label: '日志与数据', icon: FileText },
|
||||
];
|
||||
|
||||
export function SettingsModal(): React.JSX.Element | null {
|
||||
const settingsOpen = useUIStore((s) => s.settingsOpen);
|
||||
const closeSettings = useUIStore((s) => s.closeSettings);
|
||||
const theme = useUIStore((s) => s.theme);
|
||||
const setTheme = useUIStore((s) => s.setTheme);
|
||||
const [tab, setTab] = useState<SettingsTab>('llm');
|
||||
if (!settingsOpen) return null;
|
||||
|
||||
return (
|
||||
<Dialog open onClose={closeSettings} maxWidth="md" fullWidth PaperProps={{ sx: { maxWidth: 640, height: '80vh', borderRadius: 3, display: 'flex', flexDirection: 'column' } }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2.5, py: 1.5, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Typography variant="h6">设置</Typography>
|
||||
<IconButton size="small" onClick={closeSettings}><X size={14} /></IconButton>
|
||||
</Stack>
|
||||
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
<Tabs orientation="vertical" value={tab} onChange={(_, v) => setTab(v)}
|
||||
sx={{
|
||||
borderRight: 1, borderColor: 'divider', minWidth: 130,
|
||||
'& .MuiTab-root': {
|
||||
alignItems: 'center', fontSize: 12,
|
||||
textTransform: 'none', gap: '2px', px: 2, justifyContent: 'flex-start',
|
||||
minHeight: 40,
|
||||
},
|
||||
'& .MuiTab-iconWrapper': { display: 'flex', alignItems: 'center', marginRight: 0 },
|
||||
}}
|
||||
>
|
||||
{TABS.map((t) => <Tab key={t.id} value={t.id} label={t.label} icon={<t.icon size={14} />} iconPosition="start" />)}
|
||||
</Tabs>
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5 }}>
|
||||
{tab === 'workspace' && <WorkspaceSettings />}
|
||||
{tab === 'llm' && <LLMSettings />}
|
||||
{tab === 'agent' && <AgentSettings />}
|
||||
{tab === 'tools' && <ToolsSettings />}
|
||||
{tab === 'mcp' && <MCPSettings />}
|
||||
{tab === 'appearance' && <AppearanceSettings theme={theme} setTheme={setTheme} />}
|
||||
{tab === 'logs' && <LogsSettings />}
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function useConfig<T>(key: string, defaultValue: T): [T, (v: T) => void] {
|
||||
const [value, setValue] = useState<T>(defaultValue);
|
||||
useEffect(() => { if (window.metona?.config?.get) window.metona.config.get(key).then((v) => { if (v != null) setValue(v as T); }).catch(() => {}); }, [key]);
|
||||
const set = useCallback((v: T) => { setValue(v); window.metona?.config?.set(key, v).catch(() => {}); }, [key]);
|
||||
return [value, set];
|
||||
}
|
||||
|
||||
const PROVIDER_URLS: Record<string, string> = { deepseek: 'https://api.deepseek.com', agnes: 'https://apihub.agnes-ai.com/v1', ollama: 'http://localhost:11434' };
|
||||
|
||||
function WorkspaceSettings() {
|
||||
const [workspacePath, setWorkspacePath] = useConfig('workspace.path', '');
|
||||
|
||||
const handleSelect = async () => {
|
||||
if (!window.metona?.app?.selectFolder) return;
|
||||
const r = await window.metona.app.selectFolder(workspacePath || undefined);
|
||||
if (!r.canceled && r.path) setWorkspacePath(r.path);
|
||||
};
|
||||
|
||||
const handleOpen = () => { if (workspacePath) window.metona?.app?.showItemInFolder(workspacePath); };
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>工作空间</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>工作空间是 Metona 的组织核心,包含 SOUL.md、AGENTS.md、MEMORY.md、USERS.md 四个必需文件。</Typography>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<TextField size="small" value={workspacePath} onChange={(e) => setWorkspacePath(e.target.value)} placeholder="~/MetonaWorkspaces/default/" sx={{ flex: 1 }} />
|
||||
<Button variant="outlined" size="small" onClick={handleSelect}>选择文件夹</Button>
|
||||
</Stack>
|
||||
{workspacePath && (
|
||||
<Button variant="text" size="small" onClick={handleOpen} sx={{ alignSelf: 'flex-start', fontSize: 11, color: 'text.secondary' }}>
|
||||
📂 在文件管理器中打开
|
||||
</Button>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled' }}>修改工作空间路径后需重启应用生效。</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function LLMSettings() {
|
||||
const [provider, setProvider] = useConfig('llm.provider', '');
|
||||
const [model, setModel] = useConfig('llm.model', '');
|
||||
const [apiKey, setApiKey] = useConfig('llm.apiKey', '');
|
||||
const [baseURL, setBaseURL] = useConfig('llm.baseURL', '');
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>LLM 配置</Typography>
|
||||
<FormControl size="small"><InputLabel>Provider</InputLabel>
|
||||
<Select value={provider} label="Provider" onChange={(e) => setProvider(e.target.value)}>
|
||||
<MenuItem value="deepseek">DeepSeek</MenuItem>
|
||||
<MenuItem value="agnes">Agnes AI</MenuItem>
|
||||
<MenuItem value="ollama">Ollama (本地)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField size="small" label="API Base URL" value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder="如 https://api.deepseek.com" />
|
||||
<TextField size="small" label="模型名称" value={model} onChange={(e) => setModel(e.target.value)} placeholder="如 deepseek-v4-pro、qwen3:latest" />
|
||||
<TextField size="small" label="API Key" type={showKey ? 'text' : 'password'} value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-...(本地模型可留空)"
|
||||
slotProps={{ input: { endAdornment: <IconButton size="small" onClick={() => setShowKey(!showKey)}>{showKey ? <EyeOff size={14} /> : <Eye size={14} />}</IconButton> } }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentSettings() {
|
||||
const [maxIter, setMaxIter] = useConfig('agent.maxIterations', 20);
|
||||
const [timeout, setTimeout_] = useConfig('agent.totalTimeoutMs', 600000);
|
||||
const [thinking, setThinking] = useConfig('agent.enableThinking', true);
|
||||
const [thinkingEffort, setThinkingEffort] = useConfig('agent.thinkingEffort', 'high');
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>Agent 配置</Typography>
|
||||
<Box>
|
||||
<Stack direction="row" justifyContent="space-between"><Typography variant="caption" sx={{ color: 'text.secondary' }}>最大迭代次数</Typography><Typography variant="caption" sx={{ fontFamily: 'monospace' }}>{maxIter}</Typography></Stack>
|
||||
<Slider value={maxIter} onChange={(_, v) => setMaxIter(v as number)} min={1} max={50} size="small" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Stack direction="row" justifyContent="space-between"><Typography variant="caption" sx={{ color: 'text.secondary' }}>总超时 (秒)</Typography><Typography variant="caption" sx={{ fontFamily: 'monospace' }}>{timeout / 1000}s</Typography></Stack>
|
||||
<Slider value={timeout / 1000} onChange={(_, v) => setTimeout_((v as number) * 1000)} min={60} max={1800} step={60} size="small" />
|
||||
</Box>
|
||||
<FormControlLabel control={<Checkbox checked={thinking} onChange={(e) => setThinking(e.target.checked)} size="small" />} label={<Typography variant="body2">启用思考模式</Typography>} />
|
||||
{thinking && (
|
||||
<FormControl size="small"><InputLabel>思考强度</InputLabel>
|
||||
<Select value={thinkingEffort} label="思考强度" onChange={(e) => setThinkingEffort(e.target.value)}>
|
||||
<MenuItem value="low">Low</MenuItem><MenuItem value="medium">Medium</MenuItem><MenuItem value="high">High</MenuItem><MenuItem value="max">Max</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolsSettings() {
|
||||
const [tools, setTools] = useState<Array<{ name: string; description: string; riskLevel: string; enabled: boolean }>>([]);
|
||||
useEffect(() => { if (window.metona?.tools?.list) window.metona.tools.list().then((l) => setTools((l as any[]).map((t) => ({ ...t, enabled: true })))).catch(() => {}); }, []);
|
||||
const handleToggle = (name: string, enabled: boolean) => { setTools((p) => p.map((t) => t.name === name ? { ...t, enabled } : t)); window.metona?.tools?.toggle(name, enabled).catch(() => {}); };
|
||||
const riskColors: Record<string, 'success' | 'info' | 'warning' | 'error'> = { safe: 'success', low: 'info', medium: 'warning', high: 'error' };
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>工具管理</Typography>
|
||||
{tools.length === 0 ? <Typography variant="caption" sx={{ textAlign: 'center', py: 4, color: 'text.secondary' }}>加载中...</Typography> : tools.map((t) => (
|
||||
<Stack key={t.name} direction="row" alignItems="center" justifyContent="space-between" sx={{ py: 1, px: 1.5, borderRadius: 1.5, bgcolor: 'secondary.main' }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>{t.name}</Typography>
|
||||
<Typography variant="caption" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.description.slice(0, 40)}</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Chip label={t.riskLevel.toUpperCase()} size="small" color={riskColors[t.riskLevel]} variant="outlined" sx={{ height: 18, fontSize: 9 }} />
|
||||
<Checkbox checked={t.enabled} onChange={(e) => handleToggle(t.name, e.target.checked)} size="small" />
|
||||
</Stack>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function MCPSettings() {
|
||||
const [servers, setServers] = useState<Array<{ name: string; status: string; toolCount: number; error?: string }>>([]);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newCommand, setNewCommand] = useState('');
|
||||
const [newArgs, setNewArgs] = useState('');
|
||||
const loadServers = () => { if (window.metona?.mcp?.listServers) window.metona.mcp.listServers().then((l) => setServers(l as any[])).catch(() => {}); };
|
||||
useEffect(() => { loadServers(); }, []);
|
||||
const handleAdd = async () => { if (!newName.trim() || !newCommand.trim()) return; await window.metona?.mcp?.addServer({ name: newName.trim(), transport: 'stdio', command: newCommand.trim(), args: newArgs.trim() ? newArgs.trim().split(/\s+/) : [] }); setNewName(''); setNewCommand(''); setNewArgs(''); setShowAdd(false); loadServers(); };
|
||||
const statusColors: Record<string, string> = { connected: 'success.main', connecting: 'warning.main', disconnected: 'text.secondary', error: 'error.main' };
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>MCP 服务</Typography>
|
||||
{servers.length === 0 ? <Typography variant="caption" sx={{ textAlign: 'center', py: 4, color: 'text.secondary' }}>暂无 MCP 服务</Typography> : servers.map((s) => (
|
||||
<Stack key={s.name} direction="row" alignItems="center" justifyContent="space-between" sx={{ py: 1, px: 1.5, borderRadius: 1.5, bgcolor: 'secondary.main' }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50', bgcolor: statusColors[s.status] }} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>{s.name}</Typography>
|
||||
<Typography variant="caption" sx={{ color: statusColors[s.status] }}>{s.status}</Typography>
|
||||
{s.toolCount > 0 && <Typography variant="caption" sx={{ color: 'text.secondary' }}>{s.toolCount} 工具</Typography>}
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<Button size="small" sx={{ fontSize: 10, minWidth: 40 }} onClick={async () => { await window.metona?.mcp?.toggleServer(s.name, s.status !== 'connected'); loadServers(); }}>{s.status === 'connected' ? '断开' : '连接'}</Button>
|
||||
<Button size="small" color="error" sx={{ fontSize: 10, minWidth: 40 }} onClick={async () => { if (confirm(`确定移除 "${s.name}"?`)) { await window.metona?.mcp?.removeServer(s.name); loadServers(); } }}>移除</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
))}
|
||||
{showAdd ? (
|
||||
<Stack spacing={1} sx={{ p: 1.5, borderRadius: 1.5, bgcolor: 'secondary.main', border: '1px solid', borderColor: 'divider' }}>
|
||||
<TextField size="small" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="服务名称" />
|
||||
<TextField size="small" value={newCommand} onChange={(e) => setNewCommand(e.target.value)} placeholder="命令路径" />
|
||||
<TextField size="small" value={newArgs} onChange={(e) => setNewArgs(e.target.value)} placeholder="参数 (空格分隔)" />
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button variant="contained" size="small" onClick={handleAdd} disabled={!newName.trim() || !newCommand.trim()} sx={{ flex: 1 }}>添加</Button>
|
||||
<Button variant="outlined" size="small" onClick={() => setShowAdd(false)} sx={{ flex: 1 }}>取消</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : <Button variant="outlined" fullWidth size="small" onClick={() => setShowAdd(true)}>+ 添加 MCP 服务</Button>}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceSettings({ theme, setTheme }: { theme: ThemeMode; setTheme: (t: ThemeMode) => void }) {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>外观</Typography>
|
||||
<Box><Typography variant="caption" sx={{ color: 'text.secondary', mb: 1, display: 'block' }}>主题</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{(['dark', 'light', 'auto'] as ThemeMode[]).map((t) => <Button key={t} variant={theme === t ? 'contained' : 'outlined'} size="small" onClick={() => setTheme(t)}>{t === 'dark' ? '深色' : t === 'light' ? '浅色' : '跟随系统'}</Button>)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function LogsSettings() {
|
||||
const [logLevel, setLogLevel] = useConfig('logging.level', 'info');
|
||||
const [clearing, setClearing] = useState<string | null>(null);
|
||||
const handleExport = async () => { if (!window.metona?.data?.exportData) return; const r = await window.metona.data.exportData(); if (r.success && r.data) { const b = new Blob([JSON.stringify(r.data, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `metona-export-${Date.now()}.json`; a.click(); } };
|
||||
const handleClear = async (type: 'sessions' | 'memories' | 'auditLogs') => { const labels: Record<string, string> = { sessions: '所有会话', memories: '所有记忆', auditLogs: '审计日志' }; if (!confirm(`确定清理${labels[type]}?`)) return; setClearing(type); try { let r; if (type === 'sessions') r = await window.metona?.data?.clearSessions(); else if (type === 'memories') r = await window.metona?.data?.clearMemories(); else r = await window.metona?.data?.clearAuditLogs(); if (r?.success) alert(`${labels[type]}已清理`); else alert(`失败: ${r?.error}`); } catch (e) { alert(`失败: ${(e as Error).message}`); } setClearing(null); };
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>日志与数据</Typography>
|
||||
<FormControl size="small"><InputLabel>日志级别</InputLabel>
|
||||
<Select value={logLevel} label="日志级别" onChange={(e) => setLogLevel(e.target.value)}>
|
||||
<MenuItem value="debug">DEBUG</MenuItem><MenuItem value="info">INFO</MenuItem><MenuItem value="warn">WARN</MenuItem><MenuItem value="error">ERROR</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Divider />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>数据管理</Typography>
|
||||
<Button variant="outlined" fullWidth size="small" onClick={handleExport}>📦 导出全部数据(JSON)</Button>
|
||||
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => handleClear('sessions')} disabled={clearing !== null}>{clearing === 'sessions' ? '清理中...' : '🗑️ 清理所有会话'}</Button>
|
||||
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => handleClear('memories')} disabled={clearing !== null}>{clearing === 'memories' ? '清理中...' : '🗑️ 清理所有记忆'}</Button>
|
||||
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => handleClear('auditLogs')} disabled={clearing !== null}>{clearing === 'auditLogs' ? '清理中...' : '🗑️ 清理审计日志'}</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* TokenUsage — Token 统计图表
|
||||
*
|
||||
* 进度条可视化 + 数值显示。
|
||||
*/
|
||||
|
||||
import { Box, Typography, Stack, LinearProgress } from '@mui/material';
|
||||
import { Zap } from 'lucide-react';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { formatTokens } from '@renderer/lib/formatters';
|
||||
import { cn } from '@renderer/lib/cn';
|
||||
|
||||
export function TokenUsage(): React.JSX.Element {
|
||||
const tokenUsage = useAgentStore((s) => s.tokenUsage);
|
||||
const maxIterations = useAgentStore((s) => s.maxIterations);
|
||||
const currentIteration = useAgentStore((s) => s.currentIteration);
|
||||
|
||||
if (tokenUsage.totalTokens === 0) {
|
||||
return (
|
||||
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider' }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
|
||||
<Zap size={14} style={{ color: '#fbbf24' }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>Token 用量</Typography>
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ textAlign: 'center', py: 1, display: 'block', color: 'text.disabled' }}>等待 Agent 活动...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const maxTokens = 128_000;
|
||||
const usagePercent = Math.min((tokenUsage.totalTokens / maxTokens) * 100, 100);
|
||||
const inputPercent = tokenUsage.totalTokens > 0 ? (tokenUsage.inputTokens / tokenUsage.totalTokens) * 100 : 0;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider' }}>
|
||||
{/* 标题 */}
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
|
||||
<Zap size={14} style={{ color: '#fbbf24' }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>Token 用量</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* 进度条 */}
|
||||
<Box sx={{ width: '100%', height: 6, borderRadius: 3, overflow: 'hidden', mb: 2, bgcolor: 'action.hover', display: 'flex' }}>
|
||||
<Box sx={{ height: '100%', width: `${inputPercent}%`, bgcolor: '#22d3ee', transition: 'width 300ms', borderRadius: '3px 0 0 3px' }} />
|
||||
<Box sx={{ height: '100%', width: `${100 - inputPercent}%`, bgcolor: '#a855f7', transition: 'width 300ms', borderRadius: '0 3px 3px 0' }} />
|
||||
</Box>
|
||||
|
||||
{/* 数值网格 */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 1.5 }}>
|
||||
<StatCard label="输入" value={formatTokens(tokenUsage.inputTokens)} color="#22d3ee" />
|
||||
<StatCard label="输出" value={formatTokens(tokenUsage.outputTokens)} color="#a855f7" />
|
||||
</Box>
|
||||
|
||||
{/* 底部汇总 */}
|
||||
<Stack spacing={0.5}>
|
||||
<SummaryRow label="总计" value={formatTokens(tokenUsage.totalTokens)} highlight />
|
||||
<SummaryRow label="上下文" value={`${usagePercent.toFixed(1)}%`}
|
||||
color={usagePercent > 80 ? 'error.main' : usagePercent > 60 ? 'warning.main' : 'text.secondary'} />
|
||||
<SummaryRow label="迭代" value={`${currentIteration} / ${maxIterations}`} />
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, color }: { label: string; value: string; color: string }) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, borderRadius: 1.5, bgcolor: 'action.hover' }}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: color, flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 11 }}>{label}</Typography>
|
||||
<Typography variant="caption" sx={{ ml: 'auto', fontFamily: 'monospace', fontWeight: 600, color: 'text.primary', fontSize: 12 }}>{value}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryRow({ label, value, highlight, color }: { label: string; value: string; highlight?: boolean; color?: string }) {
|
||||
return (
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ py: 0.25 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 11 }}>{label}</Typography>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: highlight ? 600 : 400, color: color ?? (highlight ? 'text.primary' : 'text.secondary'), fontSize: highlight ? 12 : 11 }}>{value}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* TraceStep — 单个 Trace 步骤
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Box, Typography, IconButton, Collapse, Stack } from '@mui/material';
|
||||
import { ChevronDown, ChevronRight, CircleDot, CheckCircle, Loader2 } from 'lucide-react';
|
||||
import { TRACE_STATE_COLORS, TRACE_STATE_LABELS } from '@renderer/lib/constants';
|
||||
import { formatDuration, formatTokens } from '@renderer/lib/formatters';
|
||||
import type { TraceStep as TraceStepType } from '@renderer/stores/agent-store';
|
||||
|
||||
interface TraceStepProps { step: TraceStepType; isCurrent?: boolean; }
|
||||
|
||||
export function TraceStep({ step, isCurrent }: TraceStepProps): React.JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const color = TRACE_STATE_COLORS[step.state] ?? '#8b8fa7';
|
||||
const label = TRACE_STATE_LABELS[step.state] ?? step.state;
|
||||
const duration = step.completedAt ? step.completedAt - step.startedAt : null;
|
||||
|
||||
return (
|
||||
<Box sx={{ borderRadius: 1, border: '1px solid', borderColor: 'divider', bgcolor: 'secondary.main', transition: 'all 150ms', ...(isCurrent ? { boxShadow: `0 0 0 1px ${color}` } : {}) }}>
|
||||
<IconButton size="small" onClick={() => setExpanded(!expanded)} sx={{ width: '100%', justifyContent: 'flex-start', gap: 1, px: 1.5, py: 1, borderRadius: '4px 4px 0 0', color: 'text.primary', fontSize: 12 }}>
|
||||
{expanded ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
|
||||
{isCurrent ? <Loader2 size={12} style={{ color, animation: 'spin 1s linear infinite' }} /> : step.completedAt ? <CheckCircle size={12} style={{ color }} /> : <CircleDot size={12} style={{ color }} />}
|
||||
<Typography component="span" sx={{ fontWeight: 600, color, fontSize: 12 }}>#{step.iteration}</Typography>
|
||||
<Typography component="span" sx={{ textTransform: 'uppercase', color: 'text.secondary', fontSize: 12 }}>{label}</Typography>
|
||||
{duration != null && <Typography variant="caption" sx={{ ml: 'auto', color: 'text.secondary' }}>{formatDuration(duration)}</Typography>}
|
||||
{step.tokenUsage && <Typography variant="caption" sx={{ color: 'text.secondary' }}>{formatTokens(step.tokenUsage.totalTokens)} tok</Typography>}
|
||||
</IconButton>
|
||||
<Collapse in={expanded}>
|
||||
<Box sx={{ px: 1.5, pb: 1.5, borderTop: 1, borderColor: 'divider' }}>
|
||||
{step.thought && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'warning.main', fontSize: 10 }}>💭 Thought</Typography>
|
||||
<Box component="pre" sx={{ fontSize: 11, whiteSpace: 'pre-wrap', color: 'text.secondary', fontFamily: "'SF Mono',monospace", m: 0 }}>{step.thought}</Box>
|
||||
</Box>
|
||||
)}
|
||||
{step.toolCalls?.map((tc) => (
|
||||
<Box key={tc.id} sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#a855f7', fontSize: 10 }}>🔧 {tc.name}</Typography>
|
||||
<Box component="pre" sx={{ fontSize: 10, overflowX: 'auto', color: 'text.secondary', fontFamily: "'SF Mono',monospace", m: 0 }}>{JSON.stringify(tc.args, null, 2)}</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* TraceViewer — Trace 时间轴查看器
|
||||
*/
|
||||
|
||||
import { Box, Typography, Stack } from '@mui/material';
|
||||
import { Activity } from 'lucide-react';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { TraceStep } from './TraceStep';
|
||||
import { formatTokens } from '@renderer/lib/formatters';
|
||||
|
||||
export function TraceViewer(): React.JSX.Element {
|
||||
const traceSteps = useAgentStore((s) => s.traceSteps);
|
||||
const tokenUsage = useAgentStore((s) => s.tokenUsage);
|
||||
const agentStatus = useAgentStore((s) => s.agentStatus);
|
||||
const provider = useAgentStore((s) => s.provider);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
|
||||
<Activity size={14} style={{ color: '#818cf8' }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
|
||||
Trace Viewer
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{traceSteps.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ textAlign: 'center', py: 2, color: 'text.secondary' }}>等待 Agent 活动...</Typography>
|
||||
) : traceSteps.map((step, i) => (
|
||||
<TraceStep key={`${step.iteration}-${step.state}-${i}`} step={step} isCurrent={i === traceSteps.length - 1 && agentStatus !== 'idle'} />
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{tokenUsage.totalTokens > 0 && (
|
||||
<Box sx={{ mt: 1.5, pt: 1.5, borderTop: 1, borderColor: 'divider', fontSize: 11, color: 'text.secondary' }}>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<span>Token:</span>
|
||||
<span>入 {formatTokens(tokenUsage.inputTokens)} | 出 {formatTokens(tokenUsage.outputTokens)} | 总 {formatTokens(tokenUsage.totalTokens)}</span>
|
||||
</Stack>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<span>Provider:</span><span>{provider}</span>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}, []);
|
||||
}
|
||||
@@ -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);
|
||||
}, []);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* MetonaAI Desktop — React 渲染进程入口
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles/globals.css';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
|
||||
if (!root) {
|
||||
throw new Error('Root element not found');
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Zustand Store — Agent 状态管理
|
||||
*
|
||||
* 管理 Agent 的实时状态:当前会话、消息流、流式输出、工具调用状态、Token 统计。
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { useSessionStore } from './session-store';
|
||||
|
||||
// ===== 消息类型 =====
|
||||
|
||||
export type MessageRole = 'user' | 'assistant' | 'system' | 'tool';
|
||||
|
||||
export interface ToolCallInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
status: 'pending' | 'executing' | 'success' | 'error' | 'blocked';
|
||||
result?: unknown;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AttachmentInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'image' | 'text' | 'other';
|
||||
size: number;
|
||||
preview?: string; // 图片 base64 data URL
|
||||
textContent?: string; // 文本文件内容
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
iteration?: number;
|
||||
toolCalls?: ToolCallInfo[];
|
||||
reasoningContent?: string;
|
||||
attachments?: AttachmentInfo[];
|
||||
}
|
||||
|
||||
// ===== Agent 状态 =====
|
||||
|
||||
export type AgentStatus = 'idle' | 'thinking' | 'executing' | 'error';
|
||||
|
||||
// ===== Token 统计 =====
|
||||
|
||||
export interface TokenUsage {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
// ===== Trace 步骤 =====
|
||||
|
||||
export interface TraceStep {
|
||||
iteration: number;
|
||||
state: string;
|
||||
startedAt: number;
|
||||
completedAt?: number;
|
||||
thought?: string;
|
||||
toolCalls?: ToolCallInfo[];
|
||||
tokenUsage?: { promptTokens: number; completionTokens: number; totalTokens: number };
|
||||
}
|
||||
|
||||
// ===== Store 状态 =====
|
||||
|
||||
interface AgentState {
|
||||
// 会话
|
||||
currentSessionId: string | null;
|
||||
messages: ChatMessage[];
|
||||
agentStatus: AgentStatus;
|
||||
currentIteration: number;
|
||||
maxIterations: number;
|
||||
|
||||
// Token 统计
|
||||
tokenUsage: TokenUsage;
|
||||
|
||||
// Trace
|
||||
traceSteps: TraceStep[];
|
||||
|
||||
// 流式
|
||||
isStreaming: boolean;
|
||||
streamingContent: string;
|
||||
|
||||
// Provider
|
||||
provider: string;
|
||||
model: string;
|
||||
|
||||
// Actions
|
||||
setCurrentSession: (id: string | null) => void;
|
||||
setMessages: (messages: ChatMessage[]) => void;
|
||||
addMessage: (message: ChatMessage) => void;
|
||||
updateMessage: (id: string, updates: Partial<ChatMessage>) => void;
|
||||
sendMessage: (content: string, images?: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }>, attachments?: AttachmentInfo[]) => void;
|
||||
updateLastAssistantMessage: (delta: string) => void;
|
||||
setAgentStatus: (status: AgentStatus) => void;
|
||||
setStreaming: (streaming: boolean) => void;
|
||||
appendStreamingContent: (delta: string) => void;
|
||||
clearStreamingContent: () => void;
|
||||
addTraceStep: (step: TraceStep) => void;
|
||||
updateTraceStep: (iteration: number, updates: Partial<TraceStep>) => void;
|
||||
updateTokenUsage: (usage: Partial<TokenUsage>) => void;
|
||||
setProvider: (provider: string, model: string) => void;
|
||||
setMaxIterations: (max: number) => void;
|
||||
saveTraceData: () => void;
|
||||
clearMessages: () => void;
|
||||
abort: () => void;
|
||||
}
|
||||
|
||||
export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
// ===== 初始状态 =====
|
||||
currentSessionId: null,
|
||||
messages: [],
|
||||
agentStatus: 'idle',
|
||||
currentIteration: 0,
|
||||
maxIterations: 20,
|
||||
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
||||
traceSteps: [],
|
||||
isStreaming: false,
|
||||
streamingContent: '',
|
||||
provider: '',
|
||||
model: '',
|
||||
|
||||
// ===== Actions =====
|
||||
|
||||
setCurrentSession: (id) => {
|
||||
set({ currentSessionId: id, messages: [], traceSteps: [], tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 } });
|
||||
|
||||
// 从数据库加载该会话的消息
|
||||
if (id && window.metona?.sessions?.getMessages) {
|
||||
window.metona.sessions.getMessages(id).then((msgs) => {
|
||||
const messages = (msgs as Array<{
|
||||
id: string; role: string; content: string;
|
||||
reasoningContent?: string; toolCalls?: unknown[];
|
||||
attachments?: Array<{ id: string; name: string; type: string; size: number; preview?: string; textContent?: string }>;
|
||||
timestamp: number;
|
||||
}>).map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role as ChatMessage['role'],
|
||||
content: m.content,
|
||||
reasoningContent: m.reasoningContent,
|
||||
toolCalls: m.toolCalls as ToolCallInfo[] | undefined,
|
||||
attachments: m.attachments as AttachmentInfo[] | undefined,
|
||||
timestamp: m.timestamp,
|
||||
}));
|
||||
set({ messages });
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// 从数据库加载该会话的 trace 步骤和 token 用量
|
||||
if (id && window.metona?.sessions?.getTrace) {
|
||||
window.metona.sessions.getTrace(id).then((data) => {
|
||||
if (data) {
|
||||
if (data.traceSteps) set({ traceSteps: data.traceSteps as TraceStep[] });
|
||||
if (data.tokenUsage) set({ tokenUsage: data.tokenUsage as TokenUsage });
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
setMessages: (messages) => set({ messages }),
|
||||
|
||||
addMessage: (message) =>
|
||||
set((s) => ({ messages: [...s.messages, message] })),
|
||||
|
||||
updateMessage: (id, updates) =>
|
||||
set((s) => ({
|
||||
messages: s.messages.map((m) =>
|
||||
m.id === id ? { ...m, ...updates } : m,
|
||||
),
|
||||
})),
|
||||
|
||||
sendMessage: async (content: string, images?: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }>, attachments?: AttachmentInfo[]) => {
|
||||
let sessionId = get().currentSessionId;
|
||||
|
||||
// 没有当前会话时自动创建
|
||||
if (!sessionId && window.metona?.sessions?.create) {
|
||||
try {
|
||||
const session = await window.metona.sessions.create() as { id: string; title: string; createdAt: number; updatedAt: number; messageCount: number; pinned: boolean; archived: boolean };
|
||||
useSessionStore.getState().addSession({
|
||||
id: session.id, title: session.title, createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt, messageCount: session.messageCount,
|
||||
pinned: session.pinned, archived: session.archived,
|
||||
});
|
||||
sessionId = session.id;
|
||||
set({ currentSessionId: sessionId });
|
||||
} catch {
|
||||
// 创建失败时静默
|
||||
}
|
||||
}
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: `msg_${Date.now()}_user`,
|
||||
role: 'user',
|
||||
content, // 用户可见内容(纯文本)
|
||||
timestamp: Date.now(),
|
||||
attachments: attachments && attachments.length > 0 ? attachments : undefined,
|
||||
};
|
||||
|
||||
set((s) => ({
|
||||
messages: [...s.messages, userMessage],
|
||||
agentStatus: 'thinking',
|
||||
isStreaming: true,
|
||||
streamingContent: '',
|
||||
}));
|
||||
|
||||
// 自动更新会话标题为用户第一条消息
|
||||
if (sessionId && window.metona?.sessions?.getMessages) {
|
||||
window.metona.sessions.getMessages(sessionId).then((msgs) => {
|
||||
if (msgs.length <= 1) {
|
||||
// 这是第一条消息,更新标题
|
||||
const title = content.length > 30 ? content.slice(0, 30) + '...' : content;
|
||||
window.metona?.sessions?.rename(sessionId, title);
|
||||
useSessionStore.getState().updateSession(sessionId, { title });
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
if (sessionId && window.metona?.agent?.sendMessage) {
|
||||
// 构建发给 LLM 的 content:纯用户文本 + 文件 JSON(无文字描述)
|
||||
let llmContent = content;
|
||||
const images: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }> = [];
|
||||
|
||||
if (attachments && attachments.length > 0) {
|
||||
const parts: string[] = [];
|
||||
for (const att of attachments) {
|
||||
if (att.type === 'image' && att.preview) {
|
||||
// 图片:直接加入 images 数组
|
||||
images.push({ url: att.preview, detail: 'auto' });
|
||||
} else {
|
||||
// 所有文件:JSON 格式,Base64 编码内容
|
||||
const ext = att.name.split('.').pop() ?? 'unknown';
|
||||
let base64Content = '';
|
||||
if (att.textContent) {
|
||||
base64Content = btoa(unescape(encodeURIComponent(att.textContent)));
|
||||
} else if (att.preview) {
|
||||
// 二进制文件从 data URL 提取 base64
|
||||
base64Content = att.preview.split(',')[1] ?? '';
|
||||
}
|
||||
parts.push(JSON.stringify({
|
||||
file_name: att.name,
|
||||
file_type: ext,
|
||||
context_encode: 'Base64',
|
||||
context: base64Content,
|
||||
}));
|
||||
}
|
||||
}
|
||||
llmContent = parts.join('\n') + (content ? '\n\n' + content : '');
|
||||
}
|
||||
|
||||
const messageWithImages = {
|
||||
...userMessage,
|
||||
content: llmContent,
|
||||
images: images.length > 0 ? images : undefined,
|
||||
};
|
||||
window.metona.agent.sendMessage(messageWithImages, sessionId).catch(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
updateLastAssistantMessage: (delta: string) => {
|
||||
set((s) => {
|
||||
const messages = [...s.messages];
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
if (lastMsg?.role === 'assistant') {
|
||||
lastMsg.content += delta;
|
||||
} else {
|
||||
messages.push({
|
||||
id: `msg_${Date.now()}_assistant`,
|
||||
role: 'assistant',
|
||||
content: delta,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
return { messages };
|
||||
});
|
||||
},
|
||||
|
||||
setAgentStatus: (status) => set({ agentStatus: status }),
|
||||
|
||||
setStreaming: (streaming) => set({ isStreaming: streaming }),
|
||||
|
||||
appendStreamingContent: (delta) =>
|
||||
set((s) => ({ streamingContent: s.streamingContent + delta })),
|
||||
|
||||
clearStreamingContent: () => set({ streamingContent: '' }),
|
||||
|
||||
addTraceStep: (step) =>
|
||||
set((s) => ({ traceSteps: [...s.traceSteps, step] })),
|
||||
|
||||
updateTraceStep: (iteration, updates) =>
|
||||
set((s) => ({
|
||||
traceSteps: s.traceSteps.map((t) =>
|
||||
t.iteration === iteration ? { ...t, ...updates } : t,
|
||||
),
|
||||
})),
|
||||
|
||||
updateTokenUsage: (usage) =>
|
||||
set((s) => ({ tokenUsage: { ...s.tokenUsage, ...usage } })),
|
||||
|
||||
setProvider: (provider, model) => set({ provider, model }),
|
||||
|
||||
setMaxIterations: (max) => set({ maxIterations: max }),
|
||||
|
||||
saveTraceData: () => {
|
||||
const { currentSessionId, traceSteps, tokenUsage } = get();
|
||||
if (currentSessionId && window.metona?.sessions?.saveTrace) {
|
||||
window.metona.sessions.saveTrace(currentSessionId, { traceSteps, tokenUsage }).catch(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
clearMessages: () =>
|
||||
set({
|
||||
messages: [],
|
||||
agentStatus: 'idle',
|
||||
currentIteration: 0,
|
||||
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
||||
traceSteps: [],
|
||||
isStreaming: false,
|
||||
streamingContent: '',
|
||||
}),
|
||||
|
||||
abort: () => {
|
||||
const sessionId = get().currentSessionId;
|
||||
set({ agentStatus: 'idle', isStreaming: false });
|
||||
if (sessionId && window.metona?.agent?.abortSession) {
|
||||
window.metona.agent.abortSession(sessionId).catch(() => {});
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Zustand Store — 会话管理
|
||||
*
|
||||
* 会话列表、当前会话、搜索、置顶、归档。
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
messageCount: number;
|
||||
pinned: boolean;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
sessions: Session[];
|
||||
currentSessionId: string | null;
|
||||
searchQuery: string;
|
||||
|
||||
// Actions
|
||||
setSessions: (sessions: Session[]) => void;
|
||||
setCurrentSession: (id: string | null) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
addSession: (session: Session) => void;
|
||||
updateSession: (id: string, updates: Partial<Session>) => void;
|
||||
removeSession: (id: string) => void;
|
||||
pinSession: (id: string, pinned: boolean) => void;
|
||||
archiveSession: (id: string, archived: boolean) => void;
|
||||
}
|
||||
|
||||
export const useSessionStore = create<SessionState>((set) => ({
|
||||
// 初始状态
|
||||
sessions: [],
|
||||
currentSessionId: null,
|
||||
searchQuery: '',
|
||||
|
||||
// Actions
|
||||
setSessions: (sessions) => set({ sessions }),
|
||||
setCurrentSession: (id) => set({ currentSessionId: id }),
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
addSession: (session) =>
|
||||
set((s) => ({ sessions: [session, ...s.sessions] })),
|
||||
updateSession: (id, updates) =>
|
||||
set((s) => ({
|
||||
sessions: s.sessions.map((sess) =>
|
||||
sess.id === id ? { ...sess, ...updates } : sess,
|
||||
),
|
||||
})),
|
||||
removeSession: (id) =>
|
||||
set((s) => ({
|
||||
sessions: s.sessions.filter((sess) => sess.id !== id),
|
||||
currentSessionId: s.currentSessionId === id ? null : s.currentSessionId,
|
||||
})),
|
||||
pinSession: (id, pinned) =>
|
||||
set((s) => ({
|
||||
sessions: s.sessions.map((sess) =>
|
||||
sess.id === id ? { ...sess, pinned } : sess,
|
||||
),
|
||||
})),
|
||||
archiveSession: (id, archived) =>
|
||||
set((s) => ({
|
||||
sessions: s.sessions.map((sess) =>
|
||||
sess.id === id ? { ...sess, archived } : sess,
|
||||
),
|
||||
})),
|
||||
}));
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Zustand Store — UI 状态管理
|
||||
*
|
||||
* 主题、设置弹窗、引导向导等全局 UI 状态。
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type ThemeMode = 'dark' | 'light' | 'auto';
|
||||
|
||||
interface UIState {
|
||||
// 主题
|
||||
theme: ThemeMode;
|
||||
resolvedTheme: 'dark' | 'light';
|
||||
|
||||
// 弹窗
|
||||
settingsOpen: boolean;
|
||||
onboardingCompleted: boolean;
|
||||
|
||||
// Actions
|
||||
setTheme: (theme: ThemeMode) => void;
|
||||
setResolvedTheme: (theme: 'dark' | 'light') => void;
|
||||
openSettings: () => void;
|
||||
closeSettings: () => void;
|
||||
setOnboardingCompleted: (completed: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
theme: 'auto',
|
||||
resolvedTheme: 'dark',
|
||||
settingsOpen: false,
|
||||
onboardingCompleted: false,
|
||||
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setResolvedTheme: (resolvedTheme) => set({ resolvedTheme }),
|
||||
openSettings: () => set({ settingsOpen: true }),
|
||||
closeSettings: () => set({ settingsOpen: false }),
|
||||
setOnboardingCompleted: (completed) => set({ onboardingCompleted: completed }),
|
||||
}));
|
||||
@@ -0,0 +1,263 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ===== MetonaAI Desktop — 全局样式 ===== */
|
||||
|
||||
/* CSS 变量 — 暗色主题(默认) */
|
||||
:root,
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg-primary: #0f1117;
|
||||
--bg-secondary: #1a1d27;
|
||||
--bg-tertiary: #252836;
|
||||
--bg-hover: rgba(99, 102, 241, 0.06);
|
||||
--bg-card: #1a1d27;
|
||||
--text-primary: #e1e4ed;
|
||||
--text-secondary: #64748b;
|
||||
--text-dim: #8b8fa7;
|
||||
--border-color: #2a2d3a;
|
||||
--accent: #818cf8;
|
||||
--accent-soft: rgba(99, 102, 241, 0.15);
|
||||
--green: #34d399;
|
||||
--orange: #fb923c;
|
||||
--red: #f87171;
|
||||
--amber: #fbbf24;
|
||||
--cyan: #22d3ee;
|
||||
--purple: #a855f7;
|
||||
--pink: #f472b6;
|
||||
--background: #0f1117;
|
||||
--foreground: #e1e4ed;
|
||||
--primary: #818cf8;
|
||||
--primary-foreground: #0f1117;
|
||||
--secondary: #252836;
|
||||
--secondary-foreground: #e1e4ed;
|
||||
--muted: #252836;
|
||||
--muted-foreground: #8b8fa7;
|
||||
--destructive: #f87171;
|
||||
--destructive-foreground: #e1e4ed;
|
||||
--border: #2a2d3a;
|
||||
--input: #2a2d3a;
|
||||
--ring: #818cf8;
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
|
||||
/* 亮色主题 */
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8fafc;
|
||||
--bg-tertiary: #f1f5f9;
|
||||
--bg-hover: rgba(99, 102, 241, 0.04);
|
||||
--bg-card: #f8fafc;
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #334155;
|
||||
--text-dim: #64748b;
|
||||
--border-color: #e2e8f0;
|
||||
--accent: #6366f1;
|
||||
--accent-soft: rgba(99, 102, 241, 0.1);
|
||||
--green: #059669;
|
||||
--orange: #ea580c;
|
||||
--red: #dc2626;
|
||||
--amber: #d97706;
|
||||
--cyan: #0891b2;
|
||||
--purple: #9333ea;
|
||||
--pink: #db2777;
|
||||
--background: #ffffff;
|
||||
--foreground: #0f172a;
|
||||
--primary: #6366f1;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #f1f5f9;
|
||||
--secondary-foreground: #0f172a;
|
||||
--muted: #f1f5f9;
|
||||
--muted-foreground: #64748b;
|
||||
--destructive: #dc2626;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #e2e8f0;
|
||||
--input: #e2e8f0;
|
||||
--ring: #6366f1;
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
|
||||
/* Tailwind v4 主题注册 */
|
||||
@theme {
|
||||
--color-bg-primary: var(--bg-primary);
|
||||
--color-bg-secondary: var(--bg-secondary);
|
||||
--color-bg-tertiary: var(--bg-tertiary);
|
||||
--color-bg-hover: var(--bg-hover);
|
||||
--color-text-primary: var(--text-primary);
|
||||
--color-text-secondary: var(--text-secondary);
|
||||
--color-text-dim: var(--text-dim);
|
||||
--color-border-color: var(--border-color);
|
||||
--color-green: var(--green);
|
||||
--color-orange: var(--orange);
|
||||
--color-red: var(--red);
|
||||
--color-amber: var(--amber);
|
||||
--color-cyan: var(--cyan);
|
||||
--color-purple: var(--purple);
|
||||
--color-pink: var(--pink);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--radius: var(--radius);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* ===== 全局基础样式 ===== */
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body, #root { width: 100%; height: 100%; overflow: hidden; }
|
||||
body {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ===== 滚动条样式 ===== */
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #2a2d3a; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #8b8fa7; }
|
||||
|
||||
/* ===== 代码字体 ===== */
|
||||
|
||||
code, pre, .font-mono { font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; }
|
||||
|
||||
/* ===== 选择文本颜色 ===== */
|
||||
::selection { background: rgba(99, 102, 241, 0.15); }
|
||||
|
||||
/* ===== 焦点样式 ===== */
|
||||
:focus-visible { outline: 2px solid #818cf8; outline-offset: 2px; }
|
||||
|
||||
/* ===== 动画 Keyframes ===== */
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ===== Markdown Prose 样式 ===== */
|
||||
|
||||
.prose-metona {
|
||||
color: inherit;
|
||||
line-height: 1.7;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.prose-metona h1,
|
||||
.prose-metona h2,
|
||||
.prose-metona h3,
|
||||
.prose-metona h4 {
|
||||
font-weight: 600;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.prose-metona h1 { font-size: 1.5em; }
|
||||
.prose-metona h2 { font-size: 1.25em; }
|
||||
.prose-metona h3 { font-size: 1.125em; }
|
||||
|
||||
.prose-metona p { margin-bottom: 0.75em; }
|
||||
|
||||
.prose-metona a { color: #818cf8; text-decoration: none; }
|
||||
.prose-metona a:hover { text-decoration: underline; }
|
||||
|
||||
.prose-metona code {
|
||||
color: #22d3ee;
|
||||
background: rgba(34, 211, 238, 0.1);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.prose-metona pre {
|
||||
background: #252836;
|
||||
border: 1px solid #2a2d3a;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.prose-metona pre code {
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.prose-metona blockquote {
|
||||
border-left: 3px solid #818cf8;
|
||||
padding-left: 16px;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.prose-metona ul, .prose-metona ol { padding-left: 1.5em; margin-bottom: 0.75em; }
|
||||
.prose-metona li { margin-bottom: 0.25em; }
|
||||
|
||||
.prose-metona table { width: 100%; border-collapse: collapse; margin-bottom: 1em; font-size: 13px; }
|
||||
.prose-metona th { text-align: left; padding: 8px 12px; background: #252836; border-bottom: 1px solid #2a2d3a; color: #64748b; font-weight: 600; }
|
||||
.prose-metona td { padding: 8px 12px; border-bottom: 1px solid #2a2d3a; }
|
||||
|
||||
.prose-metona hr { border: none; height: 1px; background: #2a2d3a; margin: 1.5em 0; }
|
||||
.prose-metona img { max-width: 100%; border-radius: 8px; }
|
||||
|
||||
/* ===== 动效降级 ===== */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
Vendored
+215
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* MetonaAI Desktop — 全局类型声明
|
||||
*
|
||||
* 为渲染进程暴露的 window.metona API 提供类型支持。
|
||||
* 使用具体类型替代 unknown,确保类型安全。
|
||||
*/
|
||||
|
||||
// ===== Metona IR 类型(渲染进程侧子集) =====
|
||||
|
||||
/** MetonaMessage — IR 标准消息类型(渲染进程可构造的部分) */
|
||||
interface MetonaMessageInput {
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
content: string;
|
||||
reasoningContent?: string;
|
||||
toolCalls?: Array<{ id: string; name: string; args: Record<string, unknown> }>;
|
||||
timestamp: number;
|
||||
iteration?: number;
|
||||
images?: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }>;
|
||||
}
|
||||
|
||||
// ===== Agent API =====
|
||||
|
||||
interface MetonaStreamEventData {
|
||||
type: string;
|
||||
requestId?: string;
|
||||
sessionId?: string;
|
||||
iteration?: number;
|
||||
seq?: number;
|
||||
timestamp?: number;
|
||||
delta?: string;
|
||||
content?: string;
|
||||
toolCall?: {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
};
|
||||
toolResult?: {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
success: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
durationMs?: number;
|
||||
};
|
||||
usage?: {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
};
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
retryable?: boolean;
|
||||
};
|
||||
state?: string;
|
||||
}
|
||||
|
||||
interface MetonaAgentAPI {
|
||||
/** 发送 MetonaMessage(IR 类型)+ sessionId 到主进程 */
|
||||
sendMessage: (message: MetonaMessageInput, sessionId: string) => Promise<{ success: boolean }>;
|
||||
onStreamEvent: (callback: (event: MetonaStreamEventData) => void) => () => void;
|
||||
onStateChange: (callback: (state: { sessionId: string; state: string }) => void) => () => void;
|
||||
abortSession: (sessionId: string) => Promise<{ success: boolean }>;
|
||||
onProviderSwitched: (callback: (data: { from?: string; to?: string; reason?: string }) => void) => () => void;
|
||||
}
|
||||
|
||||
// ===== Sessions API =====
|
||||
|
||||
interface MetonaSessionInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
messageCount: number;
|
||||
pinned: boolean;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
interface MetonaSessionsAPI {
|
||||
list: () => Promise<MetonaSessionInfo[]>;
|
||||
create: (title?: string) => Promise<MetonaSessionInfo>;
|
||||
rename: (sessionId: string, title: string) => Promise<{ success: boolean }>;
|
||||
delete: (sessionId: string) => Promise<{ success: boolean }>;
|
||||
getMessages: (sessionId: string) => Promise<Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
reasoningContent?: string;
|
||||
toolCalls?: unknown[];
|
||||
attachments?: Array<{ id: string; name: string; type: string; size: number; preview?: string; textContent?: string }>;
|
||||
timestamp: number;
|
||||
}>>;
|
||||
pin: (sessionId: string, pinned: boolean) => Promise<{ success: boolean }>;
|
||||
deleteMessage: (messageId: string) => Promise<{ success: boolean }>;
|
||||
clearMessages: (sessionId: string) => Promise<{ success: boolean }>;
|
||||
saveTrace: (sessionId: string, data: { traceSteps: unknown[]; tokenUsage: unknown }) => Promise<{ success: boolean }>;
|
||||
getTrace: (sessionId: string) => Promise<{ traceSteps: unknown[]; tokenUsage: unknown } | null>;
|
||||
}
|
||||
|
||||
// ===== MCP API =====
|
||||
|
||||
interface MetonaMCPServerConfig {
|
||||
name: string;
|
||||
transport: 'stdio' | 'sse';
|
||||
command?: string;
|
||||
args?: string[];
|
||||
url?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface MetonaMCPServerStatus {
|
||||
name: string;
|
||||
status: 'connecting' | 'connected' | 'disconnected' | 'error';
|
||||
toolCount: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface MetonaMCPAPI {
|
||||
listServers: () => Promise<MetonaMCPServerStatus[]>;
|
||||
addServer: (config: MetonaMCPServerConfig) => Promise<{ success: boolean }>;
|
||||
removeServer: (name: string) => Promise<{ success: boolean }>;
|
||||
toggleServer: (name: string, enabled: boolean) => Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
// ===== Memory API =====
|
||||
|
||||
interface MetonaMemorySearchResult {
|
||||
id: string;
|
||||
type: 'episodic' | 'semantic' | 'working';
|
||||
content: string;
|
||||
summary?: string;
|
||||
importance: number;
|
||||
relevanceScore: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface MetonaMemoryAPI {
|
||||
search: (query: string, options?: {
|
||||
topK?: number;
|
||||
type?: 'episodic' | 'semantic' | 'working';
|
||||
minImportance?: number;
|
||||
}) => Promise<MetonaMemorySearchResult[]>;
|
||||
}
|
||||
|
||||
// ===== Config API =====
|
||||
|
||||
interface MetonaConfigAPI {
|
||||
get: (key: string) => Promise<string | null>;
|
||||
set: (key: string, value: unknown) => Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
// ===== App API =====
|
||||
|
||||
interface MetonaAppAPI {
|
||||
getVersion: () => Promise<string>;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
showItemInFolder: (path: string) => void;
|
||||
selectFolder: (defaultPath?: string) => Promise<{ canceled: boolean; path: string }>;
|
||||
}
|
||||
|
||||
// ===== Toast API =====
|
||||
|
||||
interface MetonaToastAPI {
|
||||
onShow: (callback: (data: {
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
options?: Record<string, unknown>;
|
||||
}) => void) => () => void;
|
||||
}
|
||||
|
||||
// ===== Tools API =====
|
||||
|
||||
interface MetonaToolInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
riskLevel: string;
|
||||
requiresPermission: boolean;
|
||||
}
|
||||
|
||||
interface MetonaToolsAPI {
|
||||
list: () => Promise<MetonaToolInfo[]>;
|
||||
toggle: (toolName: string, enabled: boolean) => Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
// ===== Data API =====
|
||||
|
||||
interface MetonaDataAPI {
|
||||
exportData: (sessionId?: string) => Promise<{ success: boolean; data?: unknown; error?: string }>;
|
||||
clearSessions: () => Promise<{ success: boolean; error?: string }>;
|
||||
clearMemories: () => Promise<{ success: boolean; error?: string }>;
|
||||
clearAuditLogs: () => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
// ===== Bridge 接口 =====
|
||||
|
||||
export interface MetonaBridge {
|
||||
agent: MetonaAgentAPI;
|
||||
sessions: MetonaSessionsAPI;
|
||||
mcp: MetonaMCPAPI;
|
||||
memory: MetonaMemoryAPI;
|
||||
config: MetonaConfigAPI;
|
||||
app: MetonaAppAPI;
|
||||
toast: MetonaToastAPI;
|
||||
tools: MetonaToolsAPI;
|
||||
data: MetonaDataAPI;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
metona: MetonaBridge;
|
||||
electron: unknown;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user