feat: v0.5.4 多模态增强 — 多轮图片记忆 + 多模态总开关 + DeepSeek vision 模型支持
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m47s
CI / 全量测试 (Electron ABI) (push) Failing after 5m25s
CI / 产物编译验证 (push) Successful in 10m4s

多轮图片记忆:
- 此前历史轮次的图片不回传 LLM(attachments 仅存压缩 preview,历史组装
  时被丢弃)— 跨轮对话中模型对图片内容"失忆"
- 修复:SessionSummaryService.buildHistoryMessages 从持久化的 attachments
  恢复 images(type=image 的 preview base64),历史图片随上下文回传
- Token 控制:最多注入最近 10 张(MAX_HISTORY_IMAGES,从最新消息向前
  收集)— 每张 1024px 压缩图约数百至千余 token,无上限会吃满上下文
- 摘要区间(summarizedUntilRowid 之前)的图片不恢复,符合滚动摘要语义

多模态总开关 llm.multimodalEnabled(默认关闭):
- 新配置项:CONFIG_DEFAULTS 种子 + 设置弹框 LLM 配置 Switch +
  首次引导向导 LLM 步骤 Switch(含说明文案)
- 上传入口双重判断:总开关 × 模型能力 — 未开启时即使模型支持多模态
  也不能上传图片(ChatInput 的选择/拖拽/粘贴统一拦截,Toast 区分
  "开关未开启"与"当前模型不支持"两种原因)
- 保存成功后同步 Agent Store 立即生效;App 启动时随 setProvider 加载

DeepSeek vision 模型支持:
- 新增 deepseek-v4-flash-vision-exp(OpenAI image_url content parts 格式,
  128K 上下文 / 8K 输出)
- adapter 按 isVisionModel() 判断:vision 模型将带 images 的消息转换为
  [{type:'text'},{type:'image_url'}] parts;非 vision 模型保持 images
  静默丢弃(防 API 400)

测试(236 → 243 用例):
- 多轮图片记忆 ×3(session-summary.test.ts):历史 attachments 恢复
  images / 上限 10 张从最新向前 / 摘要区间图片不恢复
- DeepSeek vision 请求格式 ×4(deepseek-vision.test.ts,契约级 mock
  fetch 断言请求体):image_url parts 转换 / 非 vision 模型丢弃 /
  max_tokens 钳制 8192 / 无图不转换
- 测试顺序修正:多轮图片用例置于 describe 末尾(插入新行消耗全局自增
  rowid,插在中间会破坏既有用例对 rowid 数值的断言)

文档: README 同步(DeepSeek 模型表 + vision 多模态列、llm.multimodalEnabled
配置项、多轮图片记忆特性行、243 用例数)

