feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s

This commit is contained in:
2026-09-08 14:30:27 +08:00
parent 69776e447f
commit 4cd6e997b5
86 changed files with 4303 additions and 956 deletions
+11 -9
View File
@@ -27,6 +27,8 @@ 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';
// v0.8.2 P0-4: 引用回复走受控输入桥
import { appendToChatInput } from '@renderer/lib/chat-input-bridge';
import '@renderer/lib/i18n-strings';
// v0.6.4 死代码清理:'tool-call' / 'code-block' / 'trace-step' 三个从未被任何组件
@@ -261,15 +263,15 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
icon: Quote,
label: t('contextMenu.quote'),
action: () => {
const input = document.querySelector<HTMLTextAreaElement>('[data-chat-input]');
if (input) {
input.value =
content
.split('\n')
.map((l: string) => `> ${l}`)
.join('\n') + '\n\n';
input.dispatchEvent(new Event('input', { bubbles: true }));
input.focus();
// v0.8.2 P0-4: 走受控输入桥 —— 旧实现 DOM 直查直赋(命中 InputBase
// 根 div + 受控组件不吃注入),功能完全失效
const quoted =
content
.split('\n')
.map((l: string) => `> ${l}`)
.join('\n') + '\n\n';
if (!appendToChatInput(quoted)) {
copyWithToast(quoted);
}
},
},
+55 -31
View File
@@ -7,6 +7,9 @@
*/
import { useEffect } from 'react';
// v0.8.2 P2-5: toast 内部文案跟随界面语言(此前硬编码 zh-CN)
import { getLocale, setLocale, onLocaleChange } from '@renderer/lib/i18n';
import '@renderer/lib/i18n-strings';
/**
* Toast 容器组件
@@ -19,35 +22,37 @@ import { useEffect } from 'react';
export function ToastContainer(): null {
useEffect(() => {
// 动态导入 metona-toast 并配置
import('@metona-team/metona-toast').then((mod) => {
const MeToast = mod.default;
import('@metona-team/metona-toast')
.then((mod) => {
const MeToast = mod.default;
// 全局配置(设计规范指定的参数)
MeToast.configure({
position: 'top-right',
duration: 4000,
max: 6,
theme: 'auto',
animation: 'slide',
pauseOnHover: true,
closeOnClick: true,
showProgress: true,
draggable: true,
locale: 'zh-CN',
// 全局配置(设计规范指定的参数)
MeToast.configure({
position: 'top-right',
duration: 4000,
max: 6,
theme: 'auto',
animation: 'slide',
pauseOnHover: true,
closeOnClick: true,
showProgress: true,
draggable: true,
locale: getLocale(),
});
// 安装插件
try {
MeToast.use('keyboard'); // ESC 关闭所有
MeToast.use('persistence'); // 配置持久化
MeToast.use('accessibility'); // 屏幕阅读器
} catch (err) {
console.error('[Toast]', 'Failed to install plugin:', err);
}
})
.catch((err) => {
console.error('[Toast]', 'Failed to load metona-toast:', err);
});
// 安装插件
try {
MeToast.use('keyboard'); // ESC 关闭所有
MeToast.use('persistence'); // 配置持久化
MeToast.use('accessibility'); // 屏幕阅读器
} catch (err) {
console.error('[Toast]', 'Failed to install plugin:', err);
}
}).catch((err) => {
console.error('[Toast]', 'Failed to load metona-toast:', err);
});
// 监听主进程通知桥接
if (window.metona?.toast?.onShow) {
const unsubscribe = window.metona.toast.onShow(async (data) => {
@@ -55,11 +60,20 @@ export function ToastContainer(): null {
const MeToast = (await import('@metona-team/metona-toast')).default;
const { type, message, options } = data;
switch (type) {
case 'success': MeToast.success(message, options); break;
case 'error': MeToast.error(message, options); break;
case 'warning': MeToast.warning(message, options); break;
case 'info': MeToast.info(message, options); break;
default: MeToast.info(message, options);
case 'success':
MeToast.success(message, options);
break;
case 'error':
MeToast.error(message, options);
break;
case 'warning':
MeToast.warning(message, options);
break;
case 'info':
MeToast.info(message, options);
break;
default:
MeToast.info(message, options);
}
} catch (err) {
console.error('[Toast]', 'Failed to show toast:', err);
@@ -69,5 +83,15 @@ export function ToastContainer(): null {
}
}, []);
// v0.8.2 P2-5: 语言切换时重配置 MeToast locale
useEffect(() => {
return onLocaleChange((locale) => {
void setLocale(locale);
import('@metona-team/metona-toast')
.then((mod) => mod.default.configure({ locale }))
.catch(() => {});
});
}, []);
return null;
}
+12 -2
View File
@@ -11,7 +11,7 @@
* 避免 agentStatus 频繁变化(thinking/executing/idle)触发所有 AssistantMessage 重渲染。
*/
import { useState, useCallback, memo, useMemo } from 'react';
import { useState, useCallback, memo, useMemo, useRef, useEffect } from 'react';
import { Box, Typography, Avatar, Stack, IconButton, Tooltip } from '@mui/material';
import { Bot, Copy, Check } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
@@ -306,6 +306,15 @@ export const AssistantMessage = memo(AssistantMessageImpl);
function CodeBlock({ language, code }: { language: string; code: string }): React.JSX.Element {
const [copied, setCopied] = useState(false);
// v0.8.2 P3-6: "已复制"复位定时器持有句柄并在卸载时清理 —— 裸 setTimeout 会在
// 组件卸载后触发 setStateReact 警告 / 测试环境 window 失效 unhandled error
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
},
[],
);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(code);
@@ -318,7 +327,8 @@ function CodeBlock({ language, code }: { language: string; code: string }): Reac
document.body.removeChild(ta);
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
}, [code]);
return (
+63 -3
View File
@@ -33,6 +33,8 @@ import { formatFileSize } from '@renderer/lib/formatters';
import { PROVIDER_LABELS } from '@renderer/lib/constants';
// v0.7.3 P1-4: 图片上传门控纯函数(总开关 × DeepSeek 命名防线 × Ollama 能力探测)
import { supportsImageUpload } from '@renderer/lib/model-capabilities';
// v0.8.2 P0-4: 受控输入桥注册(引用回复 / Ctrl+L 聚焦的真实能力提供方)
import { registerChatInputController } from '@renderer/lib/chat-input-bridge';
// v0.7.3 P4-3: 文案出层(字典含注册副作用,须在 t() 使用前 import
import { t } from '@renderer/lib/i18n';
import '@renderer/lib/i18n-strings';
@@ -157,6 +159,29 @@ export function ChatInput(): React.JSX.Element {
if (currentSessionId && input) sessionStorage.setItem(`draft-${currentSessionId}`, input);
}, [input, currentSessionId]);
// v0.8.2 P0-4: 注册受控输入桥 —— 引用回复 / Ctrl+L 不再 DOM 直查直赋
// (旧实现对 InputBase 根 div 赋 value/focus,且受控组件不吃 DOM 注入,功能完全失效)
useEffect(() => {
registerChatInputController({
focus: () => textareaRef.current?.focus(),
appendText: (text: string) => {
setInput((prev) => {
const base = prev.length > 0 ? `${prev}\n` : '';
return `${base}${text}\n\n`;
});
// 光标移到末尾(受控更新后 textarea 才有新值,rAF 等一帧)
requestAnimationFrame(() => {
const el = textareaRef.current;
if (el) {
el.selectionStart = el.selectionEnd = el.value.length;
el.focus();
}
});
},
});
return () => registerChatInputController(null);
}, []);
// ===== 附件处理 =====
const classifyFile = useCallback((file: File): Attachment['type'] => {
@@ -377,10 +402,45 @@ export function ChatInput(): React.JSX.Element {
}
if (cmd === '/export') {
// P2-11: /export 改为导出 Markdown(人类可读),JSON 导出走会话右键菜单
// v0.8.2 P2-3 根治: 从 DB 全量拉取 —— v0.8.1 游标分页后内存 messages 仅
// 尾部 200 条窗口,旧实现静默导出残缺会话(与右键导出全量口径不一致)。
// DB 不可用(无会话/IPC 缺失)时回退内存窗口并提示。
import('@renderer/lib/export-markdown')
.then(({ buildSessionMarkdown, downloadMarkdown }) => {
const messages = useAgentStore.getState().messages;
const md = buildSessionMarkdown(t('chat.export.title'), messages);
.then(async ({ buildSessionMarkdown, downloadMarkdown }) => {
const sid = useAgentStore.getState().currentSessionId;
let source: Array<{
id: string;
role: string;
content: string | null;
reasoningContent?: string;
toolCalls?: Array<{ name: string; args?: Record<string, unknown> }>;
attachments?: Array<{ name: string; type: string }>;
timestamp: number;
}> = useAgentStore.getState().messages;
if (sid && window.metona?.sessions?.getMessages) {
try {
const all = await window.metona.sessions.getMessages(sid);
if (all.length > 0) {
source = all
.filter((m) => m.role !== 'tool')
.map((m) => ({
id: m.id,
role: m.role,
content: m.content,
reasoningContent: m.reasoningContent,
toolCalls: (m.toolCalls ?? []) as Array<{
name: string;
args?: Record<string, unknown>;
}>,
attachments: (m.attachments ?? []) as Array<{ name: string; type: string }>,
timestamp: m.timestamp ?? Date.now(),
}));
}
} catch {
// 拉取失败回退内存窗口
}
}
const md = buildSessionMarkdown(t('chat.export.title'), source);
downloadMarkdown(`session-${Date.now()}.md`, md);
})
.catch(() => {});
+16 -1
View File
@@ -13,7 +13,7 @@
import { useEffect, useRef } from 'react';
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
import { Box, Typography } from '@mui/material';
import { Box, Typography, CircularProgress } from '@mui/material';
import { useAgentStore } from '@renderer/stores/agent-store';
import { MessageItem } from './MessageItem';
import { StreamingIndicator } from './StreamingIndicator';
@@ -33,6 +33,20 @@ const ListFooter = (): React.JSX.Element => (
</Box>
);
/**
* v0.8.2 P2-2: 向上翻页加载指示(模块级稳定引用,同 ListFooter 的 reconciliation 契约)。
* loadingOlder 状态此前存在于 store 但无任何消费 —— 慢 DB 下上翻"像是没反应"。
*/
const ListHeader = (): React.JSX.Element => {
const loadingOlder = useAgentStore((s) => s.loadingOlder);
if (!loadingOlder) return <Box sx={{ height: 8 }} />;
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 1 }}>
<CircularProgress size={18} thickness={4} />
</Box>
);
};
export function MessageList(): React.JSX.Element {
const messages = useAgentStore((s) => s.messages);
const isStreaming = useAgentStore((s) => s.isStreaming);
@@ -120,6 +134,7 @@ export function MessageList(): React.JSX.Element {
</Box>
)}
components={{
Header: ListHeader,
Footer: ListFooter,
}}
/>
+164 -23
View File
@@ -1,9 +1,16 @@
/**
* ToolResultBlock — 工具结果块
*
* v0.8.2 P2-1: 富媒体渲染 —
* - 图片类结果(view_image 的 dataUrl / web_browser 截图的 image / MCP image 块)
* 在聊天流内联预览。此前只渲染 `_displayNote` 文字摘要,用户看不到工具实际
* 看到的图(大字段剥离仍然生效 —— store 原始 result 不变,仅展示层取图)。
* - diff 类结果(diff_viewer / file_editor dry_run 的 unified diff)渲染为
* 增删着色的 diff 视图,替代裸 JSON。
*/
import { Box, Typography, Stack } from '@mui/material';
import { FileText } from 'lucide-react';
import { FileText, Image as ImageIcon, GitCompareArrows } 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';
@@ -15,14 +22,125 @@ interface ToolResultBlockProps {
toolCall: ToolCallInfo;
}
/** diff 视图最大渲染行数(超出截断,防 DOM 爆炸) */
const DIFF_MAX_LINES = 400;
/**
* 从**原始** result 提取可展示的图片 srcdisplayResult 已剥离 dataUrl,不能用于取图)。
* 兼容三种形态:dataUrlview_image / MCP)、image 字段(web_browser 裸 base64 /
* MCP data URI)。非 data:image/ 前缀且非合法 base64 形态的一律返回 null。
*/
function extractImageSrc(result: unknown): string | null {
if (typeof result !== 'object' || result === null || Array.isArray(result)) return null;
const rec = result as Record<string, unknown>;
if (rec._imageOmitted) return null; // registry 已判定超限并替换占位符
const candidate =
typeof rec.dataUrl === 'string'
? rec.dataUrl
: typeof rec.image === 'string'
? rec.image
: null;
if (!candidate) return null;
if (candidate.startsWith('data:image/')) return candidate;
// 裸 base64web_browser 截图契约:{ image, mime_type })→ 补 data URI 前缀
const mime = typeof rec.mime_type === 'string' && rec.mime_type ? rec.mime_type : 'image/png';
if (candidate.length > 64 && /^[A-Za-z0-9+/=\r\n]+$/.test(candidate.slice(0, 256))) {
return `data:${mime};base64,${candidate}`;
}
return null;
}
/**
* 从 displayResult 提取 unified diff 文本(diff 字符串体积可控,走裁剪层安全)。
* 覆盖 diff_viewer(顶层 diff)与 file_editor dry_runpreview.diff)两种形态。
*/
function extractDiffText(display: unknown): string | null {
if (typeof display !== 'object' || display === null || Array.isArray(display)) return null;
const rec = display as Record<string, unknown>;
if (typeof rec.diff === 'string' && rec.diff.trim()) return rec.diff;
const preview = rec.preview;
if (
typeof preview === 'object' &&
preview !== null &&
typeof (preview as Record<string, unknown>).diff === 'string' &&
((preview as Record<string, unknown>).diff as string).trim()
) {
return (preview as Record<string, unknown>).diff as string;
}
return null;
}
/** unified diff 视图:增删行着色 + hunk 头高亮(MUI sx,禁自写组件符合开发规范) */
function DiffView({ diff }: { diff: string }): React.JSX.Element {
const allLines = diff.split('\n');
const lines = allLines.slice(0, DIFF_MAX_LINES);
return (
<Box
component="pre"
sx={{
fontSize: 11,
borderRadius: 1,
px: 1,
py: 0.75,
overflowX: 'auto',
overflowY: 'auto',
bgcolor: 'background.default',
fontFamily: "'SF Mono',monospace",
maxHeight: 320,
m: 0,
whiteSpace: 'pre',
}}
>
{lines.map((line, i) => {
const isAdd = line.startsWith('+') && !line.startsWith('+++');
const isDel = line.startsWith('-') && !line.startsWith('---');
const isHunk = line.startsWith('@@');
return (
<Box
key={i}
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
color: isAdd
? 'success.dark'
: isDel
? 'error.dark'
: isHunk
? 'info.main'
: 'text.secondary',
bgcolor: isAdd
? 'rgba(52, 211, 153, 0.08)'
: isDel
? 'rgba(248, 113, 113, 0.08)'
: 'transparent',
fontWeight: isHunk ? 600 : 400,
}}
>
{line || ' '}
</Box>
);
})}
{allLines.length > DIFF_MAX_LINES && (
<Box sx={{ color: 'text.secondary', mt: 0.5 }}>
[ {allLines.length - DIFF_MAX_LINES} more lines]
</Box>
)}
</Box>
);
}
export function ToolResultBlock({ toolCall }: ToolResultBlockProps): React.JSX.Element {
if (toolCall.status !== 'success' || toolCall.result == null) return <></>;
// 渲染前剥离 dataUrl 等超大 base64 字段,避免 ~6.7MB/张的 dataUrl 进 DOM 导致渲染进程 OOM
// 渲染前剥离 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);
// v0.8.2 P2-1: 富媒体路由 —— diff 视图 > 图片内联 > 默认 JSON pre
const diffText = extractDiffText(displayResult);
const imageSrc = extractImageSrc(toolCall.result);
return (
<Box
sx={{
@@ -39,7 +157,13 @@ export function ToolResultBlock({ toolCall }: ToolResultBlockProps): React.JSX.E
}}
>
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
<FileText size={12} style={{ color: '#22d3ee' }} />
{diffText ? (
<GitCompareArrows size={12} style={{ color: '#22d3ee' }} />
) : imageSrc ? (
<ImageIcon size={12} style={{ color: '#22d3ee' }} />
) : (
<FileText size={12} style={{ color: '#22d3ee' }} />
)}
<Typography variant="caption" sx={{ fontWeight: 500, color: 'text.primary' }}>
{t('toolResult.title', { name: toolCall.name })}
</Typography>
@@ -49,26 +173,43 @@ export function ToolResultBlock({ toolCall }: ToolResultBlockProps): React.JSX.E
</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',
}}
>
{resultStr}
</Box>
{diffText ? (
<DiffView diff={diffText} />
) : imageSrc ? (
<Box
component="img"
src={imageSrc}
alt={t('toolResult.imageAlt')}
sx={{
maxWidth: '100%',
maxHeight: 320,
borderRadius: 1,
display: 'block',
bgcolor: 'background.default',
}}
/>
) : (
<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>
);
}
+1 -1
View File
@@ -51,7 +51,7 @@ export function UserMessage({ message }: UserMessageProps): React.JSX.Element {
editContent.trim(),
);
if (r?.success === false) {
throw new Error(r.error ?? '保存失败');
throw new Error(r.error ?? t('message.saveFailed'));
}
}
updateMessage(message.id, { content: editContent.trim() });
@@ -64,18 +64,84 @@ describe('ToolResultBlock — 结果渲染', () => {
});
});
describe('ToolResultBlock — dataUrl 剥离', () => {
it('含 dataUrl 字段时剥离并显示 _displayNote', () => {
describe('ToolResultBlock — 富媒体渲染(v0.8.2 P2-1', () => {
it('含 dataUrl 字段 → 内联 <img> 预览,base64 不再以文本进 DOM', () => {
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 ?? '';
const img = container.querySelector('img');
expect(img).not.toBeNull();
expect(img?.getAttribute('src')).toBe(result.dataUrl);
// base64 不得以文本形态出现(防 OOM 契约保持)
const text = container.textContent ?? '';
expect(text).not.toContain('data:image/png;base64');
});
it('web_browser 截图(裸 base64 + mime_type)→ 内联预览', () => {
const result = { image: 'A'.repeat(200), mime_type: 'image/jpeg', width: 800, height: 600 };
const { container } = render(
<ToolResultBlock toolCall={makeToolCall({ name: 'web_browser', result })} />,
);
const img = container.querySelector('img');
expect(img?.getAttribute('src')).toBe(`data:image/jpeg;base64,${result.image}`);
});
it('diff_viewer 结果(顶层 diff)→ unified diff 视图', () => {
const result = {
diff: [
'--- a.txt',
'+++ b.txt',
'@@ -1,2 +1,2 @@',
' context',
'-removed line',
'+added line',
].join('\n'),
summary: { lines_added: 1, lines_removed: 1, unchanged: 1 },
};
const { container } = render(
<ToolResultBlock toolCall={makeToolCall({ name: 'diff_viewer', result })} />,
);
const pre = container.querySelector('pre');
expect(pre?.textContent).toContain('added line');
expect(pre?.textContent).toContain('removed line');
});
it('file_editor dry_runpreview.diff)→ 同样走 diff 视图', () => {
const result = {
dry_run: true,
preview: {
original: 'x',
modified: 'y',
diff: ['--- old', '+++ new', '@@ -1 +1 @@', '-x', '+y'].join('\n'),
},
};
const { container } = render(<ToolResultBlock toolCall={makeToolCall({ result })} />);
expect(container.querySelector('pre')?.textContent).toContain('+y');
});
it('_imageOmittedregistry 判定超限占位)→ 不渲染 <img>', () => {
const result = { image: 'data:image/png;base64,AAAA', _imageOmitted: true };
const { container } = render(<ToolResultBlock toolCall={makeToolCall({ result })} />);
expect(container.querySelector('img')).toBeNull();
});
});
describe('ToolResultBlock — dataUrl 剥离(display 层契约保持)', () => {
it('含 dataUrl 时展示层剥离大字段(displayResult 不含 base64 文本)', () => {
const result = {
dataUrl: 'data:image/png;base64,' + 'A'.repeat(5000),
path: '/tmp/screenshot.png',
size: 1234,
};
const { container } = render(<ToolResultBlock toolCall={makeToolCall({ result })} />);
// <img> 预览之外,任何文本节点都不得包含 base64 主体
const text = Array.from(container.querySelectorAll('pre'))
.map((el) => el.textContent)
.join('');
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('普通大字符串被截断显示(防御裁剪)', () => {
+19 -4
View File
@@ -75,13 +75,28 @@ export function DetailPanel(): React.JSX.Element {
'& .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={<Activity size={12} />}
iconPosition="start"
label={t('detailPanel.tab.trace')}
value="trace"
/>
<Tab
icon={<Brain size={12} />}
iconPosition="start"
label={t('detailPanel.tab.memory')}
value="memory"
/>
<Tab
icon={<ListChecks size={12} />}
iconPosition="start"
label={t('detailPanel.tab.tasks')}
value="tasks"
/>
<Tab
icon={<FolderOpen size={12} />}
iconPosition="start"
label="Workspace"
label={t('detailPanel.tab.workspace')}
value="workspace"
/>
</Tabs>
+19 -12
View File
@@ -96,14 +96,20 @@ export function Sidebar(): React.JSX.Element {
};
}, []);
// v0.7.3 P4-1: 主进程 LLM 标题生成完成后经 config:changed 广播
// (合成 keysession.title.<sessionId>),此处消费并刷新侧栏标题
// v0.8.2 P2-4 根治: 会话元数据实时刷新 —— ① 标题生成结果改走专用
// session:updated 事件(此前伪装成 config:changed 合成 key,语义混用);
// ② messageCount/updatedAt 随主进程落库实时刷新(此前仅挂载时 list() 一次,
// "x 条消息 / 3 分钟前"停留在旧值直到重启)。
useEffect(() => {
if (!window.metona?.config?.onChanged) return;
const unsubscribe = window.metona.config.onChanged((data) => {
const match = /^session\.title\.(.+)$/.exec(data.key);
if (match && typeof data.value === 'string' && data.value) {
useSessionStore.getState().updateSession(match[1], { title: data.value });
if (!window.metona?.sessions?.onSessionUpdated) return;
const unsubscribe = window.metona.sessions.onSessionUpdated((data) => {
if (!data?.sessionId) return;
const patch: { title?: string; messageCount?: number; updatedAt?: number } = {};
if (typeof data.title === 'string' && data.title) patch.title = data.title;
if (typeof data.messageCount === 'number') patch.messageCount = data.messageCount;
if (typeof data.updatedAt === 'number') patch.updatedAt = data.updatedAt;
if (Object.keys(patch).length > 0) {
useSessionStore.getState().updateSession(data.sessionId, patch);
}
});
return unsubscribe;
@@ -788,13 +794,14 @@ function ToolManagerPanel() {
medium: 'warning.main',
high: 'error.main',
};
// v0.8.2 P2-5: 风险标签出层(此前硬编码英文 SAFE/LOW/...
const riskLabels: Record<string, string> = {
safe: 'SAFE',
low: 'LOW',
medium: 'MEDIUM',
high: 'HIGH',
safe: t('sidebar.risk.safe'),
low: t('sidebar.risk.low'),
medium: t('sidebar.risk.medium'),
high: t('sidebar.risk.high'),
// v0.8.0 P1-3.8: 补 critical —— 旧映射缺失导致 CRITICAL 工具显示原始字符串
critical: 'CRITICAL',
critical: t('sidebar.risk.critical'),
};
return (
<Box>
@@ -0,0 +1,125 @@
// @vitest-environment jsdom
/**
* Sidebar 组件测试(v0.8.2 P3-3 补齐设置面板/侧栏测试空白)
*
* 覆盖契约:
* - 挂载时拉取会话列表并渲染标题
* - v0.8.2 P2-4: session:updated 实时刷新 messageCount/updatedAt/title
* - 搜索框(标题 + 内容全文搜索入口)存在
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Sidebar } from '../Sidebar';
import { useSessionStore } from '@renderer/stores/session-store';
const noop = (): void => {};
const sessionUpdatedHandlers: Array<(data: unknown) => void> = [];
function makeSession(overrides: Record<string, unknown> = {}): {
id: string;
title: string;
createdAt: number;
updatedAt: number;
messageCount: number;
totalTokens: number;
pinned: boolean;
archived: boolean;
} {
return {
id: 's1',
title: '第一个会话',
createdAt: Date.now(),
updatedAt: Date.now(),
messageCount: 2,
totalTokens: 0,
pinned: false,
archived: false,
...overrides,
};
}
beforeEach(() => {
sessionUpdatedHandlers.length = 0;
useSessionStore.getState().setSessions([]);
useSessionStore.getState().setCurrentSession(null);
const bridge = (window as unknown as { metona: Record<string, unknown> }).metona;
bridge.sessions = {
...(bridge.sessions as Record<string, unknown>),
list: vi.fn().mockResolvedValue([makeSession(), makeSession({ id: 's2', title: 'Second' })]),
searchContent: vi.fn().mockResolvedValue({ success: true, data: [] }),
onSessionUpdated: vi.fn((cb: (data: unknown) => void) => {
sessionUpdatedHandlers.push(cb);
return noop;
}),
};
});
describe('Sidebar — 会话列表', () => {
it('挂载时拉取并渲染会话标题', async () => {
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('第一个会话')).toBeInTheDocument();
expect(screen.getByText('Second')).toBeInTheDocument();
});
});
it('渲染搜索框与新建会话按钮', () => {
render(<Sidebar />);
expect(screen.getByPlaceholderText(/搜索|Search/)).toBeInTheDocument();
expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
});
it('接收 session:updated 后实时刷新条数(P2-4', async () => {
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('第一个会话')).toBeInTheDocument();
});
expect(sessionUpdatedHandlers.length).toBeGreaterThan(0);
act(() => {
sessionUpdatedHandlers.forEach((h) =>
h({ sessionId: 's1', messageCount: 42, updatedAt: Date.now() }),
);
});
await waitFor(() => {
// t('sidebar.messageCount') zh 文案为 "{{count}} 条"
expect(screen.getByText(/42\s*条|42 messages/)).toBeInTheDocument();
});
});
it('接收 session:updated 的 title 补丁 → 标题刷新(LLM 标题生成链路)', async () => {
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('第一个会话')).toBeInTheDocument();
});
act(() => {
sessionUpdatedHandlers.forEach((h) => h({ sessionId: 's1', title: 'LLM 生成的新标题' }));
});
await waitFor(() => {
expect(screen.getByText('LLM 生成的新标题')).toBeInTheDocument();
});
});
});
describe('Sidebar — 搜索', () => {
it('输入搜索词触发内容全文搜索(FTS5 通道,300ms 防抖)', async () => {
const user = userEvent.setup();
render(<Sidebar />);
const input = screen.getByPlaceholderText(/搜索|Search/);
await user.type(input, '关键词');
await waitFor(
() => {
const bridge = (window as unknown as { metona: Record<string, unknown> }).metona;
expect(bridge.sessions).toBeDefined();
expect(
(bridge.sessions as { searchContent: { mock: { calls: unknown[][] } } }).searchContent
.mock.calls.length,
).toBeGreaterThan(0);
},
{ timeout: 2_000 },
);
});
});
+24 -1
View File
@@ -47,6 +47,9 @@ const ONBOARDING_PROGRESS_KEY = 'onboarding_progress';
export function OnboardingWizard(): React.JSX.Element | null {
const onboardingCompleted = useUIStore((s) => s.onboardingCompleted);
const setOnboardingCompleted = useUIStore((s) => s.setOnboardingCompleted);
// v0.8.2 P3-4: 配置就绪门禁 —— onboarding.completed 异步读取期间不渲染向导,
// 消除老用户每次启动的向导首帧闪烁
const onboardingGateReady = useUIStore((s) => s.onboardingGateReady);
const [step, setStep] = useState(0);
const [provider, setProvider] = useState('');
// v0.5.4: 多模态总开关(保存到 llm.multimodalEnabled,控制图片上传入口)
@@ -110,7 +113,7 @@ export function OnboardingWizard(): React.JSX.Element | null {
}
}, [step, provider, baseURL, model, workspacePath, contextWindow]);
if (onboardingCompleted) return null;
if (onboardingCompleted || !onboardingGateReady) return null;
// v0.8.1 硬性契约: 上下文长度是全局单一配置(llm.contextWindow),不再存在
// 分 Provider 默认值 —— 删除了旧的 DEFAULT_CTX 写死表(1M/128K/200K/4096)。
@@ -119,7 +122,27 @@ export function OnboardingWizard(): React.JSX.Element | null {
const ctxError =
contextWindow != null && (!Number.isFinite(contextWindow) || contextWindow < ctxMin);
// v0.8.2 P3-4: 步骤字段校验门禁 —— 此前"下一步"无任何校验,可以全空走完,
// 完成后 provider/model 缺失 → 首次发送必然 adapter 加载失败。LLM 配置步
// 强制 provider + model+ apiKeyollama 除外)+ 合法上下文长度。
const validateStep = (s: number): string | null => {
if (s === 1) {
if (!provider.trim()) return t('onboarding.validate.provider');
if (!model.trim()) return t('onboarding.validate.model');
if (provider !== 'ollama' && !apiKey.trim()) return t('onboarding.validate.apiKey');
if (ctxError) return t('onboarding.llm.ctx.minError', { min: ctxMin });
}
return null;
};
const handleNext = async () => {
const validationError = validateStep(step);
if (validationError) {
import('@metona-team/metona-toast')
.then((mod) => mod.default.warning(validationError))
.catch(() => {});
return;
}
if (step < STEPS.length - 1) {
setStep(step + 1);
return;
+4 -4
View File
@@ -107,10 +107,10 @@ export function AgentSettings() {
label={t('settings.agent.thinkingEffort')}
onChange={(e) => setThinkingEffort(e.target.value)}
>
<MenuItem value="low">Low</MenuItem>
<MenuItem value="medium">Medium</MenuItem>
<MenuItem value="high">High</MenuItem>
<MenuItem value="max">Max</MenuItem>
<MenuItem value="low">{t('agentSettings.effort.low')}</MenuItem>
<MenuItem value="medium">{t('agentSettings.effort.medium')}</MenuItem>
<MenuItem value="high">{t('agentSettings.effort.high')}</MenuItem>
<MenuItem value="max">{t('agentSettings.effort.max')}</MenuItem>
</Select>
</FormControl>
)}
+16
View File
@@ -506,14 +506,18 @@ function UpdatePanel(): React.JSX.Element {
const [installing, setInstalling] = useState(false);
const [result, setResult] = useState<string | null>(null);
const [progress, setProgress] = useState<number | null>(null);
// v0.8.2 P1-3: 更新包已下载、等待用户确认重启安装
const [downloadedReady, setDownloadedReady] = useState(false);
useEffect(() => {
if (!window.metona?.app?.onUpdateStatus) return;
const off = window.metona.app.onUpdateStatus((event) => {
if (event.status === 'downloading') {
setProgress(event.percent ?? 0);
setDownloadedReady(false);
} else if (event.status === 'downloaded') {
setProgress(100);
setDownloadedReady(true);
setResult(t('settings.logs.update.downloaded'));
} else if (event.status === 'available') {
setResult(t('settings.logs.update.available', { version: event.latestVersion ?? '' }));
@@ -584,6 +588,18 @@ function UpdatePanel(): React.JSX.Element {
: t('settings.logs.update.downloadInstall')}
</Button>
</Stack>
{downloadedReady && (
<Button
variant="contained"
color="primary"
size="small"
onClick={() => {
void window.metona?.app?.updateInstallNow();
}}
>
{t('settings.logs.update.restartToInstall')}
</Button>
)}
{progress != null && (
<LinearProgress
variant="determinate"
+15
View File
@@ -224,6 +224,21 @@ export function MCPSettings() {
<Typography variant="caption" sx={{ color: statusColors[s.status] }}>
{statusLabel(s)}
</Typography>
{/* v0.8.2 P2-7: 禁用态 server 现在也会出现在列表中(后端补齐),显式标注 */}
{(s as { enabled?: boolean }).enabled === false && (
<Typography
variant="caption"
sx={{
color: 'text.disabled',
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
px: 0.5,
}}
>
{t('settings.mcp.disabled')}
</Typography>
)}
{s.toolCount > 0 && (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('settings.mcp.toolCount', { count: s.toolCount })}
@@ -0,0 +1,121 @@
// @vitest-environment jsdom
/**
* LLMSettings 组件测试(v0.8.2 P3-3 补齐设置面板测试空白)
*
* 覆盖契约:
* - 挂载时经 useConfig 逐 key 读取配置(草稿回填)
* - v0.8.1 硬性契约保持:contextWindow/maxTokens 清空 = 写入 null(未配置语义)
* - 批量保存:handleSave 经 config.setBatch 一次提交全部 13 项
* - 保存成功后同步多模态开关到 Agent Store
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LLMSettings } from '../LLMSettings';
import { useAgentStore } from '@renderer/stores/agent-store';
const configured: Record<string, unknown> = {
'llm.provider': 'deepseek',
'llm.baseURL': 'https://api.deepseek.com',
'llm.model': 'deepseek-v4-flash',
'llm.apiKey': 'sk-test-key',
'llm.multimodalEnabled': false,
};
beforeEach(() => {
const bridge = (window as unknown as { metona: Record<string, unknown> }).metona;
bridge.config = {
...(bridge.config as Record<string, unknown>),
get: vi.fn().mockImplementation((key: string) => Promise.resolve(configured[key] ?? null)),
set: vi.fn().mockResolvedValue({ success: true }),
setBatch: vi.fn().mockResolvedValue({ success: true }),
onChanged: vi.fn(() => () => {}),
};
bridge.llm = {
...(bridge.llm as Record<string, unknown>),
listModels: vi.fn().mockResolvedValue({ success: true, data: [] }),
getBalance: vi.fn().mockResolvedValue({ success: false }),
pullModel: vi.fn().mockResolvedValue({ success: true }),
cancelPullModel: vi.fn().mockResolvedValue({ success: true }),
onOllamaPullProgress: vi.fn(() => () => {}),
onOllamaPullEnded: vi.fn(() => () => {}),
};
});
async function renderLoaded(): Promise<void> {
render(<LLMSettings />);
// loaded 门禁:模型输入框回填配置值后才可交互
await waitFor(
() => {
const inputs = document.querySelectorAll('input');
const modelInput = Array.from(inputs).find((el) => el.value === 'deepseek-v4-flash');
if (!modelInput) throw new Error('not loaded yet');
},
{ timeout: 3_000 },
);
}
describe('LLMSettings — 批量保存', () => {
it(
'修改模型后保存 → setBatch 携带 llm.model 新值与原样 apiKey',
{ timeout: 30_000 },
async () => {
const user = userEvent.setup();
await renderLoaded();
const inputs = Array.from(document.querySelectorAll('input'));
const modelInput = inputs.find((el) => el.value === 'deepseek-v4-flash') as HTMLInputElement;
await user.clear(modelInput);
await user.type(modelInput, 'deepseek-v4-pro');
const saveButton = screen.getByRole('button', { name: /保存配置|Save Configuration/ });
await user.click(saveButton);
const bridge = (window as unknown as { metona: Record<string, unknown> }).metona;
const setBatch = bridge.config as { setBatch: { mock: { calls: unknown[][] } } };
await waitFor(() => {
expect(setBatch.setBatch.mock.calls.length).toBeGreaterThan(0);
});
const entries = setBatch.setBatch.mock.calls[0][0] as Array<{ key: string; value: unknown }>;
const byKey = Object.fromEntries(entries.map((e) => [e.key, e.value]));
expect(byKey['llm.model']).toBe('deepseek-v4-pro');
expect(byKey['llm.provider']).toBe('deepseek');
expect(byKey['llm.apiKey']).toBe('sk-test-key');
// v0.8.1 契约: 未配置的 contextWindow/maxTokens 以 null 落库(未配置语义)
expect(byKey['llm.contextWindow']).toBeNull();
expect(byKey['llm.maxTokens']).toBeNull();
},
);
it('保存成功 → 多模态开关同步到 Agent Store', { timeout: 20_000 }, async () => {
const user = userEvent.setup();
await renderLoaded();
const saveButton = screen.getByRole('button', { name: /保存配置|Save Configuration/ });
await user.click(saveButton);
await waitFor(() => {
// setBatch 已调用(多模态同步发生在成功分支)
const bridge = (window as unknown as { metona: Record<string, unknown> }).metona;
const setBatch = bridge.config as { setBatch: { mock: { calls: unknown[][] } } };
expect(setBatch.setBatch.mock.calls.length).toBeGreaterThan(0);
expect(useAgentStore.getState().multimodalEnabled).toBe(false);
});
});
it('setBatch 失败 → 不视为成功(保存失败语义由 toast 呈现)', { timeout: 20_000 }, async () => {
const bridge = (window as unknown as { metona: Record<string, unknown> }).metona;
bridge.config = {
...(bridge.config as Record<string, unknown>),
setBatch: vi.fn().mockResolvedValue({ success: false, error: 'reload failed' }),
};
const user = userEvent.setup();
await renderLoaded();
const saveButton = screen.getByRole('button', { name: /保存配置|Save Configuration/ });
await user.click(saveButton);
await waitFor(() => {
const setBatch = bridge.config as { setBatch: { mock: { calls: unknown[][] } } };
expect(setBatch.setBatch.mock.calls.length).toBeGreaterThan(0);
});
// 无异常抛出(失败走 toast 分支),组件不崩溃
expect(screen.getByRole('button', { name: /保存配置|Save Configuration/ })).toBeEnabled();
});
});
+21 -1
View File
@@ -245,8 +245,28 @@ export function TokenUsage(): React.JSX.Element {
</TableCell>
</TableRow>
)}
{tokenUsage.cacheMissTokens != null && (
<TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}>
{t('tokenUsage.cacheMiss')}
</TableCell>
<TableCell
sx={{
fontFamily: 'monospace',
fontWeight: 600,
fontSize: 11,
color: 'text.secondary',
textAlign: 'right',
}}
>
{formatTokens(tokenUsage.cacheMissTokens)}
</TableCell>
</TableRow>
)}
<TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}></TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}>
{t('tokenUsage.iterations')}
</TableCell>
<TableCell
sx={{
fontFamily: 'monospace',
+1 -1
View File
@@ -151,7 +151,7 @@ export function TraceStep({ step, isCurrent }: TraceStepProps): React.JSX.Elemen
variant="caption"
sx={{ fontWeight: 600, color: 'warning.main', fontSize: 10 }}
>
💭 Thought
{t('trace.step.thought')}
</Typography>
<Box
component="pre"
+15 -4
View File
@@ -25,15 +25,22 @@ interface RunGroup {
steps: TraceStepType[];
isCurrent: boolean; // 是否为当前正在进行的 run
isCompleted: boolean; // 是否已完成(最后一个 step 有 completedAt
/** v0.8.2 P2-2: 无 runId 的 legacy 步骤分组(旧版本会话恢复的 trace) */
isLegacy: boolean;
}
/** v0.8.2 P2-2: legacy 步骤的分组键(runId 字段在 v0.8.0 前不存在) */
const LEGACY_RUN_ID = '__legacy__';
function groupByRun(traceSteps: TraceStepType[], currentRunId: string | null): RunGroup[] {
const runOrder: string[] = [];
const runMap = new Map<string, TraceStepType[]>();
for (const step of traceSteps) {
const rid = step.runId;
if (!rid) continue;
// v0.8.2 P2-2 根治: 无 runId 的 legacy 步骤此前被直接丢弃
//`if (!rid) continue`)—— 升级用户的旧会话在 Trace 面板整体空白,
// 数据在、UI 不可见。现归入独立的 legacy 分组按原顺序展示。
const rid = step.runId || LEGACY_RUN_ID;
if (!runMap.has(rid)) {
runMap.set(rid, []);
runOrder.push(rid);
@@ -54,6 +61,7 @@ function groupByRun(traceSteps: TraceStepType[], currentRunId: string | null): R
steps,
isCurrent: rid === effectiveCurrentRunId,
isCompleted: Boolean(lastStep?.completedAt),
isLegacy: rid === LEGACY_RUN_ID,
};
});
}
@@ -96,7 +104,7 @@ export function TraceViewer(): React.JSX.Element {
color: 'text.secondary',
}}
>
Trace Viewer
{t('trace.viewer.title')}
</Typography>
{!isEmpty && (
<Typography variant="caption" sx={{ color: 'text.disabled', ml: 'auto' }}>
@@ -161,7 +169,10 @@ export function TraceViewer(): React.JSX.Element {
letterSpacing: 0.5,
}}
>
{t('trace.viewer.roundNumber', { index: group.index })}
{/* v0.8.2 P2-2: legacy 分组展示"历史(旧版本)"而非轮次编号 */}
{group.isLegacy
? t('trace.viewer.legacyRound')
: t('trace.viewer.roundNumber', { index: group.index })}
</Typography>
{group.isCurrent && agentStatus !== 'idle' && (
<Chip