feat: v0.8.0 流语义补全 · 会话可靠 · 恢复力 — finish_reason 全链路贯通根治"思考中停止" · 2445 用例全量回归
P0 会话可靠性收口(根治"模型思考着会话就停止"): - P0-1 finish_reason 全链路贯通:DONE 事件与 IterationStep 新增 finishReason,OpenAI 共享 SSE / Anthropic message_delta.stop_reason / Ollama done_reason 三路采集,TRACE 层弃用硬编码 'stop' 记录真值 - P0-2 空响应守卫 + 降级重试:零产出流→可重试错误走退避;思考耗尽输出预算(reasoning-only + length)→自动关闭思考降级重试一次;仍失败→OUTPUT_LENGTH_EXCEEDED 结构化错误 + 故障转移;附带根治 abort 恰逢零工具调用轮被 COMPLETED 抢占的真实缺陷 - P0-3 思考×能力×预算三对齐:DeepSeek/MiMo/Agnes/Ollama 四家 supportsThinking=false 强制不发思考参数;小输出预算告警;设置页联动提示 - P0-4 渲染层可见性:截断/空完成/友好错误三类提示,i18n 全部出层 - P0-5 回归四件套:reasoning-only 终止判定、集成级空闲超时、504 引擎重试归类、思考中 abort→USER_INTERRUPT、P4-2 强制收尾路径 FEAT-1:LLM 设置新增「最大输出上限」——Provider 支持矩阵显隐 + 模型上限钳制提示 + 超限保存警告 + llm.maxTokens 热生效 P1 修复面收口: - 渲染层三缺陷根治:后台会话回放缓冲(2000 条/4MB 有界 + agent:getReplayState + 事件总线)+ abort 双层自愈 + sendMessage 收尾兜底 + 中断卡片清扫 - 工具 abort 信号全覆盖:web_search/web_fetch/http_request/code_search/git 系列/delegate_task 全部接入引擎中断;web_search 时间预算收敛(720s→≤240s);移除伪造 ToolExecutionContext 与死代码 - 安全:本地 Pinned CONNECT 代理根治浏览器通道 DNS rebinding(校验期 IP pinning,可注入 resolver 表测);配置 URL 域名解析深校验(DeepCheckSoftFailure 软失败);SSE 空 error 帧防御修复;Ollama generate/embed AbortSignal.any 合并 - 缺陷清单:UTF-16 BOM 读取、tmp 同毫秒碰撞(nanoid 后缀)、code_search JS 回退参数对称(case_sensitive/前后文独立)、list_directory include_node_modules、崩溃自愈退避(60s 窗 ≥3 次停 reload)、MemoryViewer/Sidebar i18n 收口 P2 能力演进: - 会话回收站:SCHEMA_VERSION 3 + 迁移 10(deleted_at,存在性守卫),软删除/恢复/彻底删除/30 天自动清理(启动+24h),searchMessages 聚合剔除,Sidebar 回收站面板 - 会话回放播放器:sessions:listRecordings/readRecording(白名单+目录边界+20MB 上限),SessionReplayPlayer 时间轴/步进/变速,Trace 面板入口 - electron-updater 自动更新:双轨(手动 feed 比对保留),生产环境启动静默检查 + update:status 广播 + app:updateInstall + LogsSettings UpdatePanel + builder publish 配置 - @ 文件提及:workspace.listFiles/readFileClip(边界/512KB/NUL 拒绝/MEMORY.md 保护),ChatInput Fuse 联想+键盘导航+附件管线注入 - MCP Resources/Prompts 发现:可选能力 try/catch 降级,mcp:listServerContents,MCPSettings 展开视图 - 文档对齐:内部 API 标准 HTML(Adapter 清单补 MiMo/已实现注记/STREAM_RESET/DONE.finishReason/ repetition_truncation 映射);README v0.8.0 亮点表 P3 测试基建: - 新增 4 个测试文件:engine-stream-contract(6)、engine-stream-reliability(4:集成空闲超时/504 重试/思考中 abort/P4-2 强制收尾)、thinking-capability-gate(7)、pinned-proxy(9,含深校验 5)、session-trash(5,DB 域)、use-agent-stream hook 级(5)、agent.test 回放缓冲(2) - 契约更新:orchestrator 被中断 SubAgent success=false(abort 优先级修复语义)、SSE 空 error 帧、UTF-16 正常读取、DeepSeek 未配置思考显式 disabled、迁移矩阵 v2→3 - 弱断言根治:registry WEBP 单向断言、hooks-contracts 自比恒真、memory 空 token 补强 全量验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2144 通过(301 DB 用例按 ABI 跳过)/ Electron ABI 2445/2445 全量通过 0 跳过
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 输入框智能特性
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
@@ -22,7 +22,8 @@ import {
|
||||
List,
|
||||
ListItemButton,
|
||||
} from '@mui/material';
|
||||
import { Send, Paperclip, Square, X, FileText, Image as ImageIcon } from 'lucide-react';
|
||||
import { Send, Paperclip, Square, X, FileText, Image as ImageIcon, AtSign } from 'lucide-react';
|
||||
import Fuse from 'fuse.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { useSessionStore } from '@renderer/stores/session-store';
|
||||
@@ -102,6 +103,12 @@ export function ChatInput(): React.JSX.Element {
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [showSlashMenu, setShowSlashMenu] = useState(false);
|
||||
const [slashFilter, setSlashFilter] = useState('');
|
||||
// v0.8.0 P2-4: @ 文件提及 —— 联想弹层状态 + 工作空间文件索引(懒加载)
|
||||
const [mentionOpen, setMentionOpen] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState('');
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
const [workspaceFiles, setWorkspaceFiles] = useState<string[]>([]);
|
||||
const workspaceFilesLoadedRef = useRef(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const sendMessage = useAgentStore((s) => s.sendMessage);
|
||||
@@ -302,6 +309,29 @@ export function ChatInput(): React.JSX.Element {
|
||||
|
||||
// ===== 发送消息 =====
|
||||
|
||||
/**
|
||||
* v0.8.0 P2-4: 从消息文本提取 @ 文件提及路径。
|
||||
* 规则:'@' 前为单词字符(邮箱场景)跳过;路径字符集 [A-Za-z0-9._\-/];
|
||||
* 尾部 '.' 剥离(句末标点);至少包含 '.' 或 '/'(排除纯代词式 @someone)。
|
||||
*/
|
||||
const extractMentions = (text: string): string[] => {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] !== '@') continue;
|
||||
if (i > 0 && /\w/.test(text[i - 1])) continue;
|
||||
let j = i + 1;
|
||||
let path = '';
|
||||
while (j < text.length && /[A-Za-z0-9._\-/]/.test(text[j])) {
|
||||
path += text[j];
|
||||
j++;
|
||||
}
|
||||
path = path.replace(/\.+$/, '');
|
||||
if (path && (path.includes('/') || path.includes('.'))) out.push(path);
|
||||
i = j - 1;
|
||||
}
|
||||
return [...new Set(out)];
|
||||
};
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed && attachments.length === 0) return;
|
||||
@@ -387,10 +417,63 @@ export function ChatInput(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
// v0.8.0 P2-4: @ 文件提及注入 —— 解析 @path token,读取文本片段并作为
|
||||
// 文本附件注入(复用附件管线:UI 卡片 + 主进程"内容已内联/截断"提示)。
|
||||
// 主进程 agent-store.sendMessage 会把 textContent 结构化内联进 LLM 消息。
|
||||
const mentionInfos: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'text';
|
||||
size: number;
|
||||
textContent: string;
|
||||
truncated?: boolean;
|
||||
}> = [];
|
||||
const mentionedPaths = extractMentions(messageContent).slice(0, 5);
|
||||
for (const p of mentionedPaths) {
|
||||
try {
|
||||
const clip = await window.metona?.workspace?.readFileClip?.(p);
|
||||
if (clip?.success && typeof clip.content === 'string') {
|
||||
let textContent = clip.content;
|
||||
if (clip.truncated) {
|
||||
textContent += t('input.text.truncatedNote', {
|
||||
size: formatFileSize(clip.size ?? 0),
|
||||
limit: formatFileSize(MAX_TEXT_ATTACHMENT_BYTES),
|
||||
});
|
||||
}
|
||||
mentionInfos.push({
|
||||
id: `mnt_${nanoid(6)}`,
|
||||
name: p,
|
||||
type: 'text',
|
||||
size: clip.size ?? clip.content.length,
|
||||
textContent,
|
||||
truncated: clip.truncated,
|
||||
});
|
||||
} else {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) =>
|
||||
mod.default.warning(
|
||||
t('input.mention.failed', { path: p, reason: clip?.error ?? '' }),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) =>
|
||||
mod.default.warning(
|
||||
t('input.mention.failed', { path: p, reason: (err as Error).message }),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const allAttachmentInfos = [...attachmentInfos, ...mentionInfos];
|
||||
|
||||
sendMessage(
|
||||
messageContent,
|
||||
images.length > 0 ? images : undefined,
|
||||
attachmentInfos.length > 0 ? attachmentInfos : undefined,
|
||||
allAttachmentInfos.length > 0 ? allAttachmentInfos : undefined,
|
||||
);
|
||||
setInput('');
|
||||
setAttachments([]);
|
||||
@@ -403,8 +486,60 @@ export function ChatInput(): React.JSX.Element {
|
||||
abort();
|
||||
}, [abort]);
|
||||
|
||||
// v0.8.0 P2-4: @ 联想候选(Fuse 模糊匹配;query 为空时按路径字典序取前 8 条)
|
||||
const mentionCandidates = useMemo(() => {
|
||||
if (workspaceFiles.length === 0) return [];
|
||||
if (!mentionQuery) return [...workspaceFiles].sort().slice(0, 8);
|
||||
const fuse = new Fuse(workspaceFiles, { includeScore: true, threshold: 0.4 });
|
||||
return fuse
|
||||
.search(mentionQuery)
|
||||
.map((r) => r.item)
|
||||
.slice(0, 8);
|
||||
}, [workspaceFiles, mentionQuery]);
|
||||
|
||||
// v0.8.0 P2-4: 选中候选 —— 把 "@query" 片段替换为 "@path"
|
||||
const applyMention = useCallback(
|
||||
(path: string): void => {
|
||||
setInput((prev) => {
|
||||
const pos = prev.lastIndexOf(`@${mentionQuery}`);
|
||||
if (pos < 0) return `${prev}${path}`;
|
||||
return `${prev.slice(0, pos)}@${path}${prev.slice(pos + mentionQuery.length + 1)}`;
|
||||
});
|
||||
setMentionOpen(false);
|
||||
setMentionQuery('');
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
},
|
||||
[mentionQuery],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
// v0.8.0 P2-4: @ 联想弹层的键盘导航(优先级高于发送/斜杠菜单)
|
||||
if (mentionOpen && mentionCandidates.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setMentionIndex((i) => (i + 1) % mentionCandidates.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setMentionIndex((i) => (i - 1 + mentionCandidates.length) % mentionCandidates.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab' || e.key === 'Enter') {
|
||||
if (e.key === 'Enter' && (e.nativeEvent.isComposing || e.keyCode === 229)) return;
|
||||
e.preventDefault();
|
||||
applyMention(mentionCandidates[Math.min(mentionIndex, mentionCandidates.length - 1)]);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setMentionOpen(false);
|
||||
setMentionQuery('');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// H-9 修复: 快捷键对齐规范
|
||||
// @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 快捷键规范
|
||||
// 规范要求: Cmd/Ctrl+Enter = 发送消息, Cmd/Ctrl+Shift+Enter = 换行
|
||||
@@ -456,7 +591,7 @@ export function ChatInput(): React.JSX.Element {
|
||||
return;
|
||||
}
|
||||
},
|
||||
[handleSend, showSlashMenu],
|
||||
[handleSend, showSlashMenu, mentionOpen, mentionCandidates, mentionIndex, applyMention],
|
||||
);
|
||||
|
||||
// H-8 修复: 使用 InputBase 后,onChange 类型需兼容 HTMLInputElement | HTMLTextAreaElement
|
||||
@@ -473,6 +608,30 @@ export function ChatInput(): React.JSX.Element {
|
||||
} else {
|
||||
setShowSlashMenu(false);
|
||||
}
|
||||
|
||||
// v0.8.0 P2-4: @ 文件提及触发 —— 光标前最后一个 '@' 到光标之间无空白时
|
||||
// 视为提及输入中;空白/删除 '@' 即关闭。首次触发懒加载文件索引。
|
||||
const pos = (e.target as HTMLTextAreaElement | HTMLInputElement).selectionStart ?? v.length;
|
||||
const before = v.slice(0, pos);
|
||||
const atIdx = before.lastIndexOf('@');
|
||||
if (atIdx >= 0 && !/[\s]/.test(before.slice(atIdx + 1))) {
|
||||
const q = before.slice(atIdx + 1);
|
||||
setMentionOpen(true);
|
||||
setMentionQuery(q);
|
||||
setMentionIndex(0);
|
||||
if (!workspaceFilesLoadedRef.current) {
|
||||
workspaceFilesLoadedRef.current = true;
|
||||
void window.metona?.workspace
|
||||
?.listFiles?.()
|
||||
.then((r) => {
|
||||
if (r?.success && r.data) setWorkspaceFiles(r.data);
|
||||
})
|
||||
.catch((err) => console.error('[ChatInput] listFiles failed:', err));
|
||||
}
|
||||
} else {
|
||||
setMentionOpen(false);
|
||||
setMentionQuery('');
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -560,6 +719,51 @@ export function ChatInput(): React.JSX.Element {
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* v0.8.0 P2-4: @ 文件提及联想弹层(MUI Paper+List,与斜杠菜单同形态) */}
|
||||
{mentionOpen && mentionCandidates.length > 0 && (
|
||||
<Paper
|
||||
elevation={8}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
mb: 1,
|
||||
maxHeight: 240,
|
||||
overflow: 'auto',
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.paper',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<List dense disablePadding>
|
||||
{mentionCandidates.map((path, idx) => (
|
||||
<ListItemButton
|
||||
key={path}
|
||||
selected={idx === mentionIndex}
|
||||
onMouseEnter={() => setMentionIndex(idx)}
|
||||
onClick={() => applyMention(path)}
|
||||
sx={{ gap: 1, py: 0.75 }}
|
||||
>
|
||||
<AtSign size={13} style={{ color: '#22d3ee', flexShrink: 0 }} />
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{path}
|
||||
</Typography>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
|
||||
@@ -20,11 +20,16 @@ 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';
|
||||
// v0.8.0 P2-2: 会话回放(TRACE JSONL 播放器)
|
||||
import { SessionReplayButton } from '@renderer/components/trace/SessionReplayPlayer';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
|
||||
export function DetailPanel(): React.JSX.Element {
|
||||
// v0.3.0: 从 ui-store 获取 tab 状态(支持斜杠命令切换)
|
||||
const tab = useUIStore((s) => s.detailTab);
|
||||
const setTab = useUIStore((s) => s.setDetailTab);
|
||||
// v0.8.0 P2-2: 当前会话 ID(回放按钮的会话标识)
|
||||
const currentSessionId = useAgentStore((s) => s.currentSessionId);
|
||||
|
||||
// v0.3.0 修复:验证 tab 值有效性,非预期值回退到 'trace'
|
||||
const validTabs: DetailTab[] = ['trace', 'memory', 'tasks', 'workspace'];
|
||||
@@ -97,6 +102,8 @@ export function DetailPanel(): React.JSX.Element {
|
||||
<TraceViewer />
|
||||
<TokenUsage />
|
||||
<AgentMonitor />
|
||||
{/* v0.8.0 P2-2: 会话回放(TRACE JSONL 播放器) */}
|
||||
<SessionReplayButton sessionId={currentSessionId} />
|
||||
</>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
|
||||
@@ -272,6 +272,8 @@ export function Sidebar(): React.JSX.Element {
|
||||
{/* F-2 修复: 归档会话视图 —— 此前归档入口激活后(会话右键菜单),
|
||||
归档会话从列表永久消失且无恢复路径(数据在 DB 但 UI 不可达) */}
|
||||
<ArchivedSessionsPanel />
|
||||
{/* v0.8.0 P2-1: 回收站 —— 软删除会话恢复/彻底删除 */}
|
||||
<TrashSessionsPanel />
|
||||
<ToolManagerPanel />
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -379,6 +381,173 @@ function ArchivedSessionsPanel(): React.JSX.Element | null {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.8.0 P2-1: 回收站折叠面板 —— 软删除会话的恢复 / 彻底删除入口。
|
||||
* 自主拉取 sessions:listTrash(回收站数据不在 session-store 中);
|
||||
* 展开时刷新,恢复/彻底删除后同步刷新。彻底删除需确认(级联清消息,不可逆)。
|
||||
*/
|
||||
function TrashSessionsPanel(): React.JSX.Element | null {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [trash, setTrash] = useState<
|
||||
Array<{ id: string; title: string; deletedAt?: number; updatedAt?: number }>
|
||||
>([]);
|
||||
const [purgeTarget, setPurgeTarget] = useState<{ id: string; title: string } | null>(null);
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
try {
|
||||
const rows = (await window.metona?.sessions?.listTrash()) ?? [];
|
||||
setTrash(rows);
|
||||
} catch (err) {
|
||||
console.error('[Sidebar] listTrash failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpand = (): void => {
|
||||
const next = !expanded;
|
||||
setExpanded(next);
|
||||
if (next) void refresh();
|
||||
};
|
||||
|
||||
const handleRestore = async (sessionId: string): Promise<void> => {
|
||||
try {
|
||||
const r = await window.metona?.sessions?.restore(sessionId);
|
||||
if (r?.success) {
|
||||
void refresh();
|
||||
} else {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r?.error ?? t('sidebar.toast.restoreFailed')))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Sidebar] Restore from trash failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePurge = async (): Promise<void> => {
|
||||
if (!purgeTarget) return;
|
||||
try {
|
||||
const r = await window.metona?.sessions?.purge(purgeTarget.id);
|
||||
if (r?.success) {
|
||||
void refresh();
|
||||
} else {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r?.error ?? t('sidebar.toast.deleteFailed')))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Sidebar] Purge failed:', err);
|
||||
} finally {
|
||||
setPurgeTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (trash.length === 0 && !expanded) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<ListItemButton onClick={handleExpand} dense sx={{ borderRadius: 1, px: 1.5, py: 0.75 }}>
|
||||
<ListItemIcon sx={{ minWidth: 24 }}>
|
||||
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
<Trash2 size={12} style={{ marginLeft: 4 }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="body2" sx={{ fontSize: 12 }}>
|
||||
{t('sidebar.trash')}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
{trash.length > 0 && (
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>
|
||||
{t('sidebar.count', { count: trash.length })}
|
||||
</Typography>
|
||||
)}
|
||||
</ListItemButton>
|
||||
<Collapse in={expanded}>
|
||||
<List
|
||||
dense
|
||||
disablePadding
|
||||
sx={{
|
||||
pl: 2,
|
||||
maxHeight: 180,
|
||||
overflowY: 'auto',
|
||||
pr: 0.5,
|
||||
'&::-webkit-scrollbar': { width: 6 },
|
||||
'&::-webkit-scrollbar-thumb': { bgcolor: 'divider', borderRadius: 3 },
|
||||
}}
|
||||
>
|
||||
{trash.length === 0 ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ px: 2, py: 1, display: 'block', color: 'text.disabled', fontSize: 11 }}
|
||||
>
|
||||
{t('sidebar.trash.empty')}
|
||||
</Typography>
|
||||
) : (
|
||||
trash.map((s) => (
|
||||
<ListItemButton key={s.id} dense sx={{ py: 0.25, px: 1, borderRadius: 1 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 11,
|
||||
color: 'text.disabled',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={s.title}
|
||||
>
|
||||
{s.title}
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
sx={{ fontSize: 10, minWidth: 40, flexShrink: 0 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleRestore(s.id);
|
||||
}}
|
||||
>
|
||||
{t('sidebar.restore')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
sx={{ fontSize: 10, minWidth: 40, flexShrink: 0 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPurgeTarget({ id: s.id, title: s.title });
|
||||
}}
|
||||
>
|
||||
{t('sidebar.trash.purge')}
|
||||
</Button>
|
||||
</ListItemButton>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
</Collapse>
|
||||
{/* 彻底删除确认(级联清消息,不可逆) */}
|
||||
<Dialog open={purgeTarget !== null} onClose={() => setPurgeTarget(null)}>
|
||||
<DialogTitle sx={{ fontSize: 14 }}>{t('sidebar.trash.purgeTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ fontSize: 12 }}>
|
||||
{t('sidebar.trash.purgeBody', { title: purgeTarget?.title ?? '' })}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button size="small" onClick={() => setPurgeTarget(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="small" color="error" onClick={() => void handlePurge()}>
|
||||
{t('sidebar.trash.purge')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionItem({
|
||||
session,
|
||||
isActive,
|
||||
@@ -624,6 +793,8 @@ function ToolManagerPanel() {
|
||||
low: 'LOW',
|
||||
medium: 'MEDIUM',
|
||||
high: 'HIGH',
|
||||
// v0.8.0 P1-3.8: 补 critical —— 旧映射缺失导致 CRITICAL 工具显示原始字符串
|
||||
critical: 'CRITICAL',
|
||||
};
|
||||
return (
|
||||
<Box>
|
||||
|
||||
@@ -374,7 +374,7 @@ export function MemoryViewer(): React.JSX.Element {
|
||||
variant="caption"
|
||||
sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}
|
||||
>
|
||||
暂无记忆数据
|
||||
{t('memory.empty')}
|
||||
</Typography>
|
||||
) : (
|
||||
MEMORY_TYPES.map((type) => {
|
||||
|
||||
@@ -32,6 +32,8 @@ import { PROVIDER_LABELS } from '@renderer/lib/constants';
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
import { PROVIDER_URLS } from './useConfig';
|
||||
// v0.8.0 FEAT-1: 输出上限配置可见性门控(Provider 支持矩阵)
|
||||
import { supportsOutputLimitConfig } from '@renderer/lib/model-capabilities';
|
||||
|
||||
/** v0.7.2 P3-9: 动态模型条目(渲染端子集,与 global.d.ts MetonaModelInfoLite 对齐) */
|
||||
interface ModelOption {
|
||||
@@ -39,6 +41,8 @@ interface ModelOption {
|
||||
name?: string;
|
||||
supportsToolCalling?: boolean;
|
||||
supportsThinking?: boolean;
|
||||
/** v0.8.0 FEAT-1: 模型输出上限(llm:listModels 元数据,用于钳制提示) */
|
||||
maxOutputTokens?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -52,6 +56,10 @@ export function LLMSettings() {
|
||||
const [apiKey, setApiKey] = useState<string>('');
|
||||
const [baseURL, setBaseURL] = useState<string>('');
|
||||
const [numCtx, setNumCtx] = useState<number | null>(null);
|
||||
// v0.8.0 FEAT-1: 最大输出上限(llm.maxTokens,按请求传给 API;null = 使用引擎默认)
|
||||
const [maxOutputTokens, setMaxOutputTokens] = useState<number | null>(null);
|
||||
// v0.8.0 P0-3: agent.enableThinking(模型不支持思考时的联动提示)
|
||||
const [agentThinkingEnabled, setAgentThinkingEnabled] = useState(true);
|
||||
// v0.3.1: DeepSeek/Agnes/MiMo contextWindow 可配置(不再写死)
|
||||
const [dsCtxWindow, setDsCtxWindow] = useState<number>(1000000);
|
||||
const [agnesCtxWindow, setAgnesCtxWindow] = useState<number>(1000000);
|
||||
@@ -235,14 +243,18 @@ export function LLMSettings() {
|
||||
window.metona.config.get('llm.fallbackBaseURL'),
|
||||
// v0.5.4: 多模态开关
|
||||
window.metona.config.get('llm.multimodalEnabled'),
|
||||
// v0.8.0 FEAT-1: 最大输出上限 + 思考开关(P0-3 模型能力联动提示)
|
||||
window.metona.config.get('llm.maxTokens'),
|
||||
window.metona.config.get('agent.enableThinking'),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
const [p, m, k, u, nc, ds, ag, mi, oa, an, fbp, fbm, fbk, fbu, mm] = results;
|
||||
const [p, m, k, u, nc, ds, ag, mi, oa, an, fbp, fbm, fbk, fbu, mm, mt, et] = results;
|
||||
setProvider((p as string) ?? '');
|
||||
setModel((m as string) ?? '');
|
||||
setApiKey((k as string) ?? '');
|
||||
setBaseURL((u as string) ?? '');
|
||||
setNumCtx((nc as number | null) ?? null);
|
||||
if (typeof mt === 'number' && mt > 0) setMaxOutputTokens(mt);
|
||||
if (typeof ds === 'number' && ds > 0) setDsCtxWindow(ds);
|
||||
if (typeof ag === 'number' && ag > 0) setAgnesCtxWindow(ag);
|
||||
if (typeof mi === 'number' && mi > 0) setMimoCtxWindow(mi);
|
||||
@@ -253,6 +265,7 @@ export function LLMSettings() {
|
||||
setFbApiKey((fbk as string) ?? '');
|
||||
setFbBaseURL((fbu as string) ?? '');
|
||||
setMultimodalEnabled(mm === true);
|
||||
setAgentThinkingEnabled(et !== false);
|
||||
} catch (err) {
|
||||
console.error('[LLMSettings]', err);
|
||||
} finally {
|
||||
@@ -313,12 +326,27 @@ export function LLMSettings() {
|
||||
const mimoCtxError = !Number.isFinite(mimoCtxWindow) || mimoCtxWindow < 4096;
|
||||
const oaCtxError = !Number.isFinite(oaCtxWindow) || oaCtxWindow < 4096;
|
||||
const anthropicCtxError = !Number.isFinite(anthropicCtxWindow) || anthropicCtxWindow < 4096;
|
||||
// v0.8.0 FEAT-1: 最大输出上限校验(≥256;上限钳制由 adapter 按模型元信息执行)
|
||||
const maxTokensError =
|
||||
maxOutputTokens != null && (!Number.isFinite(maxOutputTokens) || maxOutputTokens < 256);
|
||||
|
||||
// v0.8.0 FEAT-1: 当前模型的输出上限元信息(模型列表已加载时展示钳制提示)
|
||||
const modelMeta = model ? modelOptions.find((o) => o.id === model) : undefined;
|
||||
const modelMaxOutput = modelMeta?.maxOutputTokens;
|
||||
const exceedsModelCap =
|
||||
maxOutputTokens != null &&
|
||||
typeof modelMaxOutput === 'number' &&
|
||||
maxOutputTokens > modelMaxOutput;
|
||||
// v0.8.0 P0-3: 模型不支持思考 + 全局思考开关开启 → 联动提示
|
||||
const thinkingUnsupportedByModel =
|
||||
model != null && modelMeta?.supportsThinking === false && agentThinkingEnabled;
|
||||
|
||||
// 是否存在阻断保存的错误(API Key 为空只警告,不阻断 — 允许先填其他字段再回来填 key)
|
||||
const hasBlockingError =
|
||||
urlError ||
|
||||
modelHasSpace ||
|
||||
numCtxError ||
|
||||
maxTokensError ||
|
||||
(provider === 'deepseek' && dsCtxError) ||
|
||||
(provider === 'agnes' && agnesCtxError) ||
|
||||
(provider === 'mimo' && mimoCtxError) ||
|
||||
@@ -370,6 +398,11 @@ export function LLMSettings() {
|
||||
{ key: 'llm.baseURL', value: baseURL },
|
||||
// v0.5.4: 多模态总开关
|
||||
{ key: 'llm.multimodalEnabled', value: multimodalEnabled },
|
||||
// v0.8.0 FEAT-1: 最大输出上限(按请求传给 API;清空 = 引擎默认 63488)
|
||||
{
|
||||
key: 'llm.maxTokens',
|
||||
value: maxOutputTokens != null && maxOutputTokens >= 256 ? maxOutputTokens : 63488,
|
||||
},
|
||||
{ key: 'ollama.numCtx', value: numCtx },
|
||||
{ key: 'deepseek.contextWindow', value: dsCtxWindow },
|
||||
{ key: 'agnes.contextWindow', value: agnesCtxWindow },
|
||||
@@ -390,6 +423,16 @@ export function LLMSettings() {
|
||||
} else {
|
||||
// v0.5.4: 保存成功后同步多模态开关到 Agent Store(立即生效,控制上传入口)
|
||||
useAgentStore.getState().setMultimodalEnabled(multimodalEnabled);
|
||||
// v0.8.0 FEAT-1: 配置值超过模型上限 → 明示"将按模型上限生效"
|
||||
//(adapter 侧按 MODEL_INFO.maxOutputTokens 钳制,llm.maxTokens 经
|
||||
// applyEngineConfigKey 热更新引擎,无需重启)
|
||||
if (exceedsModelCap && typeof modelMaxOutput === 'number') {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) =>
|
||||
mod.default.warning(t('llm.maxTokens.exceedCap', { cap: modelMaxOutput })),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.success(t('llm.save.success')))
|
||||
.catch(() => {});
|
||||
@@ -530,6 +573,37 @@ export function LLMSettings() {
|
||||
{t('llm.models.hint')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{/* v0.8.0 P0-3: 模型不支持思考 × 全局思考开关 → 联动提示(adapter 侧已硬门控) */}
|
||||
{thinkingUnsupportedByModel && (
|
||||
<Typography variant="caption" sx={{ color: 'warning.main', fontSize: 11 }}>
|
||||
{t('llm.thinking.unsupported')}
|
||||
</Typography>
|
||||
)}
|
||||
{/* v0.8.0 FEAT-1: 最大输出上限 —— 仅对支持输出上限参数的 Provider 显示
|
||||
(PROVIDER_OUTPUT_LIMIT_SUPPORT 矩阵门控;经 llm.maxTokens 热生效) */}
|
||||
{supportsOutputLimitConfig(provider) && (
|
||||
<TextField
|
||||
size="small"
|
||||
label={t('llm.maxTokens.label')}
|
||||
type="number"
|
||||
value={maxOutputTokens ?? ''}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setMaxOutputTokens(v === '' ? null : Number(v));
|
||||
}}
|
||||
slotProps={{ htmlInput: { min: 256, step: 256 } }}
|
||||
error={maxTokensError}
|
||||
helperText={
|
||||
maxTokensError
|
||||
? t('llm.maxTokens.minError')
|
||||
: exceedsModelCap && typeof modelMaxOutput === 'number'
|
||||
? t('llm.maxTokens.exceedCap', { cap: modelMaxOutput })
|
||||
: typeof modelMaxOutput === 'number'
|
||||
? `${t('llm.maxTokens.helper')} · ${t('llm.maxTokens.capInfo', { cap: modelMaxOutput })}`
|
||||
: t('llm.maxTokens.helper')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */}
|
||||
<FormControlLabel
|
||||
control={
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Box,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
LinearProgress,
|
||||
} from '@mui/material';
|
||||
import { Folder, Copy, RefreshCw } from 'lucide-react';
|
||||
import { useConfig } from './useConfig';
|
||||
@@ -374,6 +375,9 @@ export function LogsSettings() {
|
||||
{/* v0.7.3 P3-3: JSONL 录制文件生命周期(统计 + 手动清理) */}
|
||||
<TraceFilesPanel />
|
||||
|
||||
{/* v0.8.0 P2-3: 检查更新 / 下载安装(electron-updater 状态实时显示) */}
|
||||
<UpdatePanel />
|
||||
|
||||
{/* v0.7.3 P3-4: SLO / 健康快照可视化(只读 + 手动刷新) */}
|
||||
<HealthSnapshotPanel />
|
||||
|
||||
@@ -495,6 +499,107 @@ function TraceFilesPanel(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
// ===== v0.8.0 P2-3: 检查更新 / 下载安装(electron-updater) =====
|
||||
|
||||
function UpdatePanel(): React.JSX.Element {
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.metona?.app?.onUpdateStatus) return;
|
||||
const off = window.metona.app.onUpdateStatus((event) => {
|
||||
if (event.status === 'downloading') {
|
||||
setProgress(event.percent ?? 0);
|
||||
} else if (event.status === 'downloaded') {
|
||||
setProgress(100);
|
||||
setResult(t('settings.logs.update.downloaded'));
|
||||
} else if (event.status === 'available') {
|
||||
setResult(t('settings.logs.update.available', { version: event.latestVersion ?? '' }));
|
||||
} else if (event.status === 'not-available') {
|
||||
setResult(t('settings.logs.update.upToDate', { version: event.latestVersion ?? '' }));
|
||||
} else if (event.status === 'error') {
|
||||
setResult(t('settings.logs.update.error', { message: event.message ?? '' }));
|
||||
}
|
||||
});
|
||||
return off;
|
||||
}, []);
|
||||
|
||||
const handleCheck = async (): Promise<void> => {
|
||||
setChecking(true);
|
||||
setResult(null);
|
||||
setProgress(null);
|
||||
try {
|
||||
const r = await window.metona?.app?.updateCheck();
|
||||
if (r?.status === 'available') {
|
||||
setResult(t('settings.logs.update.available', { version: r.latestVersion }));
|
||||
} else if (r?.status === 'up-to-date') {
|
||||
setResult(t('settings.logs.update.upToDate', { version: r.latestVersion }));
|
||||
} else if (r?.status === 'disabled') {
|
||||
setResult(t('settings.logs.update.disabled'));
|
||||
} else if (r?.status === 'error') {
|
||||
setResult(t('settings.logs.update.error', { message: r.message }));
|
||||
}
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstall = async (): Promise<void> => {
|
||||
setInstalling(true);
|
||||
try {
|
||||
const r = await window.metona?.app?.updateInstall();
|
||||
if (r && r.success === false) {
|
||||
setResult(t('settings.logs.update.error', { message: r.error ?? '' }));
|
||||
}
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||
{t('settings.logs.update.title')}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => void handleCheck()}
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? t('settings.logs.update.checking') : t('settings.logs.update.check')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => void handleInstall()}
|
||||
disabled={installing}
|
||||
>
|
||||
{installing
|
||||
? t('settings.logs.update.installing')
|
||||
: t('settings.logs.update.downloadInstall')}
|
||||
</Button>
|
||||
</Stack>
|
||||
{progress != null && (
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{ height: 6, borderRadius: 3 }}
|
||||
/>
|
||||
)}
|
||||
{result && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 11 }}>
|
||||
{result}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== v0.7.3 P3-4: SLO / 健康快照可视化 =====
|
||||
|
||||
function HealthSnapshotPanel(): React.JSX.Element {
|
||||
|
||||
@@ -164,6 +164,33 @@ export function MCPSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
// v0.8.0 P2-5: Resources / Prompts 发现视图(按 server 名懒加载展开)
|
||||
const [contentsFor, setContentsFor] = useState<string | null>(null);
|
||||
const [contents, setContents] = useState<{
|
||||
resources: Array<{ uri: string; name: string; description?: string; mimeType?: string }>;
|
||||
prompts: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
arguments?: Array<{ name: string; description?: string; required?: boolean }>;
|
||||
}>;
|
||||
} | null>(null);
|
||||
const handleToggleContents = async (name: string): Promise<void> => {
|
||||
if (contentsFor === name) {
|
||||
setContentsFor(null);
|
||||
setContents(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await window.metona?.mcp?.listServerContents?.(name);
|
||||
if (r?.success && r.data) {
|
||||
setContentsFor(name);
|
||||
setContents(r.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MCPSettings] listServerContents failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
@@ -175,80 +202,124 @@ export function MCPSettings() {
|
||||
</Typography>
|
||||
) : (
|
||||
servers.map((s) => (
|
||||
<Stack
|
||||
key={s.name}
|
||||
direction="row"
|
||||
sx={{
|
||||
py: 1,
|
||||
px: 1.5,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'secondary.main',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Box
|
||||
sx={{ width: 8, height: 8, borderRadius: '50', bgcolor: statusColors[s.status] }}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{s.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: statusColors[s.status] }}>
|
||||
{statusLabel(s)}
|
||||
</Typography>
|
||||
{s.toolCount > 0 && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('settings.mcp.toolCount', { count: s.toolCount })}
|
||||
<Box key={s.name}>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
py: 1,
|
||||
px: 1.5,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'secondary.main',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Box
|
||||
sx={{ width: 8, height: 8, borderRadius: '50', bgcolor: statusColors[s.status] }}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{s.name}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<Button
|
||||
size="small"
|
||||
sx={{ fontSize: 10, minWidth: 40 }}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const r = await window.metona?.mcp?.toggleServer(
|
||||
s.name,
|
||||
s.status !== 'connected',
|
||||
);
|
||||
if (r?.success) {
|
||||
loadServers();
|
||||
} else {
|
||||
<Typography variant="caption" sx={{ color: statusColors[s.status] }}>
|
||||
{statusLabel(s)}
|
||||
</Typography>
|
||||
{s.toolCount > 0 && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('settings.mcp.toolCount', { count: s.toolCount })}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<Button
|
||||
size="small"
|
||||
sx={{ fontSize: 10, minWidth: 40 }}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const r = await window.metona?.mcp?.toggleServer(
|
||||
s.name,
|
||||
s.status !== 'connected',
|
||||
);
|
||||
if (r?.success) {
|
||||
loadServers();
|
||||
} else {
|
||||
import('@metona-team/metona-toast')
|
||||
.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(r?.error ?? t('settings.mcp.toggleFailed')),
|
||||
mod.default.error(
|
||||
t('settings.mcp.toggleFailedMsg', { message: (err as Error).message }),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MCPSettings]', err);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) =>
|
||||
mod.default.error(
|
||||
t('settings.mcp.toggleFailedMsg', { message: (err as Error).message }),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{s.status === 'connected'
|
||||
? t('settings.mcp.disconnect')
|
||||
: t('settings.mcp.connect')}
|
||||
</Button>
|
||||
{/* L-11 修复: 点击移除打开 MUI Dialog 二次确认,而非原生 confirm() */}
|
||||
}}
|
||||
>
|
||||
{s.status === 'connected'
|
||||
? t('settings.mcp.disconnect')
|
||||
: t('settings.mcp.connect')}
|
||||
</Button>
|
||||
{/* L-11 修复: 点击移除打开 MUI Dialog 二次确认,而非原生 confirm() */}
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
sx={{ fontSize: 10, minWidth: 40 }}
|
||||
onClick={() => setConfirmRemove(s.name)}
|
||||
>
|
||||
{t('settings.mcp.remove')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{s.status === 'connected' && (
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
sx={{ fontSize: 10, minWidth: 40 }}
|
||||
onClick={() => setConfirmRemove(s.name)}
|
||||
sx={{ fontSize: 10, mt: 0.5, alignSelf: 'flex-start' }}
|
||||
onClick={() => void handleToggleContents(s.name)}
|
||||
>
|
||||
{t('settings.mcp.remove')}
|
||||
{contentsFor === s.name
|
||||
? t('settings.mcp.contents.hide')
|
||||
: t('settings.mcp.contents.show')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
{contentsFor === s.name && contents && (
|
||||
<Stack spacing={0.5} sx={{ mt: 0.5, pl: 1 }}>
|
||||
{contents.resources.length === 0 && contents.prompts.length === 0 && (
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 11 }}>
|
||||
{t('settings.mcp.contents.empty')}
|
||||
</Typography>
|
||||
)}
|
||||
{contents.resources.map((r) => (
|
||||
<Typography
|
||||
key={r.uri}
|
||||
variant="caption"
|
||||
noWrap
|
||||
sx={{ fontSize: 11, color: 'text.secondary' }}
|
||||
title={`${r.uri} — ${r.description ?? ''}`}
|
||||
>
|
||||
📄 {r.name} · {r.uri}
|
||||
</Typography>
|
||||
))}
|
||||
{contents.prompts.map((p) => (
|
||||
<Typography
|
||||
key={p.name}
|
||||
variant="caption"
|
||||
noWrap
|
||||
sx={{ fontSize: 11, color: 'text.secondary' }}
|
||||
title={p.description ?? ''}
|
||||
>
|
||||
💬 {p.name}
|
||||
{p.arguments?.length ? ` (${p.arguments.map((a) => a.name).join(', ')})` : ''}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* SessionReplayPlayer — 会话回放播放器(v0.8.0 P2-2)
|
||||
*
|
||||
* 基于 TRACE 层的 JSONL 录制文件(session_{id}_*.jsonl,9 种事件)做完整回放:
|
||||
* 文件选择 → 时间轴(iteration_start 定锚点)→ 播放/暂停/步进/速度控制 →
|
||||
* 事件流渲染(思考/正文/工具调用/结果)。
|
||||
*
|
||||
* 数据来源:sessions:listRecordings(列出该会话录制)+ sessions:readRecording
|
||||
* (严格文件名白名单 + 目录边界校验,主进程侧实施)。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
Slider,
|
||||
Stack,
|
||||
Typography,
|
||||
Chip,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
} from '@mui/material';
|
||||
import { Play, Pause, ChevronLeft, ChevronRight, X, Film } from 'lucide-react';
|
||||
import { formatDuration, formatFileSize } from '@renderer/lib/formatters';
|
||||
import { t } from '@renderer/lib/i18n';
|
||||
import '@renderer/lib/i18n-strings';
|
||||
|
||||
/** 录制事件(session-recorder TraceEvent 的宽松形态) */
|
||||
interface ReplayEvent {
|
||||
seq?: number;
|
||||
ts?: string;
|
||||
event?: string;
|
||||
sessionId?: string;
|
||||
iteration?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const SPEEDS = [0.5, 1, 2, 4];
|
||||
|
||||
export function SessionReplayPlayer({
|
||||
open,
|
||||
onClose,
|
||||
sessionId,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
sessionId: string | null;
|
||||
}): React.JSX.Element {
|
||||
const [files, setFiles] = useState<Array<{ name: string; size: number; mtime: number }>>([]);
|
||||
const [selected, setSelected] = useState<string>('');
|
||||
const [events, setEvents] = useState<ReplayEvent[]>([]);
|
||||
const [skipped, setSkipped] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [speed, setSpeed] = useState(1);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 打开时拉取录制文件列表
|
||||
useEffect(() => {
|
||||
if (!open || !sessionId) return;
|
||||
setSelected('');
|
||||
setEvents([]);
|
||||
setSkipped(0);
|
||||
setCursor(0);
|
||||
setPlaying(false);
|
||||
setError(null);
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await window.metona?.sessions?.listRecordings(sessionId);
|
||||
if (r?.success && r.data) setFiles(r.data);
|
||||
else setError(r?.error ?? t('replay.loadFailed'));
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
})();
|
||||
}, [open, sessionId]);
|
||||
|
||||
const loadFile = useCallback(async (name: string): Promise<void> => {
|
||||
setSelected(name);
|
||||
setEvents([]);
|
||||
setCursor(0);
|
||||
setPlaying(false);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await window.metona?.sessions?.readRecording(name);
|
||||
if (r?.success && r.data) {
|
||||
setEvents((r.data.events as ReplayEvent[]) ?? []);
|
||||
setSkipped(r.data.skipped ?? 0);
|
||||
} else {
|
||||
setError(r?.error ?? t('replay.loadFailed'));
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 播放循环:按 speed 定时推进游标,到尾部自动暂停
|
||||
useEffect(() => {
|
||||
if (!playing || events.length === 0) return;
|
||||
if (cursor >= events.length - 1) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
timerRef.current = setTimeout(() => {
|
||||
setCursor((c) => Math.min(c + 1, events.length - 1));
|
||||
}, 60 / speed);
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [playing, cursor, speed, events.length]);
|
||||
|
||||
/** 渲染单条事件为可读行 */
|
||||
const renderEvent = useMemo(() => {
|
||||
return (ev: ReplayEvent): React.JSX.Element => {
|
||||
const type = String(ev.event ?? '');
|
||||
let detail = '';
|
||||
switch (type) {
|
||||
case 'iteration_start':
|
||||
detail = t('replay.event.iterationStart', { iteration: ev.iteration ?? 0 });
|
||||
break;
|
||||
case 'iteration_end':
|
||||
detail = t('replay.event.iterationEnd', {
|
||||
iteration: ev.iteration ?? 0,
|
||||
duration: formatDuration(Number(ev.durationMs ?? 0)),
|
||||
});
|
||||
break;
|
||||
case 'llm_request':
|
||||
detail = `${ev.provider ?? ''}/${ev.model ?? ''}`;
|
||||
break;
|
||||
case 'llm_response':
|
||||
detail = String((ev.contentPreview as string) ?? '');
|
||||
break;
|
||||
case 'tool_call':
|
||||
detail = `${String(ev.tool ?? '')} ${JSON.stringify(ev.args ?? {})}`;
|
||||
break;
|
||||
case 'tool_result':
|
||||
detail = `${String(ev.tool ?? '')} ${ev.success ? '✓' : '✗'} ${formatDuration(
|
||||
Number(ev.durationMs ?? 0),
|
||||
)}${ev.error ? ` — ${String(ev.error).slice(0, 120)}` : ''}`;
|
||||
break;
|
||||
case 'session_end':
|
||||
detail = t('replay.event.sessionEnd', {
|
||||
reason: String(ev.terminationReason ?? ''),
|
||||
iterations: Number(ev.totalIterations ?? 0),
|
||||
});
|
||||
break;
|
||||
default:
|
||||
detail = JSON.stringify(ev).slice(0, 160);
|
||||
}
|
||||
const isTool = type === 'tool_call' || type === 'tool_result';
|
||||
const isThought = type === 'llm_response' || type === 'llm_request';
|
||||
return (
|
||||
<Box
|
||||
key={`${ev.seq ?? ''}_${String(ev.ts ?? '')}_${detail.slice(0, 12)}`}
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 0.75,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: type === 'iteration_start' ? 'secondary.main' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'flex-start' }}>
|
||||
<Chip
|
||||
label={type}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 18, fontSize: 9, flexShrink: 0 }}
|
||||
color={isTool ? 'warning' : isThought ? 'primary' : 'default'}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontFamily: 'monospace', fontSize: 11, wordBreak: 'break-all', flex: 1 }}
|
||||
>
|
||||
{detail || '—'}
|
||||
</Typography>
|
||||
{ev.ts && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: 'text.disabled', fontSize: 9, flexShrink: 0 }}
|
||||
>
|
||||
{String(ev.ts).slice(11, 19)}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1, fontSize: 14 }}>
|
||||
<Film size={16} /> {t('replay.title')}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onClose}
|
||||
sx={{ ml: 'auto' }}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={16} />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={1.5}>
|
||||
{/* 文件选择 */}
|
||||
<FormControl size="small" fullWidth disabled={files.length === 0}>
|
||||
<InputLabel>{t('replay.file')}</InputLabel>
|
||||
<Select
|
||||
value={selected}
|
||||
label={t('replay.file')}
|
||||
onChange={(e) => void loadFile(String(e.target.value))}
|
||||
>
|
||||
{files.map((f) => (
|
||||
<MenuItem key={f.name} value={f.name}>
|
||||
{`${f.name} · ${formatFileSize(f.size)}`}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{loading && <LinearProgress />}
|
||||
{error && (
|
||||
<Typography variant="caption" sx={{ color: 'error.main' }}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{events.length > 0 && (
|
||||
<>
|
||||
{/* 时间轴 + 控制 */}
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setCursor((c) => Math.max(0, c - 1))}
|
||||
disabled={cursor === 0}
|
||||
aria-label={t('replay.stepBack')}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setPlaying((p) => !p)}
|
||||
disabled={cursor >= events.length - 1}
|
||||
aria-label={playing ? t('replay.pause') : t('replay.play')}
|
||||
>
|
||||
{playing ? <Pause size={16} /> : <Play size={16} />}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setCursor((c) => Math.min(events.length - 1, c + 1))}
|
||||
disabled={cursor >= events.length - 1}
|
||||
aria-label={t('replay.stepForward')}
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</IconButton>
|
||||
<Slider
|
||||
size="small"
|
||||
min={0}
|
||||
max={Math.max(0, events.length - 1)}
|
||||
value={cursor}
|
||||
onChange={(_, v) => {
|
||||
setPlaying(false);
|
||||
setCursor(Number(v));
|
||||
}}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 72 }}>
|
||||
<Select
|
||||
value={speed}
|
||||
onChange={(e) => setSpeed(Number(e.target.value))}
|
||||
aria-label={t('replay.speed')}
|
||||
>
|
||||
{SPEEDS.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{s}×
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', flexShrink: 0 }}>
|
||||
{cursor + 1}/{events.length}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* 事件流(只渲染到当前游标,最近 200 条防 DOM 爆炸) */}
|
||||
<Box
|
||||
sx={{
|
||||
maxHeight: 420,
|
||||
overflowY: 'auto',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
'&::-webkit-scrollbar': { width: 6 },
|
||||
'&::-webkit-scrollbar-thumb': { bgcolor: 'divider', borderRadius: 3 },
|
||||
}}
|
||||
>
|
||||
{events.slice(Math.max(0, cursor - 199), cursor + 1).map(renderEvent)}
|
||||
</Box>
|
||||
{skipped > 0 && (
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
|
||||
{t('replay.skippedLines', { count: skipped })}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{files.length === 0 && !loading && !error && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('replay.noRecordings')}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Trace 面板的回放入口按钮 + 播放器挂载 */
|
||||
export function SessionReplayButton({
|
||||
sessionId,
|
||||
}: {
|
||||
sessionId: string | null;
|
||||
}): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<Film size={12} />}
|
||||
disabled={!sessionId}
|
||||
onClick={() => setOpen(true)}
|
||||
sx={{ fontSize: 11 }}
|
||||
>
|
||||
{t('replay.title')}
|
||||
</Button>
|
||||
<SessionReplayPlayer open={open} onClose={() => setOpen(false)} sessionId={sessionId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user