验证: lint 0 / typecheck 双工程 0 / test:electron 243 全过 / build 成功
This commit is contained in:
2026-08-21 23:00:20 +08:00
parent 3a30e8f5b4
commit 4ee5100661
12 changed files with 1363 additions and 353 deletions
+486 -167
View File
@@ -10,7 +10,16 @@
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { Box, Typography, IconButton, Tooltip, Stack, Button, Paper, InputBase } from '@mui/material';
import {
Box,
Typography,
IconButton,
Tooltip,
Stack,
Button,
Paper,
InputBase,
} 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';
@@ -26,14 +35,48 @@ const SLASH_COMMANDS = [
];
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'];
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; // 文本文件内容
preview?: string; // 图片 base64 data URL
textContent?: string; // 文本文件内容
}
export function ChatInput(): React.JSX.Element {
@@ -51,13 +94,29 @@ export function ChatInput(): React.JSX.Element {
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);
/** DeepSeek 不支持多模态图片 */
const supportsImages = provider !== 'deepseek';
/**
* v0.5.4: 图片上传双重判断 — 总开关 × 模型能力
* - 开关关闭:即使模型支持多模态也不能上传(显式控制)
* - 模型能力:DeepSeek 仅 vision 系列支持;其他五家 Provider 均支持
*/
const modelSupportsImages =
provider !== 'deepseek' || (model.length > 0 && model.includes('vision'));
const supportsImages = multimodalEnabled && modelSupportsImages;
// 草稿自动保存
useEffect(() => { if (currentSessionId) { const d = sessionStorage.getItem(`draft-${currentSessionId}`); setInput(d ?? ''); } }, [currentSessionId]);
useEffect(() => { if (currentSessionId && input) sessionStorage.setItem(`draft-${currentSessionId}`, input); }, [input, currentSessionId]);
useEffect(() => {
if (currentSessionId) {
const d = sessionStorage.getItem(`draft-${currentSessionId}`);
setInput(d ?? '');
}
}, [currentSessionId]);
useEffect(() => {
if (currentSessionId && input) sessionStorage.setItem(`draft-${currentSessionId}`, input);
}, [input, currentSessionId]);
// ===== 附件处理 =====
@@ -68,104 +127,136 @@ export function ChatInput(): React.JSX.Element {
return 'other';
}, []);
const processFile = useCallback(async (file: File): Promise<Attachment> => {
const type = classifyFile(file);
// L-10 修复: 统一使用 nanoid 生成附件 ID(与项目其他位置一致)
const attachment: Attachment = { id: `att_${nanoid(6)}`, file, type };
const processFile = useCallback(
async (file: File): Promise<Attachment> => {
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<string>((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') {
// 文本文件读取内容
attachment.textContent = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error('文本文件读取失败'));
reader.readAsText(file);
});
}
if (type === 'image') {
// 图片转 base64 data URL → 压缩(限制 1024px, JPEG 0.7
const dataUri = await new Promise<string>((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') {
// 文本文件读取内容
attachment.textContent = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error('文本文件读取失败'));
reader.readAsText(file);
});
}
return attachment;
}, [classifyFile]);
return attachment;
},
[classifyFile],
);
const addFiles = useCallback(async (files: FileList | File[]) => {
const fileArray = Array.from(files).slice(0, 5); // 最多 5 个附件
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));
// v0.5.4: 图片上传双重拦截(总开关 × 模型能力),非图片附件不受影响
const filtered = supportsImages
? fileArray
: fileArray.filter((f) => !IMAGE_TYPES.includes(f.type));
if (filtered.length < fileArray.length && !supportsImages) {
// v0.3.0: 用 Toast 提示用户 DeepSeek 不支持图片
const skipped = fileArray.length - filtered.length;
// v0.3.0 修复:记录 Toast 加载失败错误到控制台,而非静默吞掉
import('@metona-team/metona-toast').then((mod) => {
mod.default.warning(`DeepSeek 不支持图片,已自动跳过 ${skipped} 个图片文件`);
}).catch((err) => {
console.error('[ChatInput] Failed to load metona-toast for DeepSeek image warning:', err);
});
}
if (filtered.length < fileArray.length && !supportsImages) {
const skipped = fileArray.length - filtered.length;
// v0.5.4: 区分拒绝原因 — 开关未开启 vs 当前模型不支持
const reason = multimodalEnabled
? `当前模型不支持图片(${provider} 需多模态模型),已跳过 ${skipped} 个图片文件`
: `多模态未开启(设置 → LLM 配置),已跳过 ${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;
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<Attachment> => r.status === 'fulfilled',
).map((r) => r.value);
if (successful.length === 0) {
import('@metona-team/metona-toast').then((mod) => {
mod.default.error('文件读取失败,请检查文件是否损坏或被锁定');
}).catch(() => {});
return;
}
if (successful.length < filtered.length) {
const failedCount = filtered.length - successful.length;
import('@metona-team/metona-toast').then((mod) => {
mod.default.warning(`${failedCount} 个文件读取失败,已跳过`);
}).catch(() => {});
}
setAttachments((prev) => [...prev, ...successful]);
}, [processFile, supportsImages]);
// v0.3.0 修复: 使用 allSettled 处理部分文件读取失败,避免一个失败导致全部丢失
const results = await Promise.allSettled(filtered.map(processFile));
const successful = results
.filter((r): r is PromiseFulfilledResult<Attachment> => r.status === 'fulfilled')
.map((r) => r.value);
if (successful.length === 0) {
import('@metona-team/metona-toast')
.then((mod) => {
mod.default.error('文件读取失败,请检查文件是否损坏或被锁定');
})
.catch(() => {});
return;
}
if (successful.length < filtered.length) {
const failedCount = filtered.length - successful.length;
import('@metona-team/metona-toast')
.then((mod) => {
mod.default.warning(`${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<HTMLInputElement>) => {
if (e.target.files) addFiles(e.target.files);
e.target.value = '';
}, [addFiles]);
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 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);
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]);
if (files.length) {
e.preventDefault();
addFiles(files);
}
},
[addFiles],
);
// ===== 发送消息 =====
@@ -179,20 +270,32 @@ export function ChatInput(): React.JSX.Element {
// 处理 / 命令
if (trimmed.startsWith('/')) {
const cmd = trimmed.split(' ')[0].toLowerCase();
if (cmd === '/clear') { useAgentStore.getState().clearMessages(); setInput(''); setAttachments([]); setShowSlashMenu(false); return; }
if (cmd === '/clear') {
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('会话导出', messages);
downloadMarkdown(`session-${Date.now()}.md`, md);
}).catch(() => {});
setInput(''); setShowSlashMenu(false); return;
import('@renderer/lib/export-markdown')
.then(({ buildSessionMarkdown, downloadMarkdown }) => {
const messages = useAgentStore.getState().messages;
const md = buildSessionMarkdown('会话导出', 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);
setInput('');
setAttachments([]);
setShowSlashMenu(false);
return;
}
// v0.3.0: /memory — 切换到详情面板的 Memory 标签
@@ -208,7 +311,9 @@ export function ChatInput(): React.JSX.Element {
if (!useUIStore.getState().detailVisible) {
uiStore.toggleDetail();
}
setInput(''); setAttachments([]); setShowSlashMenu(false);
setInput('');
setAttachments([]);
setShowSlashMenu(false);
return;
}
}
@@ -234,7 +339,11 @@ export function ChatInput(): React.JSX.Element {
}
}
sendMessage(messageContent, images.length > 0 ? images : undefined, attachmentInfos.length > 0 ? attachmentInfos : undefined);
sendMessage(
messageContent,
images.length > 0 ? images : undefined,
attachmentInfos.length > 0 ? attachmentInfos : undefined,
);
setInput('');
setAttachments([]);
setShowSlashMenu(false);
@@ -242,82 +351,166 @@ export function ChatInput(): React.JSX.Element {
if (textareaRef.current) textareaRef.current.style.height = 'auto';
}, [input, attachments, isStreaming, sendMessage, currentSessionId]);
const handleAbort = useCallback(() => { abort(); }, [abort]);
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 默认行为,无需处理
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)
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSend();
return;
}
// Enter(不带修饰键)— 发送消息(保持聊天应用习惯)
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); return; }
if (e.key === 'Escape' && showSlashMenu) { setShowSlashMenu(false); return; }
}, [handleSend, showSlashMenu]);
// 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)
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSend();
return;
}
// Enter(不带修饰键)— 发送消息(保持聊天应用习惯)
if (e.key === 'Enter' && !e.shiftKey) {
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<HTMLTextAreaElement | HTMLInputElement>) => {
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 handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
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}`));
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}
<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)} />
<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 }}>
<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)' }}
<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={{ 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>
<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} />
<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}
/>
{/* H-8 修复: 使用 MUI InputBase 替代原生 textarea — 遵循 MUI 强制使用规范 */}
{/* @see standard/开发规范.md — MUI 强制使用、禁止自写 UI 组件 */}
@@ -328,7 +521,13 @@ export function ChatInput(): React.JSX.Element {
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={configLoaded ? (toolsReady ? '输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)' : '工具加载中...') : '正在加载配置...'}
placeholder={
configLoaded
? toolsReady
? '输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)'
: '工具加载中...'
: '正在加载配置...'
}
disabled={isStreaming || !configLoaded || !toolsReady}
multiline
rows={1}
@@ -350,9 +549,18 @@ export function ChatInput(): React.JSX.Element {
}}
/>
<Stack direction="row" sx={{ mt: 1, minHeight: 32, justifyContent: 'space-between', alignItems: 'center' }}>
<Stack
direction="row"
sx={{ mt: 1, minHeight: 32, justifyContent: 'space-between', alignItems: 'center' }}
>
{/* 左侧:附件按钮 */}
<Tooltip title={supportsImages ? '附加文件(图片/文本/代码)' : '附加文件(文本/代码)— DeepSeek 不支持图片'}>
<Tooltip
title={
supportsImages
? '附加文件(图片/文本/代码)'
: '附加文件(文本/代码)— DeepSeek 不支持图片'
}
>
<IconButton size="small" sx={{ color: 'text.secondary' }} onClick={handleFileSelect}>
<Paperclip size={14} />
</IconButton>
@@ -361,11 +569,32 @@ export function ChatInput(): React.JSX.Element {
{/* 右侧:发送按钮 */}
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
{isStreaming ? (
<Button variant="contained" color="error" size="small" onClick={handleAbort} sx={{ height: 28, fontSize: 12 }}>
<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) || !configLoaded || !toolsReady} sx={{ height: 28, fontSize: 12, opacity: (input.trim() || attachments.length > 0) && configLoaded && toolsReady ? 1 : 0.5 }}>
<Button
variant="contained"
size="small"
onClick={handleSend}
disabled={
(!input.trim() && attachments.length === 0) || !configLoaded || !toolsReady
}
sx={{
height: 28,
fontSize: 12,
opacity:
(input.trim() || attachments.length > 0) && configLoaded && toolsReady
? 1
: 0.5,
}}
>
<Send size={12} style={{ marginRight: 6 }} />
</Button>
)}
@@ -378,17 +607,69 @@ export function ChatInput(): React.JSX.Element {
// ===== 附件预览组件 =====
function AttachmentPreview({ attachment, onRemove }: { attachment: Attachment; onRemove: () => void }) {
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' }}>
<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 }}>
<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>
@@ -397,13 +678,49 @@ function AttachmentPreview({ attachment, onRemove }: { attachment: Attachment; o
// 文本文件 / 其他文件
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={{
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>
<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' }}>
<IconButton
size="small"
onClick={onRemove}
sx={{ width: 20, height: 20, flexShrink: 0, color: 'text.secondary' }}
>
<X size={12} />
</IconButton>
</Box>
@@ -416,11 +733,7 @@ function AttachmentPreview({ attachment, onRemove }: { attachment: Attachment; o
*
* @see AgnesAIDesktop — 已验证 Agnes AI 可正常处理压缩后的图片
*/
function compressImage(
dataUri: string,
maxSize: number,
quality: number,
): Promise<string> {
function compressImage(dataUri: string, maxSize: number, quality: number): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
@@ -430,9 +743,15 @@ function compressImage(
return;
}
if (width > height) {
if (width > maxSize) { height = Math.round((height * maxSize) / width); width = maxSize; }
if (width > maxSize) {
height = Math.round((height * maxSize) / width);
width = maxSize;
}
} else {
if (height > maxSize) { width = Math.round((width * maxSize) / height); height = maxSize; }
if (height > maxSize) {
width = Math.round((width * maxSize) / height);
height = maxSize;
}
}
const canvas = document.createElement('canvas');
canvas.width = width;