refactor: 移除多余依赖,统一 MUI 为唯一 UI 库

- 移除 radix-ui、clsx、tailwind-merge、class-variance-authority、react-router-dom

- 前后端全面适配 MUI 组件,清理残留 Tailwind 工具类引用

- 优化 Agent Loop 引擎、IPC 处理器、Provider Adapter 等后端模块

- 新增 Agent 网络工具通用设计文档 v2
This commit is contained in:
thzxx
2026-07-05 19:15:48 +08:00
parent ba85328c4f
commit f4532a2bb2
50 changed files with 1474 additions and 2042 deletions
+3 -3
View File
@@ -37,13 +37,13 @@ export default function App(): React.JSX.Element {
if (window.metona?.config?.get) {
window.metona.config.get('onboarding.completed').then((v) => {
if (v === true) useUIStore.getState().setOnboardingCompleted(true);
}).catch(() => {});
}).catch((err) => { console.error('[App]', err); });
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(() => {});
if (provider || model) useAgentStore.getState().setProvider((provider as string) || '', (model as string) || '');
}).catch((err) => { console.error('[App]', err); });
}
}, []);
+2 -2
View File
@@ -26,7 +26,7 @@ export function CommandPalette(): React.JSX.Element {
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: 'new-session', icon: Plus, label: '新建会话', description: '创建一个新的对话会话', action: async () => { if (window.metona?.sessions?.create) { const r = await window.metona.sessions.create() as MetonaSessionInfo; 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); } },
];
@@ -40,7 +40,7 @@ export function CommandPalette(): React.JSX.Element {
const commands = getCommands();
return (
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth PaperProps={{ sx: { mt: '20vh', maxWidth: 520, borderRadius: 3, overflow: 'hidden' } }}>
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth slotProps={{ paper: { 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); }}
+14 -13
View File
@@ -22,7 +22,6 @@ interface ContextMenuItem {
}
interface ContextMenuProps {
type: ContextMenuType;
x: number;
y: number;
onClose: () => void;
@@ -31,13 +30,13 @@ interface ContextMenuProps {
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 } }}>
<Menu open onClose={onClose} anchorReference="anchorPosition" anchorPosition={{ top: y, left: x }} slotProps={{ paper: { 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>
<ListItemText slotProps={{ primary: { sx: { fontSize: 12 } } }}>{item.label}</ListItemText>
</MenuItem>
);
})}
@@ -53,7 +52,7 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
case 'message': {
const content = (data as { content?: string })?.content ?? '';
return [
{ id: 'copy', icon: Copy, label: '复制', action: () => navigator.clipboard.writeText(content).catch(() => {}) },
{ id: 'copy', icon: Copy, label: '复制', action: () => navigator.clipboard.writeText(content).catch((err) => console.error('[Clipboard]', err)) },
{ 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(); }
@@ -64,9 +63,9 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
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: 'view-params', icon: Eye, label: '查看参数', action: () => { if (tc?.args) navigator.clipboard.writeText(JSON.stringify(tc.args, null, 2)).catch((err) => console.error('[Clipboard]', err)); }},
{ id: 'view-result', icon: Eye, label: '查看完整结果', action: () => { if (tc?.result) navigator.clipboard.writeText(JSON.stringify(tc.result, null, 2)).catch((err) => console.error('[Clipboard]', err)); }},
{ id: 'copy-result', icon: Copy, label: '复制结果', action: () => { if (tc?.result) navigator.clipboard.writeText(typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result)).catch((err) => console.error('[Clipboard]', err)); }},
{ 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);
@@ -78,7 +77,8 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
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() }); } }
// window.prompt may be unreliable in Electron; fall back to sidebar double-click rename
if (sid) { try { const t = prompt('新会话名称:'); if (t?.trim()) { window.metona?.sessions.rename(sid, t.trim()); useSessionStore.getState().updateSession(sid, { title: t.trim() }); } } catch { /* prompt unavailable, user can rename via sidebar double-click */ } }
}},
{ 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); } }
@@ -88,10 +88,11 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
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(() => {});
}).catch((err) => console.error('[ContextMenu]', err));
}},
{ id: 'delete', icon: Trash2, label: '删除', action: () => {
if (sid && confirm('确定删除此会话?')) { window.metona?.sessions.delete(sid); useSessionStore.getState().removeSession(sid); }
// window.confirm may be unreliable in Electron
if (sid) { try { if (confirm('确定删除此会话?')) { window.metona?.sessions.delete(sid); useSessionStore.getState().removeSession(sid); } } catch { /* confirm unavailable */ } }
}},
];
}
@@ -99,7 +100,7 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
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: 'copy-code', icon: Copy, label: '复制代码', action: () => { if (code) navigator.clipboard.writeText(code).catch((err) => console.error('[Clipboard]', err)); }},
{ id: 'open-editor', icon: Code, label: '在编辑器中打开', action: () => { if (code) window.open(URL.createObjectURL(new Blob([code], { type: 'text/plain' })), '_blank'); }},
];
}
@@ -107,9 +108,9 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
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-thought', icon: Copy, label: '复制 Thought', action: () => { if (step?.thought) navigator.clipboard.writeText(step.thought).catch((err) => console.error('[Clipboard]', err)); }},
{ 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(() => {});
if (step?.toolCalls) navigator.clipboard.writeText(step.toolCalls.map((tc) => `${tc.name}: ${JSON.stringify(tc.args, null, 2)}`).join('\n')).catch((err) => console.error('[Clipboard]', err));
}},
{ 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(); }
+6 -6
View File
@@ -41,11 +41,11 @@ export function ToastContainer(): null {
MeToast.use('keyboard'); // ESC 关闭所有
MeToast.use('persistence'); // 配置持久化
MeToast.use('accessibility'); // 屏幕阅读器
} catch {
// 插件可能已安装
} catch (err) {
console.error('[Toast]', 'Failed to install plugin:', err);
}
}).catch(() => {
// metona-toast 未安装时静默
}).catch((err) => {
console.error('[Toast]', 'Failed to load metona-toast:', err);
});
// 监听主进程通知桥接
@@ -61,8 +61,8 @@ export function ToastContainer(): null {
case 'info': MeToast.info(message, options); break;
default: MeToast.info(message, options);
}
} catch {
// 静默
} catch (err) {
console.error('[Toast]', 'Failed to show toast:', err);
}
});
return unsubscribe;
+15 -10
View File
@@ -18,17 +18,18 @@ import type { ChatMessage } from '@renderer/stores/agent-store';
import { useAgentStore } from '@renderer/stores/agent-store';
import { ThoughtBlock } from './ThoughtBlock';
import { ToolCallCard } from './ToolCallCard';
import { ToolResultBlock } from './ToolResultBlock';
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;
export function AssistantMessage({ message, isStreaming }: AssistantMessageProps): React.JSX.Element {
// 直接使用 message.content — updateLastAssistantMessage 已实时更新此字段
const content = message.content;
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const agentStatus = useAgentStore((s) => s.agentStatus);
@@ -61,7 +62,7 @@ export function AssistantMessage({ message, isStreaming, streamContent }: Assist
{/* ===== 阶段 1: 思考过程 ===== */}
{hasThinking && (
<>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: '#fbbf24', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
💭 {isThinking ? '正在思考...' : '思考过程'}
</Typography>
@@ -77,13 +78,18 @@ export function AssistantMessage({ message, isStreaming, streamContent }: Assist
{hasTools && (
<>
{hasThinking && <Box sx={{ height: 8 }} />}
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: '#a855f7', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
🔧
</Typography>
</Stack>
{message.toolCalls!.map((tc) => (
<ToolCallCard key={tc.id} toolCall={tc} />
<Box key={tc.id}>
<ToolCallCard toolCall={tc} />
{tc.status === 'success' && tc.result != null && (
<ToolResultBlock toolCall={tc} />
)}
</Box>
))}
</>
)}
@@ -92,7 +98,7 @@ export function AssistantMessage({ message, isStreaming, streamContent }: Assist
{(hasContent || isStreaming) && (
<>
{(hasThinking || hasTools) && (
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 0.5, mb: 0.5 }}>
<Stack direction="row" spacing={1} sx={{ mt: 0.5, mb: 0.5, alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: '#34d399', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
</Typography>
@@ -144,7 +150,6 @@ export function AssistantMessage({ message, isStreaming, streamContent }: Assist
{contextMenu && (
<ContextMenu
type="message"
x={contextMenu.x}
y={contextMenu.y}
items={contextMenuItems}
@@ -164,7 +169,7 @@ function CodeBlock({ language, code }: { language: string; code: string }): Reac
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' }}>
<Stack direction="row" sx={{ px: 1.5, py: 0.75, borderTopLeftRadius: 8, borderTopRightRadius: 8, borderBottom: '1px solid', borderColor: 'divider', bgcolor: 'background.default', fontSize: 11, color: 'text.secondary', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{language}</span>
<Tooltip title={copied ? '已复制' : '复制'}>
<IconButton className="copy-btn" size="small" onClick={handleCopy} sx={{ opacity: 0, transition: 'opacity 150ms', color: 'text.secondary' }}>
@@ -186,7 +191,7 @@ function extractTextContent(children: React.ReactNode): string {
if (!children) return '';
if (Array.isArray(children)) return children.map(extractTextContent).join('');
if (typeof children === 'object' && 'props' in children) {
return extractTextContent((children as React.ReactElement).props.children);
return extractTextContent((children as React.ReactElement).props as React.ReactNode);
}
return '';
}
+2 -2
View File
@@ -251,7 +251,7 @@ export function ChatInput(): React.JSX.Element {
style={{ width: '100%', background: 'transparent', border: 'none', outline: 'none', color: 'inherit', fontSize: 14, lineHeight: '28px', resize: 'none', fontFamily: 'inherit', minHeight: 42, maxHeight: 160 }}
/>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mt: 1, minHeight: 32 }}>
<Stack direction="row" sx={{ mt: 1, minHeight: 32, justifyContent: 'space-between', alignItems: 'center' }}>
{/* 左侧:附件按钮 */}
<Tooltip title={supportsImages ? '附加文件(图片/文本/代码)' : '附加文件(文本/代码)— DeepSeek 不支持图片'}>
<IconButton size="small" sx={{ color: 'text.secondary' }} onClick={handleFileSelect}>
@@ -260,7 +260,7 @@ export function ChatInput(): React.JSX.Element {
</Tooltip>
{/* 右侧:发送按钮 */}
<Stack direction="row" spacing={1} alignItems="center">
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
{isStreaming ? (
<Button variant="contained" color="error" size="small" onClick={handleAbort} sx={{ height: 28, fontSize: 12 }}>
<Square size={12} style={{ marginRight: 6 }} />
+54 -5
View File
@@ -4,19 +4,21 @@
* 根据消息 role 路由到对应的子组件渲染。
*/
import { Box, Typography, Stack } from '@mui/material';
import { Terminal } from 'lucide-react';
import type { ChatMessage } from '@renderer/stores/agent-store';
import { UserMessage } from './UserMessage';
import { AssistantMessage } from './AssistantMessage';
import { SystemMessage } from './SystemMessage';
import { formatTime } from '@renderer/lib/formatters';
interface MessageItemProps {
message: ChatMessage;
isLast?: boolean;
isStreaming?: boolean;
streamContent?: string;
}
export function MessageItem({ message, isLast, isStreaming, streamContent }: MessageItemProps): React.JSX.Element {
export function MessageItem({ message, isLast, isStreaming }: MessageItemProps): React.JSX.Element {
switch (message.role) {
case 'user':
return <UserMessage message={message} />;
@@ -26,16 +28,63 @@ export function MessageItem({ message, isLast, isStreaming, streamContent }: Mes
<AssistantMessage
message={message}
isStreaming={isLast && isStreaming}
streamContent={streamContent}
/>
);
case 'tool':
// 工具消息渲染为系统消息样式
return <SystemMessage message={message} />;
// 工具消息渲染为独立的结果块
return <ToolMessage message={message} />;
case 'system':
default:
return <SystemMessage message={message} />;
}
}
/** ToolMessage — 独立的工具结果消息(role=tool) */
function ToolMessage({ message }: { message: ChatMessage }): React.JSX.Element {
return (
<Box
sx={{
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
borderLeft: '3px solid',
borderLeftColor: '#22d3ee',
bgcolor: 'secondary.main',
px: 1.5,
py: 1,
mb: 2,
animation: 'fadeInUp 300ms ease-out',
}}
>
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
<Terminal size={12} style={{ color: '#22d3ee' }} />
<Typography variant="caption" sx={{ fontWeight: 500, color: 'text.primary' }}>
</Typography>
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled' }}>
{formatTime(message.timestamp)}
</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,
whiteSpace: 'pre-wrap',
}}
>
{message.content.length > 500 ? message.content.slice(0, 500) + '\n... [截断]' : message.content}
</Box>
</Box>
);
}
+2 -3
View File
@@ -11,10 +11,9 @@ 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]);
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
if (messages.length === 0 && !isStreaming) {
return (
@@ -33,7 +32,7 @@ export function MessageList(): React.JSX.Element {
<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} />
<MessageItem key={msg.id} message={msg} isLast={i === messages.length - 1} isStreaming={isStreaming} />
))}
<StreamingIndicator />
<div ref={messagesEndRef} />
+5 -2
View File
@@ -11,14 +11,17 @@ import { useAgentStore } from '@renderer/stores/agent-store';
export function StreamingIndicator(): React.JSX.Element | null {
const isStreaming = useAgentStore((s) => s.isStreaming);
const streamingContent = useAgentStore((s) => s.streamingContent);
const messages = useAgentStore((s) => s.messages);
// 已有内容输出时隐藏(AssistantMessage 内部展示)
if (!isStreaming) return null;
const lastMsg = messages[messages.length - 1];
const hasVisibleContent = !!streamingContent || !!lastMsg?.reasoningContent || !!lastMsg?.toolCalls?.length;
// 仅当最后一条是 assistant 消息且有内容时才隐藏
// 如果最后一条是 user 消息(刚发送,等待 AI 响应),应显示加载指示器
const isLastAssistant = lastMsg?.role === 'assistant';
const hasVisibleContent = isLastAssistant &&
(!!lastMsg?.content || !!lastMsg?.reasoningContent || !!lastMsg?.toolCalls?.length);
if (hasVisibleContent) return null;
return (
+2 -1
View File
@@ -2,7 +2,7 @@
* ThoughtBlock — 思考过程展示
*/
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Box, Typography, IconButton, Collapse } from '@mui/material';
import { ChevronDown, ChevronRight, Brain } from 'lucide-react';
@@ -10,6 +10,7 @@ interface ThoughtBlockProps { content: string; defaultExpanded?: boolean; }
export function ThoughtBlock({ content, defaultExpanded = false }: ThoughtBlockProps): React.JSX.Element {
const [expanded, setExpanded] = useState(defaultExpanded);
useEffect(() => { setExpanded(defaultExpanded); }, [defaultExpanded]);
if (!content) return <></>;
return (
+1 -7
View File
@@ -27,7 +27,7 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps): React.JSX.Element
animation: toolCall.status === 'executing' ? 'pulse 2s infinite' : 'fadeInUp 300ms ease-out',
}}
>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.75 }}>
<Stack direction="row" spacing={1} sx={{ mb: 0.75, alignItems: 'center' }}>
<Wrench size={12} style={{ color: '#a855f7' }} />
<Typography sx={{ fontSize: 12, fontWeight: 600, fontFamily: 'monospace', color: 'text.primary' }}>{toolCall.name}</Typography>
<Chip
@@ -51,12 +51,6 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps): React.JSX.Element
{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>
);
}
+1 -1
View File
@@ -15,7 +15,7 @@ export function ToolResultBlock({ toolCall }: ToolResultBlockProps): React.JSX.E
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 }}>
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
<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>}
+1 -1
View File
@@ -61,7 +61,7 @@ export function UserMessage({ message }: UserMessageProps): React.JSX.Element {
<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)} />}
{contextMenu && <ContextMenu x={contextMenu.x} y={contextMenu.y} items={createContextMenuItems('message', { content: message.content })} onClose={() => setContextMenu(null)} />}
</Stack>
);
}
+2 -2
View File
@@ -37,7 +37,7 @@ export function AgentMonitor(): React.JSX.Element {
return (
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider', flexShrink: 0 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
<Stack direction="row" spacing={1} sx={{ mb: 1.5, alignItems: 'center' }}>
<Cpu size={14} style={{ color: '#818cf8' }} />
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
Agent
@@ -46,7 +46,7 @@ export function AgentMonitor(): React.JSX.Element {
{/* 状态指示 */}
<Box sx={{ px: 1.5, py: 1, borderRadius: 1.5, mb: 1.5, bgcolor: 'background.default', border: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" spacing={1} alignItems="center">
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<StatusIcon
size={14}
style={{
+1 -1
View File
@@ -187,7 +187,7 @@ function MemorySearchPanel() {
<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 }}>
<Stack direction="row" spacing={0.5} sx={{ mb: 0.25, alignItems: 'center' }}>
<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>
+9 -1
View File
@@ -4,6 +4,7 @@
* 完全使用 MUI 组件:Table 展示状态/Provider/Token,右侧设置按钮。
*/
import { useState, useEffect } from 'react';
import { Box, Typography, IconButton, Tooltip, Table, TableBody, TableRow, TableCell } from '@mui/material';
import { Settings } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
@@ -17,6 +18,13 @@ export function StatusBar(): React.JSX.Element {
const model = useAgentStore((s) => s.model);
const tokenUsage = useAgentStore((s) => s.tokenUsage);
const openSettings = useUIStore((s) => s.openSettings);
const [version, setVersion] = useState('v0.1.0');
useEffect(() => {
if (window.metona?.app?.getVersion) {
window.metona.app.getVersion().then((v) => setVersion(`v${v}`)).catch((err) => { console.error('[StatusBar]', err); });
}
}, []);
return (
<Box
@@ -66,7 +74,7 @@ export function StatusBar(): React.JSX.Element {
{/* 右侧:版本 + 设置 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>v0.1.0</Typography>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>{version}</Typography>
<Tooltip title="设置 (Ctrl+,)">
<IconButton size="small" onClick={openSettings} sx={{ color: 'text.secondary', width: 28, height: 28 }}>
<Settings size={14} />
+19 -12
View File
@@ -23,21 +23,28 @@ export function OnboardingWizard(): React.JSX.Element | null {
if (onboardingCompleted) return null;
const handleNext = () => {
const handleNext = async () => {
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() || '');
try {
if (window.metona?.config) {
const configSets: Promise<unknown>[] = [];
if (provider.trim()) configSets.push(window.metona.config.set('llm.provider', provider.trim()));
if (baseURL.trim()) configSets.push(window.metona.config.set('llm.baseURL', baseURL.trim()));
if (model.trim()) configSets.push(window.metona.config.set('llm.model', model.trim()));
if (apiKey.trim()) configSets.push(window.metona.config.set('llm.apiKey', apiKey.trim()));
if (workspacePath.trim()) configSets.push(window.metona.config.set('workspace.path', workspacePath.trim()));
configSets.push(window.metona.config.set('onboarding.completed', true));
await Promise.all(configSets);
useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || '');
}
setOnboardingCompleted(true);
} catch (err) {
console.error('[OnboardingWizard]', 'Failed to save configuration:', err);
}
window.metona?.config?.set('onboarding.completed', true); setOnboardingCompleted(true);
};
return (
<Dialog open maxWidth="sm" PaperProps={{ sx: { borderRadius: 3, overflow: 'hidden' } }}>
<Dialog open maxWidth="sm" slotProps={{ paper: { 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>)}
@@ -46,7 +53,7 @@ export function OnboardingWizard(): React.JSX.Element | null {
<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 }} />
<Box component="img" src="./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>
@@ -88,7 +95,7 @@ export function OnboardingWizard(): React.JSX.Element | null {
<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 }}>
<Stack direction="row" spacing={1} sx={{ mb: 2, 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={async () => {
if (window.metona?.app?.selectFolder) { const r = await window.metona.app.selectFolder(workspacePath || undefined); if (!r.canceled && r.path) setWorkspacePath(r.path); }
+14 -14
View File
@@ -25,8 +25,8 @@ export function SettingsModal(): React.JSX.Element | null {
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' }}>
<Dialog open onClose={closeSettings} maxWidth="md" fullWidth slotProps={{ paper: { sx: { maxWidth: 640, height: '80vh', borderRadius: 3, display: 'flex', flexDirection: 'column' } } }}>
<Stack direction="row" sx={{ px: 2.5, py: 1.5, borderBottom: 1, borderColor: 'divider', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6"></Typography>
<IconButton size="small" onClick={closeSettings}><X size={14} /></IconButton>
</Stack>
@@ -60,8 +60,8 @@ export function SettingsModal(): React.JSX.Element | null {
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]);
useEffect(() => { if (window.metona?.config?.get) window.metona.config.get(key).then((v) => { if (v != null) setValue(v as T); }).catch((err) => { console.error('[SettingsModal]', err); }); }, [key]);
const set = useCallback((v: T) => { setValue(v); window.metona?.config?.set(key, v).catch((err) => { console.error('[SettingsModal]', err); }); }, [key]);
return [value, set];
}
@@ -82,7 +82,7 @@ function WorkspaceSettings() {
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}></Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> Metona SOUL.mdAGENTS.mdMEMORY.mdUSERS.md </Typography>
<Stack direction="row" spacing={1} alignItems="center">
<Stack direction="row" spacing={1} sx={{ 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>
@@ -193,20 +193,20 @@ function AgentSettings() {
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(() => {}); };
useEffect(() => { if (window.metona?.tools?.list) window.metona.tools.list().then((l) => setTools((l as MetonaToolInfo[]).map((t) => ({ ...t, enabled: true })))).catch((err) => { console.error('[SettingsModal]', err); }); }, []);
const handleToggle = (name: string, enabled: boolean) => { setTools((p) => p.map((t) => t.name === name ? { ...t, enabled } : t)); window.metona?.tools?.toggle(name, enabled).catch((err) => { console.error('[SettingsModal]', err); }); };
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 }}>
<Stack key={t.name} direction="row" sx={{ py: 1, px: 1.5, borderRadius: 1.5, bgcolor: 'secondary.main', justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1} sx={{ minWidth: 0, flex: 1, alignItems: 'center' }}>
<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">
<Stack direction="row" spacing={1} sx={{ 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>
@@ -222,17 +222,17 @@ function MCPSettings() {
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(() => {}); };
const loadServers = () => { if (window.metona?.mcp?.listServers) window.metona.mcp.listServers().then((l) => setServers(l as MetonaMCPServerStatus[])).catch((err) => { console.error('[SettingsModal]', err); }); };
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 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+/) : [], enabled: true }); 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">
<Stack key={s.name} direction="row" sx={{ py: 1, px: 1.5, borderRadius: 1.5, bgcolor: 'secondary.main', justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1} sx={{ 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>
+1 -1
View File
@@ -27,7 +27,7 @@ export function TokenUsage(): React.JSX.Element {
return (
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider', flexShrink: 0 }}>
{/* 标题 */}
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
<Stack direction="row" spacing={1} sx={{ mb: 1.5, alignItems: 'center' }}>
<Zap size={14} style={{ color: '#fbbf24' }} />
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
Token
+2 -1
View File
@@ -2,7 +2,7 @@
* TraceStep — 单个 Trace 步骤
*/
import { useState } from 'react';
import { useState, useEffect } 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';
@@ -13,6 +13,7 @@ interface TraceStepProps { step: TraceStepType; isCurrent?: boolean; }
export function TraceStep({ step, isCurrent }: TraceStepProps): React.JSX.Element {
const [expanded, setExpanded] = useState(false);
useEffect(() => { setExpanded(isCurrent ?? false); }, [isCurrent]);
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;
+1 -1
View File
@@ -15,7 +15,7 @@ export function TraceViewer(): React.JSX.Element {
return (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5, flexShrink: 0 }}>
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flexShrink: 0, alignItems: 'center' }}>
<Activity size={14} style={{ color: '#818cf8' }} />
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
Trace Viewer
+24 -6
View File
@@ -41,8 +41,24 @@ export function useAgentStream(): void {
// 每次收到迭代号时同步更新
if (data.iteration != null && data.iteration !== getStore().currentIteration) {
console.log(`[useAgentStream] iteration: ${getStore().currentIteration}${data.iteration} (event: ${data.type})`);
const prevIteration = getStore().currentIteration;
getStore().setCurrentIteration(data.iteration);
// 迭代号增大 → 新一轮 ReAct 迭代开始,创建新的 assistant 消息卡片
if (data.iteration > prevIteration && prevIteration > 0) {
const messages = getStore().messages;
const lastMsg = messages[messages.length - 1];
// 仅当上一条 assistant 消息已有内容时才创建新消息(避免空消息堆叠)
if (lastMsg?.role === 'assistant' && (lastMsg.content || lastMsg.toolCalls?.length || lastMsg.reasoningContent)) {
getStore().addMessage({
id: `msg_${Date.now()}_assistant`,
role: 'assistant',
content: '',
timestamp: Date.now(),
iteration: data.iteration,
});
}
}
}
switch (data.type) {
@@ -62,7 +78,7 @@ export function useAgentStream(): void {
const messages = getStore().messages;
const lastMsg = messages[messages.length - 1];
if (lastMsg?.role === 'assistant') {
// 追加到最后一条 assistant 消息
// 追加到最后一条 assistant 消息(不可变更新)
getStore().updateMessage(lastMsg.id, {
reasoningContent: (lastMsg.reasoningContent ?? '') + data.delta,
});
@@ -74,6 +90,7 @@ export function useAgentStream(): void {
content: '',
reasoningContent: data.delta,
timestamp: Date.now(),
iteration: data.iteration ?? getStore().currentIteration,
});
}
}
@@ -129,7 +146,7 @@ export function useAgentStream(): void {
if (data.toolResult) {
const msgs = getStore().messages;
const lastMsg = msgs[msgs.length - 1];
if (lastMsg?.toolCalls) {
if (lastMsg?.role === 'assistant' && lastMsg?.toolCalls) {
const updatedToolCalls = lastMsg.toolCalls.map((tc) =>
tc.id === data.toolResult!.toolCallId
? {
@@ -150,10 +167,11 @@ export function useAgentStream(): void {
// Token 使用统计
case 'usage':
if (data.usage) {
const cur = getStore().tokenUsage;
getStore().updateTokenUsage({
inputTokens: data.usage.inputTokens ?? 0,
outputTokens: data.usage.outputTokens ?? 0,
totalTokens: data.usage.totalTokens ?? 0,
inputTokens: cur.inputTokens + (data.usage.inputTokens ?? 0),
outputTokens: cur.outputTokens + (data.usage.outputTokens ?? 0),
totalTokens: cur.totalTokens + (data.usage.totalTokens ?? 0),
});
}
break;
+3 -3
View File
@@ -52,10 +52,10 @@ export function useKeyboardShortcuts(): void {
// Ctrl+N — 新建会话
if (key === 'n' && matchModifier(e, true)) {
if (window.metona?.sessions?.create) {
window.metona.sessions.create().then((result: any) => {
window.metona.sessions.create().then((result: MetonaSessionInfo) => {
useSessionStore.getState().addSession(result);
useSessionStore.getState().setCurrentSession(result.id);
}).catch(() => {});
}).catch((err) => { console.error('[KeyboardShortcuts]', err); });
}
e.preventDefault();
return;
@@ -123,7 +123,7 @@ export function useKeyboardShortcuts(): void {
const { messages } = useAgentStore.getState();
const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
if (lastAssistant?.content) {
navigator.clipboard.writeText(lastAssistant.content).catch(() => {});
navigator.clipboard.writeText(lastAssistant.content).catch((err) => console.error('[KeyboardShortcuts]', err));
}
e.preventDefault();
return;
+4 -2
View File
@@ -1,7 +1,9 @@
/**
* className 合并工具
*
* @see standard/开发规范.md — 第一铁律
* MUI 项目中主要通过 sx prop 设置样式,此工具仅用于极少数场景。
*/
export { default as cn } from 'clsx';
export function cn(...classes: (string | false | null | undefined)[]): string {
return classes.filter(Boolean).join(' ');
}
+3 -2
View File
@@ -69,9 +69,10 @@ export const TOOL_CALL_STATUS_COLORS = {
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 },
SEND_MESSAGE: { key: 'Enter', ctrl: false },
NEW_LINE: { key: 'Enter', ctrl: true },
ABORT: { key: '.', ctrl: true },
// TODO: implement in future version
FOCUS_MODE: { key: 'f', ctrl: true, shift: true },
TOGGLE_SIDEBAR: { key: 'b', ctrl: true },
TOGGLE_DETAIL: { key: 'j', ctrl: true },
+6 -2
View File
@@ -1,3 +1,5 @@
// DEPRECATED: Components use window.metona directly. This file is kept for future type-safe refactoring.
/**
* IPC Client — 渲染进程 IPC 封装
*
@@ -8,7 +10,9 @@
* @see src/types/global.d.ts — 类型声明
*/
import type { MetonaBridge, MetonaSessionInfo, MetonaStreamEventData, MetonaMCPServerStatus, MetonaMemorySearchResult } from '@renderer/types/global';
// DEPRECATED: Components use window.metona directly. This file is kept for future type-safe refactoring.
// Types are now globally available via src/types/global.d.ts (script file, no import needed).
// ===== 桥接层访问 =====
@@ -103,7 +107,7 @@ export const mcpAPI = {
return getBridge().mcp.listServers();
},
addServer(config: { name: string; transport: 'stdio' | 'sse'; command?: string; args?: string[]; url?: string }): Promise<{ success: boolean }> {
addServer(config: { name: string; transport: 'stdio' | 'sse'; command?: string; args?: string[]; url?: string; enabled: boolean }): Promise<{ success: boolean }> {
return getBridge().mcp.addServer(config);
},
+36 -13
View File
@@ -125,7 +125,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
streamingContent: '',
provider: '',
model: '',
contextWindow: 1_000_000,
contextWindow: 0,
// ===== Actions =====
@@ -138,7 +138,9 @@ export const useAgentStore = create<AgentState>((set, get) => ({
const messages = (msgs as Array<{
id: string; role: string; content: string;
reasoningContent?: string; toolCalls?: unknown[];
toolResult?: unknown;
attachments?: Array<{ id: string; name: string; type: string; size: number; preview?: string; textContent?: string }>;
iteration?: number;
timestamp: number;
}>).map((m) => ({
id: m.id,
@@ -147,10 +149,11 @@ export const useAgentStore = create<AgentState>((set, get) => ({
reasoningContent: m.reasoningContent,
toolCalls: m.toolCalls as ToolCallInfo[] | undefined,
attachments: m.attachments as AttachmentInfo[] | undefined,
iteration: m.iteration,
timestamp: m.timestamp,
}));
set({ messages });
}).catch(() => {});
}).catch((err) => { console.error('[AgentStore]', err); });
}
// 从数据库加载该会话的 trace 步骤和 token 用量
@@ -160,7 +163,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
if (data.traceSteps) set({ traceSteps: data.traceSteps as TraceStep[] });
if (data.tokenUsage) set({ tokenUsage: data.tokenUsage as TokenUsage });
}
}).catch(() => {});
}).catch((err) => { console.error('[AgentStore]', err); });
}
},
@@ -190,8 +193,16 @@ export const useAgentStore = create<AgentState>((set, get) => ({
});
sessionId = session.id;
set({ currentSessionId: sessionId });
} catch {
// 创建失败时静默
} catch (err) {
console.error('[AgentStore]', 'Failed to create session:', err);
set({ agentStatus: 'error', isStreaming: false });
get().addMessage({
id: `msg_${Date.now()}_error`,
role: 'system',
content: `错误: 无法创建会话 — ${(err as Error).message}`,
timestamp: Date.now(),
});
return;
}
}
@@ -220,7 +231,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
window.metona?.sessions?.rename(sessionId, title);
useSessionStore.getState().updateSession(sessionId, { title });
}
}).catch(() => {});
}).catch((err) => { console.error('[AgentStore]', err); });
}
if (sessionId && window.metona?.agent?.sendMessage) {
@@ -257,22 +268,34 @@ export const useAgentStore = create<AgentState>((set, get) => ({
content: llmContent,
images: llmImages.length > 0 ? llmImages : undefined,
};
window.metona.agent.sendMessage(messageWithImages, sessionId).catch(() => {});
window.metona.agent.sendMessage(messageWithImages, sessionId).catch((err) => {
console.error('[AgentStore]', 'IPC sendMessage failed:', err);
set({ agentStatus: 'error', isStreaming: false });
get().addMessage({
id: `msg_${Date.now()}_error`,
role: 'system',
content: `错误: 消息发送失败 — ${(err as Error).message}`,
timestamp: Date.now(),
});
});
}
},
updateLastAssistantMessage: (delta: string) => {
set((s) => {
const messages = [...s.messages];
const lastMsg = messages[messages.length - 1];
const lastIdx = messages.length - 1;
const lastMsg = messages[lastIdx];
if (lastMsg?.role === 'assistant') {
lastMsg.content += delta;
// 不可变更新:创建新对象而非直接突变
messages[lastIdx] = { ...lastMsg, content: lastMsg.content + delta };
} else {
messages.push({
id: `msg_${Date.now()}_assistant`,
role: 'assistant',
content: delta,
timestamp: Date.now(),
iteration: s.currentIteration || undefined,
});
}
return { messages };
@@ -303,7 +326,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
setProvider: (provider, model) => {
// DeepSeek/Agnes 固定 1MOllama 从配置读取
const ollamaCtx = provider === 'ollama' ? 128_000 : 1_000_000;
const ollamaCtx = provider === 'ollama' ? 4096 : 1_000_000;
set({ provider, model, contextWindow: ollamaCtx });
// 异步读取 Ollama 实际配置
if (provider === 'ollama' && window.metona?.config?.get) {
@@ -311,7 +334,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
if (v != null && typeof v === 'number' && v > 0) {
set({ contextWindow: v });
}
}).catch(() => {});
}).catch((err) => { console.error('[AgentStore]', err); });
}
},
@@ -322,7 +345,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
saveTraceData: () => {
const { currentSessionId, traceSteps, tokenUsage } = get();
if (currentSessionId && window.metona?.sessions?.saveTrace) {
window.metona.sessions.saveTrace(currentSessionId, { traceSteps, tokenUsage }).catch(() => {});
window.metona.sessions.saveTrace(currentSessionId, { traceSteps, tokenUsage }).catch((err) => { console.error('[AgentStore]', err); });
}
},
@@ -341,7 +364,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
const sessionId = get().currentSessionId;
set({ agentStatus: 'idle', isStreaming: false });
if (sessionId && window.metona?.agent?.abortSession) {
window.metona.agent.abortSession(sessionId).catch(() => {});
window.metona.agent.abortSession(sessionId).catch((err) => { console.error('[AgentStore]', err); });
}
},
}));
+2 -6
View File
@@ -111,10 +111,6 @@
--radius: var(--radius);
}
:root[data-theme="light"] {
color-scheme: light;
}
/* ===== 全局基础样式 ===== */
* { margin: 0; padding: 0; box-sizing: border-box; }
@@ -130,8 +126,8 @@ body {
::-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; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
/* ===== 代码字体 ===== */
+9 -7
View File
@@ -87,7 +87,9 @@ interface MetonaSessionsAPI {
content: string;
reasoningContent?: string;
toolCalls?: unknown[];
toolResult?: unknown;
attachments?: Array<{ id: string; name: string; type: string; size: number; preview?: string; textContent?: string }>;
iteration?: number;
timestamp: number;
}>>;
pin: (sessionId: string, pinned: boolean) => Promise<{ success: boolean }>;
@@ -145,7 +147,7 @@ interface MetonaMemoryAPI {
// ===== Config API =====
interface MetonaConfigAPI {
get: (key: string) => Promise<string | null>;
get: (key: string) => Promise<unknown>;
set: (key: string, value: unknown) => Promise<{ success: boolean }>;
}
@@ -195,7 +197,7 @@ interface MetonaDataAPI {
// ===== Bridge 接口 =====
export interface MetonaBridge {
interface MetonaBridge {
agent: MetonaAgentAPI;
sessions: MetonaSessionsAPI;
mcp: MetonaMCPAPI;
@@ -207,9 +209,9 @@ export interface MetonaBridge {
data: MetonaDataAPI;
}
declare global {
interface Window {
metona: MetonaBridge;
electron: unknown;
}
// ===== 全局 Window 增强 =====
interface Window {
metona: MetonaBridge;
electron: unknown;
}