feat: 升级至 v0.3.1 — 全量代码审计修复 + 安全增强

本次升级基于完整代码审查,修复 Critical/High/Medium/Low 四级共 96 项问题,
并通过返工审计修复 10 项遗留问题,tsc 双端类型检查零错误。

Critical (10/10 完成):
- C-4: command.ts 接入 shell-quote 进行 token-level 注入检测,替代原有正则匹配
  可防御 r"m" -rf /、$'rm'、$(echo rm) 等字符串拼接绕过

High (11/11 完成):
- 竞态保护、Promise.allSettled、AbortController 资源泄漏、IPC 参数校验等

Medium (55/55 完成):
- 事务保护、敏感数据脱敏、枚举校验、MUI v9 Stack prop 迁移、
  React 组件 cancelled 标志、类型收窄等

Low (20/20 完成):
- 辅助方法提取(flushToolCallBuffer/scoreAndPushMemory/tryAddColumn 等)
- nanoid 统一替代 Date.now()+Math.random()
- confirm() 替换为 MUI Dialog、useMemo 缓存、魔法数字命名化等

返工审计修复 (10/10 完成):
- L-11: LogsSettings 残留的原生 confirm()/alert() 全部替换为 MUI Dialog/Alert
- M-53: MemoryViewer handleSearch 独立 ref,修复 searching 状态卡死
- M-42: 脱敏短值(length <= 4)泄露修复
- M-47: tasks:update 补全 title/description 类型校验
- L-9: ollama.adapter 非流式路径 nanoid 统一
- M-45: audit:query limit 策略与 memory:listAll 一致化
- SettingsModal handleConfirmRemove 补全 try/catch + loadServers cleanup
- L-15: CommandPalette useMemo 补全 sessions 响应式依赖
- useAgentStream 事件类型补全 seq/timestamp 字段

