Files
metona-ai-desktop/electron/services/session.service.ts
T
thzxx 5b9d4d19b3
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m16s
CI / 全量测试 (Electron ABI) (push) Failing after 6m4s
CI / 产物编译验证 (push) Successful in 11m1s
feat: v0.8.0 流语义补全 · 会话可靠 · 恢复力 — finish_reason 全链路贯通根治"思考中停止" · 2445 用例全量回归
P0 会话可靠性收口(根治"模型思考着会话就停止"):
- P0-1 finish_reason 全链路贯通:DONE 事件与 IterationStep 新增 finishReason,OpenAI 共享 SSE / Anthropic message_delta.stop_reason / Ollama done_reason 三路采集,TRACE 层弃用硬编码 'stop' 记录真值
- P0-2 空响应守卫 + 降级重试:零产出流→可重试错误走退避;思考耗尽输出预算(reasoning-only + length)→自动关闭思考降级重试一次;仍失败→OUTPUT_LENGTH_EXCEEDED 结构化错误 + 故障转移;附带根治 abort 恰逢零工具调用轮被 COMPLETED 抢占的真实缺陷
- P0-3 思考×能力×预算三对齐:DeepSeek/MiMo/Agnes/Ollama 四家 supportsThinking=false 强制不发思考参数;小输出预算告警;设置页联动提示
- P0-4 渲染层可见性:截断/空完成/友好错误三类提示,i18n 全部出层
- P0-5 回归四件套:reasoning-only 终止判定、集成级空闲超时、504 引擎重试归类、思考中 abort→USER_INTERRUPT、P4-2 强制收尾路径

FEAT-1:LLM 设置新增「最大输出上限」——Provider 支持矩阵显隐 + 模型上限钳制提示 + 超限保存警告 + llm.maxTokens 热生效

P1 修复面收口:
- 渲染层三缺陷根治:后台会话回放缓冲(2000 条/4MB 有界 + agent:getReplayState + 事件总线)+ abort 双层自愈 + sendMessage 收尾兜底 + 中断卡片清扫
- 工具 abort 信号全覆盖:web_search/web_fetch/http_request/code_search/git 系列/delegate_task 全部接入引擎中断;web_search 时间预算收敛(720s→≤240s);移除伪造 ToolExecutionContext 与死代码
- 安全:本地 Pinned CONNECT 代理根治浏览器通道 DNS rebinding(校验期 IP pinning,可注入 resolver 表测);配置 URL 域名解析深校验(DeepCheckSoftFailure 软失败);SSE 空 error 帧防御修复;Ollama generate/embed AbortSignal.any 合并
- 缺陷清单:UTF-16 BOM 读取、tmp 同毫秒碰撞(nanoid 后缀)、code_search JS 回退参数对称(case_sensitive/前后文独立)、list_directory include_node_modules、崩溃自愈退避(60s 窗 ≥3 次停 reload)、MemoryViewer/Sidebar i18n 收口

P2 能力演进:
- 会话回收站:SCHEMA_VERSION 3 + 迁移 10(deleted_at,存在性守卫),软删除/恢复/彻底删除/30 天自动清理(启动+24h),searchMessages 聚合剔除,Sidebar 回收站面板
- 会话回放播放器:sessions:listRecordings/readRecording(白名单+目录边界+20MB 上限),SessionReplayPlayer 时间轴/步进/变速,Trace 面板入口
- electron-updater 自动更新:双轨(手动 feed 比对保留),生产环境启动静默检查 + update:status 广播 + app:updateInstall + LogsSettings UpdatePanel + builder publish 配置
- @ 文件提及:workspace.listFiles/readFileClip(边界/512KB/NUL 拒绝/MEMORY.md 保护),ChatInput Fuse 联想+键盘导航+附件管线注入
- MCP Resources/Prompts 发现:可选能力 try/catch 降级,mcp:listServerContents,MCPSettings 展开视图
- 文档对齐:内部 API 标准 HTML(Adapter 清单补 MiMo/已实现注记/STREAM_RESET/DONE.finishReason/ repetition_truncation 映射);README v0.8.0 亮点表

P3 测试基建:
- 新增 4 个测试文件:engine-stream-contract(6)、engine-stream-reliability(4:集成空闲超时/504 重试/思考中 abort/P4-2 强制收尾)、thinking-capability-gate(7)、pinned-proxy(9,含深校验 5)、session-trash(5,DB 域)、use-agent-stream hook 级(5)、agent.test 回放缓冲(2)
- 契约更新:orchestrator 被中断 SubAgent success=false(abort 优先级修复语义)、SSE 空 error 帧、UTF-16 正常读取、DeepSeek 未配置思考显式 disabled、迁移矩阵 v2→3
- 弱断言根治:registry WEBP 单向断言、hooks-contracts 自比恒真、memory 空 token 补强

