feat: v0.8.0 流语义补全 · 会话可靠 · 恢复力 — finish_reason 全链路贯通根治"思考中停止" · 2445 用例全量回归
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m16s
CI / 全量测试 (Electron ABI) (push) Failing after 6m4s
CI / 产物编译验证 (push) Successful in 11m1s

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:
2026-09-05 20:06:26 +08:00
parent 99d0c54129
commit 5b9d4d19b3
78 changed files with 5128 additions and 411 deletions
+208 -4
View File
@@ -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"
+7
View 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>
)}
+171
View File
@@ -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>
+1 -1
View File
@@ -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) => {
+75 -1
View File
@@ -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,按请求传给 APInull = 使用引擎默认)
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={
+105
View File
@@ -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 {
+135 -64
View File
@@ -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}_*.jsonl9 种事件)做完整回放:
* 文件选择 → 时间轴(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} />
</>
);
}