问题: 上传图片后 AI 无法识别,回复没有图像分析工具 根因: 原图 base64 直接发送(5-10MB),Agnes AI 可能因体积过大拒绝处理 对比 AgnesAIDesktop(可正常工作): - 上传后用 canvas 压缩: max 1024px, JPEG quality 0.7 - 小图(<500KB且尺寸未超标)保留原图 修复: - ChatInput 新增 compressImage 函数(来自 AgnesAIDesktop 验证方案) - processFile 中图片读取后立即压缩再存 preview
349 lines
16 KiB
TypeScript
349 lines
16 KiB
TypeScript
/**
|
||
* 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;
|
||
});
|
||
}
|