Files
metona-ai-desktop/electron/services/session.service.ts
T
thzxx ebe45482b0
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m47s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s
CI / 产物编译验证 (push) Successful in 9m55s
feat: v0.7.3 成本收口 · 状态一致 · 死账清理 — Prompt Cache 根治 + SSRF DNS Pinning + 87 用例扩充全量回归
P1 修复面收口: Prompt Cache 根治(日期/记忆/附件三类易变内容出 system 入用户消息
  前置块 user-context.ts, system 跨 run 字节级稳定; Anthropic system 块数组化 +
  cache_control ephemeral 断言, DeepSeek 自动缓存前缀命中 — 多轮对话输入 token
  成本降数量级); 编辑重发/重新生成幽灵 Trace 双侧根治(DB truncateMessagesAfter
  同步过滤 metadata.traceSteps + 前端 trimTraceStepsByAnchor 镜像, 严格小于锚点
  时间戳, 同毫秒等值判废); sessions:deleteMessage 死通道全链路删除(渲染层零调用
  + message_count 漂移面); Ollama vision 能力门控全链路(MetonaModelInfo
  .supportsVision 贯穿 adapter/IPC/store/UI, model-capabilities.ts 三道判定纯函数,
  未知保守放行); 记忆固化节流(consolidation-policy 纯函数: 总开关 + 内容门控
  [回答>=200字符或存在成功工具调用] + 会话级 10 分钟频率窗口, 三 memory.* 配置键)

P2 安全纵深: SSRF DNS Pinning 关闭 rebinding 窗口(ssrf-guard 重构
  resolvePublicAddresses 单源; ssrf-dispatcher 以 undici Agent.connect.lookup
  钉死校验 IP, TLS SNI 保持原域名, 一次性 dispatcher 用后即毁; 代理激活显式
  退化为仅入口校验); web_fetch 重写手动逐跳重定向循环(每跳先校验后连接,
  替代 redirect:follow 内核跟跳的中间跳裸奔, 上限 5 跳); http_request 换用
  pinned fetch; web_search 可达性预检加固(私有 URL 零请求 + 不跟跳, 3xx 视为
  可达); Agent 浏览器 CORS 通配收紧为 Origin 回显 + Vary: Origin;
  ConfirmationHook.forgetSession 会话终态清理(会话删除/abort 联动/SubAgent
  终结三处接线, 根治 rememberedDecisions 泄漏)

P3 架构还债: agent.enableReflection 死配置全链路接线(main→shared→引擎→
  Orchestrator→设置开关, REFLECTING 状态真实可达); AgentLoopConfig.timeoutMs
  死字段删除; MemoryManager.cleanupExpired 挂入健康检查周期(expires_at 回收
  管道真实化); buildSafeEnv 收敛 utils/safe-env.ts 单源(run_command 与 MCP
  stdio 共用, 终结双实现漂移); Trace 生命周期治理(metadata 只保留最近 20 个
  run — keepRecentRuns 纯函数; JSONL 录制启动自动清理保留 200 个 + 设置页
  手动清理); SLO/健康快照可视化(app:healthSnapshot IPC + 设置页只读卡片 +
  审计链一键校验)

P4 能力演进: 会话标题 LLM 自动生成(TitleGenerator — 每会话幂等/并发重入复用
  同一 Promise/自定义标题不覆盖/失败静默回退, Sidebar 经 config:changed 实时
  刷新); MCP 自动重连(5s/15s/60s 退避最多 3 次, reconnecting 状态机,
  teardownConnection 内部拆除保留簿记 — 用户断开/开关关闭即时取消, 设置页
  显示第 N/3 次); 死循环检测 ABAB 乒乓模式(最近4轮 A→B→A→B 交替判定, 补齐
  docs 第五章"两状态反复切换"检测契约); i18n 第三阶段(ChatInput/LLMSettings/
  OnboardingWizard/MemoryViewer 主链路文案出层, zh-CN + en-US 双字典补齐)

