feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m8s
CI / 全量测试 (Electron ABI) (push) Failing after 6m0s
CI / 产物编译验证 (push) Successful in 10m58s

硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(llm.contextWindow)
与「最大输出上限」(llm.maxTokens),跨 Provider/模型原样透传。

P0 正确性收口:
- 迁移 11/12(SCHEMA_VERSION 5):记忆表 embedding 列 + 分 Provider 窗口键清理
- 记忆生命周期接线:会话终态清理 working memory / episodic 90 天 TTL / access_count 回写
- 回放缓冲模块化 + 会话终态清理(杜绝 4MB/会话内存滞留)
- i18n 收口:主进程 main-locale(zh/en,ui.locale 热切换)+ 渲染层 17 处出层

P1 能力演进:
- 本地向量混合检索:0.6×向量余弦 + 0.4×TF-IDF,Ollama embeddings 首次投产,
  存量记忆惰性回填,嵌入不可用自动回退 TF-IDF
- MEMORY.md 维护闭环:固化去重消除截断盲区;两阶段维护(AI 建议 → 用户确认 →
  原子改写 + 语义记忆双轨同步 + 审计);>50KB 告警
- 可观测闭环:cacheTokens 引擎→前端透传(Token 面板命中率/成本行)+ 输入框
  上下文占用指示条
- MCP Prompts/Resources 对话可用:/mcp:{server}:{prompt} 与 @mcp:{server}:{uri}

P2 体验补全:
- 工具自定义策略(正则白/黑名单 + 频率 + 强制确认,热生效)
- 连续 ≥3 同类工具确认聚合为单弹框
- 会话消息游标分页(首屏 200 条向上翻页)
- 开机自启;Playwright + Electron E2E 冒烟(本地 mock LLM 零外联)

Review 回归修复:MCP 大小写失配 / 分页状态复位 / 清空=未配置语义(Number(null)=0
隐患)/ MEMORY.md 告警位置 / working_memories FK(迁移 13)/ 全局配置层废键清理;
附带根治权限加固启动时序、代理回环放行、safeStorage 降级、悬空 symlink 逃逸。

