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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 污染新 run:currentRunId=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(),
|
||||
|
||||
@@ -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) + '…';
|
||||
}
|
||||
|
||||
@@ -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
@@ -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); });
|
||||
|
||||
Vendored
+97
-1
@@ -59,7 +59,13 @@ interface MetonaAgentAPI {
|
||||
/** 发送 MetonaMessage(IR 类型)+ 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 增强 =====
|
||||
|
||||
Reference in New Issue
Block a user