feat: v0.7.0 四阶段全量迭代 — 修复面收口 · 安全纵深 · 架构还债 · 能力演进
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m45s
CI / 全量测试 (Electron ABI) (push) Failing after 5m22s
CI / 产物编译验证 (push) Successful in 10m3s

P1 修复面收口: v0.6.3 截断自愈推全量(Anthropic/Ollama/非流式/引擎兜底); SSE 上游错误帧检测进重试通道;
clearMessages 摘要游标根治; truncateResult 内联图片白名单统一; 前端四 bug(确认弹窗锁死/MemoryViewer/
Virtuoso Footer/abort 尾部过滤) + reasoning 缓冲跨迭代污染; 托盘通知过滤与新建会话死链接线

P2 安全纵深: MCP 审批闭环(ConfirmationHook×PolicyEngine 联动+重名拒注册); SSRF 收敛 ssrf-guard 共享模块
(web_fetch 双通道校验+重定向终态复检); Electron 加固(preload CJS 化→sandbox:true/CSP/权限白名单/will-navigate);
run_command cmd.exe 白名单通道元字符守门; diff_viewer 10MB 预检; Anthropic thinking 预算下限; Agnes 思考显式关闭

P3 架构还债: OpenAICompatibleAdapter 中间基类收敛四家样板; 错误分类单轨化(删 mapError/getFetchSignal,
超时显式 ETIMEDOUT); PRAGMA user_version 迁移版本化; 死代码清理专项(cn.ts/SHORTCUTS/ContextMenu 分支/
getWindowState/modifiedArgs/sandbox 空壳); i18next 引入; a11y 第一轮; SearXNG 页批量草稿模型统一

P4 能力演进: Ollama pull 可取消/capabilities 探测/num_ctx 实测缓存; UpdateService feed 比对式自动更新
(app:updateCheck IPC + StatusBar 入口); MiMo providerOptions(web_search 服务端工具/strict JSON);
web_fetch extract_mode=markdown(turndown); network.proxyUrl 全局代理(Chromium sessions+undici dispatcher)

