Prechádzať zdrojové kódy

fix(chat): 日志降噪 + typing 方向 + 历史重读 + 输入框滚动条

① SDK 日志降噪(控制台卡顿)
   matrix-js-sdk logger 默认输出 warn 及以上且带完整调用栈,
   push rule 告警每次 startClient 批量刷屏 + zone.js 栈 → 一次上百行
   → load() 后 setLevel('error'),取不到则静默跳过

② typing 方向反了(真 bug)
   onTyping 回传房间内所有成员(含自己),未过滤
   → 自己打字显示「对方正在输入」
   → 回调带 who,who===myUserId 时 return

③ 重开会话内容空白(真 bug)
   openReal 首读 timeline 时 /sync 尚未写入本地 store → 空数组,
   此后只靠 realtime 补,历史永远缺失
   → 新增 storeVersion signal(每次 SYNCING +1)
   → chat.service effect 订阅后重读 timeline
   → initialSyncLimit 20 → 50

④ 单行输入框出现滚动条
   overflow-y:auto 常开,border-box 下 scrollHeight 比 clientHeight 多 1~2px
   → 默认 hidden,syncHeight 判定真溢出才切 auto

⑤ 发消息无回复:非前端 bug
   实测:你的房间里有 1 条「你好」,sender 是你自己;
   房间成员含容器 @zgdcmgnqcd(join);
   容器自称 connected/listenerOk=true 但从未回复
   → 问题在容器侧监听链路

另发现:FmodeMatrixBinding 有 2 条 active(旧主人 + 你),
provision 按 {userPointer, agentId} 查,选房逻辑正确,属数据冗余

验证:ng test 503 / build / verify:ui 11 / verify:docs 48

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 1 týždeň pred
rodič
commit
5d75ddfb5b

+ 16 - 0
docs/00-tracking/操作追踪.md

@@ -857,3 +857,19 @@
 | **核心原则(写进包)** | 「**不要自己重写脚本**」——原版 1100 行覆盖变化检测/失败队列/token 自愈/任务残留清理/channel 探测/usage 聚合,复刻必然漏 |
 | 验证证据 | 云函数本地单测 **9/9** / 线上代码实测归一正确 / `verify:docs` **48 文件**(+1)|
 | 回滚基线 | `.local-backup/xlosi3m46q-v0.1.2.js`(v0.1.3 由它复制后增量修改)|
+
+### 2026-10-20 — 控制台卡顿降噪 + 会话三处逻辑修复 + 容器不回消息定位
+
+| 项 | 内容 |
+|---|---|
+| **用户反馈** | ① 控制台大量警告导致卡顿 ② 发消息无回复、无动画 ③ 重开看不到历史 ④ 输入时显示「lj-test 正在输入」(方向反了)⑤ 单行输入框出现滚动条 |
+| **① 控制台卡顿根因** | 🔴 `matrix-js-sdk` 的 logger 默认输出 **warn 及以上且每条带完整调用栈**。`Missing default global override push rule .m.rule.xxx` 在每次 `startClient` 批量刷屏,叠加 zone.js 调度栈 → 一次上百行。<br>**这些告警不影响收发**:push rule 是服务端推送规则,浏览器端本就不用 |
+| **✏️ 修复①** | `matrix-client.service.ts` `load()`:动态导入后 `logger.setLevel('error')`,收敛为只保留 error。取不到 logger 时静默跳过(不能因降噪失败影响连接)|
+| **④ typing 方向反了(真 bug)** | 🔴 `onTyping` 回传**房间内所有成员**的 typing 状态(含自己),`chat.service` 未过滤 → 自己打字时显示「对方正在输入」。<br>**修复**:回调带 `who`,`who === myUserId` 时直接 return |
+| **③ 重开会话内容空白(真 bug)** | 🔴 `openReal()` 首读 `timelineToMessages()` 时 `/sync` 尚未把历史写入本地 store → 读到空数组;此后只靠 realtime 事件补,**历史永远补不上**。<br>**修复**:`MatrixClientService` 新增 `storeVersion` signal(每次 SYNCING +1),`chat.service` 用 `effect` 订阅 → 重读 timeline(按 eventId 去重)。另 `initialSyncLimit` 20 → 50 |
+| **⑤ 单行滚动条** | 🔴 `overflow-y: auto` 常开;`box-sizing: border-box` 下 `scrollHeight` 常比 `clientHeight` 多 1~2px → 单行也冒滚动条。<br>**修复**:默认 `overflow-y: hidden`,`syncHeight()` 判定 `scrollHeight > capped + 1` 时才加 `.is-overflow` 切回 auto |
+| **② 发消息无回复(定位,非前端 bug)** | **实测证据链**:<br>· 用你的 Matrix 凭据读房间 `!miyB1DxRE...` → HTTP 200,**有 1 条消息「你好」,sender = `@xhvrjtmwqh:fmode.cn`(你自己)**<br>· 房间成员:`@xhvrjtmwqh` join + `@zgdcmgnqcd` join(容器在房间里)<br>· 容器 `channels.matrixConfig` 自称 `channelState:"connected"`、`listenerOk:true`<br>→ **消息已到 Matrix、容器在房间内,但容器从未回消息**。前端无异常 |
+| **🔴 容器侧问题(新增)** | 「自检绿、实际不回」。`listenerOk` 是容器**自报**,与真实收消息能力不符。需容器验证:是否真的订阅了房间事件(`/sync` 是否包含该房间)、收到后是否触发处理链路 |
+| **⚠️ 另一发现:重复绑定** | `FmodeMatrixBinding` 有 **2 条 active**:`sr2WiPsDyQ`(旧主人)room `!OIm7iWR6...`、`xhVRjTMwQH`(你)room `!miyB1DxRE...`。<br>provision 按 `{userPointer, agentId}` 查 → 你拿到自己的房间(**选房逻辑正确**),但旧绑定未清理属数据问题 |
+| 验证证据 | `ng test` **503** / `build` 通过 / `verify:ui` **11/11** / `verify:docs` **48 文件** |
+| 踩坑记录 | composer 注释里写了带反引号的 `` `.input.is-overflow` `` —— 在**模板字符串内**反引号直接截断样式块,报 `Failed to resolve styles at position 1`。已改为无反引号表述 |

