| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051 |
- import {
- ChangeDetectionStrategy,
- Component,
- ElementRef,
- OnDestroy,
- computed,
- effect,
- inject,
- signal,
- untracked,
- viewChild,
- } from '@angular/core';
- import { ActivatedRoute, Router } from '@angular/router';
- import { toSignal } from '@angular/core/rxjs-interop';
- import { map } from 'rxjs/operators';
- import { Agent, Deliverable, Message } from '../../../core/api/models';
- import { ChatService } from '../../../core/chat.service';
- import { ChatSessionService } from '../../../core/chat-session.service';
- import { GroupChatService } from '../../../core/group-chat.service';
- import { LiveService } from '../../../core/live.service';
- import { DispatchAnchor, GroupLiveService } from '../../../core/group-live.service';
- import { GroupStoreService } from '../../../core/group-store.service';
- import { GroupMatrixService } from '../../../core/group-matrix.service';
- import { GroupSendService } from '../../../core/group-send.service';
- import { DeliverablesService } from '../../../core/deliverables.service';
- import { fmtSize } from '../../../core/format';
- import { GroupBlock, blockDuration, buildGroupTurns, GROUP_QUIET_MS } from '../../../core/group-turns';
- import { turnsFromGroup } from '../../../core/assistant-turn';
- import { AssistantTurnComponent } from '../../../components/domain/assistant-turn/assistant-turn';
- import { FilePreviewComponent } from '../../../components/preview/file-preview/file-preview';
- import { ImageLightboxComponent } from '../../../components/preview/image-lightbox/image-lightbox';
- import { previewKindOf } from '../../../components/preview/file-kind';
- import { MergedMessage, mergeConsecutiveBodies } from '../../../core/message-merge';
- import {
- applyMention,
- filterMentionCandidates,
- parseMentionQuery,
- } from '../../../core/mention-autocomplete';
- import { MAppBarComponent } from '../../components/m-app-bar/m-app-bar';
- import { MDockComponent } from '../../components/m-dock/m-dock';
- import { MEmptyComponent } from '../../components/m-empty/m-empty';
- import { MMsgComponent } from '../../components/m-msg/m-msg';
- /** 页签:回合 / 成员 / 文件 / 成果(后两个 2026-11-10 新增,与单聊抽屉同口径) */
- type Tab = 'turn' | 'members' | 'files' | 'results';
- /**
- * 群聊会话页(移动端,2026-11-05)
- *
- * ## 伪群聊
- *
- * 这不是一个真实的 Matrix 群房 —— 每个成员容器仍在自己的 DM 房间里工作。
- * 本页做两件事:
- * ① 发送:把用户的一条消息,按 @ 拆给被点名的容器(`GroupChatService.plan`)
- * ② 展示:把各成员的回复**聚合到一条气泡**里(`buildTurns`)
- *
- * 详细背景见 `core/group-chat.service.ts` 顶部注释。
- *
- * ## 为什么独立页面而不是复用会话页
- *
- * 会话页围绕「单个 agent + 单个房间」构建(`ChatService.open(agentId)`),
- * 群聊要同时持有 N 个容器的消息流,两者的状态模型不同。
- */
- @Component({
- selector: 'app-mb-group-chat-page',
- changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [
- MAppBarComponent,
- MDockComponent,
- MEmptyComponent,
- MMsgComponent,
- AssistantTurnComponent,
- FilePreviewComponent,
- ImageLightboxComponent,
- ],
- template: `
- <div class="m-page">
- <app-m-app-bar [title]="title()" [subtitle]="subtitle()" [back]="true" (backTap)="back()">
- <button barRight type="button" class="m-fab" aria-label="文件与成果" (click)="tab.set('files')">
- <i class="fa-solid fa-folder-open" aria-hidden="true"></i>
- </button>
- <button barRight type="button" class="m-fab" aria-label="群成员" (click)="tab.set('members')">
- <i class="fa-solid fa-users" aria-hidden="true"></i>
- </button>
- </app-m-app-bar>
- @if (tab() === 'files' || tab() === 'results') {
- <div class="m-body">
- <section class="m-section">
- <nav class="m-seg" aria-label="文件与成果">
- <button
- type="button"
- class="m-seg-btn"
- [class.on]="tab() === 'files'"
- (click)="tab.set('files')"
- >
- 文件 {{ groupFiles().length }}
- </button>
- <button
- type="button"
- class="m-seg-btn"
- [class.on]="tab() === 'results'"
- (click)="tab.set('results')"
- >
- 成果 {{ groupDeliverables().length }}
- </button>
- </nav>
- @if (tab() === 'files') {
- <h2 class="m-section-title">我发的文件 · {{ groupFiles().length }}</h2>
- @if (groupFiles().length === 0) {
- <p class="m-note">还没有发过文件。</p>
- } @else {
- <ul class="m-list">
- @for (f of groupFiles(); track f.url + f.name) {
- <li class="m-row">
- <button type="button" class="m-row-body" (click)="openFile(f)">
- <span class="m-row-t1">{{ f.name }}</span>
- <span class="m-row-t2">{{ fmtSize(f.size) }}</span>
- </button>
- </li>
- }
- </ul>
- }
- } @else {
- <h2 class="m-section-title">群内成果 · {{ groupDeliverables().length }}</h2>
- @if (groupDeliverables().length === 0) {
- <p class="m-note">本群还没有产出成果。</p>
- } @else {
- <ul class="m-list">
- @for (d of groupDeliverables(); track d.id) {
- <li class="m-row">
- <button type="button" class="m-row-body" (click)="openDeliverable(d)">
- <span class="m-row-t1">{{ d.title }}</span>
- <span class="m-row-t2">
- @if (ownerNameOfDeliverable(d.agentId); as who) {
- {{ who }} ·
- }
- @if (d.artifacts[0]; as a) {
- {{ a.name }}
- } @else {
- 暂无附件
- }
- </span>
- </button>
- </li>
- }
- </ul>
- }
- }
- </section>
- </div>
- } @else if (tab() === 'members') {
- <div class="m-body">
- <section class="m-section">
- <h2 class="m-section-title">群成员 · {{ members().length }}</h2>
- <ul class="m-list">
- @for (a of members(); track a.id) {
- <li class="m-row">
- <span class="m-color" [style.background]="colorOf(a.id)" aria-hidden="true"></span>
- <span class="m-row-body">
- <span class="m-row-t1">{{ a.name }}</span>
- <span class="m-row-t2">{{ statusText(a) }} · {{ phaseText(a.id) }}</span>
- </span>
- </li>
- }
- </ul>
- <p class="m-note">
- 伪群聊:每个容器在自己的独立会话里工作,彼此看不到对方的发言。
- </p>
- </section>
- </div>
- } @else {
- <!--
- 实时状态条(2026-11-05):每个成员一行「思考中 / 正在输出 / 已完成 / 无响应」。
- 群聊是多个容器并行工作,用户需要知道「谁还在跑」。
- -->
- @if (liveRows().length > 0) {
- <div class="m-live" role="status" aria-live="polite">
- @for (r of liveRows(); track r.id) {
- <span class="m-live-item" [class.m-live--busy]="r.phase === 'thinking' || r.phase === 'replying'">
- <span class="m-color" [style.background]="colorOf(r.id)" aria-hidden="true"></span>
- {{ r.name }}
- <span class="m-live-phase">
- @switch (r.phase) {
- @case ('thinking') {
- <i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>思考中
- }
- @case ('replying') {
- <i class="fa-solid fa-ellipsis" aria-hidden="true"></i>输出中
- }
- @case ('done') {
- 已完成
- }
- @case ('failed') {
- <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>无响应
- }
- @default {
- 待命
- }
- }
- </span>
- </span>
- }
- </div>
- }
- @if (matrix.warningOf(session()?.id ?? ''); as warn) {
- <div class="m-hist-warn" role="status">{{ warn }}</div>
- }
- <div class="m-chat-body" #scroller (scroll)="onScroll()">
- <!-- 正在从 Matrix 重建历史:显示骨架,不要闪一下「开始群聊」 -->
- @if (matrix.isRefreshing(session()?.id ?? '') && turns().length === 0) {
- <div class="m-hist-skel" aria-busy="true">
- @for (i of [1, 2, 3]; track i) {
- <div class="m-sk-line"></div>
- }
- </div>
- }
- <!--
- 卡片渲染(2026-11-07 用户需求):一轮 = 一张卡,@ 多个容器时按容器分段,
- 每段标名字与色点。与桌面端、移动单聊共用 app-assistant-turn。
- -->
- @for (t of aiTurns(); track t.id) {
- @if (t.userMessage) {
- <app-m-msg [message]="t.userMessage" />
- }
- @for (f of t.userFiles; track f.id) {
- <app-m-msg [message]="f" />
- }
- <app-assistant-turn
- [turn]="t"
- [grouped]="true"
- (preview)="openFile($event)"
- />
- } @empty {
- <app-m-empty
- icon="fa-solid fa-users"
- title="开始群聊"
- description="发一条消息,@ 需要响应的容器"
- />
- }
- </div>
- <!-- @ 候选 -->
- @if (candidates().length > 0) {
- <div class="m-mention" role="listbox" aria-label="@ 选择">
- @for (c of candidates(); track c.id) {
- <button type="button" role="option" class="m-mention-item" (click)="pick(c)">
- <span class="m-color" [style.background]="colorOf(c.id)" aria-hidden="true"></span>
- {{ c.name }}
- </button>
- }
- </div>
- }
- @if (sendError(); as err) {
- <div class="m-send-err" role="alert">
- <i class="fa-solid fa-circle-exclamation" aria-hidden="true"></i>
- {{ err }}
- </div>
- } @else if (unknownMentions().length > 0) {
- <div class="m-send-err" role="alert">
- <i class="fa-solid fa-circle-exclamation" aria-hidden="true"></i>
- 这些名字不在群里:
- @for (n of unknownMentions(); track n) {
- <span class="m-bad-name">{{ n }}</span>
- }
- </div>
- }
- <div class="m-composer-area">
- <!-- 待发附件条:与移动端单聊同一形态 -->
- @if (sender.pendingFiles().length > 0) {
- <div class="m-pending" role="list" aria-label="待发送附件">
- @for (pf of sender.pendingFiles(); track pf.id) {
- <span class="m-pending-chip" role="listitem">
- @if (pf.previewUrl) {
- <img [src]="pf.previewUrl" [alt]="pf.file.name" class="m-pending-img" />
- } @else {
- <i class="fa-solid fa-paperclip" aria-hidden="true"></i>
- }
- <span class="ellipsis">{{ pf.file.name }}</span>
- <button
- type="button"
- class="m-pending-x"
- [attr.aria-label]="'移除 ' + pf.file.name"
- (click)="sender.unstageFile(pf.id)"
- >
- <i class="fa-solid fa-xmark" aria-hidden="true"></i>
- </button>
- </span>
- }
- </div>
- }
- <app-m-dock
- [draft]="draft()"
- [sending]="sender.sending()"
- [uploading]="sender.uploading()"
- [pct]="sender.uploadPct()"
- [hasAttachments]="sender.pendingFiles().length > 0"
- [target]="'群聊'"
- placeholder="发消息,@ 需要响应的容器…"
- (draftChange)="onDraft($event)"
- (send)="send($event)"
- (attach)="sender.stageFiles($event)"
- />
- </div>
- }
- </div>
- <!--
- 全屏预览层(2026-11-07):群聊此前**没有任何预览能力** ——
- 卡片上的文件点了没反应。与移动单聊同一套形态(图片灯箱 + 通用预览器)。
- -->
- <app-image-lightbox
- [open]="!!lightbox()"
- [url]="lightbox()?.url ?? ''"
- [name]="lightbox()?.name ?? ''"
- [size]="lightbox()?.size ?? 0"
- (closed)="lightbox.set(null)"
- />
- <app-file-preview
- [open]="!!preview()"
- [url]="preview()?.url ?? ''"
- [name]="preview()?.name ?? ''"
- [mimeType]="preview()?.mimeType ?? ''"
- mode="modal"
- (closed)="preview.set(null)"
- />
- `,
- styles: [
- `
- :host {
- display: block;
- background: var(--canvas);
- }
- .m-page {
- display: flex;
- flex-direction: column;
- height: 100dvh;
- box-sizing: border-box;
- }
- .m-fab {
- display: grid;
- place-items: center;
- width: var(--touch-min);
- height: var(--touch-min);
- border: 0;
- border-radius: var(--r-tag);
- background: var(--surface);
- box-shadow: var(--shadow-soft);
- color: var(--ink);
- font-size: var(--fs-17);
- cursor: pointer;
- }
- .m-body,
- .m-chat-body {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- -webkit-overflow-scrolling: touch;
- }
- .m-chat-body {
- display: flex;
- flex-direction: column;
- gap: var(--sp-4);
- padding: var(--sp-3) var(--mob-gutter) var(--sp-6);
- }
- /* 实时状态条 */
- .m-live {
- flex: none;
- display: flex;
- gap: var(--sp-2);
- padding: var(--sp-2) var(--mob-gutter);
- overflow-x: auto;
- scrollbar-width: none;
- border-bottom: 1px solid var(--edge);
- background: var(--surface);
- }
- .m-live::-webkit-scrollbar {
- display: none;
- }
- .m-live-item {
- display: inline-flex;
- align-items: center;
- gap: var(--sp-1);
- flex: none;
- height: 28px;
- padding: 0 var(--sp-2);
- border: 1px solid var(--edge);
- border-radius: var(--r-tag);
- color: var(--muted);
- font-size: var(--fs-11);
- }
- .m-live--busy {
- border-color: var(--primary);
- color: var(--primary);
- background: var(--primary-subtle);
- }
- .m-live-phase {
- display: inline-flex;
- align-items: center;
- gap: 2px;
- }
- /* 用户消息由 app-m-msg 渲染(2026-11-07),沿用其自身的己方/对方排布 */
- /* 历史未完整载入的轻提示 */
- .m-hist-warn {
- flex: none;
- margin: var(--sp-2) var(--mob-gutter) 0;
- padding: var(--sp-2) var(--sp-3);
- border-radius: var(--r-md);
- background: var(--warning-subtle);
- color: var(--warning);
- font-size: var(--fs-12);
- }
- /* 历史重建中的骨架 */
- .m-hist-skel {
- display: grid;
- gap: var(--sp-3);
- padding: var(--sp-4) var(--mob-gutter);
- }
- .m-sk-line {
- height: 48px;
- border-radius: var(--r-md);
- background: var(--surface-2);
- }
- /* 待发附件条 */
- .m-pending {
- display: flex;
- flex-wrap: wrap;
- gap: var(--sp-2);
- padding: var(--sp-2) var(--mob-gutter) 0;
- }
- .m-pending-chip {
- display: inline-flex;
- align-items: center;
- gap: var(--sp-2);
- max-width: 60vw;
- padding: var(--sp-1) var(--sp-2);
- border: 1px solid var(--edge);
- border-radius: var(--r-md);
- background: var(--surface);
- font-size: var(--fs-12);
- color: var(--ink-2);
- }
- .m-pending-img {
- width: 28px;
- height: 28px;
- border-radius: var(--r-sm);
- object-fit: cover;
- }
- .m-pending-x {
- display: grid;
- place-items: center;
- width: 20px;
- height: 20px;
- border: 0;
- border-radius: var(--r-tag);
- background: transparent;
- color: var(--faint);
- font-size: var(--fs-11);
- }
- /* 回合:一次提问的所有输出合成一条(Q12) */
- .m-turn {
- display: flex;
- flex-direction: column;
- gap: var(--sp-3);
- }
- .m-turn-head {
- display: inline-flex;
- align-items: center;
- gap: var(--sp-2);
- align-self: flex-start;
- min-height: 28px;
- padding: 0 var(--sp-3);
- border: 1px solid var(--edge);
- border-radius: var(--r-tag);
- background: var(--surface-2);
- color: var(--muted);
- font: inherit;
- font-size: var(--fs-12);
- cursor: pointer;
- }
- .m-exec {
- color: var(--info);
- }
- .m-block {
- display: flex;
- flex-direction: column;
- gap: var(--sp-1);
- padding-left: var(--sp-3);
- /* 容器色条(Q27-A):左侧 2px,颜色由容器 id 稳定派生 */
- border-left: 2px solid var(--edge);
- animation: m-block-in var(--motion-detail) var(--ease-standard);
- }
- .m-block-head {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- color: var(--ink-2);
- font-size: var(--fs-12);
- font-weight: 600;
- }
- /* 容器色条(Q27-A):不用头像,靠左侧细色条区分 */
- .m-color {
- display: inline-block;
- flex: none;
- width: 8px;
- height: 8px;
- border-radius: var(--r-tag);
- }
- @keyframes m-block-in {
- from {
- opacity: 0;
- transform: translateY(4px);
- }
- to {
- opacity: 1;
- transform: none;
- }
- }
- @media (prefers-reduced-motion: reduce) {
- .m-block {
- animation: none;
- }
- }
- .m-mention {
- display: flex;
- gap: var(--sp-2);
- padding: var(--sp-2) var(--mob-gutter);
- overflow-x: auto;
- border-top: 1px solid var(--edge);
- background: var(--surface);
- }
- .m-mention-item {
- display: inline-flex;
- align-items: center;
- gap: var(--sp-2);
- flex: none;
- min-height: 36px;
- padding: 0 var(--sp-3);
- border: 1px solid var(--edge);
- border-radius: var(--r-tag);
- background: var(--surface);
- color: var(--ink);
- font: inherit;
- font-size: var(--fs-13);
- cursor: pointer;
- }
- .m-composer-area {
- flex: none;
- padding-bottom: calc(var(--mob-safe-bottom) + var(--sp-3));
- }
- .m-send-err {
- flex: none;
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: var(--sp-2);
- padding: var(--sp-2) var(--mob-gutter);
- background: var(--danger-subtle);
- color: var(--danger);
- font-size: var(--fs-13);
- }
- .m-bad-name {
- padding: 0 var(--sp-2);
- border-radius: var(--r-tag);
- background: var(--danger);
- color: var(--inverse);
- font-weight: 600;
- }
- .m-block-phase {
- color: var(--muted);
- font-size: var(--fs-11);
- font-weight: 400;
- }
- .m-block-phase.busy {
- color: var(--info);
- }
- .m-block-time {
- margin-left: auto;
- color: var(--faint);
- font-size: var(--fs-11);
- font-weight: 400;
- }
- .m-block-wait {
- margin: 0;
- color: var(--faint);
- font-size: var(--fs-13);
- }
- .m-section {
- padding: var(--sp-3) var(--mob-gutter);
- }
- .m-section-title {
- margin: 0 0 var(--sp-3);
- color: var(--muted);
- font-size: var(--fs-13);
- font-weight: 400;
- }
- .m-list {
- margin: 0;
- padding: 0;
- list-style: none;
- border: 1px solid var(--edge);
- border-radius: var(--r-lg);
- background: var(--surface);
- overflow: hidden;
- }
- .m-row {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- min-height: 52px;
- padding: var(--sp-3) var(--sp-4);
- }
- .m-list > li + li {
- border-top: 1px solid var(--edge);
- }
- .m-row-body {
- flex: 1;
- min-width: 0;
- }
- .m-row-t1 {
- display: block;
- color: var(--ink);
- font-size: var(--fs-15);
- }
- .m-row-t2 {
- display: block;
- color: var(--muted);
- font-size: var(--fs-13);
- }
- .m-note {
- margin: var(--sp-4) 0 0;
- color: var(--muted);
- font-size: var(--fs-12);
- line-height: 1.6;
- }
- /* 文件 / 成果页签切换(2026-11-10),触控区 ≥44px */
- .m-seg {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: var(--sp-2);
- margin-bottom: var(--sp-4);
- }
- .m-seg-btn {
- min-height: 44px;
- border: 1px solid var(--edge);
- border-radius: var(--radius-m);
- background: var(--surface);
- color: var(--ink-2);
- font-size: var(--fs-14);
- }
- .m-seg-btn.on {
- background: var(--surface-2);
- color: var(--ink);
- border-color: var(--edge-strong);
- }
- `,
- ],
- })
- export class MbGroupChatPage implements OnDestroy {
- private readonly route = inject(ActivatedRoute);
- private readonly router = inject(Router);
- private readonly groups = inject(GroupChatService);
- private readonly sessions = inject(ChatSessionService);
- private readonly live = inject(LiveService);
- protected readonly chat = inject(ChatService);
- private readonly groupLive = inject(GroupLiveService);
- protected readonly store = inject(GroupStoreService);
- /** 群聊历史 Matrix 重建(2026-11-07) */
- protected readonly matrix = inject(GroupMatrixService);
- /** 群聊发送编排(含附件) */
- protected readonly sender = inject(GroupSendService);
- /** 文件/成果视图(2026-11-10,与单聊共用同一服务) */
- private readonly deliverablesSvc = inject(DeliverablesService);
- private readonly scroller = viewChild<ElementRef<HTMLDivElement>>('scroller');
- protected readonly tab = signal<Tab>('turn');
- protected readonly draft = signal('');
- /** 发送/输入校验的提示(不 @ 人、@ 了非成员) */
- protected readonly sendError = signal<string | null>(null);
- /** 每个回合的展开状态(覆盖默认的「执行中展开」) */
- private readonly openMap = signal<Record<string, boolean>>({});
- /** 时钟:驱动「静默超时」判定,每秒走一格 */
- private readonly now = signal(Date.now());
- /**
- * 当前群 id —— 订阅路由参数流(2026-11-07 与桌面端对齐)。
- *
- * 原来读 `route.snapshot`:从群 A 点到群 B 时组件被复用、构造函数不重跑,
- * 标题/成员/消息全部停在上一个群。
- */
- private readonly groupId = toSignal(
- this.route.paramMap.pipe(map((p) => p.get('groupId') ?? '')),
- { initialValue: '' },
- );
- protected readonly session = computed(() => this.sessions.byId(this.groupId()));
- protected readonly members = computed<Agent[]>(() => {
- const s = this.session();
- return s ? this.groups.membersOf(s) : [];
- });
- /**
- * 「文件」= 我自己发的附件(2026-11-10,与桌面群聊/单聊同口径)。
- *
- * 原来移动群聊完全没有文件/成果视图,用户想看「我刚才发的文件」
- * 只能翻聊天记录。
- */
- protected readonly groupFiles = computed(() =>
- this.messages()
- .filter((m) => m.sender === 'me')
- .map((m) => m.file)
- .filter((f): f is NonNullable<typeof f> => !!f),
- );
- /**
- * 「成果」= 本群这次对话里成员容器产出的文件(2026-11-10,与桌面群聊同口径)。
- *
- * 合并口径:三路都取、按 url 去重 —— 与桌面群聊 `railDeliverables`
- * 及单聊 `agentDeliverables` 完全一致:
- * ① 文件台账按群会话 id(threadRoot)查出来的(权威)
- * ② 群消息流里 agent 发的附件(实时兜底)
- * ③ 群会话记录上的 `deliverableUrls`(旧数据兜底)
- */
- protected readonly groupDeliverables = computed<Deliverable[]>(() => {
- const fromDb = this._dbFiles();
- const agentMsgs = this.messages().filter((m) => m.sender === 'agent' && m.file?.url);
- const fromMessages = this.deliverablesSvc.fromAgentFiles(agentMsgs.map((m) => m.file!)).map((d) => ({
- ...d,
- agentId: agentMsgs.find((m) => m.file?.url === d.artifacts[0]?.url)?.senderId ?? '',
- }));
- const fromRecord = this.deliverablesSvc.bySessionUrls(this.session()?.deliverableUrls);
- return this.deliverablesSvc.merge(fromDb, fromMessages, fromRecord);
- });
- /** 成果产出成员名(没有归属时返回空串) */
- protected ownerNameOfDeliverable(agentId?: string): string {
- if (!agentId) return '';
- return this.members().find((a) => a.id === agentId)?.name ?? agentId;
- }
- protected readonly title = computed(() => this.session()?.title ?? '群聊');
- protected readonly subtitle = computed(() => `${this.members().length} 个容器`);
- /**
- * 群内全部消息:把���个成员房间的消息合并、按时间排序。
- *
- * 🔴 伪群聊的核心展示逻辑 —— 每个成员的消息来自各自的 DM 房间,
- * 这里靠 `ChatService` 当前打开的会话取数(同一时刻只持有其中一个)。
- * 完整实现需要按成员分别订阅,见服务层 TODO。
- */
- protected readonly messages = computed<Message[]>(() => {
- /*
- * 群聊消息只来自 `GroupStoreService`(2026-11-05 重做)。
- *
- * 🔴 不再读容器房间的 timeline:那里装着用户与该容器的全部单聊历史,
- * 直接回读会把私聊内容串进群聊页(实测)。
- * 什么能进群由 GroupLiveService 的「实时 + 水位」两条过滤决定。
- */
- const s = this.session();
- if (!s) return [];
- return this.store.entriesOf(s.id).map((e) => e.message);
- });
- protected readonly turns = computed(() =>
- buildGroupTurns({
- entries: this.session() ? this.store.entriesOf(this.session()!.id) : [],
- targetsOf: (turnId) => this.store.targetsOf(turnId),
- typingOf: (agentId) => this.groupLive.isTyping(agentId),
- now: this.groupLive.clock(),
- }),
- );
- /**
- * 回合视图(2026-11-07):一轮 = 一张卡,多容器按容器分段。
- * 与桌面群聊、移动单聊共用同一套派生规则。
- */
- protected readonly aiTurns = computed(() =>
- turnsFromGroup(this.turns(), this.groupLive.clock(), GROUP_QUIET_MS, (id) => this.memberName(id)),
- );
- /** 按群会话从数据库加载的文件(①) */
- private readonly _dbFiles = signal<Deliverable[]>([]);
- readonly dbFiles = this._dbFiles.asReadonly();
- private loadSessionFiles(): void {
- const sid = this.session()?.id;
- if (!sid) {
- this._dbFiles.set([]);
- return;
- }
- void this.deliverablesSvc
- .loadForSession(sid)
- .then((files) => {
- if (this.session()?.id === sid) this._dbFiles.set(files);
- })
- .catch(() => {
- if (this.session()?.id === sid) this._dbFiles.set([]);
- });
- }
- /* ---------- 文件预览(2026-11-07 补齐) ---------- */
- /** 图片灯箱 */
- protected readonly lightbox = signal<{ url: string; name: string; size: number } | null>(null);
- /** 通用文件预览(模态) */
- protected readonly preview = signal<{ url: string; name: string; mimeType: string } | null>(null);
- /**
- * 按类型分流:图片进灯箱,其余进通用预览器。
- * 与移动单聊 `openFile()` 同口径。
- */
- protected openFile(f: { name: string; url: string; mimeType?: string; size?: number }): void {
- const { url, name } = f;
- if (!url) return;
- const mime = f.mimeType ?? '';
- if (previewKindOf(name || url, mime) === 'image') {
- this.lightbox.set({ url, name, size: f.size ?? 0 });
- return;
- }
- this.preview.set({ url, name, mimeType: mime });
- }
- /** 字节数 → 人类可读(与移动单聊 `fmtSize` 同源,2026-11-10) */
- protected readonly fmtSize = fmtSize;
- /**
- * 打开一条成果的首个附件(2026-11-10)。
- *
- * 成果条目可能带多个附件(`artifacts`),这里按首个可访问的打开,
- * 与桌面右栏 `previewDeliverable` 的默认行为一致。
- */
- protected openDeliverable(d: Deliverable): void {
- const a = d.artifacts.find((x) => !!x.url);
- if (!a) return;
- this.openFile({ name: a.name, url: a.url, mimeType: a.type });
- }
- /** 成员实时状态行(有状态才显示,全待命时隐藏,避免占位) */
- protected readonly liveRows = computed(() => {
- const list = this.members().map((a) => ({
- id: a.id,
- name: a.name,
- phase: this.groupLive.phaseOf(a.id),
- }));
- return list.filter((r) => r.phase !== 'idle');
- });
- protected phaseText(id: string): string {
- switch (this.groupLive.phaseOf(id)) {
- case 'thinking':
- return '思考中';
- case 'replying':
- return '输出中';
- case 'done':
- return '已完成';
- case 'failed':
- return '无响应';
- default:
- return '待命';
- }
- }
- protected readonly candidates = computed<{ id: string; name: string }[]>(() => {
- const q = parseMentionQuery(this.draft(), this.draft().length);
- if (!q.active) return [];
- const list = this.members().map((a) => ({ id: a.id, name: a.name }));
- return filterMentionCandidates(list, q.query, (x) => x.name);
- });
- constructor() {
- /*
- * 群文件台账:会话就绪或消息数变化时重拉(2026-11-10)。
- * 群文件按 threadRoot=群会话 id 查,不再按成员容器全量取。
- */
- effect(() => {
- this.session()?.id;
- this.messages().length;
- this.loadSessionFiles();
- });
- // 从 localStorage 恢复群内消息(幂等,重复调用无副作用)
- this.store.load();
- /*
- * 🔴 会话目录必须先载入本地缓存(2026-11-06):
- * 漏了这一步,刷新进来 `session()` 恒为 undefined,标题退化成「群聊」。
- */
- this.sessions.load();
- /*
- * 容器列表同理:`members()` 靠 `live.agentById()` 解析成员名。
- */
- void this.live.ensureLoaded();
- /*
- * 🔴 群会话目录也要从云端补拉(2026-11-05)。
- * 云端会话目录是**按 agentId** 取的,群会话没有自己的 agentId
- * (`ChatSession.agentId` 存的是首成员)—— 所以只有拉过首成员才会带回来。
- * 深链接直接进群聊页时本地缓存可能是空的 → `session()` 为 undefined → 页面空。
- */
- void this.loadGroupSession();
- /*
- * 订阅群成员的实时消息流(按成员房间,各自独立)。
- * 切群(2026-11-07):先释放上一个群,再订阅新群 —— 否则两个群的回复会串。
- */
- let prevGroup: string | null = null;
- effect(() => {
- const s = this.session();
- if (!s) return;
- if (prevGroup && prevGroup !== s.id) {
- this.groupLive.release(prevGroup);
- this.sender.clear();
- }
- prevGroup = s.id;
- // 先从 Matrix 重建历史(权威),不 await —— 页面先渲染本地缓存
- untracked(() => void this.matrix.refresh(s));
- void this.groupLive.subscribe(s);
- });
- // 贴底:与桌面端同一套(两帧 + 120ms 兜底;用户上翻时不拽回)
- effect(() => {
- this.messages();
- this.turns();
- if (this.userScrolledAway) return;
- this.stickToBottom();
- });
- }
- /** 用户是否主动上翻(>160px 视为在看历史) */
- private userScrolledAway = false;
- protected onScroll(): void {
- const el = this.scroller()?.nativeElement;
- if (!el) return;
- this.userScrolledAway = el.scrollHeight - el.scrollTop - el.clientHeight > 160;
- }
- private stickToBottom(): void {
- const toBottom = (): void => {
- const el = this.scroller()?.nativeElement;
- if (el) el.scrollTop = el.scrollHeight;
- };
- toBottom();
- requestAnimationFrame(() => {
- toBottom();
- setTimeout(toBottom, 120);
- });
- }
- ngOnDestroy(): void {
- const s = this.session();
- if (s) this.groupLive.release(s.id);
- this.sender.clear();
- }
- /** 本地没有该群会话时,用候选 agentId 去云端补拉一次 */
- private async loadGroupSession(): Promise<void> {
- const id = this.groupId();
- if (!id) return;
- // 等容器列表就绪,才知道该按哪些 agentId 去取
- await this.live.ensureLoaded();
- if (this.sessions.byId(id)) return;
- const ids = this.live.agents().map((a) => a.id);
- if (ids.length === 0) return;
- await this.sessions.reloadAll(ids);
- }
- /** 输入里是否有不在群里的 @ 名字(实时标红提示) */
- protected readonly unknownMentions = computed<string[]>(() => {
- const s = this.session();
- if (!s) return [];
- return this.groups.unknownMentions(this.draft(), s);
- });
- protected blockDuration(b: GroupBlock): number {
- return blockDuration(b);
- }
- /** 同一容器的多段回复合成一条(规则见 core/message-merge) */
- protected mergeBlock(b: GroupBlock): MergedMessage[] {
- return mergeConsecutiveBodies(b.messages);
- }
- protected memberName(agentId: string): string {
- return this.live.agentById(agentId)?.name ?? agentId;
- }
- protected openOf(t: { id: string; closed: boolean }): boolean {
- const explicit = this.openMap()[t.id];
- if (explicit !== undefined) return explicit;
- return !t.closed;
- }
- protected isOpen(t: { id: string; closed: boolean }): boolean {
- return this.openOf(t);
- }
- protected toggleTurn(id: string): void {
- const t = this.turns().find((x) => x.id === id);
- if (!t) return;
- const cur = this.openOf(t);
- this.openMap.update((m) => ({ ...m, [id]: !cur }));
- }
- /** 容器色条:从 id 稳定派生,同一容器永远同一个颜色 */
- /** 容器色条:从 id 稳定派生,同一容器永远同一个颜色(取自语义令牌,不用魔法值) */
- protected colorOf(id: string): string {
- const palette = [
- 'var(--primary)',
- 'var(--success)',
- 'var(--warning)',
- 'var(--info)',
- 'var(--danger)',
- 'var(--accent)',
- ];
- let h = 0;
- for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
- return palette[h % palette.length];
- }
- protected statusText(a: Agent): string {
- return a.statusText ?? a.status;
- }
- protected onDraft(v: string): void {
- this.draft.set(v);
- this.sendError.set(null);
- }
- protected async send(text: string): Promise<void> {
- const s = this.session();
- if (!s || this.sender.sending()) return;
- this.sendError.set(null);
- const r = await this.sender.send(s, text, (id) => this.memberName(id));
- if (r.error) this.sendError.set(r.error);
- if (r.ok) this.draft.set('');
- }
- protected pick(c: { id: string; name: string }): void {
- const q = parseMentionQuery(this.draft(), this.draft().length);
- const next = applyMention(this.draft(), q, c.name, this.draft().length);
- this.draft.set(next.text);
- }
- protected back(): void {
- if (history.length > 1) history.back();
- else void this.router.navigateByUrl('/mobile/messages');
- }
- }
|