Files
metona-ai-desktop/electron/services/session.service.ts
T
thzxx 26169b7be4
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m43s
CI / 全量测试 (Electron ABI) (push) Failing after 5m25s
CI / 产物编译验证 (push) Successful in 10m1s
feat: v0.7.2 安全收口 · 断链接线 · 观测补洞 — 230 用例扩充与全量回归
P1 修复面收口: /clear 全链路根治(前端清空联动 DB messages+摘要游标+TRACE 快照,
IPC 语义改"操作完成"; 流式中拒绝); web_browser open 补 SSRF 校验(Chromium 旁路关闭,
与 web_fetch/http_request 同源 validateSSRF); MCP 工具结果纳入注入扫描(mcp_* 前缀
按网络来源同级 full 模式, 收敛 resolveScanMode 单点); Trace 落库/入 store 双重瘦身
(tool_result base64/超长字段剥离, metadata 防 MB 级膨胀); 文本附件 512KB 闸门
(file.slice 首段读取+truncated 标志随消息持久化+主进程附件提示感知截断);
单实例锁(requestSingleInstanceLock + second-instance 聚焦已有窗口)

P2 安全纵深: ConfirmationHook 多窗口化(确认请求/超时提示改全窗口广播,
getAllWindows 空时回退 mainWindow, fail-closed 判定升级双通道); mcp_servers.headers
全链路接线(safeParseHeaders 容错解析+SSE/StreamableHTTP requestInit 注入+IPC 逐项
校验+设置页 JSON 输入, 远程 MCP 鉴权头可用)

P3 断链接线: llm:listModels IPC(六家 adapter 动态模型发现首次接线, 配置完整性
前置校验); Ollama pullModel IPC+设置页下载卡片(进度/取消/能力徽标, v0.7.0 死代码
激活); 后台会话运行指示(sessionRunStates 图+Sidebar 状态点, 多会话并发可见);
IR 卫生(移除 THINKING_START/END 死枚举, constraints 标注预留)

P4 质量与文档: i18n 第二阶段(确认弹框/侧栏/状态栏/AgentMonitor/终止原因出层,
外观设置 zh-CN/en-US 切换, ui.locale 持久化, 渲染时求值规避异步注册); README/D1
文档对齐(http_request 风险等级/用例数/实现状态注记); 版本号 0.7.2

测试: 507 → 737 用例(+230, 11 个新文件)。覆盖补齐: context-builder/consolidator/
orchestrator/workspace.service/session-recorder/config-layering/secure-config/
network-proxy + IPC mcp/tasks/memory/app/data 域 + 渲染层 store 与流事件管线纯函数。
测试驱动修复: workspace.appendMemory 中文分区 \b 词边界失效(JS \b 不含 CJK),
固化条目恒追加文件末尾产生重复分区头 → (?=\n|$) 前瞻断言根治

回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 687 通过 50 跳过;
Electron ABI 全量 737/737 零跳过
2026-08-30 00:09:25 +08:00

568 lines
18 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 FROM messages WHERE session_id = ? AND id = ?')
.get(sessionId, messageId) as { rid: 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));
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);
}
/**
* 删除一条消息
*/
deleteMessage(messageId: string): boolean {
const db = this.getDBFn();
const result = db.prepare('DELETE FROM messages WHERE id = ?').run(messageId);
return result.changes > 0;
}
/**
* 清空会话所有消息
*
* 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;
}
}
}