Quellcode durchsuchen

fix(groups): 自己发的消息被误判为 agent + @ 标记

两处实测发现的问题:

1. 消息归属
   用 chat.provisionInfo.userMxId 判断「是不是我发的」,但那是打开 DM 后
   才有值的;群聊页从没打开过 DM → 恒为 undefined → 所有消息都判成 agent
   (实测:自己发的消息显示在左边,还带着 xhvrjtmwqh 这种名字)。
   改为从 session.me() 推导 Matrix ID,不依赖 chat 状态。

2. @ 标记
   消息里提取 m.mentions.user_ids,气泡上显示「@ 2」并 hover 显示被 @ 的人。
   Matrix 的 mention 是结构化的,不能用文本正则推断。

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct vor 1 Woche
Ursprung
Commit
71e95b343e

+ 38 - 3
src/app/components/domain/message-bubble/message-bubble.ts

@@ -33,8 +33,21 @@ import { ToolActivityComponent } from '../tool-activity/tool-activity';
             DM 里对方只有一个,名字在头部已经写了,这里不重复。
             由调用方用 showSender 控制是否显示。
           -->
-          @if (showSender() && !own() && message().senderName) {
-            <span class="sender-name">{{ message().senderName }}</span>
+          @if (showSender() && !own()) {
+            <span class="sender-line">
+              @if (message().senderName) {
+                <span class="sender-name">{{ message().senderName }}</span>
+              }
+              <!--
+                被 @ 的标记。Matrix 的 mention 是结构化的(m.mentions),
+                不能用文本正则推断 —— 文本里的 @名字 只是给人看的。
+              -->
+              @if (mentionsWho().length > 0) {
+                <span class="mention-tag" [title]="'@ ' + mentionsWho().join('、')">
+                  <i class="fa-solid fa-at" aria-hidden="true"></i>{{ mentionsWho().length }}
+                </span>
+              }
+            </span>
           }
           @if (message().file; as file) {
             <button type="button" class="file-card" (click)="preview.emit(file!)">
@@ -105,12 +118,28 @@ import { ToolActivityComponent } from '../tool-activity/tool-activity';
       .bubble-row.own {
         justify-content: flex-end;
       }
-      .sender-name {
+      .sender-line {
+        display: inline-flex;
+        align-items: center;
+        gap: var(--sp-2);
         padding-left: var(--sp-1);
+      }
+      .sender-name {
         font-size: var(--fs-11);
         font-weight: 600;
         color: var(--muted);
       }
+      .mention-tag {
+        display: inline-flex;
+        align-items: center;
+        gap: 2px;
+        padding: 0 5px;
+        border-radius: var(--r-tag);
+        background: var(--primary-subtle);
+        color: var(--primary);
+        font-size: var(--fs-11);
+        line-height: 16px;
+      }
       .bubble-col {
         display: grid;
         gap: var(--sp-1);
@@ -269,6 +298,12 @@ export class MessageBubbleComponent {
   readonly own = input(false);
   /** 是否显示发送者名(群聊为 true,DM 为 false) */
   readonly showSender = input(false);
+  /** 群成员 Matrix ID → 展示名(用于把 mentions 显示成人能读的名字) */
+  readonly nameOf = input<(mxId: string) => string>((id) => id.replace(/^@/, '').split(':')[0]);
+
+  readonly mentionsWho = computed(() =>
+    (this.message().mentions ?? []).map((id) => this.nameOf()(id)),
+  );
 
   readonly retry = output<string>();
   readonly openTask = output<string>();

+ 2 - 0
src/app/core/api/models.ts

@@ -196,6 +196,8 @@ export interface Message {
   senderId?: string;
   /** 群聊:展示用名字(agent 的 botName,或人类昵称) */
   senderName?: string;
+  /** 这条消息 @ 了哪些人(Matrix ID)。群聊 UI 用来高亮「在叫我 / 叫我同事」 */
+  mentions?: string[];
   kind: MessageKind;
   text?: string;
   file?: FileCard;

+ 16 - 0
src/app/core/matrix-client.service.ts

@@ -34,6 +34,8 @@ export interface MxMessage {
   sender: string;
   /** 发送者显示名(群聊需要区分多个 agent;取不到时回落为 Matrix ID 的本地部分) */
   senderName?: string;
+  /** 这条消息 @ 了谁(`m.mentions.user_ids`)。UI 据此高亮「这条是在叫我」 */
+  mentions?: string[];
   body: string;
   ts: number;
   kind: 'text' | 'notice' | 'emote';
@@ -76,6 +78,19 @@ function senderDisplayName(room: { getMember(id: string): { name?: string } | nu
   return local || sender;
 }
 
+/**
+ * 从消息内容里取出被 @ 的成员。
+ *
+ * Matrix 的 mention 是结构化的(`m.mentions.user_ids`),不是从文本里正则匹配 ——
+ * 文本里的 `@名字` 只是给人看的,真正被 @ 的人在这个数组里。
+ */
+function extractMentions(content: Record<string, unknown>): string[] | undefined {
+  const m = content['m.mentions'] as { user_ids?: unknown } | undefined;
+  if (!m || !Array.isArray(m.user_ids)) return undefined;
+  const ids = m.user_ids.filter((x): x is string => typeof x === 'string');
+  return ids.length ? ids : undefined;
+}
+
 /** HTML 转义(formatted_body 里插入用户内容必须先转义) */
 function escapeHtml(s: string): string {
   return s
@@ -399,6 +414,7 @@ export class MatrixClientService implements OnDestroy {
         eventId: e.getId() ?? '',
         sender: e.getSender() ?? '',
         senderName: senderDisplayName(room, e.getSender() ?? ''),
+        mentions: extractMentions(e.getContent()),
         body: (e.getContent()['body'] as string) ?? '',
         ts: e.getTs(),
         kind: kindOf(String(e.getContent()['msgtype'] ?? '')),

+ 33 - 3
src/app/desktop/pages/groups/groups.ts

@@ -18,6 +18,7 @@ import { GroupsService } from '../../../core/groups.service';
 import { LiveService } from '../../../core/live.service';
 import { LoopGuardService } from '../../../core/loop-guard.service';
 import { MatrixClientService } from '../../../core/matrix-client.service';
+import { SessionService } from '../../../core/session.service';
 
 /**
  * 桌面·群聊(多数字生命协作)
@@ -138,6 +139,7 @@ import { MatrixClientService } from '../../../core/matrix-client.service';
                   [message]="m"
                   [own]="m.sender === 'me'"
                   [showSender]="true"
+                  [nameOf]="mxName"
                 />
               }
             }
@@ -546,6 +548,7 @@ export class DkGroupPage {
   private readonly live = inject(LiveService);
   private readonly chat = inject(ChatService);
   private readonly mx = inject(MatrixClientService);
+  private readonly session = inject(SessionService);
   private readonly route = inject(ActivatedRoute);
   private readonly router = inject(Router);
   private readonly device = inject(DeviceService);
@@ -617,13 +620,34 @@ export class DkGroupPage {
     this.scrollToBottom();
   }
 
+  /** Matrix ID → 展示名(群聊气泡里把 mentions 显示成人能读的名字) */
+  readonly mxName = (mxId: string): string => {
+    for (const a of this.live.agents()) {
+      if (a.channels?.matrixConfig?.userId === mxId) return a.name;
+    }
+    return mxId.replace(/^@/, '').split(':')[0];
+  };
+
   memberNames(roomId: string): string {
     const list = this.groups.membersOf()(roomId);
     return list.length ? list.map((a) => a.name).join('、') : '暂无数字生命';
   }
 
+  /** 我自己的 Matrix ID —— 从会话服务取,不依赖 chat.provisionInfo */
+  private readonly myMxId = computed(() => {
+    // session.me() 的 id 是平台 _User.objectId(小写后即 Matrix 本地部分)
+    const id = this.session.me()?.id;
+    return id ? `@${id.toLowerCase()}:fmode.cn` : '';
+  });
+
   private loadMessages(roomId: string): void {
-    const my = this.chat.provisionInfo?.userMxId ?? '';
+    /*
+     * 🔴 不能用 `chat.provisionInfo.userMxId` —— 那是**打开 DM 后**才有值的,
+     *    群聊页从没打开过 DM → 恒为 undefined → `my` 是空串 →
+     *    所有消息都被判成 agent(实测:自己发的消息显示在左边、还带着名字)。
+     *    改为从 session.me() 推导,不依赖 chat 的状态。
+     */
+    const my = this.chat.provisionInfo?.userMxId || this.myMxId();
     const raw = this.mx.timelineToMessages(roomId);
     /*
      * 🔴 发送者名字优先用**平台数据**(agent 的 botName),不用 Matrix 的 displayName。
@@ -638,6 +662,12 @@ export class DkGroupPage {
       const mx = a.channels?.matrixConfig?.userId;
       if (mx) nameByMx.set(mx, a.name);
     }
+    /*
+     * 自己的名字:用平台昵称(`用户9836`),不用 Matrix 的 displayName。
+     * Matrix 里那个名字可能是账号创建时随手设的(实测带着 emoji 尾巴),
+     * 在群里显示出来很奇怪。
+     */
+    if (my) nameByMx.set(my, this.session.me()?.name ?? '我');
     this.messages.set(
       raw
         .filter((m) => !!m.body?.trim())
@@ -646,12 +676,12 @@ export class DkGroupPage {
           conversationId: roomId,
           sender: m.sender === my ? ('me' as const) : ('agent' as const),
           senderId: m.sender,
-          senderName:
-            m.sender === my ? '我' : (nameByMx.get(m.sender) ?? m.senderName ?? m.sender),
+          senderName: nameByMx.get(m.sender) ?? m.senderName ?? m.sender,
           kind: m.kind,
           text: m.body,
           ts: new Date(m.ts).toISOString(),
           state: 'sent' as const,
+          mentions: m.mentions,
         })),
     );
   }