新增依赖: shell-quote + @types/shell-quote
版本号: 0.3.0 -> 0.3.1
This commit is contained in:
thzxx
2026-07-13 22:36:58 +08:00
parent 4f5f570ac8
commit e4d81d8247
47 changed files with 2247 additions and 475 deletions
+42 -9
View File
@@ -16,19 +16,35 @@
import { BaseAdapter } from './base-adapter';
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
import { MetonaFinishReason } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter';
import { buildOpenAICompatibleMessages, buildOpenAICompatibleTools } from './shared/openai-format';
import { parseSSEStream, parseOpenAICompatibleResponse } from './shared/sse-stream';
import log from 'electron-log';
export class AgnesAdapter extends BaseAdapter {
override readonly provider: string = 'agnes';
// H-2 修复: provider → providerId(规范要求)
override readonly providerId: string = 'agnes';
readonly supportedModels = ['agnes-2.0-flash'];
readonly supportsToolCalling = true;
readonly supportsThinking = true;
// H-2 修复: Agnes 模型元信息(1M 上下文,65.5K 最大输出)
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
'agnes-2.0-flash': {
id: 'agnes-2.0-flash',
name: 'Agnes 2.0 Flash',
contextWindow: 1_000_000,
maxOutputTokens: 65_536,
supportsToolCalling: true,
supportsThinking: true,
description: 'Agnes AI 快速版,1M 上下文,支持多模态图片与思考模式',
},
};
// ===== POST /chat/completions (非流式) =====
async chat(request: MetonaRequest): Promise<MetonaResponse> {
// H-2 修复: chat → send(规范要求)
async send(request: MetonaRequest): Promise<MetonaResponse> {
const body = this.toNativeRequest(request, false);
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
@@ -39,7 +55,8 @@ export class AgnesAdapter extends BaseAdapter {
...this.config.headers,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.config.timeoutMs ?? 300_000),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
if (!response.ok) {
@@ -48,12 +65,12 @@ export class AgnesAdapter extends BaseAdapter {
}
const data = await response.json() as Record<string, unknown>;
const parsed = parseOpenAICompatibleResponse(data, request.meta.requestId, this.provider, this.config.defaultModel);
const parsed = parseOpenAICompatibleResponse(data, request.meta.requestId, this.providerId, this.config.defaultModel);
return {
meta: {
requestId: request.meta.requestId,
provider: this.provider,
provider: this.providerId,
model: (data.model as string) ?? this.config.defaultModel,
latencyMs: 0,
timestamp: Date.now(),
@@ -68,7 +85,8 @@ export class AgnesAdapter extends BaseAdapter {
// ===== POST /chat/completions (流式) =====
async *chatStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
// H-2 修复: chatStream → sendStream(规范要求)
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
const body = this.toNativeRequest(request, true);
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
@@ -79,7 +97,8 @@ export class AgnesAdapter extends BaseAdapter {
...this.config.headers,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.config.timeoutMs ?? 300_000),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
if (!response.ok || !response.body) {
@@ -94,6 +113,17 @@ export class AgnesAdapter extends BaseAdapter {
);
}
/**
* H-2 修复: 获取上下文窗口大小(规范要求)
*
* Agnes 模型统一 1M 上下文窗口。
* 注意:Agnes API 未提供 /models 端点,listModels 使用基类默认实现。
*/
override getContextWindow(): number {
const modelInfo = AgnesAdapter.MODEL_INFO[this.config.defaultModel];
return modelInfo?.contextWindow ?? 1_000_000;
}
// ========== 私有方法 ==========
/**
@@ -154,9 +184,12 @@ export class AgnesAdapter extends BaseAdapter {
body.tools = tools;
}
// Thinking 模式Agnes 使用 chat_template_kwargs 而非 thinking
// C-3 修复: Thinking 模式Agnes 使用 chat_template_kwargs 而非 thinking
// Agnes API 仅支持 enable_thinking: true/false,不支持 effort 级别
// thinkingEffort === 'low' 时映射为 false(不启用深度思考),其他级别映射为 true
if (request.params.thinkingEnabled) {
body.chat_template_kwargs = { enable_thinking: true };
const effort = request.params.thinkingEffort ?? 'high';
body.chat_template_kwargs = { enable_thinking: effort !== 'low' };
}
// 停止序列
+80 -10
View File
@@ -16,17 +16,81 @@ import type {
MetonaError,
} from '../types';
import { MetonaErrorCode } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter';
export abstract class BaseAdapter implements IMetonaProviderAdapter {
abstract readonly provider: string;
// H-2 修复: provider → providerId(规范要求)
abstract readonly providerId: string;
abstract readonly supportedModels: string[];
abstract readonly supportsToolCalling: boolean;
abstract readonly supportsThinking: boolean;
/**
* C-2 修复: 外部注入的 AbortSignal(来自 Engine 的 abortController
*
* 用户点击中断时,Engine 调用 abortController.abort(),此信号触发后,
* 正在进行的 fetch 会被立即中断,避免资源泄漏。
*/
private externalAbortSignal: AbortSignal | undefined;
constructor(protected config: AdapterConfig) {}
abstract chat(request: MetonaRequest): Promise<MetonaResponse>;
abstract chatStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent>;
// H-2 修复: chat → send(规范要求)
abstract send(request: MetonaRequest): Promise<MetonaResponse>;
// H-2 修复: chatStream → sendStream(规范要求)
abstract sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent>;
/**
* H-2 修复: 获取上下文窗口大小(规范要求)
*
* 默认实现从 config 读取 contextWindow,子类可覆盖以支持动态查询。
* Engine 用此值估算上下文使用率,决定是否触发压缩。
*
* @returns 上下文窗口大小(token 数)
*/
getContextWindow(): number {
// 优先使用 AdapterConfig.contextWindow(如果存在)
const ctx = (this.config as AdapterConfig & { contextWindow?: number }).contextWindow;
if (typeof ctx === 'number' && ctx > 0) return ctx;
// 默认 1M(保守值,子类应覆盖)
return 1_000_000;
}
/**
* C-2 修复: 注入外部 AbortSignal
* Engine 在调用 send/sendStream 前调用此方法,关联 abortController
*/
setAbortSignal(signal: AbortSignal | undefined): void {
this.externalAbortSignal = signal;
}
/**
* C-2 修复: 合并外部 abort signal 和 timeout signal
*
* 使用 AbortSignal.any() 合并两个信号,任一触发都会中断 fetch:
* - timeout signal:防止请求挂起
* - external abort signal:用户主动中断
*
* @param timeoutMs 超时时间(毫秒)
* @returns 合并后的 AbortSignal
*/
protected getFetchSignal(timeoutMs: number): AbortSignal {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
// 如果没有外部信号,直接使用 timeout signal
if (!this.externalAbortSignal) {
return timeoutSignal;
}
// 如果外部信号已经 abort,直接返回它
if (this.externalAbortSignal.aborted) {
return this.externalAbortSignal;
}
// 合并两个信号 — 任一触发都会 abort
// Node.js 20+ / Electron 35+ 支持 AbortSignal.any()
return AbortSignal.any([timeoutSignal, this.externalAbortSignal]);
}
async healthCheck(): Promise<boolean> {
try {
@@ -37,8 +101,14 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
}
}
async listModels(): Promise<string[]> {
return this.supportedModels;
/**
* H-2 修复: 返回 MetonaModelInfo[](规范要求)
*
* 默认实现将 supportedModels 映射为 MetonaModelInfo[]
* 子类可覆盖以从 API 获取完整元信息。
*/
async listModels(): Promise<MetonaModelInfo[]> {
return this.supportedModels.map((id) => ({ id }));
}
/**
@@ -52,7 +122,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
return {
code: MetonaErrorCode.NETWORK_TIMEOUT,
message: error.message,
provider: this.provider,
provider: this.providerId,
retryable: true,
retryAfterMs: 3000,
};
@@ -62,7 +132,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
return {
code: MetonaErrorCode.NETWORK_ERROR,
message: error.message,
provider: this.provider,
provider: this.providerId,
retryable: true,
retryAfterMs: 3000,
};
@@ -72,7 +142,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
return {
code: MetonaErrorCode.AUTH_INVALID,
message: 'API key 无效或已过期',
provider: this.provider,
provider: this.providerId,
retryable: false,
};
}
@@ -81,7 +151,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
return {
code: MetonaErrorCode.RATE_LIMITED,
message: '请求过于频繁,请稍后重试',
provider: this.provider,
provider: this.providerId,
retryable: true,
retryAfterMs: 5000,
};
@@ -91,7 +161,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
return {
code: MetonaErrorCode.UNKNOWN,
message: error instanceof Error ? error.message : 'Unknown error',
provider: this.provider,
provider: this.providerId,
retryable: false,
};
}
+62 -12
View File
@@ -13,18 +13,43 @@
import { BaseAdapter } from './base-adapter';
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
import { MetonaFinishReason, MetonaErrorCode } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter';
import { buildOpenAICompatibleMessages, buildOpenAICompatibleTools } from './shared/openai-format';
import { parseSSEStream, parseOpenAICompatibleResponse } from './shared/sse-stream';
export class DeepSeekAdapter extends BaseAdapter {
override readonly provider: string = 'deepseek';
// H-2 修复: provider → providerId(规范要求)
override readonly providerId: string = 'deepseek';
readonly supportedModels = ['deepseek-v4-pro', 'deepseek-v4-flash'];
readonly supportsToolCalling = true;
readonly supportsThinking = true;
// H-2 修复: DeepSeek 模型元信息(1M 上下文,384K 最大输出)
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
'deepseek-v4-pro': {
id: 'deepseek-v4-pro',
name: 'DeepSeek V4 Pro',
contextWindow: 1_000_000,
maxOutputTokens: 384_000,
supportsToolCalling: true,
supportsThinking: true,
description: 'DeepSeek 旗舰模型,1M 上下文,支持深度推理与工具调用',
},
'deepseek-v4-flash': {
id: 'deepseek-v4-flash',
name: 'DeepSeek V4 Flash',
contextWindow: 1_000_000,
maxOutputTokens: 384_000,
supportsToolCalling: true,
supportsThinking: true,
description: 'DeepSeek 快速版,1M 上下文,低延迟推理',
},
};
// ===== POST /chat/completions (非流式) =====
async chat(request: MetonaRequest): Promise<MetonaResponse> {
// H-2 修复: chat → send(规范要求)
async send(request: MetonaRequest): Promise<MetonaResponse> {
const body = this.toNativeRequest(request, false);
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
@@ -35,7 +60,8 @@ export class DeepSeekAdapter extends BaseAdapter {
...this.config.headers,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.config.timeoutMs ?? 120_000),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 120_000),
});
if (!response.ok) {
@@ -44,12 +70,12 @@ export class DeepSeekAdapter extends BaseAdapter {
}
const data = await response.json() as Record<string, unknown>;
const parsed = parseOpenAICompatibleResponse(data, request.meta.requestId, this.provider, this.config.defaultModel);
const parsed = parseOpenAICompatibleResponse(data, request.meta.requestId, this.providerId, this.config.defaultModel);
return {
meta: {
requestId: request.meta.requestId,
provider: this.provider,
provider: this.providerId,
model: (data.model as string) ?? this.config.defaultModel,
latencyMs: 0,
timestamp: Date.now(),
@@ -64,7 +90,8 @@ export class DeepSeekAdapter extends BaseAdapter {
// ===== POST /chat/completions (流式) =====
async *chatStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
// H-2 修复: chatStream → sendStream(规范要求)
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
const body = this.toNativeRequest(request, true);
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
@@ -75,7 +102,8 @@ export class DeepSeekAdapter extends BaseAdapter {
...this.config.headers,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.config.timeoutMs ?? 300_000),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
if (!response.ok || !response.body) {
@@ -92,18 +120,40 @@ export class DeepSeekAdapter extends BaseAdapter {
// ===== GET /models =====
async listModels(): Promise<string[]> {
/**
* H-2 修复: 返回 MetonaModelInfo[](规范要求)
*
* 优先尝试从 API 获取实时模型列表,并合并本地 MODEL_INFO 元数据。
* API 不可用时回退到 supportedModels。
*/
async listModels(): Promise<MetonaModelInfo[]> {
try {
const response = await fetch(`${this.config.baseURL}/models`, {
headers: { Authorization: `Bearer ${this.config.apiKey}` },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) return this.supportedModels;
const data = await response.json() as { data?: Array<{ id: string }> };
return data.data?.map((m) => m.id) ?? this.supportedModels;
if (response.ok) {
const data = await response.json() as { data?: Array<{ id: string }> };
if (data.data?.length) {
// 合并 API 返回的模型 ID 与本地元数据
return data.data.map((m) => DeepSeekAdapter.MODEL_INFO[m.id] ?? { id: m.id });
}
}
} catch {
return this.supportedModels;
// API 不可用时降级
}
// 回退到 supportedModels(带本地元数据)
return this.supportedModels.map((id) => DeepSeekAdapter.MODEL_INFO[id] ?? { id });
}
/**
* H-2 修复: 获取上下文窗口大小(规范要求)
*
* DeepSeek 模型统一 1M 上下文窗口。
*/
override getContextWindow(): number {
const modelInfo = DeepSeekAdapter.MODEL_INFO[this.config.defaultModel];
return modelInfo?.contextWindow ?? 1_000_000;
}
// ===== GET /user/balance =====
+70 -15
View File
@@ -23,15 +23,22 @@
*/
import { BaseAdapter } from './base-adapter';
import log from 'electron-log';
import { nanoid } from 'nanoid';
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
import { MetonaFinishReason, MetonaStreamEventType } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter';
export class OllamaAdapter extends BaseAdapter {
override readonly provider: string = 'ollama';
// H-2 修复: provider → providerId(规范要求)
override readonly providerId: string = 'ollama';
readonly supportedModels = ['qwen3:latest', 'gemma3:latest', 'deepseek-r1:latest'];
readonly supportsToolCalling = true;
readonly supportsThinking = true;
// H-2 修复: Ollama 本地模型默认上下文窗口(可由 options.num_ctx 覆盖)
private static readonly DEFAULT_CONTEXT_WINDOW = 4096;
private baseURL: string;
constructor(config: ConstructorParameters<typeof BaseAdapter>[0]) {
@@ -41,14 +48,16 @@ export class OllamaAdapter extends BaseAdapter {
// ===== POST /api/chat =====
async chat(request: MetonaRequest): Promise<MetonaResponse> {
// H-2 修复: chat → send(规范要求)
async send(request: MetonaRequest): Promise<MetonaResponse> {
const nativeRequest = this.toNativeRequest(request);
const response = await fetch(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...nativeRequest, stream: false }),
signal: AbortSignal.timeout(this.config.timeoutMs ?? 300_000),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
if (!response.ok) {
@@ -59,14 +68,16 @@ export class OllamaAdapter extends BaseAdapter {
return this.toMetonaResponse(data, request.meta.requestId, request.meta.iteration);
}
async *chatStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
// H-2 修复: chatStream → sendStream(规范要求)
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
const nativeRequest = this.toNativeRequest(request);
const response = await fetch(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...nativeRequest, stream: true }),
signal: AbortSignal.timeout(this.config.timeoutMs ?? 300_000),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
if (!response.ok || !response.body) {
@@ -133,7 +144,8 @@ export class OllamaAdapter extends BaseAdapter {
seq: seq++,
timestamp: Date.now(),
toolCall: {
id: `tc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
// L-9 修复: 统一使用 nanoid 生成工具调用 ID(与 sse-stream.ts 一致)
id: `tc_${nanoid(8)}`,
name: tc.function?.name ?? '',
args: parsedArgs,
iteration: request.meta.iteration,
@@ -250,17 +262,55 @@ export class OllamaAdapter extends BaseAdapter {
// ===== GET /api/tags =====
async listModels(): Promise<string[]> {
/**
* H-2 修复: 返回 MetonaModelInfo[](规范要求)
*
* Ollama /api/tags 返回模型列表含详细信息(name, size, details),
* 转换为 MetonaModelInfo 并补充默认元数据。
*/
async listModels(): Promise<MetonaModelInfo[]> {
try {
const response = await fetch(`${this.baseURL}/api/tags`, {
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) return this.supportedModels;
const data = await response.json() as { models?: Array<{ name: string }> };
return data.models?.map((m) => m.name) ?? this.supportedModels;
if (response.ok) {
const data = await response.json() as {
models?: Array<{
name: string;
size?: number;
details?: { parameter_size?: string; quantization_level?: string; family?: string };
}>;
};
if (data.models?.length) {
return data.models.map((m) => ({
id: m.name,
name: m.name,
// Ollama 模型上下文窗口由 options.num_ctx 决定,此处给保守值
contextWindow: OllamaAdapter.DEFAULT_CONTEXT_WINDOW,
supportsToolCalling: true, // Ollama 多数模型支持,具体能力需通过 /api/show 查询
supportsThinking: true,
description: m.details
? `${m.details.family ?? 'unknown'} / ${m.details.parameter_size ?? '?'} / ${m.details.quantization_level ?? '?'}`
: undefined,
}));
}
}
} catch {
return this.supportedModels;
// API 不可用时降级
}
// 回退到 supportedModels
return this.supportedModels.map((id) => ({ id }));
}
/**
* H-2 修复: 获取上下文窗口大小(规范要求)
*
* Ollama 上下文窗口由 options.num_ctx 决定(默认 4096),
* Engine 应通过 MetonaRequest.params.contextLength 显式设置。
* 此处返回默认值,供 Engine 在未指定时参考。
*/
override getContextWindow(): number {
return OllamaAdapter.DEFAULT_CONTEXT_WINDOW;
}
// ===== POST /api/show =====
@@ -312,7 +362,10 @@ export class OllamaAdapter extends BaseAdapter {
try {
const chunk = JSON.parse(line);
onProgress?.({ status: chunk.status, completed: chunk.completed, total: chunk.total });
} catch {}
} catch {
// L-3 修复: 添加日志便于诊断非标准行(如进度通知、空行等)
log.debug('[Ollama] skipped non-JSON line during pull:', line.slice(0, 100));
}
}
}
}
@@ -366,7 +419,8 @@ export class OllamaAdapter extends BaseAdapter {
].filter(Boolean).join('\n\n'),
},
...request.messages.filter((m) => m.role !== 'system').map((m) => {
const msg: Record<string, unknown> = { role: m.role, content: m.content };
// C-6 修复: Ollama API 不支持 null contentassistant 仅有 tool_calls 时转为空字符串
const msg: Record<string, unknown> = { role: m.role, content: m.content ?? '' };
// Ollama 图片使用 images 字段(纯 base64 数组,不含 data: 前缀)
if (m.images?.length) {
msg.images = m.images.map((img) => {
@@ -439,7 +493,7 @@ export class OllamaAdapter extends BaseAdapter {
return {
meta: {
requestId,
provider: this.provider,
provider: this.providerId,
model: (data.model as string) ?? this.config.defaultModel,
latencyMs: 0,
timestamp: Date.now(),
@@ -464,7 +518,8 @@ export class OllamaAdapter extends BaseAdapter {
args = {};
}
return {
id: `tc_${Date.now()}_${i}`,
// L-9 修复(审计补充): 非流式路径统一使用 nanoid,与流式路径(sendStream)保持一致
id: `tc_${nanoid(8)}`,
name: (fn?.name as string) ?? '',
args,
iteration,
+56 -51
View File
@@ -12,6 +12,52 @@ import { nanoid } from 'nanoid';
import type { MetonaStreamEvent, MetonaTokenUsage } from '../../types';
import { MetonaStreamEventType } from '../../types';
/**
* L-4 修复: 提取 flushToolCallBuffer 辅助函数,消除 [DONE] 分支和 finish_reason='tool_calls' 分支的重复代码
*
* 遍历工具调用缓冲区,对每个缓冲的工具调用:
* 1. JSON.parse argsBuffer(失败则跳过)
* 2. yield 一个 TOOL_CALL_COMPLETE 事件
* 3. 清空缓冲区
*
* @param toolCallsBuffer - 工具调用缓冲区(index → { name, argsBuffer }
* @param requestId - 请求 ID
* @param sessionId - 会话 ID
* @param iteration - 当前迭代轮次
* @param seqRef - seq 计数器引用(递增)
* @yields MetonaStreamEvent
*/
function* flushToolCallBuffer(
toolCallsBuffer: Map<number, { name: string; argsBuffer: string }>,
requestId: string,
sessionId: string,
iteration: number,
seqRef: { seq: number },
): Generator<MetonaStreamEvent> {
for (const [, buf] of toolCallsBuffer) {
try {
yield {
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
requestId,
sessionId,
iteration,
seq: seqRef.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();
}
/**
* 解析 OpenAI 兼容 SSE 流式响应
*
@@ -29,7 +75,7 @@ export async function* parseSSEStream(
): AsyncGenerator<MetonaStreamEvent> {
const reader = responseBody.getReader();
const decoder = new TextDecoder();
let seq = 0;
const seqRef = { seq: 0 };
let buffer = '';
// 工具调用缓冲区:index → { name, argsBuffer }
@@ -50,36 +96,15 @@ export async function* parseSSEStream(
// 流结束
if (data === '[DONE]') {
// 将缓冲区中未完成拼接的工具调用发送
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();
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
yield {
type: MetonaStreamEventType.DONE,
requestId,
sessionId,
iteration,
seq: seq++,
seq: seqRef.seq++,
timestamp: Date.now(),
};
return;
@@ -96,7 +121,7 @@ export async function* parseSSEStream(
requestId,
sessionId,
iteration,
seq: seq++,
seq: seqRef.seq++,
timestamp: Date.now(),
delta: delta.content,
};
@@ -109,7 +134,7 @@ export async function* parseSSEStream(
requestId,
sessionId,
iteration,
seq: seq++,
seq: seqRef.seq++,
timestamp: Date.now(),
delta: delta.reasoning_content,
};
@@ -131,7 +156,7 @@ export async function* parseSSEStream(
requestId,
sessionId,
iteration,
seq: seq++,
seq: seqRef.seq++,
timestamp: Date.now(),
toolCallDelta: {
index: idx,
@@ -158,7 +183,7 @@ export async function* parseSSEStream(
requestId,
sessionId,
iteration,
seq: seq++,
seq: seqRef.seq++,
timestamp: Date.now(),
usage,
};
@@ -167,28 +192,8 @@ export async function* parseSSEStream(
// 非 [DONE] 但 finish_reason 为 tool_calls 时提前 flush 缓冲区
const finishReason = chunk.choices?.[0]?.finish_reason as string | undefined;
if (finishReason === 'tool_calls') {
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();
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
}
} catch {
// 跳过解析失败的行
+176 -76
View File
@@ -12,6 +12,7 @@
*/
import { EventEmitter } from 'events';
import { resolve } from 'path';
import { nanoid } from 'nanoid';
import {
AgentLoopState,
@@ -33,7 +34,7 @@ import type {
IMetonaProviderAdapter,
MetonaToolDef,
} from '../types';
import { MetonaStreamEventType, MetonaFinishReason } from '../types';
import { MetonaStreamEventType, MetonaFinishReason, MetonaErrorCode } from '../types';
import { estimateMessagesTokens } from '../utils/token-estimator';
import log from 'electron-log';
@@ -175,6 +176,11 @@ export class AgentLoopEngine extends EventEmitter {
this.currentSessionId = sessionId;
this.totalTokens = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
this.abortController = new AbortController();
// C-2 修复: 将 abortController 的 signal 注入到 adapter
// 使正在进行的 fetch 可被用户中断,避免资源泄漏
if (this.adapter.setAbortSignal) {
this.adapter.setAbortSignal(this.abortController.signal);
}
this.eventSeq = 0;
// v0.3.0: 重置工具调用历史(用于死循环检测)
this.toolCallHistory = [];
@@ -297,6 +303,10 @@ export class AgentLoopEngine extends EventEmitter {
this.aborted = true;
this.abortController?.abort();
this.abortController = null;
// C-2 修复: 清除 adapter 的 abort signal,防止旧的已 abort signal 影响后续请求
if (this.adapter.setAbortSignal) {
this.adapter.setAbortSignal(undefined);
}
this.emit('aborted');
}
@@ -348,7 +358,8 @@ export class AgentLoopEngine extends EventEmitter {
// 过滤掉 RETRY 类型的 ERROR 事件 — 不转发到前端,避免触发虚假错误 UI
// RETRY 事件仅用于 Engine 内部清空缓冲区(见下方 switch 分支)
if (event.type === MetonaStreamEventType.ERROR && (event.error?.code as string) === 'RETRY') {
// H-11 修复: 使用 MetonaErrorCode.RETRY 替代 'as string' 强制转换,确保类型安全
if (event.type === MetonaStreamEventType.ERROR && event.error?.code === MetonaErrorCode.RETRY) {
// 内部处理:清空已累积的内容和缓冲区(重试会从头开始接收)
fullContent = '';
reasoningContent = '';
@@ -410,26 +421,8 @@ export class AgentLoopEngine extends EventEmitter {
// === v0.2.0: PARSING 状态 — 解析流式缓冲区中的工具调用 ===
await this.transitionTo(AgentLoopState.PARSING);
// 处理缓冲区中的工具调用(TOOL_CALL_DELTA 拼接
if (toolCallsBuffer.size > 0 && (!step.toolCalls || step.toolCalls.length === 0)) {
step.toolCalls = [];
for (const [, buf] of toolCallsBuffer) {
let args: Record<string, unknown>;
try {
args = buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {};
} catch {
// JSON 解析失败,使用空参数(LLM 仍请求了该工具调用)
args = {};
}
step.toolCalls.push({
id: `tc_${nanoid(8)}`,
name: buf.name,
args,
iteration: this.currentIteration,
timestamp: Date.now(),
});
}
}
// L-19 修复: 提取 finalizeToolCallsFromBuffer 子方法(PARSING 阶段
this.finalizeToolCallsFromBuffer(step, toolCallsBuffer);
// 记录 Thought
if (fullContent || reasoningContent) {
@@ -470,49 +463,9 @@ export class AgentLoopEngine extends EventEmitter {
// transitionTo 已发射 stateChange 事件,无需重复 emit
await this.transitionTo(AgentLoopState.EXECUTING);
// 并行执行所有工具调用(独立工具之间无依赖,可安全并发
const executeAndForward = async (tc: MetonaToolCall): Promise<MetonaToolResult> => {
const result = await this.executeToolSafely(tc);
// H-5: abort 后不转发工具结果(避免 DONE 后到达的残留事件污染新流)
if (!this.aborted) {
// 立即转发工具结果到渲染进程(不等其他工具完成)
this.emit('streamEvent', {
type: MetonaStreamEventType.TOOL_RESULT,
requestId: request.meta.requestId,
sessionId,
iteration: this.currentIteration,
seq: this.nextSeq(),
timestamp: Date.now(),
toolResult: result,
runId: this.runId,
});
}
return result;
};
// H-5: abort 时提前退出工具执行等待,不再阻塞至所有工具完成
const toolsPromise = Promise.allSettled(
step.toolCalls.map((tc) => executeAndForward(tc)),
);
const controller = this.abortController;
let onAbort: (() => void) | null = null;
const abortPromise = new Promise<null>((resolve) => {
if (this.aborted) return resolve(null);
if (controller) {
onAbort = () => resolve(null);
controller.signal.addEventListener('abort', onAbort, { once: true });
}
});
const raceResult = await Promise.race([toolsPromise, abortPromise]) as
| PromiseSettledResult<MetonaToolResult>[]
| null;
// v0.3.0 修复: 若 toolsPromise 先完成(正常执行),手动移除 abort 监听器,避免监听器堆积
if (onAbort && controller && raceResult !== null) {
controller.signal.removeEventListener('abort', onAbort);
}
// L-19 修复: 提取 executeToolCallsParallel 子方法(EXECUTING 阶段
// 返回 null 表示被 abort 中断
const raceResult = await this.executeToolCallsParallel(step.toolCalls, request.meta.requestId, sessionId);
if (raceResult === null) {
// 被 abort 中断,标记步骤并退出
@@ -521,8 +474,7 @@ export class AgentLoopEngine extends EventEmitter {
return step;
}
const settledResults = raceResult;
step.toolResults = settledResults.map((result, idx) => {
step.toolResults = raceResult.map((result, idx) => {
const tc = step.toolCalls![idx];
if (result.status === 'fulfilled') return result.value;
// rejected:构造失败 result
@@ -600,6 +552,95 @@ export class AgentLoopEngine extends EventEmitter {
}
}
/**
* L-19 修复: PARSING 阶段 — 解析流式接收期间累积的工具调用缓冲区,
* 构造 MetonaToolCall[] 写入 step.toolCalls。
*
* 仅在缓冲区非空且 step 尚未通过 TOOL_CALL_COMPLETE 接收到完整调用时生效,
* 避免覆盖已就绪的 toolCalls。
*/
private finalizeToolCallsFromBuffer(
step: IterationStep,
toolCallsBuffer: Map<number, { name: string; argsBuffer: string }>,
): void {
if (toolCallsBuffer.size > 0 && (!step.toolCalls || step.toolCalls.length === 0)) {
step.toolCalls = [];
for (const [, buf] of toolCallsBuffer) {
let args: Record<string, unknown>;
try {
args = buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {};
} catch {
args = {};
}
step.toolCalls.push({
id: `tc_${nanoid(8)}`,
name: buf.name,
args,
iteration: this.currentIteration,
timestamp: Date.now(),
});
}
}
}
/**
* L-19 修复: EXECUTING 阶段 — 并行执行工具调用,转发结果到 UI,
* 并与 abort 信号竞速。返回 null 表示被 abort 中断。
*
* 注意:rejected 的工具结果由调用方处理(构造失败 result 并转发),
* 此处只负责转发 fulfilled 的结果。
*/
private async executeToolCallsParallel(
toolCalls: MetonaToolCall[],
requestId: string,
sessionId: string,
): Promise<PromiseSettledResult<MetonaToolResult>[] | null> {
const executeAndForward = async (tc: MetonaToolCall): Promise<MetonaToolResult> => {
const result = await this.executeToolSafely(tc);
// 执行成功后转发结果到 UI(abort 后不再转发,避免污染新会话的流)
if (!this.aborted) {
this.emit('streamEvent', {
type: MetonaStreamEventType.TOOL_RESULT,
requestId,
sessionId,
iteration: this.currentIteration,
seq: this.nextSeq(),
timestamp: Date.now(),
toolResult: result,
runId: this.runId,
});
}
return result;
};
// C-2 修复: 使用 Promise.allSettled 而非 Promise.all,确保单个工具失败不影响其他工具
const toolsPromise = Promise.allSettled(toolCalls.map((tc) => executeAndForward(tc)));
const controller = this.abortController;
let onAbort: (() => void) | null = null;
const abortPromise = new Promise<null>((resolve) => {
if (this.aborted) {
resolve(null);
return;
}
if (controller) {
onAbort = () => resolve(null);
controller.signal.addEventListener('abort', onAbort, { once: true });
}
});
const raceResult = (await Promise.race([toolsPromise, abortPromise])) as
| PromiseSettledResult<MetonaToolResult>[]
| null;
// 清理 abort 监听器,避免事件循环中残留
if (onAbort && controller && raceResult !== null) {
controller.signal.removeEventListener('abort', onAbort);
}
return raceResult;
}
/**
* 安全执行工具调用(经过 Hook 管道)
*/
@@ -627,12 +668,32 @@ export class AgentLoopEngine extends EventEmitter {
}
}
// H-5 修复: 精确保护工作空间根目录的 MEMORY.md
// @see project_memory.md — Only the MEMORY.md in the workspace root directory is protected;
// subdirectory MEMORY.md files are unrestricted
// 之前 permissions.ts 使用 /MEMORY\.md/i 粗粒度正则会误拦子目录的 MEMORY.md,
// 现在改为在工具执行层进行精确校验,只阻止对根目录 MEMORY.md 的读写。
// run_command 由 permissions.ts 的粗粒度正则保留保护(命令解析复杂)。
if (['read_file', 'write_file', 'file_editor'].includes(toolCall.name)) {
if (this.isTargetingRootMemoryMd(toolCall)) {
return {
toolCallId: toolCall.id, toolName: toolCall.name,
result: null, success: false,
error: 'Access to workspace root MEMORY.md is protected by security policy',
durationMs: Date.now() - startTs, timestamp: Date.now(),
};
}
}
// 执行工具(带超时)
// 兜底超时取 max(配置值, 工具自定义 timeoutMs),确保工具能跑满自己声明的超时
const configuredTimeout = this.config.toolExecutionTimeoutMs ?? 120_000;
const toolDef = this.toolRegistry.get(toolCall.name)?.definition;
const toolTimeout = Math.max(configuredTimeout, toolDef?.timeoutMs ?? 0);
let toolResult: MetonaToolResult;
// M-16 修复: 使用 try/finally 清理 setTimeout,防止事件循环 timer 堆积
// 默认 120 秒超时下,多轮迭代会堆积大量未触发 timer
let engineTimer: ReturnType<typeof setTimeout> | undefined;
try {
toolResult = await Promise.race([
this.toolRegistry.execute(toolCall, {
@@ -641,9 +702,12 @@ export class AgentLoopEngine extends EventEmitter {
iteration: this.currentIteration,
requestId: this.currentRequestId,
}),
new Promise<MetonaToolResult>((_, reject) =>
setTimeout(() => reject(new Error(`Tool '${toolCall.name}' timed out after ${toolTimeout}ms`)), toolTimeout),
),
new Promise<MetonaToolResult>((_, reject) => {
engineTimer = setTimeout(
() => reject(new Error(`Tool '${toolCall.name}' timed out after ${toolTimeout}ms`)),
toolTimeout,
);
}),
]);
} catch (err) {
toolResult = {
@@ -660,6 +724,9 @@ export class AgentLoopEngine extends EventEmitter {
await hook.afterExecute(toolCall, toolResult, this.currentSessionId);
}
return toolResult;
} finally {
// M-16 修复: 清理未触发的 timeout timer
if (engineTimer) clearTimeout(engineTimer);
}
// 后置 Hook 管道
@@ -670,6 +737,38 @@ export class AgentLoopEngine extends EventEmitter {
return toolResult;
}
/**
* H-5 修复: 检查工具调用是否针对工作空间根目录的 MEMORY.md
*
* @see project_memory.md — Only the MEMORY.md in the workspace root directory is protected;
* subdirectory MEMORY.md files are unrestricted
*
* 之前 permissions.ts 使用 /MEMORY\.md/i 粗粒度正则会误拦子目录的 MEMORY.md,
* 现在改为在工具执行层进行精确校验,只阻止对根目录 MEMORY.md 的读写。
*
* @param toolCall 工具调用
* @returns 是否指向工作空间根目录的 MEMORY.md
*/
private isTargetingRootMemoryMd(toolCall: MetonaToolCall): boolean {
if (!this.workspacePath) return false;
// 提取工具参数中的路径(不同工具使用不同的参数名)
const args = toolCall.args;
const pathStr = (args.path as string) || (args.file_path as string) ||
(args.filePath as string) || (args.file as string) ||
(args.target as string) || (args.destination as string);
if (!pathStr || typeof pathStr !== 'string') return false;
// 解析路径,判断是否指向工作空间根目录的 MEMORY.md
// 使用 toLowerCase 处理 Windows 不区分大小写的文件系统
const resolved = resolve(pathStr).toLowerCase();
const rootMemoryPath = resolve(this.workspacePath, 'MEMORY.md').toLowerCase();
// 精确匹配:路径必须等于 {workspacePath}/MEMORY.md
return resolved === rootMemoryPath;
}
/**
* 带重试的流式调用(v0.2.0: 指数退避)
*
@@ -687,10 +786,11 @@ export class AgentLoopEngine extends EventEmitter {
try {
// 首次尝试直接 yield
if (attempt === 0) {
yield* this.adapter.chatStream(request);
yield* this.adapter.sendStream(request);
return;
}
// 重试时:先发送一个 retry 事件,让 UI 清空已接收的 delta
// H-11 修复: 使用 MetonaErrorCode.RETRY 替代 'RETRY' as never,移除不安全的类型断言
yield {
type: MetonaStreamEventType.ERROR,
requestId: request.meta.requestId,
@@ -699,12 +799,12 @@ export class AgentLoopEngine extends EventEmitter {
seq: 0,
timestamp: Date.now(),
error: {
code: 'RETRY' as never,
code: MetonaErrorCode.RETRY,
message: `Retrying after error (attempt ${attempt + 1}/${this.config.retryCount + 1})`,
retryable: true,
},
} as MetonaStreamEvent;
yield* this.adapter.chatStream(request);
};
yield* this.adapter.sendStream(request);
return;
} catch (error) {
lastError = error;
@@ -861,7 +961,7 @@ export class AgentLoopEngine extends EventEmitter {
// 不截断单条消息——摘要请求是独立 API 调用,不共享主对话上下文窗口
const conversationText = toCompress.map((m) => {
const role = m.role.toUpperCase();
return `[${role}] ${m.content}`;
return `[${role}] ${m.content ?? ''}`;
}).join('\n\n');
const summaryRequest: MetonaRequest = {
@@ -892,7 +992,7 @@ export class AgentLoopEngine extends EventEmitter {
};
try {
const response = await this.adapter.chat(summaryRequest);
const response = await this.adapter.send(summaryRequest);
const summary = response.content.trim();
if (!summary) return null;
+14 -3
View File
@@ -28,6 +28,10 @@ export class ConfirmationHook implements PreToolHook {
/** 需要确认的风险等级 */
private static REQUIRES_CONFIRMATION = ['high', 'critical'];
/** L-12 修复: 确认超时范围魔法数字提取为命名常量(30 秒 ~ 600 秒) */
private static readonly MIN_CONFIRMATION_TIMEOUT_MS = 30_000;
private static readonly MAX_CONFIRMATION_TIMEOUT_MS = 600_000;
/** 工具定义缓存(由外部设置) */
private toolDefs = new Map<string, MetonaToolDef>();
@@ -112,8 +116,11 @@ export class ConfirmationHook implements PreToolHook {
try {
const timeout = this.configService.get<number>('agent.confirmationTimeoutMs');
if (timeout != null) {
// 限制范围:30 秒 ~ 600 秒
this.confirmationTimeoutMs = Math.min(600_000, Math.max(30_000, timeout));
// L-12 修复: 使用命名常量替代魔法数字
this.confirmationTimeoutMs = Math.min(
ConfirmationHook.MAX_CONFIRMATION_TIMEOUT_MS,
Math.max(ConfirmationHook.MIN_CONFIRMATION_TIMEOUT_MS, timeout),
);
}
} catch {
// 配置加载失败,使用默认值
@@ -122,7 +129,11 @@ export class ConfirmationHook implements PreToolHook {
/** 设置确认超时时间(运行时更新) */
setConfirmationTimeout(ms: number): void {
this.confirmationTimeoutMs = Math.min(600_000, Math.max(30_000, ms));
// L-12 修复: 使用命名常量替代魔法数字
this.confirmationTimeoutMs = Math.min(
ConfirmationHook.MAX_CONFIRMATION_TIMEOUT_MS,
Math.max(ConfirmationHook.MIN_CONFIRMATION_TIMEOUT_MS, ms),
);
}
/** 获取当前确认超时时间 */
+3 -1
View File
@@ -53,7 +53,9 @@ export class RateLimitHook implements PreToolHook {
}
const entry = this.callCounts.get(key);
if (entry && entry.resetTime >= now) {
// L-2 修复: 使用 > 而非 >=,确保窗口到期时正确重置(边界条件)
// 当 resetTime === now 时应视为已到期,进入 else 分支重建 entry
if (entry && entry.resetTime > now) {
if (entry.count >= this.maxCallsPerMinute) {
return { blocked: true, reason: `Rate limit exceeded for tool "${toolCall.name}"` };
}
+12 -5
View File
@@ -219,11 +219,18 @@ export class MemoryConsolidator {
try {
// v0.3.0 修复: 添加 30 秒超时保护,防止 LLM 响应缓慢导致 consolidator 任务挂起
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('LLM extraction timeout')), 30_000),
);
const response = await Promise.race([this.adapter.chat(request), timeoutPromise]);
return response.content.trim();
// M-17 修复: 使用 try/finally 清理 setTimeout,防止每次会话结束时 timer 堆积
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('LLM extraction timeout')), 30_000);
});
const response = await Promise.race([this.adapter.send(request), timeoutPromise]);
return response.content.trim();
} finally {
// M-17 修复: LLM 调用正常完成时清理未触发的 30 秒 timer
if (timer) clearTimeout(timer);
}
} catch (error) {
log.warn('[MemoryConsolidator] LLM call failed:', (error as Error).message);
return null;
+83 -66
View File
@@ -184,6 +184,61 @@ export class MemoryManager {
}
}
/**
* L-5 修复: 提取 scoreAndPushMemory 辅助函数
*
* 计算 TF-IDF 余弦相似度并应用时间衰减和重要度权重,
* 将分数 > 0 的记忆 push 到 results 数组。
*
* 三种记忆类型(episodic/semantic/working)的评分逻辑统一调用此函数,
* 仅在调用前构造 docText/createdAt/importance 等参数。
*
* @param params - 评分参数
* @param results - 结果数组(push 到此数组)
*/
private scoreAndPushMemory(
params: {
docText: string;
createdAt: number;
importance: number;
id: string;
type: MemoryType;
content: string;
summary?: string;
source: MemorySource;
sessionId?: string;
expiresAt?: number;
},
queryTF: Map<string, number>,
queryNorm: number,
now: number,
results: SearchResult[],
): void {
const docTokens = tokenize(params.docText);
const docTF = computeTF(docTokens);
const docNorm = vectorNorm(docTF, this.idfCache);
if (docNorm === 0) return;
const dotProd = dotProduct(queryTF, docTF, this.idfCache);
const cosineSim = dotProd / (queryNorm * docNorm);
// 时间衰减
const decayWeight = timeDecayWeight(params.createdAt, now);
// 最终分数 = 余弦相似度 * 时间衰减 * 重要度权重
const finalScore = cosineSim * decayWeight * (0.5 + params.importance * 0.5);
if (finalScore > 0) {
results.push({
id: params.id, type: params.type, content: params.content,
summary: params.summary, source: params.source,
importance: params.importance, sessionId: params.sessionId,
createdAt: params.createdAt, expiresAt: params.expiresAt,
score: finalScore,
});
}
}
/**
* TF-IDF 相似度搜索
*/
@@ -202,6 +257,8 @@ export class MemoryManager {
const results: SearchResult[] = [];
const now = Date.now();
// L-5 修复: 三段搜索统一调用 scoreAndPushMemory,消除重复的 tokenize/computeTF/vectorNorm/dotProduct 逻辑
// 搜索 episodic 记忆
if (!type || type === 'episodic') {
const rows = db.prepare(`
@@ -213,30 +270,16 @@ export class MemoryManager {
}>;
for (const row of rows) {
const docText = row.content + ' ' + (row.summary ?? '');
const docTokens = tokenize(docText);
const docTF = computeTF(docTokens);
const docNorm = vectorNorm(docTF, this.idfCache);
if (docNorm === 0) continue;
const dotProd = dotProduct(queryTF, docTF, this.idfCache);
const cosineSim = dotProd / (queryNorm * docNorm);
// 时间衰减
const decayWeight = timeDecayWeight(row.created_at, now);
// 最终分数 = 余弦相似度 * 时间衰减 * 重要度权重
const finalScore = cosineSim * decayWeight * (0.5 + row.importance * 0.5);
if (finalScore > 0) {
results.push({
id: row.id, type: 'episodic', content: row.content,
summary: row.summary ?? undefined, source: row.source as MemorySource,
importance: row.importance, sessionId: row.session_id ?? undefined,
createdAt: row.created_at, expiresAt: row.expires_at ?? undefined,
score: finalScore,
});
}
this.scoreAndPushMemory({
docText: row.content + ' ' + (row.summary ?? ''),
createdAt: row.created_at,
importance: row.importance,
id: row.id, type: 'episodic', content: row.content,
summary: row.summary ?? undefined,
source: row.source as MemorySource,
sessionId: row.session_id ?? undefined,
expiresAt: row.expires_at ?? undefined,
}, queryTF, queryNorm, now, results);
}
}
@@ -251,27 +294,14 @@ export class MemoryManager {
}>;
for (const row of rows) {
const docText = row.key + ' ' + row.value;
const docTokens = tokenize(docText);
const docTF = computeTF(docTokens);
const docNorm = vectorNorm(docTF, this.idfCache);
if (docNorm === 0) continue;
const dotProd = dotProduct(queryTF, docTF, this.idfCache);
const cosineSim = dotProd / (queryNorm * docNorm);
const decayWeight = timeDecayWeight(row.created_at, now);
const finalScore = cosineSim * decayWeight * (0.5 + row.confidence * 0.5);
if (finalScore > 0) {
results.push({
id: row.id, type: 'semantic', content: row.value,
source: 'imported', importance: row.confidence,
sessionId: row.source_session ?? undefined,
createdAt: row.created_at, score: finalScore,
});
}
this.scoreAndPushMemory({
docText: row.key + ' ' + row.value,
createdAt: row.created_at,
importance: row.confidence,
id: row.id, type: 'semantic', content: row.value,
source: 'imported',
sessionId: row.source_session ?? undefined,
}, queryTF, queryNorm, now, results);
}
}
@@ -285,27 +315,14 @@ export class MemoryManager {
}>;
for (const row of rows) {
const docText = row.key + ' ' + row.value;
const docTokens = tokenize(docText);
const docTF = computeTF(docTokens);
const docNorm = vectorNorm(docTF, this.idfCache);
if (docNorm === 0) continue;
const dotProd = dotProduct(queryTF, docTF, this.idfCache);
const cosineSim = dotProd / (queryNorm * docNorm);
const decayWeight = timeDecayWeight(row.updated_at, now);
const finalScore = cosineSim * decayWeight * 0.5;
if (finalScore > 0) {
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: finalScore,
});
}
this.scoreAndPushMemory({
docText: row.key + ' ' + row.value,
createdAt: row.updated_at,
importance: 0.5,
id: row.id, type: 'working', content: row.value,
source: 'agent_thought',
sessionId: row.session_id,
}, queryTF, queryNorm, now, results);
}
}
@@ -61,6 +61,19 @@ export class TaskOrchestrator extends EventEmitter {
super();
}
/**
* L-18 修复: 热更新 SubAgent 的默认配置
*
* 主 Agent 的配置变更(thinkingEnabled/thinkingEffort/contextLength 等)通过
* engine.updateConfig() 即时生效;但 SubAgent 在 delegate() 时从 defaultConfig
* 复制配置,若 defaultConfig 不同步,新创建的 SubAgent 仍使用旧配置。
*
* 此方法供 handlers.ts 在 config:set 时同步调用,确保后续 SubAgent 使用最新配置。
*/
updateDefaultConfig(partial: Partial<AgentLoopConfig>): void {
this.defaultConfig = { ...this.defaultConfig, ...partial };
}
/**
* 委派子任务
*
+22 -4
View File
@@ -29,12 +29,15 @@ export interface PermissionPolicy {
export const DEFAULT_POLICIES: PermissionPolicy[] = [
// v0.3.0 修复:deniedPatterns 使用 (?:\/|["'\s,}]|$) 匹配,
// 覆盖 /etc/ 和 /etc(无尾斜杠,在 JSON 字符串中后跟引号的情况)
{ toolName: 'read_file', requiredLevel: PermissionLevel.READ, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i, /MEMORY\.md/i] },
// H-5 修复: 移除 /MEMORY\.md/i 粗粒度正则 — 之前会误拦子目录的 MEMORY.md
// 改为在 engine.ts executeToolSafely 中进行精确的根目录校验(仅保护 workspacePath/MEMORY.md
// @see project_memory.md — Only the MEMORY.md in the workspace root directory is protected
{ toolName: 'read_file', requiredLevel: PermissionLevel.READ, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i] },
{ toolName: 'web_search', requiredLevel: PermissionLevel.READ, maxFrequency: 10 },
{ toolName: 'list_directory', requiredLevel: PermissionLevel.READ },
{ toolName: 'search_files', requiredLevel: PermissionLevel.READ },
{ toolName: 'memory_search', requiredLevel: PermissionLevel.READ },
{ toolName: 'write_file', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /\/System(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i, /MEMORY\.md/i], requireConfirmation: true, maxFrequency: 5 },
{ toolName: 'write_file', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /\/System(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i], requireConfirmation: true, maxFrequency: 5 },
{ toolName: 'memory_store', requiredLevel: PermissionLevel.WRITE },
{ toolName: 'run_command', requiredLevel: PermissionLevel.EXTERNAL_ACTION, deniedPatterns: [/MEMORY\.md/i], requireConfirmation: true, maxFrequency: 3 },
{ toolName: 'web_fetch', requiredLevel: PermissionLevel.READ },
@@ -43,7 +46,7 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
{ toolName: 'web_browser', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 20 },
// v0.3.0 修复: 补全缺失的工具策略 — 之前这5个工具未配置策略,导致被 PolicyEngine 拦截
// file_editor — 精准文件编辑(WRITE),与 write_file 同级安全约束
{ toolName: 'file_editor', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /\/System(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i, /MEMORY\.md/i], requireConfirmation: true, maxFrequency: 10 },
{ toolName: 'file_editor', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /\/System(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i], requireConfirmation: true, maxFrequency: 10 },
// code_search — 基于 ripgrep 的只读搜索(READ
{ toolName: 'code_search', requiredLevel: PermissionLevel.READ },
// diff_viewer — 文件/文本差异对比(只读,READ)
@@ -52,6 +55,10 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
{ toolName: 'task_manager', requiredLevel: PermissionLevel.WRITE },
// delegate_task — 子任务委派(启动 SubAgentEXTERNAL_ACTION
{ toolName: 'delegate_task', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: false, maxFrequency: 5 },
// C-7 修复: MCP 工具通配符策略 — MCP 工具名称动态生成(mcp_{serverName}_{toolName}
// 无法预先配置精确策略,使用 mcp_* 通配符匹配所有 MCP 工具
// @see project_memory.md — All tools must have a configured policy in DEFAULT_POLICIES
{ toolName: 'mcp_*', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 20 },
];
export class PolicyEngine {
@@ -90,7 +97,18 @@ export class PolicyEngine {
level: PermissionLevel;
requiresConfirmation: boolean;
} {
const policy = this.policies.get(toolName);
let policy = this.policies.get(toolName);
// C-7 修复: 支持通配符策略匹配(如 mcp_* 匹配所有 MCP 工具)
// MCP 工具名称动态生成(mcp_{serverName}_{toolName}),无法预先配置精确策略
if (!policy) {
for (const [pattern, p] of this.policies) {
if (pattern.endsWith('*') && toolName.startsWith(pattern.slice(0, -1))) {
policy = p;
break;
}
}
}
if (!policy) {
return {
+23 -15
View File
@@ -94,46 +94,54 @@ export class SandboxManager {
* fork bomb、PowerShell 编码执行、环境变量窃取、编码绕过等。
*/
scanCode(code: string): { safe: boolean; reason?: string } {
// C-5 修复: 补齐 28 个模式 + 加强 base64/$() 检测
// @see project_memory.md — sandbox scanCode must include 28 patterns with 'i' flag
// and base64/$() detection to prevent encoding bypass
const dangerousPatterns = [
// 危险模块导入
// 危险模块导入3
/require\s*\(\s*['"]child_process['"]\s*\)/i,
/import\s+.*from\s+['"]fs['"]/i,
/import\s+.*from\s+['"]child_process['"]/i,
// 代码执行
// 代码执行3
/\beval\s*\(/i,
/process\.exit/i,
/Function\s*\(/i,
// 路径遍历
// 路径遍历2
/\.\.\//i,
/\\\.\.\\/i, // Windows ..\
// 危险命令
// 危险命令3
/\brm\s+-rf\b/i,
/\bkillall\s+-9\b/i,
/\bchown\s+-R\s+\//i,
// 重定向到系统目录
// 重定向到系统目录2
/>\s*\/dev\/null/i,
/>\s*\/etc\//i,
// 管道执行
// 管道执行2
/\bcurl\b.*\|\s*(bash|sh|zsh)\b/i,
/\bwget\b.*\|\s*(sh|bash|zsh)\b/i,
// 反向 shell
// 反向 shell3
/\/bin\/(bash|sh)\s+-i/i,
/\bnc\s+-e\b/i,
/\bbash\s+-i\b/i,
// Fork bomb
// Fork bomb1
/:\(\)\s*\{\s*:\|:\s*&\s*\};:/i,
// PowerShell 编码执行
// PowerShell 编码执行1
/powershell.*-enc(odedCommand)?\s+/i,
// 环境变量窃取
// 环境变量窃取1
/env\b.*\b(GITHUB_TOKEN|API_KEY|SECRET|PASSWORD)\b/i,
// 编码绕过检测
/\bbase64\b.*\|\s*(sh|bash|zsh)\b/i,
// 编码绕过检测4)— C-5 加强 base64 解码后执行 + 任意 $() 替换
// 检测 base64 解码(-d 或 --decode)后管道到 shell
/\bbase64\b.*(-d|--decode)?\b.*\|\s*(sh|bash|zsh)\b/i,
/\batob\s*\(/i,
/\bprintf\s+['"]\\x[0-9a-f]/i,
// 命令替换
/\$\([^)]*(rm|kill|del|format|mkfs)\b/i,
// heredoc 执行
// 检测任意 $() 命令替换中包含危险命令(扩展检测范围)
/\$\([^)]*(rm|kill|del|format|mkfs|chmod|chown|curl|wget|nc|bash|sh)\b/i,
// heredoc 执行1
/<<\s*(EOF|END)\s*[\s\S]*?\b(rm|kill|del|format|mkfs)\b/i,
// C-5 新增模式 1: Python -c 执行危险代码
/\bpython3?\b.*-c\s+['"]\s*(import\s+(os|subprocess|shutil)|exec\s*\(|eval\s*\()/i,
// C-5 新增模式 2: Node.js -e 执行危险代码
/\bnode\b.*-e\s+['"]\s*(require\s*\(\s*['"]child_process|process\.exit|execSync|spawnSync)/i,
];
for (const pattern of dangerousPatterns) {
+123 -5
View File
@@ -8,13 +8,17 @@
* 2. 通过 SandboxManager.validatePath 校验工作目录
* 3. 命令注入模式检测扩展
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
* @see standard/开发规范.md — 使用 shell-quote 解析命令(禁止简单字符串匹配
* C-4 修复(v0.3.1):双层命令注入防御
* 1. 主层 — 使用 shell-quote 解析命令为 token 数组,对每个 token 做模式匹配
* 2. 补充层 — 保留原正则检测作为 fallback,覆盖 Windows cmd 语法
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具 + run_command 安全规则
*/
import { exec } from 'child_process';
import { promisify } from 'util';
import { resolve } from 'path';
import { parse as shellQuoteParse } from 'shell-quote';
import log from 'electron-log';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
@@ -161,8 +165,13 @@ export class RunCommandTool implements IMetonaTool {
/**
* 命令安全校验
*
* 使用模式匹配检查危险命令。
* C-4 修复: 采用双层防御
* 1. 主层 — 使用 shell-quote 解析命令为 token 数组,对每个 token 做模式匹配
* 可防御所有字符串拼接绕过(如 `r"m" -rf /`、`$'rm'`、`$(echo rm)`、`r''m`
* 2. 补充层 — 保留原正则检测作为 fallback,覆盖 Windows cmd 语法和 shell-quote 无法解析的场景
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — run_command 安全规则
* @see standard/开发规范.md — 使用 shell-quote 解析命令(禁止简单字符串匹配)
*/
private validateCommand(command: string): { allowed: boolean; reason?: string } {
const cmd = command.trim().toLowerCase();
@@ -172,8 +181,16 @@ export class RunCommandTool implements IMetonaTool {
return { allowed: false, reason: 'Access denied: MEMORY.md is managed by the memory system and cannot be accessed via command execution' };
}
// 硬阻止列表(绝对禁止执行)
// v0.2.0: 扩展危险命令检测模式
// ===== 主层: shell-quote token-level 检测 =====
// 解析失败(Windows cmd 语法等)时降级到正则补充层
const tokenBlock = this.checkTokens(command);
if (tokenBlock !== null) return tokenBlock;
// ===== 补充层: 原正则检测(保留所有原模式) =====
// 覆盖 shell-quote 无法解析的场景:
// - Windows cmd 语法(&、|、>nul、chcp 等)
// - 复杂管道序列(curl ... | sh
// - Fork bomb 等特殊语法
const hardBlocks = [
// 文件系统破坏
{ pattern: /\brm\b.*\//, reason: 'rm with absolute path is forbidden' },
@@ -218,4 +235,105 @@ export class RunCommandTool implements IMetonaTool {
return { allowed: true };
}
/**
* C-4 修复: shell-quote token-level 安全检测
*
* 将命令解析为 token 数组,提取所有命令名和参数(忽略 shell 运算符),
* 对每个 token 做精确匹配。可防御所有字符串拼接绕过:
* - `r"m" -rf /` → 解析为 ['rm', '-rf', '/'] → 命中 rm 检测
* - `$'rm'` → 解析为 ['rm'] → 命中 rm 检测
* - `$(echo rm) -rf /` → 命令替换会被 shell-quote 识别为运算符序列
*
* @returns null 表示通过检测;非 null 表示被阻止(含 reason
*/
private checkTokens(command: string): { allowed: boolean; reason?: string } | null {
let tokens: ReturnType<typeof shellQuoteParse>;
try {
tokens = shellQuoteParse(command);
} catch {
// 解析失败(Windows cmd 语法、不完整的引号等)— 降级到正则补充层
return null;
}
// 提取所有 word token(命令名和参数),忽略运算符(&&、|、; 等)
// 同时跟踪管道运算符,检测危险组合(如 `| sh`)
const words: string[] = [];
let prevWasPipe = false;
for (const entry of tokens) {
if (typeof entry === 'string') {
// 裸字符串 token — 若前一 token 是管道,检测是否为 sh/bash(远程代码执行)
if (prevWasPipe && /^(ba)?sh$/i.test(entry)) {
return { allowed: false, reason: 'Remote code execution via pipe is forbidden' };
}
words.push(entry);
prevWasPipe = false;
} else if (typeof entry === 'object' && entry !== null) {
const obj = entry as { word?: unknown; op?: unknown };
if (typeof obj.word === 'string') {
// 引号包裹或变量替换后的 token
if (prevWasPipe && /^(ba)?sh$/i.test(obj.word)) {
return { allowed: false, reason: 'Remote code execution via pipe is forbidden' };
}
words.push(obj.word);
prevWasPipe = false;
} else if (typeof obj.op === 'string') {
// 跟踪管道运算符,用于下一轮检测 `| sh`
prevWasPipe = (obj.op === '|');
}
}
}
// 危险命令名 token(精确匹配,大小写不敏感)
const dangerousCommands = new Set([
'sudo', 'su', 'doas',
'shutdown', 'reboot', 'halt', 'poweroff',
'mkfs', 'fdisk', 'format', 'diskpart',
]);
// 危险参数 token
const dangerousArgs = new Set([
'-enc', '-encodedcommand', // PowerShell 编码执行
]);
for (const word of words) {
const lower = word.toLowerCase();
// 1. 危险命令名(精确匹配,或 mkfs/fdisk 前缀匹配如 mkfs.ext4
if (dangerousCommands.has(lower) || /^(mkfs|fdisk)\b/.test(lower)) {
if (['sudo', 'su', 'doas'].includes(lower)) {
return { allowed: false, reason: 'Privilege escalation commands are forbidden' };
}
if (['shutdown', 'reboot', 'halt', 'poweroff'].includes(lower)) {
return { allowed: false, reason: 'System shutdown commands are forbidden' };
}
// mkfs/fdisk/format/diskpart + mkfs.ext4 等前缀匹配
return { allowed: false, reason: 'Disk formatting commands are forbidden' };
}
// 2. 危险参数(精确匹配)
if (dangerousArgs.has(lower)) {
return { allowed: false, reason: 'PowerShell encoded command execution is forbidden' };
}
// 3. dd 写设备文件:of=/dev/...(不依赖系统目录前置,独立检测)
if (/^of=\/dev\//.test(lower)) {
return { allowed: false, reason: 'Writing to device files is forbidden' };
}
}
// 4. 检测 rm 与系统目录的组合(token 序列检测)
// 例如: ['rm', '-rf', '/etc'] 应被拦截
let hasRm = false;
let hasSystemPath = false;
for (const word of words) {
const lower = word.toLowerCase();
if (lower === 'rm') hasRm = true;
if (/^\/(?:bin|boot|dev|etc|lib|proc|root|sbin|sys|usr|var)\b/.test(lower)) {
hasSystemPath = true;
}
}
if (hasRm && hasSystemPath) {
return { allowed: false, reason: 'rm on system directories is forbidden' };
}
return null;
}
}
+24 -1
View File
@@ -9,6 +9,7 @@
*/
import { resolve, sep } from 'path';
import { realpathSync } from 'fs';
/**
* 受保护文件名列表(工作空间根目录)
@@ -43,6 +44,14 @@ export function isProtectedWorkspaceFile(
*
* 修复前缀碰撞漏洞:`/home/user/app-evil` 不应被误判为在 `/home/user/app` 内。
*
* M-22 修复: 添加 realpathSync 二次校验防止符号链接逃逸
* 攻击场景:工作空间内创建符号链接 `ln -s /etc/passwd workspace/leak.txt`
* 字符串校验会通过(leak.txt 在 workspace 内),但实际读取的是 /etc/passwd。
*
* 注意:realpathSync 在路径不存在时会抛 ENOENT,此时降级为字符串校验
* write_file 的目标文件可能尚不存在,无法 realpath)。
*
* @see project_memory.md — sandbox validatePath must perform realpathSync secondary check
* @param filePath 用户传入的文件路径
* @param workspacePath 当前工作空间根路径
* @returns true 如果路径在工作空间内
@@ -53,7 +62,21 @@ export function isPathWithinWorkspace(
): boolean {
const resolved = resolve(workspacePath, filePath);
const workspaceRoot = resolve(workspacePath);
return resolved === workspaceRoot || resolved.startsWith(workspaceRoot + sep);
// 第一层:字符串前缀校验(快速路径)
const stringCheck = resolved === workspaceRoot || resolved.startsWith(workspaceRoot + sep);
if (!stringCheck) return false;
// 第二层:realpathSync 二次校验(防范符号链接逃逸)
// 仅对实际存在的路径做 realpath 校验;不存在的路径(如 write_file 目标)降级为字符串校验
try {
const realResolved = realpathSync(resolved);
const realWorkspaceRoot = realpathSync(workspaceRoot);
return realResolved === realWorkspaceRoot || realResolved.startsWith(realWorkspaceRoot + sep);
} catch {
// 路径不存在(ENOENT)或 realpath 失败 → 降级为字符串校验结果
return stringCheck;
}
}
/**
@@ -12,7 +12,7 @@
* @see standard/开发规范.md — 使用 fs/path 内置模块
*/
import { readFile, writeFile, readdir, stat, appendFile, mkdir, open } from 'fs/promises';
import { readFile, writeFile, readdir, stat, appendFile, mkdir, open, type FileHandle } from 'fs/promises';
import { join, relative, resolve, dirname } from 'path';
import { existsSync } from 'fs';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
@@ -42,11 +42,12 @@ function matchGlob(name: string, glob: string): boolean {
/** 二进制文件检测:读取前 8KB 检查是否含 NULL 字节 */
async function isBinaryFile(filePath: string): Promise<boolean> {
// M-20 修复: 使用 finally 块确保文件句柄关闭,防止 fd 泄漏
let fd: FileHandle | null = null;
try {
const buffer = Buffer.alloc(8192);
const fd = await open(filePath, 'r');
fd = await open(filePath, 'r');
await fd.read(buffer, 0, 8192, 0);
await fd.close();
// 含 NULL 字节 → 二进制
for (let i = 0; i < buffer.length; i++) {
if (buffer[i] === 0) return true;
@@ -54,6 +55,11 @@ async function isBinaryFile(filePath: string): Promise<boolean> {
return false;
} catch {
return false;
} finally {
// M-20 修复: 无论 read 成功或失败,都关闭文件句柄
if (fd) {
try { await fd.close(); } catch { /* 忽略关闭错误 */ }
}
}
}
@@ -90,17 +90,45 @@ export async function fetchWithTimeout(
// ===== URL 标准化(去重用) =====
/**
* H-6 增强: URL 标准化用于搜索结果去重
*
* 规范推荐使用 normalize-url 库(@see docs/Agent网络工具通用设计-v2.md §6.1.3),
* 但当前实现已覆盖核心场景,且避免 ESM-only 依赖兼容性风险,
* 故在现有基础上增强以下能力(对标 normalize-url 默认行为):
* 1. 去除追踪参数(utm_*, gclid, fbclid
* 2. 强制小写 host
* 3. 去除尾部斜杠(根路径除外)
* 4. H-6 新增: 去除默认端口(http→:80, https→:443
* 5. H-6 新增: 排序查询参数(避免 ?a=1&b=2 vs ?b=2&a=1 被视为不同 URL
*/
export function normalizeUrl(url: string): string {
try {
const u = new URL(url);
// 去除追踪参数
const trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'gclid', 'fbclid'];
for (const p of trackingParams) u.searchParams.delete(p);
// H-6 增强: 排序查询参数(确保参数顺序一致,便于去重)
// searchParams.sort() 原地排序,URLSearchParams 按码点顺序
u.searchParams.sort();
// 去除尾部斜杠(根路径除外)
let path = u.pathname;
if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1);
// H-6 增强: 去除默认端口
// http 默认 :80, https 默认 :443, ws 默认 :80, wss 默认 :443
const isDefaultPort =
(u.protocol === 'http:' && u.port === '80') ||
(u.protocol === 'https:' && u.port === '443') ||
(u.protocol === 'ws:' && u.port === '80') ||
(u.protocol === 'wss:' && u.port === '443');
const portSuffix = isDefaultPort ? '' : (u.port ? `:${u.port}` : '');
// 强制小写 host
return `${u.protocol}//${u.host.toLowerCase()}${path}${u.search}${u.hash}`;
return `${u.protocol}//${u.hostname.toLowerCase()}${portSuffix}${path}${u.search}${u.hash}`;
} catch {
return url;
}
+48 -19
View File
@@ -40,6 +40,10 @@ export class WebFetchTool implements IMetonaTool {
type: 'object',
properties: {
url: { type: 'string', description: 'Target URL (http/https only)' },
// H-3/H-4 修复: 补齐规范要求的 max_chars 和 extract_mode 参数
// @see docs/Agent网络工具通用设计-v2.md — 第 3 章 web_fetch 抓取设计
max_chars: { type: 'number', description: 'Maximum characters to return (default 50000, truncated with notice)' },
extract_mode: { type: 'string', enum: ['text', 'html'], description: 'Content extraction mode: "text"=plain text (default), "html"=cleaned HTML with scripts/styles removed' },
mobile_ua: { type: 'boolean', description: 'Use mobile User-Agent (default false)' },
retry: { type: 'boolean', description: 'Enable retry with exponential backoff (default true)' },
},
@@ -55,6 +59,9 @@ export class WebFetchTool implements IMetonaTool {
const url = args.url as string;
const mobileUA = (args.mobile_ua as boolean) ?? false;
const enableRetry = (args.retry as boolean) ?? true;
// H-3/H-4 修复: 读取 max_chars 和 extract_mode 参数
const maxChars = (args.max_chars as number) ?? 50_000;
const extractMode = ((args.extract_mode as string) ?? 'text') as 'text' | 'html';
if (!url || !/^https?:\/\//i.test(url)) {
return { url, content: '', success: false, error: 'URL must start with http:// or https://' };
@@ -64,7 +71,7 @@ export class WebFetchTool implements IMetonaTool {
const cached = fetchCache.get(url);
if (cached) {
logTool('web_fetch', `Cache hit: ${url}`);
return { url, content: cached, success: true, method: 'cache', length: cached.length };
return this.buildSuccess(url, cached, 'cache', maxChars);
}
logTool('web_fetch', `Fetching: ${url}`);
@@ -73,24 +80,29 @@ export class WebFetchTool implements IMetonaTool {
const phase1Result = await this.httpFetch(url, mobileUA, enableRetry);
if (phase1Result.success && !phase1Result.intercepted) {
// 内容过短检测 → Phase 2 升级
if (phase1Result.text.length < 200) {
logTool('web_fetch', `Phase 2: Content too short (${phase1Result.text.length} chars), upgrading to browser`);
// 根据 extract_mode 选择返回内容:'html' 模式返回清理后的 HTML'text' 模式返回纯文本
const phase1Content = extractMode === 'html' ? phase1Result.html : phase1Result.text;
// 内容过短检测 → Phase 2 升级(仅对 text 模式生效,html 模式不升级)
if (extractMode === 'text' && phase1Content.length < 200) {
logTool('web_fetch', `Phase 2: Content too short (${phase1Content.length} chars), upgrading to browser`);
const browserResult = await this.browserFetch(url);
if (browserResult) {
return this.buildSuccess(url, browserResult, 'browser');
return this.buildSuccess(url, browserResult, 'browser', maxChars);
}
}
// 写入缓存
fetchCache.set(url, phase1Result.text);
return this.buildSuccess(url, phase1Result.text, 'http');
// 写入缓存(仅缓存 text 模式的内容,html 模式不缓存以避免模式混淆)
if (extractMode === 'text') {
fetchCache.set(url, phase1Content);
}
return this.buildSuccess(url, phase1Content, 'http', maxChars, extractMode);
}
// ===== Phase 3: 浏览器回退 =====
logTool('web_fetch', `Phase 3: Falling back to browser (${phase1Result.reason})`);
const browserResult = await this.browserFetch(url);
if (browserResult) {
return this.buildSuccess(url, browserResult, 'browser');
return this.buildSuccess(url, browserResult, 'browser', maxChars);
}
// 全部失败
@@ -108,7 +120,7 @@ export class WebFetchTool implements IMetonaTool {
url: string,
mobileUA: boolean,
enableRetry: boolean,
): Promise<{ success: boolean; text: string; intercepted: boolean; reason: string }> {
): Promise<{ success: boolean; html: string; text: string; intercepted: boolean; reason: string }> {
const maxRetries = enableRetry ? 3 : 1;
const backoffBase = 2_000;
@@ -119,7 +131,7 @@ export class WebFetchTool implements IMetonaTool {
// 跳过重试的状态码 → 直接进入浏览器回退
if (SKIP_RETRY_STATUS.has(response.status)) {
return { success: false, text: '', intercepted: true, reason: `HTTP ${response.status}` };
return { success: false, html: '', text: '', intercepted: true, reason: `HTTP ${response.status}` };
}
if (!response.ok) {
@@ -128,7 +140,7 @@ export class WebFetchTool implements IMetonaTool {
await this.sleep(backoffBase * Math.pow(2, attempt) + Math.random() * backoffBase * 0.6);
continue;
}
return { success: false, text: '', intercepted: false, reason: `HTTP ${response.status} ${response.statusText}` };
return { success: false, html: '', text: '', intercepted: false, reason: `HTTP ${response.status} ${response.statusText}` };
}
// 读取正文(10MB 限制)
@@ -136,12 +148,13 @@ export class WebFetchTool implements IMetonaTool {
// 拦截检测
if (isInterceptedPage(html)) {
return { success: false, text: '', intercepted: true, reason: 'Intercepted page detected' };
return { success: false, html: '', text: '', intercepted: true, reason: 'Intercepted page detected' };
}
// HTML → 纯文本
const text = htmlToText(html);
return { success: true, text, intercepted: false, reason: '' };
// H-3/H-4 修复: 同时保留原始 HTML,供 extract_mode='html' 使用
return { success: true, html, text, intercepted: false, reason: '' };
} catch (err) {
const errorMsg = (err as Error).message;
if (attempt < maxRetries - 1) {
@@ -149,11 +162,11 @@ export class WebFetchTool implements IMetonaTool {
await this.sleep(backoffBase * Math.pow(2, attempt) + Math.random() * backoffBase * 0.6);
continue;
}
return { success: false, text: '', intercepted: false, reason: errorMsg };
return { success: false, html: '', text: '', intercepted: false, reason: errorMsg };
}
}
return { success: false, text: '', intercepted: false, reason: 'All retries exhausted' };
return { success: false, html: '', text: '', intercepted: false, reason: 'All retries exhausted' };
}
// ===== Phase 2/3: 浏览器回退(使用共享 BrowserWindowManager 单例) =====
@@ -214,13 +227,29 @@ export class WebFetchTool implements IMetonaTool {
// ===== 辅助方法 =====
private buildSuccess(url: string, text: string, method: string): unknown {
private buildSuccess(
url: string,
text: string,
method: string,
maxChars?: number,
extractMode?: 'text' | 'html',
): unknown {
// H-3/H-4 修复: 应用 max_chars 截断,防止过长内容消耗过多 token
let content = text;
let truncated = false;
if (maxChars !== undefined && maxChars > 0 && text.length > maxChars) {
content = text.slice(0, maxChars) + `\n\n[... content truncated at ${maxChars} chars ...]`;
truncated = true;
}
return {
url,
content: text,
content,
success: true,
method,
length: text.length,
length: content.length,
original_length: text.length,
truncated,
extract_mode: extractMode ?? 'text',
};
}
+7 -1
View File
@@ -97,12 +97,15 @@ export class ToolRegistry {
const startTs = Date.now();
const timeoutMs = tool.definition.timeoutMs;
// M-15 修复: 使用 try/finally 清理 setTimeout,防止事件循环 timer 堆积
// 工具正常完成时未触发的 timer 会持续占用事件循环 timeoutMs 毫秒
let timer: ReturnType<typeof setTimeout> | undefined;
try {
// 带超时执行 — 使用 Promise.race 防止工具卡死阻塞 Agent Loop
const result = await Promise.race([
tool.execute(toolCall.args, context),
new Promise<never>((_, reject) => {
setTimeout(
timer = setTimeout(
() => reject(new Error(`Tool execution timed out after ${timeoutMs}ms`)),
timeoutMs,
);
@@ -130,6 +133,9 @@ export class ToolRegistry {
durationMs: Date.now() - startTs,
timestamp: Date.now(),
};
} finally {
// M-15 修复: 无论工具成功或失败,清理 timeout timer
if (timer) clearTimeout(timer);
}
}
+52 -9
View File
@@ -3,11 +3,37 @@
*
* 所有 LLM Provider 适配器必须实现此接口。
*
* H-2 修复: 接口对齐规范
* @see docs/MetonaAI-Desktop 内部API请求与响应标准.html
* - provider → providerId(规范要求)
* - chat → send(规范要求)
* - chatStream → sendStream(规范要求)
* - 新增 getContextWindow()(规范要求)
* - listModels 返回 MetonaModelInfo[](规范要求)
*/
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from './index';
/**
* H-2 修复: 新增 MetonaModelInfo 类型 — 规范要求的模型元信息
*/
export interface MetonaModelInfo {
/** 模型 ID(如 'deepseek-v4-pro' */
id: string;
/** 模型显示名称(如 'DeepSeek V4 Pro' */
name?: string;
/** 上下文窗口大小(token 数) */
contextWindow?: number;
/** 最大输出 token 数 */
maxOutputTokens?: number;
/** 是否支持 Tool Calling */
supportsToolCalling?: boolean;
/** 是否支持 Thinking / Reasoning */
supportsThinking?: boolean;
/** 模型描述 */
description?: string;
}
/**
* Provider 适配器配置
*/
@@ -37,10 +63,10 @@ export interface AdapterConfig {
* 铁律:外部 API 原始类型不得穿透到此接口之外
*/
export interface IMetonaProviderAdapter {
/** Provider 标识 */
readonly provider: string;
/** H-2 修复: Provider 标识(规范要求使用 providerId */
readonly providerId: string;
/** 支持的模型列表 */
/** 支持的模型列表(保留作为便捷访问) */
readonly supportedModels: string[];
/** 是否支持 Tool Calling */
@@ -50,15 +76,32 @@ export interface IMetonaProviderAdapter {
readonly supportsThinking: boolean;
/**
* 发送非流式请求
* H-2 修复: 获取上下文窗口大小(token 数)
* 规范要求的方法,用于 Engine 估算上下文使用率
* @returns 上下文窗口大小(token 数)
*/
chat(request: MetonaRequest): Promise<MetonaResponse>;
getContextWindow(): number;
/**
* 发送流式请求
* H-2 修复: 发送流式请求(规范要求使用 send
*/
send(request: MetonaRequest): Promise<MetonaResponse>;
/**
* H-2 修复: 发送流式请求(规范要求使用 sendStream
* @returns AsyncIterable 逐事件推送 MetonaStreamEvent
*/
chatStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent>;
sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent>;
/**
* C-2 修复: 注入外部 AbortSignal,用于关联 Engine 的 abortController
*
* Engine 在调用 send/sendStream 前注入 signal,使用户 abort 能中断正在进行的 fetch
* 避免资源泄漏。Adapter 应将此 signal 与自身的 timeout signal 合并。
*
* @see project_memory.md — engine must use AbortController to avoid resource leaks on abort()
*/
setAbortSignal?(signal: AbortSignal | undefined): void;
/**
* 检查 Provider 可用性
@@ -66,7 +109,7 @@ export interface IMetonaProviderAdapter {
healthCheck(): Promise<boolean>;
/**
* 列出可用模型(可选
* H-2 修复: 列出可用模型(规范要求返回 MetonaModelInfo[]
*/
listModels?(): Promise<string[]>;
listModels?(): Promise<MetonaModelInfo[]>;
}
+7 -2
View File
@@ -73,8 +73,13 @@ export interface MetonaConstraints {
export interface MetonaMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
/** 文本内容(纯文本或 Markdown) */
content: string;
/**
* 文本内容(纯文本或 Markdown)
*
* C-6 修复: 允许 null — assistant 消息仅有 tool_calls 时 content 必须为 null
* @see project_memory.md — Assistant messages with tool_calls must set content to null
*/
content: string | null;
/** (仅 assistant)思考/推理内容 */
reasoningContent?: string;
+10
View File
@@ -79,6 +79,11 @@ export interface MetonaResponse {
// ===== 流式事件 =====
export enum MetonaStreamEventType {
// H-1 修复: 补齐 THINKING_START / THINKING_END — 用于显式标记思考阶段的边界
// 规范来源: docs/MetonaAI-Desktop 内部API请求与响应标准.html
// 时序: THINKING_START → REASONING_DELTA* → THINKING_END → TEXT_DELTA*
THINKING_START = 'thinking_start',
THINKING_END = 'thinking_end',
TEXT_DELTA = 'text_delta',
REASONING_DELTA = 'reasoning_delta',
TOOL_CALL_DELTA = 'tool_call_delta',
@@ -188,4 +193,9 @@ export enum MetonaErrorCode {
USER_ABORTED = 'user_aborted',
TIMEOUT = 'timeout',
UNKNOWN = 'unknown',
// H-11 修复: Engine 内部使用的伪错误码,用于重试时通知前端清空已累积的 delta 缓冲区
// 注意:这不是真正的错误,仅作为 ERROR 事件的特殊标记,engine 会过滤此事件不转发到 UI
// 之前使用 'RETRY' as never 绕过类型检查,现在改为正式枚举值以确保类型安全
RETRY = 'retry',
}
+17 -8
View File
@@ -17,12 +17,21 @@
// 中日韩统一表意文字 + 全角标点 + 日文假名 + 韩文谚文
const CJK_REGEX = /[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/;
/**
* L-17 修复: 提取魔法系数为命名常量,便于统一调整
* @see project_memory.md — Token estimation coefficients
*/
const CJK_TOKEN_RATIO = 1.5; // 中文字符(含全角标点、日韩文):1 字符 ≈ 1.5 token
const ASCII_TOKEN_RATIO = 0.25; // ASCII 字符(英文、数字、半角符号):4 字符 ≈ 1 token
const OTHER_TOKEN_RATIO = 1; // 其他 Unicodeemoji 等):1 字符 ≈ 1 token
const MSG_OVERHEAD_TOKENS = 4; // 每条消息的结构性开销(role、分隔符,参考 OpenAI 规范)
/**
* 估算字符串的 token 数
* @param text 待估算的字符串
* @param text 待估算的字符串(可为 null/undefined,视为 0 token
* @returns 估算的 token 数
*/
export function estimateStringTokens(text: string): number {
export function estimateStringTokens(text: string | null | undefined): number {
if (!text || text.length === 0) return 0;
let cjkCount = 0;
@@ -39,8 +48,8 @@ export function estimateStringTokens(text: string): number {
}
}
// 中文字符 1.5 token/字,ASCII 0.25 token/字,其他 1 token/
return Math.ceil(cjkCount * 1.5 + asciiCount * 0.25 + otherCount);
// L-17 修复: 使用命名常量替代魔法数
return Math.ceil(cjkCount * CJK_TOKEN_RATIO + asciiCount * ASCII_TOKEN_RATIO + otherCount * OTHER_TOKEN_RATIO);
}
/**
@@ -48,11 +57,11 @@ export function estimateStringTokens(text: string): number {
*
* 每条消息额外加 4 token 的结构性开销(role、分隔符等,参考 OpenAI 规范)
*
* @param messages 消息列表
* @param messages 消息列表content 可为 null,对应仅有 tool_calls 的 assistant 消息)
* @returns 估算的 token 数
*/
export function estimateMessagesTokens(messages: Array<{
content: string;
content: string | null;
reasoningContent?: string;
toolCalls?: Array<{ args: Record<string, unknown> }>;
}>): number {
@@ -65,8 +74,8 @@ export function estimateMessagesTokens(messages: Array<{
total += estimateStringTokens(JSON.stringify(tc.args));
}
}
// 每条消息的结构性开销(role、分隔符)
total += 4;
// L-17 修复: 使用命名常量替代魔法数字
total += MSG_OVERHEAD_TOKENS;
}
return total;
}
+401 -51
View File
@@ -17,14 +17,15 @@ import type { WorkspaceService } from '../services/workspace.service';
import type { ContextBuilder } from '../harness/prompts/context-builder';
import type { AgentLoopEngine } from '../harness/agent-loop';
import type { ToolRegistry } from '../harness/tools/registry';
import type { AuditService } from '../services/audit.service';
import type { AuditService, AuditEventType } from '../services/audit.service';
import type { SessionRecorder } from '../services/session-recorder.service';
import type { MemoryManager } from '../harness/memory/manager';
import type { MemoryManager, MemoryType } from '../harness/memory/manager';
import type { MCPManager } from '../services/mcp-manager.service';
import type { PromptInjectionDefender } from '../harness/security/prompt-injection-defense';
import type { OutputValidator } from '../harness/verification/output-validator';
import type { ConfirmationHook } from '../harness/hooks/confirmation-hook';
import type { MemoryConsolidator } from '../harness/memory/consolidator';
import type { TaskOrchestrator } from '../harness/orchestration/orchestrator';
import type { MetonaMessage, MetonaStreamEvent } from '../harness/types';
import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types';
import type { MetonaError } from '../harness/types';
@@ -51,12 +52,22 @@ export function registerAllIPCHandlers(
outputValidator: OutputValidator,
confirmationHook: ConfirmationHook,
memoryConsolidator: MemoryConsolidator,
orchestrator: TaskOrchestrator,
): void {
// ===== Agent 交互 =====
ipcMain.handle('agent:sendMessage', async (_event, userMessage: MetonaMessage, sessionId: string) => {
log.info('[AGENT] sendMessage:', sessionId, userMessage.content.slice(0, 80));
// M-33 修复: 参数校验,防止 undefined/非字符串导致下游异常
if (!sessionId || typeof sessionId !== 'string') {
log.warn('[AGENT] sendMessage rejected: invalid sessionId');
return { success: false, error: 'Invalid sessionId' };
}
if (!userMessage || typeof userMessage !== 'object' || typeof userMessage.content !== 'string') {
log.warn('[AGENT] sendMessage rejected: invalid userMessage');
return { success: false, error: 'Invalid message format' };
}
log.info('[AGENT] sendMessage:', sessionId, (userMessage.content ?? '').slice(0, 80));
// 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送)
if (!reloadAdapter()) {
@@ -258,10 +269,16 @@ export function registerAllIPCHandlers(
// 只有当有内容、思考内容或工具调用时才保存
if (step.thought.content || step.thought.reasoningContent || toolCallsWithResults?.length) {
// C-6 修复: assistant 消息仅有 tool_calls 时 content 必须为 null(而非空字符串)
// @see project_memory.md — Assistant messages with tool_calls must set content to null
// 空字符串会导致 LLM API 返回 400 错误,必须使用 null
const assistantContent = (toolCallsWithResults?.length && !step.thought.content)
? null
: step.thought.content;
sessionService.saveMessage({
sessionId,
role: 'assistant',
content: step.thought.content,
content: assistantContent,
reasoningContent: step.thought.reasoningContent || undefined,
toolCalls: toolCallsWithResults,
iteration: step.iteration,
@@ -413,44 +430,81 @@ export function registerAllIPCHandlers(
return sessionService.create(title);
});
ipcMain.handle('sessions:rename', async (_event, sessionId, title) => {
// M-34 修复: 统一 sessionId 校验辅助函数
const isValidSessionId = (id: unknown): id is string =>
typeof id === 'string' && id.length > 0 && id.length <= 200;
ipcMain.handle('sessions:rename', async (_event, sessionId: unknown, title: unknown) => {
// M-34 修复: 校验 sessionId 和 title
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
if (typeof title !== 'string' || !title.trim()) return { success: false, error: 'Invalid title' };
return { success: sessionService.rename(sessionId, title) };
});
ipcMain.handle('sessions:delete', async (_event, sessionId) => {
ipcMain.handle('sessions:delete', async (_event, sessionId: unknown) => {
// M-34 修复: 校验 sessionId
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
return { success: sessionService.delete(sessionId) };
});
ipcMain.handle('sessions:getMessages', async (_event, sessionId) => {
ipcMain.handle('sessions:getMessages', async (_event, sessionId: unknown) => {
// M-34 修复: 校验 sessionId
if (!isValidSessionId(sessionId)) return [];
return sessionService.getMessages(sessionId);
});
ipcMain.handle('sessions:pin', async (_event, sessionId, pinned) => {
ipcMain.handle('sessions:pin', async (_event, sessionId: unknown, pinned: unknown) => {
// M-34 修复: 校验 sessionId 和 pinned
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
if (typeof pinned !== 'boolean') return { success: false, error: 'Invalid pinned flag' };
return { success: sessionService.pin(sessionId, pinned) };
});
ipcMain.handle('sessions:archive', async (_event, sessionId, archived) => {
ipcMain.handle('sessions:archive', async (_event, sessionId: unknown, archived: unknown) => {
// M-34 修复: 校验 sessionId 和 archived
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
if (typeof archived !== 'boolean') return { success: false, error: 'Invalid archived flag' };
return { success: sessionService.archive(sessionId, archived) };
});
ipcMain.handle('sessions:deleteMessage', async (_event, messageId) => {
ipcMain.handle('sessions:deleteMessage', async (_event, messageId: unknown) => {
// M-34 修复: 校验 messageId
if (typeof messageId !== 'string' || !messageId) return { success: false, error: 'Invalid messageId' };
return { success: sessionService.deleteMessage(messageId) };
});
ipcMain.handle('sessions:clearMessages', async (_event, sessionId) => {
ipcMain.handle('sessions:clearMessages', async (_event, sessionId: unknown) => {
// M-34 修复: 校验 sessionId
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
return { success: sessionService.clearMessages(sessionId) };
});
ipcMain.handle('sessions:saveTrace', async (_event, sessionId, data) => {
ipcMain.handle('sessions:saveTrace', async (_event, sessionId: unknown, data: unknown) => {
// M-34 修复: 校验 sessionId 和 data
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
// 严格校验 data 结构:traceSteps 必须为数组,tokenUsage 必须存在
if (!data || typeof data !== 'object') return { success: false, error: 'Invalid trace data' };
const traceData = data as { traceSteps?: unknown; tokenUsage?: unknown };
if (!Array.isArray(traceData.traceSteps)) {
return { success: false, error: 'Invalid trace data: traceSteps must be an array' };
}
if (traceData.tokenUsage === undefined) {
return { success: false, error: 'Invalid trace data: tokenUsage is required' };
}
try {
sessionService.saveTraceData(sessionId, data);
sessionService.saveTraceData(sessionId, {
traceSteps: traceData.traceSteps,
tokenUsage: traceData.tokenUsage,
});
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('sessions:getTrace', async (_event, sessionId) => {
ipcMain.handle('sessions:getTrace', async (_event, sessionId: unknown) => {
// M-34 修复: 校验 sessionId
if (!isValidSessionId(sessionId)) return null;
return sessionService.getTraceData(sessionId);
});
@@ -461,10 +515,34 @@ export function registerAllIPCHandlers(
});
ipcMain.handle('mcp:addServer', async (_event, config: { name: string; transport: string; command?: string; args?: string[]; url?: string }) => {
// M-38 修复: 完整参数校验,防止字段缺失或类型不符导致异常行为
if (!config || typeof config !== 'object') {
return { success: false, error: 'Invalid config' };
}
if (typeof config.name !== 'string' || !config.name.trim()) {
return { success: false, error: 'Server name is required' };
}
// M-5 修复: transport 运行时校验(替代 as 'stdio' | 'sse' 断言)
if (config.transport !== 'stdio' && config.transport !== 'sse') {
return { success: false, error: `Invalid transport: ${config.transport}. Must be 'stdio' or 'sse'` };
}
// stdio 类型必须有 command
if (config.transport === 'stdio' && (typeof config.command !== 'string' || !config.command.trim())) {
return { success: false, error: 'command is required for stdio transport' };
}
// sse 类型必须有合法 url
if (config.transport === 'sse') {
if (typeof config.url !== 'string' || !config.url.trim()) {
return { success: false, error: 'url is required for sse transport' };
}
try { new URL(config.url); } catch {
return { success: false, error: 'Invalid url format' };
}
}
try {
await mcpManager.addServer({
name: config.name,
transport: config.transport as 'stdio' | 'sse',
transport: config.transport, // 已校验,无需断言
command: config.command,
args: config.args,
url: config.url,
@@ -478,6 +556,10 @@ export function registerAllIPCHandlers(
});
ipcMain.handle('mcp:removeServer', async (_event, name: string) => {
// M-38 修复: name 校验
if (typeof name !== 'string' || !name.trim()) {
return { success: false, error: 'Invalid server name' };
}
try {
await mcpManager.removeServer(name);
return { success: true };
@@ -487,6 +569,13 @@ export function registerAllIPCHandlers(
});
ipcMain.handle('mcp:toggleServer', async (_event, name: string, enabled: boolean) => {
// M-38 修复: name 和 enabled 校验
if (typeof name !== 'string' || !name.trim()) {
return { success: false, error: 'Invalid server name' };
}
if (typeof enabled !== 'boolean') {
return { success: false, error: 'Invalid enabled flag' };
}
try {
await mcpManager.toggleServer(name, enabled);
return { success: true };
@@ -497,8 +586,35 @@ export function registerAllIPCHandlers(
// ===== 记忆 =====
ipcMain.handle('db:searchMemories', async (_event, query, options) => {
return memoryManager.search(query, options);
ipcMain.handle('db:searchMemories', async (_event, query: unknown, options: unknown) => {
// M-37 修复: query 和 options 校验
if (typeof query !== 'string' || !query.trim()) {
return [];
}
// 构造合法的搜索选项(仅保留已知字段,强制类型安全)
const VALID_MEMORY_TYPES: readonly MemoryType[] = ['episodic', 'semantic', 'working'];
const searchOptions: { topK?: number; sessionId?: string; type?: MemoryType; minImportance?: number } = {};
if (options && typeof options === 'object') {
const opts = options as Record<string, unknown>;
// topK 限制范围 1-100(防止过大查询)
if (opts.topK !== undefined) {
const topK = Number(opts.topK);
if (Number.isFinite(topK) && topK >= 1 && topK <= 100) {
searchOptions.topK = topK;
} else {
searchOptions.topK = 10; // 默认值
}
}
if (typeof opts.sessionId === 'string') searchOptions.sessionId = opts.sessionId;
// type 必须是合法的 MemoryType 枚举值
if (typeof opts.type === 'string' && (VALID_MEMORY_TYPES as readonly string[]).includes(opts.type)) {
searchOptions.type = opts.type as MemoryType;
}
if (typeof opts.minImportance === 'number' && Number.isFinite(opts.minImportance)) {
searchOptions.minImportance = opts.minImportance;
}
}
return memoryManager.search(query, searchOptions);
});
// ===== 配置 =====
@@ -507,23 +623,58 @@ export function registerAllIPCHandlers(
return configService.get(key);
});
ipcMain.handle('config:set', async (_event, key, value) => {
ipcMain.handle('config:set', async (_event, key: unknown, value: unknown) => {
// M-42 修复: 参数校验 + 敏感数据脱敏
if (typeof key !== 'string' || !key.trim()) {
return { success: false, error: 'Invalid config key' };
}
// value 必须是可序列化的基本类型(string|number|boolean|null
if (value !== null && typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
return { success: false, error: 'Invalid config value: must be string, number, boolean, or null' };
}
// C-1 修复: Provider 切换时清空 API key,防止使用不兼容的 key 导致 401 错误
// @see project_memory.md — When switching LLM providers, the API key must be cleared
if (key === 'llm.provider') {
const oldProvider = configService.get<string>('llm.provider') ?? '';
const newProvider = (value as string) ?? '';
if (oldProvider && newProvider && oldProvider !== newProvider) {
configService.set('llm.apiKey', '');
log.info(`[CONFIG] Provider changed (${oldProvider}${newProvider}), API key cleared to prevent incompatible key usage`);
}
}
configService.set(key, value);
// M-42 修复: 敏感配置项脱敏后再写入审计日志,防止 API Key 明文泄露
// 敏感 key 包括 llm.apiKey、llm.fallbackApiKey、agnes.apiKey 等
// 审计补充修复: 短值(length <= 4)时 slice(-4) 会返回整个 value 导致脱敏失效,
// 改为:长值保留后 4 位核对,短值完全掩码为 '***'
const SENSITIVE_KEY_PATTERNS = ['apikey', 'api_key', 'apitoken', 'token', 'secret', 'password'];
const isSensitiveKey = SENSITIVE_KEY_PATTERNS.some(p => key.toLowerCase().includes(p));
const auditValue = isSensitiveKey && typeof value === 'string' && value.length > 0
? (value.length > 4 ? '***' + value.slice(-4) : '***')
: value;
// TOOL 层:记录配置变更
auditService.log({
sessionId: '',
eventType: 'config_change',
actor: 'user',
target: key,
details: { value },
details: { value: auditValue },
outcome: 'success',
});
// LLM 相关配置变更时热重载 Adapter
if (['llm.provider', 'llm.model', 'llm.apiKey', 'llm.baseURL', 'ollama.numCtx'].includes(key)) {
reloadAdapter();
log.info(`[CONFIG] LLM config changed (${key}), adapter reloaded`);
// C-1 修复: reloadAdapter 返回 false 时表示加载失败,需要通知前端
const reloadSuccess = reloadAdapter();
if (!reloadSuccess) {
log.warn(`[CONFIG] Adapter reload failed after ${key} change`);
} else {
log.info(`[CONFIG] LLM config changed (${key}), adapter reloaded`);
}
}
// Agent 配置变更时热更新 Engine
@@ -535,13 +686,17 @@ export function registerAllIPCHandlers(
log.info(`[CONFIG] Agent totalTimeoutMs updated to ${value}`);
} else if (key === 'agent.enableThinking') {
agentLoop.updateConfig({ thinkingEnabled: value as boolean });
// L-18 修复: 同步更新 orchestrator.defaultConfig,确保新建 SubAgent 使用最新配置
orchestrator.updateDefaultConfig({ thinkingEnabled: value as boolean });
log.info(`[CONFIG] Agent thinkingEnabled updated to ${value}`);
} else if (key === 'agent.thinkingEffort') {
agentLoop.updateConfig({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max' });
orchestrator.updateDefaultConfig({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max' });
log.info(`[CONFIG] Agent thinkingEffort updated to ${value}`);
} else if (key === 'ollama.numCtx') {
// numCtx 同时更新 Engine 的 contextLength
agentLoop.updateConfig({ contextLength: (value as number) || undefined });
orchestrator.updateDefaultConfig({ contextLength: (value as number) || undefined });
log.info(`[CONFIG] Agent contextLength updated to ${value}`);
} else if (key === 'agent.confirmationTimeoutMs') {
// 工具确认超时变更,即时应用到 ConfirmationHook
@@ -585,12 +740,38 @@ export function registerAllIPCHandlers(
return app.getPath('userData');
});
ipcMain.handle('app:openExternal', async (_event, url) => {
await shell.openExternal(url);
ipcMain.handle('app:openExternal', async (_event, url: unknown) => {
// M-12 修复: URL 协议白名单校验,防止打开 file:///smb:// 等危险协议
// @see main.ts setWindowOpenHandler — 两处安全策略保持一致
if (typeof url !== 'string' || !url) {
return { success: false, error: 'Invalid URL' };
}
try {
const parsed = new URL(url);
const ALLOWED_PROTOCOLS = ['http:', 'https:', 'mailto:'];
if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) {
log.warn(`[IPC] openExternal blocked: protocol "${parsed.protocol}" not in whitelist`);
return { success: false, error: `Protocol not allowed: ${parsed.protocol}` };
}
await shell.openExternal(url);
return { success: true };
} catch (error) {
log.warn('[IPC] openExternal failed:', (error as Error).message);
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('app:showItemInFolder', async (_event, path) => {
shell.showItemInFolder(path);
ipcMain.handle('app:showItemInFolder', async (_event, path: unknown) => {
// M-36 修复: path 类型校验,防止非字符串参数
if (typeof path !== 'string' || !path) {
return { success: false, error: 'Invalid path' };
}
try {
shell.showItemInFolder(path);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('app:selectFolder', async (_event, defaultPath?: string) => {
@@ -793,6 +974,16 @@ export function registerAllIPCHandlers(
});
ipcMain.handle('tools:toggle', async (_event, toolName: string, enabled: boolean) => {
// M-35 修复: 校验 toolName 合法性,防止配置 key 污染
if (typeof toolName !== 'string' || !toolName || typeof enabled !== 'boolean') {
return { success: false, error: 'Invalid parameters' };
}
// 校验 toolName 在已注册工具列表中(防止写入任意配置 key)
const allTools = toolRegistry.listAllTools();
if (!allTools.some((t) => t.name === toolName)) {
log.warn(`[IPC] tools:toggle rejected: unknown tool "${toolName}"`);
return { success: false, error: `Unknown tool: ${toolName}` };
}
// 工具开关通过配置持久化
configService.set(`tools.${toolName}.enabled`, enabled);
// 同步到 ToolRegistry(立即生效)
@@ -828,29 +1019,45 @@ export function registerAllIPCHandlers(
});
ipcMain.handle('data:clearSessions', async () => {
// M-40 修复: 危险操作审计日志,便于追踪异常调用
log.warn('[DATA] DANGER: clearSessions invoked — all sessions and messages will be deleted');
const db = sessionService.getDB();
try {
db.exec('BEGIN');
const msgCount = db.prepare('SELECT COUNT(*) as c FROM messages').get() as { c: number };
const sessCount = db.prepare('SELECT COUNT(*) as c FROM sessions').get() as { c: number };
db.exec('DELETE FROM messages');
db.exec('DELETE FROM sessions');
db.exec('COMMIT');
log.info('[DATA] All sessions cleared');
return { success: true };
log.info(`[DATA] All sessions cleared: ${sessCount.c} sessions, ${msgCount.c} messages deleted`);
return { success: true, deletedSessions: sessCount.c, deletedMessages: msgCount.c };
} catch (error) {
try { db.exec('ROLLBACK'); } catch { /* 忽略回滚错误 */ }
log.error('[DATA] clearSessions failed:', (error as Error).message);
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('data:clearMemories', async () => {
// M-40 修复: 危险操作审计日志,便于追踪异常调用
log.warn('[DATA] DANGER: clearMemories invoked — all memories will be deleted');
const db = sessionService.getDB();
try {
const db = sessionService.getDB();
// M-41 修复: 三个 DELETE 操作用事务包裹,防止部分失败导致三类记忆数据不一致
const epiCount = db.prepare('SELECT COUNT(*) as c FROM episodic_memories').get() as { c: number };
const semCount = db.prepare('SELECT COUNT(*) as c FROM semantic_memories').get() as { c: number };
const workCount = db.prepare('SELECT COUNT(*) as c FROM working_memories').get() as { c: number };
db.exec('BEGIN');
db.exec('DELETE FROM episodic_memories');
db.exec('DELETE FROM semantic_memories');
db.exec('DELETE FROM working_memories');
log.info('[DATA] All memories cleared');
return { success: true };
db.exec('COMMIT');
log.info(`[DATA] All memories cleared: ${epiCount.c} episodic, ${semCount.c} semantic, ${workCount.c} working memories deleted`);
return { success: true, deletedEpisodic: epiCount.c, deletedSemantic: semCount.c, deletedWorking: workCount.c };
} catch (error) {
// M-41 修复: 失败时回滚事务,确保数据一致性
try { db.exec('ROLLBACK'); } catch { /* 忽略回滚错误 */ }
log.error('[DATA] clearMemories failed:', (error as Error).message);
return { success: false, error: (error as Error).message };
}
});
@@ -915,13 +1122,42 @@ export function registerAllIPCHandlers(
// ===== v0.2.0: 工具确认响应(ConfirmationDialog → 主进程)=====
// 使用 ipcMain.on(渲染进程通过 ipcRenderer.send 发送)
ipcMain.on('tool:confirmationResponse', (_event, data: { toolCallId: string; approved: boolean; remember: boolean; autoExecute?: boolean }) => {
confirmationHook.resolveConfirmation(data.toolCallId, data.approved, data.remember, data.autoExecute ?? false);
log.info(`[CONFIRM] Tool ${data.toolCallId} ${data.approved ? 'approved' : 'denied'}${data.remember ? ' (remembered)' : ''}${data.autoExecute ? ' (autoExecute)' : ''}`);
ipcMain.on('tool:confirmationResponse', (_event, data: unknown) => {
// M-43 修复: 校验 data 结构,防止 undefined/null 导致 TypeError
if (!data || typeof data !== 'object') {
log.warn('[IPC] tool:confirmationResponse rejected: invalid data');
return;
}
const req = data as { toolCallId?: unknown; approved?: unknown; remember?: unknown; autoExecute?: unknown };
if (typeof req.toolCallId !== 'string' || !req.toolCallId) {
log.warn('[IPC] tool:confirmationResponse rejected: invalid toolCallId');
return;
}
if (typeof req.approved !== 'boolean') {
log.warn('[IPC] tool:confirmationResponse rejected: invalid approved');
return;
}
const remember = typeof req.remember === 'boolean' ? req.remember : false;
const autoExecute = typeof req.autoExecute === 'boolean' ? req.autoExecute : false;
confirmationHook.resolveConfirmation(req.toolCallId, req.approved, remember, autoExecute);
log.info(`[CONFIRM] Tool ${req.toolCallId} ${req.approved ? 'approved' : 'denied'}${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`);
});
// ===== v0.2.0: 持久化自动执行设置 =====
ipcMain.handle('tool:setAutoExecute', async (_event, toolName: string, enabled: boolean) => {
ipcMain.handle('tool:setAutoExecute', async (_event, toolName: unknown, enabled: unknown) => {
// M-44 修复: 校验 toolName 合法性和 enabled 类型,防止配置 key 污染
if (typeof toolName !== 'string' || !toolName) {
return { success: false, error: 'Invalid toolName' };
}
if (typeof enabled !== 'boolean') {
return { success: false, error: 'Invalid enabled (must be boolean)' };
}
// 校验 toolName 在已注册工具列表中(与 tools:toggle 保持一致)
const allTools = toolRegistry.listAllTools();
if (!allTools.some((t) => t.name === toolName)) {
log.warn(`[IPC] tool:setAutoExecute rejected: unknown tool "${toolName}"`);
return { success: false, error: `Unknown tool: ${toolName}` };
}
try {
confirmationHook.setAutoExecute(toolName, enabled);
log.info(`[CONFIRM] Tool ${toolName} autoExecute set to ${enabled}`);
@@ -947,16 +1183,49 @@ export function registerAllIPCHandlers(
}
});
ipcMain.handle('audit:query', async (_event, filters?: { sessionId?: string; eventType?: string; limit?: number }) => {
ipcMain.handle('audit:query', async (_event, filters?: unknown) => {
// M-45 修复: 校验 filters 参数类型和范围
const VALID_AUDIT_EVENT_TYPES: readonly AuditEventType[] = [
'tool_call', 'permission_check', 'error', 'llm_request',
'llm_response', 'session_start', 'session_end', 'config_change',
];
let safeFilters: { sessionId?: string; eventType?: AuditEventType; limit?: number } = {};
if (filters && typeof filters === 'object') {
const f = filters as Record<string, unknown>;
if (typeof f.sessionId === 'string' && f.sessionId) safeFilters.sessionId = f.sessionId;
if (typeof f.eventType === 'string' && f.eventType) {
// 校验 eventType 是否在合法枚举内
if (!(VALID_AUDIT_EVENT_TYPES as readonly string[]).includes(f.eventType)) {
return { success: false, error: `Invalid eventType (must be one of: ${VALID_AUDIT_EVENT_TYPES.join(', ')})` };
}
safeFilters.eventType = f.eventType as AuditEventType;
}
if (f.limit !== undefined) {
const limit = Number(f.limit);
// 审计补充修复: limit 越界时返回 error(与 memory:listAll 策略一致,原为静默降级为 100)
// 限制 1-1000 范围,防止过大查询拖慢性能
if (!Number.isFinite(limit) || limit < 1 || limit > 1000) {
return { success: false, error: 'Invalid limit (must be 1-1000)' };
}
safeFilters.limit = Math.floor(limit);
}
}
try {
return { success: true, data: auditService.query(filters as Parameters<typeof auditService.query>[0]) };
return { success: true, data: auditService.query(safeFilters) };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// ===== v0.2.0: 任务管理 IPCTaskList UI=====
ipcMain.handle('tasks:list', async (_event, sessionId?: string) => {
const VALID_TASK_PRIORITIES: readonly string[] = ['low', 'medium', 'high', 'critical'];
const VALID_TASK_STATUSES: readonly string[] = ['pending', 'in_progress', 'completed', 'blocked', 'cancelled'];
ipcMain.handle('tasks:list', async (_event, sessionId?: unknown) => {
// M-46 修复: 校验 sessionId 类型(可选参数)
if (sessionId !== undefined && (typeof sessionId !== 'string' || !sessionId)) {
return { success: false, error: 'Invalid sessionId' };
}
const db = sessionService.getDB();
let sql = 'SELECT * FROM tasks';
const params: unknown[] = [];
@@ -968,33 +1237,83 @@ export function registerAllIPCHandlers(
return { success: true, data: db.prepare(sql).all(...params) };
});
ipcMain.handle('tasks:create', async (_event, data: { sessionId: string; title: string; description?: string; priority?: string; parentId?: string }) => {
ipcMain.handle('tasks:create', async (_event, data: unknown) => {
// M-46 修复: 校验 data 结构和字段类型/枚举
if (!data || typeof data !== 'object') {
return { success: false, error: 'Invalid task data' };
}
const req = data as Record<string, unknown>;
if (typeof req.sessionId !== 'string' || !req.sessionId.trim()) {
return { success: false, error: 'Invalid sessionId' };
}
if (typeof req.title !== 'string' || !req.title.trim()) {
return { success: false, error: 'Invalid title' };
}
if (req.priority !== undefined && !VALID_TASK_PRIORITIES.includes(req.priority as string)) {
return { success: false, error: `Invalid priority (must be one of: ${VALID_TASK_PRIORITIES.join(', ')})` };
}
if (req.parentId !== undefined && req.parentId !== null && typeof req.parentId !== 'string') {
return { success: false, error: 'Invalid parentId' };
}
const db = sessionService.getDB();
const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
try {
db.prepare(`
INSERT INTO tasks (id, session_id, title, description, status, priority, parent_id, created_at, updated_at)
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?)
`).run(id, data.sessionId, data.title, data.description ?? '', data.priority ?? 'medium', data.parentId ?? null, Date.now(), Date.now());
`).run(
id,
req.sessionId,
req.title,
typeof req.description === 'string' ? req.description : '',
(req.priority as string) ?? 'medium',
(req.parentId as string | null) ?? null,
Date.now(),
Date.now(),
);
return { success: true, id };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('tasks:update', async (_event, id: string, updates: { title?: string; description?: string; status?: string; priority?: string; assignedTo?: string }) => {
ipcMain.handle('tasks:update', async (_event, id: unknown, updates: unknown) => {
// M-47 修复: 校验 id 和 updates 结构/枚举
if (typeof id !== 'string' || !id) {
return { success: false, error: 'Invalid task id' };
}
if (!updates || typeof updates !== 'object') {
return { success: false, error: 'Invalid updates' };
}
const u = updates as Record<string, unknown>;
if (u.status !== undefined && !VALID_TASK_STATUSES.includes(u.status as string)) {
return { success: false, error: `Invalid status (must be one of: ${VALID_TASK_STATUSES.join(', ')})` };
}
if (u.priority !== undefined && !VALID_TASK_PRIORITIES.includes(u.priority as string)) {
return { success: false, error: `Invalid priority (must be one of: ${VALID_TASK_PRIORITIES.join(', ')})` };
}
if (u.assignedTo !== undefined && u.assignedTo !== null && typeof u.assignedTo !== 'string') {
return { success: false, error: 'Invalid assignedTo' };
}
// 审计补充修复: 补全 title/description 类型校验(与 tasks:create 的严格校验一致)
if (u.title !== undefined && typeof u.title !== 'string') {
return { success: false, error: 'Invalid title (must be string)' };
}
if (u.description !== undefined && typeof u.description !== 'string') {
return { success: false, error: 'Invalid description (must be string)' };
}
const db = sessionService.getDB();
try {
const fields: string[] = [];
const values: unknown[] = [];
if (updates.title !== undefined) { fields.push('title = ?'); values.push(updates.title); }
if (updates.description !== undefined) { fields.push('description = ?'); values.push(updates.description); }
if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); }
if (updates.priority !== undefined) { fields.push('priority = ?'); values.push(updates.priority); }
if (updates.assignedTo !== undefined) { fields.push('assigned_to = ?'); values.push(updates.assignedTo); }
if (u.title !== undefined) { fields.push('title = ?'); values.push(u.title); }
if (u.description !== undefined) { fields.push('description = ?'); values.push(u.description); }
if (u.status !== undefined) { fields.push('status = ?'); values.push(u.status); }
if (u.priority !== undefined) { fields.push('priority = ?'); values.push(u.priority); }
if (u.assignedTo !== undefined) { fields.push('assigned_to = ?'); values.push(u.assignedTo); }
if (fields.length === 0) return { success: true };
fields.push('updated_at = ?'); values.push(Date.now());
if (updates.status === 'completed') { fields.push('completed_at = ?'); values.push(Date.now()); }
if (u.status === 'completed') { fields.push('completed_at = ?'); values.push(Date.now()); }
values.push(id);
db.prepare(`UPDATE tasks SET ${fields.join(', ')} WHERE id = ?`).run(...values);
return { success: true };
@@ -1003,7 +1322,11 @@ export function registerAllIPCHandlers(
}
});
ipcMain.handle('tasks:delete', async (_event, id: string) => {
ipcMain.handle('tasks:delete', async (_event, id: unknown) => {
// M-48 修复: 校验 id 类型
if (typeof id !== 'string' || !id) {
return { success: false, error: 'Invalid task id' };
}
const db = sessionService.getDB();
try {
db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
@@ -1014,10 +1337,30 @@ export function registerAllIPCHandlers(
});
// ===== v0.2.0: 记忆系统增强查询(Memory Viewer UI=====
ipcMain.handle('memory:listAll', async (_event, options?: { type?: string; limit?: number }) => {
const VALID_MEMORY_TYPES_LIST: readonly string[] = ['episodic', 'semantic', 'working'];
ipcMain.handle('memory:listAll', async (_event, options?: unknown) => {
// M-49 修复: 校验 options.type 枚举和 limit 范围
let limit = 100;
let type: string | undefined;
if (options && typeof options === 'object') {
const opts = options as Record<string, unknown>;
if (opts.type !== undefined) {
if (typeof opts.type !== 'string' || !VALID_MEMORY_TYPES_LIST.includes(opts.type)) {
return { success: false, error: `Invalid type (must be one of: ${VALID_MEMORY_TYPES_LIST.join(', ')})` };
}
type = opts.type;
}
if (opts.limit !== undefined) {
const num = Number(opts.limit);
// 限制 1-1000 范围,SQLite 中 LIMIT -1 表示无限制,需阻止
if (!Number.isFinite(num) || num < 1 || num > 1000) {
return { success: false, error: 'Invalid limit (must be 1-1000)' };
}
limit = Math.floor(num);
}
}
const db = sessionService.getDB();
const limit = options?.limit ?? 100;
const type = options?.type;
const results: Record<string, unknown[]> = {};
try {
if (!type || type === 'episodic') {
@@ -1038,7 +1381,14 @@ export function registerAllIPCHandlers(
}
});
ipcMain.handle('memory:delete', async (_event, type: string, id: string) => {
ipcMain.handle('memory:delete', async (_event, type: unknown, id: unknown) => {
// M-50 修复: 校验 type 枚举(防止三元表达式默认映射到 working_memories)和 id 类型
if (typeof type !== 'string' || !VALID_MEMORY_TYPES_LIST.includes(type)) {
return { success: false, error: `Invalid type (must be one of: ${VALID_MEMORY_TYPES_LIST.join(', ')})` };
}
if (typeof id !== 'string' || !id) {
return { success: false, error: 'Invalid memory id' };
}
const db = sessionService.getDB();
try {
const table = type === 'episodic' ? 'episodic_memories' : type === 'semantic' ? 'semantic_memories' : 'working_memories';
+52 -7
View File
@@ -222,7 +222,9 @@ async function initialize(): Promise<void> {
];
// ===== Agent Loop =====
// TODO: Re-read agent config on each runStream call or when config changes
// Agent 配置初始化:仅此处一次性读取,配置变更时由 handlers.ts 的 config:set
// 监听器调用 agentLoop.updateConfig() 和 orchestrator.updateDefaultConfig() 即时生效。
// @see electron/ipc/handlers.ts — 'config:set' handler
const ollamaNumCtx = configService.get<number>('ollama.numCtx');
const agentMaxIter = configService.get<number>('agent.maxIterations');
const agentTimeout = configService.get<number>('agent.totalTimeoutMs');
@@ -375,7 +377,7 @@ async function initialize(): Promise<void> {
contextBuilder, agentLoop, toolRegistry, auditService,
sessionRecorder, memoryManager, mcpManager, reloadAdapter,
promptDefender, outputValidator,
confirmationHook, memoryConsolidator,
confirmationHook, memoryConsolidator, orchestrator,
);
// TODO: Initialize UpdateService for auto-update functionality
@@ -395,16 +397,46 @@ async function initialize(): Promise<void> {
}
});
app.on('before-quit', async () => {
// M-13 修复: before-quit 回调改为同步 + event.preventDefault() 确保异步清理完成
// 之前 async 回调 Electron 不会 await,导致 MCP shutdown 未完成时应用已退出
app.on('before-quit', (event) => {
// 标记为正在退出,允许窗口关闭(两处 isQuitting 统一设置)
(global as Record<string, unknown>).isQuitting = true;
TrayManager.markQuitting();
windowManager?.unregisterGlobalShortcuts();
trayManager?.destroy();
windowManager?.closeAll();
try { await mcpManager.shutdown(); } catch (err) { log.error('[Shutdown] MCP shutdown failed:', err); }
cleanupBrowser();
if (databaseService) { databaseService.close(); databaseService = null; }
// 防止重复触发(macOS before-quit 可能触发多次)
if ((global as Record<string, unknown>).shutdownInProgress === true) {
return;
}
(global as Record<string, unknown>).shutdownInProgress = true;
event.preventDefault();
// 内部异步清理,加 5 秒超时保护防止卡死
const shutdownTimeout = setTimeout(() => {
log.warn('[Shutdown] Timeout reached, forcing exit');
if (databaseService) { databaseService.close(); databaseService = null; }
app.exit(0);
}, 5_000);
(async () => {
try {
await mcpManager.shutdown();
} catch (err) {
log.error('[Shutdown] MCP shutdown failed:', err);
}
cleanupBrowser();
if (databaseService) { databaseService.close(); databaseService = null; }
clearTimeout(shutdownTimeout);
app.exit(0);
})().catch((err) => {
log.error('[Shutdown] Async cleanup failed:', err);
clearTimeout(shutdownTimeout);
if (databaseService) { try { databaseService.close(); } catch { /* ignore */ } databaseService = null; }
app.exit(1);
});
});
// ===== 应用日志级别配置 =====
@@ -430,5 +462,18 @@ async function initialize(): Promise<void> {
app.whenReady().then(initialize);
app.on('web-contents-created', (_, contents) => {
contents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; });
// C-8 修复: 全局 web-contents 监听器也校验 URL 协议
contents.setWindowOpenHandler(({ url }) => {
try {
const parsed = new URL(url);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
shell.openExternal(url);
} else {
log.warn(`[Main] Blocked window.open with unsafe protocol: ${parsed.protocol}`);
}
} catch {
log.warn(`[Main] Blocked window.open with invalid URL: ${url.slice(0, 100)}`);
}
return { action: 'deny' };
});
});
+78 -39
View File
@@ -14,6 +14,13 @@ import { app } from 'electron';
import { existsSync, mkdirSync } from 'fs';
import log from 'electron-log';
/**
* L-8 修复: 提取 toErrorMessage 工具函数,消除 5 处重复的 error instanceof Error 三元表达式
*/
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export class DatabaseService {
private db: Database.Database | null = null;
private dbPath: string;
@@ -95,11 +102,13 @@ export class DatabaseService {
);
-- ===== 消息表 =====
-- C-6 修复: content 允许 NULL — assistant 消息仅有 tool_calls 时 content 必须为 null
-- @see project_memory.md — Assistant messages with tool_calls must set content to null
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')),
content TEXT NOT NULL,
content TEXT,
reasoning_content TEXT,
tool_calls TEXT,
tool_result TEXT,
@@ -246,50 +255,76 @@ export class DatabaseService {
private runMigrations(): void {
const db = this.db!;
// L-6 修复: 提取 tryAddColumn 辅助方法,消除 4 处重复的 try/catch 模式
// L-8 修复: 使用 toErrorMessage 替代重复的 error instanceof Error 三元表达式
const tryAddColumn = (table: string, column: string, type: string, migrationName: string) => {
try {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
log.info(`[DB] Migration: added ${column} column to ${table}`);
} catch (error) {
// 只忽略 "duplicate column" 错误(列已存在),其他错误必须抛出
const msg = toErrorMessage(error);
if (!msg.includes('duplicate column')) {
throw error;
}
}
};
// 迁移 1: messages 表添加 attachments 列
try {
db.exec('ALTER TABLE messages ADD COLUMN attachments TEXT');
log.info('[DB] Migration: added attachments column to messages');
} catch (error) {
// 只忽略 "duplicate column" 错误(列已存在),其他错误必须抛出
const msg = error instanceof Error ? error.message : String(error);
if (!msg.includes('duplicate column')) {
throw error;
}
}
tryAddColumn('messages', 'attachments', 'TEXT', 'attachments');
// 迁移 2: audit_logs 表添加 iteration 列
try {
db.exec('ALTER TABLE audit_logs ADD COLUMN iteration INTEGER');
log.info('[DB] Migration: added iteration column to audit_logs');
} catch (error) {
// 只忽略 "duplicate column" 错误(列已存在),其他错误必须抛出
const msg = error instanceof Error ? error.message : String(error);
if (!msg.includes('duplicate column')) {
throw error;
}
}
tryAddColumn('audit_logs', 'iteration', 'INTEGER', 'iteration');
// v0.2.0 迁移 3: audit_logs 表添加 prev_hash 列(链式哈希)
try {
db.exec('ALTER TABLE audit_logs ADD COLUMN prev_hash TEXT');
log.info('[DB] Migration: added prev_hash column to audit_logs');
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
if (!msg.includes('duplicate column')) {
throw error;
}
}
tryAddColumn('audit_logs', 'prev_hash', 'TEXT', 'prev_hash');
// v0.2.0 迁移 4: audit_logs 表添加 current_hash 列(链式哈希)
tryAddColumn('audit_logs', 'current_hash', 'TEXT', 'current_hash');
// C-6 修复 迁移 5: 重建 messages 表,将 content 列从 NOT NULL 改为允许 NULL
// @see project_memory.md — Assistant messages with tool_calls must set content to null
// SQLite 不支持 ALTER COLUMN,需要重建表
try {
db.exec('ALTER TABLE audit_logs ADD COLUMN current_hash TEXT');
log.info('[DB] Migration: added current_hash column to audit_logs');
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
if (!msg.includes('duplicate column')) {
throw error;
// 检测 content 列是否有 NOT NULL 约束
const columns = db.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string; notnull: number }>;
const contentCol = columns.find((c) => c.name === 'content');
if (contentCol && contentCol.notnull === 1) {
log.info('[DB] Migration: rebuilding messages table to allow NULL content');
db.exec(`
CREATE TABLE IF NOT EXISTS messages_new (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')),
content TEXT,
reasoning_content TEXT,
tool_calls TEXT,
tool_result TEXT,
attachments TEXT,
iteration INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
INSERT INTO messages_new (id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at)
SELECT id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at
FROM messages;
DROP TABLE messages;
ALTER TABLE messages_new RENAME TO messages;
`);
// 重建索引
db.exec(`
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role);
`);
log.info('[DB] Migration: messages table rebuilt successfully (content now allows NULL)');
}
} catch (error) {
// L-8 修复: 使用 toErrorMessage 替代重复的三元表达式
const msg = toErrorMessage(error);
log.warn(`[DB] Migration 5 (messages content NULL) skipped: ${msg}`);
// 非致命错误 — 如果迁移失败,NOT NULL 约束仍生效,saveMessage 会保存空字符串
}
}
@@ -316,6 +351,10 @@ export class DatabaseService {
{ key: 'agent.enableThinking', value: 'true', category: 'agent' },
{ key: 'agent.thinkingEffort', value: '"high"', category: 'agent' },
{ key: 'agent.enableReflection', value: 'false', category: 'agent' },
// C-10 修复: 补充缺失的 agent 配置默认值
// @see project_memory.md — Tool confirmation timeout is configurable via agent.confirmationTimeoutMs (30s~600s, default 120s)
{ key: 'agent.confirmationTimeoutMs', value: '120000', category: 'agent' },
{ key: 'agent.toolExecutionTimeoutMs', value: '120000', category: 'agent' },
// 安全配置
{ key: 'security.requireWriteConfirmation', value: 'true', category: 'security' },
+11 -5
View File
@@ -29,7 +29,8 @@ export interface MessageRow {
id: string;
session_id: string;
role: string;
content: string;
// C-6 修复: content 允许 null — assistant 消息仅有 tool_calls 时为 null
content: string | null;
reasoning_content: string | null;
tool_calls: string | null;
tool_result: string | null;
@@ -52,7 +53,8 @@ export interface SessionInfo {
export interface MessageInfo {
id: string;
role: string;
content: string;
// C-6 修复: content 允许 null — assistant 消息仅有 tool_calls 时为 null
content: string | null;
reasoningContent?: string;
toolCalls?: unknown[];
toolResult?: unknown;
@@ -192,7 +194,8 @@ export class SessionService {
saveMessage(params: {
sessionId: string;
role: string;
content: string;
// C-6 修复: content 允许 null — assistant 消息仅有 tool_calls 时为 null
content: string | null;
reasoningContent?: string;
toolCalls?: unknown[];
toolResult?: unknown;
@@ -311,14 +314,17 @@ export class SessionService {
}
private toMessageInfo(row: MessageRow): MessageInfo {
// M-55 修复: 运行时收窄,防止数据库被篡改或老版本数据格式不一致导致下游 .map/.length 崩溃
const parsedToolCalls = row.tool_calls ? this.safeJsonParse(row.tool_calls) : undefined;
const parsedAttachments = row.attachments ? this.safeJsonParse(row.attachments) : undefined;
return {
id: row.id,
role: row.role,
content: row.content,
reasoningContent: row.reasoning_content ?? undefined,
toolCalls: row.tool_calls ? this.safeJsonParse(row.tool_calls) as unknown[] : undefined,
toolCalls: Array.isArray(parsedToolCalls) ? parsedToolCalls : undefined,
toolResult: row.tool_result ? this.safeJsonParse(row.tool_result) : undefined,
attachments: row.attachments ? this.safeJsonParse(row.attachments) as unknown[] : undefined,
attachments: Array.isArray(parsedAttachments) ? parsedAttachments : undefined,
iteration: row.iteration ?? undefined,
timestamp: row.created_at,
};
+11 -1
View File
@@ -82,7 +82,17 @@ export class WindowManager {
});
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
// C-8 修复: 校验 URL 协议,只允许 http/https,防止 javascript:/file: 等协议执行代码
try {
const parsed = new URL(url);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
shell.openExternal(url);
} else {
log.warn(`[WindowManager] Blocked window.open with unsafe protocol: ${parsed.protocol}`);
}
} catch {
log.warn(`[WindowManager] Blocked window.open with invalid URL: ${url.slice(0, 100)}`);
}
return { action: 'deny' };
});
+177 -2
View File
@@ -1,7 +1,8 @@
/**
* Health Checker — 健康检查器
* Health Checker + SLO Monitor — 健康检查器与 SLO 监控
*
* 对数据库连通性、磁盘空间、内存使用执行真实检查。
* HealthChecker: 对数据库连通性、磁盘空间、内存使用执行真实检查。
* SLOMonitor: C-9 修复 — 补全 SLO 监控核心功能(错误率、延迟分布、吞吐量、燃烧速率)。
*
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十一章
*/
@@ -107,3 +108,177 @@ export class HealthChecker {
};
}
}
// ===== C-9 修复: SLO Monitor — SLO 监控核心功能 =====
/**
* SLO 监控配置
*/
export interface SLOConfig {
/** SLO 目标(如 0.999 表示 99.9% 可用性) */
target: number;
/** 滑动窗口大小(毫秒,默认 5 分钟) */
windowMs: number;
/** 延迟分位数(如 0.95 表示 P95) */
latencyPercentiles: number[];
/** 延迟告警阈值(毫秒) */
latencyThresholdMs: number;
}
/**
* SLO 请求记录
*/
interface SLORequestRecord {
timestamp: number;
latencyMs: number;
success: boolean;
}
/**
* SLO 状态报告
*/
export interface SLOStatus {
/** 当前错误率(0-1 */
errorRate: number;
/** 当前吞吐量(请求/秒) */
throughput: number;
/** 平均延迟(毫秒) */
avgLatencyMs: number;
/** 延迟分位数 */
percentiles: Record<string, number>;
/** 燃烧速率(实际错误率 / 错误预算) */
burnRate: number;
/** SLO 目标 */
target: number;
/** 错误预算(1 - target */
errorBudget: number;
/** 是否违反 SLO */
violated: boolean;
/** 窗口内总请求数 */
totalRequests: number;
/** 窗口内错误请求数 */
errorRequests: number;
/** 时间戳 */
timestamp: Date;
}
/**
* SLO Monitor — SLO 监控器
*
* C-9 修复: 补全 SLO 监控核心功能
*
* 功能:
* 1. 错误率追踪 — 记录请求成功/失败,计算滑动窗口内错误率
* 2. 延迟分布追踪 — 记录请求延迟,计算 P50/P95/P99 分位数
* 3. 吞吐量追踪 — 计算每秒请求数
* 4. 燃烧速率计算 — 实际错误率 / 错误预算,>1 表示 SLO 即将违反
*
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十一章
*/
export class SLOMonitor {
private records: SLORequestRecord[] = [];
private readonly config: SLOConfig;
constructor(config: Partial<SLOConfig> = {}) {
this.config = {
target: 0.999,
windowMs: 5 * 60 * 1000, // 5 分钟
latencyPercentiles: [0.5, 0.95, 0.99],
latencyThresholdMs: 5000,
...config,
};
}
/**
* 记录一次请求
* @param latencyMs 延迟(毫秒)
* @param success 是否成功
*/
recordRequest(latencyMs: number, success: boolean): void {
const record: SLORequestRecord = {
timestamp: Date.now(),
latencyMs,
success,
};
this.records.push(record);
// 清理过期记录
this.cleanup();
}
/**
* 清理窗口外的过期记录
*/
private cleanup(): void {
const cutoff = Date.now() - this.config.windowMs;
// 保留最近 10 分钟的数据(2 倍窗口),避免边界效应
this.records = this.records.filter((r) => r.timestamp >= cutoff);
}
/**
* 获取当前 SLO 状态
*/
getStatus(): SLOStatus {
this.cleanup();
const now = Date.now();
const windowStart = now - this.config.windowMs;
const windowRecords = this.records.filter((r) => r.timestamp >= windowStart);
const totalRequests = windowRecords.length;
const errorRequests = windowRecords.filter((r) => !r.success).length;
const errorRate = totalRequests > 0 ? errorRequests / totalRequests : 0;
const throughput = totalRequests / (this.config.windowMs / 1000);
const latencies = windowRecords.map((r) => r.latencyMs).sort((a, b) => a - b);
const avgLatencyMs = latencies.length > 0
? latencies.reduce((sum, l) => sum + l, 0) / latencies.length
: 0;
const percentiles: Record<string, number> = {};
for (const p of this.config.latencyPercentiles) {
const key = `P${(p * 100).toFixed(0)}`;
percentiles[key] = this.calculatePercentile(latencies, p);
}
const errorBudget = 1 - this.config.target;
const burnRate = errorBudget > 0 ? errorRate / errorBudget : 0;
return {
errorRate,
throughput,
avgLatencyMs,
percentiles,
burnRate,
target: this.config.target,
errorBudget,
violated: burnRate > 1,
totalRequests,
errorRequests,
timestamp: new Date(),
};
}
/**
* 计算分位数
*/
private calculatePercentile(sortedValues: number[], percentile: number): number {
if (sortedValues.length === 0) return 0;
const index = Math.ceil(percentile * sortedValues.length) - 1;
return sortedValues[Math.max(0, index)];
}
/**
* 重置所有记录
*/
reset(): void {
this.records = [];
}
/**
* 获取配置
*/
getConfig(): SLOConfig {
return { ...this.config };
}
}
+22 -2
View File
@@ -1,12 +1,12 @@
{
"name": "metona-ai-desktop",
"version": "0.3.0",
"version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ai-desktop",
"version": "0.3.0",
"version": "0.3.1",
"license": "MIT",
"dependencies": {
"@emotion/react": "^11.14.0",
@@ -14,6 +14,7 @@
"@modelcontextprotocol/sdk": "^1.12.1",
"@mui/icons-material": "^9.1.1",
"@mui/material": "^9.1.2",
"@types/shell-quote": "^1.7.5",
"better-sqlite3": "^11.9.1",
"date-fns": "^4.1.0",
"electron-log": "^5.3.3",
@@ -28,6 +29,7 @@
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"shell-quote": "^1.10.0",
"sql.js": "^1.12.0",
"zod": "^3.25.67",
"zustand": "^5.0.5"
@@ -3179,6 +3181,12 @@
"@types/node": "*"
}
},
"node_modules/@types/shell-quote": {
"version": "1.7.5",
"resolved": "https://registry.npmmirror.com/@types/shell-quote/-/shell-quote-1.7.5.tgz",
"integrity": "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==",
"license": "MIT"
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz",
@@ -10235,6 +10243,18 @@
"node": ">=8"
}
},
"node_modules/shell-quote": {
"version": "1.10.0",
"resolved": "https://registry.npmmirror.com/shell-quote/-/shell-quote-1.10.0.tgz",
"integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "metona-ai-desktop",
"version": "0.3.0",
"version": "0.3.1",
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
"main": "dist-electron/main/main.js",
"author": "Metona Team",
@@ -28,6 +28,7 @@
"@modelcontextprotocol/sdk": "^1.12.1",
"@mui/icons-material": "^9.1.1",
"@mui/material": "^9.1.2",
"@types/shell-quote": "^1.7.5",
"better-sqlite3": "^11.9.1",
"date-fns": "^4.1.0",
"electron-log": "^5.3.3",
@@ -42,6 +43,7 @@
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"shell-quote": "^1.10.0",
"sql.js": "^1.12.0",
"zod": "^3.25.67",
"zustand": "^5.0.5"
+10 -4
View File
@@ -9,6 +9,7 @@
import { ThemeProvider, CssBaseline } from '@mui/material';
import { useEffect } from 'react';
import { Header } from '@renderer/components/layout/Header';
import { Sidebar } from '@renderer/components/layout/Sidebar';
import { DetailPanel } from '@renderer/components/layout/DetailPanel';
import { StatusBar } from '@renderer/components/layout/StatusBar';
@@ -43,12 +44,15 @@ export default function App(): React.JSX.Element {
window.metona.config.get('onboarding.completed').then((v) => {
if (v === true) useUIStore.getState().setOnboardingCompleted(true);
}).catch((err) => { console.error('[App]', err); });
Promise.all([
// M-25 修复: 改用 Promise.allSettled,单个配置读取失败不影响另一个
Promise.allSettled([
window.metona.config.get('llm.provider'),
window.metona.config.get('llm.model'),
]).then(([provider, model]) => {
if (provider || model) useAgentStore.getState().setProvider((provider as string) || '', (model as string) || '');
}).catch((err) => { console.error('[App]', err); });
]).then(([providerResult, modelResult]) => {
const provider = providerResult.status === 'fulfilled' ? (providerResult.value as string) || '' : '';
const model = modelResult.status === 'fulfilled' ? (modelResult.value as string) || '' : '';
if (provider || model) useAgentStore.getState().setProvider(provider, model);
});
}
}, []);
@@ -59,6 +63,8 @@ export default function App(): React.JSX.Element {
className="h-screen w-screen flex flex-col overflow-hidden select-none"
style={{ background: muiTheme.palette.background.default, color: muiTheme.palette.text.primary }}
>
{/* H-7 修复: 添加 Header Bar — 规范要求布局为 Header (全宽) + [Sidebar|ChatPanel|DetailPanel] + StatusBar */}
<Header />
<div className="flex-1 flex overflow-hidden min-h-0">
{sidebarVisible && <Sidebar />}
<ChatPanel />
+16 -5
View File
@@ -2,7 +2,7 @@
* CommandPalette — 快速搜索面板 (Cmd+K)
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Dialog, DialogContent, InputBase, List, ListItemButton, ListItemIcon, ListItemText, Typography, Box, Divider } from '@mui/material';
import { Search, MessageSquare, Settings, Plus, Trash2 } from 'lucide-react';
import { useUIStore } from '@renderer/stores/ui-store';
@@ -16,13 +16,22 @@ export function CommandPalette(): React.JSX.Element {
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
// 审计补充修复: 响应式订阅 sessions,使 getCommands 在 sessions 变化时重新构建
// 原问题:getCommands 内部通过 useSessionStore.getState() 非响应式访问 sessions
// 当 sessions 变化但 query 不变时 commands 列表陈旧
const sessions = useSessionStore((s) => s.sessions);
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'k' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); setOpen((p) => !p); setQuery(''); setSelectedIndex(0); } };
window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h);
}, []);
useEffect(() => { if (open) setTimeout(() => inputRef.current?.focus(), 50); }, [open]);
// M-19 修复: useEffect 返回 cleanup 清理 setTimeout,防止快速开关时 timer 堆积
useEffect(() => {
if (!open) return;
const t = setTimeout(() => inputRef.current?.focus(), 50);
return () => clearTimeout(t);
}, [open]);
const getCommands = useCallback((): CommandItem[] => {
const cmds: CommandItem[] = [
@@ -31,13 +40,15 @@ export function CommandPalette(): React.JSX.Element {
{ id: 'settings', icon: Settings, label: '打开设置', action: () => { useUIStore.getState().openSettings(); setOpen(false); } },
];
if (query) {
const filtered = useSessionStore.getState().sessions.filter((s) => s.title.toLowerCase().includes(query.toLowerCase()));
// 审计补充修复: 使用响应式 sessions(闭包捕获),而非 useSessionStore.getState()
const filtered = sessions.filter((s) => s.title.toLowerCase().includes(query.toLowerCase()));
for (const s of filtered.slice(0, 5)) cmds.push({ id: `s-${s.id}`, icon: MessageSquare, label: s.title, description: `${s.messageCount} 条消息`, action: () => { useSessionStore.getState().setCurrentSession(s.id); useAgentStore.getState().setCurrentSession(s.id); setOpen(false); } });
}
return cmds;
}, [query]);
}, [query, sessions]);
const commands = getCommands();
// L-15 修复: 使用 useMemo 缓存 commands 列表,避免每次渲染都重新构建
const commands = useMemo(() => getCommands(), [getCommands]);
return (
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth slotProps={{ paper: { sx: { mt: '20vh', maxWidth: 520, borderRadius: 3, overflow: 'hidden' } } }}>
+3 -1
View File
@@ -273,6 +273,7 @@ export function ConfirmationDialog(): React.JSX.Element | null {
color="error"
variant="outlined"
size="small"
disabled={isExpired}
>
</Button>
@@ -282,8 +283,9 @@ export function ConfirmationDialog(): React.JSX.Element | null {
variant="contained"
size="small"
autoFocus
disabled={isExpired}
>
{isExpired ? '已超时' : '确认执行'}
</Button>
</DialogActions>
</Dialog>
+14 -4
View File
@@ -184,14 +184,24 @@ function CodeBlock({ language, code }: { language: string; code: string }): Reac
);
}
/** 从 React children(可能含高亮 span)递归提取纯文本 */
function extractTextContent(children: React.ReactNode): string {
/**
* 从 React children(可能含高亮 span)递归提取纯文本
*
* L-1 修复: 添加 maxDepth 参数防止循环引用导致的栈溢出
* React children 正常深度不会超过 10,50 已足够安全裕量
*/
function extractTextContent(children: React.ReactNode, maxDepth = 50): string {
if (maxDepth <= 0) return ''; // 超出深度上限,停止递归
if (typeof children === 'string') return children;
if (typeof children === 'number') return String(children);
if (!children) return '';
if (Array.isArray(children)) return children.map(extractTextContent).join('');
if (Array.isArray(children)) return children.map((c) => extractTextContent(c, maxDepth - 1)).join('');
if (typeof children === 'object' && 'props' in children) {
return extractTextContent((children as React.ReactElement).props.children as React.ReactNode);
// 使用 React.ReactElement<{ children?: React.ReactNode }> 显式声明 props.children 类型
return extractTextContent(
(children as React.ReactElement<{ children?: React.ReactNode }>).props.children as React.ReactNode,
maxDepth - 1,
);
}
return '';
}
+52 -10
View File
@@ -10,8 +10,9 @@
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { Box, Typography, IconButton, Tooltip, Stack, Button, Paper } from '@mui/material';
import { Box, Typography, IconButton, Tooltip, Stack, Button, Paper, InputBase } from '@mui/material';
import { Send, Paperclip, Square, X, FileText, Image as ImageIcon } from 'lucide-react';
import { nanoid } from 'nanoid';
import { useAgentStore } from '@renderer/stores/agent-store';
import { useSessionStore } from '@renderer/stores/session-store';
import { useUIStore } from '@renderer/stores/ui-store';
@@ -67,7 +68,8 @@ export function ChatInput(): React.JSX.Element {
const processFile = useCallback(async (file: File): Promise<Attachment> => {
const type = classifyFile(file);
const attachment: Attachment = { id: `att_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, file, type };
// L-10 修复: 统一使用 nanoid 生成附件 ID(与项目其他位置一致)
const attachment: Attachment = { id: `att_${nanoid(6)}`, file, type };
if (type === 'image') {
// 图片转 base64 data URL → 压缩(限制 1024px, JPEG 0.7
@@ -241,8 +243,18 @@ export function ChatInput(): React.JSX.Element {
const handleAbort = useCallback(() => { abort(); }, [abort]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
// Ctrl+Enter — 换行
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
// H-9 修复: 快捷键对齐规范
// @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 快捷键规范
// 规范要求: Cmd/Ctrl+Enter = 发送消息, Cmd/Ctrl+Shift+Enter = 换行
// 之前代码是 Ctrl+Enter=换行, Enter=发送,与规范相反
// 修复后:
// Cmd/Ctrl+Enter = 发送消息(规范要求)
// Cmd/Ctrl+Shift+Enter = 换行(规范要求)
// Enter = 发送消息(保持聊天应用习惯)
// Shift+Enter = 换行(textarea 默认行为,无需处理)
// Cmd/Ctrl+Shift+Enter — 换行(规范要求)
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && e.shiftKey) {
e.preventDefault();
const t = e.currentTarget as HTMLTextAreaElement;
const s = t.selectionStart;
@@ -251,12 +263,19 @@ export function ChatInput(): React.JSX.Element {
requestAnimationFrame(() => { t.selectionStart = t.selectionEnd = s + 1; });
return;
}
// Enter — 发送
// Cmd/Ctrl+Enter — 发送消息(规范要求,优先于 Enter
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSend();
return;
}
// Enter(不带修饰键)— 发送消息(保持聊天应用习惯)
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); return; }
if (e.key === 'Escape' && showSlashMenu) { setShowSlashMenu(false); return; }
}, [handleSend, showSlashMenu]);
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
// H-8 修复: 使用 InputBase 后,onChange 类型需兼容 HTMLInputElement | HTMLTextAreaElement
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
const v = e.target.value; setInput(v);
if (v === '/') { setShowSlashMenu(true); setSlashFilter(''); }
else if (v.startsWith('/') && !v.includes(' ')) { setShowSlashMenu(true); setSlashFilter(v.slice(1).toLowerCase()); }
@@ -298,12 +317,35 @@ export function ChatInput(): React.JSX.Element {
<input ref={fileInputRef} type="file" multiple accept={supportsImages ? 'image/*,.txt,.md,.json,.csv,.ts,.tsx,.js,.jsx,.py,.rb,.go,.rs,.java,.c,.cpp,.h,.css,.html,.xml,.yaml,.yml,.toml,.ini,.sh,.sql,.log,.pdf' : '.txt,.md,.json,.csv,.ts,.tsx,.js,.jsx,.py,.rb,.go,.rs,.java,.c,.cpp,.h,.css,.html,.xml,.yaml,.yml,.toml,.ini,.sh,.sql,.log,.pdf'} style={{ display: 'none' }} onChange={handleFileChange} />
<textarea
ref={textareaRef} data-chat-input value={input} onChange={handleChange} onKeyDown={handleKeyDown}
{/* H-8 修复: 使用 MUI InputBase 替代原生 textarea — 遵循 MUI 强制使用规范 */}
{/* @see standard/开发规范.md — MUI 强制使用、禁止自写 UI 组件 */}
<InputBase
inputRef={textareaRef}
data-chat-input
value={input}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder="输入消息... (Enter 发送, Ctrl+Enter 换行, / 命令)" disabled={isStreaming}
placeholder="输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)"
disabled={isStreaming}
multiline
rows={1}
style={{ width: '100%', background: 'transparent', border: 'none', outline: 'none', color: 'inherit', fontSize: 14, lineHeight: '28px', resize: 'none', fontFamily: 'inherit', minHeight: 42, maxHeight: 160 }}
sx={{
width: '100%',
color: 'inherit',
fontSize: 14,
lineHeight: '28px',
fontFamily: 'inherit',
'& .MuiInputBase-input': {
padding: 0,
resize: 'none',
minHeight: 42,
maxHeight: 160,
},
'&::before, &::after': {
display: 'none',
},
}}
/>
<Stack direction="row" sx={{ mt: 1, minHeight: 32, justifyContent: 'space-between', alignItems: 'center' }}>
+15 -3
View File
@@ -6,7 +6,7 @@
import { Box, Typography, Stack, Table, TableBody, TableRow, TableCell, Chip } from '@mui/material';
import { Activity, Cpu } from 'lucide-react';
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { useAgentStore, type AgentStatus } from '@renderer/stores/agent-store';
import { AGENT_STATUS_COLORS, AGENT_STATUS_LABELS, PROVIDER_LABELS } from '@renderer/lib/constants';
import { formatDuration } from '@renderer/lib/formatters';
@@ -23,17 +23,29 @@ export function AgentMonitor(): React.JSX.Element {
const setMaxIterations = useAgentStore((s) => s.setMaxIterations);
useEffect(() => {
// M-11/M-28 修复: 添加 cancelled 标志 + 错误日志记录
let cancelled = false;
if (window.metona?.config?.get) {
window.metona.config.get('agent.maxIterations').then((v) => {
if (cancelled) return;
if (typeof v === 'number' && v > 0) setMaxIterations(v);
}).catch(() => {});
}).catch((err) => {
// M-11 修复: 记录错误而非静默吞掉,便于诊断配置加载异常
console.error('[AgentMonitor] Failed to load maxIterations:', err);
});
}
return () => { cancelled = true; };
}, [setMaxIterations]);
// L-14 修复: 使用 useMemo 缓存 totalDuration,避免每次渲染都遍历 traceSteps 数组
const totalDuration = useMemo(
() => traceSteps.reduce((sum, s) => sum + (s.completedAt ? s.completedAt - s.startedAt : 0), 0),
[traceSteps],
);
const StatusIcon = STATUS_ICONS[agentStatus];
const statusColor = AGENT_STATUS_COLORS[agentStatus];
const statusLabel = AGENT_STATUS_LABELS[agentStatus];
const totalDuration = traceSteps.reduce((sum, s) => sum + (s.completedAt ? s.completedAt - s.startedAt : 0), 0);
return (
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider', flexShrink: 0 }}>
+155
View File
@@ -0,0 +1,155 @@
/**
* Header Bar — 应用顶部栏
*
* H-7 修复: 规范要求布局包含 Header Bar(全宽),之前 App.tsx 缺失此组件。
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 整体布局
*
* 布局: Header Bar (全宽) + [Sidebar | ChatPanel | DetailPanel] + StatusBar
*
* 功能:
* - 应用标题/Logo
* - 当前 Provider/Model 显示
* - 面板切换(侧边栏、详情面板、专注模式)
* - 主题切换
* - 设置入口
*/
import { AppBar, Toolbar, Typography, IconButton, Box, Chip, Tooltip, Divider } from '@mui/material';
import {
PanelLeft,
PanelRight,
Focus,
Settings,
Sun,
Moon,
Monitor,
} from 'lucide-react';
import { useUIStore, type ThemeMode } from '@renderer/stores/ui-store';
import { useAgentStore } from '@renderer/stores/agent-store';
const PROVIDER_LABELS: Record<string, string> = {
deepseek: 'DeepSeek',
agnes: 'Agnes',
ollama: 'Ollama',
};
export function Header(): React.JSX.Element {
const sidebarVisible = useUIStore((s) => s.sidebarVisible);
const detailVisible = useUIStore((s) => s.detailVisible);
const focusMode = useUIStore((s) => s.focusMode);
const theme = useUIStore((s) => s.theme);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const toggleDetail = useUIStore((s) => s.toggleDetail);
const toggleFocusMode = useUIStore((s) => s.toggleFocusMode);
const setTheme = useUIStore((s) => s.setTheme);
const openSettings = useUIStore((s) => s.openSettings);
const provider = useAgentStore((s) => s.provider);
const model = useAgentStore((s) => s.model);
const cycleTheme = () => {
const order: ThemeMode[] = ['light', 'dark', 'auto'];
const idx = order.indexOf(theme);
setTheme(order[(idx + 1) % order.length]);
};
const ThemeIcon = theme === 'light' ? Sun : theme === 'dark' ? Moon : Monitor;
const themeLabel = theme === 'light' ? '浅色主题' : theme === 'dark' ? '深色主题' : '跟随系统';
return (
<AppBar
position="static"
elevation={0}
sx={{
flexShrink: 0,
borderBottom: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
}}
>
<Toolbar variant="dense" sx={{ minHeight: 48, gap: 1 }}>
{/* 左侧:应用标题 */}
<Typography
variant="h6"
component="span"
sx={{
fontWeight: 700,
background: 'linear-gradient(135deg, #6366f1, #a855f7)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
mr: 1,
}}
>
MetonaAI
</Typography>
{/* Provider/Model 显示 */}
{provider && (
<>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
<Chip
size="small"
label={PROVIDER_LABELS[provider] ?? provider}
color="primary"
variant="outlined"
/>
{model && (
<Typography variant="caption" color="text.secondary" sx={{ ml: 0.5 }}>
{model}
</Typography>
)}
</>
)}
{/* 右侧:操作按钮 */}
<Box sx={{ flexGrow: 1 }} />
<Tooltip title={sidebarVisible ? '隐藏侧边栏' : '显示侧边栏'}>
<IconButton
size="small"
onClick={toggleSidebar}
color={sidebarVisible ? 'primary' : 'default'}
disabled={focusMode}
>
<PanelLeft size={18} />
</IconButton>
</Tooltip>
<Tooltip title={detailVisible ? '隐藏详情面板' : '显示详情面板'}>
<IconButton
size="small"
onClick={toggleDetail}
color={detailVisible ? 'primary' : 'default'}
disabled={focusMode}
>
<PanelRight size={18} />
</IconButton>
</Tooltip>
<Tooltip title={focusMode ? '退出专注模式' : '进入专注模式'}>
<IconButton
size="small"
onClick={toggleFocusMode}
color={focusMode ? 'primary' : 'default'}
>
<Focus size={18} />
</IconButton>
</Tooltip>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
<Tooltip title={themeLabel}>
<IconButton size="small" onClick={cycleTheme}>
<ThemeIcon size={18} />
</IconButton>
</Tooltip>
<Tooltip title="设置">
<IconButton size="small" onClick={openSettings}>
<Settings size={18} />
</IconButton>
</Tooltip>
</Toolbar>
</AppBar>
);
}
+45 -8
View File
@@ -4,7 +4,7 @@
* 始终显示,包含会话列表、新建会话按钮、搜索。
*/
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { Box, Typography, Button, IconButton, TextField, Stack, Collapse, List, ListItemButton, ListItemIcon, ListItemText, Badge, Divider, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material';
import Fuse from 'fuse.js';
import { Plus, Search, MessageSquare, Pin, Wrench, ChevronDown, ChevronRight, Trash2 } from 'lucide-react';
@@ -23,14 +23,26 @@ export function Sidebar(): React.JSX.Element {
const agentStatus = useAgentStore((s) => s.agentStatus);
useEffect(() => {
if (window.metona?.sessions?.list) window.metona.sessions.list().then((list) => useSessionStore.getState().setSessions((list as any[]).map((s) => ({ id: s.id, title: s.title, createdAt: s.createdAt, updatedAt: s.updatedAt, messageCount: s.messageCount, pinned: s.pinned, archived: s.archived })))).catch(() => {});
// M-27 修复: 添加 cancelled 标志防止组件卸载后 setState
let cancelled = false;
if (window.metona?.sessions?.list) {
window.metona.sessions.list().then((list) => {
if (cancelled) return;
useSessionStore.getState().setSessions((list as Array<{ id: string; title: string; createdAt: number; updatedAt: number; messageCount: number; pinned: boolean; archived: boolean }>).map((s) => ({ id: s.id, title: s.title, createdAt: s.createdAt, updatedAt: s.updatedAt, messageCount: s.messageCount, pinned: s.pinned, archived: s.archived })));
}).catch((err) => {
// M-11 修复: 记录错误而非静默吞掉,便于诊断
console.error('[Sidebar] Failed to load sessions:', err);
});
}
return () => { cancelled = true; };
}, []);
const filteredSessions = (() => {
// L-13 修复: 使用 useMemo 缓存 filteredSessions,避免每次渲染都重建 Fuse 索引
const filteredSessions = useMemo(() => {
let list = sessions.filter((s) => !s.archived);
if (searchQuery) { const fuse = new Fuse(list, { keys: ['title'], threshold: 0.4, ignoreLocation: true }); list = fuse.search(searchQuery).map((r) => r.item); }
return list.sort((a, b) => a.pinned === b.pinned ? b.updatedAt - a.updatedAt : a.pinned ? -1 : 1);
})();
}, [sessions, searchQuery]);
const handleNewSession = async () => {
if (window.metona?.sessions?.create) {
@@ -40,7 +52,15 @@ export function Sidebar(): React.JSX.Element {
setCurrentSession(r.id);
loadSessionMessages(r.id);
return;
} catch {}
} catch (err) {
// M-9 修复: 显示错误提示而非静默吞错后创建本地假会话
// 之前的行为:catch 后继续创建本地 s_${Date.now()} 会话,但该会话在主进程不存在,下次刷新消失
console.error('[Sidebar] Failed to create session:', err);
import('metona-toast').then((mod) => {
mod.default.error('创建会话失败,请检查数据库状态');
}).catch(() => {});
return; // 不创建本地假会话
}
}
const newSession: Session = { id: `s_${Date.now()}`, title: '新会话', createdAt: Date.now(), updatedAt: Date.now(), messageCount: 0, pinned: false, archived: false };
useSessionStore.getState().addSession(newSession);
@@ -91,8 +111,22 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
setShowDeleteDialog(true);
};
const confirmDelete = () => {
window.metona?.sessions.delete(session.id).catch(() => {});
const confirmDelete = async () => {
// M-10 修复: 乐观更新失败时回滚,避免 UI 与数据库状态不一致
// 之前行为:catch 静默吞错,但下一行已 removeSession,导致用户以为已删除实际未删
if (window.metona?.sessions?.delete) {
try {
await window.metona.sessions.delete(session.id);
} catch (err) {
console.error('[Sidebar] Failed to delete session:', err);
import('metona-toast').then((mod) => {
mod.default.error('删除会话失败,请重试');
}).catch(() => {});
// 不调用 removeSession,保留会话在 UI 中(与数据库状态一致)
setShowDeleteDialog(false);
return;
}
}
useSessionStore.getState().removeSession(session.id);
setShowDeleteDialog(false);
};
@@ -137,11 +171,14 @@ function ToolManagerPanel() {
const [tools, setTools] = useState<Array<{ name: string; description: string; category: string; riskLevel: string; requiresPermission: boolean; enabled: boolean }>>([]);
useEffect(() => {
// M-54 修复: 添加 cancelled 标志,防止组件卸载后 setState
let cancelled = false;
if (window.metona?.tools?.list) {
window.metona.tools.list().then((list) => {
setTools(list as MetonaToolInfo[]);
if (!cancelled) setTools(list as MetonaToolInfo[]);
}).catch((err) => { console.error('[Sidebar]', err); });
}
return () => { cancelled = true; };
}, []);
const readyCount = tools.filter((t) => t.enabled).length;
+6 -1
View File
@@ -21,9 +21,14 @@ export function StatusBar(): React.JSX.Element {
const [version, setVersion] = useState('v0.1.1');
useEffect(() => {
// M-28 修复: 添加 cancelled 标志防止组件卸载后 setState
let cancelled = false;
if (window.metona?.app?.getVersion) {
window.metona.app.getVersion().then((v) => setVersion(`v${v}`)).catch((err) => { console.error('[StatusBar]', err); });
window.metona.app.getVersion().then((v) => {
if (!cancelled) setVersion(`v${v}`);
}).catch((err) => { console.error('[StatusBar]', err); });
}
return () => { cancelled = true; };
}, []);
return (
+19
View File
@@ -83,12 +83,21 @@ export function MemoryViewer(): React.JSX.Element {
// Agent 完成时自动刷新:监听 agentStatus 从 thinking/executing -> idle
const agentStatus = useAgentStore((s) => s.agentStatus);
const prevStatus = useRef(agentStatus);
// M-53 修复: 竞态保护 ref,防止多次 loadMemories 调用乱序完成导致旧数据覆盖
const loadReqIdRef = useRef(0);
// 审计补充修复: handleSearch 使用独立 ref,避免与 loadMemories 共用导致 searching 状态卡死
// 原问题:handleSearch 与 loadMemories 共用 loadReqIdRef,当 agent 完成触发 loadMemories 时
// 会 ++ref,使 handleSearch 的 finally 检查失败,setSearching(false) 不执行,UI 永久显示"搜索中..."
const searchReqIdRef = useRef(0);
const loadMemories = useCallback(async () => {
if (!window.metona?.memory?.listAll) return;
const reqId = ++loadReqIdRef.current;
try {
setError(null);
const res = await window.metona.memory.listAll();
// 竞态保护:若已被新请求取代或组件已卸载,放弃本次结果
if (loadReqIdRef.current !== reqId) return;
if (!res.success) {
setError(res.error ?? '加载记忆失败');
return;
@@ -100,6 +109,7 @@ export function MemoryViewer(): React.JSX.Element {
working: (data.working as MemoryItem[] | undefined) ?? [],
});
} catch (err) {
if (loadReqIdRef.current !== reqId) return;
setError((err as Error).message ?? '加载记忆失败');
}
}, []);
@@ -107,6 +117,8 @@ export function MemoryViewer(): React.JSX.Element {
// 初次挂载加载
useEffect(() => {
loadMemories();
// cleanup: 使当前请求失效(防止卸载后 setState)
return () => { loadReqIdRef.current++; };
}, [loadMemories]);
// Agent 完成自动刷新
@@ -123,13 +135,20 @@ export function MemoryViewer(): React.JSX.Element {
if (!q || !window.metona?.memory?.search) return;
setSearching(true);
setError(null);
// 审计补充修复: 使用独立 searchReqIdRef,不再与 loadMemories 共用
const reqId = ++searchReqIdRef.current;
try {
const results = await window.metona.memory.search(q, { topK: 20 });
// 竞态保护:若已被新搜索请求取代或组件已卸载,放弃本次结果
if (searchReqIdRef.current !== reqId) return;
setSearchResults(results);
} catch (err) {
if (searchReqIdRef.current !== reqId) return;
setError((err as Error).message ?? '搜索失败');
setSearchResults([]);
} finally {
// 审计补充修复: 无条件清理 searching 状态,避免被 loadMemories 取代时卡死
// searching 仅对当前搜索有意义,请求被取代后应停止 spinner
setSearching(false);
}
};
@@ -34,7 +34,15 @@ export function OnboardingWizard(): React.JSX.Element | null {
if (apiKey.trim()) configSets.push(window.metona.config.set('llm.apiKey', apiKey.trim()));
if (workspacePath.trim()) configSets.push(window.metona.config.set('workspace.path', workspacePath.trim()));
configSets.push(window.metona.config.set('onboarding.completed', true));
await Promise.all(configSets);
// M-26 修复: 改用 Promise.allSettled,单个配置写入失败不阻止 onboarding 完成
// 但 onboarding.completed 必须成功,否则用户重启后仍会看到引导
const results = await Promise.allSettled(configSets);
const failedCount = results.filter((r) => r.status === 'rejected').length;
if (failedCount > 0) {
console.error('[OnboardingWizard]', `${failedCount} config(s) failed to save`);
// 不调用 setOnboardingCompleted(true),让用户重试
return;
}
useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || '');
}
setOnboardingCompleted(true);
+125 -7
View File
@@ -580,11 +580,51 @@ function MCPSettings() {
const [newName, setNewName] = useState('');
const [newCommand, setNewCommand] = useState('');
const [newArgs, setNewArgs] = useState('');
const loadServers = () => { if (window.metona?.mcp?.listServers) window.metona.mcp.listServers().then((l) => setServers(l as MetonaMCPServerStatus[])).catch((err) => { console.error('[SettingsModal]', err); }); };
useEffect(() => { loadServers(); }, []);
// L-11 修复: 用 MUI Dialog 替换浏览器原生 confirm(),保持 UI 一致性
const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
// L-20 修复: loadServers 改为多行 async/await 写法,提升可读性
const loadServers = useCallback(async () => {
if (!window.metona?.mcp?.listServers) return;
try {
const list = await window.metona.mcp.listServers();
setServers(list as MetonaMCPServerStatus[]);
} catch (err) {
console.error('[SettingsModal]', err);
}
}, []);
// 审计补充修复: useEffect 添加 cancelled 标志,防止卸载后 setState
useEffect(() => {
let cancelled = false;
(async () => {
if (!window.metona?.mcp?.listServers) return;
try {
const list = await window.metona.mcp.listServers();
if (!cancelled) setServers(list as MetonaMCPServerStatus[]);
} catch (err) {
if (!cancelled) console.error('[SettingsModal]', err);
}
})();
return () => { cancelled = true; };
}, []);
const handleAdd = async () => { if (!newName.trim() || !newCommand.trim()) return; await window.metona?.mcp?.addServer({ name: newName.trim(), transport: 'stdio', command: newCommand.trim(), args: newArgs.trim() ? newArgs.trim().split(/\s+/) : [], enabled: true }); setNewName(''); setNewCommand(''); setNewArgs(''); setShowAdd(false); loadServers(); };
const statusColors: Record<string, string> = { connected: 'success.main', connecting: 'warning.main', disconnected: 'text.secondary', error: 'error.main' };
// L-11 修复: 确认移除 MCP 服务
// 审计补充修复: 添加 try/catch,避免 removeServer reject 时 Dialog 卡死无法关闭
const [removeError, setRemoveError] = useState<string | null>(null);
const handleConfirmRemove = async () => {
if (!confirmRemove) return;
try {
setRemoveError(null);
await window.metona?.mcp?.removeServer(confirmRemove);
setConfirmRemove(null);
loadServers();
} catch (err) {
setRemoveError((err as Error).message ?? '移除失败');
}
};
return (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>MCP </Typography>
@@ -598,10 +638,33 @@ function MCPSettings() {
</Stack>
<Stack direction="row" spacing={0.5}>
<Button size="small" sx={{ fontSize: 10, minWidth: 40 }} onClick={async () => { await window.metona?.mcp?.toggleServer(s.name, s.status !== 'connected'); loadServers(); }}>{s.status === 'connected' ? '断开' : '连接'}</Button>
<Button size="small" color="error" sx={{ fontSize: 10, minWidth: 40 }} onClick={async () => { if (confirm(`确定移除 "${s.name}"`)) { await window.metona?.mcp?.removeServer(s.name); loadServers(); } }}></Button>
{/* L-11 修复: 点击移除打开 MUI Dialog 二次确认,而非原生 confirm() */}
<Button size="small" color="error" sx={{ fontSize: 10, minWidth: 40 }} onClick={() => setConfirmRemove(s.name)}></Button>
</Stack>
</Stack>
))}
{/* L-11 修复: MUI Dialog 替代原生 confirm() */}
<Dialog
open={confirmRemove !== null}
onClose={() => { setConfirmRemove(null); setRemoveError(null); }}
maxWidth="xs"
fullWidth
>
<DialogTitle></DialogTitle>
<DialogContent>
<Typography variant="body2">
MCP "{confirmRemove}"
</Typography>
{removeError && (
<Alert severity="error" sx={{ mt: 1, fontSize: 12 }}>{removeError}</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => { setConfirmRemove(null); setRemoveError(null); }} color="inherit"></Button>
<Button onClick={handleConfirmRemove} color="error" variant="contained"></Button>
</DialogActions>
</Dialog>
{showAdd ? (
<Stack spacing={1} sx={{ p: 1.5, borderRadius: 1.5, bgcolor: 'secondary.main', border: '1px solid', borderColor: 'divider' }}>
<TextField size="small" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="服务名称" />
@@ -820,8 +883,33 @@ function AppearanceSettings({ theme, setTheme }: { theme: ThemeMode; setTheme: (
function LogsSettings() {
const [logLevel, setLogLevel] = useConfig('logging.level', 'info');
const [clearing, setClearing] = useState<string | null>(null);
// L-11 修复(审计补充): 用 MUI Dialog 替换原生 confirm() 和 alert(),保持 UI 一致性
const [confirmClear, setConfirmClear] = useState<'sessions' | 'memories' | 'auditLogs' | null>(null);
const [resultAlert, setResultAlert] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const handleExport = async () => { if (!window.metona?.data?.exportData) return; const r = await window.metona.data.exportData(); if (r.success && r.data) { const b = new Blob([JSON.stringify(r.data, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `metona-export-${Date.now()}.json`; a.click(); } };
const handleClear = async (type: 'sessions' | 'memories' | 'auditLogs') => { const labels: Record<string, string> = { sessions: '所有会话', memories: '所有记忆', auditLogs: '审计日志' }; if (!confirm(`确定清理${labels[type]}`)) return; setClearing(type); try { let r; if (type === 'sessions') r = await window.metona?.data?.clearSessions(); else if (type === 'memories') r = await window.metona?.data?.clearMemories(); else r = await window.metona?.data?.clearAuditLogs(); if (r?.success) alert(`${labels[type]}已清理`); else alert(`失败: ${r?.error}`); } catch (e) { alert(`失败: ${(e as Error).message}`); } setClearing(null); };
const labels: Record<string, string> = { sessions: '所有会话', memories: '所有记忆', auditLogs: '审计日志' };
// L-11 修复(审计补充): 清理数据改用 Dialog 确认 + Alert 反馈,不再用原生 confirm/alert
const handleClearConfirm = async () => {
if (!confirmClear) return;
const type = confirmClear;
setClearing(type);
setConfirmClear(null);
try {
let r;
if (type === 'sessions') r = await window.metona?.data?.clearSessions();
else if (type === 'memories') r = await window.metona?.data?.clearMemories();
else r = await window.metona?.data?.clearAuditLogs();
if (r?.success) {
setResultAlert({ type: 'success', message: `${labels[type]}已清理` });
} else {
setResultAlert({ type: 'error', message: `失败: ${r?.error ?? '未知错误'}` });
}
} catch (e) {
setResultAlert({ type: 'error', message: `失败: ${(e as Error).message}` });
}
setClearing(null);
};
return (
<Stack spacing={2}>
@@ -834,9 +922,39 @@ function LogsSettings() {
<Divider />
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}></Typography>
<Button variant="outlined" fullWidth size="small" onClick={handleExport}>📦 JSON</Button>
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => handleClear('sessions')} disabled={clearing !== null}>{clearing === 'sessions' ? '清理中...' : '🗑️ 清理所有会话'}</Button>
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => handleClear('memories')} disabled={clearing !== null}>{clearing === 'memories' ? '清理中...' : '🗑️ 清理所有记忆'}</Button>
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => handleClear('auditLogs')} disabled={clearing !== null}>{clearing === 'auditLogs' ? '清理中...' : '🗑️ 清理审计日志'}</Button>
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => { setResultAlert(null); setConfirmClear('sessions'); }} disabled={clearing !== null}>{clearing === 'sessions' ? '清理中...' : '🗑️ 清理所有会话'}</Button>
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => { setResultAlert(null); setConfirmClear('memories'); }} disabled={clearing !== null}>{clearing === 'memories' ? '清理中...' : '🗑️ 清理所有记忆'}</Button>
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => { setResultAlert(null); setConfirmClear('auditLogs'); }} disabled={clearing !== null}>{clearing === 'auditLogs' ? '清理中...' : '🗑️ 清理审计日志'}</Button>
{/* L-11 修复(审计补充): 清理数据确认 Dialog(替代原生 confirm() */}
<Dialog
open={confirmClear !== null}
onClose={() => setConfirmClear(null)}
maxWidth="xs"
fullWidth
>
<DialogTitle></DialogTitle>
<DialogContent>
<Typography variant="body2">
{confirmClear ? labels[confirmClear] : ''}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmClear(null)} color="inherit"></Button>
<Button onClick={handleClearConfirm} color="error" variant="contained"></Button>
</DialogActions>
</Dialog>
{/* L-11 修复(审计补充): 清理结果反馈 Alert(替代原生 alert()),3 秒后自动消失 */}
{resultAlert && (
<Alert
severity={resultAlert.type}
onClose={() => setResultAlert(null)}
sx={{ fontSize: 12 }}
>
{resultAlert.message}
</Alert>
)}
</Stack>
);
}
+9 -1
View File
@@ -4,7 +4,7 @@
*
*/
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Box, Typography, Stack, List, ListItem, ListItemIcon,
Checkbox, Chip, IconButton, TextField, Button, Select, MenuItem, Collapse,
@@ -63,12 +63,17 @@ export function TaskList(): React.JSX.Element {
const [newPriority, setNewPriority] = useState<TaskPriority>('medium');
const [expandedId, setExpandedId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
// M-51 修复: 竞态保护 ref,防止快速切换会话时旧请求覆盖新数据
const loadReqIdRef = useRef(0);
const loadTasks = useCallback(async (sid?: string) => {
if (!window.metona?.tasks?.list) return;
const reqId = ++loadReqIdRef.current;
try {
setError(null);
const res = await window.metona.tasks.list(sid);
// 竞态保护:若已被新请求取代或组件已卸载,放弃本次结果
if (loadReqIdRef.current !== reqId) return;
if (!res.success) {
setError('加载任务失败');
return;
@@ -79,12 +84,15 @@ export function TaskList(): React.JSX.Element {
});
setTasks(list);
} catch (err) {
if (loadReqIdRef.current !== reqId) return;
setError((err as Error).message ?? '加载任务失败');
}
}, []);
useEffect(() => {
loadTasks(sessionId ?? undefined);
// cleanup: 使当前请求失效(防止卸载后 setState + 竞态)
return () => { loadReqIdRef.current++; };
}, [sessionId, loadTasks]);
const handleCreate = async () => {
+13 -5
View File
@@ -28,24 +28,32 @@ export function WorkspaceViewer(): React.JSX.Element {
// Agent 完成时自动刷新
const agentStatus = useAgentStore((s) => s.agentStatus);
const prevStatus = useRef(agentStatus);
// M-52 修复: 竞态保护 ref,防止多次 loadInfo 调用乱序完成导致旧数据覆盖
const loadReqIdRef = useRef(0);
const loadInfo = useCallback(async () => {
if (!window.metona?.workspace?.getInfo) return;
const reqId = ++loadReqIdRef.current;
setLoading(true);
setError(null);
try {
const res = await window.metona.workspace.getInfo();
// 竞态保护:若已被新请求取代或组件已卸载,放弃本次结果
if (loadReqIdRef.current !== reqId) return;
setInfo(res);
} catch (err) {
if (loadReqIdRef.current !== reqId) return;
setError((err as Error).message ?? '加载工作空间信息失败');
} finally {
setLoading(false);
if (loadReqIdRef.current === reqId) setLoading(false);
}
}, []);
// 初次挂载加载
useEffect(() => {
loadInfo();
// cleanup: 使当前请求失效(防止卸载后 setState)
return () => { loadReqIdRef.current++; };
}, [loadInfo]);
// Agent 完成自动刷新
@@ -73,7 +81,7 @@ export function WorkspaceViewer(): React.JSX.Element {
<Stack spacing={1.5} sx={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
{/* 工作空间根路径 */}
<Box>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 0.5 }}>
<Stack direction="row" sx={{ mb: 0.5, alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
</Typography>
@@ -83,7 +91,7 @@ export function WorkspaceViewer(): React.JSX.Element {
</IconButton>
</Tooltip>
</Stack>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ flexWrap: 'wrap' }}>
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', alignItems: 'center' }}>
<Typography
variant="body2"
sx={{
@@ -126,7 +134,7 @@ export function WorkspaceViewer(): React.JSX.Element {
}}
>
<AccordionSummary expandIcon={<ChevronDown size={14} />} sx={{ minHeight: 32, '& .MuiAccordionSummary-content': { my: 0, alignItems: 'center' } }}>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ flex: 1, mr: 1 }}>
<Stack direction="row" spacing={0.5} sx={{ flex: 1, mr: 1, alignItems: 'center' }}>
{file.exists ? (
<CheckCircle2 size={12} color="var(--mui-palette-success-main)" />
) : (
@@ -193,13 +201,13 @@ export function WorkspaceViewer(): React.JSX.Element {
key={dir.name}
direction="row"
spacing={0.5}
alignItems="center"
sx={{
p: 0.5,
border: 1,
borderColor: 'divider',
borderRadius: 1,
bgcolor: 'background.default',
alignItems: 'center',
}}
>
<Folder size={12} color={dir.exists ? 'var(--mui-palette-info-main)' : 'var(--mui-palette-text-disabled)'} />
+8
View File
@@ -31,8 +31,16 @@ export function useAgentStream(): void {
requestId?: string;
sessionId?: string;
iteration?: number;
/** 事件序列号(与 MetonaStreamEvent.seq 对齐,规范必填字段) */
seq?: number;
/** 事件时间戳(与 MetonaStreamEvent.timestamp 对齐,规范必填字段) */
timestamp?: number;
/** 当前 run 的唯一标识(前端用于过滤旧流事件,abort 后重发场景) */
runId?: string;
delta?: string;
content?: string;
/** 工具调用增量(流式参数拼接) */
toolCallDelta?: { index: number; name?: string; argsDelta?: string };
toolCall?: { id: string; name: string; args: Record<string, unknown> };
toolResult?: { toolCallId: string; success: boolean; result?: unknown; error?: string; durationMs?: number };
usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number };
+11 -1
View File
@@ -11,6 +11,15 @@ import { useSessionStore } from './session-store';
let _msgIdCounter = 0;
export const genMsgId = (role: string) => `msg_${Date.now()}_${role}_${_msgIdCounter++}`;
/**
* L-16 修复: 提取上下文窗口默认值为命名常量
* - Ollama 4096 OllamaAdapter.DEFAULT_CONTEXT_WINDOW
* - ProviderDeepSeek/Agnes 1M adapter MODEL_INFO
* getContextWindow() listModels() UI
*/
const DEFAULT_OLLAMA_CONTEXT_WINDOW = 4096;
const DEFAULT_CLOUD_CONTEXT_WINDOW = 1_000_000;
// ===== 消息类型 =====
export type MessageRole = 'user' | 'assistant' | 'system' | 'tool';
@@ -365,8 +374,9 @@ export const useAgentStore = create<AgentState>((set, get) => ({
set((s) => ({ tokenUsage: { ...s.tokenUsage, ...usage } })),
setProvider: (provider, model) => {
// L-16 修复: 使用命名常量替代魔法数字
// DeepSeek/Agnes 固定 1MOllama 从配置读取
const ollamaCtx = provider === 'ollama' ? 4096 : 1_000_000;
const ollamaCtx = provider === 'ollama' ? DEFAULT_OLLAMA_CONTEXT_WINDOW : DEFAULT_CLOUD_CONTEXT_WINDOW;
set({ provider, model, contextWindow: ollamaCtx });
// 异步读取 Ollama 实际配置
if (provider === 'ollama' && window.metona?.config?.get) {