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:
@@ -62,3 +62,6 @@ MetonaWorkspaces/
|
||||
.cache/
|
||||
.temp/
|
||||
tmp/
|
||||
|
||||
# Windows reserved device name
|
||||
nul
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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 管道
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}"` };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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 固定 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 }),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user