全量验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2144 通过(301 DB 用例按 ABI 跳过)/ Electron ABI 2445/2445 全量通过 0 跳过
2026-09-05 20:06:26 +08:00

773 lines
26 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) {}
/**
* 列出所有会话
*
* v0.8.0 P2-1: 排除回收站(deleted_at 非空)会话 —— 删除为软删除,
* 列表/查询面不再出现;回收站内容经 listDeleted 查看。
*/
list(options?: { archived?: boolean }): SessionInfo[] {
const db = this.getDBFn();
const archived = options?.archived ?? false;
const rows = db
.prepare(
`
SELECT * FROM sessions
WHERE archived = ? AND deleted_at IS NULL
ORDER BY pinned DESC, updated_at DESC
`,
)
.all(archived ? 1 : 0) as SessionRow[];
return rows.map((row) => this.toSessionInfo(row));
}
/**
* v0.8.0 P2-1: 列出回收站会话(deleted_at 非空,按删除时间倒序)
*/
listDeleted(): SessionInfo[] {
const db = this.getDBFn();
const rows = db
.prepare(
`
SELECT * FROM sessions
WHERE deleted_at IS NOT NULL
ORDER BY deleted_at DESC
`,
)
.all() as SessionRow[];
return rows.map((row) => this.toSessionInfo(row));
}
/**
* v0.8.0 P2-1: 从回收站恢复会话(清除 deleted_at
*/
restore(sessionId: string): boolean {
const db = this.getDBFn();
const result = db
.prepare('UPDATE sessions SET deleted_at = NULL WHERE id = ? AND deleted_at IS NOT NULL')
.run(sessionId);
if (result.changes > 0) log.info(`Session restored from trash: ${sessionId}`);
return result.changes > 0;
}
/**
* v0.8.0 P2-1: 彻底删除(硬删除,级联清消息/摘要/任务)
*/
purge(sessionId: string): boolean {
const db = this.getDBFn();
const result = db
.prepare('DELETE FROM sessions WHERE id = ? AND deleted_at IS NOT NULL')
.run(sessionId);
if (result.changes > 0) log.info(`Session purged from trash: ${sessionId}`);
return result.changes > 0;
}
/**
* v0.8.0 P2-1: 清理回收站中超过 maxAgeDays 的会话(硬删除,级联清理)
* 启动时与每 24h 定时调用(main.ts)。
*/
purgeExpiredTrash(maxAgeDays = 30): number {
const db = this.getDBFn();
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
const rows = db
.prepare('SELECT id FROM sessions WHERE deleted_at IS NOT NULL AND deleted_at < ?')
.all(cutoff) as Array<{ id: string }>;
for (const row of rows) {
db.prepare('DELETE FROM sessions WHERE id = ?').run(row.id);
}
return rows.length;
}
/**
* v0.7.4 P1-4: 按 ID 查询会话(含归档会话)。
* 供 agent:sendMessage 前置存在性预检 —— 指向已删除会话的消息会在 saveMessage
* 因 FK 约束(foreign_keys=ON)抛错,提前校验可走统一的 ERROR+DONE 收尾路径而非 invoke reject。
* v0.8.0 P2-1: 排除回收站会话(deleted_at 非空 = 已删除,消息不可继续发送)。
*/
getSession(sessionId: string): SessionInfo | null {
const db = this.getDBFn();
const row = db
.prepare('SELECT * FROM sessions WHERE id = ? AND deleted_at IS NULL')
.get(sessionId) as SessionRow | undefined;
return row ? this.toSessionInfo(row) : null;
}
/**
* 创建新会话
*/
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;
}
/**
* 删除会话
*
* v0.8.0 P2-1 根治: 改为**软删除**(置 deleted_at)—— 旧实现直接硬删除,
* 误删无法恢复。现进入回收站(listDeleted 查看,30 天后 purgeExpiredTrash
* 自动清理),彻底删除由 purge 显式触发(级联清消息)。
*/
delete(sessionId: string): boolean {
const db = this.getDBFn();
const result = db
.prepare('UPDATE sessions SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL')
.run(Date.now(), sessionId);
if (result.changes > 0) {
log.info(`Session moved to trash: ${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;
/**
* v0.7.4 回归修复: 透传前端消息 id。
*
* 背景:前端 ChatMessage.id 由 genMsgId 生成(msg_<时间戳>_user_<n>),DB 默认
* 生成 msg_<nanoid12> —— 两者格式不同永不相交。若不透传,用户对"刚发送的消息"
* 编辑并"仅保存"updateMessageContent 按 id 匹配)必失败(0 行更新)。
* 现由 agent:sendMessage 透传用户消息的 idassistant/tool 消息仍由 DB 生成。
*/
id?: string;
}): MessageInfo {
const db = this.getDBFn();
const id = params.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.4 P3-10: 更新单条消息内容(编辑"仅保存"落库)。
*
* 仅允许更新 user 角色消息(编辑重发/仅保存针对用户消息;assistant 消息由
* 引擎生成不可手改)。返回是否实际更新。
* 注: messages 表无 updated_at 列(v0.7.4 核查修复——原实现 SET updated_at 抛
* 'no such column'),仅更新 content。
*/
updateMessageContent(sessionId: string, messageId: string, content: string): boolean {
const db = this.getDBFn();
const result = db
.prepare(
`
UPDATE messages
SET content = ?
WHERE id = ? AND session_id = ? AND role = 'user'
`,
)
.run(content, messageId, sessionId);
return result.changes > 0;
}
/**
* 删除一条消息
*
* 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 [];
// v0.7.4 P4-4: 中文搜索增强 —— messages_fts 已升级 trigram tokenizer(迁移 9)。
// trigram 支持任意 ≥3 字符子串:中文查询 ≥3 字直接裸词匹配("人工智能"能命中
// 仅含"工智能"的消息);**≤2 字符查询**2 字中文如"引号"、2 字英文如"AI"、
// 单字符)trigram 无法构成 3-gramFTS 查询必 0 命中),走 LIKE 子串回退;
// 英文/混排 ≥3 字符保持短语语义。FTS5 运算符转义统一处理。
const cjkOnly = /^[\u4e00-\u9fff]+$/.test(trimmed);
const queryLen = [...trimmed].length;
// ≤2 字符查询:trigram 无法匹配(3-gram 需要 ≥3 字符),LIKE 子串回退(ESCAPE '\' 转义 % _ \
if (queryLen <= 2) {
const escaped = trimmed.replace(/[\\%_]/g, (ch) => `\\${ch}`);
const likeRows = db
.prepare(
`
SELECT m.id, m.session_id, m.role, m.created_at,
substr(m.content, 1, 120) AS snip
FROM messages m
WHERE m.content LIKE ? ESCAPE '\\'
ORDER BY m.created_at DESC
LIMIT ?
`,
)
.all(`%${escaped}%`, scanLimit) as Array<{
id: string;
session_id: string;
role: string;
created_at: number;
snip: string;
}>;
return this.aggregateSearchResults(likeRows, maxSessions);
}
const escapeQuote = (s: string): string => s.replace(/"/g, '""');
// 纯中文 ≥3 字:trigram 裸词子串匹配(FTS 对 CJK 无空格分词歧义)
const matchExpr = cjkOnly && queryLen >= 3 ? escapeQuote(trimmed) : `"${escapeQuote(trimmed)}"`;
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(matchExpr, scanLimit) as MatchRow[];
} catch (err) {
log.warn('[SessionService] FTS search failed:', (err as Error).message);
return [];
}
return this.aggregateSearchResults(rows, maxSessions);
}
/**
* v0.7.4 P4-4: 搜索结果按会话聚合(FTS 与 LIKE 回退共用)。
* rows 已按时间倒序,每个会话首次出现即最新匹配。
*/
private aggregateSearchResults(
rows: Array<{ id: string; session_id: string; role: string; created_at: number; snip: string }>,
maxSessions: number,
): Array<{
sessionId: string;
sessionTitle: string;
matchCount: number;
lastMessageId: string;
lastRole: string;
snippet: string;
timestamp: number;
}> {
const db = this.getDBFn();
// 按会话聚合:取每个会话的最新匹配(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 一起搜——搜索不应因归档而缺结果)
// v0.8.0 P2-1: 回收站会话(deleted_at 非空)从搜索结果剔除
const sessionIds = Array.from(aggregated.keys());
const placeholders = sessionIds.map(() => '?').join(',');
const titleRows = db
.prepare(`SELECT id, title, deleted_at FROM sessions WHERE id IN (${placeholders})`)
.all(...sessionIds) as Array<{ id: string; title: string; deleted_at: number | null }>;
const titleMap = new Map(
titleRows.filter((r) => r.deleted_at == null).map((r) => [r.id, r.title]),
);
const filteredAggregated = Array.from(aggregated.entries()).filter(([sid]) =>
titleMap.has(sid),
);
return filteredAggregated
.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;
}
}
}