From dde21f0b3fa84566546917135987c7b2004567e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B4=AB=E5=BD=B1233?= <1440196015@qq.com> Date: Mon, 20 Jul 2026 21:22:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8D=87=E7=BA=A7=E8=87=B3=20v0.3.8=20?= =?UTF-8?q?=E2=80=94=20Trace=20Viewer=20=E5=8F=A0=E5=8A=A0=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20+=20LLM=20=E9=85=8D=E7=BD=AE=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E6=94=B9=E9=80=A0=20+=20toast=20=E5=8F=8D=E9=A6=88=E7=BB=9F?= =?UTF-8?q?=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Trace Viewer 叠加渲染修复 - sendMessage 补 traceSteps/tokenUsage 重置(止血) - TraceStep 加 runId 字段,useAgentStream 按 runId 判定同迭代(稳健改造) - LLM 配置校验中等改造(SettingsModal LLMSettings) - 取消 onChange 实时落库,改为本地 state + Save 按钮统一提交 - 字段级 inline error:Base URL http(s):// 正则、Model 空格校验、contextWindow ≥ 4096 - Provider 切换清空 Model(之前只清 apiKey) - 保存结果改用 metona-toast(不再用 Alert) - toast 反馈机制全面统一(15 处问题修复) - ContextMenu: 原生 confirm/prompt 改 MUI Dialog(新增 useDialogStore + ContextMenuDialogHost) - ContextMenu: 抽 copyWithToast helper 替代散落 7 处静默失败 - OnboardingWizard: 配置保存失败静默改 toast.error - useKeyboardShortcuts: Ctrl+N / Ctrl+Shift+C 失败补 toast - SettingsModal: inheritFiles 失败 toast.warning、handleApply 失败 toast.error - LogsSettings: resultAlert Alert 改 toast(保留 Dialog 确认) - MemoryViewer: 删除失败改 toast(加载/搜索保留 Alert) - TaskList: create/update/delete/无会话改 toast - agent-store: sendMessage/session 失败补 toast(保留 system message) - 删除当前会话同步清空 ChatPanel/DetailPanel - Sidebar confirmDelete + ContextMenu delete 均补 agent-store 重置 - 修复 useSessionStore.removeSession 不触发 agent-store 同步的遗漏 --- README.md | 2 +- package-lock.json | 4 +- package.json | 2 +- src/App.tsx | 2 + src/components/ContextMenu.tsx | 165 ++++++++++- src/components/layout/Sidebar.tsx | 12 + src/components/memory/MemoryViewer.tsx | 6 +- .../onboarding/OnboardingWizard.tsx | 4 + src/components/settings/SettingsModal.tsx | 259 ++++++++++++++---- src/components/tasks/TaskList.tsx | 18 +- src/hooks/useAgentStream.ts | 16 +- src/hooks/useKeyboardShortcuts.ts | 12 +- src/stores/agent-store.ts | 13 + 13 files changed, 428 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 86c21da..52f28c2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > 生产级通用 AI Agent 智能体桌面应用 -[![Version](https://img.shields.io/badge/version-0.3.7-blue)](./package.json) +[![Version](https://img.shields.io/badge/version-0.3.8-blue)](./package.json) [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE) [![Electron](https://img.shields.io/badge/Electron-35-47848F)](https://www.electronjs.org/) [![React](https://img.shields.io/badge/React-19-61DAFB)](https://react.dev/) diff --git a/package-lock.json b/package-lock.json index 800d873..d37a43f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ai-desktop", - "version": "0.3.7", + "version": "0.3.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ai-desktop", - "version": "0.3.7", + "version": "0.3.8", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index 0aa5079..e7ddf02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ai-desktop", - "version": "0.3.7", + "version": "0.3.8", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "main": "dist-electron/main/main.js", "author": "Metona Team", diff --git a/src/App.tsx b/src/App.tsx index e2ac0c2..8e4608a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import { OnboardingWizard } from '@renderer/components/onboarding/OnboardingWiza import { CommandPalette } from '@renderer/components/CommandPalette'; import { ToastContainer } from '@renderer/components/ToastContainer'; import { ConfirmationDialog } from '@renderer/components/ConfirmationDialog'; +import { ContextMenuDialogHost } from '@renderer/components/ContextMenu'; import { useTheme } from '@renderer/hooks/useTheme'; import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts'; import { useAgentStream } from '@renderer/hooks/useAgentStream'; @@ -81,6 +82,7 @@ export default function App(): React.JSX.Element { + ); diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index e520fc0..ac06e62 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -6,8 +6,13 @@ * @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 右键菜单设计 */ -import { Menu, MenuItem, ListItemIcon, ListItemText } from '@mui/material'; +import { + Menu, MenuItem, ListItemIcon, ListItemText, + Dialog, DialogTitle, DialogContent, DialogActions, + Button, TextField, Typography, +} from '@mui/material'; import { Copy, Quote, Edit, Trash2, RotateCcw, Eye, Code, ExternalLink, Pin, Archive, FileDown } from 'lucide-react'; +import { create } from 'zustand'; import { useAgentStore } from '@renderer/stores/agent-store'; import { useSessionStore } from '@renderer/stores/session-store'; @@ -44,6 +49,126 @@ export function ContextMenu({ x, y, onClose, items }: ContextMenuProps): React.J ); } +/** + * 通用 Dialog Store — 模块级状态,供 createContextMenuItems 等工厂函数调用 + * + * 替代原生 confirm()/prompt()(Electron 下不可靠,且与 MUI 风格不一致)。 + * 由 ContextMenuDialogHost 组件渲染实际的 MUI Dialog。 + */ +interface DialogState { + // confirm dialog + confirmOpen: boolean; + confirmTitle: string; + confirmMessage: string; + confirmResolve: ((ok: boolean) => void) | null; + // prompt dialog + promptOpen: boolean; + promptTitle: string; + promptLabel: string; + promptValue: string; + promptResolve: ((value: string | null) => void) | null; + // actions + confirm: (message: string, title?: string) => Promise; + prompt: (label: string, defaultValue?: string, title?: string) => Promise; + setPromptValue: (v: string) => void; + closeConfirm: (ok: boolean) => void; + closePrompt: (value: string | null) => void; +} + +const useDialogStore = create((set, get) => ({ + confirmOpen: false, + confirmTitle: '', + confirmMessage: '', + confirmResolve: null, + promptOpen: false, + promptTitle: '', + promptLabel: '', + promptValue: '', + promptResolve: null, + confirm: (message, title = '确认') => new Promise((resolve) => { + set({ confirmOpen: true, confirmTitle: title, confirmMessage: message, confirmResolve: resolve }); + }), + prompt: (label, defaultValue = '', title = '输入') => new Promise((resolve) => { + set({ promptOpen: true, promptTitle: title, promptLabel: label, promptValue: defaultValue, promptResolve: resolve }); + }), + setPromptValue: (v) => set({ promptValue: v }), + closeConfirm: (ok) => { + const { confirmResolve } = get(); + confirmResolve?.(ok); + set({ confirmOpen: false, confirmResolve: null, confirmTitle: '', confirmMessage: '' }); + }, + closePrompt: (value) => { + const { promptResolve } = get(); + promptResolve?.(value); + set({ promptOpen: false, promptResolve: null, promptTitle: '', promptLabel: '', promptValue: '' }); + }, +})); + +/** + * ContextMenuDialogHost — 渲染通用 confirm/prompt Dialog + * + * 在 App 根组件挂载一次即可。由 useDialogStore 驱动显示。 + */ +export function ContextMenuDialogHost(): React.JSX.Element { + const confirmOpen = useDialogStore((s) => s.confirmOpen); + const confirmTitle = useDialogStore((s) => s.confirmTitle); + const confirmMessage = useDialogStore((s) => s.confirmMessage); + const closeConfirm = useDialogStore((s) => s.closeConfirm); + + const promptOpen = useDialogStore((s) => s.promptOpen); + const promptTitle = useDialogStore((s) => s.promptTitle); + const promptLabel = useDialogStore((s) => s.promptLabel); + const promptValue = useDialogStore((s) => s.promptValue); + const setPromptValue = useDialogStore((s) => s.setPromptValue); + const closePrompt = useDialogStore((s) => s.closePrompt); + + return ( + <> + closeConfirm(false)} maxWidth="xs" fullWidth> + {confirmTitle} + + {confirmMessage} + + + + + + + closePrompt(null)} maxWidth="xs" fullWidth> + {promptTitle} + + setPromptValue(e.target.value)} + autoFocus + fullWidth + size="small" + onKeyDown={(e) => { if (e.key === 'Enter') closePrompt(promptValue); }} + /> + + + + + + + + ); +} + +/** + * copyWithToast — 复制到剪贴板,失败时弹 toast + * + * 替代 navigator.clipboard.writeText(...).catch(err => console.error(...)) 模式。 + * 复制是用户主动操作,失败时必须有反馈。 + */ +function copyWithToast(text: string): void { + navigator.clipboard.writeText(text).catch((err) => { + console.error('[Clipboard]', err); + import('metona-toast').then((mod) => mod.default.error('复制失败')).catch(() => {}); + }); +} + /** * 创建右键菜单项的工厂函数 */ @@ -52,7 +177,7 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C case 'message': { const content = (data as { content?: string })?.content ?? ''; return [ - { id: 'copy', icon: Copy, label: '复制', action: () => navigator.clipboard.writeText(content).catch((err) => console.error('[Clipboard]', err)) }, + { id: 'copy', icon: Copy, label: '复制', action: () => copyWithToast(content) }, { id: 'quote', icon: Quote, label: '引用回复', action: () => { const input = document.querySelector('[data-chat-input]'); if (input) { input.value = content.split('\n').map((l: string) => `> ${l}`).join('\n') + '\n\n'; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); } @@ -63,9 +188,9 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C case 'tool-call': { const tc = data as { args?: Record; result?: unknown } | undefined; return [ - { id: 'view-params', icon: Eye, label: '查看参数', action: () => { if (tc?.args) navigator.clipboard.writeText(JSON.stringify(tc.args, null, 2)).catch((err) => console.error('[Clipboard]', err)); }}, - { id: 'view-result', icon: Eye, label: '查看完整结果', action: () => { if (tc?.result) navigator.clipboard.writeText(JSON.stringify(tc.result, null, 2)).catch((err) => console.error('[Clipboard]', err)); }}, - { id: 'copy-result', icon: Copy, label: '复制结果', action: () => { if (tc?.result) navigator.clipboard.writeText(typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result)).catch((err) => console.error('[Clipboard]', err)); }}, + { 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); @@ -83,9 +208,9 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C return [ { id: 'rename', icon: Edit, label: '重命名', action: async () => { if (!sid) return; - // window.prompt may be unreliable in Electron; fall back to sidebar double-click rename - let t: string | null = null; - try { t = prompt('新会话名称:'); } catch { /* prompt unavailable, user can rename via sidebar double-click */ return; } + // 用 MUI Dialog 替代原生 prompt()(Electron 下不可靠,且与 MUI 风格不一致) + const currentTitle = useSessionStore.getState().sessions.find((x) => x.id === sid)?.title ?? ''; + const t = await useDialogStore.getState().prompt('新会话名称', currentTitle, '重命名会话'); if (!t?.trim()) return; try { const r = await window.metona?.sessions?.rename(sid, t.trim()); @@ -144,12 +269,26 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C }}, { id: 'delete', icon: Trash2, label: '删除', action: async () => { if (!sid) return; - // window.confirm may be unreliable in Electron - try { if (!confirm('确定删除此会话?')) return; } catch { /* confirm unavailable */ return; } + // 用 MUI Dialog 替代原生 confirm()(Electron 下不可靠) + // 与 Sidebar.tsx 的 showDeleteDialog 行为一致 + const ok = await useDialogStore.getState().confirm('确定删除此会话?此操作不可撤销。', '删除会话'); + if (!ok) return; try { const r = await window.metona?.sessions?.delete(sid); if (r?.success) { useSessionStore.getState().removeSession(sid); + // 修复: 删除当前会话时同步清空 agent-store,否则 ChatPanel 和 DetailPanel 仍显示已删除会话的内容 + // 与 Sidebar.tsx 的 confirmDelete 行为一致 + if (useAgentStore.getState().currentSessionId === sid) { + useAgentStore.setState({ + currentSessionId: null, + messages: [], + traceSteps: [], + tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + currentRunId: null, + currentIteration: 0, + }); + } } else { showError(r?.error ?? '删除失败'); } @@ -164,7 +303,7 @@ 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) navigator.clipboard.writeText(code).catch((err) => console.error('[Clipboard]', err)); }}, + { 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'); }}, ]; } @@ -172,9 +311,9 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C case 'trace-step': { const step = data as { thought?: string; toolCalls?: Array<{ name: string; args: Record }> } | undefined; return [ - { id: 'copy-thought', icon: Copy, label: '复制 Thought', action: () => { if (step?.thought) navigator.clipboard.writeText(step.thought).catch((err) => console.error('[Clipboard]', err)); }}, + { id: 'copy-thought', icon: Copy, label: '复制 Thought', action: () => { if (step?.thought) copyWithToast(step.thought); }}, { id: 'copy-params', icon: Copy, label: '复制工具参数', action: () => { - if (step?.toolCalls) navigator.clipboard.writeText(step.toolCalls.map((tc) => `${tc.name}: ${JSON.stringify(tc.args, null, 2)}`).join('\n')).catch((err) => console.error('[Clipboard]', err)); + 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(); } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 86eee7e..3c698c6 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -128,6 +128,18 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S } } useSessionStore.getState().removeSession(session.id); + // 修复: 删除当前会话时同步清空 agent-store,否则 ChatPanel 和 DetailPanel 仍显示已删除会话的内容 + // 与 SettingsModal LogsSettings 的 clearSessions 分支一致 + if (useAgentStore.getState().currentSessionId === session.id) { + useAgentStore.setState({ + currentSessionId: null, + messages: [], + traceSteps: [], + tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + currentRunId: null, + currentIteration: 0, + }); + } setShowDeleteDialog(false); }; diff --git a/src/components/memory/MemoryViewer.tsx b/src/components/memory/MemoryViewer.tsx index 4a4e74c..6be747e 100644 --- a/src/components/memory/MemoryViewer.tsx +++ b/src/components/memory/MemoryViewer.tsx @@ -172,15 +172,15 @@ export function MemoryViewer(): React.JSX.Element { const handleDelete = async (type: MemoryType, id: string) => { if (!window.metona?.memory?.delete) return; try { - setError(null); const res = await window.metona.memory.delete(type, id); if (!res.success) { - setError(res.error ?? '删除失败'); + // 用户主动操作失败应用 toast(与项目惯例一致),不污染加载/搜索的 Alert + import('metona-toast').then((mod) => mod.default.error(res.error ?? '删除失败')).catch(() => {}); return; } await loadMemories(); } catch (err) { - setError((err as Error).message ?? '删除失败'); + import('metona-toast').then((mod) => mod.default.error((err as Error).message ?? '删除失败')).catch(() => {}); } }; diff --git a/src/components/onboarding/OnboardingWizard.tsx b/src/components/onboarding/OnboardingWizard.tsx index 5867b7f..29431eb 100644 --- a/src/components/onboarding/OnboardingWizard.tsx +++ b/src/components/onboarding/OnboardingWizard.tsx @@ -40,6 +40,8 @@ export function OnboardingWizard(): React.JSX.Element | null { const failedCount = results.filter((r) => r.status === 'rejected').length; if (failedCount > 0) { console.error('[OnboardingWizard]', `${failedCount} config(s) failed to save`); + // 用户主动操作失败必须有反馈,否则按钮看起来无响应 + import('metona-toast').then((mod) => mod.default.error(`部分配置保存失败(${failedCount} 项),请重试`)).catch(() => {}); // 不调用 setOnboardingCompleted(true),让用户重试 return; } @@ -50,6 +52,8 @@ export function OnboardingWizard(): React.JSX.Element | null { setOnboardingCompleted(true); } catch (err) { console.error('[OnboardingWizard]', 'Failed to save configuration:', err); + // 用户主动操作失败必须有反馈,否则向导不关闭、用户卡死 + import('metona-toast').then((mod) => mod.default.error(`保存配置失败:${(err as Error).message}`)).catch(() => {}); } }; diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index 54d4fc5..928edbb 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -161,7 +161,9 @@ function WorkspaceSettings() { files: filesToInherit, }); } catch (err) { + // 用户勾选了继承文件,失败时必须告知,否则切到新空间才发现文件是空的,潜在数据丢失风险 console.error('[SettingsModal] Inherit failed:', err); + import('metona-toast').then((mod) => mod.default.warning(`部分文件继承失败:${(err as Error).message},请手动检查`)).catch(() => {}); } } @@ -174,6 +176,8 @@ function WorkspaceSettings() { setCheckResult(null); } catch (err) { console.error('[SettingsModal]', err); + // 用户主动操作(切换工作空间)失败必须有反馈 + import('metona-toast').then((mod) => mod.default.error(`切换工作空间失败:${(err as Error).message}`)).catch(() => {}); } finally { setApplying(false); } @@ -317,18 +321,63 @@ function WorkspaceSettings() { } function LLMSettings() { - const [provider, setProvider] = useConfig('llm.provider', ''); - const [model, setModel] = useConfig('llm.model', ''); - const [apiKey, setApiKey] = useConfig('llm.apiKey', ''); - const [baseURL, setBaseURL] = useConfig('llm.baseURL', ''); - const [numCtx, setNumCtx] = useConfig('ollama.numCtx', null as number | null); + // 改为本地 state + Save 按钮统一提交,避免 onChange 实时落库导致: + // 1. 改 Base URL 时被回滚卡死(useConfig seqRef 机制与连续输入冲突) + // 2. 每按一个字符就触发一次 IPC + DB + reloadAdapter,浪费且会打断输入 + // 3. 错误提示笼统(不指向具体字段) + const [provider, setProvider] = useState(''); + const [model, setModel] = useState(''); + const [apiKey, setApiKey] = useState(''); + const [baseURL, setBaseURL] = useState(''); + const [numCtx, setNumCtx] = useState(null); // v0.3.1: DeepSeek/Agnes/MiMo contextWindow 可配置(不再写死) - const [dsCtxWindow, setDsCtxWindow] = useConfig('deepseek.contextWindow', 1000000); - const [agnesCtxWindow, setAgnesCtxWindow] = useConfig('agnes.contextWindow', 1000000); - const [mimoCtxWindow, setMimoCtxWindow] = useConfig('mimo.contextWindow', 131072); + const [dsCtxWindow, setDsCtxWindow] = useState(1000000); + const [agnesCtxWindow, setAgnesCtxWindow] = useState(1000000); + const [mimoCtxWindow, setMimoCtxWindow] = useState(131072); const [showKey, setShowKey] = 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 [p, m, k, u, nc, ds, ag, mi] = 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'), + ]); + if (cancelled) return; + 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); + } catch (err) { + console.error('[SettingsModal]', err); + } finally { + if (!cancelled) setLoaded(true); + } + }; + load(); + return () => { cancelled = true; }; + }, []); // 同步 Provider/Model 到 Agent Store(含 contextWindow) + // 注意:仅同步运行时状态,不落库 useEffect(() => { useAgentStore.getState().setProvider(provider, model); }, [provider, model]); @@ -346,16 +395,37 @@ function LLMSettings() { } }, [provider, numCtx, dsCtxWindow, agnesCtxWindow, mimoCtxWindow]); - // 切换 Provider 时自动填充默认 URL + 清空 apiKey(不同 Provider 的 key 不通用) + // ===== 字段级 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; + + // 是否存在阻断保存的错误(API Key 为空只警告,不阻断 — 允许先填其他字段再回来填 key) + const hasBlockingError = urlError || modelHasSpace || numCtxError || + (provider === 'deepseek' && dsCtxError) || + (provider === 'agnes' && agnesCtxError) || + (provider === 'mimo' && mimoCtxError); + + // 切换 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 格式不同,避免用旧 key 调用新 API 导致 401) - // Ollama 不需要 apiKey,也一并清空 + // 切换 Provider 时清空 apiKey(不同 Provider 的 key 格式不同) if (oldProvider !== newProvider && apiKey) { setApiKey(''); } - // 自动填充默认 URL(仅在 URL 为空或与旧 provider 默认 URL 匹配时) + // 切换 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) { @@ -363,6 +433,63 @@ function LLMSettings() { } }; + const handleSave = async () => { + if (hasBlockingError) { + import('metona-toast').then((mod) => mod.default.error('请修正表单中的错误后再保存')).catch(() => {}); + return; + } + setSaving(true); + try { + const setConfig = window.metona?.config?.set; + if (!setConfig) { + import('metona-toast').then((mod) => mod.default.error('配置 API 不可用')).catch(() => {}); + return; + } + // 串行保存:避免并发 IPC 调用(reloadAdapter 内部 lastConfigSig 比较会跳过中间态的重载) + const fields: Array<[string, unknown]> = [ + ['llm.provider', provider], + ['llm.model', model], + ['llm.apiKey', apiKey], + ['llm.baseURL', baseURL], + ['ollama.numCtx', numCtx], + ['deepseek.contextWindow', dsCtxWindow], + ['agnes.contextWindow', agnesCtxWindow], + ['mimo.contextWindow', mimoCtxWindow], + ]; + let firstError: string | null = null; + for (const [key, value] of fields) { + try { + const r = await setConfig(key, value); + if (r && !r.success && !firstError) { + firstError = r.error ?? '配置保存失败'; + } + } catch (err) { + if (!firstError) firstError = (err as Error).message; + } + } + if (firstError) { + import('metona-toast').then((mod) => mod.default.error(firstError!)).catch(() => {}); + } else { + import('metona-toast').then((mod) => mod.default.success('配置已保存')).catch(() => {}); + } + } catch (err) { + import('metona-toast').then((mod) => mod.default.error(`保存失败:${(err as Error).message}`)).catch(() => {}); + } finally { + setSaving(false); + } + }; + + if (!loaded) { + return ( + + LLM 配置 + 加载中... + + ); + } + + const apiKeyEmpty = provider !== 'ollama' && !apiKey.trim(); + return ( LLM 配置 @@ -374,18 +501,37 @@ function LLMSettings() { Ollama (本地) - setBaseURL(e.target.value)} placeholder="如 https://api.deepseek.com" /> - setModel(e.target.value)} placeholder="如 deepseek-v4-pro、qwen3:latest" /> + setBaseURL(e.target.value)} + placeholder="如 https://api.deepseek.com" + error={urlError} + helperText={urlError ? '需以 http:// 或 https:// 开头' : ' '} + /> + setModel(e.target.value)} + placeholder="如 deepseek-v4-pro、qwen3:latest" + error={modelHasSpace} + helperText={modelHasSpace ? '模型名称不能包含空格' : ' '} + /> {provider !== 'ollama' && ( <> - setApiKey(e.target.value)} placeholder="sk-..." + setApiKey(e.target.value)} + placeholder="sk-..." + error={apiKeyEmpty} + helperText={apiKeyEmpty ? `必填,未填 ${PROVIDER_LABELS[provider] ?? provider} 的 API Key 会 401` : ' '} slotProps={{ input: { endAdornment: setShowKey(!showKey)}>{showKey ? : } } }} /> - {!apiKey && ( - - ⚠ 请输入 {PROVIDER_LABELS[provider] ?? provider} 的 API Key,否则发送消息会报错 - - )} )} {provider === 'ollama' && ( @@ -400,6 +546,8 @@ function LLMSettings() { }} placeholder="默认由模型决定(如 2048、4096、128000)" slotProps={{ htmlInput: { min: 512, step: 512 } }} + error={numCtxError} + helperText={numCtxError ? '最小值为 512' : ' '} /> )} {/* v0.3.1: DeepSeek/Agnes 上下文窗口配置(用于 Engine 压缩判断和 UI 显示,不传给 API) */} @@ -408,11 +556,12 @@ function LLMSettings() { size="small" label="上下文窗口 (contextWindow)" type="number" - value={dsCtxWindow ?? 1000000} + value={dsCtxWindow} onChange={(e) => setDsCtxWindow(Number(e.target.value) || 1000000)} placeholder="如 64000、128000、1000000" slotProps={{ htmlInput: { min: 4096, step: 4096 } }} - helperText="用于上下文压缩判断,不传给 API" + error={dsCtxError} + helperText={dsCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'} /> )} {provider === 'agnes' && ( @@ -420,11 +569,12 @@ function LLMSettings() { size="small" label="上下文窗口 (contextWindow)" type="number" - value={agnesCtxWindow ?? 1000000} + value={agnesCtxWindow} onChange={(e) => setAgnesCtxWindow(Number(e.target.value) || 1000000)} placeholder="如 64000、128000、1000000" slotProps={{ htmlInput: { min: 4096, step: 4096 } }} - helperText="用于上下文压缩判断,不传给 API" + error={agnesCtxError} + helperText={agnesCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'} /> )} {provider === 'mimo' && ( @@ -432,13 +582,32 @@ function LLMSettings() { size="small" label="上下文窗口 (contextWindow)" type="number" - value={mimoCtxWindow ?? 131072} + value={mimoCtxWindow} onChange={(e) => setMimoCtxWindow(Number(e.target.value) || 131072)} placeholder="如 32768、65536、131072" slotProps={{ htmlInput: { min: 4096, step: 4096 } }} - helperText="官方未公布具体值,默认 131072,用于压缩判断" + error={mimoCtxError} + helperText={mimoCtxError ? '最小值为 4096' : '官方未公布具体值,默认 131072,用于压缩判断'} /> )} + + {/* Save 按钮:批量提交,取消 onChange 实时落库 */} + + + {hasBlockingError && ( + + 请修正表单错误后再保存 + + )} + ); } @@ -1013,9 +1182,8 @@ function AppearanceSettings({ theme, setTheme }: { theme: ThemeMode; setTheme: ( function LogsSettings() { const [logLevel, setLogLevel] = useConfig('logging.level', 'info'); const [clearing, setClearing] = useState(null); - // L-11 修复(审计补充): 用 MUI Dialog 替换原生 confirm() 和 alert(),保持 UI 一致性 + // L-11 修复(审计补充): 用 MUI Dialog 替换原生 confirm(),保持 UI 一致性 const [confirmClear, setConfirmClear] = useState<'sessions' | 'memories' | 'auditLogs' | null>(null); - const [resultAlert, setResultAlert] = useState<{ type: 'success' | 'error'; message: string } | null>(null); // P3-13: 显示日志文件路径,并提供"打开日志文件夹"按钮 // electron-log 默认写入路径为 ${userData}/logs/main.log const [logFilePath, setLogFilePath] = useState(''); @@ -1051,10 +1219,10 @@ function LogsSettings() { try { const r = await window.metona?.app?.showItemInFolder?.(logFilePath); if (r && !r.success) { - setResultAlert({ type: 'error', message: `打开失败: ${r.error ?? '未知错误'}` }); + import('metona-toast').then((mod) => mod.default.error(`打开失败: ${r.error ?? '未知错误'}`)).catch(() => {}); } } catch (e) { - setResultAlert({ type: 'error', message: `打开失败: ${(e as Error).message}` }); + import('metona-toast').then((mod) => mod.default.error(`打开失败: ${(e as Error).message}`)).catch(() => {}); } }; @@ -1067,7 +1235,7 @@ function LogsSettings() { } catch (e) { setCopyState('error'); setTimeout(() => setCopyState('idle'), 1500); - setResultAlert({ type: 'error', message: `复制失败: ${(e as Error).message}` }); + import('metona-toast').then((mod) => mod.default.error(`复制失败: ${(e as Error).message}`)).catch(() => {}); } }; @@ -1079,16 +1247,16 @@ function LogsSettings() { 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 { - setResultAlert({ type: 'error', message: `导出失败: ${r.error ?? '未知错误'}` }); + import('metona-toast').then((mod) => mod.default.error(`导出失败: ${r.error ?? '未知错误'}`)).catch(() => {}); } } catch (err) { console.error('[LogsSettings]', err); - setResultAlert({ type: 'error', message: `导出失败: ${(err as Error).message}` }); + import('metona-toast').then((mod) => mod.default.error(`导出失败: ${(err as Error).message}`)).catch(() => {}); } }; const labels: Record = { sessions: '所有会话', memories: '所有记忆', auditLogs: '审计日志' }; - // L-11 修复(审计补充): 清理数据改用 Dialog 确认 + Alert 反馈,不再用原生 confirm/alert + // L-11 修复(审计补充): 清理数据改用 Dialog 确认,结果用 toast 反馈 const handleClearConfirm = async () => { if (!confirmClear) return; const type = confirmClear; @@ -1100,7 +1268,7 @@ function LogsSettings() { else if (type === 'memories') r = await window.metona?.data?.clearMemories(); else r = await window.metona?.data?.clearAuditLogs(); if (r?.success) { - setResultAlert({ type: 'success', message: `${labels[type]}已清理` }); + import('metona-toast').then((mod) => mod.default.success(`${labels[type]}已清理`)).catch(() => {}); // 修复: 清理会话后同步清空前端状态,无需重启应用 if (type === 'sessions') { useSessionStore.getState().setSessions([]); @@ -1113,10 +1281,10 @@ function LogsSettings() { useUIStore.getState().bumpMemoryVersion(); } } else { - setResultAlert({ type: 'error', message: `失败: ${r?.error ?? '未知错误'}` }); + import('metona-toast').then((mod) => mod.default.error(`失败: ${r?.error ?? '未知错误'}`)).catch(() => {}); } } catch (e) { - setResultAlert({ type: 'error', message: `失败: ${(e as Error).message}` }); + import('metona-toast').then((mod) => mod.default.error(`失败: ${(e as Error).message}`)).catch(() => {}); } setClearing(null); }; @@ -1170,9 +1338,9 @@ function LogsSettings() { 数据管理 - - - + + + {/* L-11 修复(审计补充): 清理数据确认 Dialog(替代原生 confirm()) */} 清理 - - {/* L-11 修复(审计补充): 清理结果反馈 Alert(替代原生 alert()),3 秒后自动消失 */} - {resultAlert && ( - setResultAlert(null)} - sx={{ fontSize: 12 }} - > - {resultAlert.message} - - )} ); } diff --git a/src/components/tasks/TaskList.tsx b/src/components/tasks/TaskList.tsx index c5f6aee..2c6bccb 100644 --- a/src/components/tasks/TaskList.tsx +++ b/src/components/tasks/TaskList.tsx @@ -97,10 +97,12 @@ export function TaskList(): React.JSX.Element { const handleCreate = async () => { if (!sessionId) { - setError('请先选择或创建会话'); + // 用户主动操作失败应用 toast(与项目惯例一致) + import('metona-toast').then((mod) => mod.default.error('请先选择或创建会话')).catch(() => {}); return; } if (!newTitle.trim()) { + // 表单校验保留 inline error(让用户看到具体哪个字段有问题) setError('任务标题不能为空'); return; } @@ -114,7 +116,7 @@ export function TaskList(): React.JSX.Element { priority: newPriority, }); if (!res.success) { - setError(res.error ?? '创建任务失败'); + import('metona-toast').then((mod) => mod.default.error(res.error ?? '创建任务失败')).catch(() => {}); return; } setNewTitle(''); @@ -122,7 +124,7 @@ export function TaskList(): React.JSX.Element { setShowAddForm(false); await loadTasks(sessionId); } catch (err) { - setError((err as Error).message ?? '创建任务失败'); + import('metona-toast').then((mod) => mod.default.error((err as Error).message ?? '创建任务失败')).catch(() => {}); } finally { setCreating(false); } @@ -131,32 +133,30 @@ export function TaskList(): React.JSX.Element { const handleToggleComplete = async (task: MetonaTask) => { if (!window.metona?.tasks?.update) return; const nextStatus: TaskStatus = task.status === 'completed' ? 'pending' : 'completed'; - setError(null); try { const res = await window.metona.tasks.update(task.id, { status: nextStatus }); if (!res.success) { - setError(res.error ?? '更新任务失败'); + import('metona-toast').then((mod) => mod.default.error(res.error ?? '更新任务失败')).catch(() => {}); return; } await loadTasks(sessionId ?? undefined); } catch (err) { - setError((err as Error).message ?? '更新任务失败'); + import('metona-toast').then((mod) => mod.default.error((err as Error).message ?? '更新任务失败')).catch(() => {}); } }; const handleDelete = async (id: string) => { if (!window.metona?.tasks?.delete) return; - setError(null); try { const res = await window.metona.tasks.delete(id); if (!res.success) { - setError(res.error ?? '删除任务失败'); + import('metona-toast').then((mod) => mod.default.error(res.error ?? '删除任务失败')).catch(() => {}); return; } if (expandedId === id) setExpandedId(null); await loadTasks(sessionId ?? undefined); } catch (err) { - setError((err as Error).message ?? '删除任务失败'); + import('metona-toast').then((mod) => mod.default.error((err as Error).message ?? '删除任务失败')).catch(() => {}); } }; diff --git a/src/hooks/useAgentStream.ts b/src/hooks/useAgentStream.ts index a30c87d..cbba275 100644 --- a/src/hooks/useAgentStream.ts +++ b/src/hooks/useAgentStream.ts @@ -417,9 +417,10 @@ export function useAgentStream(): void { const traceSteps = store.traceSteps; const lastStep = traceSteps[traceSteps.length - 1]; - // 判断是否需要创建新步骤:同一迭代的首次状态创建新步骤 + // 判断是否需要创建新步骤:同一 runId + 同一迭代的首次状态创建新步骤 // 后续状态转换(EXECUTING/OBSERVING等)追加到 states 数组 - const isSameIteration = lastStep && lastStep.iteration === data.iteration; + // runId 判定:防止跨 run 的事件被误判为同一迭代(如 traceSteps 未清空时残留的旧 step) + const isSameIteration = lastStep && lastStep.iteration === data.iteration && lastStep.runId === data.runId; if (isSameIteration) { // 同一迭代内的状态转换 → 追加状态到 states 数组,更新当前 state @@ -437,7 +438,8 @@ export function useAgentStream(): void { // MT-2 修复: 当 state === 'TERMINATED' 且 iteration 不匹配时, // 说明 abort 发生在循环条件检查时,currentIteration 已自增但没有对应 step。 // 此时不应创建孤立的只含 TERMINATED 的步骤,而是更新最后一个步骤的 states 数组 - if (data.state === 'TERMINATED' && lastStep) { + // runId 一致性检查:仅当 lastStep 属于当前 run 时才 append,避免污染上一条消息的 step + if (data.state === 'TERMINATED' && lastStep && lastStep.runId === data.runId) { const currentStates = lastStep.states ?? [lastStep.state]; if (currentStates[currentStates.length - 1] !== 'TERMINATED') { store.updateLastTraceStep({ @@ -446,9 +448,12 @@ export function useAgentStream(): void { completedAt: Date.now(), }); } + } else if (data.state === 'TERMINATED' && (!lastStep || lastStep.runId !== data.runId)) { + // 跨 run 的孤立 TERMINATED 事件:上一条消息已结束/已 abort,没有当前 run 的 step 可更新 + // 不创建孤立的只含 TERMINATED 的 step(无意义),仅更新 Agent 状态 } else { - // 新迭代 → 标记上一步完成,创建新步骤 - if (lastStep && !lastStep.completedAt) { + // 新迭代 → 标记上一步完成(仅当 lastStep 属于当前 run),创建新步骤 + if (lastStep && !lastStep.completedAt && lastStep.runId === data.runId) { store.updateLastTraceStep({ completedAt: Date.now() }); } store.addTraceStep({ @@ -457,6 +462,7 @@ export function useAgentStream(): void { state: data.state, states: [data.state], startedAt: Date.now(), + runId: data.runId, }); } } diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts index bd21a32..6877aa1 100644 --- a/src/hooks/useKeyboardShortcuts.ts +++ b/src/hooks/useKeyboardShortcuts.ts @@ -64,7 +64,11 @@ export function useKeyboardShortcuts(): void { useSessionStore.getState().setCurrentSession(result.id); // v0.3.0 修复: 同步 agent-store,确保聊天面板清空并加载新会话 useAgentStore.getState().setCurrentSession(result.id); - }).catch((err) => { console.error('[KeyboardShortcuts]', err); }); + }).catch((err) => { + console.error('[KeyboardShortcuts]', err); + // 与 Sidebar/CommandPalette 入口对齐:失败时弹 toast 提示用户 + import('metona-toast').then((mod) => mod.default.error('创建会话失败')).catch(() => {}); + }); } e.preventDefault(); return; @@ -148,7 +152,11 @@ export function useKeyboardShortcuts(): void { const { messages } = useAgentStore.getState(); const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant'); if (lastAssistant?.content) { - navigator.clipboard.writeText(lastAssistant.content).catch((err) => console.error('[KeyboardShortcuts]', err)); + navigator.clipboard.writeText(lastAssistant.content).catch((err) => { + console.error('[KeyboardShortcuts]', err); + // 复制是用户主动操作,失败时必须有反馈 + import('metona-toast').then((mod) => mod.default.error('复制失败')).catch(() => {}); + }); } e.preventDefault(); return; diff --git a/src/stores/agent-store.ts b/src/stores/agent-store.ts index e5be85c..5fb6719 100644 --- a/src/stores/agent-store.ts +++ b/src/stores/agent-store.ts @@ -78,6 +78,11 @@ export interface TraceStep { thought?: string; toolCalls?: ToolCallInfo[]; tokenUsage?: { promptTokens: number; completionTokens: number; totalTokens: number }; + /** + * 创建该 step 的 runId,用于跨 run 隔离 + * 防御场景:abort 重发、并发 run、traceSteps 未清空时仍能正确识别新旧 step 边界 + */ + runId?: string; } // ===== Store 状态 ===== @@ -242,6 +247,8 @@ export const useAgentStore = create((set, get) => ({ content: `错误: 无法创建会话 — ${(err as Error).message}`, timestamp: Date.now(), }); + // 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见) + import('metona-toast').then((mod) => mod.default.error(`无法创建会话:${(err as Error).message}`)).catch(() => {}); return; } } @@ -260,6 +267,10 @@ export const useAgentStore = create((set, get) => ({ isStreaming: true, currentRunId: null, // 将在首个 streamEvent 中由 runId 设置 currentIteration: 0, + // 重置 trace 状态:防止新消息在上一条消息的 trace 上面叠加渲染 + // 前一条消息的 trace 已在 done 事件中通过 saveTraceData() 持久化到 DB + traceSteps: [], + tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, })); // 自动更新会话标题为用户第一条消息 @@ -317,6 +328,8 @@ export const useAgentStore = create((set, get) => ({ content: `错误: 消息发送失败 — ${(err as Error).message}`, timestamp: Date.now(), }); + // 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见) + import('metona-toast').then((mod) => mod.default.error(`消息发送失败:${(err as Error).message}`)).catch(() => {}); }); } },