5 Commits
Author SHA1 Message Date
thzxx f3a0e751ba feat: v0.3.18 上下文压缩机制修复 + MCP 工具就绪竞态修复 + 记忆系统加固
【上下文压缩机制修复】
- engine.ts 新增 lastRealInputTokens 记录 LLM 返回的真实输入 token,压缩判断取 max(估算值, 真实值),避免估算偏低导致不压缩但 API 413
- 修复 effectiveContextWindow 缺少默认值导致 compressionThreshold 变 NaN、压缩永不触发的 bug(添加 ?? 128_000 兜底)
- compressMessages 保留区从固定 10 条改为按 token 预算动态截断(50% 上下文窗口)
- 二次截断 charsPerToken 从 2 调整为 1.0,与 CJK_TOKEN_RATIO 一致
- 压缩后重置 lastRealInputTokens,避免跨迭代污染
- 每个 run 开始时重置 lastRealInputTokens

【前端 Token 显示修复】
- 区分"累计消耗"和"上下文占用"语义——之前 totalTokens(累计) / contextWindow(单次窗口) 得出无意义百分比
- TokenUsage.tsx 上下文占用改用 lastInputTokens,新增压缩节省行(绿色,仅当 > 0 时显示)
- agent-store.ts TokenUsage 接口新增 lastInputTokens 和 lastCompressedSaved 字段,4 处初始值统一更新
- useAgentStream.ts usage 事件 lastInputTokens 替换不累加,compressed 事件通过 streamEvent 接收 savedTokens
- handlers.ts onCompressed 同时发 toast + streamEvent,解决"压缩触发但前端 token 显示不降"缺陷
- 旧数据兼容使用 ?? 0,保证历史会话加载不崩溃

【MCP 工具就绪竞态修复】
- 修复输入框永久显示"工具加载中"的竞态条件:MCP initialize 几乎立即 resolve(connectServer 不 await),tools:ready 事件在前端监听器注册前已发出
- main.ts 维护 toolsReady 标志 + 注册 tools:isReady IPC handler 查询当前状态
- preload.ts 暴露 tools.isReady() 方法
- App.tsx 注册 onReady 监听器后立即查询 isReady(),无论事件是否错过都能恢复正确状态

【记忆系统加固】
- consolidator.ts 新增 runningPromise + waitForCompletion(35s),before-quit 等待固化完成,防止退出时异步 consolidate 数据丢失
- 固化到 MEMORY.md 的同时写入 semantic_memories 表,解决双轨存储无交叉验证问题
- manager.ts tokenize 按中英文标点切分子句后再做 bigram,优化中文分词
- 清理正则冗余括号

【SOUL.md 降级处理】
- context-builder.ts SOUL.md 为空或不存在时降级到默认身份,向前端发 toast 提示用户
- 新增 fallbackRoleNotified 去重标志,仅首次降级通知,避免每次发消息都弹 toast
- SOUL.md 恢复内容时重置标志

【Token 估算调整】
- token-estimator.ts CJK_TOKEN_RATIO 从 1.5 调整为 1.0

【MCP 异步初始化】
- main.ts MCP 完成后广播 tools:ready 事件,前端 UI 据以控制输入框可用性
- preload.ts + global.d.ts 暴露 tools.onReady() 监听器
- App.tsx + ChatInput.tsx toolsReady 状态控制输入框

【版本号】
- package.json + package-lock.json 从 0.3.16 升级到 0.3.18
2026-07-22 15:17:58 +08:00
thzxx 3e5ddea72d feat: v0.3.17 修复多项问题(sandbox/全局配置层/继承选项/配置刷新/附件提示/content_filter 错误处理)
- fix: sandbox: true → false(ESM preload 不兼容 sandbox 导致 window.metona 不可用,选择文件夹按钮无反应)
- feat: 全局配置层(GlobalConfigService)— LLM/Agent 等配置跨工作空间共享,切换工作空间后不再丢失
  - 读取时工作空间 DB 空值或默认值回退到全局 JSON
  - 写入时双写(DB + 全局 JSON)
  - 首次启动迁移旧配置到全局层
