feat: MetonaAI Desktop 初始项目

- Electron + React + TypeScript 架构
- 三栏布局: Sidebar | ChatPanel | DetailPanel
- 9 个内置工具 (文件系统/网络/记忆/命令)
- SQLite 持久化 (better-sqlite3)
- MUI 暗色/亮色主题系统
- Agent Loop ReAct 状态机引擎
- DeepSeek / Agnes AI / Ollama Provider 适配器
- MCP 协议集成
- 系统托盘 + 全局快捷键
- Tailwind CSS v4 + Tailwind Merge
- 修复: Sidebar 缺失 TextField 导入导致黑屏
This commit is contained in:
thzxx
2026-06-27 21:33:27 +08:00
commit 1d185db6b3
109 changed files with 39155 additions and 0 deletions
+295
View File
@@ -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>
);
}