import { computed, inject, Injectable, signal } from '@angular/core'; import { SessionService } from './session.service'; import { FmodeApi } from './api/fmode-api'; import { ChatThreadRecord, ChatThreadUpsert } from './api/models'; import { ChatSession, NEW_SESSION_TITLE, createSession, legacySessionId, sessionsOf, sortSessions, touchSession, } from './chat-session'; /** * 会话(多线程)服务 —— App 侧会话目录的**唯一 owner** * * ## 职责 * * 维护「线程根 event_id → 会话元信息」的映射,并提供新建 / 重命名 / 归档 / 删除。 * 消息内容本身仍由 Matrix 房间承载,**本服务只存元信息**。 * * ## 存储(2026-11-01 定稿) * * | 层 | 角色 | * |---|---| * | 云端 `FmodeChatThread` | **唯一真相源** | * | localStorage(`fmode_chat_sessions_v2_`) | 渲染缓存 + 删除黑名单;云端不可达时**不展示** | * * 同步方向是**单向**的:云端 → 本地(`hydrateFromCloud`), * 写入操作再单独回写云端。**不存在「本地有、云端没有 → 保留本地」的分支** —— * 那正是「删掉的会话又回来 / 云端被灌满 / 同一会话多条」的根因。 * * ## 为什么正文不进平台库 * * 会话正文(含附件)全部在 Matrix 房间里,平台库只存「哪些线程存在、叫什么名字」。 * 平台库额外存的只有 `lastPreview`(列表用的一行摘要,可丢)。 * 把正文搬进平台库等于重造一遍 Matrix,收发链路、附件、实时性全要另写一套。 * * @see chat-session.ts 纯函数层(可单测) */ @Injectable({ providedIn: 'root' }) export class ChatSessionService { private readonly session = inject(SessionService); /** * 数据访问唯一闸门(云端会话目录读写;失败静默,不影响本地)。 * * 🔴 用 `inject(..., { optional: true })`:本服务在单测/纯本地场景下不注入 * FmodeApi 也应可用(云端同步是可选的增强,不是必需依赖)。 */ private readonly api = inject(FmodeApi, { optional: true }); private readonly _all = signal([]); readonly all = this._all.asReadonly(); /** 按 agent 分组的会话(未归档,已排序) */ readonly byAgent = computed>(() => { const m = new Map(); for (const s of this._all()) { /* * 🔴 **不再在这里丢掉已归档的会话**。 * * 原实现在这一步 `if (s.archived) continue;` —— 归档的会话直接从 * 映射里消失,于是上层无论怎么判断 `showArchived` 都拿不到它们, * 「显示已归档」开关**永远无效**(实测点击无反应)。 * * 归档的取舍交给调用方:默认列表过滤掉,「已归档」目录单独展示。 */ const list = m.get(s.agentId) ?? []; list.push(s); m.set(s.agentId, list); } for (const [k, v] of m) m.set(k, sortSessions(v)); return m; }); private loadedFor: string | null = null; /** * 会话目录是否可信(2026-11-01 用户裁决)。 * * 🔴 **`false` 时列表一律为空**,宁可显示「加载失败,重试」也不展示本地缓存。 * * 为什么改掉「离线展示缓存」: * 缓存与云端没有可靠的全序(谁更新说不清),一边展示旧缓存、一边又把本地 * 回写云端,两端会互相覆盖 —— 这是「删掉的会话又回来 / 云端为空却被灌满」 * 反复出现的根源。现在只有一条规则:**云端成功返回后列表才可信**。 * * 初始值 `false`:登录后必须先成功拉一次云端,列表才可能出现内容。 */ private readonly _ready = signal(false); readonly ready = this._ready.asReadonly(); /** 最近一次云端拉取的失败原因(`null` = 正常);界面据此给出重试入口 */ private readonly _error = signal(null); readonly error = this._error.asReadonly(); /** 上次云端拉取失败的原因(`null` = 正常);供调用方判断能否继续 */ isCloudStale(): boolean { return !this._ready(); } /** * 用户主动移除过的会话 id。 * * 这些线程在 Matrix 里**仍然存在**(消息没删),但用户不想再看到它们。 * 扫描重建时必须跳过,否则删了立刻被扫回来。 */ private readonly _removed = signal>(new Set()); /** * 云端已软删的线程根(跨设备有效)。 * 与本地 `_removed` 一起构成「哪些线程不允许重新出现」的黑名单。 * 注意:目录已不再从 Matrix 扫描重建,它现在的用途是兜住 * 「云端列表与墓碑不一致」的历史数据。 */ private cloudRemoved = new Set(); /** * 每个会话上一次把元信息推到云端的时间(节流用,见 `noteMessage`)。 * * 放在内存里而不是持久化:它只用于「别太频繁地打接口」, * 刷新后重新计时即可,不影响任何数据的正确性。 */ private readonly lastPushAt = new Map(); /** * 本地已建、但还没确认推到云端的会话 id。 * * 用途很窄:`hydrateFromCloud` 整体替换列表时,这些会话在云端还不存在, * 必须保留 —— 否则「新建对话 → 换 URL → 重新拉取云端」会把刚建的会话抹掉。 * 推送成功后立即移除,避免长期存在。 */ private readonly _pendingPush = new Set(); /** * 从 localStorage 载入(Shell 启动时调用一次;重复调用幂等)。 * * 🔴 **按用户区分载入状态**:存储键含用户 id,换账号登录后必须重新载入, * 否则会把上一个账号的会话列表显示给新用户(越权 + 串号)。 * 用户 id 变化时清空内存态再读。 */ load(): void { /* * 🔴 未登录(key 为 null)时**不读也不写**: * 内存里此刻可能是上一个账号的会话,必须清空, * 否则登录页/切换账号的瞬间会把别人的对话闪出来。 */ const key = this.key(); if (key === null) { if (this.loadedFor !== null) this._all.set([]); this.loadedFor = null; return; } if (this.loadedFor === key) return; const switchingUser = this.loadedFor !== null; this.loadedFor = key; if (switchingUser) this._all.set([]); try { const raw = localStorage.getItem(key); if (!raw) return; const parsed = JSON.parse(raw) as unknown; if (!Array.isArray(parsed)) return; // 逐条校验 —— 坏数据不应该让整个列表消失 const list = parsed.filter(isSession).map(normalize); this._all.set(sortSessions(list)); // 恢复「已移除」黑名单 const removedRaw = localStorage.getItem(`${key}_removed`); if (removedRaw) { const arr = JSON.parse(removedRaw) as unknown; if (Array.isArray(arr)) { this._removed.set(new Set(arr.filter((x): x is string => typeof x === 'string'))); } } } catch { /* 隐私模式 / 坏 JSON:降级为内存态 */ } } /** * 清空内存态(登出时调用)。 * * 🔴 `_ready` 必须一起复位:否则登出再登录后,列表会凭「上一次登录的成功状态」 * 直接被视为可信,在新一轮云端拉取完成前就渲染出(可能属于别人的)内容。 */ reset(): void { this.loadedFor = null; this._all.set([]); this._ready.set(false); this._error.set(null); this.cloudRemoved = new Set(); this._removed.set(new Set()); } // ================= 云端同步(2026-11-01 重写:单向 云端 → 本地) ================= /** * 从云端拉取会话目录(**唯一真相源**,本地只是渲染缓存)。 * * ## 唯一规则 * * **云端成功返回 → 本地该容器的会话直接等于云端返回的集合**; * 云端失败 → 列表清空并标记不可用(`ready = false`),界面显示「加载失败,重试」, * **绝不展示旧缓存、也绝不把本地数据反推回云端**。 * * 为什么不再做「合并」: * 线上反复出现「删掉的会话又回来 / 云端为空却被灌满 / 同一会话七八条」, * 根因都是两端各写一半,再用「只补不覆盖」之类的规则去猜谁对。 * 只要允许本地影响云端,就必须维护一套合并规则,而合并规则本身会出错。 * 改成单向同步后,冲突面直接归零。 * * @returns 本次新增的会话条数(调用方一般不关心) */ async hydrateFromCloud(agentId: string): Promise { /* * 🔴 先确保本地缓存已载入:生产由页面在进入路由时调用 load(), * 但云端恢复不应依赖调用顺序(测试/深链进入都可能没调过)。 * load() 幂等,重复调用无副作用。 */ this.load(); const key = this.key(); const api = this.api; if (!key || this.loadedFor !== key || !api) return 0; let live: ChatThreadRecord[]; let deleted: ChatThreadRecord[]; try { /* * 同时拉两份:活跃会话 + 已软删会话。 * 软删那份只用于挡住「同一条线程被重新写回来」(墓碑跨设备有效)。 */ [live, deleted] = await Promise.all([ api.fetchChatThreads(agentId), api.fetchChatThreads(agentId, { includeDeleted: true }).catch(() => [] as ChatThreadRecord[]), ]); } catch (e) { /* * 云端不可达 → **列表不可信**(用户裁决:宁可不显示,也不显示可能过期的缓存)。 * 内存里留着旧内容没有意义 —— 界面依据 `ready()` 直接渲染「加载失败」。 */ this._ready.set(false); this._error.set(e instanceof Error && e.message ? e.message : '会话目录加载失败'); return 0; } this.cloudRemoved = new Set(deleted.map((d) => d.threadRoot)); const prev = new Map( this._all() .filter((s) => s.agentId === agentId) .map((s) => [s.id, s]), ); /* * 云端列表 → 本地会话。逐条以云端为准,本地只提供两个**可丢失**的字段: * · `lastPreview`:云端为空时用本地兜底(纯展示,不参与任何判定) * · `titleLocked`:用户改过名的标记(改名会立刻推云端,正常情况下两边一致) */ const next: ChatSession[] = []; for (const r of live) { // 墓碑挡住:用户明确删过这条线程,即使云端还挂着也不显示 if (this._removed().has(r.threadRoot)) continue; const local = prev.get(r.threadRoot); next.push({ id: r.threadRoot, roomId: r.roomId, agentId: r.agentId || agentId, title: r.title || NEW_SESSION_TITLE, titleLocked: r.titleLocked || local?.titleLocked, createdAt: local?.createdAt ?? (r.lastMessageAt || Date.now()), updatedAt: Math.max(r.lastMessageAt || 0, local?.updatedAt ?? 0), archived: r.archived, messageCount: r.messageCount, lastPreview: r.lastPreview || local?.lastPreview, /* * 工作区归属(2026-11-01 改为云端为准)。 * * 🔴 此前只从本地缓存捡(`local?.workspaceId`)—— 云端重建列表时 * 本地没这条(换设备/清缓存/刚新建还没落盘)就掉回「日常对话」, * 用户看到的就是「新建到工作区却跑到日常里」。 * 现在云端有值就用云端;云端为空才回落到本地(兼容旧数据)。 */ workspaceId: r.workspaceId || local?.workspaceId || null, }); } /* * 用「该容器」的云端结果**整体替换**本地该容器的条目,其它容器不动。 * 这里没有任何「本地有、云端没有 → 保留」的分支 —— 那正是脏数据来源。 * * 🔴 唯一例外:**还没同步到云端的本地新建会话**(2026-11-01)。 * * `create()` 之后是异步推云端的,而新建对话紧接着就会换 URL → 重新 * `open()` → 又拉一次云端。那一刻云端列表里还没有这条, * 若按「云端没有就丢弃」处理,刚建出来的会话**当场消失** * (用户实测:点了新建,聊天区是新的但左侧列表没有内容)。 * * 只保留「本地有、且明确处于待同步状态」的条目,范围极小, * 不会重新引入「本地脏数据灌回列表」的问题。 */ const pending = this._all().filter( (s) => s.agentId === agentId && this._pendingPush.has(s.id) && !next.some((n) => n.id === s.id), ); const others = this._all().filter((s) => s.agentId !== agentId); this._all.set(sortSessions([...others, ...next, ...pending])); this.persist(); this._ready.set(true); this._error.set(null); const before = new Set(prev.keys()); let added = 0; for (const s of next) if (!before.has(s.id)) added++; return added; } /** * 把本地会话(该 agent 下)补推到云端。 * * 正常路径下每次变更(新建 / 改名 / 归档 / 计数)都已单独推送, * 这里只在云端刚恢复可用时兜底补齐「推送失败留下的缺口」。 * * 🔴 云端不可用(`ready=false`)时不推送 —— 此时列表根本不可信, * 写上去就等于把本地残留灌进云端(历史上发生过)。 */ async pushAll(agentId: string): Promise { if (!this.api || !this._ready()) return; const list = this._all().filter((s) => s.agentId === agentId && !s.legacy); if (list.length === 0) return; await this.trySync(() => this.api!.upsertChatThreads(list.map(toUpsert))); // 整批推成功后,这些会话云端已存在,撤销「待同步」标记 for (const s of list) this._pendingPush.delete(s.id); } /** * 统一的「云端失败不影响本地」包装。 * * 🔴 只吞异常、不改本地状态:写入失败时本地照常可用,下次 `hydrateFromCloud` * 会以云端为准把差异抹平。**这里不允许出现「失败就改本地」的补偿逻辑** —— * 那正是两端互相覆盖的来源。 */ private async trySync(fn: () => Promise): Promise { try { await fn(); } catch { /* 离线 / 未开通:本地继续可用,下次拉取时以云端为准 */ } } /** 单条推送到云端(新建 / 变更后调用) */ private pushOne(s: ChatSession | undefined): void { if (!s || s.legacy) return; /* * 「待同步」标记只在**本地新建、云端尚无**时使用。 * * 🔴 `pushOne` 也用于「已落库会话的内容更新」(noteMessage 节流推送), * 若在这里无条件 add,那条会话会**永远**处于待同步状态 —— * noteMessage 的节流判断 `_pendingPush.has(id)` 恒为真, * 于是每条消息都打一次接口(实测:节流完全失效)。 * 所以只有 `create()` 会主动登记,这里只负责推送与撤销。 */ void (async () => { try { await this.api!.upsertChatThreads([toUpsert(s)]); this._pendingPush.delete(s.id); } catch { /* 离线/服务异常:保留标记,等下次 hydrate 或 pushAll 再补 */ } })(); } /** * 拉取**所有容器**的会话目录(左栏在没进过任何容器时也要能列出对话)。 * * 逐容器并发拉取;任意一个失败就整体视为失败(`ready=false`)—— * 只显示拉成功的部分会造成「有的对话突然不见了」,比整体重试更让人困惑。 * * 并发度做了限制(每批 5 个),避免容器多时一次打满平台接口。 */ async reloadAll(agentIds: string[]): Promise { if (!this.api) return; const list = [...new Set(agentIds)].filter(Boolean); if (list.length === 0) return; let failed = 0; let lastErr: unknown = null; for (let i = 0; i < list.length; i += 5) { const batch = list.slice(i, i + 5); const results = await Promise.allSettled(batch.map((a) => this.hydrateFromCloud(a))); for (const r of results) { if (r.status === 'rejected') { failed++; lastErr = r.reason; } } } if (failed > 0) { this._ready.set(false); this._error.set( lastErr instanceof Error && lastErr.message ? lastErr.message : '会话目录加载失败', ); } } /** 某 agent 下的会话(默认不含归档) */ listFor(agentId: string, opts: { includeArchived?: boolean } = {}): ChatSession[] { return sessionsOf(this._all(), agentId, opts); } /** 按 id 取(含归档 —— 归档会话仍可通过链接打开) */ byId(id: string): ChatSession | undefined { return this._all().find((s) => s.id === id); } /** 某 agent 最近的会话(默认会话 = 上次聊的) */ latestFor(agentId: string): ChatSession | undefined { return this.listFor(agentId)[0]; } /** * 新建会话(**不触网**)。 * * 线程根由本地生成 —— 实测容器接受客户端自造的 event_id * (`docs/03-interface/容器会话与线程机制-20261028.md` §六), * 所以新建是纯本地操作,即时可用。 */ /** * 在**本地**新建一条会话(2026-11-01 用户裁决:本地先建,落库推迟)。 * * 🔴 不再一创建就写云端。原因: * · 用户点「新建对话」后,左侧要**立刻**出现「新建对话」并选中; * · 但此时还没有任何消息 —— 若立刻写云端,就产生了一条 0 消息的云端记录, * 发不发消息都留在那儿,反复点击会让云端越积越多。 * · 现在:本地先建(界面立即可见),**首次发消息时**才真正 upsert 到云端。 * * 未落库的会话由 `_pendingPush` 标记,`hydrateFromCloud` 拉云端时不会把它冲掉。 */ create(roomId: string, agentId: string, title?: string): ChatSession { const s = createSession({ roomId, agentId, title }); this._pendingPush.add(s.id); this._all.update((list) => sortSessions([s, ...list])); this.persist(); return s; } /** * 把一条**本地已有**的会话真正落库到云端(首次发消息时调用)。 * * 与 `create()` 的分工:`create` 只管本地可见性,本方法负责持久化。 */ commit(id: string): void { const s = this.byId(id); if (!s || s.legacy) return; this.pushOne(s); } /** * 让「历史遗留会话」存在于列表中(幂等)。 * * 只有当房间里**确实存在无 thread 的旧消息**时才调用 —— 没有旧消息就不该 * 凭空多出一个空会话。调用方(`ChatService`)负责这个判断。 * * @returns `created` 表示本次是否新建;调用方据此决定要不要「首次打开就选中它」 */ adoptLegacy( roomId: string, agentId: string, preview?: string, ): { session: ChatSession; created: boolean } { const id = legacySessionId(roomId); const existing = this.byId(id); if (existing) return { session: existing, created: false }; const now = Date.now(); const s: ChatSession = { id, roomId, agentId, title: '历史对话', titleLocked: true, // 历史会话不改名 createdAt: now, updatedAt: 0, // 真实时间由首条消息的 noteMessage 推进 archived: false, legacy: true, lastPreview: preview, messageCount: 0, }; this._all.update((list) => sortSessions([s, ...list])); this.persist(); return { session: s, created: true }; } /** * 接纳一个本地不认识的会话 id(如通过链接直接打开 `/chat/:id/:sessionId`)。 * * 用于:线程确实存在于 Matrix(历史消息能读到),但本机没存过元信息。 * 标题先给占位,等第一条消息进来会由 `noteMessage` 推导 —— 在此之前**不编造**。 */ adopt(input: { id: string; roomId: string; agentId: string; title?: string }): ChatSession { const existing = this.byId(input.id); if (existing) return existing; const now = Date.now(); const s: ChatSession = { id: input.id, roomId: input.roomId, agentId: input.agentId, title: input.title ?? NEW_SESSION_TITLE, createdAt: now, updatedAt: now, archived: false, messageCount: 0, }; this._all.update((list) => sortSessions([s, ...list])); this.persist(); return s; } /** 重命名(锁定标题,之后不会被自动覆盖) */ rename(id: string, title: string): void { const t = title.replace(/\s+/g, ' ').trim(); if (!t) return; this.update(id, { title: t, titleLocked: true }); void this.trySync(() => this.api!.patchChatThread({ threadRoot: id, title: t })); } /** 归档 / 取消归档(软删,不物理删除);同步到云端 */ archive(id: string, archived = true): void { this.update(id, { archived }); void this.trySync(() => this.api!.patchChatThread({ threadRoot: id, archived })); } /** * 把会话移动到某个工作区(`null` = 日常对话)。 * * 第 3 轮(2026-10-30)接入:`workspaceId` 由 `WorkspaceService` 的工作区 id 提供, * 归属只存本地 —— 删除工作区时由调用方把会话退回日常,不会留下悬空引用。 */ /** * 把会话移动到某个工作区(`null` = 日常对话)。 * * 🔴 2026-11-01 起**同步到云端**:此前只写本地 localStorage, * 导致换设备/清缓存后归属丢失,且云端重建列表时会掉回日常对话。 * 云端用 `''` 表示日常对话(空字符串是合法值,不能用真值判断)。 */ moveTo(id: string, workspaceId: string | null): void { this.update(id, { workspaceId }); const cur = this.byId(id); if (!cur) return; /* * 🔴 **未落库的会话只改本地,不发请求**(2026-11-01 用户裁决)。 * * 持久化的唯一前提是「该会话里发过消息」。新建对话刚建出来(还没发消息)时 * 就被 `startBlank()` 调 `moveTo` 挂到工作区下 —— 若这里直接 upsert, * 等于**点一次新建就在云端留一条记录**,连点几次就堆一堆空会话 * (用户实测:多次新建后,前几次的都持久化了)。 * * 归属先记在本地,等首次发消息触发 `commit()` 时随会话一起落库。 * * 已落库的会话走 upsert(不是 patch):patch 要求云端已有记录, * 「新建 → 立刻移动工作区」时可能还没落地,patch 会 404 被静默吞掉, * 归属压根没写进云端 → 下次拉取掉回「日常对话」。 */ if (this._pendingPush.has(id)) return; void this.trySync(() => this.api!.upsertChatThreads([toUpsert(cur)])); } /** 某工作区下的会话数(不含归档) */ countIn(workspaceId: string | null): number { return this._all().filter((s) => !s.archived && (s.workspaceId ?? null) === workspaceId).length; } /** * 从列表移除一条会话(**只移除索引,Matrix 里的消息不动**)。 * * 安全性:会话列表只是「有哪些对话」的索引,消息本体在 Matrix 房间里。 * 移除索引后对话从列表消失,但消息**没有被删除** —— * 文案必须说清楚这一点,否则用户会以为内容丢了。 */ remove(id: string): void { /* * 🔴 必须记进「已移除」名单 —— 否则下一次从云端拉取时它会重新出现。 * * 会话目录的真相源是云端表。只删本地索引的话,下次 `hydrateFromCloud` * 拉到的还是同一批线程,这条又回来了(实测:删了没效果)。 * 云端删除失败时,本地黑名单是最后一道兜底。 */ const cur = this.byId(id); // 删除前取,删除后取不到 roomId/agentId this._removed.update((set) => new Set(set).add(id)); this._all.update((list) => list.filter((x) => x.id !== id)); this.persist(); /* * 云端删除索引:物理删除记录 + 留一条墓碑阻止扫描重建。 * Matrix 房间里的消息**不受影响**(会话正文仍在服务器)。 */ void this.trySync(() => this.api!.removeChatThread({ threadRoot: id, roomId: cur?.roomId, agentId: cur?.agentId }), ); } /** 该线程是否被用户移除过(拉取云端时据此跳过) */ isRemoved(id: string): boolean { return this._removed().has(id); } /** * 收敛「新建对话」空壳:**整个列表最多只留一条**。 * * 2026-11-01 用户裁决: * 新建对话是「点击即建」(左侧立刻可见并选中),但持久化的唯一前提是 * **在该会话里发过消息**。所以: * · 「新建对话」在列表里只能有一条 —— 不论在哪个工作区、哪个容器新建, * 一旦新建,**之前所有没发过消息的空会话一律清掉**; * · 不需要等 60 秒,也不需要定时器 —— 新建/切换的那一刻就收敛。 * * 实现上区分两类空壳: * · **从未落库**(`_pendingPush`)→ 删本地即可,不发云端请求(云端本来就没有); * · **已落库**(发过消息后又被清空)→ 删本地 + 云端软删,否则下次拉取会回来。 * * @param keepId 要保留的那条(通常是刚新建/正在看的会话) */ pruneEmpty(keepId?: string): void { const victims = this._all().filter( (s) => !s.legacy && s.id !== keepId && (s.messageCount ?? 0) === 0, ); if (victims.length === 0) return; const dead = new Set(victims.map((s) => s.id)); this._all.update((list) => list.filter((x) => !dead.has(x.id))); this.persist(); for (const v of victims) { if (this._pendingPush.has(v.id)) { // 从没写过云端 → 撤销标记即可,不发请求 this._pendingPush.delete(v.id); continue; } void this.trySync(() => this.api!.removeChatThread({ threadRoot: v.id, roomId: v.roomId, agentId: v.agentId }), ); } } /** * 直接设置消息计数与最后活动时间。 * * 用于「历史对话」——它的正文是无 thread 的旧消息, * 不参与线程扫描的计数统计,只能由调用方按实际条数写入。 */ setMessageCount(id: string, count: number, updatedAt?: number): void { const patch: Partial = { messageCount: count }; if (updatedAt && updatedAt > (this.byId(id)?.updatedAt ?? 0)) patch.updatedAt = updatedAt; this.update(id, patch); } /** * 写入一条消息(更新预览/时间,必要时推导标题)。 * * 由 `ChatService` 在消息到达/发出时调用。 */ noteMessage( id: string, msg: { text?: string; ts: number; fromMe: boolean }, ): void { const cur = this.byId(id); if (!cur) return; const next = touchSession(cur, msg); this.update(id, next); /* * 🔴 必须把变化推回云端(2026-11-01 修复)。 * * 此前 `noteMessage` **只写本地** —— 标题、消息数、最后时间全留在浏览器里, * 云端那条记录一直停在「新对话 / 0 条消息」。 * 于是换设备、清缓存、甚至只是重新拉一次列表,会话名就退回「新对话」 * (用户实测:历史会话名称加载不对)。 * * 节流:只在三种关键时刻推送,避免每条消息都打一次接口 —— * ① 该会话**还没落库**(`_pendingPush`)→ 必须立刻写,不节流; * ② 标题变了(首条消息命名成功); * ③ 距上次推送超过 10 秒(保证消息数与时间不会长期落后)。 */ const pending = this._pendingPush.has(id); const titleChanged = next.title !== cur.title; const due = Date.now() - (this.lastPushAt.get(id) ?? 0) > 10_000; if (!this._ready() || (!pending && !titleChanged && !due)) return; this.lastPushAt.set(id, Date.now()); this.pushOne(this.byId(id) ?? next); } /** 取某房间下所有已知会话的线程根(消息过滤用) */ threadIdsOf(roomId: string): Set { return new Set(this._all().filter((s) => s.roomId === roomId).map((s) => s.id)); } /** * 按 id 打补丁。 * * 🔴 **值没变就不写**:本方法可能被 effect 调用,无条件写 signal 会造成 * 「effect → 写 → effect 再跑」的无限循环(冷启动实测页面崩溃)。 */ private update(id: string, patch: Partial): ChatSession | undefined { const cur = this.byId(id); if (!cur) return undefined; const changed = (Object.keys(patch) as (keyof ChatSession)[]).some( (k) => cur[k] !== patch[k], ); if (!changed) return cur; const result: ChatSession = { ...cur, ...patch }; this._all.update((list) => sortSessions(list.map((s) => (s.id === id ? result : s)))); this.persist(); return result; } /** * 本地存储键(**按用户隔离**)。 * * 🔴 未登录时返回 null,调用方直接跳过读写 —— * 绝不能回落到一个共享的 `anon` 键:那样两个未登录会话、 * 或「登出后再登录另一个账号」的间隙,会把别人的会话显示出来。 * * 🔴 `_v2` 后缀(2026-11-01):缓存结构语义变了(云端失败时不再展示旧数据、 * 空会话不再落云端),旧键里的数据按新规则会被当成脏数据, * 直接换键即等于**一次性失效**,无需写迁移代码。 */ private key(): string | null { const uid = this.session.me()?.id; return uid ? `fmode_chat_sessions_v2_${uid}` : null; } private persist(): void { const key = this.key(); // 未登录不落盘 —— 没有 owner 的数据写下去就是下一个账号的串号源 if (!key) return; try { localStorage.setItem( key, JSON.stringify(this._all()), ); // 黑名单单独存 — 它描述的是「用户不想要哪些」,与会话列表同生命周期 localStorage.setItem(`${key}_removed`, JSON.stringify([...this._removed()])); } catch { /* 隐私模式:只保留内存态 */ } } } /** * 会话 → 云端 upsert 载荷(唯一构造点)。 * * 收敛到一处的原因:此前 `pushAll` / `pushOne` / `mergeScanned` 各手写一份同样的 * 字段表,改一个字段要改三处,漏一处就会出现「某条路径写上去的会话缺字段」。 */ function toUpsert(s: ChatSession): ChatThreadUpsert { return { agentId: s.agentId, roomId: s.roomId, threadRoot: s.id, title: s.title, messageCount: s.messageCount ?? 0, lastMessageAt: s.updatedAt || s.createdAt, lastPreview: s.lastPreview, // 归属随 upsert 一起上云('' = 日常对话),否则换设备后层级丢失 workspaceId: s.workspaceId ?? '', }; } /** 结构校验:坏数据不得进入列表 */ function isSession(x: unknown): x is ChatSession { if (!x || typeof x !== 'object') return false; const o = x as Record; return ( typeof o['id'] === 'string' && typeof o['roomId'] === 'string' && typeof o['agentId'] === 'string' && typeof o['title'] === 'string' && typeof o['createdAt'] === 'number' && typeof o['updatedAt'] === 'number' ); } /** 补齐可选字段,旧版本数据也能读 */ function normalize(s: ChatSession): ChatSession { return { ...s, archived: !!s.archived, messageCount: s.messageCount ?? 0, workspaceId: s.workspaceId ?? null, }; }