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 { BaseAdapter } from './base-adapter';
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types'; import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
import { MetonaFinishReason } from '../types'; import { MetonaFinishReason } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter';
import { buildOpenAICompatibleMessages, buildOpenAICompatibleTools } from './shared/openai-format'; import { buildOpenAICompatibleMessages, buildOpenAICompatibleTools } from './shared/openai-format';
import { parseSSEStream, parseOpenAICompatibleResponse } from './shared/sse-stream'; import { parseSSEStream, parseOpenAICompatibleResponse } from './shared/sse-stream';
import log from 'electron-log'; import log from 'electron-log';
export class AgnesAdapter extends BaseAdapter { 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 supportedModels = ['agnes-2.0-flash'];
readonly supportsToolCalling = true; readonly supportsToolCalling = true;
readonly supportsThinking = 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 (非流式) ===== // ===== 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 body = this.toNativeRequest(request, false);
const response = await fetch(`${this.config.baseURL}/chat/completions`, { const response = await fetch(`${this.config.baseURL}/chat/completions`, {
@@ -39,7 +55,8 @@ export class AgnesAdapter extends BaseAdapter {
...this.config.headers, ...this.config.headers,
}, },
body: JSON.stringify(body), 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) { if (!response.ok) {
@@ -48,12 +65,12 @@ export class AgnesAdapter extends BaseAdapter {
} }
const data = await response.json() as Record<string, unknown>; 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 { return {
meta: { meta: {
requestId: request.meta.requestId, requestId: request.meta.requestId,
provider: this.provider, provider: this.providerId,
model: (data.model as string) ?? this.config.defaultModel, model: (data.model as string) ?? this.config.defaultModel,
latencyMs: 0, latencyMs: 0,
timestamp: Date.now(), timestamp: Date.now(),
@@ -68,7 +85,8 @@ export class AgnesAdapter extends BaseAdapter {
// ===== POST /chat/completions (流式) ===== // ===== 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 body = this.toNativeRequest(request, true);
const response = await fetch(`${this.config.baseURL}/chat/completions`, { const response = await fetch(`${this.config.baseURL}/chat/completions`, {
@@ -79,7 +97,8 @@ export class AgnesAdapter extends BaseAdapter {
...this.config.headers, ...this.config.headers,
}, },
body: JSON.stringify(body), 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) { 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; 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) { 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, MetonaError,
} from '../types'; } from '../types';
import { MetonaErrorCode } from '../types'; import { MetonaErrorCode } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter';
export abstract class BaseAdapter implements IMetonaProviderAdapter { export abstract class BaseAdapter implements IMetonaProviderAdapter {
abstract readonly provider: string; // H-2 修复: provider → providerId(规范要求)
abstract readonly providerId: string;
abstract readonly supportedModels: string[]; abstract readonly supportedModels: string[];
abstract readonly supportsToolCalling: boolean; abstract readonly supportsToolCalling: boolean;
abstract readonly supportsThinking: boolean; abstract readonly supportsThinking: boolean;
/**
* C-2 修复: 外部注入的 AbortSignal(来自 Engine 的 abortController
*
* 用户点击中断时,Engine 调用 abortController.abort(),此信号触发后,
* 正在进行的 fetch 会被立即中断,避免资源泄漏。
*/
private externalAbortSignal: AbortSignal | undefined;
constructor(protected config: AdapterConfig) {} constructor(protected config: AdapterConfig) {}
abstract chat(request: MetonaRequest): Promise<MetonaResponse>; // H-2 修复: chat → send(规范要求)
abstract chatStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent>; 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> { async healthCheck(): Promise<boolean> {
try { 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 { return {
code: MetonaErrorCode.NETWORK_TIMEOUT, code: MetonaErrorCode.NETWORK_TIMEOUT,
message: error.message, message: error.message,
provider: this.provider, provider: this.providerId,
retryable: true, retryable: true,
retryAfterMs: 3000, retryAfterMs: 3000,
}; };
@@ -62,7 +132,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
return { return {
code: MetonaErrorCode.NETWORK_ERROR, code: MetonaErrorCode.NETWORK_ERROR,
message: error.message, message: error.message,
provider: this.provider, provider: this.providerId,
retryable: true, retryable: true,
retryAfterMs: 3000, retryAfterMs: 3000,
}; };
@@ -72,7 +142,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
return { return {
code: MetonaErrorCode.AUTH_INVALID, code: MetonaErrorCode.AUTH_INVALID,
message: 'API key 无效或已过期', message: 'API key 无效或已过期',
provider: this.provider, provider: this.providerId,
retryable: false, retryable: false,
}; };
} }
@@ -81,7 +151,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
return { return {
code: MetonaErrorCode.RATE_LIMITED, code: MetonaErrorCode.RATE_LIMITED,
message: '请求过于频繁,请稍后重试', message: '请求过于频繁,请稍后重试',
provider: this.provider, provider: this.providerId,
retryable: true, retryable: true,
retryAfterMs: 5000, retryAfterMs: 5000,
}; };
@@ -91,7 +161,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
return { return {
code: MetonaErrorCode.UNKNOWN, code: MetonaErrorCode.UNKNOWN,
message: error instanceof Error ? error.message : 'Unknown error', message: error instanceof Error ? error.message : 'Unknown error',
provider: this.provider, provider: this.providerId,
retryable: false, retryable: false,
}; };
} }
+62 -12
View File
@@ -13,18 +13,43 @@
import { BaseAdapter } from './base-adapter'; import { BaseAdapter } from './base-adapter';
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types'; import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
import { MetonaFinishReason, MetonaErrorCode } from '../types'; import { MetonaFinishReason, MetonaErrorCode } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter';
import { buildOpenAICompatibleMessages, buildOpenAICompatibleTools } from './shared/openai-format'; import { buildOpenAICompatibleMessages, buildOpenAICompatibleTools } from './shared/openai-format';
import { parseSSEStream, parseOpenAICompatibleResponse } from './shared/sse-stream'; import { parseSSEStream, parseOpenAICompatibleResponse } from './shared/sse-stream';
export class DeepSeekAdapter extends BaseAdapter { 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 supportedModels = ['deepseek-v4-pro', 'deepseek-v4-flash'];
readonly supportsToolCalling = true; readonly supportsToolCalling = true;
readonly supportsThinking = 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 (非流式) ===== // ===== 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 body = this.toNativeRequest(request, false);
const response = await fetch(`${this.config.baseURL}/chat/completions`, { const response = await fetch(`${this.config.baseURL}/chat/completions`, {
@@ -35,7 +60,8 @@ export class DeepSeekAdapter extends BaseAdapter {
...this.config.headers, ...this.config.headers,
}, },
body: JSON.stringify(body), 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) { if (!response.ok) {
@@ -44,12 +70,12 @@ export class DeepSeekAdapter extends BaseAdapter {
} }
const data = await response.json() as Record<string, unknown>; 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 { return {
meta: { meta: {
requestId: request.meta.requestId, requestId: request.meta.requestId,
provider: this.provider, provider: this.providerId,
model: (data.model as string) ?? this.config.defaultModel, model: (data.model as string) ?? this.config.defaultModel,
latencyMs: 0, latencyMs: 0,
timestamp: Date.now(), timestamp: Date.now(),
@@ -64,7 +90,8 @@ export class DeepSeekAdapter extends BaseAdapter {
// ===== POST /chat/completions (流式) ===== // ===== 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 body = this.toNativeRequest(request, true);
const response = await fetch(`${this.config.baseURL}/chat/completions`, { const response = await fetch(`${this.config.baseURL}/chat/completions`, {
@@ -75,7 +102,8 @@ export class DeepSeekAdapter extends BaseAdapter {
...this.config.headers, ...this.config.headers,
}, },
body: JSON.stringify(body), 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) { if (!response.ok || !response.body) {
@@ -92,19 +120,41 @@ export class DeepSeekAdapter extends BaseAdapter {
// ===== GET /models ===== // ===== GET /models =====
async listModels(): Promise<string[]> { /**
* H-2 修复: 返回 MetonaModelInfo[](规范要求)
*
* 优先尝试从 API 获取实时模型列表,并合并本地 MODEL_INFO 元数据。
* API 不可用时回退到 supportedModels。
*/
async listModels(): Promise<MetonaModelInfo[]> {
try { try {
const response = await fetch(`${this.config.baseURL}/models`, { const response = await fetch(`${this.config.baseURL}/models`, {
headers: { Authorization: `Bearer ${this.config.apiKey}` }, headers: { Authorization: `Bearer ${this.config.apiKey}` },
signal: AbortSignal.timeout(10_000), signal: AbortSignal.timeout(10_000),
}); });
if (!response.ok) return this.supportedModels; if (response.ok) {
const data = await response.json() as { data?: Array<{ id: string }> }; const data = await response.json() as { data?: Array<{ id: string }> };
return data.data?.map((m) => m.id) ?? this.supportedModels; if (data.data?.length) {
} catch { // 合并 API 返回的模型 ID 与本地元数据
return this.supportedModels; return data.data.map((m) => DeepSeekAdapter.MODEL_INFO[m.id] ?? { id: m.id });
} }
} }
} catch {
// 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 ===== // ===== GET /user/balance =====
+71 -16
View File
@@ -23,15 +23,22 @@
*/ */
import { BaseAdapter } from './base-adapter'; import { BaseAdapter } from './base-adapter';
import log from 'electron-log';
import { nanoid } from 'nanoid';
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types'; import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
import { MetonaFinishReason, MetonaStreamEventType } from '../types'; import { MetonaFinishReason, MetonaStreamEventType } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter';
export class OllamaAdapter extends BaseAdapter { 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 supportedModels = ['qwen3:latest', 'gemma3:latest', 'deepseek-r1:latest'];
readonly supportsToolCalling = true; readonly supportsToolCalling = true;
readonly supportsThinking = true; readonly supportsThinking = true;
// H-2 修复: Ollama 本地模型默认上下文窗口(可由 options.num_ctx 覆盖)
private static readonly DEFAULT_CONTEXT_WINDOW = 4096;
private baseURL: string; private baseURL: string;
constructor(config: ConstructorParameters<typeof BaseAdapter>[0]) { constructor(config: ConstructorParameters<typeof BaseAdapter>[0]) {
@@ -41,14 +48,16 @@ export class OllamaAdapter extends BaseAdapter {
// ===== POST /api/chat ===== // ===== 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 nativeRequest = this.toNativeRequest(request);
const response = await fetch(`${this.baseURL}/api/chat`, { const response = await fetch(`${this.baseURL}/api/chat`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...nativeRequest, stream: false }), 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) { if (!response.ok) {
@@ -59,14 +68,16 @@ export class OllamaAdapter extends BaseAdapter {
return this.toMetonaResponse(data, request.meta.requestId, request.meta.iteration); 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 nativeRequest = this.toNativeRequest(request);
const response = await fetch(`${this.baseURL}/api/chat`, { const response = await fetch(`${this.baseURL}/api/chat`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...nativeRequest, stream: true }), 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) { if (!response.ok || !response.body) {
@@ -133,7 +144,8 @@ export class OllamaAdapter extends BaseAdapter {
seq: seq++, seq: seq++,
timestamp: Date.now(), timestamp: Date.now(),
toolCall: { 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 ?? '', name: tc.function?.name ?? '',
args: parsedArgs, args: parsedArgs,
iteration: request.meta.iteration, iteration: request.meta.iteration,
@@ -250,18 +262,56 @@ export class OllamaAdapter extends BaseAdapter {
// ===== GET /api/tags ===== // ===== GET /api/tags =====
async listModels(): Promise<string[]> { /**
* H-2 修复: 返回 MetonaModelInfo[](规范要求)
*
* Ollama /api/tags 返回模型列表含详细信息(name, size, details),
* 转换为 MetonaModelInfo 并补充默认元数据。
*/
async listModels(): Promise<MetonaModelInfo[]> {
try { try {
const response = await fetch(`${this.baseURL}/api/tags`, { const response = await fetch(`${this.baseURL}/api/tags`, {
signal: AbortSignal.timeout(10_000), signal: AbortSignal.timeout(10_000),
}); });
if (!response.ok) return this.supportedModels; if (response.ok) {
const data = await response.json() as { models?: Array<{ name: string }> }; const data = await response.json() as {
return data.models?.map((m) => m.name) ?? this.supportedModels; models?: Array<{
} catch { name: string;
return this.supportedModels; 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 {
// 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 ===== // ===== POST /api/show =====
@@ -312,7 +362,10 @@ export class OllamaAdapter extends BaseAdapter {
try { try {
const chunk = JSON.parse(line); const chunk = JSON.parse(line);
onProgress?.({ status: chunk.status, completed: chunk.completed, total: chunk.total }); 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'), ].filter(Boolean).join('\n\n'),
}, },
...request.messages.filter((m) => m.role !== 'system').map((m) => { ...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: 前缀) // Ollama 图片使用 images 字段(纯 base64 数组,不含 data: 前缀)
if (m.images?.length) { if (m.images?.length) {
msg.images = m.images.map((img) => { msg.images = m.images.map((img) => {
@@ -439,7 +493,7 @@ export class OllamaAdapter extends BaseAdapter {
return { return {
meta: { meta: {
requestId, requestId,
provider: this.provider, provider: this.providerId,
model: (data.model as string) ?? this.config.defaultModel, model: (data.model as string) ?? this.config.defaultModel,
latencyMs: 0, latencyMs: 0,
timestamp: Date.now(), timestamp: Date.now(),
@@ -464,7 +518,8 @@ export class OllamaAdapter extends BaseAdapter {
args = {}; args = {};
} }
return { return {
id: `tc_${Date.now()}_${i}`, // L-9 修复(审计补充): 非流式路径统一使用 nanoid,与流式路径(sendStream)保持一致
id: `tc_${nanoid(8)}`,
name: (fn?.name as string) ?? '', name: (fn?.name as string) ?? '',
args, args,
iteration, iteration,
+56 -51
View File
@@ -12,6 +12,52 @@ import { nanoid } from 'nanoid';
import type { MetonaStreamEvent, MetonaTokenUsage } from '../../types'; import type { MetonaStreamEvent, MetonaTokenUsage } from '../../types';
import { MetonaStreamEventType } 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 流式响应 * 解析 OpenAI 兼容 SSE 流式响应
* *
@@ -29,7 +75,7 @@ export async function* parseSSEStream(
): AsyncGenerator<MetonaStreamEvent> { ): AsyncGenerator<MetonaStreamEvent> {
const reader = responseBody.getReader(); const reader = responseBody.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let seq = 0; const seqRef = { seq: 0 };
let buffer = ''; let buffer = '';
// 工具调用缓冲区:index → { name, argsBuffer } // 工具调用缓冲区:index → { name, argsBuffer }
@@ -50,36 +96,15 @@ export async function* parseSSEStream(
// 流结束 // 流结束
if (data === '[DONE]') { if (data === '[DONE]') {
// 将缓冲区中未完成拼接的工具调用发送 // L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
for (const [index, buf] of toolCallsBuffer) { yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
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();
yield { yield {
type: MetonaStreamEventType.DONE, type: MetonaStreamEventType.DONE,
requestId, requestId,
sessionId, sessionId,
iteration, iteration,
seq: seq++, seq: seqRef.seq++,
timestamp: Date.now(), timestamp: Date.now(),
}; };
return; return;
@@ -96,7 +121,7 @@ export async function* parseSSEStream(
requestId, requestId,
sessionId, sessionId,
iteration, iteration,
seq: seq++, seq: seqRef.seq++,
timestamp: Date.now(), timestamp: Date.now(),
delta: delta.content, delta: delta.content,
}; };
@@ -109,7 +134,7 @@ export async function* parseSSEStream(
requestId, requestId,
sessionId, sessionId,
iteration, iteration,
seq: seq++, seq: seqRef.seq++,
timestamp: Date.now(), timestamp: Date.now(),
delta: delta.reasoning_content, delta: delta.reasoning_content,
}; };
@@ -131,7 +156,7 @@ export async function* parseSSEStream(
requestId, requestId,
sessionId, sessionId,
iteration, iteration,
seq: seq++, seq: seqRef.seq++,
timestamp: Date.now(), timestamp: Date.now(),
toolCallDelta: { toolCallDelta: {
index: idx, index: idx,
@@ -158,7 +183,7 @@ export async function* parseSSEStream(
requestId, requestId,
sessionId, sessionId,
iteration, iteration,
seq: seq++, seq: seqRef.seq++,
timestamp: Date.now(), timestamp: Date.now(),
usage, usage,
}; };
@@ -167,28 +192,8 @@ export async function* parseSSEStream(
// 非 [DONE] 但 finish_reason 为 tool_calls 时提前 flush 缓冲区 // 非 [DONE] 但 finish_reason 为 tool_calls 时提前 flush 缓冲区
const finishReason = chunk.choices?.[0]?.finish_reason as string | undefined; const finishReason = chunk.choices?.[0]?.finish_reason as string | undefined;
if (finishReason === 'tool_calls') { if (finishReason === 'tool_calls') {
for (const [index, buf] of toolCallsBuffer) { // L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
try { yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
yield {
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
requestId,
sessionId,
iteration,
seq: seq++,
timestamp: Date.now(),
toolCall: {
id: `tc_${nanoid(8)}`,
name: buf.name,
args: buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {},
iteration,
timestamp: Date.now(),
},
};
} catch {
// JSON 解析失败,跳过
}
}
toolCallsBuffer.clear();
} }
} catch { } catch {
// 跳过解析失败的行 // 跳过解析失败的行
+176 -76
View File
@@ -12,6 +12,7 @@
*/ */
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { resolve } from 'path';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { import {
AgentLoopState, AgentLoopState,
@@ -33,7 +34,7 @@ import type {
IMetonaProviderAdapter, IMetonaProviderAdapter,
MetonaToolDef, MetonaToolDef,
} from '../types'; } from '../types';
import { MetonaStreamEventType, MetonaFinishReason } from '../types'; import { MetonaStreamEventType, MetonaFinishReason, MetonaErrorCode } from '../types';
import { estimateMessagesTokens } from '../utils/token-estimator'; import { estimateMessagesTokens } from '../utils/token-estimator';
import log from 'electron-log'; import log from 'electron-log';
@@ -175,6 +176,11 @@ export class AgentLoopEngine extends EventEmitter {
this.currentSessionId = sessionId; this.currentSessionId = sessionId;
this.totalTokens = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; this.totalTokens = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
this.abortController = new AbortController(); this.abortController = new AbortController();
// C-2 修复: 将 abortController 的 signal 注入到 adapter
// 使正在进行的 fetch 可被用户中断,避免资源泄漏
if (this.adapter.setAbortSignal) {
this.adapter.setAbortSignal(this.abortController.signal);
}
this.eventSeq = 0; this.eventSeq = 0;
// v0.3.0: 重置工具调用历史(用于死循环检测) // v0.3.0: 重置工具调用历史(用于死循环检测)
this.toolCallHistory = []; this.toolCallHistory = [];
@@ -297,6 +303,10 @@ export class AgentLoopEngine extends EventEmitter {
this.aborted = true; this.aborted = true;
this.abortController?.abort(); this.abortController?.abort();
this.abortController = null; this.abortController = null;
// C-2 修复: 清除 adapter 的 abort signal,防止旧的已 abort signal 影响后续请求
if (this.adapter.setAbortSignal) {
this.adapter.setAbortSignal(undefined);
}
this.emit('aborted'); this.emit('aborted');
} }
@@ -348,7 +358,8 @@ export class AgentLoopEngine extends EventEmitter {
// 过滤掉 RETRY 类型的 ERROR 事件 — 不转发到前端,避免触发虚假错误 UI // 过滤掉 RETRY 类型的 ERROR 事件 — 不转发到前端,避免触发虚假错误 UI
// RETRY 事件仅用于 Engine 内部清空缓冲区(见下方 switch 分支) // 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 = ''; fullContent = '';
reasoningContent = ''; reasoningContent = '';
@@ -410,26 +421,8 @@ export class AgentLoopEngine extends EventEmitter {
// === v0.2.0: PARSING 状态 — 解析流式缓冲区中的工具调用 === // === v0.2.0: PARSING 状态 — 解析流式缓冲区中的工具调用 ===
await this.transitionTo(AgentLoopState.PARSING); await this.transitionTo(AgentLoopState.PARSING);
// 处理缓冲区中的工具调用(TOOL_CALL_DELTA 拼接 // L-19 修复: 提取 finalizeToolCallsFromBuffer 子方法(PARSING 阶段
if (toolCallsBuffer.size > 0 && (!step.toolCalls || step.toolCalls.length === 0)) { this.finalizeToolCallsFromBuffer(step, toolCallsBuffer);
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(),
});
}
}
// 记录 Thought // 记录 Thought
if (fullContent || reasoningContent) { if (fullContent || reasoningContent) {
@@ -470,49 +463,9 @@ export class AgentLoopEngine extends EventEmitter {
// transitionTo 已发射 stateChange 事件,无需重复 emit // transitionTo 已发射 stateChange 事件,无需重复 emit
await this.transitionTo(AgentLoopState.EXECUTING); await this.transitionTo(AgentLoopState.EXECUTING);
// 并行执行所有工具调用(独立工具之间无依赖,可安全并发 // L-19 修复: 提取 executeToolCallsParallel 子方法(EXECUTING 阶段
const executeAndForward = async (tc: MetonaToolCall): Promise<MetonaToolResult> => { // 返回 null 表示被 abort 中断
const result = await this.executeToolSafely(tc); const raceResult = await this.executeToolCallsParallel(step.toolCalls, request.meta.requestId, sessionId);
// 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);
}
if (raceResult === null) { if (raceResult === null) {
// 被 abort 中断,标记步骤并退出 // 被 abort 中断,标记步骤并退出
@@ -521,8 +474,7 @@ export class AgentLoopEngine extends EventEmitter {
return step; return step;
} }
const settledResults = raceResult; step.toolResults = raceResult.map((result, idx) => {
step.toolResults = settledResults.map((result, idx) => {
const tc = step.toolCalls![idx]; const tc = step.toolCalls![idx];
if (result.status === 'fulfilled') return result.value; if (result.status === 'fulfilled') return result.value;
// rejected:构造失败 result // 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 管道) * 安全执行工具调用(经过 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),确保工具能跑满自己声明的超时 // 兜底超时取 max(配置值, 工具自定义 timeoutMs),确保工具能跑满自己声明的超时
const configuredTimeout = this.config.toolExecutionTimeoutMs ?? 120_000; const configuredTimeout = this.config.toolExecutionTimeoutMs ?? 120_000;
const toolDef = this.toolRegistry.get(toolCall.name)?.definition; const toolDef = this.toolRegistry.get(toolCall.name)?.definition;
const toolTimeout = Math.max(configuredTimeout, toolDef?.timeoutMs ?? 0); const toolTimeout = Math.max(configuredTimeout, toolDef?.timeoutMs ?? 0);
let toolResult: MetonaToolResult; let toolResult: MetonaToolResult;
// M-16 修复: 使用 try/finally 清理 setTimeout,防止事件循环 timer 堆积
// 默认 120 秒超时下,多轮迭代会堆积大量未触发 timer
let engineTimer: ReturnType<typeof setTimeout> | undefined;
try { try {
toolResult = await Promise.race([ toolResult = await Promise.race([
this.toolRegistry.execute(toolCall, { this.toolRegistry.execute(toolCall, {
@@ -641,9 +702,12 @@ export class AgentLoopEngine extends EventEmitter {
iteration: this.currentIteration, iteration: this.currentIteration,
requestId: this.currentRequestId, requestId: this.currentRequestId,
}), }),
new Promise<MetonaToolResult>((_, reject) => new Promise<MetonaToolResult>((_, reject) => {
setTimeout(() => reject(new Error(`Tool '${toolCall.name}' timed out after ${toolTimeout}ms`)), toolTimeout), engineTimer = setTimeout(
), () => reject(new Error(`Tool '${toolCall.name}' timed out after ${toolTimeout}ms`)),
toolTimeout,
);
}),
]); ]);
} catch (err) { } catch (err) {
toolResult = { toolResult = {
@@ -660,6 +724,9 @@ export class AgentLoopEngine extends EventEmitter {
await hook.afterExecute(toolCall, toolResult, this.currentSessionId); await hook.afterExecute(toolCall, toolResult, this.currentSessionId);
} }
return toolResult; return toolResult;
} finally {
// M-16 修复: 清理未触发的 timeout timer
if (engineTimer) clearTimeout(engineTimer);
} }
// 后置 Hook 管道 // 后置 Hook 管道
@@ -670,6 +737,38 @@ export class AgentLoopEngine extends EventEmitter {
return toolResult; 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: 指数退避) * 带重试的流式调用(v0.2.0: 指数退避)
* *
@@ -687,10 +786,11 @@ export class AgentLoopEngine extends EventEmitter {
try { try {
// 首次尝试直接 yield // 首次尝试直接 yield
if (attempt === 0) { if (attempt === 0) {
yield* this.adapter.chatStream(request); yield* this.adapter.sendStream(request);
return; return;
} }
// 重试时:先发送一个 retry 事件,让 UI 清空已接收的 delta // 重试时:先发送一个 retry 事件,让 UI 清空已接收的 delta
// H-11 修复: 使用 MetonaErrorCode.RETRY 替代 'RETRY' as never,移除不安全的类型断言
yield { yield {
type: MetonaStreamEventType.ERROR, type: MetonaStreamEventType.ERROR,
requestId: request.meta.requestId, requestId: request.meta.requestId,
@@ -699,12 +799,12 @@ export class AgentLoopEngine extends EventEmitter {
seq: 0, seq: 0,
timestamp: Date.now(), timestamp: Date.now(),
error: { error: {
code: 'RETRY' as never, code: MetonaErrorCode.RETRY,
message: `Retrying after error (attempt ${attempt + 1}/${this.config.retryCount + 1})`, message: `Retrying after error (attempt ${attempt + 1}/${this.config.retryCount + 1})`,
retryable: true, retryable: true,
}, },
} as MetonaStreamEvent; };
yield* this.adapter.chatStream(request); yield* this.adapter.sendStream(request);
return; return;
} catch (error) { } catch (error) {
lastError = error; lastError = error;
@@ -861,7 +961,7 @@ export class AgentLoopEngine extends EventEmitter {
// 不截断单条消息——摘要请求是独立 API 调用,不共享主对话上下文窗口 // 不截断单条消息——摘要请求是独立 API 调用,不共享主对话上下文窗口
const conversationText = toCompress.map((m) => { const conversationText = toCompress.map((m) => {
const role = m.role.toUpperCase(); const role = m.role.toUpperCase();
return `[${role}] ${m.content}`; return `[${role}] ${m.content ?? ''}`;
}).join('\n\n'); }).join('\n\n');
const summaryRequest: MetonaRequest = { const summaryRequest: MetonaRequest = {
@@ -892,7 +992,7 @@ export class AgentLoopEngine extends EventEmitter {
}; };
try { try {
const response = await this.adapter.chat(summaryRequest); const response = await this.adapter.send(summaryRequest);
const summary = response.content.trim(); const summary = response.content.trim();
if (!summary) return null; if (!summary) return null;
+14 -3
View File
@@ -28,6 +28,10 @@ export class ConfirmationHook implements PreToolHook {
/** 需要确认的风险等级 */ /** 需要确认的风险等级 */
private static REQUIRES_CONFIRMATION = ['high', 'critical']; 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>(); private toolDefs = new Map<string, MetonaToolDef>();
@@ -112,8 +116,11 @@ export class ConfirmationHook implements PreToolHook {
try { try {
const timeout = this.configService.get<number>('agent.confirmationTimeoutMs'); const timeout = this.configService.get<number>('agent.confirmationTimeoutMs');
if (timeout != null) { if (timeout != null) {
// 限制范围:30 秒 ~ 600 秒 // L-12 修复: 使用命名常量替代魔法数字
this.confirmationTimeoutMs = Math.min(600_000, Math.max(30_000, timeout)); this.confirmationTimeoutMs = Math.min(
ConfirmationHook.MAX_CONFIRMATION_TIMEOUT_MS,
Math.max(ConfirmationHook.MIN_CONFIRMATION_TIMEOUT_MS, timeout),
);
} }
} catch { } catch {
// 配置加载失败,使用默认值 // 配置加载失败,使用默认值
@@ -122,7 +129,11 @@ export class ConfirmationHook implements PreToolHook {
/** 设置确认超时时间(运行时更新) */ /** 设置确认超时时间(运行时更新) */
setConfirmationTimeout(ms: number): void { 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); 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) { if (entry.count >= this.maxCallsPerMinute) {
return { blocked: true, reason: `Rate limit exceeded for tool "${toolCall.name}"` }; return { blocked: true, reason: `Rate limit exceeded for tool "${toolCall.name}"` };
} }
+11 -4
View File
@@ -219,11 +219,18 @@ export class MemoryConsolidator {
try { try {
// v0.3.0 修复: 添加 30 秒超时保护,防止 LLM 响应缓慢导致 consolidator 任务挂起 // v0.3.0 修复: 添加 30 秒超时保护,防止 LLM 响应缓慢导致 consolidator 任务挂起
const timeoutPromise = new Promise<never>((_, reject) => // M-17 修复: 使用 try/finally 清理 setTimeout,防止每次会话结束时 timer 堆积
setTimeout(() => reject(new Error('LLM extraction timeout')), 30_000), let timer: ReturnType<typeof setTimeout> | undefined;
); try {
const response = await Promise.race([this.adapter.chat(request), timeoutPromise]); 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(); return response.content.trim();
} finally {
// M-17 修复: LLM 调用正常完成时清理未触发的 30 秒 timer
if (timer) clearTimeout(timer);
}
} catch (error) { } catch (error) {
log.warn('[MemoryConsolidator] LLM call failed:', (error as Error).message); log.warn('[MemoryConsolidator] LLM call failed:', (error as Error).message);
return null; return null;
+79 -62
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 相似度搜索 * TF-IDF 相似度搜索
*/ */
@@ -202,6 +257,8 @@ export class MemoryManager {
const results: SearchResult[] = []; const results: SearchResult[] = [];
const now = Date.now(); const now = Date.now();
// L-5 修复: 三段搜索统一调用 scoreAndPushMemory,消除重复的 tokenize/computeTF/vectorNorm/dotProduct 逻辑
// 搜索 episodic 记忆 // 搜索 episodic 记忆
if (!type || type === 'episodic') { if (!type || type === 'episodic') {
const rows = db.prepare(` const rows = db.prepare(`
@@ -213,30 +270,16 @@ export class MemoryManager {
}>; }>;
for (const row of rows) { for (const row of rows) {
const docText = row.content + ' ' + (row.summary ?? ''); this.scoreAndPushMemory({
const docTokens = tokenize(docText); docText: row.content + ' ' + (row.summary ?? ''),
const docTF = computeTF(docTokens); createdAt: row.created_at,
const docNorm = vectorNorm(docTF, this.idfCache); importance: row.importance,
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, id: row.id, type: 'episodic', content: row.content,
summary: row.summary ?? undefined, source: row.source as MemorySource, summary: row.summary ?? undefined,
importance: row.importance, sessionId: row.session_id ?? undefined, source: row.source as MemorySource,
createdAt: row.created_at, expiresAt: row.expires_at ?? undefined, sessionId: row.session_id ?? undefined,
score: finalScore, expiresAt: row.expires_at ?? undefined,
}); }, queryTF, queryNorm, now, results);
}
} }
} }
@@ -251,27 +294,14 @@ export class MemoryManager {
}>; }>;
for (const row of rows) { for (const row of rows) {
const docText = row.key + ' ' + row.value; this.scoreAndPushMemory({
const docTokens = tokenize(docText); docText: row.key + ' ' + row.value,
const docTF = computeTF(docTokens); createdAt: row.created_at,
const docNorm = vectorNorm(docTF, this.idfCache); importance: row.confidence,
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, id: row.id, type: 'semantic', content: row.value,
source: 'imported', importance: row.confidence, source: 'imported',
sessionId: row.source_session ?? undefined, sessionId: row.source_session ?? undefined,
createdAt: row.created_at, score: finalScore, }, queryTF, queryNorm, now, results);
});
}
} }
} }
@@ -285,27 +315,14 @@ export class MemoryManager {
}>; }>;
for (const row of rows) { for (const row of rows) {
const docText = row.key + ' ' + row.value; this.scoreAndPushMemory({
const docTokens = tokenize(docText); docText: row.key + ' ' + row.value,
const docTF = computeTF(docTokens); createdAt: row.updated_at,
const docNorm = vectorNorm(docTF, this.idfCache); importance: 0.5,
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, id: row.id, type: 'working', content: row.value,
source: 'agent_thought', importance: 0.5, source: 'agent_thought',
sessionId: row.session_id, createdAt: row.updated_at, sessionId: row.session_id,
score: finalScore, }, queryTF, queryNorm, now, results);
});
}
} }
} }
@@ -61,6 +61,19 @@ export class TaskOrchestrator extends EventEmitter {
super(); 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[] = [ export const DEFAULT_POLICIES: PermissionPolicy[] = [
// v0.3.0 修复:deniedPatterns 使用 (?:\/|["'\s,}]|$) 匹配, // v0.3.0 修复:deniedPatterns 使用 (?:\/|["'\s,}]|$) 匹配,
// 覆盖 /etc/ 和 /etc(无尾斜杠,在 JSON 字符串中后跟引号的情况) // 覆盖 /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: 'web_search', requiredLevel: PermissionLevel.READ, maxFrequency: 10 },
{ toolName: 'list_directory', requiredLevel: PermissionLevel.READ }, { toolName: 'list_directory', requiredLevel: PermissionLevel.READ },
{ toolName: 'search_files', requiredLevel: PermissionLevel.READ }, { toolName: 'search_files', requiredLevel: PermissionLevel.READ },
{ toolName: 'memory_search', 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: 'memory_store', requiredLevel: PermissionLevel.WRITE },
{ toolName: 'run_command', requiredLevel: PermissionLevel.EXTERNAL_ACTION, deniedPatterns: [/MEMORY\.md/i], requireConfirmation: true, maxFrequency: 3 }, { toolName: 'run_command', requiredLevel: PermissionLevel.EXTERNAL_ACTION, deniedPatterns: [/MEMORY\.md/i], requireConfirmation: true, maxFrequency: 3 },
{ toolName: 'web_fetch', requiredLevel: PermissionLevel.READ }, { 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 }, { toolName: 'web_browser', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 20 },
// v0.3.0 修复: 补全缺失的工具策略 — 之前这5个工具未配置策略,导致被 PolicyEngine 拦截 // v0.3.0 修复: 补全缺失的工具策略 — 之前这5个工具未配置策略,导致被 PolicyEngine 拦截
// file_editor — 精准文件编辑(WRITE),与 write_file 同级安全约束 // 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 // code_search — 基于 ripgrep 的只读搜索(READ
{ toolName: 'code_search', requiredLevel: PermissionLevel.READ }, { toolName: 'code_search', requiredLevel: PermissionLevel.READ },
// diff_viewer — 文件/文本差异对比(只读,READ) // diff_viewer — 文件/文本差异对比(只读,READ)
@@ -52,6 +55,10 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
{ toolName: 'task_manager', requiredLevel: PermissionLevel.WRITE }, { toolName: 'task_manager', requiredLevel: PermissionLevel.WRITE },
// delegate_task — 子任务委派(启动 SubAgentEXTERNAL_ACTION // delegate_task — 子任务委派(启动 SubAgentEXTERNAL_ACTION
{ toolName: 'delegate_task', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: false, maxFrequency: 5 }, { 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 { export class PolicyEngine {
@@ -90,7 +97,18 @@ export class PolicyEngine {
level: PermissionLevel; level: PermissionLevel;
requiresConfirmation: boolean; 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) { if (!policy) {
return { return {
+23 -15
View File
@@ -94,46 +94,54 @@ export class SandboxManager {
* fork bomb、PowerShell 编码执行、环境变量窃取、编码绕过等。 * fork bomb、PowerShell 编码执行、环境变量窃取、编码绕过等。
*/ */
scanCode(code: string): { safe: boolean; reason?: string } { 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 = [ const dangerousPatterns = [
// 危险模块导入 // 危险模块导入3
/require\s*\(\s*['"]child_process['"]\s*\)/i, /require\s*\(\s*['"]child_process['"]\s*\)/i,
/import\s+.*from\s+['"]fs['"]/i, /import\s+.*from\s+['"]fs['"]/i,
/import\s+.*from\s+['"]child_process['"]/i, /import\s+.*from\s+['"]child_process['"]/i,
// 代码执行 // 代码执行3
/\beval\s*\(/i, /\beval\s*\(/i,
/process\.exit/i, /process\.exit/i,
/Function\s*\(/i, /Function\s*\(/i,
// 路径遍历 // 路径遍历2
/\.\.\//i, /\.\.\//i,
/\\\.\.\\/i, // Windows ..\ /\\\.\.\\/i, // Windows ..\
// 危险命令 // 危险命令3
/\brm\s+-rf\b/i, /\brm\s+-rf\b/i,
/\bkillall\s+-9\b/i, /\bkillall\s+-9\b/i,
/\bchown\s+-R\s+\//i, /\bchown\s+-R\s+\//i,
// 重定向到系统目录 // 重定向到系统目录2
/>\s*\/dev\/null/i, />\s*\/dev\/null/i,
/>\s*\/etc\//i, />\s*\/etc\//i,
// 管道执行 // 管道执行2
/\bcurl\b.*\|\s*(bash|sh|zsh)\b/i, /\bcurl\b.*\|\s*(bash|sh|zsh)\b/i,
/\bwget\b.*\|\s*(sh|bash|zsh)\b/i, /\bwget\b.*\|\s*(sh|bash|zsh)\b/i,
// 反向 shell // 反向 shell3
/\/bin\/(bash|sh)\s+-i/i, /\/bin\/(bash|sh)\s+-i/i,
/\bnc\s+-e\b/i, /\bnc\s+-e\b/i,
/\bbash\s+-i\b/i, /\bbash\s+-i\b/i,
// Fork bomb // Fork bomb1
/:\(\)\s*\{\s*:\|:\s*&\s*\};:/i, /:\(\)\s*\{\s*:\|:\s*&\s*\};:/i,
// PowerShell 编码执行 // PowerShell 编码执行1
/powershell.*-enc(odedCommand)?\s+/i, /powershell.*-enc(odedCommand)?\s+/i,
// 环境变量窃取 // 环境变量窃取1
/env\b.*\b(GITHUB_TOKEN|API_KEY|SECRET|PASSWORD)\b/i, /env\b.*\b(GITHUB_TOKEN|API_KEY|SECRET|PASSWORD)\b/i,
// 编码绕过检测 // 编码绕过检测4)— C-5 加强 base64 解码后执行 + 任意 $() 替换
/\bbase64\b.*\|\s*(sh|bash|zsh)\b/i, // 检测 base64 解码(-d 或 --decode)后管道到 shell
/\bbase64\b.*(-d|--decode)?\b.*\|\s*(sh|bash|zsh)\b/i,
/\batob\s*\(/i, /\batob\s*\(/i,
/\bprintf\s+['"]\\x[0-9a-f]/i, /\bprintf\s+['"]\\x[0-9a-f]/i,
// 命令替换 // 检测任意 $() 命令替换中包含危险命令(扩展检测范围)
/\$\([^)]*(rm|kill|del|format|mkfs)\b/i, /\$\([^)]*(rm|kill|del|format|mkfs|chmod|chown|curl|wget|nc|bash|sh)\b/i,
// heredoc 执行 // heredoc 执行1
/<<\s*(EOF|END)\s*[\s\S]*?\b(rm|kill|del|format|mkfs)\b/i, /<<\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) { for (const pattern of dangerousPatterns) {
+123 -5
View File
@@ -8,13 +8,17 @@
* 2. 通过 SandboxManager.validatePath 校验工作目录 * 2. 通过 SandboxManager.validatePath 校验工作目录
* 3. 命令注入模式检测扩展 * 3. 命令注入模式检测扩展
* *
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具 * C-4 修复(v0.3.1):双层命令注入防御
* @see standard/开发规范.md — 使用 shell-quote 解析命令(禁止简单字符串匹配 * 1. 主层 — 使用 shell-quote 解析命令为 token 数组,对每个 token 做模式匹配
* 2. 补充层 — 保留原正则检测作为 fallback,覆盖 Windows cmd 语法
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具 + run_command 安全规则
*/ */
import { exec } from 'child_process'; import { exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import { resolve } from 'path'; import { resolve } from 'path';
import { parse as shellQuoteParse } from 'shell-quote';
import log from 'electron-log'; import log from 'electron-log';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types'; 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 docs/MetonaAI-Desktop 架构与交互设计.html — run_command 安全规则
* @see standard/开发规范.md — 使用 shell-quote 解析命令(禁止简单字符串匹配)
*/ */
private validateCommand(command: string): { allowed: boolean; reason?: string } { private validateCommand(command: string): { allowed: boolean; reason?: string } {
const cmd = command.trim().toLowerCase(); 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' }; return { allowed: false, reason: 'Access denied: MEMORY.md is managed by the memory system and cannot be accessed via command execution' };
} }
// 硬阻止列表(绝对禁止执行) // ===== 主层: shell-quote token-level 检测 =====
// v0.2.0: 扩展危险命令检测模式 // 解析失败(Windows cmd 语法等)时降级到正则补充层
const tokenBlock = this.checkTokens(command);
if (tokenBlock !== null) return tokenBlock;
// ===== 补充层: 原正则检测(保留所有原模式) =====
// 覆盖 shell-quote 无法解析的场景:
// - Windows cmd 语法(&、|、>nul、chcp 等)
// - 复杂管道序列(curl ... | sh
// - Fork bomb 等特殊语法
const hardBlocks = [ const hardBlocks = [
// 文件系统破坏 // 文件系统破坏
{ pattern: /\brm\b.*\//, reason: 'rm with absolute path is forbidden' }, { pattern: /\brm\b.*\//, reason: 'rm with absolute path is forbidden' },
@@ -218,4 +235,105 @@ export class RunCommandTool implements IMetonaTool {
return { allowed: true }; 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 { resolve, sep } from 'path';
import { realpathSync } from 'fs';
/** /**
* *
@@ -43,6 +44,14 @@ export function isProtectedWorkspaceFile(
* *
* `/home/user/app-evil` `/home/user/app` * `/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 filePath
* @param workspacePath * @param workspacePath
* @returns true * @returns true
@@ -53,7 +62,21 @@ export function isPathWithinWorkspace(
): boolean { ): boolean {
const resolved = resolve(workspacePath, filePath); const resolved = resolve(workspacePath, filePath);
const workspaceRoot = resolve(workspacePath); 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 * @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 { join, relative, resolve, dirname } from 'path';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
@@ -42,11 +42,12 @@ function matchGlob(name: string, glob: string): boolean {
/** 二进制文件检测:读取前 8KB 检查是否含 NULL 字节 */ /** 二进制文件检测:读取前 8KB 检查是否含 NULL 字节 */
async function isBinaryFile(filePath: string): Promise<boolean> { async function isBinaryFile(filePath: string): Promise<boolean> {
// M-20 修复: 使用 finally 块确保文件句柄关闭,防止 fd 泄漏
let fd: FileHandle | null = null;
try { try {
const buffer = Buffer.alloc(8192); 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.read(buffer, 0, 8192, 0);
await fd.close();
// 含 NULL 字节 → 二进制 // 含 NULL 字节 → 二进制
for (let i = 0; i < buffer.length; i++) { for (let i = 0; i < buffer.length; i++) {
if (buffer[i] === 0) return true; if (buffer[i] === 0) return true;
@@ -54,6 +55,11 @@ async function isBinaryFile(filePath: string): Promise<boolean> {
return false; return false;
} catch { } catch {
return false; return false;
} finally {
// M-20 修复: 无论 read 成功或失败,都关闭文件句柄
if (fd) {
try { await fd.close(); } catch { /* 忽略关闭错误 */ }
}
} }
} }
@@ -90,17 +90,45 @@ export async function fetchWithTimeout(
// ===== URL 标准化(去重用) ===== // ===== 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 { export function normalizeUrl(url: string): string {
try { try {
const u = new URL(url); const u = new URL(url);
// 去除追踪参数 // 去除追踪参数
const trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'gclid', 'fbclid']; const trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'gclid', 'fbclid'];
for (const p of trackingParams) u.searchParams.delete(p); for (const p of trackingParams) u.searchParams.delete(p);
// H-6 增强: 排序查询参数(确保参数顺序一致,便于去重)
// searchParams.sort() 原地排序,URLSearchParams 按码点顺序
u.searchParams.sort();
// 去除尾部斜杠(根路径除外) // 去除尾部斜杠(根路径除外)
let path = u.pathname; let path = u.pathname;
if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1); 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 // 强制小写 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 { } catch {
return url; return url;
} }
+48 -19
View File
@@ -40,6 +40,10 @@ export class WebFetchTool implements IMetonaTool {
type: 'object', type: 'object',
properties: { properties: {
url: { type: 'string', description: 'Target URL (http/https only)' }, 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)' }, mobile_ua: { type: 'boolean', description: 'Use mobile User-Agent (default false)' },
retry: { type: 'boolean', description: 'Enable retry with exponential backoff (default true)' }, 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 url = args.url as string;
const mobileUA = (args.mobile_ua as boolean) ?? false; const mobileUA = (args.mobile_ua as boolean) ?? false;
const enableRetry = (args.retry as boolean) ?? true; 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)) { if (!url || !/^https?:\/\//i.test(url)) {
return { url, content: '', success: false, error: 'URL must start with http:// or https://' }; 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); const cached = fetchCache.get(url);
if (cached) { if (cached) {
logTool('web_fetch', `Cache hit: ${url}`); 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}`); logTool('web_fetch', `Fetching: ${url}`);
@@ -73,24 +80,29 @@ export class WebFetchTool implements IMetonaTool {
const phase1Result = await this.httpFetch(url, mobileUA, enableRetry); const phase1Result = await this.httpFetch(url, mobileUA, enableRetry);
if (phase1Result.success && !phase1Result.intercepted) { if (phase1Result.success && !phase1Result.intercepted) {
// 内容过短检测 → Phase 2 升级 // 根据 extract_mode 选择返回内容:'html' 模式返回清理后的 HTML'text' 模式返回纯文本
if (phase1Result.text.length < 200) { const phase1Content = extractMode === 'html' ? phase1Result.html : phase1Result.text;
logTool('web_fetch', `Phase 2: Content too short (${phase1Result.text.length} chars), upgrading to browser`);
// 内容过短检测 → 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); const browserResult = await this.browserFetch(url);
if (browserResult) { if (browserResult) {
return this.buildSuccess(url, browserResult, 'browser'); return this.buildSuccess(url, browserResult, 'browser', maxChars);
} }
} }
// 写入缓存 // 写入缓存(仅缓存 text 模式的内容,html 模式不缓存以避免模式混淆)
fetchCache.set(url, phase1Result.text); if (extractMode === 'text') {
return this.buildSuccess(url, phase1Result.text, 'http'); fetchCache.set(url, phase1Content);
}
return this.buildSuccess(url, phase1Content, 'http', maxChars, extractMode);
} }
// ===== Phase 3: 浏览器回退 ===== // ===== Phase 3: 浏览器回退 =====
logTool('web_fetch', `Phase 3: Falling back to browser (${phase1Result.reason})`); logTool('web_fetch', `Phase 3: Falling back to browser (${phase1Result.reason})`);
const browserResult = await this.browserFetch(url); const browserResult = await this.browserFetch(url);
if (browserResult) { 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, url: string,
mobileUA: boolean, mobileUA: boolean,
enableRetry: 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 maxRetries = enableRetry ? 3 : 1;
const backoffBase = 2_000; const backoffBase = 2_000;
@@ -119,7 +131,7 @@ export class WebFetchTool implements IMetonaTool {
// 跳过重试的状态码 → 直接进入浏览器回退 // 跳过重试的状态码 → 直接进入浏览器回退
if (SKIP_RETRY_STATUS.has(response.status)) { 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) { 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); await this.sleep(backoffBase * Math.pow(2, attempt) + Math.random() * backoffBase * 0.6);
continue; 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 限制) // 读取正文(10MB 限制)
@@ -136,12 +148,13 @@ export class WebFetchTool implements IMetonaTool {
// 拦截检测 // 拦截检测
if (isInterceptedPage(html)) { 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 → 纯文本 // HTML → 纯文本
const text = htmlToText(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) { } catch (err) {
const errorMsg = (err as Error).message; const errorMsg = (err as Error).message;
if (attempt < maxRetries - 1) { 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); await this.sleep(backoffBase * Math.pow(2, attempt) + Math.random() * backoffBase * 0.6);
continue; 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 单例) ===== // ===== 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 { return {
url, url,
content: text, content,
success: true, success: true,
method, 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 startTs = Date.now();
const timeoutMs = tool.definition.timeoutMs; const timeoutMs = tool.definition.timeoutMs;
// M-15 修复: 使用 try/finally 清理 setTimeout,防止事件循环 timer 堆积
// 工具正常完成时未触发的 timer 会持续占用事件循环 timeoutMs 毫秒
let timer: ReturnType<typeof setTimeout> | undefined;
try { try {
// 带超时执行 — 使用 Promise.race 防止工具卡死阻塞 Agent Loop // 带超时执行 — 使用 Promise.race 防止工具卡死阻塞 Agent Loop
const result = await Promise.race([ const result = await Promise.race([
tool.execute(toolCall.args, context), tool.execute(toolCall.args, context),
new Promise<never>((_, reject) => { new Promise<never>((_, reject) => {
setTimeout( timer = setTimeout(
() => reject(new Error(`Tool execution timed out after ${timeoutMs}ms`)), () => reject(new Error(`Tool execution timed out after ${timeoutMs}ms`)),
timeoutMs, timeoutMs,
); );
@@ -130,6 +133,9 @@ export class ToolRegistry {
durationMs: Date.now() - startTs, durationMs: Date.now() - startTs,
timestamp: Date.now(), timestamp: Date.now(),
}; };
} finally {
// M-15 修复: 无论工具成功或失败,清理 timeout timer
if (timer) clearTimeout(timer);
} }
} }
+52 -9
View File
@@ -3,11 +3,37 @@
* *
* LLM Provider * LLM Provider
* *
* H-2 修复: 接口对齐规范
* @see docs/MetonaAI-Desktop API请求与响应标准.html * @see docs/MetonaAI-Desktop API请求与响应标准.html
* - provider providerId
* - chat send
* - chatStream sendStream
* - getContextWindow()
* - listModels MetonaModelInfo[]
*/ */
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from './index'; 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 * Provider
*/ */
@@ -37,10 +63,10 @@ export interface AdapterConfig {
* API 穿 * API 穿
*/ */
export interface IMetonaProviderAdapter { export interface IMetonaProviderAdapter {
/** Provider 标识 */ /** H-2 修复: Provider 标识(规范要求使用 providerId */
readonly provider: string; readonly providerId: string;
/** 支持的模型列表 */ /** 支持的模型列表(保留作为便捷访问) */
readonly supportedModels: string[]; readonly supportedModels: string[];
/** 是否支持 Tool Calling */ /** 是否支持 Tool Calling */
@@ -50,15 +76,32 @@ export interface IMetonaProviderAdapter {
readonly supportsThinking: boolean; 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 * @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 * Provider
@@ -66,7 +109,7 @@ export interface IMetonaProviderAdapter {
healthCheck(): Promise<boolean>; 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 { export interface MetonaMessage {
role: 'system' | 'user' | 'assistant' | 'tool'; 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)思考/推理内容 */ /** (仅 assistant)思考/推理内容 */
reasoningContent?: string; reasoningContent?: string;
+10
View File
@@ -79,6 +79,11 @@ export interface MetonaResponse {
// ===== 流式事件 ===== // ===== 流式事件 =====
export enum MetonaStreamEventType { 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', TEXT_DELTA = 'text_delta',
REASONING_DELTA = 'reasoning_delta', REASONING_DELTA = 'reasoning_delta',
TOOL_CALL_DELTA = 'tool_call_delta', TOOL_CALL_DELTA = 'tool_call_delta',
@@ -188,4 +193,9 @@ export enum MetonaErrorCode {
USER_ABORTED = 'user_aborted', USER_ABORTED = 'user_aborted',
TIMEOUT = 'timeout', TIMEOUT = 'timeout',
UNKNOWN = 'unknown', 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]/; 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 * token
* @param text * @param text null/undefined 0 token
* @returns token * @returns token
*/ */
export function estimateStringTokens(text: string): number { export function estimateStringTokens(text: string | null | undefined): number {
if (!text || text.length === 0) return 0; if (!text || text.length === 0) return 0;
let cjkCount = 0; let cjkCount = 0;
@@ -39,8 +48,8 @@ export function estimateStringTokens(text: string): number {
} }
} }
// 中文字符 1.5 token/字,ASCII 0.25 token/字,其他 1 token/ // L-17 修复: 使用命名常量替代魔法数
return Math.ceil(cjkCount * 1.5 + asciiCount * 0.25 + otherCount); 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 * 4 token role OpenAI
* *
* @param messages * @param messages content null tool_calls assistant
* @returns token * @returns token
*/ */
export function estimateMessagesTokens(messages: Array<{ export function estimateMessagesTokens(messages: Array<{
content: string; content: string | null;
reasoningContent?: string; reasoningContent?: string;
toolCalls?: Array<{ args: Record<string, unknown> }>; toolCalls?: Array<{ args: Record<string, unknown> }>;
}>): number { }>): number {
@@ -65,8 +74,8 @@ export function estimateMessagesTokens(messages: Array<{
total += estimateStringTokens(JSON.stringify(tc.args)); total += estimateStringTokens(JSON.stringify(tc.args));
} }
} }
// 每条消息的结构性开销(role、分隔符) // L-17 修复: 使用命名常量替代魔法数字
total += 4; total += MSG_OVERHEAD_TOKENS;
} }
return total; return total;
} }
+398 -48
View File
@@ -17,14 +17,15 @@ import type { WorkspaceService } from '../services/workspace.service';
import type { ContextBuilder } from '../harness/prompts/context-builder'; import type { ContextBuilder } from '../harness/prompts/context-builder';
import type { AgentLoopEngine } from '../harness/agent-loop'; import type { AgentLoopEngine } from '../harness/agent-loop';
import type { ToolRegistry } from '../harness/tools/registry'; 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 { 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 { MCPManager } from '../services/mcp-manager.service';
import type { PromptInjectionDefender } from '../harness/security/prompt-injection-defense'; import type { PromptInjectionDefender } from '../harness/security/prompt-injection-defense';
import type { OutputValidator } from '../harness/verification/output-validator'; import type { OutputValidator } from '../harness/verification/output-validator';
import type { ConfirmationHook } from '../harness/hooks/confirmation-hook'; import type { ConfirmationHook } from '../harness/hooks/confirmation-hook';
import type { MemoryConsolidator } from '../harness/memory/consolidator'; import type { MemoryConsolidator } from '../harness/memory/consolidator';
import type { TaskOrchestrator } from '../harness/orchestration/orchestrator';
import type { MetonaMessage, MetonaStreamEvent } from '../harness/types'; import type { MetonaMessage, MetonaStreamEvent } from '../harness/types';
import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types'; import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types';
import type { MetonaError } from '../harness/types'; import type { MetonaError } from '../harness/types';
@@ -51,12 +52,22 @@ export function registerAllIPCHandlers(
outputValidator: OutputValidator, outputValidator: OutputValidator,
confirmationHook: ConfirmationHook, confirmationHook: ConfirmationHook,
memoryConsolidator: MemoryConsolidator, memoryConsolidator: MemoryConsolidator,
orchestrator: TaskOrchestrator,
): void { ): void {
// ===== Agent 交互 ===== // ===== Agent 交互 =====
ipcMain.handle('agent:sendMessage', async (_event, userMessage: MetonaMessage, sessionId: string) => { 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 发送) // 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送)
if (!reloadAdapter()) { if (!reloadAdapter()) {
@@ -258,10 +269,16 @@ export function registerAllIPCHandlers(
// 只有当有内容、思考内容或工具调用时才保存 // 只有当有内容、思考内容或工具调用时才保存
if (step.thought.content || step.thought.reasoningContent || toolCallsWithResults?.length) { 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({ sessionService.saveMessage({
sessionId, sessionId,
role: 'assistant', role: 'assistant',
content: step.thought.content, content: assistantContent,
reasoningContent: step.thought.reasoningContent || undefined, reasoningContent: step.thought.reasoningContent || undefined,
toolCalls: toolCallsWithResults, toolCalls: toolCallsWithResults,
iteration: step.iteration, iteration: step.iteration,
@@ -413,44 +430,81 @@ export function registerAllIPCHandlers(
return sessionService.create(title); 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) }; 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) }; 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); 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) }; 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) }; 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) }; 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) }; 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 { try {
sessionService.saveTraceData(sessionId, data); sessionService.saveTraceData(sessionId, {
traceSteps: traceData.traceSteps,
tokenUsage: traceData.tokenUsage,
});
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
return { success: false, error: (error as Error).message }; 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); 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 }) => { 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 { try {
await mcpManager.addServer({ await mcpManager.addServer({
name: config.name, name: config.name,
transport: config.transport as 'stdio' | 'sse', transport: config.transport, // 已校验,无需断言
command: config.command, command: config.command,
args: config.args, args: config.args,
url: config.url, url: config.url,
@@ -478,6 +556,10 @@ export function registerAllIPCHandlers(
}); });
ipcMain.handle('mcp:removeServer', async (_event, name: string) => { 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 { try {
await mcpManager.removeServer(name); await mcpManager.removeServer(name);
return { success: true }; return { success: true };
@@ -487,6 +569,13 @@ export function registerAllIPCHandlers(
}); });
ipcMain.handle('mcp:toggleServer', async (_event, name: string, enabled: boolean) => { 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 { try {
await mcpManager.toggleServer(name, enabled); await mcpManager.toggleServer(name, enabled);
return { success: true }; return { success: true };
@@ -497,8 +586,35 @@ export function registerAllIPCHandlers(
// ===== 记忆 ===== // ===== 记忆 =====
ipcMain.handle('db:searchMemories', async (_event, query, options) => { ipcMain.handle('db:searchMemories', async (_event, query: unknown, options: unknown) => {
return memoryManager.search(query, options); // 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,24 +623,59 @@ export function registerAllIPCHandlers(
return configService.get(key); 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); 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 层:记录配置变更 // TOOL 层:记录配置变更
auditService.log({ auditService.log({
sessionId: '', sessionId: '',
eventType: 'config_change', eventType: 'config_change',
actor: 'user', actor: 'user',
target: key, target: key,
details: { value }, details: { value: auditValue },
outcome: 'success', outcome: 'success',
}); });
// LLM 相关配置变更时热重载 Adapter // LLM 相关配置变更时热重载 Adapter
if (['llm.provider', 'llm.model', 'llm.apiKey', 'llm.baseURL', 'ollama.numCtx'].includes(key)) { if (['llm.provider', 'llm.model', 'llm.apiKey', 'llm.baseURL', 'ollama.numCtx'].includes(key)) {
reloadAdapter(); // 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`); log.info(`[CONFIG] LLM config changed (${key}), adapter reloaded`);
} }
}
// Agent 配置变更时热更新 Engine // Agent 配置变更时热更新 Engine
if (key === 'agent.maxIterations') { if (key === 'agent.maxIterations') {
@@ -535,13 +686,17 @@ export function registerAllIPCHandlers(
log.info(`[CONFIG] Agent totalTimeoutMs updated to ${value}`); log.info(`[CONFIG] Agent totalTimeoutMs updated to ${value}`);
} else if (key === 'agent.enableThinking') { } else if (key === 'agent.enableThinking') {
agentLoop.updateConfig({ thinkingEnabled: value as boolean }); 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}`); log.info(`[CONFIG] Agent thinkingEnabled updated to ${value}`);
} else if (key === 'agent.thinkingEffort') { } else if (key === 'agent.thinkingEffort') {
agentLoop.updateConfig({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max' }); 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}`); log.info(`[CONFIG] Agent thinkingEffort updated to ${value}`);
} else if (key === 'ollama.numCtx') { } else if (key === 'ollama.numCtx') {
// numCtx 同时更新 Engine 的 contextLength // numCtx 同时更新 Engine 的 contextLength
agentLoop.updateConfig({ contextLength: (value as number) || undefined }); agentLoop.updateConfig({ contextLength: (value as number) || undefined });
orchestrator.updateDefaultConfig({ contextLength: (value as number) || undefined });
log.info(`[CONFIG] Agent contextLength updated to ${value}`); log.info(`[CONFIG] Agent contextLength updated to ${value}`);
} else if (key === 'agent.confirmationTimeoutMs') { } else if (key === 'agent.confirmationTimeoutMs') {
// 工具确认超时变更,即时应用到 ConfirmationHook // 工具确认超时变更,即时应用到 ConfirmationHook
@@ -585,12 +740,38 @@ export function registerAllIPCHandlers(
return app.getPath('userData'); return app.getPath('userData');
}); });
ipcMain.handle('app:openExternal', async (_event, 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); 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) => { 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); shell.showItemInFolder(path);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}); });
ipcMain.handle('app:selectFolder', async (_event, defaultPath?: string) => { 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) => { 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); configService.set(`tools.${toolName}.enabled`, enabled);
// 同步到 ToolRegistry(立即生效) // 同步到 ToolRegistry(立即生效)
@@ -828,29 +1019,45 @@ export function registerAllIPCHandlers(
}); });
ipcMain.handle('data:clearSessions', async () => { ipcMain.handle('data:clearSessions', async () => {
// M-40 修复: 危险操作审计日志,便于追踪异常调用
log.warn('[DATA] DANGER: clearSessions invoked — all sessions and messages will be deleted');
const db = sessionService.getDB(); const db = sessionService.getDB();
try { try {
db.exec('BEGIN'); 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 messages');
db.exec('DELETE FROM sessions'); db.exec('DELETE FROM sessions');
db.exec('COMMIT'); db.exec('COMMIT');
log.info('[DATA] All sessions cleared'); log.info(`[DATA] All sessions cleared: ${sessCount.c} sessions, ${msgCount.c} messages deleted`);
return { success: true }; return { success: true, deletedSessions: sessCount.c, deletedMessages: msgCount.c };
} catch (error) { } catch (error) {
try { db.exec('ROLLBACK'); } catch { /* 忽略回滚错误 */ } try { db.exec('ROLLBACK'); } catch { /* 忽略回滚错误 */ }
log.error('[DATA] clearSessions failed:', (error as Error).message);
return { success: false, error: (error as Error).message }; return { success: false, error: (error as Error).message };
} }
}); });
ipcMain.handle('data:clearMemories', async () => { ipcMain.handle('data:clearMemories', async () => {
try { // M-40 修复: 危险操作审计日志,便于追踪异常调用
log.warn('[DATA] DANGER: clearMemories invoked — all memories will be deleted');
const db = sessionService.getDB(); const db = sessionService.getDB();
try {
// 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 episodic_memories');
db.exec('DELETE FROM semantic_memories'); db.exec('DELETE FROM semantic_memories');
db.exec('DELETE FROM working_memories'); db.exec('DELETE FROM working_memories');
log.info('[DATA] All memories cleared'); db.exec('COMMIT');
return { success: true }; 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) { } 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 }; return { success: false, error: (error as Error).message };
} }
}); });
@@ -915,13 +1122,42 @@ export function registerAllIPCHandlers(
// ===== v0.2.0: 工具确认响应(ConfirmationDialog → 主进程)===== // ===== v0.2.0: 工具确认响应(ConfirmationDialog → 主进程)=====
// 使用 ipcMain.on(渲染进程通过 ipcRenderer.send 发送) // 使用 ipcMain.on(渲染进程通过 ipcRenderer.send 发送)
ipcMain.on('tool:confirmationResponse', (_event, data: { toolCallId: string; approved: boolean; remember: boolean; autoExecute?: boolean }) => { ipcMain.on('tool:confirmationResponse', (_event, data: unknown) => {
confirmationHook.resolveConfirmation(data.toolCallId, data.approved, data.remember, data.autoExecute ?? false); // M-43 修复: 校验 data 结构,防止 undefined/null 导致 TypeError
log.info(`[CONFIRM] Tool ${data.toolCallId} ${data.approved ? 'approved' : 'denied'}${data.remember ? ' (remembered)' : ''}${data.autoExecute ? ' (autoExecute)' : ''}`); 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: 持久化自动执行设置 ===== // ===== 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 { try {
confirmationHook.setAutoExecute(toolName, enabled); confirmationHook.setAutoExecute(toolName, enabled);
log.info(`[CONFIRM] Tool ${toolName} autoExecute set to ${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 { try {
return { success: true, data: auditService.query(filters as Parameters<typeof auditService.query>[0]) }; return { success: true, data: auditService.query(safeFilters) };
} catch (error) { } catch (error) {
return { success: false, error: (error as Error).message }; return { success: false, error: (error as Error).message };
} }
}); });
// ===== v0.2.0: 任务管理 IPCTaskList UI===== // ===== 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(); const db = sessionService.getDB();
let sql = 'SELECT * FROM tasks'; let sql = 'SELECT * FROM tasks';
const params: unknown[] = []; const params: unknown[] = [];
@@ -968,33 +1237,83 @@ export function registerAllIPCHandlers(
return { success: true, data: db.prepare(sql).all(...params) }; 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 db = sessionService.getDB();
const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
try { try {
db.prepare(` db.prepare(`
INSERT INTO tasks (id, session_id, title, description, status, priority, parent_id, created_at, updated_at) INSERT INTO tasks (id, session_id, title, description, status, priority, parent_id, created_at, updated_at)
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?) 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 }; return { success: true, id };
} catch (error) { } catch (error) {
return { success: false, error: (error as Error).message }; 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(); const db = sessionService.getDB();
try { try {
const fields: string[] = []; const fields: string[] = [];
const values: unknown[] = []; const values: unknown[] = [];
if (updates.title !== undefined) { fields.push('title = ?'); values.push(updates.title); } if (u.title !== undefined) { fields.push('title = ?'); values.push(u.title); }
if (updates.description !== undefined) { fields.push('description = ?'); values.push(updates.description); } if (u.description !== undefined) { fields.push('description = ?'); values.push(u.description); }
if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); } if (u.status !== undefined) { fields.push('status = ?'); values.push(u.status); }
if (updates.priority !== undefined) { fields.push('priority = ?'); values.push(updates.priority); } if (u.priority !== undefined) { fields.push('priority = ?'); values.push(u.priority); }
if (updates.assignedTo !== undefined) { fields.push('assigned_to = ?'); values.push(updates.assignedTo); } if (u.assignedTo !== undefined) { fields.push('assigned_to = ?'); values.push(u.assignedTo); }
if (fields.length === 0) return { success: true }; if (fields.length === 0) return { success: true };
fields.push('updated_at = ?'); values.push(Date.now()); 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); values.push(id);
db.prepare(`UPDATE tasks SET ${fields.join(', ')} WHERE id = ?`).run(...values); db.prepare(`UPDATE tasks SET ${fields.join(', ')} WHERE id = ?`).run(...values);
return { success: true }; 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(); const db = sessionService.getDB();
try { try {
db.prepare('DELETE FROM tasks WHERE id = ?').run(id); db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
@@ -1014,10 +1337,30 @@ export function registerAllIPCHandlers(
}); });
// ===== v0.2.0: 记忆系统增强查询(Memory Viewer UI===== // ===== 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 db = sessionService.getDB();
const limit = options?.limit ?? 100;
const type = options?.type;
const results: Record<string, unknown[]> = {}; const results: Record<string, unknown[]> = {};
try { try {
if (!type || type === 'episodic') { 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(); const db = sessionService.getDB();
try { try {
const table = type === 'episodic' ? 'episodic_memories' : type === 'semantic' ? 'semantic_memories' : 'working_memories'; const table = type === 'episodic' ? 'episodic_memories' : type === 'semantic' ? 'semantic_memories' : 'working_memories';
+50 -5
View File
@@ -222,7 +222,9 @@ async function initialize(): Promise<void> {
]; ];
// ===== Agent Loop ===== // ===== 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 ollamaNumCtx = configService.get<number>('ollama.numCtx');
const agentMaxIter = configService.get<number>('agent.maxIterations'); const agentMaxIter = configService.get<number>('agent.maxIterations');
const agentTimeout = configService.get<number>('agent.totalTimeoutMs'); const agentTimeout = configService.get<number>('agent.totalTimeoutMs');
@@ -375,7 +377,7 @@ async function initialize(): Promise<void> {
contextBuilder, agentLoop, toolRegistry, auditService, contextBuilder, agentLoop, toolRegistry, auditService,
sessionRecorder, memoryManager, mcpManager, reloadAdapter, sessionRecorder, memoryManager, mcpManager, reloadAdapter,
promptDefender, outputValidator, promptDefender, outputValidator,
confirmationHook, memoryConsolidator, confirmationHook, memoryConsolidator, orchestrator,
); );
// TODO: Initialize UpdateService for auto-update functionality // 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 统一设置) // 标记为正在退出,允许窗口关闭(两处 isQuitting 统一设置)
(global as Record<string, unknown>).isQuitting = true; (global as Record<string, unknown>).isQuitting = true;
TrayManager.markQuitting(); TrayManager.markQuitting();
windowManager?.unregisterGlobalShortcuts(); windowManager?.unregisterGlobalShortcuts();
trayManager?.destroy(); trayManager?.destroy();
windowManager?.closeAll(); windowManager?.closeAll();
try { await mcpManager.shutdown(); } catch (err) { log.error('[Shutdown] MCP shutdown failed:', err); }
// 防止重复触发(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(); cleanupBrowser();
if (databaseService) { databaseService.close(); databaseService = null; } 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.whenReady().then(initialize);
app.on('web-contents-created', (_, contents) => { 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 { existsSync, mkdirSync } from 'fs';
import log from 'electron-log'; 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 { export class DatabaseService {
private db: Database.Database | null = null; private db: Database.Database | null = null;
private dbPath: string; 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 ( CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
session_id TEXT NOT NULL, session_id TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')), role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')),
content TEXT NOT NULL, content TEXT,
reasoning_content TEXT, reasoning_content TEXT,
tool_calls TEXT, tool_calls TEXT,
tool_result TEXT, tool_result TEXT,
@@ -246,50 +255,76 @@ export class DatabaseService {
private runMigrations(): void { private runMigrations(): void {
const db = this.db!; 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 列 // 迁移 1: messages 表添加 attachments 列
try { tryAddColumn('messages', 'attachments', 'TEXT', 'attachments');
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;
}
}
// 迁移 2: audit_logs 表添加 iteration 列 // 迁移 2: audit_logs 表添加 iteration 列
try { tryAddColumn('audit_logs', 'iteration', 'INTEGER', 'iteration');
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;
}
}
// v0.2.0 迁移 3: audit_logs 表添加 prev_hash 列(链式哈希) // v0.2.0 迁移 3: audit_logs 表添加 prev_hash 列(链式哈希)
try { tryAddColumn('audit_logs', 'prev_hash', 'TEXT', 'prev_hash');
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;
}
}
// v0.2.0 迁移 4: audit_logs 表添加 current_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 { try {
db.exec('ALTER TABLE audit_logs ADD COLUMN current_hash TEXT'); // 检测 content 列是否有 NOT NULL 约束
log.info('[DB] Migration: added current_hash column to audit_logs'); const columns = db.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string; notnull: number }>;
} catch (error) { const contentCol = columns.find((c) => c.name === 'content');
const msg = error instanceof Error ? error.message : String(error); if (contentCol && contentCol.notnull === 1) {
if (!msg.includes('duplicate column')) { log.info('[DB] Migration: rebuilding messages table to allow NULL content');
throw error;
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.enableThinking', value: 'true', category: 'agent' },
{ key: 'agent.thinkingEffort', value: '"high"', category: 'agent' }, { key: 'agent.thinkingEffort', value: '"high"', category: 'agent' },
{ key: 'agent.enableReflection', value: 'false', 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' }, { key: 'security.requireWriteConfirmation', value: 'true', category: 'security' },
+11 -5
View File
@@ -29,7 +29,8 @@ export interface MessageRow {
id: string; id: string;
session_id: string; session_id: string;
role: string; role: string;
content: string; // C-6 修复: content 允许 null — assistant 消息仅有 tool_calls 时为 null
content: string | null;
reasoning_content: string | null; reasoning_content: string | null;
tool_calls: string | null; tool_calls: string | null;
tool_result: string | null; tool_result: string | null;
@@ -52,7 +53,8 @@ export interface SessionInfo {
export interface MessageInfo { export interface MessageInfo {
id: string; id: string;
role: string; role: string;
content: string; // C-6 修复: content 允许 null — assistant 消息仅有 tool_calls 时为 null
content: string | null;
reasoningContent?: string; reasoningContent?: string;
toolCalls?: unknown[]; toolCalls?: unknown[];
toolResult?: unknown; toolResult?: unknown;
@@ -192,7 +194,8 @@ export class SessionService {
saveMessage(params: { saveMessage(params: {
sessionId: string; sessionId: string;
role: string; role: string;
content: string; // C-6 修复: content 允许 null — assistant 消息仅有 tool_calls 时为 null
content: string | null;
reasoningContent?: string; reasoningContent?: string;
toolCalls?: unknown[]; toolCalls?: unknown[];
toolResult?: unknown; toolResult?: unknown;
@@ -311,14 +314,17 @@ export class SessionService {
} }
private toMessageInfo(row: MessageRow): MessageInfo { 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 { return {
id: row.id, id: row.id,
role: row.role, role: row.role,
content: row.content, content: row.content,
reasoningContent: row.reasoning_content ?? undefined, 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, 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, iteration: row.iteration ?? undefined,
timestamp: row.created_at, timestamp: row.created_at,
}; };
@@ -82,7 +82,17 @@ export class WindowManager {
}); });
win.webContents.setWindowOpenHandler(({ url }) => { win.webContents.setWindowOpenHandler(({ url }) => {
// C-8 修复: 校验 URL 协议,只允许 http/https,防止 javascript:/file: 等协议执行代码
try {
const parsed = new URL(url);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
shell.openExternal(url); 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' }; 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 * @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", "name": "metona-ai-desktop",
"version": "0.3.0", "version": "0.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.3.0", "version": "0.3.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
@@ -14,6 +14,7 @@
"@modelcontextprotocol/sdk": "^1.12.1", "@modelcontextprotocol/sdk": "^1.12.1",
"@mui/icons-material": "^9.1.1", "@mui/icons-material": "^9.1.1",
"@mui/material": "^9.1.2", "@mui/material": "^9.1.2",
"@types/shell-quote": "^1.7.5",
"better-sqlite3": "^11.9.1", "better-sqlite3": "^11.9.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"electron-log": "^5.3.3", "electron-log": "^5.3.3",
@@ -28,6 +29,7 @@
"rehype-highlight": "^7.0.2", "rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"shell-quote": "^1.10.0",
"sql.js": "^1.12.0", "sql.js": "^1.12.0",
"zod": "^3.25.67", "zod": "^3.25.67",
"zustand": "^5.0.5" "zustand": "^5.0.5"
@@ -3179,6 +3181,12 @@
"@types/node": "*" "@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": { "node_modules/@types/unist": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz",
@@ -10235,6 +10243,18 @@
"node": ">=8" "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": { "node_modules/side-channel": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz", "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", "name": "metona-ai-desktop",
"version": "0.3.0", "version": "0.3.1",
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
"main": "dist-electron/main/main.js", "main": "dist-electron/main/main.js",
"author": "Metona Team", "author": "Metona Team",
@@ -28,6 +28,7 @@
"@modelcontextprotocol/sdk": "^1.12.1", "@modelcontextprotocol/sdk": "^1.12.1",
"@mui/icons-material": "^9.1.1", "@mui/icons-material": "^9.1.1",
"@mui/material": "^9.1.2", "@mui/material": "^9.1.2",
"@types/shell-quote": "^1.7.5",
"better-sqlite3": "^11.9.1", "better-sqlite3": "^11.9.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"electron-log": "^5.3.3", "electron-log": "^5.3.3",
@@ -42,6 +43,7 @@
"rehype-highlight": "^7.0.2", "rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"shell-quote": "^1.10.0",
"sql.js": "^1.12.0", "sql.js": "^1.12.0",
"zod": "^3.25.67", "zod": "^3.25.67",
"zustand": "^5.0.5" "zustand": "^5.0.5"
+10 -4
View File
@@ -9,6 +9,7 @@
import { ThemeProvider, CssBaseline } from '@mui/material'; import { ThemeProvider, CssBaseline } from '@mui/material';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Header } from '@renderer/components/layout/Header';
import { Sidebar } from '@renderer/components/layout/Sidebar'; import { Sidebar } from '@renderer/components/layout/Sidebar';
import { DetailPanel } from '@renderer/components/layout/DetailPanel'; import { DetailPanel } from '@renderer/components/layout/DetailPanel';
import { StatusBar } from '@renderer/components/layout/StatusBar'; 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) => { window.metona.config.get('onboarding.completed').then((v) => {
if (v === true) useUIStore.getState().setOnboardingCompleted(true); if (v === true) useUIStore.getState().setOnboardingCompleted(true);
}).catch((err) => { console.error('[App]', err); }); }).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.provider'),
window.metona.config.get('llm.model'), window.metona.config.get('llm.model'),
]).then(([provider, model]) => { ]).then(([providerResult, modelResult]) => {
if (provider || model) useAgentStore.getState().setProvider((provider as string) || '', (model as string) || ''); const provider = providerResult.status === 'fulfilled' ? (providerResult.value as string) || '' : '';
}).catch((err) => { console.error('[App]', err); }); 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" className="h-screen w-screen flex flex-col overflow-hidden select-none"
style={{ background: muiTheme.palette.background.default, color: muiTheme.palette.text.primary }} 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"> <div className="flex-1 flex overflow-hidden min-h-0">
{sidebarVisible && <Sidebar />} {sidebarVisible && <Sidebar />}
<ChatPanel /> <ChatPanel />
+16 -5
View File
@@ -2,7 +2,7 @@
* CommandPalette (Cmd+K) * 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 { Dialog, DialogContent, InputBase, List, ListItemButton, ListItemIcon, ListItemText, Typography, Box, Divider } from '@mui/material';
import { Search, MessageSquare, Settings, Plus, Trash2 } from 'lucide-react'; import { Search, MessageSquare, Settings, Plus, Trash2 } from 'lucide-react';
import { useUIStore } from '@renderer/stores/ui-store'; import { useUIStore } from '@renderer/stores/ui-store';
@@ -16,13 +16,22 @@ export function CommandPalette(): React.JSX.Element {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// 审计补充修复: 响应式订阅 sessions,使 getCommands 在 sessions 变化时重新构建
// 原问题:getCommands 内部通过 useSessionStore.getState() 非响应式访问 sessions
// 当 sessions 变化但 query 不变时 commands 列表陈旧
const sessions = useSessionStore((s) => s.sessions);
useEffect(() => { useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'k' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); setOpen((p) => !p); setQuery(''); setSelectedIndex(0); } }; 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); 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 getCommands = useCallback((): CommandItem[] => {
const cmds: 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); } }, { id: 'settings', icon: Settings, label: '打开设置', action: () => { useUIStore.getState().openSettings(); setOpen(false); } },
]; ];
if (query) { 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); } }); 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; return cmds;
}, [query]); }, [query, sessions]);
const commands = getCommands(); // L-15 修复: 使用 useMemo 缓存 commands 列表,避免每次渲染都重新构建
const commands = useMemo(() => getCommands(), [getCommands]);
return ( return (
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth slotProps={{ paper: { sx: { mt: '20vh', maxWidth: 520, borderRadius: 3, overflow: 'hidden' } } }}> <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" color="error"
variant="outlined" variant="outlined"
size="small" size="small"
disabled={isExpired}
> >
</Button> </Button>
@@ -282,8 +283,9 @@ export function ConfirmationDialog(): React.JSX.Element | null {
variant="contained" variant="contained"
size="small" size="small"
autoFocus autoFocus
disabled={isExpired}
> >
{isExpired ? '已超时' : '确认执行'}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </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 1050
*/
function extractTextContent(children: React.ReactNode, maxDepth = 50): string {
if (maxDepth <= 0) return ''; // 超出深度上限,停止递归
if (typeof children === 'string') return children; if (typeof children === 'string') return children;
if (typeof children === 'number') return String(children); if (typeof children === 'number') return String(children);
if (!children) return ''; 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) { 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 ''; return '';
} }
+52 -10
View File
@@ -10,8 +10,9 @@
*/ */
import { useState, useCallback, useRef, useEffect } from 'react'; 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 { Send, Paperclip, Square, X, FileText, Image as ImageIcon } from 'lucide-react';
import { nanoid } from 'nanoid';
import { useAgentStore } from '@renderer/stores/agent-store'; import { useAgentStore } from '@renderer/stores/agent-store';
import { useSessionStore } from '@renderer/stores/session-store'; import { useSessionStore } from '@renderer/stores/session-store';
import { useUIStore } from '@renderer/stores/ui-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 processFile = useCallback(async (file: File): Promise<Attachment> => {
const type = classifyFile(file); 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') { if (type === 'image') {
// 图片转 base64 data URL → 压缩(限制 1024px, JPEG 0.7 // 图片转 base64 data URL → 压缩(限制 1024px, JPEG 0.7
@@ -241,8 +243,18 @@ export function ChatInput(): React.JSX.Element {
const handleAbort = useCallback(() => { abort(); }, [abort]); const handleAbort = useCallback(() => { abort(); }, [abort]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
// Ctrl+Enter — 换行 // H-9 修复: 快捷键对齐规范
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { // @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(); e.preventDefault();
const t = e.currentTarget as HTMLTextAreaElement; const t = e.currentTarget as HTMLTextAreaElement;
const s = t.selectionStart; const s = t.selectionStart;
@@ -251,12 +263,19 @@ export function ChatInput(): React.JSX.Element {
requestAnimationFrame(() => { t.selectionStart = t.selectionEnd = s + 1; }); requestAnimationFrame(() => { t.selectionStart = t.selectionEnd = s + 1; });
return; 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 === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); return; }
if (e.key === 'Escape' && showSlashMenu) { setShowSlashMenu(false); return; } if (e.key === 'Escape' && showSlashMenu) { setShowSlashMenu(false); return; }
}, [handleSend, showSlashMenu]); }, [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); const v = e.target.value; setInput(v);
if (v === '/') { setShowSlashMenu(true); setSlashFilter(''); } if (v === '/') { setShowSlashMenu(true); setSlashFilter(''); }
else if (v.startsWith('/') && !v.includes(' ')) { setShowSlashMenu(true); setSlashFilter(v.slice(1).toLowerCase()); } 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} /> <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 {/* H-8 修复: 使用 MUI InputBase 替代原生 textarea — 遵循 MUI 强制使用规范 */}
ref={textareaRef} data-chat-input value={input} onChange={handleChange} onKeyDown={handleKeyDown} {/* @see standard/开发规范.md — MUI 强制使用、禁止自写 UI 组件 */}
<InputBase
inputRef={textareaRef}
data-chat-input
value={input}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste} onPaste={handlePaste}
placeholder="输入消息... (Enter 发送, Ctrl+Enter 换行, / 命令)" disabled={isStreaming} placeholder="输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)"
disabled={isStreaming}
multiline
rows={1} 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' }}> <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 { Box, Typography, Stack, Table, TableBody, TableRow, TableCell, Chip } from '@mui/material';
import { Activity, Cpu } from 'lucide-react'; 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 { useAgentStore, type AgentStatus } from '@renderer/stores/agent-store';
import { AGENT_STATUS_COLORS, AGENT_STATUS_LABELS, PROVIDER_LABELS } from '@renderer/lib/constants'; import { AGENT_STATUS_COLORS, AGENT_STATUS_LABELS, PROVIDER_LABELS } from '@renderer/lib/constants';
import { formatDuration } from '@renderer/lib/formatters'; import { formatDuration } from '@renderer/lib/formatters';
@@ -23,17 +23,29 @@ export function AgentMonitor(): React.JSX.Element {
const setMaxIterations = useAgentStore((s) => s.setMaxIterations); const setMaxIterations = useAgentStore((s) => s.setMaxIterations);
useEffect(() => { useEffect(() => {
// M-11/M-28 修复: 添加 cancelled 标志 + 错误日志记录
let cancelled = false;
if (window.metona?.config?.get) { if (window.metona?.config?.get) {
window.metona.config.get('agent.maxIterations').then((v) => { window.metona.config.get('agent.maxIterations').then((v) => {
if (cancelled) return;
if (typeof v === 'number' && v > 0) setMaxIterations(v); 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]); }, [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 StatusIcon = STATUS_ICONS[agentStatus];
const statusColor = AGENT_STATUS_COLORS[agentStatus]; const statusColor = AGENT_STATUS_COLORS[agentStatus];
const statusLabel = AGENT_STATUS_LABELS[agentStatus]; const statusLabel = AGENT_STATUS_LABELS[agentStatus];
const totalDuration = traceSteps.reduce((sum, s) => sum + (s.completedAt ? s.completedAt - s.startedAt : 0), 0);
return ( return (
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider', flexShrink: 0 }}> <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 { 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 Fuse from 'fuse.js';
import { Plus, Search, MessageSquare, Pin, Wrench, ChevronDown, ChevronRight, Trash2 } from 'lucide-react'; 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); const agentStatus = useAgentStore((s) => s.agentStatus);
useEffect(() => { 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); 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); } 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); return list.sort((a, b) => a.pinned === b.pinned ? b.updatedAt - a.updatedAt : a.pinned ? -1 : 1);
})(); }, [sessions, searchQuery]);
const handleNewSession = async () => { const handleNewSession = async () => {
if (window.metona?.sessions?.create) { if (window.metona?.sessions?.create) {
@@ -40,7 +52,15 @@ export function Sidebar(): React.JSX.Element {
setCurrentSession(r.id); setCurrentSession(r.id);
loadSessionMessages(r.id); loadSessionMessages(r.id);
return; 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 }; const newSession: Session = { id: `s_${Date.now()}`, title: '新会话', createdAt: Date.now(), updatedAt: Date.now(), messageCount: 0, pinned: false, archived: false };
useSessionStore.getState().addSession(newSession); useSessionStore.getState().addSession(newSession);
@@ -91,8 +111,22 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
setShowDeleteDialog(true); setShowDeleteDialog(true);
}; };
const confirmDelete = () => { const confirmDelete = async () => {
window.metona?.sessions.delete(session.id).catch(() => {}); // 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); useSessionStore.getState().removeSession(session.id);
setShowDeleteDialog(false); 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 }>>([]); const [tools, setTools] = useState<Array<{ name: string; description: string; category: string; riskLevel: string; requiresPermission: boolean; enabled: boolean }>>([]);
useEffect(() => { useEffect(() => {
// M-54 修复: 添加 cancelled 标志,防止组件卸载后 setState
let cancelled = false;
if (window.metona?.tools?.list) { if (window.metona?.tools?.list) {
window.metona.tools.list().then((list) => { window.metona.tools.list().then((list) => {
setTools(list as MetonaToolInfo[]); if (!cancelled) setTools(list as MetonaToolInfo[]);
}).catch((err) => { console.error('[Sidebar]', err); }); }).catch((err) => { console.error('[Sidebar]', err); });
} }
return () => { cancelled = true; };
}, []); }, []);
const readyCount = tools.filter((t) => t.enabled).length; 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'); const [version, setVersion] = useState('v0.1.1');
useEffect(() => { useEffect(() => {
// M-28 修复: 添加 cancelled 标志防止组件卸载后 setState
let cancelled = false;
if (window.metona?.app?.getVersion) { 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 ( return (
+19
View File
@@ -83,12 +83,21 @@ export function MemoryViewer(): React.JSX.Element {
// Agent 完成时自动刷新:监听 agentStatus 从 thinking/executing -> idle // Agent 完成时自动刷新:监听 agentStatus 从 thinking/executing -> idle
const agentStatus = useAgentStore((s) => s.agentStatus); const agentStatus = useAgentStore((s) => s.agentStatus);
const prevStatus = useRef(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 () => { const loadMemories = useCallback(async () => {
if (!window.metona?.memory?.listAll) return; if (!window.metona?.memory?.listAll) return;
const reqId = ++loadReqIdRef.current;
try { try {
setError(null); setError(null);
const res = await window.metona.memory.listAll(); const res = await window.metona.memory.listAll();
// 竞态保护:若已被新请求取代或组件已卸载,放弃本次结果
if (loadReqIdRef.current !== reqId) return;
if (!res.success) { if (!res.success) {
setError(res.error ?? '加载记忆失败'); setError(res.error ?? '加载记忆失败');
return; return;
@@ -100,6 +109,7 @@ export function MemoryViewer(): React.JSX.Element {
working: (data.working as MemoryItem[] | undefined) ?? [], working: (data.working as MemoryItem[] | undefined) ?? [],
}); });
} catch (err) { } catch (err) {
if (loadReqIdRef.current !== reqId) return;
setError((err as Error).message ?? '加载记忆失败'); setError((err as Error).message ?? '加载记忆失败');
} }
}, []); }, []);
@@ -107,6 +117,8 @@ export function MemoryViewer(): React.JSX.Element {
// 初次挂载加载 // 初次挂载加载
useEffect(() => { useEffect(() => {
loadMemories(); loadMemories();
// cleanup: 使当前请求失效(防止卸载后 setState)
return () => { loadReqIdRef.current++; };
}, [loadMemories]); }, [loadMemories]);
// Agent 完成自动刷新 // Agent 完成自动刷新
@@ -123,13 +135,20 @@ export function MemoryViewer(): React.JSX.Element {
if (!q || !window.metona?.memory?.search) return; if (!q || !window.metona?.memory?.search) return;
setSearching(true); setSearching(true);
setError(null); setError(null);
// 审计补充修复: 使用独立 searchReqIdRef,不再与 loadMemories 共用
const reqId = ++searchReqIdRef.current;
try { try {
const results = await window.metona.memory.search(q, { topK: 20 }); const results = await window.metona.memory.search(q, { topK: 20 });
// 竞态保护:若已被新搜索请求取代或组件已卸载,放弃本次结果
if (searchReqIdRef.current !== reqId) return;
setSearchResults(results); setSearchResults(results);
} catch (err) { } catch (err) {
if (searchReqIdRef.current !== reqId) return;
setError((err as Error).message ?? '搜索失败'); setError((err as Error).message ?? '搜索失败');
setSearchResults([]); setSearchResults([]);
} finally { } finally {
// 审计补充修复: 无条件清理 searching 状态,避免被 loadMemories 取代时卡死
// searching 仅对当前搜索有意义,请求被取代后应停止 spinner
setSearching(false); 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 (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())); if (workspacePath.trim()) configSets.push(window.metona.config.set('workspace.path', workspacePath.trim()));
configSets.push(window.metona.config.set('onboarding.completed', true)); 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() || ''); useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || '');
} }
setOnboardingCompleted(true); setOnboardingCompleted(true);
+125 -7
View File
@@ -580,11 +580,51 @@ function MCPSettings() {
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [newCommand, setNewCommand] = useState(''); const [newCommand, setNewCommand] = useState('');
const [newArgs, setNewArgs] = 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); }); }; // L-11 修复: 用 MUI Dialog 替换浏览器原生 confirm(),保持 UI 一致性
useEffect(() => { loadServers(); }, []); 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 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' }; 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 ( return (
<Stack spacing={2}> <Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>MCP </Typography> <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>MCP </Typography>
@@ -598,10 +638,33 @@ function MCPSettings() {
</Stack> </Stack>
<Stack direction="row" spacing={0.5}> <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" 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>
</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 ? ( {showAdd ? (
<Stack spacing={1} sx={{ p: 1.5, borderRadius: 1.5, bgcolor: 'secondary.main', border: '1px solid', borderColor: 'divider' }}> <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="服务名称" /> <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() { function LogsSettings() {
const [logLevel, setLogLevel] = useConfig('logging.level', 'info'); const [logLevel, setLogLevel] = useConfig('logging.level', 'info');
const [clearing, setClearing] = useState<string | null>(null); 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 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 ( return (
<Stack spacing={2}> <Stack spacing={2}>
@@ -834,9 +922,39 @@ function LogsSettings() {
<Divider /> <Divider />
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}></Typography> <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" 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={() => { setResultAlert(null); setConfirmClear('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={() => { setResultAlert(null); setConfirmClear('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('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> </Stack>
); );
} }
+9 -1
View File
@@ -4,7 +4,7 @@
* *
*/ */
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { import {
Box, Typography, Stack, List, ListItem, ListItemIcon, Box, Typography, Stack, List, ListItem, ListItemIcon,
Checkbox, Chip, IconButton, TextField, Button, Select, MenuItem, Collapse, 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 [newPriority, setNewPriority] = useState<TaskPriority>('medium');
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
// M-51 修复: 竞态保护 ref,防止快速切换会话时旧请求覆盖新数据
const loadReqIdRef = useRef(0);
const loadTasks = useCallback(async (sid?: string) => { const loadTasks = useCallback(async (sid?: string) => {
if (!window.metona?.tasks?.list) return; if (!window.metona?.tasks?.list) return;
const reqId = ++loadReqIdRef.current;
try { try {
setError(null); setError(null);
const res = await window.metona.tasks.list(sid); const res = await window.metona.tasks.list(sid);
// 竞态保护:若已被新请求取代或组件已卸载,放弃本次结果
if (loadReqIdRef.current !== reqId) return;
if (!res.success) { if (!res.success) {
setError('加载任务失败'); setError('加载任务失败');
return; return;
@@ -79,12 +84,15 @@ export function TaskList(): React.JSX.Element {
}); });
setTasks(list); setTasks(list);
} catch (err) { } catch (err) {
if (loadReqIdRef.current !== reqId) return;
setError((err as Error).message ?? '加载任务失败'); setError((err as Error).message ?? '加载任务失败');
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
loadTasks(sessionId ?? undefined); loadTasks(sessionId ?? undefined);
// cleanup: 使当前请求失效(防止卸载后 setState + 竞态)
return () => { loadReqIdRef.current++; };
}, [sessionId, loadTasks]); }, [sessionId, loadTasks]);
const handleCreate = async () => { const handleCreate = async () => {
+13 -5
View File
@@ -28,24 +28,32 @@ export function WorkspaceViewer(): React.JSX.Element {
// Agent 完成时自动刷新 // Agent 完成时自动刷新
const agentStatus = useAgentStore((s) => s.agentStatus); const agentStatus = useAgentStore((s) => s.agentStatus);
const prevStatus = useRef(agentStatus); const prevStatus = useRef(agentStatus);
// M-52 修复: 竞态保护 ref,防止多次 loadInfo 调用乱序完成导致旧数据覆盖
const loadReqIdRef = useRef(0);
const loadInfo = useCallback(async () => { const loadInfo = useCallback(async () => {
if (!window.metona?.workspace?.getInfo) return; if (!window.metona?.workspace?.getInfo) return;
const reqId = ++loadReqIdRef.current;
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const res = await window.metona.workspace.getInfo(); const res = await window.metona.workspace.getInfo();
// 竞态保护:若已被新请求取代或组件已卸载,放弃本次结果
if (loadReqIdRef.current !== reqId) return;
setInfo(res); setInfo(res);
} catch (err) { } catch (err) {
if (loadReqIdRef.current !== reqId) return;
setError((err as Error).message ?? '加载工作空间信息失败'); setError((err as Error).message ?? '加载工作空间信息失败');
} finally { } finally {
setLoading(false); if (loadReqIdRef.current === reqId) setLoading(false);
} }
}, []); }, []);
// 初次挂载加载 // 初次挂载加载
useEffect(() => { useEffect(() => {
loadInfo(); loadInfo();
// cleanup: 使当前请求失效(防止卸载后 setState)
return () => { loadReqIdRef.current++; };
}, [loadInfo]); }, [loadInfo]);
// Agent 完成自动刷新 // Agent 完成自动刷新
@@ -73,7 +81,7 @@ export function WorkspaceViewer(): React.JSX.Element {
<Stack spacing={1.5} sx={{ flex: 1, overflow: 'auto', minHeight: 0 }}> <Stack spacing={1.5} sx={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
{/* 工作空间根路径 */} {/* 工作空间根路径 */}
<Box> <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 variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
</Typography> </Typography>
@@ -83,7 +91,7 @@ export function WorkspaceViewer(): React.JSX.Element {
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Stack> </Stack>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ flexWrap: 'wrap' }}> <Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', alignItems: 'center' }}>
<Typography <Typography
variant="body2" variant="body2"
sx={{ 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' } }}> <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 ? ( {file.exists ? (
<CheckCircle2 size={12} color="var(--mui-palette-success-main)" /> <CheckCircle2 size={12} color="var(--mui-palette-success-main)" />
) : ( ) : (
@@ -193,13 +201,13 @@ export function WorkspaceViewer(): React.JSX.Element {
key={dir.name} key={dir.name}
direction="row" direction="row"
spacing={0.5} spacing={0.5}
alignItems="center"
sx={{ sx={{
p: 0.5, p: 0.5,
border: 1, border: 1,
borderColor: 'divider', borderColor: 'divider',
borderRadius: 1, borderRadius: 1,
bgcolor: 'background.default', bgcolor: 'background.default',
alignItems: 'center',
}} }}
> >
<Folder size={12} color={dir.exists ? 'var(--mui-palette-info-main)' : 'var(--mui-palette-text-disabled)'} /> <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; requestId?: string;
sessionId?: string; sessionId?: string;
iteration?: number; iteration?: number;
/** 事件序列号(与 MetonaStreamEvent.seq 对齐,规范必填字段) */
seq?: number;
/** 事件时间戳(与 MetonaStreamEvent.timestamp 对齐,规范必填字段) */
timestamp?: number;
/** 当前 run 的唯一标识(前端用于过滤旧流事件,abort 后重发场景) */
runId?: string;
delta?: string; delta?: string;
content?: string; content?: string;
/** 工具调用增量(流式参数拼接) */
toolCallDelta?: { index: number; name?: string; argsDelta?: string };
toolCall?: { id: string; name: string; args: Record<string, unknown> }; toolCall?: { id: string; name: string; args: Record<string, unknown> };
toolResult?: { toolCallId: string; success: boolean; result?: unknown; error?: string; durationMs?: number }; toolResult?: { toolCallId: string; success: boolean; result?: unknown; error?: string; durationMs?: number };
usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number };
+11 -1
View File
@@ -11,6 +11,15 @@ import { useSessionStore } from './session-store';
let _msgIdCounter = 0; let _msgIdCounter = 0;
export const genMsgId = (role: string) => `msg_${Date.now()}_${role}_${_msgIdCounter++}`; 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'; export type MessageRole = 'user' | 'assistant' | 'system' | 'tool';
@@ -365,8 +374,9 @@ export const useAgentStore = create<AgentState>((set, get) => ({
set((s) => ({ tokenUsage: { ...s.tokenUsage, ...usage } })), set((s) => ({ tokenUsage: { ...s.tokenUsage, ...usage } })),
setProvider: (provider, model) => { setProvider: (provider, model) => {
// L-16 修复: 使用命名常量替代魔法数字
// DeepSeek/Agnes 固定 1MOllama 从配置读取 // 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 }); set({ provider, model, contextWindow: ollamaCtx });
// 异步读取 Ollama 实际配置 // 异步读取 Ollama 实际配置
if (provider === 'ollama' && window.metona?.config?.get) { if (provider === 'ollama' && window.metona?.config?.get) {