feat: 升级至 v0.2.1 — 流式渲染修复、安全增强、工具自动执行

流式渲染修复:
- runId 机制防止 abort 后旧流事件污染新 run
- run lock 防止并发 run 污染引擎状态
- abort race 提前退出工具执行等待
- TERMINATED 状态通过 stateChange 发射
- tool_call_delta 流式参数拼接 + pending 占位替换
- 首轮卡片创建路径统一,traceStep 按 ID 精确匹配
- compressed 事件转发为 toast 通知

安全增强:
- ConfirmationHook 支持持久化自动执行(跨会话)
- 设置面板新增自动执行工具管理 UI
- SandboxManager 双重安全校验 fail-closed
- 审计日志链式哈希防篡改
- PromptInjectionDefender 中文注入标记清理
- scanCode 28 模式 + base64/$() 检测
- validatePath realpathSync 防符号链接逃逸
- code-search 使用 execFile 防命令注入

新增工具:
- file_editor、code_search、task_manager、diff_viewer

其他:
- Agent Loop 加 PARSING/REFLECTING 状态 + 指数退避重试
- MemoryManager TF-IDF 语义检索
- run_command Windows 中文编码修复(chcp 65001)
- 版本号 0.2.0 → 0.2.1
This commit is contained in:
thzxx
2026-07-12 12:54:52 +08:00
parent 469f53b623
commit 3c5aea8fb7
41 changed files with 4972 additions and 423 deletions
+2
View File
@@ -17,6 +17,7 @@ import { SettingsModal } from '@renderer/components/settings/SettingsModal';
import { OnboardingWizard } from '@renderer/components/onboarding/OnboardingWizard';
import { CommandPalette } from '@renderer/components/CommandPalette';
import { ToastContainer } from '@renderer/components/ToastContainer';
import { ConfirmationDialog } from '@renderer/components/ConfirmationDialog';
import { useTheme } from '@renderer/hooks/useTheme';
import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts';
import { useAgentStream } from '@renderer/hooks/useAgentStream';
@@ -64,6 +65,7 @@ export default function App(): React.JSX.Element {
<OnboardingWizard />
<CommandPalette />
<ToastContainer />
<ConfirmationDialog />
</div>
</ThemeProvider>
);
+221
View File
@@ -0,0 +1,221 @@
/**
* ConfirmationDialog — 工具执行确认对话框(v0.2.0 新增)
*
* 当 Agent 调用 requiresPermission=true 或 riskLevel>=HIGH 的工具时,
* 通过此对话框请求用户确认。
*
* 监听 IPC 事件 `tool:confirmationRequest`,显示工具详情,
* 用户确认/拒绝后通过 `tool:confirmationResponse` IPC 通道发送结果。
*/
import { useState, useEffect, useCallback } from 'react';
import {
Dialog, DialogTitle, DialogContent, DialogActions,
Button, Typography, Box, Chip, Alert, FormControlLabel, Checkbox,
Accordion, AccordionSummary, AccordionDetails,
} from '@mui/material';
import { ShieldAlert, ChevronDown } from 'lucide-react';
interface ConfirmationRequest {
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
riskLevel: string;
reason: string;
}
const RISK_COLORS: Record<string, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
safe: 'success',
low: 'info',
medium: 'warning',
high: 'error',
critical: 'error',
};
export function ConfirmationDialog(): React.JSX.Element | null {
const [request, setRequest] = useState<ConfirmationRequest | null>(null);
const [remember, setRemember] = useState(false);
const [autoExecute, setAutoExecute] = useState(false);
useEffect(() => {
// 监听来自主进程的确认请求(通过 preload 暴露的 metona.tool API
const cleanup = window.metona?.tool?.onConfirmationRequest((data: unknown) => {
setRequest(data as ConfirmationRequest);
setRemember(false);
setAutoExecute(false);
});
return cleanup;
}, []);
const handleRespond = useCallback((approved: boolean) => {
if (!request) return;
// 通过 preload 暴露的 metona.tool API 发送响应
// autoExecute 仅在批准时生效(拒绝时无需持久化)
window.metona?.tool?.sendConfirmationResponse({
toolCallId: request.toolCallId,
approved,
remember,
autoExecute: approved && autoExecute,
});
setRequest(null);
}, [request, remember, autoExecute]);
if (!request) return null;
const riskColor = RISK_COLORS[request.riskLevel] ?? 'default';
// 格式化参数显示
const formatArg = (key: string, value: unknown): string => {
if (typeof value === 'string') {
// 长字符串截断
return value.length > 200 ? value.slice(0, 200) + '...' : value;
}
if (value === null) return 'null';
if (value === undefined) return 'undefined';
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
return (
<Dialog
open={!!request}
onClose={() => handleRespond(false)}
maxWidth="sm"
fullWidth
slotProps={{
paper: {
sx: { bgcolor: 'background.paper' },
},
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ShieldAlert size={20} color="var(--mui-palette-warning-main)" />
<Typography variant="h6" component="span"></Typography>
</DialogTitle>
<DialogContent>
<Alert severity={riskColor === 'error' ? 'error' : 'warning'} sx={{ mb: 2 }}>
{request.reason}
</Alert>
<Box sx={{ mb: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip
label={`工具: ${request.toolName}`}
color="primary"
size="small"
/>
<Chip
label={`风险: ${request.riskLevel}`}
color={riskColor}
size="small"
/>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Agent
</Typography>
<Accordion defaultExpanded sx={{ bgcolor: 'background.default' }}>
<AccordionSummary expandIcon={<ChevronDown size={16} />}>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
({Object.keys(request.args).length} )
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box
component="pre"
sx={{
fontFamily: 'monospace',
fontSize: 11,
color: 'text.primary',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
maxHeight: 300,
overflow: 'auto',
}}
>
{Object.entries(request.args).map(([key, value]) => (
<Box key={key} component="div" sx={{ mb: 1 }}>
<Typography
component="span"
variant="caption"
sx={{ color: 'info.main', fontWeight: 700 }}
>
{key}:
</Typography>
<Typography
component="span"
variant="caption"
sx={{ color: 'text.primary', ml: 1 }}
>
{formatArg(key, value)}
</Typography>
</Box>
))}
</Box>
</AccordionDetails>
</Accordion>
<FormControlLabel
control={
<Checkbox
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
size="small"
/>
}
label={
<Typography variant="caption" color="text.secondary">
</Typography>
}
sx={{ mt: 1 }}
/>
<FormControlLabel
control={
<Checkbox
checked={autoExecute}
onChange={(e) => {
setAutoExecute(e.target.checked);
// 勾选永久自动执行时,自动勾选会话内记忆(保持一致)
if (e.target.checked) setRemember(true);
}}
size="small"
/>
}
label={
<Typography variant="caption" color="text.secondary">
</Typography>
}
sx={{ mt: 0.5 }}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button
onClick={() => handleRespond(false)}
color="error"
variant="outlined"
size="small"
>
</Button>
<Button
onClick={() => handleRespond(true)}
color="success"
variant="contained"
size="small"
autoFocus
>
</Button>
</DialogActions>
</Dialog>
);
}
+1 -1
View File
@@ -191,7 +191,7 @@ function extractTextContent(children: React.ReactNode): string {
if (!children) return '';
if (Array.isArray(children)) return children.map(extractTextContent).join('');
if (typeof children === 'object' && 'props' in children) {
return extractTextContent((children as React.ReactElement).props as React.ReactNode);
return extractTextContent((children as React.ReactElement).props.children as React.ReactNode);
}
return '';
}
+7 -1
View File
@@ -13,7 +13,13 @@ export function MessageList(): React.JSX.Element {
const isStreaming = useAgentStore((s) => s.isStreaming);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
// 流式输出时高频触发,使用 throttle 避免滚动卡顿
useEffect(() => {
const timer = setTimeout(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 80);
return () => clearTimeout(timer);
}, [messages]);
if (messages.length === 0 && !isStreaming) {
return (
+53
View File
@@ -0,0 +1,53 @@
/**
* ErrorBoundary — 渲染错误边界
*
* 捕获子组件渲染时的同步异常,显示降级 UI 而非整个应用白屏。
*/
import { Component, type ReactNode } from 'react';
import { Box, Typography, Button } from '@mui/material';
import { AlertTriangle } from 'lucide-react';
interface Props {
children: ReactNode;
fallbackTitle?: string;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: { componentStack: string }): void {
console.error('[ErrorBoundary]', error, info.componentStack);
}
handleReset = (): void => {
this.setState({ hasError: false, error: null });
};
render(): ReactNode {
if (!this.state.hasError) return this.props.children;
return (
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 1, alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<AlertTriangle size={24} color="var(--mui-palette-error-main)" />
<Typography variant="caption" sx={{ fontWeight: 600 }}>
{this.props.fallbackTitle ?? '组件渲染失败'}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 10, textAlign: 'center', wordBreak: 'break-word', maxWidth: '80%' }}>
{this.state.error?.message ?? '未知错误'}
</Typography>
<Button size="small" variant="outlined" onClick={this.handleReset} sx={{ mt: 1, textTransform: 'none', fontSize: 11 }}>
</Button>
</Box>
);
}
}
+46 -5
View File
@@ -1,16 +1,25 @@
/**
* DetailPanel — 右侧详情面板
*
* 布局策略:TraceViewer flex:1 撑满,TokenUsage/AgentMonitor flexShrink:0 固定
* 通过 Tab 切换显示 Trace / Memory / Tasks
*/
import { Box } from '@mui/material';
import { useState } from 'react';
import { Box, Tabs, Tab } from '@mui/material';
import { Activity, Brain, ListChecks } from 'lucide-react';
import { TraceViewer } from '@renderer/components/trace/TraceViewer';
import { TokenUsage } from '@renderer/components/trace/TokenUsage';
import { AgentMonitor } from '@renderer/components/layout/AgentMonitor';
import { MemoryViewer } from '@renderer/components/memory/MemoryViewer';
import { TaskList } from '@renderer/components/tasks/TaskList';
import { ErrorBoundary } from '@renderer/components/common/ErrorBoundary';
import { LAYOUT } from '@renderer/lib/constants';
type DetailTab = 'trace' | 'memory' | 'tasks';
export function DetailPanel(): React.JSX.Element {
const [tab, setTab] = useState<DetailTab>('trace');
return (
<Box
component="aside"
@@ -19,10 +28,42 @@ export function DetailPanel(): React.JSX.Element {
bgcolor: 'background.paper', borderLeft: 1, borderColor: 'divider', width: LAYOUT.DETAIL_WIDTH,
}}
>
<Tabs
value={tab}
onChange={(_, v) => setTab(v as DetailTab)}
variant="fullWidth"
sx={{
minHeight: 32, height: 32, flexShrink: 0,
borderBottom: 1, borderColor: 'divider',
'& .MuiTab-root': { minHeight: 32, height: 32, fontSize: 11, textTransform: 'none', py: 0, px: 1 },
'& .MuiTabs-indicator': { height: 2 },
}}
>
<Tab icon={<Activity size={12} />} iconPosition="start" label="Trace" value="trace" />
<Tab icon={<Brain size={12} />} iconPosition="start" label="Memory" value="memory" />
<Tab icon={<ListChecks size={12} />} iconPosition="start" label="Tasks" value="tasks" />
</Tabs>
<Box sx={{ p: 1.5, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
<TraceViewer />
<TokenUsage />
<AgentMonitor />
{tab === 'trace' && (
<ErrorBoundary fallbackTitle="Trace 渲染失败">
<>
<TraceViewer />
<TokenUsage />
<AgentMonitor />
</>
</ErrorBoundary>
)}
{tab === 'memory' && (
<ErrorBoundary fallbackTitle="Memory 渲染失败">
<MemoryViewer />
</ErrorBoundary>
)}
{tab === 'tasks' && (
<ErrorBoundary fallbackTitle="Tasks 渲染失败">
<TaskList />
</ErrorBoundary>
)}
</Box>
</Box>
);
+1 -39
View File
@@ -7,7 +7,7 @@
import { useState, useEffect } from 'react';
import { Box, Typography, Button, IconButton, TextField, Stack, 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, Database, 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';
@@ -78,7 +78,6 @@ export function Sidebar(): React.JSX.Element {
<Divider sx={{ mt: 'auto', mb: 1 }} />
<ToolManagerPanel />
<MemorySearchPanel />
</Box>
</Box>
);
@@ -169,40 +168,3 @@ function ToolManagerPanel() {
</Box>
);
}
function MemorySearchPanel() {
const [expanded, setExpanded] = useState(false);
const [query, setQuery] = useState('');
const [results, setResults] = useState<Array<{ id: string; type: string; content: string; importance: number }>>([]);
const [searching, setSearching] = useState(false);
const handleSearch = async () => { if (!query.trim() || !window.metona?.memory?.search) return; setSearching(true); try { setResults((await window.metona.memory.search(query, { topK: 5 })) as any); } catch { setResults([]); } setSearching(false); };
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} />}<Database size={12} style={{ marginLeft: 4 }} /></ListItemIcon>
<ListItemText primary={<Typography variant="body2" sx={{ fontSize: 12 }}></Typography>} />
</ListItemButton>
<Collapse in={expanded}>
<Stack spacing={1} sx={{ pl: 3, pr: 1, pb: 1 }}>
<Stack direction="row" spacing={0.5}>
<TextField size="small" value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }} placeholder="搜索记忆..." sx={{ flex: 1, '& .MuiInputBase-root': { fontSize: 11, height: 28 } }} />
<Button variant="outlined" size="small" onClick={handleSearch} disabled={searching} sx={{ minWidth: 40, height: 28, fontSize: 10 }}>{searching ? '...' : '搜索'}</Button>
</Stack>
{results.length > 0 && (
<Box sx={{ maxHeight: 200, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{results.map((r) => (
<Box key={r.id} sx={{ px: 1, py: 0.75, borderRadius: 1, bgcolor: 'background.default', fontSize: 10, color: 'text.secondary' }}>
<Stack direction="row" spacing={0.5} sx={{ mb: 0.25, alignItems: 'center' }}>
<Box sx={{ px: 0.5, borderRadius: 0.5, bgcolor: 'action.hover', color: 'primary.main', fontSize: 9 }}>{r.type}</Box>
<Typography variant="caption" sx={{ fontSize: 9 }}>: {r.importance.toFixed(1)}</Typography>
</Stack>
<Typography variant="caption" sx={{ fontSize: 10, display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.content.slice(0, 100)}</Typography>
</Box>
))}
</Box>
)}
</Stack>
</Collapse>
</Box>
);
}
+440
View File
@@ -0,0 +1,440 @@
/**
* MemoryViewer — 记忆浏览器
*
* 列出所有记忆(episodic/semantic/working),支持搜索、删除。
* Agent 完成任务后自动刷新(监听 agentStatus: thinking|executing -> idle)。
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Box, Typography, Stack, Accordion, AccordionSummary, AccordionDetails,
IconButton, TextField, Chip, Alert, InputAdornment,
} from '@mui/material';
import { Brain, Search, Trash2, ChevronDown } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
import { formatTime, truncate } from '@renderer/lib/formatters';
// ===== 类型 =====
type MemoryType = 'episodic' | 'semantic' | 'working';
interface MemoryItem {
id: string;
type: MemoryType;
content: string;
summary?: string;
importance?: number;
createdAt?: number;
created_at?: number;
}
interface SearchResult {
id: string;
type: MemoryType;
content: string;
summary?: string;
importance: number;
relevanceScore: number;
createdAt: number;
}
// ===== 常量 =====
const MEMORY_TYPES: MemoryType[] = ['episodic', 'semantic', 'working'];
const MEMORY_TYPE_LABELS: Record<MemoryType, string> = {
episodic: '情景记忆',
semantic: '语义记忆',
working: '工作记忆',
};
const MEMORY_TYPE_COLORS: Record<MemoryType, string> = {
episodic: '#22d3ee',
semantic: '#a855f7',
working: '#fbbf24',
};
function importanceColor(score: number): string {
if (score >= 0.8) return '#ef4444';
if (score >= 0.5) return '#f59e0b';
return '#64748b';
}
function getCreatedAt(item: MemoryItem): number {
return item.createdAt ?? item.created_at ?? 0;
}
function getId(item: MemoryItem): string {
return item.id;
}
// ===== 主组件 =====
export function MemoryViewer(): React.JSX.Element {
const [memories, setMemories] = useState<Record<MemoryType, MemoryItem[]>>({
episodic: [], semantic: [], working: [],
});
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [searching, setSearching] = useState(false);
const [expanded, setExpanded] = useState<MemoryType | 'all'>('all');
// Agent 完成时自动刷新:监听 agentStatus 从 thinking/executing -> idle
const agentStatus = useAgentStore((s) => s.agentStatus);
const prevStatus = useRef(agentStatus);
const loadMemories = useCallback(async () => {
if (!window.metona?.memory?.listAll) return;
try {
setError(null);
const res = await window.metona.memory.listAll();
if (!res.success) {
setError(res.error ?? '加载记忆失败');
return;
}
const data = res.data ?? {};
setMemories({
episodic: (data.episodic as MemoryItem[] | undefined) ?? [],
semantic: (data.semantic as MemoryItem[] | undefined) ?? [],
working: (data.working as MemoryItem[] | undefined) ?? [],
});
} catch (err) {
setError((err as Error).message ?? '加载记忆失败');
}
}, []);
// 初次挂载加载
useEffect(() => {
loadMemories();
}, [loadMemories]);
// Agent 完成自动刷新
useEffect(() => {
const prev = prevStatus.current;
prevStatus.current = agentStatus;
if ((prev === 'thinking' || prev === 'executing') && agentStatus === 'idle') {
loadMemories();
}
}, [agentStatus, loadMemories]);
const handleSearch = async () => {
const q = searchQuery.trim();
if (!q || !window.metona?.memory?.search) return;
setSearching(true);
setError(null);
try {
const results = await window.metona.memory.search(q, { topK: 20 });
setSearchResults(results);
} catch (err) {
setError((err as Error).message ?? '搜索失败');
setSearchResults([]);
} finally {
setSearching(false);
}
};
const handleClearSearch = () => {
setSearchQuery('');
setSearchResults(null);
};
const handleDelete = async (type: MemoryType, id: string) => {
if (!window.metona?.memory?.delete) return;
try {
setError(null);
const res = await window.metona.memory.delete(type, id);
if (!res.success) {
setError(res.error ?? '删除失败');
return;
}
await loadMemories();
} catch (err) {
setError((err as Error).message ?? '删除失败');
}
};
const totalCount =
memories.episodic.length + memories.semantic.length + memories.working.length;
// 搜索结果视图
if (searchResults !== null) {
return (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flexShrink: 0, alignItems: 'center' }}>
<Brain size={14} style={{ color: '#a855f7' }} />
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
</Typography>
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
{searchResults.length}
</Typography>
</Stack>
<TextField
size="small"
fullWidth
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
placeholder="搜索记忆..."
sx={{ mb: 1.5, flexShrink: 0, '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1.5 } }}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Search size={12} style={{ color: '#8b8fa7' }} />
</InputAdornment>
),
endAdornment: searchQuery ? (
<InputAdornment position="end" sx={{ cursor: 'pointer' }} onClick={handleClearSearch}>
<Typography variant="caption" sx={{ fontSize: 10, color: 'text.secondary' }}></Typography>
</InputAdornment>
) : null,
},
}}
/>
{error && <Alert severity="error" sx={{ mb: 1, py: 0.5, fontSize: 11, flexShrink: 0 }}>{error}</Alert>}
<Box sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 0.5, minHeight: 0 }}>
{searching ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 2, color: 'text.disabled' }}>
...
</Typography>
) : searchResults.length === 0 ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 2, color: 'text.disabled' }}>
</Typography>
) : (
searchResults.map((r) => (
<MemorySearchItem key={`${r.type}-${r.id}`} item={r} onDelete={handleDelete} />
))
)}
</Box>
</Box>
);
}
// 全量列表视图
return (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flexShrink: 0, alignItems: 'center' }}>
<Brain size={14} style={{ color: '#a855f7' }} />
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
</Typography>
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
{totalCount}
</Typography>
</Stack>
<TextField
size="small"
fullWidth
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
placeholder="搜索记忆..."
sx={{ mb: 1.5, flexShrink: 0, '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1.5 } }}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Search size={12} style={{ color: '#8b8fa7' }} />
</InputAdornment>
),
},
}}
/>
{error && <Alert severity="error" sx={{ mb: 1, py: 0.5, fontSize: 11, flexShrink: 0 }}>{error}</Alert>}
<Box sx={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{totalCount === 0 ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}>
</Typography>
) : (
MEMORY_TYPES.map((type) => {
const items = memories[type];
if (items.length === 0) return null;
return (
<Accordion
key={type}
expanded={expanded === type || expanded === 'all'}
onChange={(_, isExpanded) => setExpanded(isExpanded ? type : 'all')}
elevation={0}
sx={{
'&:before': { display: 'none' },
'& .MuiAccordionSummary-root': { minHeight: 32, px: 0 },
'& .MuiAccordionSummary-content': { my: 0, mx: 0 },
'& .MuiAccordionDetails-root': { px: 0, py: 0 },
}}
>
<AccordionSummary expandIcon={<ChevronDown size={14} />}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', width: '100%' }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: MEMORY_TYPE_COLORS[type], flexShrink: 0 }} />
<Typography variant="caption" sx={{ fontWeight: 600, fontSize: 11, color: 'text.primary' }}>
{MEMORY_TYPE_LABELS[type]}
</Typography>
<Chip
label={items.length}
size="small"
sx={{
ml: 'auto', mr: 0.5, height: 16, fontSize: 10,
bgcolor: 'action.hover', color: 'text.secondary',
'& .MuiChip-label': { px: 0.5 },
}}
/>
</Stack>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={0.5}>
{items.map((item) => (
<MemoryItemRow key={getId(item)} item={item} onDelete={handleDelete} />
))}
</Stack>
</AccordionDetails>
</Accordion>
);
})
)}
</Box>
</Box>
);
}
// ===== 子组件 =====
function MemoryItemRow({
item,
onDelete,
}: {
item: MemoryItem;
onDelete: (type: MemoryType, id: string) => void;
}): React.JSX.Element {
const type = item.type;
const createdAt = getCreatedAt(item);
const importance = item.importance ?? 0;
return (
<Box
sx={{
px: 1, py: 0.75, borderRadius: 1,
bgcolor: 'background.default', border: '1px solid', borderColor: 'divider',
'&:hover .delete-btn': { opacity: 1 },
}}
>
<Stack direction="row" spacing={0.5} sx={{ mb: 0.25, alignItems: 'center' }}>
<Chip
label={MEMORY_TYPE_LABELS[type]}
size="small"
sx={{
height: 14, fontSize: 9,
bgcolor: MEMORY_TYPE_COLORS[type] + '22',
color: MEMORY_TYPE_COLORS[type],
'& .MuiChip-label': { px: 0.5 },
}}
/>
{typeof item.importance === 'number' && (
<Chip
label={`I ${(item.importance ?? 0).toFixed(2)}`}
size="small"
sx={{
height: 14, fontSize: 9,
bgcolor: importanceColor(item.importance) + '22',
color: importanceColor(item.importance),
'& .MuiChip-label': { px: 0.5, fontFamily: 'monospace' },
}}
/>
)}
{createdAt > 0 && (
<Typography
variant="caption"
sx={{ ml: 'auto', fontSize: 9, color: 'text.disabled', fontFamily: 'monospace' }}
>
{formatTime(createdAt)}
</Typography>
)}
<IconButton
className="delete-btn"
size="small"
onClick={() => onDelete(type, getId(item))}
sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, '&:hover': { color: 'error.main' } }}
>
<Trash2 size={11} />
</IconButton>
</Stack>
<Typography variant="caption" sx={{ fontSize: 11, color: 'text.primary', display: 'block', lineHeight: 1.4, wordBreak: 'break-word' }}>
{truncate(item.content, 100)}
</Typography>
</Box>
);
}
function MemorySearchItem({
item,
onDelete,
}: {
item: SearchResult;
onDelete: (type: MemoryType, id: string) => void;
}): React.JSX.Element {
return (
<Box
sx={{
px: 1, py: 0.75, borderRadius: 1,
bgcolor: 'background.default', border: '1px solid', borderColor: 'divider',
'&:hover .delete-btn': { opacity: 1 },
}}
>
<Stack direction="row" spacing={0.5} sx={{ mb: 0.25, alignItems: 'center' }}>
<Chip
label={MEMORY_TYPE_LABELS[item.type]}
size="small"
sx={{
height: 14, fontSize: 9,
bgcolor: MEMORY_TYPE_COLORS[item.type] + '22',
color: MEMORY_TYPE_COLORS[item.type],
'& .MuiChip-label': { px: 0.5 },
}}
/>
<Chip
label={`R ${(item.relevanceScore ?? 0).toFixed(2)}`}
size="small"
sx={{
height: 14, fontSize: 9,
bgcolor: '#22d3ee22', color: '#22d3ee',
'& .MuiChip-label': { px: 0.5, fontFamily: 'monospace' },
}}
/>
<Chip
label={`I ${(item.importance ?? 0).toFixed(2)}`}
size="small"
sx={{
height: 14, fontSize: 9,
bgcolor: importanceColor(item.importance) + '22',
color: importanceColor(item.importance),
'& .MuiChip-label': { px: 0.5, fontFamily: 'monospace' },
}}
/>
{item.createdAt > 0 && (
<Typography variant="caption" sx={{ ml: 'auto', fontSize: 9, color: 'text.disabled', fontFamily: 'monospace' }}>
{formatTime(item.createdAt)}
</Typography>
)}
<IconButton
className="delete-btn"
size="small"
onClick={() => onDelete(item.type, item.id)}
sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, '&:hover': { color: 'error.main' } }}
>
<Trash2 size={11} />
</IconButton>
</Stack>
<Typography variant="caption" sx={{ fontSize: 11, color: 'text.primary', display: 'block', lineHeight: 1.4, wordBreak: 'break-word' }}>
{truncate(item.content, 100)}
</Typography>
</Box>
);
}
+95 -3
View File
@@ -211,11 +211,53 @@ function AgentSettings() {
}
function ToolsSettings() {
const [tools, setTools] = useState<Array<{ name: string; description: string; riskLevel: string; enabled: boolean }>>([]);
useEffect(() => { if (window.metona?.tools?.list) window.metona.tools.list().then((l) => setTools((l as MetonaToolInfo[]).map((t) => ({ name: t.name, description: t.description, riskLevel: t.riskLevel, enabled: t.enabled })))).catch((err) => { console.error('[SettingsModal]', err); }); }, []);
const handleToggle = (name: string, enabled: boolean) => { setTools((p) => p.map((t) => t.name === name ? { ...t, enabled } : t)); window.metona?.tools?.toggle(name, enabled).catch((err) => { console.error('[SettingsModal]', err); }); };
const [tools, setTools] = useState<Array<{ name: string; description: string; riskLevel: string; requiresPermission: boolean; enabled: boolean }>>([]);
const [autoExecList, setAutoExecList] = useState<string[]>([]);
const loadTools = () => {
if (window.metona?.tools?.list) {
window.metona.tools.list().then((l) => setTools((l as MetonaToolInfo[]).map((t) => ({
name: t.name, description: t.description, riskLevel: t.riskLevel,
requiresPermission: t.requiresPermission, enabled: t.enabled,
})))).catch((err) => { console.error('[SettingsModal]', err); });
}
};
const loadAutoExec = () => {
if (window.metona?.tool?.getAutoExecuteList) {
window.metona.tool.getAutoExecuteList().then((r) => {
if (r.success) setAutoExecList(r.data);
}).catch((err) => { console.error('[SettingsModal]', err); });
}
};
useEffect(() => { loadTools(); loadAutoExec(); }, []);
const handleToggle = (name: string, enabled: boolean) => {
setTools((p) => p.map((t) => t.name === name ? { ...t, enabled } : t));
window.metona?.tools?.toggle(name, enabled).catch((err) => { console.error('[SettingsModal]', err); });
};
// 设置/取消自动执行
const handleSetAutoExec = async (name: string, enabled: boolean) => {
if (!window.metona?.tool?.setAutoExecute) return;
const r = await window.metona.tool.setAutoExecute(name, enabled);
if (r.success) {
setAutoExecList((p) => enabled ? [...p, name] : p.filter((n) => n !== name));
}
};
const riskColors: Record<string, 'success' | 'info' | 'warning' | 'error'> = { safe: 'success', low: 'info', medium: 'warning', high: 'error' };
// 需要确认的工具(high/critical 或 requiresPermission
const needsConfirmTools = tools.filter((t) =>
t.riskLevel === 'high' || t.riskLevel === 'critical' || t.requiresPermission,
);
// 需要确认但未设为自动执行的工具
const pendingConfirmTools = needsConfirmTools.filter((t) => !autoExecList.includes(t.name));
// 已设为自动执行的工具详情
const autoExecToolDetails = autoExecList
.map((name) => tools.find((t) => t.name === name))
.filter((t): t is NonNullable<typeof t> => t !== undefined);
return (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}></Typography>
@@ -231,6 +273,56 @@ function ToolsSettings() {
</Stack>
</Stack>
))}
<Divider sx={{ my: 1 }} />
{/* ===== 自动执行工具管理 ===== */}
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}></Typography>
<Chip label={`${autoExecList.length}`} size="small" color={autoExecList.length > 0 ? 'success' : 'default'} variant="outlined" sx={{ height: 18, fontSize: 10 }} />
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary', lineHeight: 1.5 }}>
</Typography>
{/* 已自动执行的工具列表 */}
{autoExecToolDetails.length === 0 ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 2, color: 'text.disabled', fontStyle: 'italic' }}>
</Typography>
) : (
autoExecToolDetails.map((t) => (
<Stack key={t.name} direction="row" sx={{ py: 1, px: 1.5, borderRadius: 1.5, bgcolor: 'success.main', opacity: 0.8, justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', minWidth: 0, flex: 1 }}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>{t.name}</Typography>
<Chip label="自动" size="small" color="success" sx={{ height: 16, fontSize: 9 }} />
</Stack>
<Button size="small" color="warning" variant="outlined" sx={{ fontSize: 10, minWidth: 70 }} onClick={() => handleSetAutoExec(t.name, false)}>
</Button>
</Stack>
))
)}
{/* 可设为自动执行的工具(需要确认但未设置) */}
{pendingConfirmTools.length > 0 && (
<>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary', mt: 1 }}>
</Typography>
{pendingConfirmTools.map((t) => (
<Stack key={t.name} direction="row" sx={{ py: 1, px: 1.5, borderRadius: 1.5, bgcolor: 'secondary.main', border: '1px dashed', borderColor: 'divider', justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', minWidth: 0, flex: 1 }}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>{t.name}</Typography>
<Chip label={t.riskLevel.toUpperCase()} size="small" color={riskColors[t.riskLevel]} variant="outlined" sx={{ height: 16, fontSize: 9 }} />
</Stack>
<Button size="small" color="success" variant="contained" sx={{ fontSize: 10, minWidth: 70 }} onClick={() => handleSetAutoExec(t.name, true)}>
</Button>
</Stack>
))}
</>
)}
</Stack>
);
}
+378
View File
@@ -0,0 +1,378 @@
/**
* TaskList — 任务列表
*
* 显示当前会话的任务,支持新增、完成切换、删除、展开查看描述。
*/
import { useState, useEffect, useCallback } from 'react';
import {
Box, Typography, Stack, List, ListItem, ListItemIcon,
Checkbox, Chip, IconButton, TextField, Button, Select, MenuItem, Collapse,
} from '@mui/material';
import { ListChecks, Plus, Trash2, ChevronRight, Circle } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
import { formatTime } from '@renderer/lib/formatters';
// ===== 类型 =====
type TaskStatus = MetonaTask['status'];
type TaskPriority = MetonaTask['priority'];
// ===== 常量 =====
const STATUS_LABELS: Record<TaskStatus, string> = {
pending: '待处理',
in_progress: '进行中',
completed: '已完成',
blocked: '阻塞',
cancelled: '已取消',
};
const STATUS_COLORS: Record<TaskStatus, string> = {
pending: '#f59e0b',
in_progress: '#a855f7',
completed: '#22c55e',
blocked: '#ef4444',
cancelled: '#64748b',
};
const PRIORITY_LABELS: Record<TaskPriority, string> = {
low: '低',
medium: '中',
high: '高',
critical: '紧急',
};
const PRIORITY_COLORS: Record<TaskPriority, string> = {
low: '#64748b',
medium: '#3b82f6',
high: '#f97316',
critical: '#ef4444',
};
const PRIORITY_OPTIONS: TaskPriority[] = ['low', 'medium', 'high', 'critical'];
// ===== 主组件 =====
export function TaskList(): React.JSX.Element {
const sessionId = useAgentStore((s) => s.currentSessionId);
const [tasks, setTasks] = useState<MetonaTask[]>([]);
const [error, setError] = useState<string | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [newPriority, setNewPriority] = useState<TaskPriority>('medium');
const [expandedId, setExpandedId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const loadTasks = useCallback(async (sid?: string) => {
if (!window.metona?.tasks?.list) return;
try {
setError(null);
const res = await window.metona.tasks.list(sid);
if (!res.success) {
setError('加载任务失败');
return;
}
const list = (res.data ?? []).slice().sort((a, b) => {
if (a.order_idx !== b.order_idx) return a.order_idx - b.order_idx;
return a.created_at - b.created_at;
});
setTasks(list);
} catch (err) {
setError((err as Error).message ?? '加载任务失败');
}
}, []);
useEffect(() => {
loadTasks(sessionId ?? undefined);
}, [sessionId, loadTasks]);
const handleCreate = async () => {
if (!sessionId) {
setError('请先选择或创建会话');
return;
}
if (!newTitle.trim()) {
setError('任务标题不能为空');
return;
}
if (!window.metona?.tasks?.create) return;
setCreating(true);
setError(null);
try {
const res = await window.metona.tasks.create({
sessionId,
title: newTitle.trim(),
priority: newPriority,
});
if (!res.success) {
setError(res.error ?? '创建任务失败');
return;
}
setNewTitle('');
setNewPriority('medium');
setShowAddForm(false);
await loadTasks(sessionId);
} catch (err) {
setError((err as Error).message ?? '创建任务失败');
} finally {
setCreating(false);
}
};
const handleToggleComplete = async (task: MetonaTask) => {
if (!window.metona?.tasks?.update) return;
const nextStatus: TaskStatus = task.status === 'completed' ? 'pending' : 'completed';
setError(null);
try {
const res = await window.metona.tasks.update(task.id, { status: nextStatus });
if (!res.success) {
setError(res.error ?? '更新任务失败');
return;
}
await loadTasks(sessionId ?? undefined);
} catch (err) {
setError((err as Error).message ?? '更新任务失败');
}
};
const handleDelete = async (id: string) => {
if (!window.metona?.tasks?.delete) return;
setError(null);
try {
const res = await window.metona.tasks.delete(id);
if (!res.success) {
setError(res.error ?? '删除任务失败');
return;
}
if (expandedId === id) setExpandedId(null);
await loadTasks(sessionId ?? undefined);
} catch (err) {
setError((err as Error).message ?? '删除任务失败');
}
};
const toggleExpand = (id: string) => {
setExpandedId(expandedId === id ? null : id);
};
return (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flexShrink: 0, alignItems: 'center' }}>
<ListChecks size={14} style={{ color: '#22d3ee' }} />
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
</Typography>
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
{tasks.length}
</Typography>
<IconButton
size="small"
onClick={() => setShowAddForm(!showAddForm)}
sx={{ p: 0.25, color: 'primary.main', '&:hover': { bgcolor: 'action.hover' } }}
title="新增任务"
>
<Plus size={14} />
</IconButton>
</Stack>
{error && (
<Typography
variant="caption"
sx={{
mb: 1, px: 1, py: 0.5, fontSize: 10,
color: 'error.main', bgcolor: 'error.main' + '14',
borderRadius: 1, flexShrink: 0,
}}
>
{error}
</Typography>
)}
<Collapse in={showAddForm} sx={{ flexShrink: 0 }}>
<Box sx={{ mb: 1.5, p: 1, borderRadius: 1, bgcolor: 'background.default', border: '1px solid', borderColor: 'divider' }}>
<TextField
size="small"
fullWidth
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
placeholder="任务标题..."
sx={{ mb: 1, '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1 } }}
/>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Select
size="small"
value={newPriority}
onChange={(e) => setNewPriority(e.target.value as TaskPriority)}
sx={{ flex: 1, height: 28, fontSize: 11, '& .MuiSelect-select': { py: 0.5, px: 1 } }}
>
{PRIORITY_OPTIONS.map((p) => (
<MenuItem key={p} value={p} sx={{ fontSize: 11, py: 0.5 }}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Circle size={8} style={{ color: PRIORITY_COLORS[p] }} />
<Typography variant="caption" sx={{ fontSize: 11 }}>{PRIORITY_LABELS[p]}</Typography>
</Stack>
</MenuItem>
))}
</Select>
<Button
variant="contained"
size="small"
onClick={handleCreate}
disabled={creating || !newTitle.trim()}
sx={{ height: 28, fontSize: 11, minWidth: 60, textTransform: 'none' }}
>
{creating ? '...' : '创建'}
</Button>
<IconButton
size="small"
onClick={() => { setShowAddForm(false); setNewTitle(''); setNewPriority('medium'); }}
sx={{ p: 0.5, color: 'text.secondary' }}
>
<Trash2 size={12} />
</IconButton>
</Stack>
</Box>
</Collapse>
<Box sx={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{!sessionId ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}>
</Typography>
) : tasks.length === 0 ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}>
+
</Typography>
) : (
<List disablePadding dense>
{tasks.map((task) => (
<TaskItemRow
key={task.id}
task={task}
expanded={expandedId === task.id}
onToggleComplete={() => handleToggleComplete(task)}
onToggleExpand={() => toggleExpand(task.id)}
onDelete={() => handleDelete(task.id)}
/>
))}
</List>
)}
</Box>
</Box>
);
}
// ===== 子组件 =====
function TaskItemRow({
task,
expanded,
onToggleComplete,
onToggleExpand,
onDelete,
}: {
task: MetonaTask;
expanded: boolean;
onToggleComplete: () => void;
onToggleExpand: () => void;
onDelete: () => void;
}): React.JSX.Element {
const isCompleted = task.status === 'completed';
const statusColor = STATUS_COLORS[task.status];
const priorityColor = PRIORITY_COLORS[task.priority];
return (
<ListItem
disableGutters
disablePadding
sx={{
display: 'block',
px: 0.5, py: 0.25,
borderRadius: 1,
'&:hover': { bgcolor: 'action.hover' },
'&:hover .delete-btn': { opacity: 1 },
}}
>
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'flex-start' }}>
<ListItemIcon sx={{ minWidth: 20, mt: 0.25 }}>
<Checkbox
size="small"
checked={isCompleted}
onChange={onToggleComplete}
sx={{ p: 0.25, '& .MuiSvgIcon-root': { fontSize: 14 } }}
/>
</ListItemIcon>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
onClick={onToggleExpand}
sx={{
fontSize: 11, lineHeight: 1.3, cursor: 'pointer',
color: isCompleted ? 'text.disabled' : 'text.primary',
textDecoration: isCompleted ? 'line-through' : 'none',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
>
{task.title}
</Box>
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center', mt: 0.25, flexWrap: 'wrap' }}>
<Chip
label={STATUS_LABELS[task.status]}
size="small"
sx={{
height: 14, fontSize: 9,
bgcolor: statusColor + '22', color: statusColor,
'& .MuiChip-label': { px: 0.5 },
}}
/>
<Chip
label={PRIORITY_LABELS[task.priority]}
size="small"
sx={{
height: 14, fontSize: 9,
bgcolor: priorityColor + '22', color: priorityColor,
'& .MuiChip-label': { px: 0.5 },
}}
/>
<Typography
variant="caption"
component="span"
sx={{ fontSize: 9, color: 'text.disabled', fontFamily: 'monospace' }}
>
{formatTime(task.created_at)}
</Typography>
</Stack>
</Box>
<ChevronRight
size={12}
style={{
color: 'text.disabled',
transition: 'transform 150ms',
transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
cursor: 'pointer',
marginTop: 4,
}}
onClick={onToggleExpand}
/>
<IconButton
className="delete-btn"
size="small"
onClick={onDelete}
sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, mt: 0.25, '&:hover': { color: 'error.main' } }}
>
<Trash2 size={11} />
</IconButton>
</Stack>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<Box sx={{ pl: 3.5, pr: 1, py: 0.5, fontSize: 11, color: 'text.secondary', lineHeight: 1.4, wordBreak: 'break-word' }}>
{task.description ? task.description : (
<Typography variant="caption" sx={{ fontSize: 10, color: 'text.disabled', fontStyle: 'italic' }}>
</Typography>
)}
</Box>
</Collapse>
</ListItem>
);
}
+167 -28
View File
@@ -11,7 +11,7 @@
*/
import { useEffect, useRef } from 'react';
import { useAgentStore, type ToolCallInfo, type AgentStatus } from '@renderer/stores/agent-store';
import { useAgentStore, genMsgId, type ToolCallInfo, type AgentStatus } from '@renderer/stores/agent-store';
/**
* Agent 流式事件监听 Hook
@@ -40,6 +40,26 @@ export function useAgentStream(): void {
state?: string;
};
// 会话过滤:忽略非当前会话的事件(防止切换会话后旧事件污染)
if (data.sessionId && data.sessionId !== useAgentStore.getState().currentSessionId) return;
// H-6/C-4: runId 过滤 — 忽略旧 run 的事件(abort 后重发场景)
// 修复 abort 后旧 DONE 污染新 runcurrentRunId=null 时忽略终止事件
const currentState = useAgentStore.getState();
if (data.runId) {
if (currentState.currentRunId) {
// currentRunId 已设置:只接受匹配的事件
if (data.runId !== currentState.currentRunId) return;
} else {
// currentRunId 未设置:
// done/error 是终止事件 — currentRunId 为 null 说明已被 abort 或尚未开始,
// 忽略旧 run 的延迟终止事件,避免错误地 setStreaming(false)
if (data.type === 'done' || data.type === 'error') return;
// 首个活跃事件,设置 currentRunId
currentState.setCurrentRunId(data.runId);
}
}
// 每次都从 store 读取最新状态(避免闭包捕获过期快照)
const getStore = () => useAgentStore.getState();
@@ -47,6 +67,33 @@ export function useAgentStream(): void {
// 推理内容增量
case 'reasoning_delta':
if (data.delta) {
// C-1: 如果 delta 属于新迭代,先创建新卡片(不依赖 stateChange 到达顺序)
if (data.iteration != null) {
const msgs = getStore().messages;
const last = msgs[msgs.length - 1];
const needsNewCard = !last || last.role !== 'assistant' ||
(last.iteration != null && last.iteration !== data.iteration);
if (needsNewCard) {
getStore().addMessage({
id: genMsgId('assistant'),
role: 'assistant',
content: '',
reasoningContent: data.delta,
timestamp: Date.now(),
iteration: data.iteration,
});
if (data.iteration !== getStore().currentIteration) {
getStore().setCurrentIteration(data.iteration);
}
// 同步更新当前 Trace 步骤的 thought 字段
const ts = getStore().traceSteps;
const step = ts[ts.length - 1];
if (step) {
getStore().updateLastTraceStep({ thought: (step.thought ?? '') + data.delta });
}
break;
}
}
const messages = getStore().messages;
const lastMsg = messages[messages.length - 1];
if (lastMsg?.role === 'assistant') {
@@ -57,7 +104,7 @@ export function useAgentStream(): void {
} else {
// 还没有 assistant 消息,先创建一条(仅含思考内容)
getStore().addMessage({
id: `msg_${Date.now()}_assistant`,
id: genMsgId('assistant'),
role: 'assistant',
content: '',
reasoningContent: data.delta,
@@ -80,7 +127,27 @@ export function useAgentStream(): void {
// 文本增量
case 'text_delta':
if (data.delta) {
getStore().setStreaming(true);
// C-1: 如果 delta 属于新迭代,先创建新卡片(不依赖 stateChange 到达顺序)
if (data.iteration != null) {
const msgs = getStore().messages;
const last = msgs[msgs.length - 1];
const needsNewCard = !last || last.role !== 'assistant' ||
(last.iteration != null && last.iteration !== data.iteration);
if (needsNewCard) {
getStore().addMessage({
id: genMsgId('assistant'),
role: 'assistant',
content: data.delta,
timestamp: Date.now(),
iteration: data.iteration,
});
if (data.iteration !== getStore().currentIteration) {
getStore().setCurrentIteration(data.iteration);
}
break;
}
}
// isStreaming 已在 sendMessage 时设置为 true,无需每个 delta 重复设置
getStore().updateLastAssistantMessage(data.delta);
// 无 reasoning 模式下,将文本内容也记入 Trace thought(便于追踪)
@@ -97,6 +164,30 @@ export function useAgentStream(): void {
}
break;
// M-1: 工具调用增量(流式参数拼接)— 仅更新 UI 占位,完整调用由 tool_call_complete 处理
case 'tool_call_delta':
if (data.toolCallDelta) {
const msgs = getStore().messages;
const lastAssistant = msgs[msgs.length - 1];
const { index, name } = data.toolCallDelta;
// 如果最后一条 assistant 消息还没有该 index 的占位工具调用,添加一个 pending 占位
if (lastAssistant?.role === 'assistant' && name) {
const existing = (lastAssistant.toolCalls ?? []).find((_, i) => i === index);
if (!existing) {
const placeholder: ToolCallInfo = {
id: `tc_pending_${index}`,
name,
args: {},
status: 'pending',
};
getStore().updateMessage(lastAssistant.id, {
toolCalls: [...(lastAssistant.toolCalls ?? []), placeholder],
});
}
}
}
break;
// 工具调用完成
case 'tool_call_complete':
if (data.toolCall) {
@@ -107,13 +198,22 @@ export function useAgentStream(): void {
status: 'executing',
};
// 追加到当前 assistant 消息
// 追加到当前 assistant 消息(替换同 index 的 pending 占位)
const msgs = getStore().messages;
const lastAssistant = msgs[msgs.length - 1];
if (lastAssistant?.role === 'assistant') {
getStore().updateMessage(lastAssistant.id, {
toolCalls: [...(lastAssistant.toolCalls ?? []), tc],
});
const existingTcs = lastAssistant.toolCalls ?? [];
// 检查是否有同 index 的 pending 占位需要替换
const pendingIdx = existingTcs.findIndex((t) => t.id.startsWith('tc_pending_'));
if (pendingIdx >= 0) {
const updated = [...existingTcs];
updated[pendingIdx] = tc;
getStore().updateMessage(lastAssistant.id, { toolCalls: updated });
} else {
getStore().updateMessage(lastAssistant.id, {
toolCalls: [...existingTcs, tc],
});
}
}
// 更新当前 Trace 步骤(添加工具调用信息)
@@ -165,7 +265,8 @@ export function useAgentStream(): void {
}
: tc,
);
getStore().updateTraceStep(trace.iteration, { toolCalls: updatedTraceToolCalls });
// L-2: 按 ID 精确匹配 traceStep(避免 iteration 碰撞)
getStore().updateTraceStepById(trace.id, { toolCalls: updatedTraceToolCalls });
break;
}
}
@@ -197,7 +298,11 @@ export function useAgentStream(): void {
// 流结束
case 'done':
getStore().setStreaming(false);
getStore().setAgentStatus('idle');
getStore().setCurrentRunId(null);
// 不覆盖 error 状态 — error handler 已设置 agentStatus='error'
if (getStore().agentStatus !== 'error') {
getStore().setAgentStatus('idle');
}
// 标记最后一个 Trace 步骤为已完成
getStore().updateLastTraceStep({ completedAt: Date.now() });
getStore().saveTraceData();
@@ -206,10 +311,11 @@ export function useAgentStream(): void {
// 错误
case 'error':
getStore().setStreaming(false);
getStore().setCurrentRunId(null);
getStore().setAgentStatus('error');
getStore().updateLastTraceStep({ completedAt: Date.now() });
getStore().addMessage({
id: `msg_${Date.now()}_error`,
id: genMsgId('error'),
role: 'system',
content: `错误: ${data.error?.message ?? '未知错误'}`,
timestamp: Date.now(),
@@ -236,31 +342,60 @@ export function useAgentStream(): void {
state?: string;
previous?: string;
current?: string;
runId?: string;
};
const store = useAgentStore.getState();
// 会话过滤:忽略非当前会话的状态变化
if (data.sessionId && data.sessionId !== store.currentSessionId) return;
// H-6/C-4: runId 过滤 — 忽略旧 run 的状态变化
// 修复 abort 后旧 TERMINATED 污染:currentRunId=null 时只有 INIT 设置 currentRunId,其他忽略
if (data.runId) {
if (store.currentRunId) {
// currentRunId 已设置:只接受匹配的状态变化
if (data.runId !== store.currentRunId) return;
} else {
// currentRunId 未设置:
// 只有 INIT 是新 run 的第一个状态,设置 currentRunId
// 其他状态(如旧 run 的 TERMINATED)忽略,避免污染
if (data.state === 'INIT') {
store.setCurrentRunId(data.runId);
} else {
return;
}
}
}
// --- 迭代号更新 + 新消息卡片创建 ---
if (data.iteration != null && data.iteration !== store.currentIteration) {
const prevIteration = store.currentIteration;
store.setCurrentIteration(data.iteration);
// M-7: 统一所有迭代的卡片创建路径(包括首轮)
// 迭代号增大 → 新一轮 ReAct 迭代开始,创建新的 assistant 消息卡片
// 仅当上一轮迭代已结束(prevIteration > 0)且上一条 assistant 消息有内容时
if (data.iteration > prevIteration && prevIteration > 0) {
// 如果最后一条已经是当前迭代的 assistant 卡片(delta handler 提前创建),不重复
if (data.iteration > prevIteration) {
const messages = store.messages;
const lastMsg = messages[messages.length - 1];
if (
lastMsg?.role === 'assistant' &&
(lastMsg.content || lastMsg.toolCalls?.length || lastMsg.reasoningContent)
) {
store.addMessage({
id: `msg_${Date.now()}_assistant`,
role: 'assistant',
content: '',
timestamp: Date.now(),
iteration: data.iteration,
});
const isAlreadyCurrentIteration = lastMsg?.role === 'assistant' && lastMsg.iteration === data.iteration;
if (!isAlreadyCurrentIteration) {
// 首轮不要求上一轮有内容(上一轮是用户消息)
const isFirstIteration = prevIteration === 0;
const prevHasContent = lastMsg?.role === 'assistant' &&
(lastMsg.content || lastMsg.toolCalls?.length || lastMsg.reasoningContent);
if (isFirstIteration || prevHasContent) {
store.addMessage({
id: genMsgId('assistant'),
role: 'assistant',
content: '',
timestamp: Date.now(),
iteration: data.iteration,
});
}
}
}
}
@@ -303,13 +438,14 @@ export function useAgentStream(): void {
}
// --- Agent 状态映射 ---
// INIT 不映射 — 避免 engine 的 transitionTo(INIT) 覆盖 sendMessage 设置的 'thinking' 状态
const stateToStatus: Record<string, AgentStatus> = {
THINKING: 'thinking',
EXECUTING: 'executing',
PARSING: 'thinking',
OBSERVING: 'thinking',
OBSERVING: 'thinking', // L-4: 观察工具结果更接近"思考"而非"执行"
REFLECTING: 'thinking',
COMPRESSING: 'thinking',
INIT: 'thinking',
TERMINATED: 'idle',
};
const newStatus = stateToStatus[data.state];
@@ -327,9 +463,12 @@ export function useAgentStream(): void {
if (!window.metona?.agent?.onProviderSwitched) return;
const unsubscribe = window.metona.agent.onProviderSwitched((data: unknown) => {
const { from, to, reason } = data as { from?: string; to?: string; reason?: string };
useAgentStore.getState().addMessage({
id: `msg_${Date.now()}_system`,
const { from, to, reason, sessionId } = data as { from?: string; to?: string; reason?: string; sessionId?: string };
// L-3: 仅在当前会话中显示 Provider 切换消息
const store = useAgentStore.getState();
if (sessionId && store.currentSessionId && sessionId !== store.currentSessionId) return;
store.addMessage({
id: genMsgId('system'),
role: 'system',
content: `Provider 已切换: ${from ?? '未知'}${to ?? '未知'}${reason ? ` (${reason})` : ''}`,
timestamp: Date.now(),
+1
View File
@@ -53,6 +53,7 @@ export function formatDuration(ms: number): string {
// ===== 截断文本(< 20 行纯函数,允许自写)=====
export function truncate(text: string, maxLength: number): string {
if (typeof text !== 'string' || text.length === 0) return '';
if (text.length <= maxLength) return text;
return text.slice(0, maxLength - 1) + '…';
}
-181
View File
@@ -1,181 +0,0 @@
// DEPRECATED: Components use window.metona directly. This file is kept for future type-safe refactoring.
/**
* IPC Client — 渲染进程 IPC 封装
*
* 为渲染进程提供类型安全的 IPC 调用封装。
* 所有调用通过 window.metona 桥接层(由 preload.ts contextBridge 暴露)。
*
* @see electron/preload.ts — contextBridge 安全暴露
* @see src/types/global.d.ts — 类型声明
*/
// DEPRECATED: Components use window.metona directly. This file is kept for future type-safe refactoring.
// Types are now globally available via src/types/global.d.ts (script file, no import needed).
// ===== 桥接层访问 =====
function getBridge(): MetonaBridge {
if (!window.metona) {
throw new Error('Metona bridge not available. Ensure preload script is loaded.');
}
return window.metona;
}
// ===== Agent API =====
export const agentAPI = {
/**
* 发送用户消息到 Agent
*/
sendMessage(message: { role: 'user'; content: string; timestamp: number }, sessionId: string): Promise<{ success: boolean }> {
return getBridge().agent.sendMessage(message, sessionId);
},
/**
* 监听流式事件
* @returns 取消监听函数
*/
onStreamEvent(callback: (event: MetonaStreamEventData) => void): () => void {
return getBridge().agent.onStreamEvent(callback);
},
/**
* 监听状态变化
* @returns 取消监听函数
*/
onStateChange(callback: (state: { sessionId: string; state: string }) => void): () => void {
return getBridge().agent.onStateChange(callback);
},
/**
* 中断当前会话
*/
abortSession(sessionId: string): Promise<{ success: boolean }> {
return getBridge().agent.abortSession(sessionId);
},
/**
* 监听 Provider 切换通知
* @returns 取消监听函数
*/
onProviderSwitched(callback: (data: { from?: string; to?: string; reason?: string }) => void): () => void {
return getBridge().agent.onProviderSwitched(callback);
},
};
// ===== Sessions API =====
export const sessionsAPI = {
list(): Promise<MetonaSessionInfo[]> {
return getBridge().sessions.list();
},
create(title?: string): Promise<MetonaSessionInfo> {
return getBridge().sessions.create(title);
},
rename(sessionId: string, title: string): Promise<{ success: boolean }> {
return getBridge().sessions.rename(sessionId, title);
},
delete(sessionId: string): Promise<{ success: boolean }> {
return getBridge().sessions.delete(sessionId);
},
getMessages(sessionId: string): Promise<Array<{
id: string;
role: string;
content: string;
reasoningContent?: string;
toolCalls?: unknown[];
timestamp: number;
}>> {
return getBridge().sessions.getMessages(sessionId);
},
pin(sessionId: string, pinned: boolean): Promise<{ success: boolean }> {
return getBridge().sessions.pin(sessionId, pinned);
},
};
// ===== MCP API =====
export const mcpAPI = {
listServers(): Promise<MetonaMCPServerStatus[]> {
return getBridge().mcp.listServers();
},
addServer(config: { name: string; transport: 'stdio' | 'sse'; command?: string; args?: string[]; url?: string; enabled: boolean }): Promise<{ success: boolean }> {
return getBridge().mcp.addServer(config);
},
removeServer(name: string): Promise<{ success: boolean }> {
return getBridge().mcp.removeServer(name);
},
toggleServer(name: string, enabled: boolean): Promise<{ success: boolean }> {
return getBridge().mcp.toggleServer(name, enabled);
},
};
// ===== Memory API =====
export const memoryAPI = {
search(query: string, options?: {
topK?: number;
type?: 'episodic' | 'semantic' | 'working';
minImportance?: number;
}): Promise<MetonaMemorySearchResult[]> {
return getBridge().memory.search(query, options);
},
};
// ===== Config API =====
export const configAPI = {
get<T = unknown>(key: string): Promise<T | null> {
return getBridge().config.get(key) as Promise<T | null>;
},
set(key: string, value: unknown): Promise<{ success: boolean }> {
return getBridge().config.set(key, value);
},
};
// ===== App API =====
export const appAPI = {
getVersion(): Promise<string> {
return getBridge().app.getVersion();
},
getAppDataPath(): Promise<string> {
return getBridge().app.getAppDataPath();
},
openExternal(url: string): Promise<void> {
return getBridge().app.openExternal(url);
},
showItemInFolder(path: string): Promise<void> {
return getBridge().app.showItemInFolder(path);
},
};
// ===== Toast API =====
export const toastAPI = {
/**
* 监听主进程 Toast 通知桥接
* @returns 取消监听函数
*/
onShow(callback: (data: {
type: 'success' | 'error' | 'warning' | 'info';
message: string;
options?: Record<string, unknown>;
}) => void): () => void {
return getBridge().toast.onShow(callback);
},
};
+28 -15
View File
@@ -7,6 +7,10 @@
import { create } from 'zustand';
import { useSessionStore } from './session-store';
// L-1: 消息 ID 防碰撞计数器
let _msgIdCounter = 0;
export const genMsgId = (role: string) => `msg_${Date.now()}_${role}_${_msgIdCounter++}`;
// ===== 消息类型 =====
export type MessageRole = 'user' | 'assistant' | 'system' | 'tool';
@@ -85,7 +89,9 @@ interface AgentState {
// 流式
isStreaming: boolean;
streamingContent: string;
// Run 标识(用于过滤旧流事件)
currentRunId: string | null;
// Provider
provider: string;
@@ -101,10 +107,10 @@ interface AgentState {
updateLastAssistantMessage: (delta: string) => void;
setAgentStatus: (status: AgentStatus) => void;
setStreaming: (streaming: boolean) => void;
appendStreamingContent: (delta: string) => void;
clearStreamingContent: () => void;
setCurrentRunId: (runId: string | null) => void;
addTraceStep: (step: TraceStep) => void;
updateTraceStep: (iteration: number, updates: Partial<TraceStep>) => void;
updateTraceStepById: (id: string, updates: Partial<TraceStep>) => void;
updateLastTraceStep: (updates: Partial<TraceStep>) => void;
updateTokenUsage: (usage: Partial<TokenUsage>) => void;
setProvider: (provider: string, model: string) => void;
@@ -125,7 +131,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
traceSteps: [],
isStreaming: false,
streamingContent: '',
currentRunId: null,
provider: '',
model: '',
contextWindow: 0,
@@ -133,7 +139,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
// ===== Actions =====
setCurrentSession: (id) => {
set({ currentSessionId: id, messages: [], traceSteps: [], currentIteration: 0, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 } });
set({ currentSessionId: id, messages: [], traceSteps: [], currentIteration: 0, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, agentStatus: 'idle', isStreaming: false, currentRunId: null });
// 从数据库加载该会话的消息
if (id && window.metona?.sessions?.getMessages) {
@@ -208,7 +214,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
console.error('[AgentStore]', 'Failed to create session:', err);
set({ agentStatus: 'error', isStreaming: false });
get().addMessage({
id: `msg_${Date.now()}_error`,
id: genMsgId('error'),
role: 'system',
content: `错误: 无法创建会话 — ${(err as Error).message}`,
timestamp: Date.now(),
@@ -218,7 +224,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
}
const userMessage: ChatMessage = {
id: `msg_${Date.now()}_user`,
id: genMsgId('user'),
role: 'user',
content, // 用户可见内容(纯文本)
timestamp: Date.now(),
@@ -229,7 +235,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
messages: [...s.messages, userMessage],
agentStatus: 'thinking',
isStreaming: true,
streamingContent: '',
currentRunId: null, // 将在首个 streamEvent 中由 runId 设置
currentIteration: 0,
}));
@@ -283,7 +289,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
console.error('[AgentStore]', 'IPC sendMessage failed:', err);
set({ agentStatus: 'error', isStreaming: false });
get().addMessage({
id: `msg_${Date.now()}_error`,
id: genMsgId('error'),
role: 'system',
content: `错误: 消息发送失败 — ${(err as Error).message}`,
timestamp: Date.now(),
@@ -302,7 +308,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
messages[lastIdx] = { ...lastMsg, content: lastMsg.content + delta };
} else {
messages.push({
id: `msg_${Date.now()}_assistant`,
id: genMsgId('assistant'),
role: 'assistant',
content: delta,
timestamp: Date.now(),
@@ -317,10 +323,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
setStreaming: (streaming) => set({ isStreaming: streaming }),
appendStreamingContent: (delta) =>
set((s) => ({ streamingContent: s.streamingContent + delta })),
clearStreamingContent: () => set({ streamingContent: '' }),
setCurrentRunId: (runId) => set({ currentRunId: runId }),
addTraceStep: (step) =>
set((s) => ({ traceSteps: [...s.traceSteps, step] })),
@@ -332,6 +335,14 @@ export const useAgentStore = create<AgentState>((set, get) => ({
),
})),
// L-2: 按 ID 精确匹配 traceStep(避免 iteration 碰撞)
updateTraceStepById: (id, updates) =>
set((s) => ({
traceSteps: s.traceSteps.map((t) =>
t.id === id ? { ...t, ...updates } : t,
),
})),
updateLastTraceStep: (updates) =>
set((s) => {
if (s.traceSteps.length === 0) return s;
@@ -376,11 +387,13 @@ export const useAgentStore = create<AgentState>((set, get) => ({
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
traceSteps: [],
isStreaming: false,
streamingContent: '',
currentRunId: null,
}),
abort: () => {
const sessionId = get().currentSessionId;
// 不清除 currentRunId — 保留用于过滤旧 run 的延迟终止事件(DONE/TERMINATED
// 旧 run 的 DONE 到达时会匹配 currentRunId,处理后清除;新 run 的 INIT 会覆盖
set({ agentStatus: 'idle', isStreaming: false });
if (sessionId && window.metona?.agent?.abortSession) {
window.metona.agent.abortSession(sessionId).catch((err) => { console.error('[AgentStore]', err); });
+97 -1
View File
@@ -59,7 +59,13 @@ interface MetonaAgentAPI {
/** 发送 MetonaMessageIR 类型)+ sessionId 到主进程 */
sendMessage: (message: MetonaMessageInput, sessionId: string) => Promise<{ success: boolean }>;
onStreamEvent: (callback: (event: MetonaStreamEventData) => void) => () => void;
onStateChange: (callback: (state: { sessionId: string; state: string }) => void) => () => void;
onStateChange: (callback: (state: {
sessionId: string | null;
iteration: number;
state: string;
previous: string;
current: string;
}) => void) => () => void;
abortSession: (sessionId: string) => Promise<{ success: boolean }>;
onProviderSwitched: (callback: (data: { from?: string; to?: string; reason?: string }) => void) => () => void;
}
@@ -93,6 +99,7 @@ interface MetonaSessionsAPI {
timestamp: number;
}>>;
pin: (sessionId: string, pinned: boolean) => Promise<{ success: boolean }>;
archive: (sessionId: string, archived: boolean) => Promise<{ success: boolean }>;
deleteMessage: (messageId: string) => Promise<{ success: boolean }>;
clearMessages: (sessionId: string) => Promise<{ success: boolean }>;
saveTrace: (sessionId: string, data: { traceSteps: unknown[]; tokenUsage: unknown }) => Promise<{ success: boolean }>;
@@ -142,6 +149,12 @@ interface MetonaMemoryAPI {
type?: 'episodic' | 'semantic' | 'working';
minImportance?: number;
}) => Promise<MetonaMemorySearchResult[]>;
listAll: (options?: { type?: string; limit?: number }) => Promise<{
success: boolean;
data?: { episodic?: unknown[]; semantic?: unknown[]; working?: unknown[] };
error?: string;
}>;
delete: (type: string, id: string) => Promise<{ success: boolean; error?: string }>;
}
// ===== Config API =====
@@ -210,6 +223,86 @@ interface MetonaDataAPI {
clearAuditLogs: () => Promise<{ success: boolean; error?: string }>;
}
// ===== v0.2.0: Tasks API =====
interface MetonaTask {
id: string;
session_id: string;
title: string;
description: string;
status: 'pending' | 'in_progress' | 'completed' | 'blocked' | 'cancelled';
priority: 'low' | 'medium' | 'high' | 'critical';
parent_id: string | null;
assigned_to: string | null;
order_idx: number;
created_at: number;
updated_at: number;
completed_at: number | null;
}
interface MetonaTasksAPI {
list: (sessionId?: string) => Promise<{ success: boolean; data: MetonaTask[] }>;
create: (data: {
sessionId: string;
title: string;
description?: string;
priority?: string;
parentId?: string;
}) => Promise<{ success: boolean; id?: string; error?: string }>;
update: (
id: string,
updates: {
title?: string;
description?: string;
status?: string;
priority?: string;
assignedTo?: string;
},
) => Promise<{ success: boolean; error?: string }>;
delete: (id: string) => Promise<{ success: boolean; error?: string }>;
}
// ===== v0.2.0: Audit API =====
interface MetonaAuditVerifyResult {
success: boolean;
valid: boolean;
totalRecords: number;
verifiedRecords: number;
tamperedId: number | null;
error?: string;
}
interface MetonaAuditAPI {
verifyChain: () => Promise<MetonaAuditVerifyResult>;
query: (filters?: { sessionId?: string; eventType?: string; limit?: number }) =>
Promise<{ success: boolean; data?: unknown[]; error?: string }>;
}
// ===== v0.2.0: Tool Confirmation API =====
interface MetonaConfirmationRequest {
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
riskLevel: string;
reason: string;
}
interface MetonaToolAPI {
onConfirmationRequest: (callback: (request: MetonaConfirmationRequest) => void) => () => void;
sendConfirmationResponse: (response: {
toolCallId: string;
approved: boolean;
remember: boolean;
autoExecute?: boolean;
}) => void;
/** 设置/取消工具的持久化自动执行(跨会话不再询问) */
setAutoExecute: (toolName: string, enabled: boolean) => Promise<{ success: boolean; error?: string }>;
/** 获取已设置为自动执行的工具列表 */
getAutoExecuteList: () => Promise<{ success: boolean; data: string[] }>;
}
// ===== Bridge 接口 =====
interface MetonaBridge {
@@ -223,6 +316,9 @@ interface MetonaBridge {
tools: MetonaToolsAPI;
data: MetonaDataAPI;
searxng: MetonaSearXNGAPI;
tasks: MetonaTasksAPI;
audit: MetonaAuditAPI;
tool: MetonaToolAPI;
}
// ===== 全局 Window 增强 =====