P1 修复面收口: - 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR), 根治"真实网络超时被误报为用户中断" - 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道) - 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 + 前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死) - 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED) - DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制) - IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复 P2 安全纵深: - preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险) - CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步) - MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径) - write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏) - 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库 - ReDoS 检测共享化(search_files/file_editor 统一拦截) - run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command) - MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 + 十六进制映射解析 + 尾点剥离) P3 架构还债: - temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘 - SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async) - 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine) - i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数) - 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释) - 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 + 用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位) P4 能力演进: - 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报) - run-lock 30s 超时强制 abort(旧 run 卡死不无限排队) - RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复) - FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退) - getContextWindow 兜底 1M→128K(未知模型防 413) 测试: - 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、 工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、 纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例) - 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷 - 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 / mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性 版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload, 新增 jsdom/@testing-library(devDependencies 不打包) 回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过; 系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
830 lines
28 KiB
TypeScript
830 lines
28 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,
|
||
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<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 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<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') {
|
||
// 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<string>((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<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],
|
||
);
|
||
|
||
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<Attachment> => 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<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;
|
||
// 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<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}`),
|
||
);
|
||
|
||
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>
|
||
)}
|
||
|
||
{/* / 命令菜单 — v0.7.4 P3-7: 手写 div 弹层改为 MUI Paper+List(遵循 MUI 铁律:
|
||
禁止自写 UI 交互组件)。悬停/点击/键盘导航由 MUI 组件内建提供。 */}
|
||
{showSlashMenu && filteredCommands.length > 0 && (
|
||
<Paper
|
||
elevation={8}
|
||
sx={{
|
||
position: 'absolute',
|
||
bottom: '100%',
|
||
left: 0,
|
||
right: 0,
|
||
mb: 1,
|
||
maxHeight: 240,
|
||
overflow: 'auto',
|
||
borderRadius: 2,
|
||
border: '1px solid',
|
||
borderColor: 'divider',
|
||
bgcolor: 'background.paper',
|
||
zIndex: 10,
|
||
}}
|
||
>
|
||
<List dense disablePadding>
|
||
{filteredCommands.map((cmd) => (
|
||
<ListItemButton
|
||
key={cmd.id}
|
||
onClick={() => {
|
||
setInput(cmd.label + ' ');
|
||
setShowSlashMenu(false);
|
||
textareaRef.current?.focus();
|
||
}}
|
||
sx={{ gap: 1.5, py: 0.75 }}
|
||
>
|
||
<Typography
|
||
sx={{ color: 'primary.main', fontSize: 14, fontWeight: 700, flexShrink: 0 }}
|
||
>
|
||
/
|
||
</Typography>
|
||
<Typography sx={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||
{cmd.label}
|
||
</Typography>
|
||
<Typography
|
||
sx={{ color: 'text.secondary', fontSize: 11, ml: 'auto', textAlign: 'right' }}
|
||
>
|
||
{cmd.description()}
|
||
</Typography>
|
||
</ListItemButton>
|
||
))}
|
||
</List>
|
||
</Paper>
|
||
)}
|
||
|
||
<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'
|
||
: '.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'
|
||
}
|
||
style={{ display: 'none' }}
|
||
onChange={handleFileChange}
|
||
/>
|
||
|
||
{/* H-8 修复: 使用 MUI InputBase 替代原生 textarea — 遵循 MUI 强制使用规范 */}
|
||
{/* @see standard/开发规范.md — MUI 强制使用、禁止自写 UI 组件 */}
|
||
<InputBase
|
||
inputRef={textareaRef}
|
||
data-chat-input
|
||
value={input}
|
||
onChange={handleChange}
|
||
onKeyDown={handleKeyDown}
|
||
onPaste={handlePaste}
|
||
placeholder={
|
||
configLoaded
|
||
? toolsReady
|
||
? t('input.placeholder.ready')
|
||
: t('input.placeholder.toolsLoading')
|
||
: t('input.placeholder.configLoading')
|
||
}
|
||
disabled={isStreaming || !configLoaded || !toolsReady}
|
||
multiline
|
||
rows={1}
|
||
sx={{
|
||
width: '100%',
|
||
color: 'inherit',
|
||
fontSize: 14,
|
||
lineHeight: '28px',
|
||
fontFamily: 'inherit',
|
||
'& .MuiInputBase-input': {
|
||
padding: 0,
|
||
resize: 'none',
|
||
minHeight: 42,
|
||
maxHeight: 160,
|
||
},
|
||
'&::before, &::after': {
|
||
display: 'none',
|
||
},
|
||
}}
|
||
/>
|
||
|
||
<Stack
|
||
direction="row"
|
||
sx={{ mt: 1, minHeight: 32, justifyContent: 'space-between', alignItems: 'center' }}
|
||
>
|
||
{/* 左侧:附件按钮 */}
|
||
<Tooltip
|
||
title={
|
||
supportsImages
|
||
? t('input.attach.image')
|
||
: multimodalEnabled
|
||
? t('input.attach.textOnly.model')
|
||
: t('input.attach.textOnly.toggle')
|
||
}
|
||
>
|
||
<IconButton size="small" sx={{ color: 'text.secondary' }} onClick={handleFileSelect}>
|
||
<Paperclip size={14} />
|
||
</IconButton>
|
||
</Tooltip>
|
||
|
||
{/* 右侧:发送按钮 */}
|
||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||
{isStreaming ? (
|
||
<Button
|
||
variant="contained"
|
||
color="error"
|
||
size="small"
|
||
onClick={handleAbort}
|
||
sx={{ height: 28, fontSize: 12 }}
|
||
>
|
||
<Square size={12} style={{ marginRight: 6 }} /> {t('input.abort')}
|
||
</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,
|
||
}}
|
||
>
|
||
<Send size={12} style={{ marginRight: 6 }} /> {t('input.send')}
|
||
</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;
|
||
});
|
||
}
|