Files
metona-ai-desktop/electron/services/__tests__/session-summary.test.ts
T
thzxx 4ee5100661
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m47s
CI / 全量测试 (Electron ABI) (push) Failing after 5m25s
CI / 产物编译验证 (push) Successful in 10m4s
feat: v0.5.4 多模态增强 — 多轮图片记忆 + 多模态总开关 + DeepSeek vision 模型支持
多轮图片记忆:
- 此前历史轮次的图片不回传 LLM(attachments 仅存压缩 preview,历史组装
  时被丢弃)— 跨轮对话中模型对图片内容"失忆"
- 修复:SessionSummaryService.buildHistoryMessages 从持久化的 attachments
  恢复 images(type=image 的 preview base64),历史图片随上下文回传
- Token 控制:最多注入最近 10 张(MAX_HISTORY_IMAGES,从最新消息向前
  收集)— 每张 1024px 压缩图约数百至千余 token,无上限会吃满上下文
- 摘要区间(summarizedUntilRowid 之前)的图片不恢复,符合滚动摘要语义

多模态总开关 llm.multimodalEnabled(默认关闭):
- 新配置项:CONFIG_DEFAULTS 种子 + 设置弹框 LLM 配置 Switch +
  首次引导向导 LLM 步骤 Switch(含说明文案)
- 上传入口双重判断:总开关 × 模型能力 — 未开启时即使模型支持多模态
  也不能上传图片(ChatInput 的选择/拖拽/粘贴统一拦截,Toast 区分
  "开关未开启"与"当前模型不支持"两种原因)
- 保存成功后同步 Agent Store 立即生效;App 启动时随 setProvider 加载

DeepSeek vision 模型支持:
- 新增 deepseek-v4-flash-vision-exp(OpenAI image_url content parts 格式,
  128K 上下文 / 8K 输出)
- adapter 按 isVisionModel() 判断:vision 模型将带 images 的消息转换为
  [{type:'text'},{type:'image_url'}] parts;非 vision 模型保持 images
  静默丢弃(防 API 400)

测试(236 → 243 用例):
- 多轮图片记忆 ×3(session-summary.test.ts):历史 attachments 恢复
  images / 上限 10 张从最新向前 / 摘要区间图片不恢复
- DeepSeek vision 请求格式 ×4(deepseek-vision.test.ts,契约级 mock
  fetch 断言请求体):image_url parts 转换 / 非 vision 模型丢弃 /
  max_tokens 钳制 8192 / 无图不转换
- 测试顺序修正:多轮图片用例置于 describe 末尾(插入新行消耗全局自增
  rowid,插在中间会破坏既有用例对 rowid 数值的断言)

文档: README 同步(DeepSeek 模型表 + vision 多模态列、llm.multimodalEnabled
配置项、多轮图片记忆特性行、243 用例数)

验证: lint 0 / typecheck 双工程 0 / test:electron 243 全过 / build 成功
2026-08-21 23:00:20 +08:00

