feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m25s
CI / 全量测试 (Electron ABI, experimental) (push) Failing after 5m19s
CI / 产物编译验证 (push) Successful in 10m3s

工程化(从零到一):
- 新增 Gitea Actions CI(debian-latest):类型检查 + Lint + 单元测试 + 产物编译验证
- 新增 husky + lint-staged 预提交钩子(lint-staged + typecheck 门禁)
- 移除坏脚本 test:e2e(无 Playwright 配置必失败);prebuild 改用内置 fs.rmSync
- 依赖清理:移除死依赖 sql.js(2MB)/@playwright/test,@types/shell-quote 移至 devDependencies

安全加固:
- PolicyEngine 频率限制按会话隔离(多会话并发不再互抢配额)
- ConfirmationHook 拒绝记忆加 10 分钟 TTL + 恢复询问入口(新增 2 个 IPC 通道)
- Windows run_command 白名单工具(git/node/npm/npx/pnpm/yarn/tsc)改走 cmd.exe /c + 参数数组执行,收窄 shell 注入面
- web_search 四引擎 HTML 解析迁移 node-html-parser(结构化主层 + 正则降级)

缺陷修复(测试驱动发现):
- mapError 大小写缺陷:网络错误码永远落入 UNKNOWN 无法触发重试
- 搜狗解析器自我过滤:相对链接补全后又被 sogou.com 过滤导致结果全丢
- 百度复合类名重复收录:class="result c-container" 被双重匹配

测试补齐(113 → 194 用例):
- 新增 5 个测试文件:sse-stream / base-adapter / confirmation-hook / ipc-agent 编排链路 / web-search 解析器
- 覆盖 sendMessage 全分支、SSE 流解析、错误映射、确认钩子竞态/超时/批量审批

