feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m25s
CI / 全量测试 (Electron ABI, experimental) (push) Failing after 5m19s
CI / 产物编译验证 (push) Successful in 10m3s

工程化(从零到一):
- 新增 Gitea Actions CI(debian-latest):类型检查 + Lint + 单元测试 + 产物编译验证
- 新增 husky + lint-staged 预提交钩子(lint-staged + typecheck 门禁)
- 移除坏脚本 test:e2e(无 Playwright 配置必失败);prebuild 改用内置 fs.rmSync
- 依赖清理:移除死依赖 sql.js(2MB)/@playwright/test,@types/shell-quote 移至 devDependencies

安全加固:
- PolicyEngine 频率限制按会话隔离(多会话并发不再互抢配额)
- ConfirmationHook 拒绝记忆加 10 分钟 TTL + 恢复询问入口(新增 2 个 IPC 通道)
- Windows run_command 白名单工具(git/node/npm/npx/pnpm/yarn/tsc)改走 cmd.exe /c + 参数数组执行,收窄 shell 注入面
- web_search 四引擎 HTML 解析迁移 node-html-parser(结构化主层 + 正则降级)

缺陷修复(测试驱动发现):
- mapError 大小写缺陷:网络错误码永远落入 UNKNOWN 无法触发重试
- 搜狗解析器自我过滤:相对链接补全后又被 sogou.com 过滤导致结果全丢
- 百度复合类名重复收录:class="result c-container" 被双重匹配

测试补齐(113 → 194 用例):
- 新增 5 个测试文件:sse-stream / base-adapter / confirmation-hook / ipc-agent 编排链路 / web-search 解析器
- 覆盖 sendMessage 全分支、SSE 流解析、错误映射、确认钩子竞态/超时/批量审批

