group-chat.ts 44 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225
  1. import {
  2. ChangeDetectionStrategy,
  3. Component,
  4. effect,
  5. inject,
  6. untracked,
  7. ElementRef,
  8. OnDestroy,
  9. computed,
  10. signal,
  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, Message } from '../../../core/api/models';
  17. import { ChatService } from '../../../core/chat.service';
  18. import { ChatSessionService } from '../../../core/chat-session.service';
  19. import { SessionService } from '../../../core/session.service';
  20. import { GroupChatService } from '../../../core/group-chat.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 { LiveService } from '../../../core/live.service';
  26. import { buildGroupTurns, GROUP_QUIET_MS } from '../../../core/group-turns';
  27. import { turnsFromGroup } from '../../../core/assistant-turn';
  28. import { routeOpen, OpenableFile } from '../../../core/file-open';
  29. import { AssistantTurnComponent } from '../../../components/domain/assistant-turn/assistant-turn';
  30. import { MentionListComponent, MentionOption, MentionState } from '../../../components/domain/mention-list/mention-list';
  31. import { GroupQueueService } from '../../../core/group-queue.service';
  32. import { agentPresence } from '../../../core/api/models';
  33. import { ImageLightboxComponent } from '../../../components/preview/image-lightbox/image-lightbox';
  34. import {
  35. applyMention,
  36. filterMentionCandidates,
  37. parseMentionQuery,
  38. } from '../../../core/mention-autocomplete';
  39. import { UiAvatar, UiButton, UiEmpty, UiSkeleton, UiTooltip } from '../../../ui';
  40. import {
  41. SessionRailComponent,
  42. RailFile,
  43. RailRuntime,
  44. } from '../../../components/domain/session-rail/session-rail';
  45. import { RailTabDef } from '../../../components/domain/session-rail/rail-shared';
  46. import {
  47. RAIL_TABS,
  48. RAIL_W_DEFAULT,
  49. RAIL_W_KEY,
  50. clampRailWidth,
  51. } from '../../../components/domain/session-rail/rail-shared';
  52. import { TasksService } from '../../../core/tasks.service';
  53. import { DeliverablesService } from '../../../core/deliverables.service';
  54. import { TaskPulseService } from '../../../core/task-pulse.service';
  55. import { Deliverable, Task } from '../../../core/api/models';
  56. import { usageOf } from '../../../core/format';
  57. import { MessageBubbleComponent } from '../../../components/domain/message-bubble/message-bubble';
  58. import { ComposerComponent } from '../../../components/domain/composer/composer';
  59. /**
  60. * 群聊会话页(PC,2026-11-05)
  61. *
  62. * 与移动端 `group-chat` 同一套模型(伪群聊 + 回合聚合),
  63. * 布局按桌面习惯:顶栏 + 成员状态条 + 消息区 + 输入区。
  64. *
  65. * 详细背景见 `core/group-chat.service.ts`。
  66. */
  67. @Component({
  68. selector: 'app-dk-group-chat-page',
  69. changeDetection: ChangeDetectionStrategy.OnPush,
  70. imports: [
  71. UiAvatar,
  72. UiButton,
  73. UiEmpty,
  74. UiSkeleton,
  75. UiTooltip,
  76. MessageBubbleComponent,
  77. ComposerComponent,
  78. SessionRailComponent,
  79. AssistantTurnComponent,
  80. ImageLightboxComponent,
  81. MentionListComponent,
  82. ],
  83. template: `
  84. <div class="gchat" [style.--rail-w.px]="railOpen() ? railWidth() : 0">
  85. <div class="gchat-main">
  86. <!--
  87. 头部与日常对话页同构(2026-11-06):固定 56px 单行,
  88. 头像组 + 标题(含容器数小字)+ 成员状态摘要 + 侧栏开关。
  89. 此前是三行堆叠(标题/副标题/成员 chip),头部明显比日常页高,
  90. 两页观感不一致。成员的完整状态在右栏「状态」Tab 里逐行展示。
  91. -->
  92. <header class="gchat-head">
  93. <span class="head-avatars" aria-hidden="true">
  94. @for (m of members().slice(0, 3); track m.id) {
  95. <ui-avatar [name]="m.name" size="sm" />
  96. }
  97. </span>
  98. <div class="head-main">
  99. <strong class="ellipsis">{{ title() }}</strong>
  100. <small class="ellipsis">{{ headLine() }}</small>
  101. </div>
  102. <!--
  103. 侧栏开合:与日常对话页同一个按钮(ghost 圆形 + 相同图标与尺寸,2026-11-06)。
  104. 此前这里是自绘的描边方块,两页顶部右端按钮一个圆一个方。
  105. -->
  106. <ui-tooltip content="侧栏">
  107. <ui-button
  108. variant="ghost"
  109. shape="circle"
  110. icon="fa-solid fa-table-columns"
  111. ariaLabel="侧栏"
  112. [class.on]="railOpen()"
  113. (click)="railOpen.set(!railOpen())"
  114. />
  115. </ui-tooltip>
  116. </header>
  117. @if (matrix.warningOf(session()?.id ?? ''); as warn) {
  118. <div class="hist-warn" role="status">
  119. <i class="fa-solid fa-circle-info" aria-hidden="true"></i>
  120. {{ warn }}
  121. </div>
  122. }
  123. <div class="messages" #scroller (scroll)="onScroll()">
  124. <!-- 正在从 Matrix 重建历史:显示骨架,不要闪一下「开始群聊」 -->
  125. @if (matrix.isRefreshing(session()?.id ?? '') && turns().length === 0) {
  126. <div class="skeleton-hold">
  127. @for (i of [1, 2, 3]; track i) {
  128. <ui-skeleton variant="rect" height="56px" />
  129. }
  130. </div>
  131. }
  132. @for (t of aiTurns(); track t.id) {
  133. <!--
  134. 用户消息复用 app-message-bubble(2026-11-06):
  135. 此前群聊页手写了独立气泡样式(蓝底、62% 宽、贴边),
  136. 与日常对话页的气泡(surface 底、留白、居中限宽)视觉不一致。
  137. 日常页也是「同一个组件 + own 标记」,这里对齐。
  138. -->
  139. @if (t.userMessage) {
  140. <app-message-bubble [message]="t.userMessage" [own]="true" (preview)="openFile($event)" />
  141. }
  142. <!--
  143. 本回合用户额外发的附件(2026-11-07):用户气泡只承载一条消息,
  144. 多个文件时其余文件跟在同一气泡下方(右对齐)。
  145. -->
  146. @for (f of t.userFiles; track f.id) {
  147. <div class="user-files">
  148. <app-message-bubble [message]="f" [own]="true" (preview)="openFile($event)" />
  149. </div>
  150. }
  151. <!--
  152. 回合主体(2026-11-06 用户需求):顶部过程区可折叠 + 按容器分块 + 文件宫格。
  153. 统一走 app-assistant-turn,与单聊/移动端同一套视图模型。
  154. -->
  155. <app-assistant-turn
  156. [turn]="t"
  157. [grouped]="true"
  158. (preview)="openFile($event)"
  159. (openTask)="openTask($event)"
  160. />
  161. } @empty {
  162. <!-- 与日常页空态同一位置与尺度:整块垂直居中,而不是贴着标题栏 -->
  163. <div class="empty-hold">
  164. <ui-empty title="开始群聊" description="发一条消息,@ 需要响应的容器。" />
  165. </div>
  166. }
  167. </div>
  168. @if (sendError(); as err) {
  169. <div class="send-err" role="alert">
  170. <i class="fa-solid fa-circle-exclamation" aria-hidden="true"></i>
  171. {{ err }}
  172. </div>
  173. } @else if (unknownMentions().length > 0) {
  174. <div class="send-err" role="alert">
  175. <i class="fa-solid fa-circle-exclamation" aria-hidden="true"></i>
  176. 这些名字不在群里:
  177. @for (n of unknownMentions(); track n) {
  178. <span class="bad-name">{{ n }}</span>
  179. }
  180. </div>
  181. }
  182. <div class="composer-hold">
  183. <!-- 排队中:浮在输入卡片上方(绝对定位,不占整行) -->
  184. @if (queueItems().length > 0) {
  185. <div class="queue-hold" role="status" aria-live="polite">
  186. @for (q of queueItems(); track q.id) {
  187. <div class="queue-row">
  188. <i class="fa-solid fa-hourglass-half" aria-hidden="true"></i>
  189. <span class="queue-text">
  190. 等待 {{ waitNames(q) }} 回复完成 · 将自动发送
  191. </span>
  192. <button type="button" class="queue-recall" (click)="recall(q.id)">撤回</button>
  193. </div>
  194. }
  195. </div>
  196. }
  197. <!-- @ 候选浮层:绝对定位在输入卡片上方(类似微信 @ 人) -->
  198. @if (candidates().length > 0) {
  199. <div class="mention-hold">
  200. <app-mention-list [items]="candidates()" (chosen)="pickMention($event)" />
  201. </div>
  202. }
  203. <!-- 待发附件条:与日常对话页同一形态(图片缩略卡 / 文件 chip + 移除) -->
  204. @if (sender.pendingFiles().length > 0) {
  205. <div class="pending-holds" role="list" aria-label="待发送附件">
  206. @for (pf of sender.pendingFiles(); track pf.id) {
  207. @if (pf.previewUrl) {
  208. <div class="pending-card" role="listitem">
  209. <img [src]="pf.previewUrl" [alt]="pf.file.name" class="pending-img" />
  210. <button
  211. type="button"
  212. class="pending-x"
  213. [attr.aria-label]="'移除 ' + pf.file.name"
  214. (click)="sender.unstageFile(pf.id)"
  215. >
  216. <i class="fa-solid fa-xmark" aria-hidden="true"></i>
  217. </button>
  218. </div>
  219. } @else {
  220. <span class="pending-chip" role="listitem">
  221. <i class="fa-solid fa-paperclip" aria-hidden="true"></i>
  222. <span class="pending-name ellipsis" [title]="pf.file.name">{{ pf.file.name }}</span>
  223. <button
  224. type="button"
  225. class="pending-x pending-x--inline"
  226. [attr.aria-label]="'移除 ' + pf.file.name"
  227. (click)="sender.unstageFile(pf.id)"
  228. >
  229. <i class="fa-solid fa-xmark" aria-hidden="true"></i>
  230. </button>
  231. </span>
  232. }
  233. }
  234. </div>
  235. }
  236. <app-composer
  237. [draft]="draft()"
  238. [sending]="sender.sending()"
  239. [uploading]="sender.uploading()"
  240. [pct]="sender.uploadPct()"
  241. [hasAttachments]="sender.pendingFiles().length > 0"
  242. [placeholder]="'发消息,@ 需要响应的容器…'"
  243. (draftChange)="draft.set($event); sendError.set(null); mentionDismissed.set(false)"
  244. (send)="send($event)"
  245. (attach)="sender.stageFiles($event)"
  246. [keyHandler]="onComposerKey"
  247. />
  248. </div>
  249. </div>
  250. <!-- 右侧信息栏:与日常对话页同一组件(2026-11-06) -->
  251. @if (railOpen()) {
  252. <!--
  253. 拖宽手柄(2026-11-06):与日常对话页同一套交互 —— 向左拖变宽、双击复原。
  254. 日常页的手柄占 5px 栅格列;这里右栏是 flex 子项,手柄绝对定位叠在
  255. 右栏左缘(见 .rail-resizer),不占排版宽度,也就不会出现一道空白缝。
  256. -->
  257. <div
  258. class="rail-resizer"
  259. role="separator"
  260. aria-orientation="vertical"
  261. aria-label="拖动调整侧栏宽度,双击复原"
  262. tabindex="0"
  263. [attr.aria-valuenow]="railWidth()"
  264. (pointerdown)="startResize($event)"
  265. (keydown.arrow-left)="nudgeWidth(40)"
  266. (keydown.arrow-right)="nudgeWidth(-40)"
  267. (dblclick)="resetRailWidth()"
  268. ></div>
  269. <app-session-rail
  270. class="rail-host"
  271. [files]="railFiles()"
  272. [tasks]="railTasks()"
  273. [deliverables]="railDeliverables()"
  274. [runtimes]="railRuntimes()"
  275. [ownerNameOf]="memberName"
  276. [tabs]="railTabs"
  277. tasksTitle="群成员的任务"
  278. tasksEmpty="群成员暂无任务。"
  279. filesTitle="群内文件"
  280. filesEmpty="这个群里还没有文件。"
  281. resultsTitle="群成员成果"
  282. resultsEmpty="群成员暂无交付成果。"
  283. [preview]="railPreview()"
  284. (previewRequest)="openFile($event)"
  285. (previewClosed)="railPreview.set(null)"
  286. (close)="railOpen.set(false)"
  287. (openTask)="openTask($event)"
  288. />
  289. }
  290. </div>
  291. <!--
  292. 图片灯箱 / 音视频模态(2026-11-06):
  293. 原先群聊页点击文件**没有任何反应** —— 三处气泡都没绑 (preview),
  294. 页面也没引入预览组件。现在与日常页同一套分流(core/file-open.ts)。
  295. -->
  296. <app-image-lightbox
  297. [open]="!!lightboxFile()"
  298. [url]="lightboxFile()?.url ?? ''"
  299. [name]="lightboxFile()?.name ?? ''"
  300. [size]="lightboxFile()?.size ?? 0"
  301. (closed)="lightboxFile.set(null)"
  302. />
  303. `,
  304. styles: [
  305. `
  306. :host {
  307. display: block;
  308. height: 100%;
  309. /* 主区底色与日常对话页一致(var(--surface),不是画布灰) */
  310. min-height: 0;
  311. overflow: hidden;
  312. background: var(--surface);
  313. }
  314. .gchat {
  315. position: relative;
  316. display: flex;
  317. flex-direction: row;
  318. height: 100%;
  319. }
  320. /* 左侧对话列:与日常页同构(顶栏/消息区/输入区三段) */
  321. .gchat-main {
  322. display: flex;
  323. flex-direction: column;
  324. flex: 1;
  325. min-width: 0;
  326. min-height: 0;
  327. border-right: 1px solid var(--edge);
  328. }
  329. .rail-host {
  330. flex: none;
  331. width: var(--rail-w, 320px);
  332. min-height: 0;
  333. }
  334. /*
  335. * 拖宽手柄:与日常页同交互(向左拖变宽)。
  336. *
  337. * 🔴 绝对定位、不占排版宽度(2026-11-06 用户反馈「右栏左边有一条空白缝」):
  338. * 原实现是 flex 子项,4px 宽 + 负 margin 后仍留下一道缝,
  339. * 看起来像断开的版面而不是可拖的边界。
  340. * 现在叠在右栏左缘,视觉上只是一条 1px 分隔线,热区左右各 4px。
  341. */
  342. .rail-resizer {
  343. position: absolute;
  344. top: 0;
  345. bottom: 0;
  346. right: var(--rail-w, 320px);
  347. width: 9px;
  348. margin-right: -4px;
  349. cursor: col-resize;
  350. background: transparent;
  351. z-index: var(--z-affix);
  352. touch-action: none;
  353. }
  354. .rail-resizer::after {
  355. content: '';
  356. position: absolute;
  357. inset: 0 4px;
  358. background: var(--edge);
  359. transition: background-color var(--motion-fast) var(--ease-standard);
  360. }
  361. .rail-resizer:hover::after,
  362. .rail-resizer:focus-visible::after,
  363. .rail-resizer.active::after {
  364. background: var(--primary);
  365. }
  366. /*
  367. * 手柄本身是 1px 分隔线,默认焦点环会把整条线包围一圈、观感突兀,
  368. * 故把焦点指示放到那条线上(变主色 + 加粗),可访问性不丢:
  369. * 键盘聚焦时有明确可见的焦点反馈(见上方 :focus-visible::after)。
  370. */
  371. .rail-resizer:focus-visible { outline: none; }
  372. /* 与日常页 .chat-head 同构:固定高度单行,内容变化不推高消息区 */
  373. .gchat-head {
  374. flex: none;
  375. display: flex;
  376. align-items: center;
  377. gap: var(--sp-3);
  378. height: var(--desk-topbar-h);
  379. padding: 0 var(--sp-4);
  380. border-bottom: 1px solid var(--edge);
  381. background: var(--surface);
  382. }
  383. .head-avatars {
  384. display: inline-flex;
  385. flex-shrink: 0;
  386. }
  387. /* 多个头像轻微重叠,暗示「这是一个群」而不是一个人 */
  388. .head-avatars > :not(:first-child) {
  389. margin-left: -8px;
  390. }
  391. .head-main {
  392. display: grid;
  393. gap: 1px;
  394. flex: 1;
  395. min-width: 0;
  396. line-height: 1.35;
  397. }
  398. .head-main strong {
  399. font-size: var(--fs-15);
  400. font-weight: 600;
  401. color: var(--ink);
  402. }
  403. .head-main small {
  404. font-size: var(--fs-12);
  405. color: var(--muted);
  406. }
  407. /* 侧栏开合的激活态:与日常对话页同一个写法(ghost 按钮 + .on) */
  408. .gchat-head ui-button.on button {
  409. background: var(--primary-subtle);
  410. color: var(--primary);
  411. }
  412. /* 与日常页 .messages 同一套布局:grid + align-content:start,子项天然撑满列 */
  413. .messages {
  414. flex: 1;
  415. min-height: 0;
  416. overflow-x: hidden;
  417. overflow-y: auto;
  418. overscroll-behavior: contain;
  419. display: grid;
  420. align-content: start;
  421. gap: var(--sp-6);
  422. padding: var(--sp-6) max(var(--sp-4), calc((100% - 768px) / 2)) var(--sp-8);
  423. }
  424. /* 空态占满消息区并居中:grid 的 align-content:start 会把它顶到最上面 */
  425. .empty-hold {
  426. display: grid;
  427. place-items: center;
  428. /*
  429. * 🔴 用 grid-template-rows 而不是 min-height:100%:
  430. * 父级是 grid + align-content:start,行高按内容算,
  431. * min-height:100% 会被解析成「内容行的高度」,等于没写。
  432. */
  433. grid-template-rows: minmax(100%, 1fr);
  434. }
  435. /* grid 下子项已撑满列,这条不再需要 */
  436. .messages > app-assistant-turn,
  437. .messages > app-message-bubble {
  438. min-width: 0;
  439. }
  440. /*
  441. * @ 候选浮层(2026-11-07 用户要求「类似微信 @ 人的效果」):
  442. * 绝对定位浮在输入框上方,不占文档流、不推挤消息区。
  443. * **没有背景条** —— 只有候选框自身带底与阴影。
  444. */
  445. .mention-hold {
  446. position: absolute;
  447. left: max(var(--sp-4), calc((100% - 768px) / 2));
  448. bottom: 100%;
  449. margin-bottom: var(--sp-2);
  450. z-index: var(--z-popover);
  451. }
  452. /* 排队提示:输入框上方一行,淡色底 */
  453. /*
  454. * 排队提示(2026-11-07 用户反馈「不要完整一行」):
  455. * 原来是一条横贯整屏的色带,太抢眼、也把输入区顶得更高。
  456. * 改成**浮在输入框正上方的紧凑胶囊**,宽度随文字收缩;
  457. * 多条时纵向堆叠,仍不占满整行。
  458. */
  459. .queue-hold {
  460. position: absolute;
  461. left: max(var(--sp-4), calc((100% - 768px) / 2));
  462. right: max(var(--sp-4), calc((100% - 768px) / 2));
  463. bottom: 100%;
  464. margin-bottom: var(--sp-2);
  465. display: flex;
  466. flex-direction: column;
  467. align-items: flex-start;
  468. gap: var(--sp-1);
  469. z-index: var(--z-popover);
  470. pointer-events: none;
  471. }
  472. /* 有 @ 候选时把排队条再抬高,避免两层叠在一起 */
  473. .mention-hold ~ .queue-hold,
  474. .queue-hold:has(+ * .mention-hold) {
  475. bottom: calc(100% + 240px);
  476. }
  477. .queue-row {
  478. pointer-events: auto;
  479. display: inline-flex;
  480. align-items: center;
  481. gap: var(--sp-2);
  482. max-width: 100%;
  483. padding: var(--sp-1) var(--sp-2) var(--sp-1) var(--sp-3);
  484. border: 1px solid var(--warning);
  485. border-radius: var(--r-tag);
  486. background: var(--warning-subtle);
  487. color: var(--warning);
  488. font-size: var(--fs-12);
  489. }
  490. .queue-text {
  491. flex: 1;
  492. min-width: 0;
  493. overflow: hidden;
  494. text-overflow: ellipsis;
  495. white-space: nowrap;
  496. }
  497. .queue-recall {
  498. flex: none;
  499. padding: 0 var(--sp-2);
  500. min-height: 24px;
  501. border: 1px solid currentColor;
  502. border-radius: var(--r-sm);
  503. background: none;
  504. color: inherit;
  505. font: inherit;
  506. font-size: var(--fs-11);
  507. cursor: pointer;
  508. }
  509. .queue-recall:hover {
  510. background: var(--warning);
  511. color: var(--inverse);
  512. }
  513. /* 用户本回合额外附件:与用户气泡同一条右对齐轨道 */
  514. .user-files {
  515. display: flex;
  516. justify-content: flex-end;
  517. }
  518. /* 历史未完整载入的提示(部分成员不可用):轻提示,不遮挡内容 */
  519. .hist-warn {
  520. flex: none;
  521. display: flex;
  522. align-items: center;
  523. gap: var(--sp-2);
  524. margin: 0 max(var(--sp-4), calc((100% - 768px) / 2)) var(--sp-2);
  525. padding: var(--sp-1) var(--sp-3);
  526. border-radius: var(--r-md);
  527. background: var(--warning-subtle);
  528. color: var(--warning);
  529. font-size: var(--fs-12);
  530. }
  531. /* 待发附件条:与日常页同一形态 */
  532. .pending-holds {
  533. display: flex;
  534. flex-wrap: wrap;
  535. gap: var(--sp-2);
  536. padding: var(--sp-2) 0;
  537. }
  538. .pending-card {
  539. position: relative;
  540. width: 132px;
  541. height: 96px;
  542. border-radius: var(--r-md);
  543. overflow: hidden;
  544. background: var(--surface-2);
  545. }
  546. .pending-img {
  547. display: block;
  548. width: 100%;
  549. height: 100%;
  550. object-fit: cover;
  551. }
  552. .pending-chip {
  553. display: inline-flex;
  554. align-items: center;
  555. gap: var(--sp-2);
  556. max-width: 220px;
  557. padding: var(--sp-1) var(--sp-2);
  558. border: 1px solid var(--edge);
  559. border-radius: var(--r-md);
  560. background: var(--surface-2);
  561. font-size: var(--fs-12);
  562. color: var(--ink-2);
  563. }
  564. .pending-name {
  565. min-width: 0;
  566. }
  567. .pending-x {
  568. position: absolute;
  569. top: 4px;
  570. right: 4px;
  571. display: grid;
  572. place-items: center;
  573. width: 20px;
  574. height: 20px;
  575. border: 0;
  576. border-radius: var(--r-tag);
  577. background: color-mix(in srgb, var(--canvas) 60%, transparent);
  578. color: var(--inverse);
  579. cursor: pointer;
  580. }
  581. .pending-x--inline {
  582. position: static;
  583. background: transparent;
  584. color: var(--faint);
  585. border-radius: var(--r-sm);
  586. }
  587. .pending-x--inline:hover {
  588. background: var(--surface-hover);
  589. color: var(--ink);
  590. }
  591. /* 与日常页完全一致:无分界线、无底色,输入卡片浮在画布上 */
  592. .composer-hold {
  593. flex: none;
  594. /* @ 候选浮层以它为定位容器(浮在输入卡片上方) */
  595. position: relative;
  596. padding: 0 max(var(--sp-4), calc((100% - 768px) / 2)) var(--sp-4);
  597. }
  598. /*
  599. * 提示条与消息流/输入卡片同宽同缩进(2026-11-06):
  600. * 此前贴边通栏,和居中 768px 的对话区不在一条线上,像另一块界面的内容。
  601. */
  602. .send-err {
  603. flex: none;
  604. display: flex;
  605. flex-wrap: wrap;
  606. align-items: center;
  607. gap: var(--sp-2);
  608. margin: 0 max(var(--sp-4), calc((100% - 768px) / 2)) var(--sp-2);
  609. padding: var(--sp-2) var(--sp-3);
  610. border-radius: var(--r-md);
  611. background: var(--danger-subtle);
  612. color: var(--danger);
  613. font-size: var(--fs-13);
  614. }
  615. .bad-name {
  616. padding: 0 var(--sp-2);
  617. border-radius: var(--r-tag);
  618. background: var(--danger);
  619. color: var(--inverse);
  620. font-weight: 600;
  621. }
  622. .ellipsis {
  623. overflow: hidden;
  624. text-overflow: ellipsis;
  625. white-space: nowrap;
  626. }
  627. `,
  628. ],
  629. })
  630. export class DkGroupChatPage implements OnDestroy {
  631. private readonly route = inject(ActivatedRoute);
  632. private readonly sessions = inject(ChatSessionService);
  633. private readonly sessionSvc = inject(SessionService);
  634. private readonly groups = inject(GroupChatService);
  635. private readonly groupLive = inject(GroupLiveService);
  636. /** 排队发送:@ 正在回复的容器时,消息等到它空闲再自动发出(2026-11-07) */
  637. private readonly queue = inject(GroupQueueService);
  638. protected readonly store = inject(GroupStoreService);
  639. /** 群聊历史 Matrix 重建(2026-11-07) */
  640. protected readonly matrix = inject(GroupMatrixService);
  641. /** 群聊发送编排(含附件) */
  642. protected readonly sender = inject(GroupSendService);
  643. private readonly live = inject(LiveService);
  644. private readonly chat = inject(ChatService);
  645. private readonly scroller = viewChild<ElementRef<HTMLDivElement>>('scroller');
  646. protected readonly draft = signal('');
  647. // ---------- 右侧信息栏(与日常对话页同一套组件与偏好,2026-11-06) ----------
  648. private readonly tasks = inject(TasksService);
  649. private readonly deliverablesSvc = inject(DeliverablesService);
  650. private readonly pulse = inject(TaskPulseService);
  651. private readonly router = inject(Router);
  652. protected readonly railOpen = signal(true);
  653. private readonly _railWidth = signal(this.readRailWidth());
  654. protected readonly railWidth = this._railWidth.asReadonly();
  655. protected readonly railTabs: RailTabDef[] = RAIL_TABS.map((t) => ({
  656. ...t,
  657. hint:
  658. t.key === 'files'
  659. ? '群里产出的文件'
  660. : t.key === 'tasks'
  661. ? '群成员名下的任务'
  662. : t.key === 'results'
  663. ? '群成员的交付成果'
  664. : '各容器的运行状态',
  665. }));
  666. private readRailWidth(): number {
  667. try {
  668. return clampRailWidth(Number(localStorage.getItem(RAIL_W_KEY)));
  669. } catch {
  670. return RAIL_W_DEFAULT;
  671. }
  672. }
  673. /*
  674. * 拖宽(2026-11-06,与日常对话页同一套交互):
  675. * 向左拖 = 变宽;拖完写回 localStorage,两页共用同一个宽度偏好。
  676. */
  677. private resizeFrom = 0;
  678. private resizeStartX = 0;
  679. private readonly onResizeMove = (e: PointerEvent): void => {
  680. const raw = this.resizeFrom + (this.resizeStartX - e.clientX);
  681. this._railWidth.set(clampRailWidth(raw));
  682. };
  683. private readonly onResizeEnd = (): void => {
  684. document.removeEventListener('pointermove', this.onResizeMove);
  685. document.removeEventListener('pointerup', this.onResizeEnd);
  686. document.body.classList.remove('is-resizing-rail');
  687. try {
  688. localStorage.setItem(RAIL_W_KEY, String(this._railWidth()));
  689. } catch {
  690. /* 隐私模式:只保留内存态 */
  691. }
  692. };
  693. protected startResize(e: PointerEvent): void {
  694. e.preventDefault();
  695. this.resizeFrom = this._railWidth();
  696. this.resizeStartX = e.clientX;
  697. document.addEventListener('pointermove', this.onResizeMove);
  698. document.addEventListener('pointerup', this.onResizeEnd);
  699. document.body.classList.add('is-resizing-rail');
  700. }
  701. /** 键盘微调(手柄可聚焦):方向键 ±40px */
  702. protected nudgeWidth(dx: number): void {
  703. this._railWidth.set(clampRailWidth(this._railWidth() + dx));
  704. try {
  705. localStorage.setItem(RAIL_W_KEY, String(this._railWidth()));
  706. } catch {
  707. /* 忽略 */
  708. }
  709. }
  710. protected resetRailWidth(): void {
  711. this._railWidth.set(RAIL_W_DEFAULT);
  712. try {
  713. localStorage.setItem(RAIL_W_KEY, String(RAIL_W_DEFAULT));
  714. } catch {
  715. /* 忽略 */
  716. }
  717. }
  718. /** 群内所有容器交付的附件(从群聊消息里汇总) */
  719. protected readonly railFiles = computed<RailFile[]>(() => {
  720. const seen = new Set<string>();
  721. const out: RailFile[] = [];
  722. for (const m of this.messages()) {
  723. const f = m.file;
  724. if (!f?.url) continue;
  725. const key = f.url + f.name;
  726. if (seen.has(key)) continue;
  727. seen.add(key);
  728. out.push({ name: f.name, url: f.url, mimeType: f.mimeType, size: f.size });
  729. }
  730. return out;
  731. });
  732. /** 群成员的任务(合并去重) */
  733. protected readonly railTasks = computed<Task[]>(() => {
  734. const ids = new Set(this.session()?.memberIds ?? []);
  735. if (ids.size === 0) return [];
  736. return this.tasks.all().filter((t) => !!t.agentId && ids.has(t.agentId));
  737. });
  738. /**
  739. * 群文件与成果(2026-11-10 修正)。
  740. *
  741. * 🔴 原实现遍历成员容器取其**全部**成果 —— A 群里会显示这些成员
  742. * 在别的会话里产出的文件,属于串台。改为按群会话 id(threadRoot)查台账。
  743. */
  744. protected readonly railDeliverables = computed<Deliverable[]>(() => this.dbFiles());
  745. /** 按群会话从数据库加载的文件 */
  746. private readonly _dbFiles = signal<Deliverable[]>([]);
  747. readonly dbFiles = this._dbFiles.asReadonly();
  748. private loadSessionFiles(): void {
  749. const sid = this.session()?.id;
  750. if (!sid) {
  751. this._dbFiles.set([]);
  752. return;
  753. }
  754. void this.deliverablesSvc
  755. .loadForSession(sid)
  756. .then((files) => {
  757. if (this.session()?.id === sid) this._dbFiles.set(files);
  758. })
  759. .catch(() => {
  760. if (this.session()?.id === sid) this._dbFiles.set([]);
  761. });
  762. }
  763. /** 状态页:每个成员一行 */
  764. protected readonly railRuntimes = computed<RailRuntime[]>(() =>
  765. this.members().map((a) => ({
  766. agentId: a.id,
  767. name: a.name,
  768. taskText: this.pulse.describe(a).text,
  769. taskTone: this.pulse.describe(a).tone,
  770. cpuPercent: a.host?.cpuPercent,
  771. memPercent: a.host?.memPercent,
  772. totalTokens: usageOf(a as never).totalTokens,
  773. })),
  774. );
  775. protected openTask(id: string): void {
  776. void this.router.navigate(['/desktop/tasks'], { queryParams: { task: id } });
  777. }
  778. /* ------------------------------------------------------------------ */
  779. /* 文件预览(2026-11-06 修复) */
  780. /* ------------------------------------------------------------------ */
  781. /** 灯箱文件(图片) */
  782. protected readonly lightboxFile = signal<{ url: string; name: string; size: number } | null>(null);
  783. /**
  784. * 交给右栏内联预览的文件。
  785. *
  786. * 🔴 这是**受控**信号:`app-session-rail` 只负责显示,打开哪个文件由这里决定。
  787. * 此前右栏自己持有 `previewFile`,页面无法命令它,导致
  788. * 「从右栏列表点能开、从消息里点没反应」。
  789. */
  790. protected readonly railPreview = signal<RailFile | null>(null);
  791. /**
  792. * 打开文件:图片 → 灯箱;音视频 → 右栏(暂以右栏承载,见下);
  793. * 其余 → 右栏内联预览。
  794. *
  795. * 分流规则统一在 `core/file-open.ts`,与日常对话页同一份实现。
  796. */
  797. openFile(f: OpenableFile): void {
  798. const target = routeOpen(f);
  799. if (target.kind === 'lightbox') {
  800. // 与日常页一致:进灯箱前清空右栏预览,避免叠层(2026-11-07)
  801. this.railPreview.set(null);
  802. this.lightboxFile.set({
  803. url: target.file.url,
  804. name: target.file.name,
  805. size: target.file.size ?? 0,
  806. });
  807. return;
  808. }
  809. /*
  810. * 音视频:群聊页暂无独立媒体模态,先落到右栏(预览组件内部会给出
  811. * 对应的播放控件)。如需与日常页一致的居中模态,可后续补齐。
  812. */
  813. this.railPreview.set({
  814. name: target.file.name,
  815. url: target.file.url,
  816. mimeType: target.file.mimeType ?? '',
  817. size: target.file.size,
  818. });
  819. this.railOpen.set(true);
  820. }
  821. /** 音视频类型判定(右栏外置模板用,暂时保留签名一致性) */
  822. protected readonly sending = signal(false);
  823. /** 发送/输入校验的提示(不 @ 人、@ 了非成员) */
  824. protected readonly sendError = signal<string | null>(null);
  825. /**
  826. * 当前群 id —— 订阅路由参数流,不用快照(2026-11-06 修复)。
  827. *
  828. * 群聊页之间切换时组件会被复用、构造函数不重跑;只读快照会让标题、
  829. * 成员、消息全都停留在上一个群(左栏点另一个群,右侧内容纹丝不动)。
  830. */
  831. private readonly groupId = toSignal(
  832. this.route.paramMap.pipe(map((p) => p.get('groupId') ?? '')),
  833. { initialValue: '' },
  834. );
  835. protected readonly session = computed(() =>
  836. this.sessions.byId(this.groupId()),
  837. );
  838. protected readonly members = computed<Agent[]>(() => {
  839. const s = this.session();
  840. return s ? this.groups.membersOf(s) : [];
  841. });
  842. protected readonly title = computed(() => this.session()?.title ?? '群聊');
  843. /**
  844. * 标题行下方的小字:容器数与忙碌概览。
  845. *
  846. * 与日常页 `.chat-title small` 同一角色 —— 一眼看出「有几个、几个在忙」,
  847. * 不必展开右栏。完整状态在右栏「状态」Tab。
  848. */
  849. protected readonly headLine = computed(() => {
  850. const all = this.members();
  851. if (all.length === 0) return '暂无可用容器';
  852. const busy = all.filter((m) => this.isBusy(m.id)).map((m) => m.name);
  853. if (busy.length === 0) return `${all.length} 个容器`;
  854. return `${all.length} 个容器 · ${busy.join('、')} 处理中`;
  855. });
  856. protected readonly messages = computed<Message[]>(() => {
  857. const s = this.session();
  858. if (!s) return [];
  859. return this.store.entriesOf(s.id).map((e) => e.message);
  860. });
  861. protected readonly turns = computed(() =>
  862. buildGroupTurns({
  863. entries: this.session() ? this.store.entriesOf(this.session()!.id) : [],
  864. targetsOf: (turnId) => this.store.targetsOf(turnId),
  865. typingOf: (agentId) => this.groupLive.isTyping(agentId),
  866. now: this.groupLive.clock(),
  867. }),
  868. );
  869. /**
  870. * 回合视图(2026-11-06 用户需求):顶部过程区 + 按容器分块 + 文件宫格。
  871. *
  872. * `buildGroupTurns` 给出的是「原始回合」,这里转成渲染用的视图模型,
  873. * 与单聊/移动端共用 `core/assistant-turn.ts` 的同一套派生规则。
  874. * `turns()` 仍保留给状态行、派发统计等既有逻辑使用。
  875. */
  876. protected readonly aiTurns = computed(() =>
  877. turnsFromGroup(this.turns(), this.groupLive.clock(), GROUP_QUIET_MS, (id) => this.memberName(id)),
  878. );
  879. constructor() {
  880. /*
  881. * 群文件台账:会话就绪或消息数变化时重拉(2026-11-10)。
  882. * 群文件按 threadRoot=群会话 id 查,不再按成员容器全量取。
  883. */
  884. effect(() => {
  885. this.session()?.id;
  886. this.messages().length;
  887. this.loadSessionFiles();
  888. });
  889. // 从 localStorage 恢复群内消息(幂等,重复调用无副作用)
  890. this.store.load();
  891. /*
  892. * 排队发送接入(2026-11-07):
  893. * ① 绑定当前用户 → 从 sessionStorage 恢复队列(刷新后继续等待)
  894. * ② 注册「到点后怎么发」——复用 sender.send,正文已剥离 @ 但需补回 @名字
  895. * ③ 心跳:跟 `groupLive.clock()` 一起走(它已有 2 秒节拍),不另起定时器
  896. */
  897. const me = this.sessionSvc.me()?.id ?? '';
  898. if (me) this.queue.bind(me);
  899. this.queue.onFlush(async (q, body) => {
  900. const s = this.session();
  901. if (!s) return;
  902. // 队列里存的是剥离 @ 后的正文,发出前要把目标容器 @ 回去
  903. const mention = q.waitingIds.map((id) => `@${this.memberName(id)}`).join(' ');
  904. await this.sender.send(s, `${mention} ${body}`.trim(), (id) => this.memberName(id));
  905. });
  906. effect(() => {
  907. const s = this.session();
  908. this.groupLive.clock();
  909. if (s) untracked(() => void this.queue.tick(s));
  910. });
  911. /*
  912. * 🔴 会话目录必须先载入本地缓存(2026-11-06)。
  913. * 单聊页在 `onRouteChange` 里做这件事,群聊页此前漏了 ——
  914. * 深链接/刷新进来时 `session()` 恒为 undefined,
  915. * 表现为标题变成「群聊」、成员数 0、历史为空(实测)。
  916. */
  917. this.sessions.load();
  918. /*
  919. * 容器列表同理:`members()` 靠 `live.agentById()` 解析成员名,
  920. * 没加载过就解析不出任何成员。
  921. */
  922. void this.live.ensureLoaded();
  923. // 本地没有该群时,去云端补拉一次
  924. void this.loadGroupSession();
  925. /*
  926. * 群切换(2026-11-07):组件在 `/group/:id` 之间会被复用。
  927. * 切走时要释放上一个群的实时订阅,否则两个群的回复会同时写进来。
  928. */
  929. let prevGroup: string | null = null;
  930. effect(() => {
  931. const s = this.session();
  932. if (!s) return;
  933. if (prevGroup && prevGroup !== s.id) {
  934. this.groupLive.release(prevGroup);
  935. this.sender.clear();
  936. }
  937. prevGroup = s.id;
  938. /*
  939. * 先从 Matrix 重建历史(权威),再订阅实时。
  940. * 🔴 重建要等初次 sync,必须 untracked 且不 await —— 页面先渲染本地缓存,
  941. * 重建完成后 store 变化会自动刷新视图。
  942. */
  943. untracked(() => void this.matrix.refresh(s));
  944. void this.groupLive.subscribe(s);
  945. });
  946. if (typeof window !== 'undefined') this.matrix.invalidate();
  947. /*
  948. * 贴底(2026-11-06 修正):与日常页同一逻辑 —— 新内容到达后滚到底。
  949. *
  950. * 🔴 原来用 queueMicrotask:那一刻消息刚进数据、DOM 还没重排,
  951. * scrollHeight 仍是旧值,滚到底后又被新内容顶出视口
  952. * (实测:最新一轮被输入区挡住半截,且再也不会自动贴底)。
  953. * 改用两帧 + 120ms 兜底,吃掉 markdown/图片渲染带来的高度增长。
  954. * 用户主动上翻时不拽回底部。
  955. */
  956. effect(() => {
  957. this.messages();
  958. this.turns();
  959. /*
  960. * 🔴 必须依赖 `aiTurns` 而不是 `turns`(2026-11-07):
  961. * 新结构下「内容增长」体现在视图模型里(正文合并、步骤累加),
  962. * 而 `turns()` 只在消息条数变化时才变。只依赖 `turns` 会让
  963. * 「同一条消息被续写」的场景不触发滚动 —— 表现为发送后不自动到底。
  964. */
  965. this.aiTurns();
  966. if (this.userScrolledAway) return;
  967. this.stickToBottom();
  968. });
  969. }
  970. /** 用户是否主动上翻(>160px 视为在看历史,新消息不拽回底部) */
  971. private userScrolledAway = false;
  972. protected onScroll(): void {
  973. const el = this.scroller()?.nativeElement;
  974. if (!el) return;
  975. this.userScrolledAway = el.scrollHeight - el.scrollTop - el.clientHeight > 160;
  976. }
  977. private stickToBottom(): void {
  978. /*
  979. * 节流:`aiTurns` 会因 typing 心跳(约 2s 一次)反复重算,
  980. * 若每次都滚到底,用户轻轻上滑就会被反复拽回(实测体验很差)。
  981. * 200ms 内的重复请求合并成一次。
  982. */
  983. const now = Date.now();
  984. if (now - this.lastStickAt < 200) return;
  985. this.lastStickAt = now;
  986. const toBottom = (): void => {
  987. const el = this.scroller()?.nativeElement;
  988. if (el) el.scrollTop = el.scrollHeight;
  989. };
  990. toBottom();
  991. requestAnimationFrame(() => {
  992. toBottom();
  993. setTimeout(toBottom, 120);
  994. });
  995. }
  996. private lastStickAt = 0;
  997. ngOnDestroy(): void {
  998. const s = this.session();
  999. if (s) this.groupLive.release(s.id);
  1000. }
  1001. /**
  1002. * 本地没有该群会话时,用候选 agentId 去云端补拉一次。
  1003. *
  1004. * 云端会话目录按 agentId 取;群会话的 `agentId` 是首成员,
  1005. * 所以必须拉过首成员才会把它带回来(深链接进入时本地缓存可能为空)。
  1006. */
  1007. private async loadGroupSession(): Promise<void> {
  1008. const id = this.groupId();
  1009. if (!id) return;
  1010. await this.live.ensureLoaded();
  1011. if (this.sessions.byId(id)) return;
  1012. const ids = this.live.agents().map((a) => a.id);
  1013. if (ids.length === 0) return;
  1014. await this.sessions.reloadAll(ids);
  1015. }
  1016. protected isBusy(id: string): boolean {
  1017. const p = this.groupLive.phaseOf(id);
  1018. return p === 'thinking' || p === 'replying';
  1019. }
  1020. protected memberName(agentId?: string): string {
  1021. if (!agentId) return '';
  1022. return this.live.agentById(agentId)?.name ?? agentId;
  1023. }
  1024. /** 容器色条:从 id 稳定派生,同一容器永远同一个颜色(取自语义令牌,不用魔法值) */
  1025. protected colorOf(id: string): string {
  1026. const palette = [
  1027. 'var(--primary)',
  1028. 'var(--success)',
  1029. 'var(--warning)',
  1030. 'var(--info)',
  1031. 'var(--danger)',
  1032. 'var(--accent)',
  1033. ];
  1034. let h = 0;
  1035. for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
  1036. return palette[h % palette.length];
  1037. }
  1038. /**
  1039. * @ 候选(只 @ 成员容器)+ **状态**(2026-11-07 用户需求)。
  1040. *
  1041. * 🔴 状态依赖 `groupLive.clock()`:它每秒推进一次,才能让
  1042. * 「正在回复 → 完成」实时反映在候选列表的颜色上。
  1043. */
  1044. protected readonly candidates = computed<MentionOption[]>(() => {
  1045. if (this.mentionDismissed()) return [];
  1046. const q = parseMentionQuery(this.draft(), this.draft().length);
  1047. if (!q.active) return [];
  1048. this.groupLive.clock(); // 建立依赖,保证状态实时刷新
  1049. const list = this.members().map((a) => ({
  1050. id: a.id,
  1051. name: a.name,
  1052. state: this.mentionStateOf(a.id),
  1053. }));
  1054. return filterMentionCandidates(list, q.query, (x) => x.name);
  1055. });
  1056. /**
  1057. * 容器的候选状态。
  1058. *
  1059. * · blocked:接入异常 / 离线 —— 不可 @
  1060. * · busy:本轮正在回复 —— 可 @,但走排队
  1061. * · ready:其余
  1062. *
  1063. * ⚠️ `phaseOf === 'failed'` 是「本轮无响应」而不是「容器异常」,不算 blocked。
  1064. */
  1065. private mentionStateOf(agentId: string): MentionState {
  1066. const a = this.live.agentById(agentId);
  1067. const presence = agentPresence(a);
  1068. if (presence === 'offline') return 'blocked';
  1069. if (a?.status === 'error' || a?.status === 'stopped') return 'blocked';
  1070. if (this.groupLive.isTyping(agentId)) return 'busy';
  1071. return 'ready';
  1072. }
  1073. /** 选中候选 → 把「@片段」补成「@完整名字」 */
  1074. protected pickMention(c: MentionOption): void {
  1075. const q = parseMentionQuery(this.draft(), this.draft().length);
  1076. this.draft.set(applyMention(this.draft(), q, c.name, this.draft().length).text);
  1077. }
  1078. /* ---------- 排队发送(2026-11-07) ---------- */
  1079. /** @ 候选浮层实例(用于转发键盘事件) */
  1080. private readonly mentionList = viewChild(MentionListComponent);
  1081. /**
  1082. * 输入框按键拦截:@ 浮层打开时接管 ↑/↓/Enter/Esc。
  1083. *
  1084. * 返回 true = 已消费,composer 不再触发 Enter 发送。
  1085. */
  1086. protected readonly onComposerKey = (e: KeyboardEvent): boolean => {
  1087. const list = this.mentionList();
  1088. if (!list || this.candidates().length === 0) return false;
  1089. if (e.key === 'Escape') {
  1090. // 收起:把草稿里的 `@` 保留,只是不再展示候选
  1091. this.mentionDismissed.set(true);
  1092. return true;
  1093. }
  1094. return list.handleKey(e);
  1095. };
  1096. /** Esc 收起候选后,再输入任意字符重新展开 */
  1097. protected readonly mentionDismissed = signal(false);
  1098. protected readonly queueItems = computed(() => {
  1099. const s = this.session();
  1100. return s ? this.queue.queue().filter((q) => q.sessionId === s.id) : [];
  1101. });
  1102. protected waitNames(q: { names: Record<string, string>; waitingIds: string[] }): string {
  1103. return q.waitingIds.map((id) => q.names[id] ?? id).join('、');
  1104. }
  1105. protected recall(id: string): void {
  1106. this.queue.recall(id);
  1107. }
  1108. /** 输入里是否有不在群里的 @ 名字(实时标红提示) */
  1109. protected readonly unknownMentions = computed<string[]>(() => {
  1110. const s = this.session();
  1111. if (!s) return [];
  1112. return this.groups.unknownMentions(this.draft(), s);
  1113. });
  1114. protected async send(text: string): Promise<void> {
  1115. const s = this.session();
  1116. if (!s || this.sender.sending()) return;
  1117. this.sendError.set(null);
  1118. /*
  1119. * 排队分流(2026-11-07 用户需求):
  1120. * @ 了**正在回复**的容器时,这条消息先不发,等它本轮答完再自动发。
  1121. *
  1122. * 🔴 用户裁决「绿立即发、黄排队」:只把忙碌的那些拆进队列,
  1123. * 其余目标照常立即发送 —— 不因为一个人忙就卡住整条消息。
  1124. * 🔴 附件不参与排队(File 无法序列化),带附件时提示用户等一等。
  1125. */
  1126. const plan = this.groups.plan(text, s);
  1127. if (plan.ok) {
  1128. const busy = plan.targets.filter((id) => this.groupLive.isTyping(id));
  1129. if (busy.length > 0) {
  1130. const files = this.sender.pendingFiles();
  1131. if (files.length > 0) {
  1132. this.sendError.set(
  1133. `附件无法排队:请等 ${busy.map((id) => this.memberName(id)).join('、')} 回复完成后再发送`,
  1134. );
  1135. return;
  1136. }
  1137. // 把 @ 从正文里剔除(targets 已经确定),只存正文本身
  1138. const names: Record<string, string> = {};
  1139. for (const id of busy) names[id] = this.memberName(id);
  1140. const idleTargets = plan.targets.filter((id) => !busy.includes(id));
  1141. if (idleTargets.length > 0) {
  1142. // 还有空闲目标:立即发给它们(用原始文本,@ 保持原样)
  1143. const r = await this.sender.send(s, text, (id) => this.memberName(id));
  1144. if (r.error) {
  1145. this.sendError.set(r.error);
  1146. return;
  1147. }
  1148. }
  1149. this.queue.enqueue(s.id, plan.body, names);
  1150. this.draft.set('');
  1151. return;
  1152. }
  1153. }
  1154. const r = await this.sender.send(s, text, (id) => this.memberName(id));
  1155. if (r.error) this.sendError.set(r.error);
  1156. if (r.ok) this.draft.set('');
  1157. }
  1158. }