feat: v0.5.0 审计修复版 — 类型基线重建 + 会话隔离 + SubAgent 可观测性 + 三项功能补全
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:
@@ -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
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+64
-4
@@ -55,6 +55,18 @@ interface MetonaStreamEventData {
|
||||
state?: string;
|
||||
}
|
||||
|
||||
/** v0.5.0: SubAgent 生命周期事件(AgentMonitor 的 SubAgent 状态区展示) */
|
||||
interface MetonaSubAgentEvent {
|
||||
taskId: string;
|
||||
parentSessionId: string;
|
||||
description: string;
|
||||
status: 'delegated' | 'running' | 'completed' | 'error';
|
||||
depth: number;
|
||||
durationMs?: number;
|
||||
iterations?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface MetonaAgentAPI {
|
||||
/** 发送 MetonaMessage(IR 类型)+ sessionId 到主进程 */
|
||||
sendMessage: (message: MetonaMessageInput, sessionId: string) => Promise<{ success: boolean }>;
|
||||
@@ -72,6 +84,8 @@ interface MetonaAgentAPI {
|
||||
onProviderSwitched: (
|
||||
callback: (data: { from?: string; to?: string; reason?: string }) => void,
|
||||
) => () => void;
|
||||
/** v0.5.0: 监听 SubAgent 生命周期事件(委派/运行/完成/失败) */
|
||||
onSubAgentEvent: (callback: (event: MetonaSubAgentEvent) => void) => () => void;
|
||||
}
|
||||
|
||||
// ===== Sessions API =====
|
||||
@@ -121,6 +135,20 @@ interface MetonaSessionsAPI {
|
||||
messageId: string,
|
||||
inclusive?: boolean,
|
||||
) => Promise<{ success: boolean; truncated?: number; error?: string }>;
|
||||
/** v0.5.0: 全文搜索会话内容(FTS5,跨会话,按会话聚合) */
|
||||
searchContent: (query: string) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: Array<{
|
||||
sessionId: string;
|
||||
sessionTitle: string;
|
||||
matchCount: number;
|
||||
lastMessageId: string;
|
||||
lastRole: string;
|
||||
snippet: string;
|
||||
timestamp: number;
|
||||
}>;
|
||||
}>;
|
||||
saveTrace: (
|
||||
sessionId: string,
|
||||
data: { traceSteps: unknown[]; tokenUsage: unknown },
|
||||
@@ -267,6 +295,22 @@ interface MetonaWorkspaceAPI {
|
||||
}>;
|
||||
}
|
||||
|
||||
// ===== LLM 运行时查询 API =====
|
||||
|
||||
interface MetonaLLMAPI {
|
||||
/** v0.5.0: DeepSeek 余额查询(仅 DeepSeek Provider 支持) */
|
||||
getBalance: () => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: {
|
||||
currency: string;
|
||||
totalBalance: string;
|
||||
grantedBalance: string;
|
||||
toppedUpBalance: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
// ===== Toast API =====
|
||||
|
||||
interface MetonaToastAPI {
|
||||
@@ -386,6 +430,14 @@ interface MetonaAuditAPI {
|
||||
eventType?: string;
|
||||
limit?: number;
|
||||
}) => Promise<{ success: boolean; data?: unknown[]; error?: string }>;
|
||||
/** v0.5.0: 导出审计日志(jsonl / csv 两种格式) */
|
||||
export: (format: 'jsonl' | 'csv') => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: string;
|
||||
format?: string;
|
||||
recordCount?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ===== v0.2.0: Tool Confirmation API =====
|
||||
@@ -424,15 +476,22 @@ interface MetonaToolAPI {
|
||||
data: MetonaConfirmationRequest[];
|
||||
}>;
|
||||
/**
|
||||
* v0.4.1: 获取本会话内记住"拒绝"的工具列表(拒绝记忆 10 分钟 TTL,到期自动恢复询问)
|
||||
* v0.4.1: 获取记住"拒绝"的工具列表(拒绝记忆 10 分钟 TTL,到期自动恢复询问)
|
||||
* 供确认弹框展示"重新询问"入口
|
||||
* v0.5.0: sessionId 参数 — 拒绝记忆按会话隔离,传当前会话只查看本会话的记忆
|
||||
*/
|
||||
getRememberedDenials: () => Promise<{
|
||||
getRememberedDenials: (sessionId?: string) => Promise<{
|
||||
success: boolean;
|
||||
data: Array<{ toolName: string; expiresInSeconds: number }>;
|
||||
}>;
|
||||
/** v0.4.1: 重置指定工具的会话内拒绝记忆(立即恢复询问) */
|
||||
resetRememberedDenial: (toolName: string) => Promise<{ success: boolean; error?: string }>;
|
||||
/**
|
||||
* v0.4.1: 重置指定工具的拒绝记忆(立即恢复询问)
|
||||
* v0.5.0: sessionId 参数 — 指定会话;缺省时重置所有会话中该工具的记忆
|
||||
*/
|
||||
resetRememberedDenial: (
|
||||
toolName: string,
|
||||
sessionId?: string,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
/** 设置/取消工具的持久化自动执行(跨会话不再询问) */
|
||||
setAutoExecute: (
|
||||
toolName: string,
|
||||
@@ -456,6 +515,7 @@ interface MetonaBridge {
|
||||
tools: MetonaToolsAPI;
|
||||
data: MetonaDataAPI;
|
||||
searxng: MetonaSearXNGAPI;
|
||||
llm: MetonaLLMAPI;
|
||||
tasks: MetonaTasksAPI;
|
||||
audit: MetonaAuditAPI;
|
||||
tool: MetonaToolAPI;
|
||||
|
||||
Reference in New Issue
Block a user