feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
This commit is contained in:
@@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 会在
|
||||
// 组件卸载后触发 setState(React 警告 / 测试环境 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 (
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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 提取可展示的图片 src(displayResult 已剥离 dataUrl,不能用于取图)。
|
||||
* 兼容三种形态:dataUrl(view_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;
|
||||
// 裸 base64(web_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_run(preview.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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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_run(preview.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('_imageOmitted(registry 判定超限占位)→ 不渲染 <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('普通大字符串被截断显示(防御裁剪)', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -96,14 +96,20 @@ export function Sidebar(): React.JSX.Element {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// v0.7.3 P4-1: 主进程 LLM 标题生成完成后经 config:changed 广播
|
||||
// (合成 key:session.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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(+ apiKey,ollama 除外)+ 合法上下文长度。
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user