+ 25 - 2
src/app/components/domain/composer/composer.ts

@@ -6,6 +6,7 @@ import {
   effect,
   input,
   output,
+  signal,
   viewChild,
 } from '@angular/core';
 import { UiProgress, UiSpinner } from '../../../ui';
@@ -50,6 +51,7 @@ import { UiProgress, UiSpinner } from '../../../ui';
       <textarea
         #area
         class="input"
+        [class.is-overflow]="overflowing()"
         rows="1"
         [placeholder]="placeholder()"
         [value]="draft()"
@@ -129,9 +131,23 @@ import { UiProgress, UiSpinner } from '../../../ui';
         font-size: var(--fs-17);
         line-height: 1.6;
         resize: none;
-        overflow-y: auto;
+        /*
+         * 🔴 默认不显示滚动条,只在内容真正超出上限后才出现(AC-04.5)。
+         *
+         * 原实现 overflow-y: auto 常开:box-sizing: border-box 下
+         * scrollHeight 常比 clientHeight 多 1~2px(舍入/边框),
+         * 于是单行也会冒出滚动条 —— 属测量误差而非内容溢出。
+         *
+         * hidden + 由 syncHeight() 把 height 顶到 scrollHeight:
+         * 未超上限时内容恰好填满、无滚动;超上限后 height 被 max-height 卡住,
+         * 此时才切 auto(见下方 .input.is-overflow 规则)。
+         */
+        overflow-y: hidden;
         box-sizing: border-box;
       }
+      .input.is-overflow {
+        overflow-y: auto;
+      }
       .input:focus-visible {
         outline: 2px solid var(--primary-ring);
         outline-offset: 1px;
@@ -169,6 +185,9 @@ export class ComposerComponent {
   readonly send = output<string>();
   readonly attach = output<FileList>();
 
+  /** 内容是否已超出 max-height(决定是否出现内部滚动条);模板绑定,须公开 */
+  readonly overflowing = signal(false);
+
   private readonly area = viewChild<ElementRef<HTMLTextAreaElement>>('area');
 
   readonly canSend = computed(() => this.draft().trim().length > 0 && !this.disabled() && !this.sending());
@@ -218,6 +237,10 @@ export class ComposerComponent {
     const target = el ?? this.area()?.nativeElement;
     if (!target) return;
     target.style.height = 'auto';
-    target.style.height = `${Math.min(target.scrollHeight, this.maxHeight())}px`;
+    const full = target.scrollHeight;
+    const capped = Math.min(full, this.maxHeight());
+    target.style.height = `${capped}px`;
+    // 内容确实超过上限才允许内部滚动(1px 容差吸收舍入误差)
+    this.overflowing.set(full > capped + 1);
   }
 }

+ 27 - 1
src/app/core/chat.service.ts

@@ -125,6 +125,27 @@ export class ChatService {
         s === 'connected' ? 'connected' : s === 'connecting' ? 'connecting' : 'disconnected',
       );
     });
