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:
@@ -3,13 +3,32 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { Dialog, InputBase, List, ListItemButton, ListItemIcon, ListItemText, Typography, Box, Divider } from '@mui/material';
|
||||
import {
|
||||
Dialog,
|
||||
InputBase,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Typography,
|
||||
Box,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import { Search, MessageSquare, Settings, Plus, Trash2 } from 'lucide-react';
|
||||
import { useUIStore } from '@renderer/stores/ui-store';
|
||||
import { useSessionStore } from '@renderer/stores/session-store';
|
||||
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';
|
||||
|
||||
interface CommandItem { id: string; icon: typeof Search; label: string; description?: string; action: () => void; }
|
||||
interface CommandItem {
|
||||
id: string;
|
||||
icon: typeof Search;
|
||||
label: string;
|
||||
description?: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
export function CommandPalette(): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -22,40 +41,83 @@ export function CommandPalette(): React.JSX.Element {
|
||||
const sessions = useSessionStore((s) => s.sessions);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'k' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); setOpen((p) => !p); setQuery(''); setSelectedIndex(0); } };
|
||||
window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h);
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.key === 'k' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
setOpen((p) => !p);
|
||||
setQuery('');
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', h);
|
||||
return () => window.removeEventListener('keydown', h);
|
||||
}, []);
|
||||
|
||||
// M-19 修复: useEffect 返回 cleanup 清理 setTimeout,防止快速开关时 timer 堆积
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 50);
|
||||
return () => clearTimeout(t);
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [open]);
|
||||
|
||||
const getCommands = useCallback((): CommandItem[] => {
|
||||
const cmds: CommandItem[] = [
|
||||
{ id: 'new-session', icon: Plus, label: '新建会话', description: '创建一个新的对话会话', action: async () => {
|
||||
if (window.metona?.sessions?.create) {
|
||||
try {
|
||||
const r = await window.metona.sessions.create() as MetonaSessionInfo;
|
||||
useSessionStore.getState().addSession(r);
|
||||
useSessionStore.getState().setCurrentSession(r.id);
|
||||
useAgentStore.getState().setCurrentSession(r.id);
|
||||
} catch (err) {
|
||||
console.error('[CommandPalette]', err);
|
||||
import('@metona-team/metona-toast').then((mod) => mod.default.error('创建会话失败')).catch(() => {});
|
||||
{
|
||||
id: 'new-session',
|
||||
icon: Plus,
|
||||
label: t('command.newSession'),
|
||||
description: t('command.newSessionDesc'),
|
||||
action: async () => {
|
||||
if (window.metona?.sessions?.create) {
|
||||
try {
|
||||
const r = (await window.metona.sessions.create()) as MetonaSessionInfo;
|
||||
useSessionStore.getState().addSession(r);
|
||||
useSessionStore.getState().setCurrentSession(r.id);
|
||||
useAgentStore.getState().setCurrentSession(r.id);
|
||||
} catch (err) {
|
||||
console.error('[CommandPalette]', err);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(t('command.createFailed')))
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
setOpen(false);
|
||||
} },
|
||||
{ id: 'clear', icon: Trash2, label: '清空当前会话', action: () => { useAgentStore.getState().clearMessages(); setOpen(false); } },
|
||||
{ id: 'settings', icon: Settings, label: '打开设置', action: () => { useUIStore.getState().openSettings(); setOpen(false); } },
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'clear',
|
||||
icon: Trash2,
|
||||
label: t('command.clearSession'),
|
||||
action: () => {
|
||||
useAgentStore.getState().clearMessages();
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
icon: Settings,
|
||||
label: t('command.openSettings'),
|
||||
action: () => {
|
||||
useUIStore.getState().openSettings();
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
];
|
||||
if (query) {
|
||||
// 审计补充修复: 使用响应式 sessions(闭包捕获),而非 useSessionStore.getState()
|
||||
const filtered = sessions.filter((s) => s.title.toLowerCase().includes(query.toLowerCase()));
|
||||
for (const s of filtered.slice(0, 5)) cmds.push({ id: `s-${s.id}`, icon: MessageSquare, label: s.title, description: `${s.messageCount} 条消息`, action: () => { useSessionStore.getState().setCurrentSession(s.id); useAgentStore.getState().setCurrentSession(s.id); setOpen(false); } });
|
||||
for (const s of filtered.slice(0, 5))
|
||||
cmds.push({
|
||||
id: `s-${s.id}`,
|
||||
icon: MessageSquare,
|
||||
label: s.title,
|
||||
description: t('command.messageCount', { count: s.messageCount }),
|
||||
action: () => {
|
||||
useSessionStore.getState().setCurrentSession(s.id);
|
||||
useAgentStore.getState().setCurrentSession(s.id);
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
return cmds;
|
||||
}, [query, sessions]);
|
||||
@@ -64,34 +126,93 @@ export function CommandPalette(): React.JSX.Element {
|
||||
const commands = useMemo(() => getCommands(), [getCommands]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth slotProps={{ paper: { sx: { mt: '20vh', maxWidth: 520, borderRadius: 3, overflow: 'hidden' } } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, px: 2, py: 1.5, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
slotProps={{
|
||||
paper: { sx: { mt: '20vh', maxWidth: 520, borderRadius: 3, overflow: 'hidden' } },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Search size={16} style={{ color: '#8b8fa7', flexShrink: 0 }} />
|
||||
<InputBase inputRef={inputRef} value={query} onChange={(e) => { setQuery(e.target.value); setSelectedIndex(0); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex((p) => Math.min(p + 1, commands.length - 1)); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex((p) => Math.max(p - 1, 0)); }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); commands[selectedIndex]?.action(); }
|
||||
<InputBase
|
||||
inputRef={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setSelectedIndex(0);
|
||||
}}
|
||||
placeholder="搜索会话、命令..." sx={{ flex: 1, fontSize: 13 }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((p) => Math.min(p + 1, commands.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((p) => Math.max(p - 1, 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commands[selectedIndex]?.action();
|
||||
}
|
||||
}}
|
||||
placeholder={t('command.searchPlaceholder')}
|
||||
sx={{ flex: 1, fontSize: 13 }}
|
||||
/>
|
||||
</Box>
|
||||
<List sx={{ maxHeight: 300, overflowY: 'auto', py: 0.5 }}>
|
||||
{commands.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ textAlign: 'center', py: 3, display: 'block', color: 'text.secondary' }}>无匹配结果</Typography>
|
||||
) : commands.map((cmd, i) => {
|
||||
const Icon = cmd.icon;
|
||||
return (
|
||||
<ListItemButton key={cmd.id} selected={i === selectedIndex} onClick={cmd.action} sx={{ px: 2, py: 1.25 }}>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}><Icon size={14} style={{ color: '#818cf8' }} /></ListItemIcon>
|
||||
<ListItemText primary={<Typography variant="body2" sx={{ fontSize: 12 }}>{cmd.label}</Typography>} secondary={cmd.description ? <Typography variant="caption">{cmd.description}</Typography> : undefined} />
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ textAlign: 'center', py: 3, display: 'block', color: 'text.secondary' }}
|
||||
>
|
||||
{t('command.noMatch')}
|
||||
</Typography>
|
||||
) : (
|
||||
commands.map((cmd, i) => {
|
||||
const Icon = cmd.icon;
|
||||
return (
|
||||
<ListItemButton
|
||||
key={cmd.id}
|
||||
selected={i === selectedIndex}
|
||||
onClick={cmd.action}
|
||||
sx={{ px: 2, py: 1.25 }}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<Icon size={14} style={{ color: '#818cf8' }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="body2" sx={{ fontSize: 12 }}>
|
||||
{cmd.label}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
cmd.description ? (
|
||||
<Typography variant="caption">{cmd.description}</Typography>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</ListItemButton>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</List>
|
||||
<Divider />
|
||||
<Box sx={{ display: 'flex', gap: 2, px: 2, py: 1, fontSize: 10, color: 'text.disabled' }}>
|
||||
<span>↑↓ 导航</span><span>↵ 选择</span><span>Esc 关闭</span>
|
||||
<span>{t('command.navHint')}</span>
|
||||
<span>{t('command.selectHint')}</span>
|
||||
<span>{t('command.closeHint')}</span>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -25,6 +25,9 @@ import { Copy, Quote, Edit, Trash2, RotateCcw, Pin, Archive, FileDown } from 'lu
|
||||
import { create } from 'zustand';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { useSessionStore } from '@renderer/stores/session-store';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
// v0.6.4 死代码清理:'tool-call' / 'code-block' / 'trace-step' 三个从未被任何组件
|
||||
// 接线的分支已删除(ToolCallCard / TraceStep 本就未挂 onContextMenu)。
|
||||
@@ -117,7 +120,7 @@ const useDialogStore = create<DialogState>((set, get) => ({
|
||||
promptLabel: '',
|
||||
promptValue: '',
|
||||
promptResolve: null,
|
||||
confirm: (message, title = '确认') =>
|
||||
confirm: (message, title = t('contextMenu.confirm')) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
set({
|
||||
confirmOpen: true,
|
||||
@@ -126,7 +129,7 @@ const useDialogStore = create<DialogState>((set, get) => ({
|
||||
confirmResolve: resolve,
|
||||
});
|
||||
}),
|
||||
prompt: (label, defaultValue = '', title = '输入') =>
|
||||
prompt: (label, defaultValue = '', title = t('contextMenu.prompt')) =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
set({
|
||||
promptOpen: true,
|
||||
@@ -182,7 +185,7 @@ export function ContextMenuDialogHost(): React.JSX.Element {
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => closeConfirm(false)} color="inherit" size="small">
|
||||
取消
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => closeConfirm(true)}
|
||||
@@ -191,7 +194,7 @@ export function ContextMenuDialogHost(): React.JSX.Element {
|
||||
size="small"
|
||||
autoFocus
|
||||
>
|
||||
确定
|
||||
{t('contextMenu.ok')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
@@ -212,10 +215,10 @@ export function ContextMenuDialogHost(): React.JSX.Element {
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => closePrompt(null)} color="inherit" size="small">
|
||||
取消
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => closePrompt(promptValue)} variant="contained" size="small">
|
||||
确定
|
||||
{t('contextMenu.ok')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
@@ -233,7 +236,7 @@ function copyWithToast(text: string): void {
|
||||
navigator.clipboard.writeText(text).catch((err) => {
|
||||
console.error('[Clipboard]', err);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error('复制失败'))
|
||||
.then((mod) => mod.default.error(t('contextMenu.toast.copyFailed')))
|
||||
.catch(() => {});
|
||||
});
|
||||
}
|
||||
@@ -247,11 +250,16 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
|
||||
const d = (data as { content?: string; role?: string }) ?? {};
|
||||
const content = d.content ?? '';
|
||||
const items: ContextMenuItem[] = [
|
||||
{ id: 'copy', icon: Copy, label: '复制', action: () => copyWithToast(content) },
|
||||
{
|
||||
id: 'copy',
|
||||
icon: Copy,
|
||||
label: t('contextMenu.copy'),
|
||||
action: () => copyWithToast(content),
|
||||
},
|
||||
{
|
||||
id: 'quote',
|
||||
icon: Quote,
|
||||
label: '引用回复',
|
||||
label: t('contextMenu.quote'),
|
||||
action: () => {
|
||||
const input = document.querySelector<HTMLTextAreaElement>('[data-chat-input]');
|
||||
if (input) {
|
||||
@@ -271,7 +279,7 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
|
||||
items.push({
|
||||
id: 'regenerate',
|
||||
icon: RotateCcw,
|
||||
label: '重新生成',
|
||||
label: t('contextMenu.regenerate'),
|
||||
action: () => {
|
||||
void useAgentStore.getState().regenerate();
|
||||
},
|
||||
@@ -291,26 +299,30 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
|
||||
{
|
||||
id: 'rename',
|
||||
icon: Edit,
|
||||
label: '重命名',
|
||||
label: t('contextMenu.rename'),
|
||||
action: async () => {
|
||||
if (!sid) return;
|
||||
// 用 MUI Dialog 替代原生 prompt()(Electron 下不可靠,且与 MUI 风格不一致)
|
||||
const currentTitle =
|
||||
useSessionStore.getState().sessions.find((x) => x.id === sid)?.title ?? '';
|
||||
const t = await useDialogStore
|
||||
const t_ = await useDialogStore
|
||||
.getState()
|
||||
.prompt('新会话名称', currentTitle, '重命名会话');
|
||||
if (!t?.trim()) return;
|
||||
.prompt(
|
||||
t('contextMenu.renamePromptLabel'),
|
||||
currentTitle,
|
||||
t('contextMenu.renamePromptTitle'),
|
||||
);
|
||||
if (!t_?.trim()) return;
|
||||
try {
|
||||
const r = await window.metona?.sessions?.rename(sid, t.trim());
|
||||
const r = await window.metona?.sessions?.rename(sid, t_.trim());
|
||||
if (r?.success) {
|
||||
useSessionStore.getState().updateSession(sid, { title: t.trim() });
|
||||
useSessionStore.getState().updateSession(sid, { title: t_.trim() });
|
||||
} else {
|
||||
showError(r?.error ?? '重命名失败');
|
||||
showError(r?.error ?? t('contextMenu.toast.renameFailed'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ContextMenu]', err);
|
||||
showError('重命名失败');
|
||||
showError(t('contextMenu.toast.renameFailed'));
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -318,8 +330,8 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
|
||||
id: 'pin',
|
||||
icon: Pin,
|
||||
label: useSessionStore.getState().sessions.find((x) => x.id === sid)?.pinned
|
||||
? '取消置顶'
|
||||
: '置顶',
|
||||
? t('contextMenu.unpin')
|
||||
: t('contextMenu.pin'),
|
||||
action: async () => {
|
||||
if (!sid) return;
|
||||
const s = useSessionStore.getState().sessions.find((x) => x.id === sid);
|
||||
@@ -330,11 +342,11 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
|
||||
if (r?.success) {
|
||||
useSessionStore.getState().pinSession(sid, newPinned);
|
||||
} else {
|
||||
showError(r?.error ?? '置顶失败');
|
||||
showError(r?.error ?? t('contextMenu.toast.pinFailed'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ContextMenu]', err);
|
||||
showError('置顶失败');
|
||||
showError(t('contextMenu.toast.pinFailed'));
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -344,8 +356,8 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
|
||||
id: 'archive',
|
||||
icon: Archive,
|
||||
label: useSessionStore.getState().sessions.find((x) => x.id === sid)?.archived
|
||||
? '取消归档'
|
||||
: '归档',
|
||||
? t('contextMenu.unarchive')
|
||||
: t('contextMenu.archive'),
|
||||
action: async () => {
|
||||
if (!sid) return;
|
||||
const s = useSessionStore.getState().sessions.find((x) => x.id === sid);
|
||||
@@ -355,32 +367,31 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
|
||||
if (r?.success) {
|
||||
useSessionStore.getState().archiveSession(sid, newArchived);
|
||||
} else {
|
||||
showError(r?.error ?? '归档失败');
|
||||
showError(r?.error ?? t('contextMenu.toast.archiveFailed'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ContextMenu]', err);
|
||||
showError('归档失败');
|
||||
showError(t('contextMenu.toast.archiveFailed'));
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'export',
|
||||
icon: FileDown,
|
||||
label: '导出 JSON',
|
||||
label: t('contextMenu.exportJson'),
|
||||
action: () => {
|
||||
if (sid)
|
||||
window.metona?.sessions
|
||||
.getMessages(sid)
|
||||
.then((msgs) => {
|
||||
.then(async (msgs) => {
|
||||
// v0.7.4 P3-9: 复用共享 downloadBlob(统一释放 ObjectURL)
|
||||
const { downloadBlob } = await import('@renderer/lib/export-markdown');
|
||||
const b = new Blob([JSON.stringify(msgs, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(b);
|
||||
a.download = `session-${sid}.json`;
|
||||
a.click();
|
||||
downloadBlob(`session-${sid}.json`, b);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[ContextMenu]', err);
|
||||
showError('导出失败');
|
||||
showError(t('contextMenu.toast.exportFailed'));
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -388,7 +399,7 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
|
||||
{
|
||||
id: 'export-md',
|
||||
icon: FileDown,
|
||||
label: '导出 Markdown',
|
||||
label: t('contextMenu.exportMarkdown'),
|
||||
action: async () => {
|
||||
if (!sid) return;
|
||||
try {
|
||||
@@ -396,7 +407,8 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
|
||||
const { buildSessionMarkdown, downloadMarkdown } =
|
||||
await import('@renderer/lib/export-markdown');
|
||||
const title =
|
||||
useSessionStore.getState().sessions.find((x) => x.id === sid)?.title ?? '会话导出';
|
||||
useSessionStore.getState().sessions.find((x) => x.id === sid)?.title ??
|
||||
t('contextMenu.sessionExportTitle');
|
||||
const md = buildSessionMarkdown(
|
||||
title,
|
||||
msgs as Array<{
|
||||
@@ -411,50 +423,36 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
|
||||
downloadMarkdown(`session-${sid}.md`, md);
|
||||
} catch (err) {
|
||||
console.error('[ContextMenu]', err);
|
||||
showError('导出失败');
|
||||
showError(t('contextMenu.toast.exportFailed'));
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
icon: Trash2,
|
||||
label: '删除',
|
||||
label: t('common.delete'),
|
||||
action: async () => {
|
||||
if (!sid) return;
|
||||
// 用 MUI Dialog 替代原生 confirm()(Electron 下不可靠)
|
||||
// 与 Sidebar.tsx 的 showDeleteDialog 行为一致
|
||||
const ok = await useDialogStore
|
||||
.getState()
|
||||
.confirm('确定删除此会话?此操作不可撤销。', '删除会话');
|
||||
.confirm(t('contextMenu.deleteConfirmBody'), t('sidebar.deleteTitle'));
|
||||
if (!ok) return;
|
||||
try {
|
||||
const r = await window.metona?.sessions?.delete(sid);
|
||||
if (r?.success) {
|
||||
useSessionStore.getState().removeSession(sid);
|
||||
// 修复: 删除当前会话时同步清空 agent-store,否则 ChatPanel 和 DetailPanel 仍显示已删除会话的内容
|
||||
// 与 Sidebar.tsx 的 confirmDelete 行为一致
|
||||
// v0.7.4 P3-8: 收敛为统一 resetSessionState(与 Sidebar 一致)
|
||||
if (useAgentStore.getState().currentSessionId === sid) {
|
||||
useAgentStore.setState({
|
||||
currentSessionId: null,
|
||||
messages: [],
|
||||
traceSteps: [],
|
||||
tokenUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
lastInputTokens: 0,
|
||||
lastCompressedSaved: 0,
|
||||
},
|
||||
currentRunId: null,
|
||||
currentIteration: 0,
|
||||
});
|
||||
useAgentStore.getState().resetSessionState();
|
||||
}
|
||||
} else {
|
||||
showError(r?.error ?? '删除失败');
|
||||
showError(r?.error ?? t('contextMenu.toast.deleteFailed'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ContextMenu]', err);
|
||||
showError('删除失败');
|
||||
showError(t('contextMenu.toast.deleteFailed'));
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ConfirmationDialog 组件测试
|
||||
*
|
||||
* 注意:组件只在收到 IPC onConfirmationRequest 事件时主动拉取 pending
|
||||
* (refreshPending(mergeNew)),挂载时不会自动拉取。因此所有用例都通过
|
||||
* 触发 confirmCb 事件驱动请求列表。
|
||||
*
|
||||
* 覆盖:拉取 pending、请求列表/分组渲染、批准单个/批量、拒绝全部、
|
||||
* remember 勾选、autoExecute 二次确认、超时自动拒绝、会话 TERMINATED 清空、
|
||||
* 拒绝记忆恢复入口。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
|
||||
import { ConfirmationDialog } from '../ConfirmationDialog';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
|
||||
const agentInitial = useAgentStore.getState();
|
||||
|
||||
interface Req {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
riskLevel: string;
|
||||
reason: string;
|
||||
sessionId?: string;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
function makeReq(overrides: Partial<Req> = {}): Req {
|
||||
return {
|
||||
toolCallId: 'call_1',
|
||||
toolName: 'read_file',
|
||||
args: { path: '/tmp/a.txt' },
|
||||
riskLevel: 'medium',
|
||||
reason: 'Agent 请求读取文件',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
let confirmCb: ((req: unknown) => void) | null = null;
|
||||
let stateCb: ((state: unknown) => void) | null = null;
|
||||
let pendingResult: Req[] = [];
|
||||
let denialsResult: Array<{ toolName: string; expiresInSeconds: number }> = [];
|
||||
|
||||
function setupBridge(): void {
|
||||
confirmCb = null;
|
||||
stateCb = null;
|
||||
pendingResult = [];
|
||||
denialsResult = [];
|
||||
(
|
||||
window.metona.tool as unknown as {
|
||||
onConfirmationRequest: (cb: (req: unknown) => void) => () => void;
|
||||
}
|
||||
).onConfirmationRequest = vi.fn((cb) => {
|
||||
confirmCb = cb;
|
||||
return () => {};
|
||||
});
|
||||
(
|
||||
window.metona.tool as unknown as {
|
||||
getPendingConfirmations: () => Promise<{ success: boolean; data: Req[] }>;
|
||||
}
|
||||
).getPendingConfirmations = vi.fn(async () => ({ success: true, data: pendingResult }));
|
||||
(
|
||||
window.metona.tool as unknown as {
|
||||
getRememberedDenials: () => Promise<{
|
||||
success: boolean;
|
||||
data: Array<{ toolName: string; expiresInSeconds: number }>;
|
||||
}>;
|
||||
}
|
||||
).getRememberedDenials = vi.fn(async () => ({ success: true, data: denialsResult }));
|
||||
(
|
||||
window.metona.tool as unknown as {
|
||||
sendConfirmationResponseBatch: (resp: {
|
||||
toolCallIds: string[];
|
||||
approved: boolean;
|
||||
remember: boolean;
|
||||
autoExecute?: boolean;
|
||||
}) => void;
|
||||
}
|
||||
).sendConfirmationResponseBatch = vi.fn();
|
||||
(
|
||||
window.metona.tool as unknown as {
|
||||
resetRememberedDenial: (
|
||||
toolName: string,
|
||||
sessionId?: string,
|
||||
) => Promise<{ success: boolean }>;
|
||||
}
|
||||
).resetRememberedDenial = vi.fn(async () => ({ success: true }));
|
||||
(
|
||||
window.metona.agent as unknown as {
|
||||
onStateChange: (cb: (state: unknown) => void) => () => void;
|
||||
}
|
||||
).onStateChange = vi.fn((cb) => {
|
||||
stateCb = cb;
|
||||
return () => {};
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAgentStore.setState(agentInitial, true);
|
||||
useAgentStore.setState({ currentSessionId: 's1' });
|
||||
setupBridge();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function getBatchSpy(): ReturnType<typeof vi.fn> {
|
||||
return (
|
||||
window.metona.tool as unknown as { sendConfirmationResponseBatch: ReturnType<typeof vi.fn> }
|
||||
).sendConfirmationResponseBatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染并触发 IPC 事件拉取 pending(以第一个请求作为触发事件,
|
||||
* 使 pendingResult 与 mergeNew 去重后保留完整列表)。
|
||||
*/
|
||||
async function mountWith(reqs: Req[]): Promise<HTMLElement> {
|
||||
pendingResult = reqs;
|
||||
const { container } = render(<ConfirmationDialog />);
|
||||
const trigger = reqs[0] ?? makeReq();
|
||||
act(() => {
|
||||
confirmCb?.(trigger);
|
||||
});
|
||||
// 刷新多次微任务队列,确保 refreshPending / refreshRememberedDenials 的
|
||||
// Promise 链全部落定(否则出现顺序相关的时序抖动)。
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('ConfirmationDialog — 拉取与渲染', () => {
|
||||
it('无 IPC 事件且无 pending 时不渲染任何节点', () => {
|
||||
const { container } = render(<ConfirmationDialog />);
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('收到 IPC 事件后拉取 pending 并渲染请求列表', async () => {
|
||||
await mountWith([makeReq()]);
|
||||
expect(window.metona.tool.getPendingConfirmations).toHaveBeenCalled();
|
||||
expect(screen.getByText('工具执行确认')).toBeInTheDocument();
|
||||
expect(screen.getByText('read_file')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Agent 请求读取文件/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染请求参数 key: value', async () => {
|
||||
await mountWith([makeReq({ args: { path: '/tmp/a.txt', count: 3 } })]);
|
||||
expect(screen.getByText('path:')).toBeInTheDocument();
|
||||
expect(screen.getByText('/tmp/a.txt')).toBeInTheDocument();
|
||||
expect(screen.getByText('count:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无参数请求渲染"(无参数)"', async () => {
|
||||
await mountWith([makeReq({ args: {} })]);
|
||||
expect(await screen.findByText('(无参数)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('多请求时渲染并行请求计数与综合原因', async () => {
|
||||
await mountWith([
|
||||
makeReq({ toolCallId: 'a', toolName: 'read_file', riskLevel: 'high' }),
|
||||
makeReq({ toolCallId: 'b', toolName: 'write_file', riskLevel: 'medium' }),
|
||||
]);
|
||||
expect(screen.getByText(/检测到 2 个并行工具调用请求确认/)).toBeInTheDocument();
|
||||
expect(screen.getByText('read_file')).toBeInTheDocument();
|
||||
expect(screen.getByText('write_file')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('同工具多次调用分组并显示 ×N 徽标', async () => {
|
||||
await mountWith([
|
||||
makeReq({ toolCallId: 'a', toolName: 'read_file' }),
|
||||
makeReq({ toolCallId: 'b', toolName: 'read_file' }),
|
||||
]);
|
||||
// Dialog 渲染在 portal(document.body)中,非 render container
|
||||
expect(document.body.textContent).toContain('×2');
|
||||
});
|
||||
|
||||
it('显示已选计数', async () => {
|
||||
await mountWith([makeReq(), makeReq({ toolCallId: 'b' })]);
|
||||
expect(screen.getByText('已选 2 / 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染 riskLevel 风险徽标', async () => {
|
||||
await mountWith([makeReq({ riskLevel: 'critical' })]);
|
||||
expect(screen.getByText('critical')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('刷新按钮重新拉取 pending', async () => {
|
||||
await mountWith([makeReq()]);
|
||||
const before = (window.metona.tool.getPendingConfirmations as ReturnType<typeof vi.fn>).mock
|
||||
.calls.length;
|
||||
const refreshBtn = document
|
||||
.querySelector('.lucide-refresh-cw')
|
||||
?.closest('button') as HTMLElement;
|
||||
fireEvent.click(refreshBtn);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
(window.metona.tool.getPendingConfirmations as ReturnType<typeof vi.fn>).mock.calls.length,
|
||||
).toBeGreaterThan(before);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConfirmationDialog — 批准/拒绝', () => {
|
||||
it('批准单个(确认执行)调用 sendConfirmationResponseBatch', async () => {
|
||||
await mountWith([makeReq()]);
|
||||
fireEvent.click(screen.getByText('确认执行 (1)'));
|
||||
expect(getBatchSpy()).toHaveBeenCalledWith({
|
||||
toolCallIds: ['call_1'],
|
||||
approved: true,
|
||||
remember: false,
|
||||
autoExecute: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('取消勾选后只批准选中的请求', async () => {
|
||||
await mountWith([
|
||||
makeReq({ toolCallId: 'a', toolName: 'read_file' }),
|
||||
makeReq({ toolCallId: 'b', toolName: 'write_file' }),
|
||||
]);
|
||||
// 取消第一个工具组的勾选(只取消 read_file 的 a)
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
fireEvent.click(checkboxes[0]);
|
||||
fireEvent.click(screen.getByText('批准选中 (1)'));
|
||||
expect(getBatchSpy()).toHaveBeenCalledWith({
|
||||
toolCallIds: ['b'],
|
||||
approved: true,
|
||||
remember: false,
|
||||
autoExecute: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('批准全部调用 sendConfirmationResponseBatch 并清空列表', async () => {
|
||||
await mountWith([
|
||||
makeReq({ toolCallId: 'a', toolName: 'read_file' }),
|
||||
makeReq({ toolCallId: 'b', toolName: 'write_file' }),
|
||||
]);
|
||||
// 全选时"批准全部"禁用,先取消一个勾选再点击
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
fireEvent.click(checkboxes[0]);
|
||||
fireEvent.click(screen.getByText('批准全部'));
|
||||
expect(getBatchSpy()).toHaveBeenCalledWith({
|
||||
toolCallIds: ['a', 'b'],
|
||||
approved: true,
|
||||
remember: false,
|
||||
autoExecute: false,
|
||||
});
|
||||
// 批准全部后清空 → 弹框关闭(Dialog exit transition 需等待)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('工具执行确认')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('拒绝全部调用 sendConfirmationResponseBatch(approved=false)', async () => {
|
||||
await mountWith([makeReq({ toolCallId: 'a' }), makeReq({ toolCallId: 'b' })]);
|
||||
fireEvent.click(screen.getByText('拒绝全部 (2)'));
|
||||
expect(getBatchSpy()).toHaveBeenCalledWith({
|
||||
toolCallIds: ['a', 'b'],
|
||||
approved: false,
|
||||
remember: false,
|
||||
autoExecute: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('全选状态时"批准全部"禁用', async () => {
|
||||
await mountWith([makeReq()]);
|
||||
const btn = screen.getByText('批准全部');
|
||||
expect((btn.closest('button') as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('取消全部勾选后"批准选中"禁用', async () => {
|
||||
await mountWith([makeReq()]);
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
fireEvent.click(checkboxes[0]);
|
||||
const approveBtn = screen.getByText(/批准选中 \(0\)/);
|
||||
expect((approveBtn.closest('button') as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConfirmationDialog — remember / autoExecute', () => {
|
||||
it('勾选 remember 后批准时携带 remember=true', async () => {
|
||||
await mountWith([makeReq()]);
|
||||
fireEvent.click(screen.getByText(/在本次会话中记住此决定/));
|
||||
fireEvent.click(screen.getByText('确认执行 (1)'));
|
||||
expect(getBatchSpy()).toHaveBeenCalledWith({
|
||||
toolCallIds: ['call_1'],
|
||||
approved: true,
|
||||
remember: true,
|
||||
autoExecute: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('勾选 autoExecute 弹出二次确认对话框', async () => {
|
||||
await mountWith([makeReq()]);
|
||||
fireEvent.click(screen.getByText(/永久自动执行选中工具/));
|
||||
expect(screen.getByText('确认永久自动执行?')).toBeInTheDocument();
|
||||
expect(screen.getByText('确定要启用吗?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('autoExecute 二次确认点取消不启用', async () => {
|
||||
await mountWith([makeReq()]);
|
||||
fireEvent.click(screen.getByText(/永久自动执行选中工具/));
|
||||
fireEvent.click(screen.getByText('取消'));
|
||||
// Dialog exit transition 需要等待卸载
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('确认永久自动执行?')).toBeNull();
|
||||
});
|
||||
fireEvent.click(screen.getByText('确认执行 (1)'));
|
||||
expect(getBatchSpy()).toHaveBeenCalledWith({
|
||||
toolCallIds: ['call_1'],
|
||||
approved: true,
|
||||
remember: false,
|
||||
autoExecute: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('autoExecute 二次确认点确认启用并同步勾选 remember', async () => {
|
||||
await mountWith([makeReq()]);
|
||||
fireEvent.click(screen.getByText(/永久自动执行选中工具/));
|
||||
fireEvent.click(screen.getByText('确认自动执行'));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('确认永久自动执行?')).toBeNull();
|
||||
});
|
||||
fireEvent.click(screen.getByText('确认执行 (1)'));
|
||||
expect(getBatchSpy()).toHaveBeenCalledWith({
|
||||
toolCallIds: ['call_1'],
|
||||
approved: true,
|
||||
remember: true,
|
||||
autoExecute: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConfirmationDialog — 超时自动拒绝', () => {
|
||||
it('请求过期后自动发送拒绝响应并移除', async () => {
|
||||
vi.useFakeTimers();
|
||||
await mountWith([makeReq({ expiresAt: Date.now() - 5000 })]);
|
||||
// 推进倒计时轮询(200ms)触发剩余时间归零 → refreshPending
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200);
|
||||
await Promise.resolve();
|
||||
});
|
||||
// 推进自动拒绝兜底(2500ms)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2600);
|
||||
});
|
||||
expect(getBatchSpy()).toHaveBeenCalledWith({
|
||||
toolCallIds: ['call_1'],
|
||||
approved: false,
|
||||
remember: false,
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('过期时显示"已超时"且批准选中按钮禁用', async () => {
|
||||
vi.useFakeTimers();
|
||||
await mountWith([makeReq({ expiresAt: Date.now() - 1000 })]);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByText('已超时')).toBeInTheDocument();
|
||||
const approveBtn = screen.getByText('已超时').closest('button') as HTMLButtonElement;
|
||||
expect(approveBtn.disabled).toBe(true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConfirmationDialog — IPC 事件联动', () => {
|
||||
it('onStateChange TERMINATED 清空对应会话的请求', async () => {
|
||||
await mountWith([makeReq({ sessionId: 's1' })]);
|
||||
expect(screen.getByText('read_file')).toBeInTheDocument();
|
||||
act(() => {
|
||||
stateCb?.({ sessionId: 's1', state: 'TERMINATED' });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('read_file')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('onStateChange TERMINATED 只清空对应会话', async () => {
|
||||
await mountWith([
|
||||
makeReq({ toolCallId: 'a', sessionId: 's1' }),
|
||||
makeReq({ toolCallId: 'b', sessionId: 's2' }),
|
||||
]);
|
||||
act(() => {
|
||||
stateCb?.({ sessionId: 's1', state: 'TERMINATED' });
|
||||
});
|
||||
await waitFor(() => {
|
||||
// s2 会话请求仍在
|
||||
expect(screen.getByText('read_file')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConfirmationDialog — 拒绝记忆恢复', () => {
|
||||
it('渲染被记住拒绝的工具并提供"重新询问"恢复入口', async () => {
|
||||
denialsResult = [{ toolName: 'write_file', expiresInSeconds: 600 }];
|
||||
await mountWith([makeReq()]);
|
||||
// refreshRememberedDenials 异步完成,用 findBy 等待
|
||||
expect(await screen.findByText(/本会话中已记住拒绝的工具/)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/write_file — 重新询问/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('点击"重新询问"调用 resetRememberedDenial 并移除条目', async () => {
|
||||
denialsResult = [{ toolName: 'write_file', expiresInSeconds: 600 }];
|
||||
await mountWith([makeReq()]);
|
||||
fireEvent.click(await screen.findByText(/write_file — 重新询问/));
|
||||
await waitFor(() => {
|
||||
expect(window.metona.tool.resetRememberedDenial).toHaveBeenCalledWith('write_file', 's1');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/write_file — 重新询问/)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,9 @@
|
||||
import { Component, type ReactNode } from 'react';
|
||||
import { Box, Typography, Button } from '@mui/material';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -49,16 +52,40 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
render(): ReactNode {
|
||||
if (!this.state.hasError) return this.props.children;
|
||||
return (
|
||||
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 1, alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={24} color="var(--mui-palette-error-main)" />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||
{this.props.fallbackTitle ?? '组件渲染失败'}
|
||||
{this.props.fallbackTitle ?? t('errorBoundary.renderFailed')}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 10, textAlign: 'center', wordBreak: 'break-word', maxWidth: '80%' }}>
|
||||
{this.state.error?.message ?? '未知错误'}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
fontSize: 10,
|
||||
textAlign: 'center',
|
||||
wordBreak: 'break-word',
|
||||
maxWidth: '80%',
|
||||
}}
|
||||
>
|
||||
{this.state.error?.message ?? t('errorBoundary.unknownError')}
|
||||
</Typography>
|
||||
<Button size="small" variant="outlined" onClick={this.handleReset} sx={{ mt: 1, textTransform: 'none', fontSize: 11 }}>
|
||||
重试
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={this.handleReset}
|
||||
sx={{ mt: 1, textTransform: 'none', fontSize: 11 }}
|
||||
>
|
||||
{t('errorBoundary.retry')}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -354,7 +354,9 @@ export function AgentMonitor(): React.JSX.Element {
|
||||
sx={{ display: 'block', fontSize: 9, color: 'text.disabled', mt: 0.25 }}
|
||||
>
|
||||
{sub.durationMs != null ? formatDuration(sub.durationMs) : ''}
|
||||
{sub.iterations != null ? ` · ${sub.iterations} 轮` : ''}
|
||||
{sub.iterations != null
|
||||
? t('monitor.iterations', { count: sub.iterations })
|
||||
: ''}
|
||||
</Typography>
|
||||
)}
|
||||
{sub.error && (
|
||||
|
||||
@@ -17,6 +17,9 @@ import { WorkspaceViewer } from '@renderer/components/workspace/WorkspaceViewer'
|
||||
import { ErrorBoundary } from '@renderer/components/common/ErrorBoundary';
|
||||
import { LAYOUT } from '@renderer/lib/constants';
|
||||
import { useUIStore, type DetailTab } from '@renderer/stores/ui-store';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
export function DetailPanel(): React.JSX.Element {
|
||||
// v0.3.0: 从 ui-store 获取 tab 状态(支持斜杠命令切换)
|
||||
@@ -31,8 +34,14 @@ export function DetailPanel(): React.JSX.Element {
|
||||
<Box
|
||||
component="aside"
|
||||
sx={{
|
||||
flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||
bgcolor: 'background.paper', borderLeft: 1, borderColor: 'divider', width: LAYOUT.DETAIL_WIDTH,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'background.paper',
|
||||
borderLeft: 1,
|
||||
borderColor: 'divider',
|
||||
width: LAYOUT.DETAIL_WIDTH,
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
@@ -45,21 +54,45 @@ export function DetailPanel(): React.JSX.Element {
|
||||
}}
|
||||
variant="fullWidth"
|
||||
sx={{
|
||||
minHeight: 32, height: 32, flexShrink: 0,
|
||||
borderBottom: 1, borderColor: 'divider',
|
||||
'& .MuiTab-root': { minHeight: 32, height: 32, fontSize: 11, textTransform: 'none', py: 0, px: 1 },
|
||||
minHeight: 32,
|
||||
height: 32,
|
||||
flexShrink: 0,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
'& .MuiTab-root': {
|
||||
minHeight: 32,
|
||||
height: 32,
|
||||
fontSize: 11,
|
||||
textTransform: 'none',
|
||||
py: 0,
|
||||
px: 1,
|
||||
},
|
||||
'& .MuiTabs-indicator': { height: 2 },
|
||||
}}
|
||||
>
|
||||
<Tab icon={<Activity size={12} />} iconPosition="start" label="Trace" value="trace" />
|
||||
<Tab icon={<Brain size={12} />} iconPosition="start" label="Memory" value="memory" />
|
||||
<Tab icon={<ListChecks size={12} />} iconPosition="start" label="Tasks" value="tasks" />
|
||||
<Tab icon={<FolderOpen size={12} />} iconPosition="start" label="Workspace" value="workspace" />
|
||||
<Tab
|
||||
icon={<FolderOpen size={12} />}
|
||||
iconPosition="start"
|
||||
label="Workspace"
|
||||
value="workspace"
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
<Box sx={{ p: 1.5, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{safeTab === 'trace' && (
|
||||
<ErrorBoundary fallbackTitle="Trace 渲染失败">
|
||||
<ErrorBoundary fallbackTitle={t('detailPanel.error.trace')}>
|
||||
<>
|
||||
<TraceViewer />
|
||||
<TokenUsage />
|
||||
@@ -68,17 +101,17 @@ export function DetailPanel(): React.JSX.Element {
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
{safeTab === 'memory' && (
|
||||
<ErrorBoundary fallbackTitle="Memory 渲染失败">
|
||||
<ErrorBoundary fallbackTitle={t('detailPanel.error.memory')}>
|
||||
<MemoryViewer />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
{safeTab === 'tasks' && (
|
||||
<ErrorBoundary fallbackTitle="Tasks 渲染失败">
|
||||
<ErrorBoundary fallbackTitle={t('detailPanel.error.tasks')}>
|
||||
<TaskList />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
{safeTab === 'workspace' && (
|
||||
<ErrorBoundary fallbackTitle="Workspace 渲染失败">
|
||||
<ErrorBoundary fallbackTitle={t('detailPanel.error.workspace')}>
|
||||
<WorkspaceViewer />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
|
||||
@@ -14,19 +14,23 @@
|
||||
* - 设置入口
|
||||
*/
|
||||
|
||||
import { AppBar, Toolbar, Typography, IconButton, Box, Chip, Tooltip, Divider } from '@mui/material';
|
||||
import {
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
Focus,
|
||||
Settings,
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
} from 'lucide-react';
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Typography,
|
||||
IconButton,
|
||||
Box,
|
||||
Chip,
|
||||
Tooltip,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import { PanelLeft, PanelRight, Focus, Settings, Sun, Moon, Monitor } from 'lucide-react';
|
||||
import { useUIStore, type ThemeMode } from '@renderer/stores/ui-store';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { PROVIDER_LABELS } from '@renderer/lib/constants';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
export function Header(): React.JSX.Element {
|
||||
const sidebarVisible = useUIStore((s) => s.sidebarVisible);
|
||||
@@ -49,7 +53,12 @@ export function Header(): React.JSX.Element {
|
||||
};
|
||||
|
||||
const ThemeIcon = theme === 'light' ? Sun : theme === 'dark' ? Moon : Monitor;
|
||||
const themeLabel = theme === 'light' ? '浅色主题' : theme === 'dark' ? '深色主题' : '跟随系统';
|
||||
const themeLabel =
|
||||
theme === 'light'
|
||||
? t('header.theme.light')
|
||||
: theme === 'dark'
|
||||
? t('header.theme.dark')
|
||||
: t('header.theme.auto');
|
||||
|
||||
return (
|
||||
<AppBar
|
||||
@@ -99,7 +108,7 @@ export function Header(): React.JSX.Element {
|
||||
{/* 右侧:操作按钮 */}
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
|
||||
<Tooltip title={sidebarVisible ? '隐藏侧边栏' : '显示侧边栏'}>
|
||||
<Tooltip title={sidebarVisible ? t('header.hideSidebar') : t('header.showSidebar')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={toggleSidebar}
|
||||
@@ -110,7 +119,7 @@ export function Header(): React.JSX.Element {
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={detailVisible ? '隐藏详情面板' : '显示详情面板'}>
|
||||
<Tooltip title={detailVisible ? t('header.hideDetail') : t('header.showDetail')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={toggleDetail}
|
||||
@@ -121,7 +130,7 @@ export function Header(): React.JSX.Element {
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={focusMode ? '退出专注模式' : '进入专注模式'}>
|
||||
<Tooltip title={focusMode ? t('header.exitFocus') : t('header.enterFocus')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={toggleFocusMode}
|
||||
@@ -139,7 +148,7 @@ export function Header(): React.JSX.Element {
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="设置">
|
||||
<Tooltip title={t('header.settings')}>
|
||||
<IconButton size="small" onClick={openSettings}>
|
||||
<Settings size={18} />
|
||||
</IconButton>
|
||||
|
||||
@@ -429,22 +429,10 @@ function SessionItem({
|
||||
}
|
||||
useSessionStore.getState().removeSession(session.id);
|
||||
// 修复: 删除当前会话时同步清空 agent-store,否则 ChatPanel 和 DetailPanel 仍显示已删除会话的内容
|
||||
// 与 SettingsModal LogsSettings 的 clearSessions 分支一致
|
||||
// v0.7.4 P3-8: 收敛为统一 resetSessionState(此前三处 partial set 口径不一,
|
||||
// 且漏清 sessionRunStates/agentStatus)
|
||||
if (useAgentStore.getState().currentSessionId === session.id) {
|
||||
useAgentStore.setState({
|
||||
currentSessionId: null,
|
||||
messages: [],
|
||||
traceSteps: [],
|
||||
tokenUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
lastInputTokens: 0,
|
||||
lastCompressedSaved: 0,
|
||||
},
|
||||
currentRunId: null,
|
||||
currentIteration: 0,
|
||||
});
|
||||
useAgentStore.getState().resetSessionState();
|
||||
}
|
||||
setShowDeleteDialog(false);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* AgentMonitor 组件测试
|
||||
*
|
||||
* 覆盖:Agent 状态区、Provider/Model/迭代信息、总耗时、
|
||||
* SubAgent 事件监听(上限 20)、空状态(无 SubAgent 不渲染)、
|
||||
* maxIterations 配置加载。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, waitFor, act } from '@testing-library/react';
|
||||
import { AgentMonitor } from '../AgentMonitor';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
|
||||
const agentInitial = useAgentStore.getState();
|
||||
|
||||
/** 捕获 onSubAgentEvent 注册的回调 */
|
||||
let subAgentCallback: ((e: unknown) => void) | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
useAgentStore.setState(agentInitial, true);
|
||||
subAgentCallback = null;
|
||||
// restoreAllMocks 撤销上一个测试的 spyOn,随后重建组件依赖的
|
||||
// window.metona mock(setup.ts 的 vi.fn 实现会被还原)。
|
||||
vi.restoreAllMocks();
|
||||
const onSubAgentEvent = vi.fn((cb: (e: unknown) => void) => {
|
||||
subAgentCallback = cb;
|
||||
return () => {};
|
||||
});
|
||||
(window.metona.agent as unknown as { onSubAgentEvent: typeof onSubAgentEvent }).onSubAgentEvent =
|
||||
onSubAgentEvent;
|
||||
(window.metona.config as unknown as { get: (key: string) => Promise<unknown> }).get = vi
|
||||
.fn()
|
||||
.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
/** 测试用 SubAgent 事件载荷 */
|
||||
interface SubAgentEventPayload {
|
||||
taskId: string;
|
||||
parentSessionId: string;
|
||||
description: string;
|
||||
status: 'delegated' | 'running' | 'completed' | 'error';
|
||||
depth: number;
|
||||
durationMs?: number;
|
||||
iterations?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function makeSubAgentEvent(overrides: Partial<SubAgentEventPayload> = {}): SubAgentEventPayload {
|
||||
return {
|
||||
taskId: 'task_1',
|
||||
parentSessionId: 's1',
|
||||
description: '分析日志文件',
|
||||
status: 'running',
|
||||
depth: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AgentMonitor — 状态区', () => {
|
||||
it.each([
|
||||
['idle', '空闲'],
|
||||
['thinking', '思考中...'],
|
||||
['executing', '执行中...'],
|
||||
['error', '错误'],
|
||||
] as const)('%s 状态渲染对应文案', (status, label) => {
|
||||
useAgentStore.setState({ agentStatus: status });
|
||||
render(<AgentMonitor />);
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染 Provider 与模型', () => {
|
||||
useAgentStore.setState({ provider: 'deepseek', model: 'deepseek-chat' });
|
||||
render(<AgentMonitor />);
|
||||
expect(screen.getByText('DeepSeek')).toBeInTheDocument();
|
||||
expect(screen.getByText('deepseek-chat')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染迭代信息 currentIteration/maxIterations', () => {
|
||||
useAgentStore.setState({ currentIteration: 3, maxIterations: 10 });
|
||||
render(<AgentMonitor />);
|
||||
expect(screen.getByText('3 / 10')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('有完成的 trace step 时显示总耗时', () => {
|
||||
const now = Date.now();
|
||||
useAgentStore.setState({
|
||||
traceSteps: [
|
||||
{
|
||||
id: 't1',
|
||||
iteration: 1,
|
||||
state: 'THINKING',
|
||||
states: ['THINKING'],
|
||||
startedAt: now,
|
||||
completedAt: now + 5000,
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<AgentMonitor />);
|
||||
expect(screen.getByText('5.0s')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无完成的 trace step 时不显示总耗时行', () => {
|
||||
useAgentStore.setState({ traceSteps: [] });
|
||||
const { container } = render(<AgentMonitor />);
|
||||
expect(container.textContent).not.toContain('总耗时');
|
||||
});
|
||||
|
||||
it('加载 maxIterations 配置', async () => {
|
||||
const configGet = vi.spyOn(window.metona.config, 'get').mockResolvedValue(30);
|
||||
render(<AgentMonitor />);
|
||||
await waitFor(() => {
|
||||
expect(configGet).toHaveBeenCalledWith('agent.maxIterations');
|
||||
});
|
||||
expect(useAgentStore.getState().maxIterations).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentMonitor — SubAgent 列表', () => {
|
||||
it('无 SubAgent 事件时不渲染 SubAgent 区', () => {
|
||||
useAgentStore.setState({ currentSessionId: 's1' });
|
||||
render(<AgentMonitor />);
|
||||
expect(screen.queryByText('SubAgent 任务')).toBeNull();
|
||||
});
|
||||
|
||||
it('收到当前会话的 SubAgent 事件后渲染任务与描述', async () => {
|
||||
useAgentStore.setState({ currentSessionId: 's1' });
|
||||
render(<AgentMonitor />);
|
||||
act(() => {
|
||||
subAgentCallback?.(makeSubAgentEvent());
|
||||
});
|
||||
expect(await screen.findByText('分析日志文件')).toBeInTheDocument();
|
||||
expect(screen.getByText('SubAgent 任务')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('其他会话的 SubAgent 事件被过滤', async () => {
|
||||
useAgentStore.setState({ currentSessionId: 's1' });
|
||||
render(<AgentMonitor />);
|
||||
act(() => {
|
||||
subAgentCallback?.(makeSubAgentEvent({ parentSessionId: 's_other' }));
|
||||
});
|
||||
expect(screen.queryByText('SubAgent 任务')).toBeNull();
|
||||
});
|
||||
|
||||
it('渲染 SubAgent 状态标签(i18n)', async () => {
|
||||
useAgentStore.setState({ currentSessionId: 's1' });
|
||||
render(<AgentMonitor />);
|
||||
act(() => {
|
||||
subAgentCallback?.(makeSubAgentEvent({ status: 'completed' }));
|
||||
});
|
||||
expect(await screen.findByText('已完成')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染深度 L 标记(depth>1)', async () => {
|
||||
useAgentStore.setState({ currentSessionId: 's1' });
|
||||
render(<AgentMonitor />);
|
||||
act(() => {
|
||||
subAgentCallback?.(makeSubAgentEvent({ depth: 2 }));
|
||||
});
|
||||
expect(await screen.findByText('L2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染耗时与迭代数', async () => {
|
||||
useAgentStore.setState({ currentSessionId: 's1' });
|
||||
render(<AgentMonitor />);
|
||||
act(() => {
|
||||
subAgentCallback?.(makeSubAgentEvent({ durationMs: 12000, iterations: 4 }));
|
||||
});
|
||||
expect(await screen.findByText(/12.0s/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/4 轮/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染错误信息', async () => {
|
||||
useAgentStore.setState({ currentSessionId: 's1' });
|
||||
render(<AgentMonitor />);
|
||||
act(() => {
|
||||
subAgentCallback?.(makeSubAgentEvent({ status: 'error', error: '超时终止' }));
|
||||
});
|
||||
expect(await screen.findByText('超时终止')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('同 taskId 更新覆盖旧条目(新事件置顶)', async () => {
|
||||
useAgentStore.setState({ currentSessionId: 's1' });
|
||||
render(<AgentMonitor />);
|
||||
act(() => {
|
||||
subAgentCallback?.(
|
||||
makeSubAgentEvent({ taskId: 't1', description: 'v1', status: 'delegated' }),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
subAgentCallback?.(makeSubAgentEvent({ taskId: 't1', description: 'v2', status: 'running' }));
|
||||
});
|
||||
const items = await screen.findAllByText('运行中');
|
||||
expect(items.length).toBe(1);
|
||||
expect(screen.queryByText('v1')).toBeNull();
|
||||
expect(screen.getByText('v2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('SubAgent 列表最多显示 20 条', async () => {
|
||||
useAgentStore.setState({ currentSessionId: 's1' });
|
||||
render(<AgentMonitor />);
|
||||
act(() => {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
subAgentCallback?.(makeSubAgentEvent({ taskId: `t${i}`, description: `任务${i}` }));
|
||||
}
|
||||
});
|
||||
// 保留最新 20 条(t5..t24),最旧的 5 条(t0..t4)被挤出
|
||||
expect(await screen.findByText('任务24')).toBeInTheDocument();
|
||||
expect(screen.getByText('任务5')).toBeInTheDocument();
|
||||
expect(screen.queryByText('任务0')).toBeNull();
|
||||
expect(screen.queryByText('任务4')).toBeNull();
|
||||
});
|
||||
|
||||
it('切换会话时清空 SubAgent 列表', async () => {
|
||||
useAgentStore.setState({ currentSessionId: 's1' });
|
||||
render(<AgentMonitor />);
|
||||
act(() => {
|
||||
subAgentCallback?.(makeSubAgentEvent());
|
||||
});
|
||||
expect(await screen.findByText('分析日志文件')).toBeInTheDocument();
|
||||
useAgentStore.setState({ currentSessionId: 's2' });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('SubAgent 任务')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('卸载时解除事件订阅', () => {
|
||||
useAgentStore.setState({ currentSessionId: 's1' });
|
||||
const unsubscribe = vi.fn();
|
||||
(
|
||||
window.metona.agent as unknown as {
|
||||
onSubAgentEvent: (cb: (e: unknown) => void) => () => void;
|
||||
}
|
||||
).onSubAgentEvent = vi.fn(() => {
|
||||
subAgentCallback = null;
|
||||
return unsubscribe;
|
||||
});
|
||||
const { unmount } = render(<AgentMonitor />);
|
||||
unmount();
|
||||
expect(unsubscribe).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Header 组件测试
|
||||
*
|
||||
* 覆盖:标题、Provider/Model 显示、主题循环按钮、侧边栏/详情面板切换、
|
||||
* 专注模式、设置入口。
|
||||
*
|
||||
* 注:MUI v9 Tooltip 不在 DOM 上落 title 属性,按钮定位使用 lucide 图标类名。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { Header } from '../Header';
|
||||
import { useUIStore } from '@renderer/stores/ui-store';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
|
||||
const uiInitial = useUIStore.getState();
|
||||
const agentInitial = useAgentStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
useUIStore.setState(uiInitial, true);
|
||||
useAgentStore.setState(agentInitial, true);
|
||||
});
|
||||
|
||||
/** 通过 lucide 图标类名定位 IconButton */
|
||||
function buttonByIcon(iconClass: string): HTMLElement {
|
||||
const icon = document.querySelector(`.${iconClass}`) as HTMLElement | null;
|
||||
if (!icon) throw new Error(`icon ${iconClass} not found`);
|
||||
return icon.closest('button') as HTMLElement;
|
||||
}
|
||||
|
||||
describe('Header — 标题与 Provider', () => {
|
||||
it('渲染应用标题 MetonaAI', () => {
|
||||
render(<Header />);
|
||||
expect(screen.getByText('MetonaAI')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('有 provider 时渲染 Provider Chip 与模型名', () => {
|
||||
useAgentStore.setState({ provider: 'ollama', model: 'qwen3:8b' });
|
||||
render(<Header />);
|
||||
expect(screen.getByText('Ollama')).toBeInTheDocument();
|
||||
expect(screen.getByText('qwen3:8b')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无 provider 时不渲染 Provider 区域', () => {
|
||||
useAgentStore.setState({ provider: '', model: '' });
|
||||
const { container } = render(<Header />);
|
||||
expect(container.textContent).not.toContain('Ollama');
|
||||
});
|
||||
|
||||
it('未知 provider 直接显示原始字符串', () => {
|
||||
useAgentStore.setState({ provider: 'weird', model: 'm1' });
|
||||
render(<Header />);
|
||||
expect(screen.getByText('weird')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Header — 主题循环', () => {
|
||||
it('auto 主题渲染 Monitor 图标', () => {
|
||||
useUIStore.setState({ theme: 'auto' });
|
||||
render(<Header />);
|
||||
expect(document.querySelector('.lucide-monitor')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('light 主题渲染 Sun 图标', () => {
|
||||
useUIStore.setState({ theme: 'light' });
|
||||
render(<Header />);
|
||||
expect(document.querySelector('.lucide-sun')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('dark 主题渲染 Moon 图标', () => {
|
||||
useUIStore.setState({ theme: 'dark' });
|
||||
render(<Header />);
|
||||
expect(document.querySelector('.lucide-moon')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('点击主题按钮 auto→light', () => {
|
||||
useUIStore.setState({ theme: 'auto' });
|
||||
render(<Header />);
|
||||
fireEvent.click(buttonByIcon('lucide-monitor'));
|
||||
expect(useUIStore.getState().theme).toBe('light');
|
||||
});
|
||||
|
||||
it('点击主题按钮 light→dark', () => {
|
||||
useUIStore.setState({ theme: 'light' });
|
||||
render(<Header />);
|
||||
fireEvent.click(buttonByIcon('lucide-sun'));
|
||||
expect(useUIStore.getState().theme).toBe('dark');
|
||||
});
|
||||
|
||||
it('点击主题按钮 dark→auto', () => {
|
||||
useUIStore.setState({ theme: 'dark' });
|
||||
render(<Header />);
|
||||
fireEvent.click(buttonByIcon('lucide-moon'));
|
||||
expect(useUIStore.getState().theme).toBe('auto');
|
||||
});
|
||||
|
||||
it('主题循环按钮完整遍历一轮后回到初始', () => {
|
||||
useUIStore.setState({ theme: 'light' });
|
||||
render(<Header />);
|
||||
fireEvent.click(buttonByIcon('lucide-sun'));
|
||||
expect(useUIStore.getState().theme).toBe('dark');
|
||||
fireEvent.click(buttonByIcon('lucide-moon'));
|
||||
expect(useUIStore.getState().theme).toBe('auto');
|
||||
fireEvent.click(buttonByIcon('lucide-monitor'));
|
||||
expect(useUIStore.getState().theme).toBe('light');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Header — 面板切换', () => {
|
||||
it('点击侧边栏按钮切换 sidebarVisible', () => {
|
||||
useUIStore.setState({ sidebarVisible: true });
|
||||
render(<Header />);
|
||||
fireEvent.click(buttonByIcon('lucide-panel-left'));
|
||||
expect(useUIStore.getState().sidebarVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('侧边栏隐藏时再次点击恢复', () => {
|
||||
useUIStore.setState({ sidebarVisible: false });
|
||||
render(<Header />);
|
||||
fireEvent.click(buttonByIcon('lucide-panel-left'));
|
||||
expect(useUIStore.getState().sidebarVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('点击详情面板按钮切换 detailVisible', () => {
|
||||
useUIStore.setState({ detailVisible: true });
|
||||
render(<Header />);
|
||||
fireEvent.click(buttonByIcon('lucide-panel-right'));
|
||||
expect(useUIStore.getState().detailVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('点击专注模式按钮进入专注模式(隐藏两面板)', () => {
|
||||
useUIStore.setState({ sidebarVisible: true, detailVisible: true, focusMode: false });
|
||||
render(<Header />);
|
||||
fireEvent.click(buttonByIcon('lucide-focus'));
|
||||
const st = useUIStore.getState();
|
||||
expect(st.focusMode).toBe(true);
|
||||
expect(st.sidebarVisible).toBe(false);
|
||||
expect(st.detailVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('专注模式中点击专注按钮退出并恢复快照', () => {
|
||||
useUIStore.setState({
|
||||
focusMode: true,
|
||||
sidebarVisible: false,
|
||||
detailVisible: false,
|
||||
preFocusSidebarVisible: true,
|
||||
preFocusDetailVisible: true,
|
||||
});
|
||||
render(<Header />);
|
||||
fireEvent.click(buttonByIcon('lucide-focus'));
|
||||
const st = useUIStore.getState();
|
||||
expect(st.focusMode).toBe(false);
|
||||
expect(st.sidebarVisible).toBe(true);
|
||||
expect(st.detailVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('专注模式下侧边栏按钮禁用', () => {
|
||||
useUIStore.setState({ focusMode: true, sidebarVisible: false });
|
||||
render(<Header />);
|
||||
const sidebarBtn = buttonByIcon('lucide-panel-left');
|
||||
expect((sidebarBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Header — 设置入口', () => {
|
||||
it('点击设置按钮打开设置面板', () => {
|
||||
render(<Header />);
|
||||
fireEvent.click(buttonByIcon('lucide-settings'));
|
||||
expect(useUIStore.getState().settingsOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* StatusBar 组件测试
|
||||
*
|
||||
* 覆盖:Agent 状态标签(i18n)、Provider/Model 显示、Token 显示条件、
|
||||
* 版本号加载(window.metona.app.getVersion)、设置按钮、更新检查。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { StatusBar } from '../StatusBar';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { useUIStore } from '@renderer/stores/ui-store';
|
||||
|
||||
const agentInitial = useAgentStore.getState();
|
||||
const uiInitial = useUIStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
useAgentStore.setState(agentInitial, true);
|
||||
useUIStore.setState(uiInitial, true);
|
||||
// restoreAllMocks 撤销上一个测试的 spyOn(含 mockRejectedValue 残留),
|
||||
// 然后重建组件依赖的 window.metona mock(setup.ts 的 vi.fn 实现会被还原)。
|
||||
vi.restoreAllMocks();
|
||||
(window.metona.app as unknown as { getVersion: () => Promise<string> }).getVersion = vi
|
||||
.fn()
|
||||
.mockResolvedValue('0.7.4');
|
||||
(window.metona.app as unknown as { updateCheck: () => Promise<unknown> }).updateCheck = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ status: 'disabled', message: 'disabled' });
|
||||
});
|
||||
|
||||
describe('StatusBar — Agent 状态', () => {
|
||||
it.each([
|
||||
['idle', '空闲'],
|
||||
['thinking', '思考中...'],
|
||||
['executing', '执行中...'],
|
||||
['error', '错误'],
|
||||
] as const)('%s 状态渲染对应文案', (status, label) => {
|
||||
useAgentStore.setState({ agentStatus: status });
|
||||
render(<StatusBar />);
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StatusBar — Provider / Model', () => {
|
||||
it('渲染 Provider 标签与模型名', () => {
|
||||
useAgentStore.setState({ provider: 'deepseek', model: 'deepseek-v4' });
|
||||
render(<StatusBar />);
|
||||
expect(screen.getByText(/DeepSeek/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/deepseek-v4/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('未知 Provider 直接显示原始字符串', () => {
|
||||
useAgentStore.setState({ provider: 'custom-provider', model: 'model-x' });
|
||||
render(<StatusBar />);
|
||||
expect(screen.getByText(/custom-provider/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StatusBar — Token 显示', () => {
|
||||
it('totalTokens>0 时显示输入/输出 token', () => {
|
||||
useAgentStore.setState({
|
||||
tokenUsage: {
|
||||
inputTokens: 1200,
|
||||
outputTokens: 800,
|
||||
totalTokens: 2000,
|
||||
lastInputTokens: 0,
|
||||
lastCompressedSaved: 0,
|
||||
},
|
||||
});
|
||||
render(<StatusBar />);
|
||||
expect(screen.getByText('1.2K↓ 800↑')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('totalTokens=0 时不渲染 token 单元格', () => {
|
||||
useAgentStore.setState({
|
||||
tokenUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
lastInputTokens: 0,
|
||||
lastCompressedSaved: 0,
|
||||
},
|
||||
});
|
||||
const { container } = render(<StatusBar />);
|
||||
expect(container.textContent).not.toContain('↓');
|
||||
});
|
||||
});
|
||||
|
||||
describe('StatusBar — 版本号', () => {
|
||||
it('加载 window.metona.app.getVersion 并显示 v 前缀', async () => {
|
||||
const getVersion = vi.spyOn(window.metona.app, 'getVersion').mockResolvedValue('0.7.4');
|
||||
render(<StatusBar />);
|
||||
await waitFor(() => {
|
||||
expect(getVersion).toHaveBeenCalled();
|
||||
});
|
||||
expect(await screen.findByText('v0.7.4')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('getVersion 失败时回退显示 dev', async () => {
|
||||
vi.spyOn(window.metona.app, 'getVersion').mockRejectedValue(new Error('fail'));
|
||||
render(<StatusBar />);
|
||||
expect(await screen.findByText('dev')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StatusBar — 设置按钮', () => {
|
||||
it('点击设置按钮打开设置面板', () => {
|
||||
render(<StatusBar />);
|
||||
const settingsBtn = document.querySelector('.lucide-settings')?.closest('button');
|
||||
expect(settingsBtn).not.toBeNull();
|
||||
fireEvent.click(settingsBtn as HTMLElement);
|
||||
expect(useUIStore.getState().settingsOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StatusBar — 更新检查', () => {
|
||||
it('点击版本号触发 updateCheck', async () => {
|
||||
const updateCheck = vi
|
||||
.spyOn(window.metona.app, 'updateCheck')
|
||||
.mockResolvedValue({ status: 'up-to-date', latestVersion: '0.7.4' });
|
||||
render(<StatusBar />);
|
||||
const versionBtn = await screen.findByText('v0.7.4');
|
||||
fireEvent.click(versionBtn);
|
||||
await waitFor(() => {
|
||||
expect(updateCheck).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('更新检查进行中显示"检查中…"', async () => {
|
||||
let resolveCheck!: (v: { status: string; message: string }) => void;
|
||||
vi.spyOn(window.metona.app, 'updateCheck').mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveCheck = resolve;
|
||||
}) as never,
|
||||
);
|
||||
render(<StatusBar />);
|
||||
const versionBtn = await screen.findByText('v0.7.4');
|
||||
fireEvent.click(versionBtn);
|
||||
expect(screen.getByText('检查中…')).toBeInTheDocument();
|
||||
resolveCheck({ status: 'disabled', message: 'disabled' });
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
FormControl,
|
||||
} from '@mui/material';
|
||||
import { useConfig } from './useConfig';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
export function AgentSettings() {
|
||||
const [maxIter, setMaxIter] = useConfig('agent.maxIterations', 20);
|
||||
@@ -33,13 +36,13 @@ export function AgentSettings() {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Agent 配置
|
||||
{t('settings.agent.title')}
|
||||
</Typography>
|
||||
<FormControl size="small">
|
||||
<InputLabel>最大迭代次数</InputLabel>
|
||||
<InputLabel>{t('settings.agent.maxIterations')}</InputLabel>
|
||||
<Select
|
||||
value={maxIter}
|
||||
label="最大迭代次数"
|
||||
label={t('settings.agent.maxIterations')}
|
||||
onChange={(e) => setMaxIter(e.target.value as number)}
|
||||
>
|
||||
{MAX_ITER_OPTIONS.map((n) => (
|
||||
@@ -51,7 +54,7 @@ export function AgentSettings() {
|
||||
</FormControl>
|
||||
<TextField
|
||||
size="small"
|
||||
label="总超时(秒)"
|
||||
label={t('settings.agent.totalTimeout')}
|
||||
type="number"
|
||||
value={timeout / 1000}
|
||||
onChange={(e) => {
|
||||
@@ -62,7 +65,7 @@ export function AgentSettings() {
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="工具执行超时(秒)"
|
||||
label={t('settings.agent.toolExecTimeout')}
|
||||
type="number"
|
||||
value={toolExecTimeout / 1000}
|
||||
onChange={(e) => {
|
||||
@@ -70,11 +73,11 @@ export function AgentSettings() {
|
||||
if (v >= 10) setToolExecTimeout(v * 1000);
|
||||
}}
|
||||
slotProps={{ htmlInput: { min: 10, step: 10 } }}
|
||||
helperText="单个工具执行的最大时长,超时自动终止(最低 10 秒,无上限)"
|
||||
helperText={t('settings.agent.toolExecTimeoutHelper')}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="工具确认超时(秒)"
|
||||
label={t('settings.agent.confirmTimeout')}
|
||||
type="number"
|
||||
value={confirmTimeout / 1000}
|
||||
onChange={(e) => {
|
||||
@@ -82,7 +85,7 @@ export function AgentSettings() {
|
||||
if (v >= 30 && v <= 600) setConfirmTimeout(v * 1000);
|
||||
}}
|
||||
slotProps={{ htmlInput: { min: 30, max: 600, step: 10 } }}
|
||||
helperText="用户未响应工具确认时,超时自动视为拒绝(30~600 秒)"
|
||||
helperText={t('settings.agent.confirmTimeoutHelper')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
@@ -92,14 +95,14 @@ export function AgentSettings() {
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="body2">启用思考模式</Typography>}
|
||||
label={<Typography variant="body2">{t('settings.agent.enableThinking')}</Typography>}
|
||||
/>
|
||||
{thinking && (
|
||||
<FormControl size="small">
|
||||
<InputLabel>思考强度</InputLabel>
|
||||
<InputLabel>{t('settings.agent.thinkingEffort')}</InputLabel>
|
||||
<Select
|
||||
value={thinkingEffort}
|
||||
label="思考强度"
|
||||
label={t('settings.agent.thinkingEffort')}
|
||||
onChange={(e) => setThinkingEffort(e.target.value)}
|
||||
>
|
||||
<MenuItem value="low">Low</MenuItem>
|
||||
@@ -120,12 +123,12 @@ export function AgentSettings() {
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2">
|
||||
启用反思阶段(REFLECTING)
|
||||
{t('settings.agent.enableReflection')}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ display: 'block', color: 'text.disabled', fontSize: 10 }}
|
||||
>
|
||||
每轮工具执行后进入反思状态,存在失败结果时记录告警(不阻断执行)
|
||||
{t('settings.agent.enableReflectionHelper')}
|
||||
</Typography>
|
||||
</Typography>
|
||||
}
|
||||
|
||||
@@ -14,13 +14,17 @@ import { Button, Stack, Typography, Box } from '@mui/material';
|
||||
import type { ThemeMode } from '@renderer/stores/ui-store';
|
||||
import { useConfig } from './useConfig';
|
||||
import { setLocale, getLocale, type Locale } from '@renderer/lib/i18n';
|
||||
// 字典含注册副作用,须在 t() 使用前 import
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
const LOCALES: Array<{ value: Locale; label: string }> = [
|
||||
{ value: 'zh-CN', label: '简体中文' },
|
||||
{ value: 'en-US', label: 'English' },
|
||||
];
|
||||
// v0.7.4 P3-1: 语言标签渲染时求值(t() 不能在模块顶层固化)
|
||||
const LOCALE_VALUES: Locale[] = ['zh-CN', 'en-US'];
|
||||
function localeLabel(value: Locale): string {
|
||||
return value === 'zh-CN'
|
||||
? t('settings.appearance.localeZhCN')
|
||||
: t('settings.appearance.localeEnUS');
|
||||
}
|
||||
|
||||
export function AppearanceSettings({
|
||||
theme,
|
||||
@@ -41,44 +45,47 @@ export function AppearanceSettings({
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
外观
|
||||
{t('settings.appearance.title')}
|
||||
</Typography>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', mb: 1, display: 'block' }}>
|
||||
主题
|
||||
{t('settings.appearance.theme')}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{(['dark', 'light', 'auto'] as ThemeMode[]).map((t) => (
|
||||
{(['dark', 'light', 'auto'] as ThemeMode[]).map((t_) => (
|
||||
<Button
|
||||
key={t}
|
||||
variant={theme === t ? 'contained' : 'outlined'}
|
||||
key={t_}
|
||||
variant={theme === t_ ? 'contained' : 'outlined'}
|
||||
size="small"
|
||||
onClick={() => setTheme(t)}
|
||||
onClick={() => setTheme(t_)}
|
||||
>
|
||||
{t === 'dark' ? '深色' : t === 'light' ? '浅色' : '跟随系统'}
|
||||
{t_ === 'dark'
|
||||
? t('settings.appearance.dark')
|
||||
: t_ === 'light'
|
||||
? t('settings.appearance.light')
|
||||
: t('settings.appearance.auto')}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', mb: 1, display: 'block' }}>
|
||||
界面语言
|
||||
{t('settings.appearance.locale')}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{LOCALES.map((l) => (
|
||||
{LOCALE_VALUES.map((v) => (
|
||||
<Button
|
||||
key={l.value}
|
||||
variant={locale === l.value ? 'contained' : 'outlined'}
|
||||
key={v}
|
||||
variant={locale === v ? 'contained' : 'outlined'}
|
||||
size="small"
|
||||
onClick={() => handleLocaleChange(l.value)}
|
||||
onClick={() => handleLocaleChange(v)}
|
||||
>
|
||||
{l.label}
|
||||
{localeLabel(v)}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', mt: 1, display: 'block' }}>
|
||||
切换即时生效;已迁移的界面(系统消息 / 工具确认弹框 / 侧边栏 / 状态栏 / Agent
|
||||
状态区)将跟随所选语言显示
|
||||
{t('settings.appearance.localeHelper')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
@@ -399,7 +399,7 @@ export function LLMSettings() {
|
||||
}
|
||||
} catch (err) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`保存失败:${(err as Error).message}`))
|
||||
.then((mod) => mod.default.error(t('llm.save.failed', { message: (err as Error).message })))
|
||||
.catch(() => {});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
|
||||
@@ -28,6 +28,9 @@ import { useUIStore } from '@renderer/stores/ui-store';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { useSessionStore } from '@renderer/stores/session-store';
|
||||
import { formatFileSize } from '@renderer/lib/formatters';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
export function LogsSettings() {
|
||||
const [logLevel, setLogLevel] = useConfig('logging.level', 'info');
|
||||
@@ -73,12 +76,20 @@ export function LogsSettings() {
|
||||
const r = await window.metona?.app?.showItemInFolder?.(logFilePath);
|
||||
if (r && !r.success) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`打开失败: ${r.error ?? '未知错误'}`))
|
||||
.then((mod) =>
|
||||
mod.default.error(
|
||||
t('settings.logs.openFailed', {
|
||||
message: r.error ?? t('errorBoundary.unknownError'),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`打开失败: ${(e as Error).message}`))
|
||||
.then((mod) =>
|
||||
mod.default.error(t('settings.logs.openFailed', { message: (e as Error).message })),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
@@ -93,7 +104,9 @@ export function LogsSettings() {
|
||||
setCopyState('error');
|
||||
setTimeout(() => setCopyState('idle'), 1500);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`复制失败: ${(e as Error).message}`))
|
||||
.then((mod) =>
|
||||
mod.default.error(t('settings.logs.copyFailed') + `: ${(e as Error).message}`),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
@@ -103,20 +116,27 @@ export function LogsSettings() {
|
||||
try {
|
||||
const r = await window.metona.data.exportData();
|
||||
if (r.success && r.data) {
|
||||
// v0.7.4 P3-9: 复用共享 downloadBlob(统一释放 ObjectURL,根治导出泄漏)
|
||||
const { downloadBlob } = await import('@renderer/lib/export-markdown');
|
||||
const b = new Blob([JSON.stringify(r.data, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(b);
|
||||
a.download = `metona-export-${Date.now()}.json`;
|
||||
a.click();
|
||||
downloadBlob(`metona-export-${Date.now()}.json`, b);
|
||||
} else {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`导出失败: ${r.error ?? '未知错误'}`))
|
||||
.then((mod) =>
|
||||
mod.default.error(
|
||||
t('settings.logs.exportFailed', {
|
||||
message: r.error ?? t('errorBoundary.unknownError'),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[LogsSettings]', err);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`导出失败: ${(err as Error).message}`))
|
||||
.then((mod) =>
|
||||
mod.default.error(t('settings.logs.exportFailed', { message: (err as Error).message })),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
@@ -130,34 +150,44 @@ export function LogsSettings() {
|
||||
const r = await window.metona.audit.export(format);
|
||||
if (r.success && r.data != null) {
|
||||
const mime = format === 'csv' ? 'text/csv;charset=utf-8' : 'application/x-ndjson';
|
||||
// v0.7.4 P3-9: 复用共享 downloadBlob(统一释放 ObjectURL)
|
||||
const { downloadBlob } = await import('@renderer/lib/export-markdown');
|
||||
const b = new Blob([r.data], { type: mime });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(b);
|
||||
a.download = `metona-audit-${Date.now()}.${format}`;
|
||||
a.click();
|
||||
// 释放 Blob URL,避免内存泄漏
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
downloadBlob(`metona-audit-${Date.now()}.${format}`, b);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.success(`已导出 ${r.recordCount ?? 0} 条审计记录`))
|
||||
.then((mod) =>
|
||||
mod.default.success(t('settings.logs.auditExported', { count: r.recordCount ?? 0 })),
|
||||
)
|
||||
.catch(() => {});
|
||||
} else {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`审计导出失败: ${r.error ?? '未知错误'}`))
|
||||
.then((mod) =>
|
||||
mod.default.error(
|
||||
t('settings.logs.auditExportFailed', {
|
||||
message: r.error ?? t('errorBoundary.unknownError'),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[LogsSettings] Audit export failed:', err);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`审计导出失败: ${(err as Error).message}`))
|
||||
.then((mod) =>
|
||||
mod.default.error(
|
||||
t('settings.logs.auditExportFailed', { message: (err as Error).message }),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
} finally {
|
||||
setAuditExporting(null);
|
||||
}
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
sessions: '所有会话',
|
||||
memories: '所有记忆',
|
||||
auditLogs: '审计日志',
|
||||
// v0.7.4 P3-1: 数据分类标签渲染时求值(t() 不能在模块顶层固化)
|
||||
const dataLabels: Record<string, string> = {
|
||||
sessions: t('settings.logs.data.sessions'),
|
||||
memories: t('settings.logs.data.memories'),
|
||||
auditLogs: t('settings.logs.data.auditLogs'),
|
||||
};
|
||||
|
||||
// L-11 修复(审计补充): 清理数据改用 Dialog 确认,结果用 toast 反馈
|
||||
@@ -173,14 +203,17 @@ export function LogsSettings() {
|
||||
else r = await window.metona?.data?.clearAuditLogs();
|
||||
if (r?.success) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.success(`${labels[type]}已清理`))
|
||||
.then((mod) =>
|
||||
mod.default.success(t('settings.logs.cleared', { label: dataLabels[type] })),
|
||||
)
|
||||
.catch(() => {});
|
||||
// 修复: 清理会话后同步清空前端状态,无需重启应用
|
||||
// v0.7.4 P3-8: 收敛为统一 resetSessionState(此前 setMessages 只清消息,
|
||||
// 漏清 traceSteps/tokenUsage/currentRunId 等)
|
||||
if (type === 'sessions') {
|
||||
useSessionStore.getState().setSessions([]);
|
||||
useSessionStore.getState().setCurrentSession(null);
|
||||
// 同时清空当前消息列表,防止聊天面板显示已删除的会话内容
|
||||
useAgentStore.getState().setMessages([]);
|
||||
useAgentStore.getState().resetSessionState();
|
||||
}
|
||||
// v0.3.6 修复: 清理记忆后触发 MemoryViewer 重新加载(之前需重启应用才看到效果)
|
||||
if (type === 'memories') {
|
||||
@@ -188,12 +221,20 @@ export function LogsSettings() {
|
||||
}
|
||||
} else {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`失败: ${r?.error ?? '未知错误'}`))
|
||||
.then((mod) =>
|
||||
mod.default.error(
|
||||
t('settings.logs.clearFailed', {
|
||||
message: r?.error ?? t('errorBoundary.unknownError'),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`失败: ${(e as Error).message}`))
|
||||
.then((mod) =>
|
||||
mod.default.error(t('settings.logs.clearFailed', { message: (e as Error).message })),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
setClearing(null);
|
||||
@@ -202,11 +243,15 @@ export function LogsSettings() {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
日志与数据
|
||||
{t('settings.logs.title')}
|
||||
</Typography>
|
||||
<FormControl size="small">
|
||||
<InputLabel>日志级别</InputLabel>
|
||||
<Select value={logLevel} label="日志级别" onChange={(e) => setLogLevel(e.target.value)}>
|
||||
<InputLabel>{t('settings.logs.logLevel')}</InputLabel>
|
||||
<Select
|
||||
value={logLevel}
|
||||
label={t('settings.logs.logLevel')}
|
||||
onChange={(e) => setLogLevel(e.target.value)}
|
||||
>
|
||||
<MenuItem value="debug">DEBUG</MenuItem>
|
||||
<MenuItem value="info">INFO</MenuItem>
|
||||
<MenuItem value="warn">WARN</MenuItem>
|
||||
@@ -220,13 +265,15 @@ export function LogsSettings() {
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 600, color: 'text.secondary', mb: 0.5, display: 'block' }}
|
||||
>
|
||||
日志文件
|
||||
{t('settings.logs.logFile')}
|
||||
</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
value={logFilePath}
|
||||
placeholder={logPathLoading ? '正在获取路径...' : '路径不可用'}
|
||||
placeholder={
|
||||
logPathLoading ? t('settings.logs.pathLoading') : t('settings.logs.pathUnavailable')
|
||||
}
|
||||
slotProps={{ input: { readOnly: true, sx: { fontSize: 11, fontFamily: 'monospace' } } }}
|
||||
/>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||
@@ -237,7 +284,7 @@ export function LogsSettings() {
|
||||
onClick={handleOpenLogFolder}
|
||||
disabled={!logFilePath}
|
||||
>
|
||||
打开日志文件夹
|
||||
{t('settings.logs.openLogFolder')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
@@ -249,20 +296,24 @@ export function LogsSettings() {
|
||||
copyState === 'success' ? 'success' : copyState === 'error' ? 'error' : 'inherit'
|
||||
}
|
||||
>
|
||||
{copyState === 'success' ? '已复制' : copyState === 'error' ? '复制失败' : '复制路径'}
|
||||
{copyState === 'success'
|
||||
? t('settings.logs.copied')
|
||||
: copyState === 'error'
|
||||
? t('settings.logs.copyFailed')
|
||||
: t('settings.logs.copyPath')}
|
||||
</Button>
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', mt: 0.5, display: 'block' }}>
|
||||
日志级别变更重启后生效。日志文件按日期滚动,旧日志保留在 logs 目录下。
|
||||
{t('settings.logs.logLevelHelper')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||
数据管理
|
||||
{t('settings.logs.dataManagement')}
|
||||
</Typography>
|
||||
<Button variant="outlined" fullWidth size="small" onClick={handleExport}>
|
||||
📦 导出全部数据(JSON)
|
||||
{t('settings.logs.exportAllData')}
|
||||
</Button>
|
||||
{/* v0.5.0: 审计日志导出(含链式哈希字段,可离线验证完整性) */}
|
||||
<Stack direction="row" spacing={1}>
|
||||
@@ -273,7 +324,9 @@ export function LogsSettings() {
|
||||
onClick={() => handleAuditExport('jsonl')}
|
||||
disabled={auditExporting !== null}
|
||||
>
|
||||
{auditExporting === 'jsonl' ? '导出中...' : '📋 导出审计 (JSONL)'}
|
||||
{auditExporting === 'jsonl'
|
||||
? t('settings.logs.exporting')
|
||||
: t('settings.logs.exportAuditJsonl')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
@@ -282,7 +335,9 @@ export function LogsSettings() {
|
||||
onClick={() => handleAuditExport('csv')}
|
||||
disabled={auditExporting !== null}
|
||||
>
|
||||
{auditExporting === 'csv' ? '导出中...' : '📊 导出审计 (CSV)'}
|
||||
{auditExporting === 'csv'
|
||||
? t('settings.logs.exporting')
|
||||
: t('settings.logs.exportAuditCsv')}
|
||||
</Button>
|
||||
</Stack>
|
||||
<Button
|
||||
@@ -293,7 +348,7 @@ export function LogsSettings() {
|
||||
onClick={() => setConfirmClear('sessions')}
|
||||
disabled={clearing !== null}
|
||||
>
|
||||
{clearing === 'sessions' ? '清理中...' : '🗑️ 清理所有会话'}
|
||||
{clearing === 'sessions' ? t('settings.logs.clearing') : t('settings.logs.clearSessions')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
@@ -303,7 +358,7 @@ export function LogsSettings() {
|
||||
onClick={() => setConfirmClear('memories')}
|
||||
disabled={clearing !== null}
|
||||
>
|
||||
{clearing === 'memories' ? '清理中...' : '🗑️ 清理所有记忆'}
|
||||
{clearing === 'memories' ? t('settings.logs.clearing') : t('settings.logs.clearMemories')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
@@ -313,7 +368,7 @@ export function LogsSettings() {
|
||||
onClick={() => setConfirmClear('auditLogs')}
|
||||
disabled={clearing !== null}
|
||||
>
|
||||
{clearing === 'auditLogs' ? '清理中...' : '🗑️ 清理审计日志'}
|
||||
{clearing === 'auditLogs' ? t('settings.logs.clearing') : t('settings.logs.clearAuditLogs')}
|
||||
</Button>
|
||||
|
||||
{/* v0.7.3 P3-3: JSONL 录制文件生命周期(统计 + 手动清理) */}
|
||||
@@ -329,18 +384,20 @@ export function LogsSettings() {
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>确认清理</DialogTitle>
|
||||
<DialogTitle>{t('settings.logs.confirmClearTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2">
|
||||
确定清理{confirmClear ? labels[confirmClear] : ''}?此操作不可撤销。
|
||||
{t('settings.logs.confirmClearBody', {
|
||||
label: confirmClear ? dataLabels[confirmClear] : '',
|
||||
})}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmClear(null)} color="inherit">
|
||||
取消
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleClearConfirm} color="error" variant="contained">
|
||||
清理
|
||||
{t('settings.logs.clear')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
@@ -374,18 +431,25 @@ function TraceFilesPanel(): React.JSX.Element {
|
||||
if (r?.success) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) =>
|
||||
mod.default.success(`已清理 ${r.data?.deleted ?? 0} 个旧录制文件(保留最近 200 个)`),
|
||||
mod.default.success(t('settings.logs.pruneDone', { count: r.data?.deleted ?? 0 })),
|
||||
)
|
||||
.catch(() => {});
|
||||
void loadStats();
|
||||
} else {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r?.error ?? '清理失败'))
|
||||
.then((mod) =>
|
||||
mod.default.error(
|
||||
r?.error ??
|
||||
t('settings.logs.pruneFailed', { message: t('errorBoundary.unknownError') }),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`清理失败:${(err as Error).message}`))
|
||||
.then((mod) =>
|
||||
mod.default.error(t('settings.logs.pruneFailed', { message: (err as Error).message })),
|
||||
)
|
||||
.catch(() => {});
|
||||
} finally {
|
||||
setPruning(false);
|
||||
@@ -396,13 +460,16 @@ function TraceFilesPanel(): React.JSX.Element {
|
||||
<>
|
||||
<Divider />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||
会话录制文件(TRACE JSONL)
|
||||
{t('settings.logs.traceFilesTitle')}
|
||||
</Typography>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 11 }}>
|
||||
{stats
|
||||
? `${stats.count} 个文件 · 共 ${formatFileSize(stats.totalBytes)}(保留策略:最近 200 个,启动时自动清理)`
|
||||
: '统计中...'}
|
||||
? t('settings.logs.traceStats', {
|
||||
count: stats.count,
|
||||
size: formatFileSize(stats.totalBytes),
|
||||
})
|
||||
: t('settings.logs.statsLoading')}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||
<Button
|
||||
@@ -411,7 +478,7 @@ function TraceFilesPanel(): React.JSX.Element {
|
||||
startIcon={<RefreshCw size={14} />}
|
||||
onClick={() => void loadStats()}
|
||||
>
|
||||
刷新统计
|
||||
{t('settings.logs.refreshStats')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
@@ -420,7 +487,7 @@ function TraceFilesPanel(): React.JSX.Element {
|
||||
onClick={handlePrune}
|
||||
disabled={pruning || !stats || stats.count === 0}
|
||||
>
|
||||
{pruning ? '清理中...' : '清理旧录制文件'}
|
||||
{pruning ? t('settings.logs.clearing') : t('settings.logs.pruneOldFiles')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
@@ -466,12 +533,19 @@ function HealthSnapshotPanel(): React.JSX.Element {
|
||||
});
|
||||
} else {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r?.error ?? '校验失败'))
|
||||
.then((mod) =>
|
||||
mod.default.error(
|
||||
r?.error ??
|
||||
t('settings.logs.verifyFailed', { message: t('errorBoundary.unknownError') }),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`校验失败:${(err as Error).message}`))
|
||||
.then((mod) =>
|
||||
mod.default.error(t('settings.logs.verifyFailed', { message: (err as Error).message })),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
@@ -483,7 +557,7 @@ function HealthSnapshotPanel(): React.JSX.Element {
|
||||
<Divider />
|
||||
<Stack direction="row" sx={{ alignItems: 'center' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||
运行健康(SLO / 健康检查)
|
||||
{t('settings.logs.healthTitle')}
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -492,12 +566,12 @@ function HealthSnapshotPanel(): React.JSX.Element {
|
||||
onClick={() => void loadSnapshot()}
|
||||
disabled={loading}
|
||||
>
|
||||
刷新
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</Stack>
|
||||
{!snapshot ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 11 }}>
|
||||
{loading ? '加载中...' : '暂无快照'}
|
||||
{loading ? t('common.loading') : t('settings.logs.noSnapshot')}
|
||||
</Typography>
|
||||
) : (
|
||||
<Box
|
||||
@@ -511,42 +585,50 @@ function HealthSnapshotPanel(): React.JSX.Element {
|
||||
>
|
||||
<Stack spacing={0.5}>
|
||||
<HealthRow
|
||||
label="请求错误率(5 分钟窗口)"
|
||||
value={`${fmtPct(snapshot.slo.errorRate)}(${snapshot.slo.totalRequests} 次请求)`}
|
||||
label={t('settings.logs.health.errorRate')}
|
||||
value={t('settings.logs.health.errorRateValue', {
|
||||
pct: fmtPct(snapshot.slo.errorRate),
|
||||
count: snapshot.slo.totalRequests,
|
||||
})}
|
||||
warn={snapshot.slo.violated}
|
||||
/>
|
||||
<HealthRow
|
||||
label="延迟 P50 / P95 / P99"
|
||||
label={t('settings.logs.health.latency')}
|
||||
value={`${Math.round(snapshot.slo.percentiles['P50'] ?? 0)} / ${Math.round(
|
||||
snapshot.slo.percentiles['P95'] ?? 0,
|
||||
)} / ${Math.round(snapshot.slo.percentiles['P99'] ?? 0)} ms`}
|
||||
/>
|
||||
<HealthRow
|
||||
label="SLO 燃烧速率(目标 99.9%)"
|
||||
label={t('settings.logs.health.burnRate')}
|
||||
value={snapshot.slo.burnRate.toFixed(2)}
|
||||
warn={snapshot.slo.violated}
|
||||
/>
|
||||
<HealthRow
|
||||
label="健康检查"
|
||||
label={t('settings.logs.health.check')}
|
||||
value={
|
||||
snapshot.health
|
||||
? snapshot.health.healthy
|
||||
? '全部通过'
|
||||
: `异常:${snapshot.health.checks
|
||||
.filter((c) => !c.healthy)
|
||||
.map((c) => c.name)
|
||||
.join(', ')}`
|
||||
: '启动后尚未执行(周期 60s)'
|
||||
? t('settings.logs.health.allPassed')
|
||||
: t('settings.logs.health.abnormal', {
|
||||
checks: snapshot.health.checks
|
||||
.filter((c) => !c.healthy)
|
||||
.map((c) => c.name)
|
||||
.join(', '),
|
||||
})
|
||||
: t('settings.logs.health.notRunYet')
|
||||
}
|
||||
warn={snapshot.health ? !snapshot.health.healthy : false}
|
||||
/>
|
||||
{chainResult && (
|
||||
<HealthRow
|
||||
label="审计日志链校验"
|
||||
label={t('settings.logs.health.chainVerify')}
|
||||
value={
|
||||
chainResult.valid
|
||||
? `通过(${chainResult.verified}/${chainResult.total} 条)`
|
||||
: `已篡改!首个异常记录 #${chainResult.total}`
|
||||
? t('settings.logs.health.chainValid', {
|
||||
verified: chainResult.verified,
|
||||
total: chainResult.total,
|
||||
})
|
||||
: t('settings.logs.health.chainTampered', { index: chainResult.total })
|
||||
}
|
||||
warn={!chainResult.valid}
|
||||
/>
|
||||
@@ -558,7 +640,7 @@ function HealthSnapshotPanel(): React.JSX.Element {
|
||||
sx={{ mt: 1, fontSize: 10, minWidth: 100 }}
|
||||
onClick={handleVerifyChain}
|
||||
>
|
||||
校验审计日志链
|
||||
{t('settings.logs.health.verifyChainBtn')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
Box,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
export function MCPSettings() {
|
||||
const [servers, setServers] = useState<
|
||||
@@ -74,16 +77,14 @@ export function MCPSettings() {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(newHeaders.trim());
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('需要 JSON 对象');
|
||||
throw new Error(t('settings.mcp.headersInvalid'));
|
||||
}
|
||||
headers = Object.fromEntries(
|
||||
Object.entries(parsed as Record<string, unknown>).map(([k, v]) => [k, String(v)]),
|
||||
);
|
||||
} catch {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) =>
|
||||
mod.default.error('自定义请求头需为合法 JSON 对象,如 {"Authorization": "Bearer xxx"}'),
|
||||
)
|
||||
.then((mod) => mod.default.error(t('settings.mcp.headersInvalid')))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
@@ -118,13 +119,15 @@ export function MCPSettings() {
|
||||
loadServers();
|
||||
} else {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r?.error ?? '添加 MCP 服务失败'))
|
||||
.then((mod) => mod.default.error(r?.error ?? t('settings.mcp.addFailed')))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MCPSettings]', err);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`添加 MCP 服务失败:${(err as Error).message}`))
|
||||
.then((mod) =>
|
||||
mod.default.error(t('settings.mcp.addFailedMsg', { message: (err as Error).message })),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
@@ -154,21 +157,21 @@ export function MCPSettings() {
|
||||
setConfirmRemove(null);
|
||||
loadServers();
|
||||
} else {
|
||||
setRemoveError(r?.error ?? '移除失败');
|
||||
setRemoveError(r?.error ?? t('settings.mcp.removeFailed'));
|
||||
}
|
||||
} catch (err) {
|
||||
setRemoveError((err as Error).message ?? '移除失败');
|
||||
setRemoveError((err as Error).message ?? t('settings.mcp.removeFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
MCP 服务
|
||||
{t('settings.mcp.title')}
|
||||
</Typography>
|
||||
{servers.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, color: 'text.secondary' }}>
|
||||
暂无 MCP 服务
|
||||
{t('settings.mcp.empty')}
|
||||
</Typography>
|
||||
) : (
|
||||
servers.map((s) => (
|
||||
@@ -196,7 +199,7 @@ export function MCPSettings() {
|
||||
</Typography>
|
||||
{s.toolCount > 0 && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{s.toolCount} 工具
|
||||
{t('settings.mcp.toolCount', { count: s.toolCount })}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -214,18 +217,26 @@ export function MCPSettings() {
|
||||
loadServers();
|
||||
} else {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r?.error ?? '操作失败'))
|
||||
.then((mod) =>
|
||||
mod.default.error(r?.error ?? t('settings.mcp.toggleFailed')),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MCPSettings]', err);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`操作失败:${(err as Error).message}`))
|
||||
.then((mod) =>
|
||||
mod.default.error(
|
||||
t('settings.mcp.toggleFailedMsg', { message: (err as Error).message }),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{s.status === 'connected' ? '断开' : '连接'}
|
||||
{s.status === 'connected'
|
||||
? t('settings.mcp.disconnect')
|
||||
: t('settings.mcp.connect')}
|
||||
</Button>
|
||||
{/* L-11 修复: 点击移除打开 MUI Dialog 二次确认,而非原生 confirm() */}
|
||||
<Button
|
||||
@@ -234,7 +245,7 @@ export function MCPSettings() {
|
||||
sx={{ fontSize: 10, minWidth: 40 }}
|
||||
onClick={() => setConfirmRemove(s.name)}
|
||||
>
|
||||
移除
|
||||
{t('settings.mcp.remove')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
@@ -251,14 +262,14 @@ export function MCPSettings() {
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>确认移除</DialogTitle>
|
||||
<DialogTitle>{t('settings.mcp.removeConfirmTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2">
|
||||
确定移除 MCP 服务 "{confirmRemove}"?此操作不可撤销。
|
||||
{t('settings.mcp.removeConfirmBody', { name: confirmRemove ?? '' })}
|
||||
</Typography>
|
||||
{removeError && (
|
||||
<Alert severity="error" sx={{ mt: 1, fontSize: 12 }}>
|
||||
移除失败:{removeError}
|
||||
{t('settings.mcp.removeFailedMsg', { message: removeError })}
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
@@ -270,10 +281,10 @@ export function MCPSettings() {
|
||||
}}
|
||||
color="inherit"
|
||||
>
|
||||
取消
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleConfirmRemove} color="error" variant="contained">
|
||||
移除
|
||||
{t('settings.mcp.remove')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
@@ -292,7 +303,7 @@ export function MCPSettings() {
|
||||
size="small"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="服务名称"
|
||||
placeholder={t('settings.mcp.serverName')}
|
||||
/>
|
||||
{/* v0.4.1: 传输方式选择(stdio 本地命令 / streamable-http 远程) */}
|
||||
<Stack direction="row" spacing={1}>
|
||||
@@ -302,7 +313,7 @@ export function MCPSettings() {
|
||||
onClick={() => setNewTransport('stdio')}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
本地 (stdio)
|
||||
{t('settings.mcp.localStdio')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={newTransport === 'streamable-http' ? 'contained' : 'outlined'}
|
||||
@@ -310,7 +321,7 @@ export function MCPSettings() {
|
||||
onClick={() => setNewTransport('streamable-http')}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
远程 (HTTP)
|
||||
{t('settings.mcp.remoteHttp')}
|
||||
</Button>
|
||||
</Stack>
|
||||
{newTransport === 'stdio' ? (
|
||||
@@ -319,13 +330,13 @@ export function MCPSettings() {
|
||||
size="small"
|
||||
value={newCommand}
|
||||
onChange={(e) => setNewCommand(e.target.value)}
|
||||
placeholder="命令路径(如 npx / node / python)"
|
||||
placeholder={t('settings.mcp.commandPlaceholder')}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
value={newArgs}
|
||||
onChange={(e) => setNewArgs(e.target.value)}
|
||||
placeholder="参数 (空格分隔)"
|
||||
placeholder={t('settings.mcp.argsPlaceholder')}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -334,14 +345,14 @@ export function MCPSettings() {
|
||||
size="small"
|
||||
value={newUrl}
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
placeholder="Streamable HTTP URL(如 https://example.com/mcp)"
|
||||
placeholder={t('settings.mcp.urlPlaceholder')}
|
||||
/>
|
||||
{/* v0.7.2 P2-8: 远程传输自定义请求头(鉴权/网关路由)—— 留空匿名连接 */}
|
||||
<TextField
|
||||
size="small"
|
||||
value={newHeaders}
|
||||
onChange={(e) => setNewHeaders(e.target.value)}
|
||||
placeholder='自定义请求头 (JSON,可选),如 {"Authorization": "Bearer xxx"}'
|
||||
placeholder={t('settings.mcp.headersPlaceholder')}
|
||||
multiline
|
||||
minRows={2}
|
||||
slotProps={{ input: { sx: { fontFamily: 'monospace', fontSize: 11 } } }}
|
||||
@@ -358,7 +369,7 @@ export function MCPSettings() {
|
||||
}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
添加
|
||||
{t('settings.mcp.add')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
@@ -366,13 +377,13 @@ export function MCPSettings() {
|
||||
onClick={() => setShowAdd(false)}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
取消
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : (
|
||||
<Button variant="outlined" fullWidth size="small" onClick={() => setShowAdd(true)}>
|
||||
+ 添加 MCP 服务
|
||||
{t('settings.mcp.addServer')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -31,6 +31,9 @@ import {
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { Eye, EyeOff, Save } from 'lucide-react';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
interface DraftConfig {
|
||||
url: string;
|
||||
@@ -145,12 +148,12 @@ export function SearXNGSettings() {
|
||||
setBaseline(draft);
|
||||
setSavedNotice(true);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.success('SearXNG 配置已保存'))
|
||||
.then((mod) => mod.default.success(t('settings.searxng.saved')))
|
||||
.catch(() => {});
|
||||
} catch (err) {
|
||||
console.error('[SearXNGSettings]', err);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error('保存失败'))
|
||||
.then((mod) => mod.default.error(t('settings.searxng.saveFailed')))
|
||||
.catch(() => {});
|
||||
}
|
||||
setSaving(false);
|
||||
@@ -169,7 +172,7 @@ export function SearXNGSettings() {
|
||||
console.error('[SearXNGSettings] toggle failed:', err);
|
||||
setEnabledState(!checked);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error('开关保存失败'))
|
||||
.then((mod) => mod.default.error(t('settings.searxng.toggleFailed')))
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
@@ -187,11 +190,15 @@ export function SearXNGSettings() {
|
||||
draft.auth_type,
|
||||
);
|
||||
if (result?.success) {
|
||||
setTestResult({ success: true, message: `连接成功(${result.latencyMs}ms)` });
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: t('settings.searxng.testSuccess', { latency: result.latencyMs }),
|
||||
});
|
||||
} else {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: result?.error || `连接失败(HTTP ${result?.statusCode})`,
|
||||
message:
|
||||
result?.error || t('settings.searxng.testFailedHttp', { code: result?.statusCode }),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -205,7 +212,7 @@ export function SearXNGSettings() {
|
||||
<Stack spacing={1} sx={{ alignItems: 'center', py: 3 }}>
|
||||
<CircularProgress size={18} />
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
|
||||
正在读取配置...
|
||||
{t('settings.searxng.loading')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
@@ -216,18 +223,17 @@ export function SearXNGSettings() {
|
||||
{/* 标题 + 状态徽章 */}
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
SearXNG 元搜索
|
||||
{t('settings.searxng.title')}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={enabled ? '已启用' : '未启用'}
|
||||
label={enabled ? t('settings.searxng.enabled') : t('settings.searxng.disabled')}
|
||||
size="small"
|
||||
color={enabled ? 'success' : 'default'}
|
||||
variant={enabled ? 'filled' : 'outlined'}
|
||||
/>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
SearXNG 是开源元搜索引擎,支持 70+
|
||||
搜索引擎聚合。启用后替代内置四引擎搜索通道,未启用时回退到 Bing + 百度 + 搜狗 + 360 搜索。
|
||||
{t('settings.searxng.description')}
|
||||
</Typography>
|
||||
|
||||
{/* 启用开关(即时生效的总开关) */}
|
||||
@@ -237,10 +243,10 @@ export function SearXNGSettings() {
|
||||
checked={enabled}
|
||||
onChange={(e) => void handleToggleEnabled(e.target.checked)}
|
||||
size="small"
|
||||
slotProps={{ input: { 'aria-label': '启用 SearXNG' } }}
|
||||
slotProps={{ input: { 'aria-label': t('settings.searxng.enableAria') } }}
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="body2">启用 SearXNG</Typography>}
|
||||
label={<Typography variant="body2">{t('settings.searxng.enable')}</Typography>}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
@@ -249,14 +255,12 @@ export function SearXNGSettings() {
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'flex-start' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="API 地址"
|
||||
label={t('settings.searxng.urlLabel')}
|
||||
value={draft.url}
|
||||
onChange={(e) => updateField('url', e.target.value)}
|
||||
placeholder="如 https://searxng.example.com"
|
||||
placeholder={t('settings.searxng.urlPlaceholder')}
|
||||
error={urlError}
|
||||
helperText={
|
||||
urlError ? '需以 http:// 或 https:// 开头' : '实例根地址(不含 /search 路径)'
|
||||
}
|
||||
helperText={urlError ? t('settings.searxng.urlError') : t('settings.searxng.urlHelper')}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
@@ -266,7 +270,7 @@ export function SearXNGSettings() {
|
||||
disabled={!draft.url.trim() || urlError || testing}
|
||||
sx={{ mt: 0.5, minWidth: 90, height: 40 }}
|
||||
>
|
||||
{testing ? <CircularProgress size={14} /> : '测试连接'}
|
||||
{testing ? <CircularProgress size={14} /> : t('settings.searxng.test')}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -283,39 +287,39 @@ export function SearXNGSettings() {
|
||||
{/* 搜索引擎 */}
|
||||
<TextField
|
||||
size="small"
|
||||
label="搜索引擎(逗号分隔)"
|
||||
label={t('settings.searxng.enginesLabel')}
|
||||
value={draft.engines}
|
||||
onChange={(e) => updateField('engines', e.target.value)}
|
||||
placeholder="如 google,bing,duckduckgo(留空使用实例默认)"
|
||||
placeholder={t('settings.searxng.enginesPlaceholder')}
|
||||
/>
|
||||
|
||||
{/* 语言 + 安全搜索 */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>语言</InputLabel>
|
||||
<InputLabel>{t('settings.searxng.language')}</InputLabel>
|
||||
<Select
|
||||
value={draft.language}
|
||||
label="语言"
|
||||
label={t('settings.searxng.language')}
|
||||
onChange={(e) => updateField('language', e.target.value)}
|
||||
>
|
||||
<MenuItem value="zh-CN">简体中文</MenuItem>
|
||||
<MenuItem value="zh-TW">繁體中文</MenuItem>
|
||||
<MenuItem value="zh-CN">{t('settings.searxng.langZhCN')}</MenuItem>
|
||||
<MenuItem value="zh-TW">{t('settings.searxng.langZhTW')}</MenuItem>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="ja">日本語</MenuItem>
|
||||
<MenuItem value="ko">한국어</MenuItem>
|
||||
<MenuItem value="auto">自动检测</MenuItem>
|
||||
<MenuItem value="auto">{t('settings.searxng.langAuto')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small">
|
||||
<InputLabel>安全搜索</InputLabel>
|
||||
<InputLabel>{t('settings.searxng.safesearch')}</InputLabel>
|
||||
<Select
|
||||
value={draft.safesearch}
|
||||
label="安全搜索"
|
||||
label={t('settings.searxng.safesearch')}
|
||||
onChange={(e) => updateField('safesearch', Number(e.target.value))}
|
||||
>
|
||||
<MenuItem value={0}>关闭</MenuItem>
|
||||
<MenuItem value={1}>中等</MenuItem>
|
||||
<MenuItem value={2}>严格</MenuItem>
|
||||
<MenuItem value={0}>{t('settings.searxng.safeOff')}</MenuItem>
|
||||
<MenuItem value={1}>{t('settings.searxng.safeMedium')}</MenuItem>
|
||||
<MenuItem value={2}>{t('settings.searxng.safeStrict')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
@@ -323,28 +327,28 @@ export function SearXNGSettings() {
|
||||
{/* 时间范围 + 返回格式 */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>时间范围</InputLabel>
|
||||
<InputLabel>{t('settings.searxng.timeRange')}</InputLabel>
|
||||
<Select
|
||||
value={draft.time_range}
|
||||
label="时间范围"
|
||||
label={t('settings.searxng.timeRange')}
|
||||
onChange={(e) => updateField('time_range', e.target.value)}
|
||||
>
|
||||
<MenuItem value="">不限</MenuItem>
|
||||
<MenuItem value="day">一天</MenuItem>
|
||||
<MenuItem value="week">一周</MenuItem>
|
||||
<MenuItem value="month">一月</MenuItem>
|
||||
<MenuItem value="year">一年</MenuItem>
|
||||
<MenuItem value="">{t('settings.searxng.timeAny')}</MenuItem>
|
||||
<MenuItem value="day">{t('settings.searxng.timeDay')}</MenuItem>
|
||||
<MenuItem value="week">{t('settings.searxng.timeWeek')}</MenuItem>
|
||||
<MenuItem value="month">{t('settings.searxng.timeMonth')}</MenuItem>
|
||||
<MenuItem value="year">{t('settings.searxng.timeYear')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small">
|
||||
<InputLabel>返回格式</InputLabel>
|
||||
<InputLabel>{t('settings.searxng.format')}</InputLabel>
|
||||
<Select
|
||||
value={draft.format}
|
||||
label="返回格式"
|
||||
label={t('settings.searxng.format')}
|
||||
onChange={(e) => updateField('format', e.target.value)}
|
||||
>
|
||||
<MenuItem value="json">JSON(结构化解析)</MenuItem>
|
||||
<MenuItem value="html">HTML(原始网页)</MenuItem>
|
||||
<MenuItem value="json">{t('settings.searxng.formatJson')}</MenuItem>
|
||||
<MenuItem value="html">{t('settings.searxng.formatHtml')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
@@ -353,38 +357,34 @@ export function SearXNGSettings() {
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="最大结果数"
|
||||
label={t('settings.searxng.maxResults')}
|
||||
type="number"
|
||||
value={draft.max_results}
|
||||
onChange={(e) =>
|
||||
updateField('max_results', clampNumber(Number(e.target.value), 0, 50))
|
||||
}
|
||||
placeholder="0 表示使用默认"
|
||||
onChange={(e) => updateField('max_results', clampNumber(Number(e.target.value), 0, 50))}
|
||||
placeholder={t('settings.searxng.maxResultsPlaceholder')}
|
||||
slotProps={{ htmlInput: { min: 0, max: 50 } }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="自动抓取条数"
|
||||
label={t('settings.searxng.fetchCount')}
|
||||
type="number"
|
||||
value={draft.fetch_count}
|
||||
onChange={(e) =>
|
||||
updateField('fetch_count', clampNumber(Number(e.target.value), 0, 8))
|
||||
}
|
||||
placeholder="0 表示由 AI 决定"
|
||||
onChange={(e) => updateField('fetch_count', clampNumber(Number(e.target.value), 0, 8))}
|
||||
placeholder={t('settings.searxng.fetchCountPlaceholder')}
|
||||
slotProps={{ htmlInput: { min: 0, max: 8 } }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 抓取类型 */}
|
||||
<FormControl size="small">
|
||||
<InputLabel>抓取类型</InputLabel>
|
||||
<InputLabel>{t('settings.searxng.fetchMode')}</InputLabel>
|
||||
<Select
|
||||
value={draft.fetch_mode}
|
||||
label="抓取类型"
|
||||
label={t('settings.searxng.fetchMode')}
|
||||
onChange={(e) => updateField('fetch_mode', e.target.value)}
|
||||
>
|
||||
<MenuItem value="sequential">顺序抓取</MenuItem>
|
||||
<MenuItem value="random">随机抓取</MenuItem>
|
||||
<MenuItem value="sequential">{t('settings.searxng.fetchSequential')}</MenuItem>
|
||||
<MenuItem value="random">{t('settings.searxng.fetchRandom')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
@@ -392,14 +392,14 @@ export function SearXNGSettings() {
|
||||
|
||||
{/* 认证设置 */}
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||
认证设置
|
||||
{t('settings.searxng.authTitle')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 2 }}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>认证类型</InputLabel>
|
||||
<InputLabel>{t('settings.searxng.authType')}</InputLabel>
|
||||
<Select
|
||||
value={draft.auth_type}
|
||||
label="认证类型"
|
||||
label={t('settings.searxng.authType')}
|
||||
onChange={(e) => updateField('auth_type', e.target.value)}
|
||||
>
|
||||
<MenuItem value="bearer">Bearer Token</MenuItem>
|
||||
@@ -408,18 +408,28 @@ export function SearXNGSettings() {
|
||||
</FormControl>
|
||||
<TextField
|
||||
size="small"
|
||||
label={draft.auth_type === 'bearer' ? 'Token' : '用户名:密码'}
|
||||
label={
|
||||
draft.auth_type === 'bearer'
|
||||
? t('settings.searxng.authToken')
|
||||
: t('settings.searxng.authUserPass')
|
||||
}
|
||||
type={showKey ? 'text' : 'password'}
|
||||
value={draft.auth_key}
|
||||
onChange={(e) => updateField('auth_key', e.target.value)}
|
||||
placeholder={draft.auth_type === 'bearer' ? '访问令牌原值' : 'username:password'}
|
||||
placeholder={
|
||||
draft.auth_type === 'bearer'
|
||||
? t('settings.searxng.authTokenPlaceholder')
|
||||
: t('settings.searxng.authUserPassPlaceholder')
|
||||
}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
aria-label={showKey ? '隐藏密钥' : '显示密钥'}
|
||||
aria-label={
|
||||
showKey ? t('settings.searxng.hideKey') : t('settings.searxng.showKey')
|
||||
}
|
||||
>
|
||||
{showKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</IconButton>
|
||||
@@ -430,8 +440,8 @@ export function SearXNGSettings() {
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
|
||||
{draft.auth_type === 'bearer'
|
||||
? 'Bearer: 直接填写令牌原值,原样透传到 Authorization 头。建议配合 HTTPS 使用。'
|
||||
: 'Basic: 填写 username:password 明文串,系统自动 Base64 编码。必须配合 HTTPS 使用。'}
|
||||
? t('settings.searxng.authBearerHelper')
|
||||
: t('settings.searxng.authBasicHelper')}
|
||||
</Typography>
|
||||
|
||||
{/* 保存区(批量草稿提交 + 变更提示) */}
|
||||
@@ -444,16 +454,16 @@ export function SearXNGSettings() {
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{saving ? '保存中...' : '保存更改'}
|
||||
{saving ? t('settings.searxng.saving') : t('settings.searxng.saveChanges')}
|
||||
</Button>
|
||||
{dirty && (
|
||||
<Typography variant="caption" sx={{ color: 'warning.main' }}>
|
||||
有未保存的修改
|
||||
{t('settings.searxng.dirty')}
|
||||
</Typography>
|
||||
)}
|
||||
{!dirty && savedNotice && (
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
|
||||
已保存
|
||||
{t('settings.searxng.savedNotice')}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useUIStore } from '@renderer/stores/ui-store';
|
||||
import { ErrorBoundary } from '@renderer/components/common/ErrorBoundary';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
import { WorkspaceSettings } from './WorkspaceSettings';
|
||||
import { LLMSettings } from './LLMSettings';
|
||||
import { AgentSettings } from './AgentSettings';
|
||||
@@ -41,15 +44,16 @@ type SettingsTab =
|
||||
| 'searxng'
|
||||
| 'appearance'
|
||||
| 'logs';
|
||||
const TABS: { id: SettingsTab; label: string; icon: typeof Settings }[] = [
|
||||
{ id: 'workspace', label: '工作空间', icon: FolderOpen },
|
||||
{ id: 'llm', label: 'LLM 配置', icon: Bot },
|
||||
{ id: 'agent', label: 'Agent 配置', icon: Settings },
|
||||
{ id: 'tools', label: '工具管理', icon: Wrench },
|
||||
{ id: 'mcp', label: 'MCP 服务', icon: Server },
|
||||
{ id: 'searxng', label: 'SearXNG', icon: Globe },
|
||||
{ id: 'appearance', label: '外观', icon: Palette },
|
||||
{ id: 'logs', label: '日志与数据', icon: FileText },
|
||||
// v0.7.4 P3-1: Tab 标签渲染时求值(t() 不能在模块顶层固化 —— i18next 字典注册是异步的)
|
||||
const TABS: { id: SettingsTab; label: () => string; icon: typeof Settings }[] = [
|
||||
{ id: 'workspace', label: () => t('settings.tabs.workspace'), icon: FolderOpen },
|
||||
{ id: 'llm', label: () => t('settings.tabs.llm'), icon: Bot },
|
||||
{ id: 'agent', label: () => t('settings.tabs.agent'), icon: Settings },
|
||||
{ id: 'tools', label: () => t('settings.tabs.tools'), icon: Wrench },
|
||||
{ id: 'mcp', label: () => t('settings.tabs.mcp'), icon: Server },
|
||||
{ id: 'searxng', label: () => t('settings.tabs.searxng'), icon: Globe },
|
||||
{ id: 'appearance', label: () => t('settings.tabs.appearance'), icon: Palette },
|
||||
{ id: 'logs', label: () => t('settings.tabs.logs'), icon: FileText },
|
||||
];
|
||||
|
||||
export function SettingsModal(): React.JSX.Element | null {
|
||||
@@ -89,7 +93,7 @@ export function SettingsModal(): React.JSX.Element | null {
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">设置</Typography>
|
||||
<Typography variant="h6">{t('settings.title')}</Typography>
|
||||
<IconButton size="small" onClick={closeSettings}>
|
||||
<X size={14} />
|
||||
</IconButton>
|
||||
@@ -115,39 +119,39 @@ export function SettingsModal(): React.JSX.Element | null {
|
||||
'& .MuiTab-iconWrapper': { display: 'flex', alignItems: 'center', marginRight: 0 },
|
||||
}}
|
||||
>
|
||||
{TABS.map((t) => (
|
||||
{TABS.map((t_) => (
|
||||
<Tab
|
||||
key={t.id}
|
||||
value={t.id}
|
||||
label={t.label}
|
||||
icon={<t.icon size={14} />}
|
||||
key={t_.id}
|
||||
value={t_.id}
|
||||
label={t_.label()}
|
||||
icon={<t_.icon size={14} />}
|
||||
iconPosition="start"
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5 }}>
|
||||
<ErrorBoundary fallbackTitle="工作空间设置渲染失败">
|
||||
<ErrorBoundary fallbackTitle={t('settings.error.workspace')}>
|
||||
{tab === 'workspace' && <WorkspaceSettings />}
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary fallbackTitle="LLM 配置渲染失败">
|
||||
<ErrorBoundary fallbackTitle={t('settings.error.llm')}>
|
||||
{tab === 'llm' && <LLMSettings />}
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary fallbackTitle="Agent 配置渲染失败">
|
||||
<ErrorBoundary fallbackTitle={t('settings.error.agent')}>
|
||||
{tab === 'agent' && <AgentSettings />}
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary fallbackTitle="工具管理渲染失败">
|
||||
<ErrorBoundary fallbackTitle={t('settings.error.tools')}>
|
||||
{tab === 'tools' && <ToolsSettings />}
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary fallbackTitle="MCP 服务渲染失败">
|
||||
<ErrorBoundary fallbackTitle={t('settings.error.mcp')}>
|
||||
{tab === 'mcp' && <MCPSettings />}
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary fallbackTitle="SearXNG 渲染失败">
|
||||
<ErrorBoundary fallbackTitle={t('settings.error.searxng')}>
|
||||
{tab === 'searxng' && <SearXNGSettings />}
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary fallbackTitle="外观设置渲染失败">
|
||||
<ErrorBoundary fallbackTitle={t('settings.error.appearance')}>
|
||||
{tab === 'appearance' && <AppearanceSettings theme={theme} setTheme={setTheme} />}
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary fallbackTitle="日志与数据渲染失败">
|
||||
<ErrorBoundary fallbackTitle={t('settings.error.logs')}>
|
||||
{tab === 'logs' && <LogsSettings />}
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
|
||||
@@ -9,6 +9,9 @@ import { useState, useEffect } from 'react';
|
||||
import { Button, Stack, Typography, Checkbox, Divider, Chip, Box, TextField } from '@mui/material';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import { useConfig } from './useConfig';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
export function ToolsSettings() {
|
||||
const [tools, setTools] = useState<
|
||||
@@ -28,12 +31,12 @@ export function ToolsSettings() {
|
||||
.list()
|
||||
.then((l) =>
|
||||
setTools(
|
||||
(l as MetonaToolInfo[]).map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
riskLevel: t.riskLevel,
|
||||
requiresPermission: t.requiresPermission,
|
||||
enabled: t.enabled,
|
||||
(l as MetonaToolInfo[]).map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
riskLevel: tool.riskLevel,
|
||||
requiresPermission: tool.requiresPermission,
|
||||
enabled: tool.enabled,
|
||||
})),
|
||||
),
|
||||
)
|
||||
@@ -63,20 +66,24 @@ export function ToolsSettings() {
|
||||
// v0.3.6 修复: 乐观更新失败时回滚 UI,避免开关显示与实际状态不一致
|
||||
// 注意: 只回滚失败的单个工具(用 !enabled),不能用 setTools(prev) 整体回滚,
|
||||
// 否则会覆盖 await 期间用户对其他工具的并发修改
|
||||
setTools((p) => p.map((t) => (t.name === name ? { ...t, enabled } : t)));
|
||||
setTools((p) => p.map((tool) => (tool.name === name ? { ...tool, enabled } : tool)));
|
||||
try {
|
||||
const r = await window.metona?.tools?.toggle(name, enabled);
|
||||
if (r && !r.success) {
|
||||
setTools((p) => p.map((t) => (t.name === name ? { ...t, enabled: !enabled } : t)));
|
||||
setTools((p) =>
|
||||
p.map((tool) => (tool.name === name ? { ...tool, enabled: !enabled } : tool)),
|
||||
);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r.error ?? '切换工具失败'))
|
||||
.then((mod) => mod.default.error(r.error ?? t('settings.tools.toggleFailed')))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ToolsSettings]', err);
|
||||
setTools((p) => p.map((t) => (t.name === name ? { ...t, enabled: !enabled } : t)));
|
||||
setTools((p) =>
|
||||
p.map((tool) => (tool.name === name ? { ...tool, enabled: !enabled } : tool)),
|
||||
);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error('切换工具失败'))
|
||||
.then((mod) => mod.default.error(t('settings.tools.toggleFailed')))
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
@@ -90,13 +97,13 @@ export function ToolsSettings() {
|
||||
setAutoExecList((p) => (enabled ? [...p, name] : p.filter((n) => n !== name)));
|
||||
} else {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r.error ?? '设置自动执行失败'))
|
||||
.then((mod) => mod.default.error(r.error ?? t('settings.tools.autoExecFailed')))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ToolsSettings]', err);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error('设置自动执行失败'))
|
||||
.then((mod) => mod.default.error(t('settings.tools.autoExecFailed')))
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
@@ -112,28 +119,28 @@ export function ToolsSettings() {
|
||||
|
||||
// 需要确认的工具(high/critical 或 requiresPermission)
|
||||
const needsConfirmTools = tools.filter(
|
||||
(t) => t.riskLevel === 'high' || t.riskLevel === 'critical' || t.requiresPermission,
|
||||
(tool) => tool.riskLevel === 'high' || tool.riskLevel === 'critical' || tool.requiresPermission,
|
||||
);
|
||||
// 需要确认但未设为自动执行的工具
|
||||
const pendingConfirmTools = needsConfirmTools.filter((t) => !autoExecList.includes(t.name));
|
||||
const pendingConfirmTools = needsConfirmTools.filter((tool) => !autoExecList.includes(tool.name));
|
||||
// 已设为自动执行的工具详情
|
||||
const autoExecToolDetails = autoExecList
|
||||
.map((name) => tools.find((t) => t.name === name))
|
||||
.filter((t): t is NonNullable<typeof t> => t !== undefined);
|
||||
.map((name) => tools.find((tool) => tool.name === name))
|
||||
.filter((tool): tool is NonNullable<typeof tool> => tool !== undefined);
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
工具管理
|
||||
{t('settings.tools.title')}
|
||||
</Typography>
|
||||
{tools.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, color: 'text.secondary' }}>
|
||||
加载中...
|
||||
{t('common.loading')}
|
||||
</Typography>
|
||||
) : (
|
||||
tools.map((t) => (
|
||||
tools.map((tool) => (
|
||||
<Stack
|
||||
key={t.name}
|
||||
key={tool.name}
|
||||
direction="row"
|
||||
sx={{
|
||||
py: 1,
|
||||
@@ -146,26 +153,26 @@ export function ToolsSettings() {
|
||||
>
|
||||
<Stack direction="row" spacing={1} sx={{ minWidth: 0, flex: 1, alignItems: 'center' }}>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||
{t.name}
|
||||
{tool.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{t.description.slice(0, 40)}
|
||||
{tool.description.slice(0, 40)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Chip
|
||||
label={t.riskLevel.toUpperCase()}
|
||||
label={tool.riskLevel.toUpperCase()}
|
||||
size="small"
|
||||
color={riskColors[t.riskLevel]}
|
||||
color={riskColors[tool.riskLevel]}
|
||||
variant="outlined"
|
||||
sx={{ height: 18, fontSize: 9 }}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={t.enabled}
|
||||
onChange={(e) => handleToggle(t.name, e.target.checked)}
|
||||
checked={tool.enabled}
|
||||
onChange={(e) => handleToggle(tool.name, e.target.checked)}
|
||||
size="small"
|
||||
/>
|
||||
</Stack>
|
||||
@@ -178,10 +185,10 @@ export function ToolsSettings() {
|
||||
{/* ===== 自动执行工具管理 ===== */}
|
||||
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
自动执行工具
|
||||
{t('settings.tools.autoExecTitle')}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${autoExecList.length} 个`}
|
||||
label={t('settings.tools.autoExecCount', { count: autoExecList.length })}
|
||||
size="small"
|
||||
color={autoExecList.length > 0 ? 'success' : 'default'}
|
||||
variant="outlined"
|
||||
@@ -189,7 +196,7 @@ export function ToolsSettings() {
|
||||
/>
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', lineHeight: 1.5 }}>
|
||||
已设为自动执行的工具将跳过用户确认步骤,直接执行。此设置跨会话持久化。
|
||||
{t('settings.tools.autoExecHelper')}
|
||||
</Typography>
|
||||
|
||||
{/* 已自动执行的工具列表 */}
|
||||
@@ -198,12 +205,12 @@ export function ToolsSettings() {
|
||||
variant="caption"
|
||||
sx={{ textAlign: 'center', py: 2, color: 'text.disabled', fontStyle: 'italic' }}
|
||||
>
|
||||
暂无自动执行工具
|
||||
{t('settings.tools.noAutoExec')}
|
||||
</Typography>
|
||||
) : (
|
||||
autoExecToolDetails.map((t) => (
|
||||
autoExecToolDetails.map((tool) => (
|
||||
<Stack
|
||||
key={t.name}
|
||||
key={tool.name}
|
||||
direction="row"
|
||||
sx={(theme) => ({
|
||||
py: 1,
|
||||
@@ -237,18 +244,23 @@ export function ToolsSettings() {
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{t.name}
|
||||
{tool.name}
|
||||
</Typography>
|
||||
<Chip label="自动" size="small" color="success" sx={{ height: 16, fontSize: 9 }} />
|
||||
<Chip
|
||||
label={t('settings.tools.autoChip')}
|
||||
size="small"
|
||||
color="success"
|
||||
sx={{ height: 16, fontSize: 9 }}
|
||||
/>
|
||||
</Stack>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
variant="contained"
|
||||
sx={{ fontSize: 11, minWidth: 72, fontWeight: 600 }}
|
||||
onClick={() => handleSetAutoExec(t.name, false)}
|
||||
onClick={() => handleSetAutoExec(tool.name, false)}
|
||||
>
|
||||
取消自动
|
||||
{t('settings.tools.cancelAuto')}
|
||||
</Button>
|
||||
</Stack>
|
||||
))
|
||||
@@ -258,11 +270,11 @@ export function ToolsSettings() {
|
||||
{pendingConfirmTools.length > 0 && (
|
||||
<>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary', mt: 1 }}>
|
||||
可设为自动执行(当前需要确认)
|
||||
{t('settings.tools.availableAutoExec')}
|
||||
</Typography>
|
||||
{pendingConfirmTools.map((t) => (
|
||||
{pendingConfirmTools.map((tool) => (
|
||||
<Stack
|
||||
key={t.name}
|
||||
key={tool.name}
|
||||
direction="row"
|
||||
sx={{
|
||||
py: 1,
|
||||
@@ -284,12 +296,12 @@ export function ToolsSettings() {
|
||||
variant="body2"
|
||||
sx={{ fontFamily: 'monospace', fontSize: 12, color: 'text.primary' }}
|
||||
>
|
||||
{t.name}
|
||||
{tool.name}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={t.riskLevel.toUpperCase()}
|
||||
label={tool.riskLevel.toUpperCase()}
|
||||
size="small"
|
||||
color={riskColors[t.riskLevel]}
|
||||
color={riskColors[tool.riskLevel]}
|
||||
variant="outlined"
|
||||
sx={{ height: 16, fontSize: 9 }}
|
||||
/>
|
||||
@@ -299,9 +311,9 @@ export function ToolsSettings() {
|
||||
color="success"
|
||||
variant="contained"
|
||||
sx={{ fontSize: 11, minWidth: 72, fontWeight: 600 }}
|
||||
onClick={() => handleSetAutoExec(t.name, true)}
|
||||
onClick={() => handleSetAutoExec(tool.name, true)}
|
||||
>
|
||||
设为自动
|
||||
{t('settings.tools.setAuto')}
|
||||
</Button>
|
||||
</Stack>
|
||||
))}
|
||||
@@ -311,7 +323,7 @@ export function ToolsSettings() {
|
||||
{/* ===== 网络代理(v0.6.4 P4-5)===== */}
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
网络代理
|
||||
{t('settings.tools.proxyTitle')}
|
||||
</Typography>
|
||||
<ProxyField />
|
||||
</Stack>
|
||||
@@ -336,14 +348,13 @@ function ProxyField(): React.JSX.Element {
|
||||
}
|
||||
}, [proxyUrl, synced]);
|
||||
|
||||
const invalid =
|
||||
!!draft.trim() && !/^(https?|socks[45]):\/\//i.test(draft.trim());
|
||||
const invalid = !!draft.trim() && !/^(https?|socks[45]):\/\//i.test(draft.trim());
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
size="small"
|
||||
label="代理地址(可选)"
|
||||
label={t('settings.tools.proxyLabel')}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
@@ -356,13 +367,9 @@ function ProxyField(): React.JSX.Element {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
placeholder="http://127.0.0.1:7890 或 socks5://…(留空直连)"
|
||||
placeholder={t('settings.tools.proxyPlaceholder')}
|
||||
error={invalid}
|
||||
helperText={
|
||||
invalid
|
||||
? '需以 http://、https://、socks5:// 或 socks4:// 开头'
|
||||
: '应用于 Chromium 会话与主进程全部网络请求;未填时回退系统环境变量 HTTPS_PROXY。'
|
||||
}
|
||||
helperText={invalid ? t('settings.tools.proxyError') : t('settings.tools.proxyHelper')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
FormControlLabel,
|
||||
} from '@mui/material';
|
||||
import { useConfig } from './useConfig';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
export function WorkspaceSettings() {
|
||||
const [workspacePath, setWorkspacePath] = useConfig('workspace.path', '');
|
||||
@@ -86,7 +89,11 @@ export function WorkspaceSettings() {
|
||||
if (!integrityResult?.success) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) =>
|
||||
mod.default.error(`源数据库校验失败:${integrityResult?.error ?? '未知错误'}`),
|
||||
mod.default.error(
|
||||
t('settings.workspace.dbIntegrityFailed', {
|
||||
message: integrityResult?.error ?? t('errorBoundary.unknownError'),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
setApplying(false);
|
||||
@@ -95,7 +102,11 @@ export function WorkspaceSettings() {
|
||||
if (!integrityResult.ok) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) =>
|
||||
mod.default.error(`源数据库损坏(${integrityResult.detail}),无法继承`),
|
||||
mod.default.error(
|
||||
t('settings.workspace.dbCorrupted', {
|
||||
detail: integrityResult.detail,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
setApplying(false);
|
||||
@@ -104,7 +115,11 @@ export function WorkspaceSettings() {
|
||||
} catch (err) {
|
||||
console.error('[WorkspaceSettings] Database integrity check failed:', err);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`源数据库校验异常:${(err as Error).message}`))
|
||||
.then((mod) =>
|
||||
mod.default.error(
|
||||
t('settings.workspace.dbIntegrityError', { message: (err as Error).message }),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
setApplying(false);
|
||||
return;
|
||||
@@ -123,7 +138,11 @@ export function WorkspaceSettings() {
|
||||
console.error('[WorkspaceSettings] Inherit failed:', err);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) =>
|
||||
mod.default.warning(`部分文件继承失败:${(err as Error).message},请手动检查`),
|
||||
mod.default.warning(
|
||||
t('settings.workspace.inheritPartialFailed', {
|
||||
message: (err as Error).message,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
@@ -140,7 +159,11 @@ export function WorkspaceSettings() {
|
||||
console.error('[WorkspaceSettings]', err);
|
||||
// 用户主动操作(切换工作空间)失败必须有反馈
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`切换工作空间失败:${(err as Error).message}`))
|
||||
.then((mod) =>
|
||||
mod.default.error(
|
||||
t('settings.workspace.switchFailed', { message: (err as Error).message }),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
} finally {
|
||||
setApplying(false);
|
||||
@@ -158,13 +181,13 @@ export function WorkspaceSettings() {
|
||||
const r = await window.metona?.app?.showItemInFolder(workspacePath);
|
||||
if (r && !r.success) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r.error ?? '打开文件夹失败'))
|
||||
.then((mod) => mod.default.error(r.error ?? t('settings.workspace.openFolderFailed')))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WorkspaceSettings]', err);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error('打开文件夹失败'))
|
||||
.then((mod) => mod.default.error(t('settings.workspace.openFolderFailed')))
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
@@ -172,10 +195,10 @@ export function WorkspaceSettings() {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
工作空间
|
||||
{t('settings.workspace.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
工作空间是 Metona 的组织核心,包含 SOUL.md、MEMORY.md 两个必需文件。
|
||||
{t('settings.workspace.description')}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
{/* F-5: 手输路径只更新草稿(pendingPath),不实时落库 */}
|
||||
@@ -183,7 +206,7 @@ export function WorkspaceSettings() {
|
||||
size="small"
|
||||
value={pendingPath ?? workspacePath}
|
||||
onChange={(e) => setPendingPath(e.target.value)}
|
||||
placeholder="~/MetonaWorkspaces/default/"
|
||||
placeholder={t('settings.workspace.placeholder')}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
@@ -192,10 +215,10 @@ export function WorkspaceSettings() {
|
||||
onClick={() => validatePath(pendingPath ?? workspacePath)}
|
||||
disabled={checking || !(pendingPath ?? workspacePath).trim()}
|
||||
>
|
||||
{checking ? '校验中...' : '校验'}
|
||||
{checking ? t('settings.workspace.checking') : t('settings.workspace.check')}
|
||||
</Button>
|
||||
<Button variant="outlined" size="small" onClick={handleSelect}>
|
||||
选择文件夹
|
||||
{t('onboarding.workspace.pick')}
|
||||
</Button>
|
||||
</Stack>
|
||||
{workspacePath && (
|
||||
@@ -205,7 +228,7 @@ export function WorkspaceSettings() {
|
||||
onClick={handleOpen}
|
||||
sx={{ alignSelf: 'flex-start', fontSize: 11, color: 'text.secondary' }}
|
||||
>
|
||||
📂 在文件管理器中打开
|
||||
{t('settings.workspace.openInFileManager')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -214,7 +237,7 @@ export function WorkspaceSettings() {
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', py: 1 }}>
|
||||
<CircularProgress size={14} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
正在校验工作空间...
|
||||
{t('settings.workspace.checkingInProgress')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
@@ -222,10 +245,12 @@ export function WorkspaceSettings() {
|
||||
{/* 校验失败 */}
|
||||
{pendingPath && checkResult && !checkResult.valid && (
|
||||
<Alert severity="error" sx={{ py: 0.5 }}>
|
||||
<Typography variant="caption">路径无效:{checkResult.reason}</Typography>
|
||||
<Typography variant="caption">
|
||||
{t('settings.workspace.invalidPath', { reason: checkResult.reason })}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||
<Button size="small" onClick={handleCancel}>
|
||||
取消
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
@@ -244,21 +269,23 @@ export function WorkspaceSettings() {
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.primary' }}>
|
||||
目标工作空间:{checkResult.path}
|
||||
{t('settings.workspace.targetWorkspace', { path: checkResult.path })}
|
||||
</Typography>
|
||||
|
||||
{checkResult.isNewWorkspace ? (
|
||||
<Alert severity="info" sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
|
||||
新工作空间 — 切换后将自动创建 2 个必需文件(SOUL.md、MEMORY.md)
|
||||
{t('settings.workspace.newWorkspace')}
|
||||
</Alert>
|
||||
) : checkResult.missingFiles && checkResult.missingFiles.length > 0 ? (
|
||||
<Alert severity="warning" sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
|
||||
已有目录但缺少 {checkResult.missingFiles.length} 个文件:
|
||||
{checkResult.missingFiles.join(', ')}。缺失文件将自动创建。
|
||||
{t('settings.workspace.missingFiles', {
|
||||
count: checkResult.missingFiles.length,
|
||||
files: checkResult.missingFiles.join(', '),
|
||||
})}
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert severity="success" sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
|
||||
已有工作空间 — 2 个必需文件均已就绪,将直接加载现有配置。
|
||||
{t('settings.workspace.existingWorkspace')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -268,7 +295,7 @@ export function WorkspaceSettings() {
|
||||
(checkResult.missingFiles?.includes('SOUL.md') || !checkResult.dbExists) && (
|
||||
<Stack spacing={0.5} sx={{ mt: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||
从当前工作空间继承:
|
||||
{t('settings.workspace.inheritFromCurrent')}
|
||||
</Typography>
|
||||
{/* SOUL.md 继承:目标缺少 SOUL.md 时才允许勾选,避免覆盖已有定义 */}
|
||||
{checkResult.missingFiles && checkResult.missingFiles.includes('SOUL.md') && (
|
||||
@@ -280,7 +307,11 @@ export function WorkspaceSettings() {
|
||||
onChange={(e) => setInheritSoul(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="caption">SOUL.md(身份与角色定义)</Typography>}
|
||||
label={
|
||||
<Typography variant="caption">
|
||||
{t('settings.workspace.inheritSoul')}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* 数据库继承:目标 .metona/agent.db 不存在时才显示,避免覆盖已有工作空间数据 */}
|
||||
@@ -296,7 +327,7 @@ export function WorkspaceSettings() {
|
||||
}
|
||||
label={
|
||||
<Typography variant="caption">
|
||||
数据库 agent.db(会话/消息/记忆/Trace 历史记录)
|
||||
{t('settings.workspace.inheritDatabase')}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
@@ -304,13 +335,12 @@ export function WorkspaceSettings() {
|
||||
variant="caption"
|
||||
sx={{ color: 'text.disabled', fontSize: 10, pl: 3 }}
|
||||
>
|
||||
勾选后将复制当前工作空间的全部历史数据到新工作空间(通过 SQLite backup API
|
||||
原子性导出)
|
||||
{t('settings.workspace.inheritDatabaseHelper')}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10, pl: 3 }}>
|
||||
MEMORY.md 不继承(记忆与工作空间项目上下文绑定)
|
||||
{t('settings.workspace.memoryNotInherited')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
@@ -323,17 +353,17 @@ export function WorkspaceSettings() {
|
||||
disabled={applying}
|
||||
startIcon={applying ? <CircularProgress size={12} /> : undefined}
|
||||
>
|
||||
{applying ? '应用中...' : '确认切换'}
|
||||
{applying ? t('settings.workspace.applying') : t('settings.workspace.confirmSwitch')}
|
||||
</Button>
|
||||
<Button variant="outlined" size="small" onClick={handleCancel} disabled={applying}>
|
||||
取消
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
|
||||
修改工作空间路径后需重启应用生效。
|
||||
{t('settings.workspace.restartHint')}
|
||||
</Typography>
|
||||
|
||||
{/* 重启确认对话框 */}
|
||||
@@ -343,10 +373,10 @@ export function WorkspaceSettings() {
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle sx={{ fontSize: 14 }}>工作空间已切换</DialogTitle>
|
||||
<DialogTitle sx={{ fontSize: 14 }}>{t('settings.workspace.switchedTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
工作空间已更新为:
|
||||
{t('settings.workspace.switchedBody')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
@@ -362,12 +392,12 @@ export function WorkspaceSettings() {
|
||||
{workspacePath}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1.5, color: 'text.secondary' }}>
|
||||
需要重启应用以加载新工作空间的配置和文件。是否立即重启?
|
||||
{t('settings.workspace.restartQuestion')}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button size="small" onClick={() => setShowRestartDialog(false)}>
|
||||
稍后手动重启
|
||||
{t('settings.workspace.restartLater')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -375,7 +405,7 @@ export function WorkspaceSettings() {
|
||||
color="primary"
|
||||
onClick={() => window.metona?.app?.restart()}
|
||||
>
|
||||
立即重启
|
||||
{t('settings.workspace.restartNow')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
export function useConfig<T>(key: string, defaultValue: T): [T, (v: T) => void] {
|
||||
const [value, setValue] = useState<T>(defaultValue);
|
||||
@@ -41,7 +44,7 @@ export function useConfig<T>(key: string, defaultValue: T): [T, (v: T) => void]
|
||||
// 只有当没有后续 set 操作时才回滚,避免覆盖用户的新修改
|
||||
if (seqRef.current === seq) setValue(prev);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r.error ?? '配置保存失败'))
|
||||
.then((mod) => mod.default.error(r.error ?? t('settings.saveFailed')))
|
||||
.catch(() => {});
|
||||
}
|
||||
})
|
||||
@@ -49,7 +52,7 @@ export function useConfig<T>(key: string, defaultValue: T): [T, (v: T) => void]
|
||||
console.error('[useConfig]', err);
|
||||
if (seqRef.current === seq) setValue(prev);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error('配置保存失败'))
|
||||
.then((mod) => mod.default.error(t('settings.saveFailed')))
|
||||
.catch(() => {});
|
||||
});
|
||||
},
|
||||
|
||||
@@ -6,12 +6,27 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Box, Typography, Stack, List, ListItem, ListItemIcon,
|
||||
Checkbox, Chip, IconButton, TextField, Button, Select, MenuItem, Collapse,
|
||||
Box,
|
||||
Typography,
|
||||
Stack,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
Checkbox,
|
||||
Chip,
|
||||
IconButton,
|
||||
TextField,
|
||||
Button,
|
||||
Select,
|
||||
MenuItem,
|
||||
Collapse,
|
||||
} from '@mui/material';
|
||||
import { ListChecks, Plus, Trash2, X, ChevronRight, Circle } from 'lucide-react';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { formatTime } from '@renderer/lib/formatters';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
// ===== 类型 =====
|
||||
|
||||
@@ -20,13 +35,38 @@ type TaskPriority = MetonaTask['priority'];
|
||||
|
||||
// ===== 常量 =====
|
||||
|
||||
const STATUS_LABELS: Record<TaskStatus, string> = {
|
||||
pending: '待处理',
|
||||
in_progress: '进行中',
|
||||
completed: '已完成',
|
||||
blocked: '阻塞',
|
||||
cancelled: '已取消',
|
||||
};
|
||||
// v0.7.4 P3-1: 状态/优先级文案渲染时求值(t() 不能在模块顶层固化)
|
||||
function statusLabel(status: TaskStatus): string {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return t('taskList.status.pending');
|
||||
case 'in_progress':
|
||||
return t('taskList.status.inProgress');
|
||||
case 'completed':
|
||||
return t('taskList.status.completed');
|
||||
case 'blocked':
|
||||
return t('taskList.status.blocked');
|
||||
case 'cancelled':
|
||||
return t('taskList.status.cancelled');
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function priorityLabel(priority: TaskPriority): string {
|
||||
switch (priority) {
|
||||
case 'low':
|
||||
return t('taskList.priority.low');
|
||||
case 'medium':
|
||||
return t('taskList.priority.medium');
|
||||
case 'high':
|
||||
return t('taskList.priority.high');
|
||||
case 'critical':
|
||||
return t('taskList.priority.critical');
|
||||
default:
|
||||
return priority;
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<TaskStatus, string> = {
|
||||
pending: '#f59e0b',
|
||||
@@ -36,13 +76,6 @@ const STATUS_COLORS: Record<TaskStatus, string> = {
|
||||
cancelled: '#64748b',
|
||||
};
|
||||
|
||||
const PRIORITY_LABELS: Record<TaskPriority, string> = {
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
critical: '紧急',
|
||||
};
|
||||
|
||||
const PRIORITY_COLORS: Record<TaskPriority, string> = {
|
||||
low: '#64748b',
|
||||
medium: '#3b82f6',
|
||||
@@ -75,7 +108,7 @@ export function TaskList(): React.JSX.Element {
|
||||
// 竞态保护:若已被新请求取代或组件已卸载,放弃本次结果
|
||||
if (loadReqIdRef.current !== reqId) return;
|
||||
if (!res.success) {
|
||||
setError('加载任务失败');
|
||||
setError(t('taskList.loadFailed'));
|
||||
return;
|
||||
}
|
||||
const list = (res.data ?? []).slice().sort((a, b) => {
|
||||
@@ -85,14 +118,16 @@ export function TaskList(): React.JSX.Element {
|
||||
setTasks(list);
|
||||
} catch (err) {
|
||||
if (loadReqIdRef.current !== reqId) return;
|
||||
setError((err as Error).message ?? '加载任务失败');
|
||||
setError((err as Error).message ?? t('taskList.loadFailed'));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks(sessionId ?? undefined);
|
||||
// cleanup: 使当前请求失效(防止卸载后 setState + 竞态)
|
||||
return () => { loadReqIdRef.current++; };
|
||||
return () => {
|
||||
loadReqIdRef.current++;
|
||||
};
|
||||
}, [sessionId, loadTasks]);
|
||||
|
||||
// P2(v0.3.13): 订阅任务变更事件(Agent 通过 task_manager 工具写入后自动刷新)
|
||||
@@ -110,12 +145,14 @@ export function TaskList(): React.JSX.Element {
|
||||
const handleCreate = async () => {
|
||||
if (!sessionId) {
|
||||
// 用户主动操作失败应用 toast(与项目惯例一致)
|
||||
import('@metona-team/metona-toast').then((mod) => mod.default.error('请先选择或创建会话')).catch(() => {});
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(t('taskList.selectSession')))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (!newTitle.trim()) {
|
||||
// 表单校验保留 inline error(让用户看到具体哪个字段有问题)
|
||||
setError('任务标题不能为空');
|
||||
setError(t('taskList.titleRequired'));
|
||||
return;
|
||||
}
|
||||
if (!window.metona?.tasks?.create) return;
|
||||
@@ -128,7 +165,9 @@ export function TaskList(): React.JSX.Element {
|
||||
priority: newPriority,
|
||||
});
|
||||
if (!res.success) {
|
||||
import('@metona-team/metona-toast').then((mod) => mod.default.error(res.error ?? '创建任务失败')).catch(() => {});
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(res.error ?? t('taskList.createFailed')))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
setNewTitle('');
|
||||
@@ -136,7 +175,9 @@ export function TaskList(): React.JSX.Element {
|
||||
setShowAddForm(false);
|
||||
await loadTasks(sessionId);
|
||||
} catch (err) {
|
||||
import('@metona-team/metona-toast').then((mod) => mod.default.error((err as Error).message ?? '创建任务失败')).catch(() => {});
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error((err as Error).message ?? t('taskList.createFailed')))
|
||||
.catch(() => {});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
@@ -149,12 +190,16 @@ export function TaskList(): React.JSX.Element {
|
||||
try {
|
||||
const res = await window.metona.tasks.update(task.id, { status: nextStatus }, sessionId);
|
||||
if (!res.success) {
|
||||
import('@metona-team/metona-toast').then((mod) => mod.default.error(res.error ?? '更新任务失败')).catch(() => {});
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(res.error ?? t('taskList.updateFailed')))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
await loadTasks(sessionId ?? undefined);
|
||||
} catch (err) {
|
||||
import('@metona-team/metona-toast').then((mod) => mod.default.error((err as Error).message ?? '更新任务失败')).catch(() => {});
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error((err as Error).message ?? t('taskList.updateFailed')))
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -164,13 +209,17 @@ export function TaskList(): React.JSX.Element {
|
||||
try {
|
||||
const res = await window.metona.tasks.delete(id, sessionId);
|
||||
if (!res.success) {
|
||||
import('@metona-team/metona-toast').then((mod) => mod.default.error(res.error ?? '删除任务失败')).catch(() => {});
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(res.error ?? t('taskList.deleteFailed')))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (expandedId === id) setExpandedId(null);
|
||||
await loadTasks(sessionId ?? undefined);
|
||||
} catch (err) {
|
||||
import('@metona-team/metona-toast').then((mod) => mod.default.error((err as Error).message ?? '删除任务失败')).catch(() => {});
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error((err as Error).message ?? t('taskList.deleteFailed')))
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -179,21 +228,31 @@ export function TaskList(): React.JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
|
||||
<Box
|
||||
sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}
|
||||
>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flexShrink: 0, alignItems: 'center' }}>
|
||||
<ListChecks size={14} style={{ color: '#22d3ee' }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
|
||||
任务列表
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{t('taskList.title')}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
|
||||
{tasks.length} 项
|
||||
{t('taskList.count', { count: tasks.length })}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
sx={{ p: 0.25, color: 'primary.main', '&:hover': { bgcolor: 'action.hover' } }}
|
||||
title="新增任务"
|
||||
aria-label={showAddForm ? '取消新增' : '新增任务'}
|
||||
title={t('taskList.addTask')}
|
||||
aria-label={showAddForm ? t('taskList.cancelAdd') : t('taskList.addTask')}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</IconButton>
|
||||
@@ -203,9 +262,14 @@ export function TaskList(): React.JSX.Element {
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
mb: 1, px: 1, py: 0.5, fontSize: 10,
|
||||
color: 'error.main', bgcolor: 'error.main' + '14',
|
||||
borderRadius: 1, flexShrink: 0,
|
||||
mb: 1,
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
fontSize: 10,
|
||||
color: 'error.main',
|
||||
bgcolor: 'error.main' + '14',
|
||||
borderRadius: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
@@ -213,15 +277,29 @@ export function TaskList(): React.JSX.Element {
|
||||
)}
|
||||
|
||||
<Collapse in={showAddForm} sx={{ flexShrink: 0 }}>
|
||||
<Box sx={{ mb: 1.5, p: 1, borderRadius: 1, bgcolor: 'background.default', border: '1px solid', borderColor: 'divider' }}>
|
||||
<Box
|
||||
sx={{
|
||||
mb: 1.5,
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'background.default',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
|
||||
placeholder="任务标题..."
|
||||
sx={{ mb: 1, '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1 } }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreate();
|
||||
}}
|
||||
placeholder={t('taskList.titlePlaceholder')}
|
||||
sx={{
|
||||
mb: 1,
|
||||
'& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1 },
|
||||
}}
|
||||
/>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Select
|
||||
@@ -234,7 +312,9 @@ export function TaskList(): React.JSX.Element {
|
||||
<MenuItem key={p} value={p} sx={{ fontSize: 11, py: 0.5 }}>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Circle size={8} style={{ color: PRIORITY_COLORS[p] }} />
|
||||
<Typography variant="caption" sx={{ fontSize: 11 }}>{PRIORITY_LABELS[p]}</Typography>
|
||||
<Typography variant="caption" sx={{ fontSize: 11 }}>
|
||||
{priorityLabel(p)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
))}
|
||||
@@ -246,13 +326,17 @@ export function TaskList(): React.JSX.Element {
|
||||
disabled={creating || !newTitle.trim()}
|
||||
sx={{ height: 28, fontSize: 11, minWidth: 60, textTransform: 'none' }}
|
||||
>
|
||||
{creating ? '...' : '创建'}
|
||||
{creating ? t('taskList.creating') : t('taskList.create')}
|
||||
</Button>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => { setShowAddForm(false); setNewTitle(''); setNewPriority('medium'); }}
|
||||
onClick={() => {
|
||||
setShowAddForm(false);
|
||||
setNewTitle('');
|
||||
setNewPriority('medium');
|
||||
}}
|
||||
sx={{ p: 0.5, color: 'text.secondary' }}
|
||||
aria-label="取消新增任务"
|
||||
aria-label={t('taskList.cancelAddTask')}
|
||||
>
|
||||
<X size={12} />
|
||||
</IconButton>
|
||||
@@ -262,12 +346,18 @@ export function TaskList(): React.JSX.Element {
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{!sessionId ? (
|
||||
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}>
|
||||
请先选择会话
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}
|
||||
>
|
||||
{t('taskList.selectSessionHint')}
|
||||
</Typography>
|
||||
) : tasks.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}>
|
||||
暂无任务,点击 + 创建
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}
|
||||
>
|
||||
{t('taskList.empty')}
|
||||
</Typography>
|
||||
) : (
|
||||
<List disablePadding dense>
|
||||
@@ -313,7 +403,8 @@ function TaskItemRow({
|
||||
disablePadding
|
||||
sx={{
|
||||
display: 'block',
|
||||
px: 0.5, py: 0.25,
|
||||
px: 0.5,
|
||||
py: 0.25,
|
||||
borderRadius: 1,
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
'&:hover .delete-btn': { opacity: 1 },
|
||||
@@ -332,30 +423,42 @@ function TaskItemRow({
|
||||
<Box
|
||||
onClick={onToggleExpand}
|
||||
sx={{
|
||||
fontSize: 11, lineHeight: 1.3, cursor: 'pointer',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.3,
|
||||
cursor: 'pointer',
|
||||
color: isCompleted ? 'text.disabled' : 'text.primary',
|
||||
textDecoration: isCompleted ? 'line-through' : 'none',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{task.title}
|
||||
</Box>
|
||||
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center', mt: 0.25, flexWrap: 'wrap' }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={0.5}
|
||||
sx={{ alignItems: 'center', mt: 0.25, flexWrap: 'wrap' }}
|
||||
>
|
||||
<Chip
|
||||
label={STATUS_LABELS[task.status]}
|
||||
label={statusLabel(task.status)}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 14, fontSize: 9,
|
||||
bgcolor: statusColor + '22', color: statusColor,
|
||||
height: 14,
|
||||
fontSize: 9,
|
||||
bgcolor: statusColor + '22',
|
||||
color: statusColor,
|
||||
'& .MuiChip-label': { px: 0.5 },
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label={PRIORITY_LABELS[task.priority]}
|
||||
label={priorityLabel(task.priority)}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 14, fontSize: 9,
|
||||
bgcolor: priorityColor + '22', color: priorityColor,
|
||||
height: 14,
|
||||
fontSize: 9,
|
||||
bgcolor: priorityColor + '22',
|
||||
color: priorityColor,
|
||||
'& .MuiChip-label': { px: 0.5 },
|
||||
}}
|
||||
/>
|
||||
@@ -383,16 +486,37 @@ function TaskItemRow({
|
||||
className="delete-btn"
|
||||
size="small"
|
||||
onClick={onDelete}
|
||||
sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, mt: 0.25, '&:hover': { color: 'error.main' } }}
|
||||
sx={{
|
||||
opacity: 0,
|
||||
transition: 'opacity 150ms',
|
||||
p: 0.25,
|
||||
mt: 0.25,
|
||||
'&:hover': { color: 'error.main' },
|
||||
}}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ pl: 3.5, pr: 1, py: 0.5, fontSize: 11, color: 'text.secondary', lineHeight: 1.4, wordBreak: 'break-word' }}>
|
||||
{task.description ? task.description : (
|
||||
<Typography variant="caption" sx={{ fontSize: 10, color: 'text.disabled', fontStyle: 'italic' }}>
|
||||
无描述
|
||||
<Box
|
||||
sx={{
|
||||
pl: 3.5,
|
||||
pr: 1,
|
||||
py: 0.5,
|
||||
fontSize: 11,
|
||||
color: 'text.secondary',
|
||||
lineHeight: 1.4,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{task.description ? (
|
||||
task.description
|
||||
) : (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontSize: 10, color: 'text.disabled', fontStyle: 'italic' }}
|
||||
>
|
||||
{t('taskList.noDescription')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* TaskList 组件测试
|
||||
*
|
||||
* 覆盖:任务列表渲染、状态/优先级标签(i18n)、空状态、
|
||||
* 无会话提示、新增任务、完成切换、删除、展开描述、错误展示。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { TaskList } from '../TaskList';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
|
||||
const agentInitial = useAgentStore.getState();
|
||||
|
||||
function makeTask(overrides: Partial<MetonaTask> = {}): MetonaTask {
|
||||
return {
|
||||
id: 'task_1',
|
||||
session_id: 's1',
|
||||
title: '调研方案',
|
||||
description: '深入分析',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
parent_id: null,
|
||||
assigned_to: null,
|
||||
order_idx: 0,
|
||||
created_at: 1730000000000,
|
||||
updated_at: 1730000000000,
|
||||
completed_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
let taskListResult: MetonaTask[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
useAgentStore.setState(agentInitial, true);
|
||||
useAgentStore.setState({ currentSessionId: 's1' });
|
||||
taskListResult = [];
|
||||
(
|
||||
window.metona.tasks as unknown as {
|
||||
list: (_sid?: string) => Promise<{ success: boolean; data: MetonaTask[] }>;
|
||||
}
|
||||
).list = vi.fn(async (_sid) => ({ success: true, data: taskListResult }));
|
||||
(
|
||||
window.metona.tasks as unknown as {
|
||||
create: (data: {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
priority: string;
|
||||
}) => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
).create = vi.fn(async () => ({ success: true }));
|
||||
(
|
||||
window.metona.tasks as unknown as {
|
||||
update: (
|
||||
id: string,
|
||||
updates: { status: string },
|
||||
sid: string,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
).update = vi.fn(async () => ({ success: true }));
|
||||
(
|
||||
window.metona.tasks as unknown as {
|
||||
delete: (id: string, sid: string) => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
).delete = vi.fn(async () => ({ success: true }));
|
||||
});
|
||||
|
||||
describe('TaskList — 渲染', () => {
|
||||
it('渲染标题与任务计数', async () => {
|
||||
taskListResult = [makeTask()];
|
||||
render(<TaskList />);
|
||||
expect(await screen.findByText('任务列表')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 项')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染任务标题', async () => {
|
||||
taskListResult = [makeTask({ title: '写测试' })];
|
||||
render(<TaskList />);
|
||||
expect(await screen.findByText('写测试')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染任务状态标签', async () => {
|
||||
taskListResult = [makeTask({ status: 'in_progress' })];
|
||||
render(<TaskList />);
|
||||
expect(await screen.findByText('进行中')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染任务优先级标签', async () => {
|
||||
taskListResult = [makeTask({ priority: 'high' })];
|
||||
render(<TaskList />);
|
||||
expect(await screen.findByText('高')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染任务创建时间', async () => {
|
||||
taskListResult = [makeTask({ created_at: 1730000000000 })];
|
||||
render(<TaskList />);
|
||||
expect(await screen.findByText(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('completed 状态渲染"已完成"', async () => {
|
||||
taskListResult = [makeTask({ status: 'completed' })];
|
||||
render(<TaskList />);
|
||||
expect(await screen.findByText('已完成')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskList — 空状态', () => {
|
||||
it('无会话时显示"请先选择会话"', async () => {
|
||||
useAgentStore.setState({ currentSessionId: null });
|
||||
render(<TaskList />);
|
||||
expect(await screen.findByText('请先选择会话')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('有会话但无任务时显示空状态', async () => {
|
||||
taskListResult = [];
|
||||
render(<TaskList />);
|
||||
expect(await screen.findByText(/暂无任务/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskList — 新增任务', () => {
|
||||
it('点击 + 展开新增表单', async () => {
|
||||
render(<TaskList />);
|
||||
const addBtn = screen.getByTitle('新增任务');
|
||||
fireEvent.click(addBtn);
|
||||
expect(screen.getByPlaceholderText('任务标题...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('输入标题并创建调用 window.metona.tasks.create', async () => {
|
||||
taskListResult = [];
|
||||
render(<TaskList />);
|
||||
fireEvent.click(screen.getByTitle('新增任务'));
|
||||
fireEvent.change(screen.getByPlaceholderText('任务标题...'), {
|
||||
target: { value: '新任务标题' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('创建'));
|
||||
await waitFor(() => {
|
||||
expect(window.metona.tasks.create).toHaveBeenCalledWith({
|
||||
sessionId: 's1',
|
||||
title: '新任务标题',
|
||||
priority: 'medium',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('空标题时创建按钮禁用(表单校验防止空任务)', async () => {
|
||||
render(<TaskList />);
|
||||
fireEvent.click(screen.getByTitle('新增任务'));
|
||||
const createBtn = await screen
|
||||
.findByText('创建')
|
||||
.then((el) => el.closest('button') as HTMLButtonElement);
|
||||
expect(createBtn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('输入标题后创建按钮可用', async () => {
|
||||
render(<TaskList />);
|
||||
fireEvent.click(screen.getByTitle('新增任务'));
|
||||
fireEvent.change(screen.getByPlaceholderText('任务标题...'), { target: { value: '标题' } });
|
||||
const createBtn = (await screen.findByText('创建')).closest('button') as HTMLButtonElement;
|
||||
expect(createBtn.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('无会话时创建提示选择会话', async () => {
|
||||
useAgentStore.setState({ currentSessionId: null });
|
||||
render(<TaskList />);
|
||||
fireEvent.click(screen.getByTitle('新增任务'));
|
||||
fireEvent.change(screen.getByPlaceholderText('任务标题...'), { target: { value: 'x' } });
|
||||
fireEvent.click(screen.getByText('创建'));
|
||||
expect(window.metona.tasks.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('点击取消关闭新增表单(Collapse 收起)', async () => {
|
||||
render(<TaskList />);
|
||||
fireEvent.click(screen.getByTitle('新增任务'));
|
||||
// 取消按钮通过 aria-label 暴露
|
||||
const cancelBtn = screen.getByRole('button', { name: '取消新增任务' });
|
||||
fireEvent.click(cancelBtn);
|
||||
// MUI Collapse 关闭时内容仍在 DOM,但外层 wrapper 高度为 0
|
||||
await waitFor(() => {
|
||||
const collapse = document.querySelector('.MuiCollapse-root') as HTMLElement | null;
|
||||
expect(collapse?.style.height).toBe('0px');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskList — 完成切换/删除/展开', () => {
|
||||
it('点击勾选框调用 update 切换完成状态', async () => {
|
||||
taskListResult = [makeTask({ id: 't1', status: 'pending' })];
|
||||
render(<TaskList />);
|
||||
const checkbox = (await screen.findAllByRole('checkbox'))[0];
|
||||
fireEvent.click(checkbox);
|
||||
await waitFor(() => {
|
||||
expect(window.metona.tasks.update).toHaveBeenCalledWith('t1', { status: 'completed' }, 's1');
|
||||
});
|
||||
});
|
||||
|
||||
it('completed 任务勾选后切回 pending', async () => {
|
||||
taskListResult = [makeTask({ id: 't1', status: 'completed' })];
|
||||
render(<TaskList />);
|
||||
const checkbox = (await screen.findAllByRole('checkbox'))[0];
|
||||
fireEvent.click(checkbox);
|
||||
await waitFor(() => {
|
||||
expect(window.metona.tasks.update).toHaveBeenCalledWith('t1', { status: 'pending' }, 's1');
|
||||
});
|
||||
});
|
||||
|
||||
it('点击删除按钮调用 window.metona.tasks.delete', async () => {
|
||||
taskListResult = [makeTask({ id: 't1' })];
|
||||
render(<TaskList />);
|
||||
await screen.findByText('调研方案');
|
||||
const trashBtn = document.querySelector('.lucide-trash-2')?.closest('button') as HTMLElement;
|
||||
fireEvent.click(trashBtn);
|
||||
await waitFor(() => {
|
||||
expect(window.metona.tasks.delete).toHaveBeenCalledWith('t1', 's1');
|
||||
});
|
||||
});
|
||||
|
||||
it('点击标题展开任务描述', async () => {
|
||||
taskListResult = [makeTask({ description: '详细的描述文本' })];
|
||||
render(<TaskList />);
|
||||
const title = await screen.findByText('调研方案');
|
||||
fireEvent.click(title);
|
||||
expect(screen.getByText('详细的描述文本')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无描述任务展开时显示"无描述"', async () => {
|
||||
taskListResult = [makeTask({ description: '' })];
|
||||
render(<TaskList />);
|
||||
const title = await screen.findByText('调研方案');
|
||||
fireEvent.click(title);
|
||||
expect(await screen.findByText('无描述')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskList — 错误与排序', () => {
|
||||
it('加载失败时渲染错误信息', async () => {
|
||||
(
|
||||
window.metona.tasks as unknown as {
|
||||
list: () => Promise<{ success: boolean; data: MetonaTask[] }>;
|
||||
}
|
||||
).list = vi.fn(async () => ({ success: false, data: [] }));
|
||||
render(<TaskList />);
|
||||
expect(await screen.findByText('加载任务失败')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('任务按 order_idx 排序', async () => {
|
||||
taskListResult = [
|
||||
makeTask({ id: 'a', title: '第二', order_idx: 2 }),
|
||||
makeTask({ id: 'b', title: '第一', order_idx: 1 }),
|
||||
];
|
||||
render(<TaskList />);
|
||||
const first = await screen.findByText('第一');
|
||||
const second = screen.getByText('第二');
|
||||
expect(first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,9 @@ import { Box, Typography, Stack, Table, TableBody, TableRow, TableCell } from '@
|
||||
import { Zap } from 'lucide-react';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { formatTokens } from '@renderer/lib/formatters';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
export function TokenUsage(): React.JSX.Element {
|
||||
const tokenUsage = useAgentStore((s) => s.tokenUsage);
|
||||
@@ -19,65 +22,149 @@ export function TokenUsage(): React.JSX.Element {
|
||||
const maxTokens = contextWindow;
|
||||
// v0.3.18 修复: 上下文占用百分比改用 lastInputTokens(单次占用),而非累计 totalTokens
|
||||
// 之前 totalTokens 是所有轮次累加值,除以单次窗口得出无意义的百分比
|
||||
const contextPercent = maxTokens > 0 && tokenUsage.lastInputTokens > 0
|
||||
? Math.min((tokenUsage.lastInputTokens / maxTokens) * 100, 100)
|
||||
: 0;
|
||||
const contextPercent =
|
||||
maxTokens > 0 && tokenUsage.lastInputTokens > 0
|
||||
? Math.min((tokenUsage.lastInputTokens / maxTokens) * 100, 100)
|
||||
: 0;
|
||||
// 累计消耗的输入/输出占比(用于进度条展示累计消耗的构成)
|
||||
const inputPercent = tokenUsage.totalTokens > 0
|
||||
? (tokenUsage.inputTokens / tokenUsage.totalTokens) * 100
|
||||
: 0;
|
||||
const inputPercent =
|
||||
tokenUsage.totalTokens > 0 ? (tokenUsage.inputTokens / tokenUsage.totalTokens) * 100 : 0;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider', flexShrink: 0 }}>
|
||||
{/* 标题 */}
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 1.5, alignItems: 'center' }}>
|
||||
<Zap size={14} style={{ color: '#fbbf24' }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
|
||||
Token 用量
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{t('trace.token.usage')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* 进度条 */}
|
||||
<Box sx={{ width: '100%', height: 6, borderRadius: 3, overflow: 'hidden', mb: 2, bgcolor: 'action.hover', display: 'flex' }}>
|
||||
<Box sx={{ height: '100%', width: `${inputPercent}%`, bgcolor: '#22d3ee', transition: 'width 300ms', borderRadius: '3px 0 0 3px' }} />
|
||||
<Box sx={{ height: '100%', width: `${Math.max(100 - inputPercent, 0)}%`, bgcolor: '#a855f7', transition: 'width 300ms', borderRadius: '0 3px 3px 0' }} />
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
mb: 2,
|
||||
bgcolor: 'action.hover',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
height: '100%',
|
||||
width: `${inputPercent}%`,
|
||||
bgcolor: '#22d3ee',
|
||||
transition: 'width 300ms',
|
||||
borderRadius: '3px 0 0 3px',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
height: '100%',
|
||||
width: `${Math.max(100 - inputPercent, 0)}%`,
|
||||
bgcolor: '#a855f7',
|
||||
transition: 'width 300ms',
|
||||
borderRadius: '0 3px 3px 0',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 数值卡片 */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 1.5 }}>
|
||||
<StatCard label="输入" value={formatTokens(tokenUsage.inputTokens)} color="#22d3ee" />
|
||||
<StatCard label="输出" value={formatTokens(tokenUsage.outputTokens)} color="#a855f7" />
|
||||
<StatCard
|
||||
label={t('trace.token.input')}
|
||||
value={formatTokens(tokenUsage.inputTokens)}
|
||||
color="#22d3ee"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('trace.token.output')}
|
||||
value={formatTokens(tokenUsage.outputTokens)}
|
||||
color="#a855f7"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 底部汇总 */}
|
||||
<Table size="small" sx={{ '& .MuiTableCell-root': { border: 0, py: 0.5, px: 0.5 } }}>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 11, width: '40%' }}>累计消耗</TableCell>
|
||||
<TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, color: 'text.primary', fontSize: 12, textAlign: 'right' }}>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 11, width: '40%' }}>
|
||||
{t('trace.token.total')}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
color: 'text.primary',
|
||||
fontSize: 12,
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{tokenUsage.totalTokens > 0 ? formatTokens(tokenUsage.totalTokens) : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}>上下文占用</TableCell>
|
||||
<TableCell sx={{
|
||||
fontFamily: 'monospace', fontWeight: 600, fontSize: 11, textAlign: 'right',
|
||||
color: contextPercent > 80 ? 'error.main' : contextPercent > 60 ? 'warning.main' : 'text.secondary',
|
||||
}}>
|
||||
{tokenUsage.lastInputTokens > 0 ? `${formatTokens(tokenUsage.lastInputTokens)} (${contextPercent.toFixed(1)}%)` : '-'}
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}>
|
||||
{t('trace.token.context')}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
fontSize: 11,
|
||||
textAlign: 'right',
|
||||
color:
|
||||
contextPercent > 80
|
||||
? 'error.main'
|
||||
: contextPercent > 60
|
||||
? 'warning.main'
|
||||
: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{tokenUsage.lastInputTokens > 0
|
||||
? `${formatTokens(tokenUsage.lastInputTokens)} (${contextPercent.toFixed(1)}%)`
|
||||
: '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{tokenUsage.lastCompressedSaved > 0 && (
|
||||
<TableRow>
|
||||
<TableCell sx={{ color: 'success.main', fontSize: 11 }}>压缩节省</TableCell>
|
||||
<TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: 11, color: 'success.main', textAlign: 'right' }}>
|
||||
<TableCell sx={{ color: 'success.main', fontSize: 11 }}>
|
||||
{t('trace.token.compressedSaved')}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
fontSize: 11,
|
||||
color: 'success.main',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{formatTokens(tokenUsage.lastCompressedSaved)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}>迭代</TableCell>
|
||||
<TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: 11, color: 'text.secondary', textAlign: 'right' }}>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
fontSize: 11,
|
||||
color: 'text.secondary',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{currentIteration} / {maxIterations}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -89,10 +176,34 @@ export function TokenUsage(): React.JSX.Element {
|
||||
|
||||
function StatCard({ label, value, color }: { label: string; value: string; color: string }) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, px: 1.5, py: 1.25, borderRadius: 1.5, bgcolor: 'action.hover' }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
px: 1.5,
|
||||
py: 1.25,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'action.hover',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: color, flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 11 }}>{label}</Typography>
|
||||
<Typography variant="caption" sx={{ ml: 'auto', pl: 1, fontFamily: 'monospace', fontWeight: 600, color: 'text.primary', fontSize: 12 }}>{value}</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 11 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
ml: 'auto',
|
||||
pl: 1,
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
color: 'text.primary',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,14 +6,29 @@
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Box, Typography, IconButton, Collapse, Stack } from '@mui/material';
|
||||
import { ChevronDown, ChevronRight, CircleDot, CheckCircle, Loader2, Clock, XCircle, Ban } from 'lucide-react';
|
||||
import { TRACE_STATE_COLORS, TRACE_STATE_LABELS } from '@renderer/lib/constants';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleDot,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Clock,
|
||||
XCircle,
|
||||
Ban,
|
||||
} from 'lucide-react';
|
||||
import { TRACE_STATE_COLORS, traceStateLabel } from '@renderer/lib/constants';
|
||||
import { formatDuration, formatTokens } from '@renderer/lib/formatters';
|
||||
import { toDisplayResult } from '@renderer/lib/tool-result-display';
|
||||
import type { ToolCallInfo } from '@renderer/stores/agent-store';
|
||||
import type { TraceStep as TraceStepType } from '@renderer/stores/agent-store';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
interface TraceStepProps { step: TraceStepType; isCurrent?: boolean; }
|
||||
interface TraceStepProps {
|
||||
step: TraceStepType;
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
// 工具状态图标
|
||||
const TOOL_STATUS_ICONS: Record<string, typeof CheckCircle> = {
|
||||
@@ -30,13 +45,23 @@ const TOOL_STATUS_COLORS: Record<string, string> = {
|
||||
error: '#f87171',
|
||||
blocked: '#fb923c',
|
||||
};
|
||||
const TOOL_STATUS_LABELS: Record<string, string> = {
|
||||
pending: '等待',
|
||||
executing: '执行中',
|
||||
success: '成功',
|
||||
error: '失败',
|
||||
blocked: '阻止',
|
||||
};
|
||||
// v0.7.4 P3-1: 工具状态文案出层(t() 渲染时求值)
|
||||
function toolStatusLabel(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;
|
||||
}
|
||||
}
|
||||
|
||||
export function TraceStep({ step, isCurrent }: TraceStepProps): React.JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -65,31 +90,82 @@ export function TraceStep({ step, isCurrent }: TraceStepProps): React.JSX.Elemen
|
||||
const duration = step.completedAt ? step.completedAt - step.startedAt : null;
|
||||
|
||||
// 构建状态进度链(如 "思考 → 执行 → 观察")
|
||||
const stateChain = (step.states ?? [step.state])
|
||||
.map((s) => TRACE_STATE_LABELS[s] ?? s)
|
||||
.join(' → ');
|
||||
const stateChain = (step.states ?? [step.state]).map((s) => traceStateLabel(s)).join(' → ');
|
||||
|
||||
return (
|
||||
<Box sx={{ borderRadius: 1, border: '1px solid', borderColor: 'divider', bgcolor: 'secondary.main', transition: 'all 150ms', ...(isCurrent ? { boxShadow: `0 0 0 1px ${color}` } : {}) }}>
|
||||
<IconButton size="small" onClick={handleToggle} sx={{ width: '100%', justifyContent: 'flex-start', gap: 1, px: 1.5, py: 1, borderRadius: '4px 4px 0 0', color: 'text.primary', fontSize: 12 }}>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'secondary.main',
|
||||
transition: 'all 150ms',
|
||||
...(isCurrent ? { boxShadow: `0 0 0 1px ${color}` } : {}),
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleToggle}
|
||||
sx={{
|
||||
width: '100%',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 1,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
borderRadius: '4px 4px 0 0',
|
||||
color: 'text.primary',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{expanded ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
|
||||
{isCurrent ? <Loader2 size={12} style={{ color, animation: 'spin 1s linear infinite' }} /> : step.completedAt ? <CheckCircle size={12} style={{ color }} /> : <CircleDot size={12} style={{ color }} />}
|
||||
<Typography component="span" sx={{ fontWeight: 600, color, fontSize: 12 }}>#{step.iteration}</Typography>
|
||||
<Typography component="span" sx={{ color: 'text.secondary', fontSize: 12 }}>{stateChain}</Typography>
|
||||
{duration != null && <Typography variant="caption" sx={{ ml: 'auto', color: 'text.secondary' }}>{formatDuration(duration)}</Typography>}
|
||||
{step.tokenUsage && <Typography variant="caption" sx={{ color: 'text.secondary' }}>{formatTokens(step.tokenUsage.totalTokens)} tok</Typography>}
|
||||
{isCurrent ? (
|
||||
<Loader2 size={12} style={{ color, animation: 'spin 1s linear infinite' }} />
|
||||
) : step.completedAt ? (
|
||||
<CheckCircle size={12} style={{ color }} />
|
||||
) : (
|
||||
<CircleDot size={12} style={{ color }} />
|
||||
)}
|
||||
<Typography component="span" sx={{ fontWeight: 600, color, fontSize: 12 }}>
|
||||
#{step.iteration}
|
||||
</Typography>
|
||||
<Typography component="span" sx={{ color: 'text.secondary', fontSize: 12 }}>
|
||||
{stateChain}
|
||||
</Typography>
|
||||
{duration != null && (
|
||||
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.secondary' }}>
|
||||
{formatDuration(duration)}
|
||||
</Typography>
|
||||
)}
|
||||
{step.tokenUsage && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{formatTokens(step.tokenUsage.totalTokens)} tok
|
||||
</Typography>
|
||||
)}
|
||||
</IconButton>
|
||||
<Collapse in={expanded}>
|
||||
<Box sx={{ px: 1.5, pb: 1.5, borderTop: 1, borderColor: 'divider' }}>
|
||||
{/* Thought 区域 */}
|
||||
{step.thought && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'warning.main', fontSize: 10 }}>💭 Thought</Typography>
|
||||
<Box component="pre" sx={{
|
||||
fontSize: 11, whiteSpace: 'pre-wrap', color: 'text.secondary',
|
||||
fontFamily: "'SF Mono',monospace", m: 0, mt: 0.5,
|
||||
maxHeight: 200, overflowY: 'auto',
|
||||
}}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 600, color: 'warning.main', fontSize: 10 }}
|
||||
>
|
||||
💭 Thought
|
||||
</Typography>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
fontSize: 11,
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: 'text.secondary',
|
||||
fontFamily: "'SF Mono',monospace",
|
||||
m: 0,
|
||||
mt: 0.5,
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{step.thought}
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -98,7 +174,12 @@ export function TraceStep({ step, isCurrent }: TraceStepProps): React.JSX.Elemen
|
||||
{/* 工具调用区域 */}
|
||||
{step.toolCalls && step.toolCalls.length > 0 && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#a855f7', fontSize: 10 }}>🔧 工具调用</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 600, color: '#a855f7', fontSize: 10 }}
|
||||
>
|
||||
🔧 {t('trace.step.toolCalls')}
|
||||
</Typography>
|
||||
<Stack spacing={0.5} sx={{ mt: 0.5 }}>
|
||||
{step.toolCalls.map((tc) => (
|
||||
<ToolCallRow key={tc.id} tc={tc} />
|
||||
@@ -116,51 +197,100 @@ export function TraceStep({ step, isCurrent }: TraceStepProps): React.JSX.Elemen
|
||||
function ToolCallRow({ tc }: { tc: ToolCallInfo }): React.JSX.Element {
|
||||
const StatusIcon = TOOL_STATUS_ICONS[tc.status] ?? Clock;
|
||||
const statusColor = TOOL_STATUS_COLORS[tc.status] ?? '#8b8fa7';
|
||||
const statusLabel = TOOL_STATUS_LABELS[tc.status] ?? tc.status;
|
||||
const statusLabel = toolStatusLabel(tc.status);
|
||||
|
||||
// 结果摘要(限制高度 + 滚动)。渲染前剥离 dataUrl 等超大 base64,避免 ~6.7MB/张进 DOM 导致 OOM
|
||||
const hasResult = tc.result != null;
|
||||
const displayResult = toDisplayResult(tc.result);
|
||||
const resultStr = hasResult
|
||||
? (typeof displayResult === 'string' ? displayResult : JSON.stringify(displayResult, null, 2))
|
||||
? typeof displayResult === 'string'
|
||||
? displayResult
|
||||
: JSON.stringify(displayResult, null, 2)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Box sx={{ borderRadius: 0.5, border: '1px solid', borderColor: 'divider', borderLeft: `2px solid ${statusColor}`, bgcolor: 'background.default', px: 1, py: 0.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 0.5,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderLeft: `2px solid ${statusColor}`,
|
||||
bgcolor: 'background.default',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
}}
|
||||
>
|
||||
{/* 标题行 */}
|
||||
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<StatusIcon size={10} style={{ color: statusColor, animation: tc.status === 'executing' ? 'spin 1s linear infinite' : 'none' }} />
|
||||
<Typography component="span" sx={{ fontSize: 10, fontWeight: 600, fontFamily: 'monospace', color: 'text.primary' }}>{tc.name}</Typography>
|
||||
<Typography component="span" sx={{ fontSize: 9, color: statusColor }}>{statusLabel}</Typography>
|
||||
<StatusIcon
|
||||
size={10}
|
||||
style={{
|
||||
color: statusColor,
|
||||
animation: tc.status === 'executing' ? 'spin 1s linear infinite' : 'none',
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{ fontSize: 10, fontWeight: 600, fontFamily: 'monospace', color: 'text.primary' }}
|
||||
>
|
||||
{tc.name}
|
||||
</Typography>
|
||||
<Typography component="span" sx={{ fontSize: 9, color: statusColor }}>
|
||||
{statusLabel}
|
||||
</Typography>
|
||||
{tc.durationMs != null && (
|
||||
<Typography component="span" sx={{ fontSize: 9, color: 'text.secondary', ml: 'auto' }}>{formatDuration(tc.durationMs)}</Typography>
|
||||
<Typography component="span" sx={{ fontSize: 9, color: 'text.secondary', ml: 'auto' }}>
|
||||
{formatDuration(tc.durationMs)}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* 参数 */}
|
||||
{Object.keys(tc.args).length > 0 && (
|
||||
<Box component="pre" sx={{
|
||||
fontSize: 9, color: 'text.secondary', fontFamily: "'SF Mono',monospace",
|
||||
m: 0, mt: 0.25, maxHeight: 60, overflowY: 'auto',
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
|
||||
}}>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
fontSize: 9,
|
||||
color: 'text.secondary',
|
||||
fontFamily: "'SF Mono',monospace",
|
||||
m: 0,
|
||||
mt: 0.25,
|
||||
maxHeight: 60,
|
||||
overflowY: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(tc.args, null, 2)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 错误信息 */}
|
||||
{tc.status === 'error' && tc.error && (
|
||||
<Typography sx={{ fontSize: 9, color: 'error.main', mt: 0.25, wordBreak: 'break-word' }}>{tc.error}</Typography>
|
||||
<Typography sx={{ fontSize: 9, color: 'error.main', mt: 0.25, wordBreak: 'break-word' }}>
|
||||
{tc.error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* 结果摘要 */}
|
||||
{hasResult && (
|
||||
<Box component="pre" sx={{
|
||||
fontSize: 9, color: 'text.secondary', fontFamily: "'SF Mono',monospace",
|
||||
m: 0, mt: 0.25, maxHeight: 80, overflowY: 'auto',
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
|
||||
borderTop: '1px dashed', borderColor: 'divider', pt: 0.25,
|
||||
}}>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
fontSize: 9,
|
||||
color: 'text.secondary',
|
||||
fontFamily: "'SF Mono',monospace",
|
||||
m: 0,
|
||||
mt: 0.25,
|
||||
maxHeight: 80,
|
||||
overflowY: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
borderTop: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
pt: 0.25,
|
||||
}}
|
||||
>
|
||||
{resultStr}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -15,12 +15,15 @@ import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import type { TraceStep as TraceStepType } from '@renderer/stores/agent-store';
|
||||
import { TraceStep } from './TraceStep';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
interface RunGroup {
|
||||
runId: string;
|
||||
index: number; // 1-based 轮次序号
|
||||
index: number; // 1-based 轮次序号
|
||||
steps: TraceStepType[];
|
||||
isCurrent: boolean; // 是否为当前正在进行的 run
|
||||
isCurrent: boolean; // 是否为当前正在进行的 run
|
||||
isCompleted: boolean; // 是否已完成(最后一个 step 有 completedAt)
|
||||
}
|
||||
|
||||
@@ -39,7 +42,8 @@ function groupByRun(traceSteps: TraceStepType[], currentRunId: string | null): R
|
||||
}
|
||||
|
||||
// 当前 runId 优先;若为空(idle)则取最后一个 run 作为"当前展示"
|
||||
const effectiveCurrentRunId = currentRunId ?? (runOrder.length > 0 ? runOrder[runOrder.length - 1] : null);
|
||||
const effectiveCurrentRunId =
|
||||
currentRunId ?? (runOrder.length > 0 ? runOrder[runOrder.length - 1] : null);
|
||||
|
||||
return runOrder.map((rid, i) => {
|
||||
const steps = runMap.get(rid)!;
|
||||
@@ -78,23 +82,54 @@ export function TraceViewer(): React.JSX.Element {
|
||||
}, [allTraceSteps, agentStatus, groups.length]);
|
||||
|
||||
return (
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
|
||||
<Box
|
||||
sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}
|
||||
>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flexShrink: 0, alignItems: 'center' }}>
|
||||
<Activity size={14} style={{ color: '#818cf8' }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
Trace Viewer
|
||||
</Typography>
|
||||
{!isEmpty && (
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', ml: 'auto' }}>
|
||||
共 {groups.length} 轮
|
||||
{t('trace.viewer.rounds', { count: groups.length })}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Box ref={scrollContainerRef} sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 1.5, minHeight: 0 }}>
|
||||
<Box
|
||||
ref={scrollContainerRef}
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.5,
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<Typography variant="caption" sx={{ textAlign: 'center', py: 2, color: 'text.disabled', flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
等待 Agent 活动...
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
py: 2,
|
||||
color: 'text.disabled',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{t('trace.viewer.waiting')}
|
||||
</Typography>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
@@ -114,7 +149,10 @@ export function TraceViewer(): React.JSX.Element {
|
||||
spacing={1}
|
||||
sx={{ mb: 1, alignItems: 'center', flexShrink: 0 }}
|
||||
>
|
||||
<MessageSquare size={12} style={{ color: group.isCurrent ? '#818cf8' : '#9ca3af' }} />
|
||||
<MessageSquare
|
||||
size={12}
|
||||
style={{ color: group.isCurrent ? '#818cf8' : '#9ca3af' }}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
@@ -123,11 +161,11 @@ export function TraceViewer(): React.JSX.Element {
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
第 {group.index} 轮
|
||||
{t('trace.viewer.roundNumber', { index: group.index })}
|
||||
</Typography>
|
||||
{group.isCurrent && agentStatus !== 'idle' && (
|
||||
<Chip
|
||||
label="进行中"
|
||||
label={t('trace.viewer.inProgress')}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
@@ -136,14 +174,22 @@ export function TraceViewer(): React.JSX.Element {
|
||||
)}
|
||||
{group.isCompleted && !group.isCurrent && (
|
||||
<Chip
|
||||
label="已完成"
|
||||
label={t('trace.viewer.completed')}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 16, fontSize: 10, '& .MuiChip-label': { px: 0.5 }, color: 'text.disabled' }}
|
||||
sx={{
|
||||
height: 16,
|
||||
fontSize: 10,
|
||||
'& .MuiChip-label': { px: 0.5 },
|
||||
color: 'text.disabled',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
|
||||
{group.steps.length} 步
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}
|
||||
>
|
||||
{t('trace.viewer.steps', { count: group.steps.length })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
@@ -152,7 +198,9 @@ export function TraceViewer(): React.JSX.Element {
|
||||
<TraceStep
|
||||
key={step.id}
|
||||
step={step}
|
||||
isCurrent={group.isCurrent && i === group.steps.length - 1 && agentStatus !== 'idle'}
|
||||
isCurrent={
|
||||
group.isCurrent && i === group.steps.length - 1 && agentStatus !== 'idle'
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* TokenUsage 组件测试
|
||||
*
|
||||
* 覆盖:输入/输出 token 卡片、双进度条百分比、累计消耗、
|
||||
* 上下文占用百分比与颜色阈值、压缩节省、迭代信息、零值显示。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TokenUsage } from '../TokenUsage';
|
||||
import { useAgentStore, type TokenUsage as TU } from '@renderer/stores/agent-store';
|
||||
|
||||
const agentInitial = useAgentStore.getState();
|
||||
|
||||
function makeUsage(overrides: Partial<TU> = {}): TU {
|
||||
return {
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
totalTokens: 1500,
|
||||
lastInputTokens: 0,
|
||||
lastCompressedSaved: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAgentStore.setState(agentInitial, true);
|
||||
});
|
||||
|
||||
describe('TokenUsage — 基础渲染', () => {
|
||||
it('渲染标题 Token 用量', () => {
|
||||
render(<TokenUsage />);
|
||||
expect(screen.getByText('Token 用量')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染输入/输出 token 数值', () => {
|
||||
useAgentStore.setState({ tokenUsage: makeUsage({ inputTokens: 1200, outputTokens: 800 }) });
|
||||
render(<TokenUsage />);
|
||||
expect(screen.getByText('1.2K')).toBeInTheDocument();
|
||||
expect(screen.getByText('800')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染累计消耗与上下文占用行', () => {
|
||||
useAgentStore.setState({
|
||||
tokenUsage: makeUsage({ totalTokens: 3000, lastInputTokens: 2000 }),
|
||||
contextWindow: 10000,
|
||||
});
|
||||
render(<TokenUsage />);
|
||||
expect(screen.getByText('3.0K')).toBeInTheDocument();
|
||||
expect(screen.getByText('2.0K (20.0%)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染迭代信息', () => {
|
||||
useAgentStore.setState({ currentIteration: 2, maxIterations: 20 });
|
||||
render(<TokenUsage />);
|
||||
expect(screen.getByText('2 / 20')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('totalTokens=0 时累计消耗显示 -', () => {
|
||||
useAgentStore.setState({ tokenUsage: makeUsage({ totalTokens: 0 }) });
|
||||
const { container } = render(<TokenUsage />);
|
||||
// 累计消耗行单元格值为 -
|
||||
const totalRow = Array.from(container.querySelectorAll('tr')).find((tr) =>
|
||||
tr.textContent?.includes('累计消耗'),
|
||||
);
|
||||
expect(totalRow?.textContent).toContain('-');
|
||||
});
|
||||
|
||||
it('lastInputTokens=0 时上下文占用显示 -', () => {
|
||||
useAgentStore.setState({ tokenUsage: makeUsage({ lastInputTokens: 0 }) });
|
||||
const { container } = render(<TokenUsage />);
|
||||
const ctxRow = Array.from(container.querySelectorAll('tr')).find((tr) =>
|
||||
tr.textContent?.includes('上下文占用'),
|
||||
);
|
||||
expect(ctxRow?.textContent).toContain('-');
|
||||
});
|
||||
|
||||
it('有 lastCompressedSaved 时渲染压缩节省行', () => {
|
||||
useAgentStore.setState({
|
||||
tokenUsage: makeUsage({ lastCompressedSaved: 4000 }),
|
||||
});
|
||||
render(<TokenUsage />);
|
||||
expect(screen.getByText('压缩节省')).toBeInTheDocument();
|
||||
expect(screen.getByText('4.0K')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无 lastCompressedSaved 时不渲染压缩节省行', () => {
|
||||
const { container } = render(<TokenUsage />);
|
||||
expect(container.textContent).not.toContain('压缩节省');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TokenUsage — 进度条与百分比', () => {
|
||||
/** 汇总 MUI 注入的全部 CSS 规则 */
|
||||
function cssText(): string {
|
||||
return Array.from(document.querySelectorAll('style'))
|
||||
.map((s) => s.textContent ?? '')
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
it('双进度条宽度与 input/total 百分比一致', () => {
|
||||
useAgentStore.setState({
|
||||
tokenUsage: makeUsage({ inputTokens: 3000, outputTokens: 1000, totalTokens: 4000 }),
|
||||
});
|
||||
render(<TokenUsage />);
|
||||
const css = cssText();
|
||||
// MUI sx={{width:'75%'}} / width:'25%' 以 CSS 类注入
|
||||
expect(css).toContain('width:75%');
|
||||
expect(css).toContain('width:25%');
|
||||
});
|
||||
|
||||
it('上下文占用百分比 = lastInputTokens / contextWindow', () => {
|
||||
useAgentStore.setState({
|
||||
tokenUsage: makeUsage({ lastInputTokens: 5000 }),
|
||||
contextWindow: 20000,
|
||||
});
|
||||
render(<TokenUsage />);
|
||||
expect(screen.getByText('5.0K (25.0%)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('上下文占用超过 80% 使用错误色(MUI error red)', () => {
|
||||
useAgentStore.setState({
|
||||
tokenUsage: makeUsage({ lastInputTokens: 9000 }),
|
||||
contextWindow: 10000,
|
||||
});
|
||||
render(<TokenUsage />);
|
||||
expect(cssText()).toContain('color:#d32f2f');
|
||||
});
|
||||
|
||||
it('上下文占用超过 60% 使用警告色(MUI warning)', () => {
|
||||
useAgentStore.setState({
|
||||
tokenUsage: makeUsage({ lastInputTokens: 7000 }),
|
||||
contextWindow: 10000,
|
||||
});
|
||||
render(<TokenUsage />);
|
||||
expect(cssText()).toContain('color:#ed6c02');
|
||||
});
|
||||
|
||||
it('上下文占用不超过 60% 使用次要色(text.secondary)', () => {
|
||||
useAgentStore.setState({
|
||||
tokenUsage: makeUsage({ lastInputTokens: 5000 }),
|
||||
contextWindow: 10000,
|
||||
});
|
||||
render(<TokenUsage />);
|
||||
expect(cssText()).toContain('rgba(0, 0, 0, 0.6)');
|
||||
});
|
||||
|
||||
it('contextWindow=0 时上下文百分比为 0', () => {
|
||||
useAgentStore.setState({
|
||||
tokenUsage: makeUsage({ lastInputTokens: 5000 }),
|
||||
contextWindow: 0,
|
||||
});
|
||||
render(<TokenUsage />);
|
||||
expect(screen.getByText('5.0K (0.0%)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -11,12 +11,24 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Stack, Accordion, AccordionSummary, AccordionDetails,
|
||||
IconButton, Chip, Alert, Tooltip, Divider,
|
||||
Box,
|
||||
Typography,
|
||||
Stack,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
IconButton,
|
||||
Chip,
|
||||
Alert,
|
||||
Tooltip,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import { FolderOpen, RefreshCw, ChevronDown, Folder, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { formatTime, formatFileSize } from '@renderer/lib/formatters';
|
||||
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
// ===== 主组件 =====
|
||||
|
||||
@@ -43,7 +55,7 @@ export function WorkspaceViewer(): React.JSX.Element {
|
||||
setInfo(res);
|
||||
} catch (err) {
|
||||
if (loadReqIdRef.current !== reqId) return;
|
||||
setError((err as Error).message ?? '加载工作空间信息失败');
|
||||
setError((err as Error).message ?? t('workspace.loadFailed'));
|
||||
} finally {
|
||||
if (loadReqIdRef.current === reqId) setLoading(false);
|
||||
}
|
||||
@@ -53,7 +65,9 @@ export function WorkspaceViewer(): React.JSX.Element {
|
||||
useEffect(() => {
|
||||
loadInfo();
|
||||
// cleanup: 使当前请求失效(防止卸载后 setState)
|
||||
return () => { loadReqIdRef.current++; };
|
||||
return () => {
|
||||
loadReqIdRef.current++;
|
||||
};
|
||||
}, [loadInfo]);
|
||||
|
||||
// Agent 完成自动刷新
|
||||
@@ -70,27 +84,40 @@ export function WorkspaceViewer(): React.JSX.Element {
|
||||
await window.metona?.app?.showItemInFolder(path);
|
||||
} catch (err) {
|
||||
console.error('[WorkspaceViewer]', err);
|
||||
import('@metona-team/metona-toast').then((mod) => mod.default.error('打开文件夹失败')).catch(() => {});
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(t('workspace.openFailed')))
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <Alert severity="error" sx={{ m: 1 }}>{error}</Alert>;
|
||||
return (
|
||||
<Alert severity="error" sx={{ m: 1 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!info) {
|
||||
return <Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>{loading ? '加载中...' : '无数据'}</Typography>;
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
|
||||
{loading ? t('common.loading') : t('workspace.noData')}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={1.5} sx={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
|
||||
{/* 工作空间根路径 */}
|
||||
<Box>
|
||||
<Stack direction="row" sx={{ mb: 0.5, alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ mb: 0.5, alignItems: 'center', justifyContent: 'space-between' }}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
|
||||
当前工作空间
|
||||
{t('workspace.currentWorkspace')}
|
||||
</Typography>
|
||||
<Tooltip title="刷新">
|
||||
<Tooltip title={t('common.refresh')}>
|
||||
<IconButton size="small" onClick={loadInfo} disabled={loading} sx={{ p: 0.3 }}>
|
||||
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
|
||||
</IconButton>
|
||||
@@ -109,7 +136,7 @@ export function WorkspaceViewer(): React.JSX.Element {
|
||||
>
|
||||
{info.path}
|
||||
</Typography>
|
||||
<Tooltip title="在文件管理器中打开">
|
||||
<Tooltip title={t('workspace.openInFileManager')}>
|
||||
<IconButton size="small" onClick={() => handleOpenInFolder(info.path)} sx={{ p: 0.3 }}>
|
||||
<FolderOpen size={13} />
|
||||
</IconButton>
|
||||
@@ -121,8 +148,15 @@ export function WorkspaceViewer(): React.JSX.Element {
|
||||
|
||||
{/* 核心文件 */}
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, mb: 0.5, display: 'block' }}>
|
||||
核心文件({info.files.filter((f) => f.exists).length}/{info.files.length})
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontWeight: 600, mb: 0.5, display: 'block' }}
|
||||
>
|
||||
{t('workspace.coreFiles', {
|
||||
count: info.files.filter((f) => f.exists).length,
|
||||
total: info.files.length,
|
||||
})}
|
||||
</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
{info.files.map((file) => (
|
||||
@@ -138,7 +172,13 @@ export function WorkspaceViewer(): React.JSX.Element {
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ChevronDown size={14} />} sx={{ minHeight: 32, '& .MuiAccordionSummary-content': { my: 0, alignItems: 'center' } }}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ChevronDown size={14} />}
|
||||
sx={{
|
||||
minHeight: 32,
|
||||
'& .MuiAccordionSummary-content': { my: 0, alignItems: 'center' },
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={0.5} sx={{ flex: 1, mr: 1, alignItems: 'center' }}>
|
||||
{file.exists ? (
|
||||
<CheckCircle2 size={12} color="var(--mui-palette-success-main)" />
|
||||
@@ -149,7 +189,11 @@ export function WorkspaceViewer(): React.JSX.Element {
|
||||
{file.name}
|
||||
</Typography>
|
||||
{file.exists && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto', fontSize: 10 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ ml: 'auto', fontSize: 10 }}
|
||||
>
|
||||
{formatFileSize(file.size)} · {formatTime(file.mtime)}
|
||||
</Typography>
|
||||
)}
|
||||
@@ -173,16 +217,20 @@ export function WorkspaceViewer(): React.JSX.Element {
|
||||
borderRadius: 0.5,
|
||||
}}
|
||||
>
|
||||
{file.preview || '(空文件)'}
|
||||
{file.preview || t('workspace.emptyFile')}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="caption" color="error.main">
|
||||
文件不存在
|
||||
{t('workspace.fileMissing')}
|
||||
</Typography>
|
||||
)}
|
||||
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5 }}>
|
||||
<Tooltip title="在文件管理器中显示">
|
||||
<IconButton size="small" onClick={() => handleOpenInFolder(file.path)} sx={{ p: 0.3 }}>
|
||||
<Tooltip title={t('workspace.showInFileManager')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleOpenInFolder(file.path)}
|
||||
sx={{ p: 0.3 }}
|
||||
>
|
||||
<FolderOpen size={11} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
@@ -197,8 +245,12 @@ export function WorkspaceViewer(): React.JSX.Element {
|
||||
|
||||
{/* 自动目录 */}
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, mb: 0.5, display: 'block' }}>
|
||||
自动目录
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontWeight: 600, mb: 0.5, display: 'block' }}
|
||||
>
|
||||
{t('workspace.autoDirs')}
|
||||
</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
{info.dirs.map((dir) => (
|
||||
@@ -215,12 +267,21 @@ export function WorkspaceViewer(): React.JSX.Element {
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Folder size={12} color={dir.exists ? 'var(--mui-palette-info-main)' : 'var(--mui-palette-text-disabled)'} />
|
||||
<Folder
|
||||
size={12}
|
||||
color={
|
||||
dir.exists ? 'var(--mui-palette-info-main)' : 'var(--mui-palette-text-disabled)'
|
||||
}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: 600 }}>
|
||||
{dir.name}/
|
||||
</Typography>
|
||||
<Chip
|
||||
label={dir.exists ? `${dir.fileCount} 个文件` : '不存在'}
|
||||
label={
|
||||
dir.exists
|
||||
? t('workspace.dirFileCount', { count: dir.fileCount })
|
||||
: t('workspace.dirMissing')
|
||||
}
|
||||
size="small"
|
||||
color={dir.exists ? 'default' : 'error'}
|
||||
variant="outlined"
|
||||
@@ -228,8 +289,12 @@ export function WorkspaceViewer(): React.JSX.Element {
|
||||
/>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{dir.exists && (
|
||||
<Tooltip title="在文件管理器中打开">
|
||||
<IconButton size="small" onClick={() => handleOpenInFolder(dir.path)} sx={{ p: 0.3 }}>
|
||||
<Tooltip title={t('workspace.openInFileManager')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleOpenInFolder(dir.path)}
|
||||
sx={{ p: 0.3 }}
|
||||
>
|
||||
<FolderOpen size={11} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* WorkspaceViewer 组件测试
|
||||
*
|
||||
* 覆盖:路径显示、核心文件列表(存在/缺失)、文件预览、自动目录、
|
||||
* 刷新按钮、Agent 完成自动刷新、错误/无数据状态、文件管理器打开。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import { WorkspaceViewer } from '../WorkspaceViewer';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
|
||||
const agentInitial = useAgentStore.getState();
|
||||
|
||||
let infoResult: MetonaWorkspaceInfo | null = null;
|
||||
|
||||
function makeFile(overrides: Partial<MetonaWorkspaceFileInfo> = {}): MetonaWorkspaceFileInfo {
|
||||
return {
|
||||
name: 'SOUL.md',
|
||||
path: '/ws/SOUL.md',
|
||||
exists: true,
|
||||
size: 1024,
|
||||
mtime: 1730000000000,
|
||||
preview: '我是 SOUL',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDir(overrides: Partial<MetonaWorkspaceDirInfo> = {}): MetonaWorkspaceDirInfo {
|
||||
return {
|
||||
name: 'logs',
|
||||
path: '/ws/logs',
|
||||
exists: true,
|
||||
fileCount: 3,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAgentStore.setState(agentInitial, true);
|
||||
useAgentStore.setState({ agentStatus: 'idle' });
|
||||
infoResult = null;
|
||||
(
|
||||
window.metona.workspace as unknown as {
|
||||
getInfo: () => Promise<MetonaWorkspaceInfo | null>;
|
||||
}
|
||||
).getInfo = vi.fn(async () => infoResult) as unknown as () => Promise<MetonaWorkspaceInfo>;
|
||||
(
|
||||
window.metona.app as unknown as {
|
||||
showItemInFolder: (path: string) => Promise<{ success: boolean }>;
|
||||
}
|
||||
).showItemInFolder = vi.fn(async () => ({ success: true }));
|
||||
});
|
||||
|
||||
describe('WorkspaceViewer — 基础渲染', () => {
|
||||
it('渲染工作空间根路径', async () => {
|
||||
infoResult = {
|
||||
path: '/home/user/ws',
|
||||
files: [],
|
||||
dirs: [],
|
||||
};
|
||||
render(<WorkspaceViewer />);
|
||||
expect(await screen.findByText('/home/user/ws')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('getInfo 返回 null 时渲染"无数据"', async () => {
|
||||
infoResult = null;
|
||||
render(<WorkspaceViewer />);
|
||||
expect(await screen.findByText('无数据')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('加载失败时渲染错误 Alert(显示异常消息)', async () => {
|
||||
(
|
||||
window.metona.workspace as unknown as {
|
||||
getInfo: () => Promise<never>;
|
||||
}
|
||||
).getInfo = vi.fn(async () => {
|
||||
throw new Error('磁盘读取失败');
|
||||
});
|
||||
render(<WorkspaceViewer />);
|
||||
expect(await screen.findByText('磁盘读取失败')).toBeInTheDocument();
|
||||
expect(document.querySelector('[role="alert"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('渲染刷新按钮并点击重新拉取', async () => {
|
||||
infoResult = { path: '/ws/a', files: [], dirs: [] };
|
||||
render(<WorkspaceViewer />);
|
||||
await screen.findByText('/ws/a');
|
||||
const refreshBtn = document
|
||||
.querySelector('.lucide-refresh-cw')
|
||||
?.closest('button') as HTMLElement;
|
||||
fireEvent.click(refreshBtn);
|
||||
await waitFor(() => {
|
||||
expect(window.metona.workspace.getInfo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkspaceViewer — 核心文件', () => {
|
||||
it('渲染核心文件计数与文件列表', async () => {
|
||||
infoResult = {
|
||||
path: '/ws',
|
||||
files: [
|
||||
makeFile({ name: 'SOUL.md', exists: true }),
|
||||
makeFile({ name: 'MEMORY.md', exists: false }),
|
||||
],
|
||||
dirs: [],
|
||||
};
|
||||
render(<WorkspaceViewer />);
|
||||
expect(await screen.findByText('核心文件(1/2)')).toBeInTheDocument();
|
||||
expect(screen.getByText('SOUL.md')).toBeInTheDocument();
|
||||
expect(screen.getByText('MEMORY.md')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('存在的文件渲染大小与修改时间', async () => {
|
||||
infoResult = {
|
||||
path: '/ws',
|
||||
files: [makeFile({ name: 'SOUL.md', size: 2048, mtime: 1730000000000 })],
|
||||
dirs: [],
|
||||
};
|
||||
render(<WorkspaceViewer />);
|
||||
// 大小与时间在同一 Typography 中拼接
|
||||
expect(await screen.findByText(/2\.0 KB · \d{4}-\d{2}-\d{2}/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('缺失文件展开后渲染"文件不存在"', async () => {
|
||||
infoResult = {
|
||||
path: '/ws',
|
||||
files: [makeFile({ name: 'MEMORY.md', exists: false })],
|
||||
dirs: [],
|
||||
};
|
||||
render(<WorkspaceViewer />);
|
||||
fireEvent.click(await screen.findByText('MEMORY.md'));
|
||||
expect(await screen.findByText('文件不存在')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('存在的文件展开渲染内容预览', async () => {
|
||||
infoResult = {
|
||||
path: '/ws',
|
||||
files: [makeFile({ name: 'SOUL.md', preview: '# 我是 Agent' })],
|
||||
dirs: [],
|
||||
};
|
||||
render(<WorkspaceViewer />);
|
||||
fireEvent.click(await screen.findByText('SOUL.md'));
|
||||
expect(await screen.findByText('# 我是 Agent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('存在的空文件展开渲染"(空文件)"', async () => {
|
||||
infoResult = {
|
||||
path: '/ws',
|
||||
files: [makeFile({ name: 'SOUL.md', preview: '' })],
|
||||
dirs: [],
|
||||
};
|
||||
render(<WorkspaceViewer />);
|
||||
fireEvent.click(await screen.findByText('SOUL.md'));
|
||||
expect(await screen.findByText('(空文件)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkspaceViewer — 自动目录', () => {
|
||||
it('渲染自动目录与文件计数', async () => {
|
||||
infoResult = {
|
||||
path: '/ws',
|
||||
files: [],
|
||||
dirs: [makeDir({ name: 'logs', fileCount: 5 }), makeDir({ name: '.metona', exists: false })],
|
||||
};
|
||||
render(<WorkspaceViewer />);
|
||||
expect(await screen.findByText('自动目录')).toBeInTheDocument();
|
||||
expect(screen.getByText('logs/')).toBeInTheDocument();
|
||||
expect(screen.getByText('5 个文件')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('缺失目录渲染"不存在"', async () => {
|
||||
infoResult = {
|
||||
path: '/ws',
|
||||
files: [],
|
||||
dirs: [makeDir({ name: '.metona', exists: false })],
|
||||
};
|
||||
render(<WorkspaceViewer />);
|
||||
expect(await screen.findByText('.metona/')).toBeInTheDocument();
|
||||
expect(screen.getByText('不存在')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkspaceViewer — 打开文件管理器', () => {
|
||||
it('点击路径旁的文件夹按钮调用 showItemInFolder', async () => {
|
||||
infoResult = { path: '/ws/root', files: [], dirs: [] };
|
||||
render(<WorkspaceViewer />);
|
||||
await screen.findByText('/ws/root');
|
||||
const btn = document.querySelector('.lucide-folder-open')?.closest('button') as HTMLElement;
|
||||
fireEvent.click(btn);
|
||||
await waitFor(() => {
|
||||
expect(window.metona.app.showItemInFolder).toHaveBeenCalledWith('/ws/root');
|
||||
});
|
||||
});
|
||||
|
||||
it('点击文件旁的打开按钮调用 showItemInFolder(文件路径)', async () => {
|
||||
infoResult = {
|
||||
path: '/ws',
|
||||
files: [makeFile({ name: 'SOUL.md', path: '/ws/SOUL.md' })],
|
||||
dirs: [],
|
||||
};
|
||||
render(<WorkspaceViewer />);
|
||||
fireEvent.click(await screen.findByText('SOUL.md'));
|
||||
const btns = document.querySelectorAll('.lucide-folder-open');
|
||||
fireEvent.click(btns[btns.length - 1].closest('button') as HTMLElement);
|
||||
await waitFor(() => {
|
||||
expect(window.metona.app.showItemInFolder).toHaveBeenCalledWith('/ws/SOUL.md');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkspaceViewer — Agent 完成自动刷新', () => {
|
||||
it('Agent 从 executing 回到 idle 时自动重新加载', async () => {
|
||||
infoResult = { path: '/ws/a', files: [], dirs: [] };
|
||||
const { unmount } = render(<WorkspaceViewer />);
|
||||
await screen.findByText('/ws/a');
|
||||
const callsAfterMount = (window.metona.workspace.getInfo as ReturnType<typeof vi.fn>).mock.calls
|
||||
.length;
|
||||
// 触发状态流转 executing → idle(分步 act 确保 effect 逐次执行)
|
||||
await act(async () => {
|
||||
useAgentStore.setState({ agentStatus: 'executing' });
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
useAgentStore.setState({ agentStatus: 'idle' });
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
(window.metona.workspace.getInfo as ReturnType<typeof vi.fn>).mock.calls.length,
|
||||
).toBeGreaterThan(callsAfterMount);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('非工作状态流转不触发自动刷新', async () => {
|
||||
infoResult = { path: '/ws/a', files: [], dirs: [] };
|
||||
render(<WorkspaceViewer />);
|
||||
await screen.findByText('/ws/a');
|
||||
const callsAfterMount = (window.metona.workspace.getInfo as ReturnType<typeof vi.fn>).mock.calls
|
||||
.length;
|
||||
await act(async () => {
|
||||
useAgentStore.setState({ agentStatus: 'error' });
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
useAgentStore.setState({ agentStatus: 'idle' });
|
||||
await Promise.resolve();
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect((window.metona.workspace.getInfo as ReturnType<typeof vi.fn>).mock.calls.length).toBe(
|
||||
callsAfterMount,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user