/** * 会话(App 侧多会话)的纯函数层 —— 无副作用,便于单测。 * * ## 为什么 App 侧要自己存会话 * * 容器的 `build_session_key` 按 `room + thread` 划分会话 * (`/opt/hermes/gateway/session.py:654`,`docs/03-interface/容器会话与线程机制-20261028.md`), * **但没有「会话名字」槽位** —— 容器只管隔离,不管展示。 * * 所以 App 必须自己维护: * - 线程根 event_id → 会话元信息(标题 / 创建时间 / 归档) * - 线程根可以**客户端自造**(实测容器接受指向不存在消息的 event_id), * 因此新建会话不需要任何网络请求 —— 本地生成一个 ID 即成为完整会话。 * * ## 线程根为什么能客户端自造 * * 2026-10-28 实测(见上文文档 §六):用 `$clientroot__:fmode.cn` 作为 * `m.relates_to.event_id` 发送,容器照常按该 thread 建立会话并正确记忆上下文。 */ /** 一条 App 会话(= 一个 Matrix 线程根) */ export interface ChatSession { /** 线程根 event_id。**直接作为会话主键** */ id: string; /** 所属房间 */ roomId: string; /** * 所属 agent。 * * 🔴 群会话里存**首个成员**(保持存量代码不炸);真实成员看 `memberIds`。 */ agentId: string; /** * 会话形态:单聊 / 群聊(2026-11-05)。 * * 缺省 / 缺失 = `dm`,兼容全部存量数据,**不做迁移**。 */ kind?: 'dm' | 'group'; /** * 群会话的成员容器 id 列表(≥2);单聊为 undefined。 * * 🔴 伪群聊(2026-11-05 裁决):消息由 App 拆成 N 条私聊分别发给这些成员, * 各容器仍只在自己的 DM 房间里工作 —— 天然不抢答、不回环。 * 容器之间互相看不到对方的消息。 */ memberIds?: string[]; /** * 本会话关联的成果链接(云盘可访问地址,2026-11-09 用户裁决)。 * * 成果按会话存放:每条会话记录里存该会话成果的在线链接, * 右栏「成果」只显示这里的,不再显示整个容器的成果。 */ deliverableUrls?: string[]; /** 展示标题(用户可改;未改时取首条用户消息前 24 字) */ title: string; /** 是否用户手动改过标题(改过就不再自动覆盖) */ titleLocked?: boolean; createdAt: number; updatedAt: number; /** 归档:不删除,列表默认隐藏 */ archived?: boolean; /** * 归属的工作区 id(`null` / 缺省 = 「日常对话」)。 * * 🔴 这是**用户编排数据**,不是矩阵侧事实 —— 所以存在本地、 * 与归档一样属于展示层元信息,不进 Matrix。 */ workspaceId?: string | null; /** * 是否为「历史遗留会话」——承接多会话上线前的无 thread 消息。 * * 这类会话的 id **不是** Matrix 线程根,发送时也不带 `m.relates_to`, * 保持与容器原来的「每条一个会话」行为一致,避免旧历史被写进新会话。 */ legacy?: boolean; /** 最后一条消息预览 */ lastPreview?: string; /** 消息计数(用于「空会话」判断与展示) */ messageCount?: number; } /** * 会话目录里暂时还查不到某条会话时的占位条目(2026-11-06)。 * * 只用于**展示**:让「选中了某条会话」这件事在元信息到达前依然成立, * 页头/输入区/右栏不会因为查不到而整块消失。不写进会话目录、不参与排序。 */ export function placeholderSession(id: string, agentId: string): ChatSession { return { id, roomId: '', agentId, title: NEW_SESSION_TITLE, createdAt: 0, updatedAt: 0, }; } /** 自动标题的最大字数 */ export const TITLE_MAX = 24; /** 未命名会话的默认标题(全项目唯一来源) */ export const NEW_SESSION_TITLE = '新建对话'; /** 新建会话 ID 的前缀(便于识别是 App 自造而非容器产生) */ const ROOT_PREFIX = '$approot_'; /** * 生成客户端自造的线程根 ID。 * * 格式 `$approot_<36进制时间>_<随机>:fmode.cn` —— 以 `$` 开头符合 Matrix * event_id 形态,`:fmode.cn` 后缀与 homeserver 域名一致,便于排障时一眼看出 * 是 App 侧的会话而不是容器内部产生的线程。 */ export function newSessionId(now: number = Date.now()): string { const rand = Math.random().toString(36).slice(2, 10); return `${ROOT_PREFIX}${now.toString(36)}_${rand}:fmode.cn`; } /** 是否为 App 自造的会话 ID(排查用) */ export function isAppSessionId(id: string): boolean { return id.startsWith(ROOT_PREFIX); } /** 历史遗留会话的固定 ID 前缀 */ const LEGACY_PREFIX = '$legacy_'; /** * 历史遗留会话的 id(按房间固定生成,同一个房间永远得到同一个值)。 * * 用于承接多会话上线前的**无 thread 消息**——它们不属于任何线程根, * 但又必须有一个地方能让用户看到,不能凭空消失。 */ export function legacySessionId(roomId: string): string { return `${LEGACY_PREFIX}${roomId}:fmode.cn`; } /** 是否为历史遗留会话 id */ export function isLegacySessionId(id: string): boolean { return id.startsWith(LEGACY_PREFIX); } /** * 从文本推导标题:去掉换行、压缩空白、截断到 `TITLE_MAX`。 * * 空内容返回 undefined —— 由调用方决定兜底文案,不要在这里编造。 */ export function deriveTitle(text: string | undefined | null): string | undefined { if (!text) return undefined; const flat = text.replace(/\s+/g, ' ').trim(); if (!flat) return undefined; // 按码点截断,避免把 emoji / 中文代理对切成半个字 const chars = Array.from(flat); return chars.length <= TITLE_MAX ? flat : chars.slice(0, TITLE_MAX).join('') + '…'; } /** * 从用户消息里提炼**会话标题**(比直接截首句更像任务名)。 * * ## 为什么需要 * * 直接截首句会得到「直接做成html然后发给我」「帮我看看这个」这类 * **口语化碎片** —— 看不出这个对话在做什么(用户反馈)。 * * 这里做的是**规则化清洗**,不是语义总结: * ① 去掉客套/请求前缀(「帮我」「请」「能不能」「直接」「麻烦你」…) * ② 去掉句末的语气/交付方式(「然后发给我」「发我」「谢谢」…) * ③ 压缩空白后按 `TITLE_MAX` 截断 * * 🔴 **不调用任何模型** —— 标题要在本地瞬时得出, * 也不能因为总结而多花 token。真正的语义标题应由容器上报(尚未实现)。 * * @returns 清洗后的标题;清洗后为空则回落到原文截断 */ export function deriveTaskTitle(text: string | undefined | null): string | undefined { if (!text) return undefined; let s = text.replace(/\s+/g, ' ').trim(); if (!s) return undefined; // ① 请求前缀(可叠加,反复剥) const PREFIX = /^(?:请你|请|麻烦你|麻烦|帮我|帮忙|能不能|可以|能否|你|直接|先|再|然后|好的?|嗯|那个)[\s,,::]*/; for (let i = 0; i < 4 && PREFIX.test(s); i++) { s = s.replace(PREFIX, '').trim(); } // ② 句末:交付方式 / 道谢 / 语气词 const SUFFIX = /(?:然后)?(?:发给我|发我|给我|谢谢|谢了|吧|啊|呢|哦|哈|嘛)[\s。.!!??]*$/; for (let i = 0; i < 3 && SUFFIX.test(s); i++) { s = s.replace(SUFFIX, '').trim(); } // 清洗后太短(<4 字)说明把有效信息剥没了 → 用原文 const cleaned = Array.from(s); if (cleaned.length < 4) return deriveTitle(text); return deriveTitle(s); } /** 新建会话(本地生成,不触网) */ export function createSession(input: { roomId: string; agentId: string; title?: string; now?: number; }): ChatSession { const now = input.now ?? Date.now(); return { id: newSessionId(now), roomId: input.roomId, agentId: input.agentId, /* * 未命名的会话统一叫「新建对话」(2026-11-01 用户要求)。 * 发出第一条消息后由 `deriveTaskTitle()` 自动改成消息提炼的短句。 * 与 `chat-session.service.ts` 里云端回落值保持一致。 */ title: input.title ?? NEW_SESSION_TITLE, createdAt: now, updatedAt: now, archived: false, messageCount: 0, }; } /** * 更新会话:写入新消息。 * * 标题自动推导规则: * - 只有「用户发的第一条消息」能定标题(agent 的话不适合当会话名) * - 用户手动改过标题(`titleLocked`)则永不覆盖 * - 之后的消息只更新预览与时间,不改标题 */ export function touchSession( s: ChatSession, msg: { text?: string; ts: number; fromMe: boolean }, ): ChatSession { /* * 🔴 命名判定必须在**累加计数之前**读取原始状态(2026-11-01)。 * 原实现先 `messageCount+1` 再判断 `messageCount === 0`, * 第一次调用时计数已是 1 → 条件永假 → 标题永远是「新对话」(实测)。 */ const isFirstMessage = (s.messageCount ?? 0) === 0; const next: ChatSession = { ...s, /* * 🔴 只有**用户自己发消息**才推进排序时间(2026-11-07 用户裁决)。 * * 否则容器在别的会话里持续推送回复,会让不相干的会话不停跳到列表顶部 —— * 用户观察到「我没动,排序一直在变」。 * 回复仍然更新 `lastPreview` / `messageCount`,只是**不影响排序位置**。 */ updatedAt: msg.fromMe ? Math.max(s.updatedAt, msg.ts) : s.updatedAt, messageCount: (s.messageCount ?? 0) + 1, lastPreview: derivePreview(msg.text), }; const shouldTitlize = !s.titleLocked && isFirstMessage && msg.fromMe && !!msg.text?.trim(); if (shouldTitlize) { next.title = deriveTaskTitle(msg.text) ?? s.title; } return next; } /** 预览比标题长一些,但同样单行 */ function derivePreview(text: string | undefined): string | undefined { if (!text) return undefined; const flat = text.replace(/\s+/g, ' ').trim(); if (!flat) return undefined; const chars = Array.from(flat); return chars.length <= 60 ? flat : chars.slice(0, 60).join('') + '…'; } /** * 列表排序:未归档在前,各自按更新时间倒序。 * * 🔴 不按创建时间排 —— 用户刚聊过的会话应该浮到顶部(与所有 IM 一致)。 */ export function sortSessions(list: ChatSession[]): ChatSession[] { return [...list].sort((a, b) => { if (!!a.archived !== !!b.archived) return a.archived ? 1 : -1; return b.updatedAt - a.updatedAt; }); } /** 取某 agent 下的会话(已排序,默认不含归档) */ export function sessionsOf( list: ChatSession[], agentId: string, opts: { includeArchived?: boolean } = {}, ): ChatSession[] { return sortSessions( list.filter((s) => s.agentId === agentId && (opts.includeArchived || !s.archived)), ); } /** * 会话在左栏(桌面)与抽屉(移动)里的**分组归属**。 * * 🔴 归档必须回到**它原来所属的分组**(2026-11-08 用户反馈)。 * * 原实现只按 `workspaceId` 分组:群会话的 `workspaceId` 恒为 `null` * (群聊不归属工作区),于是「群聊 → 归档」被塞进「日常对话 → 已归档」, * 与用户的预期(归档在哪个分组,就停在哪个分组)相违。 * * 现在归属唯一由这里决定,桌面、移动都从这里取 —— 避免两端各写一套判据再次分叉。 * · `workspace:` — 归属某工作区 * · `group` — 群聊(不论 `workspaceId`,群聊不归属工作区) * · `daily` — 其余(日常对话 / 已失效工作区的孤儿) */ export type SessionGroup = | { kind: 'workspace'; id: string } | { kind: 'group' } | { kind: 'daily' }; export function groupOf(s: Pick): SessionGroup { /* * 🔴 工作区优先于群聊(2026-11-11 修正)。 * * 原实现 `kind === 'group'` 无条件归群聊,于是「从工作区里新建群聊」 * 时写进去的 `workspaceId` **被静默丢弃** —— 用户明明在工作区点的「+」, * 会话却出现在底部的「群聊」分组里,看起来像没生效。 * * 现在的语义:**用户把它放进哪个工作区,它就在哪个工作区**; * 只有没指定工作区的群聊才归「群聊」分组。 */ if (s.workspaceId) return { kind: 'workspace', id: s.workspaceId }; if (s.kind === 'group') return { kind: 'group' }; return { kind: 'daily' }; } /** 分组 → 折叠状态的稳定键(桌面 `archOpen` / 移动 `expanded` 共用) */ export function groupKey(g: SessionGroup): string { return g.kind === 'workspace' ? `workspace:${g.id}` : g.kind; }