Files
metona-ai-desktop/electron/services/session.service.ts
T
thzxx 3940716dc2
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m45s
CI / 全量测试 (Electron ABI) (push) Failing after 5m22s
CI / 产物编译验证 (push) Successful in 10m3s
feat: v0.7.0 四阶段全量迭代 — 修复面收口 · 安全纵深 · 架构还债 · 能力演进
P1 修复面收口: v0.6.3 截断自愈推全量(Anthropic/Ollama/非流式/引擎兜底); SSE 上游错误帧检测进重试通道;
clearMessages 摘要游标根治; truncateResult 内联图片白名单统一; 前端四 bug(确认弹窗锁死/MemoryViewer/
Virtuoso Footer/abort 尾部过滤) + reasoning 缓冲跨迭代污染; 托盘通知过滤与新建会话死链接线

P2 安全纵深: MCP 审批闭环(ConfirmationHook×PolicyEngine 联动+重名拒注册); SSRF 收敛 ssrf-guard 共享模块
(web_fetch 双通道校验+重定向终态复检); Electron 加固(preload CJS 化→sandbox:true/CSP/权限白名单/will-navigate);
run_command cmd.exe 白名单通道元字符守门; diff_viewer 10MB 预检; Anthropic thinking 预算下限; Agnes 思考显式关闭

P3 架构还债: OpenAICompatibleAdapter 中间基类收敛四家样板; 错误分类单轨化(删 mapError/getFetchSignal,
超时显式 ETIMEDOUT); PRAGMA user_version 迁移版本化; 死代码清理专项(cn.ts/SHORTCUTS/ContextMenu 分支/
getWindowState/modifiedArgs/sandbox 空壳); i18next 引入; a11y 第一轮; SearXNG 页批量草稿模型统一

P4 能力演进: Ollama pull 可取消/capabilities 探测/num_ctx 实测缓存; UpdateService feed 比对式自动更新
(app:updateCheck IPC + StatusBar 入口); MiMo providerOptions(web_search 服务端工具/strict JSON);
web_fetch extract_mode=markdown(turndown); network.proxyUrl 全局代理(Chromium sessions+undici dispatcher)

测试: 264 → 507 用例(Electron ABI 全绿零跳过), 覆盖引擎压缩管线/重试竞速/MEMORY.md 闸门/file_editor 五操作/
filesystem 七工具实体夹具/git 真实仓库/SSE 错误帧/全线截断自愈/Provider 请求形态矩阵/SSRF 表测/钩子分级矩阵/
OutputValidator 全量/SLO 指标/MCP 安全纯函数/task_manager 链路/渲染层纯域/i18n 桥契约
2026-08-27 17:06:58 +08:00

567 lines
17 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): boolean {
const db = this.getDBFn();
const result = 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);
db.prepare('UPDATE sessions SET message_count = 0, updated_at = ? WHERE id = ?').run(
Date.now(),
sessionId,
);
return result.changes > 0;
}
/**
* 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;
}
}
}