| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225 |
- import {
- ChangeDetectionStrategy,
- Component,
- effect,
- inject,
- untracked,
- ElementRef,
- OnDestroy,
- computed,
- signal,
- viewChild,
- } from '@angular/core';
- import { ActivatedRoute, Router } from '@angular/router';
- import { toSignal } from '@angular/core/rxjs-interop';
- import { map } from 'rxjs/operators';
- import { Agent, Message } from '../../../core/api/models';
- import { ChatService } from '../../../core/chat.service';
- import { ChatSessionService } from '../../../core/chat-session.service';
- import { SessionService } from '../../../core/session.service';
- import { GroupChatService } from '../../../core/group-chat.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 { LiveService } from '../../../core/live.service';
- import { buildGroupTurns, GROUP_QUIET_MS } from '../../../core/group-turns';
- import { turnsFromGroup } from '../../../core/assistant-turn';
- import { routeOpen, OpenableFile } from '../../../core/file-open';
- import { AssistantTurnComponent } from '../../../components/domain/assistant-turn/assistant-turn';
- import { MentionListComponent, MentionOption, MentionState } from '../../../components/domain/mention-list/mention-list';
- import { GroupQueueService } from '../../../core/group-queue.service';
- import { agentPresence } from '../../../core/api/models';
- import { ImageLightboxComponent } from '../../../components/preview/image-lightbox/image-lightbox';
- import {
- applyMention,
- filterMentionCandidates,
- parseMentionQuery,
- } from '../../../core/mention-autocomplete';
- import { UiAvatar, UiButton, UiEmpty, UiSkeleton, UiTooltip } from '../../../ui';
- import {
- SessionRailComponent,
- RailFile,
- RailRuntime,
- } from '../../../components/domain/session-rail/session-rail';
- import { RailTabDef } from '../../../components/domain/session-rail/rail-shared';
- import {
- RAIL_TABS,
- RAIL_W_DEFAULT,
- RAIL_W_KEY,
- clampRailWidth,
- } from '../../../components/domain/session-rail/rail-shared';
- import { TasksService } from '../../../core/tasks.service';
- import { DeliverablesService } from '../../../core/deliverables.service';
- import { TaskPulseService } from '../../../core/task-pulse.service';
- import { Deliverable, Task } from '../../../core/api/models';
- import { usageOf } from '../../../core/format';
- import { MessageBubbleComponent } from '../../../components/domain/message-bubble/message-bubble';
- import { ComposerComponent } from '../../../components/domain/composer/composer';
- /**
- * 群聊会话页(PC,2026-11-05)
- *
- * 与移动端 `group-chat` 同一套模型(伪群聊 + 回合聚合),
- * 布局按桌面习惯:顶栏 + 成员状态条 + 消息区 + 输入区。
- *
- * 详细背景见 `core/group-chat.service.ts`。
- */
- @Component({
- selector: 'app-dk-group-chat-page',
- changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [
- UiAvatar,
- UiButton,
- UiEmpty,
- UiSkeleton,
- UiTooltip,
- MessageBubbleComponent,
- ComposerComponent,
- SessionRailComponent,
- AssistantTurnComponent,
- ImageLightboxComponent,
- MentionListComponent,
- ],
- template: `
- <div class="gchat" [style.--rail-w.px]="railOpen() ? railWidth() : 0">
- <div class="gchat-main">
- <!--
- 头部与日常对话页同构(2026-11-06):固定 56px 单行,
- 头像组 + 标题(含容器数小字)+ 成员状态摘要 + 侧栏开关。
- 此前是三行堆叠(标题/副标题/成员 chip),头部明显比日常页高,
- 两页观感不一致。成员的完整状态在右栏「状态」Tab 里逐行展示。
- -->
- <header class="gchat-head">
- <span class="head-avatars" aria-hidden="true">
- @for (m of members().slice(0, 3); track m.id) {
- <ui-avatar [name]="m.name" size="sm" />
- }
- </span>
- <div class="head-main">
- <strong class="ellipsis">{{ title() }}</strong>
- <small class="ellipsis">{{ headLine() }}</small>
- </div>
- <!--
- 侧栏开合:与日常对话页同一个按钮(ghost 圆形 + 相同图标与尺寸,2026-11-06)。
- 此前这里是自绘的描边方块,两页顶部右端按钮一个圆一个方。
- -->
- <ui-tooltip content="侧栏">
- <ui-button
- variant="ghost"
- shape="circle"
- icon="fa-solid fa-table-columns"
- ariaLabel="侧栏"
- [class.on]="railOpen()"
- (click)="railOpen.set(!railOpen())"
- />
- </ui-tooltip>
- </header>
- @if (matrix.warningOf(session()?.id ?? ''); as warn) {
- <div class="hist-warn" role="status">
- <i class="fa-solid fa-circle-info" aria-hidden="true"></i>
- {{ warn }}
- </div>
- }
- <div class="messages" #scroller (scroll)="onScroll()">
- <!-- 正在从 Matrix 重建历史:显示骨架,不要闪一下「开始群聊」 -->
- @if (matrix.isRefreshing(session()?.id ?? '') && turns().length === 0) {
- <div class="skeleton-hold">
- @for (i of [1, 2, 3]; track i) {
- <ui-skeleton variant="rect" height="56px" />
- }
- </div>
- }
- @for (t of aiTurns(); track t.id) {
- <!--
- 用户消息复用 app-message-bubble(2026-11-06):
- 此前群聊页手写了独立气泡样式(蓝底、62% 宽、贴边),
- 与日常对话页的气泡(surface 底、留白、居中限宽)视觉不一致。
- 日常页也是「同一个组件 + own 标记」,这里对齐。
- -->
- @if (t.userMessage) {
- <app-message-bubble [message]="t.userMessage" [own]="true" (preview)="openFile($event)" />
- }
- <!--
- 本回合用户额外发的附件(2026-11-07):用户气泡只承载一条消息,
- 多个文件时其余文件跟在同一气泡下方(右对齐)。
- -->
- @for (f of t.userFiles; track f.id) {
- <div class="user-files">
- <app-message-bubble [message]="f" [own]="true" (preview)="openFile($event)" />
- </div>
- }
- <!--
- 回合主体(2026-11-06 用户需求):顶部过程区可折叠 + 按容器分块 + 文件宫格。
- 统一走 app-assistant-turn,与单聊/移动端同一套视图模型。
- -->
- <app-assistant-turn
- [turn]="t"
- [grouped]="true"
- (preview)="openFile($event)"
- (openTask)="openTask($event)"
- />
- } @empty {
- <!-- 与日常页空态同一位置与尺度:整块垂直居中,而不是贴着标题栏 -->
- <div class="empty-hold">
- <ui-empty title="开始群聊" description="发一条消息,@ 需要响应的容器。" />
- </div>
- }
- </div>
- @if (sendError(); as err) {
- <div class="send-err" role="alert">
- <i class="fa-solid fa-circle-exclamation" aria-hidden="true"></i>
- {{ err }}
- </div>
- } @else if (unknownMentions().length > 0) {
- <div class="send-err" role="alert">
- <i class="fa-solid fa-circle-exclamation" aria-hidden="true"></i>
- 这些名字不在群里:
- @for (n of unknownMentions(); track n) {
- <span class="bad-name">{{ n }}</span>
- }
- </div>
- }
- <div class="composer-hold">
- <!-- 排队中:浮在输入卡片上方(绝对定位,不占整行) -->
- @if (queueItems().length > 0) {
- <div class="queue-hold" role="status" aria-live="polite">
- @for (q of queueItems(); track q.id) {
- <div class="queue-row">
- <i class="fa-solid fa-hourglass-half" aria-hidden="true"></i>
- <span class="queue-text">
- 等待 {{ waitNames(q) }} 回复完成 · 将自动发送
- </span>
- <button type="button" class="queue-recall" (click)="recall(q.id)">撤回</button>
- </div>
- }
- </div>
- }
- <!-- @ 候选浮层:绝对定位在输入卡片上方(类似微信 @ 人) -->
- @if (candidates().length > 0) {
- <div class="mention-hold">
- <app-mention-list [items]="candidates()" (chosen)="pickMention($event)" />
- </div>
- }
- <!-- 待发附件条:与日常对话页同一形态(图片缩略卡 / 文件 chip + 移除) -->
- @if (sender.pendingFiles().length > 0) {
- <div class="pending-holds" role="list" aria-label="待发送附件">
- @for (pf of sender.pendingFiles(); track pf.id) {
- @if (pf.previewUrl) {
- <div class="pending-card" role="listitem">
- <img [src]="pf.previewUrl" [alt]="pf.file.name" class="pending-img" />
- <button
- type="button"
- class="pending-x"
- [attr.aria-label]="'移除 ' + pf.file.name"
- (click)="sender.unstageFile(pf.id)"
- >
- <i class="fa-solid fa-xmark" aria-hidden="true"></i>
- </button>
- </div>
- } @else {
- <span class="pending-chip" role="listitem">
- <i class="fa-solid fa-paperclip" aria-hidden="true"></i>
- <span class="pending-name ellipsis" [title]="pf.file.name">{{ pf.file.name }}</span>
- <button
- type="button"
- class="pending-x pending-x--inline"
- [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-composer
- [draft]="draft()"
- [sending]="sender.sending()"
- [uploading]="sender.uploading()"
- [pct]="sender.uploadPct()"
- [hasAttachments]="sender.pendingFiles().length > 0"
- [placeholder]="'发消息,@ 需要响应的容器…'"
- (draftChange)="draft.set($event); sendError.set(null); mentionDismissed.set(false)"
- (send)="send($event)"
- (attach)="sender.stageFiles($event)"
- [keyHandler]="onComposerKey"
- />
- </div>
- </div>
- <!-- 右侧信息栏:与日常对话页同一组件(2026-11-06) -->
- @if (railOpen()) {
- <!--
- 拖宽手柄(2026-11-06):与日常对话页同一套交互 —— 向左拖变宽、双击复原。
- 日常页的手柄占 5px 栅格列;这里右栏是 flex 子项,手柄绝对定位叠在
- 右栏左缘(见 .rail-resizer),不占排版宽度,也就不会出现一道空白缝。
- -->
- <div
- class="rail-resizer"
- role="separator"
- aria-orientation="vertical"
- aria-label="拖动调整侧栏宽度,双击复原"
- tabindex="0"
- [attr.aria-valuenow]="railWidth()"
- (pointerdown)="startResize($event)"
- (keydown.arrow-left)="nudgeWidth(40)"
- (keydown.arrow-right)="nudgeWidth(-40)"
- (dblclick)="resetRailWidth()"
- ></div>
- <app-session-rail
- class="rail-host"
- [files]="railFiles()"
- [tasks]="railTasks()"
- [deliverables]="railDeliverables()"
- [runtimes]="railRuntimes()"
- [ownerNameOf]="memberName"
- [tabs]="railTabs"
- tasksTitle="群成员的任务"
- tasksEmpty="群成员暂无任务。"
- filesTitle="群内文件"
- filesEmpty="这个群里还没有文件。"
- resultsTitle="群成员成果"
- resultsEmpty="群成员暂无交付成果。"
- [preview]="railPreview()"
- (previewRequest)="openFile($event)"
- (previewClosed)="railPreview.set(null)"
- (close)="railOpen.set(false)"
- (openTask)="openTask($event)"
- />
- }
- </div>
- <!--
- 图片灯箱 / 音视频模态(2026-11-06):
- 原先群聊页点击文件**没有任何反应** —— 三处气泡都没绑 (preview),
- 页面也没引入预览组件。现在与日常页同一套分流(core/file-open.ts)。
- -->
- <app-image-lightbox
- [open]="!!lightboxFile()"
- [url]="lightboxFile()?.url ?? ''"
- [name]="lightboxFile()?.name ?? ''"
- [size]="lightboxFile()?.size ?? 0"
- (closed)="lightboxFile.set(null)"
- />
- `,
- styles: [
- `
- :host {
- display: block;
- height: 100%;
- /* 主区底色与日常对话页一致(var(--surface),不是画布灰) */
- min-height: 0;
- overflow: hidden;
- background: var(--surface);
- }
- .gchat {
- position: relative;
- display: flex;
- flex-direction: row;
- height: 100%;
- }
- /* 左侧对话列:与日常页同构(顶栏/消息区/输入区三段) */
- .gchat-main {
- display: flex;
- flex-direction: column;
- flex: 1;
- min-width: 0;
- min-height: 0;
- border-right: 1px solid var(--edge);
- }
- .rail-host {
- flex: none;
- width: var(--rail-w, 320px);
- min-height: 0;
- }
- /*
- * 拖宽手柄:与日常页同交互(向左拖变宽)。
- *
- * 🔴 绝对定位、不占排版宽度(2026-11-06 用户反馈「右栏左边有一条空白缝」):
- * 原实现是 flex 子项,4px 宽 + 负 margin 后仍留下一道缝,
- * 看起来像断开的版面而不是可拖的边界。
- * 现在叠在右栏左缘,视觉上只是一条 1px 分隔线,热区左右各 4px。
- */
- .rail-resizer {
- position: absolute;
- top: 0;
- bottom: 0;
- right: var(--rail-w, 320px);
- width: 9px;
- margin-right: -4px;
- cursor: col-resize;
- background: transparent;
- z-index: var(--z-affix);
- touch-action: none;
- }
- .rail-resizer::after {
- content: '';
- position: absolute;
- inset: 0 4px;
- background: var(--edge);
- transition: background-color var(--motion-fast) var(--ease-standard);
- }
- .rail-resizer:hover::after,
- .rail-resizer:focus-visible::after,
- .rail-resizer.active::after {
- background: var(--primary);
- }
- /*
- * 手柄本身是 1px 分隔线,默认焦点环会把整条线包围一圈、观感突兀,
- * 故把焦点指示放到那条线上(变主色 + 加粗),可访问性不丢:
- * 键盘聚焦时有明确可见的焦点反馈(见上方 :focus-visible::after)。
- */
- .rail-resizer:focus-visible { outline: none; }
- /* 与日常页 .chat-head 同构:固定高度单行,内容变化不推高消息区 */
- .gchat-head {
- flex: none;
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- height: var(--desk-topbar-h);
- padding: 0 var(--sp-4);
- border-bottom: 1px solid var(--edge);
- background: var(--surface);
- }
- .head-avatars {
- display: inline-flex;
- flex-shrink: 0;
- }
- /* 多个头像轻微重叠,暗示「这是一个群」而不是一个人 */
- .head-avatars > :not(:first-child) {
- margin-left: -8px;
- }
- .head-main {
- display: grid;
- gap: 1px;
- flex: 1;
- min-width: 0;
- line-height: 1.35;
- }
- .head-main strong {
- font-size: var(--fs-15);
- font-weight: 600;
- color: var(--ink);
- }
- .head-main small {
- font-size: var(--fs-12);
- color: var(--muted);
- }
- /* 侧栏开合的激活态:与日常对话页同一个写法(ghost 按钮 + .on) */
- .gchat-head ui-button.on button {
- background: var(--primary-subtle);
- color: var(--primary);
- }
- /* 与日常页 .messages 同一套布局:grid + align-content:start,子项天然撑满列 */
- .messages {
- flex: 1;
- min-height: 0;
- overflow-x: hidden;
- overflow-y: auto;
- overscroll-behavior: contain;
- display: grid;
- align-content: start;
- gap: var(--sp-6);
- padding: var(--sp-6) max(var(--sp-4), calc((100% - 768px) / 2)) var(--sp-8);
- }
- /* 空态占满消息区并居中:grid 的 align-content:start 会把它顶到最上面 */
- .empty-hold {
- display: grid;
- place-items: center;
- /*
- * 🔴 用 grid-template-rows 而不是 min-height:100%:
- * 父级是 grid + align-content:start,行高按内容算,
- * min-height:100% 会被解析成「内容行的高度」,等于没写。
- */
- grid-template-rows: minmax(100%, 1fr);
- }
- /* grid 下子项已撑满列,这条不再需要 */
- .messages > app-assistant-turn,
- .messages > app-message-bubble {
- min-width: 0;
- }
- /*
- * @ 候选浮层(2026-11-07 用户要求「类似微信 @ 人的效果」):
- * 绝对定位浮在输入框上方,不占文档流、不推挤消息区。
- * **没有背景条** —— 只有候选框自身带底与阴影。
- */
- .mention-hold {
- position: absolute;
- left: max(var(--sp-4), calc((100% - 768px) / 2));
- bottom: 100%;
- margin-bottom: var(--sp-2);
- z-index: var(--z-popover);
- }
- /* 排队提示:输入框上方一行,淡色底 */
- /*
- * 排队提示(2026-11-07 用户反馈「不要完整一行」):
- * 原来是一条横贯整屏的色带,太抢眼、也把输入区顶得更高。
- * 改成**浮在输入框正上方的紧凑胶囊**,宽度随文字收缩;
- * 多条时纵向堆叠,仍不占满整行。
- */
- .queue-hold {
- position: absolute;
- left: max(var(--sp-4), calc((100% - 768px) / 2));
- right: max(var(--sp-4), calc((100% - 768px) / 2));
- bottom: 100%;
- margin-bottom: var(--sp-2);
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: var(--sp-1);
- z-index: var(--z-popover);
- pointer-events: none;
- }
- /* 有 @ 候选时把排队条再抬高,避免两层叠在一起 */
- .mention-hold ~ .queue-hold,
- .queue-hold:has(+ * .mention-hold) {
- bottom: calc(100% + 240px);
- }
- .queue-row {
- pointer-events: auto;
- display: inline-flex;
- align-items: center;
- gap: var(--sp-2);
- max-width: 100%;
- padding: var(--sp-1) var(--sp-2) var(--sp-1) var(--sp-3);
- border: 1px solid var(--warning);
- border-radius: var(--r-tag);
- background: var(--warning-subtle);
- color: var(--warning);
- font-size: var(--fs-12);
- }
- .queue-text {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .queue-recall {
- flex: none;
- padding: 0 var(--sp-2);
- min-height: 24px;
- border: 1px solid currentColor;
- border-radius: var(--r-sm);
- background: none;
- color: inherit;
- font: inherit;
- font-size: var(--fs-11);
- cursor: pointer;
- }
- .queue-recall:hover {
- background: var(--warning);
- color: var(--inverse);
- }
- /* 用户本回合额外附件:与用户气泡同一条右对齐轨道 */
- .user-files {
- display: flex;
- justify-content: flex-end;
- }
- /* 历史未完整载入的提示(部分成员不可用):轻提示,不遮挡内容 */
- .hist-warn {
- flex: none;
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- margin: 0 max(var(--sp-4), calc((100% - 768px) / 2)) var(--sp-2);
- padding: var(--sp-1) var(--sp-3);
- border-radius: var(--r-md);
- background: var(--warning-subtle);
- color: var(--warning);
- font-size: var(--fs-12);
- }
- /* 待发附件条:与日常页同一形态 */
- .pending-holds {
- display: flex;
- flex-wrap: wrap;
- gap: var(--sp-2);
- padding: var(--sp-2) 0;
- }
- .pending-card {
- position: relative;
- width: 132px;
- height: 96px;
- border-radius: var(--r-md);
- overflow: hidden;
- background: var(--surface-2);
- }
- .pending-img {
- display: block;
- width: 100%;
- height: 100%;
- object-fit: cover;
- }
- .pending-chip {
- display: inline-flex;
- align-items: center;
- gap: var(--sp-2);
- max-width: 220px;
- padding: var(--sp-1) var(--sp-2);
- border: 1px solid var(--edge);
- border-radius: var(--r-md);
- background: var(--surface-2);
- font-size: var(--fs-12);
- color: var(--ink-2);
- }
- .pending-name {
- min-width: 0;
- }
- .pending-x {
- position: absolute;
- top: 4px;
- right: 4px;
- display: grid;
- place-items: center;
- width: 20px;
- height: 20px;
- border: 0;
- border-radius: var(--r-tag);
- background: color-mix(in srgb, var(--canvas) 60%, transparent);
- color: var(--inverse);
- cursor: pointer;
- }
- .pending-x--inline {
- position: static;
- background: transparent;
- color: var(--faint);
- border-radius: var(--r-sm);
- }
- .pending-x--inline:hover {
- background: var(--surface-hover);
- color: var(--ink);
- }
- /* 与日常页完全一致:无分界线、无底色,输入卡片浮在画布上 */
- .composer-hold {
- flex: none;
- /* @ 候选浮层以它为定位容器(浮在输入卡片上方) */
- position: relative;
- padding: 0 max(var(--sp-4), calc((100% - 768px) / 2)) var(--sp-4);
- }
- /*
- * 提示条与消息流/输入卡片同宽同缩进(2026-11-06):
- * 此前贴边通栏,和居中 768px 的对话区不在一条线上,像另一块界面的内容。
- */
- .send-err {
- flex: none;
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: var(--sp-2);
- margin: 0 max(var(--sp-4), calc((100% - 768px) / 2)) var(--sp-2);
- padding: var(--sp-2) var(--sp-3);
- border-radius: var(--r-md);
- background: var(--danger-subtle);
- color: var(--danger);
- font-size: var(--fs-13);
- }
- .bad-name {
- padding: 0 var(--sp-2);
- border-radius: var(--r-tag);
- background: var(--danger);
- color: var(--inverse);
- font-weight: 600;
- }
- .ellipsis {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- `,
- ],
- })
- export class DkGroupChatPage implements OnDestroy {
- private readonly route = inject(ActivatedRoute);
- private readonly sessions = inject(ChatSessionService);
- private readonly sessionSvc = inject(SessionService);
- private readonly groups = inject(GroupChatService);
- private readonly groupLive = inject(GroupLiveService);
- /** 排队发送:@ 正在回复的容器时,消息等到它空闲再自动发出(2026-11-07) */
- private readonly queue = inject(GroupQueueService);
- protected readonly store = inject(GroupStoreService);
- /** 群聊历史 Matrix 重建(2026-11-07) */
- protected readonly matrix = inject(GroupMatrixService);
- /** 群聊发送编排(含附件) */
- protected readonly sender = inject(GroupSendService);
- private readonly live = inject(LiveService);
- private readonly chat = inject(ChatService);
- private readonly scroller = viewChild<ElementRef<HTMLDivElement>>('scroller');
- protected readonly draft = signal('');
- // ---------- 右侧信息栏(与日常对话页同一套组件与偏好,2026-11-06) ----------
- private readonly tasks = inject(TasksService);
- private readonly deliverablesSvc = inject(DeliverablesService);
- private readonly pulse = inject(TaskPulseService);
- private readonly router = inject(Router);
- protected readonly railOpen = signal(true);
- private readonly _railWidth = signal(this.readRailWidth());
- protected readonly railWidth = this._railWidth.asReadonly();
- protected readonly railTabs: RailTabDef[] = RAIL_TABS.map((t) => ({
- ...t,
- hint:
- t.key === 'files'
- ? '群里产出的文件'
- : t.key === 'tasks'
- ? '群成员名下的任务'
- : t.key === 'results'
- ? '群成员的交付成果'
- : '各容器的运行状态',
- }));
- private readRailWidth(): number {
- try {
- return clampRailWidth(Number(localStorage.getItem(RAIL_W_KEY)));
- } catch {
- return RAIL_W_DEFAULT;
- }
- }
- /*
- * 拖宽(2026-11-06,与日常对话页同一套交互):
- * 向左拖 = 变宽;拖完写回 localStorage,两页共用同一个宽度偏好。
- */
- private resizeFrom = 0;
- private resizeStartX = 0;
- private readonly onResizeMove = (e: PointerEvent): void => {
- const raw = this.resizeFrom + (this.resizeStartX - e.clientX);
- this._railWidth.set(clampRailWidth(raw));
- };
- private readonly onResizeEnd = (): void => {
- document.removeEventListener('pointermove', this.onResizeMove);
- document.removeEventListener('pointerup', this.onResizeEnd);
- document.body.classList.remove('is-resizing-rail');
- try {
- localStorage.setItem(RAIL_W_KEY, String(this._railWidth()));
- } catch {
- /* 隐私模式:只保留内存态 */
- }
- };
- protected startResize(e: PointerEvent): void {
- e.preventDefault();
- this.resizeFrom = this._railWidth();
- this.resizeStartX = e.clientX;
- document.addEventListener('pointermove', this.onResizeMove);
- document.addEventListener('pointerup', this.onResizeEnd);
- document.body.classList.add('is-resizing-rail');
- }
- /** 键盘微调(手柄可聚焦):方向键 ±40px */
- protected nudgeWidth(dx: number): void {
- this._railWidth.set(clampRailWidth(this._railWidth() + dx));
- try {
- localStorage.setItem(RAIL_W_KEY, String(this._railWidth()));
- } catch {
- /* 忽略 */
- }
- }
- protected resetRailWidth(): void {
- this._railWidth.set(RAIL_W_DEFAULT);
- try {
- localStorage.setItem(RAIL_W_KEY, String(RAIL_W_DEFAULT));
- } catch {
- /* 忽略 */
- }
- }
- /** 群内所有容器交付的附件(从群聊消息里汇总) */
- protected readonly railFiles = computed<RailFile[]>(() => {
- const seen = new Set<string>();
- const out: RailFile[] = [];
- for (const m of this.messages()) {
- const f = m.file;
- if (!f?.url) continue;
- const key = f.url + f.name;
- if (seen.has(key)) continue;
- seen.add(key);
- out.push({ name: f.name, url: f.url, mimeType: f.mimeType, size: f.size });
- }
- return out;
- });
- /** 群成员的任务(合并去重) */
- protected readonly railTasks = computed<Task[]>(() => {
- const ids = new Set(this.session()?.memberIds ?? []);
- if (ids.size === 0) return [];
- return this.tasks.all().filter((t) => !!t.agentId && ids.has(t.agentId));
- });
- /**
- * 群文件与成果(2026-11-10 修正)。
- *
- * 🔴 原实现遍历成员容器取其**全部**成果 —— A 群里会显示这些成员
- * 在别的会话里产出的文件,属于串台。改为按群会话 id(threadRoot)查台账。
- */
- protected readonly railDeliverables = computed<Deliverable[]>(() => this.dbFiles());
- /** 按群会话从数据库加载的文件 */
- 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([]);
- });
- }
- /** 状态页:每个成员一行 */
- protected readonly railRuntimes = computed<RailRuntime[]>(() =>
- this.members().map((a) => ({
- agentId: a.id,
- name: a.name,
- taskText: this.pulse.describe(a).text,
- taskTone: this.pulse.describe(a).tone,
- cpuPercent: a.host?.cpuPercent,
- memPercent: a.host?.memPercent,
- totalTokens: usageOf(a as never).totalTokens,
- })),
- );
- protected openTask(id: string): void {
- void this.router.navigate(['/desktop/tasks'], { queryParams: { task: id } });
- }
- /* ------------------------------------------------------------------ */
- /* 文件预览(2026-11-06 修复) */
- /* ------------------------------------------------------------------ */
- /** 灯箱文件(图片) */
- protected readonly lightboxFile = signal<{ url: string; name: string; size: number } | null>(null);
- /**
- * 交给右栏内联预览的文件。
- *
- * 🔴 这是**受控**信号:`app-session-rail` 只负责显示,打开哪个文件由这里决定。
- * 此前右栏自己持有 `previewFile`,页面无法命令它,导致
- * 「从右栏列表点能开、从消息里点没反应」。
- */
- protected readonly railPreview = signal<RailFile | null>(null);
- /**
- * 打开文件:图片 → 灯箱;音视频 → 右栏(暂以右栏承载,见下);
- * 其余 → 右栏内联预览。
- *
- * 分流规则统一在 `core/file-open.ts`,与日常对话页同一份实现。
- */
- openFile(f: OpenableFile): void {
- const target = routeOpen(f);
- if (target.kind === 'lightbox') {
- // 与日常页一致:进灯箱前清空右栏预览,避免叠层(2026-11-07)
- this.railPreview.set(null);
- this.lightboxFile.set({
- url: target.file.url,
- name: target.file.name,
- size: target.file.size ?? 0,
- });
- return;
- }
- /*
- * 音视频:群聊页暂无独立媒体模态,先落到右栏(预览组件内部会给出
- * 对应的播放控件)。如需与日常页一致的居中模态,可后续补齐。
- */
- this.railPreview.set({
- name: target.file.name,
- url: target.file.url,
- mimeType: target.file.mimeType ?? '',
- size: target.file.size,
- });
- this.railOpen.set(true);
- }
- /** 音视频类型判定(右栏外置模板用,暂时保留签名一致性) */
- protected readonly sending = signal(false);
- /** 发送/输入校验的提示(不 @ 人、@ 了非成员) */
- protected readonly sendError = signal<string | null>(null);
- /**
- * 当前群 id —— 订阅路由参数流,不用快照(2026-11-06 修复)。
- *
- * 群聊页之间切换时组件会被复用、构造函数不重跑;只读快照会让标题、
- * 成员、消息全都停留在上一个群(左栏点另一个群,右侧内容纹丝不动)。
- */
- 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) : [];
- });
- protected readonly title = computed(() => this.session()?.title ?? '群聊');
- /**
- * 标题行下方的小字:容器数与忙碌概览。
- *
- * 与日常页 `.chat-title small` 同一角色 —— 一眼看出「有几个、几个在忙」,
- * 不必展开右栏。完整状态在右栏「状态」Tab。
- */
- protected readonly headLine = computed(() => {
- const all = this.members();
- if (all.length === 0) return '暂无可用容器';
- const busy = all.filter((m) => this.isBusy(m.id)).map((m) => m.name);
- if (busy.length === 0) return `${all.length} 个容器`;
- return `${all.length} 个容器 · ${busy.join('、')} 处理中`;
- });
- protected readonly messages = computed<Message[]>(() => {
- 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-06 用户需求):顶部过程区 + 按容器分块 + 文件宫格。
- *
- * `buildGroupTurns` 给出的是「原始回合」,这里转成渲染用的视图模型,
- * 与单聊/移动端共用 `core/assistant-turn.ts` 的同一套派生规则。
- * `turns()` 仍保留给状态行、派发统计等既有逻辑使用。
- */
- protected readonly aiTurns = computed(() =>
- turnsFromGroup(this.turns(), this.groupLive.clock(), GROUP_QUIET_MS, (id) => this.memberName(id)),
- );
- constructor() {
- /*
- * 群文件台账:会话就绪或消息数变化时重拉(2026-11-10)。
- * 群文件按 threadRoot=群会话 id 查,不再按成员容器全量取。
- */
- effect(() => {
- this.session()?.id;
- this.messages().length;
- this.loadSessionFiles();
- });
- // 从 localStorage 恢复群内消息(幂等,重复调用无副作用)
- this.store.load();
- /*
- * 排队发送接入(2026-11-07):
- * ① 绑定当前用户 → 从 sessionStorage 恢复队列(刷新后继续等待)
- * ② 注册「到点后怎么发」——复用 sender.send,正文已剥离 @ 但需补回 @名字
- * ③ 心跳:跟 `groupLive.clock()` 一起走(它已有 2 秒节拍),不另起定时器
- */
- const me = this.sessionSvc.me()?.id ?? '';
- if (me) this.queue.bind(me);
- this.queue.onFlush(async (q, body) => {
- const s = this.session();
- if (!s) return;
- // 队列里存的是剥离 @ 后的正文,发出前要把目标容器 @ 回去
- const mention = q.waitingIds.map((id) => `@${this.memberName(id)}`).join(' ');
- await this.sender.send(s, `${mention} ${body}`.trim(), (id) => this.memberName(id));
- });
- effect(() => {
- const s = this.session();
- this.groupLive.clock();
- if (s) untracked(() => void this.queue.tick(s));
- });
- /*
- * 🔴 会话目录必须先载入本地缓存(2026-11-06)。
- * 单聊页在 `onRouteChange` 里做这件事,群聊页此前漏了 ——
- * 深链接/刷新进来时 `session()` 恒为 undefined,
- * 表现为标题变成「群聊」、成员数 0、历史为空(实测)。
- */
- this.sessions.load();
- /*
- * 容器列表同理:`members()` 靠 `live.agentById()` 解析成员名,
- * 没加载过就解析不出任何成员。
- */
- void this.live.ensureLoaded();
- // 本地没有该群时,去云端补拉一次
- void this.loadGroupSession();
- /*
- * 群切换(2026-11-07):组件在 `/group/:id` 之间会被复用。
- * 切走时要释放上一个群的实时订阅,否则两个群的回复会同时写进来。
- */
- 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 重建历史(权威),再订阅实时。
- * 🔴 重建要等初次 sync,必须 untracked 且不 await —— 页面先渲染本地缓存,
- * 重建完成后 store 变化会自动刷新视图。
- */
- untracked(() => void this.matrix.refresh(s));
- void this.groupLive.subscribe(s);
- });
- if (typeof window !== 'undefined') this.matrix.invalidate();
- /*
- * 贴底(2026-11-06 修正):与日常页同一逻辑 —— 新内容到达后滚到底。
- *
- * 🔴 原来用 queueMicrotask:那一刻消息刚进数据、DOM 还没重排,
- * scrollHeight 仍是旧值,滚到底后又被新内容顶出视口
- * (实测:最新一轮被输入区挡住半截,且再也不会自动贴底)。
- * 改用两帧 + 120ms 兜底,吃掉 markdown/图片渲染带来的高度增长。
- * 用户主动上翻时不拽回底部。
- */
- effect(() => {
- this.messages();
- this.turns();
- /*
- * 🔴 必须依赖 `aiTurns` 而不是 `turns`(2026-11-07):
- * 新结构下「内容增长」体现在视图模型里(正文合并、步骤累加),
- * 而 `turns()` 只在消息条数变化时才变。只依赖 `turns` 会让
- * 「同一条消息被续写」的场景不触发滚动 —— 表现为发送后不自动到底。
- */
- this.aiTurns();
- 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 {
- /*
- * 节流:`aiTurns` 会因 typing 心跳(约 2s 一次)反复重算,
- * 若每次都滚到底,用户轻轻上滑就会被反复拽回(实测体验很差)。
- * 200ms 内的重复请求合并成一次。
- */
- const now = Date.now();
- if (now - this.lastStickAt < 200) return;
- this.lastStickAt = now;
- const toBottom = (): void => {
- const el = this.scroller()?.nativeElement;
- if (el) el.scrollTop = el.scrollHeight;
- };
- toBottom();
- requestAnimationFrame(() => {
- toBottom();
- setTimeout(toBottom, 120);
- });
- }
- private lastStickAt = 0;
- ngOnDestroy(): void {
- const s = this.session();
- if (s) this.groupLive.release(s.id);
- }
- /**
- * 本地没有该群会话时,用候选 agentId 去云端补拉一次。
- *
- * 云端会话目录按 agentId 取;群会话的 `agentId` 是首成员,
- * 所以必须拉过首成员才会把它带回来(深链接进入时本地缓存可能为空)。
- */
- private async loadGroupSession(): Promise<void> {
- const id = this.groupId();
- if (!id) return;
- 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 isBusy(id: string): boolean {
- const p = this.groupLive.phaseOf(id);
- return p === 'thinking' || p === 'replying';
- }
- protected memberName(agentId?: string): string {
- if (!agentId) return '';
- return this.live.agentById(agentId)?.name ?? agentId;
- }
- /** 容器色条:从 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];
- }
- /**
- * @ 候选(只 @ 成员容器)+ **状态**(2026-11-07 用户需求)。
- *
- * 🔴 状态依赖 `groupLive.clock()`:它每秒推进一次,才能让
- * 「正在回复 → 完成」实时反映在候选列表的颜色上。
- */
- protected readonly candidates = computed<MentionOption[]>(() => {
- if (this.mentionDismissed()) return [];
- const q = parseMentionQuery(this.draft(), this.draft().length);
- if (!q.active) return [];
- this.groupLive.clock(); // 建立依赖,保证状态实时刷新
- const list = this.members().map((a) => ({
- id: a.id,
- name: a.name,
- state: this.mentionStateOf(a.id),
- }));
- return filterMentionCandidates(list, q.query, (x) => x.name);
- });
- /**
- * 容器的候选状态。
- *
- * · blocked:接入异常 / 离线 —— 不可 @
- * · busy:本轮正在回复 —— 可 @,但走排队
- * · ready:其余
- *
- * ⚠️ `phaseOf === 'failed'` 是「本轮无响应」而不是「容器异常」,不算 blocked。
- */
- private mentionStateOf(agentId: string): MentionState {
- const a = this.live.agentById(agentId);
- const presence = agentPresence(a);
- if (presence === 'offline') return 'blocked';
- if (a?.status === 'error' || a?.status === 'stopped') return 'blocked';
- if (this.groupLive.isTyping(agentId)) return 'busy';
- return 'ready';
- }
- /** 选中候选 → 把「@片段」补成「@完整名字」 */
- protected pickMention(c: MentionOption): void {
- const q = parseMentionQuery(this.draft(), this.draft().length);
- this.draft.set(applyMention(this.draft(), q, c.name, this.draft().length).text);
- }
- /* ---------- 排队发送(2026-11-07) ---------- */
- /** @ 候选浮层实例(用于转发键盘事件) */
- private readonly mentionList = viewChild(MentionListComponent);
- /**
- * 输入框按键拦截:@ 浮层打开时接管 ↑/↓/Enter/Esc。
- *
- * 返回 true = 已消费,composer 不再触发 Enter 发送。
- */
- protected readonly onComposerKey = (e: KeyboardEvent): boolean => {
- const list = this.mentionList();
- if (!list || this.candidates().length === 0) return false;
- if (e.key === 'Escape') {
- // 收起:把草稿里的 `@` 保留,只是不再展示候选
- this.mentionDismissed.set(true);
- return true;
- }
- return list.handleKey(e);
- };
- /** Esc 收起候选后,再输入任意字符重新展开 */
- protected readonly mentionDismissed = signal(false);
- protected readonly queueItems = computed(() => {
- const s = this.session();
- return s ? this.queue.queue().filter((q) => q.sessionId === s.id) : [];
- });
- protected waitNames(q: { names: Record<string, string>; waitingIds: string[] }): string {
- return q.waitingIds.map((id) => q.names[id] ?? id).join('、');
- }
- protected recall(id: string): void {
- this.queue.recall(id);
- }
- /** 输入里是否有不在群里的 @ 名字(实时标红提示) */
- protected readonly unknownMentions = computed<string[]>(() => {
- const s = this.session();
- if (!s) return [];
- return this.groups.unknownMentions(this.draft(), s);
- });
- protected async send(text: string): Promise<void> {
- const s = this.session();
- if (!s || this.sender.sending()) return;
- this.sendError.set(null);
- /*
- * 排队分流(2026-11-07 用户需求):
- * @ 了**正在回复**的容器时,这条消息先不发,等它本轮答完再自动发。
- *
- * 🔴 用户裁决「绿立即发、黄排队」:只把忙碌的那些拆进队列,
- * 其余目标照常立即发送 —— 不因为一个人忙就卡住整条消息。
- * 🔴 附件不参与排队(File 无法序列化),带附件时提示用户等一等。
- */
- const plan = this.groups.plan(text, s);
- if (plan.ok) {
- const busy = plan.targets.filter((id) => this.groupLive.isTyping(id));
- if (busy.length > 0) {
- const files = this.sender.pendingFiles();
- if (files.length > 0) {
- this.sendError.set(
- `附件无法排队:请等 ${busy.map((id) => this.memberName(id)).join('、')} 回复完成后再发送`,
- );
- return;
- }
- // 把 @ 从正文里剔除(targets 已经确定),只存正文本身
- const names: Record<string, string> = {};
- for (const id of busy) names[id] = this.memberName(id);
- const idleTargets = plan.targets.filter((id) => !busy.includes(id));
- if (idleTargets.length > 0) {
- // 还有空闲目标:立即发给它们(用原始文本,@ 保持原样)
- const r = await this.sender.send(s, text, (id) => this.memberName(id));
- if (r.error) {
- this.sendError.set(r.error);
- return;
- }
- }
- this.queue.enqueue(s.id, plan.body, names);
- this.draft.set('');
- return;
- }
- }
- 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('');
- }
- }
|