|
@@ -0,0 +1,704 @@
|
|
|
|
|
+import {
|
|
|
|
|
+ ChangeDetectionStrategy,
|
|
|
|
|
+ Component,
|
|
|
|
|
+ computed,
|
|
|
|
|
+ effect,
|
|
|
|
|
+ ElementRef,
|
|
|
|
|
+ inject,
|
|
|
|
|
+ signal,
|
|
|
|
|
+ viewChild,
|
|
|
|
|
+} from '@angular/core';
|
|
|
|
|
+import { ActivatedRoute, Router } from '@angular/router';
|
|
|
|
|
+import { UiAlert, UiAvatar, UiButton, UiEmpty, UiSkeleton } from '../../../ui';
|
|
|
|
|
+import { MessageBubbleComponent } from '../../../components/domain/message-bubble/message-bubble';
|
|
|
|
|
+import { Agent, Message } from '../../../core/api/models';
|
|
|
|
|
+import { ChatService } from '../../../core/chat.service';
|
|
|
|
|
+import { DeviceService } from '../../../core/device.service';
|
|
|
|
|
+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';
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 桌面·群聊(多数字生命协作)
|
|
|
|
|
+ *
|
|
|
|
|
+ * ## 与单聊页的区别
|
|
|
|
|
+ *
|
|
|
|
|
+ * | | 单聊 `chat.ts` | 群聊(本页) |
|
|
|
|
|
+ * |---|---|---|
|
|
|
|
|
+ * | 消息归属 | `me` / `agent` 两态 | **每个 agent 都能发言**,要显示是谁 |
|
|
|
|
|
+ * | 右栏 | 单容器运行状态 + 成果 | **成员列表**(含各自状态) |
|
|
|
|
|
+ * | 输入 | 直接发 | 可 **@ 单个 / @ 多个** |
|
|
|
|
|
+ *
|
|
|
|
|
+ * ## 实测依据(2026-10-21 P0)
|
|
|
|
|
+ *
|
|
|
|
|
+ * - 群里 @ 单个 agent:3 秒响应
|
|
|
|
|
+ * - 群里 @ 多个 agent:两个都响应,互不干扰
|
|
|
|
|
+ * - **非主人发言会被拒绝**:容器只认自己的 owner
|
|
|
|
|
+ * → 所以群成员必须是同一主人(或已授权的成员)
|
|
|
|
|
+ */
|
|
|
|
|
+@Component({
|
|
|
|
|
+ selector: 'app-dk-group-page',
|
|
|
|
|
+ changeDetection: ChangeDetectionStrategy.OnPush,
|
|
|
|
|
+ imports: [UiAlert, UiAvatar, UiButton, UiEmpty, UiSkeleton, MessageBubbleComponent],
|
|
|
|
|
+ template: `
|
|
|
|
|
+ <div class="group-layout">
|
|
|
|
|
+ <!-- 群列表 -->
|
|
|
|
|
+ <aside class="col list-col" aria-label="群聊列表">
|
|
|
|
|
+ <header class="col-head">
|
|
|
|
|
+ <h2>工作组</h2>
|
|
|
|
|
+ <span class="count">{{ groups.rooms().length }}</span>
|
|
|
|
|
+ </header>
|
|
|
|
|
+ <div class="list-scroll">
|
|
|
|
|
+ @if (groups.rooms().length === 0) {
|
|
|
|
|
+ <ui-empty
|
|
|
|
|
+ title="还没有工作组"
|
|
|
|
|
+ description="在工作组里可以由多名数字生命协作。需要公司管理员创建。"
|
|
|
|
|
+ />
|
|
|
|
|
+ } @else {
|
|
|
|
|
+ @for (r of groups.rooms(); track r.roomId) {
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="group-row"
|
|
|
|
|
+ [class.active]="r.roomId === roomId()"
|
|
|
|
|
+ (click)="open(r.roomId)"
|
|
|
|
|
+ >
|
|
|
|
|
+ <span class="group-icon" aria-hidden="true">
|
|
|
|
|
+ <i class="fa-solid fa-users"></i>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span class="group-meta">
|
|
|
|
|
+ <strong class="ellipsis">{{ r.name }}</strong>
|
|
|
|
|
+ <small class="ellipsis">{{ memberNames(r.roomId) }}</small>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ @if (r.memberIds.length > 2) {
|
|
|
|
|
+ <span class="badge">{{ r.memberIds.length - 1 }}</span>
|
|
|
|
|
+ }
|
|
|
|
|
+ </button>
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </aside>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 群会话 -->
|
|
|
|
|
+ <section class="col main-col" aria-label="群消息">
|
|
|
|
|
+ @if (!roomId()) {
|
|
|
|
|
+ <div class="center-fill">
|
|
|
|
|
+ <ui-empty title="选择一个工作组" description="群内可以 @ 一个或多个数字生命。" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ } @else {
|
|
|
|
|
+ <header class="chat-head">
|
|
|
|
|
+ <span class="group-icon lg" aria-hidden="true">
|
|
|
|
|
+ <i class="fa-solid fa-users"></i>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <div class="chat-title">
|
|
|
|
|
+ <strong>{{ currentRoom()?.name }}</strong>
|
|
|
|
|
+ <small class="ellipsis">{{ members().length }} 名数字生命</small>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <ui-button
|
|
|
|
|
+ variant="ghost"
|
|
|
|
|
+ shape="circle"
|
|
|
|
|
+ icon="fa-solid fa-user-group"
|
|
|
|
|
+ ariaLabel="成员"
|
|
|
|
|
+ [class.on]="membersOpen()"
|
|
|
|
|
+ (click)="membersOpen.set(!membersOpen())"
|
|
|
|
|
+ />
|
|
|
|
|
+ </header>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 回环提示(群聊是高发场景:多个 agent 互相回) -->
|
|
|
|
|
+ @if (loop.paused()) {
|
|
|
|
|
+ <div class="banner-hold">
|
|
|
|
|
+ <ui-alert type="warning" title="检测到自动对话循环,已暂停">
|
|
|
|
|
+ <p class="loop-detail">{{ loop.pauseDetail() }}</p>
|
|
|
|
|
+ <ui-button variant="outline" size="sm" (click)="loop.resume()">继续</ui-button>
|
|
|
|
|
+ </ui-alert>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ <div class="messages" #msgList (scroll)="onScroll()">
|
|
|
|
|
+ @if (loading()) {
|
|
|
|
|
+ <div class="skeleton-hold">
|
|
|
|
|
+ @for (i of [1, 2, 3]; track i) {
|
|
|
|
|
+ <ui-skeleton variant="rect" height="56px" />
|
|
|
|
|
+ }
|
|
|
|
|
+ </div>
|
|
|
|
|
+ } @else if (messages().length === 0) {
|
|
|
|
|
+ <ui-empty title="工作组还没有消息" description="在下方 @ 一名或多名数字生命开始协作。" />
|
|
|
|
|
+ } @else {
|
|
|
|
|
+ @for (m of messages(); track m.id) {
|
|
|
|
|
+ <app-message-bubble
|
|
|
|
|
+ [message]="m"
|
|
|
|
|
+ [own]="m.sender === 'me'"
|
|
|
|
|
+ [showSender]="true"
|
|
|
|
|
+ />
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- @ 选择器 -->
|
|
|
|
|
+ @if (mentionOpen()) {
|
|
|
|
|
+ <div class="mention-bar">
|
|
|
|
|
+ <span class="mention-hint">选择要 @ 的成员(可多选):</span>
|
|
|
|
|
+ <div class="mention-chips">
|
|
|
|
|
+ @for (a of members(); track a.id) {
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="mention-chip"
|
|
|
|
|
+ [class.on]="mentionTargets().has(a.id)"
|
|
|
|
|
+ (click)="toggleMention(a)"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ a.name }}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ }
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ <div class="composer-hold">
|
|
|
|
|
+ @if (mentionTargets().size > 0) {
|
|
|
|
|
+ <div class="mention-preview">
|
|
|
|
|
+ <i class="fa-solid fa-at" aria-hidden="true"></i>
|
|
|
|
|
+ {{ mentionLabel() }}
|
|
|
|
|
+ <button type="button" class="clear" (click)="clearMention()" aria-label="取消 @">
|
|
|
|
|
+ <i class="fa-solid fa-xmark" aria-hidden="true"></i>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ }
|
|
|
|
|
+ <div class="composer-row">
|
|
|
|
|
+ <ui-button
|
|
|
|
|
+ variant="ghost"
|
|
|
|
|
+ shape="circle"
|
|
|
|
|
+ icon="fa-solid fa-at"
|
|
|
|
|
+ ariaLabel="@ 成员"
|
|
|
|
|
+ (click)="mentionOpen.set(!mentionOpen())"
|
|
|
|
|
+ />
|
|
|
|
|
+ <textarea
|
|
|
|
|
+ #input
|
|
|
|
|
+ class="input"
|
|
|
|
|
+ rows="1"
|
|
|
|
|
+ [placeholder]="mentionTargets().size ? '输入内容,回车发送' : '输入内容(可先点 @ 指定成员)'"
|
|
|
|
|
+ [value]="draft()"
|
|
|
|
|
+ (input)="onInput($event)"
|
|
|
|
|
+ (keydown)="onKeydown($event)"
|
|
|
|
|
+ ></textarea>
|
|
|
|
|
+ <ui-button
|
|
|
|
|
+ variant="primary"
|
|
|
|
|
+ shape="circle"
|
|
|
|
|
+ icon="fa-solid fa-paper-plane"
|
|
|
|
|
+ ariaLabel="发送"
|
|
|
|
|
+ [disabled]="!draft().trim() || sending()"
|
|
|
|
|
+ (click)="send()"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ }
|
|
|
|
|
+ </section>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 成员栏 -->
|
|
|
|
|
+ @if (roomId() && membersOpen()) {
|
|
|
|
|
+ <aside class="col members-col" aria-label="群成员">
|
|
|
|
|
+ <header class="col-head">
|
|
|
|
|
+ <h2>成员</h2>
|
|
|
|
|
+ <ui-button
|
|
|
|
|
+ variant="ghost"
|
|
|
|
|
+ shape="circle"
|
|
|
|
|
+ icon="fa-solid fa-xmark"
|
|
|
|
|
+ ariaLabel="收起"
|
|
|
|
|
+ (click)="membersOpen.set(false)"
|
|
|
|
|
+ />
|
|
|
|
|
+ </header>
|
|
|
|
|
+ <div class="member-list">
|
|
|
|
|
+ @for (a of members(); track a.id) {
|
|
|
|
|
+ <div class="member-row">
|
|
|
|
|
+ <ui-avatar [name]="a.name" [size]="'sm'" />
|
|
|
|
|
+ <span class="member-meta">
|
|
|
|
|
+ <strong class="ellipsis">{{ a.name }}</strong>
|
|
|
|
|
+ <small>{{ a.status === 'running' ? '执行中' : a.status === 'idle' ? '空闲' : a.status }}</small>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span class="dot" [class.busy]="a.status === 'running'" aria-hidden="true"></span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ }
|
|
|
|
|
+ @if (members().length === 0) {
|
|
|
|
|
+ <p class="empty-hint">该群的数字生命尚未绑定到平台账号。</p>
|
|
|
|
|
+ }
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </aside>
|
|
|
|
|
+ }
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `,
|
|
|
|
|
+ styles: [
|
|
|
|
|
+ `
|
|
|
|
|
+ :host {
|
|
|
|
|
+ display: block;
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ min-height: 0;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ }
|
|
|
|
|
+ .group-layout {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-template-columns: var(--desk-list-w) minmax(0, 1fr);
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ min-height: 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ .group-layout:has(> .members-col) {
|
|
|
|
|
+ grid-template-columns: var(--desk-list-w) minmax(0, 1fr) 260px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .col {
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ min-height: 0;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ background: var(--surface);
|
|
|
|
|
+ }
|
|
|
|
|
+ .list-col {
|
|
|
|
|
+ background: var(--surface-2);
|
|
|
|
|
+ border-right: 1px solid var(--edge);
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ }
|
|
|
|
|
+ .members-col {
|
|
|
|
|
+ background: var(--rail-surface);
|
|
|
|
|
+ border-left: 1px solid var(--edge);
|
|
|
|
|
+ }
|
|
|
|
|
+ .col-head {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ gap: var(--sp-2);
|
|
|
|
|
+ padding: var(--sp-4);
|
|
|
|
|
+ border-bottom: 1px solid var(--edge);
|
|
|
|
|
+ }
|
|
|
|
|
+ .col-head h2 {
|
|
|
|
|
+ margin: 0;
|
|
|
|
|
+ font-size: var(--fs-15);
|
|
|
|
|
+ color: var(--ink);
|
|
|
|
|
+ }
|
|
|
|
|
+ .count {
|
|
|
|
|
+ font-size: var(--fs-11);
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+ }
|
|
|
|
|
+ .list-scroll {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ min-height: 0;
|
|
|
|
|
+ overflow-y: auto;
|
|
|
|
|
+ padding: var(--sp-2);
|
|
|
|
|
+ }
|
|
|
|
|
+ .group-row {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: var(--sp-3);
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ min-height: 56px;
|
|
|
|
|
+ padding: var(--sp-2) var(--sp-3);
|
|
|
|
|
+ border: 0;
|
|
|
|
|
+ border-radius: var(--r-md);
|
|
|
|
|
+ background: transparent;
|
|
|
|
|
+ text-align: left;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ }
|
|
|
|
|
+ .group-row:hover {
|
|
|
|
|
+ background: var(--surface-hover);
|
|
|
|
|
+ }
|
|
|
|
|
+ .group-row.active {
|
|
|
|
|
+ background: var(--surface);
|
|
|
|
|
+ }
|
|
|
|
|
+ .group-icon {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ place-items: center;
|
|
|
|
|
+ width: 32px;
|
|
|
|
|
+ height: 32px;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+ border-radius: var(--r-sm);
|
|
|
|
|
+ background: var(--primary-subtle);
|
|
|
|
|
+ color: var(--primary);
|
|
|
|
|
+ }
|
|
|
|
|
+ .group-icon.lg {
|
|
|
|
|
+ width: 40px;
|
|
|
|
|
+ height: 40px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .group-meta {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ gap: 2px;
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ .group-meta strong {
|
|
|
|
|
+ font-size: var(--fs-15);
|
|
|
|
|
+ color: var(--ink);
|
|
|
|
|
+ }
|
|
|
|
|
+ .group-meta small {
|
|
|
|
|
+ font-size: var(--fs-12);
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+ }
|
|
|
|
|
+ .badge {
|
|
|
|
|
+ min-width: 18px;
|
|
|
|
|
+ padding: 0 5px;
|
|
|
|
|
+ border-radius: var(--r-tag);
|
|
|
|
|
+ background: var(--surface-active);
|
|
|
|
|
+ color: var(--ink-2);
|
|
|
|
|
+ font-size: var(--fs-11);
|
|
|
|
|
+ line-height: 18px;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ }
|
|
|
|
|
+ .center-fill {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ place-content: center;
|
|
|
|
|
+ }
|
|
|
|
|
+ .chat-head {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: var(--sp-3);
|
|
|
|
|
+ padding: var(--sp-3) var(--sp-4);
|
|
|
|
|
+ border-bottom: 1px solid var(--edge);
|
|
|
|
|
+ }
|
|
|
|
|
+ .chat-title {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ gap: 2px;
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ .chat-title strong {
|
|
|
|
|
+ font-size: var(--fs-15);
|
|
|
|
|
+ color: var(--ink);
|
|
|
|
|
+ }
|
|
|
|
|
+ .chat-title small {
|
|
|
|
|
+ font-size: var(--fs-12);
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+ }
|
|
|
|
|
+ .banner-hold {
|
|
|
|
|
+ padding: var(--sp-3) var(--sp-4) 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ .loop-detail {
|
|
|
|
|
+ margin: 0 0 var(--sp-2);
|
|
|
|
|
+ font-size: var(--fs-12);
|
|
|
|
|
+ color: var(--ink-2);
|
|
|
|
|
+ }
|
|
|
|
|
+ .messages {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ min-height: 0;
|
|
|
|
|
+ overflow-y: auto;
|
|
|
|
|
+ padding: var(--sp-4);
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ gap: var(--sp-3);
|
|
|
|
|
+ align-content: start;
|
|
|
|
|
+ }
|
|
|
|
|
+ .skeleton-hold {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ gap: var(--sp-2);
|
|
|
|
|
+ }
|
|
|
|
|
+ .mention-bar {
|
|
|
|
|
+ padding: var(--sp-2) var(--sp-4);
|
|
|
|
|
+ border-top: 1px solid var(--edge);
|
|
|
|
|
+ background: var(--surface-2);
|
|
|
|
|
+ }
|
|
|
|
|
+ .mention-hint {
|
|
|
|
|
+ font-size: var(--fs-12);
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+ }
|
|
|
|
|
+ .mention-chips {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+ gap: var(--sp-2);
|
|
|
|
|
+ margin-top: var(--sp-2);
|
|
|
|
|
+ }
|
|
|
|
|
+ .mention-chip {
|
|
|
|
|
+ min-height: 28px;
|
|
|
|
|
+ padding: 0 var(--sp-3);
|
|
|
|
|
+ border: 1px solid var(--edge);
|
|
|
|
|
+ border-radius: var(--r-tag);
|
|
|
|
|
+ background: var(--surface);
|
|
|
|
|
+ color: var(--ink-2);
|
|
|
|
|
+ font-size: var(--fs-12);
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ }
|
|
|
|
|
+ .mention-chip.on {
|
|
|
|
|
+ border-color: var(--primary);
|
|
|
|
|
+ background: var(--primary-subtle);
|
|
|
|
|
+ color: var(--primary);
|
|
|
|
|
+ }
|
|
|
|
|
+ .composer-hold {
|
|
|
|
|
+ border-top: 1px solid var(--edge);
|
|
|
|
|
+ padding: var(--sp-3) var(--sp-4);
|
|
|
|
|
+ }
|
|
|
|
|
+ .mention-preview {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: var(--sp-1);
|
|
|
|
|
+ padding: 2px var(--sp-2);
|
|
|
|
|
+ margin-bottom: var(--sp-2);
|
|
|
|
|
+ border-radius: var(--r-tag);
|
|
|
|
|
+ background: var(--primary-subtle);
|
|
|
|
|
+ color: var(--primary);
|
|
|
|
|
+ font-size: var(--fs-12);
|
|
|
|
|
+ }
|
|
|
|
|
+ .mention-preview .clear {
|
|
|
|
|
+ border: 0;
|
|
|
|
|
+ background: transparent;
|
|
|
|
|
+ color: inherit;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ }
|
|
|
|
|
+ .composer-row {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: flex-end;
|
|
|
|
|
+ gap: var(--sp-2);
|
|
|
|
|
+ }
|
|
|
|
|
+ .input {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ min-height: 40px;
|
|
|
|
|
+ max-height: 160px;
|
|
|
|
|
+ padding: var(--sp-2) var(--sp-3);
|
|
|
|
|
+ border: 1px solid var(--edge);
|
|
|
|
|
+ border-radius: var(--r-md);
|
|
|
|
|
+ background: var(--surface);
|
|
|
|
|
+ color: var(--ink);
|
|
|
|
|
+ font: inherit;
|
|
|
|
|
+ resize: none;
|
|
|
|
|
+ }
|
|
|
|
|
+ .member-list {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ min-height: 0;
|
|
|
|
|
+ overflow-y: auto;
|
|
|
|
|
+ padding: var(--sp-3);
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ gap: var(--sp-2);
|
|
|
|
|
+ align-content: start;
|
|
|
|
|
+ }
|
|
|
|
|
+ .member-row {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: var(--sp-2);
|
|
|
|
|
+ padding: var(--sp-2);
|
|
|
|
|
+ border-radius: var(--r-md);
|
|
|
|
|
+ background: var(--surface);
|
|
|
|
|
+ }
|
|
|
|
|
+ .member-meta {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ gap: 1px;
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ .member-meta strong {
|
|
|
|
|
+ font-size: var(--fs-13);
|
|
|
|
|
+ color: var(--ink);
|
|
|
|
|
+ }
|
|
|
|
|
+ .member-meta small {
|
|
|
|
|
+ font-size: var(--fs-11);
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+ }
|
|
|
|
|
+ .dot {
|
|
|
|
|
+ width: 6px;
|
|
|
|
|
+ height: 6px;
|
|
|
|
|
+ border-radius: 50%;
|
|
|
|
|
+ background: var(--edge-strong);
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ .dot.busy {
|
|
|
|
|
+ background: var(--success);
|
|
|
|
|
+ animation: pulse-dot 1.4s var(--ease-standard) infinite;
|
|
|
|
|
+ }
|
|
|
|
|
+ @keyframes pulse-dot {
|
|
|
|
|
+ 0%,
|
|
|
|
|
+ 100% {
|
|
|
|
|
+ opacity: 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ 50% {
|
|
|
|
|
+ opacity: 0.35;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ @media (prefers-reduced-motion: reduce) {
|
|
|
|
|
+ .dot.busy {
|
|
|
|
|
+ animation: none;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .empty-hint {
|
|
|
|
|
+ margin: 0;
|
|
|
|
|
+ font-size: var(--fs-12);
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+ }
|
|
|
|
|
+ .ellipsis {
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ text-overflow: ellipsis;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+ }
|
|
|
|
|
+ `,
|
|
|
|
|
+ ],
|
|
|
|
|
+})
|
|
|
|
|
+export class DkGroupPage {
|
|
|
|
|
+ readonly groups = inject(GroupsService);
|
|
|
|
|
+ readonly loop = inject(LoopGuardService);
|
|
|
|
|
+ private readonly live = inject(LiveService);
|
|
|
|
|
+ private readonly chat = inject(ChatService);
|
|
|
|
|
+ private readonly mx = inject(MatrixClientService);
|
|
|
|
|
+ private readonly route = inject(ActivatedRoute);
|
|
|
|
|
+ private readonly router = inject(Router);
|
|
|
|
|
+ private readonly device = inject(DeviceService);
|
|
|
|
|
+
|
|
|
|
|
+ readonly roomId = signal<string | null>(null);
|
|
|
|
|
+ readonly messages = signal<Message[]>([]);
|
|
|
|
|
+ readonly loading = signal(false);
|
|
|
|
|
+ readonly sending = signal(false);
|
|
|
|
|
+ readonly draft = signal('');
|
|
|
|
|
+ readonly membersOpen = signal(true);
|
|
|
|
|
+ readonly mentionOpen = signal(false);
|
|
|
|
|
+ readonly mentionTargets = signal<Set<string>>(new Set());
|
|
|
|
|
+
|
|
|
|
|
+ private readonly messagesEl = viewChild<ElementRef<HTMLElement>>('msgList');
|
|
|
|
|
+
|
|
|
|
|
+ readonly currentRoom = computed(() =>
|
|
|
|
|
+ this.groups.rooms().find((r) => r.roomId === this.roomId()),
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ /** 群内 agent 成员(已映射回平台 Agent,能显示状态) */
|
|
|
|
|
+ readonly members = computed(() => {
|
|
|
|
|
+ const id = this.roomId();
|
|
|
|
|
+ return id ? this.groups.membersOf()(id) : [];
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ readonly mentionLabel = computed(() => {
|
|
|
|
|
+ const ids = this.mentionTargets();
|
|
|
|
|
+ return this.members()
|
|
|
|
|
+ .filter((a) => ids.has(a.id))
|
|
|
|
|
+ .map((a) => '@' + a.name)
|
|
|
|
|
+ .join(' ');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ constructor() {
|
|
|
|
|
+ this.route.paramMap.subscribe((p) => {
|
|
|
|
|
+ const id = p.get('id');
|
|
|
|
|
+ if (id) void this.open(id);
|
|
|
|
|
+ else {
|
|
|
|
|
+ const first = this.groups.rooms()[0];
|
|
|
|
|
+ if (first) void this.open(first.roomId);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 同步到新消息时重读群会话
|
|
|
|
|
+ effect(() => {
|
|
|
|
|
+ this.mx.storeVersion();
|
|
|
|
|
+ const rid = this.roomId();
|
|
|
|
|
+ if (!rid) return;
|
|
|
|
|
+ this.loadMessages(rid);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async open(roomId: string): Promise<void> {
|
|
|
|
|
+ this.roomId.set(roomId);
|
|
|
|
|
+ this.groups.enter(roomId);
|
|
|
|
|
+ this.mentionTargets.set(new Set());
|
|
|
|
|
+ this.loading.set(true);
|
|
|
|
|
+ // 路由同步(可直接分享链接)
|
|
|
|
|
+ void this.router.navigateByUrl(`/${this.device.mode()}/groups/${encodeURIComponent(roomId)}`, {
|
|
|
|
|
+ replaceUrl: this.route.snapshot.paramMap.get('id') === roomId,
|
|
|
|
|
+ });
|
|
|
|
|
+ this.loadMessages(roomId);
|
|
|
|
|
+ this.loading.set(false);
|
|
|
|
|
+ this.scrollToBottom();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ memberNames(roomId: string): string {
|
|
|
|
|
+ const list = this.groups.membersOf()(roomId);
|
|
|
|
|
+ return list.length ? list.map((a) => a.name).join('、') : '暂无数字生命';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private loadMessages(roomId: string): void {
|
|
|
|
|
+ const my = this.chat.provisionInfo?.userMxId ?? '';
|
|
|
|
|
+ const raw = this.mx.timelineToMessages(roomId);
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 发送者名字优先用**平台数据**(agent 的 botName),不用 Matrix 的 displayName。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 实测:容器账号没有设置 Matrix displayName,`getMember().name` 为空,
|
|
|
|
|
+ * 于是回落到 Matrix ID(`k0nrevcfik` / `zgdcmgnqcd`)——
|
|
|
|
|
+ * 用户看到的是一串技术 ID,完全不知道是谁。
|
|
|
|
|
+ * 平台侧有 `botName`(`lj` / `lj-test`),这才是有意义的名字。
|
|
|
|
|
+ */
|
|
|
|
|
+ const nameByMx = new Map<string, string>();
|
|
|
|
|
+ for (const a of this.live.agents()) {
|
|
|
|
|
+ const mx = a.channels?.matrixConfig?.userId;
|
|
|
|
|
+ if (mx) nameByMx.set(mx, a.name);
|
|
|
|
|
+ }
|
|
|
|
|
+ this.messages.set(
|
|
|
|
|
+ raw
|
|
|
|
|
+ .filter((m) => !!m.body?.trim())
|
|
|
|
|
+ .map((m) => ({
|
|
|
|
|
+ id: m.eventId,
|
|
|
|
|
+ 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),
|
|
|
|
|
+ kind: m.kind,
|
|
|
|
|
+ text: m.body,
|
|
|
|
|
+ ts: new Date(m.ts).toISOString(),
|
|
|
|
|
+ state: 'sent' as const,
|
|
|
|
|
+ })),
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ toggleMention(a: Agent): void {
|
|
|
|
|
+ this.mentionTargets.update((s) => {
|
|
|
|
|
+ const next = new Set(s);
|
|
|
|
|
+ if (next.has(a.id)) next.delete(a.id);
|
|
|
|
|
+ else next.add(a.id);
|
|
|
|
|
+ return next;
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ clearMention(): void {
|
|
|
|
|
+ this.mentionTargets.set(new Set());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ onInput(e: Event): void {
|
|
|
|
|
+ const el = e.target as HTMLTextAreaElement;
|
|
|
|
|
+ this.draft.set(el.value);
|
|
|
|
|
+ el.style.height = 'auto';
|
|
|
|
|
+ el.style.height = Math.min(el.scrollHeight, 160) + 'px';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ onKeydown(e: KeyboardEvent): void {
|
|
|
|
|
+ if (e.key === 'Enter' && !e.shiftKey) {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ void this.send();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async send(): Promise<void> {
|
|
|
|
|
+ const text = this.draft().trim();
|
|
|
|
|
+ const rid = this.roomId();
|
|
|
|
|
+ if (!text || !rid || this.sending()) return;
|
|
|
|
|
+ const targets = this.members().filter((a) => this.mentionTargets().has(a.id));
|
|
|
|
|
+ this.sending.set(true);
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (targets.length === 0) {
|
|
|
|
|
+ await this.mx.sendText(rid, text);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ await this.mx.sendMentionMany(
|
|
|
|
|
+ rid,
|
|
|
|
|
+ targets.map((a) => a.channels?.matrixConfig?.userId ?? '').filter(Boolean),
|
|
|
|
|
+ text,
|
|
|
|
|
+ targets.map((a) => a.name),
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ this.draft.set('');
|
|
|
|
|
+ this.clearMention();
|
|
|
|
|
+ const input = document.querySelector<HTMLTextAreaElement>('.input');
|
|
|
|
|
+ if (input) {
|
|
|
|
|
+ input.value = '';
|
|
|
|
|
+ input.style.height = 'auto';
|
|
|
|
|
+ }
|
|
|
|
|
+ this.loadMessages(rid);
|
|
|
|
|
+ this.scrollToBottom();
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.sending.set(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ onScroll(): void {
|
|
|
|
|
+ // 预留:上滚加载历史(群聊消息量通常小于 DM,暂不做分页)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private scrollToBottom(): void {
|
|
|
|
|
+ requestAnimationFrame(() => {
|
|
|
|
|
+ const el = this.messagesEl()?.nativeElement;
|
|
|
|
|
+ if (el) el.scrollTop = el.scrollHeight;
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+}
|