- fix: 工作空间继承选项显示条件放宽 + push 前校验避免覆盖目标已有文件(SOUL.md/agent.db)
- fix: config:changed IPC 广播 + 前端监听,修改 Agent 配置后详情栏 maxIterations 实时刷新
- feat: 上传图片/文件时在 System Prompt 注入附件提示,避免 AI 在工作空间查找用户上传的文件
- feat: content_filter 错误识别(ContentFilterError)+ 友好提示,替代原始 JSON 错误体透传
- fix: JSDoc 注释中 llm.* / agent.* / onboarding.completed 中的 */ 被解析为注释结束符
2026-07-22 13:12:15 +08:00
thzxx 9af9984418 fix: JSDoc 注释中 GIT_*/ 被解析为注释结束符导致构建失败 2026-07-22 10:40:04 +08:00
thzxx 2c2ca4a692 feat: 升级至 v0.3.16 — 工单修复 51 项 + 审查修复 30 项(安全加固/适配器/工具系统/数据层/前端) 2026-07-22 10:31:13 +08:00
thzxx 516d8a728f feat: 升级至 v0.3.15 — MiMo 多模态 + 工作空间数据库继承 + 引导窗口上下文配置 + TS 错误修复
- feat(mimo): 适配器支持图片输入,将 images 转为 OpenAI 兼容 content parts 数组
- feat(workspace): 切换工作空间时支持继承数据库 agent.db(SQLite backup API 原子导出,自动 checkpoint WAL)
- feat(onboarding): 引导弹窗 LLM 配置加上下文长度字段(DeepSeek/Agnes=1000000、MiMo=131072、Ollama=null)
- fix(types): 修复 16 个 TypeScript 编译错误(setConfigLoaded 接口声明、10 个 IPC 返回类型加 error 字段、MUI TextField readOnly slot 迁移)
- chore(workspace): 移除死代码 traces 目录(trace 数据实际存储于数据库 sessions.metadata 字段,traces 目录从未被任何代码读写)
- docs(mimo): API 文档补充多模态输入章节和 curl/Python 代码示例
- chore: 版本号 0.3.14 → 0.3.15
2026-07-21 21:05:50 +08:00
56 changed files with 2730 additions and 396 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
> 生产级通用 AI Agent 智能体桌面应用 > 生产级通用 AI Agent 智能体桌面应用
[![Version](https://img.shields.io/badge/version-0.3.14-blue)](./package.json) [![Version](https://img.shields.io/badge/version-0.3.15-blue)](./package.json)
[![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE) [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
[![Electron](https://img.shields.io/badge/Electron-35-47848F)](https://www.electronjs.org/) [![Electron](https://img.shields.io/badge/Electron-35-47848F)](https://www.electronjs.org/)
[![React](https://img.shields.io/badge/React-19-61DAFB)](https://react.dev/) [![React](https://img.shields.io/badge/React-19-61DAFB)](https://react.dev/)
+145
View File
@@ -132,6 +132,7 @@
<a href="#tools">🔧 工具调用 (Function Calling)</a> <a href="#tools">🔧 工具调用 (Function Calling)</a>
<a href="#websearch">🌐 联网搜索</a> <a href="#websearch">🌐 联网搜索</a>
<a href="#tts">🎙️ 语音合成 (TTS)</a> <a href="#tts">🎙️ 语音合成 (TTS)</a>
<a href="#multimodal">🖼️ 多模态输入</a>
<a href="#examples">💻 代码示例</a> <a href="#examples">💻 代码示例</a>
<a href="#errors">⚠️ 错误码说明</a> <a href="#errors">⚠️ 错误码说明</a>
</div> </div>
@@ -331,6 +332,56 @@ Content-Type: application/json</pre>
</div> </div>
</section> </section>
<!-- ===== MULTIMODAL ===== -->
<section id="multimodal">
<h2>🖼️ 多模态输入</h2>
<p><code>messages[].content</code> 为数组时,可混合传入文本、图像内容。采用 OpenAI 兼容的 content parts 数组格式。</p>
<h3>content parts 字段</h3>
<table>
<tr><th width="200">字段名</th><th width="90">类型</th><th>描述</th></tr>
<tr><td><code>content</code></td><td><span class="type-tag">string | array</span></td><td>纯文本字符串,或 content parts 数组</td></tr>
<tr><td><code>content[].type</code></td><td><span class="type-tag">string</span></td><td>part 类型:<code>"text"</code> / <code>"image_url"</code></td></tr>
<tr><td><code>content[].text</code></td><td><span class="type-tag">string</span></td><td>文本内容(type 为 <code>"text"</code> 时必填)</td></tr>
<tr><td><code>content[].image_url</code></td><td><span class="type-tag">object</span></td><td>图像对象(type 为 <code>"image_url"</code> 时必填)</td></tr>
<tr><td><code>content[].image_url.url</code></td><td><span class="type-tag">string</span></td><td>图像公网 URL 或 base64 data URI(格式:<code>data:image/png;base64,...</code></td></tr>
<tr><td><code>content[].image_url.detail</code></td><td><span class="type-tag">string</span></td><td>可选,清晰度:<code>"low"</code> / <code>"high"</code> / <code>"auto"</code>(默认 <code>"auto"</code></td></tr>
</table>
<div class="info-box">
<strong>📌 支持的图像格式:</strong>
<ul style="padding-left:20px;margin:8px 0 0;">
<li><strong>URL</strong>:公网可访问的 HTTPS 图片地址</li>
<li><strong>Base64 data URI</strong><code>data:image/{format};base64,{base64_data}</code>,支持 png / jpeg / gif / webp</li>
</ul>
</div>
<h3>多模态消息示例</h3>
<pre><span class="code-lang">json</span>{
"role": "user",
"content": [
{ "type": "text", "text": "请描述这张图片的内容" },
{
"type": "image_url",
"image_url": {
"url": "https://example.com/image.jpg",
"detail": "auto"
}
}
]
}</pre>
<div class="warn-box">
<strong>⚠️ 多模态限制:</strong>
<ul style="padding-left:20px;margin:8px 0 0;">
<li><code>mimo-v2.5-pro</code> / <code>mimo-v2.5</code> 文本模型支持图像输入</li>
<li>TTS 系列模型(<code>mimo-v2.5-tts*</code>)不支持多模态图像输入</li>
<li>历史消息中的图片 content 数组应原样保留,不建议在多轮对话中删除</li>
<li>base64 data URI 会显著增加请求体大小,建议对大图先压缩或使用公网 URL</li>
</ul>
</div>
</section>
<!-- ===== RESPONSE ===== --> <!-- ===== RESPONSE ===== -->
<section id="response"> <section id="response">
<h2>📤 响应对象(非流式输出)</h2> <h2>📤 响应对象(非流式输出)</h2>
@@ -746,6 +797,100 @@ audio_bytes = base64.b64decode(audio_data)
with output("output.wav", "wb") as f: with output("output.wav", "wb") as f:
f.write(audio_bytes) f.write(audio_bytes)
print("音频已保存到 output.wav")</pre> print("音频已保存到 output.wav")</pre>
<h3>多模态图像输入</h3>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab(event,'tab-mm-curl')">curl</button>
<button class="tab-btn" onclick="switchTab(event,'tab-mm-python')">Python (OpenAI SDK)</button>
<button class="tab-btn" onclick="switchTab(event,'tab-mm-base64')">Python (Base64 本地图片)</button>
</div>
<div id="tab-mm-curl" class="tab-content active">
<pre><span class="code-lang">bash</span>curl --location --request POST 'https://api.xiaomimimo.com/v1/chat/completions' \
--header "api-key: $MIMO_API_KEY" \
--header "Content-Type: application/json" \
--data-raw '{
"model": "mimo-v2.5-pro",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "请描述这张图片的内容"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/image.jpg",
"detail": "auto"
}
}
]
}
],
"max_completion_tokens": 1024,
"thinking": {"type": "disabled"}
}'</pre>
</div>
<div id="tab-mm-python" class="tab-content">
<pre><span class="code-lang">python</span>from openai import OpenAI
client = OpenAI(api_key="$MIMO_API_KEY", base_url="https://api.xiaomimimo.com/v1")
response = client.chat.completions.create(
model="mimo-v2.5-pro",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "请描述这张图片的内容"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/image.jpg",
"detail": "auto"
}
}
]
}
],
max_completion_tokens=1024,
thinking={"type": "disabled"}
)
print(response.choices[0].message.content)
print(f"图像 token: {response.usage.prompt_tokens_details.image_tokens}")</pre>
</div>
<div id="tab-mm-base64" class="tab-content">
<pre><span class="code-lang">python</span>import base64
from openai import OpenAI
client = OpenAI(api_key="$MIMO_API_KEY", base_url="https://api.xiaomimimo.com/v1")
# 读取本地图片并转为 base64 data URI
with open("local_image.jpg", "rb") as f:
image_data = base64.b64encode(f.read()).decode("utf-8")
image_data_uri = f"data:image/jpeg;base64,{image_data}"
response = client.chat.completions.create(
model="mimo-v2.5-pro",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "这张图里有什么?"},
{
"type": "image_url",
"image_url": {"url": image_data_uri}
}
]
}
],
max_completion_tokens=1024
)
print(response.choices[0].message.content)</pre>
</div>
</section> </section>
<!-- ===== ERRORS ===== --> <!-- ===== ERRORS ===== -->
@@ -47,7 +47,8 @@ export class AgnesAdapter extends BaseAdapter {
async send(request: MetonaRequest): Promise<MetonaResponse> { 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`, { // #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -55,9 +56,7 @@ export class AgnesAdapter extends BaseAdapter {
...this.config.headers, ...this.config.headers,
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout }, this.config.timeoutMs ?? 300_000);
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
if (!response.ok) { if (!response.ok) {
await this.throwHttpError(response, 'Agnes AI API error'); await this.throwHttpError(response, 'Agnes AI API error');
@@ -88,7 +87,8 @@ export class AgnesAdapter extends BaseAdapter {
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> { 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`, { // #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -96,9 +96,7 @@ export class AgnesAdapter extends BaseAdapter {
...this.config.headers, ...this.config.headers,
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout }, this.config.timeoutMs ?? 300_000);
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
if (!response.ok || !response.body) { if (!response.ok || !response.body) {
await this.throwHttpError(response, 'Agnes AI stream error'); await this.throwHttpError(response, 'Agnes AI stream error');
+104 -2
View File
@@ -18,6 +18,24 @@ import type {
import { MetonaErrorCode } from '../types'; import { MetonaErrorCode } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter'; import type { MetonaModelInfo } from '../types/metona-adapter';
/**
* 内容审核错误 — Provider 的安全过滤策略触发的错误
*
* 当 Provider(如 MiMo)的 API 返回 content_filter 错误码时抛出。
* Engine 识别此错误后映射为 MetonaErrorCode.CONTENT_FILTERED
* 前端显示友好提示而非原始 JSON 错误体。
*/
export class ContentFilterError extends Error {
/** Provider 返回的原始错误消息(如 "The request was rejected because it was considered high risk" */
readonly providerMessage: string;
constructor(providerMessage: string, context: string) {
super(`${context}: 内容被 Provider 安全审核拦截 - ${providerMessage}`);
this.name = 'ContentFilterError';
this.providerMessage = providerMessage;
}
}
export abstract class BaseAdapter implements IMetonaProviderAdapter { export abstract class BaseAdapter implements IMetonaProviderAdapter {
// H-2 修复: provider → providerId(规范要求) // H-2 修复: provider → providerId(规范要求)
abstract readonly providerId: string; abstract readonly providerId: string;
@@ -73,6 +91,9 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
* *
* @param timeoutMs 超时时间(毫秒) * @param timeoutMs 超时时间(毫秒)
* @returns 合并后的 AbortSignal * @returns 合并后的 AbortSignal
* @deprecated 审查修复 M20: 使用 fetchWithTimeout 替代。
* getFetchSignal 内部 AbortSignal.timeout() 创建的 timer 在请求成功完成后仍会存活到超时,
* 高频调用下 timer 句柄累积;fetchWithTimeout 用 setTimeout + clearTimeout 已解决此问题。
*/ */
protected getFetchSignal(timeoutMs: number): AbortSignal { protected getFetchSignal(timeoutMs: number): AbortSignal {
const timeoutSignal = AbortSignal.timeout(timeoutMs); const timeoutSignal = AbortSignal.timeout(timeoutMs);
@@ -92,6 +113,46 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
return AbortSignal.any([timeoutSignal, this.externalAbortSignal]); return AbortSignal.any([timeoutSignal, this.externalAbortSignal]);
} }
/**
* #24 修复: 封装 fetch + 超时控制,在 finally 中 clearTimeout,避免 timer 泄漏
*
* getFetchSignal 使用 AbortSignal.timeout() 内部创建的 timer 在请求成功完成后
* 仍会存活到超时,高频调用下 timer 句柄累积。本方法使用 setTimeout + clearTimeout
* 确保 fetch 完成(无论成功/失败/abort)后立即清理 timer。
*
* @param url 请求 URL
* @param init fetch init(不含 signal,由本方法内部管理)
* @param timeoutMs 超时时间(毫秒)
*/
protected async fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
// 审查修复 M20: 保存 listener 引用,finally 中 removeEventListener 清理,避免 listener 泄漏
const onExternalAbort = () => controller.abort();
try {
// 合并外部 abort signal(来自 Engine 的 abortController
if (this.externalAbortSignal) {
if (this.externalAbortSignal.aborted) {
controller.abort();
} else {
this.externalAbortSignal.addEventListener('abort', onExternalAbort, { once: true });
}
}
return await fetch(url, { ...init, signal: controller.signal });
} finally {
// #24 修复: 关键 — 无论请求成功、失败还是 abort,都清理 timer
clearTimeout(timer);
// 审查修复 M20: 清理 externalAbortSignal 上注册的 listener
// (即使 { once: true },请求正常完成时 listener 仍挂在 signal 上直到 abort 或 GC,需显式移除)
if (this.externalAbortSignal) {
this.externalAbortSignal.removeEventListener('abort', onExternalAbort);
}
}
}
async healthCheck(): Promise<boolean> { async healthCheck(): Promise<boolean> {
try { try {
await this.listModels(); await this.listModels();
@@ -114,10 +175,35 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
/** /**
* P1-4 修复: 构造带 HTTP status 属性的 Error 并抛出 * P1-4 修复: 构造带 HTTP status 属性的 Error 并抛出
* engine.isRetryableError 依赖 error.status 判断是否可重试(429/5xx * engine.isRetryableError 依赖 error.status 判断是否可重试(429/5xx
*
* v0.3.17: 解析 Provider 返回的 JSON 错误体,识别 content_filter 错误码,
* 抛出 ContentFilterError 让 Engine 映射为 CONTENT_FILTERED 错误码。
*/ */
protected async throwHttpError(response: Response, context: string): Promise<never> { protected async throwHttpError(response: Response, context: string): Promise<never> {
let errorBody = ''; let errorBody = '';
try { errorBody = await response.text(); } catch { /* body 可能已消费或为 null */ } try { errorBody = await response.text(); } catch { /* body 可能已消费或为 null */ }
// v0.3.17: 解析 JSON 错误体,识别 content_filter
if (errorBody) {
try {
const parsed = JSON.parse(errorBody);
// 兼容 OpenAI 格式 {error: {code, message}} 和 MiMo/其他格式
const errObj = parsed?.error ?? parsed;
const errorCode = errObj?.code ?? errObj?.type ?? '';
const errorMessage = errObj?.message ?? '';
if (typeof errorCode === 'string' && errorCode.toLowerCase().includes('content_filter')) {
const providerMsg = errorMessage || '内容触发安全过滤策略';
const cfError = new ContentFilterError(providerMsg, context);
(cfError as ContentFilterError & { status: number }).status = response.status;
throw cfError;
}
} catch (parseErr) {
// JSON 解析失败(非 JSON 响应体),走原逻辑
if (parseErr instanceof ContentFilterError) throw parseErr;
}
}
const error = new Error(`${context}: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`); const error = new Error(`${context}: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`);
(error as Error & { status: number }).status = response.status; (error as Error & { status: number }).status = response.status;
throw error; throw error;
@@ -128,6 +214,16 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
*/ */
protected mapError(error: unknown): MetonaError { protected mapError(error: unknown): MetonaError {
if (error instanceof Error) { if (error instanceof Error) {
// v0.3.17: 优先识别 ContentFilterError
if (error instanceof ContentFilterError) {
return {
code: MetonaErrorCode.CONTENT_FILTERED,
message: '内容被 Provider 安全审核拦截,请修改图片或文本后重试',
provider: this.providerId,
retryable: false,
};
}
const msg = error.message.toLowerCase(); const msg = error.message.toLowerCase();
if (msg.includes('timeout') || msg.includes('ETIMEDOUT')) { if (msg.includes('timeout') || msg.includes('ETIMEDOUT')) {
@@ -150,7 +246,12 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
}; };
} }
if (msg.includes('401') || msg.includes('unauthorized')) { // #23 修复: 优先基于 HTTP status code 判断 401/429,避免字符串 includes 误匹配 URL 端口等数字
// throwHttpError 已将 response.status 挂到 error.status,优先读取此字段
const httpStatus = (error as Error & { status?: number }).status;
// #23 修复: 401 认证失败 — 优先用 status code'unauthorized' 是单词不会误匹配
if (httpStatus === 401 || msg.includes('unauthorized')) {
return { return {
code: MetonaErrorCode.AUTH_INVALID, code: MetonaErrorCode.AUTH_INVALID,
message: 'API key 无效或已过期', message: 'API key 无效或已过期',
@@ -159,7 +260,8 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
}; };
} }
if (msg.includes('429') || msg.includes('rate limit')) { // #23 修复: 429 限流 — 优先用 status code'rate limit' 是单词不会误匹配
if (httpStatus === 429 || msg.includes('rate limit')) {
return { return {
code: MetonaErrorCode.RATE_LIMITED, code: MetonaErrorCode.RATE_LIMITED,
message: '请求过于频繁,请稍后重试', message: '请求过于频繁,请稍后重试',
@@ -52,7 +52,8 @@ export class DeepSeekAdapter extends BaseAdapter {
async send(request: MetonaRequest): Promise<MetonaResponse> { 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`, { // #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -60,9 +61,7 @@ export class DeepSeekAdapter extends BaseAdapter {
...this.config.headers, ...this.config.headers,
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout }, this.config.timeoutMs ?? 120_000);
signal: this.getFetchSignal(this.config.timeoutMs ?? 120_000),
});
if (!response.ok) { if (!response.ok) {
await this.throwHttpError(response, 'DeepSeek API error'); await this.throwHttpError(response, 'DeepSeek API error');
@@ -93,7 +92,8 @@ export class DeepSeekAdapter extends BaseAdapter {
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> { 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`, { // #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -101,9 +101,7 @@ export class DeepSeekAdapter extends BaseAdapter {
...this.config.headers, ...this.config.headers,
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout }, this.config.timeoutMs ?? 300_000);
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
if (!response.ok || !response.body) { if (!response.ok || !response.body) {
await this.throwHttpError(response, 'DeepSeek stream error'); await this.throwHttpError(response, 'DeepSeek stream error');
+39 -7
View File
@@ -59,7 +59,8 @@ export class MimoAdapter extends BaseAdapter {
async send(request: MetonaRequest): Promise<MetonaResponse> { 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`, { // #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -67,8 +68,7 @@ export class MimoAdapter extends BaseAdapter {
...this.config.headers, ...this.config.headers,
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
signal: this.getFetchSignal(this.config.timeoutMs ?? 120_000), }, this.config.timeoutMs ?? 120_000);
});
if (!response.ok) { if (!response.ok) {
await this.throwHttpError(response, 'MiMo API error'); await this.throwHttpError(response, 'MiMo API error');
@@ -98,7 +98,8 @@ export class MimoAdapter extends BaseAdapter {
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> { 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`, { // #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -106,8 +107,7 @@ export class MimoAdapter extends BaseAdapter {
...this.config.headers, ...this.config.headers,
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000), }, this.config.timeoutMs ?? 300_000);
});
if (!response.ok || !response.body) { if (!response.ok || !response.body) {
await this.throwHttpError(response, 'MiMo stream error'); await this.throwHttpError(response, 'MiMo stream error');
@@ -152,6 +152,7 @@ export class MimoAdapter extends BaseAdapter {
* 构建 MiMo 原生请求体 * 构建 MiMo 原生请求体
* *
* MiMo 特有参数: * MiMo 特有参数:
* - 多模态图片:user 消息的 images[] → OpenAI content 数组 [{type:"text"}, {type:"image_url"}]
* - thinking: { type: "enabled" / "disabled" } — 与 DeepSeek 一致 * - thinking: { type: "enabled" / "disabled" } — 与 DeepSeek 一致
* - max_completion_tokens — 非 max_tokensMiMo 使用新字段名) * - max_completion_tokens — 非 max_tokensMiMo 使用新字段名)
* - stream_options: { include_usage: true } — 流式返回 usage * - stream_options: { include_usage: true } — 流式返回 usage
@@ -163,11 +164,42 @@ export class MimoAdapter extends BaseAdapter {
const messages = buildOpenAICompatibleMessages(request); const messages = buildOpenAICompatibleMessages(request);
const tools = buildOpenAICompatibleTools(request.tools); const tools = buildOpenAICompatibleTools(request.tools);
// === MiMo 多模态:将 images 转为 OpenAI content 数组 ===
// buildOpenAICompatibleMessages 不处理图片(各 Provider 自行处理)
// MiMo 是 OpenAI 兼容 API,多模态格式与 Agnes AI 一致
const nonSystemMsgs = request.messages.filter((m) => m.role !== 'system');
let imageCount = 0;
for (let i = 0; i < messages.length; i++) {
// messages[0] 是 system,非 system 消息从 messages[1] 开始
if (i === 0) continue;
const origMsg = nonSystemMsgs[i - 1];
if (!origMsg?.images?.length) continue;
imageCount += origMsg.images.length;
const contentParts: Array<Record<string, unknown>> = [];
if (origMsg.content) {
contentParts.push({ type: 'text', text: origMsg.content });
}
for (const img of origMsg.images) {
contentParts.push({
type: 'image_url',
image_url: { url: img.url },
});
}
messages[i].content = contentParts;
}
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
model: this.config.defaultModel, model: this.config.defaultModel,
messages, messages,
// MiMo 使用 max_completion_tokens(非 max_tokens // MiMo 使用 max_completion_tokens(非 max_tokens
max_completion_tokens: request.params.maxTokens, // #41 修复: thinking 模式下 max_completion_tokens 未配置时使用兜底默认值(32768)
// thinking 占用 token 配额,未配置时 API 默认值可能过小导致输出被截断
// thinkingEnabled !== false 包含 true 和 undefinedMiMo 默认 enabled)两种情况
// 审查修复: 兜底值从 63488 改为 32768,避免超出标准版上限导致 API 400
max_completion_tokens: request.params.maxTokens
?? (request.params.thinkingEnabled !== false ? 32768 : undefined),
stream, stream,
}; };
+59 -21
View File
@@ -50,15 +50,15 @@ export class OllamaAdapter extends BaseAdapter {
// H-2 修复: chat → send(规范要求) // H-2 修复: chat → send(规范要求)
async send(request: MetonaRequest): Promise<MetonaResponse> { async send(request: MetonaRequest): Promise<MetonaResponse> {
const nativeRequest = this.toNativeRequest(request); // #2 修复: toNativeRequest 改为 async(需下载 URL 图片转 base64
const nativeRequest = await this.toNativeRequest(request);
const response = await fetch(`${this.baseURL}/api/chat`, { // #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
const response = await this.fetchWithTimeout(`${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 }),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout }, this.config.timeoutMs ?? 300_000);
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
if (!response.ok) { if (!response.ok) {
await this.throwHttpError(response, 'Ollama API error'); await this.throwHttpError(response, 'Ollama API error');
@@ -70,15 +70,15 @@ export class OllamaAdapter extends BaseAdapter {
// H-2 修复: chatStream → sendStream(规范要求) // H-2 修复: chatStream → sendStream(规范要求)
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> { async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
const nativeRequest = this.toNativeRequest(request); // #2 修复: toNativeRequest 改为 async(需下载 URL 图片转 base64
const nativeRequest = await this.toNativeRequest(request);
const response = await fetch(`${this.baseURL}/api/chat`, { // #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
const response = await this.fetchWithTimeout(`${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 }),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout }, this.config.timeoutMs ?? 300_000);
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
if (!response.ok || !response.body) { if (!response.ok || !response.body) {
await this.throwHttpError(response, 'Ollama stream error'); await this.throwHttpError(response, 'Ollama stream error');
@@ -409,8 +409,33 @@ export class OllamaAdapter extends BaseAdapter {
// ========== 私有转换方法 ========== // ========== 私有转换方法 ==========
private toNativeRequest(request: MetonaRequest): Record<string, unknown> { /**
const messages = [ * #2 修复: 下载 http(s) URL 图片并转为纯 base64 字符串(不含 data: 前缀)
*
* Ollama API 的 images 字段要求纯 base64 字符串数组。
* 当 MetonaMessage.images 中存储的是 URL 时,需先下载转为 base64。
* 下载失败时返回空字符串(Ollama 会忽略空图片),不阻断整个请求。
*/
private async resolveImageToBase64(url: string): Promise<string> {
try {
// 审查修复: 使用基类 fetchWithTimeout 合并 externalAbortSignal 和 30s 超时,
// 避免用户中断时图片下载最多阻塞 30s×NexternalAbortSignal 是 BaseAdapter 的
// private 属性,子类无法直接访问,故复用已合并 signal 的 fetchWithTimeout
// 该方法同时处理了 listener 泄漏问题)
const res = await this.fetchWithTimeout(url, {}, 30_000);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const buf = Buffer.from(await res.arrayBuffer());
return buf.toString('base64');
} catch (error) {
log.warn(`[Ollama] Failed to download image ${url.slice(0, 100)}: ${(error as Error).message}`);
return '';
}
}
private async toNativeRequest(request: MetonaRequest): Promise<Record<string, unknown>> {
const messages: Record<string, unknown>[] = [
{ {
role: 'system', role: 'system',
content: [ content: [
@@ -420,20 +445,34 @@ export class OllamaAdapter extends BaseAdapter {
request.systemPrompt.dynamicReminders, request.systemPrompt.dynamicReminders,
].filter(Boolean).join('\n\n'), ].filter(Boolean).join('\n\n'),
}, },
...request.messages.filter((m) => m.role !== 'system').map((m) => { ];
// #2 修复: 改为 for 循环以支持 async 图片下载(map 回调无法 await)
for (const m of request.messages) {
if (m.role === 'system') continue;
// C-6 修复: Ollama API 不支持 null contentassistant 仅有 tool_calls 时转为空字符串 // C-6 修复: Ollama API 不支持 null contentassistant 仅有 tool_calls 时转为空字符串
const msg: Record<string, unknown> = { role: m.role, content: m.content ?? '' }; 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) => { // #2 修复: 支持公网 URL 图片,下载后转为纯 base64
// 之前直接将 URL 字符串传给 Ollama,导致 base64 解码错误
const resolvedImages: string[] = [];
for (const img of m.images) {
const url = img.url; const url = img.url;
// data:image/png;base64,iVBOR... → iVBOR...
if (url.startsWith('data:')) { if (url.startsWith('data:')) {
// data:image/png;base64,iVBOR... → iVBOR...
const base64Part = url.split(',')[1]; const base64Part = url.split(',')[1];
return base64Part ?? url; resolvedImages.push(base64Part ?? url);
} else if (url.startsWith('http://') || url.startsWith('https://')) {
// #2 修复: 公网 URL → 下载 → 纯 base64
const base64 = await this.resolveImageToBase64(url);
if (base64) resolvedImages.push(base64);
} else {
// 已是纯 base64 字符串(无 data: 前缀)
resolvedImages.push(url);
} }
return url; }
}); msg.images = resolvedImages;
} }
// 工具结果 // 工具结果
if (m.role === 'tool' && m.toolResult) { if (m.role === 'tool' && m.toolResult) {
@@ -455,9 +494,8 @@ export class OllamaAdapter extends BaseAdapter {
if (m.role === 'assistant' && m.reasoningContent) { if (m.role === 'assistant' && m.reasoningContent) {
(msg as Record<string, unknown>).reasoning_content = m.reasoningContent; (msg as Record<string, unknown>).reasoning_content = m.reasoningContent;
} }
return msg; messages.push(msg);
}), }
];
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
model: this.config.defaultModel, model: this.config.defaultModel,
@@ -46,6 +46,11 @@ export function buildOpenAICompatibleMessages(
content: m.content, content: m.content,
}; };
// 注意:图片(多模态)处理不在此共享函数中。
// DeepSeek 不支持多模态,images 被静默丢弃是正确行为。
// Agnes/MiMo 各自的 toNativeRequest 中有独立的 images 处理。
// 审查修复: #27 曾在此添加 images 处理,但 DeepSeek 不支持多模态会导致 API 400,已撤销。
// === Assistant 消息 === // === Assistant 消息 ===
if (m.role === 'assistant') { if (m.role === 'assistant') {
// 工具调用历史 // 工具调用历史
@@ -74,6 +79,10 @@ export function buildOpenAICompatibleMessages(
: (typeof m.toolResult.result === 'string' : (typeof m.toolResult.result === 'string'
? m.toolResult.result ? m.toolResult.result
: JSON.stringify(m.toolResult.result)); : JSON.stringify(m.toolResult.result));
// #26 修复: 确保 tool 消息 content 不为 undefined
// JSON.stringify(undefined) 返回 undefined(非字符串),会导致 content 字段在序列化后消失
// OpenAI/DeepSeek/Agnes API 严格要求 tool 消息必须有 content 字段,缺失会返回 400
if (msg.content === undefined) msg.content = '';
} }
return msg; return msg;
@@ -52,8 +52,14 @@ function* flushToolCallBuffer(
timestamp: Date.now(), timestamp: Date.now(),
}, },
}; };
} catch { } catch (err) {
// JSON 解析失败,跳过该工具调用 // #25 修复: 不再静默丢弃 JSON 解析失败工具调用
// 审查修复: 不再 yield ERROR 事件,因为 Engine 收到 ERROR 会 throw 中断整个请求,
// 导致一个好的工具调用 JSON 解析失败就丢弃所有工具调用。
// 改为 log.warn 记录后 continue 跳过这条坏的工具调用,继续处理 buffer 中剩余的。
const rawPreview = buf.argsBuffer?.slice(0, 200) ?? '';
log.warn(`[SSE] Tool call JSON parse failed: ${(err as Error).message}`, rawPreview);
continue;
} }
} }
toolCallsBuffer.clear(); toolCallsBuffer.clear();
+165 -25
View File
@@ -36,6 +36,7 @@ import type {
} from '../types'; } from '../types';
import { MetonaStreamEventType, MetonaFinishReason, MetonaErrorCode } from '../types'; import { MetonaStreamEventType, MetonaFinishReason, MetonaErrorCode } from '../types';
import { estimateMessagesTokens } from '../utils/token-estimator'; import { estimateMessagesTokens } from '../utils/token-estimator';
import { ContentFilterError } from '../adapters/base-adapter';
import log from 'electron-log'; import log from 'electron-log';
/** /**
@@ -80,6 +81,13 @@ export class AgentLoopEngine extends EventEmitter {
private workspacePath: string = ''; private workspacePath: string = '';
private abortController: AbortController | null = null; private abortController: AbortController | null = null;
private eventSeq = 0; private eventSeq = 0;
/**
* v0.3.18 修复: 最近一次 LLM 调用返回的真实输入 token 数
* 用于校正压缩判断——估算值可能偏低(尤其中文场景),
* 导致估算没到阈值不压缩,但真实 API 调用已经 413。
* 压缩判断时取 max(估算值, 真实值) 作为实际占用。
*/
private lastRealInputTokens = 0;
/** 当前 run 的唯一标识(用于前端过滤旧流事件) */ /** 当前 run 的唯一标识(用于前端过滤旧流事件) */
private runId: string = ''; private runId: string = '';
/** 正在进行的 run Promise(用于 run lock,防止并发 run 污染状态) */ /** 正在进行的 run Promise(用于 run lock,防止并发 run 污染状态) */
@@ -125,6 +133,22 @@ export class AgentLoopEngine extends EventEmitter {
*/ */
setAdapter(adapter: IMetonaProviderAdapter): void { setAdapter(adapter: IMetonaProviderAdapter): void {
this.adapter = adapter; this.adapter = adapter;
// #3 修复: 热切换 adapter 时同步 contextWindow,防止压缩阈值计算错误
this.syncContextWindow();
}
/**
* #3 修复: 从 adapter 同步 contextWindow 到 Engine 配置
*
* Engine 的 DEFAULT_CONFIG.contextWindow 硬编码为 128_000,但各 Provider 实际支持的
* 上下文窗口差异巨大(DeepSeek 64K / Agnes 1M / Ollama 4096)。
* 不同步会导致压缩阈值(compressionThreshold * contextWindow)计算错误。
*/
private syncContextWindow(): void {
const adapterCtx = this.adapter.getContextWindow?.();
if (adapterCtx && adapterCtx > 0) {
this.config.contextWindow = adapterCtx;
}
} }
/** /**
@@ -175,12 +199,17 @@ export class AgentLoopEngine extends EventEmitter {
this.currentIteration = 0; this.currentIteration = 0;
this.currentSessionId = sessionId; this.currentSessionId = sessionId;
this.totalTokens = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; this.totalTokens = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
// v0.3.18 修复: 每个 run 重置 lastRealInputTokens,避免跨 run 污染
this.lastRealInputTokens = 0;
this.abortController = new AbortController(); this.abortController = new AbortController();
// C-2 修复: 将 abortController 的 signal 注入到 adapter // C-2 修复: 将 abortController 的 signal 注入到 adapter
// 使正在进行的 fetch 可被用户中断,避免资源泄漏 // 使正在进行的 fetch 可被用户中断,避免资源泄漏
if (this.adapter.setAbortSignal) { if (this.adapter.setAbortSignal) {
this.adapter.setAbortSignal(this.abortController.signal); this.adapter.setAbortSignal(this.abortController.signal);
} }
// #3 修复: 每次 runStream 开始时从 adapter 同步 contextWindow
// 确保压缩阈值基于当前 Provider 的实际上下文窗口
this.syncContextWindow();
this.eventSeq = 0; this.eventSeq = 0;
// v0.3.0: 重置工具调用历史(用于死循环检测) // v0.3.0: 重置工具调用历史(用于死循环检测)
this.toolCallHistory = []; this.toolCallHistory = [];
@@ -302,6 +331,18 @@ export class AgentLoopEngine extends EventEmitter {
return this.adapter; return this.adapter;
} }
/**
* #4 修复: 恢复 adapter 的 abort signal
*
* SubEngine 共享主 Engine 的 adapter 时,SubEngine 会覆盖 adapter 的 abort signal。
* SubEngine 完成后,主 Engine 需调用此方法恢复自己的 signal,否则后续 fetch 无法被中断。
*/
restoreAbortSignal(): void {
if (this.abortController && this.adapter.setAbortSignal) {
this.adapter.setAbortSignal(this.abortController.signal);
}
}
/** 获取工作空间路径(供 SubAgent 继承) */ /** 获取工作空间路径(供 SubAgent 继承) */
getWorkspacePath(): string { getWorkspacePath(): string {
return this.workspacePath; return this.workspacePath;
@@ -331,14 +372,29 @@ export class AgentLoopEngine extends EventEmitter {
*/ */
async waitForAbort(timeoutMs: number = 5_000): Promise<boolean> { async waitForAbort(timeoutMs: number = 5_000): Promise<boolean> {
if (!this.currentRunPromise) return true; if (!this.currentRunPromise) return true;
// #29 修复: 原实现 Promise.race 永不抛错(currentRunPromise.catch 已吞异常),
// 导致 try/catch 永远走不到,总是返回 true。调用方误以为 abort 完成,
// 立即重发的新消息会卡在 runStream 的 currentRunPromise 等待中。
// 改为用 timedOut 标志检测超时,超时返回 false 让调用方提示用户"上一次操作未完成"。
let timedOut = false;
// 审查修复: 用 finally 块清理 timer,避免 currentRunPromise 先完成时 setTimeout 残留
let timerHandle: NodeJS.Timeout | undefined;
const timer = new Promise<void>((resolve) => {
timerHandle = setTimeout(() => {
timedOut = true;
resolve();
}, timeoutMs);
});
try { try {
await Promise.race([ await Promise.race([
this.currentRunPromise.catch(() => {}), this.currentRunPromise.catch(() => {}),
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)), timer,
]); ]);
return true; return !timedOut; // 超时返回 falserun 正常结束返回 true
} catch { } finally {
return false; // 审查修复: 无论 race 谁先完成,都清理 timer 防止事件循环残留
if (timerHandle) clearTimeout(timerHandle);
} }
} }
@@ -438,6 +494,12 @@ export class AgentLoopEngine extends EventEmitter {
completionTokens: event.usage.outputTokens ?? 0, completionTokens: event.usage.outputTokens ?? 0,
totalTokens: event.usage.totalTokens ?? 0, totalTokens: event.usage.totalTokens ?? 0,
}; };
// v0.3.18 修复: 记录最近一次 LLM 调用的真实输入 token,用于校正压缩判断
// 估算值可能偏低(尤其中文场景),导致不压缩但 API 413
const realInput = event.usage.inputTokens ?? 0;
if (realInput > 0) {
this.lastRealInputTokens = realInput;
}
} }
break; break;
@@ -550,18 +612,28 @@ export class AgentLoopEngine extends EventEmitter {
// === 上下文压缩(基于 token 使用率触发) === // === 上下文压缩(基于 token 使用率触发) ===
// 有效上下文窗口:Ollama 使用 contextLength (numCtx),其他 Provider 使用 contextWindow // 有效上下文窗口:Ollama 使用 contextLength (numCtx),其他 Provider 使用 contextWindow
const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow; // v0.3.18 修复: 加默认值 128_000 保护,避免 config 都为 undefined 时 compressionThreshold 变 NaN 导致压缩永不触发
const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow ?? 128_000;
const estimatedTokens = this.estimateMessagesTokens(request.messages); const estimatedTokens = this.estimateMessagesTokens(request.messages);
// v0.3.18 修复: 取 max(估算值, 真实值) 作为实际占用,避免估算偏低导致不压缩但 API 413
// 估算值用于 LLM 尚未返回 usage 时的早期判断(首轮或重试场景)
// 真实值用于校正——LLM 返回的 inputTokens 是 BPE 真实分词结果,比字符估算准确
const actualTokens = Math.max(estimatedTokens, this.lastRealInputTokens);
const compressionThreshold = this.config.compressionThreshold * effectiveContextWindow; const compressionThreshold = this.config.compressionThreshold * effectiveContextWindow;
if (estimatedTokens > compressionThreshold && request.messages.length > 10) { // v0.3.18 修复: 触发条件从"消息数 > 10"改为"消息数 >= 4"
// 新压缩策略按 token 预算动态截断,不再依赖固定 10 条。
// 至少 4 条消息(2 轮 user+assistant)才有压缩意义,否则保留区已是最小。
if (actualTokens > compressionThreshold && request.messages.length >= 4) {
await this.transitionTo(AgentLoopState.COMPRESSING); await this.transitionTo(AgentLoopState.COMPRESSING);
const compressed = await this.compressMessages(request.messages); const compressed = await this.compressMessages(request.messages);
if (compressed) { if (compressed) {
// 原地替换数组内容,确保外层 messages 引用同步更新 // 原地替换数组内容,确保外层 messages 引用同步更新
request.messages.splice(0, request.messages.length, ...compressed); request.messages.splice(0, request.messages.length, ...compressed);
// v0.3.18 修复: 压缩后重置 lastRealInputTokens,下一轮 LLM 调用会返回新的(更小的)真实值
this.lastRealInputTokens = 0;
this.emit('compressed', { this.emit('compressed', {
iteration: this.currentIteration, iteration: this.currentIteration,
originalTokens: estimatedTokens, originalTokens: actualTokens,
compressedTokens: this.estimateMessagesTokens(compressed), compressedTokens: this.estimateMessagesTokens(compressed),
}); });
} }
@@ -914,7 +986,8 @@ export class AgentLoopEngine extends EventEmitter {
/** /**
* 估算消息列表的 token 数 * 估算消息列表的 token 数
* 使用智能字符估算:中文 1.5 token/字,ASCII 0.25 token/字,其他 1 token/字 * 使用智能字符估算:中文 1.0 token/字,ASCII 0.25 token/字,其他 1 token/字
* v0.3.18: CJK 系数从 1.5 调整为 1.0,更贴近 BPE 实际值
* @see electron/harness/utils/token-estimator.ts * @see electron/harness/utils/token-estimator.ts
*/ */
private estimateMessagesTokens(messages: MetonaMessage[]): number { private estimateMessagesTokens(messages: MetonaMessage[]): number {
@@ -975,21 +1048,48 @@ export class AgentLoopEngine extends EventEmitter {
} }
/** /**
* 上下文压缩 — 将旧消息摘要为一条 system 消息,保留最近 3 轮完整对话 * 上下文压缩 — 将旧消息摘要为一条 assistant 消息,保留近期完整对话
*
* v0.3.18 修复: 保留策略从"固定 10 条"改为"按 token 预算动态截断"
* 之前固定保留最近 10 条,若 10 条里有超长 tool_result(如 read_file 读了 5000 行),
* 压缩后仍超 token 限制,下一轮 LLM 调用会返回 413。
* 现在按 effectiveContextWindow 的 50% 预算从末尾反向累加消息,
* 直到预算用尽或只剩 1 条可压缩消息。
* *
* 策略: * 策略:
* 1. 保留最后 keepRecent 条消息(约 3 轮对话 * 1. 计算 keepBudget = effectiveContextWindow * 0.5(保留区预算
* 2. 将前面的所有消息交给 LLM 生成摘要 * 2. 从末尾反向累加消息到 toKeep,直到累加 token 超过 keepBudget
* 3. 用 [Context Summary] system 消息 + 近期消息替换原数组 * 3. 将前面的所有消息交给 LLM 生成摘要
* 4. 用 [Context Summary] assistant 消息 + 占位 user + 近期消息替换原数组
* 5. 若单条消息超 keepBudget(超长 tool_result),单独二次截断
* *
* @returns 压缩后的消息数组,压缩失败时返回 null(调用方保持原数组) * @returns 压缩后的消息数组,压缩失败时返回 null(调用方保持原数组)
*/ */
private async compressMessages(messages: MetonaMessage[]): Promise<MetonaMessage[] | null> { private async compressMessages(messages: MetonaMessage[]): Promise<MetonaMessage[] | null> {
const keepRecent = 10; // 保留最近 10 条消息(约 3 轮 user+assistant+tool // v0.3.18 修复: 动态计算保留预算,避免固定 10 条在超长消息场景仍超限
if (messages.length <= keepRecent) return null; // 加默认值 128_000 保护,避免 config 都为 undefined 时 keepBudget 变 NaN
const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow ?? 128_000;
const keepBudget = Math.floor(effectiveContextWindow * 0.5); // 保留区占上下文窗口 50%
const minKeepCount = 2; // 至少保留最后 2 条(user + assistant),保证有可推理上下文
let toCompress = messages.slice(0, messages.length - keepRecent); // 从末尾反向累加,确定 toKeep 范围
let toKeep = messages.slice(messages.length - keepRecent); let keepStartIdx = messages.length;
let keepTokens = 0;
for (let i = messages.length - 1; i >= 0; i--) {
const msgTokens = this.estimateMessagesTokens([messages[i]]);
if (keepTokens + msgTokens > keepBudget && i < messages.length - minKeepCount) {
break;
}
keepTokens += msgTokens;
keepStartIdx = i;
}
// 至少保留 minKeepCount 条,且至少要有 1 条可压缩
if (keepStartIdx < minKeepCount) keepStartIdx = minKeepCount;
if (keepStartIdx >= messages.length) return null;
let toCompress = messages.slice(0, keepStartIdx);
let toKeep = messages.slice(keepStartIdx);
// MT-3 修复: 确保 toKeep 不以孤立的 tool 消息开头 // MT-3 修复: 确保 toKeep 不以孤立的 tool 消息开头
// OpenAI/DeepSeek API 要求 tool 消息前必须有带 tool_calls 的 assistant 消息 // OpenAI/DeepSeek API 要求 tool 消息前必须有带 tool_calls 的 assistant 消息
@@ -1058,18 +1158,54 @@ export class AgentLoopEngine extends EventEmitter {
if (!summary) return null; if (!summary) return null;
const summaryMessage: MetonaMessage = { const summaryMessage: MetonaMessage = {
// CE-1 修复: 使role: 'user' 而非 'system' // #30 修复: assistant 角色注入摘要,避免语义混淆
// adapter 的 buildOpenAICompatibleMessages 会过滤所有 role !== 'system' 的消息 // 原 CE-1 修复用 'user' 角色,会导致 LLM 将摘要误视为新的用户指令
// 只保留 systemPrompt 构建的 system 消息。如果用 'system',摘要会被丢弃,压缩无效 // 可能基于"Summary of previous conversation"字面意思执行奇怪操作
// 改为 'user' 后 LLM 会看到:system(systemPrompt) → user(摘要) → ...toKeep // 工单建议方案 A(system 角色)不可行:buildOpenAICompatibleMessages 会
role: 'user', // 过滤所有 role === 'system' 的消息(只保留 systemPrompt 构建的 system 消息),
// 用 system 角色摘要会被丢弃,压缩无效。
// 采用 assistant 角色:既不会被过滤,又保持语义中立(摘要是 AI 生成的总结),
// LLM 不会将其视为新的用户指令。
role: 'assistant',
content: `[Context Summary] The following is a summary of earlier conversation:\n\n${summary}`, content: `[Context Summary] The following is a summary of earlier conversation:\n\n${summary}`,
timestamp: Date.now(), timestamp: Date.now(),
}; };
log.info(`[AgentLoop] Context compressed: ${toCompress.length} messages → 1 summary, kept ${toKeep.length} recent`); // v0.3.18 修复: 若 toKeep 中仍有单条消息超 keepBudget,对其做二次截断
// 超长 tool_result(如 read_file 5000 行)即使保留也会撑爆上下文
const finalKeep = toKeep.map((msg) => {
const msgTokens = this.estimateMessagesTokens([msg]);
if (msgTokens > keepBudget && msg.content) {
// 截断内容,保留头部和尾部,中间用省略标记
const halfBudget = Math.floor(keepBudget / 2);
// v0.3.18 修复: charsPerToken 从 2 调整为 1.0(与 CJK_TOKEN_RATIO 一致)
// 原值 2 对中文偏激进(2 字符/token),实际中文约 1 字符/token,
// 导致截断后保留字符过多,实际 token 仍超 keepBudget
const charsPerToken = 1.0;
const keepChars = Math.floor(halfBudget * charsPerToken);
if (msg.content.length > keepChars * 2) {
const head = msg.content.slice(0, keepChars);
const tail = msg.content.slice(-keepChars);
return {
...msg,
content: `${head}\n\n... [truncated, ${msgTokens} tokens] ...\n\n${tail}`,
};
}
}
return msg;
});
return [summaryMessage, ...toKeep]; log.info(`[AgentLoop] Context compressed: ${toCompress.length} messages → 1 summary, kept ${finalKeep.length} recent (${keepTokens} tokens budget)`);
// 审查修复: #30 修复将摘要改为 assistant 角色,可能导致连续两个 assistant 消息
// (summary + 带 tool_calls 的 assistant),部分 Provider 会返回 400。
// 插入占位 user 消息保证对话流清晰。
return [
summaryMessage,
// 审查修复: 插入占位 user 消息避免连续 assistant 消息
{ role: 'user', content: '[Continue from the summary above.]', timestamp: Date.now() },
...finalKeep,
];
} catch (error) { } catch (error) {
log.warn('[AgentLoop] Context compression failed, keeping original messages:', (error as Error).message); log.warn('[AgentLoop] Context compression failed, keeping original messages:', (error as Error).message);
return null; return null;
@@ -1091,6 +1227,8 @@ export class AgentLoopEngine extends EventEmitter {
// v0.3.0 删除了 emit('error') 导致所有 adapter 错误对前端不可见 // v0.3.0 删除了 emit('error') 导致所有 adapter 错误对前端不可见
// 此处用 emit('streamEvent', { type: ERROR }) 不会触发 EventEmitter 的同步 throw // 此处用 emit('streamEvent', { type: ERROR }) 不会触发 EventEmitter 的同步 throw
if ((reason === TerminationReason.ERROR || reason === TerminationReason.DEAD_LOOP) && error) { if ((reason === TerminationReason.ERROR || reason === TerminationReason.DEAD_LOOP) && error) {
// v0.3.17: 识别 ContentFilterError,映射为 CONTENT_FILTERED 错误码 + 友好消息
const isContentFilter = error instanceof ContentFilterError;
this.emit('streamEvent', { this.emit('streamEvent', {
type: MetonaStreamEventType.ERROR, type: MetonaStreamEventType.ERROR,
requestId: this.currentRequestId, requestId: this.currentRequestId,
@@ -1100,8 +1238,10 @@ export class AgentLoopEngine extends EventEmitter {
timestamp: Date.now(), timestamp: Date.now(),
runId: this.runId, runId: this.runId,
error: { error: {
code: MetonaErrorCode.UNKNOWN, code: isContentFilter ? MetonaErrorCode.CONTENT_FILTERED : MetonaErrorCode.UNKNOWN,
message: error.message, message: isContentFilter
? '内容被 Provider 安全审核拦截,请修改图片或文本后重试'
: error.message,
retryable: false, retryable: false,
}, },
}); });
+15 -2
View File
@@ -324,8 +324,21 @@ export class ConfirmationHook implements PreToolHook {
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
const expiresAt = Date.now() + this.confirmationTimeoutMs; const expiresAt = Date.now() + this.confirmationTimeoutMs;
// #18 修复: 超时与用户确认的竞态条件防护
// 场景:超时 setTimeout 回调已进入事件循环队列但尚未执行时,用户点击确认,
// resolveConfirmation 中 clearTimeout 无法取消已排队的回调,
// 导致用户已确认但仍弹出"超时 toast"等副作用。
// 使用 settled 标志确保超时分支与确认分支互斥,先到者赢,另一分支直接 return。
let settled = false;
const safeResolve = (v: boolean) => {
if (settled) return;
settled = true;
resolve(v);
};
// 设置超时 // 设置超时
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (settled) return; // 已被 resolveConfirmation 处理,跳过超时副作用
this.pendingConfirmations.delete(request.toolCallId); this.pendingConfirmations.delete(request.toolCallId);
// 超时发送 toast 通知用户(3 秒节流,防止并行工具风暴) // 超时发送 toast 通知用户(3 秒节流,防止并行工具风暴)
if (this.mainWindow && !this.mainWindow.isDestroyed()) { if (this.mainWindow && !this.mainWindow.isDestroyed()) {
@@ -343,11 +356,11 @@ export class ConfirmationHook implements PreToolHook {
}); });
} }
} }
resolve(false); // 超时视为拒绝 safeResolve(false); // 超时视为拒绝
}, this.confirmationTimeoutMs); }, this.confirmationTimeoutMs);
this.pendingConfirmations.set(request.toolCallId, { this.pendingConfirmations.set(request.toolCallId, {
resolve, resolve: safeResolve,
timer, timer,
toolName: request.toolName, toolName: request.toolName,
expiresAt, expiresAt,
+8
View File
@@ -20,6 +20,10 @@ export class AuditLogHook implements PostToolHook {
constructor(private auditService: AuditService) {} constructor(private auditService: AuditService) {}
async afterExecute(toolCall: MetonaToolCall, result: MetonaToolResult, sessionId: string): Promise<void> { async afterExecute(toolCall: MetonaToolCall, result: MetonaToolResult, sessionId: string): Promise<void> {
// #17 修复: AuditHook 应为 "fire and forget"hook 失败不应影响工具执行链
// 虽然 AuditService.log() 内部已 try-catch,但 hook 层再加一层防御,
// 确保任何意外异常(如 getDB 抛错、JSON.stringify 失败)都不会冒泡到 ToolRegistry
try {
this.auditService.logToolCall({ this.auditService.logToolCall({
sessionId, sessionId,
iteration: toolCall.iteration, iteration: toolCall.iteration,
@@ -30,6 +34,10 @@ export class AuditLogHook implements PostToolHook {
error: result.error, error: result.error,
durationMs: result.durationMs, durationMs: result.durationMs,
}); });
} catch (err) {
log.error('[AuditLogHook] Failed to log audit:', err);
// 不抛出,让工具执行链继续
}
} }
} }
+117
View File
@@ -25,6 +25,7 @@ import type { IMetonaProviderAdapter } from '../types/metona-adapter';
import type { MetonaRequest, MetonaMessage } from '../types'; import type { MetonaRequest, MetonaMessage } from '../types';
import type { WorkspaceService } from '../../services/workspace.service'; import type { WorkspaceService } from '../../services/workspace.service';
import type { IterationStep } from '../agent-loop/types'; import type { IterationStep } from '../agent-loop/types';
import type { MemoryManager } from './manager';
/** 允许写入的 MEMORY.md 分区(与 WorkspaceService.MEMORY_TEMPLATE 对齐) */ /** 允许写入的 MEMORY.md 分区(与 WorkspaceService.MEMORY_TEMPLATE 对齐) */
const ALLOWED_SECTIONS = ['用户偏好', '项目上下文', '重要决策', '待办事项', '已知问题'] as const; const ALLOWED_SECTIONS = ['用户偏好', '项目上下文', '重要决策', '待办事项', '已知问题'] as const;
@@ -43,6 +44,11 @@ export interface ConsolidationResult {
} }
export class MemoryConsolidator { export class MemoryConsolidator {
/** v0.3.18 修复: 跟踪进行中的 consolidate 任务,供应用退出时等待 */
private runningPromise: Promise<ConsolidationResult> | null = null;
/** v0.3.18 修复: 注入 MemoryManager,实现 DB 记忆与 MEMORY.md 双轨交叉写入 */
private memoryManager: MemoryManager | null = null;
constructor( constructor(
private adapter: IMetonaProviderAdapter, private adapter: IMetonaProviderAdapter,
private workspaceService: WorkspaceService, private workspaceService: WorkspaceService,
@@ -55,6 +61,47 @@ export class MemoryConsolidator {
this.adapter = adapter; this.adapter = adapter;
} }
/**
* v0.3.18 修复: 注入 MemoryManager,使 consolidate 同步写入 semantic_memories 表
*/
setMemoryManager(memoryManager: MemoryManager): void {
this.memoryManager = memoryManager;
}
/**
* v0.3.18 修复: 是否有进行中的 consolidate 任务
*/
isRunning(): boolean {
return this.runningPromise !== null;
}
/**
* v0.3.18 修复: 等待进行中的 consolidate 任务完成(应用退出时调用)
*
* @param timeoutMs 超时时间(默认 35 秒,略大于 LLM 调用的 30 秒超时)
* @returns true 表示已完成,false 表示超时
*/
async waitForCompletion(timeoutMs: number = 35_000): Promise<boolean> {
if (!this.runningPromise) return true;
let timedOut = false;
let timerHandle: ReturnType<typeof setTimeout> | undefined;
const timer = new Promise<void>((resolve) => {
timerHandle = setTimeout(() => {
timedOut = true;
resolve();
}, timeoutMs);
});
try {
await Promise.race([
this.runningPromise.catch(() => {}),
timer,
]);
return !timedOut;
} finally {
if (timerHandle) clearTimeout(timerHandle);
}
}
/** /**
* 固化本次对话的重要记忆到 MEMORY.md * 固化本次对话的重要记忆到 MEMORY.md
* *
@@ -67,6 +114,32 @@ export class MemoryConsolidator {
userMessage: string, userMessage: string,
assistantAnswer: string, assistantAnswer: string,
iterations: IterationStep[], iterations: IterationStep[],
): Promise<ConsolidationResult> {
// v0.3.18 修复: 跟踪进行中的 consolidate,供应用退出时等待
const promise = this.executeConsolidation(userMessage, assistantAnswer, iterations);
this.runningPromise = promise;
try {
return await promise;
} finally {
// 只有当前 promise 仍是自己时才清理,避免被后续调用覆盖
if (this.runningPromise === promise) {
this.runningPromise = null;
}
}
}
/**
* 实际执行固化的内部方法
*
* v0.3.18 修复: 同步写入 semantic_memories 表,实现 DB 记忆与 MEMORY.md 双轨交叉
* 之前仅写 MEMORY.md,导致 DB 检索不到用户偏好等非工具触发的重要信息。
* 现在每条固化到 MEMORY.md 的条目也同步存入 semantic_memories 表,
* 使 TF-IDF 检索能命中跨会话的用户偏好/决策/项目上下文。
*/
private async executeConsolidation(
userMessage: string,
assistantAnswer: string,
iterations: IterationStep[],
): Promise<ConsolidationResult> { ): Promise<ConsolidationResult> {
try { try {
// 1. 构建对话摘要 // 1. 构建对话摘要
@@ -105,6 +178,27 @@ export class MemoryConsolidator {
try { try {
this.workspaceService.appendMemory(item.section, truncatedEntry); this.workspaceService.appendMemory(item.section, truncatedEntry);
validEntries.push({ section: item.section, entry: truncatedEntry }); validEntries.push({ section: item.section, entry: truncatedEntry });
// v0.3.18 修复: 同步写入 semantic_memories 表,实现 DB 记忆与 MEMORY.md 双轨交叉
// 之前仅写 MEMORY.md,导致 DB 检索不到用户偏好等非工具触发的重要信息。
// 现在每条固化到 MEMORY.md 的条目也存入 semantic_memories
// key 用 section(如"用户偏好"),value 用 entry 内容,
// confidence 按 section 语义分级(偏好/决策最高,待办最低)
if (this.memoryManager) {
try {
const confidence = this.getSectionConfidence(item.section);
this.memoryManager.store({
type: 'semantic',
content: truncatedEntry,
summary: `[${item.section}] ${truncatedEntry.slice(0, 60)}`,
source: 'agent_thought',
importance: confidence,
});
} catch (dbErr) {
// DB 写入失败不影响 MEMORY.md 已写入的结果,仅记录日志
log.warn(`[MemoryConsolidator] Failed to sync to semantic_memories:`, dbErr);
}
}
} catch (err) { } catch (err) {
log.warn(`[MemoryConsolidator] Failed to append to section "${item.section}":`, err); log.warn(`[MemoryConsolidator] Failed to append to section "${item.section}":`, err);
skipped++; skipped++;
@@ -278,6 +372,29 @@ export class MemoryConsolidator {
return (ALLOWED_SECTIONS as readonly string[]).includes(item.section) && item.entry.length > 0; return (ALLOWED_SECTIONS as readonly string[]).includes(item.section) && item.entry.length > 0;
} }
/**
* v0.3.18 修复: 按 MEMORY.md 分区语义返回 confidence(重要度)
*
* 用于同步写入 semantic_memories 时的 importance 字段:
* - 用户偏好/重要决策:0.9(长期有效,跨会话高价值)
* - 项目上下文/已知问题:0.7(项目级,中等价值)
* - 待办事项:0.5(短期,可能很快完成)
*/
private getSectionConfidence(section: string): number {
switch (section) {
case '用户偏好':
case '重要决策':
return 0.9;
case '项目上下文':
case '已知问题':
return 0.7;
case '待办事项':
return 0.5;
default:
return 0.6;
}
}
/** /**
* 截断字符串 * 截断字符串
*/ */
+37 -7
View File
@@ -14,6 +14,7 @@
*/ */
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { createHash } from 'crypto';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import log from 'electron-log'; import log from 'electron-log';
@@ -43,22 +44,38 @@ interface MemorySearchOptions {
minImportance?: number; minImportance?: number;
} }
/** 分词:将文本拆分为词项(支持中英文) */ /**
* 分词:将文本拆分为词项(支持中英文)
*
* v0.3.18 修复: 改进中文分词策略,减少 bigram 噪声
* 之前对整段文本连续取 bigram,会跨越标点边界产生无意义组合
* (如"开发规范。下一句"会产生"范。"和"。下"这类噪声 bigram),
* 降低 IDF 区分度。
* 现在先按中英文标点切分子句,再在每个子句内做 bigram,
* 避免跨句组合,提升检索准确度。
*/
function tokenize(text: string): string[] { function tokenize(text: string): string[] {
// 转小写,按非字母数字字符分割 // 转小写
const lower = text.toLowerCase(); const lower = text.toLowerCase();
// 英文词 // 英文词
const words = lower.match(/[a-z][a-z0-9_-]{1,}/g) ?? []; const words = lower.match(/[a-z][a-z0-9_-]{1,}/g) ?? [];
// 中文 bigram(相邻两字组合),覆盖 CJK 统一表意、扩展 A、平假名/片假名、谚文
const cjkChars = lower.match(/[\u4e00-\u9fff\u3400-\u4dbf\u3040-\u30ff\uac00-\ud7af]/g) ?? []; // v0.3.18 修复: 按中英文标点切分子句,再在每个子句内做 bigram
// 标点包括:中文句号/逗号/顿号/分号/感叹/问号 + 英文 .,;!?()
const sentences = lower.split(/[。,、;!?.,;!?()\n\r\t]/);
const bigrams: string[] = []; const bigrams: string[] = [];
for (const sentence of sentences) {
// 提取子句内的 CJK 字符(覆盖 CJK 统一表意、扩展 A、平假名/片假名、谚文)
const cjkChars = sentence.match(/[\u4e00-\u9fff\u3400-\u4dbf\u3040-\u30ff\uac00-\ud7af]/g);
if (!cjkChars || cjkChars.length === 0) continue;
for (let i = 0; i < cjkChars.length - 1; i++) { for (let i = 0; i < cjkChars.length - 1; i++) {
bigrams.push(cjkChars[i] + cjkChars[i + 1]); bigrams.push(cjkChars[i] + cjkChars[i + 1]);
} }
// 单字 CJK 补 unigram(避免单字文档无 token // 单字 CJK 子句补 unigram(避免单字文档无 token
if (cjkChars.length === 1) { if (cjkChars.length === 1) {
bigrams.push(cjkChars[0]); bigrams.push(cjkChars[0]);
} }
}
return [...words, ...bigrams]; return [...words, ...bigrams];
} }
@@ -352,17 +369,21 @@ export class MemoryManager {
break; break;
case 'semantic': case 'semantic':
// v0.3.0 修复:使用 summary 作为 key(若提供),支持更新已有语义记忆 // v0.3.0 修复:使用 summary 作为 key(若提供),支持更新已有语义记忆
// #32 修复: 当 summary 未提供时,使用 content hash 作为 key 实现基于内容的去重
// v0.3.0 用 id 作为 key 时,因 id 每次新生成,INSERT OR REPLACE 永远不触发 REPLACE
// 导致重复 store 同一内容会创建多条记忆。改为 contentHash 后,相同内容自动 REPLACE。
db.prepare(` db.prepare(`
INSERT OR REPLACE INTO semantic_memories (id, key, value, category, confidence, source_session, created_at, updated_at, access_count) INSERT OR REPLACE INTO semantic_memories (id, key, value, category, confidence, source_session, created_at, updated_at, access_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
`).run(id, item.summary ?? id, item.content, 'general', importance, item.sessionId ?? null, now, now); `).run(id, item.summary ?? this.contentHash(item.content), item.content, 'general', importance, item.sessionId ?? null, now, now);
break; break;
case 'working': case 'working':
// v0.3.0 修复:使用 summary 作为 key(若提供),避免硬编码 'default' 导致覆盖 // v0.3.0 修复:使用 summary 作为 key(若提供),避免硬编码 'default' 导致覆盖
// #32 修复: 当 summary 未提供时,使用 content hash 作为 key 实现基于内容的去重
db.prepare(` db.prepare(`
INSERT OR REPLACE INTO working_memories (id, session_id, task_id, key, value, updated_at) INSERT OR REPLACE INTO working_memories (id, session_id, task_id, key, value, updated_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`).run(id, item.sessionId ?? 'default', 'default', item.summary ?? id, item.content, now); `).run(id, item.sessionId ?? 'default', 'default', item.summary ?? this.contentHash(item.content), item.content, now);
break; break;
default: default:
// v0.3.0 修复:未知 type 抛错而非静默失败 // v0.3.0 修复:未知 type 抛错而非静默失败
@@ -519,4 +540,13 @@ export class MemoryManager {
if (item.content.length > 200) score += 0.1; if (item.content.length > 200) score += 0.1;
return Math.min(1, Math.max(0, score)); return Math.min(1, Math.max(0, score));
} }
/**
* #32 修复: 计算 content 的 SHA-256 hash(取前 16 字符),用于基于内容的去重
* 当 store 未提供 summary 时,用 contentHash 作为 semantic/working 的 key
* 使 INSERT OR REPLACE 能基于内容触发 REPLACE,避免重复存储相同内容。
*/
private contentHash(content: string): string {
return createHash('sha256').update(content, 'utf-8').digest('hex').slice(0, 16);
}
} }
+21 -14
View File
@@ -176,13 +176,6 @@ export class TaskOrchestrator extends EventEmitter {
handle.status = success ? 'completed' : 'error'; handle.status = success ? 'completed' : 'error';
handle.result = result; handle.result = result;
this.activeSubAgents.delete(taskId);
// v0.3.0 修复: 深度恢复为 0 时删除条目,避免 sessionDepth Map 随会话累积
if (currentDepth === 0) {
this.sessionDepth.delete(params.parentSessionId);
} else {
this.sessionDepth.set(params.parentSessionId, currentDepth);
}
this.emit('taskCompleted', result); this.emit('taskCompleted', result);
log.info(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) ${success ? 'completed' : 'failed'} in ${durationMs}ms, ${output.iterations.length} iterations`); log.info(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) ${success ? 'completed' : 'failed'} in ${durationMs}ms, ${output.iterations.length} iterations`);
@@ -201,18 +194,28 @@ export class TaskOrchestrator extends EventEmitter {
handle.status = 'error'; handle.status = 'error';
handle.result = result; handle.result = result;
this.activeSubAgents.delete(taskId);
// v0.3.0 修复: 深度恢复为 0 时删除条目,避免 sessionDepth Map 随会话累积
if (currentDepth === 0) {
this.sessionDepth.delete(params.parentSessionId);
} else {
this.sessionDepth.set(params.parentSessionId, currentDepth);
}
this.emit('taskError', { taskId, error: errMsg }); this.emit('taskError', { taskId, error: errMsg });
log.error(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) error: ${errMsg}`); log.error(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) error: ${errMsg}`);
return result; return result;
} finally {
// #5 修复: 统一在 finally 块恢复 sessionDepth,覆盖正常完成/异常/abort 所有路径
// 之前 try 和 catch 中各有一份重复的恢复逻辑,且 abort 路径(handle.abort)跳过了恢复
// 审查修复: 如果 abortAll 已 clear sessionDepth,不再恢复(避免覆盖紧急清理)。
// 场景:用户紧急中断时 abortAll 先 clear,若 SubEngine 随后才返回执行 finally
// 不应把已清空的 sessionDepth 又 set 回 currentDepth。
if (this.sessionDepth.has(params.parentSessionId)) {
if (currentDepth === 0) {
this.sessionDepth.delete(params.parentSessionId);
} else {
this.sessionDepth.set(params.parentSessionId, currentDepth);
}
}
this.activeSubAgents.delete(taskId);
// #4 修复: SubEngine 共享主 Engine 的 adapter,完成后必须恢复主 Engine 的 abort signal
// 否则主 Engine 后续 fetch 无法被用户中断(SubEngine 覆盖并清除了 adapter 的 signal
this.mainEngine.restoreAbortSignal();
} }
} }
@@ -302,6 +305,10 @@ export class TaskOrchestrator extends EventEmitter {
agent.abort(); agent.abort();
} }
this.activeSubAgents.clear(); this.activeSubAgents.clear();
// 审查修复: 恢复 sessionDepth.clear(),保留紧急清理能力。
// #5 修复曾移除此行,但若 SubEngine 卡死不返回,delegate 的 finally 永远不会执行,
// sessionDepth 将永久残留。此处 clear 确保紧急路径能立即恢复状态。
// 配合 delegate finally 块的 has() 检查:若已被 clear,finally 不再恢复(避免覆盖)。
this.sessionDepth.clear(); this.sessionDepth.clear();
} }
} }
+56 -4
View File
@@ -15,6 +15,7 @@
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第五章 * @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第五章
*/ */
import log from 'electron-log';
import type { MetonaContext, MetonaMemoryItem, MetonaMessage, MetonaSystemPrompt, MetonaToolDef } from '../types'; import type { MetonaContext, MetonaMemoryItem, MetonaMessage, MetonaSystemPrompt, MetonaToolDef } from '../types';
import type { WorkspaceFiles } from '../../services/workspace.service'; import type { WorkspaceFiles } from '../../services/workspace.service';
import { estimateStringTokens } from '../utils/token-estimator'; import { estimateStringTokens } from '../utils/token-estimator';
@@ -34,6 +35,30 @@ interface ContextBuildParams {
* 上下文构建器 * 上下文构建器
*/ */
export class ContextBuilder { export class ContextBuilder {
/**
* v0.3.18 修复: 标记上次 build 是否使用了兜底身份(SOUL.md 为空或不存在)
* 供 handlers.ts 读取后决定是否向前端发送 toast 提示
*/
private lastUsedFallbackRole = false;
/**
* v0.3.18 修复: 降级通知去重标志
* 第一次降级时通知,之后不再重复通知(避免每次发消息都弹 toast)
* 当 SOUL.md 恢复内容后重置为 false,下次再降级时会重新通知
*/
private fallbackRoleNotified = false;
/**
* v0.3.18 修复: 获取上次 build 是否使用了兜底身份
* 仅在首次降级(或恢复后再次降级)时返回 true,避免每次发消息都弹 toast
*/
isUsingFallbackRole(): boolean {
if (this.lastUsedFallbackRole && !this.fallbackRoleNotified) {
this.fallbackRoleNotified = true;
return true;
}
return false;
}
/** /**
* 构建完整的 MetonaContext * 构建完整的 MetonaContext
*/ */
@@ -94,12 +119,17 @@ export class ContextBuilder {
// v0.3.14: 注入当前系统日期时间(每次构建时获取最新时间) // v0.3.14: 注入当前系统日期时间(每次构建时获取最新时间)
// 用于让 AI 准确理解"今天"、"昨天"等相对时间表达 // 用于让 AI 准确理解"今天"、"昨天"等相对时间表达
// #43 修复: 时区硬编码 Asia/Shanghai 改为使用系统本地时区,跨时区用户显示正确
const now = new Date(); const now = new Date();
const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'Asia/Shanghai';
// 审查修复: 恢复 UTC 偏移显示,在时区名后附加 UTC 偏移,避免丢失时区偏移信息
const offset = -now.getTimezoneOffset() / 60;
const offsetStr = offset >= 0 ? `UTC+${offset}` : `UTC${offset}`;
const dateTimeStr = now.toLocaleString('zh-CN', { const dateTimeStr = now.toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai', timeZone: localTimezone,
hour12: false, hour12: false,
}); });
dynamicParts.push(`## Current Date & Time\n${dateTimeStr} (Asia/Shanghai, UTC+8)`); dynamicParts.push(`## Current Date & Time\n${dateTimeStr} (${localTimezone}, ${offsetStr})`);
// 注入当前工作空间路径(动态区,路径可能切换故不放入静态区) // 注入当前工作空间路径(动态区,路径可能切换故不放入静态区)
if (workspacePath) { if (workspacePath) {
@@ -133,6 +163,8 @@ export class ContextBuilder {
* *
* v0.3.14: 兜底身份定义使用 Metona 灵魂定义(含角色/原则/风格/边界/元指令) * v0.3.14: 兜底身份定义使用 Metona 灵魂定义(含角色/原则/风格/边界/元指令)
* SOUL.md 存在但内容为空(仅空白字符)时也走兜底分支 * SOUL.md 存在但内容为空(仅空白字符)时也走兜底分支
* v0.3.18 修复: 降级时设置 lastUsedFallbackRole 标志 + 打 WARN 日志,
* 避免用户自定义人格丢失但不知情的"安静失败"问题
*/ */
private buildRoleDefinition(soulContent?: string): string { private buildRoleDefinition(soulContent?: string): string {
const parts: string[] = []; const parts: string[] = [];
@@ -140,8 +172,14 @@ export class ContextBuilder {
// v0.3.14: SOUL.md 不存在或内容为空(仅空白)时使用兜底身份定义 // v0.3.14: SOUL.md 不存在或内容为空(仅空白)时使用兜底身份定义
if (soulContent && soulContent.trim()) { if (soulContent && soulContent.trim()) {
// SOUL.md 存在且有内容 — 直接使用其全部内容,不加任何固定前缀 // SOUL.md 存在且有内容 — 直接使用其全部内容,不加任何固定前缀
this.lastUsedFallbackRole = false;
// v0.3.18 修复: SOUL.md 恢复内容后重置通知标志,下次再降级时会重新通知
this.fallbackRoleNotified = false;
parts.push(soulContent); parts.push(soulContent);
} else { } else {
// v0.3.18 修复: 降级时打 WARN 日志 + 设置标志,供 handlers.ts 读取后发 toast
this.lastUsedFallbackRole = true;
log.warn('[ContextBuilder] SOUL.md is missing or empty, falling back to default Metona identity');
// 兜底身份定义(Metona 灵魂定义) // 兜底身份定义(Metona 灵魂定义)
parts.push(`# Metona — 灵魂定义 parts.push(`# Metona — 灵魂定义
> "想清楚再动手,做对比做快重要" > "想清楚再动手,做对比做快重要"
@@ -250,7 +288,8 @@ For multi-step complex tasks (3+ steps), proactively use \`task_manager\` to bre
/** /**
* Token 估算 * Token 估算
* 使用智能字符估算:中文 1.5 token/字,ASCII 0.25 token/字,其他 1 token/字 * 使用智能字符估算:中文 1.0 token/字,ASCII 0.25 token/字,其他 1 token/字
* v0.3.18: CJK 系数从 1.5 调整为 1.0,更贴近 BPE 实际值
* @see electron/harness/utils/token-estimator.ts * @see electron/harness/utils/token-estimator.ts
*/ */
private estimateTokens( private estimateTokens(
@@ -277,7 +316,20 @@ For multi-step complex tasks (3+ steps), proactively use \`task_manager\` to bre
for (const mem of memories) total += estimateStringTokens(mem.content); for (const mem of memories) total += estimateStringTokens(mem.content);
// 工具定义 // 工具定义
for (const tool of tools) total += estimateStringTokens(tool.name + tool.description); // #31 修复: 之前仅累加 tool.name + tool.description,忽略 tool.parametersJSON Schema),
// 而 parameters schema 通常占工具 token 的 70%+,导致估算严重偏低、压缩阈值判断错误
for (const tool of tools) {
total += estimateStringTokens(tool.name);
total += estimateStringTokens(tool.description);
// 审查修复: 用 try-catch 包裹 JSON.stringify,防止循环引用等异常导致估算中断
if (tool.parameters) {
try {
total += estimateStringTokens(JSON.stringify(tool.parameters));
} catch {
total += estimateStringTokens(String(tool.parameters));
}
}
}
// 用户输入 // 用户输入
total += estimateStringTokens(userInput); total += estimateStringTokens(userInput);
+67
View File
@@ -157,15 +157,34 @@ export class PolicyEngine {
argsStr = String(args); argsStr = String(args);
} }
// #14 修复: 深度递归扫描所有字符串字段值,防止 Unicode 转义/嵌套对象/字符串拼接绕过
// 原 JSON.stringify 后整体正则匹配可被 \u0029 编码、嵌套对象伪装、正则注入等方式绕过
// 现对每个字符串字段值单独匹配,同时保留整体 argsStr 兜底匹配
const stringValues = this.deepScanStrings(args);
// v0.2.0: allowedPatterns 白名单校验 — 若定义了白名单,参数必须匹配其中之一 // v0.2.0: allowedPatterns 白名单校验 — 若定义了白名单,参数必须匹配其中之一
// #14: 字段级匹配 + 整体兜底,任一匹配即通过
if (policy.allowedPatterns && policy.allowedPatterns.length > 0) { if (policy.allowedPatterns && policy.allowedPatterns.length > 0) {
let matchedAllowed = false; let matchedAllowed = false;
// 先做字段级匹配
for (const val of stringValues) {
for (const pattern of policy.allowedPatterns) {
if (pattern.test(val)) {
matchedAllowed = true;
break;
}
}
if (matchedAllowed) break;
}
// 兜底:整体 argsStr 匹配
if (!matchedAllowed) {
for (const pattern of policy.allowedPatterns) { for (const pattern of policy.allowedPatterns) {
if (pattern.test(argsStr)) { if (pattern.test(argsStr)) {
matchedAllowed = true; matchedAllowed = true;
break; break;
} }
} }
}
if (!matchedAllowed) { if (!matchedAllowed) {
return { return {
authorized: false, authorized: false,
@@ -176,7 +195,22 @@ export class PolicyEngine {
} }
} }
// #14 修复: deniedPatterns 字段级深度扫描 — 对每个字符串值单独匹配
// 防止危险内容隐藏在嵌套对象或 Unicode 编码中绕过整体 stringify 匹配
if (policy.deniedPatterns) { if (policy.deniedPatterns) {
for (const val of stringValues) {
for (const pattern of policy.deniedPatterns) {
if (pattern.test(val)) {
return {
authorized: false,
reason: 'Command blocked by security policy',
level: policy.requiredLevel,
requiresConfirmation: false,
};
}
}
}
// 兜底:整体 argsStr 匹配(保留原有行为,捕获跨字段拼接的危险模式)
for (const pattern of policy.deniedPatterns) { for (const pattern of policy.deniedPatterns) {
if (pattern.test(argsStr)) { if (pattern.test(argsStr)) {
return { return {
@@ -264,4 +298,37 @@ export class PolicyEngine {
// v0.3.0 修复: cleanupFrequencyRecords 已删除 — checkFrequency 和 recordCall 已做内联清理, // v0.3.0 修复: cleanupFrequencyRecords 已删除 — checkFrequency 和 recordCall 已做内联清理,
// 该方法属于死代码,删除以减少维护负担 // 该方法属于死代码,删除以减少维护负担
/**
* #14 修复: 深度递归扫描对象,提取所有字符串字段值
*
* 替代 JSON.stringify 后整体正则匹配的方式,防止:
* - Unicode 转义绕过(\u0029 等编码)
* - 嵌套对象伪装({a: {b: 'dangerous'}}
* - 字符串拼接绕过('rm ' + '-rf /' 在 stringify 后是合法字符串)
* - 正则注入(输入中包含正则元字符破坏匹配逻辑)
*
* @param obj 待扫描的对象
* @returns 所有字符串字段值的数组
*/
// 审查修复: 添加 visited Set 参数防止循环引用导致无限递归栈溢出
private deepScanStrings(obj: unknown, visited: Set<unknown> = new Set()): string[] {
const results: string[] = [];
if (typeof obj === 'string') {
results.push(obj);
} else if (Array.isArray(obj)) {
if (visited.has(obj)) return results;
visited.add(obj);
for (const item of obj) {
results.push(...this.deepScanStrings(item, visited));
}
} else if (obj && typeof obj === 'object') {
if (visited.has(obj)) return results;
visited.add(obj);
for (const v of Object.values(obj)) {
results.push(...this.deepScanStrings(v, visited));
}
}
return results;
}
} }
+17
View File
@@ -95,6 +95,7 @@ export class SandboxManager {
*/ */
scanCode(code: string): { safe: boolean; reason?: string } { scanCode(code: string): { safe: boolean; reason?: string } {
// C-5 修复: 补齐 28 个模式 + 加强 base64/$() 检测 // C-5 修复: 补齐 28 个模式 + 加强 base64/$() 检测
// #13 修复: 补全 require('fs')、动态 import、process.binding、new Function、Reflect.get 等绕过模式
// @see project_memory.md — sandbox scanCode must include 28 patterns with 'i' flag // @see project_memory.md — sandbox scanCode must include 28 patterns with 'i' flag
// and base64/$() detection to prevent encoding bypass // and base64/$() detection to prevent encoding bypass
const dangerousPatterns = [ const dangerousPatterns = [
@@ -102,6 +103,22 @@ export class SandboxManager {
/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,
// #13 新增: require('fs') 同步 require 形式(1
/require\s*\(\s*['"]fs['"]\s*\)/i,
// #13 新增: 动态 import('fs') / import('child_process')2
/\bimport\s*\(\s*['"](?:fs|child_process|os|net|http|https|crypto|dns|cluster)['"]\s*\)/i,
// 审查修复 (M8): 兜底检测所有动态 import 调用 — 原模式只匹配字符串字面量,
// 动态 import 用变量 import(m) 可绕过。沙箱中应禁止所有动态 import。
// 注意:此模式较宽泛,会拦截 import(safeModule),但沙箱 fail-closed 策略可接受。
/\bimport\s*\(/i,
// #13 新增: process.binding('fs') 底层绑定(1
/\bprocess\.binding\s*\(/i,
// #13 新增: new Function() 函数构造(1
/\bnew\s+Function\s*\(/i,
// #13 新增: Reflect.get 字符串拼接绕过(1
/\bReflect\.get\s*\(/i,
// #13 新增: Function('return process') 等函数构造执行(1
/\bFunction\s*\(\s*['"]return\s+(?:process|require|global|globalThis)['"]\s*\)/i,
// 代码执行(3 // 代码执行(3
/\beval\s*\(/i, /\beval\s*\(/i,
/process\.exit/i, /process\.exit/i,
@@ -42,16 +42,17 @@ export class PromptInjectionDefender {
private static INJECTION_PATTERNS: InjectionPattern[] = [ private static INJECTION_PATTERNS: InjectionPattern[] = [
// === HIGH 危险:直接越狱/忽略指令 === // === HIGH 危险:直接越狱/忽略指令 ===
// v0.3.0 修复:在关键词间允许可选限定词(the/any/all/these/those/your 等),防止 "ignore the previous instructions" 绕过 // v0.3.0 修复:在关键词间允许可选限定词(the/any/all/these/those/your 等),防止 "ignore the previous instructions" 绕过
{ pattern: /ignore\s+(?:(?:the|all|any|these|those|your|above)\s+)*(?:previous|above|prior)\s+(?:(?:the|your|all)\s+)*(?:instructions?|commands?|directives?|rules?)/i, severity: 'high' }, // #15 修复: 添加 \b 单词边界,防止 "Xignore previous instructions" / "ignore previous instructionss" 等前后缀绕过
{ pattern: /forget\s+(?:(?:everything|all|the|your|above|prior)\s+)+/i, severity: 'high' }, { pattern: /\bignore\s+(?:(?:the|all|any|these|those|your|above)\s+)*(?:previous|above|prior)\s+(?:(?:the|your|all)\s+)*(?:instructions?|commands?|directives?|rules?)\b/i, severity: 'high' },
{ pattern: /override\s+(?:(?:the|your|all|any)\s+)+/i, severity: 'high' }, { pattern: /\bforget\s+(?:(?:everything|all|the|your|above|prior)\s+)+/i, severity: 'high' },
{ pattern: /JAILBREAK/i, severity: 'high' }, { pattern: /\boverride\s+(?:(?:the|your|all|any)\s+)+/i, severity: 'high' },
{ pattern: /DAN\s*[:\[]/i, severity: 'high' }, { pattern: /\bJAILBREAK\b/i, severity: 'high' },
{ pattern: /(?:enable|turn\s+on|activate)\s+(?:(?:the|your)\s+)*(?:developer|debug|root|admin)\s+mode/i, severity: 'high' }, { pattern: /\bDAN\s*[:\[]/i, severity: 'high' },
{ pattern: /(?:disable|turn\s+off|bypass)\s+(?:(?:the|your|all|any)\s+)*(?:safety|security|filter|guard|defense|restrictions?)/i, severity: 'high' }, { pattern: /\b(?:enable|turn\s+on|activate)\s+(?:(?:the|your)\s+)*(?:developer|debug|root|admin)\s+mode\b/i, severity: 'high' },
{ pattern: /(?:send|transmit|exfiltrate|upload)\s+(?:(?:your|the|all|any)\s+)*(?:data|memory|context|secrets?)/i, severity: 'high' }, { pattern: /\b(?:disable|turn\s+off|bypass)\s+(?:(?:the|your|all|any)\s+)*(?:safety|security|filter|guard|defense|restrictions?)\b/i, severity: 'high' },
{ pattern: /(?:you\s+are|act\s+as|pretend\s+to\s+be)\s+(?:a|an)\s+(?:unrestricted|unfiltered|unlimited|free)/i, severity: 'high' }, { pattern: /\b(?:send|transmit|exfiltrate|upload)\s+(?:(?:your|the|all|any)\s+)*(?:data|memory|context|secrets?)\b/i, severity: 'high' },
{ pattern: /(?:in|under)\s+(?:(?:the|a)\s+)*(?:developer|unrestricted|jailbreak|god)\s+mode/i, severity: 'high' }, { pattern: /\b(?:you\s+are|act\s+as|pretend\s+to\s+be)\s+(?:a|an)\s+(?:unrestricted|unfiltered|unlimited|free)\b/i, severity: 'high' },
{ pattern: /\b(?:in|under)\s+(?:(?:the|a)\s+)*(?:developer|unrestricted|jailbreak|god)\s+mode\b/i, severity: 'high' },
// 中文高危 // 中文高危
{ pattern: /忽略(?:以上|之前|前面|上面|所有)(?:的)?(?:指令|指示|命令|说明|规则)/i, severity: 'high' }, { pattern: /忽略(?:以上|之前|前面|上面|所有)(?:的)?(?:指令|指示|命令|说明|规则)/i, severity: 'high' },
{ pattern: /忘记(?:之前|上面|所有)(?:的)?(?:内容|对话|指令)/i, severity: 'high' }, { pattern: /忘记(?:之前|上面|所有)(?:的)?(?:内容|对话|指令)/i, severity: 'high' },
@@ -59,15 +60,16 @@ export class PromptInjectionDefender {
{ pattern: /无视(?:以上|之前|前面|所有)(?:的)?(?:指令|指示|命令|规则)/i, severity: 'high' }, { pattern: /无视(?:以上|之前|前面|所有)(?:的)?(?:指令|指示|命令|规则)/i, severity: 'high' },
// === MEDIUM 危险:角色扮演/权限提升/编码注入 === // === MEDIUM 危险:角色扮演/权限提升/编码注入 ===
{ pattern: /you\s+are\s+now/i, severity: 'medium' }, // #15 修复: 英文关键词模式添加 \b 单词边界
{ pattern: /new\s+(instructions|directive|role|persona)/i, severity: 'medium' }, { pattern: /\byou\s+are\s+now\b/i, severity: 'medium' },
{ pattern: /(show|display|print|output|reveal|tell\s+me)\s+(your|the)\s+(system\s+)?(prompt|instruction|directive)/i, severity: 'medium' }, { pattern: /\bnew\s+(instructions|directive|role|persona)\b/i, severity: 'medium' },
{ pattern: /(dump|echo|log|expose)\s+(your|the)\s+(context|memory|history)/i, severity: 'medium' }, { pattern: /\b(show|display|print|output|reveal|tell\s+me)\s+(your|the)\s+(system\s+)?(prompt|instruction|directive)\b/i, severity: 'medium' },
{ pattern: /pretend\s+(you\s+are|to\s+be)/i, severity: 'medium' }, { pattern: /\b(dump|echo|log|expose)\s+(your|the)\s+(context|memory|history)\b/i, severity: 'medium' },
{ pattern: /act\s+as\s+(if\s+you\s+were|you're)/i, severity: 'medium' }, { pattern: /\bpretend\s+(you\s+are|to\s+be)\b/i, severity: 'medium' },
{ pattern: /(?:grant|give)\s+(me|you|us)\s+(admin|root|sudo|super)/i, severity: 'medium' }, { pattern: /\bact\s+as\s+(if\s+you\s+were|you're)\b/i, severity: 'medium' },
{ pattern: /base64\s*:?\s*[A-Za-z0-9+/=]{20,}/i, severity: 'medium' }, { pattern: /\b(?:grant|give)\s+(me|you|us)\s+(admin|root|sudo|super)\b/i, severity: 'medium' },
{ pattern: /eval\s*\(\s*atob\s*\(/i, severity: 'medium' }, { pattern: /\bbase64\s*:?\s*[A-Za-z0-9+/=]{20,}/i, severity: 'medium' },
{ pattern: /\beval\s*\(\s*atob\s*\(/i, severity: 'medium' },
{ pattern: /\\x[0-9a-f]{2}\\x[0-9a-f]{2}\\x[0-9a-f]{2}/i, severity: 'medium' }, { pattern: /\\x[0-9a-f]{2}\\x[0-9a-f]{2}\\x[0-9a-f]{2}/i, severity: 'medium' },
{ pattern: /\\u[0-9a-f]{4}\\u[0-9a-f]{4}/i, severity: 'medium' }, { pattern: /\\u[0-9a-f]{4}\\u[0-9a-f]{4}/i, severity: 'medium' },
// 中文中危 // 中文中危
@@ -103,11 +105,17 @@ export class PromptInjectionDefender {
recommendation: 'PASS: No injection patterns detected', recommendation: 'PASS: No injection patterns detected',
}; };
} }
// #16 修复: Unicode 归一化 + 移除零宽字符,防止同形字符与零宽字符绕过
// 攻击示例:іgnore (西里尔 і)、ignore\u200bprevious (零宽空格)、ignore\u00adprevious (软连字符)
// NFKC 归一化将兼容等价字符统一为规范形式,零宽字符移除后关键词检测可正常工作
const normalized = this.normalizeForDetection(input);
const findings: InjectionFinding[] = []; const findings: InjectionFinding[] = [];
let riskScore = 0; let riskScore = 0;
for (const { pattern, severity } of PromptInjectionDefender.INJECTION_PATTERNS) { for (const { pattern, severity } of PromptInjectionDefender.INJECTION_PATTERNS) {
const matches = input.match(pattern); const matches = normalized.match(pattern);
if (matches) { if (matches) {
findings.push({ findings.push({
pattern: pattern.source, pattern: pattern.source,
@@ -118,6 +126,17 @@ export class PromptInjectionDefender {
} }
} }
// #16 新增: 检测可疑 Unicode 混合脚本(拉丁 + 西里尔/希腊),提高风险分数
// 同形字符攻击通常需要混用拉丁与西里尔/希腊字母,正常输入很少混用
if (this.hasMixedScripts(input)) {
findings.push({
pattern: 'unicode:mixed_scripts',
matched: 'Mixed Latin/Cyrillic/Greek scripts detected (potential homoglyph attack)',
severity: 'medium',
});
riskScore += 3;
}
return { return {
isInjection: findings.length > 0, isInjection: findings.length > 0,
riskScore: Math.min(10, riskScore), riskScore: Math.min(10, riskScore),
@@ -148,21 +167,25 @@ export class PromptInjectionDefender {
recommendation: 'PASS: No injection patterns detected', recommendation: 'PASS: No injection patterns detected',
}; };
} }
// #16 修复: 语义检测同样使用归一化文本,防止 Unicode 绕过
const normalized = this.normalizeForDetection(input);
const findings: InjectionFinding[] = []; const findings: InjectionFinding[] = [];
let riskScore = 0; let riskScore = 0;
// 1. 先执行正则快速检测 // 1. 先执行正则快速检测detect 内部已做归一化)
const regexResult = this.detect(input); const regexResult = this.detect(input);
findings.push(...regexResult.findings); findings.push(...regexResult.findings);
riskScore += regexResult.riskScore; riskScore += regexResult.riskScore;
// 2. 语义级检测 // 2. 语义级检测(使用归一化文本)
// 2a. 指令性动词密度检测 // 2a. 指令性动词密度检测
const imperativeCheck = this.checkImperativeDensity(input); const imperativeCheck = this.checkImperativeDensity(normalized);
if (imperativeCheck.isSuspicious) { if (imperativeCheck.isSuspicious) {
findings.push({ findings.push({
pattern: 'semantic:imperative_density', pattern: 'semantic:imperative_density',
matched: `${imperativeCheck.count} imperative verbs in ${input.length} chars`, matched: `${imperativeCheck.count} imperative verbs in ${normalized.length} chars`,
severity: 'medium', severity: 'medium',
}); });
riskScore += 2; riskScore += 2;
@@ -170,7 +193,7 @@ export class PromptInjectionDefender {
// 2b. 角色边界异常检测 // 2b. 角色边界异常检测
if (context) { if (context) {
const roleAnomaly = this.checkRoleBoundary(input, context); const roleAnomaly = this.checkRoleBoundary(normalized, context);
if (roleAnomaly) { if (roleAnomaly) {
findings.push(roleAnomaly); findings.push(roleAnomaly);
riskScore += 3; riskScore += 3;
@@ -178,14 +201,14 @@ export class PromptInjectionDefender {
} }
// 2c. 结构化分隔符嵌套检测 // 2c. 结构化分隔符嵌套检测
const nestedFinding = this.checkNestedDelimiters(input); const nestedFinding = this.checkNestedDelimiters(normalized);
if (nestedFinding) { if (nestedFinding) {
findings.push(nestedFinding); findings.push(nestedFinding);
riskScore += 2; riskScore += 2;
} }
// 2d. 隐式指令检测(通过疑问句伪装指令) // 2d. 隐式指令检测(通过疑问句伪装指令)
const implicitFinding = this.checkImplicitInstructions(input); const implicitFinding = this.checkImplicitInstructions(normalized);
if (implicitFinding) { if (implicitFinding) {
findings.push(implicitFinding); findings.push(implicitFinding);
riskScore += 1; riskScore += 1;
@@ -199,6 +222,42 @@ export class PromptInjectionDefender {
}; };
} }
/**
* #16 修复: Unicode 归一化与零宽字符清理
*
* 1. NFKC 归一化:将兼容等价字符(如西里尔 і → 拉丁 i)统一为规范形式
* 2. 移除零宽字符:零宽空格(U+200B)、零宽连字符(U+200C)、零宽不连字符(U+200D)、
* 软连字符(U+00AD)、BOM(U+FEFF)
*
* 这些字符在视觉上不可见,但会破坏正则关键词匹配,被用于绕过注入检测
*/
private normalizeForDetection(input: string): string {
return input
.normalize('NFKC')
.replace(/[\u200b\u200c\u200d\u00ad\ufeff]/g, '');
}
/**
* #16 新增: 检测可疑 Unicode 混合脚本
*
* 同形字符攻击通常混用拉丁与西里尔/希腊字母(如 іgnore 中 і 是西里尔字母)。
* 正常技术输入很少混用这些脚本,检测到混用时提高风险分数。
* NFKC 归一化可能无法完全消除所有同形字符,因此混合脚本检测作为补充手段。
*/
// 审查修复: 改为统计各脚本字符数量,只有当非主导脚本的字符总数占比 > 30% 时才触发,
// 避免正常多语言输入(如 "Hello, Привет")误报
private hasMixedScripts(text: string): boolean {
const latin = (text.match(/[\u0000-\u007f]/g) || []).length;
const cyrillic = (text.match(/[\u0400-\u04ff]/g) || []).length;
const greek = (text.match(/[\u0370-\u03ff]/g) || []).length;
const total = latin + cyrillic + greek;
if (total < 10) return false; // 文本太短不检测
const max = Math.max(latin, cyrillic, greek);
const minorityRatio = (total - max) / total;
// 审查修复: 只有少数脚本占比 > 30% 时才触发,避免正常多语言输入误报
return minorityRatio > 0.3;
}
/** /**
* 指令性动词密度检测 * 指令性动词密度检测
* *
@@ -186,11 +186,44 @@ export class BrowserWindowManager {
// ===== browserEvaluate ===== // ===== browserEvaluate =====
/**
* 在浏览器页面上下文中执行 JS
*
* #46 修复: 增强安全防护
* - 审计日志:记录所有 executeJavaScript 调用,便于事后追溯恶意操作
* - 超时控制:防止恶意 JS 无限阻塞主进程(Electron 原生不支持超时,用 Promise.race 模拟)
*
* 注意:完全沙箱隔离较难实现(需要 iframe/Web Worker + API 白名单),
* 当前先实现审计 + 超时作为缓解措施。webPreferences 已配置 contextIsolation: true
* 和 sandbox: trueElectron API 不会被暴露给页面。
*/
async evaluate(js: string): Promise<unknown> { async evaluate(js: string): Promise<unknown> {
this.ensureReady(); this.ensureReady();
const result = await this.win!.webContents.executeJavaScript(js, true);
// #46 修复: 审计日志 — 记录所有 executeJavaScript 调用(截断前 500 字符),便于追溯
log.info('[BrowserWindowManager] evaluate JS (first 500 chars):', js.substring(0, 500));
// #46 修复: 超时控制 — 防止恶意 JS 无限阻塞主进程
const EVAL_TIMEOUT_MS = 10_000;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const result = await Promise.race([
this.win!.webContents.executeJavaScript(js, true),
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
// 审查修复 M17: 超时后中止页面 JS 执行。
// executeJavaScript 返回的 Promise 无法取消,页面脚本仍会继续运行,
// 调用 webContents.stop() 中止页面正在执行的脚本(win 可能已销毁,try/catch 兜底)。
try { this.win?.webContents.stop(); } catch {}
reject(new Error(`evaluate timed out after ${EVAL_TIMEOUT_MS}ms`));
}, EVAL_TIMEOUT_MS);
}),
]);
if (typeof result === 'string') return result; if (typeof result === 'string') return result;
return result; return result;
} finally {
if (timer) clearTimeout(timer);
}
} }
// ===== browserExtract ===== // ===== browserExtract =====
@@ -109,7 +109,10 @@ export class CodeSearchTool implements IMetonaTool {
rgArgs.push('-g', '!MEMORY.md'); rgArgs.push('-g', '!MEMORY.md');
if (opts.fileGlob) rgArgs.push('-g', opts.fileGlob); if (opts.fileGlob) rgArgs.push('-g', opts.fileGlob);
rgArgs.push(pattern, searchPath); // #22 修复: 在 pattern 前加 -- 终止选项解析,防止以 - 开头的 pattern 被解释为选项
// 攻击场景:pattern="--help" 输出帮助而非搜索,pattern="--ignore-file /etc/passwd" 可读取任意文件,
// pattern="--files" 列出所有文件。execFile 已用数组参数防 shell 注入,但 ripgrep 自身选项解析仍需防护
rgArgs.push('--', pattern, searchPath);
try { try {
const { stdout } = await execFileAsync('rg', rgArgs, { const { stdout } = await execFileAsync('rg', rgArgs, {
+110 -17
View File
@@ -15,7 +15,7 @@
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具 + run_command 安全规则 * @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具 + run_command 安全规则
*/ */
import { exec } from 'child_process'; import { exec, execFile } 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 { parse as shellQuoteParse } from 'shell-quote';
@@ -27,6 +27,7 @@ import { commandTouchesProtectedFile, isPathWithinWorkspace } from './file-guard
import type { SandboxManager } from '../../sandbox/sandbox'; import type { SandboxManager } from '../../sandbox/sandbox';
const execAsync = promisify(exec); const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
// ===== Windows 编码智能解码 ===== // ===== Windows 编码智能解码 =====
// 某些 Windows 程序(dir、systeminfo、ipconfig 等)不响应 chcp 65001 // 某些 Windows 程序(dir、systeminfo、ipconfig 等)不响应 chcp 65001
@@ -48,6 +49,43 @@ function decodeBuffer(buf: Buffer): string {
} }
} }
/**
* #9 修复 + 审查修复: 构建安全的子进程环境变量
*
* 审查修复: 原白名单方案遗漏了 GIT_* / PYTHONPATH / HTTP_PROXY 等常用变量,导致子进程功能破坏。
* 改为黑名单方案:剔除包含敏感后缀的变量,保留其余。
*/
function buildSafeCommandEnv(isWindows: boolean): Record<string, string> {
// 敏感变量后缀黑名单
const SENSITIVE_SUFFIXES = [
'_API_KEY', '_TOKEN', '_SECRET', '_PASSWORD', '_PASSWD',
'_CREDENTIAL', '_CREDENTIALS', '_PRIVATE_KEY',
];
// 敏感变量名黑名单(精确匹配)
const SENSITIVE_KEYS = new Set([
'DEEPSEEK_API_KEY', 'AGNES_API_KEY', 'MIMO_API_KEY',
'GITEA_PASSWORD', 'DATABASE_PASSWORD',
]);
const env: Record<string, string> = {};
for (const [key, val] of Object.entries(process.env)) {
if (!val) continue;
if (SENSITIVE_KEYS.has(key)) continue;
if (SENSITIVE_SUFFIXES.some((suffix) => key.toUpperCase().endsWith(suffix))) continue;
env[key] = val;
}
// 添加必要的运行时变量
env.NODE_ENV = 'production';
if (isWindows) {
env.PYTHONIOENCODING = 'utf-8';
env.LANG = 'zh_CN.UTF-8';
env.LC_ALL = 'zh_CN.UTF-8';
}
return env;
}
// ===== 9. run_command ===== // ===== 9. run_command =====
export class RunCommandTool implements IMetonaTool { export class RunCommandTool implements IMetonaTool {
@@ -120,28 +158,46 @@ export class RunCommandTool implements IMetonaTool {
// Windows 中文编码修复:通过 chcp 65001 切换控制台到 UTF-8 代码页 // Windows 中文编码修复:通过 chcp 65001 切换控制台到 UTF-8 代码页
// 避免 GBK/CP936 输出被 Node.js 按 UTF-8 解码导致中文乱码 // 避免 GBK/CP936 输出被 Node.js 按 UTF-8 解码导致中文乱码
const isWindows = process.platform === 'win32'; const isWindows = process.platform === 'win32';
const finalCommand = isWindows
? `chcp 65001 >nul 2>&1 && ${command}` // #8 修复: 优先使用 execFile(不经过 shell,从根本上防止命令注入)
: command; // 仅当命令为简单命令(无管道、重定向、&& 等 shell 运算符)时使用 execFile
// 复杂命令(含 shell 语法)降级到 exec(已有 SandboxManager + validateCommand 双层校验)
const simpleCmd = this.parseCommandSimple(command);
// 使用 encoding: 'buffer' 获取原始字节,手动智能解码 // 使用 encoding: 'buffer' 获取原始字节,手动智能解码
// 部分程序(如 dir、systeminfo)不响应 chcp 65001,仍输出 GBK 字节流 // 部分程序(如 dir、systeminfo)不响应 chcp 65001,仍输出 GBK 字节流
const { stdout, stderr } = await execAsync(finalCommand, { // #9 修复: 不透传完整 process.env,仅保留子进程运行所需的最小环境变量集合
// 防止 API keys / tokens / passwords 通过环境变量泄露给子进程
const execEnv = buildSafeCommandEnv(isWindows);
const execOpts = {
cwd: resolvedWorkdir, cwd: resolvedWorkdir,
timeout, timeout,
maxBuffer: 1024 * 1024, // 1MB maxBuffer: 1024 * 1024, // 1MB
encoding: 'buffer', // 返回 Buffer 而非字符串,便于智能解码 encoding: 'buffer' as const, // 返回 Buffer 而非字符串,便于智能解码
env: { env: execEnv,
...process.env, };
NODE_ENV: 'production',
...(isWindows ? { let stdout: Buffer;
// 强制常见程序使用 UTF-8 输出 let stderr: Buffer;
PYTHONIOENCODING: 'utf-8',
LANG: 'zh_CN.UTF-8', // #8 修复 + 审查修复: 简单命令使用 execFile(不经过 shell,防止命令注入)
LC_ALL: 'zh_CN.UTF-8', // 但 Windows 上 npm/npx/yarn/pnpm/tsc 等是 .cmd 批处理,execFile 无法执行(ENOENT
} : {}), // 因此 Windows 上仍用 exec(已有 SandboxManager.scanCode + validateCommand 双层校验)
}, // 非 Windows 上对简单命令用 execFile
}); if (simpleCmd && !isWindows) {
const result = await execFileAsync(simpleCmd.command, simpleCmd.args, execOpts);
stdout = result.stdout;
stderr = result.stderr;
} else {
// 复杂命令(含管道/重定向/&& 等 shell 语法)或 Windows — 使用 exec
// 已有 SandboxManager.scanCode + validateCommand 双层安全校验
const finalCommand = isWindows
? `chcp 65001 >nul 2>&1 && ${command}`
: command;
const result = await execAsync(finalCommand, execOpts);
stdout = result.stdout;
stderr = result.stderr;
}
return { return {
success: true, success: true,
@@ -336,4 +392,41 @@ export class RunCommandTool implements IMetonaTool {
return null; return null;
} }
/**
* #8 修复: 将命令解析为简单的 command + args 数组
*
* 使用 shell-quote 解析命令,如果命令只包含 word tokens(无管道、重定向、&& 等 shell 运算符),
* 返回 { command, args } 供 execFile 使用(不经过 shell,从根本上防止命令注入)。
* 如果包含 shell 运算符或解析失败,返回 null,调用方降级到 exec(已有安全校验)。
*
* @returns 简单命令的 { command, args },或 null 表示复杂命令
*/
private parseCommandSimple(command: string): { command: string; args: string[] } | null {
let tokens: ReturnType<typeof shellQuoteParse>;
try {
tokens = shellQuoteParse(command);
} catch {
// 解析失败(Windows cmd 语法、不完整引号等)— 降级到 exec
return null;
}
const words: string[] = [];
for (const entry of tokens) {
if (typeof entry === 'string') {
words.push(entry);
} else if (typeof entry === 'object' && entry !== null) {
const obj = entry as { word?: unknown; op?: unknown };
if (typeof obj.word === 'string') {
words.push(obj.word);
} else if (typeof obj.op === 'string') {
// 遇到 shell 运算符(&&、|、;、> 等)— 复杂命令,降级到 exec
return null;
}
}
}
if (words.length === 0) return null;
return { command: words[0], args: words.slice(1) };
}
} }
@@ -29,6 +29,43 @@ import {
FILE_TOOL_TIMEOUT_MS, FILE_TOOL_TIMEOUT_MS,
} from './file-guard'; } from './file-guard';
/**
* #45 修复: 检测潜在灾难性正则模式(ReDoS 风险)
*
* 灾难性回溯通常由以下模式引起:
* - 嵌套量词:(a+)+、(a*)*、(a+)*
* - 重叠量词:a+a+、a+.*a+(两个量词之间无固定字符分隔)
* - 交替分支加量词:(a|a)*
*
* 这些模式在长字符串上执行时间指数级增长,可阻塞主进程
*
* @param pattern 用户提供的正则模式字符串
* @returns true 如果检测到潜在灾难性模式
*/
function isPotentiallyCatastrophicRegex(pattern: string): boolean {
// 审查修复: 放宽规则减少误报,补充漏报检测
// 1. 嵌套量词(捕获组内量词+外层量词)
// 审查修复: 区分外层量词类型 — 外层 +* 时组内一个量词即可触发(如 (a+)+),
// 外层 ? 时需组内两个量词才触发(排除 (\d+)? 误报)
if (/\([^)]*[+*?][^)]*\)[+*]/.test(pattern)) return true;
if (/\([^)]*[+*?][^)]*[+*?][^)]*\)[?]/.test(pattern)) return true;
// 2. 重叠量词 — 补充 a+a+ 漏报
if (/[+*][+*]/.test(pattern)) return true;
// 审查修复: 补充 a+a+ / a+.*a+ 等重叠量词检测
if (/\w[+*]\s*\w[+*]/.test(pattern)) return true;
if (/\.\*[+*]\.\*[+*]/.test(pattern)) return true;
// 3. 交替分支加量词 — 放宽: 仅当分支有重叠前缀时才危险
// 移除对 (GET|POST)+ 的误报,只检测真正危险的重叠分支
// (a|a)* 类型难以用正则精确检测,保留简化版
if (/\(([^)]+)\|(\1[^)]*)\)[+*?]/.test(pattern)) return true;
if (/\(([^)]*\|[^)]*)\)[+*?]/.test(pattern) && /(.)\1.*\|.*\1/.test(pattern)) return true;
return false;
}
export class FileEditorTool implements IMetonaTool { export class FileEditorTool implements IMetonaTool {
readonly definition: MetonaToolDef = { readonly definition: MetonaToolDef = {
name: 'file_editor', name: 'file_editor',
@@ -159,6 +196,11 @@ export class FileEditorTool implements IMetonaTool {
if (pattern.length > 500) { if (pattern.length > 500) {
return { success: false, error: 'Regex pattern too long (max 500 chars)' }; return { success: false, error: 'Regex pattern too long (max 500 chars)' };
} }
// #45 修复: ReDoS 防护 — 检测灾难性正则模式,拒绝可能导致指数级回溯的 pattern
// 攻击场景:恶意 LLM 传入 (a+)+ 等模式,在长字符串上阻塞主进程
if (isPotentiallyCatastrophicRegex(pattern)) {
return { success: false, error: 'Potentially catastrophic regex pattern detected (ReDoS risk): nested or overlapping quantifiers' };
}
const startLine = Math.max(1, (args.start_line as number) ?? 1); const startLine = Math.max(1, (args.start_line as number) ?? 1);
const endLine = Math.min(lines.length, (args.end_line as number) ?? lines.length); const endLine = Math.min(lines.length, (args.end_line as number) ?? lines.length);
if (endLine < startLine) { if (endLine < startLine) {
@@ -178,6 +220,15 @@ export class FileEditorTool implements IMetonaTool {
if (multiline) { if (multiline) {
// F2-6: 跨行匹配 — 对整个目标内容做正则替换(支持多行代码块替换) // F2-6: 跨行匹配 — 对整个目标内容做正则替换(支持多行代码块替换)
const targetContent = lines.slice(startLine - 1, endLine).join('\n'); const targetContent = lines.slice(startLine - 1, endLine).join('\n');
// #45 修复: 限制 multiline 模式的目标内容长度,防止大文件上的 regex 执行导致主进程阻塞
// Node.js RegExp 执行是同步的,超长内容 + 复杂正则可阻塞事件循环
const MAX_REGEX_CONTENT_LENGTH = 100_000;
if (targetContent.length > MAX_REGEX_CONTENT_LENGTH) {
return {
success: false,
error: `Target content too large for multiline regex (${targetContent.length} chars, max ${MAX_REGEX_CONTENT_LENGTH}). Use a smaller line range or split the operation.`,
};
}
const matches = targetContent.match(regex); const matches = targetContent.match(regex);
if (matches) replaceCount = matches.length; if (matches) replaceCount = matches.length;
const replacedContent = targetContent.replace(regex, replacement); const replacedContent = targetContent.replace(regex, replacement);
+48 -3
View File
@@ -22,9 +22,9 @@
* @see standard/开发规范.md — 使用 fs/path 内置模块 * @see standard/开发规范.md — 使用 fs/path 内置模块
*/ */
import { readFile, writeFile, readdir, stat, mkdir, open, unlink, rmdir, rm, rename, type FileHandle } from 'fs/promises'; import { readFile, writeFile, readdir, stat, mkdir, open, unlink, rmdir, rm, rename, realpath, 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, realpathSync } from 'fs';
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';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
@@ -542,7 +542,23 @@ export class SearchFilesTool implements IMetonaTool {
fileGlob?: string, fileGlob?: string,
includeHidden?: boolean, includeHidden?: boolean,
workspacePath?: string, workspacePath?: string,
// #21 修复: 添加深度限制,防止深层嵌套目录触发栈溢出
depth: number = 0,
// #21 修复: 最大递归深度(默认 20),超过则停止递归
maxDepth: number = 20,
// #21 修复: 符号链接循环防护 — 记录已访问的 realpath,防止 A -> B -> A 死循环
visited: Set<string> = new Set(),
): Promise<void> { ): Promise<void> {
// #21 修复: 深度限制 — 超过 maxDepth 立即返回,防止栈溢出
if (depth > maxDepth) return;
// #21 修复: 符号链接循环防护 — realpath 去重,防止 A -> B -> A 形成循环导致死循环
// 审查修复: 改用异步 fs.promises.realpath,避免同步 realpathSync 阻塞 Electron 主进程。
// realpath 失败(权限问题等)时回退到 dirPath 本身,保守继续。
const real = await realpath(dirPath).catch(() => dirPath);
if (visited.has(real)) return;
visited.add(real);
try { try {
const entries = await readdir(dirPath, { withFileTypes: true }); const entries = await readdir(dirPath, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
@@ -552,7 +568,10 @@ export class SearchFilesTool implements IMetonaTool {
const fullPath = join(dirPath, entry.name); const fullPath = join(dirPath, entry.name);
if (entry.isDirectory()) { if (entry.isDirectory()) {
await this.walkDir(fullPath, callback, fileGlob, includeHidden, workspacePath); // #21 修复: 不跟随符号链接目录,防止循环遍历与越权访问
if (!entry.isSymbolicLink()) {
await this.walkDir(fullPath, callback, fileGlob, includeHidden, workspacePath, depth + 1, maxDepth, visited);
}
} else { } else {
// F2-4: 支持多 glob(逗号分隔,如 "*.ts,*.js,*.tsx" // F2-4: 支持多 glob(逗号分隔,如 "*.ts,*.js,*.tsx"
if (fileGlob && !matchAnyGlob(entry.name, fileGlob)) continue; if (fileGlob && !matchAnyGlob(entry.name, fileGlob)) continue;
@@ -640,9 +659,35 @@ export class DeleteFileTool implements IMetonaTool {
} }
try { try {
// #20 修复: TOCTOU 竞态防护 — 删除前 realpath 二次校验
// 攻击场景:在校验(safeResolvePath)与删除(unlink/rmdir)之间,
// 攻击者用符号链接替换目标文件,使 unlink 实际删除符号链接指向的工作空间外文件
// 防护:记录删除前 realpath,删除前再次校验未被替换
let realBefore: string;
try {
realBefore = realpathSync(resolvedPath);
} catch {
// realpath 失败(路径不存在等),使用 resolvedPath
realBefore = resolvedPath;
}
if (!isPathWithinWorkspace(realBefore, context.workspacePath)) {
return { error: 'Path escape detected (TOCTOU protection)', path: filePath, success: false };
}
const stats = await stat(resolvedPath); const stats = await stat(resolvedPath);
const isDirectory = stats.isDirectory(); const isDirectory = stats.isDirectory();
// #20 修复: 删除前再次 realpath 校验,确保文件未被替换为指向工作空间外的符号链接
let realAfter: string;
try {
realAfter = realpathSync(resolvedPath);
} catch {
realAfter = resolvedPath;
}
if (realAfter !== realBefore) {
return { error: 'TOCTOU detected: file replaced during deletion', path: filePath, success: false };
}
if (isDirectory) { if (isDirectory) {
if (recursive) { if (recursive) {
// 递归删除非空目录 // 递归删除非空目录
+17
View File
@@ -211,6 +211,23 @@ export class GitDiffTool implements IMetonaTool {
const pathspec = args.pathspec as string | undefined; const pathspec = args.pathspec as string | undefined;
const contextLines = Math.max(0, (args.contextLines as number) ?? 3); const contextLines = Math.max(0, (args.contextLines as number) ?? 3);
// #19 修复: 校验 pathspec 在工作空间内,防止读取任意仓库差异
// 攻击场景:pathspec 传 "../../../other-repo/" 或绝对路径可越权读取工作空间外的 git 差异
// resolve 后 isPathWithinWorkspace 校验,glob 字符(*?)不影响前缀校验
if (pathspec) {
// 审查修复 M18: git magic pathspec(以 : 开头,如 :(glob)**/*.ts)跳过 resolve 校验。
// resolve 会把 magic 前缀当普通字符拼接到路径,导致 pathspec 被污染;
// git magic 语法本身不构成路径越权风险(git 自身解析 magic 前缀,不指向工作空间外)。
if (pathspec.startsWith(':')) {
// git magic pathspec,跳过 resolve 校验直接放行
} else {
const resolved = resolve(context.workspacePath, pathspec);
if (!isPathWithinWorkspace(resolved, context.workspacePath)) {
return { error: `Path outside workspace: ${pathspec}`, success: false };
}
}
}
const gitArgs = ['diff']; const gitArgs = ['diff'];
if (cached) gitArgs.push('--cached'); if (cached) gitArgs.push('--cached');
gitArgs.push(`--unified=${contextLines}`); gitArgs.push(`--unified=${contextLines}`);
+133 -3
View File
@@ -5,8 +5,12 @@
* *
* 使用 Node.js 18+ 内置 fetch API。 * 使用 Node.js 18+ 内置 fetch API。
* 响应体截断到 50KB 防止结果过大。 * 响应体截断到 50KB 防止结果过大。
*
* #10 修复: SSRF 防护 — 解析 URL 域名并校验 IP,拒绝内网/回环/元数据地址。
*/ */
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
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';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
@@ -14,6 +18,120 @@ import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const; const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const;
const MAX_BODY_BYTES = 50 * 1024; // 50KB const MAX_BODY_BYTES = 50 * 1024; // 50KB
/**
* #10 修复: 检查 IP 是否为私有/内网/回环/元数据地址
*
* 覆盖:
* - IPv4: 127.0.0.0/8 (回环)、10.0.0.0/8、192.168.0.0/16、172.16.0.0/12、
* 169.254.0.0/16 (链路本地,含云元数据 169.254.169.254)、0.0.0.0/8、
* 224.0.0.0/4 (组播)、240.0.0.0/4 (保留)
* - IPv6: ::1 (回环)、fe80::/10 (链路本地)、fc00::/7 (唯一本地)、::ffff: 映射的 IPv4
*/
function isPrivateIP(ip: string): boolean {
// IPv4 直接检测
if (isIP(ip) === 4) {
const parts = ip.split('.').map(Number);
if (parts[0] === 127) return true; // 回环
if (parts[0] === 10) return true; // 内网
if (parts[0] === 192 && parts[1] === 168) return true; // 内网
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; // 内网
if (parts[0] === 169 && parts[1] === 254) return true; // 链路本地(含云元数据)
if (parts[0] === 0) return true; // 0.0.0.0/8
if (parts[0] >= 224) return true; // 组播 + 保留
return false;
}
// IPv6 检测
if (isIP(ip) === 6) {
const lower = ip.toLowerCase();
if (lower === '::1') return true; // 回环
if (lower.startsWith('fe80:')) return true; // 链路本地
if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // 唯一本地
// ::ffff: 映射的 IPv4 — 提取 IPv4 部分递归检测
const v4MappedMatch = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (v4MappedMatch) return isPrivateIP(v4MappedMatch[1]);
return false;
}
// 非 IP 格式(域名等),由调用方 DNS 解析后再检测
return false;
}
/**
* #10 修复: SSRF 校验 — 解析 URL 域名并校验 IP
*
* 1. 协议白名单:仅允许 http/https
* 2. DNS 解析域名,获取所有 IP 地址
* 3. 逐个检测 IP 是否为私有/内网/回环/元数据地址
* 4. 任意一个 IP 为私有即拒绝(防止 DNS rebinding 中只校验第一个 IP
*
* 审查修复 (M7) — 已知限制:DNS rebinding 窗口
* ---------------------------------------------------------------
* validateSSRF 在校验阶段 DNS 解析得到 IP,fetch 内部会再次 DNS 解析,
* 两次解析之间存在 DNS rebinding 攻击窗口(攻击者可在校验通过后切换
* DNS 记录到内网 IP)。
*
* 完全防护需要 "DNS pinning"(用校验通过的 IP 替换 URL hostname),
* 但在 Node.js fetch 实现下不可行:
* 1. HTTPS 请求时 fetch 会基于 URL hostname 校验证书 SAN
* 用 IP 替换会导致证书校验失败(除非目标证书 SAN 包含该 IP)。
* 2. Node fetch 将 Host 列为 forbidden header,无法通过设置
* Host header 保留原始域名。
* 3. fetch API 不暴露 SNI 自定义入口。
*
* 当前实现的缓解措施:
* - 校验所有 DNS 返回的 IP(防只校验第一个 IP 的绕过)
* - redirect: 'manual' 禁用自动重定向(防重定向到内网)
* - 窗口虽存在,但需要攻击者控制权威 DNS 并在毫秒级切换记录,
* 实际利用难度较高。
*
* 彻底防护建议:在 Electron 主进程层使用自定义 lookup 钩子实现
* DNS pinning(例如 undici 的 dispatcher.agent.connect lookup)。
*
* @throws 如果 URL 指向私有/内网/回环地址
*/
async function validateSSRF(url: string): Promise<void> {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`Invalid URL: ${url}`);
}
// 协议白名单
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`Blocked SSRF: protocol "${parsed.protocol}" not allowed (only http/https)`);
}
const hostname = parsed.hostname;
// 如果 hostname 本身就是 IP,直接检测
if (isIP(hostname)) {
if (isPrivateIP(hostname)) {
throw new Error(`Blocked SSRF: ${hostname} is a private/loopback address`);
}
return;
}
// 域名 — DNS 解析后检测所有 IP
let addresses: Array<{ address: string }>;
try {
addresses = await lookup(hostname, { all: true });
} catch (err) {
throw new Error(`Blocked SSRF: DNS resolution failed for ${hostname}: ${(err as Error).message}`);
}
if (addresses.length === 0) {
throw new Error(`Blocked SSRF: no DNS records for ${hostname}`);
}
for (const { address } of addresses) {
if (isPrivateIP(address)) {
throw new Error(`Blocked SSRF: ${hostname} resolves to private IP ${address}`);
}
}
}
export class HttpRequestTool implements IMetonaTool { export class HttpRequestTool implements IMetonaTool {
readonly definition: MetonaToolDef = { readonly definition: MetonaToolDef = {
name: 'http_request', name: 'http_request',
@@ -34,8 +152,8 @@ export class HttpRequestTool implements IMetonaTool {
required: ['url'], required: ['url'],
}, },
category: MetonaToolCategory.NETWORK, category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.LOW, riskLevel: MetonaRiskLevel.MEDIUM,
requiresPermission: false, requiresPermission: true,
timeoutMs: 30_000, timeoutMs: 30_000,
}; };
@@ -52,6 +170,13 @@ export class HttpRequestTool implements IMetonaTool {
return { error: 'Invalid URL', success: false }; return { error: 'Invalid URL', success: false };
} }
// #10 修复: SSRF 校验 — 拒绝内网/回环/元数据地址
try {
await validateSSRF(url);
} catch (ssrfErr) {
return { error: (ssrfErr as Error).message, success: false };
}
// 校验 method // 校验 method
if (!(ALLOWED_METHODS as readonly string[]).includes(method)) { if (!(ALLOWED_METHODS as readonly string[]).includes(method)) {
return { return {
@@ -69,7 +194,9 @@ export class HttpRequestTool implements IMetonaTool {
method, method,
headers, headers,
signal: controller.signal, signal: controller.signal,
redirect: 'follow', // #10 修复: 禁用自动重定向跟随 — 防止重定向到内网地址绕过 SSRF 校验
// 重定向后的 URL 由用户自行处理(响应中会包含 Location 头)
redirect: 'manual',
}; };
// GET/HEAD 不应携带 body // GET/HEAD 不应携带 body
if (body !== undefined && method !== 'GET' && method !== 'HEAD') { if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
@@ -84,11 +211,14 @@ export class HttpRequestTool implements IMetonaTool {
const safeBody = truncated ? text.slice(0, MAX_BODY_BYTES) : text; const safeBody = truncated ? text.slice(0, MAX_BODY_BYTES) : text;
// 只返回 content-type 和 content-length // 只返回 content-type 和 content-length
// 审查修复: redirect:'manual' 后需要返回 Location header,否则 LLM 无法知道重定向目标
const filteredHeaders: Record<string, string> = {}; const filteredHeaders: Record<string, string> = {};
const contentType = response.headers.get('content-type'); const contentType = response.headers.get('content-type');
if (contentType) filteredHeaders['content-type'] = contentType; if (contentType) filteredHeaders['content-type'] = contentType;
const contentLength = response.headers.get('content-length'); const contentLength = response.headers.get('content-length');
if (contentLength) filteredHeaders['content-length'] = contentLength; if (contentLength) filteredHeaders['content-length'] = contentLength;
const location = response.headers.get('location');
if (location) filteredHeaders['location'] = location;
return { return {
status: response.status, status: response.status,
+25 -6
View File
@@ -95,20 +95,39 @@ export class ToolRegistry {
} }
const startTs = Date.now(); const startTs = Date.now();
const timeoutMs = tool.definition.timeoutMs;
// #12 修复: timeoutMs 为 undefined 时使用默认值,并校验有效性
// 防止 setTimeout(fn, undefined) 被解释为 setTimeout(fn, 0) 立即触发超时
// 类型定义中 timeoutMs 是必填 number,但 MCP/外部工具运行时可能缺失,需防御
const DEFAULT_TIMEOUT_MS = 120_000;
const rawTimeout = tool.definition.timeoutMs;
const timeoutMs =
typeof rawTimeout === 'number' && rawTimeout > 0
? rawTimeout
: DEFAULT_TIMEOUT_MS;
// #11 修复: 使用 AbortController 在超时后通知工具中止,防止 Promise 未取消导致资源泄漏
// 原实现 Promise.race 超时后 tool.execute() 仍在后台运行,持续消耗资源
// 现通过 signal 传给 context,工具可在耗时操作前检查 signal.aborted 自行中止
const controller = new AbortController();
const enhancedContext: ToolExecutionContext = {
...context,
signal: controller.signal,
};
// M-15 修复: 使用 try/finally 清理 setTimeout,防止事件循环 timer 堆积 // M-15 修复: 使用 try/finally 清理 setTimeout,防止事件循环 timer 堆积
// 工具正常完成时未触发的 timer 会持续占用事件循环 timeoutMs 毫秒 // 工具正常完成时未触发的 timer 会持续占用事件循环 timeoutMs 毫秒
let timer: ReturnType<typeof setTimeout> | undefined; let timer: ReturnType<typeof setTimeout> | undefined;
try { try {
// 带超时执行 — 使用 Promise.race 防止工具卡死阻塞 Agent Loop // 带超时执行 — 使用 Promise.race 防止工具卡死阻塞 Agent Loop
// #11: 超时触发 controller.abort(),支持 signal 的工具可据此中止后台操作
const result = await Promise.race([ const result = await Promise.race([
tool.execute(toolCall.args, context), tool.execute(toolCall.args, enhancedContext),
new Promise<never>((_, reject) => { new Promise<never>((_, reject) => {
timer = setTimeout( timer = setTimeout(() => {
() => reject(new Error(`Tool execution timed out after ${timeoutMs}ms`)), controller.abort();
timeoutMs, reject(new Error(`Tool execution timed out after ${timeoutMs}ms`));
); }, timeoutMs);
}), }),
]); ]);
+2
View File
@@ -20,6 +20,8 @@ export interface ToolExecutionContext {
iteration: number; iteration: number;
/** 请求 ID */ /** 请求 ID */
requestId: string; requestId: string;
/** #11 修复: 超时 abort signal,工具可在耗时操作前检查 signal.aborted 自行中止 */
signal?: AbortSignal;
} }
/** /**
+26 -4
View File
@@ -2,10 +2,15 @@
* Token 估算工具 — 跨 Provider 通用 * Token 估算工具 — 跨 Provider 通用
* *
* 策略:智能字符估算,区分中文字符与 ASCII 字符 * 策略:智能字符估算,区分中文字符与 ASCII 字符
* - 中文字符(含全角标点、日韩文):1 字符 ≈ 1.5 token * - 中文字符(含全角标点、日韩文):1 字符 ≈ 1.0 token
* - ASCII 字符(英文、数字、半角符号):4 字符 ≈ 1 token * - ASCII 字符(英文、数字、半角符号):4 字符 ≈ 1 token
* - 其他 Unicodeemoji 等):1 字符 ≈ 1 token * - 其他 Unicodeemoji 等):1 字符 ≈ 1 token
* *
* v0.3.18 修复: CJK 系数从 1.5 调整为 1.0
* 实测 DeepSeek/GLM 等 BPE tokenizer 对中文约 0.6-0.8 token/字,
* 原系数 1.5 导致中文场景估算偏高约 2 倍,80% 阈值实际在 40-50% 就触发压缩,
* 造成上下文过早丢失。1.0 仍保守(留 ~25% 安全裕度),但更接近真实值。
*
* 对比旧的 `length / 2` 方案: * 对比旧的 `length / 2` 方案:
* - 中文场景:估算准确度从 ~50% 提升到 ~90% * - 中文场景:估算准确度从 ~50% 提升到 ~90%
* - 英文场景:从偏低变为接近真实 * - 英文场景:从偏低变为接近真实
@@ -19,9 +24,10 @@ const CJK_REGEX = /[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef\u3040-\
/** /**
* L-17 修复: 提取魔法系数为命名常量,便于统一调整 * L-17 修复: 提取魔法系数为命名常量,便于统一调整
* v0.3.18 修复: CJK_TOKEN_RATIO 从 1.5 调整为 1.0,更贴近 BPE 实际值
* @see project_memory.md — Token estimation coefficients * @see project_memory.md — Token estimation coefficients
*/ */
const CJK_TOKEN_RATIO = 1.5; // 中文字符(含全角标点、日韩文):1 字符 ≈ 1.5 token const CJK_TOKEN_RATIO = 1.0; // 中文字符(含全角标点、日韩文):1 字符 ≈ 1.0 token(保守,实测 0.6-0.8
const ASCII_TOKEN_RATIO = 0.25; // ASCII 字符(英文、数字、半角符号):4 字符 ≈ 1 token const ASCII_TOKEN_RATIO = 0.25; // ASCII 字符(英文、数字、半角符号):4 字符 ≈ 1 token
const OTHER_TOKEN_RATIO = 1; // 其他 Unicodeemoji 等):1 字符 ≈ 1 token const OTHER_TOKEN_RATIO = 1; // 其他 Unicodeemoji 等):1 字符 ≈ 1 token
const MSG_OVERHEAD_TOKENS = 4; // 每条消息的结构性开销(role、分隔符,参考 OpenAI 规范) const MSG_OVERHEAD_TOKENS = 4; // 每条消息的结构性开销(role、分隔符,参考 OpenAI 规范)
@@ -52,6 +58,11 @@ export function estimateStringTokens(text: string | null | undefined): number {
return Math.ceil(cjkCount * CJK_TOKEN_RATIO + asciiCount * ASCII_TOKEN_RATIO + otherCount * OTHER_TOKEN_RATIO); return Math.ceil(cjkCount * CJK_TOKEN_RATIO + asciiCount * ASCII_TOKEN_RATIO + otherCount * OTHER_TOKEN_RATIO);
} }
/**
* #50 修复: tool_call 结构开销({"id":"","name":"","arguments":""} 等结构字符,参考 OpenAI 规范)
*/
const TOOL_CALL_OVERHEAD_TOKENS = 8;
/** /**
* 估算多条消息的总 token 数 * 估算多条消息的总 token 数
* *
@@ -63,7 +74,8 @@ export function estimateStringTokens(text: string | null | undefined): number {
export function estimateMessagesTokens(messages: Array<{ export function estimateMessagesTokens(messages: Array<{
content: string | null; content: string | null;
reasoningContent?: string; reasoningContent?: string;
toolCalls?: Array<{ args: Record<string, unknown> }>; toolCalls?: Array<{ id?: string; name?: string; args: Record<string, unknown> }>;
toolCallId?: string;
}>): number { }>): number {
let total = 0; let total = 0;
for (const msg of messages) { for (const msg of messages) {
@@ -71,9 +83,19 @@ export function estimateMessagesTokens(messages: Array<{
if (msg.reasoningContent) total += estimateStringTokens(msg.reasoningContent); if (msg.reasoningContent) total += estimateStringTokens(msg.reasoningContent);
if (msg.toolCalls) { if (msg.toolCalls) {
for (const tc of msg.toolCalls) { for (const tc of msg.toolCalls) {
total += estimateStringTokens(JSON.stringify(tc.args)); // #50 修复: OpenAI tokenizer 会将 tool_call 的完整结构(id、name、args)都计入 token
// 之前仅估算 args,忽略 id(通常 24 字符 call_xxx)和 name(通常 5-20 字符),导致每个 tool_call 少算 5-10 tokens
total += estimateStringTokens(tc.id ?? '');
total += estimateStringTokens(tc.name ?? '');
total += estimateStringTokens(JSON.stringify(tc.args ?? {}));
// 结构开销({"id":"","name":"","arguments":""} 等结构字符)
total += TOOL_CALL_OVERHEAD_TOKENS;
} }
} }
// #50 修复: tool 消息的 tool_call_id 字段也计入 token
if (msg.toolCallId) {
total += estimateStringTokens(msg.toolCallId);
}
// L-17 修复: 使用命名常量替代魔法数字 // L-17 修复: 使用命名常量替代魔法数字
total += MSG_OVERHEAD_TOKENS; total += MSG_OVERHEAD_TOKENS;
} }
+155 -12
View File
@@ -146,6 +146,15 @@ export function registerAllIPCHandlers(
const workspaceFiles = workspaceService.getFiles(); const workspaceFiles = workspaceService.getFiles();
const systemPrompt = contextBuilder.buildSystemPrompt(workspaceFiles, workspaceService.getPath()); const systemPrompt = contextBuilder.buildSystemPrompt(workspaceFiles, workspaceService.getPath());
// v0.3.18 修复: SOUL.md 为空或不存在时降级到默认身份,向前端发 toast 提示用户
// 避免用户自定义人格丢失但不知情的"安静失败"问题
if (contextBuilder.isUsingFallbackRole() && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('toast:show', {
type: 'info',
message: '未找到 SOUL.md 或内容为空,已使用默认 Metona 身份。可在工作空间根目录创建 SOUL.md 自定义 Agent 人格',
});
}
// 检索与用户消息相关的记忆,注入到 System Prompt 动态区 // 检索与用户消息相关的记忆,注入到 System Prompt 动态区
try { try {
const memories = memoryManager.search(userMessage.content, { topK: 5, minImportance: 0.3 }); const memories = memoryManager.search(userMessage.content, { topK: 5, minImportance: 0.3 });
@@ -163,6 +172,29 @@ export function registerAllIPCHandlers(
log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err); log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err);
} }
// 附件提示注入:用户直接上传的文件/图片,避免 LLM 误以为需要在工作空间查找
// 解决场景:用户上传图片后,AI 先在工作空间找图片,找不到才意识到是用户上传的
const attachments = (userMessage as MetonaMessage & { attachments?: Array<{ name: string; type: string }> }).attachments;
if (Array.isArray(attachments) && attachments.length > 0) {
const attachmentList = attachments.map((att, i) => {
const typeLabel = att.type === 'image' ? 'image' : att.type === 'text' ? 'text file' : 'file';
const note = att.type === 'image'
? 'visible via vision capability, do NOT search in workspace'
: att.type === 'text'
? 'content already inlined in the user message, do NOT search in workspace'
: 'uploaded directly by user, do NOT search in workspace';
return `${i + 1}. [${typeLabel}] ${att.name}${note}`;
}).join('\n');
const attachmentBlock = `## User Attachments (Direct Upload)\nThe following files were uploaded directly by the user to this conversation. They are inline attachments, NOT workspace files:\n${attachmentList}`;
systemPrompt.dynamicReminders = systemPrompt.dynamicReminders
? `${systemPrompt.dynamicReminders}\n\n---\n\n${attachmentBlock}`
: attachmentBlock;
log.debug(`[AGENT] Injected ${attachments.length} attachment hints into system prompt`);
}
// 监听 Agent Loop 事件 // 监听 Agent Loop 事件
// F8: 主进程 text_delta 节流合并 // F8: 主进程 text_delta 节流合并
// 问题:主进程 1:1 转发所有流式事件,text_delta 频率 30-50 次/秒, // 问题:主进程 1:1 转发所有流式事件,text_delta 频率 30-50 次/秒,
@@ -242,12 +274,26 @@ export function registerAllIPCHandlers(
} }
}; };
// M-6: 转发上下文压缩事件为 toast 通知 // M-6: 转发上下文压缩事件为 toast 通知 + streamEvent 通知
// v0.3.18 修复: 同时通过 streamEvent 转发压缩数据,前端 store 据以更新 token 显示
// 解决"压缩触发但前端 token 显示不降"的缺陷
const onCompressed = (data: { iteration?: number; originalTokens?: number; compressedTokens?: number }) => { const onCompressed = (data: { iteration?: number; originalTokens?: number; compressedTokens?: number }) => {
const savedTokens = Math.max(0, (data.originalTokens ?? 0) - (data.compressedTokens ?? 0));
if (!mainWindow.isDestroyed()) { if (!mainWindow.isDestroyed()) {
// toast 通知用户压缩已发生
mainWindow.webContents.send('toast:show', { mainWindow.webContents.send('toast:show', {
type: 'info', type: 'info',
message: `上下文压缩: ${data.originalTokens ?? '?'}${data.compressedTokens ?? '?'} tokens`, message: `上下文压缩: ${data.originalTokens ?? '?'}${data.compressedTokens ?? '?'} tokens(节省 ${savedTokens}`,
});
// 通过 streamEvent 转发,前端 useAgentStream 监听 'compressed' 类型后更新 store
mainWindow.webContents.send('agent:streamEvent', {
type: 'compressed',
sessionId,
iteration: data.iteration ?? 0,
originalTokens: data.originalTokens,
compressedTokens: data.compressedTokens,
savedTokens,
timestamp: Date.now(),
}); });
} }
}; };
@@ -817,6 +863,11 @@ export function registerAllIPCHandlers(
log.info(`[CONFIG] Log level updated to ${level}`); log.info(`[CONFIG] Log level updated to ${level}`);
} }
// 广播配置变更事件,前端监听后更新 store(如 maxIterations 显示)
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('config:changed', { key, value });
}
// 工作空间路径变更时写入独立文件(下次启动生效) // 工作空间路径变更时写入独立文件(下次启动生效)
if (key === 'workspace.path') { if (key === 'workspace.path') {
try { try {
@@ -978,6 +1029,13 @@ export function registerAllIPCHandlers(
log.info(`[CONFIG] Log level updated to ${logLevelValue}`); log.info(`[CONFIG] Log level updated to ${logLevelValue}`);
} }
// 第四步半:广播所有配置变更事件,前端监听后更新 store
if (!mainWindow.isDestroyed()) {
for (const { key, value } of entries) {
mainWindow.webContents.send('config:changed', { key, value });
}
}
// 第五步:工作空间路径写入独立文件(下次启动生效) // 第五步:工作空间路径写入独立文件(下次启动生效)
if (workspacePathValue) { if (workspacePathValue) {
try { try {
@@ -1061,7 +1119,7 @@ export function registerAllIPCHandlers(
// ===== 工作空间校验与继承 ===== // ===== 工作空间校验与继承 =====
// 校验工作空间路径:检测路径有效性 + 4 个必需文件状态 // 校验工作空间路径:检测路径有效性 + 必需文件状态 + 数据库是否存在
ipcMain.handle('workspace:check', async (_event, targetPath: string) => { ipcMain.handle('workspace:check', async (_event, targetPath: string) => {
if (!targetPath || typeof targetPath !== 'string') { if (!targetPath || typeof targetPath !== 'string') {
return { valid: false, reason: '路径不能为空' }; return { valid: false, reason: '路径不能为空' };
@@ -1080,6 +1138,7 @@ export function registerAllIPCHandlers(
exists: false, exists: false,
missingFiles: ['SOUL.md', 'MEMORY.md'], missingFiles: ['SOUL.md', 'MEMORY.md'],
isNewWorkspace: true, isNewWorkspace: true,
dbExists: false,
reason: '目录不存在,将在切换后自动创建', reason: '目录不存在,将在切换后自动创建',
}; };
} }
@@ -1101,12 +1160,16 @@ export function registerAllIPCHandlers(
if (!existsSync(join(resolvedPath, f))) missingFiles.push(f); if (!existsSync(join(resolvedPath, f))) missingFiles.push(f);
} }
// 校验 4: 检测 .metona/agent.db 是否存在(用于判断是否显示"继承数据库"选项)
const dbExists = existsSync(join(resolvedPath, '.metona', 'agent.db'));
return { return {
valid: true, valid: true,
path: resolvedPath, path: resolvedPath,
exists: true, exists: true,
missingFiles, missingFiles,
isNewWorkspace: missingFiles.length === requiredFiles.length, isNewWorkspace: missingFiles.length === requiredFiles.length,
dbExists,
}; };
}); });
@@ -1132,25 +1195,74 @@ export function registerAllIPCHandlers(
mkdirSync(resolvedTarget, { recursive: true }); mkdirSync(resolvedTarget, { recursive: true });
} }
// 继承白名单:键为前端传入的标识,值为实际相对路径分段
// - SOUL.md: 工作空间根目录的 Agent 身份定义
// - .metona/agent.db: 工作空间内的 SQLite 数据库(会话/消息/记忆/Trace)
// 白名单映射防止路径遍历攻击(不直接使用用户传入的路径拼接到 fs 调用)
const INHERIT_WHITELIST: Record<string, string[]> = {
'SOUL.md': ['SOUL.md'],
'.metona/agent.db': ['.metona', 'agent.db'],
};
const inherited: string[] = []; const inherited: string[] = [];
const failed: Array<{ file: string; error: string }> = []; const failed: Array<{ file: string; error: string }> = [];
for (const fileName of files) { for (const fileName of files) {
// 仅允许继承白名单文件(防止路径遍历)— v0.3.14: 仅 SOUL.md const pathSegments = INHERIT_WHITELIST[fileName];
if (!['SOUL.md'].includes(fileName)) { // 不在白名单中:拒绝(防止路径遍历)
if (!pathSegments) {
failed.push({ file: fileName, error: '不在继承白名单中' }); failed.push({ file: fileName, error: '不在继承白名单中' });
continue; continue;
} }
const srcFile = join(resolvedSource, fileName); const srcFile = join(resolvedSource, ...pathSegments);
const dstFile = join(resolvedTarget, fileName); const dstFile = join(resolvedTarget, ...pathSegments);
try { try {
if (existsSync(srcFile)) { // 确保目标文件的父目录存在(如 .metona/)
const dstDir = join(resolvedTarget, ...pathSegments.slice(0, -1));
if (!existsSync(dstDir)) {
mkdirSync(dstDir, { recursive: true });
}
if (!existsSync(srcFile)) {
failed.push({ file: fileName, error: '源文件不存在' });
continue;
}
// === 数据库文件特殊处理 ===
// agent.db 启用了 WAL 模式(database.service.ts:53),直接 copyFileSync 存在两个问题:
// 1. WAL 中未 checkpoint 的事务会丢失(最近会话/消息可能没了)
// 2. 主进程可能仍持有 db 句柄,Windows 上文件锁定会导致复制失败或不一致
// 解决方案:用 better-sqlite3 的 backup API(自带 checkpoint + 一致性快照)
if (fileName === '.metona/agent.db') {
try {
const Database = (await import('better-sqlite3')).default;
// 以只读方式打开源库(如果主进程已持有句柄,better-sqlite3 会共享锁)
const srcDb = new Database(srcFile, { readonly: true, fileMustExist: true });
try {
// backup API 原子性导出完整快照,自动合并 WAL 内容
srcDb.backup(dstFile);
inherited.push(fileName);
log.info(`[WORKSPACE] Inherited ${fileName} (via SQLite backup): ${resolvedSource}${resolvedTarget}`);
} finally {
srcDb.close();
}
} catch (err) {
// backup 失败时回退到 copyFileSync(至少保证基本可用)
log.warn(`[WORKSPACE] SQLite backup failed, fallback to copyFileSync: ${(err as Error).message}`);
try {
copyFileSync(srcFile, dstFile);
inherited.push(fileName);
log.info(`[WORKSPACE] Inherited ${fileName} (fallback copyFileSync): ${resolvedSource}${resolvedTarget}`);
} catch (err2) {
failed.push({ file: fileName, error: `backup 和 fallback 均失败: ${(err2 as Error).message}` });
}
}
continue;
}
// === 普通文件直接复制 ===
copyFileSync(srcFile, dstFile); copyFileSync(srcFile, dstFile);
inherited.push(fileName); inherited.push(fileName);
log.info(`[WORKSPACE] Inherited ${fileName}: ${resolvedSource}${resolvedTarget}`); log.info(`[WORKSPACE] Inherited ${fileName}: ${resolvedSource}${resolvedTarget}`);
} else {
failed.push({ file: fileName, error: '源文件不存在' });
}
} catch (err) { } catch (err) {
failed.push({ file: fileName, error: (err as Error).message }); failed.push({ file: fileName, error: (err as Error).message });
} }
@@ -1159,6 +1271,37 @@ export function registerAllIPCHandlers(
return { success: true, inherited, failed }; return { success: true, inherited, failed };
}); });
// #51 修复: 校验源数据库完整性,防止继承损坏的数据库(如 WAL 损坏)导致新工作空间数据丢失
// 在前端勾选 inheritDatabase 时调用,执行 PRAGMA integrity_check
ipcMain.handle('workspace:checkDatabaseIntegrity', async (_event, sourcePath: string) => {
if (!sourcePath) return { success: false, error: '参数无效' };
const { existsSync } = await import('fs');
const { resolve } = await import('path');
const srcFile = join(resolve(sourcePath), '.metona', 'agent.db');
if (!existsSync(srcFile)) {
// 源数据库不存在不算损坏(可能是新工作空间尚未创建数据库),视为校验通过
return { success: true, ok: true, detail: 'source database not exist' };
}
try {
const Database = (await import('better-sqlite3')).default;
// 以只读方式打开源库,避免锁竞争
const db = new Database(srcFile, { readonly: true, fileMustExist: true });
try {
const result = db.prepare('PRAGMA integrity_check').get() as { integrity_check: string };
const ok = result.integrity_check === 'ok';
if (!ok) {
log.warn(`[WORKSPACE] Source database integrity check failed: ${result.integrity_check} (source=${sourcePath})`);
}
return { success: true, ok, detail: result.integrity_check };
} finally {
db.close();
}
} catch (err) {
log.error(`[WORKSPACE] Failed to check database integrity: ${(err as Error).message}`);
return { success: false, error: (err as Error).message };
}
});
// 获取当前工作空间详情:路径 + 2 个核心文件状态 + 自动目录状态 // 获取当前工作空间详情:路径 + 2 个核心文件状态 + 自动目录状态
ipcMain.handle('workspace:getInfo', async () => { ipcMain.handle('workspace:getInfo', async () => {
const { existsSync, statSync, readFileSync, readdirSync } = await import('fs'); const { existsSync, statSync, readFileSync, readdirSync } = await import('fs');
@@ -1166,7 +1309,7 @@ export function registerAllIPCHandlers(
const files = workspaceService.reload(); // 同步外部可能的手动修改 const files = workspaceService.reload(); // 同步外部可能的手动修改
const REQUIRED = ['SOUL.md', 'MEMORY.md'] as const; const REQUIRED = ['SOUL.md', 'MEMORY.md'] as const;
const AUTO_DIRS = ['logs', 'traces', '.metona'] as const; const AUTO_DIRS = ['logs', '.metona'] as const;
const fileInfos = REQUIRED.map((name) => { const fileInfos = REQUIRED.map((name) => {
const filePath = join(workspacePath, name); const filePath = join(workspacePath, name);
+65 -3
View File
@@ -14,7 +14,7 @@
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 窗口管理 * @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 窗口管理
*/ */
import { app, shell, Menu, BrowserWindow } from 'electron'; import { app, shell, Menu, BrowserWindow, ipcMain } from 'electron';
import { join } from 'path'; import { join } from 'path';
import { existsSync, readFileSync, writeFileSync } from 'fs'; import { existsSync, readFileSync, writeFileSync } from 'fs';
import { electronApp, optimizer } from '@electron-toolkit/utils'; import { electronApp, optimizer } from '@electron-toolkit/utils';
@@ -22,6 +22,7 @@ import log from 'electron-log';
import { DatabaseService } from './services/database.service'; import { DatabaseService } from './services/database.service';
import { SessionService } from './services/session.service'; import { SessionService } from './services/session.service';
import { ConfigService } from './services/config.service'; import { ConfigService } from './services/config.service';
import { GlobalConfigService } from './services/global-config.service';
import { WorkspaceService } from './services/workspace.service'; import { WorkspaceService } from './services/workspace.service';
import { AuditService } from './services/audit.service'; import { AuditService } from './services/audit.service';
import { SessionRecorder } from './services/session-recorder.service'; import { SessionRecorder } from './services/session-recorder.service';
@@ -131,8 +132,33 @@ async function initialize(): Promise<void> {
databaseService.initialize(); databaseService.initialize();
const db = databaseService.getDB(); const db = databaseService.getDB();
// v0.3.17: 初始化全局配置层(跨工作空间共享 LLM/Agent/onboarding 等机器级配置)
// 必须在 ConfigService 创建前初始化,以便注入
const globalConfigService = new GlobalConfigService();
globalConfigService.initialize();
const sessionService = new SessionService(() => db); const sessionService = new SessionService(() => db);
const configService = new ConfigService(() => db); const configService = new ConfigService(() => db);
// 注入全局配置层:读取时回退到全局 JSON,写入时双写
configService.setGlobalConfig(globalConfigService);
// v0.3.17 迁移: 首次启用全局配置层时,把工作空间 DB 中的全局配置同步到全局 JSON
// 幂等设计:migrateFromWorkspaceDB 仅写入全局层不存在的 key,重复执行无副作用
// 解决场景:老用户从 v0.3.16 升级,LLM 配置在工作空间 DB 里,需要迁移到全局层
// 注意: 此处直接查 DB 而不通过 configService.getAll(),因为后者会合并全局层,导致迁移逻辑循环
const configRows = db.prepare('SELECT key, value FROM app_config').all() as Array<{ key: string; value: string }>;
const workspaceConfig: Record<string, unknown> = {};
for (const row of configRows) {
try {
workspaceConfig[row.key] = JSON.parse(row.value);
} catch {
workspaceConfig[row.key] = row.value;
}
}
const migratedCount = globalConfigService.migrateFromWorkspaceDB(workspaceConfig);
if (migratedCount > 0) {
log.info(`[MIGRATION] Migrated ${migratedCount} global config keys from workspace DB to global layer`);
}
// ===== 日志服务 ===== // ===== 日志服务 =====
const auditService = new AuditService(() => db); const auditService = new AuditService(() => db);
@@ -305,15 +331,34 @@ async function initialize(): Promise<void> {
agentLoop.setWorkspacePath(workspaceInfo.path); agentLoop.setWorkspacePath(workspaceInfo.path);
// P1-7 修复: MCP 初始化在 agentLoop 创建后执行,完成后重新同步工具到 AgentLoop // P1-7 修复: MCP 初始化在 agentLoop 创建后执行,完成后重新同步工具到 AgentLoop
// v0.3.18 修复: MCP 完成后广播 tools:ready 事件,前端据以启用发送按钮
// 之前 MCP 初始化是异步的,用户在 MCP 完成前发送消息会用到不全的工具集
// v0.3.18 修复: 维护 toolsReady 标志 + 暴露 tools:isReady 查询,解决竞态条件
// MCP initialize 几乎立即 resolveconnectServer 不 await),tools:ready 事件
// 可能在前端监听器注册前就发出,导致前端永久错过事件、输入框永久禁用
let toolsReady = false;
mcpManager.initialize().then(() => { mcpManager.initialize().then(() => {
agentLoop.setTools(toolRegistry.listTools()); agentLoop.setTools(toolRegistry.listTools());
log.info('[MCP] Tools registered and synced to AgentLoop'); log.info('[MCP] Tools registered and synced to AgentLoop');
toolsReady = true;
// 广播工具就绪事件给所有窗口
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('tools:ready', { toolCount: toolRegistry.size });
}
}).catch((err) => { }).catch((err) => {
log.warn('MCP Manager initialization error:', err); log.warn('MCP Manager initialization error:', err);
// 即使 MCP 失败,内置工具已就绪,仍广播 ready 让前端启用发送
toolsReady = true;
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('tools:ready', { toolCount: toolRegistry.size });
}
}); });
// ===== Memory Consolidator(会话结束 AI 提取重要记忆到 MEMORY.md===== // ===== Memory Consolidator(会话结束 AI 提取重要记忆到 MEMORY.md=====
const memoryConsolidator = new MemoryConsolidator(adapter, workspaceService); const memoryConsolidator = new MemoryConsolidator(adapter, workspaceService);
// v0.3.18 修复: 注入 MemoryManager,使 consolidate 同步写入 semantic_memories 表
// 实现 DB 记忆与 MEMORY.md 双轨交叉,避免"AI 记得一半"的不一致
memoryConsolidator.setMemoryManager(memoryManager);
// ===== Task Orchestrator(子任务委派)===== // ===== Task Orchestrator(子任务委派)=====
const orchestrator = new TaskOrchestrator( const orchestrator = new TaskOrchestrator(
@@ -447,6 +492,9 @@ async function initialize(): Promise<void> {
promptDefender, outputValidator, promptDefender, outputValidator,
confirmationHook, memoryConsolidator, orchestrator, confirmationHook, memoryConsolidator, orchestrator,
); );
// v0.3.18 修复: 注册 tools:isReady 查询,前端注册 onReady 监听器后立即查询,
// 解决 tools:ready 事件在监听器注册前发出的竞态条件
ipcMain.handle('tools:isReady', () => ({ ready: toolsReady, toolCount: toolRegistry.size }));
}, },
}); });
@@ -515,12 +563,13 @@ async function initialize(): Promise<void> {
(global as Record<string, unknown>).shutdownInProgress = true; (global as Record<string, unknown>).shutdownInProgress = true;
event.preventDefault(); event.preventDefault();
// 内部异步清理,加 5 秒超时保护防止卡死 // v0.3.18 修复: 超时从 5 秒延长到 40 秒,以容纳 consolidate 的 35 秒等待
// 之前 5 秒超时会导致进行中的 consolidate 被 kill,记忆丢失
const shutdownTimeout = setTimeout(() => { const shutdownTimeout = setTimeout(() => {
log.warn('[Shutdown] Timeout reached, forcing exit'); log.warn('[Shutdown] Timeout reached, forcing exit');
if (databaseService) { databaseService.close(); databaseService = null; } if (databaseService) { databaseService.close(); databaseService = null; }
app.exit(0); app.exit(0);
}, 5_000); }, 40_000);
(async () => { (async () => {
try { try {
@@ -529,6 +578,19 @@ async function initialize(): Promise<void> {
log.error('[Shutdown] MCP shutdown failed:', err); log.error('[Shutdown] MCP shutdown failed:', err);
} }
cleanupBrowser(); cleanupBrowser();
// v0.3.18 修复: 等待进行中的记忆固化任务完成,避免应用退出导致记忆丢失
// consolidate 内部有 30 秒 LLM 超时保护,此处等待 35 秒足够覆盖
if (memoryConsolidator.isRunning()) {
log.info('[Shutdown] Waiting for memory consolidation to complete...');
const completed = await memoryConsolidator.waitForCompletion(35_000);
if (!completed) {
log.warn('[Shutdown] Memory consolidation timed out, proceeding with exit');
} else {
log.info('[Shutdown] Memory consolidation completed');
}
}
if (databaseService) { databaseService.close(); databaseService = null; } if (databaseService) { databaseService.close(); databaseService = null; }
clearTimeout(shutdownTimeout); clearTimeout(shutdownTimeout);
app.exit(0); app.exit(0);
+38 -2
View File
@@ -130,6 +130,13 @@ const metonaAPI = {
// v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败 // v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败
setBatch: (entries: Array<{ key: string; value: unknown }>) => setBatch: (entries: Array<{ key: string; value: unknown }>) =>
ipcRenderer.invoke('config:setBatch', entries), ipcRenderer.invoke('config:setBatch', entries),
// v0.3.17: 监听配置变更广播(后端 config:set/setBatch 后触发,用于前端 store 实时更新)
onChanged: (callback: (data: { key: string; value: unknown }) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { key: string; value: unknown }) =>
callback(data);
ipcRenderer.on('config:changed', listener);
return () => ipcRenderer.removeListener('config:changed', listener);
},
}, },
// ===== 应用工具 ===== // ===== 应用工具 =====
@@ -140,6 +147,9 @@ const metonaAPI = {
showItemInFolder: (path: string) => ipcRenderer.invoke('app:showItemInFolder', path), showItemInFolder: (path: string) => ipcRenderer.invoke('app:showItemInFolder', path),
selectFolder: (defaultPath?: string) => ipcRenderer.invoke('app:selectFolder', defaultPath), selectFolder: (defaultPath?: string) => ipcRenderer.invoke('app:selectFolder', defaultPath),
restart: () => ipcRenderer.invoke('app:restart'), restart: () => ipcRenderer.invoke('app:restart'),
// #49 修复: 前端错误上报通道(主进程可注册 'error:report' handler 记录到 electron-log/审计日志)
// 使用 send(单向)而非 invoke,即使主进程未注册 handler 也不会 reject
reportError: (payload: unknown) => ipcRenderer.send('error:report', payload),
}, },
// ===== 工作空间管理 ===== // ===== 工作空间管理 =====
@@ -148,12 +158,23 @@ const metonaAPI = {
inheritFiles: (params: { targetPath: string; sourcePath: string; files: string[] }) => inheritFiles: (params: { targetPath: string; sourcePath: string; files: string[] }) =>
ipcRenderer.invoke('workspace:inheritFiles', params), ipcRenderer.invoke('workspace:inheritFiles', params),
getInfo: () => ipcRenderer.invoke('workspace:getInfo'), getInfo: () => ipcRenderer.invoke('workspace:getInfo'),
// #51 修复: 校验源数据库完整性(PRAGMA integrity_check),用于 inheritDatabase 前置校验
checkDatabaseIntegrity: (sourcePath: string) =>
ipcRenderer.invoke('workspace:checkDatabaseIntegrity', sourcePath),
}, },
// ===== 工具管理 ===== // ===== 工具管理 =====
tools: { tools: {
list: () => ipcRenderer.invoke('tools:list'), list: () => ipcRenderer.invoke('tools:list'),
toggle: (toolName: string, enabled: boolean) => ipcRenderer.invoke('tools:toggle', toolName, enabled), toggle: (toolName: string, enabled: boolean) => ipcRenderer.invoke('tools:toggle', toolName, enabled),
/** v0.3.18 修复: 监听工具就绪事件(MCP 初始化完成后触发) */
onReady: (callback: (data: { toolCount: number }) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { toolCount: number }) => callback(data);
ipcRenderer.on('tools:ready', listener);
return () => ipcRenderer.removeListener('tools:ready', listener);
},
/** v0.3.18 修复: 查询工具当前是否已就绪(解决事件竞态,前端注册监听器后立即查询一次) */
isReady: () => ipcRenderer.invoke('tools:isReady') as Promise<{ ready: boolean; toolCount: number }>,
}, },
// ===== 数据管理 ===== // ===== 数据管理 =====
@@ -187,10 +208,25 @@ if (process.contextIsolated) {
contextBridge.exposeInMainWorld('electron', electronAPI); contextBridge.exposeInMainWorld('electron', electronAPI);
contextBridge.exposeInMainWorld('metona', metonaAPI); contextBridge.exposeInMainWorld('metona', metonaAPI);
} catch (error) { } catch (error) {
console.error('Failed to expose APIs:', error); console.error('Failed to expose APIs via contextBridge:', error);
// #7 修复: 生产环境强制失败,防止以不安全状态启动(contextBridge 失败意味着
// context isolation 边界已破坏,继续运行可能导致渲染进程直接访问 Node API)
// 开发环境降级到 globalThis 以便调试
// 审查修复: process.defaultApp 在 sandbox: true 下可能为 undefined
// 导致开发环境也无法降级。补充 process.env.NODE_ENV 判断(sandbox 下 process.env 仍可用)
if (process.env.NODE_ENV === 'development' || process.defaultApp) {
console.warn('[DEV] Falling back to globalThis assignment — context isolation is weakened');
(globalThis as unknown as Record<string, unknown>).electron = electronAPI;
(globalThis as unknown as Record<string, unknown>).metona = metonaAPI;
} else {
// 审查修复 M19: 在 throw 前记录详细错误,便于在 DevTools 中排查白屏问题。
// 生产环境 contextBridge 失败时用户只看到白屏,console.error 至少能在 DevTools 中留下线索。
console.error('[FATAL] contextBridge failed in production:', error);
throw error;
}
} }
} else { } else {
// 降级方案(不推荐 // context isolation 未启用(不推荐,仅开发环境
(globalThis as unknown as Record<string, unknown>).electron = electronAPI; (globalThis as unknown as Record<string, unknown>).electron = electronAPI;
(globalThis as unknown as Record<string, unknown>).metona = metonaAPI; (globalThis as unknown as Record<string, unknown>).metona = metonaAPI;
} }
+43 -2
View File
@@ -16,6 +16,27 @@ import type Database from 'better-sqlite3';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import log from 'electron-log'; import log from 'electron-log';
/**
* #36 修复: 稳定序列化,递归按 key 字典序排序后序列化
* JSON.stringify 对对象 key 顺序敏感({a:1,b:2} ≠ {b:2,a:1}),
* 导致相同语义的对象产生不同 hash,审计去重失效。
* stableStringify 保证相同内容始终产生相同字符串。
*/
function stableStringify(obj: unknown): string {
if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
if (Array.isArray(obj)) {
return '[' + obj.map(stableStringify).join(',') + ']';
}
const keys = Object.keys(obj as Record<string, unknown>).sort();
return (
'{' +
keys
.map((k) => JSON.stringify(k) + ':' + stableStringify((obj as Record<string, unknown>)[k]))
.join(',') +
'}'
);
}
export type AuditEventType = 'tool_call' | 'permission_check' | 'error' | 'llm_request' | 'llm_response' | 'session_start' | 'session_end' | 'config_change'; export type AuditEventType = 'tool_call' | 'permission_check' | 'error' | 'llm_request' | 'llm_response' | 'session_start' | 'session_end' | 'config_change';
export type AuditActor = 'agent' | 'user' | 'system'; export type AuditActor = 'agent' | 'user' | 'system';
export type AuditOutcome = 'success' | 'denied' | 'error'; export type AuditOutcome = 'success' | 'denied' | 'error';
@@ -38,6 +59,7 @@ export class AuditService {
* 计算链式哈希 * 计算链式哈希
* current_hash = SHA256(prev_hash + 所有内容字段) * current_hash = SHA256(prev_hash + 所有内容字段)
* v0.2.0: 纳入 iteration/outcome/durationMs 字段,使用 JSON.stringify 避免分隔符碰撞 * v0.2.0: 纳入 iteration/outcome/durationMs 字段,使用 JSON.stringify 避免分隔符碰撞
* #36 修复: 改用 stableStringify 替代 JSON.stringify,避免对象 key 顺序不稳定导致 hash 不一致
*/ */
private computeHash( private computeHash(
prevHash: string, prevHash: string,
@@ -47,8 +69,8 @@ export class AuditService {
outcome: string | null; durationMs: number | null; createdAt: number; outcome: string | null; durationMs: number | null; createdAt: number;
}, },
): string { ): string {
// 使用 JSON.stringify 避免分隔符碰撞 // #36 修复: 使用 stableStringify 避免分隔符碰撞且保证 key 顺序稳定
const content = JSON.stringify({ const content = stableStringify({
prevHash, prevHash,
sessionId: entry.sessionId, sessionId: entry.sessionId,
iteration: entry.iteration, iteration: entry.iteration,
@@ -328,6 +350,25 @@ export class AuditService {
}); });
if (row.current_hash !== expectedHash) { if (row.current_hash !== expectedHash) {
// 审查修复: #36 将 JSON.stringify 改为 stableStringify 后,旧记录的 hash 用旧算法生成
// 尝试用旧算法(JSON.stringify)重新计算,如果匹配则跳过(兼容旧数据)
const legacyContent = JSON.stringify({
prevHash,
sessionId: row.session_id,
iteration: row.iteration,
eventType: row.event_type,
actor: row.actor,
target: row.target,
details: row.details,
outcome: row.outcome,
durationMs: row.duration_ms,
createdAt: row.created_at,
});
const legacyHash = createHash('sha256').update(legacyContent, 'utf-8').digest('hex');
if (row.current_hash === legacyHash) {
prevHash = row.current_hash;
continue;
}
return { valid: false, totalRecords: rows.length, verifiedRecords: verified, tamperedId: row.id }; return { valid: false, totalRecords: rows.length, verifiedRecords: verified, tamperedId: row.id };
} }
+69 -5
View File
@@ -4,11 +4,17 @@
* 管理 app_config 表的读写操作。 * 管理 app_config 表的读写操作。
* 所有配置值以 JSON 格式存储。 * 所有配置值以 JSON 格式存储。
* *
* v0.3.17: 引入全局配置层(GlobalConfigService)。
* - 全局 keyllm.* / agent.* / onboarding.completed 等)读取时优先工作空间 DB,
* miss 时回退到全局 JSON,确保切换工作空间后 LLM 凭据和引导状态不丢失。
* - 写入时同时写工作空间 DB(兼容)和全局 JSON(跨工作空间共享)。
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 数据库配置 * @see docs/MetonaAI-Desktop 架构与交互设计.html — 数据库配置
*/ */
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import log from 'electron-log'; import log from 'electron-log';
import { GlobalConfigService, isGlobalKey, isUnconfiguredGlobalKey } from './global-config.service';
export interface ConfigRow { export interface ConfigRow {
key: string; key: string;
@@ -18,26 +24,63 @@ export interface ConfigRow {
} }
export class ConfigService { export class ConfigService {
private globalConfig: GlobalConfigService | null = null;
constructor(private getDB: () => Database.Database) {} constructor(private getDB: () => Database.Database) {}
/**
* 注入全局配置服务(可选,由 main.ts 在启动时调用)
* 注入后,全局 key 的读取会回退到全局层,写入会双写。
*/
setGlobalConfig(globalConfig: GlobalConfigService): void {
this.globalConfig = globalConfig;
}
/** /**
* 获取配置值 * 获取配置值
* 读取顺序:工作空间 DB → 全局 JSON(仅全局 key
*
* 特殊处理:对于"空值视为未配置"的全局 key(如 llm.apiKey=''、onboarding.completed=false),
* 当工作空间 DB 的值为空/默认值时,回退到全局层读取真实值。
* 这解决了新工作空间 DB 被 seedDefaults 灌入空值导致配置丢失的问题。
*/ */
get<T = unknown>(key: string): T | null { get<T = unknown>(key: string): T | null {
const db = this.getDB(); const db = this.getDB();
const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key) as ConfigRow | undefined; const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key) as ConfigRow | undefined;
if (!row) return null; if (row) {
let parsed: unknown;
try { try {
return JSON.parse(row.value) as T; parsed = JSON.parse(row.value);
} catch { } catch {
return row.value as T; parsed = row.value;
} }
// 全局 key 的空值回退:若工作空间 DB 存在该 key 但值为"未配置"(如 llm.apiKey=''),
// 且全局层有真实值,则返回全局层的值。
// 解决场景:新工作空间 DB 被 seedDefaults 灌入 llm.apiKey='' / onboarding.completed=false
// 但用户在全局层已配置过,应回退到全局层。
if (this.globalConfig && isGlobalKey(key) && isUnconfiguredGlobalKey(key, parsed)) {
const globalValue = this.globalConfig.get<T>(key);
if (globalValue !== null && !isUnconfiguredGlobalKey(key, globalValue)) {
return globalValue;
}
}
return parsed as T;
}
// 工作空间 DB miss,若为全局 key 则回退到全局层
if (this.globalConfig && isGlobalKey(key)) {
return this.globalConfig.get<T>(key);
}
return null;
} }
/** /**
* 设置配置值(保留已有 category) * 设置配置值(保留已有 category)
* 全局 key 同时写入工作空间 DB 和全局 JSON
*/ */
set(key: string, value: unknown): void { set(key: string, value: unknown): void {
const db = this.getDB(); const db = this.getDB();
@@ -52,11 +95,17 @@ export class ConfigService {
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
`).run(key, jsonValue, category, Date.now()); `).run(key, jsonValue, category, Date.now());
// 全局 key 同步写入全局层(跨工作空间共享)
if (this.globalConfig && isGlobalKey(key)) {
this.globalConfig.set(key, value);
}
log.debug(`Config set: ${key}`); log.debug(`Config set: ${key}`);
} }
/** /**
* 获取所有配置 * 获取所有配置(工作空间 DB + 全局层合并)
* 合并规则与 get() 一致:全局 key 在 DB 中为"未配置"时回退到全局层
*/ */
getAll(): Record<string, unknown> { getAll(): Record<string, unknown> {
const db = this.getDB(); const db = this.getDB();
@@ -71,6 +120,21 @@ export class ConfigService {
} }
} }
// 全局层合并:与 get() 的回退逻辑保持一致
if (this.globalConfig) {
const globalAll = this.globalConfig.getAll();
for (const [key, globalValue] of Object.entries(globalAll)) {
if (!(key in config)) {
// DB miss → 用全局层
config[key] = globalValue;
} else if (isGlobalKey(key) && isUnconfiguredGlobalKey(key, config[key])
&& !isUnconfiguredGlobalKey(key, globalValue)) {
// DB 有值但为"未配置"seedDefaults 默认值/空值),全局层有真实值 → 用全局层
config[key] = globalValue;
}
}
}
return config; return config;
} }
+16
View File
@@ -270,6 +270,11 @@ export class DatabaseService {
} }
}; };
// #33 修复: 整个迁移批次包裹在事务中,保证原子性
// 若某个 migration 部分失败(如 ALTER TABLE 成功,CREATE INDEX 失败),
// 事务回滚,数据库不会处于部分变更的不一致状态,下次启动可安全重试。
// better-sqlite3 的事务是同步原子的,嵌套事务使用 SAVEPOINT 实现。
const runAllMigrations = db.transaction(() => {
// 迁移 1: messages 表添加 attachments 列 // 迁移 1: messages 表添加 attachments 列
tryAddColumn('messages', 'attachments', 'TEXT', 'attachments'); tryAddColumn('messages', 'attachments', 'TEXT', 'attachments');
// 迁移 2: audit_logs 表添加 iteration 列 // 迁移 2: audit_logs 表添加 iteration 列
@@ -289,6 +294,9 @@ export class DatabaseService {
if (contentCol && contentCol.notnull === 1) { if (contentCol && contentCol.notnull === 1) {
log.info('[DB] Migration: rebuilding messages table to allow NULL content'); log.info('[DB] Migration: rebuilding messages table to allow NULL content');
// #33 修复: 重建表的多步骤包裹在嵌套事务中,部分失败时回滚
// 避免 CREATE messages_new 成功但 DROP/RENAME 失败导致数据丢失或 schema 不一致
const rebuildMessages = db.transaction(() => {
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS messages_new ( CREATE TABLE IF NOT EXISTS messages_new (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -313,10 +321,15 @@ export class DatabaseService {
`); `);
// 重建索引 // 重建索引
// #42 确认: idx_messages_session 已是 (session_id, created_at) 复合索引,
// 覆盖 getMessages 的 WHERE session_id = ? ORDER BY created_at ASC 查询,
// 工单描述"仅有 session_id 单字段索引"不准确,无需额外添加 idx_messages_session_timestamp
db.exec(` db.exec(`
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at); 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); CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role);
`); `);
});
rebuildMessages();
log.info('[DB] Migration: messages table rebuilt successfully (content now allows NULL)'); log.info('[DB] Migration: messages table rebuilt successfully (content now allows NULL)');
} }
@@ -326,6 +339,9 @@ export class DatabaseService {
log.warn(`[DB] Migration 5 (messages content NULL) skipped: ${msg}`); log.warn(`[DB] Migration 5 (messages content NULL) skipped: ${msg}`);
// 非致命错误 — 如果迁移失败,NOT NULL 约束仍生效,saveMessage 会保存空字符串 // 非致命错误 — 如果迁移失败,NOT NULL 约束仍生效,saveMessage 会保存空字符串
} }
});
runAllMigrations();
} }
/** /**
+247
View File
@@ -0,0 +1,247 @@
/**
* Global Config Service — 全局配置层(跨工作空间共享)
*
* 设计原因:
* 原实现中所有配置存在工作空间 DB{workspacePath}/.metona/agent.db)的 app_config 表里。
* 切换到新工作空间时,新 DB 被 seedDefaults 灌入空值,导致 LLM 凭据、onboarding.completed
* 等本应"机器级"的配置全部丢失,用户被迫重新配置。
*
* 方案:
* 将"机器级全局配置"抽到 userData/global-config.json,与工作空间解耦。
* 工作空间 DB 仍保留会话/消息/记忆/审计等"工作空间级数据"。
*
* 全局 key 规则:
* - llm.* / agent.* / ollama.* / {provider}.contextWindow / security.* / ui.* / logging.*
* - onboarding.completed
* 写入时同时写工作空间 DB(兼容旧逻辑)和全局 JSON;
* 读取时优先工作空间 DB,miss 时回退到全局 JSON。
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 配置存储
*/
import { app } from 'electron';
import { join } from 'path';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import log from 'electron-log';
/** 全局配置文件路径(userData 下,与工作空间无关) */
const GLOBAL_CONFIG_FILE = join(app.getPath('userData'), 'global-config.json');
/** 全局配置 key 前缀清单(匹配这些前缀的 key 视为全局配置) */
const GLOBAL_KEY_PREFIXES = [
'llm.',
'agent.',
'ollama.',
'security.',
'ui.',
'logging.',
'deepseek.',
'agnes.',
'mimo.',
'onboarding.',
];
/** 判断 key 是否属于全局配置 */
export function isGlobalKey(key: string): boolean {
return GLOBAL_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
}
/**
* seedDefaults 灌入的默认值映射表(用于判断"DB 值等于默认值时回退全局层")
* 必须与 database.service.ts 的 seedDefaults 保持同步
*/
export const SEED_DEFAULTS: Record<string, unknown> = {
'llm.provider': '',
'llm.model': '',
'llm.apiKey': '',
'llm.baseURL': '',
'llm.temperature': 0,
'llm.maxTokens': 63488,
'llm.fallbackProvider': '',
'llm.fallbackModel': '',
'agent.maxIterations': 20,
'agent.totalTimeoutMs': 600000,
'agent.enableThinking': true,
'agent.thinkingEffort': 'high',
'agent.enableReflection': false,
'agent.confirmationTimeoutMs': 120000,
'agent.toolExecutionTimeoutMs': 120000,
'security.requireWriteConfirmation': true,
'security.maxFileWriteSizeKB': 1024,
'security.promptInjectionDefense': true,
'ui.theme': 'auto',
'ui.animationMode': 'auto',
'ui.fontSize': 'medium',
'logging.level': 'info',
'logging.auditEnabled': true,
'logging.traceEnabled': true,
'ollama.numCtx': null,
'deepseek.contextWindow': 1000000,
'agnes.contextWindow': 1000000,
'mimo.contextWindow': 131072,
'onboarding.completed': false,
};
/**
* 判断全局 key 在工作空间 DB 中的值是否为"未配置"(应回退到全局层)
*
* 判断规则:
* - 值为空字符串/null/undefined → 未配置
* - 值等于 seedDefaults 默认值 → 未配置(新工作空间 DB 被灌入的默认值)
* - 其他 → 已配置(用户主动设置过,不回退)
*/
export function isUnconfiguredGlobalKey(key: string, value: unknown): boolean {
// 空值一律视为未配置
if (value === '' || value === null || value === undefined) {
return true;
}
// 值等于 seedDefaults 默认值 → 视为未配置(被 seedDefaults 灌入的)
const defaultValue = SEED_DEFAULTS[key];
if (defaultValue !== undefined && value === defaultValue) {
return true;
}
return false;
}
interface GlobalConfigData {
/** key → value(已 JSON.parse */
[key: string]: unknown;
}
export class GlobalConfigService {
private data: GlobalConfigData = {};
private initialized = false;
/**
* 初始化:读取全局配置文件
* 若文件不存在则创建空文件(首次启动)
*/
initialize(): void {
if (this.initialized) {
log.warn('[GlobalConfig] Already initialized');
return;
}
try {
if (existsSync(GLOBAL_CONFIG_FILE)) {
const raw = readFileSync(GLOBAL_CONFIG_FILE, 'utf-8');
this.data = JSON.parse(raw) as GlobalConfigData;
log.info(`[GlobalConfig] Loaded ${Object.keys(this.data).length} keys from ${GLOBAL_CONFIG_FILE}`);
} else {
// 确保父目录存在
const dir = join(GLOBAL_CONFIG_FILE, '..');
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
this.data = {};
this.flush();
log.info(`[GlobalConfig] Created new global config file: ${GLOBAL_CONFIG_FILE}`);
}
} catch (err) {
log.error('[GlobalConfig] Failed to load, starting with empty config:', err);
this.data = {};
}
this.initialized = true;
}
/**
* 读取全局配置
*/
get<T = unknown>(key: string): T | null {
if (!this.initialized) {
log.warn('[GlobalConfig] get() called before initialize()');
return null;
}
if (key in this.data) {
return this.data[key] as T;
}
return null;
}
/**
* 写入全局配置(内存 + 立即落盘)
*/
set(key: string, value: unknown): void {
if (!this.initialized) {
log.warn('[GlobalConfig] set() called before initialize()');
return;
}
this.data[key] = value;
this.flush();
log.debug(`[GlobalConfig] set: ${key}`);
}
/**
* 批量写入(仅落盘一次,避免多次 IO)
*/
setBatch(entries: Array<{ key: string; value: unknown }>): void {
if (!this.initialized) {
log.warn('[GlobalConfig] setBatch() called before initialize()');
return;
}
for (const { key, value } of entries) {
this.data[key] = value;
}
this.flush();
log.debug(`[GlobalConfig] setBatch: ${entries.length} keys`);
}
/**
* 获取所有全局配置(用于迁移和调试)
*/
getAll(): GlobalConfigData {
return { ...this.data };
}
/**
* 从工作空间 DB 迁移配置到全局层(首次启用全局配置层时调用)
*
* 迁移规则:
* - 仅迁移 isGlobalKey 匹配的 key
* - 仅当全局层该 key 不存在时才写入(不覆盖用户已保存的全局配置)
* - 仅迁移"已配置"的值(空值/seedDefaults 默认值不迁移)
* - false 不一律跳过:若 seedDefaults 默认值不是 false,则 false 是用户主动设置,应迁移
*
* @param workspaceConfig 工作空间 DB 的全部 app_config
*/
migrateFromWorkspaceDB(workspaceConfig: Record<string, unknown>): number {
if (!this.initialized) {
log.warn('[GlobalConfig] migrateFromWorkspaceDB() called before initialize()');
return 0;
}
let migrated = 0;
for (const [key, value] of Object.entries(workspaceConfig)) {
if (!isGlobalKey(key)) continue;
// 全局层已有该 key,不覆盖
if (key in this.data) continue;
// 空值不迁移(seedDefaults 灌入的空值)
if (value === '' || value === null || value === undefined) continue;
// 值等于 seedDefaults 默认值不迁移(避免把默认值同步到全局层)
// 注意:isUnconfiguredGlobalKey 已包含"值等于默认值"判断,此处复用
if (isUnconfiguredGlobalKey(key, value)) continue;
this.data[key] = value;
migrated++;
}
if (migrated > 0) {
this.flush();
log.info(`[GlobalConfig] Migrated ${migrated} keys from workspace DB to global config`);
}
return migrated;
}
/**
* 落盘到 JSON 文件
*/
private flush(): void {
try {
writeFileSync(GLOBAL_CONFIG_FILE, JSON.stringify(this.data, null, 2), 'utf-8');
} catch (err) {
log.error('[GlobalConfig] Failed to write config file:', err);
}
}
}
+82
View File
@@ -35,6 +35,84 @@ function safeParseArgs(raw: string): string[] {
} }
} }
// #6 修复: MCP stdio 命令安全校验
/**
* 允许的 MCP Server 启动命令白名单。
* 仅允许常见的 MCP Server 运行时,防止任意命令执行。
*/
const ALLOWED_MCP_COMMANDS = new Set([
'npx', 'node', 'npm',
'python', 'python3', 'uv', 'uvx',
'bun', 'deno',
]);
/**
* #6 修复: 校验 MCP Server 的 command 和 args,防止命令注入
*
* 1. 命令白名单:只允许已知的运行时命令
* 2. 参数注入检测:拒绝包含 shell 元字符的参数
*
* @param command MCP Server 启动命令
* @param args MCP Server 启动参数
* @throws 如果命令不在白名单或参数包含 shell 元字符
*/
function validateMcpCommand(command: string, args: string[]): void {
// 提取命令 basename(处理 /usr/bin/node、C:\node\node.exe 等路径)
const baseCmd = command.split(/[\\/]/).pop()?.replace(/\.exe$/i, '') ?? command;
if (!ALLOWED_MCP_COMMANDS.has(baseCmd)) {
throw new Error(
`MCP command "${baseCmd}" is not in the allowed list: ${[...ALLOWED_MCP_COMMANDS].join(', ')}. ` +
`For security reasons, only standard MCP runtimes are permitted.`,
);
}
// 审查修复: 移除 {}() 字符 — StdioClientTransport 用 spawn(不经 shell),
// 这些字符无注入风险,但 MCP Server 的 args 常含 JSON 配置(如 --config {"port":3000})会被误拒。
// 保留 ; & | ` $ < > 换行 等高危字符。
const shellMetacharPattern = /[;&|`$<>\n\r]/;
for (const arg of args) {
if (shellMetacharPattern.test(arg)) {
throw new Error(
`MCP command argument contains shell metacharacters and was rejected: ${arg.slice(0, 100)}`,
);
}
}
}
/**
* #6 修复 + 审查修复: 构建安全的子进程环境变量
*
* 审查修复: 原白名单方案过于激进,剥离了 MCP Server 运行所需的 npm_config_*、代理变量等,
* 导致 MCP Server 无法启动。改为黑名单方案:剔除包含敏感后缀的变量,保留其余。
*
* 注意: GITHUB_TOKEN / SLACK_BOT_TOKEN 等含 _TOKEN 后缀的变量也会被过滤。
* 如果 MCP Server 需要这些凭证,应通过 MCP Server 配置文件传递,而非环境变量。
*/
function buildSafeEnv(): Record<string, string> {
// 敏感变量后缀黑名单 — 匹配这些后缀的变量不会被传递给子进程
const SENSITIVE_SUFFIXES = [
'_API_KEY', '_TOKEN', '_SECRET', '_PASSWORD', '_PASSWD',
'_CREDENTIAL', '_CREDENTIALS', '_PRIVATE_KEY',
];
// 敏感变量名黑名单(精确匹配)
const SENSITIVE_KEYS = new Set([
'DEEPSEEK_API_KEY', 'AGNES_API_KEY', 'MIMO_API_KEY',
'GITEA_PASSWORD', 'DATABASE_PASSWORD',
]);
const env: Record<string, string> = {};
for (const [key, val] of Object.entries(process.env)) {
if (!val) continue;
// 跳过敏感变量名
if (SENSITIVE_KEYS.has(key)) continue;
// 跳过敏感后缀变量
if (SENSITIVE_SUFFIXES.some((suffix) => key.toUpperCase().endsWith(suffix))) continue;
env[key] = val;
}
return env;
}
// ===== 类型定义 ===== // ===== 类型定义 =====
export type MCPServerStatus = 'connecting' | 'connected' | 'disconnected' | 'error'; export type MCPServerStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
@@ -178,9 +256,13 @@ export class MCPManager {
if (config.transport === 'stdio' && config.command) { if (config.transport === 'stdio' && config.command) {
// stdio 模式 // stdio 模式
const args = config.args ?? []; const args = config.args ?? [];
// #6 修复: 命令白名单 + 参数元字符检测,防止命令注入
validateMcpCommand(config.command, args);
transport = new StdioClientTransport({ transport = new StdioClientTransport({
command: config.command, command: config.command,
args, args,
// #6 修复: 不透传完整 process.env,仅保留 MCP Server 运行所需的最小环境变量
env: buildSafeEnv(),
}); });
} else if (config.transport === 'sse' && config.url) { } else if (config.transport === 'sse' && config.url) {
// SSE 模式(远程 HTTP // SSE 模式(远程 HTTP
+68 -4
View File
@@ -13,7 +13,7 @@
*/ */
import { join } from 'path'; import { join } from 'path';
import { appendFileSync, existsSync, mkdirSync } from 'fs'; import { appendFileSync, existsSync, mkdirSync, promises } from 'fs';
import log from 'electron-log'; import log from 'electron-log';
// ===== 事件类型 ===== // ===== 事件类型 =====
@@ -43,6 +43,10 @@ export class SessionRecorder {
private filePath: string | null = null; private filePath: string | null = null;
private seq = 0; private seq = 0;
private sessionId: string | null = null; private sessionId: string | null = null;
// #35 修复: 缓冲写入,避免高频 appendFileSync 阻塞主进程(30-50 次/秒 → 每 100ms 批量异步 flush
private buffer: string[] = [];
private flushTimer: NodeJS.Timeout | null = null;
private flushing = false;
constructor(private workspacePath: string) {} constructor(private workspacePath: string) {}
@@ -91,6 +95,9 @@ export class SessionRecorder {
terminationReason: params.terminationReason, terminationReason: params.terminationReason,
}); });
// #35 修复: 同步 flush 确保最后的 session_end 事件写入文件
this.flushSync();
log.info(`Session recording stopped: ${this.filePath}`); log.info(`Session recording stopped: ${this.filePath}`);
this.filePath = null; this.filePath = null;
this.sessionId = null; this.sessionId = null;
@@ -222,7 +229,10 @@ export class SessionRecorder {
// ===== 私有方法 ===== // ===== 私有方法 =====
/** /**
* 写入事件到 JSONL 文件 * 写入事件到缓冲区
*
* #35 修复: 改为缓冲写入,定时异步 flush,避免每次 appendFileSync 阻塞主进程
* 高频事件(30-50 次/秒)先 push 到内存 buffer,每 100ms 批量异步写入文件
*/ */
private writeEvent(data: Record<string, unknown>): void { private writeEvent(data: Record<string, unknown>): void {
if (!this.filePath) return; if (!this.filePath) return;
@@ -234,10 +244,64 @@ export class SessionRecorder {
...data, ...data,
} as TraceEvent; } as TraceEvent;
const line = JSON.stringify(event);
// 审查修复: buffer 上限防止 OOM — 高频事件持续 flush 失败时避免内存无限增长
const MAX_BUFFER_SIZE = 1000;
if (this.buffer.length >= MAX_BUFFER_SIZE) {
// 超限时强制同步写入,避免内存无限增长
this.flushSync();
}
this.buffer.push(line);
if (!this.flushTimer) {
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
void this.flush();
}, 100);
}
}
/**
* #35 修复: 异步 flush 缓冲区到文件
* 审查修复: 用局部变量保存 filePath,防止 stopRecording 将 filePath 置 null 后 appendFile 抛错;
* 失败时将数据 unshift 回 buffer 避免丢整批数据
*/
private async flush(): Promise<void> {
if (this.flushing || this.buffer.length === 0 || !this.filePath) return;
this.flushing = true;
const filePath = this.filePath; // 局部变量,防止中途变 null
const data = this.buffer.join('\n') + '\n';
this.buffer = [];
try { try {
appendFileSync(this.filePath, JSON.stringify(event) + '\n', 'utf-8'); await promises.appendFile(filePath, data, 'utf-8');
} catch (error) { } catch (error) {
log.error('Trace event write failed:', error); log.error('Trace event flush failed:', error);
// 审查修复: 失败时将数据放回 buffer 头部,下次 flush/flushSync 重试
this.buffer.unshift(data.trimEnd());
} finally {
this.flushing = false;
}
}
/**
* #35 修复: 同步 flush 缓冲区到文件
* 用于 stopRecording 确保最后的数据(如 session_end 事件)写入文件
* 审查修复: 如果异步 flush 正在进行(flushing=true),等待其完成后再写入,避免数据交叉/丢失
*/
private flushSync(): void {
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
if (this.buffer.length === 0 || !this.filePath) return;
const data = this.buffer.join('\n') + '\n';
this.buffer = [];
try {
appendFileSync(this.filePath, data, 'utf-8');
} catch (error) {
log.error('Trace event flushSync failed:', error);
// 审查修复: 失败时将数据放回 buffer,避免数据丢失
this.buffer.unshift(data.trimEnd());
} }
} }
} }
+23 -6
View File
@@ -175,15 +175,26 @@ export class SessionService {
/** /**
* 获取会话消息列表 * 获取会话消息列表
*
* #44 修复: 添加 limit/offset 参数支持分页,避免超长会话一次性加载导致 OOM
* 默认不限制(limit=0),保持向后兼容;调用者可传 limit 限制返回条数
*/ */
getMessages(sessionId: string): MessageInfo[] { getMessages(
sessionId: string,
options: { limit?: number; offset?: number } = {},
): MessageInfo[] {
const db = this.getDBFn(); const db = this.getDBFn();
const { limit = 0, offset = 0 } = options;
const rows = db.prepare(` const rows = (limit > 0
SELECT * FROM messages ? db
WHERE session_id = ? .prepare(
ORDER BY created_at ASC 'SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC LIMIT ? OFFSET ?',
`).all(sessionId) as MessageRow[]; )
.all(sessionId, limit, offset)
: db
.prepare('SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC')
.all(sessionId)) as MessageRow[];
return rows.map((row) => this.toMessageInfo(row)); return rows.map((row) => this.toMessageInfo(row));
} }
@@ -206,6 +217,10 @@ export class SessionService {
const id = `msg_${nanoid(12)}`; const id = `msg_${nanoid(12)}`;
const now = Date.now(); const now = Date.now();
// #34 修复: 包裹事务保证 INSERT messages 和 UPDATE sessions 原子执行
// message_count = message_count + 1 已是原子 SQL 表达式(避免读-改-写竞态)
// 事务进一步保证消息插入和计数更新要么全部成功,要么全部回滚
const saveMessageTxn = db.transaction(() => {
db.prepare(` db.prepare(`
INSERT INTO messages (id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at) INSERT INTO messages (id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -228,6 +243,8 @@ export class SessionService {
SET updated_at = ?, message_count = message_count + 1 SET updated_at = ?, message_count = message_count + 1
WHERE id = ? WHERE id = ?
`).run(now, params.sessionId); `).run(now, params.sessionId);
});
saveMessageTxn();
return { return {
id, id,
@@ -54,6 +54,11 @@ export class WindowManager {
title: options.title ?? 'MetonaAI Desktop', title: options.title ?? 'MetonaAI Desktop',
webPreferences: { webPreferences: {
preload: join(__dirname, '../preload/preload.mjs'), preload: join(__dirname, '../preload/preload.mjs'),
// sandbox: false — preload 使用 ESM 格式(.mjs + import 语法),
// Electron sandbox 不支持 ESM preload(官方 ESM 支持矩阵: Sandboxed = Unsupported)。
// 若启用 sandbox: truepreload.mjs 的 import 语句无法解析,contextBridge 不执行,
// window.metona 为 undefined,所有 IPC 调用静默失败。
// 要启用 sandbox,需先将 preload 改为 CJS 格式 + require 语法(工程改动较大)。
sandbox: false, sandbox: false,
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
+2 -2
View File
@@ -35,7 +35,7 @@ export interface WorkspaceInfo {
const REQUIRED_FILES = ['SOUL.md', 'MEMORY.md'] as const; const REQUIRED_FILES = ['SOUL.md', 'MEMORY.md'] as const;
const AUTO_CREATED_DIRS = ['logs', 'traces', '.metona'] as const; const AUTO_CREATED_DIRS = ['logs', '.metona'] as const;
const MEMORY_TEMPLATE = `# MEMORY.md — AI 持久记忆 const MEMORY_TEMPLATE = `# MEMORY.md — AI 持久记忆
@@ -77,7 +77,7 @@ export class WorkspaceService {
// 创建工作空间目录 // 创建工作空间目录
this.ensureDirectory(this.workspacePath); this.ensureDirectory(this.workspacePath);
// 创建自动目录(logs/, traces/, .metona/ // 创建自动目录(logs/, .metona/
for (const dir of AUTO_CREATED_DIRS) { for (const dir of AUTO_CREATED_DIRS) {
this.ensureDirectory(join(this.workspacePath, dir)); this.ensureDirectory(join(this.workspacePath, dir));
} }
+5 -5
View File
@@ -1,12 +1,12 @@
{ {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.3.14", "version": "0.3.18",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.3.14", "version": "0.3.18",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
@@ -9646,9 +9646,9 @@
} }
}, },
"node_modules/react-is": { "node_modules/react-is": {
"version": "19.2.7", "version": "19.2.8",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.7.tgz", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.8.tgz",
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-markdown": { "node_modules/react-markdown": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.3.14", "version": "0.3.18",
"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",
+21
View File
@@ -61,6 +61,27 @@ export default function App(): React.JSX.Element {
// 无 IPC 可用时也标记为已加载(避免永久禁用) // 无 IPC 可用时也标记为已加载(避免永久禁用)
useAgentStore.getState().setConfigLoaded(true); useAgentStore.getState().setConfigLoaded(true);
} }
// v0.3.18 修复: 监听工具就绪事件(MCP 初始化完成后触发)
// 未就绪前 sendMessage 会被阻止,避免用户在工具不全时发送消息
// v0.3.18 修复: 注册监听器后立即查询一次,解决 tools:ready 事件在监听器注册前发出的竞态
if (window.metona?.tools?.onReady) {
const unsubscribe = window.metona.tools.onReady((data) => {
console.log('[App] Tools ready:', data.toolCount, 'tools');
useAgentStore.getState().setToolsReady(true);
});
// 竞态保护: 事件可能在监听器注册前已发出,主动查询当前状态
window.metona.tools.isReady?.().then((status) => {
if (status.ready) {
console.log('[App] Tools already ready (polled):', status.toolCount, 'tools');
useAgentStore.getState().setToolsReady(true);
}
}).catch((err) => { console.error('[App] tools.isReady query failed:', err); });
return unsubscribe;
} else {
// 无 IPC 可用时直接标记为就绪(避免永久禁用)
useAgentStore.getState().setToolsReady(true);
}
}, []); }, []);
return ( return (
+50 -3
View File
@@ -62,6 +62,8 @@ export function ConfirmationDialog(): React.JSX.Element | null {
const [remainingMs, setRemainingMs] = useState<number>(0); const [remainingMs, setRemainingMs] = useState<number>(0);
// 记录首次接收时的剩余时间,作为进度条总量(固定不变) // 记录首次接收时的剩余时间,作为进度条总量(固定不变)
const [initialMs, setInitialMs] = useState<number>(0); const [initialMs, setInitialMs] = useState<number>(0);
// #40 修复: autoExecute 永久自动执行风险高,勾选时弹出二次确认避免误点击
const [confirmAutoExecute, setConfirmAutoExecute] = useState(false);
// ===== 弹框打开时主动拉取已积压的 pending 请求 ===== // ===== 弹框打开时主动拉取已积压的 pending 请求 =====
// 解决:并行工具触发的多个 IPC 事件可能在本组件 mount 前已到达, // 解决:并行工具触发的多个 IPC 事件可能在本组件 mount 前已到达,
@@ -291,12 +293,16 @@ export function ConfirmationDialog(): React.JSX.Element | null {
: requests[0]?.reason ?? 'Agent 正在请求执行工具'; : requests[0]?.reason ?? 'Agent 正在请求执行工具';
return ( return (
<>
<Dialog <Dialog
open={requests.length > 0} open={requests.length > 0}
onClose={(_, reason) => { onClose={(_, reason) => {
// 超时时禁止通过外部点击/ESC 关闭(与"拒绝全部"按钮 disabled 一致) // 超时时禁止通过外部点击/ESC 关闭(与"拒绝全部"按钮 disabled 一致)
// 防止超时后用户误触关闭,导致后端 pending 状态不一致 // 防止超时后用户误触关闭,导致后端 pending 状态不一致
if (isExpired) return; if (isExpired) return;
// 审查修复: 内层二次确认 Dialog 打开时,外层禁用 ESC/backdrop 关闭
// 防止 ESC 穿透到外层导致意外拒绝所有工具执行
if (confirmAutoExecute) return;
// 只允许"拒绝全部"语义的关闭方式(点击外部 / ESC) // 只允许"拒绝全部"语义的关闭方式(点击外部 / ESC)
// reason: 'backdropClick' | 'escapeKeyDown' | 'closeButtonClick' // reason: 'backdropClick' | 'escapeKeyDown' | 'closeButtonClick'
if (reason === 'backdropClick' || reason === 'escapeKeyDown') { if (reason === 'backdropClick' || reason === 'escapeKeyDown') {
@@ -305,6 +311,7 @@ export function ConfirmationDialog(): React.JSX.Element | null {
}} }}
maxWidth="md" maxWidth="md"
fullWidth fullWidth
disableEscapeKeyDown={confirmAutoExecute}
slotProps={{ slotProps={{
paper: { paper: {
sx: { bgcolor: 'background.paper' }, sx: { bgcolor: 'background.paper' },
@@ -520,9 +527,12 @@ export function ConfirmationDialog(): React.JSX.Element | null {
<Checkbox <Checkbox
checked={autoExecute} checked={autoExecute}
onChange={(e) => { onChange={(e) => {
setAutoExecute(e.target.checked); // #40 修复: autoExecute 永久自动执行风险高,勾选时弹出二次确认避免误点击
// 勾选永久自动执行时,自动勾选会话内记忆(保持一致) if (e.target.checked) {
if (e.target.checked) setRemember(true); setConfirmAutoExecute(true);
} else {
setAutoExecute(false);
}
}} }}
size="small" size="small"
/> />
@@ -576,5 +586,42 @@ export function ConfirmationDialog(): React.JSX.Element | null {
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
{/* #40 修复: autoExecute 二次确认 Dialog — 避免误点击导致永久自动执行 */}
{/* 审查修复: disableEscapeKeyDown + onKeyDown stopPropagation 防止 ESC 事件穿透到外层 Dialog */}
<Dialog
open={confirmAutoExecute}
onClose={() => setConfirmAutoExecute(false)}
maxWidth="xs"
fullWidth
disableEscapeKeyDown
onKeyDown={(e) => e.stopPropagation()}
>
<DialogTitle sx={{ fontSize: 14 }}></DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
</Typography>
<Typography variant="body2" sx={{ mt: 1, color: 'warning.main', fontWeight: 600 }}>
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmAutoExecute(false)} color="inherit"></Button>
<Button
onClick={() => {
setAutoExecute(true);
// 勾选永久自动执行时,自动勾选会话内记忆(保持一致)
setRemember(true);
setConfirmAutoExecute(false);
}}
color="warning"
variant="contained"
>
</Button>
</DialogActions>
</Dialog>
</>
); );
} }
+6 -3
View File
@@ -47,6 +47,8 @@ export function ChatInput(): React.JSX.Element {
const abort = useAgentStore((s) => s.abort); const abort = useAgentStore((s) => s.abort);
const isStreaming = useAgentStore((s) => s.isStreaming); const isStreaming = useAgentStore((s) => s.isStreaming);
const configLoaded = useAgentStore((s) => s.configLoaded); const configLoaded = useAgentStore((s) => s.configLoaded);
// v0.3.18 修复: 工具未就绪时禁用发送按钮
const toolsReady = useAgentStore((s) => s.toolsReady);
const tokenUsage = useAgentStore((s) => s.tokenUsage); const tokenUsage = useAgentStore((s) => s.tokenUsage);
const currentSessionId = useSessionStore((s) => s.currentSessionId); const currentSessionId = useSessionStore((s) => s.currentSessionId);
const provider = useAgentStore((s) => s.provider); const provider = useAgentStore((s) => s.provider);
@@ -173,6 +175,7 @@ export function ChatInput(): React.JSX.Element {
if (!trimmed && attachments.length === 0) return; if (!trimmed && attachments.length === 0) return;
if (isStreaming) return; if (isStreaming) return;
if (!configLoaded) return; // P1-6: 配置未加载完成时禁止发送 if (!configLoaded) return; // P1-6: 配置未加载完成时禁止发送
if (!toolsReady) return; // v0.3.18: 工具未就绪时禁止发送
// 处理 / 命令 // 处理 / 命令
if (trimmed.startsWith('/')) { if (trimmed.startsWith('/')) {
@@ -328,8 +331,8 @@ export function ChatInput(): React.JSX.Element {
onChange={handleChange} onChange={handleChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste} onPaste={handlePaste}
placeholder={configLoaded ? '输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)' : '正在加载配置...'} placeholder={configLoaded ? (toolsReady ? '输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)' : '工具加载中...') : '正在加载配置...'}
disabled={isStreaming || !configLoaded} disabled={isStreaming || !configLoaded || !toolsReady}
multiline multiline
rows={1} rows={1}
sx={{ sx={{
@@ -365,7 +368,7 @@ export function ChatInput(): React.JSX.Element {
<Square size={12} style={{ marginRight: 6 }} /> <Square size={12} style={{ marginRight: 6 }} />
</Button> </Button>
) : ( ) : (
<Button variant="contained" size="small" onClick={handleSend} disabled={(!input.trim() && attachments.length === 0) || !configLoaded} sx={{ height: 28, fontSize: 12, opacity: (input.trim() || attachments.length > 0) && configLoaded ? 1 : 0.5 }}> <Button variant="contained" size="small" onClick={handleSend} disabled={(!input.trim() && attachments.length === 0) || !configLoaded || !toolsReady} sx={{ height: 28, fontSize: 12, opacity: (input.trim() || attachments.length > 0) && configLoaded && toolsReady ? 1 : 0.5 }}>
<Send size={12} style={{ marginRight: 6 }} /> <Send size={12} style={{ marginRight: 6 }} />
</Button> </Button>
)} )}
+6 -54
View File
@@ -9,13 +9,10 @@
*/ */
import { memo } from 'react'; import { memo } from 'react';
import { Box, Typography, Stack } from '@mui/material';
import { Terminal } from 'lucide-react';
import type { ChatMessage } from '@renderer/stores/agent-store'; import type { ChatMessage } from '@renderer/stores/agent-store';
import { UserMessage } from './UserMessage'; import { UserMessage } from './UserMessage';
import { AssistantMessage } from './AssistantMessage'; import { AssistantMessage } from './AssistantMessage';
import { SystemMessage } from './SystemMessage'; import { SystemMessage } from './SystemMessage';
import { formatTime } from '@renderer/lib/formatters';
interface MessageItemProps { interface MessageItemProps {
message: ChatMessage; message: ChatMessage;
@@ -23,7 +20,7 @@ interface MessageItemProps {
isStreaming?: boolean; isStreaming?: boolean;
} }
function MessageItemImpl({ message, isLast, isStreaming }: MessageItemProps): React.JSX.Element { function MessageItemImpl({ message, isLast, isStreaming }: MessageItemProps): React.JSX.Element | null {
switch (message.role) { switch (message.role) {
case 'user': case 'user':
return <UserMessage message={message} />; return <UserMessage message={message} />;
@@ -37,8 +34,11 @@ function MessageItemImpl({ message, isLast, isStreaming }: MessageItemProps): Re
); );
case 'tool': case 'tool':
// 工具消息渲染为独立的结果块 // #38 修复: 与 AgentStore.setCurrentSession 的过滤逻辑保持一致,
return <ToolMessage message={message} />; // 不在 UI 中渲染独立的 tool 消息卡片。
// tool 结果已包含在 assistant 消息的 toolCalls 中(见 AssistantMessage),
// 独立的 tool 消息仅用于 LLM API 上下文,不需要在前端显示为独立卡片。
return null;
case 'system': case 'system':
default: default:
@@ -53,51 +53,3 @@ function MessageItemImpl({ message, isLast, isStreaming }: MessageItemProps): Re
* - isLast boolean * - isLast boolean
*/ */
export const MessageItem = memo(MessageItemImpl); export const MessageItem = memo(MessageItemImpl);
/** ToolMessage — 独立的工具结果消息(role=tool) */
function ToolMessage({ message }: { message: ChatMessage }): React.JSX.Element {
return (
<Box
sx={{
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
borderLeft: '3px solid',
borderLeftColor: '#22d3ee',
bgcolor: 'secondary.main',
px: 1.5,
py: 1,
mb: 2,
animation: 'fadeInUp 300ms ease-out',
}}
>
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
<Terminal size={12} style={{ color: '#22d3ee' }} />
<Typography variant="caption" sx={{ fontWeight: 500, color: 'text.primary' }}>
</Typography>
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled' }}>
{formatTime(message.timestamp)}
</Typography>
</Stack>
<Box
component="pre"
sx={{
fontSize: 11,
borderRadius: 1,
px: 1,
py: 0.75,
overflowX: 'auto',
bgcolor: 'background.default',
color: 'text.secondary',
fontFamily: "'SF Mono',monospace",
maxHeight: 120,
m: 0,
whiteSpace: 'pre-wrap',
}}
>
{message.content.length > 500 ? message.content.slice(0, 500) + '\n... [截断]' : message.content}
</Box>
</Box>
);
}
+13
View File
@@ -26,6 +26,19 @@ export class ErrorBoundary extends Component<Props, State> {
} }
componentDidCatch(error: Error, info: { componentStack: string }): void { componentDidCatch(error: Error, info: { componentStack: string }): void {
// #49 修复: 上报错误到主进程,便于记录到 electron-log 与审计日志,集中排查
// 主进程可注册 ipcMain.on('error:report', ...) 接收;未注册时 send 为静默失败,不影响兜底
try {
window.metona?.app?.reportError({
type: 'react_error_boundary',
error: error.message,
stack: error.stack,
componentStack: info.componentStack,
timestamp: Date.now(),
});
} catch {
// 上报失败不影响兜底逻辑
}
console.error('[ErrorBoundary]', error, info.componentStack); console.error('[ErrorBoundary]', error, info.componentStack);
} }
+93 -2
View File
@@ -2,7 +2,7 @@
* OnboardingWizard 使 * OnboardingWizard 使
*/ */
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Dialog, DialogContent, Button, TextField, Select, MenuItem, Stepper, Step, StepLabel, Box, Typography, Stack, FormControl, InputLabel, IconButton, InputAdornment } from '@mui/material'; import { Dialog, DialogContent, Button, TextField, Select, MenuItem, Stepper, Step, StepLabel, Box, Typography, Stack, FormControl, InputLabel, IconButton, InputAdornment } from '@mui/material';
import { ArrowRight, ArrowLeft, FolderOpen, Play, CheckCircle, Eye, EyeOff } from 'lucide-react'; import { ArrowRight, ArrowLeft, FolderOpen, Play, CheckCircle, Eye, EyeOff } from 'lucide-react';
import { useUIStore } from '@renderer/stores/ui-store'; import { useUIStore } from '@renderer/stores/ui-store';
@@ -10,6 +10,9 @@ import { useAgentStore } from '@renderer/stores/agent-store';
const STEPS = ['欢迎', '配置 LLM', '自定义 Agent', '工作空间', '开始使用']; const STEPS = ['欢迎', '配置 LLM', '自定义 Agent', '工作空间', '开始使用'];
// #48 修复: Onboarding 进度持久化 key
const ONBOARDING_PROGRESS_KEY = 'onboarding_progress';
export function OnboardingWizard(): React.JSX.Element | null { export function OnboardingWizard(): React.JSX.Element | null {
const onboardingCompleted = useUIStore((s) => s.onboardingCompleted); const onboardingCompleted = useUIStore((s) => s.onboardingCompleted);
const setOnboardingCompleted = useUIStore((s) => s.setOnboardingCompleted); const setOnboardingCompleted = useUIStore((s) => s.setOnboardingCompleted);
@@ -20,9 +23,64 @@ export function OnboardingWizard(): React.JSX.Element | null {
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState('');
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [workspacePath, setWorkspacePath] = useState(''); const [workspacePath, setWorkspacePath] = useState('');
// 上下文窗口(联动 Provider:ollama 可空=由模型决定;其他 provider 默认值见 DEFAULT_CTX
const [contextWindow, setContextWindow] = useState<number | null>(null);
// #48 修复: 启动时从 localStorage 恢复未完成的引导进度,避免中途退出后需重新填写
// 注意: apiKey 属于敏感信息,不持久化到 localStorage,恢复时需用户重新输入
useEffect(() => {
try {
const saved = localStorage.getItem(ONBOARDING_PROGRESS_KEY);
if (!saved) return;
const p = JSON.parse(saved) as {
step?: number; provider?: string; baseURL?: string;
model?: string; workspacePath?: string;
contextWindow?: number | null;
};
if (typeof p.step === 'number' && p.step >= 0 && p.step < STEPS.length) setStep(p.step);
if (typeof p.provider === 'string') setProvider(p.provider);
if (typeof p.baseURL === 'string') setBaseURL(p.baseURL);
if (typeof p.model === 'string') setModel(p.model);
if (typeof p.workspacePath === 'string') setWorkspacePath(p.workspacePath);
if (p.contextWindow !== undefined) setContextWindow(p.contextWindow);
// 审查修复: 恢复后如果 provider 需要 apiKey 但 apiKey 为空(未持久化),
// 回退到步骤 1(配置 LLM)让用户重新输入 apiKey,避免配置不完整
// ollama 不需要 apiKey
if (p.provider && p.provider !== 'ollama' && p.step && p.step >= 2) {
setStep(1);
}
} catch {
// 解析失败忽略,使用默认值
}
}, []);
// #48 修复: 每步完成后保存进度到 localStorage,中途退出(关闭窗口/刷新)可恢复
// 注意: 不保存 apiKey(敏感信息不写入 localStorage
useEffect(() => {
try {
localStorage.setItem(ONBOARDING_PROGRESS_KEY, JSON.stringify({
step, provider, baseURL, model, workspacePath, contextWindow,
}));
} catch {
// 写入失败(如隐私模式)忽略
}
}, [step, provider, baseURL, model, workspacePath, contextWindow]);
if (onboardingCompleted) return null; if (onboardingCompleted) return null;
// 各 Provider 上下文窗口默认值(与 SettingsModal 保持一致)
// ollama 返回 null(由模型决定),其他 provider 返回正整数
const DEFAULT_CTX: Record<string, number | null> = {
deepseek: 1_000_000,
agnes: 1_000_000,
mimo: 131_072,
ollama: null,
};
// 上下文窗口校验:ollama 允许空,最小 512;其他 provider 最小 4096
const ctxMin = provider === 'ollama' ? 512 : 4096;
const ctxError =
contextWindow != null && (!Number.isFinite(contextWindow) || contextWindow < ctxMin);
const handleNext = async () => { const handleNext = async () => {
if (step < STEPS.length - 1) { setStep(step + 1); return; } if (step < STEPS.length - 1) { setStep(step + 1); return; }
try { try {
@@ -39,6 +97,14 @@ export function OnboardingWizard(): React.JSX.Element | null {
if (model.trim()) entries.push({ key: 'llm.model', value: model.trim() }); if (model.trim()) entries.push({ key: 'llm.model', value: model.trim() });
if (apiKey.trim()) entries.push({ key: 'llm.apiKey', value: apiKey.trim() }); if (apiKey.trim()) entries.push({ key: 'llm.apiKey', value: apiKey.trim() });
if (workspacePath.trim()) entries.push({ key: 'workspace.path', value: workspacePath.trim() }); if (workspacePath.trim()) entries.push({ key: 'workspace.path', value: workspacePath.trim() });
// 上下文窗口:根据 Provider 落库到对应 key
// - ollama: ollama.numCtx(允许 null=由模型决定)
// - deepseek/agnes/mimo: {provider}.contextWindow(必须有值且 >= 4096
if (provider === 'ollama') {
entries.push({ key: 'ollama.numCtx', value: contextWindow });
} else if (provider && contextWindow != null && contextWindow >= 4096) {
entries.push({ key: `${provider}.contextWindow`, value: contextWindow });
}
entries.push({ key: 'onboarding.completed', value: true }); entries.push({ key: 'onboarding.completed', value: true });
const r = await window.metona.config.setBatch(entries); const r = await window.metona.config.setBatch(entries);
@@ -48,10 +114,16 @@ export function OnboardingWizard(): React.JSX.Element | null {
return; return;
} }
useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || ''); useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || '');
// 同步 contextWindow 到 Agent Store(与 SettingsModal 行为一致)
if (contextWindow != null && contextWindow >= ctxMin) {
useAgentStore.setState({ contextWindow });
}
// P1-6 修复: Onboarding 完成后标记配置已加载 // P1-6 修复: Onboarding 完成后标记配置已加载
useAgentStore.getState().setConfigLoaded(true); useAgentStore.getState().setConfigLoaded(true);
} }
setOnboardingCompleted(true); setOnboardingCompleted(true);
// #48 修复: 引导完成后清理 localStorage 进度,下次启动不再恢复
try { localStorage.removeItem(ONBOARDING_PROGRESS_KEY); } catch { /* ignore */ }
} catch (err) { } catch (err) {
console.error('[OnboardingWizard]', 'Failed to save configuration:', err); console.error('[OnboardingWizard]', 'Failed to save configuration:', err);
// 用户主动操作失败必须有反馈,否则向导不关闭、用户卡死 // 用户主动操作失败必须有反馈,否则向导不关闭、用户卡死
@@ -81,7 +153,12 @@ export function OnboardingWizard(): React.JSX.Element | null {
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}> Provider API Base URL </Typography> <Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}> Provider API Base URL </Typography>
<Stack spacing={2}> <Stack spacing={2}>
<FormControl size="small"><InputLabel>Provider</InputLabel> <FormControl size="small"><InputLabel>Provider</InputLabel>
<Select value={provider} label="Provider" onChange={(e) => setProvider(e.target.value)}> <Select value={provider} label="Provider" onChange={(e) => {
const v = e.target.value;
setProvider(v);
// 联动默认上下文窗口(与 SettingsModal 默认值一致)
setContextWindow(DEFAULT_CTX[v] ?? null);
}}>
<MenuItem value="deepseek">DeepSeek</MenuItem> <MenuItem value="deepseek">DeepSeek</MenuItem>
<MenuItem value="agnes">Agnes AI</MenuItem> <MenuItem value="agnes">Agnes AI</MenuItem>
<MenuItem value="mimo">MiMo ()</MenuItem> <MenuItem value="mimo">MiMo ()</MenuItem>
@@ -93,6 +170,20 @@ export function OnboardingWizard(): React.JSX.Element | null {
<TextField size="small" label="API Key" type={showKey ? 'text' : 'password'} value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-...(本地模型可留空)" <TextField size="small" label="API Key" type={showKey ? 'text' : 'password'} value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-...(本地模型可留空)"
slotProps={{ input: { endAdornment: <InputAdornment position="end"><IconButton size="small" onClick={() => setShowKey(!showKey)}>{showKey ? <EyeOff size={14} /> : <Eye size={14} />}</IconButton></InputAdornment> } }} slotProps={{ input: { endAdornment: <InputAdornment position="end"><IconButton size="small" onClick={() => setShowKey(!showKey)}>{showKey ? <EyeOff size={14} /> : <Eye size={14} />}</IconButton></InputAdornment> } }}
/> />
<TextField
size="small"
label={provider === 'ollama' ? '上下文长度 (num_ctx)' : '上下文窗口 (contextWindow)'}
type="number"
value={contextWindow ?? ''}
onChange={(e) => {
const v = e.target.value;
setContextWindow(v === '' ? null : Number(v));
}}
placeholder={provider === 'ollama' ? '默认由模型决定(如 2048、4096、128000' : '如 64000、128000、1000000'}
slotProps={{ htmlInput: { min: ctxMin, step: ctxMin } }}
error={ctxError}
helperText={ctxError ? `最小值为 ${ctxMin}` : (provider === 'ollama' ? ' ' : '用于上下文压缩判断,不传给 API')}
/>
</Stack> </Stack>
</Box> </Box>
)} )}
+49 -7
View File
@@ -116,6 +116,8 @@ function WorkspaceSettings() {
const [checkResult, setCheckResult] = useState<MetonaWorkspaceCheckResult | null>(null); const [checkResult, setCheckResult] = useState<MetonaWorkspaceCheckResult | null>(null);
const [checking, setChecking] = useState(false); const [checking, setChecking] = useState(false);
const [inheritSoul, setInheritSoul] = useState(true); const [inheritSoul, setInheritSoul] = useState(true);
// 数据库继承:默认勾选(历史会话/消息/记忆/Trace 丢失不可逆,默认带过去更安全)
const [inheritDatabase, setInheritDatabase] = useState(true);
const [applying, setApplying] = useState(false); const [applying, setApplying] = useState(false);
const [showRestartDialog, setShowRestartDialog] = useState(false); const [showRestartDialog, setShowRestartDialog] = useState(false);
@@ -145,9 +147,34 @@ function WorkspaceSettings() {
if (!pendingPath || !checkResult?.valid) return; if (!pendingPath || !checkResult?.valid) return;
setApplying(true); setApplying(true);
try { try {
// 如果勾选了继承文件,从旧工作空间复制到新工作空间v0.3.14: 仅 SOUL.md // 如果勾选了继承文件,从旧工作空间复制到新工作空间
// - SOUL.md: Agent 身份定义(仅目标缺少时才继承,避免覆盖已有定义)
// - .metona/agent.db: 数据库(仅目标不存在时才继承,避免覆盖已有数据)
const filesToInherit: string[] = []; const filesToInherit: string[] = [];
if (inheritSoul) filesToInherit.push('SOUL.md'); if (inheritSoul && checkResult?.missingFiles?.includes('SOUL.md')) filesToInherit.push('SOUL.md');
if (inheritDatabase && !checkResult?.dbExists) filesToInherit.push('.metona/agent.db');
// #51 修复: 继承数据库前校验源数据库完整性,避免继承损坏的数据库导致新工作空间数据丢失
if (inheritDatabase && currentPath) {
try {
const integrityResult = await window.metona?.workspace?.checkDatabaseIntegrity(currentPath);
if (!integrityResult?.success) {
import('metona-toast').then((mod) => mod.default.error(`源数据库校验失败:${integrityResult?.error ?? '未知错误'}`)).catch(() => {});
setApplying(false);
return;
}
if (!integrityResult.ok) {
import('metona-toast').then((mod) => mod.default.error(`源数据库损坏(${integrityResult.detail}),无法继承`)).catch(() => {});
setApplying(false);
return;
}
} catch (err) {
console.error('[SettingsModal] Database integrity check failed:', err);
import('metona-toast').then((mod) => mod.default.error(`源数据库校验异常:${(err as Error).message}`)).catch(() => {});
setApplying(false);
return;
}
}
if (filesToInherit.length > 0 && currentPath) { if (filesToInherit.length > 0 && currentPath) {
try { try {
@@ -250,16 +277,32 @@ function WorkspaceSettings() {
</Alert> </Alert>
)} )}
{/* 继承选项(仅当当前有工作空间且新工作空间需要创建文件时显示) */} {/* 继承选项(当前有工作空间、目标不是同一目录、且目标有可继承的文件时显示) */}
{currentPath && currentPath !== pendingPath && (checkResult.isNewWorkspace || (checkResult.missingFiles && checkResult.missingFiles.length > 0)) && ( {currentPath && currentPath !== pendingPath &&
(checkResult.missingFiles?.includes('SOUL.md') || !checkResult.dbExists) && (
<Stack spacing={0.5} sx={{ mt: 0.5 }}> <Stack spacing={0.5} sx={{ mt: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}> <Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
</Typography> </Typography>
{/* SOUL.md 继承:目标缺少 SOUL.md 时才允许勾选,避免覆盖已有定义 */}
{checkResult.missingFiles && checkResult.missingFiles.includes('SOUL.md') && (
<FormControlLabel <FormControlLabel
control={<Checkbox size="small" checked={inheritSoul} onChange={(e) => setInheritSoul(e.target.checked)} />} control={<Checkbox size="small" checked={inheritSoul} onChange={(e) => setInheritSoul(e.target.checked)} />}
label={<Typography variant="caption">SOUL.md</Typography>} label={<Typography variant="caption">SOUL.md</Typography>}
/> />
)}
{/* 数据库继承:目标 .metona/agent.db 不存在时才显示,避免覆盖已有工作空间数据 */}
{!checkResult.dbExists && (
<>
<FormControlLabel
control={<Checkbox size="small" checked={inheritDatabase} onChange={(e) => setInheritDatabase(e.target.checked)} />}
label={<Typography variant="caption"> agent.db///Trace </Typography>}
/>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10, pl: 3 }}>
SQLite backup API
</Typography>
</>
)}
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10, pl: 3 }}> <Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10, pl: 3 }}>
MEMORY.md MEMORY.md
</Typography> </Typography>
@@ -1288,8 +1331,7 @@ function LogsSettings() {
fullWidth fullWidth
value={logFilePath} value={logFilePath}
placeholder={logPathLoading ? '正在获取路径...' : '路径不可用'} placeholder={logPathLoading ? '正在获取路径...' : '路径不可用'}
readOnly slotProps={{ input: { readOnly: true, sx: { fontSize: 11, fontFamily: 'monospace' } } }}
slotProps={{ input: { sx: { fontSize: 11, fontFamily: 'monospace' } } }}
/> />
<Stack direction="row" spacing={1} sx={{ mt: 1 }}> <Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Button <Button
+17 -6
View File
@@ -17,9 +17,12 @@ export function TokenUsage(): React.JSX.Element {
const contextWindow = useAgentStore((s) => s.contextWindow); const contextWindow = useAgentStore((s) => s.contextWindow);
const maxTokens = contextWindow; const maxTokens = contextWindow;
const usagePercent = tokenUsage.totalTokens > 0 // v0.3.18 修复: 上下文占用百分比改用 lastInputTokens(单次占用),而非累计 totalTokens
? Math.min((tokenUsage.totalTokens / maxTokens) * 100, 100) // 之前 totalTokens 是所有轮次累加值,除以单次窗口得出无意义的百分比
const contextPercent = maxTokens > 0 && tokenUsage.lastInputTokens > 0
? Math.min((tokenUsage.lastInputTokens / maxTokens) * 100, 100)
: 0; : 0;
// 累计消耗的输入/输出占比(用于进度条展示累计消耗的构成)
const inputPercent = tokenUsage.totalTokens > 0 const inputPercent = tokenUsage.totalTokens > 0
? (tokenUsage.inputTokens / tokenUsage.totalTokens) * 100 ? (tokenUsage.inputTokens / tokenUsage.totalTokens) * 100
: 0; : 0;
@@ -50,20 +53,28 @@ export function TokenUsage(): React.JSX.Element {
<Table size="small" sx={{ '& .MuiTableCell-root': { border: 0, py: 0.5, px: 0.5 } }}> <Table size="small" sx={{ '& .MuiTableCell-root': { border: 0, py: 0.5, px: 0.5 } }}>
<TableBody> <TableBody>
<TableRow> <TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11, width: '40%' }}></TableCell> <TableCell sx={{ color: 'text.secondary', fontSize: 11, width: '40%' }}></TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, color: 'text.primary', fontSize: 12, textAlign: 'right' }}> <TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, color: 'text.primary', fontSize: 12, textAlign: 'right' }}>
{tokenUsage.totalTokens > 0 ? formatTokens(tokenUsage.totalTokens) : '-'} {tokenUsage.totalTokens > 0 ? formatTokens(tokenUsage.totalTokens) : '-'}
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}></TableCell> <TableCell sx={{ color: 'text.secondary', fontSize: 11 }}></TableCell>
<TableCell sx={{ <TableCell sx={{
fontFamily: 'monospace', fontWeight: 600, fontSize: 11, textAlign: 'right', fontFamily: 'monospace', fontWeight: 600, fontSize: 11, textAlign: 'right',
color: usagePercent > 80 ? 'error.main' : usagePercent > 60 ? 'warning.main' : 'text.secondary', color: contextPercent > 80 ? 'error.main' : contextPercent > 60 ? 'warning.main' : 'text.secondary',
}}> }}>
{tokenUsage.totalTokens > 0 ? `${usagePercent.toFixed(1)}%` : '-'} {tokenUsage.lastInputTokens > 0 ? `${formatTokens(tokenUsage.lastInputTokens)} (${contextPercent.toFixed(1)}%)` : '-'}
</TableCell> </TableCell>
</TableRow> </TableRow>
{tokenUsage.lastCompressedSaved > 0 && (
<TableRow>
<TableCell sx={{ color: 'success.main', fontSize: 11 }}></TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: 11, color: 'success.main', textAlign: 'right' }}>
{formatTokens(tokenUsage.lastCompressedSaved)}
</TableCell>
</TableRow>
)}
<TableRow> <TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}></TableCell> <TableCell sx={{ color: 'text.secondary', fontSize: 11 }}></TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: 11, color: 'text.secondary', textAlign: 'right' }}> <TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: 11, color: 'text.secondary', textAlign: 'right' }}>
+2 -2
View File
@@ -3,8 +3,8 @@
* *
* *
* - * -
* - 4 SOUL/AGENTS/MEMORY/USERS * - 2 SOUL/MEMORY
* - logs/traces/.metona * - logs/.metona
* *
* Agent thinking/executing idle * Agent thinking/executing idle
*/ */
+39 -1
View File
@@ -104,6 +104,10 @@ export function useAgentStream(): void {
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 };
/** v0.3.18 修复: 上下文压缩事件数据 */
savedTokens?: number;
originalTokens?: number;
compressedTokens?: number;
error?: { code: string; message: string }; error?: { code: string; message: string };
state?: string; state?: string;
}; };
@@ -345,9 +349,13 @@ export function useAgentStream(): void {
if (data.usage) { if (data.usage) {
const cur = getStore().tokenUsage; const cur = getStore().tokenUsage;
getStore().updateTokenUsage({ getStore().updateTokenUsage({
// 累计值:所有轮次累加,只增不减
inputTokens: cur.inputTokens + (data.usage.inputTokens ?? 0), inputTokens: cur.inputTokens + (data.usage.inputTokens ?? 0),
outputTokens: cur.outputTokens + (data.usage.outputTokens ?? 0), outputTokens: cur.outputTokens + (data.usage.outputTokens ?? 0),
totalTokens: cur.totalTokens + (data.usage.totalTokens ?? 0), totalTokens: cur.totalTokens + (data.usage.totalTokens ?? 0),
// v0.3.18 修复: 单次上下文占用 — 替换不累加
// 反映当前对话上下文的真实大小,压缩后会大幅下降
lastInputTokens: data.usage.inputTokens ?? 0,
}); });
// 同步更新当前 Trace 步骤的 token 用量 // 同步更新当前 Trace 步骤的 token 用量
@@ -361,6 +369,12 @@ export function useAgentStream(): void {
} }
break; break;
// v0.3.18 修复: 上下文压缩事件 — 记录节省的 token,供 UI 显示压缩效果
case 'compressed': {
getStore().applyCompression(data.savedTokens ?? 0);
break;
}
// 流结束 // 流结束
case 'done': case 'done':
// F5: 流结束前立即 flush 缓冲区,避免最后一段 delta 丢失 // F5: 流结束前立即 flush 缓冲区,避免最后一段 delta 丢失
@@ -400,10 +414,15 @@ export function useAgentStream(): void {
getStore().setCurrentRunId(null); getStore().setCurrentRunId(null);
getStore().setAgentStatus('error'); getStore().setAgentStatus('error');
getStore().updateLastTraceStep({ completedAt: Date.now() }); getStore().updateLastTraceStep({ completedAt: Date.now() });
// v0.3.17: 对 content_filtered 错误码显示更友好的提示
const errorCode = data.error?.code;
const errorMessage = data.error?.message ?? '未知错误';
getStore().addMessage({ getStore().addMessage({
id: genMsgId('error'), id: genMsgId('error'),
role: 'system', role: 'system',
content: `错误: ${data.error?.message ?? '未知错误'}`, content: errorCode === 'content_filtered'
? `⚠️ ${errorMessage}`
: `错误: ${errorMessage}`,
timestamp: Date.now(), timestamp: Date.now(),
}); });
break; break;
@@ -596,4 +615,23 @@ export function useAgentStream(): void {
return () => unsubscribe(); return () => unsubscribe();
}, []); }, []);
// v0.3.17: 监听配置变更广播,实时更新 store 中的配置字段
// 解决场景:用户在 SettingsModal 修改 agent.maxIterations 后,详情栏分母不刷新
useEffect(() => {
if (!window.metona?.config?.onChanged) return;
const unsubscribe = window.metona.config.onChanged((data: { key: string; value: unknown }) => {
const store = useAgentStore.getState();
const { key, value } = data;
// 按需更新 store 中缓存的配置字段
if (key === 'agent.maxIterations' && typeof value === 'number') {
store.setMaxIterations(value);
}
// 其他 agent.* 配置项若 store 有对应字段,可在此扩展
});
return () => unsubscribe();
}, []);
} }
+16
View File
@@ -36,6 +36,16 @@ function matchModifier(event: KeyboardEvent, ctrl?: boolean, shift?: boolean): b
return true; return true;
} }
/**
* #39 修复: 检测键盘事件目标是否为输入框input/textarea/contentEditable
*
*/
function isInputTarget(e: KeyboardEvent): boolean {
const target = e.target as HTMLElement | null;
if (!target) return false;
return ['INPUT', 'TEXTAREA'].includes(target.tagName) || target.isContentEditable;
}
/** /**
* Hook * Hook
* *
@@ -46,6 +56,12 @@ export function useKeyboardShortcuts(): void {
const handleKeyDown = (e: KeyboardEvent): void => { const handleKeyDown = (e: KeyboardEvent): void => {
const key = e.key.toLowerCase(); const key = e.key.toLowerCase();
// #39 修复: 输入框聚焦时,单字符无修饰键直接放行,避免拦截用户输入
// Ctrl/Cmd 组合键不受影响(如 Ctrl+N/L/D 等仍可触发)
if (isInputTarget(e) && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
return;
}
// Esc — 关闭弹窗 // Esc — 关闭弹窗
if (key === 'escape') { if (key === 'escape') {
const { settingsOpen, closeSettings } = useUIStore.getState(); const { settingsOpen, closeSettings } = useUIStore.getState();
+59 -5
View File
@@ -61,9 +61,24 @@ export type AgentStatus = 'idle' | 'thinking' | 'executing' | 'error';
// ===== Token 统计 ===== // ===== Token 统计 =====
export interface TokenUsage { export interface TokenUsage {
/** 累计输入 token(所有轮次累加) */
inputTokens: number; inputTokens: number;
/** 累计输出 token(所有轮次累加) */
outputTokens: number; outputTokens: number;
/** 累计总 token(所有轮次累加) */
totalTokens: number; totalTokens: number;
/**
* v0.3.18 修复: 最近一次 LLM token
* "上下文占用百分比"
* LLM inputTokens
*
*/
lastInputTokens: number;
/**
* v0.3.18 修复: 最近一次上下文压缩节省的 token
* UI "压缩节省了 X tokens"
*/
lastCompressedSaved: number;
} }
// ===== Trace 步骤 ===== // ===== Trace 步骤 =====
@@ -107,6 +122,10 @@ interface AgentState {
// P1-6 修复: 配置加载完成标志 — 加载完成前禁用发送按钮 // P1-6 修复: 配置加载完成标志 — 加载完成前禁用发送按钮
configLoaded: boolean; configLoaded: boolean;
// v0.3.18 修复: 工具就绪标志 — MCP 初始化完成前禁用发送按钮
// 避免用户在工具不全时发送消息,导致子任务委派等 MCP 工具不可用
toolsReady: boolean;
// Run 标识(用于过滤旧流事件) // Run 标识(用于过滤旧流事件)
currentRunId: string | null; currentRunId: string | null;
@@ -124,12 +143,21 @@ interface AgentState {
updateLastAssistantMessage: (delta: string) => void; updateLastAssistantMessage: (delta: string) => void;
setAgentStatus: (status: AgentStatus) => void; setAgentStatus: (status: AgentStatus) => void;
setStreaming: (streaming: boolean) => void; setStreaming: (streaming: boolean) => void;
// P1-6 修复: 配置加载完成标志的 setter
setConfigLoaded: (loaded: boolean) => void;
// v0.3.18 修复: 工具就绪标志的 setter
setToolsReady: (ready: boolean) => void;
setCurrentRunId: (runId: string | null) => void; setCurrentRunId: (runId: string | null) => void;
addTraceStep: (step: TraceStep) => void; addTraceStep: (step: TraceStep) => void;
updateTraceStep: (iteration: number, updates: Partial<TraceStep>) => void; updateTraceStep: (iteration: number, updates: Partial<TraceStep>) => void;
updateTraceStepById: (id: string, updates: Partial<TraceStep>) => void; updateTraceStepById: (id: string, updates: Partial<TraceStep>) => void;
updateLastTraceStep: (updates: Partial<TraceStep>) => void; updateLastTraceStep: (updates: Partial<TraceStep>) => void;
updateTokenUsage: (usage: Partial<TokenUsage>) => void; updateTokenUsage: (usage: Partial<TokenUsage>) => void;
/**
* v0.3.18 修复: 应用上下文压缩事件
* token UI
*/
applyCompression: (savedTokens: number) => void;
setProvider: (provider: string, model: string) => void; setProvider: (provider: string, model: string) => void;
setMaxIterations: (max: number) => void; setMaxIterations: (max: number) => void;
setCurrentIteration: (n: number) => void; setCurrentIteration: (n: number) => void;
@@ -145,10 +173,11 @@ export const useAgentStore = create<AgentState>((set, get) => ({
agentStatus: 'idle', agentStatus: 'idle',
currentIteration: 0, currentIteration: 0,
maxIterations: 20, maxIterations: 20,
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 },
traceSteps: [], traceSteps: [],
isStreaming: false, isStreaming: false,
configLoaded: false, configLoaded: false,
toolsReady: false,
currentRunId: null, currentRunId: null,
provider: '', provider: '',
model: '', model: '',
@@ -157,7 +186,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
// ===== Actions ===== // ===== Actions =====
setCurrentSession: (id) => { setCurrentSession: (id) => {
set({ currentSessionId: id, messages: [], traceSteps: [], currentIteration: 0, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, agentStatus: 'idle', isStreaming: false, currentRunId: null }); set({ currentSessionId: id, messages: [], traceSteps: [], currentIteration: 0, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 }, agentStatus: 'idle', isStreaming: false, currentRunId: null });
// 从数据库加载该会话的消息 // 从数据库加载该会话的消息
if (id && window.metona?.sessions?.getMessages) { if (id && window.metona?.sessions?.getMessages) {
@@ -204,7 +233,19 @@ export const useAgentStore = create<AgentState>((set, get) => ({
})); }));
set({ traceSteps: steps }); set({ traceSteps: steps });
} }
if (data.tokenUsage) set({ tokenUsage: data.tokenUsage as TokenUsage }); // v0.3.18 修复: 兼容旧数据 — 旧 tokenUsage 没有 lastInputTokens/lastCompressedSaved 字段
if (data.tokenUsage) {
const old = data.tokenUsage as Partial<TokenUsage>;
set({
tokenUsage: {
inputTokens: old.inputTokens ?? 0,
outputTokens: old.outputTokens ?? 0,
totalTokens: old.totalTokens ?? 0,
lastInputTokens: old.lastInputTokens ?? 0,
lastCompressedSaved: old.lastCompressedSaved ?? 0,
},
});
}
} }
}).catch((err) => { console.error('[AgentStore]', err); }); }).catch((err) => { console.error('[AgentStore]', err); });
} }
@@ -225,6 +266,12 @@ export const useAgentStore = create<AgentState>((set, get) => ({
sendMessage: async (content: string, images?: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }>, attachments?: AttachmentInfo[]) => { sendMessage: async (content: string, images?: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }>, attachments?: AttachmentInfo[]) => {
let sessionId = get().currentSessionId; let sessionId = get().currentSessionId;
// v0.3.18 修复: 工具未就绪时阻止发送,避免 MCP 工具不可用
if (!get().toolsReady) {
import('metona-toast').then((mod) => mod.default.warning('工具正在加载中,请稍候...')).catch(() => {});
return;
}
// 没有当前会话时自动创建 // 没有当前会话时自动创建
if (!sessionId && window.metona?.sessions?.create) { if (!sessionId && window.metona?.sessions?.create) {
try { try {
@@ -270,7 +317,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
// 方案 A: 不清空 traceSteps,避免前一条消息的 trace 被永久覆盖 // 方案 A: 不清空 traceSteps,避免前一条消息的 trace 被永久覆盖
// TraceViewer 按 runId 过滤显示,只展示当前 run 的 steps // TraceViewer 按 runId 过滤显示,只展示当前 run 的 steps
// 历史 trace 仍在 DB 中,切换会话回来可恢复 // 历史 trace 仍在 DB 中,切换会话回来可恢复
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 },
})); }));
// 自动更新会话标题为用户第一条消息 // 自动更新会话标题为用户第一条消息
@@ -360,6 +407,8 @@ export const useAgentStore = create<AgentState>((set, get) => ({
setStreaming: (streaming) => set({ isStreaming: streaming }), setStreaming: (streaming) => set({ isStreaming: streaming }),
setConfigLoaded: (loaded) => set({ configLoaded: loaded }), setConfigLoaded: (loaded) => set({ configLoaded: loaded }),
// v0.3.18 修复: 工具就绪标志 setter
setToolsReady: (ready) => set({ toolsReady: ready }),
setCurrentRunId: (runId) => set({ currentRunId: runId }), setCurrentRunId: (runId) => set({ currentRunId: runId }),
@@ -392,6 +441,11 @@ export const useAgentStore = create<AgentState>((set, get) => ({
updateTokenUsage: (usage) => updateTokenUsage: (usage) =>
set((s) => ({ tokenUsage: { ...s.tokenUsage, ...usage } })), set((s) => ({ tokenUsage: { ...s.tokenUsage, ...usage } })),
// v0.3.18 修复: 应用上下文压缩事件,记录节省的 token 数
// 压缩后下一轮 LLM 调用的 inputTokens 会大幅下降,lastInputTokens 会自动反映
applyCompression: (savedTokens) =>
set((s) => ({ tokenUsage: { ...s.tokenUsage, lastCompressedSaved: savedTokens } })),
setProvider: (provider, model) => { setProvider: (provider, model) => {
// L-16 修复: 使用命名常量替代魔法数字 // L-16 修复: 使用命名常量替代魔法数字
// v0.3.1: DeepSeek/Agnes 不再固定 1M,从配置读取 // v0.3.1: DeepSeek/Agnes 不再固定 1M,从配置读取
@@ -428,7 +482,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
messages: [], messages: [],
agentStatus: 'idle', agentStatus: 'idle',
currentIteration: 0, currentIteration: 0,
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 },
traceSteps: [], traceSteps: [],
isStreaming: false, isStreaming: false,
currentRunId: null, currentRunId: null,
+26 -10
View File
@@ -85,8 +85,8 @@ interface MetonaSessionInfo {
interface MetonaSessionsAPI { interface MetonaSessionsAPI {
list: () => Promise<MetonaSessionInfo[]>; list: () => Promise<MetonaSessionInfo[]>;
create: (title?: string) => Promise<MetonaSessionInfo>; create: (title?: string) => Promise<MetonaSessionInfo>;
rename: (sessionId: string, title: string) => Promise<{ success: boolean }>; rename: (sessionId: string, title: string) => Promise<{ success: boolean; error?: string }>;
delete: (sessionId: string) => Promise<{ success: boolean }>; delete: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
getMessages: (sessionId: string) => Promise<Array<{ getMessages: (sessionId: string) => Promise<Array<{
id: string; id: string;
role: string; role: string;
@@ -98,8 +98,8 @@ interface MetonaSessionsAPI {
iteration?: number; iteration?: number;
timestamp: number; timestamp: number;
}>>; }>>;
pin: (sessionId: string, pinned: boolean) => Promise<{ success: boolean }>; pin: (sessionId: string, pinned: boolean) => Promise<{ success: boolean; error?: string }>;
archive: (sessionId: string, archived: boolean) => Promise<{ success: boolean }>; archive: (sessionId: string, archived: boolean) => Promise<{ success: boolean; error?: string }>;
deleteMessage: (messageId: string) => Promise<{ success: boolean }>; deleteMessage: (messageId: string) => Promise<{ success: boolean }>;
clearMessages: (sessionId: string) => Promise<{ success: boolean }>; clearMessages: (sessionId: string) => Promise<{ success: boolean }>;
saveTrace: (sessionId: string, data: { traceSteps: unknown[]; tokenUsage: unknown }) => Promise<{ success: boolean }>; saveTrace: (sessionId: string, data: { traceSteps: unknown[]; tokenUsage: unknown }) => Promise<{ success: boolean }>;
@@ -126,9 +126,9 @@ interface MetonaMCPServerStatus {
interface MetonaMCPAPI { interface MetonaMCPAPI {
listServers: () => Promise<MetonaMCPServerStatus[]>; listServers: () => Promise<MetonaMCPServerStatus[]>;
addServer: (config: MetonaMCPServerConfig) => Promise<{ success: boolean }>; addServer: (config: MetonaMCPServerConfig) => Promise<{ success: boolean; error?: string }>;
removeServer: (name: string) => Promise<{ success: boolean }>; removeServer: (name: string) => Promise<{ success: boolean; error?: string }>;
toggleServer: (name: string, enabled: boolean) => Promise<{ success: boolean }>; toggleServer: (name: string, enabled: boolean) => Promise<{ success: boolean; error?: string }>;
} }
// ===== Memory API ===== // ===== Memory API =====
@@ -161,9 +161,11 @@ interface MetonaMemoryAPI {
interface MetonaConfigAPI { interface MetonaConfigAPI {
get: (key: string) => Promise<unknown>; get: (key: string) => Promise<unknown>;
set: (key: string, value: unknown) => Promise<{ success: boolean }>; set: (key: string, value: unknown) => Promise<{ success: boolean; error?: string }>;
// v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败 // v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败
setBatch: (entries: Array<{ key: string; value: unknown }>) => Promise<{ success: boolean }>; setBatch: (entries: Array<{ key: string; value: unknown }>) => Promise<{ success: boolean; error?: string }>;
// v0.3.17: 监听配置变更广播(后端 config:set/setBatch 后触发,用于前端 store 实时更新)
onChanged: (callback: (data: { key: string; value: unknown }) => void) => () => void;
} }
// ===== App API ===== // ===== App API =====
@@ -175,6 +177,7 @@ interface MetonaAppAPI {
showItemInFolder: (path: string) => Promise<{ success: boolean; error?: string }>; showItemInFolder: (path: string) => Promise<{ success: boolean; error?: string }>;
selectFolder: (defaultPath?: string) => Promise<{ canceled: boolean; path: string }>; selectFolder: (defaultPath?: string) => Promise<{ canceled: boolean; path: string }>;
restart: () => Promise<{ success: boolean }>; restart: () => Promise<{ success: boolean }>;
reportError: (payload: unknown) => void;
} }
// ===== Workspace API ===== // ===== Workspace API =====
@@ -185,6 +188,8 @@ interface MetonaWorkspaceCheckResult {
exists?: boolean; exists?: boolean;
missingFiles?: string[]; missingFiles?: string[];
isNewWorkspace?: boolean; isNewWorkspace?: boolean;
/** 目标目录下 .metona/agent.db 是否存在(用于判断是否显示"继承数据库"选项) */
dbExists?: boolean;
reason?: string; reason?: string;
} }
@@ -222,6 +227,13 @@ interface MetonaWorkspaceAPI {
inheritFiles: (params: { targetPath: string; sourcePath: string; files: string[] }) => inheritFiles: (params: { targetPath: string; sourcePath: string; files: string[] }) =>
Promise<MetonaWorkspaceInheritResult>; Promise<MetonaWorkspaceInheritResult>;
getInfo: () => Promise<MetonaWorkspaceInfo>; getInfo: () => Promise<MetonaWorkspaceInfo>;
// #51 修复: 校验源数据库完整性(PRAGMA integrity_check
checkDatabaseIntegrity: (sourcePath: string) => Promise<{
success: boolean;
ok?: boolean;
detail?: string;
error?: string;
}>;
} }
// ===== Toast API ===== // ===== Toast API =====
@@ -247,7 +259,11 @@ interface MetonaToolInfo {
interface MetonaToolsAPI { interface MetonaToolsAPI {
list: () => Promise<MetonaToolInfo[]>; list: () => Promise<MetonaToolInfo[]>;
toggle: (toolName: string, enabled: boolean) => Promise<{ success: boolean }>; toggle: (toolName: string, enabled: boolean) => Promise<{ success: boolean; error?: string }>;
/** v0.3.18 修复: 监听工具就绪事件(MCP 初始化完成后触发) */
onReady: (callback: (data: { toolCount: number }) => void) => () => void;
/** v0.3.18 修复: 查询工具当前是否已就绪(解决事件竞态) */
isReady: () => Promise<{ ready: boolean; toolCount: number }>;
} }
// ===== SearXNG API ===== // ===== SearXNG API =====