diff --git a/README.md b/README.md index eddca8e..86c21da 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > 生产级通用 AI Agent 智能体桌面应用 -[![Version](https://img.shields.io/badge/version-0.3.4-blue)](./package.json) +[![Version](https://img.shields.io/badge/version-0.3.7-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/electron/harness/adapters/agnes-ai.adapter.ts b/electron/harness/adapters/agnes-ai.adapter.ts index 1290e68..6862b56 100644 --- a/electron/harness/adapters/agnes-ai.adapter.ts +++ b/electron/harness/adapters/agnes-ai.adapter.ts @@ -60,8 +60,7 @@ export class AgnesAdapter extends BaseAdapter { }); if (!response.ok) { - const errorBody = await response.text().catch(() => ''); - throw new Error(`Agnes AI API error: ${response.status} ${response.statusText} - ${errorBody}`); + await this.throwHttpError(response, 'Agnes AI API error'); } const data = await response.json() as Record; @@ -102,11 +101,12 @@ export class AgnesAdapter extends BaseAdapter { }); if (!response.ok || !response.body) { - throw new Error(`Agnes AI stream error: ${response.status}`); + await this.throwHttpError(response, 'Agnes AI stream error'); } yield* parseSSEStream( - response.body, + // 非空断言:上方 if 已确保 response.body 不为 null + response.body!, request.meta.requestId, request.meta.sessionId, request.meta.iteration, diff --git a/electron/harness/adapters/base-adapter.ts b/electron/harness/adapters/base-adapter.ts index a8d5248..0c2c43c 100644 --- a/electron/harness/adapters/base-adapter.ts +++ b/electron/harness/adapters/base-adapter.ts @@ -111,6 +111,18 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter { return this.supportedModels.map((id) => ({ id })); } + /** + * P1-4 修复: 构造带 HTTP status 属性的 Error 并抛出 + * engine.isRetryableError 依赖 error.status 判断是否可重试(429/5xx) + */ + protected async throwHttpError(response: Response, context: string): Promise { + let errorBody = ''; + try { errorBody = await response.text(); } catch { /* body 可能已消费或为 null */ } + const error = new Error(`${context}: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`); + (error as Error & { status: number }).status = response.status; + throw error; + } + /** * 将原生错误映射为 MetonaError */ diff --git a/electron/harness/adapters/deepseek.adapter.ts b/electron/harness/adapters/deepseek.adapter.ts index 0458b91..16bbd84 100644 --- a/electron/harness/adapters/deepseek.adapter.ts +++ b/electron/harness/adapters/deepseek.adapter.ts @@ -65,8 +65,7 @@ export class DeepSeekAdapter extends BaseAdapter { }); if (!response.ok) { - const errorBody = await response.text().catch(() => ''); - throw new Error(`DeepSeek API error: ${response.status} ${response.statusText} - ${errorBody}`); + await this.throwHttpError(response, 'DeepSeek API error'); } const data = await response.json() as Record; @@ -107,11 +106,13 @@ export class DeepSeekAdapter extends BaseAdapter { }); if (!response.ok || !response.body) { - throw new Error(`DeepSeek stream error: ${response.status}`); + await this.throwHttpError(response, 'DeepSeek stream error'); } yield* parseSSEStream( - response.body, + // 非空断言:上方 if 已确保 response.body 不为 null + // TypeScript 无法通过 await Promise 正确收窄,需显式断言 + response.body!, request.meta.requestId, request.meta.sessionId, request.meta.iteration, diff --git a/electron/harness/adapters/mimo.adapter.ts b/electron/harness/adapters/mimo.adapter.ts index d5351f3..d7089de 100644 --- a/electron/harness/adapters/mimo.adapter.ts +++ b/electron/harness/adapters/mimo.adapter.ts @@ -71,8 +71,7 @@ export class MimoAdapter extends BaseAdapter { }); if (!response.ok) { - const errorBody = await response.text().catch(() => ''); - throw new Error(`MiMo API error: ${response.status} ${response.statusText} - ${errorBody}`); + await this.throwHttpError(response, 'MiMo API error'); } const data = await response.json() as Record; @@ -111,11 +110,12 @@ export class MimoAdapter extends BaseAdapter { }); if (!response.ok || !response.body) { - throw new Error(`MiMo stream error: ${response.status}`); + await this.throwHttpError(response, 'MiMo stream error'); } yield* parseSSEStream( - response.body, + // 非空断言:上方 if 已确保 response.body 不为 null + response.body!, request.meta.requestId, request.meta.sessionId, request.meta.iteration, diff --git a/electron/harness/adapters/ollama.adapter.ts b/electron/harness/adapters/ollama.adapter.ts index 6038b6c..178e74c 100644 --- a/electron/harness/adapters/ollama.adapter.ts +++ b/electron/harness/adapters/ollama.adapter.ts @@ -61,7 +61,7 @@ export class OllamaAdapter extends BaseAdapter { }); if (!response.ok) { - throw new Error(`Ollama API error: ${response.status}`); + await this.throwHttpError(response, 'Ollama API error'); } const data = await response.json() as Record; @@ -81,10 +81,11 @@ export class OllamaAdapter extends BaseAdapter { }); if (!response.ok || !response.body) { - throw new Error(`Ollama stream error: ${response.status}`); + await this.throwHttpError(response, 'Ollama stream error'); } - const reader = response.body.getReader(); + // 非空断言:上方 if 已确保 response.body 不为 null + const reader = response.body!.getReader(); const decoder = new TextDecoder(); let seq = 0; let buffer = ''; @@ -183,8 +184,9 @@ export class OllamaAdapter extends BaseAdapter { }; return; } - } catch { - // 跳过解析失败的行 + } catch (parseErr) { + // P2-8 修复: 与 sse-stream.ts 一致,记录解析失败行便于诊断 + log.warn(`[Ollama] Failed to parse NDJSON line: ${(parseErr as Error).message}`, trimmed.slice(0, 200)); } } } @@ -436,9 +438,12 @@ export class OllamaAdapter extends BaseAdapter { // 工具结果 if (m.role === 'tool' && m.toolResult) { msg.tool_call_id = m.toolResult.toolCallId; - msg.content = typeof m.toolResult.result === 'string' - ? m.toolResult.result - : JSON.stringify(m.toolResult.result); + // CE-2 修复: 工具失败时 result 为 null,优先用 error 字段作为 content + msg.content = m.toolResult.error + ? m.toolResult.error + : (typeof m.toolResult.result === 'string' + ? m.toolResult.result + : JSON.stringify(m.toolResult.result)); } // assistant 工具调用(Ollama REST API 要求 arguments 为 JSON 字符串) if (m.role === 'assistant' && m.toolCalls?.length) { diff --git a/electron/harness/adapters/shared/openai-format.ts b/electron/harness/adapters/shared/openai-format.ts index 741ffbd..2ab339e 100644 --- a/electron/harness/adapters/shared/openai-format.ts +++ b/electron/harness/adapters/shared/openai-format.ts @@ -67,10 +67,13 @@ export function buildOpenAICompatibleMessages( // === 工具执行结果 === if (m.role === 'tool' && m.toolResult) { msg.tool_call_id = m.toolResult.toolCallId; - msg.content = - typeof m.toolResult.result === 'string' + // CE-2 修复: 工具失败时 result 为 null,优先用 error 字段作为 content + // 否则 LLM 看到 "null" 不知道失败原因,可能重复调用导致死循环 + msg.content = m.toolResult.error + ? m.toolResult.error + : (typeof m.toolResult.result === 'string' ? m.toolResult.result - : JSON.stringify(m.toolResult.result); + : JSON.stringify(m.toolResult.result)); } return msg; diff --git a/electron/harness/adapters/shared/sse-stream.ts b/electron/harness/adapters/shared/sse-stream.ts index 6fa6f50..3e81385 100644 --- a/electron/harness/adapters/shared/sse-stream.ts +++ b/electron/harness/adapters/shared/sse-stream.ts @@ -9,6 +9,7 @@ */ import { nanoid } from 'nanoid'; +import log from 'electron-log'; import type { MetonaStreamEvent, MetonaTokenUsage } from '../../types'; import { MetonaStreamEventType } from '../../types'; @@ -198,8 +199,9 @@ export async function* parseSSEStream( // L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码 yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef); } - } catch { - // 跳过解析失败的行 + } catch (parseErr) { + // P2-8 修复: 不再静默跳过,记录 warning 便于排查 SSE 数据损坏 + log.warn(`[SSE] Failed to parse stream line: ${(parseErr as Error).message}`, line.slice(0, 200)); } } } diff --git a/electron/harness/agent-loop/engine.ts b/electron/harness/agent-loop/engine.ts index 899f41a..86eb4b3 100644 --- a/electron/harness/agent-loop/engine.ts +++ b/electron/harness/agent-loop/engine.ts @@ -258,7 +258,11 @@ export class AgentLoopEngine extends EventEmitter { for (const result of step.toolResults) { messages.push({ role: 'tool', - content: typeof result.result === 'string' ? result.result : JSON.stringify(result.result), + // CE-2 修复: 工具失败时 result.result 为 null,LLM 会看到 "null" 而非错误信息 + // 优先使用 error 字段,让 LLM 知道失败原因,避免重复调用导致死循环 + content: result.error + ? result.error + : (typeof result.result === 'string' ? result.result : JSON.stringify(result.result)), toolResult: result, timestamp: Date.now(), iteration: this.currentIteration, @@ -274,11 +278,16 @@ export class AgentLoopEngine extends EventEmitter { return this.finish(TerminationReason.TIMEOUT); } catch (error) { // v0.3.0: 捕获 DeadLoopError — 以 DEAD_LOOP 原因终止 + // P0-1 审查修复: 必须传 error 参数(第三参数),否则 finish() 不会发射 ERROR 事件 + // 前端只收到 DONE 会导致 agentStatus 被设为 'idle' 而非 'error',且无错误消息卡片 if (error instanceof DeadLoopError) { - return this.finish(TerminationReason.DEAD_LOOP, error.message); + return this.finish(TerminationReason.DEAD_LOOP, undefined, error as Error); } - const errMsg = (error as Error).message; - if (this.aborted || errMsg.includes('Aborted')) { + const errMsg = (error as Error).message ?? ''; + // P2-9 修复: toLowerCase 避免大小写敏感导致超时误判为 ERROR + // Node fetch 超时错误 "The operation timed out" / abort "Aborted" 都需覆盖 + const errMsgLower = errMsg.toLowerCase(); + if (this.aborted || errMsgLower.includes('aborted') || errMsgLower.includes('timed out') || errMsgLower.includes('timeout')) { return this.finish(TerminationReason.USER_INTERRUPT); } // v0.3.0 修复: 不使用 emit('error') — Node EventEmitter 对无监听器的 'error' 事件会同步 throw, @@ -310,6 +319,29 @@ export class AgentLoopEngine extends EventEmitter { this.emit('aborted'); } + /** + * MT-1 修复: 等待当前 run 结束(用于 abortSession IPC handler) + * + * abort() 只是设置了标志和触发了 abortController, + * 但 currentRunPromise 仍在进行中(可能在等待工具超时或 LLM 响应)。 + * 如果不等待就返回,用户立即重发会导致新消息卡在 runStream 的 currentRunPromise 等待中。 + * + * @param timeoutMs 等待超时(默认 5 秒,防止永久挂起) + * @returns true 表示 run 已结束,false 表示等待超时 + */ + async waitForAbort(timeoutMs: number = 5_000): Promise { + if (!this.currentRunPromise) return true; + try { + await Promise.race([ + this.currentRunPromise.catch(() => {}), + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]); + return true; + } catch { + return false; + } + } + /** * 销毁引擎,清理所有监听器 */ @@ -854,8 +886,10 @@ export class AgentLoopEngine extends EventEmitter { if (err.status && err.status >= 500 && err.status < 600) return true; // 网络超时/连接错误 — 可重试 if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT' || err.code === 'ENOTFOUND') return true; - // SSE 流中断 — 可重试 - if (err.message?.includes('aborted') || err.message?.includes('socket hang up')) return true; + // P2-9 一致性修复: toLowerCase 避免大小写敏感漏判 + // SSE 流中断 — 可重试(注意:用户主动 abort 已在 chatStreamWithRetry 入口由 this.aborted 提前拦截) + const msg = err.message?.toLowerCase() ?? ''; + if (msg.includes('aborted') || msg.includes('socket hang up')) return true; // 其他错误(400/401/403/4xx)不重试 return false; } @@ -954,8 +988,34 @@ export class AgentLoopEngine extends EventEmitter { const keepRecent = 10; // 保留最近 10 条消息(约 3 轮 user+assistant+tool) if (messages.length <= keepRecent) return null; - const toCompress = messages.slice(0, messages.length - keepRecent); - const toKeep = messages.slice(messages.length - keepRecent); + let toCompress = messages.slice(0, messages.length - keepRecent); + let toKeep = messages.slice(messages.length - keepRecent); + + // MT-3 修复: 确保 toKeep 不以孤立的 tool 消息开头 + // OpenAI/DeepSeek API 要求 tool 消息前必须有带 tool_calls 的 assistant 消息 + // 如果压缩边界正好切在 assistant(tool_calls) 和 tool 之间,下一轮 LLM 调用会返回 400 + if (toKeep.length > 0 && toKeep[0].role === 'tool' && toCompress.length > 0) { + // 反向查找最近的带 tool_calls 的 assistant 消息 + let assistantIdx = -1; + for (let i = toCompress.length - 1; i >= 0; i--) { + if (toCompress[i].role === 'assistant' && toCompress[i].toolCalls?.length) { + assistantIdx = i; + break; + } + } + if (assistantIdx < 0) { + // 找不到配对的 assistant 消息,直接丢弃孤立的 tool 消息 + toKeep = toKeep.slice(1); + } else { + // 将配对的 assistant 消息及其之后的所有消息移到 toKeep 开头 + const moved = toCompress.slice(assistantIdx); + toCompress = toCompress.slice(0, assistantIdx); + toKeep = [...moved, ...toKeep]; + } + } + + // 如果 toCompress 为空,无法生成摘要 + if (toCompress.length === 0) return null; // 构建摘要请求 // 不截断单条消息——摘要请求是独立 API 调用,不共享主对话上下文窗口 @@ -998,7 +1058,11 @@ export class AgentLoopEngine extends EventEmitter { if (!summary) return null; const summaryMessage: MetonaMessage = { - role: 'system', + // CE-1 修复: 使用 role: 'user' 而非 'system' + // adapter 的 buildOpenAICompatibleMessages 会过滤所有 role !== 'system' 的消息, + // 只保留 systemPrompt 构建的 system 消息。如果用 'system',摘要会被丢弃,压缩无效。 + // 改为 'user' 后 LLM 会看到:system(systemPrompt) → user(摘要) → ...toKeep + role: 'user', content: `[Context Summary] The following is a summary of earlier conversation:\n\n${summary}`, timestamp: Date.now(), }; @@ -1023,6 +1087,26 @@ export class AgentLoopEngine extends EventEmitter { answer?: string, error?: Error, ): AgentLoopOutput { + // P0-1 修复: ERROR/DEAD_LOOP 终止时先发 ERROR 流式事件,让前端能看到错误 + // v0.3.0 删除了 emit('error') 导致所有 adapter 错误对前端不可见 + // 此处用 emit('streamEvent', { type: ERROR }) 不会触发 EventEmitter 的同步 throw + if ((reason === TerminationReason.ERROR || reason === TerminationReason.DEAD_LOOP) && error) { + this.emit('streamEvent', { + type: MetonaStreamEventType.ERROR, + requestId: this.currentRequestId, + sessionId: this.currentSessionId, + iteration: this.currentIteration, + seq: this.nextSeq(), + timestamp: Date.now(), + runId: this.runId, + error: { + code: MetonaErrorCode.UNKNOWN, + message: error.message, + retryable: false, + }, + }); + } + // 发送唯一的最终 DONE 事件 — UI 只在此处结束流式状态 this.emit('streamEvent', { type: MetonaStreamEventType.DONE, @@ -1032,6 +1116,7 @@ export class AgentLoopEngine extends EventEmitter { seq: this.nextSeq(), timestamp: Date.now(), runId: this.runId, + terminationReason: reason, }); // H-3: 通过 stateChange 发射 TERMINATED 状态(前端可据此清理 UI) diff --git a/electron/harness/types/metona-response.ts b/electron/harness/types/metona-response.ts index 28114bd..50c72c1 100644 --- a/electron/harness/types/metona-response.ts +++ b/electron/harness/types/metona-response.ts @@ -130,6 +130,9 @@ export interface MetonaStreamEvent { /** ERROR */ error?: MetonaError; + + /** DONE — 终止原因(前端可据此区分正常完成/错误/中断) */ + terminationReason?: string; } // ===== 思考内容 ===== diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 7a3b939..6d56703 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -59,12 +59,31 @@ export function registerAllIPCHandlers( ipcMain.handle('agent:sendMessage', async (_event, userMessage: MetonaMessage, sessionId: string) => { // M-33 修复: 参数校验,防止 undefined/非字符串导致下游异常 + // P1-5 修复: 校验失败时也发 ERROR+DONE 流事件,防止 isStreaming 永久卡死 if (!sessionId || typeof sessionId !== 'string') { log.warn('[AGENT] sendMessage rejected: invalid sessionId'); + if (!mainWindow.isDestroyed()) { + const errorEvent: MetonaStreamEvent = { + type: MetonaStreamEventType.ERROR, + requestId: '', sessionId: sessionId ?? '', iteration: 0, seq: 0, timestamp: Date.now(), + error: { code: MetonaErrorCode.UNKNOWN, message: '无效的会话 ID', retryable: false }, + }; + mainWindow.webContents.send('agent:streamEvent', errorEvent); + mainWindow.webContents.send('agent:streamEvent', { ...errorEvent, type: MetonaStreamEventType.DONE }); + } return { success: false, error: 'Invalid sessionId' }; } if (!userMessage || typeof userMessage !== 'object' || typeof userMessage.content !== 'string') { log.warn('[AGENT] sendMessage rejected: invalid userMessage'); + if (!mainWindow.isDestroyed()) { + const errorEvent: MetonaStreamEvent = { + type: MetonaStreamEventType.ERROR, + requestId: '', sessionId, iteration: 0, seq: 0, timestamp: Date.now(), + error: { code: MetonaErrorCode.UNKNOWN, message: '无效的消息格式', retryable: false }, + }; + mainWindow.webContents.send('agent:streamEvent', errorEvent); + mainWindow.webContents.send('agent:streamEvent', { ...errorEvent, type: MetonaStreamEventType.DONE }); + } return { success: false, error: 'Invalid message format' }; } log.info('[AGENT] sendMessage:', sessionId, (userMessage.content ?? '').slice(0, 80)); @@ -404,6 +423,9 @@ export function registerAllIPCHandlers( ipcMain.handle('agent:abortSession', async (_event, sessionId) => { log.info('[AGENT] Abort:', sessionId); agentLoop.abort(); + // MT-1 修复: 等待当前 run 完全结束再返回,防止用户立即重发时新消息卡在 + // runStream 的 currentRunPromise 等待中(最长卡 120s 工具超时) + await agentLoop.waitForAbort(); // v0.3.0 修复: 清理所有等待中的工具确认,避免定时器泄漏和超时 toast 在新会话中弹出 confirmationHook.clearPending(); @@ -670,10 +692,11 @@ export function registerAllIPCHandlers( // v0.3.1: 加入 deepseek.contextWindow / agnes.contextWindow / mimo.contextWindow,使上下文窗口配置变化也触发热重载 if (['llm.provider', 'llm.model', 'llm.apiKey', 'llm.baseURL', 'ollama.numCtx', 'deepseek.contextWindow', 'agnes.contextWindow', 'mimo.contextWindow'].includes(key)) { - // C-1 修复: reloadAdapter 返回 false 时表示加载失败,需要通知前端 + // P0-3 修复: reloadAdapter 失败时返回 success:false,让前端知道配置不完整 const reloadSuccess = reloadAdapter(); if (!reloadSuccess) { log.warn(`[CONFIG] Adapter reload failed after ${key} change`); + return { success: false, error: 'LLM 配置不完整,请检查 Provider、API Key、Base URL 和 Model 是否都已填写' }; } else { log.info(`[CONFIG] LLM config changed (${key}), adapter reloaded`); } diff --git a/electron/main.ts b/electron/main.ts index 6cab036..f687c19 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -38,6 +38,7 @@ import { DeepSeekAdapter } from './harness/adapters/deepseek.adapter'; import { AgnesAdapter } from './harness/adapters/agnes-ai.adapter'; import { MimoAdapter } from './harness/adapters/mimo.adapter'; import { OllamaAdapter } from './harness/adapters/ollama.adapter'; +import type { IMetonaProviderAdapter } from './harness/types/metona-adapter'; import { ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool, WebSearchTool, WebFetchTool, @@ -140,18 +141,22 @@ async function initialize(): Promise { memoryManager.initialize(); // ===== 步骤 4: Provider Adapter 工厂 ===== - const createAdapter = () => { + // P0-2 修复: 配置缺失时返回 null 而非抛异常,让应用能启动到 Onboarding + const createAdapter = (): IMetonaProviderAdapter | null => { const provider = configService.get('llm.provider') ?? ''; const model = configService.get('llm.model') ?? ''; const apiKey = configService.get('llm.apiKey') ?? ''; const baseURL = configService.get('llm.baseURL') ?? ''; + // 配置不完整时返回 null(不抛异常,让应用能启动到 Onboarding) if (!provider || !baseURL || !model) { - log.warn('LLM not configured. Please set provider, baseURL, and model in Settings.'); + log.warn('LLM not fully configured. Please set provider, baseURL, and model in Settings.'); + return null; } if (!apiKey && provider !== 'ollama') { - throw new Error(`API key is required for provider "${provider || 'unknown'}". Please set it in Settings.`); + log.warn(`API key is required for provider "${provider}". Please set it in Settings.`); + return null; } // v0.3.1: 读取 Provider 对应的 contextWindow 配置(Ollama 不使用此字段) @@ -168,7 +173,12 @@ async function initialize(): Promise { } }; - const adapter = createAdapter(); + // 配置未就绪时的 fallback adapter — getContextWindow 返回安全值,send/sendStream 会报错但 P0-1 修复后前端可见 + const FALLBACK_ADAPTER = new DeepSeekAdapter({ + provider: '', baseURL: '', apiKey: '', defaultModel: '', contextWindow: 4096, + }); + + const adapter = createAdapter() ?? FALLBACK_ADAPTER; // ===== 步骤 5: 工作空间文件 + System Prompt ===== const contextBuilder = new ContextBuilder(); @@ -242,9 +252,7 @@ async function initialize(): Promise { // ===== MCP Manager ===== const mcpManager = new MCPManager(() => db, toolRegistry); - mcpManager.initialize().catch((err) => { - log.warn('MCP Manager initialization error:', err); - }); + // P1-7 修复: initialize() 移到 agentLoop 创建之后,完成后重新同步工具 // ===== Hooks ===== // v0.2.0: ConfirmationHook 注入到 preToolHooks 管道 @@ -285,6 +293,14 @@ async function initialize(): Promise { agentLoop.setTools(toolRegistry.listTools()); agentLoop.setWorkspacePath(workspaceInfo.path); + // P1-7 修复: MCP 初始化在 agentLoop 创建后执行,完成后重新同步工具到 AgentLoop + mcpManager.initialize().then(() => { + agentLoop.setTools(toolRegistry.listTools()); + log.info('[MCP] Tools registered and synced to AgentLoop'); + }).catch((err) => { + log.warn('MCP Manager initialization error:', err); + }); + // ===== Memory Consolidator(会话结束 AI 提取重要记忆到 MEMORY.md)===== const memoryConsolidator = new MemoryConsolidator(adapter, workspaceService); @@ -343,6 +359,17 @@ async function initialize(): Promise { // 配置变化 — 重建 adapter const newAdapter = createAdapter(); + if (!newAdapter) { + log.warn('[CONFIG] Cannot create adapter: LLM config incomplete (provider/apiKey/baseURL/model)'); + // 不更新 lastConfigSig,下次还会尝试 + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('toast:show', { + type: 'warning', + message: 'LLM 配置不完整,请在设置中补全 Provider、API Key、Base URL 和 Model', + }); + } + return false; + } agentLoop.setAdapter(newAdapter); memoryConsolidator.setAdapter(newAdapter); // Provider 切换时同步 contextLength 和 contextWindow @@ -399,10 +426,21 @@ async function initialize(): Promise { id: 'main', workspacePath: workspaceInfo.path, title: 'MetonaAI Desktop', + // P2-11 修复: 在 loadURL 之前注册 IPC handler,消除渲染进程加载与 IPC 注册的时序窗口 + beforeLoad: (win) => { + confirmationHook.setMainWindow(win); + registerAllIPCHandlers( + win, sessionService, configService, workspaceService, + contextBuilder, agentLoop, toolRegistry, auditService, + sessionRecorder, memoryManager, mcpManager, reloadAdapter, + promptDefender, outputValidator, + confirmationHook, memoryConsolidator, orchestrator, + ); + }, }); - // v0.2.0: 为 ConfirmationHook 注入主窗口(用于向渲染进程发送确认请求) - confirmationHook.setMainWindow(mainWindow); + // P2-11 修复: ConfirmationHook.setMainWindow 已在 beforeLoad 中调用,此处无需重复 + // (beforeLoad 在 loadURL 之前执行,确保 IPC handler 注册时 mainWindow 已注入) // ===== 系统托盘 ===== const resourcesPath = join(__dirname, '../../assets'); @@ -432,15 +470,6 @@ async function initialize(): Promise { ); }); - // ===== 注册 IPC ===== - registerAllIPCHandlers( - mainWindow, sessionService, configService, workspaceService, - contextBuilder, agentLoop, toolRegistry, auditService, - sessionRecorder, memoryManager, mcpManager, reloadAdapter, - promptDefender, outputValidator, - confirmationHook, memoryConsolidator, orchestrator, - ); - // TODO: Initialize UpdateService for auto-update functionality // const updateService = new UpdateService(); // updateService.initialize(mainWindow); diff --git a/electron/services/window-manager.service.ts b/electron/services/window-manager.service.ts index 3ea8f4a..08ec404 100644 --- a/electron/services/window-manager.service.ts +++ b/electron/services/window-manager.service.ts @@ -36,6 +36,7 @@ export class WindowManager { workspacePath?: string; title?: string; state?: WindowState; + beforeLoad?: (win: BrowserWindow) => void; }): BrowserWindow { const id = options.id ?? `window_${Date.now()}`; const state = options.state ?? { width: 1440, height: 900 }; @@ -59,6 +60,9 @@ export class WindowManager { }, }); + // P2-11 修复: 在 loadURL 之前执行回调,确保 IPC handler 在渲染进程加载前注册 + if (options.beforeLoad) options.beforeLoad(win); + // 加载页面 if (is.dev && process.env['ELECTRON_RENDERER_URL']) { win.loadURL(process.env['ELECTRON_RENDERER_URL']); diff --git a/package-lock.json b/package-lock.json index 91eb8a0..800d873 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ai-desktop", - "version": "0.3.4", + "version": "0.3.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ai-desktop", - "version": "0.3.4", + "version": "0.3.7", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index e73fcc1..0aa5079 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ai-desktop", - "version": "0.3.4", + "version": "0.3.7", "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 cfcc9f1..e2ac0c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -52,7 +52,12 @@ export default function App(): React.JSX.Element { const provider = providerResult.status === 'fulfilled' ? (providerResult.value as string) || '' : ''; const model = modelResult.status === 'fulfilled' ? (modelResult.value as string) || '' : ''; if (provider || model) useAgentStore.getState().setProvider(provider, model); + // P1-6 修复: 配置加载完成,启用发送按钮 + useAgentStore.getState().setConfigLoaded(true); }); + } else { + // 无 IPC 可用时也标记为已加载(避免永久禁用) + useAgentStore.getState().setConfigLoaded(true); } }, []); diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 7fd4be2..37e5990 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -35,7 +35,20 @@ export function CommandPalette(): React.JSX.Element { const getCommands = useCallback((): CommandItem[] => { const cmds: CommandItem[] = [ - { id: 'new-session', icon: Plus, label: '新建会话', description: '创建一个新的对话会话', action: async () => { if (window.metona?.sessions?.create) { const r = await window.metona.sessions.create() as MetonaSessionInfo; useSessionStore.getState().addSession(r); useSessionStore.getState().setCurrentSession(r.id); useAgentStore.getState().setCurrentSession(r.id); } setOpen(false); } }, + { id: 'new-session', icon: Plus, label: '新建会话', description: '创建一个新的对话会话', action: async () => { + if (window.metona?.sessions?.create) { + try { + const r = await window.metona.sessions.create() as MetonaSessionInfo; + useSessionStore.getState().addSession(r); + useSessionStore.getState().setCurrentSession(r.id); + useAgentStore.getState().setCurrentSession(r.id); + } catch (err) { + console.error('[CommandPalette]', err); + import('metona-toast').then((mod) => mod.default.error('创建会话失败')).catch(() => {}); + } + } + setOpen(false); + } }, { id: 'clear', icon: Trash2, label: '清空当前会话', action: () => { useAgentStore.getState().clearMessages(); setOpen(false); } }, { id: 'settings', icon: Settings, label: '打开设置', action: () => { useUIStore.getState().openSettings(); setOpen(false); } }, ]; diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index dc96941..e520fc0 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -75,24 +75,88 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C case 'session': { const sid = (data as { sessionId?: string })?.sessionId; + // 修复: 4 个 session 操作统一改为 await IPC + 失败回滚,避免 UI 与 DB 状态不一致 + // 之前 archive 仅调用前端 store 未调用 IPC,rename/pin/delete 未等待 IPC 结果即更新 UI + const showError = (msg: string) => { + import('metona-toast').then((mod) => mod.default.error(msg)).catch(() => {}); + }; return [ - { id: 'rename', icon: Edit, label: '重命名', action: () => { + { id: 'rename', icon: Edit, label: '重命名', action: async () => { + if (!sid) return; // window.prompt may be unreliable in Electron; fall back to sidebar double-click rename - if (sid) { try { const t = prompt('新会话名称:'); if (t?.trim()) { window.metona?.sessions.rename(sid, t.trim()); useSessionStore.getState().updateSession(sid, { title: t.trim() }); } } catch { /* prompt unavailable, user can rename via sidebar double-click */ } } + let t: string | null = null; + try { t = prompt('新会话名称:'); } catch { /* prompt unavailable, user can rename via sidebar double-click */ return; } + if (!t?.trim()) return; + try { + const r = await window.metona?.sessions?.rename(sid, t.trim()); + if (r?.success) { + useSessionStore.getState().updateSession(sid, { title: t.trim() }); + } else { + showError(r?.error ?? '重命名失败'); + } + } catch (err) { + console.error('[ContextMenu]', err); + showError('重命名失败'); + } }}, - { id: 'pin', icon: Pin, label: '置顶', action: () => { - if (sid) { const s = useSessionStore.getState().sessions.find((x) => x.id === sid); if (s) { window.metona?.sessions.pin(sid, !s.pinned); useSessionStore.getState().pinSession(sid, !s.pinned); } } + { id: 'pin', icon: Pin, label: '置顶', action: async () => { + if (!sid) return; + const s = useSessionStore.getState().sessions.find((x) => x.id === sid); + if (!s) return; + const newPinned = !s.pinned; + try { + const r = await window.metona?.sessions?.pin(sid, newPinned); + if (r?.success) { + useSessionStore.getState().pinSession(sid, newPinned); + } else { + showError(r?.error ?? '置顶失败'); + } + } catch (err) { + console.error('[ContextMenu]', err); + showError('置顶失败'); + } + }}, + // 修复: archive 之前完全未调用 IPC,UI 改了但 DB 没改,重启后状态丢失 + { id: 'archive', icon: Archive, label: '归档', action: async () => { + if (!sid) return; + const s = useSessionStore.getState().sessions.find((x) => x.id === sid); + const newArchived = s ? !s.archived : true; + try { + const r = await window.metona?.sessions?.archive(sid, newArchived); + if (r?.success) { + useSessionStore.getState().archiveSession(sid, newArchived); + } else { + showError(r?.error ?? '归档失败'); + } + } catch (err) { + console.error('[ContextMenu]', err); + showError('归档失败'); + } }}, - { id: 'archive', icon: Archive, label: '归档', action: () => { if (sid) useSessionStore.getState().archiveSession(sid, true); }}, { id: 'export', icon: FileDown, label: '导出', action: () => { if (sid) window.metona?.sessions.getMessages(sid).then((msgs) => { const b = new Blob([JSON.stringify(msgs, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `session-${sid}.json`; a.click(); - }).catch((err) => console.error('[ContextMenu]', err)); + }).catch((err) => { + console.error('[ContextMenu]', err); + showError('导出失败'); + }); }}, - { id: 'delete', icon: Trash2, label: '删除', action: () => { + { id: 'delete', icon: Trash2, label: '删除', action: async () => { + if (!sid) return; // window.confirm may be unreliable in Electron - if (sid) { try { if (confirm('确定删除此会话?')) { window.metona?.sessions.delete(sid); useSessionStore.getState().removeSession(sid); } } catch { /* confirm unavailable */ } } + try { if (!confirm('确定删除此会话?')) return; } catch { /* confirm unavailable */ return; } + try { + const r = await window.metona?.sessions?.delete(sid); + if (r?.success) { + useSessionStore.getState().removeSession(sid); + } else { + showError(r?.error ?? '删除失败'); + } + } catch (err) { + console.error('[ContextMenu]', err); + showError('删除失败'); + } }}, ]; } diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index 638e39c..68c2817 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -46,6 +46,7 @@ export function ChatInput(): React.JSX.Element { const sendMessage = useAgentStore((s) => s.sendMessage); const abort = useAgentStore((s) => s.abort); const isStreaming = useAgentStore((s) => s.isStreaming); + const configLoaded = useAgentStore((s) => s.configLoaded); const tokenUsage = useAgentStore((s) => s.tokenUsage); const currentSessionId = useSessionStore((s) => s.currentSessionId); const provider = useAgentStore((s) => s.provider); @@ -171,6 +172,7 @@ export function ChatInput(): React.JSX.Element { const trimmed = input.trim(); if (!trimmed && attachments.length === 0) return; if (isStreaming) return; + if (!configLoaded) return; // P1-6: 配置未加载完成时禁止发送 // 处理 / 命令 if (trimmed.startsWith('/')) { @@ -326,8 +328,8 @@ export function ChatInput(): React.JSX.Element { onChange={handleChange} onKeyDown={handleKeyDown} onPaste={handlePaste} - placeholder="输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)" - disabled={isStreaming} + placeholder={configLoaded ? '输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)' : '正在加载配置...'} + disabled={isStreaming || !configLoaded} multiline rows={1} sx={{ @@ -363,7 +365,7 @@ export function ChatInput(): React.JSX.Element { 中断 ) : ( - )} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index ccc624f..86eee7e 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -176,7 +176,12 @@ function ToolManagerPanel() { if (window.metona?.tools?.list) { window.metona.tools.list().then((list) => { if (!cancelled) setTools(list as MetonaToolInfo[]); - }).catch((err) => { console.error('[Sidebar]', err); }); + }).catch((err) => { + console.error('[Sidebar]', err); + if (!cancelled) { + import('metona-toast').then((mod) => mod.default.error('加载工具列表失败')).catch(() => {}); + } + }); } return () => { cancelled = true; }; }, []); diff --git a/src/components/memory/MemoryViewer.tsx b/src/components/memory/MemoryViewer.tsx index 8606ea4..4a4e74c 100644 --- a/src/components/memory/MemoryViewer.tsx +++ b/src/components/memory/MemoryViewer.tsx @@ -12,6 +12,7 @@ import { } from '@mui/material'; import { Brain, Search, Trash2, ChevronDown } from 'lucide-react'; import { useAgentStore } from '@renderer/stores/agent-store'; +import { useUIStore } from '@renderer/stores/ui-store'; import { formatTime, truncate } from '@renderer/lib/formatters'; // ===== 类型 ===== @@ -83,6 +84,8 @@ export function MemoryViewer(): React.JSX.Element { // Agent 完成时自动刷新:监听 agentStatus 从 thinking/executing -> idle const agentStatus = useAgentStore((s) => s.agentStatus); const prevStatus = useRef(agentStatus); + // v0.3.6: 监听 memoryVersion 变化(SettingsModal 清理记忆后自增),触发重新加载 + const memoryVersion = useUIStore((s) => s.memoryVersion); // M-53 修复: 竞态保护 ref,防止多次 loadMemories 调用乱序完成导致旧数据覆盖 const loadReqIdRef = useRef(0); // 审计补充修复: handleSearch 使用独立 ref,避免与 loadMemories 共用导致 searching 状态卡死 @@ -130,6 +133,14 @@ export function MemoryViewer(): React.JSX.Element { } }, [agentStatus, loadMemories]); + // v0.3.6: SettingsModal 清理记忆后 memoryVersion 自增,触发重新加载 + // 之前清理后需重启应用 MemoryViewer 才会刷新(与 clearSessions 同样的 bug) + useEffect(() => { + if (memoryVersion > 0) { + loadMemories(); + } + }, [memoryVersion, loadMemories]); + const handleSearch = async () => { const q = searchQuery.trim(); if (!q || !window.metona?.memory?.search) return; diff --git a/src/components/onboarding/OnboardingWizard.tsx b/src/components/onboarding/OnboardingWizard.tsx index 0a20503..5867b7f 100644 --- a/src/components/onboarding/OnboardingWizard.tsx +++ b/src/components/onboarding/OnboardingWizard.tsx @@ -44,6 +44,8 @@ export function OnboardingWizard(): React.JSX.Element | null { return; } useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || ''); + // P1-6 修复: Onboarding 完成后标记配置已加载 + useAgentStore.getState().setConfigLoaded(true); } setOnboardingCompleted(true); } catch (err) { @@ -107,7 +109,15 @@ export function OnboardingWizard(): React.JSX.Element | null { setWorkspacePath(e.target.value)} placeholder="~/MetonaWorkspaces/default/" sx={{ flex: 1 }} /> 包含 SOUL.md、AGENTS.md、MEMORY.md、USERS.md 四个必需文件,首次打开时自动创建。 diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index 3c781a4..54d4fc5 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -2,12 +2,13 @@ * SettingsModal — 设置弹窗 */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { Dialog, DialogContent, DialogTitle, DialogActions, Button, TextField, Select, MenuItem, Tabs, Tab, Checkbox, Box, Typography, Stack, Divider, FormControlLabel, InputLabel, FormControl, IconButton, Chip, Switch, Alert, CircularProgress } from '@mui/material'; import { alpha } from '@mui/material/styles'; -import { X, Settings, Bot, Wrench, Server, Palette, FileText, Eye, EyeOff, FolderOpen, Globe } from 'lucide-react'; +import { X, Settings, Bot, Wrench, Server, Palette, FileText, Eye, EyeOff, FolderOpen, Globe, Folder, Copy } from 'lucide-react'; import { useUIStore, type ThemeMode } from '@renderer/stores/ui-store'; import { useAgentStore } from '@renderer/stores/agent-store'; +import { useSessionStore } from '@renderer/stores/session-store'; import { PROVIDER_LABELS } from '@renderer/lib/constants'; import { ErrorBoundary } from '@renderer/components/common/ErrorBoundary'; @@ -81,8 +82,28 @@ export function SettingsModal(): React.JSX.Element | null { function useConfig(key: string, defaultValue: T): [T, (v: T) => void] { const [value, setValue] = useState(defaultValue); + const valueRef = useRef(value); + valueRef.current = value; + // v0.3.6 修复: 配置保存失败时回滚 UI 并提示用户,避免 UI 与 DB 状态不一致 + // seqRef 防止竞态:连续修改时旧请求失败不回滚覆盖新值 + const seqRef = useRef(0); useEffect(() => { if (window.metona?.config?.get) window.metona.config.get(key).then((v) => { if (v != null) setValue(v as T); }).catch((err) => { console.error('[SettingsModal]', err); }); }, [key]); - const set = useCallback((v: T) => { setValue(v); window.metona?.config?.set(key, v).catch((err) => { console.error('[SettingsModal]', err); }); }, [key]); + const set = useCallback((v: T) => { + const seq = ++seqRef.current; + const prev = valueRef.current; + setValue(v); + window.metona?.config?.set(key, v).then((r: { success?: boolean; error?: string } | undefined) => { + if (r && !r.success) { + // 只有当没有后续 set 操作时才回滚,避免覆盖用户的新修改 + if (seqRef.current === seq) setValue(prev); + import('metona-toast').then((mod) => mod.default.error(r.error ?? '配置保存失败')).catch(() => {}); + } + }).catch((err: unknown) => { + console.error('[SettingsModal]', err); + if (seqRef.current === seq) setValue(prev); + import('metona-toast').then((mod) => mod.default.error('配置保存失败')).catch(() => {}); + }); + }, [key]); return [value, set]; } @@ -163,7 +184,18 @@ function WorkspaceSettings() { setCheckResult(null); }; - const handleOpen = () => { if (workspacePath) window.metona?.app?.showItemInFolder(workspacePath); }; + const handleOpen = async () => { + if (!workspacePath) return; + try { + const r = await window.metona?.app?.showItemInFolder(workspacePath); + if (r && !r.success) { + import('metona-toast').then((mod) => mod.default.error(r.error ?? '打开文件夹失败')).catch(() => {}); + } + } catch (err) { + console.error('[SettingsModal]', err); + import('metona-toast').then((mod) => mod.default.error('打开文件夹失败')).catch(() => {}); + } + }; return ( @@ -500,17 +532,37 @@ function ToolsSettings() { }; useEffect(() => { loadTools(); loadAutoExec(); }, []); - const handleToggle = (name: string, enabled: boolean) => { + const handleToggle = async (name: string, enabled: boolean) => { + // v0.3.6 修复: 乐观更新失败时回滚 UI,避免开关显示与实际状态不一致 + // 注意: 只回滚失败的单个工具(用 !enabled),不能用 setTools(prev) 整体回滚, + // 否则会覆盖 await 期间用户对其他工具的并发修改 setTools((p) => p.map((t) => t.name === name ? { ...t, enabled } : t)); - window.metona?.tools?.toggle(name, enabled).catch((err) => { console.error('[SettingsModal]', err); }); + try { + const r = await window.metona?.tools?.toggle(name, enabled); + if (r && !r.success) { + setTools((p) => p.map((t) => t.name === name ? { ...t, enabled: !enabled } : t)); + import('metona-toast').then((mod) => mod.default.error(r.error ?? '切换工具失败')).catch(() => {}); + } + } catch (err) { + console.error('[SettingsModal]', err); + setTools((p) => p.map((t) => t.name === name ? { ...t, enabled: !enabled } : t)); + import('metona-toast').then((mod) => mod.default.error('切换工具失败')).catch(() => {}); + } }; // 设置/取消自动执行 const handleSetAutoExec = async (name: string, enabled: boolean) => { if (!window.metona?.tool?.setAutoExecute) return; - const r = await window.metona.tool.setAutoExecute(name, enabled); - if (r.success) { - setAutoExecList((p) => enabled ? [...p, name] : p.filter((n) => n !== name)); + try { + const r = await window.metona.tool.setAutoExecute(name, enabled); + if (r.success) { + setAutoExecList((p) => enabled ? [...p, name] : p.filter((n) => n !== name)); + } else { + import('metona-toast').then((mod) => mod.default.error(r.error ?? '设置自动执行失败')).catch(() => {}); + } + } catch (err) { + console.error('[SettingsModal]', err); + import('metona-toast').then((mod) => mod.default.error('设置自动执行失败')).catch(() => {}); } }; @@ -656,7 +708,20 @@ function MCPSettings() { })(); return () => { cancelled = true; }; }, []); - const handleAdd = async () => { if (!newName.trim() || !newCommand.trim()) return; await window.metona?.mcp?.addServer({ name: newName.trim(), transport: 'stdio', command: newCommand.trim(), args: newArgs.trim() ? newArgs.trim().split(/\s+/) : [], enabled: true }); setNewName(''); setNewCommand(''); setNewArgs(''); setShowAdd(false); loadServers(); }; + const handleAdd = async () => { + if (!newName.trim() || !newCommand.trim()) return; + try { + const r = await window.metona?.mcp?.addServer({ name: newName.trim(), transport: 'stdio', command: newCommand.trim(), args: newArgs.trim() ? newArgs.trim().split(/\s+/) : [], enabled: true }); + if (r?.success) { + setNewName(''); setNewCommand(''); setNewArgs(''); setShowAdd(false); loadServers(); + } else { + import('metona-toast').then((mod) => mod.default.error(r?.error ?? '添加 MCP 服务失败')).catch(() => {}); + } + } catch (err) { + console.error('[SettingsModal]', err); + import('metona-toast').then((mod) => mod.default.error(`添加 MCP 服务失败:${(err as Error).message}`)).catch(() => {}); + } + }; const statusColors: Record = { connected: 'success.main', connecting: 'warning.main', disconnected: 'text.secondary', error: 'error.main' }; // L-11 修复: 确认移除 MCP 服务 @@ -666,9 +731,13 @@ function MCPSettings() { if (!confirmRemove) return; try { setRemoveError(null); - await window.metona?.mcp?.removeServer(confirmRemove); - setConfirmRemove(null); - loadServers(); + const r = await window.metona?.mcp?.removeServer(confirmRemove); + if (r?.success) { + setConfirmRemove(null); + loadServers(); + } else { + setRemoveError(r?.error ?? '移除失败'); + } } catch (err) { setRemoveError((err as Error).message ?? '移除失败'); } @@ -686,7 +755,19 @@ function MCPSettings() { {s.toolCount > 0 && {s.toolCount} 工具} - + {/* L-11 修复: 点击移除打开 MUI Dialog 二次确认,而非原生 confirm() */} @@ -935,7 +1016,76 @@ function LogsSettings() { // L-11 修复(审计补充): 用 MUI Dialog 替换原生 confirm() 和 alert(),保持 UI 一致性 const [confirmClear, setConfirmClear] = useState<'sessions' | 'memories' | 'auditLogs' | null>(null); const [resultAlert, setResultAlert] = useState<{ type: 'success' | 'error'; message: string } | null>(null); - const handleExport = async () => { if (!window.metona?.data?.exportData) return; const r = await window.metona.data.exportData(); if (r.success && r.data) { const b = new Blob([JSON.stringify(r.data, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `metona-export-${Date.now()}.json`; a.click(); } }; + // P3-13: 显示日志文件路径,并提供"打开日志文件夹"按钮 + // electron-log 默认写入路径为 ${userData}/logs/main.log + const [logFilePath, setLogFilePath] = useState(''); + const [logPathLoading, setLogPathLoading] = useState(true); + const [copyState, setCopyState] = useState<'idle' | 'success' | 'error'>('idle'); + + // P3-13: 组件挂载时获取日志文件路径 + useEffect(() => { + let cancelled = false; + (async () => { + try { + const appData = await window.metona?.app?.getAppDataPath?.(); + if (cancelled) return; + if (appData) { + // electron-log 默认日志路径: ${userData}/logs/main.log + // 路径分隔符由系统决定,直接拼接避免引入 path 模块 + const sep = appData.includes('/') && !appData.includes('\\') ? '/' : '\\'; + setLogFilePath(`${appData}${sep}logs${sep}main.log`); + } + } catch (e) { + // 获取失败不阻塞 UI + // eslint-disable-next-line no-console + console.warn('[LogsSettings] Failed to get app data path:', e); + } finally { + if (!cancelled) setLogPathLoading(false); + } + })(); + return () => { cancelled = true; }; + }, []); + + const handleOpenLogFolder = async () => { + if (!logFilePath) return; + try { + const r = await window.metona?.app?.showItemInFolder?.(logFilePath); + if (r && !r.success) { + setResultAlert({ type: 'error', message: `打开失败: ${r.error ?? '未知错误'}` }); + } + } catch (e) { + setResultAlert({ type: 'error', message: `打开失败: ${(e as Error).message}` }); + } + }; + + const handleCopyLogPath = async () => { + if (!logFilePath) return; + try { + await navigator.clipboard.writeText(logFilePath); + setCopyState('success'); + setTimeout(() => setCopyState('idle'), 1500); + } catch (e) { + setCopyState('error'); + setTimeout(() => setCopyState('idle'), 1500); + setResultAlert({ type: 'error', message: `复制失败: ${(e as Error).message}` }); + } + }; + + const handleExport = async () => { + if (!window.metona?.data?.exportData) return; + try { + const r = await window.metona.data.exportData(); + if (r.success && r.data) { + const b = new Blob([JSON.stringify(r.data, null, 2)], { type: 'application/json' }); + const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `metona-export-${Date.now()}.json`; a.click(); + } else { + setResultAlert({ type: 'error', message: `导出失败: ${r.error ?? '未知错误'}` }); + } + } catch (err) { + console.error('[LogsSettings]', err); + setResultAlert({ type: 'error', message: `导出失败: ${(err as Error).message}` }); + } + }; const labels: Record = { sessions: '所有会话', memories: '所有记忆', auditLogs: '审计日志' }; // L-11 修复(审计补充): 清理数据改用 Dialog 确认 + Alert 反馈,不再用原生 confirm/alert @@ -951,6 +1101,17 @@ function LogsSettings() { else r = await window.metona?.data?.clearAuditLogs(); if (r?.success) { setResultAlert({ type: 'success', message: `${labels[type]}已清理` }); + // 修复: 清理会话后同步清空前端状态,无需重启应用 + if (type === 'sessions') { + useSessionStore.getState().setSessions([]); + useSessionStore.getState().setCurrentSession(null); + // 同时清空当前消息列表,防止聊天面板显示已删除的会话内容 + useAgentStore.getState().setMessages([]); + } + // v0.3.6 修复: 清理记忆后触发 MemoryViewer 重新加载(之前需重启应用才看到效果) + if (type === 'memories') { + useUIStore.getState().bumpMemoryVersion(); + } } else { setResultAlert({ type: 'error', message: `失败: ${r?.error ?? '未知错误'}` }); } @@ -968,6 +1129,44 @@ function LogsSettings() { DEBUGINFOWARNERROR + + {/* P3-13: 日志文件路径展示与打开按钮 */} + + 日志文件 + + + + + + + 日志级别变更重启后生效。日志文件按日期滚动,旧日志保留在 logs 目录下。 + + + 数据管理 diff --git a/src/components/workspace/WorkspaceViewer.tsx b/src/components/workspace/WorkspaceViewer.tsx index d64686b..afa2cbe 100644 --- a/src/components/workspace/WorkspaceViewer.tsx +++ b/src/components/workspace/WorkspaceViewer.tsx @@ -65,8 +65,13 @@ export function WorkspaceViewer(): React.JSX.Element { } }, [agentStatus, loadInfo]); - const handleOpenInFolder = (path: string) => { - window.metona?.app?.showItemInFolder(path); + const handleOpenInFolder = async (path: string) => { + try { + await window.metona?.app?.showItemInFolder(path); + } catch (err) { + console.error('[WorkspaceViewer]', err); + import('metona-toast').then((mod) => mod.default.error('打开文件夹失败')).catch(() => {}); + } }; if (error) { diff --git a/src/hooks/useAgentStream.ts b/src/hooks/useAgentStream.ts index 1eaaeac..a30c87d 100644 --- a/src/hooks/useAgentStream.ts +++ b/src/hooks/useAgentStream.ts @@ -428,20 +428,37 @@ export function useAgentStream(): void { store.updateLastTraceStep({ state: data.state, states: [...currentStates, data.state], + // MT-2 修复: TERMINATED 状态到达时,标记步骤为已完成 + // 防止 abort 后最后一个步骤永远显示转圈 + ...(data.state === 'TERMINATED' ? { completedAt: Date.now() } : {}), }); } } else { - // 新迭代 → 标记上一步完成,创建新步骤 - if (lastStep && !lastStep.completedAt) { - store.updateLastTraceStep({ completedAt: Date.now() }); + // MT-2 修复: 当 state === 'TERMINATED' 且 iteration 不匹配时, + // 说明 abort 发生在循环条件检查时,currentIteration 已自增但没有对应 step。 + // 此时不应创建孤立的只含 TERMINATED 的步骤,而是更新最后一个步骤的 states 数组 + if (data.state === 'TERMINATED' && lastStep) { + const currentStates = lastStep.states ?? [lastStep.state]; + if (currentStates[currentStates.length - 1] !== 'TERMINATED') { + store.updateLastTraceStep({ + state: 'TERMINATED', + states: [...currentStates, 'TERMINATED'], + completedAt: Date.now(), + }); + } + } else { + // 新迭代 → 标记上一步完成,创建新步骤 + if (lastStep && !lastStep.completedAt) { + store.updateLastTraceStep({ completedAt: Date.now() }); + } + store.addTraceStep({ + id: `trace_${data.iteration}_${data.state}_${Date.now()}`, + iteration: data.iteration, + state: data.state, + states: [data.state], + startedAt: Date.now(), + }); } - store.addTraceStep({ - id: `trace_${data.iteration}_${data.state}_${Date.now()}`, - iteration: data.iteration, - state: data.state, - states: [data.state], - startedAt: Date.now(), - }); } } diff --git a/src/stores/agent-store.ts b/src/stores/agent-store.ts index a6deea9..e5be85c 100644 --- a/src/stores/agent-store.ts +++ b/src/stores/agent-store.ts @@ -99,6 +99,9 @@ interface AgentState { // 流式 isStreaming: boolean; + // P1-6 修复: 配置加载完成标志 — 加载完成前禁用发送按钮 + configLoaded: boolean; + // Run 标识(用于过滤旧流事件) currentRunId: string | null; @@ -140,6 +143,7 @@ export const useAgentStore = create((set, get) => ({ tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, traceSteps: [], isStreaming: false, + configLoaded: false, currentRunId: null, provider: '', model: '', @@ -342,6 +346,8 @@ export const useAgentStore = create((set, get) => ({ setStreaming: (streaming) => set({ isStreaming: streaming }), + setConfigLoaded: (loaded) => set({ configLoaded: loaded }), + setCurrentRunId: (runId) => set({ currentRunId: runId }), addTraceStep: (step) => @@ -382,7 +388,10 @@ export const useAgentStore = create((set, get) => ({ if (window.metona?.config?.get) { // Ollama 用 ollama.numCtx,DeepSeek 用 deepseek.contextWindow,Agnes 用 agnes.contextWindow const configKey = provider === 'ollama' ? 'ollama.numCtx' : `${provider}.contextWindow`; + // P2-12 修复: 竞态保护 — 快速切换 provider 时,旧 Promise resolve 不覆盖新值 + const expectedProvider = provider; window.metona.config.get(configKey).then((v) => { + if (get().provider !== expectedProvider) return; // provider 已切换,丢弃旧结果 if (v != null && typeof v === 'number' && v > 0) { set({ contextWindow: v }); } diff --git a/src/stores/ui-store.ts b/src/stores/ui-store.ts index 17a8f31..4fda181 100644 --- a/src/stores/ui-store.ts +++ b/src/stores/ui-store.ts @@ -37,6 +37,10 @@ interface UIState { preFocusSidebarVisible: boolean; preFocusDetailVisible: boolean; + // v0.3.6: 记忆数据版本号 — 清理记忆后自增,MemoryViewer 监听变化重新加载 + // 避免 SettingsModal 清理记忆后 MemoryViewer 仍显示旧数据(需重启应用才刷新) + memoryVersion: number; + // Actions setTheme: (theme: ThemeMode) => void; setResolvedTheme: (theme: 'dark' | 'light') => void; @@ -49,6 +53,8 @@ interface UIState { toggleSidebar: () => void; toggleDetail: () => void; toggleFocusMode: () => void; + // v0.3.6: 记忆清理后触发前端刷新 + bumpMemoryVersion: () => void; } export const useUIStore = create((set) => ({ @@ -64,6 +70,8 @@ export const useUIStore = create((set) => ({ detailTab: 'trace', preFocusSidebarVisible: true, preFocusDetailVisible: true, + // v0.3.6: 记忆数据版本号初始为 0 + memoryVersion: 0, setTheme: (theme) => set({ theme }), setResolvedTheme: (resolvedTheme) => set({ resolvedTheme }), @@ -101,4 +109,6 @@ export const useUIStore = create((set) => ({ detailVisible: s.preFocusDetailVisible, }; }), + // v0.3.6: 清理记忆后自增版本号,触发 MemoryViewer 重新加载 + bumpMemoryVersion: () => set((s) => ({ memoryVersion: s.memoryVersion + 1 })), })); diff --git a/src/types/global.d.ts b/src/types/global.d.ts index dc2a3b5..0096334 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -169,8 +169,8 @@ interface MetonaConfigAPI { interface MetonaAppAPI { getVersion: () => Promise; getAppDataPath: () => Promise; - openExternal: (url: string) => Promise; - showItemInFolder: (path: string) => Promise; + openExternal: (url: string) => Promise<{ success: boolean; error?: string }>; + showItemInFolder: (path: string) => Promise<{ success: boolean; error?: string }>; selectFolder: (defaultPath?: string) => Promise<{ canceled: boolean; path: string }>; restart: () => Promise<{ success: boolean }>; }