feat: v0.5.0 审计修复版 — 类型基线重建 + 会话隔离 + SubAgent 可观测性 + 三项功能补全
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m38s
CI / 产物编译验证 (push) Successful in 10m15s
CI / 全量测试 (Electron ABI) (push) Failing after 5m27s

P0 安全与工程基线(止血):
- .npmrc 移除硬编码 Gitea npm 凭据,改为 GITEA_NPM_AUTH 环境变量注入(已验证未设变量时 401)
- 修复 typecheck 空操作缺陷:solution-style 根 tsconfig 改为双工程真检查(node + web),
  pre-commit 与 CI 门禁恢复拦截能力
- 修复 4 处 v0.4.1 遗留类型错误:confirmation-hook.test 枚举名 FILE_SYSTEM→FILESYSTEM、
  agent.ts VALIDATION 事件 severity 类型谓词收窄、ContextMenu.tsx 导出 attachments 类型
- 补装 v0.4.1 声明但未安装的 node-html-parser 依赖

P1 逻辑缺陷修复(跨模块边界):
- ConfirmationHook 会话隔离:rememberedDecisions 与 pendingConfirmations 按 sessionId 隔离,
  abortSession 只清本会话 pending(修复 A 会话中断误杀 B 会话确认、拒绝记忆跨会话污染)
- SubAgent 可观测性:orchestrator 六个事件此前全项目零消费者,现接入
  ① subagent:event 生命周期广播(AgentMonitor 新增 SubAgent 状态区)
  ② SubEngine 流事件独立 TRACE 录制(sessionId=taskId 的 JSONL 文件)
- main.ts 启动链路异常兜底:初始化失败时记录日志 + 系统错误对话框 + 退出(原为白屏挂起)

P2 工程强化:
- CI:typecheck 双工程真检查;electron-test 从 experimental(continue-on-error)转正为阻塞门禁;
  GITEA_NPM_AUTH secret 注入说明
- 渲染 bundle 代码分割:单 2630KB chunk 拆为 main 557KB + vendor-react/mui/markdown/icons
  (业务代码变更不再使 vendor 缓存失效)
- database 建表 mcp_servers CHECK 直接含 streamable-http(新库不再依赖迁移 6 立即重建)

P3 功能补全:
- DeepSeek 余额显示:新增 llm:getBalance IPC + LLMSettings 余额卡片(复用适配器原死代码 getBalance)
- FTS5 会话内容搜索:messages_fts 虚表 + INSERT/UPDATE/DELETE 触发器实时同步 +
  存量库 rebuild 迁移 + sessions:searchContent IPC + Sidebar 搜索框标题∪内容联合搜索
  (短语转义防 FTS 运算符注入,按会话聚合展示 snippet)
- 审计日志导出:audit:export IPC(JSONL / CSV RFC 4180 转义)+ LogsSettings 导出按钮

文档一致性大扫除:
- README:工具数统一为 28(原 26/27/30 三口径)、handlers.ts→ipc/、录制事件名更正、
  删除虚构的审计导出/归档宣称与 Schema 虚构字段、MCP 三种传输、配置 key 更正、
  项目结构树对齐实际(settings 10 文件/lib 6 文件/react-virtuoso)、clone 地址改为 Gitea、
  新增 GITEA_NPM_AUTH 配置说明、测试数 207
