feat: v0.7.0 四阶段全量迭代 — 修复面收口 · 安全纵深 · 架构还债 · 能力演进
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:
@@ -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
|
||||
? '已超时'
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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' } }}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 保存即触发主进程
|
||||
* applySessionProxy(shared.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。'
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user