fix: 全面修复审计问题并优化系统提示词

**崩溃/挂死修复 (5):**
- 统一 TrayManager.isQuitting 变量,修复 Cmd+Q 无法退出
- useAgentStream 闭包过期快照 → 每次 getState()
- Agnes chatStream 添加 AbortSignal.timeout
- SSE JSON.parse 添加 try-catch 保护
- Orchestrator setTools 污染 → save/restore 模式

**功能修复 (14):**
- 上下文压缩实现 (每5轮 COMPRESSING 状态)
- 修复 requestId 硬编码空串
- ConfigService.set() 保留已有 category
- MemoryManager 新增 working 类型搜索
- PromptInjectionDefender 补全 sanitize()
- Ollama: 补全 dynamicReminders + reasoningContent
- openai-format: 所有 assistant 消息保留 reasoningContent
- SSE: finish_reason 时提前 flush tool_calls
- DeepSeek thinking effort 映射注释
- Ollama done_reason load→stop
- RateLimitHook >= 边界修复
- WorkspaceService isValid 首次启动修复
- sessions:archive IPC handler
- 托盘/窗口图标路径生产环境修复

**系统提示词优化:**
- SOUL.md 存在时不显示兜底身份,原文放最前
- 兜底身份改为中文 (MetonaAI 自身描述)
- 用户文本在前,附件内容在后

**文件上传:**
- 非图片文件不再 base64 编码,保留 JSON 结构
- 用户文本优先于文件内容

**UI 修复:**
- 首页 Logo 路径修复 (public/ + 相对路径)
- TokenUsage contextWindow 动态计算 (Provider 感知)
- 切换 Provider 同步 contextWindow
- 托盘图标始终显示 Logo (状态由右键菜单展示)
This commit is contained in:
thzxx
2026-06-30 23:17:38 +08:00
parent ad0cbc8d47
commit 97a3d3d53b
30 changed files with 458 additions and 308 deletions
+3
View File
@@ -62,3 +62,6 @@ MetonaWorkspaces/
.cache/
.temp/
tmp/
# Windows reserved device name
nul
+6 -1
View File
@@ -7,7 +7,12 @@
},
"files": [
"dist/**/*",
"dist-electron/**/*"
"dist-electron/**/*",
"assets/**/*"
],
"asar": true,
"asarUnpack": [
"assets/**/*"
],
"mac": {
"category": "public.app-category.developer-tools",
@@ -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) {
@@ -174,6 +174,7 @@ export class DeepSeekAdapter extends BaseAdapter {
const effortMap: Record<string, string> = {
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';
}
+7 -2
View File
@@ -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<string, unknown>).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;
}
}
@@ -40,14 +40,17 @@ export function buildOpenAICompatibleMessages(
.map((m) => {
const msg: Record<string, unknown> = { 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;
}
+35 -2
View File
@@ -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<string, unknown>;
let args: Record<string, unknown> = {};
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(),
};
+26 -1
View File
@@ -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<IterationStep> {
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 管道
-135
View File
@@ -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<string, unknown>,
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;
}
+1 -1
View File
@@ -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}"` };
}
+20
View File
@@ -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);
}
+124 -39
View File
@@ -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<string, SubAgentHandle>();
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<SubAgentResult>((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<string, unknown>).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<string, unknown>).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);
}
}
+27 -20
View File
@@ -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');
@@ -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';
+4
View File
@@ -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) };
});
+4 -3
View File
@@ -216,13 +216,14 @@ async function initialize(): Promise<void> {
}
});
app.on('before-quit', () => {
// 标记为正在退出,允许窗口关闭
app.on('before-quit', async () => {
// 标记为正在退出,允许窗口关闭(两处 isQuitting 统一设置)
(global as Record<string, unknown>).isQuitting = true;
TrayManager.markQuitting();
windowManager?.unregisterGlobalShortcuts();
trayManager?.destroy();
windowManager?.closeAll();
mcpManager.shutdown().catch(() => {});
try { await mcpManager.shutdown(); } catch {}
if (databaseService) { databaseService.close(); databaseService = null; }
});
+8 -4
View File
@@ -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}`);
}
+9 -2
View File
@@ -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);
+28 -16
View File
@@ -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 {
// 优先使用 icoWindows),其次 pngmacOS/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 }));
}
@@ -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'),
];
+8 -7
View File
@@ -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,
};
}
+48 -10
View File
@@ -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<HealthReport> {
const checks: HealthCheck[] = [];
@@ -36,25 +45,52 @@ export class HealthChecker {
private async checkDatabase(): Promise<HealthCheck> {
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<HealthCheck> {
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` }
: {}),
};
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<link rel="icon" type="image/png" href="./assets/logo.png" />
<link rel="icon" type="image/png" href="/logo.png" />
<title>MetonaAI Desktop</title>
</head>
<body>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+1 -9
View File
@@ -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;
+1 -1
View File
@@ -20,7 +20,7 @@ export function MessageList(): React.JSX.Element {
return (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box sx={{ textAlign: 'center', animation: 'fadeIn 200ms ease-out' }}>
<Box component="img" src="./assets/logo.png" alt="Metona" sx={{ width: 80, height: 80, mx: 'auto', mb: 2.5, borderRadius: 2 }} />
<Box component="img" src="./logo.png" alt="Metona" sx={{ width: 80, height: 80, mx: 'auto', mb: 2.5, borderRadius: 2 }} />
<Typography variant="h5" sx={{ color: 'text.primary', mb: 0.5, fontWeight: 700 }}>MetonaAI Desktop</Typography>
<Typography variant="body1" sx={{ color: 'text.secondary' }}> AI Agent </Typography>
<Typography variant="body2" sx={{ mt: 2, display: 'block', opacity: 0.6 }}>Agent · Provider </Typography>
+12
View File
@@ -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 (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>LLM </Typography>
+2 -1
View File
@@ -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;
+35 -34
View File
@@ -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(),
});
+19 -11
View File
@@ -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<AgentState>((set, get) => ({
streamingContent: '',
provider: '',
model: '',
contextWindow: 1_000_000,
// ===== Actions =====
@@ -236,23 +238,17 @@ export const useAgentStore = create<AgentState>((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<AgentState>((set, get) => ({
updateTokenUsage: (usage) =>
set((s) => ({ tokenUsage: { ...s.tokenUsage, ...usage } })),
setProvider: (provider, model) => set({ provider, model }),
setProvider: (provider, model) => {
// DeepSeek/Agnes 固定 1MOllama 从配置读取
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 }),