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:
@@ -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' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止序列
|
// 停止序列
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =====
|
||||||
|
|
||||||
|
|||||||
@@ -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 content,assistant 仅有 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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
// 跳过解析失败的行
|
// 跳过解析失败的行
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取当前确认超时时间 */
|
/** 获取当前确认超时时间 */
|
||||||
|
|||||||
@@ -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}"` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 委派子任务
|
* 委派子任务
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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 — 子任务委派(启动 SubAgent,EXTERNAL_ACTION)
|
// delegate_task — 子任务委派(启动 SubAgent,EXTERNAL_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 {
|
||||||
|
|||||||
@@ -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
|
// 反向 shell(3)
|
||||||
/\/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 bomb(1)
|
||||||
/:\(\)\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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,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; // 其他 Unicode(emoji 等):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
@@ -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: 任务管理 IPC(TaskList UI)=====
|
// ===== v0.2.0: 任务管理 IPC(TaskList 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
@@ -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' };
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Generated
+22
-2
@@ -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
@@ -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
@@ -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 />
|
||||||
|
|||||||
@@ -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' } } }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -184,14 +184,24 @@ function CodeBlock({ language, code }: { language: string; code: string }): Reac
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从 React children(可能含高亮 span)递归提取纯文本 */
|
/**
|
||||||
function extractTextContent(children: React.ReactNode): string {
|
* 从 React children(可能含高亮 span)递归提取纯文本
|
||||||
|
*
|
||||||
|
* L-1 修复: 添加 maxDepth 参数防止循环引用导致的栈溢出
|
||||||
|
* React children 正常深度不会超过 10,50 已足够安全裕量
|
||||||
|
*/
|
||||||
|
function extractTextContent(children: React.ReactNode, maxDepth = 50): string {
|
||||||
|
if (maxDepth <= 0) return ''; // 超出深度上限,停止递归
|
||||||
if (typeof children === 'string') return children;
|
if (typeof children === '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 '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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)'} />
|
||||||
|
|||||||
@@ -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,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 保持一致)
|
||||||
|
* - 云端 Provider(DeepSeek/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 固定 1M,Ollama 从配置读取
|
// DeepSeek/Agnes 固定 1M,Ollama 从配置读取
|
||||||
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user