测试: 737 → 824 用例(+87, 新增 8 个测试文件 + 扩展 3 个)。新覆盖: user-context
  分组/空值收缩/拼接契约、context-builder 字节级稳定性、Anthropic cache_control
  四态、consolidation-policy 九路判定矩阵、ssrf-dispatcher(pinned lookup/重定向
  解析/IP 校验)、forget-session 会话隔离、trace-lifecycle run 淘汰、
  trace-trim 严格小于边界、safe-env 净化矩阵、mcp-reconnect 退避状态机
  (fake timers)、title-generator 并发重入、SQLite 侧 truncate×TRACE 联动
  (Electron ABI)。测试驱动修复: GIT_*/ 注释终止块注释、重连计数被自身重试
  前置断开重置(拆 teardownConnection 保留簿记)、TitleGenerator 幂等占位与
  并发去重的检查顺序竞态(去重先于幂等)

版本: 0.7.3; README 同步(配置表新增 agent.enableReflection/memory.*/mcp.autoReconnect)

回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 771 通过 53 跳过
  (better-sqlite3 ABI); Electron ABI 全量 824/824 零跳过
2026-08-30 09:44:43 +08:00

603 lines
19 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.
/**
* Session Service — 会话 CRUD + 消息持久化
*
* 管理会话的创建、查询、更新、删除,以及消息的存取。
* 所有操作通过 better-sqlite3 同步执行。
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html
*/
import { nanoid } from 'nanoid';
import type Database from 'better-sqlite3';
import log from 'electron-log';
// ===== 类型定义 =====
export interface SessionRow {
id: string;
title: string;
created_at: number;
updated_at: number;
message_count: number;
total_tokens: number;
pinned: number;
archived: number;
metadata: string;
}
export interface MessageRow {
id: string;
/** P2: rowid(插入序号,消息截断与分层加载的游标) */
row_id?: number;
session_id: string;
role: string;
// C-6 修复: content 允许 null — assistant 消息仅有 tool_calls 时为 null
content: string | null;
reasoning_content: string | null;
tool_calls: string | null;
tool_result: string | null;
attachments: string | null;
iteration: number | null;
created_at: number;
}
export interface SessionInfo {
id: string;
title: string;
createdAt: number;
updatedAt: number;
messageCount: number;
totalTokens: number;
pinned: boolean;
archived: boolean;
}
export interface MessageInfo {
id: string;
/** P2: rowid(插入序号;会话摘要分层加载与消息截断的游标) */
rowId?: number;
role: string;
// C-6 修复: content 允许 null — assistant 消息仅有 tool_calls 时为 null
content: string | null;
reasoningContent?: string;
toolCalls?: unknown[];
toolResult?: unknown;
attachments?: unknown[];
iteration?: number;
timestamp: number;
}
// ===== 服务类 =====
export class SessionService {
/**
* 获取数据库实例
*/
getDB(): Database.Database {
return this.getDBFn();
}
constructor(private getDBFn: () => Database.Database) {}
/**
* 列出所有会话
*/
list(options?: { archived?: boolean }): SessionInfo[] {
const db = this.getDBFn();
const archived = options?.archived ?? false;
const rows = db
.prepare(
`
SELECT * FROM sessions
WHERE archived = ?
ORDER BY pinned DESC, updated_at DESC
`,
)
.all(archived ? 1 : 0) as SessionRow[];
return rows.map((row) => this.toSessionInfo(row));
}
/**
* 创建新会话
*/
create(title?: string): SessionInfo {
const db = this.getDBFn();
const id = `s_${nanoid(12)}`;
const now = Date.now();
const sessionTitle = title ?? '新会话';
db.prepare(
`
INSERT INTO sessions (id, title, created_at, updated_at)
VALUES (?, ?, ?, ?)
`,
).run(id, sessionTitle, now, now);
log.info(`Session created: ${id} (${sessionTitle})`);
return {
id,
title: sessionTitle,
createdAt: now,
updatedAt: now,
messageCount: 0,
totalTokens: 0,
pinned: false,
archived: false,
};
}
/**
* 重命名会话
*/
rename(sessionId: string, title: string): boolean {
const db = this.getDBFn();
const result = db
.prepare(
`
UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?
`,
)
.run(title, Date.now(), sessionId);
if (result.changes > 0) {
log.info(`Session renamed: ${sessionId}${title}`);
return true;
}
return false;
}
/**
* 删除会话(级联删除消息)
*/
delete(sessionId: string): boolean {
const db = this.getDBFn();
const result = db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId);
if (result.changes > 0) {
log.info(`Session deleted: ${sessionId}`);
return true;
}
return false;
}
/**
* 置顶/取消置顶
*/
pin(sessionId: string, pinned: boolean): boolean {
const db = this.getDBFn();
const result = db
.prepare(
`
UPDATE sessions SET pinned = ?, updated_at = ? WHERE id = ?
`,
)
.run(pinned ? 1 : 0, Date.now(), sessionId);
return result.changes > 0;
}
/**
* 归档/取消归档
*/
archive(sessionId: string, archived: boolean): boolean {
const db = this.getDBFn();
const result = db
.prepare(
`
UPDATE sessions SET archived = ?, updated_at = ? WHERE id = ?
`,
)
.run(archived ? 1 : 0, Date.now(), sessionId);
return result.changes > 0;
}
/**
* 获取会话消息列表
*
* #44 修复: 添加 limit/offset 参数支持分页,避免超长会话一次性加载导致 OOM
* 默认不限制(limit=0),保持向后兼容;调用者可传 limit 限制返回条数
* P2-11: 新增 afterRowid 参数——仅返回 rowid 大于该值的消息(分层上下文加载游标);
* 排序改用 rowid(插入序号),与截断/摘要游标语义一致
*/
getMessages(
sessionId: string,
options: { limit?: number; offset?: number; afterRowid?: number } = {},
): MessageInfo[] {
const db = this.getDBFn();
const { limit = 0, offset = 0, afterRowid = 0 } = options;
let sql = 'SELECT rowid AS row_id, * FROM messages WHERE session_id = ?';
const params: unknown[] = [sessionId];
if (afterRowid > 0) {
sql += ' AND rowid > ?';
params.push(afterRowid);
}
sql += ' ORDER BY rowid ASC';
if (limit > 0) {
sql += ' LIMIT ? OFFSET ?';
params.push(limit, offset);
}
const rows = db.prepare(sql).all(...params) as MessageRow[];
return rows.map((row) => this.toMessageInfo(row));
}
/**
* P2-11: 截断消息——删除指定消息(含/不含)之后的所有消息
*
* 用途:
* - 编辑重发:删除原用户消息及其后所有消息(inclusive=true),重新发送修订版
* - 重新生成:删除最后一条用户消息之后的所有回复(inclusive=true 于该用户消息)
*
* 审查修复(全量复检 #1: 同步清理 session_summaries 摘要游标。
* 若摘要游标(summarized_until_rowid)落在被删除范围内而不清理,会导致两个缺陷:
* 1. buildHistoryMessages 的 afterRowid 过滤返回空 tail —— 截断点之前的原文永远不加载
* 2. 摘要内容包含已被撤销的消息("未来"内容因果污染——用户回退历史但 LLM 仍记得)
*
* @param sessionId 会话 ID
* @param messageId 锚点消息 ID
* @param inclusive true=连同锚点消息一起删除;false=仅删除其后消息
* @returns 是否有消息被删除
*/
truncateMessagesAfter(sessionId: string, messageId: string, inclusive = true): boolean {
const db = this.getDBFn();
const op = inclusive ? '>=' : '>';
// 先查锚点 rowid 与时间戳(删除后无法再定位)
const anchor = db
.prepare(
'SELECT rowid AS rid, created_at AS ts FROM messages WHERE session_id = ? AND id = ?',
)
.get(sessionId, messageId) as { rid: number; ts: number } | undefined;
if (!anchor) return false;
const result = db
.prepare(`DELETE FROM messages WHERE session_id = ? AND rowid ${op} ?`)
.run(sessionId, anchor.rid);
if (result.changes > 0) {
// 同步修正会话消息计数(避免侧栏计数与实际不一致)
db.prepare(
`UPDATE sessions SET message_count = (SELECT COUNT(*) FROM messages WHERE session_id = ?), updated_at = ? WHERE id = ?`,
).run(sessionId, Date.now(), sessionId);
// 摘要游标清理:游标覆盖到被删除范围即删摘要(下次消息量达标后由 maybeSummarize 重建)
// - inclusive=true:锚点本身被删,游标 >= 锚点即视为被覆盖
// - inclusive=false:锚点保留,游标 > 锚点才被覆盖(游标==锚点时摘要与现存消息仍一致)
db.prepare(
'DELETE FROM session_summaries WHERE session_id = ? AND summarized_until_rowid >= ?',
).run(sessionId, anchor.rid + (inclusive ? 0 : 1));
// v0.7.3 P1-2 根治: 同步截断 metadata 中的 TRACE 步骤。
// 此前编辑重发/重新生成只删消息 —— traceSteps 残留,Trace 面板出现
// "幽灵步骤"(与 /clear 的 metadata 残留同类,v0.7.2 A1 只修了 clear 路径)。
// 截断语义:锚点消息(inclusive 时含锚点)触发的 run 及其之后全部作废 ——
// 按 startedAt < 锚点消息 created_at 过滤保留更早 run 的步骤(严格小于:
// 锚点消息触发的 run 与消息同毫秒落库,等值属于"锚点侧"必须丢弃 ——
// 宁可多删一个边界步骤也不留幽灵步骤)。tokenUsage 为最近一次 run 的
// 累计展示值,紧随其后的重发 run 会重写,无需修正。
try {
const row = db.prepare('SELECT metadata FROM sessions WHERE id = ?').get(sessionId) as
| { metadata: string }
| undefined;
if (row?.metadata) {
const data = JSON.parse(row.metadata) as {
traceSteps?: Array<{ startedAt?: number }>;
tokenUsage?: unknown;
};
if (Array.isArray(data.traceSteps)) {
const kept = data.traceSteps.filter(
(s) => typeof s?.startedAt !== 'number' || s.startedAt < anchor.ts,
);
if (kept.length !== data.traceSteps.length) {
data.traceSteps = kept;
db.prepare('UPDATE sessions SET metadata = ? WHERE id = ?').run(
JSON.stringify(data),
sessionId,
);
}
}
}
} catch {
// metadata 解析失败不阻断截断主流程(与截断语义无耦合)
}
log.info(
`Session truncated: ${sessionId} (${result.changes} messages removed after ${messageId})`,
);
}
return result.changes > 0;
}
/**
* 保存一条消息
*/
saveMessage(params: {
sessionId: string;
role: string;
// C-6 修复: content 允许 null — assistant 消息仅有 tool_calls 时为 null
content: string | null;
reasoningContent?: string;
toolCalls?: unknown[];
toolResult?: unknown;
attachments?: unknown[];
iteration?: number;
}): MessageInfo {
const db = this.getDBFn();
const id = `msg_${nanoid(12)}`;
const now = Date.now();
// #34 修复: 包裹事务保证 INSERT messages 和 UPDATE sessions 原子执行
// message_count = message_count + 1 已是原子 SQL 表达式(避免读-改-写竞态)
// 事务进一步保证消息插入和计数更新要么全部成功,要么全部回滚
const saveMessageTxn = db.transaction(() => {
db.prepare(
`
INSERT INTO messages (id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
id,
params.sessionId,
params.role,
params.content,
params.reasoningContent ?? null,
params.toolCalls ? JSON.stringify(params.toolCalls) : null,
params.toolResult ? JSON.stringify(params.toolResult) : null,
params.attachments ? JSON.stringify(params.attachments) : null,
params.iteration ?? null,
now,
);
// 更新会话的 updated_at 和 message_count
db.prepare(
`
UPDATE sessions
SET updated_at = ?, message_count = message_count + 1
WHERE id = ?
`,
).run(now, params.sessionId);
});
saveMessageTxn();
return {
id,
role: params.role,
content: params.content,
reasoningContent: params.reasoningContent,
toolCalls: params.toolCalls,
toolResult: params.toolResult,
attachments: params.attachments,
iteration: params.iteration,
timestamp: now,
};
}
/**
* 更新会话 Token 统计
*/
updateTokenUsage(sessionId: string, tokens: number): void {
const db = this.getDBFn();
db.prepare(
`
UPDATE sessions SET total_tokens = total_tokens + ? WHERE id = ?
`,
).run(tokens, sessionId);
}
/**
* 删除一条消息
*
* v0.7.3 P1-3: 随 sessions:deleteMessage 死通道一并移除 —— 该方法不回减
* sessions.message_countsaveMessage 加、删除不加的计数漂移面),且渲染层
* 从未有调用方。消息删除语义由 truncateMessagesAfter(含计数修正)覆盖。
*/
/**
* 清空会话所有消息
*
* v0.6.4 修复(B-2): 必须同步删除该会话的滚动摘要记录。
*
* 根因:messages 表使用隐式 rowid(非 AUTOINCREMENT),全表 DELETE 后
* 新插入消息的 rowid 从 1 重新分配。若仅清空消息而保留 session_summaries 中
* 的 summarized_until_rowid 游标(例如 =500),后续 buildHistoryMessages 的
* `rowid > 500` 过滤会让所有新消息全部落空 —— 新对话永远不进入 LLM 历史;
* maybeSummarize 的 `rowid > lastSummarized` 增量判定也长期误报"无增量"。
* 对比 truncateMessagesAfter(本文件 269-271 一带)早已做了游标清理,
* 此处是同一契约的遗漏点。删除摘要后游标从零重建,历史分层自然复位。
*/
clearMessages(sessionId: string): void {
const db = this.getDBFn();
db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId);
// B-2: 同步清除滚动摘要(含 summarized_until_rowid 游标)
db.prepare('DELETE FROM session_summaries WHERE session_id = ?').run(sessionId);
// v0.7.2 A1 根治: 同步重置 metadata 中的 TRACE 快照(traceSteps/tokenUsage)。
// 此前只删 messages —— 前端"清空会话"后 traceSteps 残留,切换会话再切回时
// getTrace 仍返回旧 TraceTrace 面板出现"幽灵步骤"。
db.prepare(
"UPDATE sessions SET message_count = 0, metadata = '{}', updated_at = ? WHERE id = ?",
).run(Date.now(), sessionId);
}
/**
* v0.5.0: 全文搜索会话内容(FTS5
*
* 跨会话搜索消息内容,按会话聚合返回(每个会话取最新一条匹配 + 匹配总数)。
* 查询词按 FTS5 短语语法转义(内部双引号翻倍 + 整体加引号),防止用户输入的
* FTS 运算符(AND/OR/NOT/*)导致语法错误或语义偏移。
*
* @param query 搜索关键词
* @param options.maxSessions 返回的会话数上限(默认 20)
* @param options.scanLimit 匹配行扫描上限(默认 200,避免超大结果集拖慢聚合)
*/
searchMessages(
query: string,
options: { maxSessions?: number; scanLimit?: number } = {},
): Array<{
sessionId: string;
sessionTitle: string;
matchCount: number;
lastMessageId: string;
lastRole: string;
snippet: string;
timestamp: number;
}> {
const db = this.getDBFn();
const { maxSessions = 20, scanLimit = 200 } = options;
const trimmed = query.trim();
if (!trimmed) return [];
// FTS5 短语转义:内部双引号翻倍,整体作为带引号短语匹配
const phrase = `"${trimmed.replace(/"/g, '""')}"`;
interface MatchRow {
id: string;
session_id: string;
role: string;
created_at: number;
snip: string;
}
let rows: MatchRow[];
try {
rows = db
.prepare(
`
SELECT m.id, m.session_id, m.role, m.created_at,
snippet(messages_fts, 0, '[', '] ', ' … ', 12) AS snip
FROM messages_fts f
JOIN messages m ON m.rowid = f.rowid
WHERE messages_fts MATCH ?
ORDER BY m.created_at DESC
LIMIT ?
`,
)
.all(phrase, scanLimit) as MatchRow[];
} catch (err) {
log.warn('[SessionService] FTS search failed:', (err as Error).message);
return [];
}
// 按会话聚合:取每个会话的最新匹配(rows 已按时间倒序,首次出现即最新)
const aggregated = new Map<
string,
{
matchCount: number;
lastMessageId: string;
lastRole: string;
snippet: string;
timestamp: number;
}
>();
for (const row of rows) {
const existing = aggregated.get(row.session_id);
if (existing) {
existing.matchCount++;
} else {
aggregated.set(row.session_id, {
matchCount: 1,
lastMessageId: row.id,
lastRole: row.role,
snippet: row.snip,
timestamp: row.created_at,
});
}
}
if (aggregated.size === 0) return [];
// 补全会话标题(archived 一起搜——搜索不应因归档而缺结果)
const sessionIds = Array.from(aggregated.keys());
const placeholders = sessionIds.map(() => '?').join(',');
const titleRows = db
.prepare(`SELECT id, title FROM sessions WHERE id IN (${placeholders})`)
.all(...sessionIds) as Array<{ id: string; title: string }>;
const titleMap = new Map(titleRows.map((r) => [r.id, r.title]));
return Array.from(aggregated.entries())
.map(([sessionId, info]) => ({
sessionId,
sessionTitle: titleMap.get(sessionId) ?? '(已删除会话)',
...info,
}))
.slice(0, maxSessions);
}
/**
* 保存会话的 trace 步骤和 token 用量(存入 metadata JSON
*/
saveTraceData(sessionId: string, data: { traceSteps: unknown[]; tokenUsage: unknown }): void {
const db = this.getDBFn();
const metadata = JSON.stringify({ traceSteps: data.traceSteps, tokenUsage: data.tokenUsage });
db.prepare(
`
UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?
`,
).run(metadata, Date.now(), sessionId);
}
/**
* 加载会话的 trace 步骤和 token 用量
*/
getTraceData(sessionId: string): { traceSteps: unknown[]; tokenUsage: unknown } | null {
const db = this.getDBFn();
const row = db.prepare('SELECT metadata FROM sessions WHERE id = ?').get(sessionId) as
| { metadata: string }
| undefined;
if (!row?.metadata) return null;
try {
const data = JSON.parse(row.metadata);
if (data.traceSteps || data.tokenUsage) return data;
return null;
} catch {
return null;
}
}
// ===== 私有转换方法 =====
private toSessionInfo(row: SessionRow): SessionInfo {
return {
id: row.id,
title: row.title,
createdAt: row.created_at,
updatedAt: row.updated_at,
messageCount: row.message_count,
totalTokens: row.total_tokens,
pinned: row.pinned === 1,
archived: row.archived === 1,
};
}
private toMessageInfo(row: MessageRow): MessageInfo {
// M-55 修复: 运行时收窄,防止数据库被篡改或老版本数据格式不一致导致下游 .map/.length 崩溃
const parsedToolCalls = row.tool_calls ? this.safeJsonParse(row.tool_calls) : undefined;
const parsedAttachments = row.attachments ? this.safeJsonParse(row.attachments) : undefined;
return {
id: row.id,
rowId: row.row_id,
role: row.role,
content: row.content,
reasoningContent: row.reasoning_content ?? undefined,
toolCalls: Array.isArray(parsedToolCalls) ? parsedToolCalls : undefined,
toolResult: row.tool_result ? this.safeJsonParse(row.tool_result) : undefined,
attachments: Array.isArray(parsedAttachments) ? parsedAttachments : undefined,
iteration: row.iteration ?? undefined,
timestamp: row.created_at,
};
}
/**
* 安全 JSON 解析:解析失败时返回 undefined 而非抛出异常
*/
private safeJsonParse(json: string): unknown {
try {
return JSON.parse(json);
} catch {
return undefined;
}
}
}