/** * 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([]); const [showSlashMenu, setShowSlashMenu] = useState(false); const [slashFilter, setSlashFilter] = useState(''); const textareaRef = useRef(null); const fileInputRef = useRef(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); const provider = useAgentStore((s) => s.provider); /** DeepSeek 不支持多模态图片 */ const supportsImages = provider !== 'deepseek'; // 草稿自动保存 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 => { 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 → 压缩(限制 1024px, JPEG 0.7) const dataUri = await new Promise((resolve) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.readAsDataURL(file); }); attachment.preview = await compressImage(dataUri, 1024, 0.7); } else if (type === 'text') { // 文本文件读取内容 attachment.textContent = await new Promise((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 个附件 // DeepSeek 不支持多模态,过滤图片 const filtered = supportsImages ? fileArray : fileArray.filter((f) => !IMAGE_TYPES.includes(f.type)); if (filtered.length < fileArray.length && !supportsImages) { // TODO: 用 Toast 提示用户 "DeepSeek 不支持图片,已自动跳过 N 个图片文件" } if (filtered.length === 0) return; const newAttachments = await Promise.all(filtered.map(processFile)); setAttachments((prev) => [...prev, ...newAttachments]); }, [processFile, supportsImages]); 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) => { 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) => { 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 ( {/* 附件预览区 */} {attachments.length > 0 && ( {attachments.map((att) => ( removeAttachment(att.id)} /> ))} )} {/* / 命令菜单 */} {showSlashMenu && filteredCommands.length > 0 && (
{filteredCommands.map((cmd) => (
{ 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')} > / {cmd.label} {cmd.description}
))}
)}