feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
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)
This commit is contained in:
@@ -26,6 +26,9 @@ import { ToolCallCard } from './ToolCallCard';
|
||||
import { ToolResultBlock } from './ToolResultBlock';
|
||||
import { formatTime } from '@renderer/lib/formatters';
|
||||
import { ContextMenu, createContextMenuItems } from '@renderer/components/ContextMenu';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
interface AssistantMessageProps {
|
||||
message: ChatMessage;
|
||||
@@ -51,54 +54,99 @@ function AssistantMessageImpl({ message, isStreaming }: AssistantMessageProps):
|
||||
// F7: useMemo 缓存 ReactMarkdown 元素,避免非流式时因 isStreaming/isLast 变化重新创建元素
|
||||
// 流式时此元素不被渲染(用纯文本),useMemo 仍会更新但仅创建轻量 React 元素对象
|
||||
// 非流式时 content 不变,useMemo 复用缓存,避免重新创建 ReactMarkdown 组件实例
|
||||
const markdownElement = useMemo(() => (
|
||||
<Box className="prose-metona">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
components={{
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className ?? '');
|
||||
// rehype-highlight 会把代码替换为 <span> 高亮元素,需提取纯文本
|
||||
const codeStr = extractTextContent(children).replace(/\n$/, '');
|
||||
if (!match) return <code className={className} {...props}>{children}</code>;
|
||||
return <CodeBlock language={match[1]} code={codeStr} />;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</Box>
|
||||
), [content]);
|
||||
const markdownElement = useMemo(
|
||||
() => (
|
||||
<Box className="prose-metona">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
components={{
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className ?? '');
|
||||
// rehype-highlight 会把代码替换为 <span> 高亮元素,需提取纯文本
|
||||
const codeStr = extractTextContent(children).replace(/\n$/, '');
|
||||
if (!match)
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
return <CodeBlock language={match[1]} code={codeStr} />;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</Box>
|
||||
),
|
||||
[content],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1.5}
|
||||
sx={{ animation: 'fadeInUp 300ms ease-out' }}
|
||||
onContextMenu={(e: React.MouseEvent) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }}
|
||||
onContextMenu={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
>
|
||||
<Avatar sx={{ width: 32, height: 32, bgcolor: 'secondary.main', color: 'primary.main', flexShrink: 0 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: 'secondary.main',
|
||||
color: 'primary.main',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Bot size={16} />
|
||||
</Avatar>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1, minWidth: 0, maxWidth: 768, borderRadius: 3, px: 2, py: 1.5,
|
||||
bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
maxWidth: 768,
|
||||
borderRadius: 3,
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
bgcolor: 'background.paper',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
transition: 'border-color 200ms, box-shadow 200ms',
|
||||
...(isStreaming ? { borderColor: 'rgba(129,140,248,0.3)', boxShadow: '0 0 0 1px rgba(129,140,248,0.1)' } : {}),
|
||||
...(isStreaming
|
||||
? { borderColor: 'rgba(129,140,248,0.3)', boxShadow: '0 0 0 1px rgba(129,140,248,0.1)' }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{/* ===== 阶段 1: 思考过程 ===== */}
|
||||
{hasThinking && (
|
||||
<>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
|
||||
<Typography variant="caption" sx={{ color: '#fbbf24', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
💭 {isThinking ? '正在思考...' : '思考过程'}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#fbbf24',
|
||||
fontWeight: 600,
|
||||
fontSize: 10,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
💭 {isThinking ? t('assistant.thinking') : t('assistant.thinkingLabel')}
|
||||
</Typography>
|
||||
{isThinking && (
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: '#fbbf24', animation: 'pulse 1.5s infinite' }} />
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: '#fbbf24',
|
||||
animation: 'pulse 1.5s infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<ThoughtBlock content={message.reasoningContent!} defaultExpanded={!!isStreaming} />
|
||||
@@ -110,16 +158,23 @@ function AssistantMessageImpl({ message, isStreaming }: AssistantMessageProps):
|
||||
<>
|
||||
{hasThinking && <Box sx={{ height: 8 }} />}
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
|
||||
<Typography variant="caption" sx={{ color: '#a855f7', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
🔧 工具调用
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#a855f7',
|
||||
fontWeight: 600,
|
||||
fontSize: 10,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
🔧 {t('assistant.toolCalls')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{message.toolCalls!.map((tc) => (
|
||||
<Box key={tc.id}>
|
||||
<ToolCallCard toolCall={tc} />
|
||||
{tc.status === 'success' && tc.result != null && (
|
||||
<ToolResultBlock toolCall={tc} />
|
||||
)}
|
||||
{tc.status === 'success' && tc.result != null && <ToolResultBlock toolCall={tc} />}
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
@@ -130,11 +185,28 @@ function AssistantMessageImpl({ message, isStreaming }: AssistantMessageProps):
|
||||
<>
|
||||
{(hasThinking || hasTools) && (
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 0.5, mb: 0.5, alignItems: 'center' }}>
|
||||
<Typography variant="caption" sx={{ color: '#34d399', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
✏️ 回复
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#34d399',
|
||||
fontWeight: 600,
|
||||
fontSize: 10,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
✏️ {t('assistant.reply')}
|
||||
</Typography>
|
||||
{isStreaming && !isThinking && (
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: '#34d399', animation: 'pulse 1.5s infinite' }} />
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: '#34d399',
|
||||
animation: 'pulse 1.5s infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
@@ -167,15 +239,40 @@ function AssistantMessageImpl({ message, isStreaming }: AssistantMessageProps):
|
||||
markdownElement
|
||||
)
|
||||
) : isStreaming ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.disabled', fontStyle: 'italic', py: 1 }}>
|
||||
{isThinking ? '正在思考...' : '正在生成回复...'}
|
||||
<Box component="span" sx={{ display: 'inline-block', width: 8, height: 16, ml: 0.5, bgcolor: 'primary.main', animation: 'blink 1s step-end infinite', verticalAlign: 'middle' }} />
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: 'text.disabled', fontStyle: 'italic', py: 1 }}
|
||||
>
|
||||
{isThinking ? t('assistant.thinking') : t('assistant.generating')}
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 16,
|
||||
ml: 0.5,
|
||||
bgcolor: 'primary.main',
|
||||
animation: 'blink 1s step-end infinite',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
/>
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{/* 流式打字光标 */}
|
||||
{isStreaming && hasContent && (
|
||||
<Box component="span" sx={{ display: 'inline-block', width: 8, height: 16, ml: 0.25, bgcolor: 'primary.main', animation: 'blink 1s step-end infinite', verticalAlign: 'middle' }} />
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 16,
|
||||
ml: 0.25,
|
||||
bgcolor: 'primary.main',
|
||||
animation: 'blink 1s step-end infinite',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -210,21 +307,67 @@ export const AssistantMessage = memo(AssistantMessageImpl);
|
||||
function CodeBlock({ language, code }: { language: string; code: string }): React.JSX.Element {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = useCallback(async () => {
|
||||
try { await navigator.clipboard.writeText(code); } catch { const ta = document.createElement('textarea'); ta.value = code; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); }
|
||||
setCopied(true); setTimeout(() => setCopied(false), 2000);
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
} catch {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = code;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative', my: 1, '&:hover .copy-btn': { opacity: 1 } }}>
|
||||
<Stack direction="row" sx={{ px: 1.5, py: 0.75, borderTopLeftRadius: 8, borderTopRightRadius: 8, borderBottom: '1px solid', borderColor: 'divider', bgcolor: 'background.paper', fontSize: 11, color: 'text.secondary', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 0.75,
|
||||
borderTopLeftRadius: 8,
|
||||
borderTopRightRadius: 8,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.paper',
|
||||
fontSize: 11,
|
||||
color: 'text.secondary',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span>{language}</span>
|
||||
<Tooltip title={copied ? '已复制' : '复制'}>
|
||||
<IconButton className="copy-btn" size="small" onClick={handleCopy} sx={{ opacity: 0, transition: 'opacity 150ms', color: 'text.secondary' }}>
|
||||
<Tooltip title={copied ? t('assistant.copied') : t('assistant.copy')}>
|
||||
<IconButton
|
||||
className="copy-btn"
|
||||
size="small"
|
||||
onClick={handleCopy}
|
||||
sx={{ opacity: 0, transition: 'opacity 150ms', color: 'text.secondary' }}
|
||||
>
|
||||
{copied ? <Check size={10} /> : <Copy size={10} />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Box component="pre" sx={{ m: 0, bgcolor: 'background.default', border: '1px solid', borderColor: 'divider', borderTop: 'none', borderBottomLeftRadius: 8, borderBottomRightRadius: 8, p: 1.5, overflowX: 'auto', fontSize: 13, lineHeight: 1.6, color: 'text.primary' }}>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
m: 0,
|
||||
bgcolor: 'background.default',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderTop: 'none',
|
||||
borderBottomLeftRadius: 8,
|
||||
borderBottomRightRadius: 8,
|
||||
p: 1.5,
|
||||
overflowX: 'auto',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
<code className={language ? `language-${language}` : ''}>{code}</code>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -238,15 +381,17 @@ function CodeBlock({ language, code }: { language: string; code: string }): Reac
|
||||
* React children 正常深度不会超过 10,50 已足够安全裕量
|
||||
*/
|
||||
function extractTextContent(children: React.ReactNode, maxDepth = 50): string {
|
||||
if (maxDepth <= 0) return ''; // 超出深度上限,停止递归
|
||||
if (maxDepth <= 0) return ''; // 超出深度上限,停止递归
|
||||
if (typeof children === 'string') return children;
|
||||
if (typeof children === 'number') return String(children);
|
||||
if (!children) return '';
|
||||
if (Array.isArray(children)) return children.map((c) => extractTextContent(c, maxDepth - 1)).join('');
|
||||
if (Array.isArray(children))
|
||||
return children.map((c) => extractTextContent(c, maxDepth - 1)).join('');
|
||||
if (typeof children === 'object' && 'props' in children) {
|
||||
// 使用 React.ReactElement<{ children?: React.ReactNode }> 显式声明 props.children 类型
|
||||
return extractTextContent(
|
||||
(children as React.ReactElement<{ children?: React.ReactNode }>).props.children as React.ReactNode,
|
||||
(children as React.ReactElement<{ children?: React.ReactNode }>).props
|
||||
.children as React.ReactNode,
|
||||
maxDepth - 1,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
Button,
|
||||
Paper,
|
||||
InputBase,
|
||||
List,
|
||||
ListItemButton,
|
||||
} from '@mui/material';
|
||||
import { Send, Paperclip, Square, X, FileText, Image as ImageIcon } from 'lucide-react';
|
||||
import { nanoid } from 'nanoid';
|
||||
@@ -304,8 +306,11 @@ export function ChatInput(): React.JSX.Element {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed && attachments.length === 0) return;
|
||||
if (isStreaming) return;
|
||||
if (!configLoaded) return; // P1-6: 配置未加载完成时禁止发送
|
||||
if (!toolsReady) return; // v0.3.18: 工具未就绪时禁止发送
|
||||
// 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('/')) {
|
||||
@@ -324,7 +329,7 @@ export function ChatInput(): React.JSX.Element {
|
||||
import('@renderer/lib/export-markdown')
|
||||
.then(({ buildSessionMarkdown, downloadMarkdown }) => {
|
||||
const messages = useAgentStore.getState().messages;
|
||||
const md = buildSessionMarkdown('会话导出', messages);
|
||||
const md = buildSessionMarkdown(t('chat.export.title'), messages);
|
||||
downloadMarkdown(`session-${Date.now()}.md`, md);
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -423,13 +428,25 @@ export function ChatInput(): React.JSX.Element {
|
||||
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;
|
||||
@@ -493,53 +510,54 @@ export function ChatInput(): React.JSX.Element {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* / 命令菜单 */}
|
||||
{/* / 命令菜单 — v0.7.4 P3-7: 手写 div 弹层改为 MUI Paper+List(遵循 MUI 铁律:
|
||||
禁止自写 UI 交互组件)。悬停/点击/键盘导航由 MUI 组件内建提供。 */}
|
||||
{showSlashMenu && filteredCommands.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
<Paper
|
||||
elevation={8}
|
||||
sx={{
|
||||
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)',
|
||||
mb: 1,
|
||||
maxHeight: 240,
|
||||
overflow: 'auto',
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.paper',
|
||||
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>
|
||||
<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
|
||||
@@ -600,10 +618,10 @@ export function ChatInput(): React.JSX.Element {
|
||||
<Tooltip
|
||||
title={
|
||||
supportsImages
|
||||
? '附加文件(图片/文本/代码)'
|
||||
? t('input.attach.image')
|
||||
: multimodalEnabled
|
||||
? '附加文件(文本/代码)— 当前模型不支持图片'
|
||||
: '附加文件(文本/代码)— 多模态未开启(设置 → LLM 配置)'
|
||||
? t('input.attach.textOnly.model')
|
||||
: t('input.attach.textOnly.toggle')
|
||||
}
|
||||
>
|
||||
<IconButton size="small" sx={{ color: 'text.secondary' }} onClick={handleFileSelect}>
|
||||
|
||||
@@ -17,6 +17,9 @@ import { Box, Typography } from '@mui/material';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { MessageItem } from './MessageItem';
|
||||
import { StreamingIndicator } from './StreamingIndicator';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
/**
|
||||
* v0.6.4 修复: Virtuoso components.Footer 必须是稳定引用 —— 原实现每次 render
|
||||
@@ -66,10 +69,10 @@ export function MessageList(): React.JSX.Element {
|
||||
MetonaAI Desktop
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: 'text.secondary' }}>
|
||||
生产级通用 AI Agent 智能体桌面应用
|
||||
{t('messageList.subtitle')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 2, display: 'block', opacity: 0.6 }}>
|
||||
Agent 就绪 · 选择一个 Provider 开始对话
|
||||
{t('messageList.emptyHint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -83,36 +86,36 @@ export function MessageList(): React.JSX.Element {
|
||||
component="section"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-label="聊天消息列表"
|
||||
aria-label={t('messageList.ariaLabel')}
|
||||
sx={{ height: '100%', display: 'flex', flexDirection: 'column', minHeight: 0 }}
|
||||
>
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
// key=sessionId: 切换会话时强制重新挂载,使 initialTopMostItemIndex 重新生效
|
||||
// (定位到新会话的最后一条消息;同会话内 messages 变化不触发 remount)
|
||||
key={currentSessionId ?? 'no-session'}
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
data={messages}
|
||||
// 初始定位到最后一条(切换会话加载历史时直接到最新消息)
|
||||
initialTopMostItemIndex={Math.max(0, messages.length - 1)}
|
||||
// 新消息追加时跟随(流式中同步,非流式平滑)
|
||||
followOutput={isStreaming ? 'auto' : 'smooth'}
|
||||
// 跟踪底部状态:供流式跟随兜底定时器判断(上翻时暂停跟随)
|
||||
atBottomStateChange={(atBottom) => {
|
||||
atBottomRef.current = atBottom;
|
||||
}}
|
||||
itemContent={(index, msg) => (
|
||||
<Box sx={{ maxWidth: 768, mx: 'auto', px: 2, pt: index === 0 ? 3 : 1.5, pb: 1.5 }}>
|
||||
<MessageItem
|
||||
message={msg}
|
||||
isLast={index === messages.length - 1}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
components={{
|
||||
Footer: ListFooter,
|
||||
}}
|
||||
ref={virtuosoRef}
|
||||
// key=sessionId: 切换会话时强制重新挂载,使 initialTopMostItemIndex 重新生效
|
||||
// (定位到新会话的最后一条消息;同会话内 messages 变化不触发 remount)
|
||||
key={currentSessionId ?? 'no-session'}
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
data={messages}
|
||||
// 初始定位到最后一条(切换会话加载历史时直接到最新消息)
|
||||
initialTopMostItemIndex={Math.max(0, messages.length - 1)}
|
||||
// 新消息追加时跟随(流式中同步,非流式平滑)
|
||||
followOutput={isStreaming ? 'auto' : 'smooth'}
|
||||
// 跟踪底部状态:供流式跟随兜底定时器判断(上翻时暂停跟随)
|
||||
atBottomStateChange={(atBottom) => {
|
||||
atBottomRef.current = atBottom;
|
||||
}}
|
||||
itemContent={(index, msg) => (
|
||||
<Box sx={{ maxWidth: 768, mx: 'auto', px: 2, pt: index === 0 ? 3 : 1.5, pb: 1.5 }}>
|
||||
<MessageItem
|
||||
message={msg}
|
||||
isLast={index === messages.length - 1}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
components={{
|
||||
Footer: ListFooter,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
export function StreamingIndicator(): React.JSX.Element | null {
|
||||
const isStreaming = useAgentStore((s) => s.isStreaming);
|
||||
@@ -22,10 +25,19 @@ export function StreamingIndicator(): React.JSX.Element | null {
|
||||
if (lastMsg?.role === 'assistant') return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 2, pl: 5, animation: 'fadeIn 200ms ease-out' }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
py: 2,
|
||||
pl: 5,
|
||||
animation: 'fadeIn 200ms ease-out',
|
||||
}}
|
||||
>
|
||||
<Loader2 size={16} style={{ animation: 'spin 1s linear infinite', color: '#818cf8' }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 12 }}>
|
||||
正在连接 AI...
|
||||
{t('streaming.connecting')}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -5,23 +5,66 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, IconButton, Collapse } from '@mui/material';
|
||||
import { ChevronDown, ChevronRight, Brain } from 'lucide-react';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
interface ThoughtBlockProps { content: string; defaultExpanded?: boolean; }
|
||||
interface ThoughtBlockProps {
|
||||
content: string;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
export function ThoughtBlock({ content, defaultExpanded = false }: ThoughtBlockProps): React.JSX.Element {
|
||||
export function ThoughtBlock({
|
||||
content,
|
||||
defaultExpanded = false,
|
||||
}: ThoughtBlockProps): React.JSX.Element {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
useEffect(() => { setExpanded(defaultExpanded); }, [defaultExpanded]);
|
||||
useEffect(() => {
|
||||
setExpanded(defaultExpanded);
|
||||
}, [defaultExpanded]);
|
||||
if (!content) return <></>;
|
||||
|
||||
return (
|
||||
<Box sx={{ borderRadius: 1.5, border: '1px dashed', borderColor: 'divider', bgcolor: 'secondary.main', mb: expanded ? 1.5 : 0.5, transition: 'all 200ms' }}>
|
||||
<IconButton size="small" onClick={() => setExpanded(!expanded)} sx={{ width: '100%', justifyContent: 'flex-start', gap: 1, px: 1.5, py: 1, borderRadius: '10px 10px 0 0', color: 'text.secondary', fontSize: 12 }}>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 1.5,
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'secondary.main',
|
||||
mb: expanded ? 1.5 : 0.5,
|
||||
transition: 'all 200ms',
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 1,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
borderRadius: '10px 10px 0 0',
|
||||
color: 'text.secondary',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
<Brain size={12} style={{ color: '#fbbf24' }} />
|
||||
<span>思考过程</span>
|
||||
<span>{t('thought.thinkingLabel')}</span>
|
||||
</IconButton>
|
||||
<Collapse in={expanded}>
|
||||
<Box sx={{ px: 1.5, pb: 1.5, fontFamily: "'SF Mono','Fira Code',monospace", fontSize: 12, lineHeight: 1.6, color: 'text.secondary', whiteSpace: 'pre-wrap' }}>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
pb: 1.5,
|
||||
fontFamily: "'SF Mono','Fira Code',monospace",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
color: 'text.secondary',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
@@ -6,50 +6,129 @@ import { Box, Typography, Stack, Chip } from '@mui/material';
|
||||
import { Wrench, Clock, CheckCircle, XCircle, Ban, Loader2 } from 'lucide-react';
|
||||
import type { ToolCallInfo } from '@renderer/stores/agent-store';
|
||||
import { formatDuration } from '@renderer/lib/formatters';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
interface ToolCallCardProps { toolCall: ToolCallInfo; }
|
||||
interface ToolCallCardProps {
|
||||
toolCall: ToolCallInfo;
|
||||
}
|
||||
|
||||
const STATUS_ICONS = { pending: Clock, executing: Loader2, success: CheckCircle, error: XCircle, blocked: Ban };
|
||||
const STATUS_LABELS = { pending: '等待中', executing: '执行中', success: '成功', error: '失败', blocked: '已阻止' };
|
||||
const STATUS_COLORS: Record<string, string> = { pending: '#fbbf24', executing: '#a855f7', success: '#34d399', error: '#f87171', blocked: '#fb923c' };
|
||||
const CHIP_VARIANTS: Record<string, 'outlined' | 'filled'> = { pending: 'outlined', executing: 'filled', success: 'filled', error: 'filled', blocked: 'outlined' };
|
||||
const STATUS_ICONS = {
|
||||
pending: Clock,
|
||||
executing: Loader2,
|
||||
success: CheckCircle,
|
||||
error: XCircle,
|
||||
blocked: Ban,
|
||||
};
|
||||
// v0.7.4 P3-1: 状态文案出层(t() 必须在渲染时求值 —— i18next 字典注册是异步的)
|
||||
function statusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return t('toolCall.status.pending');
|
||||
case 'executing':
|
||||
return t('toolCall.status.executing');
|
||||
case 'success':
|
||||
return t('toolCall.status.success');
|
||||
case 'error':
|
||||
return t('toolCall.status.error');
|
||||
case 'blocked':
|
||||
return t('toolCall.status.blocked');
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: '#fbbf24',
|
||||
executing: '#a855f7',
|
||||
success: '#34d399',
|
||||
error: '#f87171',
|
||||
blocked: '#fb923c',
|
||||
};
|
||||
const CHIP_VARIANTS: Record<string, 'outlined' | 'filled'> = {
|
||||
pending: 'outlined',
|
||||
executing: 'filled',
|
||||
success: 'filled',
|
||||
error: 'filled',
|
||||
blocked: 'outlined',
|
||||
};
|
||||
|
||||
export function ToolCallCard({ toolCall }: ToolCallCardProps): React.JSX.Element {
|
||||
const Icon = STATUS_ICONS[toolCall.status];
|
||||
const color = STATUS_COLORS[toolCall.status];
|
||||
const label = STATUS_LABELS[toolCall.status];
|
||||
const label = statusLabel(toolCall.status);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 1.5, border: '1px solid', borderColor: 'divider', borderLeft: '3px solid', borderLeftColor: color,
|
||||
bgcolor: 'secondary.main', px: 1.5, py: 1.25, mb: 1,
|
||||
animation: toolCall.status === 'executing' ? 'pulse 2s infinite' : 'fadeInUp 300ms ease-out',
|
||||
borderRadius: 1.5,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderLeft: '3px solid',
|
||||
borderLeftColor: color,
|
||||
bgcolor: 'secondary.main',
|
||||
px: 1.5,
|
||||
py: 1.25,
|
||||
mb: 1,
|
||||
animation:
|
||||
toolCall.status === 'executing' ? 'pulse 2s infinite' : 'fadeInUp 300ms ease-out',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 0.75, alignItems: 'center' }}>
|
||||
<Wrench size={12} style={{ color: '#a855f7' }} />
|
||||
<Typography sx={{ fontSize: 12, fontWeight: 600, fontFamily: 'monospace', color: 'text.primary' }}>{toolCall.name}</Typography>
|
||||
<Typography
|
||||
sx={{ fontSize: 12, fontWeight: 600, fontFamily: 'monospace', color: 'text.primary' }}
|
||||
>
|
||||
{toolCall.name}
|
||||
</Typography>
|
||||
<Chip
|
||||
icon={<Icon size={10} style={{ animation: toolCall.status === 'executing' ? 'spin 1s linear infinite' : 'none' }} />}
|
||||
icon={
|
||||
<Icon
|
||||
size={10}
|
||||
style={{
|
||||
animation: toolCall.status === 'executing' ? 'spin 1s linear infinite' : 'none',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={label}
|
||||
size="small"
|
||||
variant={CHIP_VARIANTS[toolCall.status]}
|
||||
sx={{ height: 18, fontSize: 10, color, borderColor: color, '& .MuiChip-icon': { color } }}
|
||||
/>
|
||||
{toolCall.durationMs != null && (
|
||||
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.secondary' }}>{formatDuration(toolCall.durationMs)}</Typography>
|
||||
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.secondary' }}>
|
||||
{formatDuration(toolCall.durationMs)}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{Object.keys(toolCall.args).length > 0 && (
|
||||
<Box component="pre" sx={{ fontSize: 11, borderRadius: 1, px: 1, py: 0.75, overflowX: 'auto', overflowY: 'auto', bgcolor: 'background.default', color: 'text.secondary', fontFamily: "'SF Mono',monospace", maxHeight: 80, m: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
fontSize: 11,
|
||||
borderRadius: 1,
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
overflowX: 'auto',
|
||||
overflowY: 'auto',
|
||||
bgcolor: 'background.default',
|
||||
color: 'text.secondary',
|
||||
fontFamily: "'SF Mono',monospace",
|
||||
maxHeight: 80,
|
||||
m: 0,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(toolCall.args, null, 2)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{toolCall.status === 'error' && toolCall.error && (
|
||||
<Typography variant="caption" sx={{ mt: 0.75, display: 'block', color: 'error.main' }}>{toolCall.error}</Typography>
|
||||
<Typography variant="caption" sx={{ mt: 0.75, display: 'block', color: 'error.main' }}>
|
||||
{toolCall.error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -7,24 +7,66 @@ import { FileText } from 'lucide-react';
|
||||
import type { ToolCallInfo } from '@renderer/stores/agent-store';
|
||||
import { formatDuration } from '@renderer/lib/formatters';
|
||||
import { toDisplayResult } from '@renderer/lib/tool-result-display';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
interface ToolResultBlockProps { toolCall: ToolCallInfo; }
|
||||
interface ToolResultBlockProps {
|
||||
toolCall: ToolCallInfo;
|
||||
}
|
||||
|
||||
export function ToolResultBlock({ toolCall }: ToolResultBlockProps): React.JSX.Element {
|
||||
if (toolCall.status !== 'success' || toolCall.result == null) return <></>;
|
||||
// 渲染前剥离 dataUrl 等超大 base64 字段,避免 ~6.7MB/张的 dataUrl 进 DOM 导致渲染进程 OOM
|
||||
// store 内原始 result 不变(LLM 看图能力不受影响)
|
||||
const displayResult = toDisplayResult(toolCall.result);
|
||||
const resultStr = typeof displayResult === 'string' ? displayResult : JSON.stringify(displayResult, null, 2);
|
||||
const resultStr =
|
||||
typeof displayResult === 'string' ? displayResult : JSON.stringify(displayResult, null, 2);
|
||||
|
||||
return (
|
||||
<Box sx={{ borderRadius: 1.5, border: '1px solid', borderColor: 'divider', borderLeft: '3px solid', borderLeftColor: 'info.main', bgcolor: 'secondary.main', px: 1.5, py: 1, mb: 1, animation: 'fadeInUp 300ms ease-out' }}>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 1.5,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderLeft: '3px solid',
|
||||
borderLeftColor: 'info.main',
|
||||
bgcolor: 'secondary.main',
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
mb: 1,
|
||||
animation: 'fadeInUp 300ms ease-out',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
|
||||
<FileText size={12} style={{ color: '#22d3ee' }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 500, color: 'text.primary' }}>{toolCall.name} 结果</Typography>
|
||||
{toolCall.durationMs != null && <Typography variant="caption" sx={{ ml: 'auto', color: 'text.secondary' }}>{formatDuration(toolCall.durationMs)}</Typography>}
|
||||
<Typography variant="caption" sx={{ fontWeight: 500, color: 'text.primary' }}>
|
||||
{t('toolResult.title', { name: toolCall.name })}
|
||||
</Typography>
|
||||
{toolCall.durationMs != null && (
|
||||
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.secondary' }}>
|
||||
{formatDuration(toolCall.durationMs)}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
<Box component="pre" sx={{ fontSize: 11, borderRadius: 1, px: 1, py: 0.75, overflowX: 'auto', overflowY: 'auto', bgcolor: 'background.default', color: 'text.secondary', fontFamily: "'SF Mono',monospace", maxHeight: 300, m: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
fontSize: 11,
|
||||
borderRadius: 1,
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
overflowX: 'auto',
|
||||
overflowY: 'auto',
|
||||
bgcolor: 'background.default',
|
||||
color: 'text.secondary',
|
||||
fontFamily: "'SF Mono',monospace",
|
||||
maxHeight: 300,
|
||||
m: 0,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{resultStr}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -12,8 +12,13 @@ import type { ChatMessage, AttachmentInfo } from '@renderer/stores/agent-store';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { formatTime, formatFileSize } from '@renderer/lib/formatters';
|
||||
import { ContextMenu, createContextMenuItems } from '@renderer/components/ContextMenu';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
interface UserMessageProps { message: ChatMessage; }
|
||||
interface UserMessageProps {
|
||||
message: ChatMessage;
|
||||
}
|
||||
|
||||
export function UserMessage({ message }: UserMessageProps): React.JSX.Element {
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -25,35 +30,101 @@ export function UserMessage({ message }: UserMessageProps): React.JSX.Element {
|
||||
const editAndResend = useAgentStore((s) => s.editAndResend);
|
||||
const isStreaming = useAgentStore((s) => s.isStreaming);
|
||||
|
||||
const handleDoubleClick = useCallback(() => { setEditing(true); setEditContent(message.content); }, [message.content]);
|
||||
const handleEditSave = useCallback(() => {
|
||||
if (editContent.trim() && editContent !== message.content) updateMessage(message.id, { content: editContent.trim() });
|
||||
setEditing(false);
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
setEditing(true);
|
||||
setEditContent(message.content);
|
||||
}, [message.content]);
|
||||
const handleEditSave = useCallback(async () => {
|
||||
if (!editContent.trim()) {
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
if (editContent.trim() !== message.content) {
|
||||
// v0.7.4 P3-10 修正: 先 IPC 落库,成功后再同步前端 store;失败保留编辑态 + toast。
|
||||
// 旧实现先改前端 store 再 IPC(失败时界面已生效但重载丢失,用户无感知)。
|
||||
const currentSessionId = useAgentStore.getState().currentSessionId;
|
||||
try {
|
||||
if (currentSessionId && window.metona?.sessions?.updateMessageContent) {
|
||||
const r = await window.metona.sessions.updateMessageContent(
|
||||
currentSessionId,
|
||||
message.id,
|
||||
editContent.trim(),
|
||||
);
|
||||
if (r?.success === false) {
|
||||
throw new Error(r.error ?? '保存失败');
|
||||
}
|
||||
}
|
||||
updateMessage(message.id, { content: editContent.trim() });
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
console.error('[UserMessage] save failed:', err);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`保存失败:${(err as Error).message}`))
|
||||
.catch(() => {});
|
||||
// 保留编辑态,让用户重试或复制内容
|
||||
}
|
||||
} else {
|
||||
setEditing(false);
|
||||
}
|
||||
}, [editContent, message.content, message.id, updateMessage]);
|
||||
const handleEditResend = useCallback(() => {
|
||||
if (!editContent.trim()) return;
|
||||
setEditing(false);
|
||||
void editAndResend(message.id, editContent);
|
||||
}, [editContent, editAndResend, message.id]);
|
||||
const handleEditKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { setEditing(false); setEditContent(message.content); }
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !e.shiftKey) handleEditResend();
|
||||
}, [handleEditResend, message.content]);
|
||||
useEffect(() => { if (editing) textareaRef.current?.focus(); }, [editing]);
|
||||
const handleEditKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setEditing(false);
|
||||
setEditContent(message.content);
|
||||
}
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !e.shiftKey) handleEditResend();
|
||||
},
|
||||
[handleEditResend, message.content],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (editing) textareaRef.current?.focus();
|
||||
}, [editing]);
|
||||
|
||||
const hasAttachments = message.attachments && message.attachments.length > 0;
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={1.5} sx={{ animation: 'fadeInUp 300ms ease-out' }}>
|
||||
<Avatar sx={{ width: 32, height: 32, bgcolor: 'action.hover', color: 'primary.main', flexShrink: 0 }}><User size={16} /></Avatar>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: 'action.hover',
|
||||
color: 'primary.main',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<User size={16} />
|
||||
</Avatar>
|
||||
<Box
|
||||
sx={{ maxWidth: 768, borderRadius: 3, px: 2, py: 1.5, cursor: 'default', bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider' }}
|
||||
sx={{
|
||||
maxWidth: 768,
|
||||
borderRadius: 3,
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
cursor: 'default',
|
||||
bgcolor: 'background.paper',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenu={(e: React.MouseEvent) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }}
|
||||
onContextMenu={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
>
|
||||
{/* 附件预览区 */}
|
||||
{hasAttachments && (
|
||||
<Stack direction="row" spacing={1} sx={{ mb: message.content ? 1.5 : 0, flexWrap: 'wrap', gap: 1 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ mb: message.content ? 1.5 : 0, flexWrap: 'wrap', gap: 1 }}
|
||||
>
|
||||
{message.attachments!.map((att) => (
|
||||
<AttachmentPreview key={att.id} attachment={att} />
|
||||
))}
|
||||
@@ -63,21 +134,71 @@ export function UserMessage({ message }: UserMessageProps): React.JSX.Element {
|
||||
{/* 文本内容 */}
|
||||
{editing ? (
|
||||
<>
|
||||
<TextareaAutosize ref={textareaRef} value={editContent} onChange={(e) => setEditContent(e.target.value)} onKeyDown={handleEditKeyDown} minRows={3} style={{ width: '100%', background: 'transparent', border: 'none', outline: 'none', color: 'inherit', fontSize: 13, lineHeight: 1.7, resize: 'none', fontFamily: 'inherit' }} />
|
||||
<TextareaAutosize
|
||||
ref={textareaRef}
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
minRows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: 'inherit',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.7,
|
||||
resize: 'none',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1, alignItems: 'center' }}>
|
||||
<Button size="small" variant="outlined" onClick={() => { setEditing(false); setEditContent(message.content); }}>取消</Button>
|
||||
<Button size="small" variant="outlined" onClick={handleEditSave}>仅保存</Button>
|
||||
<Button size="small" variant="contained" onClick={handleEditResend} disabled={isStreaming || !editContent.trim()}>保存并重发</Button>
|
||||
<Typography variant="caption" sx={{ fontSize: 10, color: 'text.disabled' }}>重发将删除此消息之后的所有消息 · Ctrl+Enter</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setEditContent(message.content);
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" onClick={handleEditSave}>
|
||||
{t('userMessage.saveOnly')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={handleEditResend}
|
||||
disabled={isStreaming || !editContent.trim()}
|
||||
>
|
||||
{t('userMessage.saveAndResend')}
|
||||
</Button>
|
||||
<Typography variant="caption" sx={{ fontSize: 10, color: 'text.disabled' }}>
|
||||
{t('userMessage.resendHint')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</>
|
||||
) : message.content ? (
|
||||
<Typography sx={{ whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.7, color: 'text.primary' }}>{message.content}</Typography>
|
||||
<Typography
|
||||
sx={{ whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.7, color: 'text.primary' }}
|
||||
>
|
||||
{message.content}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
<Typography variant="caption" sx={{ mt: 0.75, display: 'block', color: 'text.disabled' }}>{formatTime(message.timestamp)}</Typography>
|
||||
<Typography variant="caption" sx={{ mt: 0.75, display: 'block', color: 'text.disabled' }}>
|
||||
{formatTime(message.timestamp)}
|
||||
</Typography>
|
||||
</Box>
|
||||
{contextMenu && <ContextMenu x={contextMenu.x} y={contextMenu.y} items={createContextMenuItems('message', { content: message.content, role: 'user' })} onClose={() => setContextMenu(null)} />}
|
||||
{contextMenu && (
|
||||
<ContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
items={createContextMenuItems('message', { content: message.content, role: 'user' })}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -89,10 +210,46 @@ function AttachmentPreview({ attachment }: { attachment: AttachmentInfo }) {
|
||||
|
||||
if (type === 'image' && preview) {
|
||||
return (
|
||||
<Box sx={{ position: 'relative', width: 120, height: 120, borderRadius: 2, overflow: 'hidden', border: '1px solid', borderColor: 'divider', flexShrink: 0 }}>
|
||||
<Box component="img" src={preview} alt={name} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
<Box sx={{ position: 'absolute', bottom: 0, left: 0, right: 0, bgcolor: 'rgba(0,0,0,0.6)', px: 1, py: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#fff', fontSize: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={preview}
|
||||
alt={name}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bgcolor: 'rgba(0,0,0,0.6)',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#fff',
|
||||
fontSize: 10,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -103,11 +260,39 @@ function AttachmentPreview({ attachment }: { attachment: AttachmentInfo }) {
|
||||
// 文本文件
|
||||
if (type === 'text') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, borderRadius: 1.5, bgcolor: 'action.hover', border: '1px solid', borderColor: 'divider', maxWidth: 200, flexShrink: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'action.hover',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
maxWidth: 200,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<FileText size={20} style={{ color: '#22d3ee', flexShrink: 0 }} />
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 11, color: 'text.primary' }}>{name}</Typography>
|
||||
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.secondary' }}>{formatFileSize(size)}</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: 11,
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.secondary' }}>
|
||||
{formatFileSize(size)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
@@ -115,11 +300,39 @@ function AttachmentPreview({ attachment }: { attachment: AttachmentInfo }) {
|
||||
|
||||
// 其他文件
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, borderRadius: 1.5, bgcolor: 'action.hover', border: '1px solid', borderColor: 'divider', maxWidth: 200, flexShrink: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'action.hover',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
maxWidth: 200,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<ImageIcon size={20} style={{ color: '#8b8fa7', flexShrink: 0 }} />
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 11, color: 'text.primary' }}>{name}</Typography>
|
||||
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.secondary' }}>{formatFileSize(size)}</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: 11,
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.secondary' }}>
|
||||
{formatFileSize(size)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ChatInput 组件测试(v0.7.4 引入 jsdom 组件测试)
|
||||
*
|
||||
* 覆盖:输入渲染、发送调用、IME 合成回车不发送(P1-7)、
|
||||
* Ctrl+Enter 发送、Enter 发送、/clear 命令、附件按钮存在。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { useUIStore } from '@renderer/stores/ui-store';
|
||||
import { useSessionStore } from '@renderer/stores/session-store';
|
||||
import { ChatInput } from '../ChatInput';
|
||||
|
||||
function resetStore(): void {
|
||||
useAgentStore.setState({
|
||||
currentSessionId: 's_test',
|
||||
messages: [],
|
||||
isStreaming: false,
|
||||
configLoaded: true,
|
||||
toolsReady: true,
|
||||
agentStatus: 'idle',
|
||||
currentIteration: 0,
|
||||
tokenUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
lastInputTokens: 0,
|
||||
lastCompressedSaved: 0,
|
||||
},
|
||||
traceSteps: [],
|
||||
currentRunId: null,
|
||||
sessionRunStates: {},
|
||||
modelVisionCaps: null,
|
||||
} as never);
|
||||
useUIStore.setState({
|
||||
settingsOpen: false,
|
||||
detailTab: 'trace',
|
||||
detailVisible: true,
|
||||
sidebarVisible: true,
|
||||
focusMode: false,
|
||||
preFocusSidebarVisible: true,
|
||||
preFocusDetailVisible: true,
|
||||
} as never);
|
||||
useSessionStore.setState({ currentSessionId: null, sessions: [], searchQuery: '' });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetStore();
|
||||
});
|
||||
|
||||
describe('ChatInput — 输入与发送', () => {
|
||||
it('渲染输入框与发送按钮', () => {
|
||||
render(<ChatInput />);
|
||||
expect(screen.getByPlaceholderText(/输入/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('输入文字后 Enter 触发 sendMessage', () => {
|
||||
const sendMessage = vi.fn();
|
||||
useAgentStore.setState({ sendMessage: sendMessage } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '你好' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', shiftKey: false });
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith('你好', undefined, undefined);
|
||||
});
|
||||
|
||||
it('空输入 Enter 不发送', () => {
|
||||
const sendMessage = vi.fn();
|
||||
useAgentStore.setState({ sendMessage: sendMessage } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Ctrl+Enter 触发发送', () => {
|
||||
const sendMessage = vi.fn();
|
||||
useAgentStore.setState({ sendMessage: sendMessage } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '测试' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', ctrlKey: true });
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith('测试', undefined, undefined);
|
||||
});
|
||||
|
||||
it('Shift+Enter 换行不发送', () => {
|
||||
const sendMessage = vi.fn();
|
||||
useAgentStore.setState({ sendMessage: sendMessage } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '多行' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true });
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('流式中 Enter 不发送', () => {
|
||||
const sendMessage = vi.fn();
|
||||
useAgentStore.setState({ sendMessage: sendMessage } as never);
|
||||
useAgentStore.setState({ isStreaming: true } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '内容' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatInput — IME 保护(P1-7)', () => {
|
||||
// fireEvent.keyDown 的 nativeEvent 无法可靠携带 isComposing;
|
||||
// 用真实 KeyboardEvent + dispatchEvent 模拟 IME 合成事件。
|
||||
function dispatchEnterWithComposing(
|
||||
input: HTMLElement,
|
||||
composing: boolean,
|
||||
ctrlKey = false,
|
||||
): void {
|
||||
const ev = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
ctrlKey,
|
||||
});
|
||||
Object.defineProperty(ev, 'isComposing', { value: composing });
|
||||
input.dispatchEvent(ev);
|
||||
}
|
||||
|
||||
it('IME 合成中 Enter 不发送(isComposing=true)', () => {
|
||||
const sendMessage = vi.fn();
|
||||
useAgentStore.setState({ sendMessage: sendMessage } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: 'nihao' } });
|
||||
dispatchEnterWithComposing(input, true);
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('IME 合成中 Ctrl+Enter 也不发送(P1-7 修正)', () => {
|
||||
const sendMessage = vi.fn();
|
||||
useAgentStore.setState({ sendMessage: sendMessage } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: 'nihao' } });
|
||||
dispatchEnterWithComposing(input, true, true);
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('IME 合成结束(isComposing=false)Enter 正常发送', () => {
|
||||
const sendMessage = vi.fn();
|
||||
useAgentStore.setState({ sendMessage: sendMessage } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '你好' } });
|
||||
dispatchEnterWithComposing(input, false);
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith('你好', undefined, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatInput — / 命令', () => {
|
||||
it('输入 /clear 清空会话', async () => {
|
||||
const clearMessages = vi.fn().mockResolvedValue(undefined);
|
||||
useAgentStore.setState({ clearMessages: clearMessages } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '/clear' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(clearMessages).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('输入 / 显示斜杠菜单', () => {
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '/' } });
|
||||
// 斜杠菜单命令(clear/export/tool/memory)
|
||||
expect(screen.getAllByText(/^\//).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatInput — 斜杠菜单(v0.7.4 P3-7)', () => {
|
||||
it('输入 / 显示全部 4 个命令', () => {
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '/' } });
|
||||
expect(screen.getByText('/clear')).toBeInTheDocument();
|
||||
expect(screen.getByText('/export')).toBeInTheDocument();
|
||||
expect(screen.getByText('/tool')).toBeInTheDocument();
|
||||
expect(screen.getByText('/memory')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('输入 /cl 过滤菜单只显示 /clear', () => {
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '/cl' } });
|
||||
expect(screen.getByText('/clear')).toBeInTheDocument();
|
||||
expect(screen.queryByText('/export')).toBeNull();
|
||||
});
|
||||
|
||||
it('输入普通文本隐藏斜杠菜单', () => {
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '/' } });
|
||||
expect(screen.getByText('/clear')).toBeInTheDocument();
|
||||
fireEvent.change(input, { target: { value: 'hello' } });
|
||||
expect(screen.queryByText('/clear')).toBeNull();
|
||||
});
|
||||
|
||||
it('点击菜单项插入命令文本并聚焦输入框', () => {
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '/' } });
|
||||
fireEvent.click(screen.getByText('/memory'));
|
||||
expect((input as HTMLTextAreaElement).value).toBe('/memory ');
|
||||
});
|
||||
|
||||
it('菜单显示时按 Escape 关闭', () => {
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '/' } });
|
||||
expect(screen.getByText('/clear')).toBeInTheDocument();
|
||||
fireEvent.keyDown(input, { key: 'Escape' });
|
||||
expect(screen.queryByText('/clear')).toBeNull();
|
||||
});
|
||||
|
||||
it('输入 /tool 执行打开设置面板', () => {
|
||||
useUIStore.setState({ settingsOpen: false } as never);
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '/tool' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(useUIStore.getState().settingsOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('输入 /memory 切换到详情面板 Memory 标签', () => {
|
||||
useUIStore.setState({ detailTab: 'trace', detailVisible: false, focusMode: false } as never);
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '/memory' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(useUIStore.getState().detailTab).toBe('memory');
|
||||
expect(useUIStore.getState().detailVisible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatInput — 快捷键与按钮状态', () => {
|
||||
it('Ctrl+Shift+Enter 插入换行而不发送', () => {
|
||||
const sendMessage = vi.fn();
|
||||
useAgentStore.setState({ sendMessage: sendMessage } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '第一行' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', ctrlKey: true, shiftKey: true });
|
||||
expect((input as HTMLTextAreaElement).value).toBe('第一行\n');
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('空输入时发送按钮禁用', () => {
|
||||
render(<ChatInput />);
|
||||
const sendBtn = screen.getByText('发送').closest('button') as HTMLButtonElement;
|
||||
expect(sendBtn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('有内容时发送按钮可用', () => {
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: 'hello' } });
|
||||
const sendBtn = screen.getByText('发送').closest('button') as HTMLButtonElement;
|
||||
expect(sendBtn.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('点击发送按钮调用 sendMessage 并清空输入', () => {
|
||||
const sendMessage = vi.fn();
|
||||
useAgentStore.setState({ sendMessage: sendMessage } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '按钮发送' } });
|
||||
fireEvent.click(screen.getByText('发送').closest('button') as HTMLElement);
|
||||
expect(sendMessage).toHaveBeenCalledWith('按钮发送', undefined, undefined);
|
||||
expect((input as HTMLTextAreaElement).value).toBe('');
|
||||
});
|
||||
|
||||
it('流式中显示中断按钮,点击调用 abort', () => {
|
||||
const abort = vi.fn();
|
||||
useAgentStore.setState({ abort: abort } as never);
|
||||
useAgentStore.setState({ isStreaming: true } as never);
|
||||
|
||||
render(<ChatInput />);
|
||||
const abortBtn = screen.getByText('中断').closest('button') as HTMLElement;
|
||||
expect(abortBtn).not.toBeNull();
|
||||
fireEvent.click(abortBtn);
|
||||
expect(abort).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('流式中发送按钮被替换为中断按钮', () => {
|
||||
useAgentStore.setState({ isStreaming: true } as never);
|
||||
render(<ChatInput />);
|
||||
expect(screen.getByText('中断')).toBeInTheDocument();
|
||||
expect(screen.queryByText('发送')).toBeNull();
|
||||
});
|
||||
|
||||
it('configLoaded=false 时输入框禁用且占位符为"正在加载配置..."', () => {
|
||||
useAgentStore.setState({ configLoaded: false, toolsReady: true } as never);
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText('正在加载配置...');
|
||||
expect((input as HTMLTextAreaElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('toolsReady=false 时占位符为"工具加载中..."', () => {
|
||||
useAgentStore.setState({ configLoaded: true, toolsReady: false } as never);
|
||||
render(<ChatInput />);
|
||||
expect(screen.getByPlaceholderText('工具加载中...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('configLoaded=false 时即使有内容发送按钮也禁用', () => {
|
||||
useAgentStore.setState({ configLoaded: false, toolsReady: true } as never);
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/正在加载配置/);
|
||||
fireEvent.change(input, { target: { value: '内容' } });
|
||||
const sendBtn = screen.getByText('发送').closest('button') as HTMLButtonElement;
|
||||
expect(sendBtn.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatInput — 附件入口', () => {
|
||||
it('渲染附件按钮(回形针图标)', () => {
|
||||
render(<ChatInput />);
|
||||
const attachBtn = document.querySelector('.lucide-paperclip')?.closest('button');
|
||||
expect(attachBtn).not.toBeNull();
|
||||
});
|
||||
|
||||
it('存在隐藏的文件输入框(multiple)', () => {
|
||||
render(<ChatInput />);
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
expect(fileInput).not.toBeNull();
|
||||
expect(fileInput.multiple).toBe(true);
|
||||
expect(fileInput.style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('点击附件按钮触发隐藏文件输入框 click', () => {
|
||||
render(<ChatInput />);
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const clickSpy = vi.spyOn(fileInput, 'click');
|
||||
const attachBtn = document.querySelector('.lucide-paperclip')?.closest('button') as HTMLElement;
|
||||
fireEvent.click(attachBtn);
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatInput — 草稿自动保存', () => {
|
||||
it('输入内容后写入 sessionStorage 草稿', () => {
|
||||
useSessionStore.setState({ currentSessionId: 's_test' });
|
||||
sessionStorage.removeItem('draft-s_test');
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '草稿内容' } });
|
||||
expect(sessionStorage.getItem('draft-s_test')).toBe('草稿内容');
|
||||
});
|
||||
|
||||
it('挂载时从 sessionStorage 恢复草稿', () => {
|
||||
useSessionStore.setState({ currentSessionId: 's_test' });
|
||||
sessionStorage.setItem('draft-s_test', '恢复的草稿');
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/) as HTMLTextAreaElement;
|
||||
expect(input.value).toBe('恢复的草稿');
|
||||
});
|
||||
|
||||
it('发送后清除草稿', () => {
|
||||
useSessionStore.setState({ currentSessionId: 's_test' });
|
||||
sessionStorage.setItem('draft-s_test', '待清除');
|
||||
const sendMessage = vi.fn();
|
||||
useAgentStore.setState({ sendMessage: sendMessage } as never);
|
||||
render(<ChatInput />);
|
||||
const input = screen.getByPlaceholderText(/输入/);
|
||||
fireEvent.change(input, { target: { value: '内容' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(sendMessage).toHaveBeenCalled();
|
||||
expect(sessionStorage.getItem('draft-s_test')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,475 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* MessageList / MessageItem / AssistantMessage 组件测试
|
||||
*
|
||||
* 覆盖:
|
||||
* - MessageList 空列表 / 有消息渲染 / 流式跟随(StreamingIndicator 显隐)
|
||||
* - MessageItem 消息路由(user / assistant / system / tool)
|
||||
* - AssistantMessage 思考块、工具卡片、Markdown 渲染(代码块/列表)、
|
||||
* 流式纯文本渲染、时间戳、右键菜单、复制按钮
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MessageList } from '../MessageList';
|
||||
import { MessageItem } from '../MessageItem';
|
||||
import { AssistantMessage } from '../AssistantMessage';
|
||||
import { useAgentStore, type ChatMessage, type ToolCallInfo } from '@renderer/stores/agent-store';
|
||||
|
||||
// react-virtuoso 在 jsdom 下无真实测量能力,mock 为渲染全部条目的简单列表
|
||||
vi.mock('react-virtuoso', async () => {
|
||||
const React = await import('react');
|
||||
return {
|
||||
Virtuoso: (props: {
|
||||
data: ChatMessage[];
|
||||
itemContent: (index: number, msg: ChatMessage) => React.ReactNode;
|
||||
components?: { Footer?: React.ComponentType };
|
||||
}) =>
|
||||
React.createElement(
|
||||
React.Fragment,
|
||||
null,
|
||||
props.data.map((msg, index) =>
|
||||
React.createElement(React.Fragment, { key: msg.id }, props.itemContent(index, msg)),
|
||||
),
|
||||
props.components?.Footer ? React.createElement(props.components.Footer) : null,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const agentInitial = useAgentStore.getState();
|
||||
|
||||
function makeMessage(overrides: Partial<ChatMessage> & { id: string }): ChatMessage {
|
||||
return {
|
||||
role: 'user',
|
||||
content: '',
|
||||
timestamp: 1730000000000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeToolCall(overrides: Partial<ToolCallInfo> = {}): ToolCallInfo {
|
||||
return {
|
||||
id: 'tc_1',
|
||||
name: 'read_file',
|
||||
args: { path: '/tmp/a.txt' },
|
||||
status: 'success',
|
||||
durationMs: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAgentStore.setState(agentInitial, true);
|
||||
});
|
||||
|
||||
describe('MessageList — 空列表', () => {
|
||||
it('无消息且非流式时渲染空状态(标题/副标题/提示)', () => {
|
||||
useAgentStore.setState({ messages: [], isStreaming: false });
|
||||
render(<MessageList />);
|
||||
expect(screen.getByText('MetonaAI Desktop')).toBeInTheDocument();
|
||||
expect(screen.getByText('生产级通用 AI Agent 智能体桌面应用')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Agent 就绪/)).toBeInTheDocument();
|
||||
// logo
|
||||
expect(document.querySelector('img[alt="Metona"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('空列表时消息区无 role=log', () => {
|
||||
useAgentStore.setState({ messages: [], isStreaming: false });
|
||||
render(<MessageList />);
|
||||
expect(document.querySelector('[role="log"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessageList — 有消息渲染', () => {
|
||||
it('渲染 user 与 assistant 消息内容', () => {
|
||||
useAgentStore.setState({
|
||||
messages: [
|
||||
makeMessage({ id: 'm1', role: 'user', content: '你好' }),
|
||||
makeMessage({ id: 'm2', role: 'assistant', content: '我是助手' }),
|
||||
],
|
||||
isStreaming: false,
|
||||
currentSessionId: 's1',
|
||||
});
|
||||
render(<MessageList />);
|
||||
expect(screen.getByText('你好')).toBeInTheDocument();
|
||||
expect(screen.getByText('我是助手')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('消息区为 live region', () => {
|
||||
useAgentStore.setState({
|
||||
messages: [makeMessage({ id: 'm1', role: 'user', content: 'x' })],
|
||||
isStreaming: false,
|
||||
currentSessionId: 's1',
|
||||
});
|
||||
render(<MessageList />);
|
||||
const log = document.querySelector('[role="log"]');
|
||||
expect(log).toBeInTheDocument();
|
||||
expect(log?.getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
|
||||
it('渲染 system 消息', () => {
|
||||
useAgentStore.setState({
|
||||
messages: [makeMessage({ id: 'm1', role: 'system', content: '系统提示' })],
|
||||
isStreaming: false,
|
||||
});
|
||||
render(<MessageList />);
|
||||
expect(screen.getByText('系统提示')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessageList — 流式', () => {
|
||||
it('流式且最后一条是 user 消息时显示 StreamingIndicator', () => {
|
||||
useAgentStore.setState({
|
||||
messages: [makeMessage({ id: 'm1', role: 'user', content: '请回答' })],
|
||||
isStreaming: true,
|
||||
});
|
||||
render(<MessageList />);
|
||||
expect(screen.getByText('正在连接 AI...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('流式且最后一条是 assistant 消息时隐藏 StreamingIndicator', () => {
|
||||
useAgentStore.setState({
|
||||
messages: [
|
||||
makeMessage({ id: 'm1', role: 'user', content: '请回答' }),
|
||||
makeMessage({ id: 'm2', role: 'assistant', content: '回复中' }),
|
||||
],
|
||||
isStreaming: true,
|
||||
});
|
||||
render(<MessageList />);
|
||||
expect(screen.queryByText('正在连接 AI...')).toBeNull();
|
||||
});
|
||||
|
||||
it('流式时最后一条 assistant 消息使用纯文本渲染(无 markdown 解析)', () => {
|
||||
useAgentStore.setState({
|
||||
messages: [
|
||||
makeMessage({ id: 'm1', role: 'user', content: 'q' }),
|
||||
makeMessage({ id: 'm2', role: 'assistant', content: '# 标题\n\n正文' }),
|
||||
],
|
||||
isStreaming: true,
|
||||
agentStatus: 'thinking',
|
||||
});
|
||||
const { container } = render(<MessageList />);
|
||||
// prose-streaming 用于流式纯文本
|
||||
expect(container.querySelector('.prose-streaming')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('流式时非最后一条消息仍正常渲染 markdown', () => {
|
||||
useAgentStore.setState({
|
||||
messages: [
|
||||
makeMessage({ id: 'm1', role: 'user', content: 'q' }),
|
||||
makeMessage({ id: 'm2', role: 'assistant', content: '**历史回复**' }),
|
||||
makeMessage({ id: 'm3', role: 'assistant', content: '新内容' }),
|
||||
],
|
||||
isStreaming: true,
|
||||
});
|
||||
render(<MessageList />);
|
||||
expect(screen.getByText('历史回复')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessageItem — 消息路由', () => {
|
||||
it('user 消息渲染 UserMessage', () => {
|
||||
const { container } = render(
|
||||
<MessageItem message={makeMessage({ id: 'm1', role: 'user', content: '用户内容' })} />,
|
||||
);
|
||||
expect(screen.getByText('用户内容')).toBeInTheDocument();
|
||||
expect(container.querySelector('.lucide-user')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('assistant 消息渲染 AssistantMessage', () => {
|
||||
render(
|
||||
<MessageItem message={makeMessage({ id: 'm1', role: 'assistant', content: '助手内容' })} />,
|
||||
);
|
||||
expect(screen.getByText('助手内容')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('system 消息渲染 SystemMessage(居中胶囊)', () => {
|
||||
const { container } = render(
|
||||
<MessageItem message={makeMessage({ id: 'm1', role: 'system', content: '公告' })} />,
|
||||
);
|
||||
expect(screen.getByText('公告')).toBeInTheDocument();
|
||||
// system 无头像
|
||||
expect(container.querySelector('.lucide-bot')).toBeNull();
|
||||
});
|
||||
|
||||
it('tool 消息不渲染(返回 null)', () => {
|
||||
const { container } = render(
|
||||
<MessageItem message={makeMessage({ id: 'm1', role: 'tool', content: 'tool result' })} />,
|
||||
);
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('isStreaming 且 isLast 时向 AssistantMessage 传递流式状态(使用纯文本渲染)', () => {
|
||||
render(
|
||||
<MessageItem
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: '流' })}
|
||||
isLast
|
||||
isStreaming
|
||||
/>,
|
||||
);
|
||||
// 流式路径使用 prose-streaming 纯文本 pre(非 markdown 解析)
|
||||
expect(document.querySelector('pre.prose-streaming')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssistantMessage — 内容与 Markdown 渲染', () => {
|
||||
it('渲染普通文本内容', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: '普通回复' })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('普通回复')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染 Markdown 标题与加粗', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: '# 大标题\n\n**加粗** 文本' })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('heading', { level: 1, name: '大标题' })).toBeInTheDocument();
|
||||
expect(screen.getByText('加粗')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染 Markdown 无序列表', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: '- 项目一\n- 项目二' })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('项目一')).toBeInTheDocument();
|
||||
expect(screen.getByText('项目二')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染 Markdown 有序列表', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: '1. 第一步\n2. 第二步' })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('第一步')).toBeInTheDocument();
|
||||
expect(screen.getByText('第二步')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染代码块:显示语言标签与代码内容', () => {
|
||||
const { container } = render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: '```ts\nconst x = 1;\n```' })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('ts')).toBeInTheDocument();
|
||||
expect(container.querySelector('pre')?.textContent).toContain('const x = 1;');
|
||||
});
|
||||
|
||||
it('行内代码不带语言标签时渲染为普通 code', () => {
|
||||
const { container } = render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: '使用 `inline()` 调用' })}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector('code')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('复制按钮点击后复制代码并切换为已复制图标', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
|
||||
try {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: '```js\nlet a = 1;\n```' })}
|
||||
/>,
|
||||
);
|
||||
const copyBtn = document.querySelector('.copy-btn') as HTMLElement;
|
||||
expect(copyBtn).not.toBeNull();
|
||||
// 初始为复制图标
|
||||
expect(copyBtn.querySelector('.lucide-copy')).not.toBeNull();
|
||||
fireEvent.click(copyBtn);
|
||||
expect(writeText).toHaveBeenCalledWith('let a = 1;');
|
||||
// 点击后切换为已复制图标(Tooltip 文本需 hover 才渲染,用图标断言)
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.copy-btn .lucide-check')).not.toBeNull();
|
||||
});
|
||||
} finally {
|
||||
// v0.7.4 回归修复: 还原 clipboard 桩,避免跨用例污染
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('渲染链接文本(GFM autolink 不生成跳转外链 DOM 问题)', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
content: '访问 [官网](https://example.com)',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const link = screen.getByRole('link', { name: '官网' });
|
||||
expect(link.getAttribute('href')).toBe('https://example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssistantMessage — 思考过程', () => {
|
||||
it('有 reasoningContent 时显示思考标签', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
content: '回复',
|
||||
reasoningContent: '推理中...',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('思考过程')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无 reasoningContent 时不渲染思考区', () => {
|
||||
const { container } = render(
|
||||
<AssistantMessage message={makeMessage({ id: 'm1', role: 'assistant', content: '回复' })} />,
|
||||
);
|
||||
expect(container.textContent).not.toContain('思考过程');
|
||||
});
|
||||
|
||||
it('流式且 agentStatus=thinking 时思考标签变为"正在思考..."且可展开思考内容', () => {
|
||||
useAgentStore.setState({ agentStatus: 'thinking' });
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
reasoningContent: '正在思考的内容',
|
||||
})}
|
||||
isStreaming
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('正在思考...')).toBeInTheDocument();
|
||||
expect(screen.getByText('正在思考的内容')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssistantMessage — 工具调用', () => {
|
||||
it('有 toolCalls 时显示工具调用标签与工具名', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
content: '回复',
|
||||
toolCalls: [makeToolCall({ name: 'list_directory' })],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
// 标签带 🔧 emoji 前缀,用正则匹配
|
||||
expect(screen.getByText(/工具调用/)).toBeInTheDocument();
|
||||
expect(screen.getByText('list_directory')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('工具成功且有结果时渲染 ToolResultBlock', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
content: '回复',
|
||||
toolCalls: [makeToolCall({ status: 'success', result: { ok: true } })],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/read_file 结果/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('工具失败时不渲染结果块', () => {
|
||||
const { container } = render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
content: '回复',
|
||||
toolCalls: [makeToolCall({ status: 'error', error: 'boom' })],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent).toContain('boom');
|
||||
expect(screen.queryByText(/read_file 结果/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssistantMessage — 流式状态与时间戳', () => {
|
||||
it('流式时隐藏时间戳', () => {
|
||||
const { container } = render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: 'x' })}
|
||||
isStreaming
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent).not.toMatch(/\d{4}-\d{2}-\d{2}/);
|
||||
});
|
||||
|
||||
it('非流式时显示格式化时间戳', () => {
|
||||
const { container } = render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
content: 'x',
|
||||
timestamp: 1730000000000,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent).toMatch(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
it('流式且有内容时使用纯文本 pre 并在其后渲染打字光标', () => {
|
||||
const { container } = render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: '正在输出' })}
|
||||
isStreaming
|
||||
/>,
|
||||
);
|
||||
// 流式路径:内容渲染为 prose-streaming pre
|
||||
const pre = container.querySelector('pre.prose-streaming');
|
||||
expect(pre).not.toBeNull();
|
||||
expect(pre?.textContent).toBe('正在输出');
|
||||
// 打字光标:紧随 pre 的空 span
|
||||
expect(pre?.nextElementSibling?.tagName).toBe('SPAN');
|
||||
});
|
||||
|
||||
it('流式但无内容时显示"正在生成回复..."', () => {
|
||||
useAgentStore.setState({ agentStatus: 'idle' });
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: '' })}
|
||||
isStreaming
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('正在生成回复...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssistantMessage — 右键菜单', () => {
|
||||
it('右键弹出菜单,包含复制与引用回复', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={makeMessage({ id: 'm1', role: 'assistant', content: '可复制内容' })}
|
||||
/>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByText('可复制内容'));
|
||||
expect(screen.getByText('复制')).toBeInTheDocument();
|
||||
expect(screen.getByText('引用回复')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('assistant 消息右键菜单包含"重新生成"', () => {
|
||||
render(
|
||||
<AssistantMessage message={makeMessage({ id: 'm1', role: 'assistant', content: '内容' })} />,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByText('内容'));
|
||||
expect(screen.getByText('重新生成')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* StreamingIndicator 组件测试
|
||||
*
|
||||
* 覆盖:非流式不渲染、流式且无消息渲染、流式最后一条 user 消息渲染、
|
||||
* 流式最后一条 assistant 消息不渲染。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { StreamingIndicator } from '../StreamingIndicator';
|
||||
import { useAgentStore, type ChatMessage } from '@renderer/stores/agent-store';
|
||||
|
||||
const agentInitial = useAgentStore.getState();
|
||||
|
||||
function makeMsg(overrides: Partial<ChatMessage> & { id: string }): ChatMessage {
|
||||
return { role: 'user', content: '', timestamp: 1730000000000, ...overrides };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAgentStore.setState(agentInitial, true);
|
||||
});
|
||||
|
||||
describe('StreamingIndicator — 显示/隐藏条件', () => {
|
||||
it('非流式时不渲染', () => {
|
||||
useAgentStore.setState({ isStreaming: false, messages: [] });
|
||||
render(<StreamingIndicator />);
|
||||
expect(screen.queryByText('正在连接 AI...')).toBeNull();
|
||||
});
|
||||
|
||||
it('流式且无消息时渲染指示器', () => {
|
||||
useAgentStore.setState({ isStreaming: true, messages: [] });
|
||||
render(<StreamingIndicator />);
|
||||
expect(screen.getByText('正在连接 AI...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('流式且最后一条为 user 消息时渲染指示器', () => {
|
||||
useAgentStore.setState({
|
||||
isStreaming: true,
|
||||
messages: [makeMsg({ id: 'm1', role: 'user', content: 'q' })],
|
||||
});
|
||||
render(<StreamingIndicator />);
|
||||
expect(screen.getByText('正在连接 AI...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('流式且最后一条为 assistant 消息时不渲染(内容已在卡片内展示)', () => {
|
||||
useAgentStore.setState({
|
||||
isStreaming: true,
|
||||
messages: [makeMsg({ id: 'm2', role: 'assistant', content: '回答' })],
|
||||
});
|
||||
render(<StreamingIndicator />);
|
||||
expect(screen.queryByText('正在连接 AI...')).toBeNull();
|
||||
});
|
||||
|
||||
it('流式但最后一条为 system 消息时渲染(非 assistant)', () => {
|
||||
useAgentStore.setState({
|
||||
isStreaming: true,
|
||||
messages: [makeMsg({ id: 'm3', role: 'system', content: '通知' })],
|
||||
});
|
||||
render(<StreamingIndicator />);
|
||||
expect(screen.getByText('正在连接 AI...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ThoughtBlock 组件测试
|
||||
*
|
||||
* 覆盖:默认折叠、defaultExpanded 展开、点击切换、空内容不渲染、
|
||||
* 展开状态随 defaultExpanded 变化同步。
|
||||
*
|
||||
* 注:MUI Collapse 折叠时内容仍在 DOM 中(height 0),状态判定用
|
||||
* chevron 图标类名(chevron-right=折叠 / chevron-down=展开)。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThoughtBlock } from '../ThoughtBlock';
|
||||
|
||||
describe('ThoughtBlock — 折叠/展开', () => {
|
||||
it('默认折叠:显示折叠指示图标,无展开指示图标', () => {
|
||||
const { container } = render(<ThoughtBlock content="推理内容" />);
|
||||
expect(screen.getByText('思考过程')).toBeInTheDocument();
|
||||
expect(container.querySelector('.lucide-chevron-right')).not.toBeNull();
|
||||
expect(container.querySelector('.lucide-chevron-down')).toBeNull();
|
||||
});
|
||||
|
||||
it('defaultExpanded=true 时展开并渲染内容', () => {
|
||||
const { container } = render(<ThoughtBlock content="推理内容" defaultExpanded />);
|
||||
expect(screen.getByText('推理内容')).toBeInTheDocument();
|
||||
expect(container.querySelector('.lucide-chevron-down')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('点击切换展开/折叠', () => {
|
||||
const { container } = render(<ThoughtBlock content="推理内容" />);
|
||||
fireEvent.click(screen.getByText('思考过程'));
|
||||
expect(container.querySelector('.lucide-chevron-down')).not.toBeNull();
|
||||
expect(screen.getByText('推理内容')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('思考过程'));
|
||||
expect(container.querySelector('.lucide-chevron-right')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('内容变化时 defaultExpanded 同步到展开状态', () => {
|
||||
const { rerender } = render(<ThoughtBlock content="A" defaultExpanded={false} />);
|
||||
rerender(<ThoughtBlock content="B" defaultExpanded />);
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
expect(document.querySelector('.lucide-chevron-down')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('空内容时不渲染任何节点', () => {
|
||||
const { container } = render(<ThoughtBlock content="" />);
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ToolCallCard 组件测试(v0.7.4 引入 jsdom 组件测试)
|
||||
*
|
||||
* 覆盖:五状态渲染(pending/executing/success/error/blocked)、
|
||||
* 工具名/参数/耗时/错误信息的条件渲染。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ToolCallCard } from '../ToolCallCard';
|
||||
|
||||
function makeToolCall(overrides: Partial<Parameters<typeof ToolCallCard>[0]['toolCall']> = {}) {
|
||||
return {
|
||||
id: 'tc_1',
|
||||
name: 'read_file',
|
||||
args: {},
|
||||
status: 'success' as const,
|
||||
durationMs: 120,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ToolCallCard — 状态渲染', () => {
|
||||
it.each([
|
||||
['pending', '等待中'],
|
||||
['executing', '执行中'],
|
||||
['success', '成功'],
|
||||
['error', '失败'],
|
||||
['blocked', '已阻止'],
|
||||
] as const)('%s 状态显示对应文案', (status, label) => {
|
||||
const { container } = render(<ToolCallCard toolCall={makeToolCall({ status })} />);
|
||||
// Chip 的 label 可能在 DOM 中拆分,用 textContent 匹配
|
||||
expect(container.textContent).toContain(label);
|
||||
});
|
||||
|
||||
it('显示工具名', () => {
|
||||
render(<ToolCallCard toolCall={makeToolCall({ name: 'list_directory' })} />);
|
||||
expect(screen.getByText('list_directory')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('显示耗时(formatDuration 格式化)', () => {
|
||||
render(<ToolCallCard toolCall={makeToolCall({ durationMs: 1500 })} />);
|
||||
expect(screen.getByText('1.5s')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无耗时字段时不渲染耗时', () => {
|
||||
const { container } = render(
|
||||
<ToolCallCard toolCall={makeToolCall({ durationMs: undefined })} />,
|
||||
);
|
||||
expect(container.textContent).not.toMatch(/s$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToolCallCard — 参数与错误', () => {
|
||||
it('有参数时渲染 JSON 格式参数', () => {
|
||||
const args = { file_path: 'a.ts', offset: 10 };
|
||||
render(<ToolCallCard toolCall={makeToolCall({ args })} />);
|
||||
expect(screen.getByText(/"file_path"/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/"a\.ts"/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无参数时不渲染参数区', () => {
|
||||
const { container } = render(<ToolCallCard toolCall={makeToolCall({ args: {} })} />);
|
||||
expect(container.querySelector('pre')).toBeNull();
|
||||
});
|
||||
|
||||
it('error 状态且有错误信息时渲染错误文本', () => {
|
||||
render(
|
||||
<ToolCallCard toolCall={makeToolCall({ status: 'error', error: 'Permission denied' })} />,
|
||||
);
|
||||
expect(screen.getByText('Permission denied')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('error 状态但无错误信息时不渲染错误区', () => {
|
||||
const { container } = render(
|
||||
<ToolCallCard toolCall={makeToolCall({ status: 'error', error: undefined })} />,
|
||||
);
|
||||
expect(container.textContent).not.toContain('undefined');
|
||||
});
|
||||
|
||||
it('非 error 状态即使有 error 字段也不渲染', () => {
|
||||
const { container } = render(
|
||||
<ToolCallCard toolCall={makeToolCall({ status: 'success', error: 'should not show' })} />,
|
||||
);
|
||||
expect(container.textContent).not.toContain('should not show');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ToolResultBlock 组件测试
|
||||
*
|
||||
* 覆盖:结果渲染、dataUrl 剥离(toDisplayResult 摘要 + _displayNote)、
|
||||
* 非 success 状态不渲染、null result 不渲染、耗时显示、字符串结果直接渲染。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ToolResultBlock } from '../ToolResultBlock';
|
||||
import type { ToolCallInfo } from '@renderer/stores/agent-store';
|
||||
|
||||
function makeToolCall(overrides: Partial<ToolCallInfo> = {}): ToolCallInfo {
|
||||
return {
|
||||
id: 'tc_1',
|
||||
name: 'view_image',
|
||||
args: {},
|
||||
status: 'success',
|
||||
result: { ok: true },
|
||||
durationMs: 200,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ToolResultBlock — 结果渲染', () => {
|
||||
it('渲染工具名与结果标题', () => {
|
||||
render(<ToolResultBlock toolCall={makeToolCall({ name: 'read_file' })} />);
|
||||
expect(screen.getByText(/read_file 结果/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染字符串结果', () => {
|
||||
render(<ToolResultBlock toolCall={makeToolCall({ result: '文件内容' })} />);
|
||||
expect(screen.getByText('文件内容')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染对象结果(JSON 格式化)', () => {
|
||||
const { container } = render(
|
||||
<ToolResultBlock toolCall={makeToolCall({ result: { files: ['a.txt'] } })} />,
|
||||
);
|
||||
expect(container.querySelector('pre')?.textContent).toContain('a.txt');
|
||||
});
|
||||
|
||||
it('渲染耗时(formatDuration)', () => {
|
||||
render(<ToolResultBlock toolCall={makeToolCall({ durationMs: 1500 })} />);
|
||||
expect(screen.getByText('1.5s')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无耗时时不渲染耗时', () => {
|
||||
const { container } = render(
|
||||
<ToolResultBlock toolCall={makeToolCall({ durationMs: undefined })} />,
|
||||
);
|
||||
expect(container.textContent).not.toMatch(/s$/);
|
||||
});
|
||||
|
||||
it('非 success 状态不渲染', () => {
|
||||
const { container } = render(<ToolResultBlock toolCall={makeToolCall({ status: 'error' })} />);
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('result 为 null 时不渲染', () => {
|
||||
const { container } = render(<ToolResultBlock toolCall={makeToolCall({ result: null })} />);
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToolResultBlock — dataUrl 剥离', () => {
|
||||
it('含 dataUrl 字段时剥离并显示 _displayNote', () => {
|
||||
const result = {
|
||||
dataUrl: 'data:image/png;base64,' + 'A'.repeat(5000),
|
||||
path: '/tmp/screenshot.png',
|
||||
size: 1234,
|
||||
};
|
||||
const { container } = render(<ToolResultBlock toolCall={makeToolCall({ result })} />);
|
||||
const text = container.querySelector('pre')?.textContent ?? '';
|
||||
expect(text).not.toContain('data:image/png;base64');
|
||||
expect(text).toContain('[image base64 omitted for display — visible to LLM only]');
|
||||
expect(text).toContain('/tmp/screenshot.png');
|
||||
});
|
||||
|
||||
it('普通大字符串被截断显示(防御裁剪)', () => {
|
||||
const result = { content: 'x'.repeat(20000) };
|
||||
const { container } = render(<ToolResultBlock toolCall={makeToolCall({ result })} />);
|
||||
const text = container.querySelector('pre')?.textContent ?? '';
|
||||
expect(text).toContain('[truncated for display]');
|
||||
});
|
||||
|
||||
it('store 原始 result 不被修改(纯函数不变异入参)', () => {
|
||||
const result = {
|
||||
dataUrl: 'data:image/png;base64,QQ',
|
||||
path: '/a.png',
|
||||
};
|
||||
const originalDataUrl = result.dataUrl;
|
||||
render(<ToolResultBlock toolCall={makeToolCall({ result })} />);
|
||||
expect(result.dataUrl).toBe(originalDataUrl);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* UserMessage 组件测试(v0.7.4 引入 jsdom 组件测试)
|
||||
*
|
||||
* 覆盖:内容渲染、附件预览(图片/文本/其他)、双击编辑、
|
||||
* 仅保存落库(P3-10:IPC 成功后更新 store)、保存并重发、取消、Escape 退出。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { UserMessage } from '../UserMessage';
|
||||
import type { ChatMessage, AttachmentInfo } from '@renderer/stores/agent-store';
|
||||
|
||||
function makeMessage(overrides: Partial<ChatMessage> = {}): ChatMessage {
|
||||
return {
|
||||
id: 'm1',
|
||||
role: 'user',
|
||||
content: '帮我看看这段代码',
|
||||
timestamp: 1700000000000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeAttachment(
|
||||
type: AttachmentInfo['type'],
|
||||
name: string,
|
||||
extra: Partial<AttachmentInfo> = {},
|
||||
): AttachmentInfo {
|
||||
return {
|
||||
id: `att_${name}`,
|
||||
name,
|
||||
type,
|
||||
size: 1024,
|
||||
...extra,
|
||||
} as AttachmentInfo;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// 重置 agent-store 到最小状态(含 currentSessionId 供 P3-10 落库)
|
||||
useAgentStore.setState({
|
||||
currentSessionId: 's_test',
|
||||
messages: [],
|
||||
isStreaming: false,
|
||||
configLoaded: true,
|
||||
toolsReady: true,
|
||||
agentStatus: 'idle',
|
||||
currentIteration: 0,
|
||||
tokenUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
lastInputTokens: 0,
|
||||
lastCompressedSaved: 0,
|
||||
},
|
||||
traceSteps: [],
|
||||
currentRunId: null,
|
||||
sessionRunStates: {},
|
||||
modelVisionCaps: null,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe('UserMessage — 内容渲染', () => {
|
||||
it('渲染用户消息文本', () => {
|
||||
render(<UserMessage message={makeMessage({ content: '你好,世界' })} />);
|
||||
expect(screen.getByText('你好,世界')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染用户头像图标', () => {
|
||||
const { container } = render(<UserMessage message={makeMessage()} />);
|
||||
expect(container.querySelector('svg')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('空内容时只显示时间戳', () => {
|
||||
const { container } = render(<UserMessage message={makeMessage({ content: '' })} />);
|
||||
expect(container.textContent).not.toContain('帮我看看');
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserMessage — 附件预览', () => {
|
||||
it('图片附件渲染缩略图', () => {
|
||||
const attachments = [
|
||||
makeAttachment('image', 'photo.png', { preview: 'data:image/png;base64,AAA' }),
|
||||
];
|
||||
render(<UserMessage message={makeMessage({ attachments })} />);
|
||||
const img = document.querySelector('img');
|
||||
expect(img).not.toBeNull();
|
||||
expect(img?.getAttribute('src')).toContain('data:image/png');
|
||||
});
|
||||
|
||||
it('文本附件渲染文件名与大小', () => {
|
||||
const attachments = [makeAttachment('text', 'readme.md', { textContent: '# 标题' })];
|
||||
render(<UserMessage message={makeMessage({ attachments })} />);
|
||||
expect(screen.getByText('readme.md')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('其他文件渲染通用卡片', () => {
|
||||
const attachments = [makeAttachment('other', 'archive.zip')];
|
||||
render(<UserMessage message={makeMessage({ attachments })} />);
|
||||
expect(screen.getByText('archive.zip')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserMessage — 编辑与保存(P3-10)', () => {
|
||||
it('双击进入编辑态并显示按钮', () => {
|
||||
render(<UserMessage message={makeMessage()} />);
|
||||
fireEvent.doubleClick(screen.getByText('帮我看看这段代码'));
|
||||
expect(screen.getByText('仅保存')).toBeInTheDocument();
|
||||
expect(screen.getByText('保存并重发')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('仅保存:IPC 落库成功后更新 store 并退出编辑', async () => {
|
||||
const updateMessageContent = vi.fn().mockResolvedValue({ success: true });
|
||||
(
|
||||
window.metona as unknown as {
|
||||
sessions: { updateMessageContent: typeof updateMessageContent };
|
||||
}
|
||||
).sessions.updateMessageContent = updateMessageContent;
|
||||
// store 预置该消息(updateMessage 才能命中)
|
||||
useAgentStore.setState({ messages: [makeMessage()] } as never);
|
||||
|
||||
render(<UserMessage message={makeMessage()} />);
|
||||
fireEvent.doubleClick(screen.getByText('帮我看看这段代码'));
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '修改后的内容' } });
|
||||
fireEvent.click(screen.getByText('仅保存'));
|
||||
|
||||
// 等待异步 IPC + store 更新
|
||||
await vi.waitFor(() => {
|
||||
expect(updateMessageContent).toHaveBeenCalledWith('s_test', 'm1', '修改后的内容');
|
||||
});
|
||||
// store 已更新
|
||||
expect(useAgentStore.getState().messages.find((m) => m.id === 'm1')?.content).toBe(
|
||||
'修改后的内容',
|
||||
);
|
||||
});
|
||||
|
||||
it('仅保存:内容未变时直接退出编辑不调 IPC', async () => {
|
||||
const updateMessageContent = vi.fn();
|
||||
(
|
||||
window.metona as unknown as {
|
||||
sessions: { updateMessageContent: typeof updateMessageContent };
|
||||
}
|
||||
).sessions.updateMessageContent = updateMessageContent;
|
||||
|
||||
render(<UserMessage message={makeMessage()} />);
|
||||
fireEvent.doubleClick(screen.getByText('帮我看看这段代码'));
|
||||
fireEvent.click(screen.getByText('仅保存'));
|
||||
expect(updateMessageContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('仅保存:IPC 失败保留编辑态并显示错误 toast', async () => {
|
||||
const updateMessageContent = vi.fn().mockResolvedValue({ success: false, error: 'db error' });
|
||||
(
|
||||
window.metona as unknown as {
|
||||
sessions: { updateMessageContent: typeof updateMessageContent };
|
||||
}
|
||||
).sessions.updateMessageContent = updateMessageContent;
|
||||
|
||||
render(<UserMessage message={makeMessage()} />);
|
||||
fireEvent.doubleClick(screen.getByText('帮我看看这段代码'));
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '新内容' } });
|
||||
fireEvent.click(screen.getByText('仅保存'));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// 编辑态保留(保存按钮仍在)
|
||||
expect(screen.getByText('仅保存')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('保存并重发调用 editAndResend', () => {
|
||||
const editAndResend = vi.fn();
|
||||
// 注入 store action
|
||||
useAgentStore.setState({ editAndResend: editAndResend } as never);
|
||||
|
||||
render(<UserMessage message={makeMessage()} />);
|
||||
fireEvent.doubleClick(screen.getByText('帮我看看这段代码'));
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '重发内容' } });
|
||||
fireEvent.click(screen.getByText('保存并重发'));
|
||||
expect(editAndResend).toHaveBeenCalledWith('m1', '重发内容');
|
||||
});
|
||||
|
||||
it('Escape 取消编辑恢复原文', () => {
|
||||
render(<UserMessage message={makeMessage()} />);
|
||||
fireEvent.doubleClick(screen.getByText('帮我看看这段代码'));
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '改动' } });
|
||||
fireEvent.keyDown(textarea, { key: 'Escape' });
|
||||
expect(screen.queryByText('仅保存')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user