feat: v0.5.4 多模态增强 — 多轮图片记忆 + 多模态总开关 + DeepSeek vision 模型支持
多轮图片记忆:
- 此前历史轮次的图片不回传 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:
+486
-167
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user