| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784 |
- 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_<userId>`) | 渲染缓存 + 删除黑名单;云端不可达时**不展示** |
- *
- * 同步方向是**单向**的:云端 → 本地(`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<ChatSession[]>([]);
- readonly all = this._all.asReadonly();
- /** 按 agent 分组的会话(未归档,已排序) */
- readonly byAgent = computed<Map<string, ChatSession[]>>(() => {
- const m = new Map<string, ChatSession[]>();
- 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<string | null>(null);
- readonly error = this._error.asReadonly();
- /** 上次云端拉取失败的原因(`null` = 正常);供调用方判断能否继续 */
- isCloudStale(): boolean {
- return !this._ready();
- }
- /**
- * 用户主动移除过的会话 id。
- *
- * 这些线程在 Matrix 里**仍然存在**(消息没删),但用户不想再看到它们。
- * 扫描重建时必须跳过,否则删了立刻被扫回来。
- */
- private readonly _removed = signal<Set<string>>(new Set());
- /**
- * 云端已软删的线程根(跨设备有效)。
- * 与本地 `_removed` 一起构成「哪些线程不允许重新出现」的黑名单。
- * 注意:目录已不再从 Matrix 扫描重建,它现在的用途是兜住
- * 「云端列表与墓碑不一致」的历史数据。
- */
- private cloudRemoved = new Set<string>();
- /**
- * 每个会话上一次把元信息推到云端的时间(节流用,见 `noteMessage`)。
- *
- * 放在内存里而不是持久化:它只用于「别太频繁地打接口」,
- * 刷新后重新计时即可,不影响任何数据的正确性。
- */
- private readonly lastPushAt = new Map<string, number>();
- /**
- * 本地已建、但还没确认推到云端的会话 id。
- *
- * 用途很窄:`hydrateFromCloud` 整体替换列表时,这些会话在云端还不存在,
- * 必须保留 —— 否则「新建对话 → 换 URL → 重新拉取云端」会把刚建的会话抹掉。
- * 推送成功后立即移除,避免长期存在。
- */
- private readonly _pendingPush = new Set<string>();
- /**
- * 从 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<number> {
- /*
- * 🔴 先确保本地缓存已载入:生产由页面在进入路由时调用 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<void> {
- 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<void>): Promise<void> {
- 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<void> {
- 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<ChatSession> = { 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<string> {
- 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>): 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<string, unknown>;
- 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,
- };
- }
|