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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user