Files
metona-ai-desktop/src/components/chat/ChatInput.tsx
T
thzxx 4924e2d339 fix: 图片上传增加压缩(1024px/JPEG 0.7),对齐 AgnesAIDesktop 方案
问题: 上传图片后 AI 无法识别,回复没有图像分析工具
根因: 原图 base64 直接发送(5-10MB),Agnes AI 可能因体积过大拒绝处理

对比 AgnesAIDesktop(可正常工作):
- 上传后用 canvas 压缩: max 1024px, JPEG quality 0.7
- 小图(<500KB且尺寸未超标)保留原图

修复:
- ChatInput 新增 compressImage 函数(来自 AgnesAIDesktop 验证方案)
- processFile 中图片读取后立即压缩再存 preview
2026-06-27 22:27:56 +08:00

349 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
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<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 → 压缩(限制 1024px, JPEG 0.7
const dataUri = await new Promise<string>((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<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 个附件
// 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<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={supportsImages ? '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' : '.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={supportsImages ? '附加文件(图片/文本/代码)' : '附加文件(文本/代码)— DeepSeek 不支持图片'}>
<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>
);
}
/**
* 压缩图片:限制最大边长,输出 JPEG base64。
* 小图(< 500KB 且尺寸未超标)直接返回原图,避免重复编码。
*
* @see AgnesAIDesktop — 已验证 Agnes AI 可正常处理压缩后的图片
*/
function compressImage(
dataUri: string,
maxSize: number,
quality: number,
): Promise<string> {
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;
});
}