/** * 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, InputBase, List, ListItemButton, } from '@mui/material'; import { Send, Paperclip, Square, X, FileText, Image as ImageIcon } from 'lucide-react'; import { nanoid } from 'nanoid'; import { useAgentStore } from '@renderer/stores/agent-store'; import { useSessionStore } from '@renderer/stores/session-store'; import { useUIStore } from '@renderer/stores/ui-store'; import { formatFileSize } from '@renderer/lib/formatters'; import { PROVIDER_LABELS } from '@renderer/lib/constants'; // v0.7.3 P1-4: 图片上传门控纯函数(总开关 × DeepSeek 命名防线 × Ollama 能力探测) import { supportsImageUpload } from '@renderer/lib/model-capabilities'; // v0.7.3 P4-3: 文案出层(字典含注册副作用,须在 t() 使用前 import) import { t } from '@renderer/lib/i18n'; import '@renderer/lib/i18n-strings'; // v0.7.3 P4-3: 描述文案渲染时求值(i18next 字典注册是异步的,模块顶层固化会拿到 key 本体) const SLASH_COMMANDS: Array<{ id: string; label: string; description: () => string }> = [ { id: 'tool', label: '/tool', description: () => t('input.slash.tool') }, { id: 'memory', label: '/memory', description: () => t('input.slash.memory') }, { id: 'clear', label: '/clear', description: () => t('input.slash.clear') }, { id: 'export', label: '/export', description: () => t('input.slash.export') }, ]; const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']; /** * v0.7.2 A5: 文本附件内容上限(512KB)。 * 此前文本文件全文读入 textContent 并内联进 LLM 消息,无任何大小闸门 —— * 一个 10MB 日志即可制造超长请求(token 爆炸 + IPC 载荷膨胀)。 * 超限文件只读取首段内容并附加截断标记(truncated 随消息持久化, * 主进程附件提示同步告知 LLM 内容不完整)。 */ const MAX_TEXT_ATTACHMENT_BYTES = 512 * 1024; 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; // 文本文件内容 truncated?: boolean; // v0.7.2 A5: 文本附件超 512KB 被截断 } 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 configLoaded = useAgentStore((s) => s.configLoaded); // v0.3.18 修复: 工具未就绪时禁用发送按钮 const toolsReady = useAgentStore((s) => s.toolsReady); const currentSessionId = useSessionStore((s) => s.currentSessionId); const provider = useAgentStore((s) => s.provider); const model = useAgentStore((s) => s.model); // v0.5.4: 多模态总开关(llm.multimodalEnabled,设置/引导向导中配置) const multimodalEnabled = useAgentStore((s) => s.multimodalEnabled); const modelVisionCaps = useAgentStore((s) => s.modelVisionCaps); const imageGate = supportsImageUpload({ multimodalEnabled, provider, model, visionCaps: modelVisionCaps, }); const supportsImages = imageGate.allowed; // 草稿自动保存 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); // L-10 修复: 统一使用 nanoid 生成附件 ID(与项目其他位置一致) const attachment: Attachment = { id: `att_${nanoid(6)}`, file, type }; if (type === 'image') { // 图片转 base64 data URL → 压缩(限制 1024px, JPEG 0.7) const dataUri = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = () => reject(new Error('图片读取失败')); reader.readAsDataURL(file); }); attachment.preview = await compressImage(dataUri, 1024, 0.7); } else if (type === 'text') { // v0.7.2 A5: 文本附件大小闸门 —— 超过 512KB 时只读取首段内容(file.slice // 避免 FileReader 全量读入大文件),附加截断标记并告知 LLM 内容不完整。 if (file.size > MAX_TEXT_ATTACHMENT_BYTES) { attachment.truncated = true; const headBlob = file.slice(0, MAX_TEXT_ATTACHMENT_BYTES); attachment.textContent = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = () => reject(new Error('文本文件读取失败')); reader.readAsText(headBlob); }); attachment.textContent += t('input.text.truncatedNote', { size: formatFileSize(file.size), limit: formatFileSize(MAX_TEXT_ATTACHMENT_BYTES), }); import('@metona-team/metona-toast') .then((mod) => mod.default.warning(t('input.text.truncated', { name: file.name }))) .catch(() => {}); } else { attachment.textContent = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = () => reject(new Error('文本文件读取失败')); reader.readAsText(file); }); } } return attachment; }, [classifyFile], ); const addFiles = useCallback( async (files: FileList | File[]) => { const fileArray = Array.from(files).slice(0, 5); // 最多 5 个附件 // v0.5.4: 图片上传双重拦截(总开关 × 模型能力),非图片附件不受影响 const filtered = supportsImages ? fileArray : fileArray.filter((f) => !IMAGE_TYPES.includes(f.type)); if (filtered.length < fileArray.length && !supportsImages) { const skipped = fileArray.length - filtered.length; // v0.5.4: 区分拒绝原因 — 开关未开启 vs 当前模型不支持 const reason = imageGate.reason === 'disabled' ? t('input.image.skipped.toggle', { count: skipped }) : imageGate.reason === 'probed-no-vision' ? t('input.image.skipped.probe', { count: skipped }) : t('input.image.skipped.model', { provider: PROVIDER_LABELS[provider] ?? provider, count: skipped, }); import('@metona-team/metona-toast') .then((mod) => { mod.default.warning(reason); }) .catch((err) => { console.error('[ChatInput] Failed to load metona-toast for image warning:', err); }); } if (filtered.length === 0) return; // v0.3.0 修复: 使用 allSettled 处理部分文件读取失败,避免一个失败导致全部丢失 const results = await Promise.allSettled(filtered.map(processFile)); const successful = results .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') .map((r) => r.value); if (successful.length === 0) { import('@metona-team/metona-toast') .then((mod) => { mod.default.error(t('input.file.readFailed')); }) .catch(() => {}); return; } if (successful.length < filtered.length) { const failedCount = filtered.length - successful.length; import('@metona-team/metona-toast') .then((mod) => { mod.default.warning(t('input.file.partialFailed', { count: failedCount })); }) .catch(() => {}); } setAttachments((prev) => [...prev, ...successful]); }, [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; // v0.7.4 P1-8: 闭包陈旧修复 —— configLoaded/toolsReady 不在依赖数组内 // (草稿恢复后 input 不再变化,闭包永远捕获初始 false,按钮看似可用但点击被静默拦截)。 // 改为从 store 读取最新值,彻底消除依赖遗漏类 bug。 if (!useAgentStore.getState().configLoaded) return; // P1-6: 配置未加载完成时禁止发送 if (!useAgentStore.getState().toolsReady) return; // v0.3.18: 工具未就绪时禁止发送 // 处理 / 命令 if (trimmed.startsWith('/')) { const cmd = trimmed.split(' ')[0].toLowerCase(); if (cmd === '/clear') { // v0.7.2 A1: clearMessages 已升级为先清 DB(messages + 摘要游标 + TRACE 快照) // 再清前端 state;流式进行中由 store 侧拒绝并 toast 提示 await useAgentStore.getState().clearMessages(); setInput(''); setAttachments([]); setShowSlashMenu(false); return; } if (cmd === '/export') { // P2-11: /export 改为导出 Markdown(人类可读),JSON 导出走会话右键菜单 import('@renderer/lib/export-markdown') .then(({ buildSessionMarkdown, downloadMarkdown }) => { const messages = useAgentStore.getState().messages; const md = buildSessionMarkdown(t('chat.export.title'), messages); downloadMarkdown(`session-${Date.now()}.md`, md); }) .catch(() => {}); setInput(''); setShowSlashMenu(false); return; } // v0.3.0: /tool — 打开设置面板的工具管理 Tab if (cmd === '/tool') { useUIStore.getState().openSettings(); setInput(''); setAttachments([]); setShowSlashMenu(false); return; } // v0.3.0: /memory — 切换到详情面板的 Memory 标签 // v0.3.0 修复:确保详情面板可见,否则切换标签用户看不到 if (cmd === '/memory') { const uiStore = useUIStore.getState(); uiStore.setDetailTab('memory'); // v0.3.0 修复: 专注模式下先退出专注模式(恢复快照),再确保详情面板可见 if (uiStore.focusMode) { uiStore.toggleFocusMode(); } // 退出专注模式后再次检查详情面板可见性 if (!useUIStore.getState().detailVisible) { uiStore.toggleDetail(); } setInput(''); setAttachments([]); setShowSlashMenu(false); return; } } // 构建用户可见内容(纯文本 + 附件描述隐藏) const 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, truncated: att.truncated, // v0.7.2 A5: 文本附件截断标记(随消息持久化) })); // 图片加入 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) => { // H-9 修复: 快捷键对齐规范 // @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 快捷键规范 // 规范要求: Cmd/Ctrl+Enter = 发送消息, Cmd/Ctrl+Shift+Enter = 换行 // 之前代码是 Ctrl+Enter=换行, Enter=发送,与规范相反 // 修复后: // Cmd/Ctrl+Enter = 发送消息(规范要求) // Cmd/Ctrl+Shift+Enter = 换行(规范要求) // Enter = 发送消息(保持聊天应用习惯) // Shift+Enter = 换行(textarea 默认行为,无需处理) // Cmd/Ctrl+Shift+Enter — 换行(规范要求) if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && e.shiftKey) { 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; } // Cmd/Ctrl+Enter — 发送消息(规范要求,优先于 Enter) // v0.7.4 P1-7: IME 合成期间组合键同样拦截(拼音选字时按 Cmd/Ctrl+Enter 不应发送) if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { if (e.nativeEvent.isComposing || e.keyCode === 229) { e.preventDefault(); return; } e.preventDefault(); handleSend(); return; } // Enter(不带修饰键)— 发送消息(保持聊天应用习惯) // v0.7.4 P1-7: 中文输入法(IME)合成事件保护 —— 拼音选字回车会触发 // keydown Enter(keyCode 229 / isComposing=true),旧实现直接发送, // 中文用户高频误发送。合成中回车一律拦截,仅放行真实发送回车。 if (e.key === 'Enter' && !e.shiftKey) { if (e.nativeEvent.isComposing || e.keyCode === 229) { e.preventDefault(); return; } e.preventDefault(); handleSend(); return; } if (e.key === 'Escape' && showSlashMenu) { setShowSlashMenu(false); return; } }, [handleSend, showSlashMenu], ); // H-8 修复: 使用 InputBase 后,onChange 类型需兼容 HTMLInputElement | HTMLTextAreaElement 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)} /> ))} )} {/* / 命令菜单 — v0.7.4 P3-7: 手写 div 弹层改为 MUI Paper+List(遵循 MUI 铁律: 禁止自写 UI 交互组件)。悬停/点击/键盘导航由 MUI 组件内建提供。 */} {showSlashMenu && filteredCommands.length > 0 && ( {filteredCommands.map((cmd) => ( { setInput(cmd.label + ' '); setShowSlashMenu(false); textareaRef.current?.focus(); }} sx={{ gap: 1.5, py: 0.75 }} > / {cmd.label} {cmd.description()} ))} )} {/* H-8 修复: 使用 MUI InputBase 替代原生 textarea — 遵循 MUI 强制使用规范 */} {/* @see standard/开发规范.md — MUI 强制使用、禁止自写 UI 组件 */} {/* 左侧:附件按钮 */} {/* 右侧:发送按钮 */} {isStreaming ? ( ) : ( )} ); } // ===== 附件预览组件 ===== function AttachmentPreview({ attachment, onRemove, }: { attachment: Attachment; onRemove: () => void; }) { const { type, file, preview } = attachment; if (type === 'image' && preview) { return ( {file.name} ); } // 文本文件 / 其他文件 return ( {type === 'text' ? ( ) : ( )} {file.name} {formatFileSize(file.size)} ); } /** * 压缩图片:限制最大边长,输出 JPEG base64。 * 小图(< 500KB 且尺寸未超标)直接返回原图,避免重复编码。 * * @see AgnesAIDesktop — 已验证 Agnes AI 可正常处理压缩后的图片 */ function compressImage(dataUri: string, maxSize: number, quality: number): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { let { width, height } = img; if (width <= maxSize && height <= maxSize && dataUri.length < 500 * 1024) { resolve(dataUri); return; } if (width > height) { if (width > maxSize) { height = Math.round((height * maxSize) / width); width = maxSize; } } else { if (height > maxSize) { width = Math.round((width * maxSize) / height); height = maxSize; } } const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d')!; ctx.drawImage(img, 0, 0, width, height); resolve(canvas.toDataURL('image/jpeg', quality)); }; img.onerror = () => reject(new Error('图片加载失败')); img.src = dataUri; }); }