测试: 264 → 507 用例(Electron ABI 全绿零跳过), 覆盖引擎压缩管线/重试竞速/MEMORY.md 闸门/file_editor 五操作/
filesystem 七工具实体夹具/git 真实仓库/SSE 错误帧/全线截断自愈/Provider 请求形态矩阵/SSRF 表测/钩子分级矩阵/
OutputValidator 全量/SLO 指标/MCP 安全纯函数/task_manager 链路/渲染层纯域/i18n 桥契约
This commit is contained in:
2026-08-27 17:06:58 +08:00
parent b6e2a8bd25
commit 3940716dc2
78 changed files with 6369 additions and 1341 deletions
+34 -5
View File
@@ -226,6 +226,33 @@ export function ConfirmationDialog(): React.JSX.Element | null {
return () => clearInterval(interval);
}, [requests, refreshPending]);
// ===== v0.6.4 根治"超时锁死"===== 原缺陷:倒计时归零后若 refreshPending 拉回的
// 条目仍处于过期态(后端超时 timer 尚未触发),isExpired 使全部按钮 disabled 且
// ESC/backdrop 关闭被禁止 —— 弹窗进入完全锁死的静止态。
// 双保险修复:
// ① 兜底自动拒绝 —— 过期 2.5 秒后若过期条目仍在(F-6 刷新没能消解它们),
// 前端按后端一致的"超时=拒绝"语义补发批量响应并移除,弹窗必然收敛;
// ② 恢复用户能动性 —— 见下方按钮区:拒绝全部始终可用、ESC/backdrop 可执行
// 拒绝全部(对已失效 id 的响应由后端安全跳过,不再有状态不一致风险)。
useEffect(() => {
if (requests.length === 0 || remainingMs > 0) return;
const expiredIds = requests
.filter((r) => typeof r.expiresAt === 'number' && r.expiresAt <= Date.now())
.map((r) => r.toolCallId);
if (expiredIds.length === 0) return;
const timer = setTimeout(() => {
window.metona?.tool?.sendConfirmationResponseBatch({
toolCallIds: expiredIds,
approved: false,
remember: false,
});
const idSet = new Set(expiredIds);
setRequests((prev) => prev.filter((r) => !idSet.has(r.toolCallId)));
setSelectedIds(new Set());
}, 2500);
return () => clearTimeout(timer);
}, [requests, remainingMs]);
// 按工具名分组(同工具多次调用折叠为一组)
const grouped: GroupedRequests[] = useMemo(() => {
const map = new Map<string, GroupedRequests>();
@@ -371,9 +398,9 @@ export function ConfirmationDialog(): React.JSX.Element | null {
<Dialog
open={requests.length > 0}
onClose={(_, reason) => {
// 超时禁止通过外部点击/ESC 关闭(与"拒绝全部"按钮 disabled 一致)
// 防止超时后用户误触关闭,导致后端 pending 状态不一致
if (isExpired) return;
// v0.6.4: 移除"超时禁止关闭"拦截 —— 它配合全按钮 disabled 会把弹窗
// 锁死在静止态(见上方自动拒绝兜底注释)。对已失效 id 的批量响应由
// 后端 resolveConfirmationsBatch 安全跳过,拒绝语义幂等无副作用。
// 审查修复: 内层二次确认 Dialog 打开时,外层禁用 ESC/backdrop 关闭
// 防止 ESC 穿透到外层导致意外拒绝所有工具执行
if (confirmAutoExecute) return;
@@ -660,12 +687,13 @@ export function ConfirmationDialog(): React.JSX.Element | null {
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
{/* v0.6.4: 拒绝全部不再因过期被 disabled —— 过期场景下这是唯一有效的清理动作 */}
<Button
onClick={() => handleRespond(false, false)}
color="error"
variant="outlined"
size="small"
disabled={isExpired}
title={isExpired ? '已超时,点击立即清理全部请求' : '拒绝全部请求'}
>
({totalCount})
</Button>
@@ -675,7 +703,7 @@ export function ConfirmationDialog(): React.JSX.Element | null {
variant="outlined"
size="small"
disabled={isExpired || allSelected}
title={allSelected ? '已全部选中,请使用"批准选中"' : '批准全部请求'}
title={isExpired ? '已超时(批准不再生效)' : allSelected ? '已全部选中,请使用"批准选中"' : '批准全部请求'}
>
</Button>
@@ -686,6 +714,7 @@ export function ConfirmationDialog(): React.JSX.Element | null {
size="small"
autoFocus
disabled={isExpired || selectedCount === 0}
title={isExpired ? '已超时,将自动拒绝;如需重新触发请等待 Agent 重试' : undefined}
>
{isExpired
? '已超时'
+3 -109
View File
@@ -36,7 +36,9 @@ import { create } from 'zustand';
import { useAgentStore } from '@renderer/stores/agent-store';
import { useSessionStore } from '@renderer/stores/session-store';
export type ContextMenuType = 'message' | 'tool-call' | 'session' | 'code-block' | 'trace-step';
// v0.6.4 死代码清理:'tool-call' / 'code-block' / 'trace-step' 三个从未被任何组件
// 接线的分支已删除(ToolCallCard / TraceStep 本就未挂 onContextMenu)。
export type ContextMenuType = 'message' | 'session';
interface ContextMenuItem {
id: string;
@@ -288,48 +290,6 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
return items;
}
case 'tool-call': {
const tc = data as { args?: Record<string, unknown>; result?: unknown } | undefined;
return [
{
id: 'view-params',
icon: Eye,
label: '查看参数',
action: () => {
if (tc?.args) copyWithToast(JSON.stringify(tc.args, null, 2));
},
},
{
id: 'view-result',
icon: Eye,
label: '查看完整结果',
action: () => {
if (tc?.result) copyWithToast(JSON.stringify(tc.result, null, 2));
},
},
{
id: 'copy-result',
icon: Copy,
label: '复制结果',
action: () => {
if (tc?.result)
copyWithToast(typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result));
},
},
{
id: 're-execute',
icon: RotateCcw,
label: '重新执行',
action: () => {
const m = [...useAgentStore.getState().messages]
.reverse()
.find((m) => m.role === 'user');
if (m) useAgentStore.getState().sendMessage(m.content);
},
},
];
}
case 'session': {
const sid = (data as { sessionId?: string })?.sessionId;
// 修复: 4 个 session 操作统一改为 await IPC + 失败回滚,避免 UI 与 DB 状态不一致
@@ -511,72 +471,6 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
];
}
case 'code-block': {
const code = (data as { code?: string })?.code;
return [
{
id: 'copy-code',
icon: Copy,
label: '复制代码',
action: () => {
if (code) copyWithToast(code);
},
},
{
id: 'open-editor',
icon: Code,
label: '在编辑器中打开',
action: () => {
if (code)
window.open(URL.createObjectURL(new Blob([code], { type: 'text/plain' })), '_blank');
},
},
];
}
case 'trace-step': {
const step = data as
| { thought?: string; toolCalls?: Array<{ name: string; args: Record<string, unknown> }> }
| undefined;
return [
{
id: 'copy-thought',
icon: Copy,
label: '复制 Thought',
action: () => {
if (step?.thought) copyWithToast(step.thought);
},
},
{
id: 'copy-params',
icon: Copy,
label: '复制工具参数',
action: () => {
if (step?.toolCalls)
copyWithToast(
step.toolCalls
.map((tc) => `${tc.name}: ${JSON.stringify(tc.args, null, 2)}`)
.join('\n'),
);
},
},
{
id: 'export',
icon: ExternalLink,
label: '导出步骤详情',
action: () => {
if (step) {
const b = new Blob([JSON.stringify(step, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = `trace-step-${Date.now()}.json`;
a.click();
}
},
},
];
}
default:
return [];
}
+25 -7
View File
@@ -18,6 +18,18 @@ import { useAgentStore } from '@renderer/stores/agent-store';
import { MessageItem } from './MessageItem';
import { StreamingIndicator } from './StreamingIndicator';
/**
* v0.6.4 修复: Virtuoso components.Footer 必须是稳定引用 —— 原实现每次 render
* 都传入新的内联匿名组件,react-virtuoso 按组件类型做 reconciliation,类型变化
* 导致 Footer(内含 StreamingIndicator)被整体卸载重建、动画状态反复重置。
* 提升为模块级常量组件后引用恒定,Footer 仅挂载一次。
*/
const ListFooter = (): React.JSX.Element => (
<Box sx={{ maxWidth: 768, mx: 'auto', px: 2, pb: 3 }}>
<StreamingIndicator />
</Box>
);
export function MessageList(): React.JSX.Element {
const messages = useAgentStore((s) => s.messages);
const isStreaming = useAgentStore((s) => s.isStreaming);
@@ -65,7 +77,16 @@ export function MessageList(): React.JSX.Element {
}
return (
<Virtuoso
<Box
// v0.6.4 P3-6 a11y: 消息区声明为 live region —— 屏幕阅读器可感知流式增量
// 与新消息到达(此前流式输出对辅助技术完全静默)。role="log" 表明追加语义。
component="section"
role="log"
aria-live="polite"
aria-label="聊天消息列表"
sx={{ height: '100%', display: 'flex', flexDirection: 'column', minHeight: 0 }}
>
<Virtuoso
ref={virtuosoRef}
// key=sessionId: 切换会话时强制重新挂载,使 initialTopMostItemIndex 重新生效
// (定位到新会话的最后一条消息;同会话内 messages 变化不触发 remount
@@ -90,12 +111,9 @@ export function MessageList(): React.JSX.Element {
</Box>
)}
components={{
Footer: () => (
<Box sx={{ maxWidth: 768, mx: 'auto', px: 2, pb: 3 }}>
<StreamingIndicator />
</Box>
),
Footer: ListFooter,
}}
/>
/>
</Box>
);
}
+1 -9
View File
@@ -26,15 +26,7 @@ import {
} from 'lucide-react';
import { useUIStore, type ThemeMode } from '@renderer/stores/ui-store';
import { useAgentStore } from '@renderer/stores/agent-store';
const PROVIDER_LABELS: Record<string, string> = {
deepseek: 'DeepSeek',
agnes: 'Agnes',
mimo: 'MiMo',
ollama: 'Ollama',
openai: 'OpenAI',
anthropic: 'Anthropic',
};
import { PROVIDER_LABELS } from '@renderer/lib/constants';
export function Header(): React.JSX.Element {
const sidebarVisible = useUIStore((s) => s.sidebarVisible);
+8 -12
View File
@@ -179,18 +179,13 @@ export function Sidebar(): React.JSX.Element {
return; // 不创建本地假会话
}
}
const newSession: Session = {
id: `s_${Date.now()}`,
title: '新会话',
createdAt: Date.now(),
updatedAt: Date.now(),
messageCount: 0,
pinned: false,
archived: false,
};
useSessionStore.getState().addSession(newSession);
setCurrentSession(newSession.id);
loadSessionMessages(newSession.id);
// v0.6.4 修复: 与 M-9 注释对齐 —— IPC 桥不可用(window.metona.sessions.create
// 不存在)时同样不构造本地假会话。假会话重启后即蒸发,且会污染 session 列表状态。
// 直接提示用户(正常构建下 preload 必然提供该 API,此分支仅在桥接损坏时触达)。
console.error('[Sidebar] window.metona.sessions.create is unavailable — IPC bridge broken');
import('@metona-team/metona-toast')
.then((mod) => mod.default.error('IPC 桥不可用:无法创建会话'))
.catch(() => {});
};
return (
@@ -517,6 +512,7 @@ function SessionItem({
<IconButton
className="delete-btn"
size="small"
aria-label={`删除会话 ${session.title}`}
onClick={handleDelete}
sx={{
opacity: 0,
+47 -1
View File
@@ -22,6 +22,7 @@ export function StatusBar(): React.JSX.Element {
const [version, setVersion] = useState(
typeof __APP_VERSION__ !== 'undefined' && __APP_VERSION__ ? `v${__APP_VERSION__}` : 'dev',
);
const [checkingUpdate, setCheckingUpdate] = useState(false);
useEffect(() => {
// M-28 修复: 添加 cancelled 标志防止组件卸载后 setState
@@ -82,7 +83,52 @@ export function StatusBar(): React.JSX.Element {
{/* 右侧:版本 + 设置 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>{version}</Typography>
{/* v0.6.4 P4-2: 版本号可点击 → 手动检查更新(feed 比对式) */}
<Tooltip title={`点击检查更新(当前 ${version}`}>
<Typography
variant="caption"
component="button"
onClick={() => {
if (checkingUpdate) return;
setCheckingUpdate(true);
window.metona?.app
?.updateCheck()
.then((result) => {
if (result.status === 'available') {
import('@metona-team/metona-toast')
.then((mod) =>
mod.default.info(`发现新版本 ${result.latestVersion},点击此通知或设置中打开下载页`, {
onClick: result.downloadUrl
? () =>
void window.metona?.app?.openExternal(result.downloadUrl as string)
: undefined,
}),
)
.catch(() => {});
} else if (result.status === 'up-to-date') {
import('@metona-team/metona-toast')
.then((mod) => mod.default.success('已是最新版本'))
.catch(() => {});
} else {
import('@metona-team/metona-toast')
.then((mod) => mod.default.warning(result.message))
.catch(() => {});
}
})
.catch((err) => console.error('[StatusBar] update check failed:', err))
.finally(() => setCheckingUpdate(false));
}}
sx={{
color: checkingUpdate ? 'warning.main' : 'text.disabled',
fontSize: 10,
cursor: 'pointer',
border: 0, p: 0, bgcolor: 'transparent', lineHeight: 1,
'&:hover': { color: 'primary.main' },
}}
>
{checkingUpdate ? '检查中…' : version}
</Typography>
</Tooltip>
<Tooltip title="设置 (Ctrl+,)">
<IconButton size="small" onClick={openSettings} sx={{ color: 'text.secondary', width: 28, height: 28 }}>
<Settings size={14} />
+20 -3
View File
@@ -79,7 +79,13 @@ export function MemoryViewer(): React.JSX.Element {
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [searching, setSearching] = useState(false);
const [expanded, setExpanded] = useState<MemoryType | 'all'>('all');
// v0.6.4 修复(折叠交互根治):单值 `MemoryType | 'all'` 无法同时表达
// "全展开"与"收起其一"——初始 'all' 时点击任一类型想收起,onChange(false) 会把
// 状态设回 'all',首次点击视觉无效、二次点击却收起其它组。改为显式集合模型,
// 每个类型的展开态互不干扰,初始仍为全展开(保持原体验)。
const [expandedTypes, setExpandedTypes] = useState<Set<MemoryType>>(
() => new Set<MemoryType>(MEMORY_TYPES),
);
// Agent 完成时自动刷新:监听 agentStatus 从 thinking/executing -> idle
const agentStatus = useAgentStore((s) => s.agentStatus);
@@ -292,8 +298,18 @@ export function MemoryViewer(): React.JSX.Element {
return (
<Accordion
key={type}
expanded={expanded === type || expanded === 'all'}
onChange={(_, isExpanded) => setExpanded(isExpanded ? type : 'all')}
expanded={expandedTypes.has(type)}
onChange={(_, isExpanded) =>
setExpandedTypes((prev) => {
const next = new Set(prev);
if (isExpanded) {
next.add(type);
} else {
next.delete(type);
}
return next;
})
}
elevation={0}
sx={{
'&:before': { display: 'none' },
@@ -455,6 +471,7 @@ function MemorySearchItem({
<IconButton
className="delete-btn"
size="small"
aria-label="删除该记忆"
onClick={() => onDelete(item.type, item.id)}
sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, '&:hover': { color: 'error.main' } }}
>
+243 -43
View File
@@ -3,9 +3,15 @@
*
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
* 功能:SearXNG 实例配置(12 项)+ 连接测试。
*
* v0.6.4 P3-7 重构(批量草稿模型统一):原先 12 个字段各自经 useConfig
* "逐键实时落库" —— 数字输入每敲一个字符就触发一次 IPC 写 + reloadAdapter 副作用,
* 且 SearXNG 的数字字段无范围 guard 会把中间态写进配置。现改为与 LLMSettings 同一
* 草稿范式:mount 时一次读入 → 本地草稿编辑(dirty 标记)→ 显式"保存"批量提交。
* 启用开关保持即时生效(它是功能总开关,语义上属于立即动作而非表单字段)。
*/
import { useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Button,
TextField,
@@ -24,36 +30,162 @@ import {
IconButton,
CircularProgress,
} from '@mui/material';
import { Eye, EyeOff } from 'lucide-react';
import { useConfig } from './useConfig';
import { Eye, EyeOff, Save } from 'lucide-react';
interface DraftConfig {
url: string;
engines: string;
language: string;
safesearch: number;
time_range: string;
max_results: number;
auth_key: string;
auth_type: string;
format: string;
fetch_count: number;
fetch_mode: string;
}
type DraftKey = keyof DraftConfig;
const DRAFT_DEFAULTS: DraftConfig = {
url: '',
engines: '',
language: 'zh-CN',
safesearch: 1,
time_range: '',
max_results: 0,
auth_key: '',
auth_type: 'bearer',
format: 'json',
fetch_count: 0,
fetch_mode: 'sequential',
};
/** 配置键 → 草稿字段的映射与读取类型转换 */
const KEY_MAP: Array<{ key: string; field: DraftKey; type: 'string' | 'number' }> = [
{ key: 'searxng.url', field: 'url', type: 'string' },
{ key: 'searxng.engines', field: 'engines', type: 'string' },
{ key: 'searxng.language', field: 'language', type: 'string' },
{ key: 'searxng.safesearch', field: 'safesearch', type: 'number' },
{ key: 'searxng.time_range', field: 'time_range', type: 'string' },
{ key: 'searxng.max_results', field: 'max_results', type: 'number' },
{ key: 'searxng.auth_key', field: 'auth_key', type: 'string' },
{ key: 'searxng.auth_type', field: 'auth_type', type: 'string' },
{ key: 'searxng.format', field: 'format', type: 'string' },
{ key: 'searxng.fetch_count', field: 'fetch_count', type: 'number' },
{ key: 'searxng.fetch_mode', field: 'fetch_mode', type: 'string' },
];
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 [enabled, setEnabledState] = useState<boolean>(false);
const [enabledLoaded, setEnabledLoaded] = useState(false);
const [draft, setDraft] = useState<DraftConfig>(DRAFT_DEFAULTS);
/** 首次加载完成(或最近一次成功保存)时的草稿快照 —— dirty 判定基准 */
const [baseline, setBaseline] = useState<DraftConfig | null>(null);
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
const [savedNotice, setSavedNotice] = useState(false);
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);
// ===== mount:一次性读入全部配置(12 次 IPC 并行,替代原逐键写路径)=====
useEffect(() => {
let cancelled = false;
void (async () => {
const entries = await Promise.all(
KEY_MAP.map(async ({ key }) => ({ key, value: await window.metona?.config?.get(key) })),
);
if (cancelled) return;
const next = { ...DRAFT_DEFAULTS };
for (const entry of entries) {
const spec = KEY_MAP.find((k) => k.key === entry.key)!;
if (entry.value === null || entry.value === undefined) continue;
(next[spec.field] as unknown) =
spec.type === 'number' ? Number(entry.value) : String(entry.value);
}
setDraft(next);
// 精确 dirty 基准:以加载时快照对比,而非"任何交互即 dirty"
setBaseline(next);
setLoaded(true);
})();
void window.metona?.config
?.get('searxng.enabled')
.then((v) => {
if (!cancelled) {
setEnabledState(v === true);
setEnabledLoaded(true);
}
})
.catch(() => setEnabledLoaded(true));
return () => {
cancelled = true;
};
}, []);
const updateField = useCallback(<K extends DraftKey>(field: K, value: DraftConfig[K]) => {
setSavedNotice(false);
setDraft((prev) => ({ ...prev, [field]: value }));
}, []);
// v0.6.4 P3-7: dirty 精确判定 —— 当前草稿与基线快照逐字段比较
const dirty = useMemo(() => {
if (!loaded || !baseline) return false;
return KEY_MAP.some(({ field }) => draft[field] !== baseline[field]);
}, [draft, baseline, loaded]);
const handleSave = async () => {
setSaving(true);
try {
await window.metona?.config?.setBatch(
KEY_MAP.map(({ key, field }) => ({ key, value: draft[field] as string | number })),
);
setBaseline(draft);
setSavedNotice(true);
import('@metona-team/metona-toast')
.then((mod) => mod.default.success('SearXNG 配置已保存'))
.catch(() => {});
} catch (err) {
console.error('[SearXNGSettings]', err);
import('@metona-team/metona-toast')
.then((mod) => mod.default.error('保存失败'))
.catch(() => {});
}
setSaving(false);
};
// ===== 数字输入安全钳制(v0.6.4: 原 useConfig 无 guard 导致中间态逐键落库)=====
const clampNumber = (value: number, min: number, max: number): number =>
Number.isFinite(value) ? Math.min(max, Math.max(min, Math.trunc(value))) : min;
const handleToggleEnabled = async (checked: boolean): Promise<void> => {
// 总开关保持即时落库(立即动作语义),并向前端状态同步
setEnabledState(checked);
try {
await window.metona?.config?.set('searxng.enabled', checked);
} catch (err) {
console.error('[SearXNGSettings] toggle failed:', err);
setEnabledState(!checked);
import('@metona-team/metona-toast')
.then((mod) => mod.default.error('开关保存失败'))
.catch(() => {});
}
};
const urlError = !!draft.url && !/^https?:\/\//.test(draft.url);
const handleTest = async () => {
if (!url.trim() || urlError) return;
if (!draft.url.trim() || urlError) return;
setTesting(true);
setTestResult(null);
try {
const result = await window.metona?.searxng?.testConnection(url.trim(), authKey, authType);
const result = await window.metona?.searxng?.testConnection(
draft.url.trim(),
draft.auth_key,
draft.auth_type,
);
if (result?.success) {
setTestResult({ success: true, message: `连接成功(${result.latencyMs}ms` });
} else {
@@ -68,6 +200,17 @@ export function SearXNGSettings() {
setTesting(false);
};
if (!enabledLoaded || !loaded) {
return (
<Stack spacing={1} sx={{ alignItems: 'center', py: 3 }}>
<CircularProgress size={18} />
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
...
</Typography>
</Stack>
);
}
return (
<Stack spacing={2}>
{/* 标题 + 状态徽章 */}
@@ -87,10 +230,15 @@ export function SearXNGSettings() {
退 Bing + + + 360
</Typography>
{/* 启用开关 */}
{/* 启用开关(即时生效的总开关) */}
<FormControlLabel
control={
<Switch checked={enabled} onChange={(e) => setEnabled(e.target.checked)} size="small" />
<Switch
checked={enabled}
onChange={(e) => void handleToggleEnabled(e.target.checked)}
size="small"
slotProps={{ input: { 'aria-label': '启用 SearXNG' } }}
/>
}
label={<Typography variant="body2"> SearXNG</Typography>}
/>
@@ -102,8 +250,8 @@ export function SearXNGSettings() {
<TextField
size="small"
label="API 地址"
value={url}
onChange={(e) => setUrl(e.target.value)}
value={draft.url}
onChange={(e) => updateField('url', e.target.value)}
placeholder="如 https://searxng.example.com"
error={urlError}
helperText={
@@ -115,7 +263,7 @@ export function SearXNGSettings() {
variant="outlined"
size="small"
onClick={handleTest}
disabled={!url.trim() || urlError || testing}
disabled={!draft.url.trim() || urlError || testing}
sx={{ mt: 0.5, minWidth: 90, height: 40 }}
>
{testing ? <CircularProgress size={14} /> : '测试连接'}
@@ -136,8 +284,8 @@ export function SearXNGSettings() {
<TextField
size="small"
label="搜索引擎(逗号分隔)"
value={engines}
onChange={(e) => setEngines(e.target.value)}
value={draft.engines}
onChange={(e) => updateField('engines', e.target.value)}
placeholder="如 google,bing,duckduckgo(留空使用实例默认)"
/>
@@ -145,7 +293,11 @@ export function SearXNGSettings() {
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<FormControl size="small">
<InputLabel></InputLabel>
<Select value={language} label="语言" onChange={(e) => setLanguage(e.target.value)}>
<Select
value={draft.language}
label="语言"
onChange={(e) => updateField('language', e.target.value)}
>
<MenuItem value="zh-CN"></MenuItem>
<MenuItem value="zh-TW"></MenuItem>
<MenuItem value="en">English</MenuItem>
@@ -157,9 +309,9 @@ export function SearXNGSettings() {
<FormControl size="small">
<InputLabel></InputLabel>
<Select
value={safesearch}
value={draft.safesearch}
label="安全搜索"
onChange={(e) => setSafesearch(e.target.value as number)}
onChange={(e) => updateField('safesearch', Number(e.target.value))}
>
<MenuItem value={0}></MenuItem>
<MenuItem value={1}></MenuItem>
@@ -172,7 +324,11 @@ export function SearXNGSettings() {
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<FormControl size="small">
<InputLabel></InputLabel>
<Select value={timeRange} label="时间范围" onChange={(e) => setTimeRange(e.target.value)}>
<Select
value={draft.time_range}
label="时间范围"
onChange={(e) => updateField('time_range', e.target.value)}
>
<MenuItem value=""></MenuItem>
<MenuItem value="day"></MenuItem>
<MenuItem value="week"></MenuItem>
@@ -182,7 +338,11 @@ export function SearXNGSettings() {
</FormControl>
<FormControl size="small">
<InputLabel></InputLabel>
<Select value={format} label="返回格式" onChange={(e) => setFormat(e.target.value)}>
<Select
value={draft.format}
label="返回格式"
onChange={(e) => updateField('format', e.target.value)}
>
<MenuItem value="json">JSON</MenuItem>
<MenuItem value="html">HTML</MenuItem>
</Select>
@@ -195,8 +355,10 @@ export function SearXNGSettings() {
size="small"
label="最大结果数"
type="number"
value={maxResults}
onChange={(e) => setMaxResults(Number(e.target.value))}
value={draft.max_results}
onChange={(e) =>
updateField('max_results', clampNumber(Number(e.target.value), 0, 50))
}
placeholder="0 表示使用默认"
slotProps={{ htmlInput: { min: 0, max: 50 } }}
/>
@@ -204,8 +366,10 @@ export function SearXNGSettings() {
size="small"
label="自动抓取条数"
type="number"
value={fetchCount}
onChange={(e) => setFetchCount(Number(e.target.value))}
value={draft.fetch_count}
onChange={(e) =>
updateField('fetch_count', clampNumber(Number(e.target.value), 0, 8))
}
placeholder="0 表示由 AI 决定"
slotProps={{ htmlInput: { min: 0, max: 8 } }}
/>
@@ -214,7 +378,11 @@ export function SearXNGSettings() {
{/* 抓取类型 */}
<FormControl size="small">
<InputLabel></InputLabel>
<Select value={fetchMode} label="抓取类型" onChange={(e) => setFetchMode(e.target.value)}>
<Select
value={draft.fetch_mode}
label="抓取类型"
onChange={(e) => updateField('fetch_mode', e.target.value)}
>
<MenuItem value="sequential"></MenuItem>
<MenuItem value="random"></MenuItem>
</Select>
@@ -229,22 +397,30 @@ export function SearXNGSettings() {
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 2 }}>
<FormControl size="small">
<InputLabel></InputLabel>
<Select value={authType} label="认证类型" onChange={(e) => setAuthType(e.target.value)}>
<Select
value={draft.auth_type}
label="认证类型"
onChange={(e) => updateField('auth_type', e.target.value)}
>
<MenuItem value="bearer">Bearer Token</MenuItem>
<MenuItem value="basic">Basic Auth</MenuItem>
</Select>
</FormControl>
<TextField
size="small"
label={authType === 'bearer' ? 'Token' : '用户名:密码'}
label={draft.auth_type === 'bearer' ? 'Token' : '用户名:密码'}
type={showKey ? 'text' : 'password'}
value={authKey}
onChange={(e) => setAuthKey(e.target.value)}
placeholder={authType === 'bearer' ? '访问令牌原值' : 'username:password'}
value={draft.auth_key}
onChange={(e) => updateField('auth_key', e.target.value)}
placeholder={draft.auth_type === 'bearer' ? '访问令牌原值' : 'username:password'}
slotProps={{
input: {
endAdornment: (
<IconButton size="small" onClick={() => setShowKey(!showKey)}>
<IconButton
size="small"
onClick={() => setShowKey(!showKey)}
aria-label={showKey ? '隐藏密钥' : '显示密钥'}
>
{showKey ? <EyeOff size={14} /> : <Eye size={14} />}
</IconButton>
),
@@ -253,10 +429,34 @@ export function SearXNGSettings() {
/>
</Box>
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
{authType === 'bearer'
{draft.auth_type === 'bearer'
? 'Bearer: 直接填写令牌原值,原样透传到 Authorization 头。建议配合 HTTPS 使用。'
: 'Basic: 填写 username:password 明文串,系统自动 Base64 编码。必须配合 HTTPS 使用。'}
</Typography>
{/* 保存区(批量草稿提交 + 变更提示) */}
<Divider />
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Button
variant="contained"
size="small"
startIcon={<Save size={14} />}
disabled={!dirty || saving}
onClick={() => void handleSave()}
>
{saving ? '保存中...' : '保存更改'}
</Button>
{dirty && (
<Typography variant="caption" sx={{ color: 'warning.main' }}>
</Typography>
)}
{!dirty && savedNotice && (
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
</Typography>
)}
</Stack>
</Stack>
);
}
+59 -1
View File
@@ -6,8 +6,9 @@
*/
import { useState, useEffect } from 'react';
import { Button, Stack, Typography, Checkbox, Divider, Chip, Box } from '@mui/material';
import { Button, Stack, Typography, Checkbox, Divider, Chip, Box, TextField } from '@mui/material';
import { alpha } from '@mui/material/styles';
import { useConfig } from './useConfig';
export function ToolsSettings() {
const [tools, setTools] = useState<
@@ -306,6 +307,63 @@ export function ToolsSettings() {
))}
</>
)}
{/* ===== 网络代理(v0.6.4 P4-5===== */}
<Divider sx={{ my: 1 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
</Typography>
<ProxyField />
</Stack>
);
}
/**
* v0.6.4 P4-5: network.proxyUrl 编辑项 —— useConfig 保存即触发主进程
* applySessionProxyshared.ts 副作用联动),Chromium session 与主进程 fetch
* 双通道同时生效。格式示例:http://127.0.0.1:7890 或 socks5://user:pass@host:1080。
*/
function ProxyField(): React.JSX.Element {
const [proxyUrl, setProxyUrl] = useConfig('network.proxyUrl', '');
const [draft, setDraft] = useState<string>('');
// 首次载入后把配置值同步到草稿(此后用户自由编辑,blur/save 时提交)
const [synced, setSynced] = useState(false);
useEffect(() => {
if (!synced && proxyUrl !== null) {
setDraft(proxyUrl);
setSynced(true);
}
}, [proxyUrl, synced]);
const invalid =
!!draft.trim() && !/^(https?|socks[45]):\/\//i.test(draft.trim());
return (
<>
<TextField
size="small"
label="代理地址(可选)"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => {
if (draft.trim() !== (proxyUrl ?? '') && !invalid) {
setProxyUrl(draft.trim());
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
}
}}
placeholder="http://127.0.0.1:7890 或 socks5://…(留空直连)"
error={invalid}
helperText={
invalid
? '需以 http://、https://、socks5:// 或 socks4:// 开头'
: '应用于 Chromium 会话与主进程全部网络请求;未填时回退系统环境变量 HTTPS_PROXY。'
}
/>
</>
);
}
+4 -2
View File
@@ -9,7 +9,7 @@ import {
Box, Typography, Stack, List, ListItem, ListItemIcon,
Checkbox, Chip, IconButton, TextField, Button, Select, MenuItem, Collapse,
} from '@mui/material';
import { ListChecks, Plus, Trash2, ChevronRight, Circle } from 'lucide-react';
import { ListChecks, Plus, Trash2, X, ChevronRight, Circle } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
import { formatTime } from '@renderer/lib/formatters';
@@ -193,6 +193,7 @@ export function TaskList(): React.JSX.Element {
onClick={() => setShowAddForm(!showAddForm)}
sx={{ p: 0.25, color: 'primary.main', '&:hover': { bgcolor: 'action.hover' } }}
title="新增任务"
aria-label={showAddForm ? '取消新增' : '新增任务'}
>
<Plus size={14} />
</IconButton>
@@ -251,8 +252,9 @@ export function TaskList(): React.JSX.Element {
size="small"
onClick={() => { setShowAddForm(false); setNewTitle(''); setNewPriority('medium'); }}
sx={{ p: 0.5, color: 'text.secondary' }}
aria-label="取消新增任务"
>
<Trash2 size={12} />
<X size={12} />
</IconButton>
</Stack>
</Box>