体验升级:
- OutputValidator 验证结果可见化(VALIDATION 流事件 → 聊天流提示卡)
- SettingsModal 巨型组件拆分(1503 行 → 10 个文件,可独立维护)
- MessageList 接入 react-virtuoso 真虚拟滚动(千条消息恒定开销)
- MCP 新增 streamable HTTP 传输支持(SDK 内置传输 + DB 迁移 6 + UI 双模式)
This commit is contained in:
2026-08-21 13:58:48 +08:00
parent 2230bcec3f
commit 49c9b25538
41 changed files with 6254 additions and 2608 deletions
@@ -0,0 +1,238 @@
/**
* SSE 流式解析器单元测试(v0.4.1 测试补齐)
* 覆盖:TEXT_DELTA / REASONING_DELTA / TOOL_CALL 增量拼接 / USAGE /
* [DONE] / finish_reason=tool_calls 提前 flush / 坏 JSON 行容错 / 损坏工具调用跳过
*/
import { describe, it, expect } from 'vitest';
import { parseSSEStream, parseOpenAICompatibleResponse } from '../shared/sse-stream';
import { MetonaStreamEventType } from '../../types';
/** 构造 SSE 测试流 */
function makeStream(chunks: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
return new ReadableStream<Uint8Array>({
start(controller) {
for (const c of chunks) controller.enqueue(encoder.encode(c));
controller.close();
},
});
}
async function collect(
stream: ReadableStream<Uint8Array>,
): Promise<Array<{ type: string; [k: string]: unknown }>> {
const events: Array<{ type: string; [k: string]: unknown }> = [];
for await (const ev of parseSSEStream(stream, 'req_test', 'sess_test', 1)) {
events.push(ev as unknown as { type: string; [k: string]: unknown });
}
return events;
}
describe('parseSSEStream — 文本与推理增量', () => {
it('TEXT_DELTA 事件按序产出', async () => {
const events = await collect(
makeStream([
'data: {"choices":[{"delta":{"content":"你好"}}]}\n\n',
'data: {"choices":[{"delta":{"content":",世界"}}]}\n\n',
'data: [DONE]\n\n',
]),
);
const deltas = events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA);
expect(deltas).toHaveLength(2);
expect(deltas[0].delta).toBe('你好');
expect(deltas[1].delta).toBe(',世界');
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
});
it('REASONING_DELTAThinking 模式)事件产出', async () => {
const events = await collect(
makeStream([
'data: {"choices":[{"delta":{"reasoning_content":"让我想想"}}]}\n\n',
'data: [DONE]\n\n',
]),
);
const reasoning = events.find((e) => e.type === MetonaStreamEventType.REASONING_DELTA);
expect(reasoning?.delta).toBe('让我想想');
});
it('同一个 chunk 中 content 和 reasoning_content 同时产出', async () => {
const events = await collect(
makeStream([
'data: {"choices":[{"delta":{"content":"答","reasoning_content":"思考"}}]}\n\n',
'data: [DONE]\n\n',
]),
);
expect(events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toHaveLength(1);
expect(events.filter((e) => e.type === MetonaStreamEventType.REASONING_DELTA)).toHaveLength(1);
});
});
describe('parseSSEStream — 工具调用增量拼接', () => {
it('分段 arguments 拼接为完整 JSON 并在 finish_reason=tool_calls 时 flush', async () => {
const events = await collect(
makeStream([
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"read_file","arguments":"{\\"pa"}}]}}]}\n\n',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"th\\": \\"a.ts\\"}"}}]}}]}\n\n',
'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\n',
'data: [DONE]\n\n',
]),
);
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
expect(completes).toHaveLength(1);
const tc = completes[0].toolCall as { name: string; args: Record<string, unknown> };
expect(tc.name).toBe('read_file');
expect(tc.args).toEqual({ path: 'a.ts' });
});
it('多个工具调用按 index 分别拼接', async () => {
const events = await collect(
makeStream([
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"tool_a","arguments":"{}"}}]}}]}\n\n',
'data: {"choices":[{"delta":{"tool_calls":[{"index":1,"function":{"name":"tool_b","arguments":"{}"}}]}}]}\n\n',
'data: [DONE]\n\n',
]),
);
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
expect(completes).toHaveLength(2);
const names = completes.map((e) => (e.toolCall as { name: string }).name);
expect(names).toEqual(['tool_a', 'tool_b']);
});
it('损坏的 args JSON 跳过该工具调用且不中断流', async () => {
const events = await collect(
makeStream([
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"bad_tool","arguments":"{invalid json"}}]}}]}\n\n',
'data: {"choices":[{"delta":{"content":"后续文本"}}]}\n\n',
'data: [DONE]\n\n',
]),
);
// 坏 JSON 的工具调用被跳过(不产出 TOOL_CALL_COMPLETE
expect(events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE)).toHaveLength(
0,
);
// 流继续处理后续事件
expect(events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toBe(true);
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
});
});
describe('parseSSEStream — USAGE 与容错', () => {
it('USAGE 事件解析 DeepSeek 缓存字段', async () => {
const events = await collect(
makeStream([
'data: {"choices":[],"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150,"prompt_cache_hit_tokens":80,"prompt_cache_miss_tokens":20}}\n\n',
'data: [DONE]\n\n',
]),
);
const usageEvent = events.find((e) => e.type === MetonaStreamEventType.USAGE);
const usage = usageEvent?.usage as Record<string, number>;
expect(usage.inputTokens).toBe(100);
expect(usage.outputTokens).toBe(50);
expect(usage.totalTokens).toBe(150);
expect(usage.cacheHitTokens).toBe(80);
expect(usage.cacheMissTokens).toBe(20);
});
it('坏 JSON 行不中断后续事件(记录警告后继续)', async () => {
const events = await collect(
makeStream([
'data: {broken json\n\n',
'data: {"choices":[{"delta":{"content":"ok"}}]}\n\n',
'data: [DONE]\n\n',
]),
);
expect(events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toBe(true);
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
});
it('跨 chunk 分割的 SSE 行正确拼接', async () => {
// "data: {...}\n\n" 被切到两个网络 chunk 中
const events = await collect(
makeStream([
'data: {"choices":[{"delta":{"cont',
'ent":"拼接成功"}}]}\n\n',
'data: [DONE]\n\n',
]),
);
const delta = events.find((e) => e.type === MetonaStreamEventType.TEXT_DELTA);
expect(delta?.delta).toBe('拼接成功');
});
it('空行与非 data 行被忽略', async () => {
const events = await collect(
makeStream([
': comment line\n\n',
'\n',
'data: {"choices":[{"delta":{"content":"x"}}]}\n\n',
'data: [DONE]\n\n',
]),
);
expect(events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toHaveLength(1);
});
});
describe('parseOpenAICompatibleResponse — 非流式响应', () => {
it('解析普通文本响应', () => {
const result = parseOpenAICompatibleResponse({
choices: [{ message: { content: 'hello' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
});
expect(result.content).toBe('hello');
expect(result.finishReason).toBe('stop');
expect(result.usage.inputTokens).toBe(10);
expect(result.toolCalls).toBeUndefined();
});
it('解析 reasoning_contentThinking 模式)', () => {
const result = parseOpenAICompatibleResponse({
choices: [
{ message: { content: 'answer', reasoning_content: 'thinking...' }, finish_reason: 'stop' },
],
usage: {},
});
expect(result.reasoningContent).toBe('thinking...');
});
it('解析 tool_calls(字符串 arguments 反序列化)', () => {
const result = parseOpenAICompatibleResponse({
choices: [
{
message: {
content: null,
tool_calls: [{ id: 'tc_1', function: { name: 'run', arguments: '{"cmd":"ls"}' } }],
},
finish_reason: 'tool_calls',
},
],
usage: {},
});
expect(result.toolCalls).toHaveLength(1);
expect(result.toolCalls![0].name).toBe('run');
expect(result.toolCalls![0].args).toEqual({ cmd: 'ls' });
});
it('损坏的 tool_calls arguments 降级为空对象', () => {
const result = parseOpenAICompatibleResponse({
choices: [
{
message: {
content: null,
tool_calls: [{ id: 'tc_1', function: { name: 'run', arguments: '{bad' } }],
},
finish_reason: 'tool_calls',
},
],
usage: {},
});
expect(result.toolCalls![0].args).toEqual({});
});
it('mapOpenAIFinishReason 覆盖 MiMo repetition_truncation', () => {
const result = parseOpenAICompatibleResponse({
choices: [{ message: { content: 'x' }, finish_reason: 'repetition_truncation' }],
usage: {},
});
expect(result.finishReason).toBe('stop');
});
});