258 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* SessionSummaryService + truncateMessagesAfter 交互回归测试
*
* 覆盖全量复检 #1 修复:消息截断必须同步清理 session_summaries 摘要游标,
* 否则 (1) afterRowid 过滤返回空 tail 导致截断点前原文永不加载;
* (2) 摘要包含被撤销的"未来"内容(因果污染)。
*
* 运行要求:better-sqlite3 为 Electron ABI 构建,需 test:electron 模式
* cross-env ELECTRON_RUN_AS_NODE=1 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() },
}));
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
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)('SessionSummary 分层上下文 × 截断交互', () => {
let db: any;
let dir: string;
let sessionService: any;
let summaryService: any;
const insertMessage = (sessionId: string, id: string, role: string, content: string): void => {
db.prepare(
`INSERT INTO messages (id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`,
).run(id, sessionId, role, content, Date.now());
};
beforeAll(async () => {
const { SessionService } = await import('../session.service');
const { SessionSummaryService } = await import('../session-summary.service');
dir = mkdtempSync(join(tmpdir(), 'metona-summary-'));
db = new Database(':memory:');
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,
pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
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
);
CREATE TABLE session_summaries (
session_id TEXT PRIMARY KEY,
summary TEXT NOT NULL,
summarized_until_rowid INTEGER NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
`);
db.prepare(
'INSERT INTO sessions (id, created_at, updated_at, message_count) VALUES (?, ?, ?, ?)',
).run('s_test', Date.now(), Date.now(), 0);
sessionService = new SessionService(() => db);
summaryService = new SessionSummaryService(
() => db,
sessionService,
// buildHistoryMessages 不触 adapter(仅 maybeSummarize 用),占位即可
() => null as unknown as Parameters<typeof Object>[0],
);
});
afterAll(() => {
try {
db?.close();
} catch {
/* ignore */
}
rmSync(dir, { recursive: true, force: true });
});
it('分层加载:摘要存在时返回 [摘要消息, 游标之后的原文]', () => {
// 5 条消息,摘要覆盖到第 3 条(rowid 3)
for (let i = 1; i <= 5; i++) {
insertMessage('s_test', `m${i}`, i % 2 === 1 ? 'user' : 'assistant', `消息 ${i}`);
}
summaryService.saveSummary('s_test', '早期对话的滚动摘要', 3);
const history = summaryService.buildHistoryMessages('s_test');
// 1 条摘要 + rowid 4、5 两条原文
expect(history.length).toBe(3);
expect((history[0].content as string).startsWith('[Context Summary]')).toBe(true);
expect(history[1].content).toBe('消息 4');
expect(history[2].content).toBe('消息 5');
});
it('其他会话的摘要不影响本会话(游标按 session_id 隔离)', () => {
// s_other 的摘要不应影响 s_test 的分层加载结果
summaryService.saveSummary('s_other', 'x', 0);
const history = summaryService.buildHistoryMessages('s_test');
// s_test 摘要(游标 3)仍生效:摘要 + rowid 4、5
expect(history.length).toBe(3);
});
// ===== v0.5.4: 多轮图片记忆用例置于 describe 末尾(插入新行消耗全局自增 rowid,
// 若插在中间会破坏 inclusive=false 用例对 rowid 数值的断言) =====
it('截断清理摘要(缺陷 #1 回归):游标落在删除范围内时摘要被删', () => {
// 编辑重发第 2 条消息(rowid 2inclusive)→ 删除 rowid>=2 的 4 条
// 摘要游标为 3,落在 [2, ∞) 内 → 摘要必须被清理
const truncated = sessionService.truncateMessagesAfter('s_test', 'm2', true);
expect(truncated).toBe(true);
// 摘要已删除
expect(summaryService.getSummary('s_test')).toBeNull();
// 只剩 rowid 1 的消息
const remaining = sessionService.getMessages('s_test');
expect(remaining.length).toBe(1);
expect(remaining[0].content).toBe('消息 1');
// 分层加载退化为全量原文(无摘要注入)
const history = summaryService.buildHistoryMessages('s_test');
expect(history.length).toBe(1);
expect(history[0].content).toBe('消息 1');
});
it('inclusive=false:游标等于锚点时摘要保留(语义仍一致)', () => {
// 重建场景:rowid 1(消息1+ 新插入 3 条 → rowid 2-4;摘要游标 = 2
insertMessage('s_test', 'm2b', 'user', '消息 2b');
insertMessage('s_test', 'm3b', 'assistant', '消息 3b');
insertMessage('s_test', 'm4b', 'user', '消息 4b');
summaryService.saveSummary('s_test', '摘要到 rowid2', 2);
// 截断 rowid > 3(保留锚点 3):游标 2 < 3+1 → 摘要保留
const truncated = sessionService.truncateMessagesAfter('s_test', 'm3b', false);
expect(truncated).toBe(true);
expect(summaryService.getSummary('s_test')).not.toBeNull();
// 分层加载:摘要 + rowid 3 原文(rowid 4 已删)
const history = summaryService.buildHistoryMessages('s_test');
expect(history.length).toBe(2);
expect(history[1].content).toBe('消息 3b');
});
it('锚点消息不存在时返回 false 且不产生副作用', () => {
const before = sessionService.getMessages('s_test').length;
expect(sessionService.truncateMessagesAfter('s_test', 'not_exist', true)).toBe(false);
expect(sessionService.getMessages('s_test').length).toBe(before);
});
/** 插入带 attachments 的用户消息(模拟 ChatInput 持久化形态) */
const insertMessageWithAttachments = (
sessionId: string,
id: string,
content: string,
attachments: Array<{ type: string; preview?: string; textContent?: string }>,
): void => {
db.prepare(
`INSERT INTO messages (id, session_id, role, content, attachments, created_at) VALUES (?, ?, 'user', ?, ?, ?)`,
).run(id, sessionId, content, JSON.stringify(attachments), Date.now());
};
it('多轮图片记忆:历史消息的图片 attachments 恢复为 images 回传', () => {
db.prepare(`DELETE FROM messages WHERE session_id = 's_img'`).run();
db.prepare(
`INSERT INTO sessions (id, created_at, updated_at, message_count) VALUES ('s_img', ?, ?, 0)`,
).run(Date.now(), Date.now());
insertMessageWithAttachments('s_img', 'img_m1', '第一轮带图', [
{ type: 'image', preview: 'data:image/jpeg;base64,ROUND1PIC' },
]);
insertMessageWithAttachments('s_img', 'img_m2', '第二轮无图', []);
insertMessageWithAttachments('s_img', 'img_m3', '第三轮带图', [
{ type: 'image', preview: 'data:image/jpeg;base64,ROUND3PIC' },
{ type: 'text', textContent: '文本附件不恢复为图片' },
]);
const history = summaryService.buildHistoryMessages('s_img');
// 第一轮的图片仍被恢复(多轮记忆修复点:此前历史消息 images 恒为 undefined
const round1 = history.find((m: any) => m.content === '第一轮带图');
expect(round1?.images).toEqual([{ url: 'data:image/jpeg;base64,ROUND1PIC', detail: 'auto' }]);
// 第三轮两张附件只恢复 image 类型
const round3 = history.find((m: any) => m.content === '第三轮带图');
expect(round3?.images).toEqual([{ url: 'data:image/jpeg;base64,ROUND3PIC', detail: 'auto' }]);
// 无图消息不带 images 字段
const round2 = history.find((m: any) => m.content === '第二轮无图');
expect(round2?.images).toBeUndefined();
});
it('多轮图片记忆:注入上限 10 张(从最新向前收集,更早的丢弃)', () => {
db.prepare(`DELETE FROM messages WHERE session_id = 's_cap'`).run();
db.prepare(
`INSERT INTO sessions (id, created_at, updated_at, message_count) VALUES ('s_cap', ?, ?, 0)`,
).run(Date.now(), Date.now());
// 12 条带图消息(每条 1 张)— 上限 10 张,最早的 2 条不注入
for (let i = 1; i <= 12; i++) {
insertMessageWithAttachments('s_cap', `cap_m${i}`, `第 ${i} 轮`, [
{ type: 'image', preview: `data:image/jpeg;base64,PIC${i}` },
]);
}
const history = summaryService.buildHistoryMessages('s_cap');
const withImages = history.filter((m: any) => m.images?.length > 0);
expect(withImages).toHaveLength(10);
// 保留的是第 3~12 轮(最新的 10 张)
const contents = withImages.map((m: any) => m.content);
expect(contents).not.toContain('第 1 轮');
expect(contents).not.toContain('第 2 轮');
expect(contents).toContain('第 12 轮');
});
it('多轮图片记忆:摘要区间内的图片不恢复(符合滚动摘要语义)', () => {
db.prepare(`DELETE FROM messages WHERE session_id = 's_sum_img'`).run();
db.prepare(
`INSERT INTO sessions (id, created_at, updated_at, message_count) VALUES ('s_sum_img', ?, ?, 0)`,
).run(Date.now(), Date.now());
for (let i = 1; i <= 3; i++) {
insertMessageWithAttachments('s_sum_img', `sm${i}`, `摘要区间第 ${i} 条`, [
{ type: 'image', preview: `data:image/jpeg;base64,OLDPIC${i}` },
]);
}
// 摘要覆盖前 3 条(含全部图片消息)
const cursor = (
db.prepare(`SELECT MAX(rowid) AS r FROM messages WHERE session_id = 's_sum_img'`).get() as {
r: number;
}
).r;
summaryService.saveSummary('s_sum_img', '早期含图对话的摘要', cursor);
insertMessageWithAttachments('s_sum_img', 'sm4', '摘要后的新消息(无图)', []);
const history = summaryService.buildHistoryMessages('s_sum_img');
// 摘要消息 + 1 条原文,均无图片(游标前的图片消息不在 tail 中)
expect(history).toHaveLength(2);
expect(history.every((m: any) => !m.images?.length)).toBe(true);
});
});