chat-session.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. /**
  2. * 会话(App 侧多会话)的纯函数层 —— 无副作用,便于单测。
  3. *
  4. * ## 为什么 App 侧要自己存会话
  5. *
  6. * 容器的 `build_session_key` 按 `room + thread` 划分会话
  7. * (`/opt/hermes/gateway/session.py:654`,`docs/03-interface/容器会话与线程机制-20261028.md`),
  8. * **但没有「会话名字」槽位** —— 容器只管隔离,不管展示。
  9. *
  10. * 所以 App 必须自己维护:
  11. * - 线程根 event_id → 会话元信息(标题 / 创建时间 / 归档)
  12. * - 线程根可以**客户端自造**(实测容器接受指向不存在消息的 event_id),
  13. * 因此新建会话不需要任何网络请求 —— 本地生成一个 ID 即成为完整会话。
  14. *
  15. * ## 线程根为什么能客户端自造
  16. *
  17. * 2026-10-28 实测(见上文文档 §六):用 `$clientroot_<ts>_<rand>:fmode.cn` 作为
  18. * `m.relates_to.event_id` 发送,容器照常按该 thread 建立会话并正确记忆上下文。
  19. */
  20. /** 一条 App 会话(= 一个 Matrix 线程根) */
  21. export interface ChatSession {
  22. /** 线程根 event_id。**直接作为会话主键** */
  23. id: string;
  24. /** 所属房间 */
  25. roomId: string;
  26. /**
  27. * 所属 agent。
  28. *
  29. * 🔴 群会话里存**首个成员**(保持存量代码不炸);真实成员看 `memberIds`。
  30. */
  31. agentId: string;
  32. /**
  33. * 会话形态:单聊 / 群聊(2026-11-05)。
  34. *
  35. * 缺省 / 缺失 = `dm`,兼容全部存量数据,**不做迁移**。
  36. */
  37. kind?: 'dm' | 'group';
  38. /**
  39. * 群会话的成员容器 id 列表(≥2);单聊为 undefined。
  40. *
  41. * 🔴 伪群聊(2026-11-05 裁决):消息由 App 拆成 N 条私聊分别发给这些成员,
  42. * 各容器仍只在自己的 DM 房间里工作 —— 天然不抢答、不回环。
  43. * 容器之间互相看不到对方的消息。
  44. */
  45. memberIds?: string[];
  46. /**
  47. * 本会话关联的成果链接(云盘可访问地址,2026-11-09 用户裁决)。
  48. *
  49. * 成果按会话存放:每条会话记录里存该会话成果的在线链接,
  50. * 右栏「成果」只显示这里的,不再显示整个容器的成果。
  51. */
  52. deliverableUrls?: string[];
  53. /** 展示标题(用户可改;未改时取首条用户消息前 24 字) */
  54. title: string;
  55. /** 是否用户手动改过标题(改过就不再自动覆盖) */
  56. titleLocked?: boolean;
  57. createdAt: number;
  58. updatedAt: number;
  59. /** 归档:不删除,列表默认隐藏 */
  60. archived?: boolean;
  61. /**
  62. * 归属的工作区 id(`null` / 缺省 = 「日常对话」)。
  63. *
  64. * 🔴 这是**用户编排数据**,不是矩阵侧事实 —— 所以存在本地、
  65. * 与归档一样属于展示层元信息,不进 Matrix。
  66. */
  67. workspaceId?: string | null;
  68. /**
  69. * 是否为「历史遗留会话」——承接多会话上线前的无 thread 消息。
  70. *
  71. * 这类会话的 id **不是** Matrix 线程根,发送时也不带 `m.relates_to`,
  72. * 保持与容器原来的「每条一个会话」行为一致,避免旧历史被写进新会话。
  73. */
  74. legacy?: boolean;
  75. /** 最后一条消息预览 */
  76. lastPreview?: string;
  77. /** 消息计数(用于「空会话」判断与展示) */
  78. messageCount?: number;
  79. }
  80. /**
  81. * 会话目录里暂时还查不到某条会话时的占位条目(2026-11-06)。
  82. *
  83. * 只用于**展示**:让「选中了某条会话」这件事在元信息到达前依然成立,
  84. * 页头/输入区/右栏不会因为查不到而整块消失。不写进会话目录、不参与排序。
  85. */
  86. export function placeholderSession(id: string, agentId: string): ChatSession {
  87. return {
  88. id,
  89. roomId: '',
  90. agentId,
  91. title: NEW_SESSION_TITLE,
  92. createdAt: 0,
  93. updatedAt: 0,
  94. };
  95. }
  96. /** 自动标题的最大字数 */
  97. export const TITLE_MAX = 24;
  98. /** 未命名会话的默认标题(全项目唯一来源) */
  99. export const NEW_SESSION_TITLE = '新建对话';
  100. /** 新建会话 ID 的前缀(便于识别是 App 自造而非容器产生) */
  101. const ROOT_PREFIX = '$approot_';
  102. /**
  103. * 生成客户端自造的线程根 ID。
  104. *
  105. * 格式 `$approot_<36进制时间>_<随机>:fmode.cn` —— 以 `$` 开头符合 Matrix
  106. * event_id 形态,`:fmode.cn` 后缀与 homeserver 域名一致,便于排障时一眼看出
  107. * 是 App 侧的会话而不是容器内部产生的线程。
  108. */
  109. export function newSessionId(now: number = Date.now()): string {
  110. const rand = Math.random().toString(36).slice(2, 10);
  111. return `${ROOT_PREFIX}${now.toString(36)}_${rand}:fmode.cn`;
  112. }
  113. /** 是否为 App 自造的会话 ID(排查用) */
  114. export function isAppSessionId(id: string): boolean {
  115. return id.startsWith(ROOT_PREFIX);
  116. }
  117. /** 历史遗留会话的固定 ID 前缀 */
  118. const LEGACY_PREFIX = '$legacy_';
  119. /**
  120. * 历史遗留会话的 id(按房间固定生成,同一个房间永远得到同一个值)。
  121. *
  122. * 用于承接多会话上线前的**无 thread 消息**——它们不属于任何线程根,
  123. * 但又必须有一个地方能让用户看到,不能凭空消失。
  124. */
  125. export function legacySessionId(roomId: string): string {
  126. return `${LEGACY_PREFIX}${roomId}:fmode.cn`;
  127. }
  128. /** 是否为历史遗留会话 id */
  129. export function isLegacySessionId(id: string): boolean {
  130. return id.startsWith(LEGACY_PREFIX);
  131. }
  132. /**
  133. * 从文本推导标题:去掉换行、压缩空白、截断到 `TITLE_MAX`。
  134. *
  135. * 空内容返回 undefined —— 由调用方决定兜底文案,不要在这里编造。
  136. */
  137. export function deriveTitle(text: string | undefined | null): string | undefined {
  138. if (!text) return undefined;
  139. const flat = text.replace(/\s+/g, ' ').trim();
  140. if (!flat) return undefined;
  141. // 按码点截断,避免把 emoji / 中文代理对切成半个字
  142. const chars = Array.from(flat);
  143. return chars.length <= TITLE_MAX ? flat : chars.slice(0, TITLE_MAX).join('') + '…';
  144. }
  145. /**
  146. * 从用户消息里提炼**会话标题**(比直接截首句更像任务名)。
  147. *
  148. * ## 为什么需要
  149. *
  150. * 直接截首句会得到「直接做成html然后发给我」「帮我看看这个」这类
  151. * **口语化碎片** —— 看不出这个对话在做什么(用户反馈)。
  152. *
  153. * 这里做的是**规则化清洗**,不是语义总结:
  154. * ① 去掉客套/请求前缀(「帮我」「请」「能不能」「直接」「麻烦你」…)
  155. * ② 去掉句末的语气/交付方式(「然后发给我」「发我」「谢谢」…)
  156. * ③ 压缩空白后按 `TITLE_MAX` 截断
  157. *
  158. * 🔴 **不调用任何模型** —— 标题要在本地瞬时得出,
  159. * 也不能因为总结而多花 token。真正的语义标题应由容器上报(尚未实现)。
  160. *
  161. * @returns 清洗后的标题;清洗后为空则回落到原文截断
  162. */
  163. export function deriveTaskTitle(text: string | undefined | null): string | undefined {
  164. if (!text) return undefined;
  165. let s = text.replace(/\s+/g, ' ').trim();
  166. if (!s) return undefined;
  167. // ① 请求前缀(可叠加,反复剥)
  168. const PREFIX = /^(?:请你|请|麻烦你|麻烦|帮我|帮忙|能不能|可以|能否|你|直接|先|再|然后|好的?|嗯|那个)[\s,,::]*/;
  169. for (let i = 0; i < 4 && PREFIX.test(s); i++) {
  170. s = s.replace(PREFIX, '').trim();
  171. }
  172. // ② 句末:交付方式 / 道谢 / 语气词
  173. const SUFFIX = /(?:然后)?(?:发给我|发我|给我|谢谢|谢了|吧|啊|呢|哦|哈|嘛)[\s。.!!??]*$/;
  174. for (let i = 0; i < 3 && SUFFIX.test(s); i++) {
  175. s = s.replace(SUFFIX, '').trim();
  176. }
  177. // 清洗后太短(<4 字)说明把有效信息剥没了 → 用原文
  178. const cleaned = Array.from(s);
  179. if (cleaned.length < 4) return deriveTitle(text);
  180. return deriveTitle(s);
  181. }
  182. /** 新建会话(本地生成,不触网) */
  183. export function createSession(input: {
  184. roomId: string;
  185. agentId: string;
  186. title?: string;
  187. now?: number;
  188. }): ChatSession {
  189. const now = input.now ?? Date.now();
  190. return {
  191. id: newSessionId(now),
  192. roomId: input.roomId,
  193. agentId: input.agentId,
  194. /*
  195. * 未命名的会话统一叫「新建对话」(2026-11-01 用户要求)。
  196. * 发出第一条消息后由 `deriveTaskTitle()` 自动改成消息提炼的短句。
  197. * 与 `chat-session.service.ts` 里云端回落值保持一致。
  198. */
  199. title: input.title ?? NEW_SESSION_TITLE,
  200. createdAt: now,
  201. updatedAt: now,
  202. archived: false,
  203. messageCount: 0,
  204. };
  205. }
  206. /**
  207. * 更新会话:写入新消息。
  208. *
  209. * 标题自动推导规则:
  210. * - 只有「用户发的第一条消息」能定标题(agent 的话不适合当会话名)
  211. * - 用户手动改过标题(`titleLocked`)则永不覆盖
  212. * - 之后的消息只更新预览与时间,不改标题
  213. */
  214. export function touchSession(
  215. s: ChatSession,
  216. msg: { text?: string; ts: number; fromMe: boolean },
  217. ): ChatSession {
  218. /*
  219. * 🔴 命名判定必须在**累加计数之前**读取原始状态(2026-11-01)。
  220. * 原实现先 `messageCount+1` 再判断 `messageCount === 0`,
  221. * 第一次调用时计数已是 1 → 条件永假 → 标题永远是「新对话」(实测)。
  222. */
  223. const isFirstMessage = (s.messageCount ?? 0) === 0;
  224. const next: ChatSession = {
  225. ...s,
  226. /*
  227. * 🔴 只有**用户自己发消息**才推进排序时间(2026-11-07 用户裁决)。
  228. *
  229. * 否则容器在别的会话里持续推送回复,会让不相干的会话不停跳到列表顶部 ——
  230. * 用户观察到「我没动,排序一直在变」。
  231. * 回复仍然更新 `lastPreview` / `messageCount`,只是**不影响排序位置**。
  232. */
  233. updatedAt: msg.fromMe ? Math.max(s.updatedAt, msg.ts) : s.updatedAt,
  234. messageCount: (s.messageCount ?? 0) + 1,
  235. lastPreview: derivePreview(msg.text),
  236. };
  237. const shouldTitlize = !s.titleLocked && isFirstMessage && msg.fromMe && !!msg.text?.trim();
  238. if (shouldTitlize) {
  239. next.title = deriveTaskTitle(msg.text) ?? s.title;
  240. }
  241. return next;
  242. }
  243. /** 预览比标题长一些,但同样单行 */
  244. function derivePreview(text: string | undefined): string | undefined {
  245. if (!text) return undefined;
  246. const flat = text.replace(/\s+/g, ' ').trim();
  247. if (!flat) return undefined;
  248. const chars = Array.from(flat);
  249. return chars.length <= 60 ? flat : chars.slice(0, 60).join('') + '…';
  250. }
  251. /**
  252. * 列表排序:未归档在前,各自按更新时间倒序。
  253. *
  254. * 🔴 不按创建时间排 —— 用户刚聊过的会话应该浮到顶部(与所有 IM 一致)。
  255. */
  256. export function sortSessions(list: ChatSession[]): ChatSession[] {
  257. return [...list].sort((a, b) => {
  258. if (!!a.archived !== !!b.archived) return a.archived ? 1 : -1;
  259. return b.updatedAt - a.updatedAt;
  260. });
  261. }
  262. /** 取某 agent 下的会话(已排序,默认不含归档) */
  263. export function sessionsOf(
  264. list: ChatSession[],
  265. agentId: string,
  266. opts: { includeArchived?: boolean } = {},
  267. ): ChatSession[] {
  268. return sortSessions(
  269. list.filter((s) => s.agentId === agentId && (opts.includeArchived || !s.archived)),
  270. );
  271. }
  272. /**
  273. * 会话在左栏(桌面)与抽屉(移动)里的**分组归属**。
  274. *
  275. * 🔴 归档必须回到**它原来所属的分组**(2026-11-08 用户反馈)。
  276. *
  277. * 原实现只按 `workspaceId` 分组:群会话的 `workspaceId` 恒为 `null`
  278. * (群聊不归属工作区),于是「群聊 → 归档」被塞进「日常对话 → 已归档」,
  279. * 与用户的预期(归档在哪个分组,就停在哪个分组)相违。
  280. *
  281. * 现在归属唯一由这里决定,桌面、移动都从这里取 —— 避免两端各写一套判据再次分叉。
  282. * · `workspace:<id>` — 归属某工作区
  283. * · `group` — 群聊(不论 `workspaceId`,群聊不归属工作区)
  284. * · `daily` — 其余(日常对话 / 已失效工作区的孤儿)
  285. */
  286. export type SessionGroup =
  287. | { kind: 'workspace'; id: string }
  288. | { kind: 'group' }
  289. | { kind: 'daily' };
  290. export function groupOf(s: Pick<ChatSession, 'kind' | 'workspaceId'>): SessionGroup {
  291. /*
  292. * 🔴 工作区优先于群聊(2026-11-11 修正)。
  293. *
  294. * 原实现 `kind === 'group'` 无条件归群聊,于是「从工作区里新建群聊」
  295. * 时写进去的 `workspaceId` **被静默丢弃** —— 用户明明在工作区点的「+」,
  296. * 会话却出现在底部的「群聊」分组里,看起来像没生效。
  297. *
  298. * 现在的语义:**用户把它放进哪个工作区,它就在哪个工作区**;
  299. * 只有没指定工作区的群聊才归「群聊」分组。
  300. */
  301. if (s.workspaceId) return { kind: 'workspace', id: s.workspaceId };
  302. if (s.kind === 'group') return { kind: 'group' };
  303. return { kind: 'daily' };
  304. }
  305. /** 分组 → 折叠状态的稳定键(桌面 `archOpen` / 移动 `expanded` 共用) */
  306. export function groupKey(g: SessionGroup): string {
  307. return g.kind === 'workspace' ? `workspace:${g.id}` : g.kind;
  308. }