+
+    /**
+     * 🔴 同步完成后重读 timeline(修复「重开会话内容空白」)。
+     *
+     * `openReal()` 首读 timeline 时,`/sync` 往往还没把历史事件写入本地 store
+     * → 读到空数组。此前只有 realtime 事件能把消息补进来,历史内容永远缺失。
+     * 这里订阅 store 版本号:每次 sync 落地后重新映射一次 timeline
+     * (已读消息按 eventId 去重,不会重复)。
+     */
+    effect(() => {
+      this.mx.storeVersion();
+      const rid = this.roomId;
+      const agentId = this._agentId();
+      if (!rid || !agentId) return;
+      const myUserId = this.provision?.userMxId ?? '';
+      const next = this.mapTimeline(this.mx.timelineToMessages(rid), myUserId);
+      if (next.length === 0) return;
+      this._messages.set(next);
+      if (this._state() === 'empty') this._state.set('ready');
+      this._hasMoreHistory.set(this.mx.paginationToken(rid) != null);
+    });
   }
 
   /** 打开会话(路由进入时调用) */
@@ -226,8 +247,13 @@ export class ChatService {
       if (my !== this.seq) return;
       this.appendIncoming(m, myUserId);
     });
-    this.unbindTyping = this.mx.onTyping(rid, (typing) => {
+    this.unbindTyping = this.mx.onTyping(rid, (typing, who) => {
       if (my !== this.seq) return;
+      /*
+       * 🔴 必须排除自己:`m.typing` 会广播**房间内所有成员**的输入状态(含自己)。
+       * 不过滤 → 用户自己打字时界面显示「对方正在输入」,方向完全相反。
+       */
+      if (myUserId && who === myUserId) return;
       this._peerTyping.set(typing);
     });
 

+ 38 - 3
src/app/core/matrix-client.service.ts

@@ -69,6 +69,13 @@ export class MatrixClientService implements OnDestroy {
   private readonly _syncState = signal<MxSyncState>('disconnected');
   readonly syncState = this._syncState.asReadonly();
 
+  /**
+   * 本地 store 版本号(每次 sync 完成 +1)。
+   * 聊天页 `effect` 订阅它 → 重读 timeline,解决「同步晚于首读 → 历史空白」。
+   */
+  private readonly _storeVersion = signal(0);
+  readonly storeVersion = this._storeVersion.asReadonly();
+
   private readonly _userId = signal<string>('');
   /** 当前登录的 Matrix 用户 id(`@xxx:fmode.cn`) */
   readonly userId = this._userId.asReadonly();
@@ -160,7 +167,20 @@ export class MatrixClientService implements OnDestroy {
       else if (state === 'ERROR') this._syncState.set('disconnected');
       else if (state === 'RECONNECTING') this._syncState.set('connecting');
       // 🔴 首个 SYNCING = 初次同步完成 → 之后的消息才算「实时」
-      if (state === 'SYNCING') this.initialSyncDone = true;
+      if (state === 'SYNCING') {
+        this.initialSyncDone = true;
+        /*
+         * 🔴 通知「timeline 已就绪」。
+         *
+         * `startClient()` 是异步的:`openReal()` 里紧接着读 `timelineToMessages()`
+         * 时,房间的历史事件**往往还没落到本地 store** → 拿到空数组;
+         * 而后续只依赖 realtime 事件补消息,历史内容永远补不上
+         * (表现为:刷新后会话空白、重开看不到之前的内容)。
+         *
+         * 每次 SYNCING 递增版本号,聊天页据此重读一次 timeline。
+         */
+        this._storeVersion.update((n) => n + 1);
+      }
     });
 
     // 收到邀请自动接受(Agent 侧建房的场景)
@@ -174,7 +194,7 @@ export class MatrixClientService implements OnDestroy {
     // 全局消息流(未读计数 + 聊天页复用)
     this.bindGlobalMessageStream();
 
-    this.client.startClient({ initialSyncLimit: 20 });
+    this.client.startClient({ initialSyncLimit: 50 });
   }
 
   /** 断开并清理(**仅退出登录时**调用) */
@@ -459,7 +479,22 @@ export class MatrixClientService implements OnDestroy {
 
   /** 动态导入 SDK(NFR-02:不进主包) */
   private async load(): Promise<typeof import('matrix-js-sdk')> {
-    if (!this.sdk) this.sdk = await import('matrix-js-sdk');
+    if (!this.sdk) {
+      this.sdk = await import('matrix-js-sdk');
+      /*
+       * 🔴 SDK 日志降噪(否则控制台卡顿)。
+       *
+       * matrix-js-sdk 的 logger 默认输出 **warn 及以上**,且每条都带完整调用栈
+       * (push rule 缺失的告警会在每次 startClient 时批量刷屏,
+       *   叠加 zone.js 的调度栈,一次上百行)。
+       * 这些告警不影响收发:push rule 是服务端推送规则,浏览器端本就不用。
+       * 故收敛为只保留 error;取不到 logger 时静默跳过,绝不影响连接。
+       */
+      const sdkAny = this.sdk as { logger?: { setLevel?: (l: string) => void } };
+      if (sdkAny.logger && typeof sdkAny.logger.setLevel === 'function') {
+        sdkAny.logger.setLevel('error');
+      }
+    }
     return this.sdk;
   }
 }