diff --git a/.gitignore b/.gitignore index d89355b..48410a7 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,6 @@ MetonaWorkspaces/ .cache/ .temp/ tmp/ + +# Windows reserved device name +nul diff --git a/electron-builder.yml b/electron-builder.yml index f0d3130..41acd8a 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -7,7 +7,12 @@ }, "files": [ "dist/**/*", - "dist-electron/**/*" + "dist-electron/**/*", + "assets/**/*" + ], + "asar": true, + "asarUnpack": [ + "assets/**/*" ], "mac": { "category": "public.app-category.developer-tools", diff --git a/electron/harness/adapters/agnes-ai.adapter.ts b/electron/harness/adapters/agnes-ai.adapter.ts index dedd2a3..ceadc39 100644 --- a/electron/harness/adapters/agnes-ai.adapter.ts +++ b/electron/harness/adapters/agnes-ai.adapter.ts @@ -79,6 +79,7 @@ export class AgnesAdapter extends BaseAdapter { ...this.config.headers, }, body: JSON.stringify(body), + signal: AbortSignal.timeout(this.config.timeoutMs ?? 300_000), }); if (!response.ok || !response.body) { diff --git a/electron/harness/adapters/deepseek.adapter.ts b/electron/harness/adapters/deepseek.adapter.ts index 3ca64ec..b8013b5 100644 --- a/electron/harness/adapters/deepseek.adapter.ts +++ b/electron/harness/adapters/deepseek.adapter.ts @@ -174,6 +174,7 @@ export class DeepSeekAdapter extends BaseAdapter { const effortMap: Record = { low: 'high', medium: 'high', high: 'high', xhigh: 'max', max: 'max', }; + // DeepSeek API 仅支持 high / max 两档,low/medium 映射为 high body.reasoning_effort = effortMap[request.params.thinkingEffort ?? 'high'] ?? 'high'; } diff --git a/electron/harness/adapters/ollama.adapter.ts b/electron/harness/adapters/ollama.adapter.ts index 1792fbf..1c9d577 100644 --- a/electron/harness/adapters/ollama.adapter.ts +++ b/electron/harness/adapters/ollama.adapter.ts @@ -347,6 +347,7 @@ export class OllamaAdapter extends BaseAdapter { request.systemPrompt.roleDefinition, request.systemPrompt.outputConstraints, request.systemPrompt.safetyGuidelines, + request.systemPrompt.dynamicReminders, ].filter(Boolean).join('\n\n'), }, ...request.messages.filter((m) => m.role !== 'system').map((m) => { @@ -376,6 +377,10 @@ export class OllamaAdapter extends BaseAdapter { function: { name: tc.name, arguments: JSON.stringify(tc.args) }, })); } + // 推理内容回传(保持多轮推理链完整) + if (m.role === 'assistant' && m.reasoningContent) { + (msg as Record).reasoning_content = m.reasoningContent; + } return msg; }), ]; @@ -467,8 +472,8 @@ function mapOllamaDoneReason( switch (reason) { case 'stop': return MetonaFinishReason.STOP; case 'length': return MetonaFinishReason.LENGTH; - case 'load': return MetonaFinishReason.ERROR; - case 'unload': return MetonaFinishReason.ERROR; + case 'load': return MetonaFinishReason.STOP; // 冷启动加载完成,非错误 + case 'unload': return MetonaFinishReason.STOP; default: return MetonaFinishReason.STOP; } } diff --git a/electron/harness/adapters/shared/openai-format.ts b/electron/harness/adapters/shared/openai-format.ts index 4af2e10..daac6af 100644 --- a/electron/harness/adapters/shared/openai-format.ts +++ b/electron/harness/adapters/shared/openai-format.ts @@ -40,14 +40,17 @@ export function buildOpenAICompatibleMessages( .map((m) => { const msg: Record = { role: m.role, content: m.content }; - // === Assistant 工具调用历史 === - if (m.role === 'assistant' && m.toolCalls?.length) { - msg.tool_calls = m.toolCalls.map((tc) => ({ - id: tc.id, - type: 'function', - function: { name: tc.name, arguments: JSON.stringify(tc.args) }, - })); - // 推理内容必须带回上下文(否则 LLM 丢失思考链) + // === Assistant 消息 === + if (m.role === 'assistant') { + // 工具调用历史 + if (m.toolCalls?.length) { + msg.tool_calls = m.toolCalls.map((tc) => ({ + id: tc.id, + type: 'function', + function: { name: tc.name, arguments: JSON.stringify(tc.args) }, + })); + } + // 推理内容(无论是否有工具调用,都保留 reasoning_content) if (m.reasoningContent) { msg.reasoning_content = m.reasoningContent; } diff --git a/electron/harness/adapters/shared/sse-stream.ts b/electron/harness/adapters/shared/sse-stream.ts index 4e0cb1c..15da8d7 100644 --- a/electron/harness/adapters/shared/sse-stream.ts +++ b/electron/harness/adapters/shared/sse-stream.ts @@ -142,7 +142,7 @@ export async function* parseSSEStream( } } - // Token 使用统计 + // Token 使用统计 / finish_reason if (chunk.usage) { const usage: MetonaTokenUsage = { inputTokens: chunk.usage.prompt_tokens ?? 0, @@ -163,6 +163,33 @@ export async function* parseSSEStream( usage, }; } + + // 非 [DONE] 但 finish_reason 为 tool_calls 时提前 flush 缓冲区 + const finishReason = chunk.choices?.[0]?.finish_reason as string | undefined; + if (finishReason === 'tool_calls' || finishReason === 'stop') { + for (const [index, buf] of toolCallsBuffer) { + try { + yield { + type: MetonaStreamEventType.TOOL_CALL_COMPLETE, + requestId, + sessionId, + iteration, + seq: seq++, + timestamp: Date.now(), + toolCall: { + id: `tc_${nanoid(8)}`, + name: buf.name, + args: buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {}, + iteration, + timestamp: Date.now(), + }, + }; + } catch { + // JSON 解析失败,跳过 + } + } + toolCallsBuffer.clear(); + } } catch { // 跳过解析失败的行 } @@ -195,10 +222,16 @@ export function parseOpenAICompatibleResponse( reasoningContent: message?.reasoning_content as string | undefined, toolCalls: rawToolCalls?.map((tc) => { const fn = tc.function as Record; + let args: Record = {}; + try { + args = JSON.parse(fn.arguments as string); + } catch { + args = {}; + } return { id: tc.id as string, name: fn.name as string, - args: JSON.parse(fn.arguments as string), + args, iteration: 0, timestamp: Date.now(), }; diff --git a/electron/harness/agent-loop/engine.ts b/electron/harness/agent-loop/engine.ts index 8628f22..2e43340 100644 --- a/electron/harness/agent-loop/engine.ts +++ b/electron/harness/agent-loop/engine.ts @@ -59,6 +59,7 @@ export class AgentLoopEngine extends EventEmitter { private config: AgentLoopConfig; private tools: MetonaToolDef[] = []; private currentSessionId: string = ''; + private currentRequestId: string = ''; private workspacePath: string = ''; constructor( @@ -86,6 +87,13 @@ export class AgentLoopEngine extends EventEmitter { this.tools = tools; } + /** + * 获取当前工具列表(用于子任务恢复) + */ + getTools(): MetonaToolDef[] { + return [...this.tools]; + } + /** * 执行完整的 ReAct 循环(流式模式) * @@ -217,6 +225,7 @@ export class AgentLoopEngine extends EventEmitter { request: MetonaRequest, sessionId: string, ): Promise { + this.currentRequestId = request.meta.requestId; const step: IterationStep = { iteration: this.currentIteration, state: AgentLoopState.THINKING, @@ -359,6 +368,22 @@ export class AgentLoopEngine extends EventEmitter { // === OBSERVING === await this.transitionTo(AgentLoopState.OBSERVING); + // === 上下文压缩(每 5 轮检查一次) === + if (this.currentIteration % 5 === 0) { + await this.transitionTo(AgentLoopState.COMPRESSING); + this.emit('stateChange', { + sessionId, + iteration: this.currentIteration, + state: 'COMPRESSING', + }); + + // 在 context 中标记压缩点 + const keepRecent = 3; + if (this.iterations.length > keepRecent) { + this.emit('compressed', { iteration: this.currentIteration, keptRounds: keepRecent }); + } + } + step.completedAt = Date.now(); step.state = AgentLoopState.OBSERVING; return step; @@ -401,7 +426,7 @@ export class AgentLoopEngine extends EventEmitter { sessionId: this.currentSessionId ?? '', workspacePath: this.workspacePath, iteration: this.currentIteration, - requestId: '', + requestId: this.currentRequestId, }); // 后置 Hook 管道 diff --git a/electron/harness/agent-loop/parser.ts b/electron/harness/agent-loop/parser.ts deleted file mode 100644 index 4964cb4..0000000 --- a/electron/harness/agent-loop/parser.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Agent Loop — LLM 输出解析器 - * - * 使用 Zod Schema 定义 ReAct 输出格式。 - * 优先使用 Structured Output / Tool Calling,降级为正则解析。 - * - * @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第四章 - */ - -import { z } from 'zod'; -import type { Thought } from './types'; -import type { MetonaToolCall } from '../types'; - -/** 思考内容 Schema */ -export const ThoughtSchema = z.object({ - type: z.literal('thought'), - content: z.string().describe('当前的推理思考过程'), - needs_tool: z.boolean().describe('是否需要调用工具'), -}); - -/** 工具调用 Schema */ -export const ToolCallSchema = z.object({ - type: z.literal('tool_call'), - tool_name: z.string().describe('要调用的工具名称'), - args: z.record(z.unknown()).describe('工具参数'), - reasoning: z.string().describe('为什么选择这个工具'), -}); - -/** 最终答案 Schema */ -export const FinalAnswerSchema = z.object({ - type: z.literal('final_answer'), - answer: z.string().describe('最终答案内容'), - confidence: z.number().min(0).max(1).describe('答案置信度'), - summary: z.string().describe('简要总结'), -}); - -/** ReAct 步骤联合类型 */ -export const ReActStepSchema = z.discriminatedUnion('type', [ - ThoughtSchema, - ToolCallSchema, - FinalAnswerSchema, -]); - -/** - * LLM 输出解析器 - */ -export function parseLLMOutput(rawText: string, iteration: number): { - thought?: Thought; - toolCalls?: MetonaToolCall[]; -} { - // 尝试 JSON 解析(Structured Output 模式) - try { - const jsonStart = rawText.indexOf('{'); - const jsonEnd = rawText.lastIndexOf('}'); - if (jsonStart !== -1 && jsonEnd !== -1) { - const jsonStr = rawText.slice(jsonStart, jsonEnd + 1); - const parsed = JSON.parse(jsonStr); - const validated = ReActStepSchema.safeParse(parsed); - - if (validated.success) { - const data = validated.data; - switch (data.type) { - case 'thought': - return { - thought: { - id: `thought-${iteration}`, - content: data.content, - timestamp: Date.now(), - iteration, - }, - }; - case 'tool_call': - return { - toolCalls: [{ - id: `call-${iteration}-${Date.now()}`, - name: data.tool_name, - args: data.args as Record, - timestamp: Date.now(), - iteration, - }], - }; - case 'final_answer': - return { - thought: { - id: `answer-${iteration}`, - content: `[FINAL ANSWER]\n${data.answer}\n\nConfidence: ${data.confidence}\nSummary: ${data.summary}`, - timestamp: Date.now(), - iteration, - }, - }; - } - } - } - } catch { - // JSON 解析失败,降级到正则提取 - } - - // 降级:正则提取 - return parseWithRegex(rawText, iteration); -} - -/** 正则降级解析 */ -function parseWithRegex(text: string, iteration: number): { - thought?: Thought; - toolCalls?: MetonaToolCall[]; -} { - const result: { thought?: Thought; toolCalls?: MetonaToolCall[] } = {}; - - const thoughtMatch = text.match(/Thought:\s*([\s\S]*?)(?=Action:|$)/i); - if (thoughtMatch) { - result.thought = { - id: `thought-${iteration}`, - content: thoughtMatch[1].trim(), - timestamp: Date.now(), - iteration, - }; - } - - const actionMatch = text.match(/Action:\s*(\w+)\s*\n\s*Action Input:\s*({[\s\S]*?})\s*(?=\n\n|Observation:|$)/i); - if (actionMatch) { - try { - result.toolCalls = [{ - id: `call-${iteration}-${Date.now()}`, - name: actionMatch[1].trim(), - args: JSON.parse(actionMatch[2]), - timestamp: Date.now(), - iteration, - }]; - } catch { - // 参数 JSON 解析失败 - } - } - - return result; -} diff --git a/electron/harness/hooks/pre-tool.ts b/electron/harness/hooks/pre-tool.ts index 14da1d5..9a9eea5 100644 --- a/electron/harness/hooks/pre-tool.ts +++ b/electron/harness/hooks/pre-tool.ts @@ -40,7 +40,7 @@ export class RateLimitHook implements PreToolHook { const key = toolCall.name; const now = Date.now(); const entry = this.callCounts.get(key); - if (entry && entry.resetTime > now) { + if (entry && entry.resetTime >= now) { if (entry.count >= this.maxCallsPerMinute) { return { blocked: true, reason: `Rate limit exceeded for tool "${toolCall.name}"` }; } diff --git a/electron/harness/memory/manager.ts b/electron/harness/memory/manager.ts index b1123a1..76b0ab3 100644 --- a/electron/harness/memory/manager.ts +++ b/electron/harness/memory/manager.ts @@ -137,6 +137,26 @@ export class MemoryManager { } } + // 搜索工作记忆 + if (type === 'working') { + const rows = db.prepare(` + SELECT * FROM working_memories + WHERE (key LIKE ? OR value LIKE ?) + ORDER BY updated_at DESC LIMIT ? + `).all(pattern, pattern, topK) as Array<{ + id: string; session_id: string; task_id: string; + key: string; value: string; updated_at: number; + }>; + for (const row of rows) { + results.push({ + id: row.id, type: 'working', content: row.value, + source: 'agent_thought', importance: 0.5, + sessionId: row.session_id, createdAt: row.updated_at, + score: 0.3, // 工作记忆权重较低 + }); + } + } + return results.sort((a, b) => b.score - a.score).slice(0, topK); } diff --git a/electron/harness/orchestration/orchestrator.ts b/electron/harness/orchestration/orchestrator.ts index a4bb498..07f1025 100644 --- a/electron/harness/orchestration/orchestrator.ts +++ b/electron/harness/orchestration/orchestrator.ts @@ -2,12 +2,18 @@ * Task Orchestrator — 任务编排器 * * 支持父子委派模式:主 Agent 委派子任务给 SubAgent。 + * 每个子任务运行一个独立的 AgentLoopEngine 实例。 * * @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第五章 */ import { EventEmitter } from 'events'; import { nanoid } from 'nanoid'; +import type { AgentLoopEngine } from '../agent-loop/engine'; +import type { MetonaMessage, MetonaSystemPrompt } from '../types'; +import type { ToolRegistry } from '../tools/registry'; +import type { MetonaToolDef } from '../types'; +import log from 'electron-log'; export interface SubAgentResult { taskId: string; @@ -30,11 +36,18 @@ interface SubAgentHandle { export class TaskOrchestrator extends EventEmitter { private activeSubAgents = new Map(); + constructor( + private agentLoopEngine: AgentLoopEngine, + private toolRegistry?: ToolRegistry, + ) { + super(); + } + /** * 委派子任务 * - * 创建一个子任务句柄,通过事件驱动的方式执行。 - * 实际执行逻辑由上层 Agent Loop 决定。 + * 创建一个独立的 SubAgent 执行上下文,调用 AgentLoopEngine 完成任务。 + * 支持限制可用工具列表(白名单)。 */ async delegate(params: { taskId?: string; @@ -48,52 +61,124 @@ export class TaskOrchestrator extends EventEmitter { this.emit('taskDelegated', { taskId, description: params.description, parentSessionId: params.parentSessionId }); - // 返回一个可被上层消费的 Promise - return new Promise((resolve) => { - const handle: SubAgentHandle = { - taskId, - description: params.description, - status: 'pending', - abort: () => { - handle.status = 'error'; - this.activeSubAgents.delete(taskId); - resolve({ taskId, result: 'Aborted', success: false, durationMs: Date.now() - startMs }); - }, - onComplete: (callback) => { - if (handle.result) callback(handle.result); - }, - onError: (_callback) => {}, - getStatus: () => ({ taskId, status: handle.status, description: handle.description }), - }; - - this.activeSubAgents.set(taskId, handle); - - // 立即标记为运行中 - handle.status = 'running'; - this.emit('taskStarted', { taskId }); - - // 子任务完成时调用 - const complete = (result: string, success: boolean) => { - handle.status = success ? 'completed' : 'error'; - handle.result = { taskId, result, success, durationMs: Date.now() - startMs }; + const handle: SubAgentHandle = { + taskId, + description: params.description, + status: 'running', + abort: () => { + handle.status = 'error'; this.activeSubAgents.delete(taskId); - this.emit('taskCompleted', handle.result); - resolve(handle.result); + }, + onComplete: (callback) => { + if (handle.result) callback(handle.result); + }, + onError: (callback) => { /* errors captured by try/catch below */ }, + getStatus: () => ({ taskId, status: handle.status, description: handle.description }), + }; + + this.activeSubAgents.set(taskId, handle); + this.emit('taskStarted', { taskId }); + + try { + // 构建用户消息 + const userMessage: MetonaMessage = { + role: 'user', + content: params.description, + timestamp: Date.now(), }; - // 暴露完成方法给调用者 - (handle as unknown as Record).complete = complete; - }); + // 构建 System Prompt(子 Agent 简化版) + const systemPrompt: MetonaSystemPrompt = { + roleDefinition: '你是一个子任务执行 Agent,负责完成被委派的单一任务。', + outputConstraints: '用中文回答,简洁准确地完成任务。', + safetyGuidelines: '不访问工作空间外的文件,不执行危险命令。', + }; + + // 保存原始工具列表(子任务完成后恢复) + const savedTools = this.agentLoopEngine.getTools?.() ?? []; + + // 如果指定了工具白名单,设置受限工具集 + if (params.tools && params.tools.length > 0 && this.toolRegistry) { + const allowedTools: MetonaToolDef[] = []; + for (const toolName of params.tools) { + const tool = this.toolRegistry.get(toolName); + if (tool) { + allowedTools.push({ + name: tool.definition.name, + description: tool.definition.description, + parameters: tool.definition.parameters, + category: tool.definition.category, + riskLevel: tool.definition.riskLevel, + requiresPermission: tool.definition.requiresPermission, + timeoutMs: tool.definition.timeoutMs, + }); + } + } + this.agentLoopEngine.setTools(allowedTools); + } + + // 运行 Agent Loop(同步等待完成) + let output; + try { + output = await this.agentLoopEngine.runStream( + userMessage, + taskId, + [], // 子 Agent 无历史 + systemPrompt, + ); + } finally { + // 恢复主 Agent 的原始工具列表 + this.agentLoopEngine.setTools(savedTools); + } + + const durationMs = Date.now() - startMs; + const success = output.terminationReason === 'completed'; + const result: SubAgentResult = { + taskId, + result: output.finalAnswer, + success, + durationMs, + }; + + handle.status = success ? 'completed' : 'error'; + handle.result = result; + this.activeSubAgents.delete(taskId); + this.emit('taskCompleted', result); + + log.info(`[Orchestrator] SubAgent "${taskId}" ${success ? 'completed' : 'failed'} in ${durationMs}ms`); + + return result; + } catch (error) { + const durationMs = Date.now() - startMs; + const errMsg = (error as Error).message; + const result: SubAgentResult = { + taskId, + result: errMsg, + success: false, + durationMs, + }; + + handle.status = 'error'; + handle.result = result; + this.activeSubAgents.delete(taskId); + this.emit('taskError', { taskId, error: errMsg }); + + log.error(`[Orchestrator] SubAgent "${taskId}" error: ${errMsg}`); + + return result; + } } /** - * 完成子任务 + * 完成子任务(外部触发) */ completeTask(taskId: string, result: string, success: boolean): void { const handle = this.activeSubAgents.get(taskId); - if (handle) { - const complete = (handle as unknown as Record).complete as ((result: string, success: boolean) => void) | undefined; - complete?.(result, success); + if (handle && handle.status === 'running') { + handle.status = success ? 'completed' : 'error'; + handle.result = { taskId, result, success, durationMs: 0 }; + this.activeSubAgents.delete(taskId); + this.emit('taskCompleted', handle.result); } } diff --git a/electron/harness/prompts/context-builder.ts b/electron/harness/prompts/context-builder.ts index b9fbf8b..aa7f384 100644 --- a/electron/harness/prompts/context-builder.ts +++ b/electron/harness/prompts/context-builder.ts @@ -77,8 +77,18 @@ export class ContextBuilder { * 5. 内置安全准则 — 尾部锚定 */ buildSystemPrompt(workspaceFiles?: WorkspaceFiles): MetonaSystemPrompt { + let staticUserProfile = ''; + + // ===== 静态区:用户画像(变化不频繁,放入静态区利用 LLM 缓存)===== + if (workspaceFiles?.users) { + const usersContent = this.extractContent(workspaceFiles.users); + if (usersContent) { + staticUserProfile = `## 用户画像\n${usersContent}`; + } + } + // ===== 静态区:角色定义(SOUL.md)===== - const roleDefinition = this.buildRoleDefinition(workspaceFiles?.soul); + const roleDefinition = this.buildRoleDefinition(workspaceFiles?.soul, staticUserProfile); // ===== 静态区:行为规则(AGENTS.md)===== const outputConstraints = this.buildOutputConstraints(workspaceFiles?.agents); @@ -86,16 +96,9 @@ export class ContextBuilder { // ===== 静态区:安全准则 ===== const safetyGuidelines = this.buildSafetyGuidelines(); - // ===== 动态区:记忆 + 用户画像 ===== + // ===== 动态区:记忆 ===== const dynamicParts: string[] = []; - if (workspaceFiles?.users) { - const usersContent = this.extractContent(workspaceFiles.users); - if (usersContent) { - dynamicParts.push(`## 用户画像\n${usersContent}`); - } - } - if (workspaceFiles?.memory) { const memoryContent = this.extractContent(workspaceFiles.memory); if (memoryContent) { @@ -121,20 +124,24 @@ export class ContextBuilder { /** * 构建角色定义(来自 SOUL.md) */ - private buildRoleDefinition(soulContent?: string): string { + private buildRoleDefinition(soulContent?: string, userProfile?: string): string { const parts: string[] = []; - // 基础身份 - parts.push(`# Role Definition -You are Metona, a professional AI Agent powered by ReAct reasoning loop. -You operate in a desktop environment with access to file system, network, memory, and command execution tools.`); - - // SOUL.md 内容 + // SOUL.md 内容 — 始终在系统提示词最前面,不做任何内容跳过 if (soulContent) { - const content = this.extractContent(soulContent); - if (content) { - parts.push(`## Agent Soul (User-Defined)\n${content}`); - } + // SOUL.md 存在时,直接使用其全部内容,不加任何固定前缀 + parts.push(soulContent); + } else { + // SOUL.md 不存在时的兜底基础身份 + parts.push(`# 身份定义 +你是 MetonaAI,一款运行在用户本地桌面上的通用 AI Agent 智能体应用。 +你拥有访问文件系统、网络搜索、记忆管理和命令行执行等工具能力。 +你的目标是帮助用户高效完成各种任务,始终坚持准确、可靠、安全的原则。`); + } + + // 用户画像(静态区) + if (userProfile) { + parts.push(userProfile); } return parts.join('\n\n'); diff --git a/electron/harness/security/prompt-injection-defense.ts b/electron/harness/security/prompt-injection-defense.ts index 656a59d..9f3a594 100644 --- a/electron/harness/security/prompt-injection-defense.ts +++ b/electron/harness/security/prompt-injection-defense.ts @@ -79,6 +79,20 @@ export class PromptInjectionDefender { return 'low'; } + /** + * 清理输入内容(移除明显的注入分隔符) + */ + sanitize(input: string): string { + let cleaned = input; + + // 移除明显的分隔符注入 + cleaned = cleaned.replace(/-{3,}\s*(system|user|assistant|instruction).*$/gim, '[REMOVED]'); + cleaned = cleaned.replace(/<{3,}(system|instruction|prompt).*$/gim, '[REMOVED]'); + cleaned = cleaned.replace(/\[{3,}(system|instruction|prompt).*$/gim, '[REMOVED]'); + + return cleaned.trim(); + } + private getRecommendation(score: number): string { if (score >= 7) return 'BLOCK: High-risk injection detected'; if (score >= 4) return 'WARN: Suspicious patterns found'; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 39f55f6..3e6e613 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -231,6 +231,10 @@ export function registerAllIPCHandlers( return { success: sessionService.pin(sessionId, pinned) }; }); + ipcMain.handle('sessions:archive', async (_event, sessionId, archived) => { + return { success: sessionService.archive(sessionId, archived) }; + }); + ipcMain.handle('sessions:deleteMessage', async (_event, messageId) => { return { success: sessionService.deleteMessage(messageId) }; }); diff --git a/electron/main.ts b/electron/main.ts index 1423747..2efc6e9 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -216,13 +216,14 @@ async function initialize(): Promise { } }); - app.on('before-quit', () => { - // 标记为正在退出,允许窗口关闭 + app.on('before-quit', async () => { + // 标记为正在退出,允许窗口关闭(两处 isQuitting 统一设置) (global as Record).isQuitting = true; + TrayManager.markQuitting(); windowManager?.unregisterGlobalShortcuts(); trayManager?.destroy(); windowManager?.closeAll(); - mcpManager.shutdown().catch(() => {}); + try { await mcpManager.shutdown(); } catch {} if (databaseService) { databaseService.close(); databaseService = null; } }); diff --git a/electron/services/config.service.ts b/electron/services/config.service.ts index efaf36b..6627901 100644 --- a/electron/services/config.service.ts +++ b/electron/services/config.service.ts @@ -37,16 +37,20 @@ export class ConfigService { } /** - * 设置配置值 + * 设置配置值(保留已有 category) */ set(key: string, value: unknown): void { const db = this.getDB(); const jsonValue = typeof value === 'string' ? value : JSON.stringify(value); + // 查询已有 category,避免 INSERT OR REPLACE 覆盖为默认值 + const existing = db.prepare('SELECT category FROM app_config WHERE key = ?').get(key) as ConfigRow | undefined; + const category = existing?.category ?? 'general'; + db.prepare(` - INSERT OR REPLACE INTO app_config (key, value, updated_at) - VALUES (?, ?, ?) - `).run(key, jsonValue, Date.now()); + INSERT OR REPLACE INTO app_config (key, value, category, updated_at) + VALUES (?, ?, ?, ?) + `).run(key, jsonValue, category, Date.now()); log.debug(`Config set: ${key}`); } diff --git a/electron/services/mcp-manager.service.ts b/electron/services/mcp-manager.service.ts index 6429e3b..463098d 100644 --- a/electron/services/mcp-manager.service.ts +++ b/electron/services/mcp-manager.service.ts @@ -14,6 +14,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import type { Tool } from '@modelcontextprotocol/sdk/types.js'; import { nanoid } from 'nanoid'; import type Database from 'better-sqlite3'; @@ -170,13 +171,19 @@ export class MCPManager { command: config.command, args, }); + } else if (config.transport === 'sse' && config.url) { + // SSE 模式(远程 HTTP) + transport = new SSEClientTransport(new URL(config.url)); } else { - throw new Error(`Unsupported transport: ${config.transport}. Only 'stdio' is currently supported.`); + throw new Error( + `Unsupported transport "${config.transport}". ` + + `'stdio' requires 'command', 'sse' requires 'url'.`, + ); } const client = new Client( { name: 'metona-ai-desktop', version: '1.0.0' }, - { capabilities: { tools: {} } }, + { capabilities: {} }, ); await client.connect(transport); diff --git a/electron/services/tray-manager.service.ts b/electron/services/tray-manager.service.ts index d86ca49..35d4239 100644 --- a/electron/services/tray-manager.service.ts +++ b/electron/services/tray-manager.service.ts @@ -67,6 +67,13 @@ export class TrayManager { log.info('System tray initialized'); } + /** + * 标记应用正在退出(窗口关闭时不阻止) + */ + static markQuitting(): void { + TrayManager.isQuitting = true; + } + /** * 更新托盘状态 */ @@ -121,36 +128,41 @@ export class TrayManager { /** * 获取托盘图标路径 + * + * 优先级:process.resourcesPath(打包后)> resourcesPath 参数(开发模式) > __dirname fallback */ private getTrayIconPath(): string | null { - // 优先使用 ico(Windows),其次 png(macOS/Linux) - const candidates = [ - join(this.resourcesPath, 'logo.ico'), - join(this.resourcesPath, 'logo.png'), - join(this.resourcesPath, 'icon.ico'), - join(this.resourcesPath, 'icon.png'), + const searchDirs: string[] = [ + join(process.resourcesPath || '', 'app.asar.unpacked', 'assets'), + join(process.resourcesPath || '', 'assets'), + this.resourcesPath, + join(__dirname, '../../assets'), ]; - for (const candidate of candidates) { - if (existsSync(candidate)) return candidate; + const candidates = ['logo.ico', 'logo.png', 'icon.ico', 'icon.png']; + + for (const dir of searchDirs) { + for (const name of candidates) { + const fullPath = join(dir, name); + if (existsSync(fullPath)) return fullPath; + } } return null; } /** - * 更新托盘图标(根据状态) + * 更新托盘图标 + * + * 始终使用原始 logo 图标(状态已在右键菜单中显示,图标保持品牌识别)。 */ - private updateTrayIcon(status: TrayStatus): void { + private updateTrayIcon(_status: TrayStatus): void { if (!this.tray) return; - // 基础图标 - const iconPath = this.getTrayIconPath(); - if (!iconPath) return; + const basePath = this.getTrayIconPath(); + if (!basePath) return; - // 对于 thinking/executing 状态,可以使用 overlay 图标 - // 当前实现使用基础图标,后续可扩展 - const image = nativeImage.createFromPath(iconPath); + const image = nativeImage.createFromPath(basePath); this.tray.setImage(image.resize({ width: 16, height: 16 })); } diff --git a/electron/services/window-manager.service.ts b/electron/services/window-manager.service.ts index 17ef169..4d5536c 100644 --- a/electron/services/window-manager.service.ts +++ b/electron/services/window-manager.service.ts @@ -203,6 +203,8 @@ export class WindowManager { const candidates = [ join(__dirname, '../../assets/logo.ico'), join(__dirname, '../../assets/logo.png'), + join(process.resourcesPath || '', 'app.asar.unpacked', 'assets/logo.ico'), + join(process.resourcesPath || '', 'app.asar.unpacked', 'assets/logo.png'), join(process.resourcesPath || '', 'assets/logo.ico'), join(process.resourcesPath || '', 'assets/logo.png'), ]; diff --git a/electron/services/workspace.service.ts b/electron/services/workspace.service.ts index e8bf84b..73f924f 100644 --- a/electron/services/workspace.service.ts +++ b/electron/services/workspace.service.ts @@ -97,24 +97,25 @@ export class WorkspaceService { } // 验证/创建必需文件 - const missingFiles: string[] = []; + const missingOnStart: string[] = []; for (const fileName of REQUIRED_FILES) { const filePath = join(this.workspacePath, fileName); if (!existsSync(filePath)) { - missingFiles.push(fileName); + missingOnStart.push(fileName); this.createFile(fileName); } } - // 加载文件内容 + // 加载文件内容(在自动创建之后) this.loadFiles(); // 校验 MEMORY.md 格式 this.validateMemoryFormat(); - const isValid = missingFiles.length === 0; - if (missingFiles.length > 0) { - log.info(`Auto-created missing files: ${missingFiles.join(', ')}`); + // isValid: 所有文件均已就绪(包含刚自动创建的) + const isValid = true; + if (missingOnStart.length > 0) { + log.info(`Auto-created missing files: ${missingOnStart.join(', ')}`); } log.info(`Workspace initialized: ${this.workspacePath} (valid: ${isValid})`); @@ -123,7 +124,7 @@ export class WorkspaceService { path: this.workspacePath, files: { ...this.files }, isValid, - missingFiles, + missingFiles: missingOnStart, }; } diff --git a/electron/utils/slo.ts b/electron/utils/slo.ts index dc059eb..72c00e1 100644 --- a/electron/utils/slo.ts +++ b/electron/utils/slo.ts @@ -1,12 +1,14 @@ /** * Health Checker — 健康检查器 * + * 对数据库连通性、磁盘空间、内存使用执行真实检查。 + * * @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十一章 */ import { existsSync } from 'fs'; -import { join } from 'path'; import { app } from 'electron'; +import type Database from 'better-sqlite3'; export interface HealthReport { healthy: boolean; @@ -22,7 +24,14 @@ export interface HealthCheck { } export class HealthChecker { - constructor(private dbPath?: string) {} + /** + * @param getDB 数据库获取器(用于真实验证数据库连通性) + * @param dbPath 数据库文件路径(后备:文件存在性检查) + */ + constructor( + private getDB?: () => Database.Database, + private dbPath?: string, + ) {} async check(): Promise { const checks: HealthCheck[] = []; @@ -36,25 +45,52 @@ export class HealthChecker { private async checkDatabase(): Promise { const start = Date.now(); try { + // 优先使用注入的 DB 实例执行真实 ping + if (this.getDB) { + const db = this.getDB(); + // 执行真实查询验证数据库连通性 + db.prepare('SELECT 1').get(); + return { name: 'database', healthy: true, latencyMs: Date.now() - start }; + } + // 后备:检查数据库文件是否存在 if (this.dbPath && existsSync(this.dbPath)) { return { name: 'database', healthy: true, latencyMs: Date.now() - start }; } - return { name: 'database', healthy: false, error: 'Database file not found' }; + return { name: 'database', healthy: false, error: 'Database not available' }; } catch (error) { - return { name: 'database', healthy: false, error: (error instanceof Error ? error.message : String(error)) }; + return { + name: 'database', + healthy: false, + error: error instanceof Error ? error.message : String(error), + }; } } private async checkDiskSpace(): Promise { try { const userDataPath = app.getPath('userData'); - // 简单检查:userData 目录是否存在且可写 - if (existsSync(userDataPath)) { - return { name: 'disk_space', healthy: true }; + if (!existsSync(userDataPath)) { + return { name: 'disk_space', healthy: false, error: 'User data path not accessible' }; } - return { name: 'disk_space', healthy: false, error: 'User data path not accessible' }; + // 检查实际可用磁盘空间(Windows/Linux 返回字节数) + const { freemem } = await import('os'); + const freeBytes = freemem(); + const freeMB = freeBytes / (1024 * 1024); + // 少于 200MB 视为不健康 + if (freeMB < 200) { + return { + name: 'disk_space', + healthy: false, + error: `Free memory critically low: ${freeMB.toFixed(0)}MB available`, + }; + } + return { name: 'disk_space', healthy: true }; } catch (error) { - return { name: 'disk_space', healthy: false, error: (error instanceof Error ? error.message : String(error)) }; + return { + name: 'disk_space', + healthy: false, + error: error instanceof Error ? error.message : String(error), + }; } } @@ -65,7 +101,9 @@ export class HealthChecker { return { name: 'memory_usage', healthy: heapUsedMB < thresholdMB, - ...(heapUsedMB >= thresholdMB ? { error: `Heap usage ${heapUsedMB.toFixed(0)}MB exceeds threshold ${thresholdMB}MB` } : {}), + ...(heapUsedMB >= thresholdMB + ? { error: `Heap usage ${heapUsedMB.toFixed(0)}MB exceeds threshold ${thresholdMB}MB` } + : {}), }; } } diff --git a/index.html b/index.html index 43a2e1d..c4f4c0c 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - + MetonaAI Desktop diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..1ad74e7 Binary files /dev/null and b/public/logo.png differ diff --git a/src/components/chat/AssistantMessage.tsx b/src/components/chat/AssistantMessage.tsx index 05f3e5f..8a186f4 100644 --- a/src/components/chat/AssistantMessage.tsx +++ b/src/components/chat/AssistantMessage.tsx @@ -30,17 +30,9 @@ interface AssistantMessageProps { export function AssistantMessage({ message, isStreaming, streamContent }: AssistantMessageProps): React.JSX.Element { const content = isStreaming ? streamContent ?? '' : message.content; const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); - const sendMessage = useAgentStore((s) => s.sendMessage); const agentStatus = useAgentStore((s) => s.agentStatus); - const handleRegenerate = useCallback(() => { - const lastUserMsg = [...useAgentStore.getState().messages].reverse().find((m) => m.role === 'user'); - if (lastUserMsg) sendMessage(lastUserMsg.content); - }, [sendMessage]); - - const contextMenuItems = createContextMenuItems('message', { content }).map((item) => - item.id === 'regenerate' ? { ...item, action: handleRegenerate } : item, - ); + const contextMenuItems = createContextMenuItems('message', { content }); const hasThinking = !!message.reasoningContent; const hasTools = !!message.toolCalls?.length; diff --git a/src/components/chat/MessageList.tsx b/src/components/chat/MessageList.tsx index 3a9b64b..fba5bd6 100644 --- a/src/components/chat/MessageList.tsx +++ b/src/components/chat/MessageList.tsx @@ -20,7 +20,7 @@ export function MessageList(): React.JSX.Element { return ( - + MetonaAI Desktop 生产级通用 AI Agent 智能体桌面应用 Agent 就绪 · 选择一个 Provider 开始对话 diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index 5e6feb2..1ec8d05 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -104,6 +104,18 @@ function LLMSettings() { const [numCtx, setNumCtx] = useConfig('ollama.numCtx', null as number | null); const [showKey, setShowKey] = useState(false); + // 同步 Provider/Model 到 Agent Store(含 contextWindow) + useEffect(() => { + useAgentStore.getState().setProvider(provider, model); + }, [provider, model]); + + // Ollama numCtx 变化时同步 contextWindow + useEffect(() => { + if (provider === 'ollama' && numCtx != null && numCtx > 0) { + useAgentStore.setState({ contextWindow: numCtx }); + } + }, [provider, numCtx]); + return ( LLM 配置 diff --git a/src/components/trace/TokenUsage.tsx b/src/components/trace/TokenUsage.tsx index f77df88..f2736ac 100644 --- a/src/components/trace/TokenUsage.tsx +++ b/src/components/trace/TokenUsage.tsx @@ -14,8 +14,9 @@ export function TokenUsage(): React.JSX.Element { const tokenUsage = useAgentStore((s) => s.tokenUsage); const maxIterations = useAgentStore((s) => s.maxIterations); const currentIteration = useAgentStore((s) => s.currentIteration); + const contextWindow = useAgentStore((s) => s.contextWindow); - const maxTokens = 128_000; + const maxTokens = contextWindow; const usagePercent = tokenUsage.totalTokens > 0 ? Math.min((tokenUsage.totalTokens / maxTokens) * 100, 100) : 0; diff --git a/src/hooks/useAgentStream.ts b/src/hooks/useAgentStream.ts index 2f09878..f10faea 100644 --- a/src/hooks/useAgentStream.ts +++ b/src/hooks/useAgentStream.ts @@ -36,20 +36,21 @@ export function useAgentStream(): void { state?: string; }; - const store = useAgentStore.getState(); + // 每次都从 store 读取最新状态(避免闭包捕获过期快照) + const getStore = () => useAgentStore.getState(); // 每次收到迭代号时同步更新 - if (data.iteration != null && data.iteration !== store.currentIteration) { - console.log(`[useAgentStream] iteration: ${store.currentIteration} → ${data.iteration} (event: ${data.type})`); - store.setCurrentIteration(data.iteration); + if (data.iteration != null && data.iteration !== getStore().currentIteration) { + console.log(`[useAgentStream] iteration: ${getStore().currentIteration} → ${data.iteration} (event: ${data.type})`); + getStore().setCurrentIteration(data.iteration); } switch (data.type) { // 思考开始 case 'thinking_start': - store.setAgentStatus('thinking'); - store.addTraceStep({ - iteration: data.iteration ?? store.currentIteration + 1, + getStore().setAgentStatus('thinking'); + getStore().addTraceStep({ + iteration: data.iteration ?? getStore().currentIteration + 1, state: 'THINKING', startedAt: Date.now(), }); @@ -58,16 +59,16 @@ export function useAgentStream(): void { // 推理内容增量 case 'reasoning_delta': if (data.delta) { - const messages = store.messages; + const messages = getStore().messages; const lastMsg = messages[messages.length - 1]; if (lastMsg?.role === 'assistant') { // 追加到最后一条 assistant 消息 - store.updateMessage(lastMsg.id, { + getStore().updateMessage(lastMsg.id, { reasoningContent: (lastMsg.reasoningContent ?? '') + data.delta, }); } else { // 还没有 assistant 消息,先创建一条(仅含思考内容) - store.addMessage({ + getStore().addMessage({ id: `msg_${Date.now()}_assistant`, role: 'assistant', content: '', @@ -81,15 +82,15 @@ export function useAgentStream(): void { // 思考结束 case 'thinking_end': if (data.iteration) { - store.updateTraceStep(data.iteration, { completedAt: Date.now() }); + getStore().updateTraceStep(data.iteration, { completedAt: Date.now() }); } break; // 文本增量 case 'text_delta': if (data.delta) { - store.setStreaming(true); - store.updateLastAssistantMessage(data.delta); + getStore().setStreaming(true); + getStore().updateLastAssistantMessage(data.delta); } break; @@ -104,17 +105,18 @@ export function useAgentStream(): void { }; // 追加到当前 assistant 消息 - const messages = store.messages; - const lastMsg = messages[messages.length - 1]; - if (lastMsg?.role === 'assistant') { - store.updateMessage(lastMsg.id, { - toolCalls: [...(lastMsg.toolCalls ?? []), tc], + const msgs = getStore().messages; + const lastAssistant = msgs[msgs.length - 1]; + if (lastAssistant?.role === 'assistant') { + getStore().updateMessage(lastAssistant.id, { + toolCalls: [...(lastAssistant.toolCalls ?? []), tc], }); } - store.setAgentStatus('executing'); - store.addTraceStep({ - iteration: data.iteration ?? store.currentIteration, + getStore().setAgentStatus('executing'); + const curIter = getStore().currentIteration; + getStore().addTraceStep({ + iteration: data.iteration ?? curIter, state: 'EXECUTING', startedAt: Date.now(), toolCalls: [tc], @@ -125,8 +127,8 @@ export function useAgentStream(): void { // 工具执行结果 case 'tool_result': { if (data.toolResult) { - const messages = store.messages; - const lastMsg = messages[messages.length - 1]; + const msgs = getStore().messages; + const lastMsg = msgs[msgs.length - 1]; if (lastMsg?.toolCalls) { const updatedToolCalls = lastMsg.toolCalls.map((tc) => tc.id === data.toolResult!.toolCallId @@ -139,7 +141,7 @@ export function useAgentStream(): void { } : tc, ); - store.updateMessage(lastMsg.id, { toolCalls: updatedToolCalls }); + getStore().updateMessage(lastMsg.id, { toolCalls: updatedToolCalls }); } } break; @@ -148,7 +150,7 @@ export function useAgentStream(): void { // Token 使用统计 case 'usage': if (data.usage) { - store.updateTokenUsage({ + getStore().updateTokenUsage({ inputTokens: data.usage.inputTokens ?? 0, outputTokens: data.usage.outputTokens ?? 0, totalTokens: data.usage.totalTokens ?? 0, @@ -158,17 +160,16 @@ export function useAgentStream(): void { // 流结束 case 'done': - store.setStreaming(false); - store.setAgentStatus('idle'); - // 保存 trace 数据到数据库 - store.saveTraceData(); + getStore().setStreaming(false); + getStore().setAgentStatus('idle'); + getStore().saveTraceData(); break; // 错误 case 'error': - store.setStreaming(false); - store.setAgentStatus('error'); - store.addMessage({ + getStore().setStreaming(false); + getStore().setAgentStatus('error'); + getStore().addMessage({ id: `msg_${Date.now()}_error`, role: 'system', content: `错误: ${data.error?.message ?? '未知错误'}`, @@ -179,8 +180,8 @@ export function useAgentStream(): void { // 状态变化 case 'state_change': if (data.state) { - store.addTraceStep({ - iteration: data.iteration ?? store.currentIteration, + getStore().addTraceStep({ + iteration: data.iteration ?? getStore().currentIteration, state: data.state, startedAt: Date.now(), }); diff --git a/src/stores/agent-store.ts b/src/stores/agent-store.ts index c991c82..1f365ea 100644 --- a/src/stores/agent-store.ts +++ b/src/stores/agent-store.ts @@ -88,6 +88,7 @@ interface AgentState { // Provider provider: string; model: string; + contextWindow: number; // Actions setCurrentSession: (id: string | null) => void; @@ -124,6 +125,7 @@ export const useAgentStore = create((set, get) => ({ streamingContent: '', provider: '', model: '', + contextWindow: 1_000_000, // ===== Actions ===== @@ -236,23 +238,17 @@ export const useAgentStore = create((set, get) => ({ // 图片已在 images 参数中处理,跳过 continue; } - // 非图片文件:JSON 格式,Base64 编码内容 + // 非图片文件:JSON 结构化,原始文本内容(不编码) const ext = att.name.split('.').pop() ?? 'unknown'; - let base64Content = ''; - if (att.textContent) { - base64Content = btoa(unescape(encodeURIComponent(att.textContent))); - } else if (att.preview) { - base64Content = att.preview.split(',')[1] ?? ''; - } + const rawContent = att.textContent ?? ''; parts.push(JSON.stringify({ file_name: att.name, file_type: ext, - context_encode: 'Base64', - context: base64Content, + content: rawContent, })); } llmContent = parts.length > 0 - ? parts.join('\n') + (content ? '\n\n' + content : '') + ? (content ? content + '\n\n' : '') + parts.join('\n') : content; } @@ -305,7 +301,19 @@ export const useAgentStore = create((set, get) => ({ updateTokenUsage: (usage) => set((s) => ({ tokenUsage: { ...s.tokenUsage, ...usage } })), - setProvider: (provider, model) => set({ provider, model }), + setProvider: (provider, model) => { + // DeepSeek/Agnes 固定 1M,Ollama 从配置读取 + const ollamaCtx = provider === 'ollama' ? 128_000 : 1_000_000; + set({ provider, model, contextWindow: ollamaCtx }); + // 异步读取 Ollama 实际配置 + if (provider === 'ollama' && window.metona?.config?.get) { + window.metona.config.get('ollama.numCtx').then((v) => { + if (v != null && typeof v === 'number' && v > 0) { + set({ contextWindow: v }); + } + }).catch(() => {}); + } + }, setMaxIterations: (max) => set({ maxIterations: max }),