|
@@ -12,6 +12,8 @@ import { DEMO_MESSAGES } from './api/demo-data';
|
|
|
import { environment } from '../../environments/environment';
|
|
import { environment } from '../../environments/environment';
|
|
|
import { LiveService } from './live.service';
|
|
import { LiveService } from './live.service';
|
|
|
import { SessionService } from './session.service';
|
|
import { SessionService } from './session.service';
|
|
|
|
|
+import { UnreadService } from './unread.service';
|
|
|
|
|
+import { MxMessage, MatrixClientService } from './matrix-client.service';
|
|
|
|
|
|
|
|
export type ConnectionState = 'connecting' | 'connected' | 'disconnected';
|
|
export type ConnectionState = 'connecting' | 'connected' | 'disconnected';
|
|
|
|
|
|
|
@@ -20,15 +22,27 @@ export type ConnectionState = 'connecting' | 'connected' | 'disconnected';
|
|
|
*
|
|
*
|
|
|
* 职责:provision → 连接 → 房间 → 收发 → 分页,对应 SPEC FR-04 各条 AC。
|
|
* 职责:provision → 连接 → 房间 → 收发 → 分页,对应 SPEC FR-04 各条 AC。
|
|
|
*
|
|
*
|
|
|
- * ⚠️ **本文件不直接使用 matrix-js-sdk**:SDK 调用收敛在 `MatrixClientService`
|
|
|
|
|
- * (懒加载 chunk,不进主包 —— NFR-02)。本服务负责状态编排与消息模型归一。
|
|
|
|
|
- * Matrix 客户端在演示模式下不实例化。
|
|
|
|
|
|
|
+ * ## 两条分支(由 `environment.useMockData` 单开关决定)
|
|
|
|
|
+ *
|
|
|
|
|
+ * - **演示态**:用 `DEMO_MESSAGES` 填充,发送走 `simulateSend()`(不产生真实请求)
|
|
|
|
|
+ * - **真实态**:`provisionMatrix` 取服务端签发凭据 → `MatrixClientService.connect()`
|
|
|
|
|
+ * → `ensureRoom()` → `/sync` 实时收发
|
|
|
|
|
+ *
|
|
|
|
|
+ * ⚠️ **本文件不直接 import `matrix-js-sdk`**:SDK 调用收敛在 `MatrixClientService`
|
|
|
|
|
+ * (懒加载 chunk,不进主包 —— NFR-02)。本服务只做状态编排与消息模型归一。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ## 凭据来源(黄金规则 9)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 真实态下 accessToken **一律来自 provision 云函数**(服务端签发);
|
|
|
|
|
+ * 前端不派生任何密码,也不持有任何长期密钥。
|
|
|
*/
|
|
*/
|
|
|
@Injectable({ providedIn: 'root' })
|
|
@Injectable({ providedIn: 'root' })
|
|
|
export class ChatService {
|
|
export class ChatService {
|
|
|
private readonly api = inject(FmodeApi);
|
|
private readonly api = inject(FmodeApi);
|
|
|
private readonly live = inject(LiveService);
|
|
private readonly live = inject(LiveService);
|
|
|
private readonly session = inject(SessionService);
|
|
private readonly session = inject(SessionService);
|
|
|
|
|
+ private readonly mx = inject(MatrixClientService);
|
|
|
|
|
+ private readonly unread = inject(UnreadService);
|
|
|
|
|
|
|
|
private readonly _agentId = signal<string | null>(null);
|
|
private readonly _agentId = signal<string | null>(null);
|
|
|
private readonly _messages = signal<Message[]>([]);
|
|
private readonly _messages = signal<Message[]>([]);
|
|
@@ -39,6 +53,11 @@ export class ChatService {
|
|
|
private readonly _uploading = signal(false);
|
|
private readonly _uploading = signal(false);
|
|
|
private readonly _uploadPct = signal(0);
|
|
private readonly _uploadPct = signal(0);
|
|
|
private readonly _draft = signal('');
|
|
private readonly _draft = signal('');
|
|
|
|
|
+ private readonly _peerTyping = signal(false);
|
|
|
|
|
+ /** 正在加载更早历史(防重入,AC-04.4) */
|
|
|
|
|
+ private readonly _paginating = signal(false);
|
|
|
|
|
+ /** 是否可能还有更早历史 */
|
|
|
|
|
+ private readonly _hasMoreHistory = signal(false);
|
|
|
|
|
|
|
|
readonly agentId = this._agentId.asReadonly();
|
|
readonly agentId = this._agentId.asReadonly();
|
|
|
readonly messages = this._messages.asReadonly();
|
|
readonly messages = this._messages.asReadonly();
|
|
@@ -49,6 +68,12 @@ export class ChatService {
|
|
|
readonly uploading = this._uploading.asReadonly();
|
|
readonly uploading = this._uploading.asReadonly();
|
|
|
readonly uploadPct = this._uploadPct.asReadonly();
|
|
readonly uploadPct = this._uploadPct.asReadonly();
|
|
|
readonly draft = this._draft.asReadonly();
|
|
readonly draft = this._draft.asReadonly();
|
|
|
|
|
+ /** 对方正在输入(真实态由 typing 事件驱动) */
|
|
|
|
|
+ readonly peerTyping = this._peerTyping.asReadonly();
|
|
|
|
|
+ /** 正在加载更早历史 */
|
|
|
|
|
+ readonly paginating = this._paginating.asReadonly();
|
|
|
|
|
+ /** 是否可能还有更早历史(真实态:上次分页仍有返回) */
|
|
|
|
|
+ readonly hasMoreHistory = this._hasMoreHistory.asReadonly();
|
|
|
|
|
|
|
|
readonly agent = computed<Agent | undefined>(() => {
|
|
readonly agent = computed<Agent | undefined>(() => {
|
|
|
const id = this._agentId();
|
|
const id = this._agentId();
|
|
@@ -58,6 +83,24 @@ export class ChatService {
|
|
|
/** 序号防串扰(存量 seq 模式):切会话时丢弃过期回包 */
|
|
/** 序号防串扰(存量 seq 模式):切会话时丢弃过期回包 */
|
|
|
private seq = 0;
|
|
private seq = 0;
|
|
|
private provision: MatrixProvision | null = null;
|
|
private provision: MatrixProvision | null = null;
|
|
|
|
|
+ /** 真实态下的当前房间 id */
|
|
|
|
|
+ private roomId: string | null = null;
|
|
|
|
|
+ /** 事件订阅解绑函数(切会话必须调用,防串扰) */
|
|
|
|
|
+ private unbindMessage: (() => void) | null = null;
|
|
|
|
|
+ private unbindTyping: (() => void) | null = null;
|
|
|
|
|
+ /** 全局未读监听解绑(仅登出/销毁时调用) */
|
|
|
|
|
+ private unbindUnread: (() => void) | null = null;
|
|
|
|
|
+ /**
|
|
|
|
|
+ * agentId → roomId 映射(未读归属反查)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 🔴 **持久化**(localStorage,按用户隔离):未读计数需要「某房间的新消息属于哪个
|
|
|
|
|
+ * Agent」,而 roomId 只有在**打开过该会话**后才由 provision 返回。
|
|
|
|
|
+ * 不持久化的话,每次刷新都会丢失映射 → 只有本次打开过的会话能计未读。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ⚠️ **已知限制**:从未打开过的 Agent 会话,其消息不计未读
|
|
|
|
|
+ * (因为拿不到 roomId ↔ agentId 的对应关系)。属可接受折损,已在文档披露。
|
|
|
|
|
+ */
|
|
|
|
|
+ private readonly roomAgentMap = new Map<string, string>();
|
|
|
|
|
|
|
|
get provisionInfo(): MatrixProvision | null {
|
|
get provisionInfo(): MatrixProvision | null {
|
|
|
return this.provision;
|
|
return this.provision;
|
|
@@ -68,52 +111,214 @@ export class ChatService {
|
|
|
if (this._agentId() === agentId && this._state() === 'ready') return;
|
|
if (this._agentId() === agentId && this._state() === 'ready') return;
|
|
|
const my = ++this.seq;
|
|
const my = ++this.seq;
|
|
|
|
|
|
|
|
|
|
+ this.teardownSubscriptions();
|
|
|
this._agentId.set(agentId);
|
|
this._agentId.set(agentId);
|
|
|
this._state.set('loading');
|
|
this._state.set('loading');
|
|
|
this._error.set(null);
|
|
this._error.set(null);
|
|
|
this._messages.set([]);
|
|
this._messages.set([]);
|
|
|
this._draft.set('');
|
|
this._draft.set('');
|
|
|
|
|
+ this._peerTyping.set(false);
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
await this.live.ensureLoaded();
|
|
await this.live.ensureLoaded();
|
|
|
- const agent = this.live.agentById(agentId);
|
|
|
|
|
if (my !== this.seq) return;
|
|
if (my !== this.seq) return;
|
|
|
|
|
|
|
|
|
|
+ const agent = this.live.agentById(agentId);
|
|
|
if (!agent) {
|
|
if (!agent) {
|
|
|
this._state.set('error');
|
|
this._state.set('error');
|
|
|
this._error.set('数字生命不存在');
|
|
this._error.set('数字生命不存在');
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 未接入的数字生命不应能进入会话(路由层已拦截,此处兜底)
|
|
|
|
|
this._connection.set('connecting');
|
|
this._connection.set('connecting');
|
|
|
- await this.api.provisionMatrix(agentId);
|
|
|
|
|
- if (my !== this.seq) return;
|
|
|
|
|
|
|
|
|
|
- this._connection.set('connected');
|
|
|
|
|
- this._messages.set(this.seedMessages(agentId));
|
|
|
|
|
- this._state.set(this._messages().length === 0 ? 'empty' : 'ready');
|
|
|
|
|
|
|
+ if (environment.useMockData) {
|
|
|
|
|
+ // ---- 演示态 ----
|
|
|
|
|
+ this.provision = await this.api.provisionMatrix(agentId);
|
|
|
|
|
+ if (my !== this.seq) return;
|
|
|
|
|
+ this._connection.set('connected');
|
|
|
|
|
+ this._messages.set(this.seedMessages(agentId));
|
|
|
|
|
+ this._state.set(this._messages().length === 0 ? 'empty' : 'ready');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 真实态 ----
|
|
|
|
|
+ await this.openReal(agentId, my);
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
if (my !== this.seq) return;
|
|
if (my !== this.seq) return;
|
|
|
this._connection.set('disconnected');
|
|
this._connection.set('disconnected');
|
|
|
this._state.set('error');
|
|
this._state.set('error');
|
|
|
- this._error.set(e instanceof Error ? e.message : '会话连接失败');
|
|
|
|
|
|
|
+ this._error.set(msgOf(e));
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 真实态开启流程。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 失败时**分环节报错**(SPEC FR-04:不得用笼统文案掩盖具体环节):
|
|
|
|
|
+ * FMODE 开通 / Matrix 登录 / 房间创建。
|
|
|
|
|
+ */
|
|
|
|
|
+ private async openReal(agentId: string, my: number): Promise<void> {
|
|
|
|
|
+ // ① provision:拿服务端签发凭据(含绑定表命中的 roomId)
|
|
|
|
|
+ let prov: MatrixProvision;
|
|
|
|
|
+ try {
|
|
|
|
|
+ prov = await this.api.provisionMatrix(agentId);
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ if (my !== this.seq) return;
|
|
|
|
|
+ throw new Error(`FMODE 开通失败:${msgOf(e)}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (my !== this.seq) return;
|
|
|
|
|
+ this.provision = prov;
|
|
|
|
|
+
|
|
|
|
|
+ // ② 会话级连接(幂等;同身份重复进入不打断 sync —— 未读计数依赖它常驻)
|
|
|
|
|
+ try {
|
|
|
|
|
+ await this.mx.connectSession(prov.accessToken, prov.userMxId ?? '', prov.deviceId);
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ if (my !== this.seq) return;
|
|
|
|
|
+ throw new Error(`Matrix 登录失败:${msgOf(e)}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (my !== this.seq) return;
|
|
|
|
|
+
|
|
|
|
|
+ // ③ 房间(绑定表 roomId 优先)
|
|
|
|
|
+ const agentMxId = prov.agentMxId ?? '';
|
|
|
|
|
+ if (!agentMxId) throw new Error('房间创建失败:服务端未返回 Agent 身份');
|
|
|
|
|
+
|
|
|
|
|
+ let rid: string;
|
|
|
|
|
+ try {
|
|
|
|
|
+ rid = await this.mx.ensureRoom(agentMxId, {
|
|
|
|
|
+ roomId: prov.roomId,
|
|
|
|
|
+ agentName: this.live.agentById(agentId)?.name,
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ if (my !== this.seq) return;
|
|
|
|
|
+ throw new Error(`房间创建失败:${msgOf(e)}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (my !== this.seq) return;
|
|
|
|
|
+ this.roomId = rid;
|
|
|
|
|
+ // 记录 agentId ↔ roomId 映射并持久化(未读计数按房间归属到 Agent 时需要)
|
|
|
|
|
+ this.roomAgentMap.set(agentId, rid);
|
|
|
|
|
+ this.persistRoomMap();
|
|
|
|
|
+
|
|
|
|
|
+ // ④ 订阅实时事件(先订阅再读 timeline,避免中间漏事件)
|
|
|
|
|
+ const myUserId = prov.userMxId ?? '';
|
|
|
|
|
+ this.unbindMessage = this.mx.onMessage(rid, (m) => {
|
|
|
|
|
+ if (my !== this.seq) return;
|
|
|
|
|
+ this.appendIncoming(m, myUserId);
|
|
|
|
|
+ });
|
|
|
|
|
+ this.unbindTyping = this.mx.onTyping(rid, (typing) => {
|
|
|
|
|
+ if (my !== this.seq) return;
|
|
|
|
|
+ this._peerTyping.set(typing);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // ⑤ 读当前 timeline
|
|
|
|
|
+ this._messages.set(this.mapTimeline(this.mx.timelineToMessages(rid), myUserId));
|
|
|
|
|
+
|
|
|
|
|
+ // ⑥ 连接状态由 SDK 同步事件驱动
|
|
|
|
|
+ this._connection.set(this.mx.syncState() === 'connected' ? 'connected' : 'connecting');
|
|
|
|
|
+ this._state.set(this._messages().length === 0 ? 'empty' : 'ready');
|
|
|
|
|
+ // timeline pagination token 是否存在 = 更早可能有历史
|
|
|
|
|
+ this._hasMoreHistory.set(this.mx.paginationToken(rid) != null);
|
|
|
|
|
+
|
|
|
|
|
+ // ⑦ 进入会话 = 已读:基线推进到最新消息时间(清未读计数)
|
|
|
|
|
+ const latest = this._messages().reduce((max, m) => {
|
|
|
|
|
+ const t = Date.parse(m.ts);
|
|
|
|
|
+ return Number.isFinite(t) && t > max ? t : max;
|
|
|
|
|
+ }, 0);
|
|
|
|
|
+ this.unread.markRead(agentId, latest || Date.now());
|
|
|
|
|
+ this.ensureUnreadListener();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 全局未读监听:Agent 消息到达且**当前不在该会话**时 +1。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 只注册一次(幂等);由 `MatrixClientService` 的全局消息流多路复用,
|
|
|
|
|
+ * 故不会随会话切换重复挂监听。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 由 `MatrixSessionService.bootstrap()` 在**建连前**调用 —— 保证连接建立后
|
|
|
|
|
+ * 到用户首次进入会话之间的消息也会被计入未读。
|
|
|
|
|
+ */
|
|
|
|
|
+ ensureUnreadListener(): void {
|
|
|
|
|
+ if (this.unbindUnread) return;
|
|
|
|
|
+ this.unbindUnread = this.mx.onAnyMessage((m) => {
|
|
|
|
|
+ const myUserId = this.provision?.userMxId ?? '';
|
|
|
|
|
+ // 只计 Agent 发来的
|
|
|
|
|
+ if (myUserId && m.sender === myUserId) return;
|
|
|
|
|
+ const agentId = this.agentIdByRoom(m.roomId);
|
|
|
|
|
+ if (!agentId) return;
|
|
|
|
|
+ // 正在看的会话 → 直接推进已读基线,不产生未读
|
|
|
|
|
+ if (agentId === this._agentId()) {
|
|
|
|
|
+ this.unread.markRead(agentId, m.ts);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 判定交给 UnreadService:初次同步回填阶段只推基线、不计数
|
|
|
|
|
+ this.unread.onIncoming(agentId, m.ts, !this.mx.hasInitialSync);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** roomId → agentId(用已记录的会话映射反查) */
|
|
|
|
|
+ private agentIdByRoom(roomId: string): string | undefined {
|
|
|
|
|
+ for (const [agentId, rid] of this.roomAgentMap) {
|
|
|
|
|
+ if (rid === roomId) return agentId;
|
|
|
|
|
+ }
|
|
|
|
|
+ return undefined;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- agentId ↔ roomId 映射持久化 ----------
|
|
|
|
|
+
|
|
|
|
|
+ private roomMapKey(): string {
|
|
|
|
|
+ const uid = this.session.me()?.id ?? 'anon';
|
|
|
|
|
+ return `fmode_room_map_${uid}`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private persistRoomMap(): void {
|
|
|
|
|
+ try {
|
|
|
|
|
+ localStorage.setItem(this.roomMapKey(), JSON.stringify([...this.roomAgentMap]));
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ /* 隐私模式忽略 */
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 从 localStorage 载入映射(Shell 启动时调用一次) */
|
|
|
|
|
+ loadRoomMap(): void {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const raw = localStorage.getItem(this.roomMapKey());
|
|
|
|
|
+ if (!raw) return;
|
|
|
|
|
+ for (const [agentId, rid] of JSON.parse(raw) as [string, string][]) {
|
|
|
|
|
+ if (agentId && rid) this.roomAgentMap.set(agentId, rid);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ /* 忽略 */
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 关闭会话(离开聊天页)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 🔴 **不断开 Matrix 会话级连接** —— sync 常驻,否则离开后收不到消息,
|
|
|
|
|
+ * 未读计数永远无法累加。只解绑本房间的事件订阅。
|
|
|
|
|
+ */
|
|
|
close(): void {
|
|
close(): void {
|
|
|
this.seq++;
|
|
this.seq++;
|
|
|
|
|
+ this.teardownSubscriptions();
|
|
|
this.provision = null;
|
|
this.provision = null;
|
|
|
|
|
+ this.roomId = null;
|
|
|
this._agentId.set(null);
|
|
this._agentId.set(null);
|
|
|
this._messages.set([]);
|
|
this._messages.set([]);
|
|
|
this._state.set('idle');
|
|
this._state.set('idle');
|
|
|
this._error.set(null);
|
|
this._error.set(null);
|
|
|
this._connection.set('disconnected');
|
|
this._connection.set('disconnected');
|
|
|
this._draft.set('');
|
|
this._draft.set('');
|
|
|
|
|
+ this._peerTyping.set(false);
|
|
|
|
|
+ this._paginating.set(false);
|
|
|
|
|
+ this._hasMoreHistory.set(false);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
setDraft(text: string): void {
|
|
setDraft(text: string): void {
|
|
|
this._draft.set(text);
|
|
this._draft.set(text);
|
|
|
|
|
+ // 真实态下发 typing 通知(失败非致命)
|
|
|
|
|
+ if (this.roomId && !environment.useMockData) {
|
|
|
|
|
+ void this.mx.setTyping(this.roomId, text.length > 0);
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** 发送文本(AC-04.2);失败留痕可重试(AC-04.8) */
|
|
/** 发送文本(AC-04.2);失败留痕可重试(AC-04.8) */
|
|
@@ -138,11 +343,14 @@ export class ChatService {
|
|
|
this._sending.set(true);
|
|
this._sending.set(true);
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
- // 真实实现:把文本交给 MatrixClientService 发送到房间
|
|
|
|
|
- await this.simulateSend();
|
|
|
|
|
|
|
+ if (environment.useMockData) {
|
|
|
|
|
+ await this.simulateSend();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ await this.sendReal(body);
|
|
|
|
|
+ }
|
|
|
this.markSent(msg.id);
|
|
this.markSent(msg.id);
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
- this.markFailed(msg.id, e instanceof Error ? e.message : '发送失败');
|
|
|
|
|
|
|
+ this.markFailed(msg.id, msgOf(e));
|
|
|
} finally {
|
|
} finally {
|
|
|
this._sending.set(false);
|
|
this._sending.set(false);
|
|
|
}
|
|
}
|
|
@@ -181,13 +389,35 @@ export class ChatService {
|
|
|
]);
|
|
]);
|
|
|
}
|
|
}
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
- this._error.set(e instanceof Error ? e.message : '文件上传失败');
|
|
|
|
|
|
|
+ this._error.set(msgOf(e));
|
|
|
} finally {
|
|
} finally {
|
|
|
this._uploading.set(false);
|
|
this._uploading.set(false);
|
|
|
this._uploadPct.set(0);
|
|
this._uploadPct.set(0);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** 向上翻历史(AC-04.4);返回是否还有更早的消息 */
|
|
|
|
|
+ async paginate(): Promise<boolean> {
|
|
|
|
|
+ if (environment.useMockData || !this.roomId) return false;
|
|
|
|
|
+ // 防重入:滚动事件高频触发,加载中直接忽略,避免重复分页
|
|
|
|
|
+ if (this._paginating()) return true;
|
|
|
|
|
+ this._paginating.set(true);
|
|
|
|
|
+ const my = this.seq;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const before = this._messages().length;
|
|
|
|
|
+ const ok = await this.mx.paginateBack(this.roomId, 30);
|
|
|
|
|
+ if (my !== this.seq) return false;
|
|
|
|
|
+ const myUserId = this.provision?.userMxId ?? '';
|
|
|
|
|
+ const next = this.mapTimeline(this.mx.timelineToMessages(this.roomId), myUserId);
|
|
|
|
|
+ this._messages.set(next);
|
|
|
|
|
+ // 条数未增长 = 已到最早,停止继续请求
|
|
|
|
|
+ this._hasMoreHistory.set(ok && next.length > before);
|
|
|
|
|
+ return this._hasMoreHistory();
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (my === this.seq) this._paginating.set(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/** 重试失败消息(AC-04.8) */
|
|
/** 重试失败消息(AC-04.8) */
|
|
|
async retry(messageId: string): Promise<void> {
|
|
async retry(messageId: string): Promise<void> {
|
|
|
const msg = this._messages().find((m) => m.id === messageId);
|
|
const msg = this._messages().find((m) => m.id === messageId);
|
|
@@ -196,18 +426,72 @@ export class ChatService {
|
|
|
list.map((m) => (m.id === messageId ? { ...m, state: 'sending', error: undefined } : m)),
|
|
list.map((m) => (m.id === messageId ? { ...m, state: 'sending', error: undefined } : m)),
|
|
|
);
|
|
);
|
|
|
try {
|
|
try {
|
|
|
- await this.simulateSend();
|
|
|
|
|
|
|
+ if (environment.useMockData) {
|
|
|
|
|
+ await this.simulateSend();
|
|
|
|
|
+ } else if (msg.text) {
|
|
|
|
|
+ await this.sendReal(msg.text);
|
|
|
|
|
+ }
|
|
|
this.markSent(messageId);
|
|
this.markSent(messageId);
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
- this.markFailed(messageId, e instanceof Error ? e.message : '发送失败');
|
|
|
|
|
|
|
+ this.markFailed(messageId, msgOf(e));
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /** 模拟断线(演示控制台用;真实态由 MatrixClientService 的同步事件驱动) */
|
|
|
|
|
|
|
+ /** 模拟断线(演示控制台用;真实态由 SDK 同步事件驱动) */
|
|
|
setConnection(state: ConnectionState): void {
|
|
setConnection(state: ConnectionState): void {
|
|
|
this._connection.set(state);
|
|
this._connection.set(state);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // ---------- 内部 ----------
|
|
|
|
|
+
|
|
|
|
|
+ /** 发真实消息(真实态由 MatrixClientService 落地到房间) */
|
|
|
|
|
+ private async sendReal(text: string): Promise<void> {
|
|
|
|
|
+ if (!this.roomId) throw new Error('会话未就绪,请稍后重试');
|
|
|
|
|
+ await this.mx.sendText(this.roomId, text);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 归一 timeline → 领域消息 */
|
|
|
|
|
+ private mapTimeline(list: MxMessage[], myUserId: string): Message[] {
|
|
|
|
|
+ const cid = this._agentId() ?? '';
|
|
|
|
|
+ return list.map((m) => {
|
|
|
|
|
+ const file = MatrixClientService.parseFileCard(m.body);
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: m.eventId || `${m.ts}-${m.sender}`,
|
|
|
|
|
+ conversationId: cid,
|
|
|
|
|
+ sender: m.sender === myUserId ? ('me' as const) : ('agent' as const),
|
|
|
|
|
+ kind: m.kind as MessageKind,
|
|
|
|
|
+ text: file ? undefined : m.body,
|
|
|
|
|
+ file: file
|
|
|
|
|
+ ? {
|
|
|
|
|
+ kind: 'file' as const,
|
|
|
|
|
+ name: file.name,
|
|
|
|
|
+ size: file.size,
|
|
|
|
|
+ mimeType: file.mimeType,
|
|
|
|
|
+ url: file.url,
|
|
|
|
|
+ }
|
|
|
|
|
+ : undefined,
|
|
|
|
|
+ ts: new Date(m.ts).toISOString(),
|
|
|
|
|
+ state: 'sent' as const,
|
|
|
|
|
+ };
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 追加一条新到消息(按 eventId 去重) */
|
|
|
|
|
+ private appendIncoming(m: MxMessage, myUserId: string): void {
|
|
|
|
|
+ if (!this._agentId()) return;
|
|
|
|
|
+ if (m.eventId && this._messages().some((x) => x.id === m.eventId)) return;
|
|
|
|
|
+ const [mapped] = this.mapTimeline([m], myUserId);
|
|
|
|
|
+ this._messages.update((list) => [...list, mapped]);
|
|
|
|
|
+ if (this._state() === 'empty') this._state.set('ready');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private teardownSubscriptions(): void {
|
|
|
|
|
+ this.unbindMessage?.();
|
|
|
|
|
+ this.unbindTyping?.();
|
|
|
|
|
+ this.unbindMessage = null;
|
|
|
|
|
+ this.unbindTyping = null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private markSent(id: string): void {
|
|
private markSent(id: string): void {
|
|
|
this._messages.update((list) =>
|
|
this._messages.update((list) =>
|
|
|
list.map((m) => (m.id === id ? { ...m, state: 'sent', error: undefined } : m)),
|
|
list.map((m) => (m.id === id ? { ...m, state: 'sent', error: undefined } : m)),
|
|
@@ -215,11 +499,13 @@ export class ChatService {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private markFailed(id: string, error: string): void {
|
|
private markFailed(id: string, error: string): void {
|
|
|
- this._messages.update((list) => list.map((m) => (m.id === id ? { ...m, state: 'failed', error } : m)));
|
|
|
|
|
|
|
+ this._messages.update((list) =>
|
|
|
|
|
+ list.map((m) => (m.id === id ? { ...m, state: 'failed', error } : m)),
|
|
|
|
|
+ );
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private async simulateSend(): Promise<void> {
|
|
private async simulateSend(): Promise<void> {
|
|
|
- if (!environment.useMockData) return; // 真实模式由 Matrix 客户端负责
|
|
|
|
|
|
|
+ // 仅演示态使用
|
|
|
if (this._connection() !== 'connected') {
|
|
if (this._connection() !== 'connected') {
|
|
|
throw new Error('连接已断开,请稍后重试');
|
|
throw new Error('连接已断开,请稍后重试');
|
|
|
}
|
|
}
|
|
@@ -257,3 +543,8 @@ export class ChatService {
|
|
|
return y.toISOString();
|
|
return y.toISOString();
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+/** 统一错误文案提取 */
|
|
|
|
|
+function msgOf(e: unknown): string {
|
|
|
|
|
+ return e instanceof Error && e.message ? e.message : '未知错误';
|
|
|
|
|
+}
|