验证:typecheck/lint 0 问题;test:electron 2478/2478(0 跳过);E2E 2/2;
docs/v0.8.1-迭代实施清单.md 全项留档。
This commit is contained in:
2026-09-08 09:35:58 +08:00
parent 839860083f
commit 9b45c445bf
85 changed files with 5286 additions and 1158 deletions
@@ -178,7 +178,7 @@ describe('AnthropicAdapter — 请求体契约', () => {
expect(toolResultBlocks[0].tool_use_id).toBe('tc_1');
});
it('A2: max_tokens 按模型上限钳制(63488 → sonnet 64000 / opus 32000', async () => {
it('A2: max_tokens 原样透传(v0.8.1:模型钳制已废除,设置面板是唯一上限来源', async () => {
const sonnet = new AnthropicAdapter({
provider: 'anthropic',
baseURL: 'http://a.test',
@@ -194,9 +194,9 @@ describe('AnthropicAdapter — 请求体契约', () => {
const { bodies } = captureFetch();
await sonnet.send(makeRequest());
await opus.send(makeRequest());
// 引擎默认 63488 低于 sonnet 上限 64000 → 原样保留;opus 上限 32000 → 钳制生效
// v0.8.1: 设置面板「最大输出上限」对一切模型原样透传,无任何按模型钳制
expect(bodies[0].max_tokens).toBe(63_488);
expect(bodies[1].max_tokens).toBe(32_000);
expect(bodies[1].max_tokens).toBe(63_488);
});
it('A3: 小 maxTokens 时 thinking budget 不跌破协议下限 1024v0.6.4 边界加固)', async () => {
@@ -516,8 +516,8 @@ describe('AnthropicAdapter — thinking budget 按 effort 映射矩阵', () => {
['low', 1024],
['medium', 4096],
['high', 16384],
// max=32768 但 sonnet 的 max_tokens 先钳到 64000 → budget 二次钳到 floor(64000/2)=32000
['max', 32000],
// v0.8.1: max_tokens 不再按模型钳制(100_000 原样透传)→ budget = min(32768, floor(100000/2)) = 32768
['max', 32768],
] as const)('effort=%s → budget 为该档值且 < max_tokens', async (effort, expectBudget) => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
@@ -570,13 +570,13 @@ describe('AnthropicAdapter — thinking budget 按 effort 映射矩阵', () => {
});
});
describe('AnthropicAdapter — max_tokens 钳制矩阵', () => {
describe('AnthropicAdapter — max_tokens 透传矩阵(v0.8.1 无钳制)', () => {
it.each([
['claude-sonnet-4-5', 63_488, 63_488], // 引擎默认低于上限 → 原样
['claude-sonnet-4-5', 70_000, 64_000], // 超上限 → 钳到 sonnet 64000
['claude-opus-4-1', 63_488, 32_000], // opus 上限 32000
['claude-haiku-4-5', 63_488, 32_000], // haiku 上限 32000
['claude-sonnet-4-5', 500, 500], // 低于上限 → 原样
['claude-sonnet-4-5', 63_488, 63_488],
['claude-sonnet-4-5', 70_000, 70_000], // 超过任何旧元信息上限 → 原样透传
['claude-opus-4-1', 63_488, 63_488],
['claude-haiku-4-5', 63_488, 63_488],
['claude-sonnet-4-5', 500, 500],
])('%s maxTokens=%d → max_tokens=%d', async (model, requested, expected) => {
const adapter = new AnthropicAdapter({
provider: 'anthropic',
@@ -795,7 +795,7 @@ describe('DeepSeekAdapter — thinking 映射矩阵', () => {
expect(bodies[0].stop).toEqual(['<END>']);
});
it('max_tokens 按模型钳制(pro 384000 / vision 8192', async () => {
it('max_tokens 原样透传(v0.8.1pro/vision 均不钳制', async () => {
const pro = makeAdapter('deepseek-v4-pro');
const vision = makeAdapter('deepseek-v4-flash-vision-exp');
const { bodies } = captureFetch();
@@ -803,8 +803,8 @@ describe('DeepSeekAdapter — thinking 映射矩阵', () => {
await vision.send(
makeRequest({ params: { maxTokens: 63_488, temperature: 0, stream: false } }),
);
expect(bodies[0].max_tokens).toBe(384_000);
expect(bodies[1].max_tokens).toBe(8_192);
expect(bodies[0].max_tokens).toBe(500_000);
expect(bodies[1].max_tokens).toBe(63_488);
});
});
@@ -864,8 +864,8 @@ describe('AgnesAdapter — enable_thinking 对称性矩阵', () => {
makeRequest({ params: { maxTokens: 70_000, temperature: 0.9, stream: false } }),
);
expect(bodies[0].temperature).toBe(0.9);
// 65536 上限钳制
expect(bodies[0].max_tokens).toBe(65_536);
// v0.8.1: 原样透传,无 65536 钳制
expect(bodies[0].max_tokens).toBe(70_000);
});
});
@@ -912,21 +912,21 @@ describe('MimoAdapter — thinking 显式开关', () => {
expect(bodies[0].top_p).toBeUndefined();
});
it('max_completion_tokens 钳制矩阵(pro 131072 / standard 32768', async () => {
it('max_completion_tokens 原样透传(v0.8.1pro/standard 均不钳制', async () => {
const pro = makeAdapter({ defaultModel: 'mimo-v2.5-pro' });
const std = makeAdapter({ defaultModel: 'mimo-v2.5' });
const { bodies } = captureFetch();
await pro.send(makeRequest({ params: { maxTokens: 200_000, temperature: 0, stream: false } }));
await std.send(makeRequest({ params: { maxTokens: 63_488, temperature: 0, stream: false } }));
expect(bodies[0].max_completion_tokens).toBe(131_072);
expect(bodies[1].max_completion_tokens).toBe(32_768);
expect(bodies[0].max_completion_tokens).toBe(200_000);
expect(bodies[1].max_completion_tokens).toBe(63_488);
});
it('thinking 未关闭时未配置 maxTokens → 兜底 32768(思考占配额,防截断', async () => {
it('thinking 未关闭时未配置 maxTokens → 不下发该字段(v0.8.1:无写死兜底值', async () => {
const pro = makeAdapter({ defaultModel: 'mimo-v2.5-pro' });
const { bodies } = captureFetch();
await pro.send(makeRequest({ params: { temperature: 0, stream: false } }));
expect(bodies[0].max_completion_tokens).toBe(32_768);
expect(bodies[0].max_completion_tokens).toBeUndefined();
});
it('enableWebSearch 且存在客户端 tools → web_search 服务端工具追加(不覆盖客户端工具)', async () => {
@@ -1029,27 +1029,36 @@ describe('OpenAIAdapter — 推理模型字段路由(v0.6.4 P3-1', () => {
expect(bodies[0].reasoning_effort).toBeUndefined();
});
it('gpt-4.1 长上下文 1M → getContextWindow 返回 1M(模型元信息表', () => {
const adapter = makeAdapter('gpt-4.1');
it('gpt-4.1 → getContextWindow 返回设置面板配置值(v0.8.1:元信息不再承载窗口', () => {
const adapter = new OpenAIAdapter({
provider: 'openai',
baseURL: 'http://o.test',
apiKey: 'k',
defaultModel: 'gpt-4.1',
contextWindow: 1_000_000,
});
expect(adapter.getContextWindow()).toBe(1_000_000);
// 未配置 → 0(引擎跳过压缩判定),无任何写死兜底
const noCfg = makeAdapter('gpt-4.1');
expect(noCfg.getContextWindow()).toBe(0);
});
it('o3-mini max_completion_tokens 钳制到 100000', async () => {
it('o3-mini max_completion_tokens 原样透传(v0.8.1:无 100000 钳制)', async () => {
const adapter = makeAdapter('o3-mini');
const { bodies } = captureFetch();
await adapter.send(
makeRequest({ params: { maxTokens: 200_000, temperature: 0, stream: false } }),
);
expect(bodies[0].max_completion_tokens).toBe(100_000);
expect(bodies[0].max_completion_tokens).toBe(200_000);
});
it('gpt-4o max_tokens 钳制到 16384', async () => {
it('gpt-4o max_tokens 原样透传(v0.8.1:无 16384 钳制)', async () => {
const adapter = makeAdapter('gpt-4o');
const { bodies } = captureFetch();
await adapter.send(
makeRequest({ params: { maxTokens: 63_488, temperature: 0, stream: false } }),
);
expect(bodies[0].max_tokens).toBe(16_384);
expect(bodies[0].max_tokens).toBe(63_488);
});
});
@@ -1255,16 +1264,16 @@ describe('OllamaAdapter — options 缺省与工具定义', () => {
// ===== 跨 Provider maxTokens 钳制矩阵 =====
describe('跨 Provider — maxTokens 钳制矩阵汇总', () => {
describe('跨 Provider — maxTokens 透传矩阵汇总v0.8.1 无钳制)', () => {
it.each([
['anthropic', 'claude-opus-4-1', 100_000, 32_000],
['anthropic', 'claude-sonnet-4-5', 100_000, 64_000],
['deepseek', 'deepseek-v4-flash-vision-exp', 100_000, 8_192],
['agnes', 'agnes-2.0-flash', 100_000, 65_536],
['mimo', 'mimo-v2.5', 100_000, 32_768],
['openai', 'gpt-4o', 100_000, 16_384],
['anthropic', 'claude-opus-4-1', 100_000, 100_000],
['anthropic', 'claude-sonnet-4-5', 100_000, 100_000],
['deepseek', 'deepseek-v4-flash-vision-exp', 100_000, 100_000],
['agnes', 'agnes-2.0-flash', 100_000, 100_000],
['mimo', 'mimo-v2.5', 100_000, 100_000],
['openai', 'gpt-4o', 100_000, 100_000],
] as const)(
'%s %s maxTokens=100000 → 钳制为 %d',
'%s %s maxTokens=100000 → 原样透传 %d',
async (provider, model, requested, expected) => {
const adapterMap: Record<string, unknown> = {
anthropic: new AnthropicAdapter({