group-chat.ts 36 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051
  1. import {
  2. ChangeDetectionStrategy,
  3. Component,
  4. ElementRef,
  5. OnDestroy,
  6. computed,
  7. effect,
  8. inject,
  9. signal,
  10. untracked,
  11. viewChild,
  12. } from '@angular/core';
  13. import { ActivatedRoute, Router } from '@angular/router';
  14. import { toSignal } from '@angular/core/rxjs-interop';
  15. import { map } from 'rxjs/operators';
  16. import { Agent, Deliverable, Message } from '../../../core/api/models';
  17. import { ChatService } from '../../../core/chat.service';
  18. import { ChatSessionService } from '../../../core/chat-session.service';
  19. import { GroupChatService } from '../../../core/group-chat.service';
  20. import { LiveService } from '../../../core/live.service';
  21. import { DispatchAnchor, GroupLiveService } from '../../../core/group-live.service';
  22. import { GroupStoreService } from '../../../core/group-store.service';
  23. import { GroupMatrixService } from '../../../core/group-matrix.service';
  24. import { GroupSendService } from '../../../core/group-send.service';
  25. import { DeliverablesService } from '../../../core/deliverables.service';
  26. import { fmtSize } from '../../../core/format';
  27. import { GroupBlock, blockDuration, buildGroupTurns, GROUP_QUIET_MS } from '../../../core/group-turns';
  28. import { turnsFromGroup } from '../../../core/assistant-turn';
  29. import { AssistantTurnComponent } from '../../../components/domain/assistant-turn/assistant-turn';
  30. import { FilePreviewComponent } from '../../../components/preview/file-preview/file-preview';
  31. import { ImageLightboxComponent } from '../../../components/preview/image-lightbox/image-lightbox';
  32. import { previewKindOf } from '../../../components/preview/file-kind';
  33. import { MergedMessage, mergeConsecutiveBodies } from '../../../core/message-merge';
  34. import {
  35. applyMention,
  36. filterMentionCandidates,
  37. parseMentionQuery,
  38. } from '../../../core/mention-autocomplete';
  39. import { MAppBarComponent } from '../../components/m-app-bar/m-app-bar';
  40. import { MDockComponent } from '../../components/m-dock/m-dock';
  41. import { MEmptyComponent } from '../../components/m-empty/m-empty';
  42. import { MMsgComponent } from '../../components/m-msg/m-msg';
  43. /** 页签:回合 / 成员 / 文件 / 成果(后两个 2026-11-10 新增,与单聊抽屉同口径) */
  44. type Tab = 'turn' | 'members' | 'files' | 'results';
  45. /**
  46. * 群聊会话页(移动端,2026-11-05)
  47. *
  48. * ## 伪群聊
  49. *
  50. * 这不是一个真实的 Matrix 群房 —— 每个成员容器仍在自己的 DM 房间里工作。
  51. * 本页做两件事:
  52. * ① 发送:把用户的一条消息,按 @ 拆给被点名的容器(`GroupChatService.plan`)
  53. * ② 展示:把各成员的回复**聚合到一条气泡**里(`buildTurns`)
  54. *
  55. * 详细背景见 `core/group-chat.service.ts` 顶部注释。
  56. *
  57. * ## 为什么独立页面而不是复用会话页
  58. *
  59. * 会话页围绕「单个 agent + 单个房间」构建(`ChatService.open(agentId)`),
  60. * 群聊要同时持有 N 个容器的消息流,两者的状态模型不同。
  61. */
  62. @Component({
  63. selector: 'app-mb-group-chat-page',
  64. changeDetection: ChangeDetectionStrategy.OnPush,
  65. imports: [
  66. MAppBarComponent,
  67. MDockComponent,
  68. MEmptyComponent,
  69. MMsgComponent,
  70. AssistantTurnComponent,
  71. FilePreviewComponent,
  72. ImageLightboxComponent,
  73. ],
  74. template: `
  75. <div class="m-page">
  76. <app-m-app-bar [title]="title()" [subtitle]="subtitle()" [back]="true" (backTap)="back()">
  77. <button barRight type="button" class="m-fab" aria-label="文件与成果" (click)="tab.set('files')">
  78. <i class="fa-solid fa-folder-open" aria-hidden="true"></i>
  79. </button>
  80. <button barRight type="button" class="m-fab" aria-label="群成员" (click)="tab.set('members')">
  81. <i class="fa-solid fa-users" aria-hidden="true"></i>
  82. </button>
  83. </app-m-app-bar>
  84. @if (tab() === 'files' || tab() === 'results') {
  85. <div class="m-body">
  86. <section class="m-section">
  87. <nav class="m-seg" aria-label="文件与成果">
  88. <button
  89. type="button"
  90. class="m-seg-btn"
  91. [class.on]="tab() === 'files'"
  92. (click)="tab.set('files')"
  93. >
  94. 文件 {{ groupFiles().length }}
  95. </button>
  96. <button
  97. type="button"
  98. class="m-seg-btn"
  99. [class.on]="tab() === 'results'"
  100. (click)="tab.set('results')"
  101. >
  102. 成果 {{ groupDeliverables().length }}
  103. </button>
  104. </nav>
  105. @if (tab() === 'files') {
  106. <h2 class="m-section-title">我发的文件 · {{ groupFiles().length }}</h2>
  107. @if (groupFiles().length === 0) {
  108. <p class="m-note">还没有发过文件。</p>
  109. } @else {
  110. <ul class="m-list">
  111. @for (f of groupFiles(); track f.url + f.name) {
  112. <li class="m-row">
  113. <button type="button" class="m-row-body" (click)="openFile(f)">
  114. <span class="m-row-t1">{{ f.name }}</span>
  115. <span class="m-row-t2">{{ fmtSize(f.size) }}</span>
  116. </button>
  117. </li>
  118. }
  119. </ul>
  120. }
  121. } @else {
  122. <h2 class="m-section-title">群内成果 · {{ groupDeliverables().length }}</h2>
  123. @if (groupDeliverables().length === 0) {
  124. <p class="m-note">本群还没有产出成果。</p>
  125. } @else {
  126. <ul class="m-list">
  127. @for (d of groupDeliverables(); track d.id) {
  128. <li class="m-row">
  129. <button type="button" class="m-row-body" (click)="openDeliverable(d)">
  130. <span class="m-row-t1">{{ d.title }}</span>
  131. <span class="m-row-t2">
  132. @if (ownerNameOfDeliverable(d.agentId); as who) {
  133. {{ who }} ·
  134. }
  135. @if (d.artifacts[0]; as a) {
  136. {{ a.name }}
  137. } @else {
  138. 暂无附件
  139. }
  140. </span>
  141. </button>
  142. </li>
  143. }
  144. </ul>
  145. }
  146. }
  147. </section>
  148. </div>
  149. } @else if (tab() === 'members') {
  150. <div class="m-body">
  151. <section class="m-section">
  152. <h2 class="m-section-title">群成员 · {{ members().length }}</h2>
  153. <ul class="m-list">
  154. @for (a of members(); track a.id) {
  155. <li class="m-row">
  156. <span class="m-color" [style.background]="colorOf(a.id)" aria-hidden="true"></span>
  157. <span class="m-row-body">
  158. <span class="m-row-t1">{{ a.name }}</span>
  159. <span class="m-row-t2">{{ statusText(a) }} · {{ phaseText(a.id) }}</span>
  160. </span>
  161. </li>
  162. }
  163. </ul>
  164. <p class="m-note">
  165. 伪群聊:每个容器在自己的独立会话里工作,彼此看不到对方的发言。
  166. </p>
  167. </section>
  168. </div>
  169. } @else {
  170. <!--
  171. 实时状态条(2026-11-05):每个成员一行「思考中 / 正在输出 / 已完成 / 无响应」。
  172. 群聊是多个容器并行工作,用户需要知道「谁还在跑」。
  173. -->
  174. @if (liveRows().length > 0) {
  175. <div class="m-live" role="status" aria-live="polite">
  176. @for (r of liveRows(); track r.id) {
  177. <span class="m-live-item" [class.m-live--busy]="r.phase === 'thinking' || r.phase === 'replying'">
  178. <span class="m-color" [style.background]="colorOf(r.id)" aria-hidden="true"></span>
  179. {{ r.name }}
  180. <span class="m-live-phase">
  181. @switch (r.phase) {
  182. @case ('thinking') {
  183. <i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>思考中
  184. }
  185. @case ('replying') {
  186. <i class="fa-solid fa-ellipsis" aria-hidden="true"></i>输出中
  187. }
  188. @case ('done') {
  189. 已完成
  190. }
  191. @case ('failed') {
  192. <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>无响应
  193. }
  194. @default {
  195. 待命
  196. }
  197. }
  198. </span>
  199. </span>
  200. }
  201. </div>
  202. }
  203. @if (matrix.warningOf(session()?.id ?? ''); as warn) {
  204. <div class="m-hist-warn" role="status">{{ warn }}</div>
  205. }
  206. <div class="m-chat-body" #scroller (scroll)="onScroll()">
  207. <!-- 正在从 Matrix 重建历史:显示骨架,不要闪一下「开始群聊」 -->
  208. @if (matrix.isRefreshing(session()?.id ?? '') && turns().length === 0) {
  209. <div class="m-hist-skel" aria-busy="true">
  210. @for (i of [1, 2, 3]; track i) {
  211. <div class="m-sk-line"></div>
  212. }
  213. </div>
  214. }
  215. <!--
  216. 卡片渲染(2026-11-07 用户需求):一轮 = 一张卡,@ 多个容器时按容器分段,
  217. 每段标名字与色点。与桌面端、移动单聊共用 app-assistant-turn。
  218. -->
  219. @for (t of aiTurns(); track t.id) {
  220. @if (t.userMessage) {
  221. <app-m-msg [message]="t.userMessage" />
  222. }
  223. @for (f of t.userFiles; track f.id) {
  224. <app-m-msg [message]="f" />
  225. }
  226. <app-assistant-turn
  227. [turn]="t"
  228. [grouped]="true"
  229. (preview)="openFile($event)"
  230. />
  231. } @empty {
  232. <app-m-empty
  233. icon="fa-solid fa-users"
  234. title="开始群聊"
  235. description="发一条消息,@ 需要响应的容器"
  236. />
  237. }
  238. </div>
  239. <!-- @ 候选 -->
  240. @if (candidates().length > 0) {
  241. <div class="m-mention" role="listbox" aria-label="@ 选择">
  242. @for (c of candidates(); track c.id) {
  243. <button type="button" role="option" class="m-mention-item" (click)="pick(c)">
  244. <span class="m-color" [style.background]="colorOf(c.id)" aria-hidden="true"></span>
  245. {{ c.name }}
  246. </button>
  247. }
  248. </div>
  249. }
  250. @if (sendError(); as err) {
  251. <div class="m-send-err" role="alert">
  252. <i class="fa-solid fa-circle-exclamation" aria-hidden="true"></i>
  253. {{ err }}
  254. </div>
  255. } @else if (unknownMentions().length > 0) {
  256. <div class="m-send-err" role="alert">
  257. <i class="fa-solid fa-circle-exclamation" aria-hidden="true"></i>
  258. 这些名字不在群里:
  259. @for (n of unknownMentions(); track n) {
  260. <span class="m-bad-name">{{ n }}</span>
  261. }
  262. </div>
  263. }
  264. <div class="m-composer-area">
  265. <!-- 待发附件条:与移动端单聊同一形态 -->
  266. @if (sender.pendingFiles().length > 0) {
  267. <div class="m-pending" role="list" aria-label="待发送附件">
  268. @for (pf of sender.pendingFiles(); track pf.id) {
  269. <span class="m-pending-chip" role="listitem">
  270. @if (pf.previewUrl) {
  271. <img [src]="pf.previewUrl" [alt]="pf.file.name" class="m-pending-img" />
  272. } @else {
  273. <i class="fa-solid fa-paperclip" aria-hidden="true"></i>
  274. }
  275. <span class="ellipsis">{{ pf.file.name }}</span>
  276. <button
  277. type="button"
  278. class="m-pending-x"
  279. [attr.aria-label]="'移除 ' + pf.file.name"
  280. (click)="sender.unstageFile(pf.id)"
  281. >
  282. <i class="fa-solid fa-xmark" aria-hidden="true"></i>
  283. </button>
  284. </span>
  285. }
  286. </div>
  287. }
  288. <app-m-dock
  289. [draft]="draft()"
  290. [sending]="sender.sending()"
  291. [uploading]="sender.uploading()"
  292. [pct]="sender.uploadPct()"
  293. [hasAttachments]="sender.pendingFiles().length > 0"
  294. [target]="'群聊'"
  295. placeholder="发消息,@ 需要响应的容器…"
  296. (draftChange)="onDraft($event)"
  297. (send)="send($event)"
  298. (attach)="sender.stageFiles($event)"
  299. />
  300. </div>
  301. }
  302. </div>
  303. <!--
  304. 全屏预览层(2026-11-07):群聊此前**没有任何预览能力** ——
  305. 卡片上的文件点了没反应。与移动单聊同一套形态(图片灯箱 + 通用预览器)。
  306. -->
  307. <app-image-lightbox
  308. [open]="!!lightbox()"
  309. [url]="lightbox()?.url ?? ''"
  310. [name]="lightbox()?.name ?? ''"
  311. [size]="lightbox()?.size ?? 0"
  312. (closed)="lightbox.set(null)"
  313. />
  314. <app-file-preview
  315. [open]="!!preview()"
  316. [url]="preview()?.url ?? ''"
  317. [name]="preview()?.name ?? ''"
  318. [mimeType]="preview()?.mimeType ?? ''"
  319. mode="modal"
  320. (closed)="preview.set(null)"
  321. />
  322. `,
  323. styles: [
  324. `
  325. :host {
  326. display: block;
  327. background: var(--canvas);
  328. }
  329. .m-page {
  330. display: flex;
  331. flex-direction: column;
  332. height: 100dvh;
  333. box-sizing: border-box;
  334. }
  335. .m-fab {
  336. display: grid;
  337. place-items: center;
  338. width: var(--touch-min);
  339. height: var(--touch-min);
  340. border: 0;
  341. border-radius: var(--r-tag);
  342. background: var(--surface);
  343. box-shadow: var(--shadow-soft);
  344. color: var(--ink);
  345. font-size: var(--fs-17);
  346. cursor: pointer;
  347. }
  348. .m-body,
  349. .m-chat-body {
  350. flex: 1;
  351. min-height: 0;
  352. overflow-y: auto;
  353. -webkit-overflow-scrolling: touch;
  354. }
  355. .m-chat-body {
  356. display: flex;
  357. flex-direction: column;
  358. gap: var(--sp-4);
  359. padding: var(--sp-3) var(--mob-gutter) var(--sp-6);
  360. }
  361. /* 实时状态条 */
  362. .m-live {
  363. flex: none;
  364. display: flex;
  365. gap: var(--sp-2);
  366. padding: var(--sp-2) var(--mob-gutter);
  367. overflow-x: auto;
  368. scrollbar-width: none;
  369. border-bottom: 1px solid var(--edge);
  370. background: var(--surface);
  371. }
  372. .m-live::-webkit-scrollbar {
  373. display: none;
  374. }
  375. .m-live-item {
  376. display: inline-flex;
  377. align-items: center;
  378. gap: var(--sp-1);
  379. flex: none;
  380. height: 28px;
  381. padding: 0 var(--sp-2);
  382. border: 1px solid var(--edge);
  383. border-radius: var(--r-tag);
  384. color: var(--muted);
  385. font-size: var(--fs-11);
  386. }
  387. .m-live--busy {
  388. border-color: var(--primary);
  389. color: var(--primary);
  390. background: var(--primary-subtle);
  391. }
  392. .m-live-phase {
  393. display: inline-flex;
  394. align-items: center;
  395. gap: 2px;
  396. }
  397. /* 用户消息由 app-m-msg 渲染(2026-11-07),沿用其自身的己方/对方排布 */
  398. /* 历史未完整载入的轻提示 */
  399. .m-hist-warn {
  400. flex: none;
  401. margin: var(--sp-2) var(--mob-gutter) 0;
  402. padding: var(--sp-2) var(--sp-3);
  403. border-radius: var(--r-md);
  404. background: var(--warning-subtle);
  405. color: var(--warning);
  406. font-size: var(--fs-12);
  407. }
  408. /* 历史重建中的骨架 */
  409. .m-hist-skel {
  410. display: grid;
  411. gap: var(--sp-3);
  412. padding: var(--sp-4) var(--mob-gutter);
  413. }
  414. .m-sk-line {
  415. height: 48px;
  416. border-radius: var(--r-md);
  417. background: var(--surface-2);
  418. }
  419. /* 待发附件条 */
  420. .m-pending {
  421. display: flex;
  422. flex-wrap: wrap;
  423. gap: var(--sp-2);
  424. padding: var(--sp-2) var(--mob-gutter) 0;
  425. }
  426. .m-pending-chip {
  427. display: inline-flex;
  428. align-items: center;
  429. gap: var(--sp-2);
  430. max-width: 60vw;
  431. padding: var(--sp-1) var(--sp-2);
  432. border: 1px solid var(--edge);
  433. border-radius: var(--r-md);
  434. background: var(--surface);
  435. font-size: var(--fs-12);
  436. color: var(--ink-2);
  437. }
  438. .m-pending-img {
  439. width: 28px;
  440. height: 28px;
  441. border-radius: var(--r-sm);
  442. object-fit: cover;
  443. }
  444. .m-pending-x {
  445. display: grid;
  446. place-items: center;
  447. width: 20px;
  448. height: 20px;
  449. border: 0;
  450. border-radius: var(--r-tag);
  451. background: transparent;
  452. color: var(--faint);
  453. font-size: var(--fs-11);
  454. }
  455. /* 回合:一次提问的所有输出合成一条(Q12) */
  456. .m-turn {
  457. display: flex;
  458. flex-direction: column;
  459. gap: var(--sp-3);
  460. }
  461. .m-turn-head {
  462. display: inline-flex;
  463. align-items: center;
  464. gap: var(--sp-2);
  465. align-self: flex-start;
  466. min-height: 28px;
  467. padding: 0 var(--sp-3);
  468. border: 1px solid var(--edge);
  469. border-radius: var(--r-tag);
  470. background: var(--surface-2);
  471. color: var(--muted);
  472. font: inherit;
  473. font-size: var(--fs-12);
  474. cursor: pointer;
  475. }
  476. .m-exec {
  477. color: var(--info);
  478. }
  479. .m-block {
  480. display: flex;
  481. flex-direction: column;
  482. gap: var(--sp-1);
  483. padding-left: var(--sp-3);
  484. /* 容器色条(Q27-A):左侧 2px,颜色由容器 id 稳定派生 */
  485. border-left: 2px solid var(--edge);
  486. animation: m-block-in var(--motion-detail) var(--ease-standard);
  487. }
  488. .m-block-head {
  489. display: flex;
  490. align-items: center;
  491. gap: var(--sp-2);
  492. color: var(--ink-2);
  493. font-size: var(--fs-12);
  494. font-weight: 600;
  495. }
  496. /* 容器色条(Q27-A):不用头像,靠左侧细色条区分 */
  497. .m-color {
  498. display: inline-block;
  499. flex: none;
  500. width: 8px;
  501. height: 8px;
  502. border-radius: var(--r-tag);
  503. }
  504. @keyframes m-block-in {
  505. from {
  506. opacity: 0;
  507. transform: translateY(4px);
  508. }
  509. to {
  510. opacity: 1;
  511. transform: none;
  512. }
  513. }
  514. @media (prefers-reduced-motion: reduce) {
  515. .m-block {
  516. animation: none;
  517. }
  518. }
  519. .m-mention {
  520. display: flex;
  521. gap: var(--sp-2);
  522. padding: var(--sp-2) var(--mob-gutter);
  523. overflow-x: auto;
  524. border-top: 1px solid var(--edge);
  525. background: var(--surface);
  526. }
  527. .m-mention-item {
  528. display: inline-flex;
  529. align-items: center;
  530. gap: var(--sp-2);
  531. flex: none;
  532. min-height: 36px;
  533. padding: 0 var(--sp-3);
  534. border: 1px solid var(--edge);
  535. border-radius: var(--r-tag);
  536. background: var(--surface);
  537. color: var(--ink);
  538. font: inherit;
  539. font-size: var(--fs-13);
  540. cursor: pointer;
  541. }
  542. .m-composer-area {
  543. flex: none;
  544. padding-bottom: calc(var(--mob-safe-bottom) + var(--sp-3));
  545. }
  546. .m-send-err {
  547. flex: none;
  548. display: flex;
  549. flex-wrap: wrap;
  550. align-items: center;
  551. gap: var(--sp-2);
  552. padding: var(--sp-2) var(--mob-gutter);
  553. background: var(--danger-subtle);
  554. color: var(--danger);
  555. font-size: var(--fs-13);
  556. }
  557. .m-bad-name {
  558. padding: 0 var(--sp-2);
  559. border-radius: var(--r-tag);
  560. background: var(--danger);
  561. color: var(--inverse);
  562. font-weight: 600;
  563. }
  564. .m-block-phase {
  565. color: var(--muted);
  566. font-size: var(--fs-11);
  567. font-weight: 400;
  568. }
  569. .m-block-phase.busy {
  570. color: var(--info);
  571. }
  572. .m-block-time {
  573. margin-left: auto;
  574. color: var(--faint);
  575. font-size: var(--fs-11);
  576. font-weight: 400;
  577. }
  578. .m-block-wait {
  579. margin: 0;
  580. color: var(--faint);
  581. font-size: var(--fs-13);
  582. }
  583. .m-section {
  584. padding: var(--sp-3) var(--mob-gutter);
  585. }
  586. .m-section-title {
  587. margin: 0 0 var(--sp-3);
  588. color: var(--muted);
  589. font-size: var(--fs-13);
  590. font-weight: 400;
  591. }
  592. .m-list {
  593. margin: 0;
  594. padding: 0;
  595. list-style: none;
  596. border: 1px solid var(--edge);
  597. border-radius: var(--r-lg);
  598. background: var(--surface);
  599. overflow: hidden;
  600. }
  601. .m-row {
  602. display: flex;
  603. align-items: center;
  604. gap: var(--sp-3);
  605. min-height: 52px;
  606. padding: var(--sp-3) var(--sp-4);
  607. }
  608. .m-list > li + li {
  609. border-top: 1px solid var(--edge);
  610. }
  611. .m-row-body {
  612. flex: 1;
  613. min-width: 0;
  614. }
  615. .m-row-t1 {
  616. display: block;
  617. color: var(--ink);
  618. font-size: var(--fs-15);
  619. }
  620. .m-row-t2 {
  621. display: block;
  622. color: var(--muted);
  623. font-size: var(--fs-13);
  624. }
  625. .m-note {
  626. margin: var(--sp-4) 0 0;
  627. color: var(--muted);
  628. font-size: var(--fs-12);
  629. line-height: 1.6;
  630. }
  631. /* 文件 / 成果页签切换(2026-11-10),触控区 ≥44px */
  632. .m-seg {
  633. display: grid;
  634. grid-template-columns: 1fr 1fr;
  635. gap: var(--sp-2);
  636. margin-bottom: var(--sp-4);
  637. }
  638. .m-seg-btn {
  639. min-height: 44px;
  640. border: 1px solid var(--edge);
  641. border-radius: var(--radius-m);
  642. background: var(--surface);
  643. color: var(--ink-2);
  644. font-size: var(--fs-14);
  645. }
  646. .m-seg-btn.on {
  647. background: var(--surface-2);
  648. color: var(--ink);
  649. border-color: var(--edge-strong);
  650. }
  651. `,
  652. ],
  653. })
  654. export class MbGroupChatPage implements OnDestroy {
  655. private readonly route = inject(ActivatedRoute);
  656. private readonly router = inject(Router);
  657. private readonly groups = inject(GroupChatService);
  658. private readonly sessions = inject(ChatSessionService);
  659. private readonly live = inject(LiveService);
  660. protected readonly chat = inject(ChatService);
  661. private readonly groupLive = inject(GroupLiveService);
  662. protected readonly store = inject(GroupStoreService);
  663. /** 群聊历史 Matrix 重建(2026-11-07) */
  664. protected readonly matrix = inject(GroupMatrixService);
  665. /** 群聊发送编排(含附件) */
  666. protected readonly sender = inject(GroupSendService);
  667. /** 文件/成果视图(2026-11-10,与单聊共用同一服务) */
  668. private readonly deliverablesSvc = inject(DeliverablesService);
  669. private readonly scroller = viewChild<ElementRef<HTMLDivElement>>('scroller');
  670. protected readonly tab = signal<Tab>('turn');
  671. protected readonly draft = signal('');
  672. /** 发送/输入校验的提示(不 @ 人、@ 了非成员) */
  673. protected readonly sendError = signal<string | null>(null);
  674. /** 每个回合的展开状态(覆盖默认的「执行中展开」) */
  675. private readonly openMap = signal<Record<string, boolean>>({});
  676. /** 时钟:驱动「静默超时」判定,每秒走一格 */
  677. private readonly now = signal(Date.now());
  678. /**
  679. * 当前群 id —— 订阅路由参数流(2026-11-07 与桌面端对齐)。
  680. *
  681. * 原来读 `route.snapshot`:从群 A 点到群 B 时组件被复用、构造函数不重跑,
  682. * 标题/成员/消息全部停在上一个群。
  683. */
  684. private readonly groupId = toSignal(
  685. this.route.paramMap.pipe(map((p) => p.get('groupId') ?? '')),
  686. { initialValue: '' },
  687. );
  688. protected readonly session = computed(() => this.sessions.byId(this.groupId()));
  689. protected readonly members = computed<Agent[]>(() => {
  690. const s = this.session();
  691. return s ? this.groups.membersOf(s) : [];
  692. });
  693. /**
  694. * 「文件」= 我自己发的附件(2026-11-10,与桌面群聊/单聊同口径)。
  695. *
  696. * 原来移动群聊完全没有文件/成果视图,用户想看「我刚才发的文件」
  697. * 只能翻聊天记录。
  698. */
  699. protected readonly groupFiles = computed(() =>
  700. this.messages()
  701. .filter((m) => m.sender === 'me')
  702. .map((m) => m.file)
  703. .filter((f): f is NonNullable<typeof f> => !!f),
  704. );
  705. /**
  706. * 「成果」= 本群这次对话里成员容器产出的文件(2026-11-10,与桌面群聊同口径)。
  707. *
  708. * 合并口径:三路都取、按 url 去重 —— 与桌面群聊 `railDeliverables`
  709. * 及单聊 `agentDeliverables` 完全一致:
  710. * ① 文件台账按群会话 id(threadRoot)查出来的(权威)
  711. * ② 群消息流里 agent 发的附件(实时兜底)
  712. * ③ 群会话记录上的 `deliverableUrls`(旧数据兜底)
  713. */
  714. protected readonly groupDeliverables = computed<Deliverable[]>(() => {
  715. const fromDb = this._dbFiles();
  716. const agentMsgs = this.messages().filter((m) => m.sender === 'agent' && m.file?.url);
  717. const fromMessages = this.deliverablesSvc.fromAgentFiles(agentMsgs.map((m) => m.file!)).map((d) => ({
  718. ...d,
  719. agentId: agentMsgs.find((m) => m.file?.url === d.artifacts[0]?.url)?.senderId ?? '',
  720. }));
  721. const fromRecord = this.deliverablesSvc.bySessionUrls(this.session()?.deliverableUrls);
  722. return this.deliverablesSvc.merge(fromDb, fromMessages, fromRecord);
  723. });
  724. /** 成果产出成员名(没有归属时返回空串) */
  725. protected ownerNameOfDeliverable(agentId?: string): string {
  726. if (!agentId) return '';
  727. return this.members().find((a) => a.id === agentId)?.name ?? agentId;
  728. }
  729. protected readonly title = computed(() => this.session()?.title ?? '群聊');
  730. protected readonly subtitle = computed(() => `${this.members().length} 个容器`);
  731. /**
  732. * 群内全部消息:把���个成员房间的消息合并、按时间排序。
  733. *
  734. * 🔴 伪群聊的核心展示逻辑 —— 每个成员的消息来自各自的 DM 房间,
  735. * 这里靠 `ChatService` 当前打开的会话取数(同一时刻只持有其中一个)。
  736. * 完整实现需要按成员分别订阅,见服务层 TODO。
  737. */
  738. protected readonly messages = computed<Message[]>(() => {
  739. /*
  740. * 群聊消息只来自 `GroupStoreService`(2026-11-05 重做)。
  741. *
  742. * 🔴 不再读容器房间的 timeline:那里装着用户与该容器的全部单聊历史,
  743. * 直接回读会把私聊内容串进群聊页(实测)。
  744. * 什么能进群由 GroupLiveService 的「实时 + 水位」两条过滤决定。
  745. */
  746. const s = this.session();
  747. if (!s) return [];
  748. return this.store.entriesOf(s.id).map((e) => e.message);
  749. });
  750. protected readonly turns = computed(() =>
  751. buildGroupTurns({
  752. entries: this.session() ? this.store.entriesOf(this.session()!.id) : [],
  753. targetsOf: (turnId) => this.store.targetsOf(turnId),
  754. typingOf: (agentId) => this.groupLive.isTyping(agentId),
  755. now: this.groupLive.clock(),
  756. }),
  757. );
  758. /**
  759. * 回合视图(2026-11-07):一轮 = 一张卡,多容器按容器分段。
  760. * 与桌面群聊、移动单聊共用同一套派生规则。
  761. */
  762. protected readonly aiTurns = computed(() =>
  763. turnsFromGroup(this.turns(), this.groupLive.clock(), GROUP_QUIET_MS, (id) => this.memberName(id)),
  764. );
  765. /** 按群会话从数据库加载的文件(①) */
  766. private readonly _dbFiles = signal<Deliverable[]>([]);
  767. readonly dbFiles = this._dbFiles.asReadonly();
  768. private loadSessionFiles(): void {
  769. const sid = this.session()?.id;
  770. if (!sid) {
  771. this._dbFiles.set([]);
  772. return;
  773. }
  774. void this.deliverablesSvc
  775. .loadForSession(sid)
  776. .then((files) => {
  777. if (this.session()?.id === sid) this._dbFiles.set(files);
  778. })
  779. .catch(() => {
  780. if (this.session()?.id === sid) this._dbFiles.set([]);
  781. });
  782. }
  783. /* ---------- 文件预览(2026-11-07 补齐) ---------- */
  784. /** 图片灯箱 */
  785. protected readonly lightbox = signal<{ url: string; name: string; size: number } | null>(null);
  786. /** 通用文件预览(模态) */
  787. protected readonly preview = signal<{ url: string; name: string; mimeType: string } | null>(null);
  788. /**
  789. * 按类型分流:图片进灯箱,其余进通用预览器。
  790. * 与移动单聊 `openFile()` 同口径。
  791. */
  792. protected openFile(f: { name: string; url: string; mimeType?: string; size?: number }): void {
  793. const { url, name } = f;
  794. if (!url) return;
  795. const mime = f.mimeType ?? '';
  796. if (previewKindOf(name || url, mime) === 'image') {
  797. this.lightbox.set({ url, name, size: f.size ?? 0 });
  798. return;
  799. }
  800. this.preview.set({ url, name, mimeType: mime });
  801. }
  802. /** 字节数 → 人类可读(与移动单聊 `fmtSize` 同源,2026-11-10) */
  803. protected readonly fmtSize = fmtSize;
  804. /**
  805. * 打开一条成果的首个附件(2026-11-10)。
  806. *
  807. * 成果条目可能带多个附件(`artifacts`),这里按首个可访问的打开,
  808. * 与桌面右栏 `previewDeliverable` 的默认行为一致。
  809. */
  810. protected openDeliverable(d: Deliverable): void {
  811. const a = d.artifacts.find((x) => !!x.url);
  812. if (!a) return;
  813. this.openFile({ name: a.name, url: a.url, mimeType: a.type });
  814. }
  815. /** 成员实时状态行(有状态才显示,全待命时隐藏,避免占位) */
  816. protected readonly liveRows = computed(() => {
  817. const list = this.members().map((a) => ({
  818. id: a.id,
  819. name: a.name,
  820. phase: this.groupLive.phaseOf(a.id),
  821. }));
  822. return list.filter((r) => r.phase !== 'idle');
  823. });
  824. protected phaseText(id: string): string {
  825. switch (this.groupLive.phaseOf(id)) {
  826. case 'thinking':
  827. return '思考中';
  828. case 'replying':
  829. return '输出中';
  830. case 'done':
  831. return '已完成';
  832. case 'failed':
  833. return '无响应';
  834. default:
  835. return '待命';
  836. }
  837. }
  838. protected readonly candidates = computed<{ id: string; name: string }[]>(() => {
  839. const q = parseMentionQuery(this.draft(), this.draft().length);
  840. if (!q.active) return [];
  841. const list = this.members().map((a) => ({ id: a.id, name: a.name }));
  842. return filterMentionCandidates(list, q.query, (x) => x.name);
  843. });
  844. constructor() {
  845. /*
  846. * 群文件台账:会话就绪或消息数变化时重拉(2026-11-10)。
  847. * 群文件按 threadRoot=群会话 id 查,不再按成员容器全量取。
  848. */
  849. effect(() => {
  850. this.session()?.id;
  851. this.messages().length;
  852. this.loadSessionFiles();
  853. });
  854. // 从 localStorage 恢复群内消息(幂等,重复调用无副作用)
  855. this.store.load();
  856. /*
  857. * 🔴 会话目录必须先载入本地缓存(2026-11-06):
  858. * 漏了这一步,刷新进来 `session()` 恒为 undefined,标题退化成「群聊」。
  859. */
  860. this.sessions.load();
  861. /*
  862. * 容器列表同理:`members()` 靠 `live.agentById()` 解析成员名。
  863. */
  864. void this.live.ensureLoaded();
  865. /*
  866. * 🔴 群会话目录也要从云端补拉(2026-11-05)。
  867. * 云端会话目录是**按 agentId** 取的,群会话没有自己的 agentId
  868. * (`ChatSession.agentId` 存的是首成员)—— 所以只有拉过首成员才会带回来。
  869. * 深链接直接进群聊页时本地缓存可能是空的 → `session()` 为 undefined → 页面空。
  870. */
  871. void this.loadGroupSession();
  872. /*
  873. * 订阅群成员的实时消息流(按成员房间,各自独立)。
  874. * 切群(2026-11-07):先释放上一个群,再订阅新群 —— 否则两个群的回复会串。
  875. */
  876. let prevGroup: string | null = null;
  877. effect(() => {
  878. const s = this.session();
  879. if (!s) return;
  880. if (prevGroup && prevGroup !== s.id) {
  881. this.groupLive.release(prevGroup);
  882. this.sender.clear();
  883. }
  884. prevGroup = s.id;
  885. // 先从 Matrix 重建历史(权威),不 await —— 页面先渲染本地缓存
  886. untracked(() => void this.matrix.refresh(s));
  887. void this.groupLive.subscribe(s);
  888. });
  889. // 贴底:与桌面端同一套(两帧 + 120ms 兜底;用户上翻时不拽回)
  890. effect(() => {
  891. this.messages();
  892. this.turns();
  893. if (this.userScrolledAway) return;
  894. this.stickToBottom();
  895. });
  896. }
  897. /** 用户是否主动上翻(>160px 视为在看历史) */
  898. private userScrolledAway = false;
  899. protected onScroll(): void {
  900. const el = this.scroller()?.nativeElement;
  901. if (!el) return;
  902. this.userScrolledAway = el.scrollHeight - el.scrollTop - el.clientHeight > 160;
  903. }
  904. private stickToBottom(): void {
  905. const toBottom = (): void => {
  906. const el = this.scroller()?.nativeElement;
  907. if (el) el.scrollTop = el.scrollHeight;
  908. };
  909. toBottom();
  910. requestAnimationFrame(() => {
  911. toBottom();
  912. setTimeout(toBottom, 120);
  913. });
  914. }
  915. ngOnDestroy(): void {
  916. const s = this.session();
  917. if (s) this.groupLive.release(s.id);
  918. this.sender.clear();
  919. }
  920. /** 本地没有该群会话时,用候选 agentId 去云端补拉一次 */
  921. private async loadGroupSession(): Promise<void> {
  922. const id = this.groupId();
  923. if (!id) return;
  924. // 等容器列表就绪,才知道该按哪些 agentId 去取
  925. await this.live.ensureLoaded();
  926. if (this.sessions.byId(id)) return;
  927. const ids = this.live.agents().map((a) => a.id);
  928. if (ids.length === 0) return;
  929. await this.sessions.reloadAll(ids);
  930. }
  931. /** 输入里是否有不在群里的 @ 名字(实时标红提示) */
  932. protected readonly unknownMentions = computed<string[]>(() => {
  933. const s = this.session();
  934. if (!s) return [];
  935. return this.groups.unknownMentions(this.draft(), s);
  936. });
  937. protected blockDuration(b: GroupBlock): number {
  938. return blockDuration(b);
  939. }
  940. /** 同一容器的多段回复合成一条(规则见 core/message-merge) */
  941. protected mergeBlock(b: GroupBlock): MergedMessage[] {
  942. return mergeConsecutiveBodies(b.messages);
  943. }
  944. protected memberName(agentId: string): string {
  945. return this.live.agentById(agentId)?.name ?? agentId;
  946. }
  947. protected openOf(t: { id: string; closed: boolean }): boolean {
  948. const explicit = this.openMap()[t.id];
  949. if (explicit !== undefined) return explicit;
  950. return !t.closed;
  951. }
  952. protected isOpen(t: { id: string; closed: boolean }): boolean {
  953. return this.openOf(t);
  954. }
  955. protected toggleTurn(id: string): void {
  956. const t = this.turns().find((x) => x.id === id);
  957. if (!t) return;
  958. const cur = this.openOf(t);
  959. this.openMap.update((m) => ({ ...m, [id]: !cur }));
  960. }
  961. /** 容器色条:从 id 稳定派生,同一容器永远同一个颜色 */
  962. /** 容器色条:从 id 稳定派生,同一容器永远同一个颜色(取自语义令牌,不用魔法值) */
  963. protected colorOf(id: string): string {
  964. const palette = [
  965. 'var(--primary)',
  966. 'var(--success)',
  967. 'var(--warning)',
  968. 'var(--info)',
  969. 'var(--danger)',
  970. 'var(--accent)',
  971. ];
  972. let h = 0;
  973. for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
  974. return palette[h % palette.length];
  975. }
  976. protected statusText(a: Agent): string {
  977. return a.statusText ?? a.status;
  978. }
  979. protected onDraft(v: string): void {
  980. this.draft.set(v);
  981. this.sendError.set(null);
  982. }
  983. protected async send(text: string): Promise<void> {
  984. const s = this.session();
  985. if (!s || this.sender.sending()) return;
  986. this.sendError.set(null);
  987. const r = await this.sender.send(s, text, (id) => this.memberName(id));
  988. if (r.error) this.sendError.set(r.error);
  989. if (r.ok) this.draft.set('');
  990. }
  991. protected pick(c: { id: string; name: string }): void {
  992. const q = parseMentionQuery(this.draft(), this.draft().length);
  993. const next = applyMention(this.draft(), q, c.name, this.draft().length);
  994. this.draft.set(next.text);
  995. }
  996. protected back(): void {
  997. if (history.length > 1) history.back();
  998. else void this.router.navigateByUrl('/mobile/messages');
  999. }
  1000. }