feat: MetonaAI Desktop 初始项目
- Electron + React + TypeScript 架构 - 三栏布局: Sidebar | ChatPanel | DetailPanel - 9 个内置工具 (文件系统/网络/记忆/命令) - SQLite 持久化 (better-sqlite3) - MUI 暗色/亮色主题系统 - Agent Loop ReAct 状态机引擎 - DeepSeek / Agnes AI / Ollama Provider 适配器 - MCP 协议集成 - 系统托盘 + 全局快捷键 - Tailwind CSS v4 + Tailwind Merge - 修复: Sidebar 缺失 TextField 导入导致黑屏
This commit is contained in:
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user