体验升级:
- OutputValidator 验证结果可见化(VALIDATION 流事件 → 聊天流提示卡)
- SettingsModal 巨型组件拆分(1503 行 → 10 个文件,可独立维护)
- MessageList 接入 react-virtuoso 真虚拟滚动(千条消息恒定开销)
- MCP 新增 streamable HTTP 传输支持(SDK 内置传输 + DB 迁移 6 + UI 双模式)
This commit is contained in:
2026-08-21 13:58:48 +08:00
parent 2230bcec3f
commit 49c9b25538
41 changed files with 6254 additions and 2608 deletions
+434 -348
View File
@@ -18,11 +18,28 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
Dialog, DialogTitle, DialogContent, DialogActions,
Button, Typography, Box, Chip, Alert, FormControlLabel, Checkbox,
Accordion, AccordionSummary, AccordionDetails,
LinearProgress, List, ListItem, ListItemIcon, ListItemText,
IconButton, Tooltip, Divider,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Box,
Chip,
Alert,
FormControlLabel,
Checkbox,
Accordion,
AccordionSummary,
AccordionDetails,
LinearProgress,
List,
ListItem,
ListItemIcon,
ListItemText,
IconButton,
Tooltip,
Divider,
} from '@mui/material';
import { ShieldAlert, ChevronDown, Timer, RefreshCw } from 'lucide-react';
@@ -64,36 +81,65 @@ export function ConfirmationDialog(): React.JSX.Element | null {
const [initialMs, setInitialMs] = useState<number>(0);
// #40 修复: autoExecute 永久自动执行风险高,勾选时弹出二次确认避免误点击
const [confirmAutoExecute, setConfirmAutoExecute] = useState(false);
// v0.4.1: 本会话内记住"拒绝"的工具(带 TTL,可手动恢复询问)
const [rememberedDenials, setRememberedDenials] = useState<
Array<{ toolName: string; expiresInSeconds: number }>
>([]);
// v0.4.1: 拉取被拒工具列表(弹框打开/刷新时同步)
const refreshRememberedDenials = useCallback(async () => {
try {
const result = await window.metona?.tool?.getRememberedDenials();
setRememberedDenials(result?.success ? (result.data ?? []) : []);
} catch {
setRememberedDenials([]);
}
}, []);
// v0.4.1: 重置某个工具的拒绝记忆(恢复询问)
const handleResetDenial = useCallback(async (toolName: string) => {
try {
await window.metona?.tool?.resetRememberedDenial(toolName);
setRememberedDenials((prev) => prev.filter((d) => d.toolName !== toolName));
} catch {
// 重置失败保持现状,TTL 到期后仍会自动恢复
}
}, []);
// ===== 弹框打开时主动拉取已积压的 pending 请求 =====
// 解决:并行工具触发的多个 IPC 事件可能在本组件 mount 前已到达,
// 或在 React state 更新批次中被覆盖。主动拉取确保不丢请求。
const refreshPending = useCallback(async (mergeNew?: ConfirmationRequest) => {
try {
const result = await window.metona?.tool?.getPendingConfirmations();
const pendingList: ConfirmationRequest[] = result?.success ? result.data : [];
// 合并新到的 IPC 请求(若 pending 快照已包含则去重)
const merged = [...pendingList];
if (mergeNew) {
const exists = merged.some((r) => r.toolCallId === mergeNew.toolCallId);
if (!exists) merged.push(mergeNew);
}
// 按 toolCallId 去重(防止 refresh 与 IPC 事件重复添加)
const dedupedMap = new Map<string, ConfirmationRequest>();
for (const r of merged) dedupedMap.set(r.toolCallId, r);
const deduped = Array.from(dedupedMap.values());
const refreshPending = useCallback(
async (mergeNew?: ConfirmationRequest) => {
try {
const result = await window.metona?.tool?.getPendingConfirmations();
const pendingList: ConfirmationRequest[] = result?.success ? result.data : [];
// 合并新到的 IPC 请求(若 pending 快照已包含则去重)
const merged = [...pendingList];
if (mergeNew) {
const exists = merged.some((r) => r.toolCallId === mergeNew.toolCallId);
if (!exists) merged.push(mergeNew);
}
// 按 toolCallId 去重(防止 refresh 与 IPC 事件重复添加)
const dedupedMap = new Map<string, ConfirmationRequest>();
for (const r of merged) dedupedMap.set(r.toolCallId, r);
const deduped = Array.from(dedupedMap.values());
setRequests(deduped);
// 默认全选
setSelectedIds(new Set(deduped.map((r) => r.toolCallId)));
} catch {
// 拉取失败时回退到只显示新到的请求
if (mergeNew) {
setRequests([mergeNew]);
setSelectedIds(new Set([mergeNew.toolCallId]));
setRequests(deduped);
// 默认全选
setSelectedIds(new Set(deduped.map((r) => r.toolCallId)));
} catch {
// 拉取失败时回退到只显示新到的请求
if (mergeNew) {
setRequests([mergeNew]);
setSelectedIds(new Set([mergeNew.toolCallId]));
}
}
}
}, []);
// v0.4.1: 弹框打开时同步拉取被拒工具列表(展示恢复询问入口)
void refreshRememberedDenials();
},
[refreshRememberedDenials],
);
useEffect(() => {
// 监听来自主进程的确认请求(通过 preload 暴露的 metona.tool API
@@ -207,44 +253,45 @@ export function ConfirmationDialog(): React.JSX.Element | null {
setSelectedIds(new Set());
}, []);
const handleRespond = useCallback((approved: boolean, onlySelected: boolean = true) => {
// 决定要处理的 toolCallId 列表
// onlySelected=true: 仅处理勾选项(用于"批准选中")
// onlySelected=false: 处理全部请求(用于"拒绝全部" / "批准全部"
const targetIds = onlySelected
? Array.from(selectedIds)
: requests.map((r) => r.toolCallId);
const handleRespond = useCallback(
(approved: boolean, onlySelected: boolean = true) => {
// 决定要处理的 toolCallId 列表
// onlySelected=true: 处理勾选项(用于"批准选中"
// onlySelected=false: 处理全部请求(用于"拒绝全部" / "批准全部"
const targetIds = onlySelected ? Array.from(selectedIds) : requests.map((r) => r.toolCallId);
if (targetIds.length === 0) return;
if (targetIds.length === 0) return;
// 通过批量 IPC 通道发送响应
// autoExecute 仅在批准时生效(拒绝时无需持久化)
window.metona?.tool?.sendConfirmationResponseBatch({
toolCallIds: targetIds,
approved,
remember,
autoExecute: approved && autoExecute,
});
// 通过批量 IPC 通道发送响应
// autoExecute 仅在批准时生效(拒绝时无需持久化)
window.metona?.tool?.sendConfirmationResponseBatch({
toolCallIds: targetIds,
approved,
remember,
autoExecute: approved && autoExecute,
});
if (onlySelected) {
// "批准选中":只移除已处理的,保留未选中的 pending
// 防止未选中的 pending 被清空后丢失(用户看不到,会超时失败)
const targetSet = new Set(targetIds);
const remaining = requests.filter((r) => !targetSet.has(r.toolCallId));
setRequests(remaining);
// 更新选中项:清空已处理的,保留未选中的(但实际上未选中的本来就不在 selectedIds 中)
setSelectedIds(new Set());
// 主动刷新后端 pending 列表,拉取可能新到达的请求
// 用 setTimeout 避免与 setRequests 同批次,确保后端已处理完批量响应
if (remaining.length === 0) {
setTimeout(() => refreshPending(), 50);
if (onlySelected) {
// "批准选中":只移除已处理的,保留未选中的 pending
// 防止未选中的 pending 被清空后丢失(用户看不到,会超时失败)
const targetSet = new Set(targetIds);
const remaining = requests.filter((r) => !targetSet.has(r.toolCallId));
setRequests(remaining);
// 更新选中项:清空已处理的,保留未选中的(但实际上未选中的本来就不在 selectedIds 中)
setSelectedIds(new Set());
// 主动刷新后端 pending 列表,拉取可能新到达的请求
// 用 setTimeout 避免与 setRequests 同批次,确保后端已处理完批量响应
if (remaining.length === 0) {
setTimeout(() => refreshPending(), 50);
}
} else {
// "拒绝全部 / 批准全部":清空所有
setRequests([]);
setSelectedIds(new Set());
}
} else {
// "拒绝全部 / 批准全部":清空所有
setRequests([]);
setSelectedIds(new Set());
}
}, [requests, selectedIds, remember, autoExecute, refreshPending]);
},
[requests, selectedIds, remember, autoExecute, refreshPending],
);
if (requests.length === 0) return null;
@@ -269,9 +316,8 @@ export function ConfirmationDialog(): React.JSX.Element | null {
const isUrgent = remainingSec <= 10 && remainingSec > 0;
const isExpired = remainingMs <= 0 && earliestExpires != null;
const totalMs = initialMs || remainingMs;
const progressPercent = totalMs > 0
? Math.max(0, Math.min(100, (remainingMs / totalMs) * 100))
: 100;
const progressPercent =
totalMs > 0 ? Math.max(0, Math.min(100, (remainingMs / totalMs) * 100)) : 100;
// 格式化参数显示
const formatArg = (key: string, value: unknown): string => {
@@ -288,303 +334,340 @@ export function ConfirmationDialog(): React.JSX.Element | null {
};
// 综合风险文案
const batchReason = totalCount > 1
? `检测到 ${totalCount} 个并行工具调用请求确认(涉及 ${grouped.length} 个不同工具)。综合最高风险:${highestRisk}`
: requests[0]?.reason ?? 'Agent 正在请求执行工具';
const batchReason =
totalCount > 1
? `检测到 ${totalCount} 个并行工具调用请求确认(涉及 ${grouped.length} 个不同工具)。综合最高风险:${highestRisk}`
: (requests[0]?.reason ?? 'Agent 正在请求执行工具');
return (
<>
<Dialog
open={requests.length > 0}
onClose={(_, reason) => {
// 超时时禁止通过外部点击/ESC 关闭(与"拒绝全部"按钮 disabled 一致)
// 防止超时后用户误触关闭,导致后端 pending 状态不一致
if (isExpired) return;
// 审查修复: 内层二次确认 Dialog 打开时,外层禁用 ESC/backdrop 关闭
// 防止 ESC 穿透到外层导致意外拒绝所有工具执行
if (confirmAutoExecute) return;
// 只允许"拒绝全部"语义的关闭方式(点击外部 / ESC)
// reason: 'backdropClick' | 'escapeKeyDown' | 'closeButtonClick'
if (reason === 'backdropClick' || reason === 'escapeKeyDown') {
handleRespond(false, false);
}
}}
maxWidth="md"
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">
{totalCount > 1 && `${totalCount} 个并行请求)`}
</Typography>
<Box sx={{ flex: 1 }} />
<Tooltip title="刷新 pending 列表">
<IconButton size="small" onClick={() => refreshPending()}>
<RefreshCw size={16} />
</IconButton>
</Tooltip>
</DialogTitle>
<DialogContent>
<Alert severity={riskColor === 'error' ? 'error' : 'warning'} sx={{ mb: 2 }}>
{batchReason}
</Alert>
{earliestExpires && !isExpired && (
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Timer
size={16}
color={isUrgent ? 'var(--mui-palette-error-main)' : 'var(--mui-palette-text-secondary)'}
style={isUrgent ? { animation: 'metona-pulse 1s ease-in-out infinite' } : undefined}
/>
<Typography
variant="caption"
sx={{
color: isUrgent ? 'error.main' : 'text.secondary',
fontWeight: isUrgent ? 700 : 500,
minWidth: 80,
animation: isUrgent ? 'metona-pulse 1s ease-in-out infinite' : 'none',
'@keyframes metona-pulse': {
'0%, 100%': { opacity: 1 },
'50%': { opacity: 0.4 },
},
}}
>
{totalCount > 1 ? '最早过期 ' : '剩余 '}{remainingSec}s
</Typography>
<LinearProgress
variant="determinate"
value={progressPercent}
color={isUrgent ? 'error' : 'primary'}
sx={{ flex: 1, height: 6, borderRadius: 3 }}
/>
</Box>
)}
{/* 选择控制条 */}
<Box sx={{ mb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Button size="small" onClick={handleSelectAll} disabled={allSelected}>
</Button>
<Button size="small" onClick={handleDeselectAll} disabled={selectedCount === 0}>
</Button>
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
{selectedCount} / {totalCount}
<Dialog
open={requests.length > 0}
onClose={(_, reason) => {
// 超时时禁止通过外部点击/ESC 关闭(与"拒绝全部"按钮 disabled 一致)
// 防止超时后用户误触关闭,导致后端 pending 状态不一致
if (isExpired) return;
// 审查修复: 内层二次确认 Dialog 打开时,外层禁用 ESC/backdrop 关闭
// 防止 ESC 穿透到外层导致意外拒绝所有工具执行
if (confirmAutoExecute) return;
// 只允许"拒绝全部"语义的关闭方式(点击外部 / ESC)
// reason: 'backdropClick' | 'escapeKeyDown' | 'closeButtonClick'
if (reason === 'backdropClick' || reason === 'escapeKeyDown') {
handleRespond(false, false);
}
}}
maxWidth="md"
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">
{totalCount > 1 && `${totalCount} 个并行请求)`}
</Typography>
</Box>
<Box sx={{ flex: 1 }} />
<Tooltip title="刷新 pending 列表">
<IconButton size="small" onClick={() => refreshPending()}>
<RefreshCw size={16} />
</IconButton>
</Tooltip>
</DialogTitle>
<Divider sx={{ mb: 1 }} />
<DialogContent>
<Alert severity={riskColor === 'error' ? 'error' : 'warning'} sx={{ mb: 2 }}>
{batchReason}
</Alert>
{/* 分组列表 */}
<List sx={{ maxHeight: 400, overflow: 'auto', py: 0 }}>
{grouped.map((group) => {
const groupIds = group.requests.map((r) => r.toolCallId);
const groupAllSelected = groupIds.every((id) => selectedIds.has(id));
const groupSomeSelected = groupIds.some((id) => selectedIds.has(id));
const groupRiskColor = RISK_COLORS[group.riskLevel] ?? 'default';
const isMulti = group.requests.length > 1;
{/* v0.4.1: 本会话内被记住拒绝的工具 — 提供恢复询问入口(拒绝记忆 10 分钟后自动过期) */}
{rememberedDenials.length > 0 && (
<Alert severity="info" sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
{Math.ceil(rememberedDenials[0].expiresInSeconds / 60)}{' '}
:
</Typography>
{rememberedDenials.map((d) => (
<Chip
key={d.toolName}
label={`${d.toolName} — 重新询问`}
size="small"
color="primary"
variant="outlined"
onClick={() => handleResetDenial(d.toolName)}
sx={{ cursor: 'pointer' }}
/>
))}
</Box>
</Alert>
)}
return (
<Accordion
key={group.toolName}
defaultExpanded
sx={{ bgcolor: 'background.default', mb: 0.5 }}
{earliestExpires && !isExpired && (
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Timer
size={16}
color={
isUrgent ? 'var(--mui-palette-error-main)' : 'var(--mui-palette-text-secondary)'
}
style={isUrgent ? { animation: 'metona-pulse 1s ease-in-out infinite' } : undefined}
/>
<Typography
variant="caption"
sx={{
color: isUrgent ? 'error.main' : 'text.secondary',
fontWeight: isUrgent ? 700 : 500,
minWidth: 80,
animation: isUrgent ? 'metona-pulse 1s ease-in-out infinite' : 'none',
'@keyframes metona-pulse': {
'0%, 100%': { opacity: 1 },
'50%': { opacity: 0.4 },
},
}}
>
<AccordionSummary expandIcon={<ChevronDown size={16} />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%', pr: 1 }}>
<Checkbox
size="small"
checked={groupAllSelected}
indeterminate={!groupAllSelected && groupSomeSelected}
onChange={(e) => {
e.stopPropagation();
handleToggleGroupSelect(group);
}}
onClick={(e) => e.stopPropagation()}
/>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{group.toolName}
</Typography>
{isMulti && (
{totalCount > 1 ? '最早过期 ' : '剩余 '}
{remainingSec}s
</Typography>
<LinearProgress
variant="determinate"
value={progressPercent}
color={isUrgent ? 'error' : 'primary'}
sx={{ flex: 1, height: 6, borderRadius: 3 }}
/>
</Box>
)}
{/* 选择控制条 */}
<Box sx={{ mb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Button size="small" onClick={handleSelectAll} disabled={allSelected}>
</Button>
<Button size="small" onClick={handleDeselectAll} disabled={selectedCount === 0}>
</Button>
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
{selectedCount} / {totalCount}
</Typography>
</Box>
<Divider sx={{ mb: 1 }} />
{/* 分组列表 */}
<List sx={{ maxHeight: 400, overflow: 'auto', py: 0 }}>
{grouped.map((group) => {
const groupIds = group.requests.map((r) => r.toolCallId);
const groupAllSelected = groupIds.every((id) => selectedIds.has(id));
const groupSomeSelected = groupIds.some((id) => selectedIds.has(id));
const groupRiskColor = RISK_COLORS[group.riskLevel] ?? 'default';
const isMulti = group.requests.length > 1;
return (
<Accordion
key={group.toolName}
defaultExpanded
sx={{ bgcolor: 'background.default', mb: 0.5 }}
>
<AccordionSummary expandIcon={<ChevronDown size={16} />}>
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%', pr: 1 }}
>
<Checkbox
size="small"
checked={groupAllSelected}
indeterminate={!groupAllSelected && groupSomeSelected}
onChange={(e) => {
e.stopPropagation();
handleToggleGroupSelect(group);
}}
onClick={(e) => e.stopPropagation()}
/>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{group.toolName}
</Typography>
{isMulti && (
<Chip
label={`×${group.requests.length}`}
size="small"
color="primary"
variant="outlined"
sx={{ height: 20, fontSize: 11 }}
/>
)}
<Chip
label={`×${group.requests.length}`}
label={group.riskLevel}
color={groupRiskColor}
size="small"
color="primary"
variant="outlined"
sx={{ height: 20, fontSize: 11 }}
/>
)}
<Chip
label={group.riskLevel}
color={groupRiskColor}
size="small"
sx={{ height: 20, fontSize: 11 }}
/>
</Box>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0 }}>
{group.requests.map((req, idx) => {
const isSelected = selectedIds.has(req.toolCallId);
return (
<ListItem
key={req.toolCallId}
sx={{
py: 0.5,
bgcolor: isSelected ? 'action.selected' : 'transparent',
borderRadius: 1,
}}
secondaryAction={
isMulti ? (
<Typography variant="caption" color="text.secondary">
#{idx + 1}
</Typography>
) : undefined
}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<Checkbox
size="small"
checked={isSelected}
onChange={() => handleToggleSelect(req.toolCallId)}
/>
</ListItemIcon>
<ListItemText
primary={
<Box
component="pre"
sx={{
fontFamily: 'monospace',
fontSize: 11,
color: 'text.primary',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
maxHeight: 200,
overflow: 'auto',
}}
>
{Object.entries(req.args).map(([key, value]) => (
<Box key={key} component="div" sx={{ mb: 0.5 }}>
<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>
))}
{Object.keys(req.args).length === 0 && (
<Typography variant="caption" color="text.secondary">
</Typography>
)}
</Box>
</Box>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0 }}>
{group.requests.map((req, idx) => {
const isSelected = selectedIds.has(req.toolCallId);
return (
<ListItem
key={req.toolCallId}
sx={{
py: 0.5,
bgcolor: isSelected ? 'action.selected' : 'transparent',
borderRadius: 1,
}}
secondaryAction={
isMulti ? (
<Typography variant="caption" color="text.secondary">
#{idx + 1}
</Typography>
) : undefined
}
/>
</ListItem>
);
})}
</AccordionDetails>
</Accordion>
);
})}
</List>
>
<ListItemIcon sx={{ minWidth: 36 }}>
<Checkbox
size="small"
checked={isSelected}
onChange={() => handleToggleSelect(req.toolCallId)}
/>
</ListItemIcon>
<ListItemText
primary={
<Box
component="pre"
sx={{
fontFamily: 'monospace',
fontSize: 11,
color: 'text.primary',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
maxHeight: 200,
overflow: 'auto',
}}
>
{Object.entries(req.args).map(([key, value]) => (
<Box key={key} component="div" sx={{ mb: 0.5 }}>
<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>
))}
{Object.keys(req.args).length === 0 && (
<Typography variant="caption" color="text.secondary">
</Typography>
)}
</Box>
}
/>
</ListItem>
);
})}
</AccordionDetails>
</Accordion>
);
})}
</List>
<Divider sx={{ my: 1 }} />
<Divider sx={{ my: 1 }} />
<FormControlLabel
control={
<Checkbox
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
size="small"
/>
}
label={
<Typography variant="caption" color="text.secondary">
</Typography>
}
/>
<FormControlLabel
control={
<Checkbox
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
size="small"
/>
}
label={
<Typography variant="caption" color="text.secondary">
</Typography>
}
/>
<FormControlLabel
control={
<Checkbox
checked={autoExecute}
onChange={(e) => {
// #40 修复: autoExecute 永久自动执行风险高,勾选时弹出二次确认避免误点击
if (e.target.checked) {
setConfirmAutoExecute(true);
} else {
setAutoExecute(false);
}
}}
size="small"
/>
}
label={
<Typography variant="caption" color="text.secondary">
{selectedCount > 0 && autoExecute && (
<Box component="span" sx={{ color: 'warning.main', ml: 1 }}>
· {new Set(Array.from(selectedIds).map((id) => requests.find((r) => r.toolCallId === id)?.toolName).filter(Boolean) as string[]).size}
</Box>
)}
</Typography>
}
/>
</DialogContent>
<FormControlLabel
control={
<Checkbox
checked={autoExecute}
onChange={(e) => {
// #40 修复: autoExecute 永久自动执行风险高,勾选时弹出二次确认避免误点击
if (e.target.checked) {
setConfirmAutoExecute(true);
} else {
setAutoExecute(false);
}
}}
size="small"
/>
}
label={
<Typography variant="caption" color="text.secondary">
{selectedCount > 0 && autoExecute && (
<Box component="span" sx={{ color: 'warning.main', ml: 1 }}>
· {' '}
{
new Set(
Array.from(selectedIds)
.map((id) => requests.find((r) => r.toolCallId === id)?.toolName)
.filter(Boolean) as string[],
).size
}{' '}
</Box>
)}
</Typography>
}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button
onClick={() => handleRespond(false, false)}
color="error"
variant="outlined"
size="small"
disabled={isExpired}
>
({totalCount})
</Button>
<Button
onClick={() => handleRespond(true, false)}
color="success"
variant="outlined"
size="small"
disabled={isExpired || allSelected}
title={allSelected ? '已全部选中,请使用"批准选中"' : '批准全部请求'}
>
</Button>
<Button
onClick={() => handleRespond(true, true)}
color="success"
variant="contained"
size="small"
autoFocus
disabled={isExpired || selectedCount === 0}
>
{isExpired
? '已超时'
: selectedCount === totalCount
? `确认执行 (${selectedCount})`
: `批准选中 (${selectedCount})`}
</Button>
</DialogActions>
</Dialog>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button
onClick={() => handleRespond(false, false)}
color="error"
variant="outlined"
size="small"
disabled={isExpired}
>
({totalCount})
</Button>
<Button
onClick={() => handleRespond(true, false)}
color="success"
variant="outlined"
size="small"
disabled={isExpired || allSelected}
title={allSelected ? '已全部选中,请使用"批准选中"' : '批准全部请求'}
>
</Button>
<Button
onClick={() => handleRespond(true, true)}
color="success"
variant="contained"
size="small"
autoFocus
disabled={isExpired || selectedCount === 0}
>
{isExpired
? '已超时'
: selectedCount === totalCount
? `确认执行 (${selectedCount})`
: `批准选中 (${selectedCount})`}
</Button>
</DialogActions>
</Dialog>
{/* #40 修复: autoExecute 二次确认 Dialog — 避免误点击导致永久自动执行 */}
{/* 审查修复: MUI v9 移除了 disableEscapeKeyDown 顶层 prop,改为在 onClose 中按 reason 拦截 escapeKeyDown
@@ -603,14 +686,17 @@ export function ConfirmationDialog(): React.JSX.Element | null {
<DialogTitle sx={{ fontSize: 14 }}></DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
</Typography>
<Typography variant="body2" sx={{ mt: 1, color: 'warning.main', fontWeight: 600 }}>
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmAutoExecute(false)} color="inherit"></Button>
<Button onClick={() => setConfirmAutoExecute(false)} color="inherit">
</Button>
<Button
onClick={() => {
setAutoExecute(true);
+71 -94
View File
@@ -1,8 +1,18 @@
/**
* MessageList — 消息列表容器
* MessageList — 消息列表容器v0.4.1: react-virtuoso 真虚拟滚动)
*
* v0.4.1 重构:content-visibility 准虚拟滚动升级为 react-virtuoso 真虚拟滚动。
* - 千条消息会话:屏幕外 DOM 节点不再挂载(此前仅跳过渲染,节点仍全量存在)
* - 新消息滚动:followOutput(数组追加时生效)
* - 流式跟随(复查补充): followOutput 只响应"数组长度变化",流式 delta 更新
* 最后一条消息内容时高度增长不会自动滚底 — 用 atBottomStateChange 跟踪底部
* 状态 + 流式期间 200ms 定时滚底兜底,仅当用户处于底部时才跟随
* (用户上翻查看历史时不打断)
* - 动态高度:Markdown/代码块/工具卡片高度变化由 Virtuoso 自动测量
*/
import { useEffect, useRef } from 'react';
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
import { Box, Typography } from '@mui/material';
import { useAgentStore } from '@renderer/stores/agent-store';
import { MessageItem } from './MessageItem';
@@ -11,114 +21,81 @@ import { StreamingIndicator } from './StreamingIndicator';
export function MessageList(): React.JSX.Element {
const messages = useAgentStore((s) => s.messages);
const isStreaming = useAgentStore((s) => s.isStreaming);
const messagesEndRef = useRef<HTMLDivElement>(null);
const currentSessionId = useAgentStore((s) => s.currentSessionId);
// F2: 滚动节流优化
// 问题:原实现用 setTimeout(80) debounce + 'smooth',高频 delta 时 trailing 永不触发,
// 且 'smooth' 在长列表上触发主线程布局动画,加剧卡顿。
// 方案:
// - 流式时:100ms 节流 + trailing 兜底 + 'auto' 行为(同步布局,无动画占主线程)
// - 非流式时:立即 'smooth' 滚动(新消息发送/接收完成)
// - 用 rAF 同步到下一帧,与 React 渲染合并,避免一帧内多次布局
const lastScrollRef = useRef(0);
const trailingTimerRef = useRef<number | null>(null);
const rafRef = useRef<number | null>(null);
const virtuosoRef = useRef<VirtuosoHandle>(null);
// 用户是否处于列表底部(离开底部=上翻查看历史,此时流式输出不强制拉底)
const atBottomRef = useRef(true);
// 流式期间的滚动跟随兜底(复查修复)
// followOutput 只在 data 数组追加时触发;流式 delta 增高最后一条消息时需主动跟随。
// 仅当用户在底部时执行 scrollToIndex,保证上翻浏览不被打断。
useEffect(() => {
const now = performance.now();
const elapsed = now - lastScrollRef.current;
// 调度一次 rAF 滚动(自动取消上一次挂起的 rAF)
const doScroll = (behavior: ScrollBehavior) => {
lastScrollRef.current = performance.now();
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
messagesEndRef.current?.scrollIntoView({ behavior });
});
};
// 非流式或首次进入:立即平滑滚动(新消息出现)
if (!isStreaming || lastScrollRef.current === 0) {
if (trailingTimerRef.current !== null) {
clearTimeout(trailingTimerRef.current);
trailingTimerRef.current = null;
if (!isStreaming) return;
const timer = window.setInterval(() => {
if (atBottomRef.current) {
virtuosoRef.current?.scrollToIndex({ index: 'LAST', align: 'end', behavior: 'auto' });
}
doScroll('smooth');
return;
}
// 流式中:100ms 节流 + trailing 兜底
if (elapsed >= 100) {
// 已过节流窗口,立即执行
if (trailingTimerRef.current !== null) {
clearTimeout(trailingTimerRef.current);
trailingTimerRef.current = null;
}
doScroll('auto');
} else if (trailingTimerRef.current === null) {
// 在节流窗口内,安排 trailing 滚动(保证最后一次 delta 后到底)
const remaining = 100 - elapsed;
trailingTimerRef.current = window.setTimeout(() => {
trailingTimerRef.current = null;
doScroll('auto');
}, remaining);
}
}, [messages, isStreaming]);
// 组件卸载时清理所有挂起的 timer 和 rAF
useEffect(() => {
return () => {
if (trailingTimerRef.current !== null) {
clearTimeout(trailingTimerRef.current);
trailingTimerRef.current = null;
}
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, []);
}, 200);
return () => window.clearInterval(timer);
}, [isStreaming]);
if (messages.length === 0 && !isStreaming) {
return (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box sx={{ textAlign: 'center', animation: 'fadeIn 200ms ease-out' }}>
<Box component="img" src="./logo.png" alt="Metona" sx={{ width: 80, height: 80, mx: 'auto', mb: 2.5, borderRadius: 2 }} />
<Typography variant="h5" sx={{ color: 'text.primary', mb: 0.5, fontWeight: 700 }}>MetonaAI Desktop</Typography>
<Typography variant="body1" sx={{ color: 'text.secondary' }}> AI Agent </Typography>
<Typography variant="body2" sx={{ mt: 2, display: 'block', opacity: 0.6 }}>Agent · Provider </Typography>
<Box
component="img"
src="./logo.png"
alt="Metona"
sx={{ width: 80, height: 80, mx: 'auto', mb: 2.5, borderRadius: 2 }}
/>
<Typography variant="h5" sx={{ color: 'text.primary', mb: 0.5, fontWeight: 700 }}>
MetonaAI Desktop
</Typography>
<Typography variant="body1" sx={{ color: 'text.secondary' }}>
AI Agent
</Typography>
<Typography variant="body2" sx={{ mt: 2, display: 'block', opacity: 0.6 }}>
Agent · Provider
</Typography>
</Box>
</Box>
);
}
return (
// F12: GPU 加速 — transform: translateZ(0) 将滚动容器提升为合成层
// 滚动时由合成器线程处理,避免主线程重绘叠加卡顿
// 风险评估:已确认 chat 目录内无 position: fixed 元素;
// ContextMenu 用 MUI Portal 渲染到 document.body,不受 transform 影响
<Box sx={{ flex: 1, overflowY: 'auto', px: 2, py: 3, transform: 'translateZ(0)' }}>
<Box sx={{ maxWidth: 768, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 3 }}>
{messages.map((msg, i) => (
// F4: content-visibility 准虚拟滚动
// 浏览器原生支持,不可见区域跳过布局和绘制(DOM 节点保留但渲染开销 O(1))。
// 配合 F1 memo(跳过 re-render)+ F3 流式纯文本,历史消息开销降至最低。
// contain-intrinsic-size 提供估算高度,避免滚动时高度抖动。
// 注:如后续需真正虚拟滚动(移除 DOM 节点),可升级到 react-virtuoso。
<Box
key={msg.id}
sx={{
'content-visibility': 'auto',
'contain-intrinsic-size': 'auto 300px',
}}
>
<MessageItem message={msg} isLast={i === messages.length - 1} isStreaming={isStreaming} />
<Virtuoso
ref={virtuosoRef}
// key=sessionId: 切换会话时强制重新挂载,使 initialTopMostItemIndex 重新生效
// (定位到新会话的最后一条消息;同会话内 messages 变化不触发 remount
key={currentSessionId ?? 'no-session'}
style={{ flex: 1, minHeight: 0 }}
data={messages}
// 初始定位到最后一条(切换会话加载历史时直接到最新消息)
initialTopMostItemIndex={Math.max(0, messages.length - 1)}
// 新消息追加时跟随(流式中同步,非流式平滑)
followOutput={isStreaming ? 'auto' : 'smooth'}
// 跟踪底部状态:供流式跟随兜底定时器判断(上翻时暂停跟随)
atBottomStateChange={(atBottom) => {
atBottomRef.current = atBottom;
}}
itemContent={(index, msg) => (
<Box sx={{ maxWidth: 768, mx: 'auto', px: 2, pt: index === 0 ? 3 : 1.5, pb: 1.5 }}>
<MessageItem
message={msg}
isLast={index === messages.length - 1}
isStreaming={isStreaming}
/>
</Box>
)}
components={{
Footer: () => (
<Box sx={{ maxWidth: 768, mx: 'auto', px: 2, pb: 3 }}>
<StreamingIndicator />
</Box>
))}
<StreamingIndicator />
<div ref={messagesEndRef} />
</Box>
</Box>
),
}}
/>
);
}
+112
View File
@@ -0,0 +1,112 @@
/**
* AgentSettings — Agent 配置 Tab
*
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
* 功能:迭代次数 / 总超时 / 工具超时 / 确认超时 / 思考模式配置。
*/
import {
TextField,
Select,
MenuItem,
Stack,
Typography,
Checkbox,
FormControlLabel,
InputLabel,
FormControl,
} from '@mui/material';
import { useConfig } from './useConfig';
export function AgentSettings() {
const [maxIter, setMaxIter] = useConfig('agent.maxIterations', 20);
const [timeout, setTimeout_] = useConfig('agent.totalTimeoutMs', 600000);
const [thinking, setThinking] = useConfig('agent.enableThinking', true);
const [thinkingEffort, setThinkingEffort] = useConfig('agent.thinkingEffort', 'high');
const [confirmTimeout, setConfirmTimeout] = useConfig('agent.confirmationTimeoutMs', 120000);
const [toolExecTimeout, setToolExecTimeout] = useConfig('agent.toolExecutionTimeoutMs', 120000);
const MAX_ITER_OPTIONS = [10, 20, 50, 85, 128, 256, 512];
return (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
Agent
</Typography>
<FormControl size="small">
<InputLabel></InputLabel>
<Select
value={maxIter}
label="最大迭代次数"
onChange={(e) => setMaxIter(e.target.value as number)}
>
{MAX_ITER_OPTIONS.map((n) => (
<MenuItem key={n} value={n}>
{n}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
size="small"
label="总超时(秒)"
type="number"
value={timeout / 1000}
onChange={(e) => {
const v = Number(e.target.value);
if (v >= 120 && v <= 3600) setTimeout_(v * 1000);
}}
slotProps={{ htmlInput: { min: 120, max: 3600, step: 30 } }}
/>
<TextField
size="small"
label="工具执行超时(秒)"
type="number"
value={toolExecTimeout / 1000}
onChange={(e) => {
const v = Number(e.target.value);
if (v >= 10 && v <= 600) setToolExecTimeout(v * 1000);
}}
slotProps={{ htmlInput: { min: 10, max: 600, step: 10 } }}
helperText="单个工具执行的最大时长,超时自动终止(10~600 秒)"
/>
<TextField
size="small"
label="工具确认超时(秒)"
type="number"
value={confirmTimeout / 1000}
onChange={(e) => {
const v = Number(e.target.value);
if (v >= 30 && v <= 600) setConfirmTimeout(v * 1000);
}}
slotProps={{ htmlInput: { min: 30, max: 600, step: 10 } }}
helperText="用户未响应工具确认时,超时自动视为拒绝(30~600 秒)"
/>
<FormControlLabel
control={
<Checkbox
checked={thinking}
onChange={(e) => setThinking(e.target.checked)}
size="small"
/>
}
label={<Typography variant="body2"></Typography>}
/>
{thinking && (
<FormControl size="small">
<InputLabel></InputLabel>
<Select
value={thinkingEffort}
label="思考强度"
onChange={(e) => setThinkingEffort(e.target.value)}
>
<MenuItem value="low">Low</MenuItem>
<MenuItem value="medium">Medium</MenuItem>
<MenuItem value="high">High</MenuItem>
<MenuItem value="max">Max</MenuItem>
</Select>
</FormControl>
)}
</Stack>
);
}
@@ -0,0 +1,42 @@
/**
* AppearanceSettings — 外观设置 Tab
*
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
* 功能:主题切换(深色/浅色/跟随系统)。
*/
import { Button, Stack, Typography, Box } from '@mui/material';
import type { ThemeMode } from '@renderer/stores/ui-store';
export function AppearanceSettings({
theme,
setTheme,
}: {
theme: ThemeMode;
setTheme: (t: ThemeMode) => void;
}) {
return (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
</Typography>
<Box>
<Typography variant="caption" sx={{ color: 'text.secondary', mb: 1, display: 'block' }}>
</Typography>
<Stack direction="row" spacing={1}>
{(['dark', 'light', 'auto'] as ThemeMode[]).map((t) => (
<Button
key={t}
variant={theme === t ? 'contained' : 'outlined'}
size="small"
onClick={() => setTheme(t)}
>
{t === 'dark' ? '深色' : t === 'light' ? '浅色' : '跟随系统'}
</Button>
))}
</Stack>
</Box>
</Stack>
);
}
+494
View File
@@ -0,0 +1,494 @@
/**
* LLMSettings — LLM 配置 Tab
*
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
* 功能:主 Provider / 故障转移 Provider / 上下文窗口配置,批量保存。
*/
import { useState, useEffect } from 'react';
import {
Button,
TextField,
Select,
MenuItem,
Stack,
Typography,
Divider,
InputLabel,
FormControl,
IconButton,
CircularProgress,
} from '@mui/material';
import { Eye, EyeOff } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
import { PROVIDER_LABELS } from '@renderer/lib/constants';
import { PROVIDER_URLS } from './useConfig';
export function LLMSettings() {
// 改为本地 state + Save 按钮统一提交,避免 onChange 实时落库导致:
// 1. 改 Base URL 时被回滚卡死(useConfig seqRef 机制与连续输入冲突)
// 2. 每按一个字符就触发一次 IPC + DB + reloadAdapter,浪费且会打断输入
// 3. 错误提示笼统(不指向具体字段)
const [provider, setProvider] = useState<string>('');
const [model, setModel] = useState<string>('');
const [apiKey, setApiKey] = useState<string>('');
const [baseURL, setBaseURL] = useState<string>('');
const [numCtx, setNumCtx] = useState<number | null>(null);
// v0.3.1: DeepSeek/Agnes/MiMo contextWindow 可配置(不再写死)
const [dsCtxWindow, setDsCtxWindow] = useState<number>(1000000);
const [agnesCtxWindow, setAgnesCtxWindow] = useState<number>(1000000);
const [mimoCtxWindow, setMimoCtxWindow] = useState<number>(1000000);
// P3: OpenAI/Anthropic contextWindow
const [oaCtxWindow, setOaCtxWindow] = useState<number>(128000);
const [anthropicCtxWindow, setAnthropicCtxWindow] = useState<number>(200000);
// P1: 故障转移 Provider 配置
const [fbProvider, setFbProvider] = useState<string>('');
const [fbModel, setFbModel] = useState<string>('');
const [fbApiKey, setFbApiKey] = useState<string>('');
const [fbBaseURL, setFbBaseURL] = useState<string>('');
const [showKey, setShowKey] = useState(false);
const [showFbKey, setShowFbKey] = useState(false);
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
// 初始化:一次性加载所有 LLM 配置字段
useEffect(() => {
let cancelled = false;
const load = async () => {
if (!window.metona?.config?.get) {
setLoaded(true);
return;
}
try {
const results = await Promise.all([
window.metona.config.get('llm.provider'),
window.metona.config.get('llm.model'),
window.metona.config.get('llm.apiKey'),
window.metona.config.get('llm.baseURL'),
window.metona.config.get('ollama.numCtx'),
window.metona.config.get('deepseek.contextWindow'),
window.metona.config.get('agnes.contextWindow'),
window.metona.config.get('mimo.contextWindow'),
window.metona.config.get('openai.contextWindow'),
window.metona.config.get('anthropic.contextWindow'),
// P1: 故障转移配置
window.metona.config.get('llm.fallbackProvider'),
window.metona.config.get('llm.fallbackModel'),
window.metona.config.get('llm.fallbackApiKey'),
window.metona.config.get('llm.fallbackBaseURL'),
]);
if (cancelled) return;
const [p, m, k, u, nc, ds, ag, mi, oa, an, fbp, fbm, fbk, fbu] = results;
setProvider((p as string) ?? '');
setModel((m as string) ?? '');
setApiKey((k as string) ?? '');
setBaseURL((u as string) ?? '');
setNumCtx((nc as number | null) ?? null);
if (typeof ds === 'number' && ds > 0) setDsCtxWindow(ds);
if (typeof ag === 'number' && ag > 0) setAgnesCtxWindow(ag);
if (typeof mi === 'number' && mi > 0) setMimoCtxWindow(mi);
if (typeof oa === 'number' && oa > 0) setOaCtxWindow(oa);
if (typeof an === 'number' && an > 0) setAnthropicCtxWindow(an);
setFbProvider((fbp as string) ?? '');
setFbModel((fbm as string) ?? '');
setFbApiKey((fbk as string) ?? '');
setFbBaseURL((fbu as string) ?? '');
} catch (err) {
console.error('[LLMSettings]', err);
} finally {
if (!cancelled) setLoaded(true);
}
};
load();
return () => {
cancelled = true;
};
}, []);
// 同步 Provider/Model 到 Agent Store(含 contextWindow
// 注意:仅同步运行时状态,不落库
useEffect(() => {
useAgentStore.getState().setProvider(provider, model);
}, [provider, model]);
// v0.3.1: contextWindow 变化时同步到 Agent Store(支持所有 Provider
useEffect(() => {
if (provider === 'ollama') {
if (numCtx != null && numCtx > 0) useAgentStore.setState({ contextWindow: numCtx });
} else if (provider === 'deepseek') {
if (dsCtxWindow != null && dsCtxWindow > 0)
useAgentStore.setState({ contextWindow: dsCtxWindow });
} else if (provider === 'agnes') {
if (agnesCtxWindow != null && agnesCtxWindow > 0)
useAgentStore.setState({ contextWindow: agnesCtxWindow });
} else if (provider === 'mimo') {
if (mimoCtxWindow != null && mimoCtxWindow > 0)
useAgentStore.setState({ contextWindow: mimoCtxWindow });
} else if (provider === 'openai') {
if (oaCtxWindow != null && oaCtxWindow > 0)
useAgentStore.setState({ contextWindow: oaCtxWindow });
} else if (provider === 'anthropic') {
if (anthropicCtxWindow != null && anthropicCtxWindow > 0)
useAgentStore.setState({ contextWindow: anthropicCtxWindow });
}
}, [
provider,
numCtx,
dsCtxWindow,
agnesCtxWindow,
mimoCtxWindow,
oaCtxWindow,
anthropicCtxWindow,
]);
// ===== 字段级 inline 校验 =====
// Base URL:非空时必须以 http:// 或 https:// 开头(避免漏写协议头导致发消息时报 Invalid URL
const urlError = !!baseURL && !/^https?:\/\/.+/.test(baseURL);
// Model:非空时不允许包含空格(OpenAI API 会把空格后的部分当作额外参数)
const modelHasSpace = !!model && /\s/.test(model);
// contextWindow / numCtx:必须为有限正数且不低于最小值
const numCtxError = numCtx != null && (!Number.isFinite(numCtx) || numCtx < 512);
const dsCtxError = !Number.isFinite(dsCtxWindow) || dsCtxWindow < 4096;
const agnesCtxError = !Number.isFinite(agnesCtxWindow) || agnesCtxWindow < 4096;
const mimoCtxError = !Number.isFinite(mimoCtxWindow) || mimoCtxWindow < 4096;
const oaCtxError = !Number.isFinite(oaCtxWindow) || oaCtxWindow < 4096;
const anthropicCtxError = !Number.isFinite(anthropicCtxWindow) || anthropicCtxWindow < 4096;
// 是否存在阻断保存的错误(API Key 为空只警告,不阻断 — 允许先填其他字段再回来填 key)
const hasBlockingError =
urlError ||
modelHasSpace ||
numCtxError ||
(provider === 'deepseek' && dsCtxError) ||
(provider === 'agnes' && agnesCtxError) ||
(provider === 'mimo' && mimoCtxError) ||
(provider === 'openai' && oaCtxError) ||
(provider === 'anthropic' && anthropicCtxError);
// 切换 Provider 时:清空 apiKey + 清空 model + 自动填充默认 URL
// 不同 Provider 的 key/model 互不通用,避免用旧值调用新 API 导致 401 / model not found
const handleProviderChange = (newProvider: string) => {
const oldProvider = provider;
setProvider(newProvider);
// 切换 Provider 时清空 apiKey(不同 Provider 的 key 格式不同)
if (oldProvider !== newProvider && apiKey) {
setApiKey('');
}
// 切换 Provider 时清空 model(不同 Provider 支持的模型名不同,如 deepseek-v4-pro 不适用于 ollama
if (oldProvider !== newProvider && model) {
setModel('');
}
// 自动填充默认 URL(仅在 URL 为空或与旧 provider 默认 URL 匹配时覆盖)
const currentUrl = baseURL.trim();
const isDefaultUrl = Object.values(PROVIDER_URLS).includes(currentUrl);
if (isDefaultUrl || !currentUrl) {
setBaseURL(PROVIDER_URLS[newProvider] ?? '');
}
};
const handleSave = async () => {
if (hasBlockingError) {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error('请修正表单中的错误后再保存'))
.catch(() => {});
return;
}
setSaving(true);
try {
const setBatch = window.metona?.config?.setBatch;
if (!setBatch) {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error('配置 API 不可用'))
.catch(() => {});
return;
}
// v0.3.9: 批量保存,避免串行保存中间态触发 reloadAdapter 失败
const entries: Array<{ key: string; value: unknown }> = [
{ key: 'llm.provider', value: provider },
{ key: 'llm.model', value: model },
{ key: 'llm.apiKey', value: apiKey },
{ key: 'llm.baseURL', value: baseURL },
{ key: 'ollama.numCtx', value: numCtx },
{ key: 'deepseek.contextWindow', value: dsCtxWindow },
{ key: 'agnes.contextWindow', value: agnesCtxWindow },
{ key: 'mimo.contextWindow', value: mimoCtxWindow },
{ key: 'openai.contextWindow', value: oaCtxWindow },
{ key: 'anthropic.contextWindow', value: anthropicCtxWindow },
// P1: 故障转移 Provider(主 Provider 失败时切换)
{ key: 'llm.fallbackProvider', value: fbProvider },
{ key: 'llm.fallbackModel', value: fbModel },
{ key: 'llm.fallbackApiKey', value: fbApiKey },
{ key: 'llm.fallbackBaseURL', value: fbBaseURL },
];
const r = await setBatch(entries);
if (r && !r.success) {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(r.error ?? '配置保存失败'))
.catch(() => {});
} else {
import('@metona-team/metona-toast')
.then((mod) => mod.default.success('配置已保存'))
.catch(() => {});
}
} catch (err) {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`保存失败:${(err as Error).message}`))
.catch(() => {});
} finally {
setSaving(false);
}
};
if (!loaded) {
return (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
LLM
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
...
</Typography>
</Stack>
);
}
const apiKeyEmpty = provider !== 'ollama' && !apiKey.trim();
return (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
LLM
</Typography>
<FormControl size="small">
<InputLabel>Provider</InputLabel>
<Select
value={provider}
label="Provider"
onChange={(e) => handleProviderChange(e.target.value)}
>
<MenuItem value="deepseek">DeepSeek</MenuItem>
<MenuItem value="agnes">Agnes AI</MenuItem>
<MenuItem value="mimo">MiMo ()</MenuItem>
<MenuItem value="ollama">Ollama ()</MenuItem>
<MenuItem value="openai">OpenAI</MenuItem>
<MenuItem value="anthropic">Anthropic</MenuItem>
</Select>
</FormControl>
<TextField
size="small"
label="API Base URL"
value={baseURL}
onChange={(e) => setBaseURL(e.target.value)}
placeholder="如 https://api.deepseek.com"
error={urlError}
helperText={urlError ? '需以 http:// 或 https:// 开头' : ' '}
/>
<TextField
size="small"
label="模型名称"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="如 deepseek-v4-pro、gpt-4o、claude-sonnet-4-5"
error={modelHasSpace}
helperText={modelHasSpace ? '模型名称不能包含空格' : ' '}
/>
{provider !== 'ollama' && (
<>
<TextField
size="small"
label="API Key"
type={showKey ? 'text' : 'password'}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
error={apiKeyEmpty}
helperText={
apiKeyEmpty
? `必填,未填 ${PROVIDER_LABELS[provider] ?? provider} 的 API Key 会 401`
: ' '
}
slotProps={{
input: {
endAdornment: (
<IconButton size="small" onClick={() => setShowKey(!showKey)}>
{showKey ? <EyeOff size={14} /> : <Eye size={14} />}
</IconButton>
),
},
}}
/>
</>
)}
{provider === 'ollama' && (
<TextField
size="small"
label="上下文长度 (num_ctx)"
type="number"
value={numCtx ?? ''}
onChange={(e) => {
const v = e.target.value;
setNumCtx(v === '' ? null : Number(v));
}}
placeholder="默认由模型决定(如 2048、4096、128000"
slotProps={{ htmlInput: { min: 512, step: 512 } }}
error={numCtxError}
helperText={numCtxError ? '最小值为 512' : ' '}
/>
)}
{/* v0.3.1: DeepSeek/Agnes 上下文窗口配置(用于 Engine 压缩判断和 UI 显示,不传给 API) */}
{provider === 'deepseek' && (
<TextField
size="small"
label="上下文窗口 (contextWindow)"
type="number"
value={dsCtxWindow}
onChange={(e) => setDsCtxWindow(Number(e.target.value) || 1000000)}
placeholder="如 64000、128000、1000000"
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
error={dsCtxError}
helperText={dsCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'}
/>
)}
{provider === 'agnes' && (
<TextField
size="small"
label="上下文窗口 (contextWindow)"
type="number"
value={agnesCtxWindow}
onChange={(e) => setAgnesCtxWindow(Number(e.target.value) || 1000000)}
placeholder="如 64000、128000、1000000"
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
error={agnesCtxError}
helperText={agnesCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'}
/>
)}
{provider === 'mimo' && (
<TextField
size="small"
label="上下文窗口 (contextWindow)"
type="number"
value={mimoCtxWindow}
onChange={(e) => setMimoCtxWindow(Number(e.target.value) || 1000000)}
placeholder="如 65536、131072、1000000"
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
error={mimoCtxError}
helperText={mimoCtxError ? '最小值为 4096' : '默认 10000001M),用于上下文压缩判断'}
/>
)}
{provider === 'openai' && (
<TextField
size="small"
label="上下文窗口 (contextWindow)"
type="number"
value={oaCtxWindow}
onChange={(e) => setOaCtxWindow(Number(e.target.value) || 128000)}
placeholder="如 128000、200000、1000000"
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
error={oaCtxError}
helperText={oaCtxError ? '最小值为 4096' : 'gpt-4o 默认 128Kgpt-4.1 默认 1M'}
/>
)}
{provider === 'anthropic' && (
<TextField
size="small"
label="上下文窗口 (contextWindow)"
type="number"
value={anthropicCtxWindow}
onChange={(e) => setAnthropicCtxWindow(Number(e.target.value) || 200000)}
placeholder="如 200000"
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
error={anthropicCtxError}
helperText={anthropicCtxError ? '最小值为 4096' : 'Claude 默认 200K'}
/>
)}
{/* ===== P1: 故障转移 Provider(主 Provider 请求失败时自动切换) ===== */}
<Divider />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Provider Provider
</Typography>
<FormControl size="small">
<InputLabel> Provider</InputLabel>
<Select
value={fbProvider}
label="备用 Provider"
onChange={(e) => {
const v = e.target.value;
setFbProvider(v);
setFbModel('');
setFbApiKey('');
setFbBaseURL(PROVIDER_URLS[v] ?? '');
}}
>
<MenuItem value="">
<em></em>
</MenuItem>
<MenuItem value="deepseek">DeepSeek</MenuItem>
<MenuItem value="agnes">Agnes AI</MenuItem>
<MenuItem value="mimo">MiMo ()</MenuItem>
<MenuItem value="ollama">Ollama ()</MenuItem>
<MenuItem value="openai">OpenAI</MenuItem>
<MenuItem value="anthropic">Anthropic</MenuItem>
</Select>
</FormControl>
{fbProvider && (
<>
<TextField
size="small"
label="备用 Base URL"
value={fbBaseURL}
onChange={(e) => setFbBaseURL(e.target.value)}
placeholder="如 https://api.deepseek.com"
/>
<TextField
size="small"
label="备用模型名称"
value={fbModel}
onChange={(e) => setFbModel(e.target.value)}
placeholder="如 deepseek-v4-flash"
/>
{fbProvider !== 'ollama' && (
<TextField
size="small"
label="备用 API Key"
type={showFbKey ? 'text' : 'password'}
value={fbApiKey}
onChange={(e) => setFbApiKey(e.target.value)}
placeholder="sk-..."
slotProps={{
input: {
endAdornment: (
<IconButton size="small" onClick={() => setShowFbKey(!showFbKey)}>
{showFbKey ? <EyeOff size={14} /> : <Eye size={14} />}
</IconButton>
),
},
}}
/>
)}
</>
)}
{/* Save 按钮:批量提交,取消 onChange 实时落库 */}
<Stack direction="row" spacing={1} sx={{ mt: 1, alignItems: 'center' }}>
<Button
variant="contained"
size="small"
onClick={handleSave}
disabled={saving || hasBlockingError}
startIcon={saving ? <CircularProgress size={12} /> : undefined}
>
{saving ? '保存中...' : '保存配置'}
</Button>
{hasBlockingError && (
<Typography variant="caption" sx={{ color: 'error.main', fontSize: 11 }}>
</Typography>
)}
</Stack>
</Stack>
);
}
+287
View File
@@ -0,0 +1,287 @@
/**
* LogsSettings — 日志与数据 Tab
*
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
* 功能:日志级别、日志文件路径(打开/复制)、数据导出与清理(Dialog 确认)。
*/
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogTitle,
DialogActions,
Button,
TextField,
Select,
MenuItem,
Stack,
Typography,
Divider,
Box,
InputLabel,
FormControl,
} from '@mui/material';
import { Folder, Copy } from 'lucide-react';
import { useConfig } from './useConfig';
import { useUIStore } from '@renderer/stores/ui-store';
import { useAgentStore } from '@renderer/stores/agent-store';
import { useSessionStore } from '@renderer/stores/session-store';
export function LogsSettings() {
const [logLevel, setLogLevel] = useConfig('logging.level', 'info');
const [clearing, setClearing] = useState<string | null>(null);
// L-11 修复(审计补充): 用 MUI Dialog 替换原生 confirm(),保持 UI 一致性
const [confirmClear, setConfirmClear] = useState<'sessions' | 'memories' | 'auditLogs' | null>(
null,
);
// P3-13: 显示日志文件路径,并提供"打开日志文件夹"按钮
// electron-log 默认写入路径为 ${userData}/logs/main.log
const [logFilePath, setLogFilePath] = useState<string>('');
const [logPathLoading, setLogPathLoading] = useState<boolean>(true);
const [copyState, setCopyState] = useState<'idle' | 'success' | 'error'>('idle');
// P3-13: 组件挂载时获取日志文件路径
useEffect(() => {
let cancelled = false;
(async () => {
try {
const appData = await window.metona?.app?.getAppDataPath?.();
if (cancelled) return;
if (appData) {
// electron-log 默认日志路径: ${userData}/logs/main.log
// 路径分隔符由系统决定,直接拼接避免引入 path 模块
const sep = appData.includes('/') && !appData.includes('\\') ? '/' : '\\';
setLogFilePath(`${appData}${sep}logs${sep}main.log`);
}
} catch (e) {
// 获取失败不阻塞 UI
console.warn('[LogsSettings] Failed to get app data path:', e);
} finally {
if (!cancelled) setLogPathLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const handleOpenLogFolder = async () => {
if (!logFilePath) return;
try {
const r = await window.metona?.app?.showItemInFolder?.(logFilePath);
if (r && !r.success) {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`打开失败: ${r.error ?? '未知错误'}`))
.catch(() => {});
}
} catch (e) {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`打开失败: ${(e as Error).message}`))
.catch(() => {});
}
};
const handleCopyLogPath = async () => {
if (!logFilePath) return;
try {
await navigator.clipboard.writeText(logFilePath);
setCopyState('success');
setTimeout(() => setCopyState('idle'), 1500);
} catch (e) {
setCopyState('error');
setTimeout(() => setCopyState('idle'), 1500);
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`复制失败: ${(e as Error).message}`))
.catch(() => {});
}
};
const handleExport = async () => {
if (!window.metona?.data?.exportData) return;
try {
const r = await window.metona.data.exportData();
if (r.success && r.data) {
const b = new Blob([JSON.stringify(r.data, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = `metona-export-${Date.now()}.json`;
a.click();
} else {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`导出失败: ${r.error ?? '未知错误'}`))
.catch(() => {});
}
} catch (err) {
console.error('[LogsSettings]', err);
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`导出失败: ${(err as Error).message}`))
.catch(() => {});
}
};
const labels: Record<string, string> = {
sessions: '所有会话',
memories: '所有记忆',
auditLogs: '审计日志',
};
// L-11 修复(审计补充): 清理数据改用 Dialog 确认,结果用 toast 反馈
const handleClearConfirm = async () => {
if (!confirmClear) return;
const type = confirmClear;
setClearing(type);
setConfirmClear(null);
try {
let r;
if (type === 'sessions') r = await window.metona?.data?.clearSessions();
else if (type === 'memories') r = await window.metona?.data?.clearMemories();
else r = await window.metona?.data?.clearAuditLogs();
if (r?.success) {
import('@metona-team/metona-toast')
.then((mod) => mod.default.success(`${labels[type]}已清理`))
.catch(() => {});
// 修复: 清理会话后同步清空前端状态,无需重启应用
if (type === 'sessions') {
useSessionStore.getState().setSessions([]);
useSessionStore.getState().setCurrentSession(null);
// 同时清空当前消息列表,防止聊天面板显示已删除的会话内容
useAgentStore.getState().setMessages([]);
}
// v0.3.6 修复: 清理记忆后触发 MemoryViewer 重新加载(之前需重启应用才看到效果)
if (type === 'memories') {
useUIStore.getState().bumpMemoryVersion();
}
} else {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`失败: ${r?.error ?? '未知错误'}`))
.catch(() => {});
}
} catch (e) {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`失败: ${(e as Error).message}`))
.catch(() => {});
}
setClearing(null);
};
return (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
</Typography>
<FormControl size="small">
<InputLabel></InputLabel>
<Select value={logLevel} label="日志级别" onChange={(e) => setLogLevel(e.target.value)}>
<MenuItem value="debug">DEBUG</MenuItem>
<MenuItem value="info">INFO</MenuItem>
<MenuItem value="warn">WARN</MenuItem>
<MenuItem value="error">ERROR</MenuItem>
</Select>
</FormControl>
{/* P3-13: 日志文件路径展示与打开按钮 */}
<Box>
<Typography
variant="caption"
sx={{ fontWeight: 600, color: 'text.secondary', mb: 0.5, display: 'block' }}
>
</Typography>
<TextField
size="small"
fullWidth
value={logFilePath}
placeholder={logPathLoading ? '正在获取路径...' : '路径不可用'}
slotProps={{ input: { readOnly: true, sx: { fontSize: 11, fontFamily: 'monospace' } } }}
/>
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Button
variant="outlined"
size="small"
startIcon={<Folder size={14} />}
onClick={handleOpenLogFolder}
disabled={!logFilePath}
>
</Button>
<Button
variant="outlined"
size="small"
startIcon={<Copy size={14} />}
onClick={handleCopyLogPath}
disabled={!logFilePath}
color={
copyState === 'success' ? 'success' : copyState === 'error' ? 'error' : 'inherit'
}
>
{copyState === 'success' ? '已复制' : copyState === 'error' ? '复制失败' : '复制路径'}
</Button>
</Stack>
<Typography variant="caption" sx={{ color: 'text.disabled', mt: 0.5, display: 'block' }}>
logs
</Typography>
</Box>
<Divider />
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
</Typography>
<Button variant="outlined" fullWidth size="small" onClick={handleExport}>
📦 JSON
</Button>
<Button
variant="outlined"
fullWidth
size="small"
color="error"
onClick={() => setConfirmClear('sessions')}
disabled={clearing !== null}
>
{clearing === 'sessions' ? '清理中...' : '🗑️ 清理所有会话'}
</Button>
<Button
variant="outlined"
fullWidth
size="small"
color="error"
onClick={() => setConfirmClear('memories')}
disabled={clearing !== null}
>
{clearing === 'memories' ? '清理中...' : '🗑️ 清理所有记忆'}
</Button>
<Button
variant="outlined"
fullWidth
size="small"
color="error"
onClick={() => setConfirmClear('auditLogs')}
disabled={clearing !== null}
>
{clearing === 'auditLogs' ? '清理中...' : '🗑️ 清理审计日志'}
</Button>
{/* L-11 修复(审计补充): 清理数据确认 Dialog(替代原生 confirm() */}
<Dialog
open={confirmClear !== null}
onClose={() => setConfirmClear(null)}
maxWidth="xs"
fullWidth
>
<DialogTitle></DialogTitle>
<DialogContent>
<Typography variant="body2">
{confirmClear ? labels[confirmClear] : ''}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmClear(null)} color="inherit">
</Button>
<Button onClick={handleClearConfirm} color="error" variant="contained">
</Button>
</DialogActions>
</Dialog>
</Stack>
);
}
+334
View File
@@ -0,0 +1,334 @@
/**
* MCPSettings — MCP 服务管理 Tab
*
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
* 功能:MCP Server 列表/连接/断开/移除(Dialog 二次确认)/添加。
*/
import { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogTitle,
DialogActions,
Button,
TextField,
Stack,
Typography,
Box,
Alert,
} from '@mui/material';
export function MCPSettings() {
const [servers, setServers] = useState<
Array<{ name: string; status: string; toolCount: number; error?: string }>
>([]);
const [showAdd, setShowAdd] = useState(false);
const [newName, setNewName] = useState('');
const [newCommand, setNewCommand] = useState('');
const [newArgs, setNewArgs] = useState('');
// v0.4.1: 新增传输方式选择(stdio / streamable-http)与 URL 字段
const [newTransport, setNewTransport] = useState<'stdio' | 'streamable-http'>('stdio');
const [newUrl, setNewUrl] = useState('');
// L-11 修复: 用 MUI Dialog 替换浏览器原生 confirm(),保持 UI 一致性
const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
// L-20 修复: loadServers 改为多行 async/await 写法,提升可读性
const loadServers = useCallback(async () => {
if (!window.metona?.mcp?.listServers) return;
try {
const list = await window.metona.mcp.listServers();
setServers(list as MetonaMCPServerStatus[]);
} catch (err) {
console.error('[MCPSettings]', err);
}
}, []);
// 审计补充修复: useEffect 添加 cancelled 标志,防止卸载后 setState
useEffect(() => {
let cancelled = false;
(async () => {
if (!window.metona?.mcp?.listServers) return;
try {
const list = await window.metona.mcp.listServers();
if (!cancelled) setServers(list as MetonaMCPServerStatus[]);
} catch (err) {
if (!cancelled) console.error('[MCPSettings]', err);
}
})();
return () => {
cancelled = true;
};
}, []);
const handleAdd = async () => {
// v0.4.1: 按传输方式校验必填字段(stdio→命令,streamable-http→URL
if (!newName.trim()) return;
if (newTransport === 'stdio' && !newCommand.trim()) return;
if (newTransport === 'streamable-http' && !newUrl.trim()) return;
try {
const config =
newTransport === 'stdio'
? {
name: newName.trim(),
transport: 'stdio' as const,
command: newCommand.trim(),
args: newArgs.trim() ? newArgs.trim().split(/\s+/) : [],
enabled: true,
}
: {
name: newName.trim(),
transport: 'streamable-http' as const,
url: newUrl.trim(),
enabled: true,
};
const r = await window.metona?.mcp?.addServer(config);
if (r?.success) {
setNewName('');
setNewCommand('');
setNewArgs('');
setNewUrl('');
setNewTransport('stdio');
setShowAdd(false);
loadServers();
} else {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(r?.error ?? '添加 MCP 服务失败'))
.catch(() => {});
}
} catch (err) {
console.error('[MCPSettings]', err);
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`添加 MCP 服务失败:${(err as Error).message}`))
.catch(() => {});
}
};
const statusColors: Record<string, string> = {
connected: 'success.main',
connecting: 'warning.main',
disconnected: 'text.secondary',
error: 'error.main',
};
// L-11 修复: 确认移除 MCP 服务
// 审计补充修复: 添加 try/catch,避免 removeServer reject 时 Dialog 卡死无法关闭
const [removeError, setRemoveError] = useState<string | null>(null);
const handleConfirmRemove = async () => {
if (!confirmRemove) return;
try {
setRemoveError(null);
const r = await window.metona?.mcp?.removeServer(confirmRemove);
if (r?.success) {
setConfirmRemove(null);
loadServers();
} else {
setRemoveError(r?.error ?? '移除失败');
}
} catch (err) {
setRemoveError((err as Error).message ?? '移除失败');
}
};
return (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
MCP
</Typography>
{servers.length === 0 ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, color: 'text.secondary' }}>
MCP
</Typography>
) : (
servers.map((s) => (
<Stack
key={s.name}
direction="row"
sx={{
py: 1,
px: 1.5,
borderRadius: 1.5,
bgcolor: 'secondary.main',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Box
sx={{ width: 8, height: 8, borderRadius: '50', bgcolor: statusColors[s.status] }}
/>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{s.name}
</Typography>
<Typography variant="caption" sx={{ color: statusColors[s.status] }}>
{s.status}
</Typography>
{s.toolCount > 0 && (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{s.toolCount}
</Typography>
)}
</Stack>
<Stack direction="row" spacing={0.5}>
<Button
size="small"
sx={{ fontSize: 10, minWidth: 40 }}
onClick={async () => {
try {
const r = await window.metona?.mcp?.toggleServer(
s.name,
s.status !== 'connected',
);
if (r?.success) {
loadServers();
} else {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(r?.error ?? '操作失败'))
.catch(() => {});
}
} catch (err) {
console.error('[MCPSettings]', err);
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`操作失败:${(err as Error).message}`))
.catch(() => {});
}
}}
>
{s.status === 'connected' ? '断开' : '连接'}
</Button>
{/* L-11 修复: 点击移除打开 MUI Dialog 二次确认,而非原生 confirm() */}
<Button
size="small"
color="error"
sx={{ fontSize: 10, minWidth: 40 }}
onClick={() => setConfirmRemove(s.name)}
>
</Button>
</Stack>
</Stack>
))
)}
{/* L-11 修复: MUI Dialog 替代原生 confirm() */}
<Dialog
open={confirmRemove !== null}
onClose={() => {
setConfirmRemove(null);
setRemoveError(null);
}}
maxWidth="xs"
fullWidth
>
<DialogTitle></DialogTitle>
<DialogContent>
<Typography variant="body2">
MCP "{confirmRemove}"
</Typography>
{removeError && (
<Alert severity="error" sx={{ mt: 1, fontSize: 12 }}>
{removeError}
</Alert>
)}
</DialogContent>
<DialogActions>
<Button
onClick={() => {
setConfirmRemove(null);
setRemoveError(null);
}}
color="inherit"
>
</Button>
<Button onClick={handleConfirmRemove} color="error" variant="contained">
</Button>
</DialogActions>
</Dialog>
{showAdd ? (
<Stack
spacing={1}
sx={{
p: 1.5,
borderRadius: 1.5,
bgcolor: 'secondary.main',
border: '1px solid',
borderColor: 'divider',
}}
>
<TextField
size="small"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="服务名称"
/>
{/* v0.4.1: 传输方式选择(stdio 本地命令 / streamable-http 远程) */}
<Stack direction="row" spacing={1}>
<Button
variant={newTransport === 'stdio' ? 'contained' : 'outlined'}
size="small"
onClick={() => setNewTransport('stdio')}
sx={{ flex: 1 }}
>
(stdio)
</Button>
<Button
variant={newTransport === 'streamable-http' ? 'contained' : 'outlined'}
size="small"
onClick={() => setNewTransport('streamable-http')}
sx={{ flex: 1 }}
>
(HTTP)
</Button>
</Stack>
{newTransport === 'stdio' ? (
<>
<TextField
size="small"
value={newCommand}
onChange={(e) => setNewCommand(e.target.value)}
placeholder="命令路径(如 npx / node / python"
/>
<TextField
size="small"
value={newArgs}
onChange={(e) => setNewArgs(e.target.value)}
placeholder="参数 (空格分隔)"
/>
</>
) : (
<TextField
size="small"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
placeholder="Streamable HTTP URL(如 https://example.com/mcp"
/>
)}
<Stack direction="row" spacing={1}>
<Button
variant="contained"
size="small"
onClick={handleAdd}
disabled={
!newName.trim() || (newTransport === 'stdio' ? !newCommand.trim() : !newUrl.trim())
}
sx={{ flex: 1 }}
>
</Button>
<Button
variant="outlined"
size="small"
onClick={() => setShowAdd(false)}
sx={{ flex: 1 }}
>
</Button>
</Stack>
</Stack>
) : (
<Button variant="outlined" fullWidth size="small" onClick={() => setShowAdd(true)}>
+ MCP
</Button>
)}
</Stack>
);
}
+262
View File
@@ -0,0 +1,262 @@
/**
* SearXNGSettings — SearXNG 元搜索配置 Tab
*
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
* 功能:SearXNG 实例配置(12 项)+ 连接测试。
*/
import { useState } from 'react';
import {
Button,
TextField,
Select,
MenuItem,
Stack,
Typography,
Divider,
Chip,
Switch,
Alert,
Box,
FormControlLabel,
InputLabel,
FormControl,
IconButton,
CircularProgress,
} from '@mui/material';
import { Eye, EyeOff } from 'lucide-react';
import { useConfig } from './useConfig';
export function SearXNGSettings() {
// ===== 12 项配置(useConfig 实时持久化) =====
const [enabled, setEnabled] = useConfig('searxng.enabled', false);
const [url, setUrl] = useConfig('searxng.url', '');
const [engines, setEngines] = useConfig('searxng.engines', '');
const [language, setLanguage] = useConfig('searxng.language', 'zh-CN');
const [safesearch, setSafesearch] = useConfig('searxng.safesearch', 1);
const [timeRange, setTimeRange] = useConfig('searxng.time_range', '');
const [maxResults, setMaxResults] = useConfig('searxng.max_results', 0);
const [authKey, setAuthKey] = useConfig('searxng.auth_key', '');
const [authType, setAuthType] = useConfig('searxng.auth_type', 'bearer');
const [format, setFormat] = useConfig('searxng.format', 'json');
const [fetchCount, setFetchCount] = useConfig('searxng.fetch_count', 0);
const [fetchMode, setFetchMode] = useConfig('searxng.fetch_mode', 'sequential');
const [showKey, setShowKey] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const urlError = !!url && !/^https?:\/\//.test(url);
const handleTest = async () => {
if (!url.trim() || urlError) return;
setTesting(true);
setTestResult(null);
try {
const result = await window.metona?.searxng?.testConnection(url.trim(), authKey, authType);
if (result?.success) {
setTestResult({ success: true, message: `连接成功(${result.latencyMs}ms` });
} else {
setTestResult({
success: false,
message: result?.error || `连接失败(HTTP ${result?.statusCode}`,
});
}
} catch (e) {
setTestResult({ success: false, message: (e as Error).message });
}
setTesting(false);
};
return (
<Stack spacing={2}>
{/* 标题 + 状态徽章 */}
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
SearXNG
</Typography>
<Chip
label={enabled ? '已启用' : '未启用'}
size="small"
color={enabled ? 'success' : 'default'}
variant={enabled ? 'filled' : 'outlined'}
/>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
SearXNG 70+
退 Bing + + + 360
</Typography>
{/* 启用开关 */}
<FormControlLabel
control={
<Switch checked={enabled} onChange={(e) => setEnabled(e.target.checked)} size="small" />
}
label={<Typography variant="body2"> SearXNG</Typography>}
/>
<Divider />
{/* API 地址 + 连接测试 */}
<Stack direction="row" spacing={1} sx={{ alignItems: 'flex-start' }}>
<TextField
size="small"
label="API 地址"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="如 https://searxng.example.com"
error={urlError}
helperText={
urlError ? '需以 http:// 或 https:// 开头' : '实例根地址(不含 /search 路径)'
}
sx={{ flex: 1 }}
/>
<Button
variant="outlined"
size="small"
onClick={handleTest}
disabled={!url.trim() || urlError || testing}
sx={{ mt: 0.5, minWidth: 90, height: 40 }}
>
{testing ? <CircularProgress size={14} /> : '测试连接'}
</Button>
</Stack>
{/* 测试结果 */}
{testResult && (
<Alert
severity={testResult.success ? 'success' : 'error'}
sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}
>
{testResult.message}
</Alert>
)}
{/* 搜索引擎 */}
<TextField
size="small"
label="搜索引擎(逗号分隔)"
value={engines}
onChange={(e) => setEngines(e.target.value)}
placeholder="如 google,bing,duckduckgo(留空使用实例默认)"
/>
{/* 语言 + 安全搜索 */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<FormControl size="small">
<InputLabel></InputLabel>
<Select value={language} label="语言" onChange={(e) => setLanguage(e.target.value)}>
<MenuItem value="zh-CN"></MenuItem>
<MenuItem value="zh-TW"></MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="ja"></MenuItem>
<MenuItem value="ko"></MenuItem>
<MenuItem value="auto"></MenuItem>
</Select>
</FormControl>
<FormControl size="small">
<InputLabel></InputLabel>
<Select
value={safesearch}
label="安全搜索"
onChange={(e) => setSafesearch(e.target.value as number)}
>
<MenuItem value={0}></MenuItem>
<MenuItem value={1}></MenuItem>
<MenuItem value={2}></MenuItem>
</Select>
</FormControl>
</Box>
{/* 时间范围 + 返回格式 */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<FormControl size="small">
<InputLabel></InputLabel>
<Select value={timeRange} label="时间范围" onChange={(e) => setTimeRange(e.target.value)}>
<MenuItem value=""></MenuItem>
<MenuItem value="day"></MenuItem>
<MenuItem value="week"></MenuItem>
<MenuItem value="month"></MenuItem>
<MenuItem value="year"></MenuItem>
</Select>
</FormControl>
<FormControl size="small">
<InputLabel></InputLabel>
<Select value={format} label="返回格式" onChange={(e) => setFormat(e.target.value)}>
<MenuItem value="json">JSON</MenuItem>
<MenuItem value="html">HTML</MenuItem>
</Select>
</FormControl>
</Box>
{/* 最大结果数 + 自动抓取条数 */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<TextField
size="small"
label="最大结果数"
type="number"
value={maxResults}
onChange={(e) => setMaxResults(Number(e.target.value))}
placeholder="0 表示使用默认"
slotProps={{ htmlInput: { min: 0, max: 50 } }}
/>
<TextField
size="small"
label="自动抓取条数"
type="number"
value={fetchCount}
onChange={(e) => setFetchCount(Number(e.target.value))}
placeholder="0 表示由 AI 决定"
slotProps={{ htmlInput: { min: 0, max: 8 } }}
/>
</Box>
{/* 抓取类型 */}
<FormControl size="small">
<InputLabel></InputLabel>
<Select value={fetchMode} label="抓取类型" onChange={(e) => setFetchMode(e.target.value)}>
<MenuItem value="sequential"></MenuItem>
<MenuItem value="random"></MenuItem>
</Select>
</FormControl>
<Divider />
{/* 认证设置 */}
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 2 }}>
<FormControl size="small">
<InputLabel></InputLabel>
<Select value={authType} label="认证类型" onChange={(e) => setAuthType(e.target.value)}>
<MenuItem value="bearer">Bearer Token</MenuItem>
<MenuItem value="basic">Basic Auth</MenuItem>
</Select>
</FormControl>
<TextField
size="small"
label={authType === 'bearer' ? 'Token' : '用户名:密码'}
type={showKey ? 'text' : 'password'}
value={authKey}
onChange={(e) => setAuthKey(e.target.value)}
placeholder={authType === 'bearer' ? '访问令牌原值' : 'username:password'}
slotProps={{
input: {
endAdornment: (
<IconButton size="small" onClick={() => setShowKey(!showKey)}>
{showKey ? <EyeOff size={14} /> : <Eye size={14} />}
</IconButton>
),
},
}}
/>
</Box>
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
{authType === 'bearer'
? 'Bearer: 直接填写令牌原值,原样透传到 Authorization 头。建议配合 HTTPS 使用。'
: 'Basic: 填写 username:password 明文串,系统自动 Base64 编码。必须配合 HTTPS 使用。'}
</Typography>
</Stack>
);
}
File diff suppressed because it is too large Load Diff
+311
View File
@@ -0,0 +1,311 @@
/**
* ToolsSettings — 工具管理 Tab
*
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
* 功能:工具启用/禁用开关、自动执行工具管理(乐观更新 + 失败回滚)。
*/
import { useState, useEffect } from 'react';
import { Button, Stack, Typography, Checkbox, Divider, Chip, Box } from '@mui/material';
import { alpha } from '@mui/material/styles';
export function ToolsSettings() {
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('[ToolsSettings]', err);
});
}
};
const loadAutoExec = () => {
if (window.metona?.tool?.getAutoExecuteList) {
window.metona.tool
.getAutoExecuteList()
.then((r) => {
if (r.success) setAutoExecList(r.data);
})
.catch((err) => {
console.error('[ToolsSettings]', err);
});
}
};
useEffect(() => {
loadTools();
loadAutoExec();
}, []);
const handleToggle = async (name: string, enabled: boolean) => {
// v0.3.6 修复: 乐观更新失败时回滚 UI,避免开关显示与实际状态不一致
// 注意: 只回滚失败的单个工具(用 !enabled),不能用 setTools(prev) 整体回滚,
// 否则会覆盖 await 期间用户对其他工具的并发修改
setTools((p) => p.map((t) => (t.name === name ? { ...t, enabled } : t)));
try {
const r = await window.metona?.tools?.toggle(name, enabled);
if (r && !r.success) {
setTools((p) => p.map((t) => (t.name === name ? { ...t, enabled: !enabled } : t)));
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(r.error ?? '切换工具失败'))
.catch(() => {});
}
} catch (err) {
console.error('[ToolsSettings]', err);
setTools((p) => p.map((t) => (t.name === name ? { ...t, enabled: !enabled } : t)));
import('@metona-team/metona-toast')
.then((mod) => mod.default.error('切换工具失败'))
.catch(() => {});
}
};
// 设置/取消自动执行
const handleSetAutoExec = async (name: string, enabled: boolean) => {
if (!window.metona?.tool?.setAutoExecute) return;
try {
const r = await window.metona.tool.setAutoExecute(name, enabled);
if (r.success) {
setAutoExecList((p) => (enabled ? [...p, name] : p.filter((n) => n !== name)));
} else {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(r.error ?? '设置自动执行失败'))
.catch(() => {});
}
} catch (err) {
console.error('[ToolsSettings]', err);
import('@metona-team/metona-toast')
.then((mod) => mod.default.error('设置自动执行失败'))
.catch(() => {});
}
};
// v0.3.1: 添加 critical 键,防止 critical 级别工具 Chip 渲染为 undefined color
const riskColors: Record<string, 'success' | 'info' | 'warning' | 'error'> = {
safe: 'success',
low: 'info',
medium: 'warning',
high: 'error',
critical: '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>
{tools.length === 0 ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, color: 'text.secondary' }}>
...
</Typography>
) : (
tools.map((t) => (
<Stack
key={t.name}
direction="row"
sx={{
py: 1,
px: 1.5,
borderRadius: 1.5,
bgcolor: 'secondary.main',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Stack direction="row" spacing={1} sx={{ minWidth: 0, flex: 1, alignItems: 'center' }}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>
{t.name}
</Typography>
<Typography
variant="caption"
sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{t.description.slice(0, 40)}
</Typography>
</Stack>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Chip
label={t.riskLevel.toUpperCase()}
size="small"
color={riskColors[t.riskLevel]}
variant="outlined"
sx={{ height: 18, fontSize: 9 }}
/>
<Checkbox
checked={t.enabled}
onChange={(e) => handleToggle(t.name, e.target.checked)}
size="small"
/>
</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={(theme) => ({
py: 1,
px: 1.5,
borderRadius: 1.5,
// 用主题 success 色的 12% 透明度做底,文字和按钮保持不透明
bgcolor: alpha(theme.palette.success.main, 0.12),
// 左侧绿色状态条,强化"已自动执行"视觉
boxShadow: `inset 3px 0 0 ${theme.palette.success.main}`,
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', minWidth: 0, flex: 1 }}>
<Box
component="span"
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: 'success.main',
flexShrink: 0,
}}
/>
<Typography
variant="body2"
sx={{
fontFamily: 'monospace',
fontSize: 12,
color: 'text.primary',
fontWeight: 600,
}}
>
{t.name}
</Typography>
<Chip label="自动" size="small" color="success" sx={{ height: 16, fontSize: 9 }} />
</Stack>
<Button
size="small"
color="error"
variant="contained"
sx={{ fontSize: 11, minWidth: 72, fontWeight: 600 }}
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, color: 'text.primary' }}
>
{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: 11, minWidth: 72, fontWeight: 600 }}
onClick={() => handleSetAutoExec(t.name, true)}
>
</Button>
</Stack>
))}
</>
)}
</Stack>
);
}
@@ -0,0 +1,365 @@
/**
* WorkspaceSettings — 工作空间设置 Tab
*
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
* 功能:工作空间路径选择/校验/切换、SOUL.md 与数据库继承、重启确认。
*/
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogTitle,
DialogActions,
Button,
TextField,
Stack,
Typography,
Checkbox,
Alert,
CircularProgress,
FormControlLabel,
} from '@mui/material';
import { useConfig } from './useConfig';
export function WorkspaceSettings() {
const [workspacePath, setWorkspacePath] = useConfig('workspace.path', '');
// 切换工作空间的中间状态
const [pendingPath, setPendingPath] = useState<string | null>(null);
const [checkResult, setCheckResult] = useState<MetonaWorkspaceCheckResult | null>(null);
const [checking, setChecking] = useState(false);
const [inheritSoul, setInheritSoul] = useState(true);
// 数据库继承:默认勾选(历史会话/消息/记忆/Trace 丢失不可逆,默认带过去更安全)
const [inheritDatabase, setInheritDatabase] = useState(true);
const [applying, setApplying] = useState(false);
const [showRestartDialog, setShowRestartDialog] = useState(false);
const currentPath = workspacePath;
const handleSelect = async () => {
if (!window.metona?.app?.selectFolder) return;
const r = await window.metona.app.selectFolder(currentPath || undefined);
if (!r.canceled && r.path) {
// 立即校验新路径
setPendingPath(r.path);
setChecking(true);
setCheckResult(null);
try {
const result = await window.metona.workspace.check(r.path);
setCheckResult(result);
} catch (err) {
console.error('[WorkspaceSettings]', err);
setCheckResult({ valid: false, reason: (err as Error).message });
} finally {
setChecking(false);
}
}
};
const handleApply = async () => {
if (!pendingPath || !checkResult?.valid) return;
setApplying(true);
try {
// 如果勾选了继承文件,从旧工作空间复制到新工作空间
// - SOUL.md: Agent 身份定义(仅目标缺少时才继承,避免覆盖已有定义)
// - .metona/agent.db: 数据库(仅目标不存在时才继承,避免覆盖已有数据)
const filesToInherit: string[] = [];
if (inheritSoul && checkResult?.missingFiles?.includes('SOUL.md'))
filesToInherit.push('SOUL.md');
if (inheritDatabase && !checkResult?.dbExists) filesToInherit.push('.metona/agent.db');
// #51 修复: 继承数据库前校验源数据库完整性,避免继承损坏的数据库导致新工作空间数据丢失
if (inheritDatabase && currentPath) {
try {
const integrityResult =
await window.metona?.workspace?.checkDatabaseIntegrity(currentPath);
if (!integrityResult?.success) {
import('@metona-team/metona-toast')
.then((mod) =>
mod.default.error(`源数据库校验失败:${integrityResult?.error ?? '未知错误'}`),
)
.catch(() => {});
setApplying(false);
return;
}
if (!integrityResult.ok) {
import('@metona-team/metona-toast')
.then((mod) =>
mod.default.error(`源数据库损坏(${integrityResult.detail}),无法继承`),
)
.catch(() => {});
setApplying(false);
return;
}
} catch (err) {
console.error('[WorkspaceSettings] Database integrity check failed:', err);
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`源数据库校验异常:${(err as Error).message}`))
.catch(() => {});
setApplying(false);
return;
}
}
if (filesToInherit.length > 0 && currentPath) {
try {
await window.metona.workspace.inheritFiles({
targetPath: pendingPath,
sourcePath: currentPath,
files: filesToInherit,
});
} catch (err) {
// 用户勾选了继承文件,失败时必须告知,否则切到新空间才发现文件是空的,潜在数据丢失风险
console.error('[WorkspaceSettings] Inherit failed:', err);
import('@metona-team/metona-toast')
.then((mod) =>
mod.default.warning(`部分文件继承失败:${(err as Error).message},请手动检查`),
)
.catch(() => {});
}
}
// 保存新路径到配置
setWorkspacePath(pendingPath);
// 弹出重启确认对话框
setShowRestartDialog(true);
// 清理中间状态
setPendingPath(null);
setCheckResult(null);
} catch (err) {
console.error('[WorkspaceSettings]', err);
// 用户主动操作(切换工作空间)失败必须有反馈
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`切换工作空间失败:${(err as Error).message}`))
.catch(() => {});
} finally {
setApplying(false);
}
};
const handleCancel = () => {
setPendingPath(null);
setCheckResult(null);
};
const handleOpen = async () => {
if (!workspacePath) return;
try {
const r = await window.metona?.app?.showItemInFolder(workspacePath);
if (r && !r.success) {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(r.error ?? '打开文件夹失败'))
.catch(() => {});
}
} catch (err) {
console.error('[WorkspaceSettings]', err);
import('@metona-team/metona-toast')
.then((mod) => mod.default.error('打开文件夹失败'))
.catch(() => {});
}
};
return (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Metona SOUL.mdMEMORY.md
</Typography>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<TextField
size="small"
value={workspacePath}
onChange={(e) => setWorkspacePath(e.target.value)}
placeholder="~/MetonaWorkspaces/default/"
sx={{ flex: 1 }}
/>
<Button variant="outlined" size="small" onClick={handleSelect}>
</Button>
</Stack>
{workspacePath && (
<Button
variant="text"
size="small"
onClick={handleOpen}
sx={{ alignSelf: 'flex-start', fontSize: 11, color: 'text.secondary' }}
>
📂
</Button>
)}
{/* 校验中状态 */}
{checking && (
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', py: 1 }}>
<CircularProgress size={14} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
...
</Typography>
</Stack>
)}
{/* 校验失败 */}
{pendingPath && checkResult && !checkResult.valid && (
<Alert severity="error" sx={{ py: 0.5 }}>
<Typography variant="caption">{checkResult.reason}</Typography>
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Button size="small" onClick={handleCancel}>
</Button>
</Stack>
</Alert>
)}
{/* 校验成功 — 显示工作空间状态 + 继承选项 */}
{pendingPath && checkResult?.valid && (
<Stack
spacing={1.5}
sx={{
p: 1.5,
borderRadius: 1.5,
bgcolor: 'background.default',
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.primary' }}>
{checkResult.path}
</Typography>
{checkResult.isNewWorkspace ? (
<Alert severity="info" sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
2 SOUL.mdMEMORY.md
</Alert>
) : checkResult.missingFiles && checkResult.missingFiles.length > 0 ? (
<Alert severity="warning" sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
{checkResult.missingFiles.length}
{checkResult.missingFiles.join(', ')}
</Alert>
) : (
<Alert severity="success" sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
2
</Alert>
)}
{/* 继承选项(当前有工作空间、目标不是同一目录、且目标有可继承的文件时显示) */}
{currentPath &&
currentPath !== pendingPath &&
(checkResult.missingFiles?.includes('SOUL.md') || !checkResult.dbExists) && (
<Stack spacing={0.5} sx={{ mt: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
</Typography>
{/* SOUL.md 继承:目标缺少 SOUL.md 时才允许勾选,避免覆盖已有定义 */}
{checkResult.missingFiles && checkResult.missingFiles.includes('SOUL.md') && (
<FormControlLabel
control={
<Checkbox
size="small"
checked={inheritSoul}
onChange={(e) => setInheritSoul(e.target.checked)}
/>
}
label={<Typography variant="caption">SOUL.md</Typography>}
/>
)}
{/* 数据库继承:目标 .metona/agent.db 不存在时才显示,避免覆盖已有工作空间数据 */}
{!checkResult.dbExists && (
<>
<FormControlLabel
control={
<Checkbox
size="small"
checked={inheritDatabase}
onChange={(e) => setInheritDatabase(e.target.checked)}
/>
}
label={
<Typography variant="caption">
agent.db///Trace
</Typography>
}
/>
<Typography
variant="caption"
sx={{ color: 'text.disabled', fontSize: 10, pl: 3 }}
>
SQLite backup API
</Typography>
</>
)}
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10, pl: 3 }}>
MEMORY.md
</Typography>
</Stack>
)}
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Button
variant="contained"
size="small"
onClick={handleApply}
disabled={applying}
startIcon={applying ? <CircularProgress size={12} /> : undefined}
>
{applying ? '应用中...' : '确认切换'}
</Button>
<Button variant="outlined" size="small" onClick={handleCancel} disabled={applying}>
</Button>
</Stack>
</Stack>
)}
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
</Typography>
{/* 重启确认对话框 */}
<Dialog
open={showRestartDialog}
onClose={() => setShowRestartDialog(false)}
maxWidth="xs"
fullWidth
>
<DialogTitle sx={{ fontSize: 14 }}></DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: 'monospace',
fontSize: 12,
mt: 0.5,
p: 1,
borderRadius: 1,
bgcolor: 'background.default',
}}
>
{workspacePath}
</Typography>
<Typography variant="body2" sx={{ mt: 1.5, color: 'text.secondary' }}>
</Typography>
</DialogContent>
<DialogActions>
<Button size="small" onClick={() => setShowRestartDialog(false)}>
</Button>
<Button
size="small"
variant="contained"
color="primary"
onClick={() => window.metona?.app?.restart()}
>
</Button>
</DialogActions>
</Dialog>
</Stack>
);
}
+69
View File
@@ -0,0 +1,69 @@
/**
* useConfig — 设置面板共享配置读写 Hook
*
* 从 SettingsModal.tsx 提取(v0.4.1 拆分),供各设置 Tab 组件复用。
*
* 特性:
* - 配置读取:挂载时异步加载,null/undefined 保持默认值
* - 配置写入:失败时回滚 UI 并 toast 提示,避免 UI 与 DB 状态不一致
* - 竞态保护:seqRef 防止连续修改时旧请求失败回滚覆盖新值
*/
import { useState, useEffect, useCallback, useRef } from 'react';
export function useConfig<T>(key: string, defaultValue: T): [T, (v: T) => void] {
const [value, setValue] = useState<T>(defaultValue);
const valueRef = useRef(value);
valueRef.current = value;
// v0.3.6 修复: 配置保存失败时回滚 UI 并提示用户,避免 UI 与 DB 状态不一致
// seqRef 防止竞态:连续修改时旧请求失败不回滚覆盖新值
const seqRef = useRef(0);
useEffect(() => {
if (window.metona?.config?.get)
window.metona.config
.get(key)
.then((v) => {
if (v != null) setValue(v as T);
})
.catch((err) => {
console.error('[useConfig]', err);
});
}, [key]);
const set = useCallback(
(v: T) => {
const seq = ++seqRef.current;
const prev = valueRef.current;
setValue(v);
window.metona?.config
?.set(key, v)
.then((r: { success?: boolean; error?: string } | undefined) => {
if (r && !r.success) {
// 只有当没有后续 set 操作时才回滚,避免覆盖用户的新修改
if (seqRef.current === seq) setValue(prev);
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(r.error ?? '配置保存失败'))
.catch(() => {});
}
})
.catch((err: unknown) => {
console.error('[useConfig]', err);
if (seqRef.current === seq) setValue(prev);
import('@metona-team/metona-toast')
.then((mod) => mod.default.error('配置保存失败'))
.catch(() => {});
});
},
[key],
);
return [value, set];
}
/** 各 Provider 的默认 Base URL(切换 Provider 时自动填充) */
export const PROVIDER_URLS: Record<string, string> = {
deepseek: 'https://api.deepseek.com',
agnes: 'https://apihub.agnes-ai.com/v1',
mimo: 'https://api.xiaomimimo.com/v1',
ollama: 'http://localhost:11434',
openai: 'https://api.openai.com/v1',
anthropic: 'https://api.anthropic.com',
};
+63 -14
View File
@@ -11,7 +11,12 @@
*/
import { useEffect, useRef } from 'react';
import { useAgentStore, genMsgId, type ToolCallInfo, type AgentStatus } from '@renderer/stores/agent-store';
import {
useAgentStore,
genMsgId,
type ToolCallInfo,
type AgentStatus,
} from '@renderer/stores/agent-store';
/**
* Agent 流式事件监听 Hook
@@ -102,12 +107,23 @@ export function useAgentStream(): void {
/** 工具调用增量(流式参数拼接) */
toolCallDelta?: { index: number; name?: string; argsDelta?: string };
toolCall?: { id: string; name: string; args: Record<string, unknown> };
toolResult?: { toolCallId: string; success: boolean; result?: unknown; error?: string; durationMs?: number };
toolResult?: {
toolCallId: string;
success: boolean;
result?: unknown;
error?: string;
durationMs?: number;
};
usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number };
/** v0.3.18 修复: 上下文压缩事件数据 */
savedTokens?: number;
originalTokens?: number;
compressedTokens?: number;
/** v0.4.1: 输出验证结果(OutputValidator 检出的疑似问题,不阻断输出) */
validation?: {
score: number;
issues: Array<{ severity: string; type: string; message: string }>;
};
error?: { code: string; message: string };
state?: string;
};
@@ -143,7 +159,9 @@ export function useAgentStream(): void {
if (data.iteration != null) {
const msgs = getStore().messages;
const last = msgs[msgs.length - 1];
const needsNewCard = !last || last.role !== 'assistant' ||
const needsNewCard =
!last ||
last.role !== 'assistant' ||
(last.iteration != null && last.iteration !== data.iteration);
if (needsNewCard) {
getStore().addMessage({
@@ -201,7 +219,9 @@ export function useAgentStream(): void {
if (data.iteration != null) {
const msgs = getStore().messages;
const last = msgs[msgs.length - 1];
const needsNewCard = !last || last.role !== 'assistant' ||
const needsNewCard =
!last ||
last.role !== 'assistant' ||
(last.iteration != null && last.iteration !== data.iteration);
if (needsNewCard) {
// F5: 新迭代前先 flush 旧缓冲区(属于上一条消息的 delta)
@@ -307,7 +327,7 @@ export function useAgentStream(): void {
tc.id === data.toolResult!.toolCallId
? {
...tc,
status: data.toolResult!.success ? 'success' as const : 'error' as const,
status: data.toolResult!.success ? ('success' as const) : ('error' as const),
result: data.toolResult!.result,
error: data.toolResult!.error,
durationMs: data.toolResult!.durationMs,
@@ -328,7 +348,9 @@ export function useAgentStream(): void {
tc.id === data.toolResult!.toolCallId
? {
...tc,
status: data.toolResult!.success ? 'success' as const : 'error' as const,
status: data.toolResult!.success
? ('success' as const)
: ('error' as const),
result: data.toolResult!.result,
error: data.toolResult!.error,
durationMs: data.toolResult!.durationMs,
@@ -375,6 +397,23 @@ export function useAgentStream(): void {
break;
}
// v0.4.1: 输出验证结果 — OutputValidator 检出的疑似问题以轻量 system 消息展示(不阻断)
case 'validation': {
const issues = data.validation?.issues ?? [];
if (issues.length > 0) {
const lines = issues.map(
(i) => `${i.severity === 'error' ? '❌' : '⚠️'} [${i.type}] ${i.message}`,
);
getStore().addMessage({
id: genMsgId('system'),
role: 'system',
content: `🔍 输出验证:发现 ${issues.length} 个疑似问题(含幻觉/事实一致性检测,仅供参考)\n${lines.join('\n')}`,
timestamp: Date.now(),
});
}
break;
}
// 流结束
case 'done':
// F5: 流结束前立即 flush 缓冲区,避免最后一段 delta 丢失
@@ -420,9 +459,8 @@ export function useAgentStream(): void {
getStore().addMessage({
id: genMsgId('error'),
role: 'system',
content: errorCode === 'content_filtered'
? `⚠️ ${errorMessage}`
: `错误: ${errorMessage}`,
content:
errorCode === 'content_filtered' ? `⚠️ ${errorMessage}` : `错误: ${errorMessage}`,
timestamp: Date.now(),
});
break;
@@ -494,12 +532,14 @@ export function useAgentStream(): void {
if (data.iteration > prevIteration) {
const messages = store.messages;
const lastMsg = messages[messages.length - 1];
const isAlreadyCurrentIteration = lastMsg?.role === 'assistant' && lastMsg.iteration === data.iteration;
const isAlreadyCurrentIteration =
lastMsg?.role === 'assistant' && lastMsg.iteration === data.iteration;
if (!isAlreadyCurrentIteration) {
// 首轮不要求上一轮有内容(上一轮是用户消息)
const isFirstIteration = prevIteration === 0;
const prevHasContent = lastMsg?.role === 'assistant' &&
const prevHasContent =
lastMsg?.role === 'assistant' &&
(lastMsg.content || lastMsg.toolCalls?.length || lastMsg.reasoningContent);
if (isFirstIteration || prevHasContent) {
@@ -527,7 +567,8 @@ export function useAgentStream(): void {
// 判断是否需要创建新步骤:同一 runId + 同一迭代的首次状态创建新步骤
// 后续状态转换(EXECUTING/OBSERVING等)追加到 states 数组
// runId 判定:防止跨 run 的事件被误判为同一迭代(如 traceSteps 未清空时残留的旧 step
const isSameIteration = lastStep && lastStep.iteration === data.iteration && lastStep.runId === data.runId;
const isSameIteration =
lastStep && lastStep.iteration === data.iteration && lastStep.runId === data.runId;
if (isSameIteration) {
// 同一迭代内的状态转换 → 追加状态到 states 数组,更新当前 state
@@ -555,7 +596,10 @@ export function useAgentStream(): void {
completedAt: Date.now(),
});
}
} else if (data.state === 'TERMINATED' && (!lastStep || lastStep.runId !== data.runId)) {
} else if (
data.state === 'TERMINATED' &&
(!lastStep || lastStep.runId !== data.runId)
) {
// 跨 run 的孤立 TERMINATED 事件:上一条消息已结束/已 abort,没有当前 run 的 step 可更新
// 不创建孤立的只含 TERMINATED 的 step(无意义),仅更新 Agent 状态
} else {
@@ -601,7 +645,12 @@ export function useAgentStream(): void {
if (!window.metona?.agent?.onProviderSwitched) return;
const unsubscribe = window.metona.agent.onProviderSwitched((data: unknown) => {
const { from, to, reason, sessionId } = data as { from?: string; to?: string; reason?: string; sessionId?: string };
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;
+90 -40
View File
@@ -59,15 +59,19 @@ interface MetonaAgentAPI {
/** 发送 MetonaMessageIR 类型)+ sessionId 到主进程 */
sendMessage: (message: MetonaMessageInput, sessionId: string) => Promise<{ success: boolean }>;
onStreamEvent: (callback: (event: MetonaStreamEventData) => void) => () => void;
onStateChange: (callback: (state: {
sessionId: string | null;
iteration: number;
state: string;
previous: string;
current: 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;
onProviderSwitched: (
callback: (data: { from?: string; to?: string; reason?: string }) => void,
) => () => void;
}
// ===== Sessions API =====
@@ -87,25 +91,40 @@ interface MetonaSessionsAPI {
create: (title?: string) => Promise<MetonaSessionInfo>;
rename: (sessionId: string, title: string) => Promise<{ success: boolean; error?: string }>;
delete: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
getMessages: (sessionId: string) => Promise<Array<{
id: string;
role: string;
content: string;
reasoningContent?: string;
toolCalls?: unknown[];
toolResult?: unknown;
attachments?: Array<{ id: string; name: string; type: string; size: number; preview?: string; textContent?: string }>;
iteration?: number;
timestamp: number;
}>>;
getMessages: (sessionId: string) => Promise<
Array<{
id: string;
role: string;
content: string;
reasoningContent?: string;
toolCalls?: unknown[];
toolResult?: unknown;
attachments?: Array<{
id: string;
name: string;
type: string;
size: number;
preview?: string;
textContent?: string;
}>;
iteration?: number;
timestamp: number;
}>
>;
pin: (sessionId: string, pinned: boolean) => Promise<{ success: boolean; error?: string }>;
archive: (sessionId: string, archived: boolean) => Promise<{ success: boolean; error?: string }>;
deleteMessage: (messageId: string) => Promise<{ success: boolean }>;
clearMessages: (sessionId: string) => Promise<{ success: boolean }>;
/** P2-11: 截断消息(编辑重发/重新生成) */
truncateAfter: (sessionId: string, messageId: string, inclusive?: boolean) =>
Promise<{ success: boolean; truncated?: number; error?: string }>;
saveTrace: (sessionId: string, data: { traceSteps: unknown[]; tokenUsage: unknown }) => Promise<{ success: boolean }>;
truncateAfter: (
sessionId: string,
messageId: string,
inclusive?: boolean,
) => Promise<{ success: boolean; truncated?: number; error?: string }>;
saveTrace: (
sessionId: string,
data: { traceSteps: unknown[]; tokenUsage: unknown },
) => Promise<{ success: boolean }>;
getTrace: (sessionId: string) => Promise<{ traceSteps: unknown[]; tokenUsage: unknown } | null>;
}
@@ -113,7 +132,8 @@ interface MetonaSessionsAPI {
interface MetonaMCPServerConfig {
name: string;
transport: 'stdio' | 'sse';
/** v0.4.1: 新增 'streamable-http'MCP 当前主流远程传输) */
transport: 'stdio' | 'sse' | 'streamable-http';
command?: string;
args?: string[];
url?: string;
@@ -147,11 +167,14 @@ interface MetonaMemorySearchResult {
}
interface MetonaMemoryAPI {
search: (query: string, options?: {
topK?: number;
type?: 'episodic' | 'semantic' | 'working';
minImportance?: number;
}) => Promise<MetonaMemorySearchResult[]>;
search: (
query: string,
options?: {
topK?: number;
type?: 'episodic' | 'semantic' | 'working';
minImportance?: number;
},
) => Promise<MetonaMemorySearchResult[]>;
listAll: (options?: { type?: string; limit?: number }) => Promise<{
success: boolean;
data?: { episodic?: unknown[]; semantic?: unknown[]; working?: unknown[] };
@@ -166,7 +189,9 @@ interface MetonaConfigAPI {
get: (key: string) => Promise<unknown>;
set: (key: string, value: unknown) => Promise<{ success: boolean; error?: string }>;
// v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败
setBatch: (entries: Array<{ key: string; value: unknown }>) => Promise<{ success: boolean; error?: string }>;
setBatch: (
entries: Array<{ key: string; value: unknown }>,
) => Promise<{ success: boolean; error?: string }>;
// v0.3.17: 监听配置变更广播(后端 config:set/setBatch 后触发,用于前端 store 实时更新)
onChanged: (callback: (data: { key: string; value: unknown }) => void) => () => void;
}
@@ -227,8 +252,11 @@ interface MetonaWorkspaceInfo {
interface MetonaWorkspaceAPI {
check: (targetPath: string) => Promise<MetonaWorkspaceCheckResult>;
inheritFiles: (params: { targetPath: string; sourcePath: string; files: string[] }) =>
Promise<MetonaWorkspaceInheritResult>;
inheritFiles: (params: {
targetPath: string;
sourcePath: string;
files: string[];
}) => Promise<MetonaWorkspaceInheritResult>;
getInfo: () => Promise<MetonaWorkspaceInfo>;
// #51 修复: 校验源数据库完整性(PRAGMA integrity_check
checkDatabaseIntegrity: (sourcePath: string) => Promise<{
@@ -242,11 +270,13 @@ interface MetonaWorkspaceAPI {
// ===== Toast API =====
interface MetonaToastAPI {
onShow: (callback: (data: {
type: 'success' | 'error' | 'warning' | 'info';
message: string;
options?: Record<string, unknown>;
}) => void) => () => void;
onShow: (
callback: (data: {
type: 'success' | 'error' | 'warning' | 'info';
message: string;
options?: Record<string, unknown>;
}) => void,
) => () => void;
}
// ===== Tools API =====
@@ -280,7 +310,11 @@ interface MetonaSearXNGTestResult {
interface MetonaSearXNGAPI {
/** 测试 SearXNG 实例连接可达性与认证有效性 */
testConnection: (url: string, authKey: string, authType: string) => Promise<MetonaSearXNGTestResult>;
testConnection: (
url: string,
authKey: string,
authType: string,
) => Promise<MetonaSearXNGTestResult>;
}
// ===== Data API =====
@@ -347,8 +381,11 @@ interface MetonaAuditVerifyResult {
interface MetonaAuditAPI {
verifyChain: () => Promise<MetonaAuditVerifyResult>;
query: (filters?: { sessionId?: string; eventType?: string; limit?: number }) =>
Promise<{ success: boolean; data?: unknown[]; error?: string }>;
query: (filters?: {
sessionId?: string;
eventType?: string;
limit?: number;
}) => Promise<{ success: boolean; data?: unknown[]; error?: string }>;
}
// ===== v0.2.0: Tool Confirmation API =====
@@ -386,8 +423,21 @@ interface MetonaToolAPI {
success: boolean;
data: MetonaConfirmationRequest[];
}>;
/**
* v0.4.1: 获取本会话内记住"拒绝"的工具列表(拒绝记忆 10 分钟 TTL,到期自动恢复询问)
* 供确认弹框展示"重新询问"入口
*/
getRememberedDenials: () => Promise<{
success: boolean;
data: Array<{ toolName: string; expiresInSeconds: number }>;
}>;
/** v0.4.1: 重置指定工具的会话内拒绝记忆(立即恢复询问) */
resetRememberedDenial: (toolName: string) => Promise<{ success: boolean; error?: string }>;
/** 设置/取消工具的持久化自动执行(跨会话不再询问) */
setAutoExecute: (toolName: string, enabled: boolean) => Promise<{ success: boolean; error?: string }>;
setAutoExecute: (
toolName: string,
enabled: boolean,
) => Promise<{ success: boolean; error?: string }>;
/** 获取已设置为自动执行的工具列表 */
getAutoExecuteList: () => Promise<{ success: boolean; data: string[] }>;
}