feat: v0.8.0 流语义补全 · 会话可靠 · 恢复力 — finish_reason 全链路贯通根治"思考中停止" · 2445 用例全量回归
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m16s
CI / 全量测试 (Electron ABI) (push) Failing after 6m4s
CI / 产物编译验证 (push) Successful in 11m1s

P0 会话可靠性收口(根治"模型思考着会话就停止"):
- P0-1 finish_reason 全链路贯通:DONE 事件与 IterationStep 新增 finishReason,OpenAI 共享 SSE / Anthropic message_delta.stop_reason / Ollama done_reason 三路采集,TRACE 层弃用硬编码 'stop' 记录真值
- P0-2 空响应守卫 + 降级重试:零产出流→可重试错误走退避;思考耗尽输出预算(reasoning-only + length)→自动关闭思考降级重试一次;仍失败→OUTPUT_LENGTH_EXCEEDED 结构化错误 + 故障转移;附带根治 abort 恰逢零工具调用轮被 COMPLETED 抢占的真实缺陷
- P0-3 思考×能力×预算三对齐:DeepSeek/MiMo/Agnes/Ollama 四家 supportsThinking=false 强制不发思考参数;小输出预算告警;设置页联动提示
- P0-4 渲染层可见性:截断/空完成/友好错误三类提示,i18n 全部出层
- P0-5 回归四件套:reasoning-only 终止判定、集成级空闲超时、504 引擎重试归类、思考中 abort→USER_INTERRUPT、P4-2 强制收尾路径

FEAT-1:LLM 设置新增「最大输出上限」——Provider 支持矩阵显隐 + 模型上限钳制提示 + 超限保存警告 + llm.maxTokens 热生效

P1 修复面收口:
- 渲染层三缺陷根治:后台会话回放缓冲(2000 条/4MB 有界 + agent:getReplayState + 事件总线)+ abort 双层自愈 + sendMessage 收尾兜底 + 中断卡片清扫
- 工具 abort 信号全覆盖:web_search/web_fetch/http_request/code_search/git 系列/delegate_task 全部接入引擎中断;web_search 时间预算收敛(720s→≤240s);移除伪造 ToolExecutionContext 与死代码
- 安全:本地 Pinned CONNECT 代理根治浏览器通道 DNS rebinding(校验期 IP pinning,可注入 resolver 表测);配置 URL 域名解析深校验(DeepCheckSoftFailure 软失败);SSE 空 error 帧防御修复;Ollama generate/embed AbortSignal.any 合并
- 缺陷清单:UTF-16 BOM 读取、tmp 同毫秒碰撞(nanoid 后缀)、code_search JS 回退参数对称(case_sensitive/前后文独立)、list_directory include_node_modules、崩溃自愈退避(60s 窗 ≥3 次停 reload)、MemoryViewer/Sidebar i18n 收口

P2 能力演进:
- 会话回收站:SCHEMA_VERSION 3 + 迁移 10(deleted_at,存在性守卫),软删除/恢复/彻底删除/30 天自动清理(启动+24h),searchMessages 聚合剔除,Sidebar 回收站面板
- 会话回放播放器:sessions:listRecordings/readRecording(白名单+目录边界+20MB 上限),SessionReplayPlayer 时间轴/步进/变速,Trace 面板入口
- electron-updater 自动更新:双轨(手动 feed 比对保留),生产环境启动静默检查 + update:status 广播 + app:updateInstall + LogsSettings UpdatePanel + builder publish 配置
- @ 文件提及:workspace.listFiles/readFileClip(边界/512KB/NUL 拒绝/MEMORY.md 保护),ChatInput Fuse 联想+键盘导航+附件管线注入
- MCP Resources/Prompts 发现:可选能力 try/catch 降级,mcp:listServerContents,MCPSettings 展开视图
- 文档对齐:内部 API 标准 HTML(Adapter 清单补 MiMo/已实现注记/STREAM_RESET/DONE.finishReason/ repetition_truncation 映射);README v0.8.0 亮点表

P3 测试基建:
- 新增 4 个测试文件:engine-stream-contract(6)、engine-stream-reliability(4:集成空闲超时/504 重试/思考中 abort/P4-2 强制收尾)、thinking-capability-gate(7)、pinned-proxy(9,含深校验 5)、session-trash(5,DB 域)、use-agent-stream hook 级(5)、agent.test 回放缓冲(2)
- 契约更新:orchestrator 被中断 SubAgent success=false(abort 优先级修复语义)、SSE 空 error 帧、UTF-16 正常读取、DeepSeek 未配置思考显式 disabled、迁移矩阵 v2→3
- 弱断言根治:registry WEBP 单向断言、hooks-contracts 自比恒真、memory 空 token 补强

全量验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2144 通过(301 DB 用例按 ABI 跳过)/ Electron ABI 2445/2445 全量通过 0 跳过
This commit is contained in:
2026-09-05 20:06:26 +08:00
parent 99d0c54129
commit 5b9d4d19b3
78 changed files with 5128 additions and 411 deletions
+18 -1
View File
@@ -10,7 +10,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/version-0.7.4-blue?style=flat-square" alt="Version" /> <img src="https://img.shields.io/badge/version-0.8.0-blue?style=flat-square" alt="Version" />
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License" /> <img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License" />
<img src="https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron" alt="Electron" /> <img src="https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron" alt="Electron" />
<img src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react" alt="React" /> <img src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react" alt="React" />
@@ -54,6 +54,23 @@
--- ---
## 🆕 v0.8.0 更新亮点
| 特性 | 说明 |
|:---|:---|
| 🔒 **流结束契约(根治"思考中会话停止")** | finish_reason 全链路贯通:`length` 截断不再被静默判定为完成;零产出流自动重试;思考耗尽输出预算时自动降级(关闭思考)重试一次,仍失败则给出 OUTPUT_LENGTH_EXCEEDED 结构化错误与修复建议 |
| 🧠 **思考参数 × 模型能力门控** | `supportsThinking:false` 的模型(如 deepseek-v4-flash-vision-exp)不再发送思考参数,防止思考烧光输出预算;设置页同步提示 |
| 🪟 **后台会话回放缓冲** | 切走会话后其运行内容不再丢失,切回时自动恢复;abort 失败自愈、收尾兜底、中断卡片清扫三重防线 |
| 🗑️ **会话回收站** | 删除改为软删除,30 天自动清理;支持恢复与彻底删除 |
| 🎬 **会话回放播放器** | TRACE JSONL 录制可视化回放(时间轴 / 步进 / 变速) |
| 🛡️ **浏览器通道 SSRF 根治** | Agent 浏览器分区流量经本地 Pinned CONNECT 代理(校验期 IP pinning),关闭 DNS rebinding 残余窗口;配置类 URL 增加域名解析深校验 |
| 📦 **electron-updater 自动更新** | 生产环境启动静默检查、可用即通知,一键下载安装 |
| ✏️ **@ 文件提及** | 输入 `@` 联想工作空间文件,发送时以附件管线注入文本片段(512KB 上限 + 二进制拒绝) |
| 🔍 **MCP Resources/Prompts 发现** | 连接后自动发现并列出 Server 的资源与提示词(可选能力,失败静默降级) |
| ⚡ **工具中断全覆盖** | web_search / web_fetch / http_request / code_search / git 系列 / delegate_task 全部接入引擎 abort 信号;web_search 自动抓取增加时间预算收敛 |
---
## ✨ 核心亮点 ## ✨ 核心亮点
<table> <table>
@@ -639,8 +639,8 @@
<span class="hl-prop">REASONING_DELTA</span> = <span class="hl-str">'reasoning_delta'</span>, <span class="hl-cm">// 推理内容增量</span> <span class="hl-prop">REASONING_DELTA</span> = <span class="hl-str">'reasoning_delta'</span>, <span class="hl-cm">// 推理内容增量</span>
<span class="hl-prop">TOOL_CALL_DELTA</span> = <span class="hl-str">'tool_call_delta'</span>, <span class="hl-cm">// 工具调用增量</span> <span class="hl-prop">TOOL_CALL_DELTA</span> = <span class="hl-str">'tool_call_delta'</span>, <span class="hl-cm">// 工具调用增量</span>
<span class="hl-prop">TOOL_CALL_COMPLETE</span> = <span class="hl-str">'tool_call_complete'</span>, <span class="hl-prop">TOOL_CALL_COMPLETE</span> = <span class="hl-str">'tool_call_complete'</span>,
<span class="hl-prop">THINKING_START</span> = <span class="hl-str">'thinking_start'</span>, <span class="hl-cm">// 思考开始</span> <span class="hl-cm">// v0.7.2 注:THINKING_START/THINKING_END 已移除(全链路零发送方零消费者)</span>
<span class="hl-prop">THINKING_END</span> = <span class="hl-str">'thinking_end'</span>, <span class="hl-cm">// 思考结束</span> <span class="hl-prop">STREAM_RESET</span> = <span class="hl-str">'stream_reset'</span>, <span class="hl-cm">// v0.7.4: 重试/故障转移后清空前端增量缓冲</span>
<span class="hl-prop">ERROR</span> = <span class="hl-str">'error'</span>, <span class="hl-cm">// 流中错误</span> <span class="hl-prop">ERROR</span> = <span class="hl-str">'error'</span>, <span class="hl-cm">// 流中错误</span>
<span class="hl-prop">DONE</span> = <span class="hl-str">'done'</span>, <span class="hl-cm">// 流结束</span> <span class="hl-prop">DONE</span> = <span class="hl-str">'done'</span>, <span class="hl-cm">// 流结束</span>
<span class="hl-prop">USAGE</span> = <span class="hl-str">'usage'</span>, <span class="hl-cm">// Token 统计(通常在 DONE 前)</span> <span class="hl-prop">USAGE</span> = <span class="hl-str">'usage'</span>, <span class="hl-cm">// Token 统计(通常在 DONE 前)</span>
@@ -663,6 +663,9 @@
}; <span class="hl-cm">// TOOL_CALL_DELTA</span> }; <span class="hl-cm">// TOOL_CALL_DELTA</span>
<span class="hl-prop">toolCall</span>?: <span class="hl-type">MetonaToolCall</span>; <span class="hl-cm">// TOOL_CALL_COMPLETE(拼接完成后的完整调用)</span> <span class="hl-prop">toolCall</span>?: <span class="hl-type">MetonaToolCall</span>; <span class="hl-cm">// TOOL_CALL_COMPLETE(拼接完成后的完整调用)</span>
<span class="hl-prop">usage</span>?: <span class="hl-type">MetonaTokenUsage</span>; <span class="hl-cm">// USAGE</span> <span class="hl-prop">usage</span>?: <span class="hl-type">MetonaTokenUsage</span>; <span class="hl-cm">// USAGE</span>
<span class="hl-cm">// v0.8.0: DONE 事件新增 finishReason(归一化 OpenAI 语义:stop/length/</span>
<span class="hl-cm">// tool_calls/content_filterMiMo repetition_truncation→stop)。引擎据此</span>
<span class="hl-cm">// 区分自然完成与输出上限截断(空响应守卫 + 降级重试)。</span>
<span class="hl-prop">error</span>?: <span class="hl-type">MetonaError</span>; <span class="hl-cm">// ERROR</span> <span class="hl-prop">error</span>?: <span class="hl-type">MetonaError</span>; <span class="hl-cm">// ERROR</span>
}</pre> }</pre>
</div> </div>
@@ -1000,16 +1003,22 @@
<td>Ollama 原生 JSON / NDJSON</td> <td>Ollama 原生 JSON / NDJSON</td>
</tr> </tr>
<tr> <tr>
<td class="field-name">AnthropicAdapter未来计划,未实现)</td> <td class="field-name">AnthropicAdapterv0.8.0 注:已实现)</td>
<td class="field-type">anthropic</td> <td class="field-type">anthropic</td>
<td>https://api.anthropic.com/v1/messages</td> <td>https://api.anthropic.com/v1/messages</td>
<td>Anthropic 原生 JSON</td> <td>Anthropic 原生 JSONmessage_start/content_block_delta/message_delta 事件机)</td>
</tr> </tr>
<tr> <tr>
<td class="field-name">OpenAIAdapter未来计划,未实现)</td> <td class="field-name">OpenAIAdapterv0.8.0 注:已实现)</td>
<td class="field-type">openai</td> <td class="field-type">openai</td>
<td>https://api.openai.com/v1/chat/completions</td> <td>https://api.openai.com/v1/chat/completions</td>
<td>OpenAI 原生 JSON</td> <td>OpenAI 原生 JSONo 系列走 max_completion_tokens + reasoning_effort</td>
</tr>
<tr>
<td class="field-name">MimoAdapterv0.8.0 注:文档此前遗漏,已实现)</td>
<td class="field-type">mimo</td>
<td>https://api.xiaomimimo.com/v1/chat/completions</td>
<td>OpenAI 兼容 JSONmax_completion_tokens 含推理 tokenfinish_reason 含 repetition_truncation</td>
</tr> </tr>
</table> </table>
@@ -1051,14 +1060,17 @@
</div> </div>
<h3>MVP 优先级</h3> <h3>MVP 优先级</h3>
<p class="desc">当前优先实现以下 3 个 AdapterAnthropic 和 OpenAI 为未来计划:</p> <p class="desc">v0.8.0 注:六家 AdapterDeepSeek / Agnes / MiMo / Ollama / OpenAI / Anthropic)均已实现。
流式 DONE 事件自 v0.8.0 起携带归一化 finishReasonstop / length / tool_calls / content_filter),
MiMo 的 repetition_truncation 映射为 stop;引擎以 finishReason=length 驱动空响应守卫与降级重试。</p>
<table class="spec"> <table class="spec">
<tr><th>优先级</th><th>Adapter</th><th>状态</th></tr> <tr><th>优先级</th><th>Adapter</th><th>状态</th></tr>
<tr><td class="field-name">P0</td><td>DeepSeekAdapter</td><td class="field-type">MVP 必须实现</td></tr> <tr><td class="field-name">P0</td><td>DeepSeekAdapter</td><td class="field-type">实现</td></tr>
<tr><td class="field-name">P0</td><td>AgnesAdapter</td><td class="field-type">MVP 必须实现</td></tr> <tr><td class="field-name">P0</td><td>AgnesAdapter</td><td class="field-type">实现</td></tr>
<tr><td class="field-name">P1</td><td>OllamaAdapter</td><td class="field-type">MVP 必须实现(本地推理)</td></tr> <tr><td class="field-name">P1</td><td>OllamaAdapter</td><td class="field-type">实现(本地推理)</td></tr>
<tr><td class="field-name">P2</td><td>AnthropicAdapter</td><td class="field-type">未来计划</td></tr> <tr><td class="field-name">P2</td><td>AnthropicAdapter</td><td class="field-type">已实现</td></tr>
<tr><td class="field-name">P2</td><td>OpenAIAdapter</td><td class="field-type">未来计划</td></tr> <tr><td class="field-name">P2</td><td>OpenAIAdapter</td><td class="field-type">已实现</td></tr>
<tr><td class="field-name">P2</td><td>MimoAdapter</td><td class="field-type">已实现</td></tr>
</table> </table>
<h3>Provider 故障转移策略</h3> <h3>Provider 故障转移策略</h3>
+4
View File
@@ -43,5 +43,9 @@
"allowToChangeInstallationDirectory": true, "allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true, "createDesktopShortcut": true,
"createStartMenuShortcut": true "createStartMenuShortcut": true
},
"publish": {
"provider": "generic",
"url": "https://git.metona.cn/MetonaTeam/metona-updates/latest/"
} }
} }
@@ -238,8 +238,10 @@ describe('DeepSeek vision 模型多模态请求格式(v0.5.4', () => {
} as MetonaRequest); } as MetonaRequest);
const body = requestBody(); const body = requestBody();
// vision 模型 supportsThinking=false,但参数仍透传 thinking enabled(引擎决定) // v0.8.0 P0-3 能力门控: vision 模型 supportsThinking=false → 显式 disabled
expect(body.thinking).toEqual({ type: 'enabled' }); // 且不发送 reasoning_effort(思考会耗尽该模型 8192 输出预算 —— 生产事故根因)
expect(body.thinking).toEqual({ type: 'disabled' });
expect(body.reasoning_effort).toBeUndefined();
}); });
it('vision 模型 messages 数组首位始终为 system 消息', async () => { it('vision 模型 messages 数组首位始终为 system 消息', async () => {
@@ -757,11 +757,14 @@ describe('DeepSeekAdapter — thinking 映射矩阵', () => {
expect(bodies[0].reasoning_effort).toBeUndefined(); expect(bodies[0].reasoning_effort).toBeUndefined();
}); });
it('thinkingEnabled 未配置 → 不发送 thinking 字段', async () => { it('thinkingEnabled 未配置 → 显式 disabledv0.8.0 P0-3 确定性契约)', async () => {
// v0.8.0 P0-3 契约变更: DeepSeek 服务端默认 thinking.enabled,未配置即发请求
// 会得到隐式思考 —— 现在未配置一律显式 disabled,行为不依赖服务端隐式默认
//(与 Agnes/MiMo 的显式口径对齐;引擎路径恒传布尔值,此处为兜底确定性)。
const adapter = makeAdapter(); const adapter = makeAdapter();
const { bodies } = captureFetch(); const { bodies } = captureFetch();
await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false } })); await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false } }));
expect(bodies[0].thinking).toBeUndefined(); expect(bodies[0].thinking).toEqual({ type: 'disabled' });
}); });
it('effort 未配置缺省 high → reasoning_effort=high', async () => { it('effort 未配置缺省 high → reasoning_effort=high', async () => {
@@ -11,7 +11,7 @@ import {
readStreamChunkWithIdleTimeout, readStreamChunkWithIdleTimeout,
SseUpstreamError, SseUpstreamError,
} from '../shared/sse-stream'; } from '../shared/sse-stream';
import { MetonaStreamEventType } from '../../types'; import { MetonaStreamEventType, type MetonaStreamEvent } from '../../types';
/** 构造 SSE 测试流 */ /** 构造 SSE 测试流 */
function makeStream(chunks: string[]): ReadableStream<Uint8Array> { function makeStream(chunks: string[]): ReadableStream<Uint8Array> {
@@ -586,13 +586,33 @@ describe('parseSSEStream — 上游错误帧 status 推断矩阵', () => {
expect((err as SseUpstreamError).status).toBe(503); expect((err as SseUpstreamError).status).toBe(503);
}); });
it('error 为空对象 → 回退 message 为 {}JSON.stringify 兜底使其仍被识别为错误帧', async () => { it('error 为空对象 → 不构成错误帧(v0.8.0 P1-3.1 防御分支根治后契约', async () => {
// 源码注明的防御意图是无害空对象不报错,但 message 兜底取 JSON.stringify({})='{}' // v0.8.0 P1-3.1 根治: 旧实现 message 兜底取 JSON.stringify({})='{}' 恒真值
// 恒真值使该防御分支失效 —— 此处锁定实际行为并留档(见报告) // `{"error":{}}` 被误判为错误帧抛 SseUpstreamError 并触发不必要的重试
const err = await collectError({ error: {} }); // 新契约:无 message 且无 status/code 标识的空对象为无害帧,静默跳过。
const events: MetonaStreamEvent[] = [];
let error: Error | null = null;
try {
for await (const ev of parseSSEStream(
makeStream(['data: {"error":{}}\n\n', 'data: [DONE]\n\n']),
'r',
's',
1,
)) {
events.push(ev);
}
} catch (e) {
error = e as Error;
}
expect(error).toBeNull();
expect(events.some((ev) => ev.type === MetonaStreamEventType.DONE)).toBe(true);
});
it('error 有 code 无 message → 仍构成错误帧(标识存在,占位文案兜底)', async () => {
const err = await collectError({ error: { code: 'rate_limit_exceeded' } });
expect(err).toBeInstanceOf(SseUpstreamError); expect(err).toBeInstanceOf(SseUpstreamError);
expect((err as Error).message).toContain('upstream_error'); expect((err as Error).message).toContain('upstream_error');
expect((err as SseUpstreamError).status).toBeUndefined(); expect((err as SseUpstreamError).status).toBe(429);
}); });
it('error 为空白字符串 → 不算错误帧', async () => { it('error 为空白字符串 → 不算错误帧', async () => {
@@ -0,0 +1,159 @@
/**
* v0.8.0 P0-3: 思考参数 × 模型能力 门控矩阵。
*
* 根因回顾:MODEL_INFO 标注 supportsThinking:false 的模型(如
* deepseek-v4-flash-vision-exp)此前仍被发送 thinking 参数 —— 思考耗尽输出
* 预算(8192 上限)导致 finish_reason=length 空回复、会话静默停止。
* 本文件钉住四家 Provider 的能力门控行为与输出预算告警前置条件。
*/
import { describe, it, expect } from 'vitest';
import { DeepSeekAdapter } from '../deepseek.adapter';
import { AgnesAdapter } from '../agnes-ai.adapter';
import { MimoAdapter } from '../mimo.adapter';
import { OllamaAdapter } from '../ollama.adapter';
import type { MetonaRequest } from '../../types';
const SYSTEM_PROMPT = {
roleDefinition: 'test',
outputConstraints: '',
safetyGuidelines: '',
};
function makeRequest(overrides?: Partial<MetonaRequest['params']>): MetonaRequest {
return {
meta: {
sessionId: 's',
iteration: 1,
requestId: 'r',
timestamp: Date.now(),
agentVersion: '1.0.0',
},
systemPrompt: SYSTEM_PROMPT,
messages: [{ role: 'user', content: 'hi', timestamp: Date.now() }],
params: {
maxTokens: 63488,
temperature: 0,
stream: true,
thinkingEnabled: true,
thinkingEffort: 'max',
...overrides,
},
};
}
function asNative(
adapter: unknown,
): (req: MetonaRequest, stream: boolean) => Record<string, unknown> {
return (
adapter as { toNativeRequest: (r: MetonaRequest, s: boolean) => Record<string, unknown> }
).toNativeRequest.bind(adapter);
}
describe('P0-3 thinking capability gate', () => {
it('DeepSeek: vision-exp (supportsThinking:false) → thinking disabled, no reasoning_effort, max_tokens clamped to 8192', async () => {
const adapter = new DeepSeekAdapter({
provider: 'deepseek',
baseURL: 'https://api.deepseek.com',
apiKey: 'k',
defaultModel: 'deepseek-v4-flash-vision-exp',
});
const body = asNative(adapter)(makeRequest(), false);
expect(body.thinking).toEqual({ type: 'disabled' });
expect(body.reasoning_effort).toBeUndefined();
expect(body.max_tokens).toBe(8192);
});
it('DeepSeek: pro (supportsThinking:true) → thinking enabled + reasoning_effort mapped (max→max)', async () => {
const adapter = new DeepSeekAdapter({
provider: 'deepseek',
baseURL: 'https://api.deepseek.com',
apiKey: 'k',
defaultModel: 'deepseek-v4-pro',
});
const body = asNative(adapter)(makeRequest(), false);
expect(body.thinking).toEqual({ type: 'enabled' });
expect(body.reasoning_effort).toBe('max');
});
it('Agnes: supportsThinking:false model → enable_thinking:false', async () => {
const adapter = new AgnesAdapter({
provider: 'agnes',
baseURL: 'https://apihub.agnes-ai.com/v1',
apiKey: 'k',
defaultModel: 'agnes-2.0-flash',
});
// 临时改写元信息模拟"不支持思考"的模型,结束后恢复
const table = AgnesAdapter['MODEL_INFO'] as Record<string, { supportsThinking: boolean }>;
const original = table['agnes-2.0-flash'].supportsThinking;
table['agnes-2.0-flash'].supportsThinking = false;
try {
const body = asNative(adapter)(makeRequest(), false);
expect(body.chat_template_kwargs).toEqual({ enable_thinking: false });
} finally {
table['agnes-2.0-flash'].supportsThinking = original;
}
});
it('Agnes: supportsThinking:true model → enable_thinking:true', async () => {
const adapter = new AgnesAdapter({
provider: 'agnes',
baseURL: 'https://apihub.agnes-ai.com/v1',
apiKey: 'k',
defaultModel: 'agnes-2.0-flash',
});
const body = asNative(adapter)(makeRequest(), false);
expect(body.chat_template_kwargs).toEqual({ enable_thinking: true });
});
it('MiMo: supportsThinking:false model → thinking disabled + temperature passthrough', async () => {
const adapter = new MimoAdapter({
provider: 'mimo',
baseURL: 'https://api.xiaomimimo.com/v1',
apiKey: 'k',
defaultModel: 'mimo-v2.5-pro',
});
const table = MimoAdapter['MODEL_INFO'] as Record<string, { supportsThinking: boolean }>;
const original = table['mimo-v2.5-pro'].supportsThinking;
table['mimo-v2.5-pro'].supportsThinking = false;
try {
const body = asNative(adapter)(makeRequest(), false);
expect(body.thinking).toEqual({ type: 'disabled' });
expect(body.temperature).toBe(0);
} finally {
table['mimo-v2.5-pro'].supportsThinking = original;
}
});
it('Ollama: probed no-thinking (cachedThinkingSupport=false) → no think parameter', async () => {
const adapter = new OllamaAdapter({
provider: 'ollama',
baseURL: 'http://localhost:11434',
apiKey: '',
defaultModel: 'qwen3:latest',
});
(adapter as unknown as { cachedThinkingSupport: boolean | null }).cachedThinkingSupport = false;
const body = await (
adapter as unknown as {
toNativeRequest: (r: MetonaRequest) => Promise<Record<string, unknown>>;
}
).toNativeRequest(makeRequest());
expect(body.think).toBeUndefined();
});
it('Ollama: probe unknown (null) fails open → think parameter present', async () => {
const adapter = new OllamaAdapter({
provider: 'ollama',
baseURL: 'http://localhost:11434',
apiKey: '',
defaultModel: 'qwen3:latest',
});
(adapter as unknown as { cachedThinkingSupport: boolean | null }).cachedThinkingSupport = null;
const body = await (
adapter as unknown as {
toNativeRequest: (r: MetonaRequest) => Promise<Record<string, unknown>>;
}
).toNativeRequest(makeRequest());
expect(body.think).toBe(true); // effort=max → true
});
});
+14 -2
View File
@@ -56,7 +56,10 @@ export class AgnesAdapter extends OpenAICompatibleAdapter {
// ========== 协议参数映射(Agnes 差异点) ========== // ========== 协议参数映射(Agnes 差异点) ==========
protected override toNativeRequest(request: MetonaRequest, stream: boolean): Record<string, unknown> { protected override toNativeRequest(
request: MetonaRequest,
stream: boolean,
): Record<string, unknown> {
// v0.6.2: images 处理收敛至共享层(原索引对齐循环在孤立 tool 过滤后会错位) // v0.6.2: images 处理收敛至共享层(原索引对齐循环在孤立 tool 过滤后会错位)
const messages = buildOpenAICompatibleMessages(request, true); const messages = buildOpenAICompatibleMessages(request, true);
const tools = buildOpenAICompatibleTools(request.tools); const tools = buildOpenAICompatibleTools(request.tools);
@@ -96,12 +99,21 @@ export class AgnesAdapter extends OpenAICompatibleAdapter {
// 显式发送 enable_thinking:false —— 原实现只在 thinkingEnabled===true 时写该字段, // 显式发送 enable_thinking:false —— 原实现只在 thinkingEnabled===true 时写该字段,
// 若服务端默认开启思考,客户端没有任何路径把它关掉(DeepSeek/MiMo 均显式发送 // 若服务端默认开启思考,客户端没有任何路径把它关掉(DeepSeek/MiMo 均显式发送
// disabled 保持对称,唯独此处漏了)。 // disabled 保持对称,唯独此处漏了)。
// v0.8.0 P0-3: 模型能力门控 —— supportsThinking===false 的模型强制关闭思考
{ {
const effort = request.params.thinkingEffort ?? 'high'; const effort = request.params.thinkingEffort ?? 'high';
const modelThinkingSupported =
AgnesAdapter.MODEL_INFO[this.config.defaultModel]?.supportsThinking !== false;
// 未配置 thinkingEnabled 一律显式关闭 —— 与 DeepSeek/MiMo 的"服务端默认开启, // 未配置 thinkingEnabled 一律显式关闭 —— 与 DeepSeek/MiMo 的"服务端默认开启,
// 必须显式发送 disabled"口径对齐,让行为确定性不依赖服务端隐式默认。 // 必须显式发送 disabled"口径对齐,让行为确定性不依赖服务端隐式默认。
const wantThinking = request.params.thinkingEnabled === true && effort !== 'low'; const wantThinking =
request.params.thinkingEnabled === true && effort !== 'low' && modelThinkingSupported;
body.chat_template_kwargs = { enable_thinking: wantThinking }; body.chat_template_kwargs = { enable_thinking: wantThinking };
if (request.params.thinkingEnabled === true && !modelThinkingSupported) {
log.warn(
`[Agnes] model "${this.config.defaultModel}" does not support thinking — sending enable_thinking:false (P0-3 capability gate)`,
);
}
} }
// 停止序列 // 停止序列
+45 -2
View File
@@ -24,6 +24,28 @@ import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types'
import { MetonaFinishReason, MetonaStreamEventType } from '../types'; import { MetonaFinishReason, MetonaStreamEventType } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter'; import type { MetonaModelInfo } from '../types/metona-adapter';
/**
* v0.8.0 P0-1: Anthropic stop_reason → 归一化 OpenAI 语义(与 MetonaFinishReason
* 的非流式映射语义一致)。pause_turn(长回复暂停续传标记)视为自然停止。
*/
function mapAnthropicStopReason(reason: string): string {
switch (reason) {
case 'max_tokens':
return 'length';
case 'tool_use':
return 'tool_calls';
case 'refusal':
case 'content_filter':
return 'content_filter';
case 'end_turn':
case 'pause_turn':
case 'stop_sequence':
return 'stop';
default:
return 'stop';
}
}
export class AnthropicAdapter extends BaseAdapter { export class AnthropicAdapter extends BaseAdapter {
override readonly providerId: string = 'anthropic'; override readonly providerId: string = 'anthropic';
readonly supportedModels = ['claude-sonnet-4-5', 'claude-opus-4-1', 'claude-haiku-4-5']; readonly supportedModels = ['claude-sonnet-4-5', 'claude-opus-4-1', 'claude-haiku-4-5'];
@@ -116,6 +138,10 @@ export class AnthropicAdapter extends BaseAdapter {
let buffer = ''; let buffer = '';
let eventName = ''; let eventName = '';
let streamEndedNormally = false; let streamEndedNormally = false;
// v0.8.0 P0-1: 采集 message_delta.delta.stop_reason —— Anthropic 的停止原因
// 在 message_delta(而非 message_stop)事件携带;旧实现只读 usage,
// max_tokens 截断在流式路径完全不可见(与 OpenAI 共享层 finish_reason 缺口同源)
let streamStopReason: string | undefined;
// 工具调用缓冲:content block index → { id, name, argsBuffer } // 工具调用缓冲:content block index → { id, name, argsBuffer }
const toolBlocks = new Map<number, { id: string; name: string; argsBuffer: string }>(); const toolBlocks = new Map<number, { id: string; name: string; argsBuffer: string }>();
@@ -256,11 +282,22 @@ export class AnthropicAdapter extends BaseAdapter {
}, },
}); });
} }
// v0.8.0 P0-1: 采集停止原因(message_delta.delta.stop_reason,可能在
// 多个 message_delta 中重复出现,取任一即可;归一化为 OpenAI 语义)
const delta = data.delta as Record<string, unknown> | undefined;
if (delta && typeof delta.stop_reason === 'string') {
streamStopReason = mapAnthropicStopReason(delta.stop_reason);
}
break; break;
} }
case 'message_stop': { case 'message_stop': {
streamEndedNormally = true; streamEndedNormally = true;
events.push({ type: MetonaStreamEventType.DONE, ...base() }); events.push({
type: MetonaStreamEventType.DONE,
...base(),
// v0.8.0 P0-1: 携带归一化停止原因
...(streamStopReason ? { finishReason: streamStopReason } : {}),
});
break; break;
} }
case 'error': { case 'error': {
@@ -362,7 +399,13 @@ export class AnthropicAdapter extends BaseAdapter {
log.warn('[Anthropic] Stream ended without message_stop (connection likely dropped)'); log.warn('[Anthropic] Stream ended without message_stop (connection likely dropped)');
} }
toolBlocks.clear(); toolBlocks.clear();
yield { type: MetonaStreamEventType.DONE, ...base() }; yield {
type: MetonaStreamEventType.DONE,
...base(),
// v0.8.0 P0-1: 断流合成路径同样携带已观察到的停止原因
//(断流时多为 undefined —— 引擎据此走空响应守卫/重试而非误判自然结束)
...(streamStopReason ? { finishReason: streamStopReason } : {}),
};
} }
} }
+20 -3
View File
@@ -164,7 +164,10 @@ export class DeepSeekAdapter extends OpenAICompatibleAdapter {
// ========== 协议参数映射(DeepSeek 差异点) ========== // ========== 协议参数映射(DeepSeek 差异点) ==========
protected override toNativeRequest(request: MetonaRequest, stream: boolean): Record<string, unknown> { protected override toNativeRequest(
request: MetonaRequest,
stream: boolean,
): Record<string, unknown> {
// v0.6.2: images 处理收敛至共享层(includeImages = vision 模型才转换, // v0.6.2: images 处理收敛至共享层(includeImages = vision 模型才转换,
// 非 vision 静默丢弃——正确行为,见 openai-format.ts #27 记录) // 非 vision 静默丢弃——正确行为,见 openai-format.ts #27 记录)
const messages = buildOpenAICompatibleMessages(request, this.isVisionModel()); const messages = buildOpenAICompatibleMessages(request, this.isVisionModel());
@@ -200,10 +203,17 @@ export class DeepSeekAdapter extends OpenAICompatibleAdapter {
} }
// Thinking 模式 // Thinking 模式
// v0.8.0 P0-3 根治: 模型能力门控 —— MODEL_INFO.supportsThinking === false 的
// 模型(如 deepseek-v4-flash-vision-exp)一律不发思考参数并显式 disabled,
// 防止思考消耗输出预算(生产事故:vision-exp 8192 输出预算被 max 档思考
// 全部烧光 → finish_reason=length 空回复 → 会话静默停止)。
const modelThinkingSupported =
DeepSeekAdapter.MODEL_INFO[this.config.defaultModel]?.supportsThinking !== false;
const wantThinking = request.params.thinkingEnabled === true && modelThinkingSupported;
// API 默认 thinking.type = "enabled",必须显式发送 disabled 才能关闭 // API 默认 thinking.type = "enabled",必须显式发送 disabled 才能关闭
if (request.params.thinkingEnabled === false) { if (!wantThinking) {
body.thinking = { type: 'disabled' }; body.thinking = { type: 'disabled' };
} else if (request.params.thinkingEnabled) { } else {
body.thinking = { type: 'enabled' }; body.thinking = { type: 'enabled' };
const effortMap: Record<string, string> = { const effortMap: Record<string, string> = {
low: 'high', low: 'high',
@@ -213,6 +223,13 @@ export class DeepSeekAdapter extends OpenAICompatibleAdapter {
}; };
// DeepSeek API 仅支持 high / max 两档,low/medium 映射为 high // DeepSeek API 仅支持 high / max 两档,low/medium 映射为 high
body.reasoning_effort = effortMap[request.params.thinkingEffort ?? 'high'] ?? 'high'; body.reasoning_effort = effortMap[request.params.thinkingEffort ?? 'high'] ?? 'high';
// v0.8.0 P0-3: 思考会占用输出预算 —— 钳制后预算过小时显式告警
//(思考 token 计入 max_tokens,预算过小会出现"思考耗尽正文为零"截断)
if (maxTokens < 8192) {
log.warn(
`[DeepSeek] thinking enabled with small output budget (${maxTokens} tokens after model clamp) — reasoning may consume the entire budget and truncate the answer`,
);
}
} }
// 停止序列 // 停止序列
+18 -2
View File
@@ -11,6 +11,7 @@
* @see apis/mimo-api-docs-20260715.html * @see apis/mimo-api-docs-20260715.html
*/ */
import log from 'electron-log';
import type { MetonaRequest } from '../types'; import type { MetonaRequest } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter'; import type { MetonaModelInfo } from '../types/metona-adapter';
import { buildOpenAICompatibleMessages, buildOpenAICompatibleTools } from './shared/openai-format'; import { buildOpenAICompatibleMessages, buildOpenAICompatibleTools } from './shared/openai-format';
@@ -72,7 +73,10 @@ export class MimoAdapter extends OpenAICompatibleAdapter {
// ========== 协议参数映射(MiMo 差异点) ========== // ========== 协议参数映射(MiMo 差异点) ==========
protected override toNativeRequest(request: MetonaRequest, stream: boolean): Record<string, unknown> { protected override toNativeRequest(
request: MetonaRequest,
stream: boolean,
): Record<string, unknown> {
// v0.6.2: images 处理收敛至共享层(原索引对齐循环在孤立 tool 过滤后会错位) // v0.6.2: images 处理收敛至共享层(原索引对齐循环在孤立 tool 过滤后会错位)
const messages = buildOpenAICompatibleMessages(request, true); const messages = buildOpenAICompatibleMessages(request, true);
const tools = buildOpenAICompatibleTools(request.tools); const tools = buildOpenAICompatibleTools(request.tools);
@@ -122,7 +126,12 @@ export class MimoAdapter extends OpenAICompatibleAdapter {
// Thinking 模式(与 DeepSeek 参数结构一致) // Thinking 模式(与 DeepSeek 参数结构一致)
// MiMo API 默认 thinking.type = "enabled",必须显式发送 disabled 才能关闭 // MiMo API 默认 thinking.type = "enabled",必须显式发送 disabled 才能关闭
if (request.params.thinkingEnabled === false) { // v0.8.0 P0-3: 模型能力门控 —— supportsThinking===false 的模型强制 disabled
//(思考 token 计入 max_completion_tokens,能力不符的模型上会耗尽输出预算)
const modelThinkingSupported =
MimoAdapter.MODEL_INFO[this.config.defaultModel]?.supportsThinking !== false;
const wantThinking = request.params.thinkingEnabled !== false && modelThinkingSupported;
if (!wantThinking) {
// 显式禁用思考:传 disabled + temperature/top_p(非思考模式下这两个参数有效) // 显式禁用思考:传 disabled + temperature/top_p(非思考模式下这两个参数有效)
body.thinking = { type: 'disabled' }; body.thinking = { type: 'disabled' };
body.temperature = request.params.temperature; body.temperature = request.params.temperature;
@@ -131,6 +140,13 @@ export class MimoAdapter extends OpenAICompatibleAdapter {
// 启用思考(包括 undefined,因为 MiMo 默认 enabled // 启用思考(包括 undefined,因为 MiMo 默认 enabled
// 思考模式下 temperature/top_p 被 API 强制覆盖为 1.0/0.95,不传 // 思考模式下 temperature/top_p 被 API 强制覆盖为 1.0/0.95,不传
body.thinking = { type: 'enabled' }; body.thinking = { type: 'enabled' };
// v0.8.0 P0-3: 思考占用输出预算 —— 钳制后预算过小时显式告警
const effectiveMax = body.max_completion_tokens as number;
if (typeof effectiveMax === 'number' && effectiveMax < 8192) {
log.warn(
`[MiMo] thinking enabled with small output budget (${effectiveMax} tokens after model clamp) — reasoning may consume the entire budget and truncate the answer`,
);
}
} }
// 停止序列 // 停止序列
+61 -4
View File
@@ -192,6 +192,14 @@ export class OllamaAdapter extends BaseAdapter {
// 流结束 // 流结束
if (chunk.done) { if (chunk.done) {
streamEndedNormally = true; streamEndedNormally = true;
// v0.8.0 P0-1: 采集 done_reason —— Ollama 的停止原因在最终 done chunk
// 携带;旧实现流式路径完全忽略(length 截断不可见),现归一化后随
// DONE 事件上交引擎
const doneReason = mapOllamaDoneReason(
chunk.done_reason as string | undefined,
Array.isArray(chunk.message?.tool_calls) && chunk.message.tool_calls.length > 0,
);
const finishReason = doneReason === MetonaFinishReason.LENGTH ? 'length' : undefined;
// 发送 usage 信息 // 发送 usage 信息
yield { yield {
type: MetonaStreamEventType.USAGE, type: MetonaStreamEventType.USAGE,
@@ -214,6 +222,9 @@ export class OllamaAdapter extends BaseAdapter {
iteration: request.meta.iteration, iteration: request.meta.iteration,
seq: seq++, seq: seq++,
timestamp: Date.now(), timestamp: Date.now(),
// v0.8.0 P0-1: 仅 length 需要显式上交(stop/tool_calls 语义由
// 事件流本身表达;load/unload 属本地引擎状态非响应语义)
...(finishReason ? { finishReason } : {}),
}; };
return; return;
} }
@@ -252,6 +263,8 @@ export class OllamaAdapter extends BaseAdapter {
format?: string | object; format?: string | object;
images?: string[]; images?: string[];
options?: Record<string, unknown>; options?: Record<string, unknown>;
/** v0.8.0 P1-3.2: 外部取消信号(与固定 300s 超时合并,任一触发即中止) */
signal?: AbortSignal;
}): Promise<{ }): Promise<{
response: string; response: string;
thinking?: string; thinking?: string;
@@ -262,8 +275,13 @@ export class OllamaAdapter extends BaseAdapter {
const response = await fetch(`${this.baseURL}/api/generate`, { const response = await fetch(`${this.baseURL}/api/generate`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...params, stream: false }), body: JSON.stringify({ ...params, stream: false, signal: undefined }),
signal: AbortSignal.timeout(300_000), // v0.8.0 P1-3.2 根治: 旧实现固定 AbortSignal.timeout(300_000) —— 外部中断
// 无法取消该请求(最长 5 分钟资源悬挂)。现用 AbortSignal.any 合并外部
// signal 与超时信号,任一触发即中止(Node ≥20.3 / Electron 35 满足)。
signal: params.signal
? AbortSignal.any([params.signal, AbortSignal.timeout(300_000)])
: AbortSignal.timeout(300_000),
}); });
if (!response.ok) throw new Error(`Ollama generate error: ${response.status}`); if (!response.ok) throw new Error(`Ollama generate error: ${response.status}`);
@@ -290,12 +308,17 @@ export class OllamaAdapter extends BaseAdapter {
model: string; model: string;
input: string | string[]; input: string | string[];
dimensions?: number; dimensions?: number;
/** v0.8.0 P1-3.2: 外部取消信号(与固定 60s 超时合并) */
signal?: AbortSignal;
}): Promise<{ embeddings: number[][]; totalDuration: number }> { }): Promise<{ embeddings: number[][]; totalDuration: number }> {
const response = await fetch(`${this.baseURL}/api/embed`, { const response = await fetch(`${this.baseURL}/api/embed`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params), body: JSON.stringify(params),
signal: AbortSignal.timeout(60_000), // v0.8.0 P1-3.2: 与 generate 同口径 —— 外部 signal 与超时合并
signal: params.signal
? AbortSignal.any([params.signal, AbortSignal.timeout(60_000)])
: AbortSignal.timeout(60_000),
}); });
if (!response.ok) throw new Error(`Ollama embed error: ${response.status}`); if (!response.ok) throw new Error(`Ollama embed error: ${response.status}`);
@@ -381,9 +404,18 @@ export class OllamaAdapter extends BaseAdapter {
*/ */
private cachedContextWindow: number | null = null; private cachedContextWindow: number | null = null;
private refreshingContextWindow = false; private refreshingContextWindow = false;
/** v0.8.0 P0-3: /api/show 探测只发一次(构造函数发起),失败不重试(fail-open) */
private probeAttempted = false;
/**
* v0.8.0 P0-3: /api/show capabilities 探测缓存 —— 模型是否支持思考。
* null = 未探测/探测失败(fail-open 放行,与 listModels 能力回退策略一致);
* false = 服务端明确不支持 → toNativeRequest 不发 think 参数。
*/
private cachedThinkingSupport: boolean | null = null;
private refreshContextWindow(): void { private refreshContextWindow(): void {
if (this.refreshingContextWindow) return; if (this.refreshingContextWindow || this.probeAttempted) return;
this.probeAttempted = true;
this.refreshingContextWindow = true; this.refreshingContextWindow = true;
void this.showModel(this.config.defaultModel) void this.showModel(this.config.defaultModel)
.then((info) => { .then((info) => {
@@ -396,6 +428,10 @@ export class OllamaAdapter extends BaseAdapter {
log.info(`[Ollama] Context window (num_ctx) detected: ${value}`); log.info(`[Ollama] Context window (num_ctx) detected: ${value}`);
} }
} }
// v0.8.0 P0-3: 同一次探测顺带缓存思考能力(供 toNativeRequest 同步门控)
if (Array.isArray(info.capabilities)) {
this.cachedThinkingSupport = info.capabilities.map(String).includes('thinking');
}
}) })
.catch(() => { .catch(() => {
/* 模型探测失败不阻塞对话 */ /* 模型探测失败不阻塞对话 */
@@ -573,6 +609,9 @@ export class OllamaAdapter extends BaseAdapter {
} }
private async toNativeRequest(request: MetonaRequest): Promise<Record<string, unknown>> { private async toNativeRequest(request: MetonaRequest): Promise<Record<string, unknown>> {
// 说明:能力/num_ctx 探测由构造函数 fire-and-forget 发起(probeAttempts 上限
// 1 次)—— 此处不再重复探测,避免每次请求都打 /api/show(也保持测试环境的
// fetch 捕获不受污染)。探测失败时 cachedThinkingSupport=null → 门控 fail-open。
const messages: Record<string, unknown>[] = [ const messages: Record<string, unknown>[] = [
{ {
role: 'system', role: 'system',
@@ -662,7 +701,13 @@ export class OllamaAdapter extends BaseAdapter {
} }
// Thinking 模式 // Thinking 模式
// v0.8.0 P0-3: 模型能力门控 —— /api/show capabilities 探测为不支持思考
//cachedThinkingSupport === false)时不发 think 参数(Ollama 服务端默认关闭);
// 未探测/探测失败(nullfail-open 放行,与 listModels 能力回退策略一致。
// 探测在适配器实例创建时 fire-and-forget 发起(refreshContextWindow)。
if (request.params.thinkingEnabled) { if (request.params.thinkingEnabled) {
const modelThinkingSupported = this.cachedThinkingSupport !== false;
if (modelThinkingSupported) {
const effortMap: Record<string, string | boolean> = { const effortMap: Record<string, string | boolean> = {
low: 'low', low: 'low',
medium: 'medium', medium: 'medium',
@@ -670,6 +715,18 @@ export class OllamaAdapter extends BaseAdapter {
max: true, max: true,
}; };
body.think = effortMap[request.params.thinkingEffort ?? 'high'] ?? true; body.think = effortMap[request.params.thinkingEffort ?? 'high'] ?? true;
// v0.8.0 P0-3: 思考占用 num_predict 输出预算 —— 预算过小时显式告警
const numPredict = request.params.maxTokens;
if (typeof numPredict === 'number' && numPredict < 8192) {
log.warn(
`[Ollama] thinking enabled with small num_predict budget (${numPredict}) — reasoning may consume the entire budget and truncate the answer`,
);
}
} else {
log.warn(
`[Ollama] model "${this.config.defaultModel}" does not support thinking (per /api/show capabilities) — omitting think parameter (P0-3 capability gate)`,
);
}
} }
return body; return body;
+12 -5
View File
@@ -12,13 +12,11 @@
* @see https://platform.openai.com/docs/api-reference/chat * @see https://platform.openai.com/docs/api-reference/chat
*/ */
import log from 'electron-log';
import type { MetonaRequest } from '../types'; import type { MetonaRequest } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter'; import type { MetonaModelInfo } from '../types/metona-adapter';
import { buildOpenAICompatibleMessages, buildOpenAICompatibleTools } from './shared/openai-format'; import { buildOpenAICompatibleMessages, buildOpenAICompatibleTools } from './shared/openai-format';
import { import { ModelCapabilityError, OpenAICompatibleAdapter } from './shared/openai-compatible-base';
ModelCapabilityError,
OpenAICompatibleAdapter,
} from './shared/openai-compatible-base';
export class OpenAIAdapter extends OpenAICompatibleAdapter { export class OpenAIAdapter extends OpenAICompatibleAdapter {
override readonly providerId: string = 'openai'; override readonly providerId: string = 'openai';
@@ -110,7 +108,10 @@ export class OpenAIAdapter extends OpenAICompatibleAdapter {
// ========== 协议参数映射(OpenAI 差异点) ========== // ========== 协议参数映射(OpenAI 差异点) ==========
protected override toNativeRequest(request: MetonaRequest, stream: boolean): Record<string, unknown> { protected override toNativeRequest(
request: MetonaRequest,
stream: boolean,
): Record<string, unknown> {
// 推理模型检测(o 系列使用新参数名) // 推理模型检测(o 系列使用新参数名)
const model = this.config.defaultModel; const model = this.config.defaultModel;
const isReasoningModel = /^(o\d|gpt-5)/.test(model); const isReasoningModel = /^(o\d|gpt-5)/.test(model);
@@ -168,6 +169,12 @@ export class OpenAIAdapter extends OpenAICompatibleAdapter {
max: 'high', max: 'high',
}; };
body.reasoning_effort = effortMap[request.params.thinkingEffort ?? 'high'] ?? 'high'; body.reasoning_effort = effortMap[request.params.thinkingEffort ?? 'high'] ?? 'high';
// v0.8.0 P0-3: 推理 token 计入 max_completion_tokens —— 钳制后预算过小时告警
if (oaMaxTokens < 8192) {
log.warn(
`[OpenAI] reasoning enabled with small output budget (${oaMaxTokens} tokens after model clamp) — reasoning may consume the entire budget and truncate the answer`,
);
}
} else if (!isReasoningModel) { } else if (!isReasoningModel) {
// 非推理模型使用温度控制 // 非推理模型使用温度控制
body.temperature = request.params.temperature; body.temperature = request.params.temperature;
+24 -9
View File
@@ -139,17 +139,17 @@ function extractUpstreamErrorFrame(
? e.status_code ? e.status_code
: undefined; : undefined;
const rawCode = typeof e.code === 'string' ? e.code : typeof e.type === 'string' ? e.type : ''; const rawCode = typeof e.code === 'string' ? e.code : typeof e.type === 'string' ? e.type : '';
const message =
typeof e.message === 'string'
? e.message
: typeof e.msg === 'string'
? e.msg
: JSON.stringify(errObj);
// 无消息且无状态码的无害空对象不算错误(防御性) // 无消息且无状态码的无害空对象不算错误(防御性)
if (!message && rawStatus === undefined && !rawCode) return null; // v0.8.0 P1-3.1 根治: 旧实现 message 兜底取 JSON.stringify(errObj) —— '{}' 恒为
// 真值,使本防御分支永不可达,`{"error":{}}` 被误判为错误帧抛 SseUpstreamError
// (原行为被 sse-stream.test.ts 锁定留档,本次随修复同步改约)。
// 现契约:仅当存在显式 message/msg 或 status/code 标识之一才构成错误帧;
// 描述文本只在有标识但无显式文本时由序列化/占位生成。
const explicitMessage =
typeof e.message === 'string' ? e.message : typeof e.msg === 'string' ? e.msg : '';
if (!explicitMessage && rawStatus === undefined && !rawCode) return null;
return { return {
message: message || `upstream error (${rawCode || rawStatus})`, message: explicitMessage || `upstream error (${rawCode || rawStatus})`,
status: rawStatus, status: rawStatus,
code: rawCode || undefined, code: rawCode || undefined,
}; };
@@ -326,6 +326,10 @@ export async function* parseSSEStream(
let buffer = ''; let buffer = '';
// v0.6.3: 是否收到过 [DONE](流断开兜底用) // v0.6.3: 是否收到过 [DONE](流断开兜底用)
let sawDone = false; let sawDone = false;
// v0.8.0 P0-1: 跟踪 Provider 原生 finish_reason,随 DONE 事件携带给引擎
// 'stop' | 'length' | 'tool_calls' | 'content_filter' 等;引擎据此区分
// 自然完成与输出上限截断,此前 length 仅落日志、引擎不可见)
let lastFinishReason: string | undefined;
// v0.7.4 P1-2: 流空闲超时 — 服务器保活但不再推送数据(连接挂死)时, // v0.7.4 P1-2: 流空闲超时 — 服务器保活但不再推送数据(连接挂死)时,
// reader.read() 会无限挂起,totalTimeoutMs 只在迭代之间检查,无法兜底。 // reader.read() 会无限挂起,totalTimeoutMs 只在迭代之间检查,无法兜底。
@@ -369,6 +373,8 @@ export async function* parseSSEStream(
iteration, iteration,
seq: seqRef.seq++, seq: seqRef.seq++,
timestamp: Date.now(), timestamp: Date.now(),
// v0.8.0 P0-1: 携带 Provider 原生 finish_reason(未见任何帧时 undefined
...(lastFinishReason ? { finishReason: lastFinishReason } : {}),
}; };
return; return;
} }
@@ -480,6 +486,11 @@ export async function* parseSSEStream(
// 非 [DONE] 但 finish_reason 为 tool_calls 时提前 flush 缓冲区 // 非 [DONE] 但 finish_reason 为 tool_calls 时提前 flush 缓冲区
const finishReason = chunk.choices?.[0]?.finish_reason as string | undefined; const finishReason = chunk.choices?.[0]?.finish_reason as string | undefined;
// v0.8.0 P0-1: 记录最近一次 finish_reason(最终帧通常携带于最后一个
// 含 choices 的 chunk),随 DONE 事件上交通知引擎与前端
if (finishReason) {
lastFinishReason = finishReason;
}
if (finishReason === 'tool_calls') { if (finishReason === 'tool_calls') {
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码 // L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef); yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
@@ -528,6 +539,10 @@ export async function* parseSSEStream(
iteration, iteration,
seq: seqRef.seq++, seq: seqRef.seq++,
timestamp: Date.now(), timestamp: Date.now(),
// v0.8.0 P0-1: 断流合成路径同样携带已观察到的 finish_reason
//(注意:断流时 finishReason 多为 undefined —— 引擎据此走空响应守卫/重试,
// 而不是误判为模型自然说完了)
...(lastFinishReason ? { finishReason: lastFinishReason } : {}),
}; };
} }
} }
@@ -0,0 +1,230 @@
/**
* v0.8.0 P0-5: 引擎流结束契约测试 —— "模型思考着会话停止"根治回归防线。
*
* 钉住 chatStreamWithRetry 的流结束契约(P0-2)与 finish_reason 贯通(P0-1):
* 1. 零产出流 → EmptyResponseError → 退避重试 → 重试耗尽 ERROR;
* 2. reasoning-only + finish_reason=length → 一次性降级重试(thinkingEnabled=false
* → 降级后成功 COMPLETED
* 3. 降级重试仍 length → OutputTruncatedError → ERROR 事件携带
* MetonaErrorCode.OUTPUT_LENGTH_EXCEEDED
* 4. 有正文 + length → COMPLETED 且最终 DONE 事件携带 finishReason='length'
* 5. 引擎最终 DONE 贯通最后一轮 step.finishReason。
*/
import { describe, it, expect } from 'vitest';
import { AgentLoopEngine } from '../engine';
import type { AgentLoopOutput } from '../types';
import type {
MetonaRequest,
MetonaStreamEvent,
IMetonaProviderAdapter,
MetonaResponse,
} from '../../types';
import { MetonaStreamEventType, MetonaErrorCode } from '../../types';
const SYSTEM_PROMPT = {
roleDefinition: 'test',
outputConstraints: '',
safetyGuidelines: '',
};
function makeAdapter(
script: (request: MetonaRequest) => MetonaStreamEvent[],
): IMetonaProviderAdapter & { calls: MetonaRequest[] } {
const calls: MetonaRequest[] = [];
const adapter = {
providerId: 'mock' as const,
supportedModels: ['m'],
supportsToolCalling: true,
supportsThinking: true,
calls,
getContextWindow(): number {
return 128_000;
},
async send(_request: MetonaRequest): Promise<MetonaResponse> {
throw new Error('send() not expected in this test');
},
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
calls.push(request);
for (const ev of script(request)) {
yield ev;
}
},
};
return adapter as unknown as IMetonaProviderAdapter & { calls: MetonaRequest[] };
}
function makeEngine(adapter: IMetonaProviderAdapter, retryCount = 1): AgentLoopEngine {
return new AgentLoopEngine(
{
maxIterations: 3,
totalTimeoutMs: 10_000,
retryCount,
thinkingEnabled: true,
thinkingEffort: 'high',
contextWindow: 128_000,
},
adapter,
);
}
function textDelta(t: string): MetonaStreamEvent {
return {
type: MetonaStreamEventType.TEXT_DELTA,
requestId: 'r',
sessionId: 's',
iteration: 1,
seq: 0,
timestamp: Date.now(),
delta: t,
};
}
function reasoningDelta(t: string): MetonaStreamEvent {
return {
type: MetonaStreamEventType.REASONING_DELTA,
requestId: 'r',
sessionId: 's',
iteration: 1,
seq: 0,
timestamp: Date.now(),
delta: t,
};
}
function doneEvent(finishReason?: string): MetonaStreamEvent {
return {
type: MetonaStreamEventType.DONE,
requestId: 'r',
sessionId: 's',
iteration: 1,
seq: 0,
timestamp: Date.now(),
...(finishReason ? { finishReason } : {}),
};
}
describe('engine stream-end contract (v0.8.0 P0-2)', () => {
it('scenario 2: reasoning-only + length → one degraded retry with thinking disabled → COMPLETED', async () => {
let call = 0;
const adapter = makeAdapter((req) => {
call++;
if (call === 1) {
// 第一次:思考烧满输出预算(reasoning-only + length 截断)
expect(req.params.thinkingEnabled).toBe(true);
return [reasoningDelta('thinking...'), doneEvent('length')];
}
// 降级重试:thinkingEnabled 必须已被引擎置 false
expect(req.params.thinkingEnabled).toBe(false);
return [textDelta('answer'), doneEvent('stop')];
});
const engine = makeEngine(adapter);
const events: MetonaStreamEvent[] = [];
engine.on('streamEvent', (e: MetonaStreamEvent) => events.push(e));
const output = await engine.runStream(
{ role: 'user', content: 'hi', timestamp: Date.now() },
's1',
[],
SYSTEM_PROMPT,
);
expect(output.terminationReason).toBe('completed');
expect(output.finalAnswer).toBe('answer');
expect(adapter.calls.length).toBe(2);
// 降级重试经过 RETRY 事件(引擎消费后向渲染层转 STREAM_RESET
expect(events.some((e) => e.type === MetonaStreamEventType.STREAM_RESET)).toBe(true);
});
it('scenario 2b: degraded retry still length → OUTPUT_LENGTH_EXCEEDED error (no infinite retry)', async () => {
const adapter = makeAdapter(() => [reasoningDelta('thinking...'), doneEvent('length')]);
const engine = makeEngine(adapter);
const events: MetonaStreamEvent[] = [];
engine.on('streamEvent', (e: MetonaStreamEvent) => events.push(e));
const output: AgentLoopOutput = await engine.runStream(
{ role: 'user', content: 'hi', timestamp: Date.now() },
's1',
[],
SYSTEM_PROMPT,
);
// 恰好两次调用(原始 + 一次性降级重试),随后终止
expect(adapter.calls.length).toBe(2);
expect(output.terminationReason).toBe('error');
const errorEvent = events.find((e) => e.type === MetonaStreamEventType.ERROR);
expect(errorEvent).toBeDefined();
expect(errorEvent?.error?.code).toBe(MetonaErrorCode.OUTPUT_LENGTH_EXCEEDED);
});
it('scenario 1: zero-content stream → retryable → retry exhausted → ERROR', async () => {
const adapter = makeAdapter(() => []);
const engine = makeEngine(adapter, 1);
const events: MetonaStreamEvent[] = [];
engine.on('streamEvent', (e: MetonaStreamEvent) => events.push(e));
const output = await engine.runStream(
{ role: 'user', content: 'hi', timestamp: Date.now() },
's1',
[],
SYSTEM_PROMPT,
);
expect(output.terminationReason).toBe('error');
expect(adapter.calls.length).toBe(2); // 原始 1 次 + 重试 1 次(retryCount=1
const errorEvent = events.find((e) => e.type === MetonaStreamEventType.ERROR);
expect(errorEvent?.error?.message).toContain('empty stream');
});
it('scenario 4: text + length → COMPLETED with DONE.finishReason=length (no retry)', async () => {
const adapter = makeAdapter(() => [textDelta('partial answer'), doneEvent('length')]);
const engine = makeEngine(adapter);
const events: MetonaStreamEvent[] = [];
engine.on('streamEvent', (e: MetonaStreamEvent) => events.push(e));
const output = await engine.runStream(
{ role: 'user', content: 'hi', timestamp: Date.now() },
's1',
[],
SYSTEM_PROMPT,
);
expect(output.terminationReason).toBe('completed');
expect(output.finalAnswer).toBe('partial answer');
expect(adapter.calls.length).toBe(1); // 有产出不重试
const done = events.find((e) => e.type === MetonaStreamEventType.DONE);
expect(done?.finishReason).toBe('length');
});
it('P0-1: normal stop → DONE carries finishReason=stop from adapter', async () => {
const adapter = makeAdapter(() => [textDelta('ok'), doneEvent('stop')]);
const engine = makeEngine(adapter);
const events: MetonaStreamEvent[] = [];
engine.on('streamEvent', (e: MetonaStreamEvent) => events.push(e));
const output = await engine.runStream(
{ role: 'user', content: 'hi', timestamp: Date.now() },
's1',
[],
SYSTEM_PROMPT,
);
expect(output.terminationReason).toBe('completed');
const done = events.find((e) => e.type === MetonaStreamEventType.DONE);
expect(done?.finishReason).toBe('stop');
});
it('reasoning-only + natural stop (no length) → retryable empty response path', async () => {
const adapter = makeAdapter(() => [reasoningDelta('hmm'), doneEvent('stop')]);
const engine = makeEngine(adapter, 1);
const output = await engine.runStream(
{ role: 'user', content: 'hi', timestamp: Date.now() },
's1',
[],
SYSTEM_PROMPT,
);
// 无产出且非 length 截断 → EmptyResponseError(可重试)→ 重试耗尽 ERROR
expect(output.terminationReason).toBe('error');
expect(adapter.calls.length).toBe(2);
});
});
@@ -0,0 +1,243 @@
/**
* v0.8.0 P0-5 回归测试(第二批):流可靠性四件套的剩余项。
*
* 1. parseSSEStream 集成级空闲超时:挂死 ReadableStream → 60s 无数据 →
* SseUpstreamError(504)P1-2 防线的集成真实性,此前仅纯函数层覆盖);
* 2. SseUpstreamError(504) 经引擎 isRetryableError 归类 → 退避重试后成功;
* 3. REASONING_DELTA 持续输出中 abort → USER_INTERRUPT"思考中点停止"场景);
* 4. 引擎 P4-2:上一 run 卡死(不响应 abort)→ 新 run 等待 30s → 强制 abort
* → 再等 5s → 抛错(防"会话永久排队")。
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { parseSSEStream, SseUpstreamError } from '../../adapters/shared/sse-stream';
import { AgentLoopEngine } from '../engine';
import type { MetonaRequest, MetonaStreamEvent, IMetonaProviderAdapter } from '../../types';
import { MetonaStreamEventType } from '../../types';
const SYSTEM_PROMPT = {
roleDefinition: 'test',
outputConstraints: '',
safetyGuidelines: '',
};
afterEach(() => {
vi.useRealTimers();
});
// ===== 1. 集成级空闲超时 =====
describe('parseSSEStream integrated idle timeout (P0-5 #2)', () => {
function makeHungBody(): ReadableStream<Uint8Array> {
return {
getReader: () => ({
// 永不 resolve 的 read —— 模拟"服务器保活但不再推数据"
read: () => new Promise<never>(() => {}),
releaseLock: () => {},
}),
} as unknown as ReadableStream<Uint8Array>;
}
it('挂死流 60s 无数据 → SseUpstreamError(504)', async () => {
vi.useFakeTimers();
const iterator = parseSSEStream(makeHungBody(), 'r', 's', 1);
let thrown: unknown = null;
const firstRead = iterator.next().catch((e: unknown) => {
thrown = e;
return { done: true, value: undefined } as const;
});
// 推进 61sIDLE_TIMEOUT_MS=60_000 触发 idle controller → read() 竞速失败
await vi.advanceTimersByTimeAsync(61_000);
await firstRead;
expect(thrown).toBeInstanceOf(SseUpstreamError);
expect((thrown as SseUpstreamError).status).toBe(504);
expect((thrown as Error).message).toContain('idle timeout');
});
});
// ===== 2. SseUpstreamError(504) → 引擎重试 =====
describe('engine: SseUpstreamError(504) retry classification (P0-5 #3)', () => {
it('504 首败 → 退避重试 → 第二次流成功 → COMPLETED', async () => {
let call = 0;
const adapter = {
providerId: 'mock' as const,
supportedModels: ['m'],
supportsToolCalling: true,
supportsThinking: true,
getContextWindow: () => 128_000,
async send(): Promise<unknown> {
throw new Error('not used');
},
async *sendStream(_r: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
call++;
if (call === 1) {
throw new SseUpstreamError('Stream idle timeout after 60000ms (no data received)', {
status: 504,
});
}
yield {
type: MetonaStreamEventType.TEXT_DELTA,
requestId: 'r',
sessionId: 's',
iteration: 1,
seq: 0,
timestamp: Date.now(),
delta: 'recovered',
};
yield {
type: MetonaStreamEventType.DONE,
requestId: 'r',
sessionId: 's',
iteration: 1,
seq: 1,
timestamp: Date.now(),
finishReason: 'stop',
};
},
} as unknown as IMetonaProviderAdapter;
const engine = new AgentLoopEngine(
{ maxIterations: 3, totalTimeoutMs: 15_000, retryCount: 2, contextWindow: 128_000 },
adapter,
);
const events: MetonaStreamEvent[] = [];
engine.on('streamEvent', (e: MetonaStreamEvent) => events.push(e));
const output = await engine.runStream(
{ role: 'user', content: 'hi', timestamp: Date.now() },
's1',
[],
SYSTEM_PROMPT,
);
expect(output.terminationReason).toBe('completed');
expect(output.finalAnswer).toBe('recovered');
expect(call).toBe(2);
// 重试经过 RETRY 事件(引擎消费后向渲染层转 STREAM_RESET
expect(events.some((e) => e.type === MetonaStreamEventType.STREAM_RESET)).toBe(true);
});
});
// ===== 3. REASONING_DELTA 中途 abort =====
describe('engine: abort during reasoning stream (P0-5 #4)', () => {
it('思考增量持续输出中 abort → USER_INTERRUPT,迟到 reasoning 不再产出事件', async () => {
const adapter = {
providerId: 'mock' as const,
supportedModels: ['m'],
supportsToolCalling: true,
supportsThinking: true,
getContextWindow: () => 128_000,
async send(): Promise<unknown> {
throw new Error('not used');
},
// 持续每 10ms 推送一个 reasoning delta,直到 abort 或 500 个(挂起)
async *sendStream(_r: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
for (let i = 0; i < 500; i++) {
if (_r.params.thinkingEnabled === false) return;
yield {
type: MetonaStreamEventType.REASONING_DELTA,
requestId: 'r',
sessionId: 's',
iteration: 1,
seq: i,
timestamp: Date.now(),
delta: `t${i};`,
};
await new Promise((r) => setTimeout(r, 10));
}
yield {
type: MetonaStreamEventType.DONE,
requestId: 'r',
sessionId: 's',
iteration: 1,
seq: 999,
timestamp: Date.now(),
};
},
} as unknown as IMetonaProviderAdapter;
const engine = new AgentLoopEngine(
{ maxIterations: 3, totalTimeoutMs: 30_000, retryCount: 0, contextWindow: 128_000 },
adapter,
);
const events: MetonaStreamEvent[] = [];
engine.on('streamEvent', (e: MetonaStreamEvent) => events.push(e));
const run = engine.runStream(
{ role: 'user', content: 'hi', timestamp: Date.now() },
's1',
[],
SYSTEM_PROMPT,
);
// 等待至少 3 个 reasoning delta 到达(确认流确实在思考态输出中)
await new Promise((r) => setTimeout(r, 80));
expect(
events.filter((e) => e.type === MetonaStreamEventType.REASONING_DELTA).length,
).toBeGreaterThanOrEqual(3);
engine.abort();
const output = await run;
expect(output.terminationReason).toBe('user_interrupt');
const lastReasoningIdx = events
.map((e) => e.type)
.lastIndexOf(MetonaStreamEventType.REASONING_DELTA);
const doneIdx = events.findIndex((e) => e.type === MetonaStreamEventType.DONE);
// abort 生效后不再有迟到 reasoning(abort 尾巴过滤在引擎侧)
expect(lastReasoningIdx).toBeLessThan(doneIdx);
}, 15_000);
});
// ===== 4. P4-2: 上一 run 卡死 → 30s 强制 abort + 5s 再等待 → 抛错 =====
describe('engine: stuck previous run force-abort path (P4-2)', () => {
it('上一 run 不响应 abort → 新 run 30s 超时强制 abort → 5s 后仍卡 → 抛错', async () => {
vi.useFakeTimers();
let released = false;
const stuckAdapter = {
providerId: 'mock' as const,
supportedModels: ['m'],
supportsToolCalling: true,
supportsThinking: true,
getContextWindow: () => 128_000,
async send(): Promise<unknown> {
throw new Error('not used');
},
// 第一次 run:永远挂起且不响应 abort(模拟卡死的底层 fetch);
// 挂起生成器无 yield 属场景定义(require-yield 由下行指令豁免)
// eslint-disable-next-line require-yield
async *sendStream(): AsyncIterable<MetonaStreamEvent> {
while (!released) {
await new Promise(() => {});
}
return;
},
} as unknown as IMetonaProviderAdapter;
const engine = new AgentLoopEngine(
{ maxIterations: 3, totalTimeoutMs: 600_000, retryCount: 0, contextWindow: 128_000 },
stuckAdapter,
);
const firstRun = engine.runStream(
{ role: 'user', content: 'first', timestamp: Date.now() },
's1',
[],
SYSTEM_PROMPT,
);
firstRun.catch(() => {});
// 新消息进入 → 等待上一 run 结束(30s 超时)→ 强制 abort → 5s 再等待 → 抛错
const secondRun = engine.runStream(
{ role: 'user', content: 'second', timestamp: Date.now() },
's1',
[],
SYSTEM_PROMPT,
);
const expectation = expect(secondRun).rejects.toThrow(/Previous run did not finish/i);
// 驱动 30s 等待超时 + 5s 强制 abort 等待
await vi.advanceTimersByTimeAsync(30_500);
await vi.advanceTimersByTimeAsync(5_500);
// 清理:放行挂死流,让 firstRun 收敛
released = true;
await expectation;
}, 20_000);
});
+167 -6
View File
@@ -48,6 +48,37 @@ class DeadLoopError extends Error {
} }
} }
/**
* v0.8.0 P0-2: 空响应错误 — 流结束但零产出(无 reasoning、无正文、无工具调用)。
* 属可重试的瞬时故障(上游断流被 [DONE] 兜底合成 / 网关干净空响应),携带
* status=503 使 engine.isRetryableError 的 5xx 分支命中,走指数退避重试;
* 重试耗尽后以 ERROR 收尾并给用户明确文案(此前该形态被静默判定 COMPLETED,
* 即"模型思考着会话就停止"的根因之一)。
*/
export class EmptyResponseError extends Error {
readonly status = 503;
constructor(message: string) {
super(message);
this.name = 'EmptyResponseError';
}
}
/**
* v0.8.0 P0-2: 输出截断错误 — 思考耗尽型截断(reasoning 烧满输出预算、正文零字节,
* finish_reason=length),一次性降级重试(关闭思考)后仍复现时抛出。
* 不可重试(status=400),携带 code=OUTPUT_LENGTH_EXCEEDEDIR 预留错误码首次
* 投产),engine.finish() 据此向前端发送结构化错误;非可重试语义同时触发
* Provider 故障转移(备用 Provider 输出预算更大时即构成自愈)。
*/
export class OutputTruncatedError extends Error {
readonly status = 400;
readonly code = MetonaErrorCode.OUTPUT_LENGTH_EXCEEDED;
constructor(message: string) {
super(message);
this.name = 'OutputTruncatedError';
}
}
const DEFAULT_CONFIG: AgentLoopConfig = { const DEFAULT_CONFIG: AgentLoopConfig = {
maxIterations: 20, maxIterations: 20,
totalTimeoutMs: 600_000, totalTimeoutMs: 600_000,
@@ -88,6 +119,12 @@ export class AgentLoopEngine extends EventEmitter {
private lastRealInputTokens = 0; private lastRealInputTokens = 0;
/** 当前 run 的唯一标识(用于前端过滤旧流事件) */ /** 当前 run 的唯一标识(用于前端过滤旧流事件) */
private runId: string = ''; private runId: string = '';
/**
* v0.8.0 P0-1: 最近一轮 LLM 流的 Provider 原生停止原因(归一化 OpenAI 语义)。
* 由 adapter DONE 事件携带、引擎捕获;finish() 据此在最终 DONE 事件上转发,
* 前端/TRACE 据此区分自然完成与输出上限截断。
*/
private lastIterationFinishReason: string | undefined;
/** 正在进行的 run Promise(用于 run lock,防止并发 run 污染状态) */ /** 正在进行的 run Promise(用于 run lock,防止并发 run 污染状态) */
private currentRunPromise: Promise<AgentLoopOutput> | null = null; private currentRunPromise: Promise<AgentLoopOutput> | null = null;
@@ -269,6 +306,8 @@ export class AgentLoopEngine extends EventEmitter {
this.toolCallHistory = []; this.toolCallHistory = [];
// v0.7.4 P4-1: 重置工具结果历史(进度信号,与调用历史按轮对应) // v0.7.4 P4-1: 重置工具结果历史(进度信号,与调用历史按轮对应)
this.toolResultHistory = []; this.toolResultHistory = [];
// v0.8.0 P0-1: 重置最近一轮停止原因
this.lastIterationFinishReason = undefined;
try { try {
await this.transitionTo(AgentLoopState.INIT); await this.transitionTo(AgentLoopState.INIT);
@@ -316,6 +355,16 @@ export class AgentLoopEngine extends EventEmitter {
const step = await this.executeOneIterationStream(request, sessionId); const step = await this.executeOneIterationStream(request, sessionId);
this.iterations.push(step); this.iterations.push(step);
// v0.8.0 P0-1: 记录最近一轮停止原因(供 finish() 的 DONE 事件转发)
this.lastIterationFinishReason = step.finishReason;
// v0.8.0 P0-2 回归根治: abort 优先级 —— 旧实现该检查位于 while 循环
// 末尾,若 abort 恰逢"零工具调用轮"(如思考中点停止),空轮会先命中
// 下方 COMPLETED 分支,把 USER_INTERRUPT 静默吞成 completed(与新
// 回归测试 engine-stream-reliability 同时发现)。此处前置检查修复。
if (this.aborted) {
return this.finish(TerminationReason.USER_INTERRUPT);
}
// 将 assistant 回复加入消息历史 // 将 assistant 回复加入消息历史
// 崩溃修复(会话停止根因): 原条件 `if (step.thought)` 在模型发起纯工具调用 // 崩溃修复(会话停止根因): 原条件 `if (step.thought)` 在模型发起纯工具调用
@@ -499,13 +548,20 @@ export class AgentLoopEngine extends EventEmitter {
let reasoningContent = ''; let reasoningContent = '';
const toolCallsBuffer = new Map<number, { name: string; argsBuffer: string }>(); const toolCallsBuffer = new Map<number, { name: string; argsBuffer: string }>();
let tokenUsage: TokenUsage | undefined; let tokenUsage: TokenUsage | undefined;
// v0.8.0 P0-1: 本轮流的 Provider 原生停止原因(adapter DONE 携带)
let iterationFinishReason: string | undefined;
// 流式接收响应 // 流式接收响应
for await (const event of this.chatStreamWithRetry(request)) { for await (const event of this.chatStreamWithRetry(request)) {
if (this.aborted) break; if (this.aborted) break;
// 过滤掉每轮的 DONE 事件 — 只在全部迭代完成后发送一个最终 DONE // 过滤掉每轮的 DONE 事件 — 只在全部迭代完成后发送一个最终 DONE
if (event.type === MetonaStreamEventType.DONE) continue; // v0.8.0 P0-1: 先捕获 adapter 携带的 Provider 原生停止原因再过滤 ——
// 引擎与 TRACE 据此区分自然完成与输出上限截断(length 此前完全不可见)
if (event.type === MetonaStreamEventType.DONE) {
if (event.finishReason) iterationFinishReason = event.finishReason;
continue;
}
// 过滤掉 RETRY 类型的 ERROR 事件 — 不转发到前端,避免触发虚假错误 UI // 过滤掉 RETRY 类型的 ERROR 事件 — 不转发到前端,避免触发虚假错误 UI
// RETRY 事件仅用于 Engine 内部清空缓冲区(见下方 switch 分支) // RETRY 事件仅用于 Engine 内部清空缓冲区(见下方 switch 分支)
@@ -598,6 +654,11 @@ export class AgentLoopEngine extends EventEmitter {
// === v0.2.0: PARSING 状态 — 解析流式缓冲区中的工具调用 === // === v0.2.0: PARSING 状态 — 解析流式缓冲区中的工具调用 ===
await this.transitionTo(AgentLoopState.PARSING); await this.transitionTo(AgentLoopState.PARSING);
// v0.8.0 P0-1: 记录本轮停止原因到迭代步骤(TRACE/审计/前端提示的输入)
if (iterationFinishReason) {
step.finishReason = iterationFinishReason;
}
// L-19 修复: 提取 finalizeToolCallsFromBuffer 子方法(PARSING 阶段) // L-19 修复: 提取 finalizeToolCallsFromBuffer 子方法(PARSING 阶段)
this.finalizeToolCallsFromBuffer(step, toolCallsBuffer); this.finalizeToolCallsFromBuffer(step, toolCallsBuffer);
@@ -1031,6 +1092,11 @@ export class AgentLoopEngine extends EventEmitter {
let attempt = 0; let attempt = 0;
let currentAdapter = this.adapter; let currentAdapter = this.adapter;
let failoverUsed = false; let failoverUsed = false;
// v0.8.0 P0-2: 降级重试状态。思考耗尽型截断(reasoning 烧满输出预算、正文
// 零字节)时以 thinkingEnabled=false 重发一次(每 run 最多一次);activeRequest
// 被替换后,后续的重试与故障转移均使用降级版请求(降级跨故障转移保持)。
let degradedThinkingRetryUsed = false;
let activeRequest = request;
while (true) { while (true) {
try { try {
@@ -1038,9 +1104,9 @@ export class AgentLoopEngine extends EventEmitter {
// 重试时:先发送一个 retry 事件,让 UI 清空已接收的 delta // 重试时:先发送一个 retry 事件,让 UI 清空已接收的 delta
yield { yield {
type: MetonaStreamEventType.ERROR, type: MetonaStreamEventType.ERROR,
requestId: request.meta.requestId, requestId: activeRequest.meta.requestId,
sessionId: request.meta.sessionId, sessionId: activeRequest.meta.sessionId,
iteration: request.meta.iteration, iteration: activeRequest.meta.iteration,
seq: 0, seq: 0,
timestamp: Date.now(), timestamp: Date.now(),
error: { error: {
@@ -1050,7 +1116,86 @@ export class AgentLoopEngine extends EventEmitter {
}, },
}; };
} }
yield* currentAdapter.sendStream(request); // v0.8.0 P0-2: 逐事件跟踪内容产出与停止原因(原 yield* 直通改显式转发)。
// yield* 正常返回后执行流结束契约检查;异常路径不进入检查(走 catch 重试)。
// - sawThought: 思考有产出(REASONING_DELTA
// - sawOutput: 正文/工具调用有产出(TEXT_DELTA / TOOL_CALL_*
// - lastFinishReason: adapter DONE 携带的 Provider 原生停止原因
let sawThought = false;
let sawOutput = false;
let lastFinishReason: string | undefined;
for await (const event of currentAdapter.sendStream(activeRequest)) {
if (event.type === MetonaStreamEventType.REASONING_DELTA) {
sawThought = true;
}
if (
event.type === MetonaStreamEventType.TEXT_DELTA ||
event.type === MetonaStreamEventType.TOOL_CALL_DELTA ||
event.type === MetonaStreamEventType.TOOL_CALL_COMPLETE
) {
sawOutput = true;
}
if (event.type === MetonaStreamEventType.DONE && event.finishReason) {
lastFinishReason = event.finishReason;
}
yield event;
}
// ===== 流结束契约检查(根治"空回复 → 静默 COMPLETED → 会话无声停止"=====
if (!sawOutput) {
if (!sawThought) {
// 零产出流:连接中断被 [DONE] 兜底合成 / 上游干净空响应 —— 可重试
// 瞬时故障(status=503 命中 5xx 重试分支),重试耗尽后 ERROR 收尾
throw new EmptyResponseError(
'Model returned an empty stream (no reasoning, no text, no tool calls). ' +
'This is usually caused by an upstream connection drop or an output-limit cutoff.',
);
}
if (lastFinishReason === 'length') {
// 思考耗尽型截断(v0.7.4 生产事故形态:finish_reason=length、正文零
// 字节)—— 先做一次性降级重试(临时关闭思考重发),仍复现则终止。
if (!degradedThinkingRetryUsed && activeRequest.params.thinkingEnabled === true) {
degradedThinkingRetryUsed = true;
attempt = 0;
activeRequest = {
...activeRequest,
params: { ...activeRequest.params, thinkingEnabled: false },
};
log.warn(
'[AgentLoop] Output budget exhausted by reasoning (finish_reason=length, empty content) — retrying once with thinking disabled',
);
yield {
type: MetonaStreamEventType.ERROR,
requestId: activeRequest.meta.requestId,
sessionId: activeRequest.meta.sessionId,
iteration: activeRequest.meta.iteration,
seq: 0,
timestamp: Date.now(),
error: {
code: MetonaErrorCode.RETRY,
message: 'Thinking consumed the output budget — retrying with thinking disabled',
retryable: true,
},
};
continue;
}
// 降级重试不可用(已用 / 思考本就关闭)仍截断 —— 终止:携带 IR 预留
// 的 OUTPUT_LENGTH_EXCEEDED 码,finish() 映射为结构化错误事件,
// 非 retryable 同时触发 Provider 故障转移(备用预算更大即自愈)。
throw new OutputTruncatedError(
'The model consumed the entire output token budget on reasoning and produced no answer ' +
'(finish_reason=length). Raise the max output limit, disable thinking, or switch to a model with a larger output budget.',
);
}
// 思考有产出但自然结束且零正文(罕见的服务端异常收尾)—— 与零产出流
// 同按可重试空响应处理(retryCount 兜底防循环)
throw new EmptyResponseError(
'Model stream ended after reasoning without any answer content.',
);
}
// sawOutput=true:正文/工具调用已有产出。length 截断场景不再重试 ——
// 工具参数截断由 _truncatedArguments 自愈(v0.6.3 契约)、正文截断由
// DONE.finishReason='length' 驱动前端截断提醒(P0-4)。
return; return;
} catch (error) { } catch (error) {
if (this.aborted) throw error; if (this.aborted) throw error;
@@ -1136,6 +1281,11 @@ export class AgentLoopEngine extends EventEmitter {
/** 判断错误是否可重试 */ /** 判断错误是否可重试 */
private isRetryableError(error: unknown): boolean { private isRetryableError(error: unknown): boolean {
// v0.8.0 P0-2: 流结束契约错误的显式归类(EmptyResponseError 自带 status=503、
// OutputTruncatedError 自带 status=400,本可由下方 5xx/4xx 分支覆盖;此处
// 显式 instanceof 使两类新错误的语义不依赖 status 字段约定)
if (error instanceof EmptyResponseError) return true;
if (error instanceof OutputTruncatedError) return false;
const err = error as { status?: number; code?: string; message?: string }; const err = error as { status?: number; code?: string; message?: string };
// 429 Too Many Requests — 可重试 // 429 Too Many Requests — 可重试
if (err.status === 429) return true; if (err.status === 429) return true;
@@ -1482,9 +1632,17 @@ export class AgentLoopEngine extends EventEmitter {
if ((reason === TerminationReason.ERROR || reason === TerminationReason.DEAD_LOOP) && error) { if ((reason === TerminationReason.ERROR || reason === TerminationReason.DEAD_LOOP) && error) {
// v0.3.17: 识别 ContentFilterError,映射为 CONTENT_FILTERED 错误码 + 友好消息 // v0.3.17: 识别 ContentFilterError,映射为 CONTENT_FILTERED 错误码 + 友好消息
// v0.6.4: 流式路径的拦截以 err.code='content_filtered' 抵达(无 instanceof 上下文),一并识别 // v0.6.4: 流式路径的拦截以 err.code='content_filtered' 抵达(无 instanceof 上下文),一并识别
// v0.8.0 P0-2: 透传流结束契约错误的结构化错误码(OUTPUT_LENGTH_EXCEEDED 等),
// 前端据此展示针对性文案而非泛化 UNKNOWN
const isContentFilter = const isContentFilter =
error instanceof ContentFilterError || error instanceof ContentFilterError ||
(error as Error & { code?: string }).code === MetonaErrorCode.CONTENT_FILTERED; (error as Error & { code?: string }).code === MetonaErrorCode.CONTENT_FILTERED;
const rawErrorCode = (error as Error & { code?: string }).code;
const errorCode = isContentFilter
? MetonaErrorCode.CONTENT_FILTERED
: rawErrorCode === MetonaErrorCode.OUTPUT_LENGTH_EXCEEDED
? MetonaErrorCode.OUTPUT_LENGTH_EXCEEDED
: MetonaErrorCode.UNKNOWN;
this.emit('streamEvent', { this.emit('streamEvent', {
type: MetonaStreamEventType.ERROR, type: MetonaStreamEventType.ERROR,
requestId: this.currentRequestId, requestId: this.currentRequestId,
@@ -1494,7 +1652,7 @@ export class AgentLoopEngine extends EventEmitter {
timestamp: Date.now(), timestamp: Date.now(),
runId: this.runId, runId: this.runId,
error: { error: {
code: isContentFilter ? MetonaErrorCode.CONTENT_FILTERED : MetonaErrorCode.UNKNOWN, code: errorCode,
message: isContentFilter message: isContentFilter
? '内容被 Provider 安全审核拦截,请修改图片或文本后重试' ? '内容被 Provider 安全审核拦截,请修改图片或文本后重试'
: error.message, : error.message,
@@ -1513,6 +1671,9 @@ export class AgentLoopEngine extends EventEmitter {
timestamp: Date.now(), timestamp: Date.now(),
runId: this.runId, runId: this.runId,
terminationReason: reason, terminationReason: reason,
// v0.8.0 P0-1: 转发最后一轮的 Provider 原生停止原因(前端据此展示
// "回答可能因输出上限被截断"等提示;无流式轮次时为 undefined)
...(this.lastIterationFinishReason ? { finishReason: this.lastIterationFinishReason } : {}),
}); });
// H-3: 通过 stateChange 发射 TERMINATED 状态(前端可据此清理 UI) // H-3: 通过 stateChange 发射 TERMINATED 状态(前端可据此清理 UI)
+2
View File
@@ -1,4 +1,6 @@
export { AgentLoopEngine } from './engine'; export { AgentLoopEngine } from './engine';
// v0.8.0 P0-2: 流结束契约错误(空响应重试 / 思考耗尽截断终止)—— 导出供测试断言
export { EmptyResponseError, OutputTruncatedError } from './engine';
export { export {
AgentLoopState, AgentLoopState,
TerminationReason, TerminationReason,
+7
View File
@@ -48,6 +48,13 @@ export interface IterationStep {
toolCalls?: MetonaToolCall[]; toolCalls?: MetonaToolCall[];
toolResults?: MetonaToolResult[]; toolResults?: MetonaToolResult[];
tokenUsage?: TokenUsage; tokenUsage?: TokenUsage;
/**
* v0.8.0 P0-1: 本轮 LLM 流的 Provider 原生停止原因(归一化 OpenAI 语义:
* 'stop' | 'length' | 'tool_calls' | 'content_filter')。
* 由 adapter DONE 事件携带、引擎捕获;TRACE 层据此记录真值,
* 引擎据此区分"自然完成"与"输出上限截断"(P0-2 空响应守卫的输入)。
*/
finishReason?: string;
} }
export interface TokenUsage { export interface TokenUsage {
@@ -248,7 +248,9 @@ describe('SecurityScanHook — 分级防护矩阵', () => {
const hook = new SecurityScanHook(asDefender(sd)); const hook = new SecurityScanHook(asDefender(sd));
await hook.afterExecute(toolCall('web_fetch'), failRes, 's'); await hook.afterExecute(toolCall('web_fetch'), failRes, 's');
await hook.afterExecute(toolCall('web_fetch'), zeroScoreRes, 's'); await hook.afterExecute(toolCall('web_fetch'), zeroScoreRes, 's');
expect(failRes.result).toBe(failRes.result); // v0.8.0 P3 根治: 旧断言 failRes.result 自比恒真 —— 现断言"失败结果不触发
// 扫描、内容保持原样"(与下方 zero-score 计数互补)
expect(failRes.result).toBe(longText('__low_score_'));
expect( expect(
sd.detectSemantic.mock.calls.filter((c: unknown[]) => String(c[0]).includes('__zero')).length, sd.detectSemantic.mock.calls.filter((c: unknown[]) => String(c[0]).includes('__zero')).length,
).toBe(1); ).toBe(1);
@@ -157,6 +157,8 @@ describe.skipIf(!dbAvailable)('MemoryManager — tokenize 分词', () => {
it('数字/纯符号文本产生空 token 列表(search 返回空)', () => { it('数字/纯符号文本产生空 token 列表(search 返回空)', () => {
const tokens = tokensOf('12345 !@#'); const tokens = tokensOf('12345 !@#');
expect(Array.isArray(tokens)).toBe(true); expect(Array.isArray(tokens)).toBe(true);
// v0.8.0 P3: 补强断言 —— 旧断言只验数组类型未验为空
expect(tokens).toHaveLength(0);
}); });
}); });
@@ -39,12 +39,24 @@ const seenRequests: MetonaRequest[] = [];
function doneOnlyStream(request: MetonaRequest): AsyncGenerator<MetonaStreamEvent> { function doneOnlyStream(request: MetonaRequest): AsyncGenerator<MetonaStreamEvent> {
return (async function* () { return (async function* () {
// v0.8.0 P0-2 契约对齐: 引擎的流结束契约要求至少有一个产出事件
//TEXT/REASONING/TOOL_CALL),纯 DONE 空流会被 EmptyResponseError 守卫
// 判为可重试空响应并触发退避重试。此处补一个 TEXT_DELTA 使流构成合法完成。
yield {
type: MetonaStreamEventType.TEXT_DELTA,
requestId: request.meta.requestId,
sessionId: request.meta.sessionId,
iteration: request.meta.iteration,
seq: 0,
timestamp: Date.now(),
delta: 'sub-agent done',
} as MetonaStreamEvent;
yield { yield {
type: MetonaStreamEventType.DONE, type: MetonaStreamEventType.DONE,
requestId: request.meta.requestId, requestId: request.meta.requestId,
sessionId: request.meta.sessionId, sessionId: request.meta.sessionId,
iteration: request.meta.iteration, iteration: request.meta.iteration,
seq: 0, seq: 1,
timestamp: Date.now(), timestamp: Date.now(),
} as MetonaStreamEvent; } as MetonaStreamEvent;
})(); })();
@@ -314,8 +326,10 @@ describe('TaskOrchestrator — 中断契约', () => {
releases.forEach((r) => r()); releases.forEach((r) => r());
const results = await Promise.all([a, b]); const results = await Promise.all([a, b]);
// A 被中断、B 正常完成,两者都最终收尾 // A 被中断、B 正常完成,两者都最终收尾
expect(results.map((r) => r.success)).toEqual([true, true]); // v0.8.0 P0-2 abort 优先级修复后:被中断的 A 以 USER_INTERRUPT 终止 →
// success=false(旧实现 COMPLETED 抢占使 A 误报 success=true,属缺陷语义)。
expect(results.map((r) => r.success)).toEqual([false, true]);
expect(orchestrator.getActiveAgentsStatus()).toHaveLength(0); expect(orchestrator.getActiveAgentsStatus()).toHaveLength(0);
}); });
@@ -129,6 +129,12 @@ export class TaskOrchestrator extends EventEmitter {
parentSessionId: string; parentSessionId: string;
maxIterations?: number; maxIterations?: number;
tools?: string[]; tools?: string[];
/**
* v0.8.0 P1-2: 引擎级取消信号 delegate_task context.signal
* SubEngine registry
* SubEngine
*/
signal?: AbortSignal;
}): Promise<SubAgentResult> { }): Promise<SubAgentResult> {
const taskId = params.taskId ?? `sub_${nanoid(8)}`; const taskId = params.taskId ?? `sub_${nanoid(8)}`;
const startMs = Date.now(); const startMs = Date.now();
@@ -183,6 +189,18 @@ export class TaskOrchestrator extends EventEmitter {
subEngine.setFallbackAdapter(this.engines.getFallbackAdapter()); subEngine.setFallbackAdapter(this.engines.getFallbackAdapter());
subEngine.setWorkspacePath(this.engines.getWorkspacePath()); subEngine.setWorkspacePath(this.engines.getWorkspacePath());
// v0.8.0 P1-2: 外部取消信号联动 —— signal 已中止或运行中触发 abort 时
// 终止 SubEnginefinally 中解除监听防泄漏
let onExternalAbort: (() => void) | null = null;
if (params.signal) {
if (params.signal.aborted) {
subEngine.abort();
} else {
onExternalAbort = () => subEngine.abort();
params.signal.addEventListener('abort', onExternalAbort, { once: true });
}
}
// v0.5.0: 转发 SubEngine 的流式/状态事件(供 TRACE 录制与前端可观测) // v0.5.0: 转发 SubEngine 的流式/状态事件(供 TRACE 录制与前端可观测)
// 事件以 taskId 为 sessionId,录制到独立 JSONL 文件,不污染父会话的流 // 事件以 taskId 为 sessionId,录制到独立 JSONL 文件,不污染父会话的流
this.forwardSubEngineEvents(subEngine, taskId); this.forwardSubEngineEvents(subEngine, taskId);
@@ -283,6 +301,10 @@ export class TaskOrchestrator extends EventEmitter {
return result; return result;
} finally { } finally {
// v0.8.0 P1-2: 解除外部取消信号监听(防事件循环泄漏)
if (params.signal && onExternalAbort) {
params.signal.removeEventListener('abort', onExternalAbort);
}
// #5 修复: 统一在 finally 块恢复 sessionDepth,覆盖正常完成/异常/abort 所有路径 // #5 修复: 统一在 finally 块恢复 sessionDepth,覆盖正常完成/异常/abort 所有路径
// 审查修复: 如果 abortAll 已 clear sessionDepth,不再恢复(避免覆盖紧急清理)。 // 审查修复: 如果 abortAll 已 clear sessionDepth,不再恢复(避免覆盖紧急清理)。
// 场景:用户紧急中断时 abortAll 先 clear,若 SubEngine 随后才返回执行 finally // 场景:用户紧急中断时 abortAll 先 clear,若 SubEngine 随后才返回执行 finally
@@ -104,16 +104,13 @@ describe('ToolRegistry.truncateResult', () => {
expect(truncate(shot)).toBe(shot); expect(truncate(shot)).toBe(shot);
}); });
it('WEBP 魔数(RIFF....WEBP)的裸 base64 放行', () => { it('WEBP 魔数(RIFF....WEBP)的裸 base64 放行(v0.8.0 P3: 单向断言替代恒真分支)', () => {
const shot = { image: `UklGRg==${'A'.repeat(200_000)}WEBP` }; // v0.8.0 P3 根治: 旧断言按 _truncated 分支双向放行(无论实现如何都通过,恒真)。
// RIFF 头 base64 'UklGRg==' 解码为 'RIFF\x00\x00\x00\x00'WEBP 魔数在 8-11 字节 // 现构造 8-11 字节确为 'WEBP' 的确定性载荷:base64('RIFF\0\0\0\0WEBP') 前缀,
const result = truncate(shot) as { image?: string; _truncated?: boolean }; // isInlineImagePayload 必命中白名单 → 整段放行。
if (result._truncated === true) { const head = Buffer.from('RIFF\0\0\0\0WEBP', 'latin1').toString('base64');
// WEBP 魔数未命中(base64 头部 64 字节内未含完整 RIFF+WEBP)→ 走常规截断(保守) const shot = { image: head + 'A'.repeat(200_000) };
expect(result.image).toBeUndefined(); expect(truncate(shot)).toBe(shot);
} else {
expect(result).toBe(shot);
}
}); });
it('image 字段但非图片内容(普通长文本)仍按常规 50KB 截断(堵住旧白名单漏洞)', () => { it('image 字段但非图片内容(普通长文本)仍按常规 50KB 截断(堵住旧白名单漏洞)', () => {
@@ -248,22 +248,25 @@ describe('filesystem 工具 — read_file', () => {
expect(String(r.content).charCodeAt(0)).not.toBe(0xfeff); expect(String(r.content).charCodeAt(0)).not.toBe(0xfeff);
}); });
it('UTF-16LE 文件被二进制检测拒绝(0x00 字节触发,编码探测死代码', async () => { it('UTF-16LE 文件正常读取(v0.8.0 P1-3.3 BOM 检测根治后契约', async () => {
// 已知源码缺陷:isBinaryFile 的 NUL 字节检测拒绝一切 UTF-16 文件, // v0.8.0 P1-3.3 根治: isBinaryFile 此前的 NUL 字节启发式拒绝一切 UTF-16
// decodeBufferWithDetection 的 UTF-16 分支因此不可达。此处锁定实际契约。 // 文件,decodeBufferWithDetection 的 UTF-16 分支不可达(原缺陷被本测试
// 锁定留档)。现 UTF-16 BOMFF FE / FE FF)视为文本,编码探测生效。
const r = (await tool.execute({ file_path: 'utf16le.txt' }, ctxFor(ws))) as { const r = (await tool.execute({ file_path: 'utf16le.txt' }, ctxFor(ws))) as {
success: boolean; success: boolean;
error?: string; content?: string;
encoding?: string;
}; };
expect(r.success).toBe(false); expect(r.success).toBe(true);
expect(String((r as { error?: string }).error)).toContain('Binary'); expect(r.encoding).toBe('utf-16le');
}); });
it('UTF-16BE 文件同样被二进制检测拒绝(实况契约', async () => { it('UTF-16BE 文件正常读取(v0.8.0 P1-3.3', async () => {
const r = (await tool.execute({ file_path: 'utf16be.txt' }, ctxFor(ws))) as { const r = (await tool.execute({ file_path: 'utf16be.txt' }, ctxFor(ws))) as {
success: boolean; success: boolean;
content?: string;
}; };
expect(r.success).toBe(false); expect(r.success).toBe(true);
}); });
it('GBK 字节样本 → 降级 gbk 编码并正确解码', async () => { it('GBK 字节样本 → 降级 gbk 编码并正确解码', async () => {
@@ -11,6 +11,9 @@ import { BrowserWindow, session } from 'electron';
import log from 'electron-log'; import log from 'electron-log';
// v0.7.3 P2-2: CORS Origin 回显(纯函数在 network-utils,可表测) // v0.7.3 P2-2: CORS Origin 回显(纯函数在 network-utils,可表测)
import { corsAllowOrigin, extractOriginHeader } from './network-utils'; import { corsAllowOrigin, extractOriginHeader } from './network-utils';
// v0.8.0 P1-4: 浏览器通道 SSRF 根治 —— 分区流量走本地 Pinned CONNECT 代理
import { ensurePinnedProxy } from '../../../utils/pinned-proxy';
import { isProxyActive } from '../../../utils/network-proxy';
/** Agent 浏览器专用 session partition — 与主应用 default session 完全隔离 */ /** Agent 浏览器专用 session partition — 与主应用 default session 完全隔离 */
const AGENT_PARTITION = 'persist:metona-agent-browser'; const AGENT_PARTITION = 'persist:metona-agent-browser';
@@ -146,6 +149,28 @@ export class BrowserWindowManager {
// 跨域 Origin / 无 Origin 一律不加 ACAO 头 —— 保持默认同源策略阻止读取, // 跨域 Origin / 无 Origin 一律不加 ACAO 头 —— 保持默认同源策略阻止读取,
// 彻底堵死"第三方页面借该分区凭据读取跨域资源"的通道。 // 彻底堵死"第三方页面借该分区凭据读取跨域资源"的通道。
const agentSession = session.fromPartition(AGENT_PARTITION); const agentSession = session.fromPartition(AGENT_PARTITION);
// v0.8.0 P1-4 根治: 浏览器通道 SSRF —— 分区全部流量经本地 Pinned CONNECT
// 代理(入口同源校验 + 校验期 IP pinningChromium 端 TLS/SNI 端到端不变),
// 关闭"入口校验通过 → Chromium 独立 DNS 解析"之间的 rebinding 残余窗口。
// 用户已显式配置 network.proxyUrl 时不启用(代理模式下 DNS 在代理端解析、
// pinning 不可实现,保持 M7 已知限制口径);代理启动失败降级可用性优先。
if (!isProxyActive()) {
const proxyPort = await ensurePinnedProxy();
if (proxyPort) {
try {
await agentSession.setProxy({
proxyRules: `http=127.0.0.1:${proxyPort};https=127.0.0.1:${proxyPort}`,
});
log.info(
`[BrowserWindowManager] agent partition routed via pinned proxy :${proxyPort}`,
);
} catch (err) {
log.warn('[BrowserWindowManager] pinned proxy setProxy failed:', err);
}
}
}
agentSession.webRequest.onHeadersReceived((details, callback) => { agentSession.webRequest.onHeadersReceived((details, callback) => {
// Electron 类型在此版本的 OnHeadersReceivedListenerDetails 上不暴露 // Electron 类型在此版本的 OnHeadersReceivedListenerDetails 上不暴露
// requestHeaders —— 显式声明读取面(Origin 大小写不敏感提取) // requestHeaders —— 显式声明读取面(Origin 大小写不敏感提取)
@@ -308,12 +333,35 @@ export class BrowserWindowManager {
* open/evaluate * open/evaluate
* openChain 使 * openChain 使
* *
* v0.8.0 P1-2: 新增可选 abort signal webContents.stop
* openevaluate
*
* @returns / null * @returns / null
*/ */
async fetchPageText(url: string): Promise<string | null> { async fetchPageText(url: string, signal?: AbortSignal): Promise<string | null> {
const run = async (): Promise<string | null> => { const run = async (): Promise<string | null> => {
const throwIfAborted = (): void => {
if (signal?.aborted) throw new Error('Browser fetch aborted');
};
// abort 时停止页面加载 —— loadURL/evaluate 会以 ERR_ABORTED 拒绝,
// 序列随之终止(窗口由单例管理,不在此销毁)
let onAbort: (() => void) | null = null;
if (signal && !signal.aborted) {
onAbort = () => {
try {
this.win?.webContents.stop();
} catch {
/* 窗口可能已销毁 */
}
};
signal.addEventListener('abort', onAbort, { once: true });
}
try {
throwIfAborted();
await this.openInternal({ url }); await this.openInternal({ url });
throwIfAborted();
await this.sleep(2_500); await this.sleep(2_500);
throwIfAborted();
const text = (await this.evaluate(` const text = (await this.evaluate(`
(function() { (function() {
var clone = document.body.cloneNode(true); var clone = document.body.cloneNode(true);
@@ -322,7 +370,11 @@ export class BrowserWindowManager {
return clone.innerText || ''; return clone.innerText || '';
})(); })();
`)) as string; `)) as string;
throwIfAborted();
return text && text.trim() ? text : null; return text && text.trim() ? text : null;
} finally {
if (signal && onAbort) signal.removeEventListener('abort', onAbort);
}
}; };
this.openChain = this.openChain.then(run, run); this.openChain = this.openChain.then(run, run);
return this.openChain as Promise<string | null>; return this.openChain as Promise<string | null>;
+75 -12
View File
@@ -40,7 +40,8 @@ export class CodeSearchTool implements IMetonaTool {
readonly definition: MetonaToolDef = { readonly definition: MetonaToolDef = {
name: 'code_search', name: 'code_search',
description: 'Search code using ripgrep. Supports regex patterns, file type filtering, and context lines. Much faster than search_files for large codebases. Falls back to JS implementation if ripgrep is not installed.', description:
'Search code using ripgrep. Supports regex patterns, file type filtering, and context lines. Much faster than search_files for large codebases. Falls back to JS implementation if ripgrep is not installed.',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
@@ -48,8 +49,14 @@ export class CodeSearchTool implements IMetonaTool {
path: { type: 'string', description: 'Search directory (default: workspace root)' }, path: { type: 'string', description: 'Search directory (default: workspace root)' },
file_glob: { type: 'string', description: 'File name glob filter (e.g., "*.ts", "*.py")' }, file_glob: { type: 'string', description: 'File name glob filter (e.g., "*.ts", "*.py")' },
case_sensitive: { type: 'boolean', description: 'Case sensitive search (default false)' }, case_sensitive: { type: 'boolean', description: 'Case sensitive search (default false)' },
context_before: { type: 'number', description: 'Lines of context before match (default 0, max 5)' }, context_before: {
context_after: { type: 'number', description: 'Lines of context after match (default 0, max 5)' }, type: 'number',
description: 'Lines of context before match (default 0, max 5)',
},
context_after: {
type: 'number',
description: 'Lines of context after match (default 0, max 5)',
},
max_results: { type: 'number', description: 'Maximum results (default 50, max 200)' }, max_results: { type: 'number', description: 'Maximum results (default 50, max 200)' },
}, },
required: ['pattern'], required: ['pattern'],
@@ -98,7 +105,13 @@ export class CodeSearchTool implements IMetonaTool {
private async searchWithRipgrep( private async searchWithRipgrep(
pattern: string, pattern: string,
searchPath: string, searchPath: string,
opts: { fileGlob?: string; caseSensitive: boolean; contextBefore: number; contextAfter: number; maxResults: number }, opts: {
fileGlob?: string;
caseSensitive: boolean;
contextBefore: number;
contextAfter: number;
maxResults: number;
},
context: ToolExecutionContext, context: ToolExecutionContext,
): Promise<unknown> { ): Promise<unknown> {
const rgArgs: string[] = ['--json']; const rgArgs: string[] = ['--json'];
@@ -118,12 +131,31 @@ export class CodeSearchTool implements IMetonaTool {
const { stdout } = await execFileAsync('rg', rgArgs, { const { stdout } = await execFileAsync('rg', rgArgs, {
maxBuffer: 10 * 1024 * 1024, maxBuffer: 10 * 1024 * 1024,
timeout: 25_000, timeout: 25_000,
// v0.8.0 P1-2: 引擎级 abort 信号 —— 用户中断会话时立即 kill rg 子进程
//Node execFile 原生支持 signal 选项,触发时以 AbortError 拒绝)
signal: context.signal,
}); });
const results = this.parseRipgrepJsonOutput(stdout); const results = this.parseRipgrepJsonOutput(stdout);
return { results: results.slice(0, opts.maxResults), count: results.length, engine: 'ripgrep' }; return {
results: results.slice(0, opts.maxResults),
count: results.length,
engine: 'ripgrep',
};
} catch (error) { } catch (error) {
const err = error as { code?: number; signal?: string; stdout?: string; stderr?: string; killed?: boolean; message?: string }; const err = error as {
code?: number;
signal?: string;
stdout?: string;
stderr?: string;
killed?: boolean;
message?: string;
name?: string;
};
// v0.8.0 P1-2: 引擎 abort → 直接以中断语义返回(不再回退 JS 搜索)
if (err.name === 'AbortError' || context.signal?.aborted) {
return { results: [], count: 0, error: 'Search aborted', engine: 'ripgrep', aborted: true };
}
// rg 退出码 1 = 无匹配,不是错误 // rg 退出码 1 = 无匹配,不是错误
if (err.code === 1) { if (err.code === 1) {
return { results: [], count: 0, engine: 'ripgrep' }; return { results: [], count: 0, engine: 'ripgrep' };
@@ -148,10 +180,24 @@ export class CodeSearchTool implements IMetonaTool {
before?: string[]; before?: string[];
after?: string[]; after?: string[];
}> { }> {
const results: Array<{ path: string; line: number; column: number; match: string; before?: string[]; after?: string[] }> = []; const results: Array<{
path: string;
line: number;
column: number;
match: string;
before?: string[];
after?: string[];
}> = [];
const lines = output.split('\n').filter((l) => l.trim()); const lines = output.split('\n').filter((l) => l.trim());
let currentMatch: { path: string; line: number; column: number; match: string; before?: string[]; after?: string[] } | null = null; let currentMatch: {
path: string;
line: number;
column: number;
match: string;
before?: string[];
after?: string[];
} | null = null;
let beforeBuffer: string[] = []; let beforeBuffer: string[] = [];
let afterBuffer: string[] = []; let afterBuffer: string[] = [];
@@ -185,7 +231,9 @@ export class CodeSearchTool implements IMetonaTool {
} }
const text = (data.lines as { text?: string } | undefined)?.text ?? ''; const text = (data.lines as { text?: string } | undefined)?.text ?? '';
const submatches = (data.submatches as Array<{ match: { text?: string }; start?: number }> | undefined) ?? []; const submatches =
(data.submatches as Array<{ match: { text?: string }; start?: number }> | undefined) ??
[];
const matchText = submatches[0]?.match?.text ?? text; const matchText = submatches[0]?.match?.text ?? text;
const column = (submatches[0]?.start ?? 0) + 1; const column = (submatches[0]?.start ?? 0) + 1;
@@ -214,18 +262,33 @@ export class CodeSearchTool implements IMetonaTool {
private async searchWithJs( private async searchWithJs(
pattern: string, pattern: string,
searchPath: string, searchPath: string,
opts: { fileGlob?: string; caseSensitive: boolean; contextBefore: number; contextAfter: number; maxResults: number }, opts: {
fileGlob?: string;
caseSensitive: boolean;
contextBefore: number;
contextAfter: number;
maxResults: number;
},
context: ToolExecutionContext, context: ToolExecutionContext,
): Promise<unknown> { ): Promise<unknown> {
// 动态导入以避免循环依赖 // 动态导入以避免循环依赖
const { SearchFilesTool } = await import('./filesystem'); const { SearchFilesTool } = await import('./filesystem');
const searchTool = new SearchFilesTool(); const searchTool = new SearchFilesTool();
return searchTool.execute({ // v0.8.0 P1-3.5 根治: 回退路径此前静默丢失 case_sensitive / context_before /
// context_after —— 同一调用在 ripgrep 存在与否会产生不同结果(行为不对称)。
// SearchFilesTool 现已支持这三个参数(filesystem.ts P1-3.5),原样透传。
return searchTool.execute(
{
pattern, pattern,
target: 'content', target: 'content',
path: searchPath, path: searchPath,
file_glob: opts.fileGlob, file_glob: opts.fileGlob,
limit: opts.maxResults, limit: opts.maxResults,
}, context); case_sensitive: opts.caseSensitive,
context_before: opts.contextBefore,
context_after: opts.contextAfter,
},
context,
);
} }
} }
@@ -15,18 +15,21 @@ import type { TaskOrchestrator } from '../../orchestration/orchestrator';
export class DelegateTaskTool implements IMetonaTool { export class DelegateTaskTool implements IMetonaTool {
readonly definition: MetonaToolDef = { readonly definition: MetonaToolDef = {
name: 'delegate_task', name: 'delegate_task',
description: 'Delegate a sub-task to an independent SubAgent for parallel or isolated execution. The SubAgent runs its own ReAct loop and returns the final result. Use this for complex sub-tasks that benefit from focused reasoning.', description:
'Delegate a sub-task to an independent SubAgent for parallel or isolated execution. The SubAgent runs its own ReAct loop and returns the final result. Use this for complex sub-tasks that benefit from focused reasoning.',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
description: { description: {
type: 'string', type: 'string',
description: 'Clear and detailed description of the sub-task to delegate. This will be the SubAgent\'s user message.', description:
"Clear and detailed description of the sub-task to delegate. This will be the SubAgent's user message.",
}, },
tools: { tools: {
type: 'array', type: 'array',
items: { type: 'string', description: 'Tool name' }, items: { type: 'string', description: 'Tool name' },
description: 'Optional whitelist of tool names the SubAgent can use (e.g. ["read_file", "web_search"]). If omitted, SubAgent inherits all tools.', description:
'Optional whitelist of tool names the SubAgent can use (e.g. ["read_file", "web_search"]). If omitted, SubAgent inherits all tools.',
}, },
maxIterations: { maxIterations: {
type: 'number', type: 'number',
@@ -58,6 +61,8 @@ export class DelegateTaskTool implements IMetonaTool {
parentSessionId: context.sessionId, parentSessionId: context.sessionId,
maxIterations, maxIterations,
tools, tools,
// v0.8.0 P1-2: 引擎级 abort 信号透传 —— 用户中断会话时联动终止 SubAgent
signal: context.signal,
}); });
return { return {
@@ -19,6 +19,7 @@
import { readFile, writeFile, rename, mkdir, stat, unlink } from 'fs/promises'; import { readFile, writeFile, rename, mkdir, stat, unlink } from 'fs/promises';
import { dirname } from 'path'; import { dirname } from 'path';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { nanoid } from 'nanoid';
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';
@@ -422,7 +423,8 @@ export class FileEditorTool implements IMetonaTool {
const newContent = newLines.join('\n'); const newContent = newLines.join('\n');
// F1.7: 原子写入 - 临时文件 + rename // F1.7: 原子写入 - 临时文件 + rename
const tmpPath = `${filePath}.tmp_${Date.now()}`; // v0.8.0 P1-3.4: tmp 文件名追加 nanoid 随机段(同毫秒并发写防碰撞)
const tmpPath = `${filePath}.tmp_${Date.now()}_${nanoid(6)}`;
try { try {
await writeFile(tmpPath, newContent, 'utf-8'); await writeFile(tmpPath, newContent, 'utf-8');
await rename(tmpPath, filePath); await rename(tmpPath, filePath);
+71 -10
View File
@@ -38,6 +38,7 @@ import {
} from 'fs/promises'; } from 'fs/promises';
import { join, relative, resolve, dirname } from 'path'; import { join, relative, resolve, dirname } from 'path';
import { existsSync, realpathSync } from 'fs'; import { existsSync, realpathSync } from 'fs';
import { nanoid } from 'nanoid';
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';
@@ -61,6 +62,13 @@ import {
* v0.3.2 FAIL: 使用 bytesRead buffer.length * v0.3.2 FAIL: 使用 bytesRead buffer.length
* Buffer.alloc(8192) 0fd.read [0, bytesRead) * Buffer.alloc(8192) 0fd.read [0, bytesRead)
* buffer.length < 8192 * buffer.length < 8192
*
* v0.8.0 P1-3.3 根治: UTF-16 LE: FF FE / BE: FE FF BOM
* 0x00 NULL UTF-16
* 使 decodeBufferWithDetection UTF-16
* filesystem-tools.test.ts UTF-16
* BOMUTF-16 decodeBufferWithDetection
* BOM NULL
*/ */
async function isBinaryFile(filePath: string): Promise<boolean> { async function isBinaryFile(filePath: string): Promise<boolean> {
// M-20 修复: 使用 finally 块确保文件句柄关闭,防止 fd 泄漏 // M-20 修复: 使用 finally 块确保文件句柄关闭,防止 fd 泄漏
@@ -69,6 +77,9 @@ async function isBinaryFile(filePath: string): Promise<boolean> {
const buffer = Buffer.alloc(8192); const buffer = Buffer.alloc(8192);
fd = await open(filePath, 'r'); fd = await open(filePath, 'r');
const { bytesRead } = await fd.read(buffer, 0, 8192, 0); const { bytesRead } = await fd.read(buffer, 0, 8192, 0);
// v0.8.0 P1-3.3: UTF-16 LE/BE BOM → 文本(NUL 启发式对其无效)
if (bytesRead >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) return false; // UTF-16 LE BOM
if (bytesRead >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) return false; // UTF-16 BE BOM
// 仅检查实际读取的字节范围 [0, bytesRead) // 仅检查实际读取的字节范围 [0, bytesRead)
// 含 NULL 字节 → 二进制;空文件(bytesRead=0)→ 非二进制 // 含 NULL 字节 → 二进制;空文件(bytesRead=0)→ 非二进制
for (let i = 0; i < bytesRead; i++) { for (let i = 0; i < bytesRead; i++) {
@@ -346,7 +357,9 @@ export class WriteFileTool implements IMetonaTool {
} }
// v0.3.2: overwrite 模式使用原子写入(临时文件 + rename,与 file_editor 一致) // v0.3.2: overwrite 模式使用原子写入(临时文件 + rename,与 file_editor 一致)
const tmpPath = `${filePath}.tmp_${Date.now()}`; // v0.8.0 P1-3.4: tmp 文件名追加 nanoid 随机段 —— 同毫秒并发写同一文件时
// Date.now() 精度的 tmp 名会互相覆盖(并行工具调用场景)
const tmpPath = `${filePath}.tmp_${Date.now()}_${nanoid(6)}`;
try { try {
await writeFile(tmpPath, content, 'utf-8'); await writeFile(tmpPath, content, 'utf-8');
await rename(tmpPath, filePath); await rename(tmpPath, filePath);
@@ -395,6 +408,11 @@ export class ListDirectoryTool implements IMetonaTool {
type: 'boolean', type: 'boolean',
description: 'Include hidden files/dirs starting with "." (default false)', description: 'Include hidden files/dirs starting with "." (default false)',
}, },
include_node_modules: {
type: 'boolean',
description:
'Include node_modules directories (default false — skipped for performance; set true to inspect dependency packages)',
},
}, },
}, },
category: MetonaToolCategory.FILESYSTEM, category: MetonaToolCategory.FILESYSTEM,
@@ -411,6 +429,8 @@ export class ListDirectoryTool implements IMetonaTool {
const depth = Math.min(5, Math.max(1, (args.depth as number) ?? 1)); const depth = Math.min(5, Math.max(1, (args.depth as number) ?? 1));
const glob = args.glob as string | undefined; const glob = args.glob as string | undefined;
const includeHidden = (args.include_hidden as boolean) ?? false; const includeHidden = (args.include_hidden as boolean) ?? false;
// v0.8.0 P1-3.6: node_modules 可选纳入(默认跳过保持兼容)
const includeNodeModules = (args.include_node_modules as boolean) ?? false;
// v0.3.2 修复 WARN-3: listDir 内部提前终止,避免大目录全量遍历 // v0.3.2 修复 WARN-3: listDir 内部提前终止,避免大目录全量遍历
const MAX_ENTRIES = 1000; const MAX_ENTRIES = 1000;
@@ -421,7 +441,17 @@ export class ListDirectoryTool implements IMetonaTool {
size?: number; size?: number;
modified?: string; modified?: string;
}> = []; }> = [];
await this.listDir(dirPath, dirPath, depth, glob, includeHidden, 0, entries, MAX_ENTRIES); await this.listDir(
dirPath,
dirPath,
depth,
glob,
includeHidden,
includeNodeModules,
0,
entries,
MAX_ENTRIES,
);
return { return {
entries, entries,
count: entries.length, count: entries.length,
@@ -439,6 +469,7 @@ export class ListDirectoryTool implements IMetonaTool {
maxDepth: number, maxDepth: number,
glob: string | undefined, glob: string | undefined,
includeHidden: boolean, includeHidden: boolean,
includeNodeModules: boolean,
currentDepth: number, currentDepth: number,
results: Array<{ name: string; path: string; type: string; size?: number; modified?: string }>, results: Array<{ name: string; path: string; type: string; size?: number; modified?: string }>,
maxEntries: number, maxEntries: number,
@@ -453,8 +484,9 @@ export class ListDirectoryTool implements IMetonaTool {
if (results.length >= maxEntries) return; if (results.length >= maxEntries) return;
// v0.3.2: include_hidden 参数控制隐藏文件 // v0.3.2: include_hidden 参数控制隐藏文件
if (!includeHidden && entry.name.startsWith('.')) continue; if (!includeHidden && entry.name.startsWith('.')) continue;
// node_modules 始终跳过(太大) // v0.8.0 P1-3.6: node_modules 默认跳过(太大)include_node_modules=true
if (entry.name === 'node_modules') continue; // 时纳入(分析依赖包场景;上限保护仍生效)
if (entry.name === 'node_modules' && !includeNodeModules) continue;
const fullPath = join(dirPath, entry.name); const fullPath = join(dirPath, entry.name);
// 相对路径始终以根请求目录为基准 // 相对路径始终以根请求目录为基准
@@ -470,6 +502,7 @@ export class ListDirectoryTool implements IMetonaTool {
maxDepth, maxDepth,
glob, glob,
includeHidden, includeHidden,
includeNodeModules,
currentDepth + 1, currentDepth + 1,
results, results,
maxEntries, maxEntries,
@@ -526,6 +559,19 @@ export class SearchFilesTool implements IMetonaTool {
description: description:
'Lines of context to show around content matches (default 0, max 5). Only for target="content".', 'Lines of context to show around content matches (default 0, max 5). Only for target="content".',
}, },
case_sensitive: {
type: 'boolean',
description:
'Case sensitive search (default false — v0.8.0 P1-3.5: previously hardcoded case-insensitive, breaking code_search fallback symmetry)',
},
context_before: {
type: 'number',
description: 'Lines of context before match (default = context_lines, max 5)',
},
context_after: {
type: 'number',
description: 'Lines of context after match (default = context_lines, max 5)',
},
include_hidden: { include_hidden: {
type: 'boolean', type: 'boolean',
description: 'Include hidden files/dirs starting with "." (default false)', description: 'Include hidden files/dirs starting with "." (default false)',
@@ -549,6 +595,15 @@ export class SearchFilesTool implements IMetonaTool {
const fileGlob = args.file_glob as string | undefined; const fileGlob = args.file_glob as string | undefined;
const limit = Math.min(200, Math.max(1, (args.limit as number) ?? 50)); const limit = Math.min(200, Math.max(1, (args.limit as number) ?? 50));
const contextLines = Math.min(5, Math.max(0, (args.context_lines as number) ?? 0)); const contextLines = Math.min(5, Math.max(0, (args.context_lines as number) ?? 0));
// v0.8.0 P1-3.5: 与 code_search 参数对称 —— case_sensitive 此前被硬编码
// 'gi' 忽略;context_before/after 此前在 code_search JS 回退路径静默丢失。
// 缺省时回退 context_lines(向后兼容)。
const caseSensitive = (args.case_sensitive as boolean) ?? false;
const contextBefore = Math.min(
5,
Math.max(0, (args.context_before as number) ?? contextLines),
);
const contextAfter = Math.min(5, Math.max(0, (args.context_after as number) ?? contextLines));
const includeHidden = (args.include_hidden as boolean) ?? false; const includeHidden = (args.include_hidden as boolean) ?? false;
if (target === 'files') { if (target === 'files') {
@@ -566,7 +621,9 @@ export class SearchFilesTool implements IMetonaTool {
pattern, pattern,
fileGlob, fileGlob,
limit, limit,
contextLines, contextBefore,
contextAfter,
caseSensitive,
includeHidden, includeHidden,
context.workspacePath, context.workspacePath,
); );
@@ -606,7 +663,9 @@ export class SearchFilesTool implements IMetonaTool {
pattern: string, pattern: string,
fileGlob: string | undefined, fileGlob: string | undefined,
limit: number, limit: number,
contextLines: number, contextBefore: number,
contextAfter: number,
caseSensitive: boolean,
includeHidden: boolean, includeHidden: boolean,
workspacePath: string, workspacePath: string,
): Promise<unknown> { ): Promise<unknown> {
@@ -635,7 +694,8 @@ export class SearchFilesTool implements IMetonaTool {
} }
let regex: RegExp; let regex: RegExp;
try { try {
regex = new RegExp(pattern, 'gi'); // v0.8.0 P1-3.5: case_sensitive 参数生效(此前硬编码 'gi' 恒大小写不敏感)
regex = new RegExp(pattern, caseSensitive ? 'g' : 'gi');
} catch { } catch {
return { results: [], count: 0, error: `Invalid regex pattern: ${pattern}`, success: false }; return { results: [], count: 0, error: `Invalid regex pattern: ${pattern}`, success: false };
} }
@@ -668,10 +728,11 @@ export class SearchFilesTool implements IMetonaTool {
if (regex.test(lines[i])) { if (regex.test(lines[i])) {
const match = lines[i].trim().slice(0, 200); const match = lines[i].trim().slice(0, 200);
// v0.3.2: context lines 支持 // v0.3.2: context lines 支持
// v0.8.0 P1-3.5: 前后文行数独立(code_search 对称性)
let context: string[] | undefined; let context: string[] | undefined;
if (contextLines > 0) { if (contextBefore > 0 || contextAfter > 0) {
const start = Math.max(0, i - contextLines); const start = Math.max(0, i - contextBefore);
const end = Math.min(lines.length - 1, i + contextLines); const end = Math.min(lines.length - 1, i + contextAfter);
context = lines.slice(start, end + 1); context = lines.slice(start, end + 1);
} }
results.push({ results.push({
+42 -11
View File
@@ -28,18 +28,23 @@ const MAX_DIFF_SIZE = 50 * 1024;
* *
* v0.3.1 WARN-4: 允许调用方覆盖 maxBuffer 1MB * v0.3.1 WARN-4: 允许调用方覆盖 maxBuffer 1MB
* git_diff 5MB maxBuffer exceeded * git_diff 5MB maxBuffer exceeded
*
* v0.8.0 P1-2: 新增可选 signal / kill git
* Node execFile AbortSignal context
*/ */
async function runGit( async function runGit(
args: string[], args: string[],
cwd: string, cwd: string,
timeout = 10_000, timeout = 10_000,
maxBuffer = 1024 * 1024, // 默认 1MB maxBuffer = 1024 * 1024, // 默认 1MB
signal?: AbortSignal,
): Promise<string> { ): Promise<string> {
const { stdout } = await execFileAsync('git', args, { const { stdout } = await execFileAsync('git', args, {
cwd, cwd,
maxBuffer, maxBuffer,
timeout, timeout,
encoding: 'utf-8', encoding: 'utf-8',
...(signal ? { signal } : {}),
}); });
return stdout; return stdout;
} }
@@ -84,11 +89,15 @@ function extractRenamedFile(file: string): string {
export class GitStatusTool implements IMetonaTool { export class GitStatusTool implements IMetonaTool {
readonly definition: MetonaToolDef = { readonly definition: MetonaToolDef = {
name: 'git_status', name: 'git_status',
description: 'Show the working tree status. Returns current branch, ahead/behind counts, and lists of staged, unstaged, and untracked files.', description:
'Show the working tree status. Returns current branch, ahead/behind counts, and lists of staged, unstaged, and untracked files.',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
pathspec: { type: 'string', description: 'Limit to a path scope, e.g. "src/" or "package.json"' }, pathspec: {
type: 'string',
description: 'Limit to a path scope, e.g. "src/" or "package.json"',
},
}, },
}, },
category: MetonaToolCategory.FILESYSTEM, category: MetonaToolCategory.FILESYSTEM,
@@ -191,11 +200,15 @@ export class GitStatusTool implements IMetonaTool {
export class GitDiffTool implements IMetonaTool { export class GitDiffTool implements IMetonaTool {
readonly definition: MetonaToolDef = { readonly definition: MetonaToolDef = {
name: 'git_diff', name: 'git_diff',
description: 'Show changes between the working tree and the index (or staged changes). Returns unified diff text. Output is truncated at 50KB.', description:
'Show changes between the working tree and the index (or staged changes). Returns unified diff text. Output is truncated at 50KB.',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
cached: { type: 'boolean', description: 'Show only staged (cached) changes (default false)' }, cached: {
type: 'boolean',
description: 'Show only staged (cached) changes (default false)',
},
pathspec: { type: 'string', description: 'Limit to a path scope, e.g. "src/"' }, pathspec: { type: 'string', description: 'Limit to a path scope, e.g. "src/"' },
contextLines: { type: 'number', description: 'Number of context lines (default 3)' }, contextLines: { type: 'number', description: 'Number of context lines (default 3)' },
}, },
@@ -269,7 +282,8 @@ export class GitDiffTool implements IMetonaTool {
export class GitLogTool implements IMetonaTool { export class GitLogTool implements IMetonaTool {
readonly definition: MetonaToolDef = { readonly definition: MetonaToolDef = {
name: 'git_log', name: 'git_log',
description: 'Show commit history. Returns commits with hash, message, and optionally author/date. Supports filtering by author and path.', description:
'Show commit history. Returns commits with hash, message, and optionally author/date. Supports filtering by author and path.',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
@@ -357,17 +371,25 @@ export class GitLogTool implements IMetonaTool {
export class GitCommitTool implements IMetonaTool { export class GitCommitTool implements IMetonaTool {
readonly definition: MetonaToolDef = { readonly definition: MetonaToolDef = {
name: 'git_commit', name: 'git_commit',
description: 'Stage files and create a git commit. Supports amending the previous commit. In amend mode, message is optional (omitting it keeps the original commit message). Requires user confirmation.', description:
'Stage files and create a git commit. Supports amending the previous commit. In amend mode, message is optional (omitting it keeps the original commit message). Requires user confirmation.',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
message: { type: 'string', description: 'Commit message. Required for new commits. Optional in amend mode (omitting keeps original message).' }, message: {
type: 'string',
description:
'Commit message. Required for new commits. Optional in amend mode (omitting keeps original message).',
},
files: { files: {
type: 'array', type: 'array',
items: { type: 'string', description: 'File path to stage' }, items: { type: 'string', description: 'File path to stage' },
description: 'Files to stage (default: all changes via git add -A)', description: 'Files to stage (default: all changes via git add -A)',
}, },
amend: { type: 'boolean', description: 'Amend the previous commit instead of creating a new one (default false)' }, amend: {
type: 'boolean',
description: 'Amend the previous commit instead of creating a new one (default false)',
},
}, },
// F1-3: message 不再硬性 required — amend 模式下可省略(保留原 message) // F1-3: message 不再硬性 required — amend 模式下可省略(保留原 message)
// 校验在 execute 内根据 amend 标志动态进行 // 校验在 execute 内根据 amend 标志动态进行
@@ -385,11 +407,17 @@ export class GitCommitTool implements IMetonaTool {
// F1-3: 非 amend 模式必须有 messageamend 模式可省略(保留原 message) // F1-3: 非 amend 模式必须有 messageamend 模式可省略(保留原 message)
if (!amend && (!message || message.trim().length === 0)) { if (!amend && (!message || message.trim().length === 0)) {
return { success: false, error: 'Commit message is required (use amend: true to reuse the previous message)' }; return {
success: false,
error: 'Commit message is required (use amend: true to reuse the previous message)',
};
} }
// amend 模式下若提供 message,需校验非空字符串 // amend 模式下若提供 message,需校验非空字符串
if (amend && message !== undefined && message.trim().length === 0) { if (amend && message !== undefined && message.trim().length === 0) {
return { success: false, error: 'Commit message must not be empty (or omit message field to keep original)' }; return {
success: false,
error: 'Commit message must not be empty (or omit message field to keep original)',
};
} }
try { try {
@@ -402,7 +430,10 @@ export class GitCommitTool implements IMetonaTool {
return { return {
success: false, success: false,
error: `File path outside workspace: ${f}`, error: `File path outside workspace: ${f}`,
commit: '', branch: '', message: '', filesChanged: 0, commit: '',
branch: '',
message: '',
filesChanged: 0,
}; };
} }
} }
@@ -82,7 +82,7 @@ export class HttpRequestTool implements IMetonaTool {
timeoutMs: 30_000, timeoutMs: 30_000,
}; };
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> { async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
try { try {
const url = args.url as string; const url = args.url as string;
const method = ((args.method as string) ?? 'GET').toUpperCase(); const method = ((args.method as string) ?? 'GET').toUpperCase();
@@ -127,7 +127,9 @@ export class HttpRequestTool implements IMetonaTool {
// v0.7.3 P2-1: pinned fetch —— 校验通过的 IP pin 到连接层, // v0.7.3 P2-1: pinned fetch —— 校验通过的 IP pin 到连接层,
// 关闭校验-连接之间的 DNS rebinding 窗口 // 关闭校验-连接之间的 DNS rebinding 窗口
const response = await ssrfPinnedFetch(url, fetchOptions, timeout); // v0.8.0 P1-2: 第 4 参透传引擎级 abort signal —— 用户中断会话时底层
// fetch 立即取消(此前最长 60s 资源悬挂)
const response = await ssrfPinnedFetch(url, fetchOptions, timeout, context.signal);
const text = await response.text(); const text = await response.text();
// 截断到 50KB // 截断到 50KB
@@ -83,13 +83,23 @@ export async function fetchWithTimeout(
url: string, url: string,
options: RequestInit = {}, options: RequestInit = {},
timeoutMs = 20_000, timeoutMs = 20_000,
externalSignal?: AbortSignal,
): Promise<Response> { ): Promise<Response> {
// v0.8.0 P1-2: 合并外部 abort 信号(引擎中断时立即取消,不再跑到自身超时)
const controller = new AbortController(); const controller = new AbortController();
const onExternalAbort = (): void => controller.abort();
if (externalSignal) {
if (externalSignal.aborted) controller.abort();
else externalSignal.addEventListener('abort', onExternalAbort, { once: true });
}
const timer = setTimeout(() => controller.abort(), timeoutMs); const timer = setTimeout(() => controller.abort(), timeoutMs);
try { try {
return await fetch(url, { ...options, signal: controller.signal }); return await fetch(url, { ...options, signal: controller.signal });
} finally { } finally {
clearTimeout(timer); clearTimeout(timer);
if (externalSignal) {
externalSignal.removeEventListener('abort', onExternalAbort);
}
} }
} }
+89 -1
View File
@@ -17,6 +17,12 @@
import { lookup } from 'node:dns/promises'; import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net'; import { isIP } from 'node:net';
/**
* v0.8.0 P1-5: DNS lookup node:dns/promises lookup
* current ESM namespace
*/
export const __dnsLookup: { current: typeof lookup } = { current: lookup };
/** /**
* IP /// * IP ///
* *
@@ -98,7 +104,7 @@ export async function resolvePublicAddresses(url: string): Promise<string[]> {
// 域名 — DNS 解析后检测所有 IP // 域名 — DNS 解析后检测所有 IP
let addresses: Array<{ address: string }>; let addresses: Array<{ address: string }>;
try { try {
addresses = await lookup(hostname, { all: true }); addresses = await __dnsLookup.current(hostname, { all: true });
} catch (err) { } catch (err) {
throw new Error( throw new Error(
`Blocked SSRF: DNS resolution failed for ${hostname}: ${(err as Error).message}`, `Blocked SSRF: DNS resolution failed for ${hostname}: ${(err as Error).message}`,
@@ -231,3 +237,85 @@ export function assertSafeConfigTarget(url: string): void {
} }
// 域名(非 IP):允许(DNS 可能解析到内网,但用户显式配置的本地服务是合法场景) // 域名(非 IP):允许(DNS 可能解析到内网,但用户显式配置的本地服务是合法场景)
} }
/**
* v0.8.0 P1-5: 配置类 URL **** assertSafeConfigTarget
* DNS /0/8
* ::ffff: 映射等价段
*
* assertSafeConfigTarget "域名放行" DNS
* "域名解析到云元数据 IP"
* attacker.example 169.254.169.254
*
* 127.0.0.1 / RFC1918 MCP/SearXNG DNS
* 线
* WARN
*
* @throws URL
*/
export async function assertSafeConfigTargetDeep(url: string): Promise<void> {
// 先执行静态规则(协议白名单/元数据主机名/IP 字面量段位)
assertSafeConfigTarget(url);
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return; // assertSafeConfigTarget 已抛,此处不可达(防御)
}
const rawHostname = parsed.hostname.toLowerCase();
// IPv6 字面量去方括号(与 assertSafeConfigTarget 同口径)
const hostname =
rawHostname.startsWith('[') && rawHostname.endsWith(']')
? rawHostname.slice(1, -1)
: rawHostname;
if (isIP(hostname) !== 0) return; // IP 字面量已由静态规则判定,无需解析
let addresses: Array<{ address: string }>;
try {
addresses = await __dnsLookup.current(hostname, { all: true });
} catch (err) {
// DNS 解析失败:放行(配置期纵深校验不做可用性裁决),由调用方日志留痕
throw new DeepCheckSoftFailure(
`config target DNS resolution failed for ${hostname}: ${(err as Error).message}`,
);
}
for (const { address } of addresses) {
// 复用 isPrivateIP 的完整段位表做"高危段"判定?——不行:isPrivateIP 把
// 回环/RFC1918 也判为私有,而配置路径放行它们。此处仅拦"静态规则拦不到、
// 但解析后才暴露"的高危段:链路本地(含云元数据)、0/8、组播保留。
if (isHighRiskResolvedIP(address)) {
throw new Error(
`Blocked: ${hostname} resolves to high-risk address ${address} (link-local/metadata/multicast)`,
);
}
}
}
/** 高危段判定(仅配置深校验使用):链路本地/0/8/组播保留,放行回环与 RFC1918 */
function isHighRiskResolvedIP(ip: string): boolean {
if (isIP(ip) === 4) {
const parts = ip.split('.').map(Number);
return (parts[0] === 169 && parts[1] === 254) || parts[0] === 0 || parts[0] >= 224;
}
if (isIP(ip) === 6) {
const lower = ip.toLowerCase();
if (lower.startsWith('fe80:') || lower.startsWith('ff')) return true;
if (lower === '::') return true;
const v4MappedMatch = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (v4MappedMatch) return isHighRiskResolvedIP(v4MappedMatch[1]);
return false;
}
return false;
}
/**
* v0.8.0 P1-5: 深校验的"软失败" DNS
* "校验通过""跳过校验"便
*/
export class DeepCheckSoftFailure extends Error {
constructor(message: string) {
super(message);
this.name = 'DeepCheckSoftFailure';
}
}
+61 -10
View File
@@ -84,7 +84,7 @@ export class WebFetchTool implements IMetonaTool {
timeoutMs: 240_000, timeoutMs: 240_000,
}; };
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> { async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const url = args.url as string; const url = args.url as string;
const mobileUA = (args.mobile_ua as boolean) ?? false; const mobileUA = (args.mobile_ua as boolean) ?? false;
const enableRetry = (args.retry as boolean) ?? true; const enableRetry = (args.retry as boolean) ?? true;
@@ -96,6 +96,11 @@ export class WebFetchTool implements IMetonaTool {
return { url, content: '', success: false, error: 'URL must start with http:// or https://' }; return { url, content: '', success: false, error: 'URL must start with http:// or https://' };
} }
// v0.8.0 P1-2: abort 立即生效 —— 引擎中断(超时/用户停止)时不再继续后续阶段
if (context.signal?.aborted) {
return { url, content: '', success: false, error: 'Fetch aborted' };
}
// v0.6.4 P2-2: SSRF 校验 —— 覆盖 Phase1 HTTP 与 Phase3 浏览器两条通道的入口。 // v0.6.4 P2-2: SSRF 校验 —— 覆盖 Phase1 HTTP 与 Phase3 浏览器两条通道的入口。
// 协议白名单 / 私有段 IP / 云元数据地址一律拒绝。 // 协议白名单 / 私有段 IP / 云元数据地址一律拒绝。
try { try {
@@ -117,7 +122,12 @@ export class WebFetchTool implements IMetonaTool {
logTool('web_fetch', `Fetching: ${url}`); logTool('web_fetch', `Fetching: ${url}`);
// ===== Phase 1: HTTP 抓取 ===== // ===== Phase 1: HTTP 抓取 =====
const phase1Result = await this.httpFetch(url, mobileUA, enableRetry); const phase1Result = await this.httpFetch(url, mobileUA, enableRetry, context.signal);
// v0.8.0 P1-2: HTTP 阶段被 abort —— 直接终止,不进浏览器回退
if (context.signal?.aborted) {
return { url, content: '', success: false, error: 'Fetch aborted' };
}
if (phase1Result.success && !phase1Result.intercepted) { if (phase1Result.success && !phase1Result.intercepted) {
// 根据 extract_mode 选择返回内容:'html' 模式返回清理后的 HTML'text' 模式返回纯文本 // 根据 extract_mode 选择返回内容:'html' 模式返回清理后的 HTML'text' 模式返回纯文本
@@ -136,7 +146,7 @@ export class WebFetchTool implements IMetonaTool {
'web_fetch', 'web_fetch',
`Phase 2: Content too short (${phase1Content.length} chars), upgrading to browser`, `Phase 2: Content too short (${phase1Content.length} chars), upgrading to browser`,
); );
const browserResult = await this.browserFetch(url); const browserResult = await this.browserFetch(url, context.signal);
if (browserResult) { if (browserResult) {
return this.buildSuccess(url, browserResult, 'browser', maxChars); return this.buildSuccess(url, browserResult, 'browser', maxChars);
} }
@@ -159,7 +169,7 @@ export class WebFetchTool implements IMetonaTool {
}; };
} }
logTool('web_fetch', `Phase 3: Falling back to browser (${phase1Result.reason})`); logTool('web_fetch', `Phase 3: Falling back to browser (${phase1Result.reason})`);
const browserResult = await this.browserFetch(url); const browserResult = await this.browserFetch(url, context.signal);
if (browserResult) { if (browserResult) {
return this.buildSuccess(url, browserResult, 'browser', maxChars); return this.buildSuccess(url, browserResult, 'browser', maxChars);
} }
@@ -179,6 +189,7 @@ export class WebFetchTool implements IMetonaTool {
url: string, url: string,
mobileUA: boolean, mobileUA: boolean,
enableRetry: boolean, enableRetry: boolean,
signal?: AbortSignal,
): Promise<{ ): Promise<{
success: boolean; success: boolean;
html: string; html: string;
@@ -206,7 +217,14 @@ export class WebFetchTool implements IMetonaTool {
let redirectBlocked: string | null = null; let redirectBlocked: string | null = null;
for (let hop = 0; hop <= MAX_REDIRECT_HOPS; hop++) { for (let hop = 0; hop <= MAX_REDIRECT_HOPS; hop++) {
response = await ssrfPinnedFetch(currentUrl, { headers, redirect: 'manual' }, 20_000); // v0.8.0 P1-2: 每跳透传引擎级 abort signal —— 用户中断时底层 fetch
// 立即取消(含重定向链中间跳)
response = await ssrfPinnedFetch(
currentUrl,
{ headers, redirect: 'manual' },
20_000,
signal,
);
const next = resolveRedirectTarget(response, currentUrl); const next = resolveRedirectTarget(response, currentUrl);
if (next === null) break; // 非重定向(或无/非法 Location)—— 当前响应即终态 if (next === null) break; // 非重定向(或无/非法 Location)—— 当前响应即终态
if (hop === MAX_REDIRECT_HOPS) { if (hop === MAX_REDIRECT_HOPS) {
@@ -261,6 +279,7 @@ export class WebFetchTool implements IMetonaTool {
if (response.status >= 500 && attempt < maxRetries - 1) { if (response.status >= 500 && attempt < maxRetries - 1) {
await this.sleep( await this.sleep(
backoffBase * Math.pow(2, attempt) + Math.random() * backoffBase * 0.6, backoffBase * Math.pow(2, attempt) + Math.random() * backoffBase * 0.6,
signal,
); );
continue; continue;
} }
@@ -293,9 +312,22 @@ export class WebFetchTool implements IMetonaTool {
return { success: true, html, text, intercepted: false, reason: '' }; return { success: true, html, text, intercepted: false, reason: '' };
} catch (err) { } catch (err) {
const errorMsg = (err as Error).message; const errorMsg = (err as Error).message;
// v0.8.0 P1-2: abort 语义 —— 不重试、不睡眠,直接终止本阶段
if (signal?.aborted || (err as Error & { name?: string }).name === 'AbortError') {
return {
success: false,
html: '',
text: '',
intercepted: false,
reason: 'Fetch aborted',
};
}
if (attempt < maxRetries - 1) { if (attempt < maxRetries - 1) {
logTool('web_fetch', `Attempt ${attempt + 1} failed: ${errorMsg}, retrying...`); logTool('web_fetch', `Attempt ${attempt + 1} failed: ${errorMsg}, retrying...`);
await this.sleep(backoffBase * Math.pow(2, attempt) + Math.random() * backoffBase * 0.6); await this.sleep(
backoffBase * Math.pow(2, attempt) + Math.random() * backoffBase * 0.6,
signal,
);
continue; continue;
} }
return { success: false, html: '', text: '', intercepted: false, reason: errorMsg }; return { success: false, html: '', text: '', intercepted: false, reason: errorMsg };
@@ -313,7 +345,7 @@ export class WebFetchTool implements IMetonaTool {
// ===== Phase 2/3: 浏览器回退(使用共享 BrowserWindowManager 单例) ===== // ===== Phase 2/3: 浏览器回退(使用共享 BrowserWindowManager 单例) =====
private async browserFetch(url: string): Promise<string | null> { private async browserFetch(url: string, signal?: AbortSignal): Promise<string | null> {
// 查缓存(浏览器阶段产出的是纯文本,与 text 模式同键) // 查缓存(浏览器阶段产出的是纯文本,与 text 模式同键)
const cached = fetchCache.get(`text:${url}`); const cached = fetchCache.get(`text:${url}`);
if (cached) { if (cached) {
@@ -322,10 +354,13 @@ export class WebFetchTool implements IMetonaTool {
} }
try { try {
// v0.8.0 P1-2: 引擎级 abort 透传 —— 中断时 BrowserWindowManager 停止页面
// 加载并终止抓取序列(此前浏览器回退完全不受 abort 控制)
if (signal?.aborted) return null;
// 崩溃修复: 走 manager.fetchPageText(内部串行化完整的 open→等待→evaluate 序列)。 // 崩溃修复: 走 manager.fetchPageText(内部串行化完整的 open→等待→evaluate 序列)。
// 原实现直接 open/evaluate 共享单例 —— web_search 并行抓取触发多个回退同时进入时, // 原实现直接 open/evaluate 共享单例 —— web_search 并行抓取触发多个回退同时进入时,
// 后到者销毁前者的窗口(ERR_ABORTED ×3 = 应用崩溃 ×3,见 manager 注释)。 // 后到者销毁前者的窗口(ERR_ABORTED ×3 = 应用崩溃 ×3,见 manager 注释)。
const text = await getBrowserManager().fetchPageText(url); const text = await getBrowserManager().fetchPageText(url, signal);
if (text && text.trim().length >= 80) { if (text && text.trim().length >= 80) {
// 拦截检测(浏览器渲染后仍可能是验证码挑战页) // 拦截检测(浏览器渲染后仍可能是验证码挑战页)
@@ -383,7 +418,23 @@ export class WebFetchTool implements IMetonaTool {
}; };
} }
private sleep(ms: number): Promise<void> { /** v0.8.0 P1-2: abort 感知的 sleep —— 中断期间不再等待退避间隔 */
return new Promise((resolve) => setTimeout(resolve, ms)); private sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal?.aborted) return resolve();
const cleanup = (): void => {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
};
const onAbort = (): void => {
cleanup();
resolve();
};
const timer = setTimeout(() => {
cleanup();
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
} }
} }
+83 -18
View File
@@ -338,9 +338,15 @@ function parse360Regex(html: string): SearchResult[] {
// 探测经 ssrfPinnedFetchDNS pinning),重定向不自动跟随(3xx 即视为可达 —— // 探测经 ssrfPinnedFetchDNS pinning),重定向不自动跟随(3xx 即视为可达 ——
// 链接活性已证明,且跟跳目标不再绕过校验)。 // 链接活性已证明,且跟跳目标不再绕过校验)。
async function checkReachability(urls: string[], concurrency = 5): Promise<Map<string, boolean>> { async function checkReachability(
urls: string[],
signal?: AbortSignal,
concurrency = 5,
): Promise<Map<string, boolean>> {
const result = new Map<string, boolean>(); const result = new Map<string, boolean>();
for (let i = 0; i < urls.length; i += concurrency) { for (let i = 0; i < urls.length; i += concurrency) {
// v0.8.0 P1-2: abort 后立即终止预检批次
if (signal?.aborted) break;
const batch = urls.slice(i, i + concurrency); const batch = urls.slice(i, i + concurrency);
const checks = batch.map(async (url) => { const checks = batch.map(async (url) => {
try { try {
@@ -349,7 +355,13 @@ async function checkReachability(urls: string[], concurrency = 5): Promise<Map<s
result.set(url, false); result.set(url, false);
return; return;
} }
const resp = await ssrfPinnedFetch(url, { method: 'HEAD', redirect: 'manual' }, 3_000); // v0.8.0 P1-2: 透传引擎级 abort signal
const resp = await ssrfPinnedFetch(
url,
{ method: 'HEAD', redirect: 'manual' },
3_000,
signal,
);
result.set(url, resp.ok || (resp.status >= 300 && resp.status < 400)); result.set(url, resp.ok || (resp.status >= 300 && resp.status < 400));
} catch { } catch {
result.set(url, false); result.set(url, false);
@@ -424,9 +436,14 @@ export class WebSearchTool implements IMetonaTool {
private webFetchTool: WebFetchTool, private webFetchTool: WebFetchTool,
) {} ) {}
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> { async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const query = args.query as string; const query = args.query as string;
// v0.8.0 P1-2: abort 立即生效(后续各阶段均携带本 signal)
if (context.signal?.aborted) {
return { success: false, query, results: [], error: 'Search aborted' };
}
// 读取 SearXNG 配置 // 读取 SearXNG 配置
const searxngConfig = this.readSearXNGConfig(); const searxngConfig = this.readSearXNGConfig();
const useSearXNG = searxngConfig.enabled && !!searxngConfig.url; const useSearXNG = searxngConfig.enabled && !!searxngConfig.url;
@@ -463,12 +480,23 @@ export class WebSearchTool implements IMetonaTool {
let engineStats: Record<string, string>; let engineStats: Record<string, string>;
if (useSearXNG) { if (useSearXNG) {
const searxResult = await this.searchSearXNG(query, searxngConfig, maxResults, timeRange); const searxResult = await this.searchSearXNG(
query,
searxngConfig,
maxResults,
timeRange,
context.signal,
);
results = searxResult.results; results = searxResult.results;
mode = 'searxng'; mode = 'searxng';
engineStats = searxResult.engineStats; engineStats = searxResult.engineStats;
} else { } else {
const builtinResult = await this.searchBuiltinEngines(query, maxResults, timeRange); const builtinResult = await this.searchBuiltinEngines(
query,
maxResults,
timeRange,
context.signal,
);
results = builtinResult.results; results = builtinResult.results;
mode = 'builtin'; mode = 'builtin';
engineStats = builtinResult.engineStats; engineStats = builtinResult.engineStats;
@@ -479,7 +507,7 @@ export class WebSearchTool implements IMetonaTool {
// 可达性预检 // 可达性预检
const topUrls = deduped.slice(0, Math.min(20, deduped.length)).map((r) => r.url); const topUrls = deduped.slice(0, Math.min(20, deduped.length)).map((r) => r.url);
const reachabilityMap = await checkReachability(topUrls); const reachabilityMap = await checkReachability(topUrls, context.signal);
for (const r of deduped) { for (const r of deduped) {
r.reachable = reachabilityMap.get(r.url) ?? false; r.reachable = reachabilityMap.get(r.url) ?? false;
} }
@@ -489,11 +517,17 @@ export class WebSearchTool implements IMetonaTool {
// 摘要增强 // 摘要增强
if (enhanceSnippets) { if (enhanceSnippets) {
await this.enhanceSnippets(sorted, 3); await this.enhanceSnippets(sorted, 3, context);
} }
// 自动抓取完整内容 // 自动抓取完整内容
const fetchedContent = await this.autoFetch(query, sorted, searxngConfig.fetch_mode, fetchTop); const fetchedContent = await this.autoFetch(
query,
sorted,
searxngConfig.fetch_mode,
fetchTop,
context,
);
// 格式化输出 // 格式化输出
const formatted = this.formatResults(query, sorted); const formatted = this.formatResults(query, sorted);
@@ -536,6 +570,7 @@ export class WebSearchTool implements IMetonaTool {
config: SearXNGConfig, config: SearXNGConfig,
maxResults: number, maxResults: number,
timeRange: string, timeRange: string,
signal?: AbortSignal,
): Promise<{ results: SearchResult[]; engineStats: Record<string, string> }> { ): Promise<{ results: SearchResult[]; engineStats: Record<string, string> }> {
const results: SearchResult[] = []; const results: SearchResult[] = [];
const engineStats: Record<string, string> = {}; const engineStats: Record<string, string> = {};
@@ -549,6 +584,8 @@ export class WebSearchTool implements IMetonaTool {
let page = 1; let page = 1;
while (results.length < maxResults && page <= maxPages) { while (results.length < maxResults && page <= maxPages) {
// v0.8.0 P1-2: abort 后停止翻页
if (signal?.aborted) break;
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('q', query); params.set('q', query);
params.set('format', config.format || 'json'); params.set('format', config.format || 'json');
@@ -564,7 +601,8 @@ export class WebSearchTool implements IMetonaTool {
const pageResults: SearchResult[] = []; const pageResults: SearchResult[] = [];
try { try {
const response = await fetchWithTimeout(searchUrl, { headers }, 15_000); // v0.8.0 P1-2: SearXNG 请求携带引擎级 abort signal
const response = await fetchWithTimeout(searchUrl, { headers }, 15_000, signal);
if (!response.ok) { if (!response.ok) {
throw new Error(`SearXNG API error: ${response.status} ${response.statusText}`); throw new Error(`SearXNG API error: ${response.status} ${response.statusText}`);
} }
@@ -625,6 +663,7 @@ export class WebSearchTool implements IMetonaTool {
query: string, query: string,
maxResults: number, maxResults: number,
timeRange: string, timeRange: string,
signal?: AbortSignal,
): Promise<{ results: SearchResult[]; engineStats: Record<string, string> }> { ): Promise<{ results: SearchResult[]; engineStats: Record<string, string> }> {
const searchPromises = ENGINES.map(async (engine) => { const searchPromises = ENGINES.map(async (engine) => {
try { try {
@@ -639,6 +678,7 @@ export class WebSearchTool implements IMetonaTool {
}, },
}, },
8_000, 8_000,
signal,
); );
if (!response.ok) { if (!response.ok) {
@@ -689,16 +729,23 @@ export class WebSearchTool implements IMetonaTool {
// ===== 摘要增强(委托给 WebFetchTool ===== // ===== 摘要增强(委托给 WebFetchTool =====
private async enhanceSnippets(results: SearchResult[], maxEnhance: number): Promise<void> { private async enhanceSnippets(
results: SearchResult[],
maxEnhance: number,
context: ToolExecutionContext,
): Promise<void> {
let enhanced = 0; let enhanced = 0;
for (const r of results) { for (const r of results) {
// v0.8.0 P1-2: abort 后停止增强
if (context.signal?.aborted) break;
if (enhanced >= maxEnhance) break; if (enhanced >= maxEnhance) break;
if (r.snippet.length < 30 && r.reachable) { if (r.snippet.length < 30 && r.reachable) {
try { try {
const fetchResult = (await this.webFetchTool.execute( // v0.8.0 P1-2: 透传真实执行上下文(signal)—— 移除"伪造空上下文"
{ url: r.url }, const fetchResult = (await this.webFetchTool.execute({ url: r.url }, context)) as {
{ sessionId: '', workspacePath: '', iteration: 0, requestId: '' }, success: boolean;
)) as { success: boolean; content?: string }; content?: string;
};
if (fetchResult.success && fetchResult.content) { if (fetchResult.success && fetchResult.content) {
const text = fetchResult.content.slice(0, 200); const text = fetchResult.content.slice(0, 200);
@@ -729,13 +776,21 @@ export class WebSearchTool implements IMetonaTool {
results: SearchResult[], results: SearchResult[],
fetchMode: string, fetchMode: string,
fetchTop: number, fetchTop: number,
context: ToolExecutionContext,
): Promise<Array<{ url: string; title: string; content: string }>> { ): Promise<Array<{ url: string; title: string; content: string }>> {
// v0.8.0 P1-2 根治: 时间预算收敛 —— 旧实现最坏耗时(8 条 × 3 并发 × 单次
// web_fetch 240s ≈ 720s)远超工具自身 300s 超时,registry 层超时后整次调用
// 作废(已抓取内容全部丢弃)。现以 deadline 为界:剩余预算不足一次最小
// 抓取(30s)即停,已抓取内容照常返回。
const BUDGET_RATIO = 0.8;
const MIN_FETCH_BUDGET_MS = 30_000;
const deadlineMs = Date.now() + 300_000 * BUDGET_RATIO;
// 相关性评分(不过滤,relevance=0 的结果也参与抓取候选) // 相关性评分(不过滤,relevance=0 的结果也参与抓取候选)
const withRelevance = results.map((r) => ({ const filtered = results.map((r) => ({
result: r, result: r,
relevance: computeRelevance(query, r), relevance: computeRelevance(query, r),
})); }));
const filtered = withRelevance.length > 0 ? withRelevance : [];
// 确定抓取数量:fetchTop 已在 execute() 中综合了配置面板和工具参数 // 确定抓取数量:fetchTop 已在 execute() 中综合了配置面板和工具参数
const topN = Math.min(fetchTop, 8, filtered.length); const topN = Math.min(fetchTop, 8, filtered.length);
@@ -761,9 +816,10 @@ export class WebSearchTool implements IMetonaTool {
}): Promise<{ url: string; title: string; content: string } | null> => { }): Promise<{ url: string; title: string; content: string } | null> => {
try { try {
// 委托给 WebFetchTool — 享受三阶段回退策略(HTTP + 反爬 + 浏览器渲染) // 委托给 WebFetchTool — 享受三阶段回退策略(HTTP + 反爬 + 浏览器渲染)
// v0.8.0 P1-2: 透传真实执行上下文(signal)—— 移除"伪造空上下文"
const fetchResult = (await this.webFetchTool.execute( const fetchResult = (await this.webFetchTool.execute(
{ url: item.result.url }, { url: item.result.url },
{ sessionId: '', workspacePath: '', iteration: 0, requestId: '' }, context,
)) as { success: boolean; content?: string }; )) as { success: boolean; content?: string };
if (fetchResult.success && fetchResult.content) { if (fetchResult.success && fetchResult.content) {
@@ -779,9 +835,18 @@ export class WebSearchTool implements IMetonaTool {
} }
}; };
// 并行批次抓取(并发 3 // 并行批次抓取(并发 3,时间预算收敛
const CONCURRENCY = 3; const CONCURRENCY = 3;
for (let i = 0; i < toFetch.length; i += CONCURRENCY) { for (let i = 0; i < toFetch.length; i += CONCURRENCY) {
// v0.8.0 P1-2: 剩余预算不足一次最小抓取 → 停止,保留已抓取内容
const remaining = deadlineMs - Date.now();
if (context.signal?.aborted || remaining < MIN_FETCH_BUDGET_MS) {
logTool(
'web_search',
`Auto-fetch budget stop: fetched=${fetched.length}, remaining=${Math.max(0, remaining)}ms, aborted=${!!context.signal?.aborted}`,
);
break;
}
const batch = toFetch.slice(i, i + CONCURRENCY); const batch = toFetch.slice(i, i + CONCURRENCY);
const settled = await Promise.allSettled(batch.map((item) => fetchOne(item))); const settled = await Promise.allSettled(batch.map((item) => fetchOne(item)));
for (const r of settled) { for (const r of settled) {
+12
View File
@@ -144,6 +144,18 @@ export interface MetonaStreamEvent {
/** DONE — 终止原因(前端可据此区分正常完成/错误/中断) */ /** DONE — 终止原因(前端可据此区分正常完成/错误/中断) */
terminationReason?: string; terminationReason?: string;
/**
* DONE Provider v0.8.0 P0-1 finish_reason
*
* OpenAI 'stop' | 'length' | 'tool_calls' |
* 'content_filter'Anthropic max_tokenslengthrefusalcontent_filter
* Ollama done_reason MiMo repetition_truncationstop
* adapter [DONE]/message_stop/done:true
* DONE /TRACE
* v0.7.4 'stop'length
*/
finishReason?: string;
} }
/** v0.4.1: 输出验证事件载荷 — OutputValidator 检出的疑似问题(幻觉/事实矛盾/敏感信息/格式) */ /** v0.4.1: 输出验证事件载荷 — OutputValidator 检出的疑似问题(幻觉/事实矛盾/敏感信息/格式) */
+70
View File
@@ -1054,3 +1054,73 @@ describe('llm:ollamaPull / ollamaPullCancel — 模型下载', () => {
expect(result.success).toBe(false); expect(result.success).toBe(false);
}); });
}); });
// ===== v0.8.0 P1-1a: 后台会话回放缓冲 =====
describe('agent:getReplayState — v0.8.0 P1-1a 后台会话回放缓冲', () => {
it('streamEvent + stateChange 按序入缓冲,runId 记录,INIT 清空上一 run', async () => {
const { ctx } = makeCtx();
registerAgentHandlers(ctx);
const em = ctx.agentEngineManager as unknown as EventEmitter;
em.emit('streamEvent', {
type: 'text_delta',
sessionId: 'sess_1',
seq: 0,
timestamp: Date.now(),
delta: 'a',
});
em.emit('stateChange', {
sessionId: 'sess_1',
state: 'THINKING',
iteration: 1,
runId: 'run_x',
});
em.emit('streamEvent', {
type: 'text_delta',
sessionId: 'sess_1',
seq: 1,
timestamp: Date.now(),
delta: 'b',
runId: 'run_x',
});
const r = (await getHandler('agent:getReplayState')(null, 'sess_1')) as {
success: boolean;
data: {
isRunning: boolean;
runId: string | null;
truncated: boolean;
events: Array<{ channel: string }>;
};
};
expect(r.success).toBe(true);
expect(r.data.events.length).toBe(3);
expect(r.data.runId).toBe('run_x');
expect(r.data.events.map((e) => e.channel)).toEqual([
'streamEvent',
'stateChange',
'streamEvent',
]);
// 新 run INIT → 上一 run 缓冲作废
em.emit('stateChange', {
sessionId: 'sess_1',
state: 'INIT',
iteration: 0,
runId: 'run_y',
});
const r2 = (await getHandler('agent:getReplayState')(null, 'sess_1')) as {
success: boolean;
data: { events: unknown[] };
};
expect(r2.data.events.length).toBe(0);
});
it('无效 sessionId → 明确失败', async () => {
const { ctx } = makeCtx();
registerAgentHandlers(ctx);
const r = (await getHandler('agent:getReplayState')(null, '')) as { success: boolean };
expect(r.success).toBe(false);
});
});
+137 -4
View File
@@ -59,8 +59,43 @@ interface IterationTrace {
text: string; text: string;
usage?: { input: number; output: number; total: number }; usage?: { input: number; output: number; total: number };
responded: boolean; // 本轮 llm_response 是否已记录(PARSING 与下一轮 THINKING 去重) responded: boolean; // 本轮 llm_response 是否已记录(PARSING 与下一轮 THINKING 去重)
/**
* v0.8.0 P0-1: 本轮 Provider DONE finishReason
* v0.7.4 recordLLMResponse 'stop'length TRACE
* 退 'stop'
*/
finishReason?: string;
} }
/**
* v0.8.0 P1-1a: 单会话的流式回放缓冲
* streamEvent + stateChange
* agent:getReplayState 线
* "后台会话运行内容不丢失"
*
* MAX_REPLAY_EVENTS / MAX_REPLAY_BYTES
* truncated TERMINATED "完成后切回"
* run INIT stateChange
*/
interface ReplayEntry {
channel: 'streamEvent' | 'stateChange';
payload: unknown;
ts: number;
}
interface ReplayBuffer {
events: ReplayEntry[];
bytes: number;
truncated: boolean;
/** 最近一次观察到的 runId(供渲染层回放后对齐 run 守卫) */
runId?: string;
terminated: boolean;
}
const MAX_REPLAY_EVENTS = 2000;
const MAX_REPLAY_BYTES = 4 * 1024 * 1024;
const MAX_REPLAY_SESSIONS = 50;
export function registerAgentHandlers(ctx: IPCContext): void { export function registerAgentHandlers(ctx: IPCContext): void {
const { const {
agentEngineManager, agentEngineManager,
@@ -83,6 +118,54 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// ===== 常驻事件管道:text_delta 按会话节流(F8 ===== // ===== 常驻事件管道:text_delta 按会话节流(F8 =====
const throttleStates = new Map<string, ThrottleState>(); const throttleStates = new Map<string, ThrottleState>();
const iterationTraces = new Map<string, IterationTrace>(); const iterationTraces = new Map<string, IterationTrace>();
// v0.8.0 P1-1a: 每会话流式回放缓冲(后台会话内容恢复)
const replayBuffers = new Map<string, ReplayBuffer>();
/** 追加一条事件到会话回放缓冲(有界:条数/字节双上限,溢出丢最旧) */
const appendReplay = (sessionId: string, entry: ReplayEntry): void => {
let buf = replayBuffers.get(sessionId);
if (!buf) {
// 会话数上限保护:超出后淘汰最早的缓冲(Map 迭代序 = 插入序)
if (replayBuffers.size >= MAX_REPLAY_SESSIONS) {
const oldest = replayBuffers.keys().next().value as string | undefined;
if (oldest !== undefined) replayBuffers.delete(oldest);
}
buf = { events: [], bytes: 0, truncated: false, terminated: false };
replayBuffers.set(sessionId, buf);
}
// run 启动(INIT)后旧 run 缓冲作废 —— 由 stateChange 监听器在 INIT 时清空,
// 此处仅在 terminated 缓冲上遇到新内容时兜底重置(正常路径 INIT 先行)
if (buf.terminated) {
replayBuffers.delete(sessionId);
buf = { events: [], bytes: 0, truncated: false, terminated: false };
replayBuffers.set(sessionId, buf);
}
let size = 0;
try {
size = JSON.stringify(entry.payload).length;
} catch {
size = 256; // 序列化失败按保守值计入
}
buf.events.push(entry);
buf.bytes += size;
while (
buf.events.length > MAX_REPLAY_EVENTS ||
(buf.bytes > MAX_REPLAY_BYTES && buf.events.length > 1)
) {
const dropped = buf.events.shift();
buf.truncated = true;
if (dropped) {
try {
buf.bytes -= JSON.stringify(dropped.payload).length;
} catch {
buf.bytes -= 256;
}
}
}
// 记录 runId(首个携带者)
const payload = entry.payload as { runId?: string } | null;
if (!buf.runId && payload?.runId) buf.runId = payload.runId;
};
// v0.7.3 P1-5: 会话级记忆固化时间戳(consolidation-policy 频率门控的状态持有方) // v0.7.3 P1-5: 会话级记忆固化时间戳(consolidation-policy 频率门控的状态持有方)
// v0.7.4 P3-5: LRU 化 —— 旧实现每会话一条永不清除,长期运行后无界增长。 // v0.7.4 P3-5: LRU 化 —— 旧实现每会话一条永不清除,长期运行后无界增长。
@@ -136,6 +219,9 @@ export function registerAgentHandlers(ctx: IPCContext): void {
agentEngineManager.on('streamEvent', (event: MetonaStreamEvent) => { agentEngineManager.on('streamEvent', (event: MetonaStreamEvent) => {
if (!event.sessionId) return; if (!event.sessionId) return;
const sessionId = event.sessionId; const sessionId = event.sessionId;
// v0.8.0 P1-1a: 全量事件进入回放缓冲(含 text_delta —— 必须在节流分支
// return 之前入队,否则切回会话时已缓冲的正文增量缺失)
appendReplay(sessionId, { channel: 'streamEvent', payload: event, ts: Date.now() });
const trace = iterationTraces.get(sessionId); const trace = iterationTraces.get(sessionId);
// F8: text_delta 聚合,其他事件立即转发(先 flush 保证顺序) // F8: text_delta 聚合,其他事件立即转发(先 flush 保证顺序)
@@ -224,6 +310,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// 旧实现在 DONE 时删除 iterationTraces → 最终迭代的 TRACE 完整性缺失。 // 旧实现在 DONE 时删除 iterationTraces → 最终迭代的 TRACE 完整性缺失。
// 现仅在 TERMINATED 分支统一收尾(flush throttle buffer + 删 iterationTraces)。 // 现仅在 TERMINATED 分支统一收尾(flush throttle buffer + 删 iterationTraces)。
// 注意:此处仍需转发 DONE 事件到前端(下方 broadcast 统一执行)。 // 注意:此处仍需转发 DONE 事件到前端(下方 broadcast 统一执行)。
// v0.8.0 P0-1: 捕获 Provider 原生停止原因(recordLLMResponse 记录真值)
if (trace && event.finishReason) trace.finishReason = event.finishReason;
break; break;
default: default:
break; break;
@@ -247,6 +335,20 @@ export function registerAgentHandlers(ctx: IPCContext): void {
broadcast('agent:stateChange', data); broadcast('agent:stateChange', data);
const sessionId = data.sessionId; const sessionId = data.sessionId;
// v0.8.0 P1-1a: stateChange 事件进入回放缓冲。INIT(run 启动)清空上一 run
// 的缓冲;TERMINATED 置终态标记(保留缓冲供"完成后切回"回放最终内容)。
if (sessionId) {
const stateRaw = data.state ?? data.current ?? '';
if (stateRaw === 'INIT') {
replayBuffers.delete(sessionId);
} else {
appendReplay(sessionId, { channel: 'stateChange', payload: data, ts: Date.now() });
if (stateRaw === 'TERMINATED') {
const buf = replayBuffers.get(sessionId);
if (buf) buf.terminated = true;
}
}
}
if (!sessionId || data.iteration == null) return; if (!sessionId || data.iteration == null) return;
const stateValue = data.state ?? data.current ?? ''; const stateValue = data.state ?? data.current ?? '';
const trace = iterationTraces.get(sessionId); const trace = iterationTraces.get(sessionId);
@@ -258,7 +360,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
sessionId, sessionId,
iteration: trace.iteration, iteration: trace.iteration,
content: trace.text, content: trace.text,
finishReason: 'stop', // v0.8.0 P0-1: 记录真实停止原因(旧实现硬编码 'stop'length 截断不可归因)
finishReason: trace.finishReason ?? 'stop',
tokenUsage: trace.usage ?? { input: 0, output: 0, total: 0 }, tokenUsage: trace.usage ?? { input: 0, output: 0, total: 0 },
}); });
} }
@@ -298,7 +401,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
sessionId, sessionId,
iteration: trace.iteration, iteration: trace.iteration,
content: trace.text, content: trace.text,
finishReason: 'stop', // v0.8.0 P0-1: 记录真实停止原因(旧实现硬编码 'stop'length 截断不可归因)
finishReason: trace.finishReason ?? 'stop',
tokenUsage: trace.usage ?? { input: 0, output: 0, total: 0 }, tokenUsage: trace.usage ?? { input: 0, output: 0, total: 0 },
}); });
} }
@@ -991,6 +1095,27 @@ export function registerAgentHandlers(ctx: IPCContext): void {
return { success: true }; return { success: true };
}); });
// ===== v0.8.0 P1-1a: 后台会话流回放(切回会话时恢复运行内容) =====
// 渲染层 setCurrentSession 检测到目标会话在 sessionRunStates 中运行(或刚结束)
// 时调用本通道:返回回放缓冲中按序保存的 streamEvent + stateChange 事件,
// 渲染层经事件总线灌入 useAgentStream 管线重建消息/Trace 状态,实时事件随后
// 无缝衔接(runId 对齐由缓冲记录的 runId 提供)。
ipcMain.handle('agent:getReplayState', (_event, sessionId: unknown) => {
if (typeof sessionId !== 'string' || !sessionId) {
return { success: false, error: 'Invalid sessionId' };
}
const buf = replayBuffers.get(sessionId);
return {
success: true,
data: {
isRunning: agentEngineManager.isRunning(sessionId),
runId: buf?.runId ?? null,
truncated: buf?.truncated ?? false,
events: buf?.events ?? [],
},
};
});
// ===== v0.5.0: SubAgent 可观测性 ===== // ===== v0.5.0: SubAgent 可观测性 =====
// 1) 生命周期事件广播给前端(AgentMonitor 的 SubAgent 状态区) // 1) 生命周期事件广播给前端(AgentMonitor 的 SubAgent 状态区)
// 2) SubEngine 流事件录制到独立 TRACE 文件(sessionId = taskId),不污染父会话的流 // 2) SubEngine 流事件录制到独立 TRACE 文件(sessionId = taskId),不污染父会话的流
@@ -1005,6 +1130,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
text: string; text: string;
usage: { input: number; output: number; total: number }; usage: { input: number; output: number; total: number };
responded: boolean; responded: boolean;
/** v0.8.0 P0-1: 本轮 Provider 原生停止原因(DONE 事件携带) */
finishReason?: string;
} }
const subTraces = new Map<string, SubTraceState>(); const subTraces = new Map<string, SubTraceState>();
@@ -1117,7 +1244,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
sessionId: taskId, sessionId: taskId,
iteration: st.iteration, iteration: st.iteration,
content: st.text, content: st.text,
finishReason: 'stop', // v0.8.0 P0-1: 记录真实停止原因(与主管道同口径)
finishReason: st.finishReason ?? 'stop',
tokenUsage: st.usage, tokenUsage: st.usage,
}); });
sessionRecorder.recordIterationEnd(taskId, { sessionRecorder.recordIterationEnd(taskId, {
@@ -1152,7 +1280,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
sessionId: taskId, sessionId: taskId,
iteration: st.iteration, iteration: st.iteration,
content: st.text, content: st.text,
finishReason: 'stop', // v0.8.0 P0-1: 记录真实停止原因(与主管道同口径)
finishReason: st.finishReason ?? 'stop',
tokenUsage: st.usage, tokenUsage: st.usage,
}); });
} }
@@ -1184,6 +1313,10 @@ export function registerAgentHandlers(ctx: IPCContext): void {
}; };
} }
break; break;
case MetonaStreamEventType.DONE:
// v0.8.0 P0-1: 捕获 Provider 原生停止原因(SubAgent TRACE 真值)
if (event.finishReason) st.finishReason = event.finishReason;
break;
case MetonaStreamEventType.TOOL_CALL_COMPLETE: case MetonaStreamEventType.TOOL_CALL_COMPLETE:
if (event.toolCall) { if (event.toolCall) {
sessionRecorder.recordToolCall({ sessionRecorder.recordToolCall({
+12
View File
@@ -35,6 +35,18 @@ export function registerAppHandlers(ctx: IPCContext): void {
return result; return result;
}); });
// ===== v0.8.0 P2-3: 下载并安装更新(electron-updater;未启用时明确失败) =====
ipcMain.handle('app:updateInstall', async () => {
const { getAutoUpdaterHandle } = await import('../services/update.service');
const handle = getAutoUpdaterHandle();
if (!handle) {
return { success: false, error: '自动更新未启用(未配置更新源或开发模式)' };
}
// fire-and-forget:进度经 update:status 广播,完成后 quitAndInstall
void handle.downloadAndInstall();
return { success: true };
});
// ===== v0.7.3 P3-4: 健康快照(SLO 指标 + 最近健康检查报告)===== // ===== v0.7.3 P3-4: 健康快照(SLO 指标 + 最近健康检查报告)=====
ipcMain.handle('app:healthSnapshot', async () => { ipcMain.handle('app:healthSnapshot', async () => {
try { try {
+4 -2
View File
@@ -42,8 +42,9 @@ export function registerConfigHandlers(ctx: IPCContext): void {
} }
// v0.7.4 P2-9-C: URL 类配置键高危目标校验(llm.baseURL/searxng.url/updateFeedUrl // v0.7.4 P2-9-C: URL 类配置键高危目标校验(llm.baseURL/searxng.url/updateFeedUrl
// v0.8.0 P1-5: 升级为 async 深校验(域名 DNS 解析拦云元数据绕过)
try { try {
assertSafeConfigUrls([{ key, value }]); await assertSafeConfigUrls([{ key, value }]);
} catch (err) { } catch (err) {
log.warn(`[CONFIG] config:set rejected URL for ${key}: ${(err as Error).message}`); log.warn(`[CONFIG] config:set rejected URL for ${key}: ${(err as Error).message}`);
return { success: false, error: (err as Error).message }; return { success: false, error: (err as Error).message };
@@ -104,8 +105,9 @@ export function registerConfigHandlers(ctx: IPCContext): void {
} }
// v0.7.4 P2-9-C: 批量写入同样校验 URL 类配置键 // v0.7.4 P2-9-C: 批量写入同样校验 URL 类配置键
// v0.8.0 P1-5: 升级为 async 深校验
try { try {
assertSafeConfigUrls(entries as Array<{ key: string; value: unknown }>); await assertSafeConfigUrls(entries as Array<{ key: string; value: unknown }>);
} catch (err) { } catch (err) {
log.warn(`[CONFIG] config:setBatch rejected URL entry: ${(err as Error).message}`); log.warn(`[CONFIG] config:setBatch rejected URL entry: ${(err as Error).message}`);
return { success: false, error: (err as Error).message }; return { success: false, error: (err as Error).message };
+8
View File
@@ -144,4 +144,12 @@ export function registerMCPHandlers(ctx: IPCContext): void {
return { success: false, error: error instanceof Error ? error.message : String(error) }; return { success: false, error: error instanceof Error ? error.message : String(error) };
} }
}); });
// ===== v0.8.0 P2-5: MCP Resources / Prompts 发现 =====
ipcMain.handle('mcp:listServerContents', async (_event, name: unknown) => {
if (typeof name !== 'string' || !name.trim()) {
return { success: false, error: 'Invalid server name' };
}
return { success: true, data: mcpManager.getServerContents(name) };
});
} }
+90
View File
@@ -6,12 +6,19 @@
*/ */
import { ipcMain } from 'electron'; import { ipcMain } from 'electron';
import { join, resolve as resolvePath, sep } from 'path';
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
import type { IPCContext } from './context'; import type { IPCContext } from './context';
/** M-34 修复: 统一 sessionId 校验辅助函数 */ /** M-34 修复: 统一 sessionId 校验辅助函数 */
const isValidSessionId = (id: unknown): id is string => const isValidSessionId = (id: unknown): id is string =>
typeof id === 'string' && id.length > 0 && id.length <= 200; typeof id === 'string' && id.length > 0 && id.length <= 200;
/** v0.8.0 P2-2: 会话录制文件名白名单(防路径逃逸/任意文件读取) */
const RECORDING_NAME_RE = /^session_[A-Za-z0-9_-]+\.jsonl$/;
/** v0.8.0 P2-2: 单个录制文件读取上限(防超大文件撑爆 IPC) */
const MAX_RECORDING_BYTES = 20 * 1024 * 1024;
export function registerSessionHandlers(ctx: IPCContext): void { export function registerSessionHandlers(ctx: IPCContext): void {
const { sessionService, confirmationHook, agentEngineManager } = ctx; const { sessionService, confirmationHook, agentEngineManager } = ctx;
@@ -40,6 +47,8 @@ export function registerSessionHandlers(ctx: IPCContext): void {
agentEngineManager.abort(sessionId); agentEngineManager.abort(sessionId);
await agentEngineManager.waitForAbort(sessionId); await agentEngineManager.waitForAbort(sessionId);
} }
// v0.8.0 P2-1: 软删除 —— 会话进入回收站(deleted_at),30 天后自动清理,
// 可经 sessions:restore 恢复 / sessions:purge 彻底删除
const result = { success: sessionService.delete(sessionId) }; const result = { success: sessionService.delete(sessionId) };
// v0.7.3 P2-3: 会话删除 = 会话终态 —— 决策记忆/pending 确认一并清理 // v0.7.3 P2-3: 会话删除 = 会话终态 —— 决策记忆/pending 确认一并清理
// (防 rememberedDecisions 随会话数累积泄漏) // (防 rememberedDecisions 随会话数累积泄漏)
@@ -52,6 +61,27 @@ export function registerSessionHandlers(ctx: IPCContext): void {
return result; return result;
}); });
// ===== v0.8.0 P2-1: 回收站 =====
ipcMain.handle('sessions:listTrash', async () => {
return sessionService.listDeleted();
});
ipcMain.handle('sessions:restore', async (_event, sessionId: unknown) => {
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
return { success: sessionService.restore(sessionId) };
});
ipcMain.handle('sessions:purge', async (_event, sessionId: unknown) => {
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
// 彻底删除 = 终态:决策记忆/引擎与软删除同口径清理
const result = { success: sessionService.purge(sessionId) };
if (result.success) {
confirmationHook.forgetSession(sessionId);
agentEngineManager.disposeEngine(sessionId);
}
return result;
});
ipcMain.handle('sessions:getMessages', async (_event, sessionId: unknown) => { ipcMain.handle('sessions:getMessages', async (_event, sessionId: unknown) => {
// M-34 修复: 校验 sessionId // M-34 修复: 校验 sessionId
if (!isValidSessionId(sessionId)) return []; if (!isValidSessionId(sessionId)) return [];
@@ -161,4 +191,64 @@ export function registerSessionHandlers(ctx: IPCContext): void {
if (!isValidSessionId(sessionId)) return null; if (!isValidSessionId(sessionId)) return null;
return sessionService.getTraceData(sessionId); return sessionService.getTraceData(sessionId);
}); });
// ===== v0.8.0 P2-2: 会话回放(JSONL 录制读取) =====
ipcMain.handle('sessions:listRecordings', async (_event, sessionId: unknown) => {
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
try {
const logsDir = join(ctx.workspaceService.getPath(), 'logs');
if (!existsSync(logsDir)) return { success: true, data: [] };
const prefix = `session_${sessionId}_`;
const files = readdirSync(logsDir)
.filter((n) => n.startsWith(prefix) && RECORDING_NAME_RE.test(n))
.map((name) => {
try {
const st = statSync(join(logsDir, name));
return { name, size: st.size, mtime: st.mtimeMs };
} catch {
return null;
}
})
.filter((f): f is { name: string; size: number; mtime: number } => f !== null)
.sort((a, b) => b.mtime - a.mtime);
return { success: true, data: files };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('sessions:readRecording', async (_event, name: unknown) => {
if (typeof name !== 'string' || !RECORDING_NAME_RE.test(name)) {
return { success: false, error: 'Invalid recording file name' };
}
try {
const logsDir = resolvePath(ctx.workspaceService.getPath(), 'logs');
const filePath = resolvePath(logsDir, name);
// 目录边界校验(双保险:文件名白名单已杜绝子目录,此处防 symlink/拼接逃逸)
if (!filePath.startsWith(logsDir + sep)) {
return { success: false, error: 'Recording path escapes logs directory' };
}
if (!existsSync(filePath)) return { success: false, error: 'Recording file not found' };
const size = statSync(filePath).size;
if (size > MAX_RECORDING_BYTES) {
return { success: false, error: `Recording file too large (${size} bytes)` };
}
const raw = readFileSync(filePath, 'utf-8');
// 逐行解析,坏行跳过并计数(录制文件允许尾部截断)
const events: Array<Record<string, unknown>> = [];
let skipped = 0;
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
events.push(JSON.parse(trimmed) as Record<string, unknown>);
} catch {
skipped++;
}
}
return { success: true, data: { name, events, skipped } };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
} }
+20 -3
View File
@@ -10,7 +10,11 @@ import log from 'electron-log';
import type { IPCContext } from './context'; import type { IPCContext } from './context';
import { broadcast } from './context'; import { broadcast } from './context';
import { isSensitiveConfigKey } from '../utils/secure-config'; import { isSensitiveConfigKey } from '../utils/secure-config';
import { assertSafeConfigTarget } from '../harness/tools/built-in/ssrf-guard'; // v0.8.0 P1-5: 配置 URL 深校验(域名真实 DNS 解析,拦"解析到云元数据 IP"绕过)
import {
assertSafeConfigTargetDeep,
DeepCheckSoftFailure,
} from '../harness/tools/built-in/ssrf-guard';
/** /**
* v0.7.4 P2-9-C: URL assertSafeConfigTarget * v0.7.4 P2-9-C: URL assertSafeConfigTarget
@@ -33,12 +37,25 @@ const URL_CONFIG_KEYS = new Set([
/** /**
* URL * URL
* URL / / baseURL "未配置" * URL / / baseURL "未配置"
*
* v0.8.0 P1-5: 升级为深校验 assertSafeConfigTarget
* DNS //
* DNS 线 WARN
*/ */
export function assertSafeConfigUrls(entries: Array<{ key: string; value: unknown }>): void { export async function assertSafeConfigUrls(
entries: Array<{ key: string; value: unknown }>,
): Promise<void> {
for (const { key, value } of entries) { for (const { key, value } of entries) {
if (!URL_CONFIG_KEYS.has(key)) continue; if (!URL_CONFIG_KEYS.has(key)) continue;
if (typeof value !== 'string' || value.trim() === '') continue; if (typeof value !== 'string' || value.trim() === '') continue;
assertSafeConfigTarget(value); await assertSafeConfigTargetDeep(value).catch((err) => {
// 软失败(DNS 解析失败)放行,仅日志留痕;硬失败(高危目标)向上抛
if (err instanceof DeepCheckSoftFailure) {
log.warn(`[CONFIG] URL deep check skipped for ${key}: ${(err as Error).message}`);
return;
}
throw err;
});
} }
} }
+53 -8
View File
@@ -12,6 +12,34 @@ import log from 'electron-log';
export function registerWorkspaceHandlers(ctx: IPCContext): void { export function registerWorkspaceHandlers(ctx: IPCContext): void {
const { workspaceService } = ctx; const { workspaceService } = ctx;
// ===== v0.8.0 P2-4: @ 文件提及 =====
// 工作空间文件名索引(联想数据源;renderer 侧 Fuse 模糊过滤)
ipcMain.handle('workspace:listFiles', async (_event, limit: unknown) => {
const maxFiles =
typeof limit === 'number' && Number.isFinite(limit) && limit > 0
? Math.min(5000, Math.floor(limit))
: 2000;
try {
return { success: true, data: workspaceService.listFiles(maxFiles) };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// 读取提及文件的文本片段(路径边界 + 512KB 上限 + 二进制拒绝 + MEMORY.md 保护)
ipcMain.handle('workspace:readFileClip', async (_event, relPath: unknown, maxBytes: unknown) => {
if (typeof relPath !== 'string' || !relPath.trim()) {
return { success: false, error: 'Invalid path' };
}
const cap =
typeof maxBytes === 'number' && Number.isFinite(maxBytes) && maxBytes > 0
? Math.min(1024 * 1024, Math.floor(maxBytes))
: 512 * 1024;
log.info(`[WORKSPACE] readFileClip: ${relPath.slice(0, 120)} (cap=${cap})`);
return workspaceService.readFileClip(relPath, cap);
});
// 校验工作空间路径:检测路径有效性 + 必需文件状态 + 数据库是否存在 // 校验工作空间路径:检测路径有效性 + 必需文件状态 + 数据库是否存在
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') {
@@ -67,11 +95,16 @@ export function registerWorkspaceHandlers(ctx: IPCContext): void {
}); });
// 从旧工作空间继承文件到新工作空间 // 从旧工作空间继承文件到新工作空间
ipcMain.handle('workspace:inheritFiles', async (_event, params: { ipcMain.handle(
'workspace:inheritFiles',
async (
_event,
params: {
targetPath: string; targetPath: string;
sourcePath: string; sourcePath: string;
files: string[]; files: string[];
}) => { },
) => {
const { targetPath, sourcePath, files } = params; const { targetPath, sourcePath, files } = params;
if (!targetPath || !sourcePath || !Array.isArray(files)) { if (!targetPath || !sourcePath || !Array.isArray(files)) {
return { success: false, error: '参数无效' }; return { success: false, error: '参数无效' };
@@ -128,19 +161,28 @@ export function registerWorkspaceHandlers(ctx: IPCContext): void {
try { try {
srcDb.backup(dstFile); srcDb.backup(dstFile);
inherited.push(fileName); inherited.push(fileName);
log.info(`[WORKSPACE] Inherited ${fileName} (via SQLite backup): ${resolvedSource}${resolvedTarget}`); log.info(
`[WORKSPACE] Inherited ${fileName} (via SQLite backup): ${resolvedSource}${resolvedTarget}`,
);
} finally { } finally {
srcDb.close(); srcDb.close();
} }
} catch (err) { } catch (err) {
// backup 失败时回退到 copyFileSync(至少保证基本可用) // backup 失败时回退到 copyFileSync(至少保证基本可用)
log.warn(`[WORKSPACE] SQLite backup failed, fallback to copyFileSync: ${(err as Error).message}`); log.warn(
`[WORKSPACE] SQLite backup failed, fallback to copyFileSync: ${(err as Error).message}`,
);
try { try {
copyFileSync(srcFile, dstFile); copyFileSync(srcFile, dstFile);
inherited.push(fileName); inherited.push(fileName);
log.info(`[WORKSPACE] Inherited ${fileName} (fallback copyFileSync): ${resolvedSource}${resolvedTarget}`); log.info(
`[WORKSPACE] Inherited ${fileName} (fallback copyFileSync): ${resolvedSource}${resolvedTarget}`,
);
} catch (err2) { } catch (err2) {
failed.push({ file: fileName, error: `backup 和 fallback 均失败: ${(err2 as Error).message}` }); failed.push({
file: fileName,
error: `backup 和 fallback 均失败: ${(err2 as Error).message}`,
});
} }
} }
continue; continue;
@@ -156,7 +198,8 @@ export function registerWorkspaceHandlers(ctx: IPCContext): void {
} }
return { success: true, inherited, failed }; return { success: true, inherited, failed };
}); },
);
// #51 修复: 校验源数据库完整性,防止继承损坏的数据库导致新工作空间数据丢失 // #51 修复: 校验源数据库完整性,防止继承损坏的数据库导致新工作空间数据丢失
ipcMain.handle('workspace:checkDatabaseIntegrity', async (_event, sourcePath: string) => { ipcMain.handle('workspace:checkDatabaseIntegrity', async (_event, sourcePath: string) => {
@@ -175,7 +218,9 @@ export function registerWorkspaceHandlers(ctx: IPCContext): void {
const result = db.prepare('PRAGMA integrity_check').get() as { integrity_check: string }; const result = db.prepare('PRAGMA integrity_check').get() as { integrity_check: string };
const ok = result.integrity_check === 'ok'; const ok = result.integrity_check === 'ok';
if (!ok) { if (!ok) {
log.warn(`[WORKSPACE] Source database integrity check failed: ${result.integrity_check} (source=${sourcePath})`); log.warn(
`[WORKSPACE] Source database integrity check failed: ${result.integrity_check} (source=${sourcePath})`,
);
} }
return { success: true, ok, detail: result.integrity_check }; return { success: true, ok, detail: result.integrity_check };
} finally { } finally {
+38
View File
@@ -321,6 +321,24 @@ async function initialize(): Promise<void> {
// v0.7.3 P3-3: 启动期清理旧 JSONL 录制文件(按 mtime 保留最近 200 个) // v0.7.3 P3-3: 启动期清理旧 JSONL 录制文件(按 mtime 保留最近 200 个)
void sessionRecorder.pruneOldRecordings(); void sessionRecorder.pruneOldRecordings();
// v0.8.0 P2-1: 回收站 30 天自动清理(启动 + 每 24h;软删除会话超期硬删除)
const TRASH_RETENTION_DAYS = 30;
const purgeTrash = (): void => {
try {
const purged = sessionService.purgeExpiredTrash(TRASH_RETENTION_DAYS);
if (purged > 0) {
log.info(
`[Trash] Purged ${purged} session(s) older than ${TRASH_RETENTION_DAYS} days from trash`,
);
}
} catch (err) {
log.warn('[Trash] Purge failed:', err);
}
};
purgeTrash();
const trashTimer = setInterval(purgeTrash, 24 * 60 * 60 * 1000);
trashTimer.unref?.();
// ===== v0.2.0: 安全模块(必须在工具注册之前) ===== // ===== v0.2.0: 安全模块(必须在工具注册之前) =====
const policyEngine = new PolicyEngine(); const policyEngine = new PolicyEngine();
const sandboxManager = new SandboxManager({ const sandboxManager = new SandboxManager({
@@ -759,6 +777,26 @@ async function initialize(): Promise<void> {
trayManager = new TrayManager(resourcesPath); trayManager = new TrayManager(resourcesPath);
trayManager.initialize(mainWindow); trayManager.initialize(mainWindow);
// ===== v0.8.0 P2-3: electron-updater 自动更新(仅生产环境 + 已配置 feed) =====
// dev 模式 / 未配置 app.updateFeedUrl 时静默禁用;状态事件广播 update:status
if (!process.env['ELECTRON_RENDERER_URL']) {
const feedUrl = configService.get<string>('app.updateFeedUrl') ?? '';
if (feedUrl) {
const { startAutoUpdater } = await import('./services/update.service');
await startAutoUpdater(feedUrl, (event) => {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send('update:status', event);
}
if (event.status === 'available') {
trayManager?.sendNotification(
'MetonaAI 更新可用',
`新版本 ${event.latestVersion} 已发布,可在 设置 → 日志与数据 中下载安装`,
);
}
});
}
}
// ===== 全局快捷键 ===== // ===== 全局快捷键 =====
windowManager.registerGlobalShortcuts(); windowManager.registerGlobalShortcuts();
+24
View File
@@ -36,6 +36,8 @@ const metonaAPI = {
}, },
/** 中断会话 */ /** 中断会话 */
abortSession: (sessionId: string) => ipcRenderer.invoke('agent:abortSession', sessionId), abortSession: (sessionId: string) => ipcRenderer.invoke('agent:abortSession', sessionId),
/** v0.8.0 P1-1a: 拉取后台会话流式回放缓冲(切回运行中会话时恢复内容) */
getReplayState: (sessionId: string) => ipcRenderer.invoke('agent:getReplayState', sessionId),
/** 监听 Provider 切换通知 */ /** 监听 Provider 切换通知 */
onProviderSwitched: (callback: (data: unknown) => void) => { onProviderSwitched: (callback: (data: unknown) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data); const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data);
@@ -72,6 +74,13 @@ const metonaAPI = {
/** P2-11: 截断消息(编辑重发/重新生成——删除锚点消息之后的所有消息) */ /** P2-11: 截断消息(编辑重发/重新生成——删除锚点消息之后的所有消息) */
truncateAfter: (sessionId: string, messageId: string, inclusive?: boolean) => truncateAfter: (sessionId: string, messageId: string, inclusive?: boolean) =>
ipcRenderer.invoke('sessions:truncateAfter', sessionId, messageId, inclusive), ipcRenderer.invoke('sessions:truncateAfter', sessionId, messageId, inclusive),
// v0.8.0 P2-1: 回收站(软删除 30 天保留)
listTrash: () => ipcRenderer.invoke('sessions:listTrash'),
restore: (sessionId: string) => ipcRenderer.invoke('sessions:restore', sessionId),
purge: (sessionId: string) => ipcRenderer.invoke('sessions:purge', sessionId),
// v0.8.0 P2-2: 会话回放(JSONL 录制读取)
listRecordings: (sessionId: string) => ipcRenderer.invoke('sessions:listRecordings', sessionId),
readRecording: (name: string) => ipcRenderer.invoke('sessions:readRecording', name),
/** v0.5.0: 全文搜索会话内容(FTS5,跨会话,按会话聚合) */ /** v0.5.0: 全文搜索会话内容(FTS5,跨会话,按会话聚合) */
searchContent: (query: string) => searchContent: (query: string) =>
ipcRenderer.invoke('sessions:searchContent', query) as Promise<{ ipcRenderer.invoke('sessions:searchContent', query) as Promise<{
@@ -99,6 +108,8 @@ const metonaAPI = {
removeServer: (name: string) => ipcRenderer.invoke('mcp:removeServer', name), removeServer: (name: string) => ipcRenderer.invoke('mcp:removeServer', name),
toggleServer: (name: string, enabled: boolean) => toggleServer: (name: string, enabled: boolean) =>
ipcRenderer.invoke('mcp:toggleServer', name, enabled), ipcRenderer.invoke('mcp:toggleServer', name, enabled),
// v0.8.0 P2-5: Resources / Prompts 发现
listServerContents: (name: string) => ipcRenderer.invoke('mcp:listServerContents', name),
}, },
// ===== 记忆系统 ===== // ===== 记忆系统 =====
@@ -239,6 +250,15 @@ const metonaAPI = {
| { status: 'up-to-date'; latestVersion: string } | { status: 'up-to-date'; latestVersion: string }
| { status: 'available'; latestVersion: string; downloadUrl?: string; notes?: string } | { status: 'available'; latestVersion: string; downloadUrl?: string; notes?: string }
>, >,
// v0.8.0 P2-3: 下载并安装更新(electron-updater;仅生产环境可用)
updateInstall: () =>
ipcRenderer.invoke('app:updateInstall') as Promise<{ success: boolean; error?: string }>,
// v0.8.0 P2-3: 订阅更新状态事件(checking/available/downloading/downloaded/error
onUpdateStatus: (callback: (event: unknown) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data);
ipcRenderer.on('update:status', listener);
return () => ipcRenderer.removeListener('update:status', listener);
},
// #49 修复: 前端错误上报通道(主进程可注册 'error:report' handler 记录到 electron-log/审计日志) // #49 修复: 前端错误上报通道(主进程可注册 'error:report' handler 记录到 electron-log/审计日志)
// 使用 send(单向)而非 invoke,即使主进程未注册 handler 也不会 reject // 使用 send(单向)而非 invoke,即使主进程未注册 handler 也不会 reject
reportError: (payload: unknown) => ipcRenderer.send('error:report', payload), reportError: (payload: unknown) => ipcRenderer.send('error:report', payload),
@@ -296,6 +316,10 @@ const metonaAPI = {
// #51 修复: 校验源数据库完整性(PRAGMA integrity_check),用于 inheritDatabase 前置校验 // #51 修复: 校验源数据库完整性(PRAGMA integrity_check),用于 inheritDatabase 前置校验
checkDatabaseIntegrity: (sourcePath: string) => checkDatabaseIntegrity: (sourcePath: string) =>
ipcRenderer.invoke('workspace:checkDatabaseIntegrity', sourcePath), ipcRenderer.invoke('workspace:checkDatabaseIntegrity', sourcePath),
// v0.8.0 P2-4: @ 文件提及(文件名索引 + 文本片段读取)
listFiles: (limit?: number) => ipcRenderer.invoke('workspace:listFiles', limit),
readFileClip: (relPath: string, maxBytes?: number) =>
ipcRenderer.invoke('workspace:readFileClip', relPath, maxBytes),
}, },
// ===== 工具管理 ===== // ===== 工具管理 =====
@@ -281,15 +281,21 @@ describe.skipIf(!dbAvailable)('DatabaseService — 多版本迁移矩阵(user_
rmSync(dir, { recursive: true, force: true }); rmSync(dir, { recursive: true, force: true });
}); });
it('user_version=2 的库:快速路径跳过迁移,版本号保持不变', async () => { it('user_version=2 的库:增量迁移至 SCHEMA_VERSIONv0.8.0 补 sessions.deleted_at', async () => {
const { DatabaseService } = await import('../database.service'); const { DatabaseService } = await import('../database.service');
const dir = mkdtempSync(join(tmpdir(), 'metona-migv2-')); const dir = mkdtempSync(join(tmpdir(), 'metona-migv2-'));
const legacy = openLegacyDb(dir); const legacy = openLegacyDb(dir);
// v0.8.0 注: 真实 v2 库必含 sessions 表 —— fixture 补齐以覆盖迁移 10
legacy.exec(` legacy.exec(`
CREATE TABLE messages ( CREATE TABLE messages (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL,
content TEXT, created_at INTEGER NOT NULL content TEXT, created_at INTEGER NOT NULL
); );
CREATE TABLE sessions (
id TEXT PRIMARY KEY, title TEXT DEFAULT '新会话', created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL, message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0, archived INTEGER DEFAULT 0, metadata TEXT DEFAULT '{}'
);
CREATE VIRTUAL TABLE messages_fts USING fts5(content, content=messages, content_rowid=rowid, tokenize='trigram'); CREATE VIRTUAL TABLE messages_fts USING fts5(content, content=messages, content_rowid=rowid, tokenize='trigram');
`); `);
legacy.pragma('user_version = 2'); legacy.pragma('user_version = 2');
@@ -298,7 +304,14 @@ describe.skipIf(!dbAvailable)('DatabaseService — 多版本迁移矩阵(user_
const svc = new DatabaseService(dir); const svc = new DatabaseService(dir);
svc.initialize(); svc.initialize();
const db = svc.getDB(); const db = svc.getDB();
expect(db.pragma('user_version', { simple: true })).toBe(2); // v0.8.0 P2-1 契约更新: SCHEMA_VERSION 升至 3(迁移 10 sessions.deleted_at),
// user_version=2 的库不再是最新版 —— 会执行增量迁移并盖章到 3;
// FTS 已是 trigram(迁移 9 幂等跳过),增量迁移只补 sessions.deleted_at 列。
expect(db.pragma('user_version', { simple: true })).toBe(DatabaseService.SCHEMA_VERSION);
const delCol = db
.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('sessions') WHERE name='deleted_at'")
.get() as { c: number };
expect(delCol.c).toBe(1);
// 快速路径不重建 FTS(保持原有 trigram 表) // 快速路径不重建 FTS(保持原有 trigram 表)
const ftsSql = db const ftsSql = db
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`) .prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
@@ -50,6 +50,7 @@ describe.skipIf(!dbAvailable)('FTS5 会话内容搜索', () => {
message_count INTEGER DEFAULT 0, message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0, pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0, archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}' metadata TEXT DEFAULT '{}'
); );
CREATE TABLE messages ( CREATE TABLE messages (
@@ -281,6 +282,7 @@ describe.skipIf(!dbAvailable)('SessionService — 会话 CRUD 与消息边界',
total_tokens INTEGER DEFAULT 0, total_tokens INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0, pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0, archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}' metadata TEXT DEFAULT '{}'
); );
CREATE TABLE messages ( CREATE TABLE messages (
@@ -618,6 +620,7 @@ describe.skipIf(!dbAvailable)('searchMessages — 更多查询形态与边界',
message_count INTEGER DEFAULT 0, message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0, pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0, archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}' metadata TEXT DEFAULT '{}'
); );
CREATE TABLE messages ( CREATE TABLE messages (
@@ -787,11 +790,26 @@ describe.skipIf(!dbAvailable)('searchMessages — 更多查询形态与边界',
expect(hits.some((r: any) => r.sessionId === 'q8')).toBe(true); expect(hits.some((r: any) => r.sessionId === 'q8')).toBe(true);
}); });
it('已删除会话的标题回退为占位文案((已删除会话)', () => { it('回收站会话(deleted_at 非空)从搜索结果整体剔除(v0.8.0 P2-1 契约', () => {
// 消息引用已不存在的会话 id(真实库 FK 会阻止,此处验证 title 补全兜底) // v0.8.0 P2-1 契约变更: 旧实现把缺标题的会话显示为"(已删除会话)"占位;
// 软删除引入后,回收站会话的搜索命中整体剔除(恢复后重新可见),
// 硬删除会话的消息已级联清除,占位分支不再可达。
insertMessage('q9_m1', 'q9_ghost', 'user', 'ghostkeyword 消息', 1); insertMessage('q9_m1', 'q9_ghost', 'user', 'ghostkeyword 消息', 1);
const hits = sessionService.searchMessages('ghostkeyword'); db.prepare(
expect(hits.some((r: any) => r.sessionTitle === '(已删除会话)')).toBe(true); `INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q9_ghost', 'ghost', 1, 1)`,
).run();
const before = sessionService.searchMessages('ghostkeyword');
expect(before.some((r: any) => r.sessionId === 'q9_ghost')).toBe(true);
// 软删除后 → 搜索不再命中
db.prepare(`UPDATE sessions SET deleted_at = ? WHERE id = 'q9_ghost'`).run(Date.now());
const after = sessionService.searchMessages('ghostkeyword');
expect(after.some((r: any) => r.sessionId === 'q9_ghost')).toBe(false);
// 恢复后 → 重新命中
db.prepare(`UPDATE sessions SET deleted_at = NULL WHERE id = 'q9_ghost'`).run();
const restored = sessionService.searchMessages('ghostkeyword');
expect(restored.some((r: any) => r.sessionId === 'q9_ghost')).toBe(true);
}); });
it('单字符 CJK 查询走 LIKE 回退命中子串', () => { it('单字符 CJK 查询走 LIKE 回退命中子串', () => {
@@ -945,6 +963,7 @@ describe.skipIf(!dbAvailable)('saveMessage id 透传与 updateMessageContentR
message_count INTEGER DEFAULT 0, message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0, pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0, archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}' metadata TEXT DEFAULT '{}'
); );
CREATE TABLE messages ( CREATE TABLE messages (
@@ -56,6 +56,7 @@ describe.skipIf(!dbAvailable)('SessionSummary 分层上下文 × 截断交互',
message_count INTEGER DEFAULT 0, message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0, pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0, archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}' metadata TEXT DEFAULT '{}'
); );
CREATE TABLE messages ( CREATE TABLE messages (
@@ -284,6 +285,7 @@ describe.skipIf(!dbAvailable)('clearMessages × 摘要游标交互(B-2', ()
message_count INTEGER DEFAULT 0, message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0, pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0, archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}' metadata TEXT DEFAULT '{}'
); );
CREATE TABLE messages ( CREATE TABLE messages (
@@ -392,6 +394,7 @@ describe.skipIf(!dbAvailable)('session_summaries 级联删除(F-3', () => {
message_count INTEGER DEFAULT 0, message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0, pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0, archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}' metadata TEXT DEFAULT '{}'
); );
CREATE TABLE session_summaries ( CREATE TABLE session_summaries (
@@ -472,6 +475,7 @@ describe.skipIf(!dbAvailable)('maybeSummarize — 触发阈值与增量', () =>
message_count INTEGER DEFAULT 0, message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0, pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0, archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}' metadata TEXT DEFAULT '{}'
); );
CREATE TABLE messages ( CREATE TABLE messages (
@@ -652,6 +656,7 @@ describe.skipIf(!dbAvailable)('buildHistoryMessages — 无摘要与 role 过滤
message_count INTEGER DEFAULT 0, message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0, pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0, archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}' metadata TEXT DEFAULT '{}'
); );
CREATE TABLE messages ( CREATE TABLE messages (
@@ -746,6 +751,7 @@ describe.skipIf(!dbAvailable)('restoreHistoryImages — 更多边界', () => {
message_count INTEGER DEFAULT 0, message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0, pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0, archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}' metadata TEXT DEFAULT '{}'
); );
CREATE TABLE messages ( CREATE TABLE messages (
@@ -0,0 +1,157 @@
/**
* v0.8.0 P2-1: 会话回收站 30
*
*
* - delete deleted_at list/getSession listDeleted
* - restore
* - purge
* - purgeExpiredTrash(30d)
* - searchMessages
*
* better-sqlite3 Electron ABI test:electron Node
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
vi.mock('electron-log', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
let dbAvailable = true;
let Database: typeof import('better-sqlite3');
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
Database = require('better-sqlite3');
const probe = new Database(':memory:');
probe.close();
} catch {
dbAvailable = false;
}
describe.skipIf(!dbAvailable)('会话回收站(v0.8.0 P2-1', () => {
let sessionService: any;
let db: InstanceType<typeof Database>;
beforeAll(async () => {
const { SessionService } = await import('../session.service');
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
// 最小 schema(与 DatabaseService.createTables 对齐的关键列:deleted_at + 级联删除)
db.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
title TEXT DEFAULT '新会话',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
message_count INTEGER DEFAULT 0,
total_tokens INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}'
);
CREATE TABLE messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
reasoning_content TEXT,
tool_calls TEXT,
tool_result TEXT,
attachments TEXT,
iteration INTEGER,
created_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
CREATE TABLE session_summaries (
session_id TEXT PRIMARY KEY,
summary TEXT NOT NULL,
summarized_until_rowid INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
`);
sessionService = new SessionService(() => db);
});
afterAll(() => {
try {
db.close();
} catch {
/* ignore */
}
});
it('delete → 软删除:list 不返回、listDeleted 返回、getSession 排除', () => {
const a = sessionService.create('A');
const b = sessionService.create('B');
expect(sessionService.delete(a.id)).toBe(true);
const list = sessionService.list().map((s: { id: string }) => s.id);
expect(list).toContain(b.id);
expect(list).not.toContain(a.id);
const trash = sessionService.listDeleted().map((s: { id: string }) => s.id);
expect(trash).toEqual([a.id]);
expect(sessionService.getSession(a.id)).toBeNull();
expect(sessionService.getSession(b.id)).not.toBeNull();
});
it('restore → 恢复到主列表', () => {
const a = sessionService.listDeleted()[0];
expect(sessionService.restore(a.id)).toBe(true);
expect(sessionService.list().map((s: { id: string }) => s.id)).toContain(a.id);
expect(sessionService.listDeleted()).toHaveLength(0);
});
it('purge → 硬删除(仅对回收站条目生效)', () => {
const s = sessionService.create('temp');
sessionService.delete(s.id);
sessionService.saveMessage({ sessionId: s.id, role: 'user', content: 'x' });
expect(sessionService.purge(s.id)).toBe(true);
const count = db
.prepare('SELECT COUNT(*) AS c FROM messages WHERE session_id = ?')
.get(s.id) as {
c: number;
};
expect(count.c).toBe(0);
// 未删除的会话不可 purge(幂等保护)
const live = sessionService.create('live');
expect(sessionService.purge(live.id)).toBe(false);
});
it('purgeExpiredTrash(30d) → 仅清理超期条目', () => {
const old = sessionService.create('old');
sessionService.delete(old.id);
db.prepare('UPDATE sessions SET deleted_at = ? WHERE id = ?').run(
Date.now() - 31 * 24 * 60 * 60 * 1000,
old.id,
);
const fresh = sessionService.create('fresh');
sessionService.delete(fresh.id); // deleted_at = now
const purged = sessionService.purgeExpiredTrash(30);
expect(purged).toBe(1);
const remaining = db.prepare('SELECT COUNT(*) AS c FROM sessions WHERE id = ?').get(old.id) as {
c: number;
};
expect(remaining.c).toBe(0);
expect(sessionService.listDeleted().map((s: { id: string }) => s.id)).toContain(fresh.id);
});
it('searchMessages 聚合剔除回收站会话(≤2 字符 LIKE 回退路径)', () => {
const s1 = sessionService.create('搜索目标会话');
sessionService.saveMessage({ sessionId: s1.id, role: 'user', content: '量子计算机的独特内容' });
// '量子' ≤2 字符走 LIKE 回退(最小 schema 无 FTS 表,与生产 trigram 路径
// 共用同一个 aggregateSearchResults 聚合出口,剔除逻辑同源)
const r1 = sessionService.searchMessages('量子');
expect(r1.length).toBe(1);
sessionService.delete(s1.id);
const r2 = sessionService.searchMessages('量子');
expect(r2.length).toBe(0);
});
});
@@ -45,6 +45,7 @@ describe.skipIf(!dbAvailable)('truncateMessagesAfter × TRACE metadata 过滤(
message_count INTEGER DEFAULT 0, message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0, pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0, archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}' metadata TEXT DEFAULT '{}'
); );
CREATE TABLE messages ( CREATE TABLE messages (
+19 -1
View File
@@ -113,8 +113,9 @@ export class DatabaseService {
* *
* v0.7.4 P4-4: 1 2 9messages_fts trigram * v0.7.4 P4-4: 1 2 9messages_fts trigram
* "失败被吞 + 永久跳过" * "失败被吞 + 永久跳过"
* v0.8.0 P2-1: 2 3 10sessions.deleted_at
*/ */
static readonly SCHEMA_VERSION = 2; static readonly SCHEMA_VERSION = 3;
constructor(workspacePath?: string) { constructor(workspacePath?: string) {
const baseDir = workspacePath ?? join(app.getPath('userData'), 'MetonaWorkspaces', 'default'); const baseDir = workspacePath ?? join(app.getPath('userData'), 'MetonaWorkspaces', 'default');
@@ -180,6 +181,7 @@ export class DatabaseService {
db.exec(` db.exec(`
-- ===== ===== -- ===== =====
-- v0.8.0 P2-1: deleted_at NULL = =
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '新会话', title TEXT NOT NULL DEFAULT '新会话',
@@ -189,6 +191,7 @@ export class DatabaseService {
total_tokens INTEGER NOT NULL DEFAULT 0, total_tokens INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}' metadata TEXT DEFAULT '{}'
); );
@@ -321,6 +324,8 @@ export class DatabaseService {
CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role); CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role);
CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at DESC); CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_pinned ON sessions(pinned DESC, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_sessions_pinned ON sessions(pinned DESC, updated_at DESC);
-- v0.8.0 P2-1: idx_sessions_deleted 10 createTables
-- CREATE INDEX "表存在但缺 deleted_at 列" no-such-column
CREATE INDEX IF NOT EXISTS idx_audit_session ON audit_logs(session_id); CREATE INDEX IF NOT EXISTS idx_audit_session ON audit_logs(session_id);
CREATE INDEX IF NOT EXISTS idx_audit_type ON audit_logs(event_type); CREATE INDEX IF NOT EXISTS idx_audit_type ON audit_logs(event_type);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at); CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at);
@@ -639,6 +644,19 @@ export class DatabaseService {
// 非致命错误 — 如果迁移失败,NOT NULL 约束仍生效,saveMessage 会保存空字符串 // 非致命错误 — 如果迁移失败,NOT NULL 约束仍生效,saveMessage 会保存空字符串
} }
// v0.8.0 P2-1 迁移 10: sessions.deleted_at 回收站软删除列 + 索引
// 存在性守卫:极简遗留库(如 v2 盖章的早期测试库)可能没有 sessions 表,
// 迁移必须可重试且不因缺表中断整个批次
{
const hasSessions = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'")
.get();
if (hasSessions) {
tryAddColumn('sessions', 'deleted_at', 'INTEGER');
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_deleted ON sessions(deleted_at);');
}
}
// v0.7.4 P4-4 迁移 9: messages_fts 升级 trigram tokenizer // v0.7.4 P4-4 迁移 9: messages_fts 升级 trigram tokenizer
// 存量库的 messages_fts 建表语句不含 trigram —— 直接 DROP + 重建 + rebuild // 存量库的 messages_fts 建表语句不含 trigram —— 直接 DROP + 重建 + rebuild
// 使中文非连续子串搜索(trigram ≥3 字符)可用。检测方式:读 sqlite_master 的 // 使中文非连续子串搜索(trigram ≥3 字符)可用。检测方式:读 sqlite_master 的
+70
View File
@@ -234,6 +234,20 @@ export interface MCPServerConfig {
enabled: boolean; enabled: boolean;
} }
/** v0.8.0 P2-5: MCP Resource / Prompt 发现条目(渲染端展示用宽松形态) */
export interface MCPResourceInfo {
uri: string;
name: string;
description?: string;
mimeType?: string;
}
export interface MCPPromptInfo {
name: string;
description?: string;
arguments?: Array<{ name: string; description?: string; required?: boolean }>;
}
export interface MCPServerState { export interface MCPServerState {
config: MCPServerConfig; config: MCPServerConfig;
status: MCPServerStatus; status: MCPServerStatus;
@@ -241,6 +255,9 @@ export interface MCPServerState {
tools: Tool[]; tools: Tool[];
error?: string; error?: string;
connectedAt?: number; connectedAt?: number;
/** v0.8.0 P2-5: 发现的 Resources / Prompts(可选能力,未声明/失败时为空数组) */
resources: MCPResourceInfo[];
prompts: MCPPromptInfo[];
} }
// ===== MCP Tool Adapter ===== // ===== MCP Tool Adapter =====
@@ -506,6 +523,8 @@ export class MCPManager {
status: 'connecting', status: 'connecting',
client: null, client: null,
tools: [], tools: [],
resources: [],
prompts: [],
}); });
try { try {
@@ -558,6 +577,42 @@ export class MCPManager {
const toolsResult = await client.listTools(); const toolsResult = await client.listTools();
const tools = toolsResult.tools ?? []; const tools = toolsResult.tools ?? [];
// v0.8.0 P2-5: Resources / Prompts 发现(可选能力 —— server 未声明或
// 请求失败一律置空数组,不阻断连接与工具注册)
const discoveredResources: MCPResourceInfo[] = [];
const discoveredPrompts: MCPPromptInfo[] = [];
try {
const res = await client.listResources();
for (const r of res.resources ?? []) {
discoveredResources.push({
uri: String(r.uri ?? ''),
name: String(r.name ?? ''),
description: typeof r.description === 'string' ? r.description : undefined,
mimeType: typeof r.mimeType === 'string' ? r.mimeType : undefined,
});
}
} catch {
/* server 未声明 resources 能力 */
}
try {
const res = await client.listPrompts();
for (const p of res.prompts ?? []) {
discoveredPrompts.push({
name: String(p.name ?? ''),
description: typeof p.description === 'string' ? p.description : undefined,
arguments: Array.isArray(p.arguments)
? p.arguments.map((a) => ({
name: String(a.name ?? ''),
description: typeof a.description === 'string' ? a.description : undefined,
required: a.required === true,
}))
: undefined,
});
}
} catch {
/* server 未声明 prompts 能力 */
}
// 注册到 ToolRegistryv0.6.4: registerMCP 对重名冲突返回 false,此处聚合上报) // 注册到 ToolRegistryv0.6.4: registerMCP 对重名冲突返回 false,此处聚合上报)
let registeredCount = 0; let registeredCount = 0;
let skippedCount = 0; let skippedCount = 0;
@@ -580,6 +635,8 @@ export class MCPManager {
state.tools = tools; state.tools = tools;
state.connectedAt = Date.now(); state.connectedAt = Date.now();
state.error = undefined; state.error = undefined;
state.resources = discoveredResources;
state.prompts = discoveredPrompts;
// v0.7.3 P4-2: 连接成功 —— 清零重连计数并取消排程 // v0.7.3 P4-2: 连接成功 —— 清零重连计数并取消排程
this.reconnectAttempts.delete(name); this.reconnectAttempts.delete(name);
@@ -646,6 +703,8 @@ export class MCPManager {
} }
state.client = null; state.client = null;
state.tools = []; state.tools = [];
state.resources = [];
state.prompts = [];
this.notifyToolsChanged(); this.notifyToolsChanged();
} }
@@ -801,6 +860,17 @@ export class MCPManager {
}; };
} }
/**
* v0.8.0 P2-5: 获取 server Resources / Prompts
*/
getServerContents(name: string): { resources: MCPResourceInfo[]; prompts: MCPPromptInfo[] } {
const state = this.servers.get(name);
if (!state || state.status !== 'connected') {
return { resources: [], prompts: [] };
}
return { resources: state.resources ?? [], prompts: state.prompts ?? [] };
}
/** /**
* *
*/ */
+85 -12
View File
@@ -81,6 +81,9 @@ export class SessionService {
/** /**
* *
*
* v0.8.0 P2-1: 排除回收站deleted_at
* / listDeleted
*/ */
list(options?: { archived?: boolean }): SessionInfo[] { list(options?: { archived?: boolean }): SessionInfo[] {
const db = this.getDBFn(); const db = this.getDBFn();
@@ -90,7 +93,7 @@ export class SessionService {
.prepare( .prepare(
` `
SELECT * FROM sessions SELECT * FROM sessions
WHERE archived = ? WHERE archived = ? AND deleted_at IS NULL
ORDER BY pinned DESC, updated_at DESC ORDER BY pinned DESC, updated_at DESC
`, `,
) )
@@ -99,16 +102,74 @@ export class SessionService {
return rows.map((row) => this.toSessionInfo(row)); return rows.map((row) => this.toSessionInfo(row));
} }
/**
* v0.8.0 P2-1: 列出回收站会话deleted_at
*/
listDeleted(): SessionInfo[] {
const db = this.getDBFn();
const rows = db
.prepare(
`
SELECT * FROM sessions
WHERE deleted_at IS NOT NULL
ORDER BY deleted_at DESC
`,
)
.all() as SessionRow[];
return rows.map((row) => this.toSessionInfo(row));
}
/**
* v0.8.0 P2-1: 从回收站恢复会话 deleted_at
*/
restore(sessionId: string): boolean {
const db = this.getDBFn();
const result = db
.prepare('UPDATE sessions SET deleted_at = NULL WHERE id = ? AND deleted_at IS NOT NULL')
.run(sessionId);
if (result.changes > 0) log.info(`Session restored from trash: ${sessionId}`);
return result.changes > 0;
}
/**
* v0.8.0 P2-1: 彻底删除//
*/
purge(sessionId: string): boolean {
const db = this.getDBFn();
const result = db
.prepare('DELETE FROM sessions WHERE id = ? AND deleted_at IS NOT NULL')
.run(sessionId);
if (result.changes > 0) log.info(`Session purged from trash: ${sessionId}`);
return result.changes > 0;
}
/**
* v0.8.0 P2-1: 清理回收站中超过 maxAgeDays
* 24h main.ts
*/
purgeExpiredTrash(maxAgeDays = 30): number {
const db = this.getDBFn();
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
const rows = db
.prepare('SELECT id FROM sessions WHERE deleted_at IS NOT NULL AND deleted_at < ?')
.all(cutoff) as Array<{ id: string }>;
for (const row of rows) {
db.prepare('DELETE FROM sessions WHERE id = ?').run(row.id);
}
return rows.length;
}
/** /**
* v0.7.4 P1-4: ID * v0.7.4 P1-4: ID
* agent:sendMessage saveMessage * agent:sendMessage saveMessage
* FK ERROR+DONE invoke reject * FK foreign_keys=ON ERROR+DONE invoke reject
* v0.8.0 P2-1: 排除回收站会话deleted_at =
*/ */
getSession(sessionId: string): SessionInfo | null { getSession(sessionId: string): SessionInfo | null {
const db = this.getDBFn(); const db = this.getDBFn();
const row = db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId) as const row = db
| SessionRow .prepare('SELECT * FROM sessions WHERE id = ? AND deleted_at IS NULL')
| undefined; .get(sessionId) as SessionRow | undefined;
return row ? this.toSessionInfo(row) : null; return row ? this.toSessionInfo(row) : null;
} }
@@ -163,14 +224,20 @@ export class SessionService {
} }
/** /**
* *
*
* v0.8.0 P2-1 根治: 改为**** deleted_at
* listDeleted 30 purgeExpiredTrash
* purge
*/ */
delete(sessionId: string): boolean { delete(sessionId: string): boolean {
const db = this.getDBFn(); const db = this.getDBFn();
const result = db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId); const result = db
.prepare('UPDATE sessions SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL')
.run(Date.now(), sessionId);
if (result.changes > 0) { if (result.changes > 0) {
log.info(`Session deleted: ${sessionId}`); log.info(`Session moved to trash: ${sessionId}`);
return true; return true;
} }
return false; return false;
@@ -606,14 +673,20 @@ export class SessionService {
if (aggregated.size === 0) return []; if (aggregated.size === 0) return [];
// 补全会话标题(archived 一起搜——搜索不应因归档而缺结果) // 补全会话标题(archived 一起搜——搜索不应因归档而缺结果)
// v0.8.0 P2-1: 回收站会话(deleted_at 非空)从搜索结果剔除
const sessionIds = Array.from(aggregated.keys()); const sessionIds = Array.from(aggregated.keys());
const placeholders = sessionIds.map(() => '?').join(','); const placeholders = sessionIds.map(() => '?').join(',');
const titleRows = db const titleRows = db
.prepare(`SELECT id, title FROM sessions WHERE id IN (${placeholders})`) .prepare(`SELECT id, title, deleted_at FROM sessions WHERE id IN (${placeholders})`)
.all(...sessionIds) as Array<{ id: string; title: string }>; .all(...sessionIds) as Array<{ id: string; title: string; deleted_at: number | null }>;
const titleMap = new Map(titleRows.map((r) => [r.id, r.title])); const titleMap = new Map(
titleRows.filter((r) => r.deleted_at == null).map((r) => [r.id, r.title]),
);
const filteredAggregated = Array.from(aggregated.entries()).filter(([sid]) =>
titleMap.has(sid),
);
return Array.from(aggregated.entries()) return filteredAggregated
.map(([sessionId, info]) => ({ .map(([sessionId, info]) => ({
sessionId, sessionId,
sessionTitle: titleMap.get(sessionId) ?? '(已删除会话)', sessionTitle: titleMap.get(sessionId) ?? '(已删除会话)',
+114 -8
View File
@@ -1,15 +1,17 @@
/** /**
* UpdateService v0.6.4 P4-2 * UpdateService v0.6.4 P4-2 v0.8.0 P2-3 electron-updater
* *
* electron-updater/ *
* "feed 比对" * 1. check()feed JSON
* - app.updateFeedUrl JSON * IPC electron-updaterdev
* { "version": "0.7.0", "url": "https://…/MetonaAI-Setup.exe", "notes": "…" } * 2. startAutoUpdaterv0.8.0 P2-3 electron-updater
* - "检查更新" toast/UI * generic provider 5s available 广 update:status +
* available shell.openExternal app:openExternal * app:updateInstall downloadAndInstall
* electron-updater IPC UI check() * feed / dev / electron-updater
*/ */
import log from 'electron-log';
export interface UpdateFeedInfo { export interface UpdateFeedInfo {
version: string; version: string;
url?: string; url?: string;
@@ -22,6 +24,15 @@ export type UpdateCheckResult =
| { status: 'up-to-date'; latestVersion: string } | { status: 'up-to-date'; latestVersion: string }
| { status: 'available'; latestVersion: string; downloadUrl?: string; notes?: string }; | { status: 'available'; latestVersion: string; downloadUrl?: string; notes?: string };
/** v0.8.0 P2-3: 广播给渲染层的更新状态事件(update:status 通道载荷) */
export type UpdateStatusEvent =
| { status: 'checking' }
| { status: 'available'; latestVersion: string; notes?: string }
| { status: 'not-available'; latestVersion: string }
| { status: 'downloading'; percent: number }
| { status: 'downloaded' }
| { status: 'error'; message: string };
/** /**
* Major.Minor.Patch * Major.Minor.Patch
* false * false
@@ -91,3 +102,98 @@ export class UpdateService {
} }
} }
} }
// ===== v0.8.0 P2-3: electron-updater 集成(生产环境自动更新) =====
/** AutoUpdater 句柄(IPC 层消费;null = 未启用/dev 模式/未配置 feed */
export interface AutoUpdaterHandle {
checkForUpdates: () => Promise<void>;
downloadAndInstall: () => Promise<void>;
}
let activeHandle: AutoUpdaterHandle | null = null;
/** 获取已启动的 AutoUpdater 句柄(app:updateInstall 消费;未启用时返回 null */
export function getAutoUpdaterHandle(): AutoUpdaterHandle | null {
return activeHandle;
}
/**
* electron-updater
*
* @param feedUrl generic providerapp.updateFeedUrl
* @param onEvent 广 update:status
* @param autoCheckDelayMs 5s
* @returns null dev / electron-updater
*/
export async function startAutoUpdater(
feedUrl: string,
onEvent: (event: UpdateStatusEvent) => void,
autoCheckDelayMs = 5_000,
): Promise<AutoUpdaterHandle | null> {
if (!feedUrl) {
log.info('[Update] auto-updater disabled: no feed URL configured');
return null;
}
try {
// 动态导入:dev 模式(未打包)下 electron-updater 无法工作(无 app-update.yml),
// 加载失败时静默禁用,保持可用性
const { autoUpdater } = await import('electron-updater');
autoUpdater.autoDownload = false; // 下载需用户触发(app:updateInstall
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.logger = null; // 事件统一经 onEvent 广播,避免双通道日志噪声
autoUpdater.on('checking-for-update', () => onEvent({ status: 'checking' }));
autoUpdater.on('update-available', (info) => {
onEvent({ status: 'available', latestVersion: String(info.version ?? ''), notes: undefined });
});
autoUpdater.on('update-not-available', (info) => {
onEvent({ status: 'not-available', latestVersion: String(info.version ?? '') });
});
autoUpdater.on('download-progress', (progress) => {
onEvent({ status: 'downloading', percent: Math.round(progress.percent ?? 0) });
});
autoUpdater.on('update-downloaded', () => onEvent({ status: 'downloaded' }));
autoUpdater.on('error', (err) => {
onEvent({ status: 'error', message: (err as Error).message });
});
// generic provider feedapp-update.yml 缺失时显式 setFeedURL 兜底)
autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl });
const handle: AutoUpdaterHandle = {
checkForUpdates: async () => {
try {
await autoUpdater.checkForUpdates();
} catch (err) {
onEvent({ status: 'error', message: (err as Error).message });
}
},
downloadAndInstall: async () => {
try {
await autoUpdater.downloadUpdate();
onEvent({ status: 'downloaded' });
autoUpdater.quitAndInstall();
} catch (err) {
onEvent({ status: 'error', message: (err as Error).message });
}
},
};
activeHandle = handle;
// 启动 5s 后静默检查(不阻塞窗口就绪)
const timer = setTimeout(() => {
void handle.checkForUpdates();
}, autoCheckDelayMs);
timer.unref?.();
log.info(`[Update] auto-updater started (feed=${feedUrl})`);
return handle;
} catch (err) {
log.warn(
'[Update] electron-updater unavailable, auto-update disabled:',
(err as Error).message,
);
return null;
}
}
@@ -27,6 +27,18 @@ export interface WindowState {
export class WindowManager { export class WindowManager {
private windows = new Map<string, BrowserWindow>(); private windows = new Map<string, BrowserWindow>();
private activeWindowId: string | null = null; private activeWindowId: string | null = null;
/**
* v0.8.0 P1-3.7: 渲染进程崩溃自愈退避 window id { count, windowStart }
* 60s 3 reload crashreloadcrash
* reload 5
*/
private crashReloadAttempts = new Map<
number,
{ count: number; windowStart: number; lastAt: number }
>();
private static readonly CRASH_RELOAD_MAX = 3;
private static readonly CRASH_RELOAD_WINDOW_MS = 60_000;
private static readonly CRASH_RELOAD_RESET_MS = 5 * 60_000;
/** /**
* *
@@ -92,11 +104,51 @@ export class WindowManager {
// 崩溃可观测性 + 自愈: 渲染进程崩溃(OOM/原生崩溃)时记录归因日志并自动重载, // 崩溃可观测性 + 自愈: 渲染进程崩溃(OOM/原生崩溃)时记录归因日志并自动重载,
// 替代静默白屏(用户此前感知为"应用崩溃"且无从恢复) // 替代静默白屏(用户此前感知为"应用崩溃"且无从恢复)
// v0.8.0 P1-3.7: 增加退避 —— 60s 内连续 ≥3 次崩溃停止自动 reload 并弹窗
//(根因持久存在时旧实现会 crash→reload→crash 死循环);稳定 5 分钟后计数复位。
win.webContents.on('render-process-gone', (_event, details) => { win.webContents.on('render-process-gone', (_event, details) => {
log.error( log.error(
`[WindowManager] render-process-gone (id=${win.id}): reason=${details.reason} exitCode=${details.exitCode}`, `[WindowManager] render-process-gone (id=${win.id}): reason=${details.reason} exitCode=${details.exitCode}`,
); );
if (!win.isDestroyed() && details.reason !== 'clean-exit') { if (!win.isDestroyed() && details.reason !== 'clean-exit') {
const now = Date.now();
const record = this.crashReloadAttempts.get(win.id) ?? {
count: 0,
windowStart: now,
lastAt: 0,
};
// 稳定 5 分钟 → 计数复位
if (record.lastAt > 0 && now - record.lastAt > WindowManager.CRASH_RELOAD_RESET_MS) {
record.count = 0;
record.windowStart = now;
}
// 60s 滑动窗口外 → 重新起算
if (now - record.windowStart > WindowManager.CRASH_RELOAD_WINDOW_MS) {
record.count = 0;
record.windowStart = now;
}
record.count += 1;
record.lastAt = now;
this.crashReloadAttempts.set(win.id, record);
if (record.count > WindowManager.CRASH_RELOAD_MAX) {
log.error(
`[WindowManager] Crash reload threshold exceeded (${record.count} in ${WindowManager.CRASH_RELOAD_WINDOW_MS / 1000}s) — NOT reloading automatically (crash loop protection)`,
);
try {
// showErrorBox 为同步 APIvoid 返回),崩溃循环保护路径直接弹窗
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { dialog } = require('electron') as typeof import('electron');
dialog.showErrorBox(
'MetonaAI Desktop 反复崩溃',
`渲染进程在 ${WindowManager.CRASH_RELOAD_WINDOW_MS / 1000} 秒内连续崩溃 ${record.count} 次,已停止自动恢复。\n\n请重启应用;若反复出现,请携带日志(设置 → 日志与数据 → 打开日志目录)反馈。`,
);
} catch {
/* dialog 不可用时仅保留日志 */
}
return;
}
try { try {
win.webContents.reload(); win.webContents.reload();
log.info(`[WindowManager] Window ${id} reloaded after renderer crash`); log.info(`[WindowManager] Window ${id} reloaded after renderer crash`);
+110 -2
View File
@@ -12,10 +12,12 @@
* @see standard/.md 使 fs/path * @see standard/.md 使 fs/path
*/ */
import { join } from 'path'; import { join, resolve, sep } from 'path';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, statSync } from 'fs';
import { app } from 'electron'; import { app } from 'electron';
import log from 'electron-log'; import log from 'electron-log';
// v0.8.0 P2-4: @ 提及文本片段的编码检测(与 read_file 同源)
import { decodeBufferWithDetection } from '../harness/tools/built-in/file-guard';
// ===== 类型定义 ===== // ===== 类型定义 =====
@@ -138,6 +140,112 @@ export class WorkspaceService {
return { ...this.files }; return { ...this.files };
} }
// ===== v0.8.0 P2-4: @ 文件提及数据源 =====
/** 列出目录树时的噪声目录(依赖产物/版本库/运行时数据,永不进入提及索引) */
private static readonly MENTION_SKIP_DIRS = new Set([
'node_modules',
'.git',
'logs',
'.metona',
'dist',
'dist-electron',
'out',
'build',
'release',
]);
/**
* @
*
* MENTION_SKIP_DIRS 8
* maxFilesFileGuard MEMORY.md
* "文件名索引"
*/
listFiles(maxFiles = 2000): string[] {
const out: string[] = [];
const walk = (dir: string, rel: string, depth: number): void => {
if (out.length >= maxFiles || depth > 8) return;
let entries: import('fs').Dirent[];
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (out.length >= maxFiles) return;
if (entry.name.startsWith('.')) continue;
if (entry.isDirectory() && WorkspaceService.MENTION_SKIP_DIRS.has(entry.name)) continue;
const relName = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isFile()) {
out.push(relName);
} else if (entry.isDirectory()) {
walk(join(dir, entry.name), relName, depth + 1);
}
}
};
walk(this.workspacePath, '', 0);
return out;
}
/**
* @ maxBytes 512KB
*
* resolve MEMORY.md
* isProtectedWorkspaceFile file-guard
* decodeBufferWithDetectionNUL BOM
*/
readFileClip(
relPath: string,
maxBytes = 512 * 1024,
): {
success: boolean;
error?: string;
name?: string;
content?: string;
truncated?: boolean;
size?: number;
} {
try {
const resolved = resolve(this.workspacePath, relPath);
if (resolved !== this.workspacePath && !resolved.startsWith(this.workspacePath + sep)) {
return { success: false, error: 'Path escapes workspace' };
}
if (!existsSync(resolved) || !statSync(resolved).isFile()) {
return { success: false, error: 'File not found' };
}
// 根目录 MEMORY.md 保护(与 read_file 工具同口径)
if (
resolve(resolved).toLowerCase() === resolve(this.workspacePath, 'MEMORY.md').toLowerCase()
) {
return { success: false, error: 'Access to workspace root MEMORY.md is protected' };
}
const size = statSync(resolved).size;
const buf = readFileSync(resolved);
const head = buf.subarray(0, Math.min(buf.length, 8192));
// 二进制判定:非 UTF-16 BOM 前缀下含 NUL 字节 → 拒绝(文本附件语义)
const isUtf16Bom =
(head[0] === 0xff && head[1] === 0xfe) || (head[0] === 0xfe && head[1] === 0xff);
if (!isUtf16Bom) {
for (const b of head) {
if (b === 0)
return { success: false, error: 'Binary file is not supported for @ mention' };
}
}
const clipped = buf.length > maxBytes ? buf.subarray(0, maxBytes) : buf;
const { content } = decodeBufferWithDetection(clipped);
return {
success: true,
name: relPath,
content,
truncated: buf.length > maxBytes,
size,
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
/** /**
* MEMORY.md * MEMORY.md
*/ */
@@ -0,0 +1,194 @@
/**
* v0.8.0 P1-4/P1-5: Pinned CONNECT + URL
*
* - pinned-proxy/ CONNECT resolver mock
*
* - assertSafeConfigTargetDeep IP DNS
* DeepCheckSoftFailureIP
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as net from 'net';
import {
createPinnedProxy,
ensurePinnedProxy,
isPinnedProxyActive,
} from '../../utils/pinned-proxy';
import {
assertSafeConfigTargetDeep,
DeepCheckSoftFailure,
__dnsLookup,
} from '../../harness/tools/built-in/ssrf-guard';
// ===== Pinned CONNECT 代理 =====
describe('pinned CONNECT proxy (P1-4)', () => {
const keepAlive: Array<net.Server> = [];
it('public target → CONNECT tunnel established (200), traffic pinned to resolved IP', async () => {
// 本地 echo 服务器充当"上游"mock resolver 把域名 pin 到 127.0.0.1
// 验证隧道建立后字节双向透传(不触公网)
const echo = net.createServer((socket) => {
socket.pipe(socket);
});
await new Promise<void>((r) => echo.listen(0, '127.0.0.1', r));
const echoPort = (echo.address() as net.AddressInfo).port;
keepAlive.push(echo);
const proxy = await createPinnedProxy(async () => {
// 校验通过 → 返回校验期锁定的 IP(pinning 语义:连接用同一批 IP)
return ['127.0.0.1'];
});
keepAlive.push(proxy as unknown as net.Server);
const target = net.connect({ host: '127.0.0.1', port: proxy.port });
const result = await new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('timeout')), 3_000);
let buf = '';
target.on('data', (d) => {
buf += d.toString();
if (buf.includes('\r\n\r\n')) {
// 200 已到 → 隧道建立,写 payload 验证透传
target.write(`ping-to-${echoPort}`);
}
if (buf.includes(`ping-to-${echoPort}`)) {
clearTimeout(timer);
resolve(buf.split('\r\n')[0]);
}
});
target.on('error', (e) => {
clearTimeout(timer);
reject(e);
});
target.write(`CONNECT example.com:${echoPort} HTTP/1.1\r\nHost: example.com\r\n\r\n`);
});
expect(result).toContain('200');
target.destroy();
echo.close();
await proxy.close();
});
it('private/metadata target → 403 rejected (fail-closed)', async () => {
const proxy = await createPinnedProxy(async () => {
throw new Error('Blocked SSRF: resolves to private IP');
});
const target = net.connect({ host: '127.0.0.1', port: proxy.port });
const response = await new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('timeout')), 2_000);
let buf = '';
target.on('data', (d) => {
buf += d.toString();
if (buf.includes('\r\n\r\n')) {
clearTimeout(timer);
resolve(buf.split('\r\n')[0]);
}
});
target.on('error', (e) => {
clearTimeout(timer);
reject(e);
});
target.write('CONNECT 169.254.169.254:80 HTTP/1.1\r\nHost: 169.254.169.254\r\n\r\n');
});
expect(response).toContain('403');
target.destroy();
await proxy.close();
});
it('invalid CONNECT target → 400', async () => {
const proxy = await createPinnedProxy(async () => ['1.2.3.4']);
const target = net.connect({ host: '127.0.0.1', port: proxy.port });
const response = await new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('timeout')), 2_000);
let buf = '';
target.on('data', (d) => {
buf += d.toString();
if (buf.includes('\r\n\r\n')) {
clearTimeout(timer);
resolve(buf.split('\r\n')[0]);
}
});
target.on('error', (e) => {
clearTimeout(timer);
reject(e);
});
target.write('CONNECT no-port-target HTTP/1.1\r\n\r\n');
});
expect(response).toContain('400');
target.destroy();
await proxy.close();
});
it('ensurePinnedProxy: idempotent start + active flag', async () => {
const port1 = await ensurePinnedProxy();
const port2 = await ensurePinnedProxy();
expect(port1).not.toBeNull();
expect(port2).toBe(port1);
expect(isPinnedProxyActive()).toBe(true);
}, 10_000);
afterAll(() => {
for (const s of keepAlive) {
try {
s.close();
} catch {
/* ignore */
}
}
});
});
// ===== 配置 URL 深校验 =====
describe('assertSafeConfigTargetDeep (P1-5)', () => {
it('hostname resolving to metadata IP → rejected', async () => {
const original = __dnsLookup.current;
__dnsLookup.current = (async () => [
{ address: '169.254.169.254', family: 4 },
]) as unknown as typeof __dnsLookup.current;
try {
await expect(assertSafeConfigTargetDeep('https://attacker.example/search')).rejects.toThrow(
/high-risk/,
);
} finally {
__dnsLookup.current = original;
}
});
it('hostname resolving to public IP → allowed', async () => {
const original = __dnsLookup.current;
__dnsLookup.current = (async () => [
{ address: '93.184.216.34', family: 4 },
]) as unknown as typeof __dnsLookup.current;
try {
await expect(assertSafeConfigTargetDeep('https://example.com')).resolves.toBeUndefined();
} finally {
__dnsLookup.current = original;
}
});
it('DNS failure → DeepCheckSoftFailure (soft-fail allow, caller logs)', async () => {
const original = __dnsLookup.current;
__dnsLookup.current = (async () => {
throw new Error('ENOTFOUND');
}) as unknown as typeof __dnsLookup.current;
try {
await expect(assertSafeConfigTargetDeep('https://offline.example')).rejects.toBeInstanceOf(
DeepCheckSoftFailure,
);
} finally {
__dnsLookup.current = original;
}
});
it('IP-literal link-local target → rejected without DNS', async () => {
await expect(assertSafeConfigTargetDeep('http://169.254.169.254/latest')).rejects.toThrow(
/link-local|metadata/,
);
});
it('loopback target → allowed (local SearXNG/MCP legit)', async () => {
await expect(
assertSafeConfigTargetDeep('http://127.0.0.1:8080/search'),
).resolves.toBeUndefined();
});
});
+18 -1
View File
@@ -24,6 +24,9 @@
import { session } from 'electron'; import { session } from 'electron';
import log from 'electron-log'; import log from 'electron-log';
// v0.8.0 P1-4: Agent 浏览器分区的 Pinned 代理状态探测(避免覆盖浏览器通道
// 的 SSRF pinning 代理设置)
import { isPinnedProxyActive } from './pinned-proxy';
const AGENT_BROWSER_PARTITION = 'persist:metona-agent-browser'; const AGENT_BROWSER_PARTITION = 'persist:metona-agent-browser';
@@ -50,9 +53,23 @@ export async function applySessionProxy(proxyUrl: string | null | undefined): Pr
try { try {
const config = rules !== '' ? { proxyRules: rules } : { mode: 'direct' as const }; const config = rules !== '' ? { proxyRules: rules } : { mode: 'direct' as const };
await session.defaultSession.setProxy(config); await session.defaultSession.setProxy(config);
// v0.8.0 P1-4: agent-browser 分区的代理优先级 ——
// ① 用户显式配置了代理(rules 非空)→ 用户代理语义优先,覆盖 Pinned 代理
// (M7 已知限制口径:该模式下浏览器通道无 pinning);
// ② 未配置代理但 Pinned 代理已激活 → 不动该分区(保留浏览器通道 SSRF pinning);
// ③ 其余 → 显式直连。
if (rules !== '' || !isPinnedProxyActive()) {
await session.fromPartition(AGENT_BROWSER_PARTITION).setProxy(config); await session.fromPartition(AGENT_BROWSER_PARTITION).setProxy(config);
log.info( log.info(
`[Network] Chromium sessions proxy ${rules === '' ? 'set to direct (no proxy)' : `rules: ${rules}`}`, `[Network] agent-browser partition proxy ${rules === '' ? 'set to direct' : `rules: ${rules}`}`,
);
} else {
log.info(
'[Network] agent-browser partition left on SSRF pinned proxy (no user proxy configured)',
);
}
log.info(
`[Network] Chromium default session proxy ${rules === '' ? 'set to direct (no proxy)' : `rules: ${rules}`}`,
); );
} catch (err) { } catch (err) {
log.warn( log.warn(
+267
View File
@@ -0,0 +1,267 @@
/**
* Pinned CONNECT Proxy Agent SSRF v0.8.0 P1-4
*
* web_fetch 退 web_browser Chromium
* SSRF Chromium DNS
* DNSTTL=0DNS
* rebinding HTTP DNS pinning 线
*
* HTTP 127.0.0.1 Agent
* persist:metona-agent-browser
* - CONNECThttps host resolvePublicAddresses
* ** IP** TLS/SNI/
* Chromium MITM
* rebinding HTTPS
* - HTTP pinned IP
* - 403 + WARN fail-closed
*
*
* - network.proxyUrlisProxyActive()
* DNS pinning
* M7
* - "无 pinned 代理" WARN
*
*/
import * as http from 'http';
import * as net from 'net';
import log from 'electron-log';
import { resolvePublicAddresses } from '../harness/tools/built-in/ssrf-guard';
/** 校验函数签名(可注入以便测试;默认为生产实现 resolvePublicAddresses */
export type AddressResolver = (url: string) => Promise<string[]>;
export interface PinnedProxy {
/** 本地监听端口(127.0.0.1 */
port: number;
/** 停止代理服务器 */
close: () => Promise<void>;
}
/** 解析 "host:port" 形态(CONNECT 请求行目标) */
function splitHostPort(target: string): { host: string; port: number } | null {
const lastColon = target.lastIndexOf(':');
if (lastColon <= 0) return null;
let host = target.slice(0, lastColon);
const portStr = target.slice(lastColon + 1);
// IPv6 字面量目标:去方括号(resolvePublicAddresses 侧已处理 [host] 形态)
if (host.startsWith('[') && host.endsWith(']')) {
host = host.slice(1, -1);
}
const port = Number(portStr);
if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
return { host, port };
}
/** 400/403 之类的简易响应后断开 */
function rejectRequest(socket: net.Socket, status: string, body: string): void {
if (socket.writable) {
socket.write(
`HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Length: ${body.length}\r\n\r\n${body}`,
);
}
socket.destroy();
}
/**
* Pinned resolver
*
* @param resolve / resolvePublicAddresses
*/
export function createPinnedProxy(
resolve: AddressResolver = resolvePublicAddresses,
): Promise<PinnedProxy> {
const server = http.createServer((req, res) => {
// 绝对形式 HTTP(Chromium 经代理的明文请求):同校验后按 pinned IP 转发
void handlePlainHttp(req, res, resolve);
});
server.on('connect', (req, clientSocket, head) => {
void handleConnect(req, clientSocket as net.Socket, head, resolve);
});
server.on('clientError', (_err, socket) => {
try {
socket.destroy();
} catch {
/* ignore */
}
});
return new Promise<PinnedProxy>((resolvePromise, rejectPromise) => {
const onError = (err: Error): void => {
server.removeListener('listening', onListening);
rejectPromise(err);
};
const onListening = (): void => {
server.removeListener('error', onError);
const addr = server.address();
const port = typeof addr === 'object' && addr ? addr.port : 0;
resolvePromise({
port,
close: () =>
new Promise<void>((res) => {
server.close(() => res());
}),
});
};
server.once('error', onError);
server.once('listening', onListening);
server.listen(0, '127.0.0.1');
});
}
/** CONNECT 隧道:校验目标 → 直连 pinned IP → 双向管道 */
async function handleConnect(
req: http.IncomingMessage,
clientSocket: net.Socket,
head: Buffer,
resolve: AddressResolver,
): Promise<void> {
const target = req.url ?? '';
const parsed = splitHostPort(target);
if (!parsed) {
rejectRequest(clientSocket, '400 Bad Request', 'Invalid CONNECT target');
return;
}
const { host, port } = parsed;
let ips: string[];
try {
// 以 http(s) 形式构造 URL 供 resolvePublicAddresses 校验(其只关心协议与 host;
// 443 用 https 仅为语义准确,校验逻辑对两者一致)
const scheme = port === 443 ? 'https' : 'http';
ips = await resolve(`${scheme}://${host}:${port}`);
} catch (err) {
log.warn(`[PinnedProxy] CONNECT ${host}:${port} blocked: ${(err as Error).message}`);
rejectRequest(clientSocket, '403 Forbidden', 'SSRF validation failed');
return;
}
const upstream = net.connect({ host: ips[0] ?? host, port });
let established = false;
upstream.once('connect', () => {
established = true;
if (clientSocket.writable) {
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
if (head && head.length > 0) upstream.write(head);
clientSocket.pipe(upstream);
upstream.pipe(clientSocket);
} else {
upstream.destroy();
}
});
const cleanup = (): void => {
try {
clientSocket.destroy();
} catch {
/* ignore */
}
try {
upstream.destroy();
} catch {
/* ignore */
}
};
clientSocket.once('error', cleanup);
clientSocket.once('close', () => {
if (established) cleanup();
});
upstream.once('error', (err) => {
log.warn(`[PinnedProxy] upstream error for ${host}:${port}: ${(err as Error).message}`);
if (!established) {
rejectRequest(clientSocket, '502 Bad Gateway', 'Upstream connect failed');
} else {
cleanup();
}
});
}
/** 绝对形式 HTTP 转发:校验目标 → 按 pinned IP 发起请求 → 回写响应 */
async function handlePlainHttp(
req: http.IncomingMessage,
res: http.ServerResponse,
resolve: AddressResolver,
): Promise<void> {
const rawUrl = req.url ?? '';
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
res.writeHead(400).end('Invalid absolute-form URL');
return;
}
if (parsed.protocol !== 'http:') {
// https 走 CONNECT,明文通道只处理 http 绝对形式
res.writeHead(400).end('Only http absolute-form supported (https uses CONNECT)');
return;
}
let ips: string[];
try {
ips = await resolve(rawUrl);
} catch (err) {
log.warn(`[PinnedProxy] HTTP ${parsed.host} blocked: ${(err as Error).message}`);
res.writeHead(403).end('SSRF validation failed');
return;
}
const port = parsed.port ? Number(parsed.port) : 80;
const upstreamReq = http.request(
{
host: ips[0] ?? parsed.hostname,
port,
method: req.method,
path: parsed.pathname + parsed.search,
headers: { ...req.headers, host: parsed.host },
},
(upstreamRes) => {
res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
upstreamRes.pipe(res);
},
);
upstreamReq.on('error', (err) => {
log.warn(`[PinnedProxy] upstream http error for ${parsed.host}: ${(err as Error).message}`);
if (!res.headersSent) res.writeHead(502);
res.end('Upstream request failed');
});
req.pipe(upstreamReq);
}
// ===== 模块级单例(BrowserWindowManager / network-proxy 消费) =====
let singleton: PinnedProxy | null = null;
let starting: Promise<PinnedProxy | null> | null = null;
/** 是否已有 Pinned 代理在运行(applySessionProxy 据此决定是否覆盖分区代理) */
export function isPinnedProxyActive(): boolean {
return singleton !== null;
}
/**
* Pinned null pinned
*
*/
export async function ensurePinnedProxy(): Promise<number | null> {
if (singleton) return singleton.port;
if (!starting) {
starting = (async (): Promise<PinnedProxy | null> => {
try {
singleton = await createPinnedProxy();
log.info(`[PinnedProxy] SSRF-pinned proxy listening on 127.0.0.1:${singleton.port}`);
return singleton;
} catch (err) {
log.warn(
'[PinnedProxy] failed to start pinned proxy (browser channel falls back to unpinned):',
err,
);
singleton = null;
return null;
} finally {
starting = null;
}
})();
}
const result = await starting;
return result?.port ?? null;
}
+97 -20
View File
@@ -1,12 +1,12 @@
{ {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.7.4", "version": "0.8.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.7.4", "version": "0.8.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
@@ -19,6 +19,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"electron-log": "^5.3.3", "electron-log": "^5.3.3",
"electron-updater": "^6.8.9",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"i18next": "^26.4.0", "i18next": "^26.4.0",
"lru-cache": "^11.1.0", "lru-cache": "^11.1.0",
@@ -3572,9 +3573,9 @@
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.6", "version": "7.0.8",
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.6.tgz", "resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.8.tgz",
"integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -4263,7 +4264,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/aria-query": { "node_modules/aria-query": {
@@ -4665,7 +4665,6 @@
"version": "9.7.0", "version": "9.7.0",
"resolved": "https://registry.npmmirror.com/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", "resolved": "https://registry.npmmirror.com/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz",
"integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"debug": "^4.3.4", "debug": "^4.3.4",
@@ -5953,6 +5952,69 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/electron-updater": {
"version": "6.8.9",
"resolved": "https://registry.npmmirror.com/electron-updater/-/electron-updater-6.8.9.tgz",
"integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==",
"license": "MIT",
"dependencies": {
"builder-util-runtime": "9.7.0",
"fs-extra": "^10.1.0",
"js-yaml": "^4.1.0",
"lazy-val": "^1.0.5",
"lodash.escaperegexp": "^4.1.2",
"lodash.isequal": "^4.5.0",
"semver": "~7.7.3",
"tiny-typed-emitter": "^2.1.0"
}
},
"node_modules/electron-updater/node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/electron-updater/node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/electron-updater/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/electron-updater/node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/electron-vite": { "node_modules/electron-vite": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmmirror.com/electron-vite/-/electron-vite-3.1.0.tgz", "resolved": "https://registry.npmmirror.com/electron-vite/-/electron-vite-3.1.0.tgz",
@@ -7192,7 +7254,6 @@
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/has-flag": { "node_modules/has-flag": {
@@ -7874,7 +7935,6 @@
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.2.0.tgz", "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.2.0.tgz",
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -8023,7 +8083,6 @@
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmmirror.com/lazy-val/-/lazy-val-1.0.5.tgz", "resolved": "https://registry.npmmirror.com/lazy-val/-/lazy-val-1.0.5.tgz",
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/levn": { "node_modules/levn": {
@@ -8344,9 +8403,9 @@
} }
}, },
"node_modules/lint-staged/node_modules/tinyexec": { "node_modules/lint-staged/node_modules/tinyexec": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.3.0.tgz", "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.3.1.tgz",
"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -8376,6 +8435,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.escaperegexp": {
"version": "4.1.2",
"resolved": "https://registry.npmmirror.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -9653,9 +9725,9 @@
} }
}, },
"node_modules/node-gyp/node_modules/undici": { "node_modules/node-gyp/node_modules/undici": {
"version": "6.28.0", "version": "6.28.1",
"resolved": "https://registry.npmmirror.com/undici/-/undici-6.28.0.tgz", "resolved": "https://registry.npmmirror.com/undici/-/undici-6.28.1.tgz",
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -10989,7 +11061,6 @@
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmmirror.com/sax/-/sax-1.6.0.tgz", "resolved": "https://registry.npmmirror.com/sax/-/sax-1.6.0.tgz",
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
"node": ">=11.0.0" "node": ">=11.0.0"
@@ -11718,6 +11789,12 @@
"semver": "bin/semver" "semver": "bin/semver"
} }
}, },
"node_modules/tiny-typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz",
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
"license": "MIT"
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz",
@@ -12012,9 +12089,9 @@
} }
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "8.10.0", "version": "8.10.2",
"resolved": "https://registry.npmmirror.com/undici/-/undici-8.10.0.tgz", "resolved": "https://registry.npmmirror.com/undici/-/undici-8.10.2.tgz",
"integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=22.19.0" "node": ">=22.19.0"
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.7.4", "version": "0.8.0",
"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",
@@ -47,6 +47,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"electron-log": "^5.3.3", "electron-log": "^5.3.3",
"electron-updater": "^6.8.9",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"i18next": "^26.4.0", "i18next": "^26.4.0",
"lru-cache": "^11.1.0", "lru-cache": "^11.1.0",
+129
View File
@@ -0,0 +1,129 @@
// @vitest-environment jsdom
/**
* v0.8.0 P0-4/P3-3: useAgentStream hook
*
* stream-event-pipeline
* hook
* - done(completed) isStreaming=false / agentStatus=idle / runId
* - done(completed) + finishReason=length system P0-4
* - error(output_length_exceeded) system P0-4
* - reasoning_delta
* - text_delta
*
* 线P1-1a使 IPC
* emitStreamBus IPC
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useAgentStream } from '../hooks/useAgentStream';
import { useAgentStore } from '../stores/agent-store';
import { emitStreamBus } from '../lib/stream-event-bus';
import '@renderer/lib/i18n-strings';
vi.mock('electron-log', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
function makeEvent(partial: Record<string, unknown>): Record<string, unknown> {
return {
type: 'text_delta',
requestId: 'r',
sessionId: 'sess_1',
iteration: 1,
seq: 0,
timestamp: Date.now(),
runId: 'run_x',
...partial,
};
}
describe('useAgentStreamv0.8.0 P3-3 hook 级)', () => {
beforeEach(() => {
// 重置 store 到"流式中"的典型状态
useAgentStore.setState({
currentSessionId: 'sess_1',
isStreaming: true,
currentRunId: 'run_x',
agentStatus: 'thinking',
currentIteration: 1,
messages: [],
traceSteps: [
{
id: 'trace_1',
iteration: 1,
state: 'THINKING',
states: ['THINKING'],
startedAt: Date.now(),
runId: 'run_x',
},
],
tokenUsage: {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
lastInputTokens: 0,
lastCompressedSaved: 0,
},
});
});
it('done(completed) → 收尾:isStreaming=false、runId 清空、agentStatus=idle', () => {
renderHook(() => useAgentStream());
emitStreamBus('streamEvent', makeEvent({ type: 'done', terminationReason: 'completed' }));
const s = useAgentStore.getState();
expect(s.isStreaming).toBe(false);
expect(s.currentRunId).toBeNull();
expect(s.agentStatus).toBe('idle');
});
it('done(completed) + finishReason=length → 插入输出截断提示(P0-4', () => {
renderHook(() => useAgentStream());
// 先产出一段正文(模拟截断前的部分回答)
emitStreamBus('streamEvent', makeEvent({ type: 'text_delta', delta: '部分回答' }));
emitStreamBus(
'streamEvent',
makeEvent({ type: 'done', terminationReason: 'completed', finishReason: 'length' }),
);
const s = useAgentStore.getState();
const notice = s.messages.find((m) => m.role === 'system');
expect(notice).toBeDefined();
expect(notice?.content).toContain('输出上限');
expect(s.isStreaming).toBe(false);
});
it("error(output_length_exceeded) → 友好文案(含'思考内容耗尽'提示)", () => {
renderHook(() => useAgentStream());
emitStreamBus(
'streamEvent',
makeEvent({
type: 'error',
error: { code: 'output_length_exceeded', message: 'raw message', retryable: false },
}),
);
const s = useAgentStore.getState();
expect(s.agentStatus).toBe('error');
const err = s.messages.find((m) => m.role === 'system');
expect(err?.content).toContain('思考内容耗尽');
});
it('reasoning_delta → 思考内容累积进 assistant 卡片', () => {
renderHook(() => useAgentStream());
emitStreamBus('streamEvent', makeEvent({ type: 'reasoning_delta', delta: '思考A' }));
emitStreamBus('streamEvent', makeEvent({ type: 'reasoning_delta', delta: '思考B', seq: 1 }));
const s = useAgentStore.getState();
const assistant = s.messages.find((m) => m.role === 'assistant');
expect(assistant?.reasoningContent).toBe('思考A思考B');
});
it('text_delta → 正文累积进 assistant 卡片(rAF 批处理 flush 后)', async () => {
renderHook(() => useAgentStream());
emitStreamBus('streamEvent', makeEvent({ type: 'text_delta', delta: '你好' }));
emitStreamBus('streamEvent', makeEvent({ type: 'text_delta', delta: '世界', seq: 1 }));
// text_delta 走 rAF 批处理 —— 等 2 帧让缓冲 flush
await new Promise((r) => setTimeout(r, 40));
const s = useAgentStore.getState();
const assistant = s.messages.find((m) => m.role === 'assistant');
expect(assistant?.content).toBe('你好世界');
});
});
+208 -4
View File
@@ -9,7 +9,7 @@
* @see docs/MetonaAI-Desktop UI UX .html * @see docs/MetonaAI-Desktop UI UX .html
*/ */
import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { import {
Box, Box,
Typography, Typography,
@@ -22,7 +22,8 @@ import {
List, List,
ListItemButton, ListItemButton,
} from '@mui/material'; } from '@mui/material';
import { Send, Paperclip, Square, X, FileText, Image as ImageIcon } from 'lucide-react'; import { Send, Paperclip, Square, X, FileText, Image as ImageIcon, AtSign } from 'lucide-react';
import Fuse from 'fuse.js';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { useAgentStore } from '@renderer/stores/agent-store'; import { useAgentStore } from '@renderer/stores/agent-store';
import { useSessionStore } from '@renderer/stores/session-store'; import { useSessionStore } from '@renderer/stores/session-store';
@@ -102,6 +103,12 @@ export function ChatInput(): React.JSX.Element {
const [attachments, setAttachments] = useState<Attachment[]>([]); const [attachments, setAttachments] = useState<Attachment[]>([]);
const [showSlashMenu, setShowSlashMenu] = useState(false); const [showSlashMenu, setShowSlashMenu] = useState(false);
const [slashFilter, setSlashFilter] = useState(''); const [slashFilter, setSlashFilter] = useState('');
// v0.8.0 P2-4: @ 文件提及 —— 联想弹层状态 + 工作空间文件索引(懒加载)
const [mentionOpen, setMentionOpen] = useState(false);
const [mentionQuery, setMentionQuery] = useState('');
const [mentionIndex, setMentionIndex] = useState(0);
const [workspaceFiles, setWorkspaceFiles] = useState<string[]>([]);
const workspaceFilesLoadedRef = useRef(false);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const sendMessage = useAgentStore((s) => s.sendMessage); const sendMessage = useAgentStore((s) => s.sendMessage);
@@ -302,6 +309,29 @@ export function ChatInput(): React.JSX.Element {
// ===== 发送消息 ===== // ===== 发送消息 =====
/**
* v0.8.0 P2-4: 从消息文本提取 @
* '@' [A-Za-z0-9._\-/]
* '.' '.' '/' @someone
*/
const extractMentions = (text: string): string[] => {
const out: string[] = [];
for (let i = 0; i < text.length; i++) {
if (text[i] !== '@') continue;
if (i > 0 && /\w/.test(text[i - 1])) continue;
let j = i + 1;
let path = '';
while (j < text.length && /[A-Za-z0-9._\-/]/.test(text[j])) {
path += text[j];
j++;
}
path = path.replace(/\.+$/, '');
if (path && (path.includes('/') || path.includes('.'))) out.push(path);
i = j - 1;
}
return [...new Set(out)];
};
const handleSend = useCallback(async () => { const handleSend = useCallback(async () => {
const trimmed = input.trim(); const trimmed = input.trim();
if (!trimmed && attachments.length === 0) return; if (!trimmed && attachments.length === 0) return;
@@ -387,10 +417,63 @@ export function ChatInput(): React.JSX.Element {
} }
} }
// v0.8.0 P2-4: @ 文件提及注入 —— 解析 @path token,读取文本片段并作为
// 文本附件注入(复用附件管线:UI 卡片 + 主进程"内容已内联/截断"提示)。
// 主进程 agent-store.sendMessage 会把 textContent 结构化内联进 LLM 消息。
const mentionInfos: Array<{
id: string;
name: string;
type: 'text';
size: number;
textContent: string;
truncated?: boolean;
}> = [];
const mentionedPaths = extractMentions(messageContent).slice(0, 5);
for (const p of mentionedPaths) {
try {
const clip = await window.metona?.workspace?.readFileClip?.(p);
if (clip?.success && typeof clip.content === 'string') {
let textContent = clip.content;
if (clip.truncated) {
textContent += t('input.text.truncatedNote', {
size: formatFileSize(clip.size ?? 0),
limit: formatFileSize(MAX_TEXT_ATTACHMENT_BYTES),
});
}
mentionInfos.push({
id: `mnt_${nanoid(6)}`,
name: p,
type: 'text',
size: clip.size ?? clip.content.length,
textContent,
truncated: clip.truncated,
});
} else {
import('@metona-team/metona-toast')
.then((mod) =>
mod.default.warning(
t('input.mention.failed', { path: p, reason: clip?.error ?? '' }),
),
)
.catch(() => {});
}
} catch (err) {
import('@metona-team/metona-toast')
.then((mod) =>
mod.default.warning(
t('input.mention.failed', { path: p, reason: (err as Error).message }),
),
)
.catch(() => {});
}
}
const allAttachmentInfos = [...attachmentInfos, ...mentionInfos];
sendMessage( sendMessage(
messageContent, messageContent,
images.length > 0 ? images : undefined, images.length > 0 ? images : undefined,
attachmentInfos.length > 0 ? attachmentInfos : undefined, allAttachmentInfos.length > 0 ? allAttachmentInfos : undefined,
); );
setInput(''); setInput('');
setAttachments([]); setAttachments([]);
@@ -403,8 +486,60 @@ export function ChatInput(): React.JSX.Element {
abort(); abort();
}, [abort]); }, [abort]);
// v0.8.0 P2-4: @ 联想候选(Fuse 模糊匹配;query 为空时按路径字典序取前 8 条)
const mentionCandidates = useMemo(() => {
if (workspaceFiles.length === 0) return [];
if (!mentionQuery) return [...workspaceFiles].sort().slice(0, 8);
const fuse = new Fuse(workspaceFiles, { includeScore: true, threshold: 0.4 });
return fuse
.search(mentionQuery)
.map((r) => r.item)
.slice(0, 8);
}, [workspaceFiles, mentionQuery]);
// v0.8.0 P2-4: 选中候选 —— 把 "@query" 片段替换为 "@path"
const applyMention = useCallback(
(path: string): void => {
setInput((prev) => {
const pos = prev.lastIndexOf(`@${mentionQuery}`);
if (pos < 0) return `${prev}${path}`;
return `${prev.slice(0, pos)}@${path}${prev.slice(pos + mentionQuery.length + 1)}`;
});
setMentionOpen(false);
setMentionQuery('');
requestAnimationFrame(() => textareaRef.current?.focus());
},
[mentionQuery],
);
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
// v0.8.0 P2-4: @ 联想弹层的键盘导航(优先级高于发送/斜杠菜单)
if (mentionOpen && mentionCandidates.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setMentionIndex((i) => (i + 1) % mentionCandidates.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setMentionIndex((i) => (i - 1 + mentionCandidates.length) % mentionCandidates.length);
return;
}
if (e.key === 'Tab' || e.key === 'Enter') {
if (e.key === 'Enter' && (e.nativeEvent.isComposing || e.keyCode === 229)) return;
e.preventDefault();
applyMention(mentionCandidates[Math.min(mentionIndex, mentionCandidates.length - 1)]);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setMentionOpen(false);
setMentionQuery('');
return;
}
}
// H-9 修复: 快捷键对齐规范 // H-9 修复: 快捷键对齐规范
// @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 快捷键规范 // @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 快捷键规范
// 规范要求: Cmd/Ctrl+Enter = 发送消息, Cmd/Ctrl+Shift+Enter = 换行 // 规范要求: Cmd/Ctrl+Enter = 发送消息, Cmd/Ctrl+Shift+Enter = 换行
@@ -456,7 +591,7 @@ export function ChatInput(): React.JSX.Element {
return; return;
} }
}, },
[handleSend, showSlashMenu], [handleSend, showSlashMenu, mentionOpen, mentionCandidates, mentionIndex, applyMention],
); );
// H-8 修复: 使用 InputBase 后,onChange 类型需兼容 HTMLInputElement | HTMLTextAreaElement // H-8 修复: 使用 InputBase 后,onChange 类型需兼容 HTMLInputElement | HTMLTextAreaElement
@@ -473,6 +608,30 @@ export function ChatInput(): React.JSX.Element {
} else { } else {
setShowSlashMenu(false); setShowSlashMenu(false);
} }
// v0.8.0 P2-4: @ 文件提及触发 —— 光标前最后一个 '@' 到光标之间无空白时
// 视为提及输入中;空白/删除 '@' 即关闭。首次触发懒加载文件索引。
const pos = (e.target as HTMLTextAreaElement | HTMLInputElement).selectionStart ?? v.length;
const before = v.slice(0, pos);
const atIdx = before.lastIndexOf('@');
if (atIdx >= 0 && !/[\s]/.test(before.slice(atIdx + 1))) {
const q = before.slice(atIdx + 1);
setMentionOpen(true);
setMentionQuery(q);
setMentionIndex(0);
if (!workspaceFilesLoadedRef.current) {
workspaceFilesLoadedRef.current = true;
void window.metona?.workspace
?.listFiles?.()
.then((r) => {
if (r?.success && r.data) setWorkspaceFiles(r.data);
})
.catch((err) => console.error('[ChatInput] listFiles failed:', err));
}
} else {
setMentionOpen(false);
setMentionQuery('');
}
}, },
[], [],
); );
@@ -560,6 +719,51 @@ export function ChatInput(): React.JSX.Element {
</Paper> </Paper>
)} )}
{/* v0.8.0 P2-4: @ 文件提及联想弹层(MUI Paper+List,与斜杠菜单同形态) */}
{mentionOpen && mentionCandidates.length > 0 && (
<Paper
elevation={8}
sx={{
position: 'absolute',
bottom: '100%',
left: 0,
right: 0,
mb: 1,
maxHeight: 240,
overflow: 'auto',
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
zIndex: 10,
}}
>
<List dense disablePadding>
{mentionCandidates.map((path, idx) => (
<ListItemButton
key={path}
selected={idx === mentionIndex}
onMouseEnter={() => setMentionIndex(idx)}
onClick={() => applyMention(path)}
sx={{ gap: 1, py: 0.75 }}
>
<AtSign size={13} style={{ color: '#22d3ee', flexShrink: 0 }} />
<Typography
sx={{
fontFamily: 'monospace',
fontSize: 12,
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{path}
</Typography>
</ListItemButton>
))}
</List>
</Paper>
)}
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
+7
View File
@@ -20,11 +20,16 @@ import { useUIStore, type DetailTab } from '@renderer/stores/ui-store';
// v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import // v0.7.4 P3-1: 文案出层(字典含注册副作用,须在 t() 使用前 import
import { t } from '@renderer/lib/i18n'; import { t } from '@renderer/lib/i18n';
import '@renderer/lib/i18n-strings'; import '@renderer/lib/i18n-strings';
// v0.8.0 P2-2: 会话回放(TRACE JSONL 播放器)
import { SessionReplayButton } from '@renderer/components/trace/SessionReplayPlayer';
import { useAgentStore } from '@renderer/stores/agent-store';
export function DetailPanel(): React.JSX.Element { export function DetailPanel(): React.JSX.Element {
// v0.3.0: 从 ui-store 获取 tab 状态(支持斜杠命令切换) // v0.3.0: 从 ui-store 获取 tab 状态(支持斜杠命令切换)
const tab = useUIStore((s) => s.detailTab); const tab = useUIStore((s) => s.detailTab);
const setTab = useUIStore((s) => s.setDetailTab); const setTab = useUIStore((s) => s.setDetailTab);
// v0.8.0 P2-2: 当前会话 ID(回放按钮的会话标识)
const currentSessionId = useAgentStore((s) => s.currentSessionId);
// v0.3.0 修复:验证 tab 值有效性,非预期值回退到 'trace' // v0.3.0 修复:验证 tab 值有效性,非预期值回退到 'trace'
const validTabs: DetailTab[] = ['trace', 'memory', 'tasks', 'workspace']; const validTabs: DetailTab[] = ['trace', 'memory', 'tasks', 'workspace'];
@@ -97,6 +102,8 @@ export function DetailPanel(): React.JSX.Element {
<TraceViewer /> <TraceViewer />
<TokenUsage /> <TokenUsage />
<AgentMonitor /> <AgentMonitor />
{/* v0.8.0 P2-2: 会话回放(TRACE JSONL 播放器) */}
<SessionReplayButton sessionId={currentSessionId} />
</> </>
</ErrorBoundary> </ErrorBoundary>
)} )}
+171
View File
@@ -272,6 +272,8 @@ export function Sidebar(): React.JSX.Element {
{/* F-2 : {/* F-2 :
DB UI */} DB UI */}
<ArchivedSessionsPanel /> <ArchivedSessionsPanel />
{/* v0.8.0 P2-1: 回收站 —— 软删除会话恢复/彻底删除 */}
<TrashSessionsPanel />
<ToolManagerPanel /> <ToolManagerPanel />
</Box> </Box>
</Box> </Box>
@@ -379,6 +381,173 @@ function ArchivedSessionsPanel(): React.JSX.Element | null {
); );
} }
/**
* v0.8.0 P2-1: 回收站折叠面板 /
* sessions:listTrash session-store
* /
*/
function TrashSessionsPanel(): React.JSX.Element | null {
const [expanded, setExpanded] = useState(false);
const [trash, setTrash] = useState<
Array<{ id: string; title: string; deletedAt?: number; updatedAt?: number }>
>([]);
const [purgeTarget, setPurgeTarget] = useState<{ id: string; title: string } | null>(null);
const refresh = async (): Promise<void> => {
try {
const rows = (await window.metona?.sessions?.listTrash()) ?? [];
setTrash(rows);
} catch (err) {
console.error('[Sidebar] listTrash failed:', err);
}
};
const handleExpand = (): void => {
const next = !expanded;
setExpanded(next);
if (next) void refresh();
};
const handleRestore = async (sessionId: string): Promise<void> => {
try {
const r = await window.metona?.sessions?.restore(sessionId);
if (r?.success) {
void refresh();
} else {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(r?.error ?? t('sidebar.toast.restoreFailed')))
.catch(() => {});
}
} catch (err) {
console.error('[Sidebar] Restore from trash failed:', err);
}
};
const handlePurge = async (): Promise<void> => {
if (!purgeTarget) return;
try {
const r = await window.metona?.sessions?.purge(purgeTarget.id);
if (r?.success) {
void refresh();
} else {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(r?.error ?? t('sidebar.toast.deleteFailed')))
.catch(() => {});
}
} catch (err) {
console.error('[Sidebar] Purge failed:', err);
} finally {
setPurgeTarget(null);
}
};
if (trash.length === 0 && !expanded) return null;
return (
<Box sx={{ mb: 1 }}>
<ListItemButton onClick={handleExpand} dense sx={{ borderRadius: 1, px: 1.5, py: 0.75 }}>
<ListItemIcon sx={{ minWidth: 24 }}>
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<Trash2 size={12} style={{ marginLeft: 4 }} />
</ListItemIcon>
<ListItemText
primary={
<Typography variant="body2" sx={{ fontSize: 12 }}>
{t('sidebar.trash')}
</Typography>
}
/>
{trash.length > 0 && (
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>
{t('sidebar.count', { count: trash.length })}
</Typography>
)}
</ListItemButton>
<Collapse in={expanded}>
<List
dense
disablePadding
sx={{
pl: 2,
maxHeight: 180,
overflowY: 'auto',
pr: 0.5,
'&::-webkit-scrollbar': { width: 6 },
'&::-webkit-scrollbar-thumb': { bgcolor: 'divider', borderRadius: 3 },
}}
>
{trash.length === 0 ? (
<Typography
variant="caption"
sx={{ px: 2, py: 1, display: 'block', color: 'text.disabled', fontSize: 11 }}
>
{t('sidebar.trash.empty')}
</Typography>
) : (
trash.map((s) => (
<ListItemButton key={s.id} dense sx={{ py: 0.25, px: 1, borderRadius: 1 }}>
<Typography
variant="caption"
sx={{
flex: 1,
minWidth: 0,
fontSize: 11,
color: 'text.disabled',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={s.title}
>
{s.title}
</Typography>
<Button
size="small"
sx={{ fontSize: 10, minWidth: 40, flexShrink: 0 }}
onClick={(e) => {
e.stopPropagation();
void handleRestore(s.id);
}}
>
{t('sidebar.restore')}
</Button>
<Button
size="small"
color="error"
sx={{ fontSize: 10, minWidth: 40, flexShrink: 0 }}
onClick={(e) => {
e.stopPropagation();
setPurgeTarget({ id: s.id, title: s.title });
}}
>
{t('sidebar.trash.purge')}
</Button>
</ListItemButton>
))
)}
</List>
</Collapse>
{/* 彻底删除确认(级联清消息,不可逆) */}
<Dialog open={purgeTarget !== null} onClose={() => setPurgeTarget(null)}>
<DialogTitle sx={{ fontSize: 14 }}>{t('sidebar.trash.purgeTitle')}</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ fontSize: 12 }}>
{t('sidebar.trash.purgeBody', { title: purgeTarget?.title ?? '' })}
</Typography>
</DialogContent>
<DialogActions>
<Button size="small" onClick={() => setPurgeTarget(null)}>
{t('common.cancel')}
</Button>
<Button size="small" color="error" onClick={() => void handlePurge()}>
{t('sidebar.trash.purge')}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
function SessionItem({ function SessionItem({
session, session,
isActive, isActive,
@@ -624,6 +793,8 @@ function ToolManagerPanel() {
low: 'LOW', low: 'LOW',
medium: 'MEDIUM', medium: 'MEDIUM',
high: 'HIGH', high: 'HIGH',
// v0.8.0 P1-3.8: 补 critical —— 旧映射缺失导致 CRITICAL 工具显示原始字符串
critical: 'CRITICAL',
}; };
return ( return (
<Box> <Box>
+1 -1
View File
@@ -374,7 +374,7 @@ export function MemoryViewer(): React.JSX.Element {
variant="caption" variant="caption"
sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }} sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}
> >
{t('memory.empty')}
</Typography> </Typography>
) : ( ) : (
MEMORY_TYPES.map((type) => { MEMORY_TYPES.map((type) => {
+75 -1
View File
@@ -32,6 +32,8 @@ import { PROVIDER_LABELS } from '@renderer/lib/constants';
import { t } from '@renderer/lib/i18n'; import { t } from '@renderer/lib/i18n';
import '@renderer/lib/i18n-strings'; import '@renderer/lib/i18n-strings';
import { PROVIDER_URLS } from './useConfig'; import { PROVIDER_URLS } from './useConfig';
// v0.8.0 FEAT-1: 输出上限配置可见性门控(Provider 支持矩阵)
import { supportsOutputLimitConfig } from '@renderer/lib/model-capabilities';
/** v0.7.2 P3-9: 动态模型条目(渲染端子集,与 global.d.ts MetonaModelInfoLite 对齐) */ /** v0.7.2 P3-9: 动态模型条目(渲染端子集,与 global.d.ts MetonaModelInfoLite 对齐) */
interface ModelOption { interface ModelOption {
@@ -39,6 +41,8 @@ interface ModelOption {
name?: string; name?: string;
supportsToolCalling?: boolean; supportsToolCalling?: boolean;
supportsThinking?: boolean; supportsThinking?: boolean;
/** v0.8.0 FEAT-1: 模型输出上限(llm:listModels 元数据,用于钳制提示) */
maxOutputTokens?: number;
description?: string; description?: string;
} }
@@ -52,6 +56,10 @@ export function LLMSettings() {
const [apiKey, setApiKey] = useState<string>(''); const [apiKey, setApiKey] = useState<string>('');
const [baseURL, setBaseURL] = useState<string>(''); const [baseURL, setBaseURL] = useState<string>('');
const [numCtx, setNumCtx] = useState<number | null>(null); const [numCtx, setNumCtx] = useState<number | null>(null);
// v0.8.0 FEAT-1: 最大输出上限(llm.maxTokens,按请求传给 APInull = 使用引擎默认)
const [maxOutputTokens, setMaxOutputTokens] = useState<number | null>(null);
// v0.8.0 P0-3: agent.enableThinking(模型不支持思考时的联动提示)
const [agentThinkingEnabled, setAgentThinkingEnabled] = useState(true);
// v0.3.1: DeepSeek/Agnes/MiMo contextWindow 可配置(不再写死) // v0.3.1: DeepSeek/Agnes/MiMo contextWindow 可配置(不再写死)
const [dsCtxWindow, setDsCtxWindow] = useState<number>(1000000); const [dsCtxWindow, setDsCtxWindow] = useState<number>(1000000);
const [agnesCtxWindow, setAgnesCtxWindow] = useState<number>(1000000); const [agnesCtxWindow, setAgnesCtxWindow] = useState<number>(1000000);
@@ -235,14 +243,18 @@ export function LLMSettings() {
window.metona.config.get('llm.fallbackBaseURL'), window.metona.config.get('llm.fallbackBaseURL'),
// v0.5.4: 多模态开关 // v0.5.4: 多模态开关
window.metona.config.get('llm.multimodalEnabled'), window.metona.config.get('llm.multimodalEnabled'),
// v0.8.0 FEAT-1: 最大输出上限 + 思考开关(P0-3 模型能力联动提示)
window.metona.config.get('llm.maxTokens'),
window.metona.config.get('agent.enableThinking'),
]); ]);
if (cancelled) return; if (cancelled) return;
const [p, m, k, u, nc, ds, ag, mi, oa, an, fbp, fbm, fbk, fbu, mm] = results; const [p, m, k, u, nc, ds, ag, mi, oa, an, fbp, fbm, fbk, fbu, mm, mt, et] = results;
setProvider((p as string) ?? ''); setProvider((p as string) ?? '');
setModel((m as string) ?? ''); setModel((m as string) ?? '');
setApiKey((k as string) ?? ''); setApiKey((k as string) ?? '');
setBaseURL((u as string) ?? ''); setBaseURL((u as string) ?? '');
setNumCtx((nc as number | null) ?? null); setNumCtx((nc as number | null) ?? null);
if (typeof mt === 'number' && mt > 0) setMaxOutputTokens(mt);
if (typeof ds === 'number' && ds > 0) setDsCtxWindow(ds); if (typeof ds === 'number' && ds > 0) setDsCtxWindow(ds);
if (typeof ag === 'number' && ag > 0) setAgnesCtxWindow(ag); if (typeof ag === 'number' && ag > 0) setAgnesCtxWindow(ag);
if (typeof mi === 'number' && mi > 0) setMimoCtxWindow(mi); if (typeof mi === 'number' && mi > 0) setMimoCtxWindow(mi);
@@ -253,6 +265,7 @@ export function LLMSettings() {
setFbApiKey((fbk as string) ?? ''); setFbApiKey((fbk as string) ?? '');
setFbBaseURL((fbu as string) ?? ''); setFbBaseURL((fbu as string) ?? '');
setMultimodalEnabled(mm === true); setMultimodalEnabled(mm === true);
setAgentThinkingEnabled(et !== false);
} catch (err) { } catch (err) {
console.error('[LLMSettings]', err); console.error('[LLMSettings]', err);
} finally { } finally {
@@ -313,12 +326,27 @@ export function LLMSettings() {
const mimoCtxError = !Number.isFinite(mimoCtxWindow) || mimoCtxWindow < 4096; const mimoCtxError = !Number.isFinite(mimoCtxWindow) || mimoCtxWindow < 4096;
const oaCtxError = !Number.isFinite(oaCtxWindow) || oaCtxWindow < 4096; const oaCtxError = !Number.isFinite(oaCtxWindow) || oaCtxWindow < 4096;
const anthropicCtxError = !Number.isFinite(anthropicCtxWindow) || anthropicCtxWindow < 4096; const anthropicCtxError = !Number.isFinite(anthropicCtxWindow) || anthropicCtxWindow < 4096;
// v0.8.0 FEAT-1: 最大输出上限校验(≥256;上限钳制由 adapter 按模型元信息执行)
const maxTokensError =
maxOutputTokens != null && (!Number.isFinite(maxOutputTokens) || maxOutputTokens < 256);
// v0.8.0 FEAT-1: 当前模型的输出上限元信息(模型列表已加载时展示钳制提示)
const modelMeta = model ? modelOptions.find((o) => o.id === model) : undefined;
const modelMaxOutput = modelMeta?.maxOutputTokens;
const exceedsModelCap =
maxOutputTokens != null &&
typeof modelMaxOutput === 'number' &&
maxOutputTokens > modelMaxOutput;
// v0.8.0 P0-3: 模型不支持思考 + 全局思考开关开启 → 联动提示
const thinkingUnsupportedByModel =
model != null && modelMeta?.supportsThinking === false && agentThinkingEnabled;
// 是否存在阻断保存的错误(API Key 为空只警告,不阻断 — 允许先填其他字段再回来填 key) // 是否存在阻断保存的错误(API Key 为空只警告,不阻断 — 允许先填其他字段再回来填 key)
const hasBlockingError = const hasBlockingError =
urlError || urlError ||
modelHasSpace || modelHasSpace ||
numCtxError || numCtxError ||
maxTokensError ||
(provider === 'deepseek' && dsCtxError) || (provider === 'deepseek' && dsCtxError) ||
(provider === 'agnes' && agnesCtxError) || (provider === 'agnes' && agnesCtxError) ||
(provider === 'mimo' && mimoCtxError) || (provider === 'mimo' && mimoCtxError) ||
@@ -370,6 +398,11 @@ export function LLMSettings() {
{ key: 'llm.baseURL', value: baseURL }, { key: 'llm.baseURL', value: baseURL },
// v0.5.4: 多模态总开关 // v0.5.4: 多模态总开关
{ key: 'llm.multimodalEnabled', value: multimodalEnabled }, { key: 'llm.multimodalEnabled', value: multimodalEnabled },
// v0.8.0 FEAT-1: 最大输出上限(按请求传给 API;清空 = 引擎默认 63488
{
key: 'llm.maxTokens',
value: maxOutputTokens != null && maxOutputTokens >= 256 ? maxOutputTokens : 63488,
},
{ key: 'ollama.numCtx', value: numCtx }, { key: 'ollama.numCtx', value: numCtx },
{ key: 'deepseek.contextWindow', value: dsCtxWindow }, { key: 'deepseek.contextWindow', value: dsCtxWindow },
{ key: 'agnes.contextWindow', value: agnesCtxWindow }, { key: 'agnes.contextWindow', value: agnesCtxWindow },
@@ -390,6 +423,16 @@ export function LLMSettings() {
} else { } else {
// v0.5.4: 保存成功后同步多模态开关到 Agent Store(立即生效,控制上传入口) // v0.5.4: 保存成功后同步多模态开关到 Agent Store(立即生效,控制上传入口)
useAgentStore.getState().setMultimodalEnabled(multimodalEnabled); useAgentStore.getState().setMultimodalEnabled(multimodalEnabled);
// v0.8.0 FEAT-1: 配置值超过模型上限 → 明示"将按模型上限生效"
//adapter 侧按 MODEL_INFO.maxOutputTokens 钳制,llm.maxTokens 经
// applyEngineConfigKey 热更新引擎,无需重启)
if (exceedsModelCap && typeof modelMaxOutput === 'number') {
import('@metona-team/metona-toast')
.then((mod) =>
mod.default.warning(t('llm.maxTokens.exceedCap', { cap: modelMaxOutput })),
)
.catch(() => {});
}
import('@metona-team/metona-toast') import('@metona-team/metona-toast')
.then((mod) => mod.default.success(t('llm.save.success'))) .then((mod) => mod.default.success(t('llm.save.success')))
.catch(() => {}); .catch(() => {});
@@ -530,6 +573,37 @@ export function LLMSettings() {
{t('llm.models.hint')} {t('llm.models.hint')}
</Typography> </Typography>
</Stack> </Stack>
{/* v0.8.0 P0-3: 模型不支持思考 × 全局思考开关 → 联动提示(adapter 侧已硬门控) */}
{thinkingUnsupportedByModel && (
<Typography variant="caption" sx={{ color: 'warning.main', fontSize: 11 }}>
{t('llm.thinking.unsupported')}
</Typography>
)}
{/* v0.8.0 FEAT-1: Provider
PROVIDER_OUTPUT_LIMIT_SUPPORT llm.maxTokens */}
{supportsOutputLimitConfig(provider) && (
<TextField
size="small"
label={t('llm.maxTokens.label')}
type="number"
value={maxOutputTokens ?? ''}
onChange={(e) => {
const v = e.target.value;
setMaxOutputTokens(v === '' ? null : Number(v));
}}
slotProps={{ htmlInput: { min: 256, step: 256 } }}
error={maxTokensError}
helperText={
maxTokensError
? t('llm.maxTokens.minError')
: exceedsModelCap && typeof modelMaxOutput === 'number'
? t('llm.maxTokens.exceedCap', { cap: modelMaxOutput })
: typeof modelMaxOutput === 'number'
? `${t('llm.maxTokens.helper')} · ${t('llm.maxTokens.capInfo', { cap: modelMaxOutput })}`
: t('llm.maxTokens.helper')
}
/>
)}
{/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */} {/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */}
<FormControlLabel <FormControlLabel
control={ control={
+105
View File
@@ -21,6 +21,7 @@ import {
Box, Box,
InputLabel, InputLabel,
FormControl, FormControl,
LinearProgress,
} from '@mui/material'; } from '@mui/material';
import { Folder, Copy, RefreshCw } from 'lucide-react'; import { Folder, Copy, RefreshCw } from 'lucide-react';
import { useConfig } from './useConfig'; import { useConfig } from './useConfig';
@@ -374,6 +375,9 @@ export function LogsSettings() {
{/* v0.7.3 P3-3: JSONL 录制文件生命周期(统计 + 手动清理) */} {/* v0.7.3 P3-3: JSONL 录制文件生命周期(统计 + 手动清理) */}
<TraceFilesPanel /> <TraceFilesPanel />
{/* v0.8.0 P2-3: 检查更新 / 下载安装(electron-updater 状态实时显示) */}
<UpdatePanel />
{/* v0.7.3 P3-4: SLO / 健康快照可视化(只读 + 手动刷新) */} {/* v0.7.3 P3-4: SLO / 健康快照可视化(只读 + 手动刷新) */}
<HealthSnapshotPanel /> <HealthSnapshotPanel />
@@ -495,6 +499,107 @@ function TraceFilesPanel(): React.JSX.Element {
); );
} }
// ===== v0.8.0 P2-3: 检查更新 / 下载安装(electron-updater =====
function UpdatePanel(): React.JSX.Element {
const [checking, setChecking] = useState(false);
const [installing, setInstalling] = useState(false);
const [result, setResult] = useState<string | null>(null);
const [progress, setProgress] = useState<number | null>(null);
useEffect(() => {
if (!window.metona?.app?.onUpdateStatus) return;
const off = window.metona.app.onUpdateStatus((event) => {
if (event.status === 'downloading') {
setProgress(event.percent ?? 0);
} else if (event.status === 'downloaded') {
setProgress(100);
setResult(t('settings.logs.update.downloaded'));
} else if (event.status === 'available') {
setResult(t('settings.logs.update.available', { version: event.latestVersion ?? '' }));
} else if (event.status === 'not-available') {
setResult(t('settings.logs.update.upToDate', { version: event.latestVersion ?? '' }));
} else if (event.status === 'error') {
setResult(t('settings.logs.update.error', { message: event.message ?? '' }));
}
});
return off;
}, []);
const handleCheck = async (): Promise<void> => {
setChecking(true);
setResult(null);
setProgress(null);
try {
const r = await window.metona?.app?.updateCheck();
if (r?.status === 'available') {
setResult(t('settings.logs.update.available', { version: r.latestVersion }));
} else if (r?.status === 'up-to-date') {
setResult(t('settings.logs.update.upToDate', { version: r.latestVersion }));
} else if (r?.status === 'disabled') {
setResult(t('settings.logs.update.disabled'));
} else if (r?.status === 'error') {
setResult(t('settings.logs.update.error', { message: r.message }));
}
} finally {
setChecking(false);
}
};
const handleInstall = async (): Promise<void> => {
setInstalling(true);
try {
const r = await window.metona?.app?.updateInstall();
if (r && r.success === false) {
setResult(t('settings.logs.update.error', { message: r.error ?? '' }));
}
} finally {
setInstalling(false);
}
};
return (
<>
<Divider />
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
{t('settings.logs.update.title')}
</Typography>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Button
variant="outlined"
size="small"
onClick={() => void handleCheck()}
disabled={checking}
>
{checking ? t('settings.logs.update.checking') : t('settings.logs.update.check')}
</Button>
<Button
variant="contained"
size="small"
onClick={() => void handleInstall()}
disabled={installing}
>
{installing
? t('settings.logs.update.installing')
: t('settings.logs.update.downloadInstall')}
</Button>
</Stack>
{progress != null && (
<LinearProgress
variant="determinate"
value={progress}
sx={{ height: 6, borderRadius: 3 }}
/>
)}
{result && (
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 11 }}>
{result}
</Typography>
)}
</>
);
}
// ===== v0.7.3 P3-4: SLO / 健康快照可视化 ===== // ===== v0.7.3 P3-4: SLO / 健康快照可视化 =====
function HealthSnapshotPanel(): React.JSX.Element { function HealthSnapshotPanel(): React.JSX.Element {
+72 -1
View File
@@ -164,6 +164,33 @@ export function MCPSettings() {
} }
}; };
// v0.8.0 P2-5: Resources / Prompts 发现视图(按 server 名懒加载展开)
const [contentsFor, setContentsFor] = useState<string | null>(null);
const [contents, setContents] = useState<{
resources: Array<{ uri: string; name: string; description?: string; mimeType?: string }>;
prompts: Array<{
name: string;
description?: string;
arguments?: Array<{ name: string; description?: string; required?: boolean }>;
}>;
} | null>(null);
const handleToggleContents = async (name: string): Promise<void> => {
if (contentsFor === name) {
setContentsFor(null);
setContents(null);
return;
}
try {
const r = await window.metona?.mcp?.listServerContents?.(name);
if (r?.success && r.data) {
setContentsFor(name);
setContents(r.data);
}
} catch (err) {
console.error('[MCPSettings] listServerContents failed:', err);
}
};
return ( return (
<Stack spacing={2}> <Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}> <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
@@ -175,8 +202,8 @@ export function MCPSettings() {
</Typography> </Typography>
) : ( ) : (
servers.map((s) => ( servers.map((s) => (
<Box key={s.name}>
<Stack <Stack
key={s.name}
direction="row" direction="row"
sx={{ sx={{
py: 1, py: 1,
@@ -249,6 +276,50 @@ export function MCPSettings() {
</Button> </Button>
</Stack> </Stack>
</Stack> </Stack>
{s.status === 'connected' && (
<Button
size="small"
sx={{ fontSize: 10, mt: 0.5, alignSelf: 'flex-start' }}
onClick={() => void handleToggleContents(s.name)}
>
{contentsFor === s.name
? t('settings.mcp.contents.hide')
: t('settings.mcp.contents.show')}
</Button>
)}
{contentsFor === s.name && contents && (
<Stack spacing={0.5} sx={{ mt: 0.5, pl: 1 }}>
{contents.resources.length === 0 && contents.prompts.length === 0 && (
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 11 }}>
{t('settings.mcp.contents.empty')}
</Typography>
)}
{contents.resources.map((r) => (
<Typography
key={r.uri}
variant="caption"
noWrap
sx={{ fontSize: 11, color: 'text.secondary' }}
title={`${r.uri}${r.description ?? ''}`}
>
📄 {r.name} · {r.uri}
</Typography>
))}
{contents.prompts.map((p) => (
<Typography
key={p.name}
variant="caption"
noWrap
sx={{ fontSize: 11, color: 'text.secondary' }}
title={p.description ?? ''}
>
💬 {p.name}
{p.arguments?.length ? ` (${p.arguments.map((a) => a.name).join(', ')})` : ''}
</Typography>
))}
</Stack>
)}
</Box>
)) ))
)} )}
@@ -0,0 +1,353 @@
/**
* SessionReplayPlayer v0.8.0 P2-2
*
* TRACE JSONL session_{id}_*.jsonl9
* iteration_start ///
* ///
*
* sessions:listRecordings+ sessions:readRecording
* +
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Button,
Dialog,
DialogContent,
DialogTitle,
IconButton,
LinearProgress,
Slider,
Stack,
Typography,
Chip,
Select,
MenuItem,
FormControl,
InputLabel,
} from '@mui/material';
import { Play, Pause, ChevronLeft, ChevronRight, X, Film } from 'lucide-react';
import { formatDuration, formatFileSize } from '@renderer/lib/formatters';
import { t } from '@renderer/lib/i18n';
import '@renderer/lib/i18n-strings';
/** 录制事件(session-recorder TraceEvent 的宽松形态) */
interface ReplayEvent {
seq?: number;
ts?: string;
event?: string;
sessionId?: string;
iteration?: number;
[key: string]: unknown;
}
const SPEEDS = [0.5, 1, 2, 4];
export function SessionReplayPlayer({
open,
onClose,
sessionId,
}: {
open: boolean;
onClose: () => void;
sessionId: string | null;
}): React.JSX.Element {
const [files, setFiles] = useState<Array<{ name: string; size: number; mtime: number }>>([]);
const [selected, setSelected] = useState<string>('');
const [events, setEvents] = useState<ReplayEvent[]>([]);
const [skipped, setSkipped] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cursor, setCursor] = useState(0);
const [playing, setPlaying] = useState(false);
const [speed, setSpeed] = useState(1);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 打开时拉取录制文件列表
useEffect(() => {
if (!open || !sessionId) return;
setSelected('');
setEvents([]);
setSkipped(0);
setCursor(0);
setPlaying(false);
setError(null);
void (async () => {
try {
const r = await window.metona?.sessions?.listRecordings(sessionId);
if (r?.success && r.data) setFiles(r.data);
else setError(r?.error ?? t('replay.loadFailed'));
} catch (err) {
setError((err as Error).message);
}
})();
}, [open, sessionId]);
const loadFile = useCallback(async (name: string): Promise<void> => {
setSelected(name);
setEvents([]);
setCursor(0);
setPlaying(false);
setLoading(true);
setError(null);
try {
const r = await window.metona?.sessions?.readRecording(name);
if (r?.success && r.data) {
setEvents((r.data.events as ReplayEvent[]) ?? []);
setSkipped(r.data.skipped ?? 0);
} else {
setError(r?.error ?? t('replay.loadFailed'));
}
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}, []);
// 播放循环:按 speed 定时推进游标,到尾部自动暂停
useEffect(() => {
if (!playing || events.length === 0) return;
if (cursor >= events.length - 1) {
setPlaying(false);
return;
}
timerRef.current = setTimeout(() => {
setCursor((c) => Math.min(c + 1, events.length - 1));
}, 60 / speed);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [playing, cursor, speed, events.length]);
/** 渲染单条事件为可读行 */
const renderEvent = useMemo(() => {
return (ev: ReplayEvent): React.JSX.Element => {
const type = String(ev.event ?? '');
let detail = '';
switch (type) {
case 'iteration_start':
detail = t('replay.event.iterationStart', { iteration: ev.iteration ?? 0 });
break;
case 'iteration_end':
detail = t('replay.event.iterationEnd', {
iteration: ev.iteration ?? 0,
duration: formatDuration(Number(ev.durationMs ?? 0)),
});
break;
case 'llm_request':
detail = `${ev.provider ?? ''}/${ev.model ?? ''}`;
break;
case 'llm_response':
detail = String((ev.contentPreview as string) ?? '');
break;
case 'tool_call':
detail = `${String(ev.tool ?? '')} ${JSON.stringify(ev.args ?? {})}`;
break;
case 'tool_result':
detail = `${String(ev.tool ?? '')} ${ev.success ? '✓' : '✗'} ${formatDuration(
Number(ev.durationMs ?? 0),
)}${ev.error ? `${String(ev.error).slice(0, 120)}` : ''}`;
break;
case 'session_end':
detail = t('replay.event.sessionEnd', {
reason: String(ev.terminationReason ?? ''),
iterations: Number(ev.totalIterations ?? 0),
});
break;
default:
detail = JSON.stringify(ev).slice(0, 160);
}
const isTool = type === 'tool_call' || type === 'tool_result';
const isThought = type === 'llm_response' || type === 'llm_request';
return (
<Box
key={`${ev.seq ?? ''}_${String(ev.ts ?? '')}_${detail.slice(0, 12)}`}
sx={{
px: 1.5,
py: 0.75,
borderBottom: '1px solid',
borderColor: 'divider',
bgcolor: type === 'iteration_start' ? 'secondary.main' : 'transparent',
}}
>
<Stack direction="row" spacing={1} sx={{ alignItems: 'flex-start' }}>
<Chip
label={type}
size="small"
variant="outlined"
sx={{ height: 18, fontSize: 9, flexShrink: 0 }}
color={isTool ? 'warning' : isThought ? 'primary' : 'default'}
/>
<Typography
variant="caption"
sx={{ fontFamily: 'monospace', fontSize: 11, wordBreak: 'break-all', flex: 1 }}
>
{detail || '—'}
</Typography>
{ev.ts && (
<Typography
variant="caption"
sx={{ color: 'text.disabled', fontSize: 9, flexShrink: 0 }}
>
{String(ev.ts).slice(11, 19)}
</Typography>
)}
</Stack>
</Box>
);
};
}, []);
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1, fontSize: 14 }}>
<Film size={16} /> {t('replay.title')}
<IconButton
size="small"
onClick={onClose}
sx={{ ml: 'auto' }}
aria-label={t('common.close')}
>
<X size={16} />
</IconButton>
</DialogTitle>
<DialogContent>
<Stack spacing={1.5}>
{/* 文件选择 */}
<FormControl size="small" fullWidth disabled={files.length === 0}>
<InputLabel>{t('replay.file')}</InputLabel>
<Select
value={selected}
label={t('replay.file')}
onChange={(e) => void loadFile(String(e.target.value))}
>
{files.map((f) => (
<MenuItem key={f.name} value={f.name}>
{`${f.name} · ${formatFileSize(f.size)}`}
</MenuItem>
))}
</Select>
</FormControl>
{loading && <LinearProgress />}
{error && (
<Typography variant="caption" sx={{ color: 'error.main' }}>
{error}
</Typography>
)}
{events.length > 0 && (
<>
{/* 时间轴 + 控制 */}
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<IconButton
size="small"
onClick={() => setCursor((c) => Math.max(0, c - 1))}
disabled={cursor === 0}
aria-label={t('replay.stepBack')}
>
<ChevronLeft size={16} />
</IconButton>
<IconButton
size="small"
onClick={() => setPlaying((p) => !p)}
disabled={cursor >= events.length - 1}
aria-label={playing ? t('replay.pause') : t('replay.play')}
>
{playing ? <Pause size={16} /> : <Play size={16} />}
</IconButton>
<IconButton
size="small"
onClick={() => setCursor((c) => Math.min(events.length - 1, c + 1))}
disabled={cursor >= events.length - 1}
aria-label={t('replay.stepForward')}
>
<ChevronRight size={16} />
</IconButton>
<Slider
size="small"
min={0}
max={Math.max(0, events.length - 1)}
value={cursor}
onChange={(_, v) => {
setPlaying(false);
setCursor(Number(v));
}}
sx={{ flex: 1 }}
/>
<FormControl size="small" sx={{ minWidth: 72 }}>
<Select
value={speed}
onChange={(e) => setSpeed(Number(e.target.value))}
aria-label={t('replay.speed')}
>
{SPEEDS.map((s) => (
<MenuItem key={s} value={s}>
{s}×
</MenuItem>
))}
</Select>
</FormControl>
<Typography variant="caption" sx={{ color: 'text.disabled', flexShrink: 0 }}>
{cursor + 1}/{events.length}
</Typography>
</Stack>
{/* 事件流(只渲染到当前游标,最近 200 条防 DOM 爆炸) */}
<Box
sx={{
maxHeight: 420,
overflowY: 'auto',
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
'&::-webkit-scrollbar': { width: 6 },
'&::-webkit-scrollbar-thumb': { bgcolor: 'divider', borderRadius: 3 },
}}
>
{events.slice(Math.max(0, cursor - 199), cursor + 1).map(renderEvent)}
</Box>
{skipped > 0 && (
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
{t('replay.skippedLines', { count: skipped })}
</Typography>
)}
</>
)}
{files.length === 0 && !loading && !error && (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('replay.noRecordings')}
</Typography>
)}
</Stack>
</DialogContent>
</Dialog>
);
}
/** Trace 面板的回放入口按钮 + 播放器挂载 */
export function SessionReplayButton({
sessionId,
}: {
sessionId: string | null;
}): React.JSX.Element {
const [open, setOpen] = useState(false);
return (
<>
<Button
size="small"
variant="outlined"
startIcon={<Film size={12} />}
disabled={!sessionId}
onClick={() => setOpen(true)}
sx={{ fontSize: 11 }}
>
{t('replay.title')}
</Button>
<SessionReplayPlayer open={open} onClose={() => setOpen(false)} sessionId={sessionId} />
</>
);
}
+109 -10
View File
@@ -27,6 +27,8 @@ import {
needsNewAssistantCard, needsNewAssistantCard,
findTraceStepIndexForToolCall, findTraceStepIndexForToolCall,
} from '@renderer/lib/stream-event-pipeline'; } from '@renderer/lib/stream-event-pipeline';
// v0.8.0 P1-1a: 事件总线 —— IPC 与后台会话回放统一入口
import { subscribeStreamBus, emitStreamBus } from '@renderer/lib/stream-event-bus';
// v0.7.2 A4: traceSteps 入 store 时即剥离工具结果中的 base64/超长字段 // v0.7.2 A4: traceSteps 入 store 时即剥离工具结果中的 base64/超长字段
import { toDisplayResult } from '@renderer/lib/tool-result-display'; import { toDisplayResult } from '@renderer/lib/tool-result-display';
@@ -119,6 +121,32 @@ export function useAgentStream(): void {
flushTraceThought(); flushTraceThought();
}, [flushTextDelta, flushTraceThought]); }, [flushTextDelta, flushTraceThought]);
/**
* v0.8.0 P1-1d: 清扫流结束后仍处于 executing/pending
* abort tool_result
* done/error error
*/
const sweepInterruptedToolCalls = useCallback((): void => {
const store = useAgentStore.getState();
const stamp = t('agent.system.tool_interrupted');
const sweep = (tcs?: ToolCallInfo[]): ToolCallInfo[] | undefined =>
tcs?.some((tc) => tc.status === 'executing' || tc.status === 'pending')
? tcs.map((tc) =>
tc.status === 'executing' || tc.status === 'pending'
? { ...tc, status: 'error' as const, error: stamp }
: tc,
)
: undefined;
for (const m of store.messages) {
const updated = sweep(m.toolCalls);
if (updated) store.updateMessage(m.id, { toolCalls: updated });
}
for (const step of store.traceSteps) {
const updated = sweep(step.toolCalls);
if (updated && step.id) store.updateTraceStepById(step.id, { toolCalls: updated });
}
}, []);
// ===== 流式内容事件 ===== // ===== 流式内容事件 =====
useEffect(() => { useEffect(() => {
if (!window.metona?.agent?.onStreamEvent) return; if (!window.metona?.agent?.onStreamEvent) return;
@@ -134,7 +162,13 @@ export function useAgentStream(): void {
if (textRafIdRef.current != null) return; if (textRafIdRef.current != null) return;
textRafIdRef.current = requestAnimationFrame(flushTextDelta); textRafIdRef.current = requestAnimationFrame(flushTextDelta);
}; };
const unsubscribe = window.metona.agent.onStreamEvent((event: unknown) => { // v0.8.0 P1-1a: IPC 监听器只负责把事件投递进总线;处理逻辑订阅总线。
// 后台会话回放(agent-store.setCurrentSession)经同一总线灌入缓冲事件,
// 与实时事件无缝衔接。
const unsubscribeIpc = window.metona.agent.onStreamEvent((event: unknown) => {
emitStreamBus('streamEvent', event);
});
const unsubscribe = subscribeStreamBus('streamEvent', (event: unknown) => {
const data = event as { const data = event as {
type?: string; type?: string;
requestId?: string; requestId?: string;
@@ -170,6 +204,8 @@ export function useAgentStream(): void {
}; };
/** DONE 事件的终止原因(completed / max_iterations / timeout / user_interrupt / dead_loop / error */ /** DONE 事件的终止原因(completed / max_iterations / timeout / user_interrupt / dead_loop / error */
terminationReason?: string; terminationReason?: string;
/** v0.8.0 P0-1: DONE 事件携带的 Provider 原生停止原因(length 触发截断提示) */
finishReason?: string;
error?: { code: string; message: string }; error?: { code: string; message: string };
state?: string; state?: string;
}; };
@@ -529,6 +565,35 @@ export function useAgentStream(): void {
traceRafIdRef.current = null; traceRafIdRef.current = null;
} }
flushTraceThought(); flushTraceThought();
// v0.8.0 P1-1d: 清扫悬空的 executing/pending 工具卡片 —— abort 尾巴
// 过滤曾让中断后到达的 tool_result 被丢弃,卡片永久转圈
sweepInterruptedToolCalls();
// v0.8.0 P0-4: 输出截断 / 空完成可见性(引擎空响应守卫的 UI 侧配合)
if (reason === 'completed' && data.finishReason === 'length') {
getStore().addMessage({
id: genMsgId('system'),
role: 'system',
content: t('agent.terminated.output_truncated'),
timestamp: Date.now(),
});
} else if (reason === 'completed') {
const targetId = currentRunAssistantIdRef.current;
const card = targetId ? getStore().messages.find((m) => m.id === targetId) : undefined;
if (
card &&
card.role === 'assistant' &&
!card.content &&
!card.reasoningContent &&
!(card.toolCalls && card.toolCalls.length > 0)
) {
getStore().addMessage({
id: genMsgId('system'),
role: 'system',
content: t('agent.system.empty_completed'),
timestamp: Date.now(),
});
}
}
getStore().setStreaming(false); getStore().setStreaming(false);
getStore().setCurrentRunId(null); getStore().setCurrentRunId(null);
// 不覆盖 error 状态 — error handler 已设置 agentStatus='error' // 不覆盖 error 状态 — error handler 已设置 agentStatus='error'
@@ -559,23 +624,35 @@ 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.8.0 P1-1d: 清扫悬空的 executing/pending 工具卡片
sweepInterruptedToolCalls();
// v0.3.17: 对 content_filtered 错误码显示更友好的提示 // v0.3.17: 对 content_filtered 错误码显示更友好的提示
// v0.8.0 P0-4: output_length_exceeded / invalid_response(空响应重试耗尽)
// 映射针对性文案(引擎 P0-2 空响应守卫的结构化错误码)
const errorCode = data.error?.code; const errorCode = data.error?.code;
const errorMessage = data.error?.message ?? '未知错误'; const errorMessage = data.error?.message ?? '未知错误';
const friendlyContent =
errorCode === 'content_filtered'
? t('agent.error.content_filtered', { message: errorMessage })
: errorCode === 'output_length_exceeded'
? t('agent.error.output_length_exceeded')
: errorCode === 'invalid_response'
? t('agent.error.empty_response')
: t('agent.error.generic', { message: errorMessage });
getStore().addMessage({ getStore().addMessage({
id: genMsgId('error'), id: genMsgId('error'),
role: 'system', role: 'system',
content: content: friendlyContent,
errorCode === 'content_filtered'
? t('agent.error.content_filtered', { message: errorMessage })
: t('agent.error.generic', { message: errorMessage }),
timestamp: Date.now(), timestamp: Date.now(),
}); });
break; break;
} }
}); });
cleanupRef.current = unsubscribe; cleanupRef.current = () => {
unsubscribe();
unsubscribeIpc();
};
return () => { return () => {
// F5: 组件卸载时清理挂起的 rAF,并 flush 残留 delta(保留已接收内容) // F5: 组件卸载时清理挂起的 rAF,并 flush 残留 delta(保留已接收内容)
@@ -592,14 +669,18 @@ export function useAgentStream(): void {
flushTraceThought(); flushTraceThought();
cleanupRef.current?.(); cleanupRef.current?.();
}; };
}, [flushTextDelta, flushTraceThought]); }, [flushTextDelta, flushTraceThought, sweepInterruptedToolCalls]);
// ===== 状态变化事件(迭代追踪 + Trace 步骤 + 消息卡片) ===== // ===== 状态变化事件(迭代追踪 + Trace 步骤 + 消息卡片) =====
useEffect(() => { useEffect(() => {
if (!window.metona?.agent?.onStateChange) return; if (!window.metona?.agent?.onStateChange) return;
const unsubscribe = window.metona.agent.onStateChange((state: unknown) => { // v0.8.0 P1-1a: IPC → 总线,处理逻辑订阅总线(回放复用同一管线)
const data = state as { const unsubscribeIpc = window.metona.agent.onStateChange((state: unknown) => {
emitStreamBus('stateChange', state);
});
const unsubscribe = subscribeStreamBus('stateChange', (raw: unknown) => {
const data = raw as {
sessionId?: string; sessionId?: string;
iteration?: number; iteration?: number;
state?: string; state?: string;
@@ -635,6 +716,21 @@ export function useAgentStream(): void {
if (decision.action === 'reject') return; if (decision.action === 'reject') return;
if (decision.adoptRunId) store.setCurrentRunId(decision.adoptRunId); if (decision.adoptRunId) store.setCurrentRunId(decision.adoptRunId);
// v0.8.0 P1-1b: abort 失败自愈 —— 用户中断后若引擎仍在运行(IPC 失败或
// waitForAbort 超时),其后续状态事件持续到达;本地 isStreaming 已被
// abort() 置 false,此处按 runId 匹配恢复流式显示,避免"UI 显示 idle
// 但引擎静默运行、内容全部被准入层丢弃"。
if (
!store.isStreaming &&
data.runId &&
data.runId === store.currentRunId &&
['THINKING', 'PARSING', 'EXECUTING', 'OBSERVING', 'REFLECTING', 'COMPRESSING'].includes(
data.state ?? '',
)
) {
store.setStreaming(true);
}
// --- 迭代号更新 + 新消息卡片创建 --- // --- 迭代号更新 + 新消息卡片创建 ---
if (data.iteration != null && data.iteration !== store.currentIteration) { if (data.iteration != null && data.iteration !== store.currentIteration) {
const prevIteration = store.currentIteration; const prevIteration = store.currentIteration;
@@ -757,7 +853,10 @@ export function useAgentStream(): void {
} }
}); });
return () => unsubscribe(); return () => {
unsubscribe();
unsubscribeIpc();
};
}, [flushPendingBuffersBeforeNewIteration]); }, [flushPendingBuffersBeforeNewIteration]);
// 监听 Provider 切换通知 // 监听 Provider 切换通知
+126
View File
@@ -21,11 +21,42 @@ registerTranslations('zh-CN', {
'agent.system.stopped': '⏹ 会话已停止:{{reason}}', 'agent.system.stopped': '⏹ 会话已停止:{{reason}}',
'agent.error.generic': '错误: {{message}}', 'agent.error.generic': '错误: {{message}}',
'agent.error.content_filtered': '⚠️ {{message}}', 'agent.error.content_filtered': '⚠️ {{message}}',
// v0.8.0 P2-4: @ 文件提及
'input.mention.failed': '@{{path}} 读取失败:{{reason}}',
// ===== v0.8.0 P0-2/P0-4: 流结束契约提示 =====
'agent.terminated.output_truncated':
'⚠️ 回答可能因输出上限被截断(finish_reason=length)。可在 设置 → LLM 配置 中提高"最大输出上限",或降低思考强度',
'agent.error.output_length_exceeded':
'⚠️ 模型的思考内容耗尽了输出 token 预算,未能产出回答(finish_reason=length)。建议:提高"最大输出上限" / 关闭思考模式或降低思考强度 / 更换输出预算更大的模型',
'agent.error.empty_response':
'⚠️ 模型未返回任何内容(含重试)。可能为上游中断或输出上限截断,请重试或调整输出上限/思考设置',
'agent.system.empty_completed': '⚠️ 模型未返回内容。请重试,或检查输出上限与思考模式设置',
'agent.system.finalize_missing':
'⚠️ 会话已结束但未收到正常收尾事件,已恢复输入(结果以数据库内容为准)',
'agent.system.abort_failed': '⚠️ 中断请求失败,Agent 可能仍在运行;已恢复实时显示',
'agent.system.replay_truncated': '(提示:后台运行内容较多,仅恢复了最近部分事件)',
'agent.system.tool_interrupted': '已中断',
// ===== 通用 ===== // ===== 通用 =====
'common.cancel': '取消', 'common.cancel': '取消',
'common.delete': '删除', 'common.delete': '删除',
'common.refresh': '刷新', 'common.refresh': '刷新',
'common.close': '关闭',
// v0.8.0 P2-2: 会话回放
'replay.title': '会话回放',
'replay.file': '选择录制文件',
'replay.play': '播放',
'replay.pause': '暂停',
'replay.stepBack': '上一步',
'replay.stepForward': '下一步',
'replay.speed': '播放速度',
'replay.loadFailed': '加载录制失败',
'replay.noRecordings': '该会话还没有 TRACE 录制文件(logs/session_*.jsonl',
'replay.skippedLines': '{{count}} 行损坏/截断被跳过',
'replay.event.iterationStart': '▶ 第 {{iteration}} 轮开始',
'replay.event.iterationEnd': '■ 第 {{iteration}} 轮结束({{duration}}',
'replay.event.sessionEnd': '会话结束:{{reason}}{{iterations}} 轮)',
// ===== 工具确认弹框(ConfirmationDialog===== // ===== 工具确认弹框(ConfirmationDialog=====
'confirm.title': '工具执行确认', 'confirm.title': '工具执行确认',
@@ -68,6 +99,12 @@ registerTranslations('zh-CN', {
'sidebar.noMatch': '无匹配结果', 'sidebar.noMatch': '无匹配结果',
'sidebar.empty': '暂无会话', 'sidebar.empty': '暂无会话',
'sidebar.archived': '已归档', 'sidebar.archived': '已归档',
// v0.8.0 P2-1: 回收站
'sidebar.trash': '回收站(30 天后自动清理)',
'sidebar.trash.empty': '回收站为空',
'sidebar.trash.purge': '彻底删除',
'sidebar.trash.purgeTitle': '彻底删除会话?',
'sidebar.trash.purgeBody': '「{{title}}」及其全部消息将被永久删除,此操作不可恢复。',
'sidebar.count': '{{count}} 个', 'sidebar.count': '{{count}} 个',
'sidebar.restore': '恢复', 'sidebar.restore': '恢复',
'sidebar.deleteTitle': '删除会话', 'sidebar.deleteTitle': '删除会话',
@@ -159,6 +196,13 @@ registerTranslations('zh-CN', {
'llm.ctxWindow.mimoHelper': '默认 10000001M),用于上下文压缩判断', 'llm.ctxWindow.mimoHelper': '默认 10000001M),用于上下文压缩判断',
'llm.ctxWindow.oaHelper': 'gpt-4o 默认 128Kgpt-4.1 默认 1M', 'llm.ctxWindow.oaHelper': 'gpt-4o 默认 128Kgpt-4.1 默认 1M',
'llm.ctxWindow.anthropicHelper': 'Claude 默认 200K', 'llm.ctxWindow.anthropicHelper': 'Claude 默认 200K',
// v0.8.0 FEAT-1: 最大输出上限(llm.maxTokens,按请求传给 API
'llm.maxTokens.label': '最大输出上限 (tokens)',
'llm.maxTokens.helper': '单次回复(含思考内容)的最大 token 数;思考会占用此预算',
'llm.maxTokens.minError': '最小值为 256',
'llm.maxTokens.exceedCap': '当前模型输出上限为 {{cap}},保存后将按模型上限生效(配置值被钳制)',
'llm.maxTokens.capInfo': '当前模型输出上限:{{cap}} tokens',
'llm.thinking.unsupported': '当前模型不支持思考模式:请求将自动关闭思考(避免思考耗尽输出预算)',
'llm.pull.title': '下载 Ollama 模型', 'llm.pull.title': '下载 Ollama 模型',
'llm.pull.placeholder': '模型名,如 qwen3:8b', 'llm.pull.placeholder': '模型名,如 qwen3:8b',
'llm.pull.download': '下载', 'llm.pull.download': '下载',
@@ -397,6 +441,17 @@ registerTranslations('zh-CN', {
'settings.logs.data.memories': '所有记忆', 'settings.logs.data.memories': '所有记忆',
'settings.logs.data.auditLogs': '审计日志', 'settings.logs.data.auditLogs': '审计日志',
'settings.logs.traceFilesTitle': '会话录制文件(TRACE JSONL', 'settings.logs.traceFilesTitle': '会话录制文件(TRACE JSONL',
// v0.8.0 P2-3: 检查更新 / 下载安装
'settings.logs.update.title': '应用更新',
'settings.logs.update.check': '检查更新',
'settings.logs.update.checking': '检查中…',
'settings.logs.update.downloadInstall': '下载并安装',
'settings.logs.update.installing': '准备安装…',
'settings.logs.update.available': '新版本 {{version}} 可用,点击"下载并安装"升级',
'settings.logs.update.upToDate': '已是最新版本({{version}}',
'settings.logs.update.disabled': '未配置更新源(app.updateFeedUrl',
'settings.logs.update.error': '更新失败:{{message}}',
'settings.logs.update.downloaded': '下载完成,即将重启安装…',
'settings.logs.traceStats': 'settings.logs.traceStats':
'{{count}} 个文件 · 共 {{size}}(保留策略:最近 200 个,启动时自动清理)', '{{count}} 个文件 · 共 {{size}}(保留策略:最近 200 个,启动时自动清理)',
'settings.logs.statsLoading': '统计中...', 'settings.logs.statsLoading': '统计中...',
@@ -442,6 +497,10 @@ registerTranslations('zh-CN', {
'settings.mcp.addFailed': '添加 MCP 服务失败', 'settings.mcp.addFailed': '添加 MCP 服务失败',
'settings.mcp.addFailedMsg': '添加 MCP 服务失败:{{message}}', 'settings.mcp.addFailedMsg': '添加 MCP 服务失败:{{message}}',
'settings.mcp.removeFailed': '移除失败', 'settings.mcp.removeFailed': '移除失败',
// v0.8.0 P2-5: MCP Resources / Prompts 发现
'settings.mcp.contents.show': '查看资源与提示词',
'settings.mcp.contents.hide': '收起资源与提示词',
'settings.mcp.contents.empty': '该 Server 未声明 Resources / Prompts 能力',
'settings.mcp.removeFailedMsg': '移除失败:{{message}}', 'settings.mcp.removeFailedMsg': '移除失败:{{message}}',
'settings.mcp.toggleFailed': '操作失败', 'settings.mcp.toggleFailed': '操作失败',
'settings.mcp.toggleFailedMsg': '操作失败:{{message}}', 'settings.mcp.toggleFailedMsg': '操作失败:{{message}}',
@@ -666,10 +725,44 @@ registerTranslations('en-US', {
'agent.system.stopped': '⏹ Session stopped: {{reason}}', 'agent.system.stopped': '⏹ Session stopped: {{reason}}',
'agent.error.generic': 'Error: {{message}}', 'agent.error.generic': 'Error: {{message}}',
'agent.error.content_filtered': '⚠️ {{message}}', 'agent.error.content_filtered': '⚠️ {{message}}',
// v0.8.0 P2-4: @ file mention
'input.mention.failed': '@{{path}} read failed: {{reason}}',
// ===== v0.8.0 P0-2/P0-4: stream-end contract notices =====
'agent.terminated.output_truncated':
'⚠️ The answer may have been truncated by the output token limit (finish_reason=length). Raise "Max Output Tokens" in Settings → LLM, or lower thinking effort',
'agent.error.output_length_exceeded':
'⚠️ The model consumed its entire output token budget on reasoning and produced no answer (finish_reason=length). Try: raise "Max Output Tokens" / disable or lower thinking / switch to a model with a larger output budget',
'agent.error.empty_response':
'⚠️ The model returned no content (after retries). This may be an upstream drop or output-limit cutoff — retry, or adjust output limit / thinking settings',
'agent.system.empty_completed':
'⚠️ The model returned no content. Please retry, or check the output limit / thinking settings',
'agent.system.finalize_missing':
'⚠️ The run ended without a normal completion event — input restored (results reflect the database)',
'agent.system.abort_failed':
'⚠️ Abort request failed — the agent may still be running; live display restored',
'agent.system.replay_truncated':
' (Note: many events occurred in the background; only the most recent were restored)',
'agent.system.tool_interrupted': 'Interrupted',
'common.cancel': 'Cancel', 'common.cancel': 'Cancel',
'common.delete': 'Delete', 'common.delete': 'Delete',
'common.refresh': 'Refresh', 'common.refresh': 'Refresh',
'common.close': 'Close',
// v0.8.0 P2-2: Session replay
'replay.title': 'Session replay',
'replay.file': 'Select recording',
'replay.play': 'Play',
'replay.pause': 'Pause',
'replay.stepBack': 'Step back',
'replay.stepForward': 'Step forward',
'replay.speed': 'Playback speed',
'replay.loadFailed': 'Failed to load recording',
'replay.noRecordings': 'No TRACE recordings for this session yet (logs/session_*.jsonl)',
'replay.skippedLines': '{{count}} corrupt/truncated line(s) skipped',
'replay.event.iterationStart': '▶ Iteration {{iteration}} start',
'replay.event.iterationEnd': '■ Iteration {{iteration}} end ({{duration}})',
'replay.event.sessionEnd': 'Session ended: {{reason}} ({{iterations}} iterations)',
'confirm.title': 'Tool Execution Confirmation', 'confirm.title': 'Tool Execution Confirmation',
'confirm.parallelSuffix': ' ({{count}} parallel requests)', 'confirm.parallelSuffix': ' ({{count}} parallel requests)',
@@ -712,6 +805,13 @@ registerTranslations('en-US', {
'sidebar.noMatch': 'No matches', 'sidebar.noMatch': 'No matches',
'sidebar.empty': 'No sessions yet', 'sidebar.empty': 'No sessions yet',
'sidebar.archived': 'Archived', 'sidebar.archived': 'Archived',
// v0.8.0 P2-1: Trash
'sidebar.trash': 'Trash (auto-purged after 30 days)',
'sidebar.trash.empty': 'Trash is empty',
'sidebar.trash.purge': 'Delete forever',
'sidebar.trash.purgeTitle': 'Delete session forever?',
'sidebar.trash.purgeBody':
'"{{title}}" and all of its messages will be permanently deleted. This cannot be undone.',
'sidebar.count': '{{count}}', 'sidebar.count': '{{count}}',
'sidebar.restore': 'Restore', 'sidebar.restore': 'Restore',
'sidebar.deleteTitle': 'Delete Session', 'sidebar.deleteTitle': 'Delete Session',
@@ -806,6 +906,16 @@ registerTranslations('en-US', {
'llm.ctxWindow.mimoHelper': 'Default 1000000 (1M), used for compression decisions', 'llm.ctxWindow.mimoHelper': 'Default 1000000 (1M), used for compression decisions',
'llm.ctxWindow.oaHelper': 'gpt-4o defaults to 128K, gpt-4.1 to 1M', 'llm.ctxWindow.oaHelper': 'gpt-4o defaults to 128K, gpt-4.1 to 1M',
'llm.ctxWindow.anthropicHelper': 'Claude defaults to 200K', 'llm.ctxWindow.anthropicHelper': 'Claude defaults to 200K',
// v0.8.0 FEAT-1: max output tokens (llm.maxTokens, sent per request)
'llm.maxTokens.label': 'Max output tokens',
'llm.maxTokens.helper':
'Maximum tokens per response (reasoning included); thinking consumes this budget',
'llm.maxTokens.minError': 'Minimum is 256',
'llm.maxTokens.exceedCap':
'This model caps output at {{cap}} — the saved value will be clamped to the model limit',
'llm.maxTokens.capInfo': 'Model output cap: {{cap}} tokens',
'llm.thinking.unsupported':
'This model does not support thinking: requests will disable thinking automatically (to avoid burning the output budget on reasoning)',
'llm.pull.title': 'Download Ollama model', 'llm.pull.title': 'Download Ollama model',
'llm.pull.placeholder': 'Model name, e.g. qwen3:8b', 'llm.pull.placeholder': 'Model name, e.g. qwen3:8b',
'llm.pull.download': 'Download', 'llm.pull.download': 'Download',
@@ -1048,6 +1158,18 @@ registerTranslations('en-US', {
'settings.logs.data.memories': 'all memories', 'settings.logs.data.memories': 'all memories',
'settings.logs.data.auditLogs': 'audit logs', 'settings.logs.data.auditLogs': 'audit logs',
'settings.logs.traceFilesTitle': 'Session recording files (TRACE JSONL)', 'settings.logs.traceFilesTitle': 'Session recording files (TRACE JSONL)',
// v0.8.0 P2-3: App update
'settings.logs.update.title': 'App update',
'settings.logs.update.check': 'Check for updates',
'settings.logs.update.checking': 'Checking…',
'settings.logs.update.downloadInstall': 'Download & install',
'settings.logs.update.installing': 'Preparing to install…',
'settings.logs.update.available':
'Version {{version}} is available — click "Download & install" to upgrade',
'settings.logs.update.upToDate': 'Up to date ({{version}})',
'settings.logs.update.disabled': 'No update feed configured (app.updateFeedUrl)',
'settings.logs.update.error': 'Update failed: {{message}}',
'settings.logs.update.downloaded': 'Download complete — restarting to install…',
'settings.logs.traceStats': 'settings.logs.traceStats':
'{{count}} file(s) · {{size}} total (retention: last 200, auto-cleaned on startup)', '{{count}} file(s) · {{size}} total (retention: last 200, auto-cleaned on startup)',
'settings.logs.statsLoading': 'Computing...', 'settings.logs.statsLoading': 'Computing...',
@@ -1093,6 +1215,10 @@ registerTranslations('en-US', {
'settings.mcp.addFailed': 'Failed to add MCP server', 'settings.mcp.addFailed': 'Failed to add MCP server',
'settings.mcp.addFailedMsg': 'Failed to add MCP server: {{message}}', 'settings.mcp.addFailedMsg': 'Failed to add MCP server: {{message}}',
'settings.mcp.removeFailed': 'Remove failed', 'settings.mcp.removeFailed': 'Remove failed',
// v0.8.0 P2-5: MCP discovery
'settings.mcp.contents.show': 'Show resources & prompts',
'settings.mcp.contents.hide': 'Hide resources & prompts',
'settings.mcp.contents.empty': 'This server does not declare Resources / Prompts capabilities',
'settings.mcp.removeFailedMsg': 'Remove failed: {{message}}', 'settings.mcp.removeFailedMsg': 'Remove failed: {{message}}',
'settings.mcp.toggleFailed': 'Operation failed', 'settings.mcp.toggleFailed': 'Operation failed',
'settings.mcp.toggleFailedMsg': 'Operation failed: {{message}}', 'settings.mcp.toggleFailedMsg': 'Operation failed: {{message}}',
+31
View File
@@ -60,3 +60,34 @@ export function supportsImageUpload(input: ImageUploadGateInput): ImageUploadGat
return { allowed: true }; return { allowed: true };
} }
// ===== v0.8.0 FEAT-1: Provider 输出上限参数支持矩阵 =====
/**
* Provider "最大输出上限"LLM
* Provider
*
* API + adapter
* - deepseek: max_tokens384K / vision 8192
* - agnes: max_tokens 65.5K
* - mimo: max_completion_tokens 131072 / 32768 token
* - openai: max_tokenschat / max_completion_tokenso
* - anthropic: max_tokens
* - ollama: options.num_predict
*
* Provider
* false UI
*/
export const PROVIDER_OUTPUT_LIMIT_SUPPORT: Record<string, boolean> = {
deepseek: true,
agnes: true,
mimo: true,
openai: true,
anthropic: true,
ollama: true,
};
/** 查询当前 Provider 是否支持输出上限配置(未知 Provider 保守返回 false → 隐藏) */
export function supportsOutputLimitConfig(provider: string): boolean {
return PROVIDER_OUTPUT_LIMIT_SUPPORT[provider] === true;
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Stream Event Bus 线v0.8.0 P1-1a
*
* useAgentStream IPC window.metona.agent.
* onStreamEvent / onStateChange run
* non-streaming-content / terminal-without-run UI
* "回复到一半消失"
*
* IPC 线 useAgentStream
* agent:getReplayState
* emit 线
*
* pub-subuseAgentStream App
* 退
*/
export type StreamEventChannel = 'streamEvent' | 'stateChange';
type Listener = (payload: unknown) => void;
const listeners = new Map<StreamEventChannel, Set<Listener>>();
export function subscribeStreamBus(channel: StreamEventChannel, fn: Listener): () => void {
let set = listeners.get(channel);
if (!set) {
set = new Set();
listeners.set(channel, set);
}
set.add(fn);
return () => {
set?.delete(fn);
};
}
export function emitStreamBus(channel: StreamEventChannel, payload: unknown): void {
const set = listeners.get(channel);
if (!set) return;
for (const fn of set) {
try {
fn(payload);
} catch (err) {
// 单个消费者异常不阻断其他消费者(当前单消费者,防御性)
console.error('[stream-event-bus] listener error:', err);
}
}
}
+94 -3
View File
@@ -11,6 +11,11 @@ import { useSessionStore } from './session-store';
import { toDisplayResult } from '../lib/tool-result-display'; import { toDisplayResult } from '../lib/tool-result-display';
// v0.7.3 P3-3: Trace 生命周期 —— metadata 只保留最近 N 个 run 的步骤 // v0.7.3 P3-3: Trace 生命周期 —— metadata 只保留最近 N 个 run 的步骤
import { keepRecentRuns } from '../lib/trace-lifecycle'; import { keepRecentRuns } from '../lib/trace-lifecycle';
// v0.8.0 P1-1a: 后台会话回放经事件总线灌入 useAgentStream 管线
import { emitStreamBus } from '../lib/stream-event-bus';
// v0.8.0 P1-1b/c: 文案出层
import { t } from '../lib/i18n';
import '../lib/i18n-strings';
// L-1: 消息 ID 防碰撞计数器 // L-1: 消息 ID 防碰撞计数器
let _msgIdCounter = 0; let _msgIdCounter = 0;
@@ -380,6 +385,46 @@ export const useAgentStore = create<AgentState>((set, get) => ({
console.error('[AgentStore]', err); console.error('[AgentStore]', err);
}); });
} }
// v0.8.0 P1-1a: 后台会话运行内容恢复 —— 该会话正在后台运行(或刚结束但
// 缓冲未清)时,拉取主进程回放缓冲并按序灌入事件总线,重建消息/Trace 状态;
// 实时事件随后经同一总线无缝衔接(runId 对齐由缓冲记录提供)。
if (id && window.metona?.agent?.getReplayState) {
window.metona.agent
.getReplayState(id)
.then((r) => {
// 竞态保护:用户已切走则丢弃
if (get().currentSessionId !== id) return;
if (!r?.success || !r.data || r.data.events.length === 0) return;
const { events, truncated, runId, isRunning } = r.data;
// 恢复流式态与 run 归属:回放的内容事件需要 isStreaming=true 才能通过
// 准入层(non-streaming-content 过滤);终止事件(done/error)会正常
// 收尾并把 isStreaming 复位为 false。
if (runId) {
set({ currentRunId: runId, isStreaming: true });
}
for (const ev of events) {
emitStreamBus(ev.channel, ev.payload);
}
if (truncated) {
get().addMessage({
id: genMsgId('system'),
role: 'system',
content: t('agent.system.replay_truncated'),
timestamp: Date.now(),
});
}
// 已运行中的会话:回放后实时事件继续;stateChange THINKING 自愈
//(P1-1b)会在流式态意外复位时恢复。此处对 isRunning 会话兜底确保
// isStreaming 不为 falsedone 未到达时 bus 回放不会复位它)。
if (isRunning && runId && !get().isStreaming) {
set({ isStreaming: true });
}
})
.catch((err) => {
console.error('[AgentStore] getReplayState failed:', err);
});
}
}, },
setMessages: (messages) => set({ messages }), setMessages: (messages) => set({ messages }),
@@ -544,7 +589,40 @@ export const useAgentStore = create<AgentState>((set, get) => ({
content: llmContent, content: llmContent,
images: llmImages.length > 0 ? llmImages : undefined, images: llmImages.length > 0 ? llmImages : undefined,
}; };
window.metona.agent.sendMessage(messageWithImages, sessionId).catch((err) => { window.metona.agent.sendMessage(messageWithImages, sessionId).then(
(r) => {
// v0.8.0 P1-1c: 收尾兜底 —— agent:sendMessage 的 invoke 在整个 run 结束后
// 才 resolve。resolve 时若本地仍处于流式态,说明 DONE/ERROR 收尾事件
// 未到达(事件投递异常/主进程异常收尾),渲染层将永久卡在流式状态。
const st = get();
if (st.currentSessionId !== sessionId || !st.isStreaming) return;
if (r && r.success === false) {
// 主进程显式失败(注入拦截/防重入/adapter 加载失败等)——通常已广播
// ERROR 事件;若事件未达,本地以返回的 error 消息兜底收尾
set({ agentStatus: 'error', isStreaming: false, currentRunId: null });
get().addMessage({
id: genMsgId('error'),
role: 'system',
content: t('agent.error.generic', { message: r.error ?? '消息发送失败' }),
timestamp: Date.now(),
});
return;
}
// 成功 resolve:给事件投递留 2s 宽限,仍卡流式则强制收尾
setTimeout(() => {
const s2 = get();
if (s2.currentSessionId !== sessionId || !s2.isStreaming) return;
set({ agentStatus: 'idle', isStreaming: false, currentRunId: null });
s2.addMessage({
id: genMsgId('system'),
role: 'system',
content: t('agent.system.finalize_missing'),
timestamp: Date.now(),
});
s2.saveTraceData();
}, 2_000);
},
(err) => {
console.error('[AgentStore]', 'IPC sendMessage failed:', err); console.error('[AgentStore]', 'IPC sendMessage failed:', err);
set({ agentStatus: 'error', isStreaming: false }); set({ agentStatus: 'error', isStreaming: false });
get().addMessage({ get().addMessage({
@@ -557,7 +635,8 @@ export const useAgentStore = create<AgentState>((set, get) => ({
import('@metona-team/metona-toast') import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`消息发送失败:${(err as Error).message}`)) .then((mod) => mod.default.error(`消息发送失败:${(err as Error).message}`))
.catch(() => {}); .catch(() => {});
}); },
);
} }
}, },
@@ -810,11 +889,23 @@ export const useAgentStore = create<AgentState>((set, get) => ({
// 不清除 currentRunId — 保留用于过滤旧 run 的延迟终止事件(DONE/TERMINATED // 不清除 currentRunId — 保留用于过滤旧 run 的延迟终止事件(DONE/TERMINATED
// 旧 run 的 DONE 到达时会匹配 currentRunId,处理后清除;新 run 的 INIT 会覆盖 // 旧 run 的 DONE 到达时会匹配 currentRunId,处理后清除;新 run 的 INIT 会覆盖
set({ agentStatus: 'idle', isStreaming: false }); set({ agentStatus: 'idle', isStreaming: false });
// v0.7.2 P3-11: 立即熄灭运行指示点;若中断未生效,后续 THINKING 状态事件会重新点亮(自愈) // v0.7.2 P3-11: 立即熄灭运行指示点
// v0.8.0 P1-1b 修正注释与实现:此前注释称"中断未生效时 THINKING 事件会重新
// 点亮(自愈)",但 isStreaming=false 下内容事件仍被准入层丢弃。现两层自愈:
// ① 本处 IPC 失败 → 回滚本地状态并恢复运行指示(下方 catch);
// ② IPC 成功但引擎未真正停止 → useAgentStream 的 stateChange THINKING
// 自愈(runId 匹配时恢复 isStreaming=true)。
if (sessionId) get().updateSessionRunState(sessionId, null); if (sessionId) get().updateSessionRunState(sessionId, null);
if (sessionId && window.metona?.agent?.abortSession) { if (sessionId && window.metona?.agent?.abortSession) {
window.metona.agent.abortSession(sessionId).catch((err) => { window.metona.agent.abortSession(sessionId).catch((err) => {
console.error('[AgentStore]', err); console.error('[AgentStore]', err);
// v0.8.0 P1-1b: 中断请求失败 —— 引擎很可能仍在运行,回滚本地状态
// 并恢复运行指示与实时显示(内容事件不再被准入层丢弃)
set({ agentStatus: 'thinking', isStreaming: true });
if (sessionId) get().updateSessionRunState(sessionId, 'thinking');
import('@metona-team/metona-toast')
.then((mod) => mod.default.warning(t('agent.system.abort_failed')))
.catch(() => {});
}); });
} }
}, },
+84 -1
View File
@@ -53,6 +53,10 @@ interface MetonaStreamEventData {
retryable?: boolean; retryable?: boolean;
}; };
state?: string; state?: string;
/** v0.8.0 P0-1: DONE 事件携带的 Provider 原生停止原因(stop/length/tool_calls/content_filter */
finishReason?: string;
/** v0.8.0 P1-1a: DONE 事件终止原因(useAgentStream 消费;与 terminationReason 字段对齐) */
terminationReason?: string;
} }
/** v0.5.0: SubAgent 生命周期事件(AgentMonitor 的 SubAgent 状态区展示) */ /** v0.5.0: SubAgent 生命周期事件(AgentMonitor 的 SubAgent 状态区展示) */
@@ -69,7 +73,10 @@ interface MetonaSubAgentEvent {
interface MetonaAgentAPI { interface MetonaAgentAPI {
/** 发送 MetonaMessageIR 类型)+ sessionId 到主进程 */ /** 发送 MetonaMessageIR 类型)+ sessionId 到主进程 */
sendMessage: (message: MetonaMessageInput, sessionId: string) => Promise<{ success: boolean }>; sendMessage: (
message: MetonaMessageInput,
sessionId: string,
) => Promise<{ success: boolean; error?: string }>;
onStreamEvent: (callback: (event: MetonaStreamEventData) => void) => () => void; onStreamEvent: (callback: (event: MetonaStreamEventData) => void) => () => void;
onStateChange: ( onStateChange: (
callback: (state: { callback: (state: {
@@ -81,6 +88,20 @@ interface MetonaAgentAPI {
}) => void, }) => void,
) => () => void; ) => () => void;
abortSession: (sessionId: string) => Promise<{ success: boolean }>; abortSession: (sessionId: string) => Promise<{ success: boolean }>;
/**
* v0.8.0 P1-1a: 拉取后台会话的流式回放缓冲
* events { channel: 'streamEvent'|'stateChange', payload }
*/
getReplayState: (sessionId: string) => Promise<{
success: boolean;
error?: string;
data?: {
isRunning: boolean;
runId: string | null;
truncated: boolean;
events: Array<{ channel: 'streamEvent' | 'stateChange'; payload: unknown; ts: number }>;
};
}>;
onProviderSwitched: ( onProviderSwitched: (
callback: (data: { from?: string; to?: string; reason?: string }) => void, callback: (data: { from?: string; to?: string; reason?: string }) => void,
) => () => void; ) => () => void;
@@ -142,6 +163,32 @@ interface MetonaSessionsAPI {
messageId: string, messageId: string,
inclusive?: boolean, inclusive?: boolean,
) => Promise<{ success: boolean; truncated?: number; error?: string }>; ) => Promise<{ success: boolean; truncated?: number; error?: string }>;
// ===== v0.8.0 P2-1: 回收站(软删除 30 天保留) =====
listTrash: () => Promise<
Array<{
id: string;
title: string;
createdAt: number;
updatedAt: number;
messageCount: number;
totalTokens: number;
pinned: boolean;
archived: boolean;
}>
>;
restore: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
purge: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
// ===== v0.8.0 P2-2: 会话回放 =====
listRecordings: (sessionId: string) => Promise<{
success: boolean;
error?: string;
data?: Array<{ name: string; size: number; mtime: number }>;
}>;
readRecording: (name: string) => Promise<{
success: boolean;
error?: string;
data?: { name: string; events: Array<Record<string, unknown>>; skipped: number };
}>;
/** v0.5.0: 全文搜索会话内容(FTS5,跨会话,按会话聚合) */ /** v0.5.0: 全文搜索会话内容(FTS5,跨会话,按会话聚合) */
searchContent: (query: string) => Promise<{ searchContent: (query: string) => Promise<{
success: boolean; success: boolean;
@@ -192,6 +239,19 @@ interface MetonaMCPAPI {
addServer: (config: MetonaMCPServerConfig) => Promise<{ success: boolean; error?: string }>; addServer: (config: MetonaMCPServerConfig) => Promise<{ success: boolean; error?: string }>;
removeServer: (name: string) => Promise<{ success: boolean; error?: string }>; removeServer: (name: string) => Promise<{ success: boolean; error?: string }>;
toggleServer: (name: string, enabled: boolean) => Promise<{ success: boolean; error?: string }>; toggleServer: (name: string, enabled: boolean) => Promise<{ success: boolean; error?: string }>;
// v0.8.0 P2-5: Resources / Prompts 发现
listServerContents: (name: string) => Promise<{
success: boolean;
error?: string;
data?: {
resources: Array<{ uri: string; name: string; description?: string; mimeType?: string }>;
prompts: Array<{
name: string;
description?: string;
arguments?: Array<{ name: string; description?: string; required?: boolean }>;
}>;
};
}>;
} }
// ===== Memory API ===== // ===== Memory API =====
@@ -252,6 +312,16 @@ interface MetonaAppAPI {
| { status: 'up-to-date'; latestVersion: string } | { status: 'up-to-date'; latestVersion: string }
| { status: 'available'; latestVersion: string; downloadUrl?: string; notes?: string } | { status: 'available'; latestVersion: string; downloadUrl?: string; notes?: string }
>; >;
// ===== v0.8.0 P2-3: electron-updater 自动更新 =====
updateInstall: () => Promise<{ success: boolean; error?: string }>;
onUpdateStatus: (
callback: (event: {
status: 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error';
latestVersion?: string;
percent?: number;
message?: string;
}) => void,
) => () => void;
/** v0.7.3 P3-4: 健康快照(SLO 指标 + 最近健康检查报告) */ /** v0.7.3 P3-4: 健康快照(SLO 指标 + 最近健康检查报告) */
getHealthSnapshot: () => Promise<{ getHealthSnapshot: () => Promise<{
success: boolean; success: boolean;
@@ -355,6 +425,19 @@ interface MetonaWorkspaceAPI {
detail?: string; detail?: string;
error?: string; error?: string;
}>; }>;
// v0.8.0 P2-4: @ 文件提及
listFiles: (limit?: number) => Promise<{ success: boolean; error?: string; data?: string[] }>;
readFileClip: (
relPath: string,
maxBytes?: number,
) => Promise<{
success: boolean;
error?: string;
name?: string;
content?: string;
truncated?: boolean;
size?: number;
}>;
} }
// ===== LLM 运行时查询 API ===== // ===== LLM 运行时查询 API =====