- 架构/构建指南/UI UX/IR 标准 4 份 HTML 设计文档同步修正(工具数、表数 10、
  磁盘文件 2 个现状注记、ipc/*.ts 路径)
- eslint.config.js 与开发规范.md 注释对齐零容忍基线与 better-sqlite3 选型

测试: 199→207 用例(新增 ConfirmationHook 跨会话隔离 5 用例 + FTS5 搜索/审计导出 8 用例)
验证: lint 0 problems / typecheck 双工程 0 errors / test:electron 207 全过 / build 成功
This commit is contained in:
2026-08-21 21:07:01 +08:00
parent 49c9b25538
commit 7e8b4882a0
32 changed files with 3031 additions and 579 deletions
+7 -2
View File
@@ -42,6 +42,7 @@ import {
Divider,
} from '@mui/material';
import { ShieldAlert, ChevronDown, Timer, RefreshCw } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
interface ConfirmationRequest {
toolCallId: string;
@@ -87,9 +88,11 @@ export function ConfirmationDialog(): React.JSX.Element | null {
>([]);
// v0.4.1: 拉取被拒工具列表(弹框打开/刷新时同步)
// v0.5.0: 传当前会话 ID — 拒绝记忆按会话隔离,只展示本会话的记忆
const refreshRememberedDenials = useCallback(async () => {
try {
const result = await window.metona?.tool?.getRememberedDenials();
const sessionId = useAgentStore.getState().currentSessionId ?? undefined;
const result = await window.metona?.tool?.getRememberedDenials(sessionId);
setRememberedDenials(result?.success ? (result.data ?? []) : []);
} catch {
setRememberedDenials([]);
@@ -97,9 +100,11 @@ export function ConfirmationDialog(): React.JSX.Element | null {
}, []);
// v0.4.1: 重置某个工具的拒绝记忆(恢复询问)
// v0.5.0: 传当前会话 ID,只重置本会话的记忆
const handleResetDenial = useCallback(async (toolName: string) => {
try {
await window.metona?.tool?.resetRememberedDenial(toolName);
const sessionId = useAgentStore.getState().currentSessionId ?? undefined;
await window.metona?.tool?.resetRememberedDenial(toolName, sessionId);
setRememberedDenials((prev) => prev.filter((d) => d.toolName !== toolName));
} catch {
// 重置失败保持现状,TTL 到期后仍会自动恢复
+374 -147
View File
@@ -7,11 +7,31 @@
*/
import {
Menu, MenuItem, ListItemIcon, ListItemText,
Dialog, DialogTitle, DialogContent, DialogActions,
Button, TextField, Typography,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Typography,
} from '@mui/material';
import { Copy, Quote, Edit, Trash2, RotateCcw, Eye, Code, ExternalLink, Pin, Archive, FileDown } from 'lucide-react';
import {
Copy,
Quote,
Edit,
Trash2,
RotateCcw,
Eye,
Code,
ExternalLink,
Pin,
Archive,
FileDown,
} from 'lucide-react';
import { create } from 'zustand';
import { useAgentStore } from '@renderer/stores/agent-store';
import { useSessionStore } from '@renderer/stores/session-store';
@@ -35,13 +55,33 @@ interface ContextMenuProps {
export function ContextMenu({ x, y, onClose, items }: ContextMenuProps): React.JSX.Element {
return (
<Menu open onClose={onClose} anchorReference="anchorPosition" anchorPosition={{ top: y, left: x }} slotProps={{ paper: { sx: { minWidth: 160 } } }}>
<Menu
open
onClose={onClose}
anchorReference="anchorPosition"
anchorPosition={{ top: y, left: x }}
slotProps={{ paper: { sx: { minWidth: 160 } } }}
>
{items.map((item) => {
const Icon = item.icon;
return (
<MenuItem key={item.id} onClick={() => { if (!item.disabled) { item.action(); onClose(); } }} disabled={item.disabled} dense>
<ListItemIcon sx={{ minWidth: 28 }}><Icon size={12} /></ListItemIcon>
<ListItemText slotProps={{ primary: { sx: { fontSize: 12 } } }}>{item.label}</ListItemText>
<MenuItem
key={item.id}
onClick={() => {
if (!item.disabled) {
item.action();
onClose();
}
}}
disabled={item.disabled}
dense
>
<ListItemIcon sx={{ minWidth: 28 }}>
<Icon size={12} />
</ListItemIcon>
<ListItemText slotProps={{ primary: { sx: { fontSize: 12 } } }}>
{item.label}
</ListItemText>
</MenuItem>
);
})}
@@ -85,12 +125,25 @@ const useDialogStore = create<DialogState>((set, get) => ({
promptLabel: '',
promptValue: '',
promptResolve: null,
confirm: (message, title = '确认') => new Promise<boolean>((resolve) => {
set({ confirmOpen: true, confirmTitle: title, confirmMessage: message, confirmResolve: resolve });
}),
prompt: (label, defaultValue = '', title = '输入') => new Promise<string | null>((resolve) => {
set({ promptOpen: true, promptTitle: title, promptLabel: label, promptValue: defaultValue, promptResolve: resolve });
}),
confirm: (message, title = '确认') =>
new Promise<boolean>((resolve) => {
set({
confirmOpen: true,
confirmTitle: title,
confirmMessage: message,
confirmResolve: resolve,
});
}),
prompt: (label, defaultValue = '', title = '输入') =>
new Promise<string | null>((resolve) => {
set({
promptOpen: true,
promptTitle: title,
promptLabel: label,
promptValue: defaultValue,
promptResolve: resolve,
});
}),
setPromptValue: (v) => set({ promptValue: v }),
closeConfirm: (ok) => {
const { confirmResolve } = get();
@@ -100,7 +153,13 @@ const useDialogStore = create<DialogState>((set, get) => ({
closePrompt: (value) => {
const { promptResolve } = get();
promptResolve?.(value);
set({ promptOpen: false, promptResolve: null, promptTitle: '', promptLabel: '', promptValue: '' });
set({
promptOpen: false,
promptResolve: null,
promptTitle: '',
promptLabel: '',
promptValue: '',
});
},
}));
@@ -130,8 +189,18 @@ export function ContextMenuDialogHost(): React.JSX.Element {
<Typography variant="body2">{confirmMessage}</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => closeConfirm(false)} color="inherit" size="small"></Button>
<Button onClick={() => closeConfirm(true)} variant="contained" color="error" size="small" autoFocus></Button>
<Button onClick={() => closeConfirm(false)} color="inherit" size="small">
</Button>
<Button
onClick={() => closeConfirm(true)}
variant="contained"
color="error"
size="small"
autoFocus
>
</Button>
</DialogActions>
</Dialog>
<Dialog open={promptOpen} onClose={() => closePrompt(null)} maxWidth="xs" fullWidth>
@@ -144,12 +213,18 @@ export function ContextMenuDialogHost(): React.JSX.Element {
autoFocus
fullWidth
size="small"
onKeyDown={(e) => { if (e.key === 'Enter') closePrompt(promptValue); }}
onKeyDown={(e) => {
if (e.key === 'Enter') closePrompt(promptValue);
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => closePrompt(null)} color="inherit" size="small"></Button>
<Button onClick={() => closePrompt(promptValue)} variant="contained" size="small"></Button>
<Button onClick={() => closePrompt(null)} color="inherit" size="small">
</Button>
<Button onClick={() => closePrompt(promptValue)} variant="contained" size="small">
</Button>
</DialogActions>
</Dialog>
</>
@@ -165,7 +240,9 @@ export function ContextMenuDialogHost(): React.JSX.Element {
function copyWithToast(text: string): void {
navigator.clipboard.writeText(text).catch((err) => {
console.error('[Clipboard]', err);
import('@metona-team/metona-toast').then((mod) => mod.default.error('复制失败')).catch(() => {});
import('@metona-team/metona-toast')
.then((mod) => mod.default.error('复制失败'))
.catch(() => {});
});
}
@@ -179,10 +256,23 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
const content = d.content ?? '';
const items: ContextMenuItem[] = [
{ id: 'copy', icon: Copy, label: '复制', action: () => copyWithToast(content) },
{ id: 'quote', icon: Quote, label: '引用回复', 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(); }
}},
{
id: 'quote',
icon: Quote,
label: '引用回复',
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();
}
},
},
];
// P2-11: assistant 消息支持重新生成(删除最后一条用户消息后的回复并重发)
if (d.role === 'assistant') {
@@ -190,7 +280,9 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
id: 'regenerate',
icon: RotateCcw,
label: '重新生成',
action: () => { void useAgentStore.getState().regenerate(); },
action: () => {
void useAgentStore.getState().regenerate();
},
});
}
return items;
@@ -199,13 +291,42 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
case 'tool-call': {
const tc = data as { args?: Record<string, unknown>; result?: unknown } | undefined;
return [
{ id: 'view-params', icon: Eye, label: '查看参数', action: () => { if (tc?.args) copyWithToast(JSON.stringify(tc.args, null, 2)); }},
{ id: 'view-result', icon: Eye, label: '查看完整结果', action: () => { if (tc?.result) copyWithToast(JSON.stringify(tc.result, null, 2)); }},
{ id: 'copy-result', icon: Copy, label: '复制结果', action: () => { if (tc?.result) copyWithToast(typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result)); }},
{ id: 're-execute', icon: RotateCcw, label: '重新执行', action: () => {
const m = [...useAgentStore.getState().messages].reverse().find((m) => m.role === 'user');
if (m) useAgentStore.getState().sendMessage(m.content);
}},
{
id: 'view-params',
icon: Eye,
label: '查看参数',
action: () => {
if (tc?.args) copyWithToast(JSON.stringify(tc.args, null, 2));
},
},
{
id: 'view-result',
icon: Eye,
label: '查看完整结果',
action: () => {
if (tc?.result) copyWithToast(JSON.stringify(tc.result, null, 2));
},
},
{
id: 'copy-result',
icon: Copy,
label: '复制结果',
action: () => {
if (tc?.result)
copyWithToast(typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result));
},
},
{
id: 're-execute',
icon: RotateCcw,
label: '重新执行',
action: () => {
const m = [...useAgentStore.getState().messages]
.reverse()
.find((m) => m.role === 'user');
if (m) useAgentStore.getState().sendMessage(m.content);
},
},
];
}
@@ -217,135 +338,241 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
import('@metona-team/metona-toast').then((mod) => mod.default.error(msg)).catch(() => {});
};
return [
{ id: 'rename', icon: Edit, label: '重命名', action: async () => {
if (!sid) return;
// 用 MUI Dialog 替代原生 prompt()Electron 下不可靠,且与 MUI 风格不一致)
const currentTitle = useSessionStore.getState().sessions.find((x) => x.id === sid)?.title ?? '';
const t = await useDialogStore.getState().prompt('新会话名称', currentTitle, '重命名会话');
if (!t?.trim()) return;
try {
const r = await window.metona?.sessions?.rename(sid, t.trim());
if (r?.success) {
useSessionStore.getState().updateSession(sid, { title: t.trim() });
} else {
showError(r?.error ?? '重命名失败');
}
} catch (err) {
console.error('[ContextMenu]', err);
showError('重命名失败');
}
}},
{ id: 'pin', icon: Pin, label: '置顶', action: async () => {
if (!sid) return;
const s = useSessionStore.getState().sessions.find((x) => x.id === sid);
if (!s) return;
const newPinned = !s.pinned;
try {
const r = await window.metona?.sessions?.pin(sid, newPinned);
if (r?.success) {
useSessionStore.getState().pinSession(sid, newPinned);
} else {
showError(r?.error ?? '置顶失败');
}
} catch (err) {
console.error('[ContextMenu]', err);
showError('置顶失败');
}
}},
// 修复: archive 之前完全未调用 IPC,UI 改了但 DB 没改,重启后状态丢失
{ id: 'archive', icon: Archive, label: '归档', action: async () => {
if (!sid) return;
const s = useSessionStore.getState().sessions.find((x) => x.id === sid);
const newArchived = s ? !s.archived : true;
try {
const r = await window.metona?.sessions?.archive(sid, newArchived);
if (r?.success) {
useSessionStore.getState().archiveSession(sid, newArchived);
} else {
showError(r?.error ?? '归档失败');
}
} catch (err) {
console.error('[ContextMenu]', err);
showError('归档失败');
}
}},
{ id: 'export', icon: FileDown, label: '导出 JSON', action: () => {
if (sid) window.metona?.sessions.getMessages(sid).then((msgs) => {
const b = new Blob([JSON.stringify(msgs, null, 2)], { type: 'application/json' });
const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `session-${sid}.json`; a.click();
}).catch((err) => {
console.error('[ContextMenu]', err);
showError('导出失败');
});
}},
// P2-11: 导出 Markdown(人类可读格式)
{ id: 'export-md', icon: FileDown, label: '导出 Markdown', action: async () => {
if (!sid) return;
try {
const msgs = await window.metona?.sessions.getMessages(sid);
const { buildSessionMarkdown, downloadMarkdown } = await import('@renderer/lib/export-markdown');
const title = useSessionStore.getState().sessions.find((x) => x.id === sid)?.title ?? '会话导出';
const md = buildSessionMarkdown(title, msgs as Array<{ id: string; role: string; content: string | null; toolCalls?: Array<{ name: string }>; attachments?: Array<{ name: string }>; timestamp: number }>);
downloadMarkdown(`session-${sid}.md`, md);
} catch (err) {
console.error('[ContextMenu]', err);
showError('导出失败');
}
}},
{ id: 'delete', icon: Trash2, label: '删除', action: async () => {
if (!sid) return;
// 用 MUI Dialog 替代原生 confirm()Electron 下不可靠)
// 与 Sidebar.tsx 的 showDeleteDialog 行为一致
const ok = await useDialogStore.getState().confirm('确定删除此会话?此操作不可撤销。', '删除会话');
if (!ok) return;
try {
const r = await window.metona?.sessions?.delete(sid);
if (r?.success) {
useSessionStore.getState().removeSession(sid);
// 修复: 删除当前会话时同步清空 agent-store,否则 ChatPanel 和 DetailPanel 仍显示已删除会话的内容
// 与 Sidebar.tsx 的 confirmDelete 行为一致
if (useAgentStore.getState().currentSessionId === sid) {
useAgentStore.setState({
currentSessionId: null,
messages: [],
traceSteps: [],
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 },
currentRunId: null,
currentIteration: 0,
});
{
id: 'rename',
icon: Edit,
label: '重命名',
action: async () => {
if (!sid) return;
// 用 MUI Dialog 替代原生 prompt()Electron 下不可靠,且与 MUI 风格不一致)
const currentTitle =
useSessionStore.getState().sessions.find((x) => x.id === sid)?.title ?? '';
const t = await useDialogStore
.getState()
.prompt('新会话名称', currentTitle, '重命名会话');
if (!t?.trim()) return;
try {
const r = await window.metona?.sessions?.rename(sid, t.trim());
if (r?.success) {
useSessionStore.getState().updateSession(sid, { title: t.trim() });
} else {
showError(r?.error ?? '重命名失败');
}
} else {
showError(r?.error ?? '删除失败');
} catch (err) {
console.error('[ContextMenu]', err);
showError('重命名失败');
}
} catch (err) {
console.error('[ContextMenu]', err);
showError('删除失败');
}
}},
},
},
{
id: 'pin',
icon: Pin,
label: '置顶',
action: async () => {
if (!sid) return;
const s = useSessionStore.getState().sessions.find((x) => x.id === sid);
if (!s) return;
const newPinned = !s.pinned;
try {
const r = await window.metona?.sessions?.pin(sid, newPinned);
if (r?.success) {
useSessionStore.getState().pinSession(sid, newPinned);
} else {
showError(r?.error ?? '置顶失败');
}
} catch (err) {
console.error('[ContextMenu]', err);
showError('置顶失败');
}
},
},
// 修复: archive 之前完全未调用 IPC,UI 改了但 DB 没改,重启后状态丢失
{
id: 'archive',
icon: Archive,
label: '归档',
action: async () => {
if (!sid) return;
const s = useSessionStore.getState().sessions.find((x) => x.id === sid);
const newArchived = s ? !s.archived : true;
try {
const r = await window.metona?.sessions?.archive(sid, newArchived);
if (r?.success) {
useSessionStore.getState().archiveSession(sid, newArchived);
} else {
showError(r?.error ?? '归档失败');
}
} catch (err) {
console.error('[ContextMenu]', err);
showError('归档失败');
}
},
},
{
id: 'export',
icon: FileDown,
label: '导出 JSON',
action: () => {
if (sid)
window.metona?.sessions
.getMessages(sid)
.then((msgs) => {
const b = new Blob([JSON.stringify(msgs, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = `session-${sid}.json`;
a.click();
})
.catch((err) => {
console.error('[ContextMenu]', err);
showError('导出失败');
});
},
},
// P2-11: 导出 Markdown(人类可读格式)
{
id: 'export-md',
icon: FileDown,
label: '导出 Markdown',
action: async () => {
if (!sid) return;
try {
const msgs = await window.metona?.sessions.getMessages(sid);
const { buildSessionMarkdown, downloadMarkdown } =
await import('@renderer/lib/export-markdown');
const title =
useSessionStore.getState().sessions.find((x) => x.id === sid)?.title ?? '会话导出';
const md = buildSessionMarkdown(
title,
msgs as Array<{
id: string;
role: string;
content: string | null;
toolCalls?: Array<{ name: string }>;
attachments?: Array<{ name: string; type: string }>;
timestamp: number;
}>,
);
downloadMarkdown(`session-${sid}.md`, md);
} catch (err) {
console.error('[ContextMenu]', err);
showError('导出失败');
}
},
},
{
id: 'delete',
icon: Trash2,
label: '删除',
action: async () => {
if (!sid) return;
// 用 MUI Dialog 替代原生 confirm()Electron 下不可靠)
// 与 Sidebar.tsx 的 showDeleteDialog 行为一致
const ok = await useDialogStore
.getState()
.confirm('确定删除此会话?此操作不可撤销。', '删除会话');
if (!ok) return;
try {
const r = await window.metona?.sessions?.delete(sid);
if (r?.success) {
useSessionStore.getState().removeSession(sid);
// 修复: 删除当前会话时同步清空 agent-store,否则 ChatPanel 和 DetailPanel 仍显示已删除会话的内容
// 与 Sidebar.tsx 的 confirmDelete 行为一致
if (useAgentStore.getState().currentSessionId === sid) {
useAgentStore.setState({
currentSessionId: null,
messages: [],
traceSteps: [],
tokenUsage: {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
lastInputTokens: 0,
lastCompressedSaved: 0,
},
currentRunId: null,
currentIteration: 0,
});
}
} else {
showError(r?.error ?? '删除失败');
}
} catch (err) {
console.error('[ContextMenu]', err);
showError('删除失败');
}
},
},
];
}
case 'code-block': {
const code = (data as { code?: string })?.code;
return [
{ id: 'copy-code', icon: Copy, label: '复制代码', action: () => { if (code) copyWithToast(code); }},
{ id: 'open-editor', icon: Code, label: '在编辑器中打开', action: () => { if (code) window.open(URL.createObjectURL(new Blob([code], { type: 'text/plain' })), '_blank'); }},
{
id: 'copy-code',
icon: Copy,
label: '复制代码',
action: () => {
if (code) copyWithToast(code);
},
},
{
id: 'open-editor',
icon: Code,
label: '在编辑器中打开',
action: () => {
if (code)
window.open(URL.createObjectURL(new Blob([code], { type: 'text/plain' })), '_blank');
},
},
];
}
case 'trace-step': {
const step = data as { thought?: string; toolCalls?: Array<{ name: string; args: Record<string, unknown> }> } | undefined;
const step = data as
| { thought?: string; toolCalls?: Array<{ name: string; args: Record<string, unknown> }> }
| undefined;
return [
{ id: 'copy-thought', icon: Copy, label: '复制 Thought', action: () => { if (step?.thought) copyWithToast(step.thought); }},
{ id: 'copy-params', icon: Copy, label: '复制工具参数', action: () => {
if (step?.toolCalls) copyWithToast(step.toolCalls.map((tc) => `${tc.name}: ${JSON.stringify(tc.args, null, 2)}`).join('\n'));
}},
{ id: 'export', icon: ExternalLink, label: '导出步骤详情', action: () => {
if (step) { const b = new Blob([JSON.stringify(step, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `trace-step-${Date.now()}.json`; a.click(); }
}},
{
id: 'copy-thought',
icon: Copy,
label: '复制 Thought',
action: () => {
if (step?.thought) copyWithToast(step.thought);
},
},
{
id: 'copy-params',
icon: Copy,
label: '复制工具参数',
action: () => {
if (step?.toolCalls)
copyWithToast(
step.toolCalls
.map((tc) => `${tc.name}: ${JSON.stringify(tc.args, null, 2)}`)
.join('\n'),
);
},
},
{
id: 'export',
icon: ExternalLink,
label: '导出步骤详情',
action: () => {
if (step) {
const b = new Blob([JSON.stringify(step, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = `trace-step-${Date.now()}.json`;
a.click();
}
},
},
];
}
default: return [];
default:
return [];
}
}
+274 -22
View File
@@ -4,14 +4,48 @@
* 完全使用 MUI 组件,Table 布局标签-值对。
*/
import { Box, Typography, Stack, Table, TableBody, TableRow, TableCell } from '@mui/material';
import { Activity, Cpu } from 'lucide-react';
import { useEffect, useMemo } from 'react';
import { Box, Typography, Stack, Table, TableBody, TableRow, TableCell, Chip } from '@mui/material';
import { Activity, Cpu, Bot } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useAgentStore, type AgentStatus } from '@renderer/stores/agent-store';
import { AGENT_STATUS_COLORS, AGENT_STATUS_LABELS, PROVIDER_LABELS } from '@renderer/lib/constants';
import { formatDuration } from '@renderer/lib/formatters';
const STATUS_ICONS: Record<AgentStatus, typeof Activity> = { idle: Activity, thinking: Cpu, executing: Cpu, error: Activity };
const STATUS_ICONS: Record<AgentStatus, typeof Activity> = {
idle: Activity,
thinking: Cpu,
executing: Cpu,
error: Activity,
};
/** v0.5.0: SubAgent 状态区条目 */
interface SubAgentItem {
taskId: string;
description: string;
status: 'delegated' | 'running' | 'completed' | 'error';
depth: number;
durationMs?: number;
iterations?: number;
error?: string;
updatedAt: number;
}
/** SubAgent 状态展示上限(防长会话无限累积) */
const MAX_SUBAGENT_ITEMS = 20;
const SUB_STATUS_COLORS: Record<SubAgentItem['status'], string> = {
delegated: '#fbbf24',
running: '#a855f7',
completed: '#34d399',
error: '#f87171',
};
const SUB_STATUS_LABELS: Record<SubAgentItem['status'], string> = {
delegated: '已委派',
running: '运行中',
completed: '已完成',
error: '失败',
};
export function AgentMonitor(): React.JSX.Element {
const agentStatus = useAgentStore((s) => s.agentStatus);
@@ -21,20 +55,57 @@ export function AgentMonitor(): React.JSX.Element {
const maxIterations = useAgentStore((s) => s.maxIterations);
const traceSteps = useAgentStore((s) => s.traceSteps);
const setMaxIterations = useAgentStore((s) => s.setMaxIterations);
const sessionId = useAgentStore((s) => s.currentSessionId);
// v0.5.0: SubAgent 状态(监听主进程生命周期事件,按父会话过滤)
const [subAgents, setSubAgents] = useState<SubAgentItem[]>([]);
useEffect(() => {
if (!window.metona?.agent?.onSubAgentEvent) return;
const unsubscribe = window.metona.agent.onSubAgentEvent((e) => {
// 只展示当前会话派生的 SubAgent
if (!sessionId || e.parentSessionId !== sessionId) return;
setSubAgents((prev) => {
const next = prev.filter((item) => item.taskId !== e.taskId);
next.unshift({
taskId: e.taskId,
description: e.description,
status: e.status,
depth: e.depth,
durationMs: e.durationMs,
iterations: e.iterations,
error: e.error,
updatedAt: Date.now(),
});
return next.slice(0, MAX_SUBAGENT_ITEMS);
});
});
return unsubscribe;
}, [sessionId]);
// 切换会话时清空(新会话的 SubAgent 从零开始)
useEffect(() => {
setSubAgents([]);
}, [sessionId]);
useEffect(() => {
// M-11/M-28 修复: 添加 cancelled 标志 + 错误日志记录
let cancelled = false;
if (window.metona?.config?.get) {
window.metona.config.get('agent.maxIterations').then((v) => {
if (cancelled) return;
if (typeof v === 'number' && v > 0) setMaxIterations(v);
}).catch((err) => {
// M-11 修复: 记录错误而非静默吞掉,便于诊断配置加载异常
console.error('[AgentMonitor] Failed to load maxIterations:', err);
});
window.metona.config
.get('agent.maxIterations')
.then((v) => {
if (cancelled) return;
if (typeof v === 'number' && v > 0) setMaxIterations(v);
})
.catch((err) => {
// M-11 修复: 记录错误而非静默吞掉,便于诊断配置加载异常
console.error('[AgentMonitor] Failed to load maxIterations:', err);
});
}
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [setMaxIterations]);
// L-14 修复: 使用 useMemo 缓存 totalDuration,避免每次渲染都遍历 traceSteps 数组
@@ -51,24 +122,56 @@ export function AgentMonitor(): React.JSX.Element {
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider', flexShrink: 0 }}>
<Stack direction="row" spacing={1} sx={{ mb: 1.5, alignItems: 'center' }}>
<Cpu size={14} style={{ color: '#818cf8' }} />
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
<Typography
variant="caption"
sx={{
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: 1,
color: 'text.secondary',
}}
>
Agent
</Typography>
</Stack>
{/* 状态指示 */}
<Box sx={{ px: 1.5, py: 1, borderRadius: 1.5, mb: 1.5, bgcolor: 'background.default', border: '1px solid', borderColor: 'divider' }}>
<Box
sx={{
px: 1.5,
py: 1,
borderRadius: 1.5,
mb: 1.5,
bgcolor: 'background.default',
border: '1px solid',
borderColor: 'divider',
}}
>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<StatusIcon
size={14}
style={{
color: statusColor,
animation: (agentStatus === 'thinking' || agentStatus === 'executing') ? 'pulse 2s infinite' : 'none',
animation:
agentStatus === 'thinking' || agentStatus === 'executing'
? 'pulse 2s infinite'
: 'none',
}}
/>
<Typography variant="caption" sx={{ fontWeight: 500, color: statusColor }}>{statusLabel}</Typography>
<Typography variant="caption" sx={{ fontWeight: 500, color: statusColor }}>
{statusLabel}
</Typography>
{(agentStatus === 'thinking' || agentStatus === 'executing') && (
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: statusColor, animation: 'pulse 1.5s infinite', ml: 'auto' }} />
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: statusColor,
animation: 'pulse 1.5s infinite',
ml: 'auto',
}}
/>
)}
</Stack>
</Box>
@@ -77,33 +180,182 @@ export function AgentMonitor(): React.JSX.Element {
<Table size="small" sx={{ '& .MuiTableCell-root': { border: 0, py: 0.5, px: 0.5 } }}>
<TableBody>
<TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11, width: '40%' }}>Provider</TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: 11, color: 'text.primary', textAlign: 'right', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 0 }}>
<TableCell sx={{ color: 'text.secondary', fontSize: 11, width: '40%' }}>
Provider
</TableCell>
<TableCell
sx={{
fontFamily: 'monospace',
fontWeight: 600,
fontSize: 11,
color: 'text.primary',
textAlign: 'right',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 0,
}}
>
{PROVIDER_LABELS[provider] ?? provider}
</TableCell>
</TableRow>
<TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}></TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: 11, color: 'text.primary', textAlign: 'right', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 0 }}>
<TableCell
sx={{
fontFamily: 'monospace',
fontWeight: 600,
fontSize: 11,
color: 'text.primary',
textAlign: 'right',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 0,
}}
>
{model}
</TableCell>
</TableRow>
<TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}></TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: 11, color: 'text.secondary', textAlign: 'right' }}>
<TableCell
sx={{
fontFamily: 'monospace',
fontWeight: 600,
fontSize: 11,
color: 'text.secondary',
textAlign: 'right',
}}
>
{currentIteration} / {maxIterations}
</TableCell>
</TableRow>
{totalDuration > 0 && (
<TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}></TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: 11, color: 'text.secondary', textAlign: 'right' }}>
<TableCell
sx={{
fontFamily: 'monospace',
fontWeight: 600,
fontSize: 11,
color: 'text.secondary',
textAlign: 'right',
}}
>
{formatDuration(totalDuration)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{/* v0.5.0: SubAgent 状态区 — delegate_task 委派的子任务生命周期 */}
{subAgents.length > 0 && (
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider', flexShrink: 0 }}>
<Stack direction="row" spacing={1} sx={{ mb: 1.5, alignItems: 'center' }}>
<Bot size={14} style={{ color: '#a855f7' }} />
<Typography
variant="caption"
sx={{
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: 1,
color: 'text.secondary',
}}
>
SubAgent
</Typography>
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
{subAgents.length}
</Typography>
</Stack>
<Stack spacing={0.5} sx={{ maxHeight: 220, overflowY: 'auto' }}>
{subAgents.map((sub) => (
<Box
key={sub.taskId}
sx={{
px: 1,
py: 0.75,
borderRadius: 1,
bgcolor: 'background.default',
border: '1px solid',
borderColor: 'divider',
borderLeft: `2px solid ${SUB_STATUS_COLORS[sub.status]}`,
}}
>
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center' }}>
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
flexShrink: 0,
bgcolor: SUB_STATUS_COLORS[sub.status],
animation: sub.status === 'running' ? 'pulse 1.5s infinite' : 'none',
}}
/>
<Typography
variant="caption"
sx={{
flex: 1,
minWidth: 0,
fontSize: 11,
color: 'text.primary',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={sub.description}
>
{sub.description || sub.taskId}
</Typography>
{sub.depth > 1 && (
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.disabled' }}>
L{sub.depth}
</Typography>
)}
<Chip
label={SUB_STATUS_LABELS[sub.status]}
size="small"
sx={{
height: 16,
fontSize: 9,
flexShrink: 0,
bgcolor: SUB_STATUS_COLORS[sub.status] + '22',
color: SUB_STATUS_COLORS[sub.status],
'& .MuiChip-label': { px: 0.5 },
}}
/>
</Stack>
{(sub.durationMs != null || sub.iterations != null) && (
<Typography
variant="caption"
sx={{ display: 'block', fontSize: 9, color: 'text.disabled', mt: 0.25 }}
>
{sub.durationMs != null ? formatDuration(sub.durationMs) : ''}
{sub.iterations != null ? ` · ${sub.iterations}` : ''}
</Typography>
)}
{sub.error && (
<Typography
variant="caption"
sx={{
display: 'block',
fontSize: 9,
color: 'error.main',
mt: 0.25,
wordBreak: 'break-word',
}}
>
{sub.error}
</Typography>
)}
</Box>
))}
</Stack>
</Box>
)}
</Box>
);
}
+423 -62
View File
@@ -5,9 +5,35 @@
*/
import { useState, useEffect, useMemo } from 'react';
import { Box, Typography, Button, IconButton, TextField, Collapse, List, ListItemButton, ListItemIcon, ListItemText, Badge, Divider, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material';
import {
Box,
Typography,
Button,
IconButton,
TextField,
Collapse,
List,
ListItemButton,
ListItemIcon,
ListItemText,
Badge,
Divider,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import Fuse from 'fuse.js';
import { Plus, Search, MessageSquare, Pin, Wrench, ChevronDown, ChevronRight, Trash2 } from 'lucide-react';
import {
Plus,
Search,
MessageSquare,
Pin,
Wrench,
ChevronDown,
ChevronRight,
Trash2,
} from 'lucide-react';
import { useSessionStore, type Session } from '@renderer/stores/session-store';
import { useAgentStore } from '@renderer/stores/agent-store';
import { formatRelativeTime } from '@renderer/lib/formatters';
@@ -21,34 +47,125 @@ export function Sidebar(): React.JSX.Element {
const searchQuery = useSessionStore((s) => s.searchQuery);
const setSearchQuery = useSessionStore((s) => s.setSearchQuery);
const agentStatus = useAgentStore((s) => s.agentStatus);
// v0.5.0: 内容搜索结果(FTS5),按会话聚合 { sessionId → snippet }
const [contentMatches, setContentMatches] = useState<
Map<string, { snippet: string; matchCount: number }>
>(new Map());
useEffect(() => {
// M-27 修复: 添加 cancelled 标志防止组件卸载后 setState
let cancelled = false;
if (window.metona?.sessions?.list) {
window.metona.sessions.list().then((list) => {
if (cancelled) return;
useSessionStore.getState().setSessions((list as Array<{ id: string; title: string; createdAt: number; updatedAt: number; messageCount: number; pinned: boolean; archived: boolean }>).map((s) => ({ id: s.id, title: s.title, createdAt: s.createdAt, updatedAt: s.updatedAt, messageCount: s.messageCount, pinned: s.pinned, archived: s.archived })));
}).catch((err) => {
// M-11 修复: 记录错误而非静默吞掉,便于诊断
console.error('[Sidebar] Failed to load sessions:', err);
});
window.metona.sessions
.list()
.then((list) => {
if (cancelled) return;
useSessionStore
.getState()
.setSessions(
(
list as Array<{
id: string;
title: string;
createdAt: number;
updatedAt: number;
messageCount: number;
pinned: boolean;
archived: boolean;
}>
).map((s) => ({
id: s.id,
title: s.title,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
messageCount: s.messageCount,
pinned: s.pinned,
archived: s.archived,
})),
);
})
.catch((err) => {
// M-11 修复: 记录错误而非静默吞掉,便于诊断
console.error('[Sidebar] Failed to load sessions:', err);
});
}
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, []);
// v0.5.0: 搜索词变化时防抖查询会话内容(FTS5 全文搜索,300ms 防抖减少 IPC 频率)
useEffect(() => {
const q = searchQuery.trim();
if (!q || !window.metona?.sessions?.searchContent) {
setContentMatches(new Map());
return;
}
const timer = window.setTimeout(async () => {
try {
const r = await window.metona.sessions.searchContent(q);
if (r.success && r.data) {
const map = new Map<string, { snippet: string; matchCount: number }>();
for (const item of r.data) {
map.set(item.sessionId, { snippet: item.snippet, matchCount: item.matchCount });
}
setContentMatches(map);
} else {
setContentMatches(new Map());
}
} catch (err) {
console.error('[Sidebar] Content search failed:', err);
setContentMatches(new Map());
}
}, 300);
return () => window.clearTimeout(timer);
}, [searchQuery]);
// L-13 修复: 使用 useMemo 缓存 filteredSessions,避免每次渲染都重建 Fuse 索引
// v0.5.0: 标题匹配(Fuse 模糊)∪ 内容匹配(FTS5 精确短语),标题匹配优先排序
const filteredSessions = useMemo(() => {
let list = sessions.filter((s) => !s.archived);
if (searchQuery) { const fuse = new Fuse(list, { keys: ['title'], threshold: 0.4, ignoreLocation: true }); list = fuse.search(searchQuery).map((r) => r.item); }
return list.sort((a, b) => a.pinned === b.pinned ? b.updatedAt - a.updatedAt : a.pinned ? -1 : 1);
}, [sessions, searchQuery]);
const list = sessions.filter((s) => !s.archived);
if (searchQuery) {
const fuse = new Fuse(list, { keys: ['title'], threshold: 0.4, ignoreLocation: true });
const titleMatched = new Set(fuse.search(searchQuery).map((r) => r.item.id));
// 内容匹配的会话并入结果(已含标题匹配的不重复)
const merged = list.filter((s) => titleMatched.has(s.id) || contentMatches.has(s.id));
// 标题匹配优先,内容匹配其次(各按置顶/更新时间排序)
return merged.sort((a, b) => {
const aTitle = titleMatched.has(a.id) ? 0 : 1;
const bTitle = titleMatched.has(b.id) ? 0 : 1;
if (aTitle !== bTitle) return aTitle - bTitle;
return a.pinned === b.pinned ? b.updatedAt - a.updatedAt : a.pinned ? -1 : 1;
});
}
return list.sort((a, b) =>
a.pinned === b.pinned ? b.updatedAt - a.updatedAt : a.pinned ? -1 : 1,
);
}, [sessions, searchQuery, contentMatches]);
const handleNewSession = async () => {
if (window.metona?.sessions?.create) {
try {
const r = await window.metona.sessions.create() as { id: string; title: string; createdAt: number; updatedAt: number; messageCount: number; pinned: boolean; archived: boolean };
useSessionStore.getState().addSession({ id: r.id, title: r.title, createdAt: r.createdAt, updatedAt: r.updatedAt, messageCount: r.messageCount, pinned: r.pinned, archived: r.archived });
const r = (await window.metona.sessions.create()) as {
id: string;
title: string;
createdAt: number;
updatedAt: number;
messageCount: number;
pinned: boolean;
archived: boolean;
};
useSessionStore
.getState()
.addSession({
id: r.id,
title: r.title,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
messageCount: r.messageCount,
pinned: r.pinned,
archived: r.archived,
});
setCurrentSession(r.id);
loadSessionMessages(r.id);
return;
@@ -56,22 +173,50 @@ export function Sidebar(): React.JSX.Element {
// M-9 修复: 显示错误提示而非静默吞错后创建本地假会话
// 之前的行为:catch 后继续创建本地 s_${Date.now()} 会话,但该会话在主进程不存在,下次刷新消失
console.error('[Sidebar] Failed to create session:', err);
import('@metona-team/metona-toast').then((mod) => {
mod.default.error('创建会话失败,请检查数据库状态');
}).catch(() => {});
return; // 不创建本地假会话
import('@metona-team/metona-toast')
.then((mod) => {
mod.default.error('创建会话失败,请检查数据库状态');
})
.catch(() => {});
return; // 不创建本地假会话
}
}
const newSession: Session = { id: `s_${Date.now()}`, title: '新会话', createdAt: Date.now(), updatedAt: Date.now(), messageCount: 0, pinned: false, archived: false };
const newSession: Session = {
id: `s_${Date.now()}`,
title: '新会话',
createdAt: Date.now(),
updatedAt: Date.now(),
messageCount: 0,
pinned: false,
archived: false,
};
useSessionStore.getState().addSession(newSession);
setCurrentSession(newSession.id);
loadSessionMessages(newSession.id);
};
return (
<Box component="aside" sx={{ flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: 'background.paper', borderRight: 1, borderColor: 'divider', width: LAYOUT.SIDEBAR_WIDTH }}>
<Box
component="aside"
sx={{
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
bgcolor: 'background.paper',
borderRight: 1,
borderColor: 'divider',
width: LAYOUT.SIDEBAR_WIDTH,
}}
>
<Box sx={{ p: 1.5, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Button variant="outlined" fullWidth size="small" onClick={handleNewSession} sx={{ mb: 1.5, fontSize: 12 }}>
<Button
variant="outlined"
fullWidth
size="small"
onClick={handleNewSession}
sx={{ mb: 1.5, fontSize: 12 }}
>
<Plus size={14} style={{ marginRight: 8 }} />
</Button>
@@ -79,21 +224,44 @@ export function Sidebar(): React.JSX.Element {
size="small"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索会话..."
placeholder="搜索会话标题与内容..."
slotProps={{
input: {
startAdornment: <Search size={14} style={{ color: '#8b8fa7', marginRight: 8 }} />,
},
}}
sx={{ mb: 1.5, '& .MuiOutlinedInput-root': { fontSize: 12, borderRadius: 1.5, height: 34 } }}
sx={{
mb: 1.5,
'& .MuiOutlinedInput-root': { fontSize: 12, borderRadius: 1.5, height: 34 },
}}
/>
<Box sx={{ flex: 1, overflowY: 'auto' }}>
{filteredSessions.length === 0 ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.secondary' }}>{searchQuery ? '无匹配结果' : '暂无会话'}</Typography>
) : filteredSessions.map((session) => (
<SessionItem key={session.id} session={session} isActive={session.id === currentSessionId} isAgentActive={session.id === currentSessionId && (agentStatus === 'thinking' || agentStatus === 'executing')} onClick={() => { setCurrentSession(session.id); loadSessionMessages(session.id); }} />
))}
<Typography
variant="caption"
sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.secondary' }}
>
{searchQuery ? '无匹配结果' : '暂无会话'}
</Typography>
) : (
filteredSessions.map((session) => (
<SessionItem
key={session.id}
session={session}
isActive={session.id === currentSessionId}
isAgentActive={
session.id === currentSessionId &&
(agentStatus === 'thinking' || agentStatus === 'executing')
}
contentMatch={contentMatches.get(session.id)}
onClick={() => {
setCurrentSession(session.id);
loadSessionMessages(session.id);
}}
/>
))
)}
</Box>
<Divider sx={{ mt: 'auto', mb: 1 }} />
@@ -103,7 +271,19 @@ export function Sidebar(): React.JSX.Element {
);
}
function SessionItem({ session, isActive, isAgentActive, onClick }: { session: Session; isActive: boolean; isAgentActive: boolean; onClick: () => void }) {
function SessionItem({
session,
isActive,
isAgentActive,
contentMatch,
onClick,
}: {
session: Session;
isActive: boolean;
isAgentActive: boolean;
contentMatch?: { snippet: string; matchCount: number };
onClick: () => void;
}) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const handleDelete = (e: React.MouseEvent) => {
@@ -119,9 +299,11 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
await window.metona.sessions.delete(session.id);
} catch (err) {
console.error('[Sidebar] Failed to delete session:', err);
import('@metona-team/metona-toast').then((mod) => {
mod.default.error('删除会话失败,请重试');
}).catch(() => {});
import('@metona-team/metona-toast')
.then((mod) => {
mod.default.error('删除会话失败,请重试');
})
.catch(() => {});
// 不调用 removeSession,保留会话在 UI 中(与数据库状态一致)
setShowDeleteDialog(false);
return;
@@ -135,7 +317,13 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
currentSessionId: null,
messages: [],
traceSteps: [],
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 },
tokenUsage: {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
lastInputTokens: 0,
lastCompressedSaved: 0,
},
currentRunId: null,
currentIteration: 0,
});
@@ -145,24 +333,101 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
return (
<>
<ListItemButton onClick={onClick} selected={isActive} dense sx={{ borderRadius: 1.5, mb: 0.25, px: 1.5, py: 0.75, borderRight: isActive ? '2px solid' : '2px solid transparent', borderColor: isActive ? 'primary.main' : 'transparent', '&:hover .delete-btn': { opacity: 1 } }}>
<ListItemButton
onClick={onClick}
selected={isActive}
dense
sx={{
borderRadius: 1.5,
mb: 0.25,
px: 1.5,
py: 0.75,
borderRight: isActive ? '2px solid' : '2px solid transparent',
borderColor: isActive ? 'primary.main' : 'transparent',
'&:hover .delete-btn': { opacity: 1 },
}}
>
<ListItemIcon sx={{ minWidth: 24 }}>
{session.pinned ? <Pin size={10} style={{ color: '#818cf8' }} /> : isAgentActive ? <Badge color="success" variant="dot" sx={{ '& .MuiBadge-dot': { animation: 'pulse 2s infinite', width: 8, height: 8 } }}><MessageSquare size={12} style={{ color: '#8b8fa7' }} /></Badge> : <MessageSquare size={12} style={{ color: '#8b8fa7' }} />}
{session.pinned ? (
<Pin size={10} style={{ color: '#818cf8' }} />
) : isAgentActive ? (
<Badge
color="success"
variant="dot"
sx={{ '& .MuiBadge-dot': { animation: 'pulse 2s infinite', width: 8, height: 8 } }}
>
<MessageSquare size={12} style={{ color: '#8b8fa7' }} />
</Badge>
) : (
<MessageSquare size={12} style={{ color: '#8b8fa7' }} />
)}
</ListItemIcon>
<ListItemText primary={<Typography variant="body2" sx={{ fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: isActive ? 'text.primary' : 'text.secondary' }}>{session.title}</Typography>}
secondary={<Typography variant="caption" sx={{ fontSize: 10 }}>{formatRelativeTime(session.updatedAt)}{session.messageCount > 0 ? ` · ${session.messageCount}` : ''}</Typography>}
<ListItemText
primary={
<Typography
variant="body2"
sx={{
fontSize: 12,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: isActive ? 'text.primary' : 'text.secondary',
}}
>
{session.title}
</Typography>
}
secondary={
<>
<Typography variant="caption" sx={{ fontSize: 10 }}>
{formatRelativeTime(session.updatedAt)}
{session.messageCount > 0 ? ` · ${session.messageCount}` : ''}
</Typography>
{/* v0.5.0: 内容匹配摘要(FTS5 snippet,高亮标记为 [匹配] */}
{contentMatch && (
<Typography
variant="caption"
sx={{
display: 'block',
fontSize: 10,
color: 'text.disabled',
mt: 0.25,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={contentMatch.snippet}
>
{contentMatch.matchCount > 1 ? `(${contentMatch.matchCount} 处) ` : ''}
{contentMatch.snippet}
</Typography>
)}
</>
}
/>
<IconButton
className="delete-btn"
size="small"
onClick={handleDelete}
sx={{ opacity: 0, transition: 'opacity 150ms', color: 'text.disabled', '&:hover': { color: 'error.main' }, width: 20, height: 20 }}
sx={{
opacity: 0,
transition: 'opacity 150ms',
color: 'text.disabled',
'&:hover': { color: 'error.main' },
width: 20,
height: 20,
}}
>
<Trash2 size={12} />
</IconButton>
</ListItemButton>
<Dialog open={showDeleteDialog} onClose={() => setShowDeleteDialog(false)} maxWidth="xs" fullWidth>
<Dialog
open={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)}
maxWidth="xs"
fullWidth
>
<DialogTitle sx={{ fontSize: 14 }}></DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
@@ -170,8 +435,16 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
</Typography>
</DialogContent>
<DialogActions>
<Button size="small" onClick={() => setShowDeleteDialog(false)} sx={{ color: 'text.secondary' }}></Button>
<Button size="small" color="error" variant="contained" onClick={confirmDelete}></Button>
<Button
size="small"
onClick={() => setShowDeleteDialog(false)}
sx={{ color: 'text.secondary' }}
>
</Button>
<Button size="small" color="error" variant="contained" onClick={confirmDelete}>
</Button>
</DialogActions>
</Dialog>
</>
@@ -180,41 +453,129 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
function ToolManagerPanel() {
const [expanded, setExpanded] = useState(false);
const [tools, setTools] = useState<Array<{ name: string; description: string; category: string; riskLevel: string; requiresPermission: boolean; enabled: boolean }>>([]);
const [tools, setTools] = useState<
Array<{
name: string;
description: string;
category: string;
riskLevel: string;
requiresPermission: boolean;
enabled: boolean;
}>
>([]);
useEffect(() => {
// M-54 修复: 添加 cancelled 标志,防止组件卸载后 setState
let cancelled = false;
if (window.metona?.tools?.list) {
window.metona.tools.list().then((list) => {
if (!cancelled) setTools(list as MetonaToolInfo[]);
}).catch((err) => {
console.error('[Sidebar]', err);
if (!cancelled) {
import('@metona-team/metona-toast').then((mod) => mod.default.error('加载工具列表失败')).catch(() => {});
}
});
window.metona.tools
.list()
.then((list) => {
if (!cancelled) setTools(list as MetonaToolInfo[]);
})
.catch((err) => {
console.error('[Sidebar]', err);
if (!cancelled) {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error('加载工具列表失败'))
.catch(() => {});
}
});
}
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, []);
const readyCount = tools.filter((t) => t.enabled).length;
const riskColors: Record<string, string> = { safe: 'success.main', low: 'info.main', medium: 'warning.main', high: 'error.main' };
const riskLabels: Record<string, string> = { safe: 'SAFE', low: 'LOW', medium: 'MEDIUM', high: 'HIGH' };
const riskColors: Record<string, string> = {
safe: 'success.main',
low: 'info.main',
medium: 'warning.main',
high: 'error.main',
};
const riskLabels: Record<string, string> = {
safe: 'SAFE',
low: 'LOW',
medium: 'MEDIUM',
high: 'HIGH',
};
return (
<Box>
<ListItemButton onClick={() => setExpanded(!expanded)} dense sx={{ borderRadius: 1, px: 1.5, py: 0.75 }}>
<ListItemIcon sx={{ minWidth: 24 }}>{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}<Wrench size={12} style={{ marginLeft: 4 }} /></ListItemIcon>
<ListItemText primary={<Typography variant="body2" sx={{ fontSize: 12 }}></Typography>} />
<Typography variant="caption" sx={{ color: readyCount > 0 ? 'success.main' : 'text.disabled', fontSize: 10 }}>{readyCount} </Typography>
<ListItemButton
onClick={() => setExpanded(!expanded)}
dense
sx={{ borderRadius: 1, px: 1.5, py: 0.75 }}
>
<ListItemIcon sx={{ minWidth: 24 }}>
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<Wrench size={12} style={{ marginLeft: 4 }} />
</ListItemIcon>
<ListItemText
primary={
<Typography variant="body2" sx={{ fontSize: 12 }}>
</Typography>
}
/>
<Typography
variant="caption"
sx={{ color: readyCount > 0 ? 'success.main' : 'text.disabled', fontSize: 10 }}
>
{readyCount}
</Typography>
</ListItemButton>
<Collapse in={expanded}>
<List dense disablePadding sx={{ pl: 3, maxHeight: 200, overflowY: 'auto', pr: 0.5, '&::-webkit-scrollbar': { width: 6 }, '&::-webkit-scrollbar-track': { borderRadius: 3 }, '&::-webkit-scrollbar-thumb': { bgcolor: 'divider', borderRadius: 3, '&:hover': { bgcolor: 'action.hover' } } }}>
<List
dense
disablePadding
sx={{
pl: 3,
maxHeight: 200,
overflowY: 'auto',
pr: 0.5,
'&::-webkit-scrollbar': { width: 6 },
'&::-webkit-scrollbar-track': { borderRadius: 3 },
'&::-webkit-scrollbar-thumb': {
bgcolor: 'divider',
borderRadius: 3,
'&:hover': { bgcolor: 'action.hover' },
},
}}
>
{tools.map((t) => (
<ListItemButton key={t.name} dense sx={{ py: 0.25, px: 1, borderRadius: 1 }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: t.enabled ? riskColors[t.riskLevel] ?? 'text.disabled' : 'text.disabled', mr: 1, flexShrink: 0 }} />
<Typography variant="caption" sx={{ flex: 1, fontSize: 11, color: t.enabled ? 'text.secondary' : 'text.disabled' }}>{t.name}</Typography>
<Typography variant="caption" sx={{ fontSize: 9, color: t.enabled ? (riskColors[t.riskLevel] ?? 'text.disabled') : 'text.disabled' }}>{riskLabels[t.riskLevel] ?? t.riskLevel}</Typography>
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: t.enabled
? (riskColors[t.riskLevel] ?? 'text.disabled')
: 'text.disabled',
mr: 1,
flexShrink: 0,
}}
/>
<Typography
variant="caption"
sx={{
flex: 1,
fontSize: 11,
color: t.enabled ? 'text.secondary' : 'text.disabled',
}}
>
{t.name}
</Typography>
<Typography
variant="caption"
sx={{
fontSize: 9,
color: t.enabled ? (riskColors[t.riskLevel] ?? 'text.disabled') : 'text.disabled',
}}
>
{riskLabels[t.riskLevel] ?? t.riskLevel}
</Typography>
</ListItemButton>
))}
</List>
+90 -1
View File
@@ -5,7 +5,7 @@
* 功能:主 Provider / 故障转移 Provider / 上下文窗口配置,批量保存。
*/
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import {
Button,
TextField,
@@ -50,6 +50,46 @@ export function LLMSettings() {
const [showFbKey, setShowFbKey] = useState(false);
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
// v0.5.0: DeepSeek 余额显示(复用主进程 getBalance,原为适配器死代码)
const [balance, setBalance] = useState<{
currency: string;
totalBalance: string;
grantedBalance: string;
toppedUpBalance: string;
} | null>(null);
const [balanceError, setBalanceError] = useState<string | null>(null);
const [balanceLoading, setBalanceLoading] = useState(false);
const loadBalance = useCallback(async () => {
if (!window.metona?.llm?.getBalance) return;
setBalanceLoading(true);
setBalanceError(null);
try {
const r = await window.metona.llm.getBalance();
if (r.success && r.data) {
setBalance(r.data);
} else {
setBalance(null);
setBalanceError(r.error ?? '查询失败');
}
} catch (err) {
setBalance(null);
setBalanceError((err as Error).message);
} finally {
setBalanceLoading(false);
}
}, []);
// DeepSeek Provider 且配置加载完成后自动查询一次(仅一次,避免频繁请求 API)
// 依赖故意不含 apiKey —— 仅在加载完成与 Provider 切换时查询,apiKey 输入变化不重复请求
useEffect(() => {
if (loaded && provider === 'deepseek' && apiKey.trim()) {
void loadBalance();
} else {
setBalance(null);
setBalanceError(null);
}
}, [loaded, provider]);
// 初始化:一次性加载所有 LLM 配置字段
useEffect(() => {
@@ -349,6 +389,55 @@ export function LLMSettings() {
helperText={dsCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'}
/>
)}
{/* v0.5.0: DeepSeek 账户余额显示 */}
{provider === 'deepseek' && (
<Stack
direction="row"
spacing={1}
sx={{
alignItems: 'center',
px: 1.5,
py: 1,
borderRadius: 1.5,
bgcolor: 'secondary.main',
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
</Typography>
{balanceLoading ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
...
</Typography>
) : balance ? (
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: 600 }}>
{balance.totalBalance} {balance.currency}
<Typography component="span" variant="caption" sx={{ color: 'text.disabled', ml: 1 }}>
{balance.grantedBalance} + {balance.toppedUpBalance}
</Typography>
</Typography>
) : (
<Typography
variant="caption"
sx={{ color: balanceError ? 'error.main' : 'text.disabled' }}
>
{balanceError ?? '未查询(需已保存 API Key'}
</Typography>
)}
<Button
size="small"
variant="outlined"
onClick={loadBalance}
disabled={balanceLoading || !apiKey.trim()}
sx={{ ml: 'auto', minWidth: 64, fontSize: 11 }}
>
</Button>
</Stack>
)}
{provider === 'agnes' && (
<TextField
size="small"
+55
View File
@@ -119,6 +119,40 @@ export function LogsSettings() {
.catch(() => {});
}
};
// v0.5.0: 审计日志导出(JSONL / CSV,复用主进程 AuditService 导出,含链式哈希字段)
const [auditExporting, setAuditExporting] = useState<string | null>(null);
const handleAuditExport = async (format: 'jsonl' | 'csv') => {
if (!window.metona?.audit?.export) return;
setAuditExporting(format);
try {
const r = await window.metona.audit.export(format);
if (r.success && r.data != null) {
const mime = format === 'csv' ? 'text/csv;charset=utf-8' : 'application/x-ndjson';
const b = new Blob([r.data], { type: mime });
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = `metona-audit-${Date.now()}.${format}`;
a.click();
// 释放 Blob URL,避免内存泄漏
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
import('@metona-team/metona-toast')
.then((mod) => mod.default.success(`已导出 ${r.recordCount ?? 0} 条审计记录`))
.catch(() => {});
} else {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`审计导出失败: ${r.error ?? '未知错误'}`))
.catch(() => {});
}
} catch (err) {
console.error('[LogsSettings] Audit export failed:', err);
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`审计导出失败: ${(err as Error).message}`))
.catch(() => {});
} finally {
setAuditExporting(null);
}
};
const labels: Record<string, string> = {
sessions: '所有会话',
memories: '所有记忆',
@@ -229,6 +263,27 @@ export function LogsSettings() {
<Button variant="outlined" fullWidth size="small" onClick={handleExport}>
📦 JSON
</Button>
{/* v0.5.0: 审计日志导出(含链式哈希字段,可离线验证完整性) */}
<Stack direction="row" spacing={1}>
<Button
variant="outlined"
fullWidth
size="small"
onClick={() => handleAuditExport('jsonl')}
disabled={auditExporting !== null}
>
{auditExporting === 'jsonl' ? '导出中...' : '📋 导出审计 (JSONL)'}
</Button>
<Button
variant="outlined"
fullWidth
size="small"
onClick={() => handleAuditExport('csv')}
disabled={auditExporting !== null}
>
{auditExporting === 'csv' ? '导出中...' : '📊 导出审计 (CSV)'}
</Button>
</Stack>
<Button
variant="outlined"
fullWidth