|
|
@@ -16,13 +16,21 @@ 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 { GroupBlock, buildGroupTurns, blockDuration } from '../../../core/group-turns';
|
|
|
+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,
|
|
|
@@ -45,7 +53,6 @@ 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 { MergedMessage, mergeConsecutiveBodies } from '../../../core/message-merge';
|
|
|
import { usageOf } from '../../../core/format';
|
|
|
import { MessageBubbleComponent } from '../../../components/domain/message-bubble/message-bubble';
|
|
|
import { ComposerComponent } from '../../../components/domain/composer/composer';
|
|
|
@@ -70,6 +77,9 @@ import { ComposerComponent } from '../../../components/domain/composer/composer'
|
|
|
MessageBubbleComponent,
|
|
|
ComposerComponent,
|
|
|
SessionRailComponent,
|
|
|
+ AssistantTurnComponent,
|
|
|
+ ImageLightboxComponent,
|
|
|
+ MentionListComponent,
|
|
|
],
|
|
|
template: `
|
|
|
<div class="gchat" [style.--rail-w.px]="railOpen() ? railWidth() : 0">
|
|
|
@@ -121,55 +131,35 @@ import { ComposerComponent } from '../../../components/domain/composer/composer'
|
|
|
}
|
|
|
</div>
|
|
|
}
|
|
|
- @for (t of turns(); track t.id) {
|
|
|
+ @for (t of aiTurns(); track t.id) {
|
|
|
<!--
|
|
|
用户消息复用 app-message-bubble(2026-11-06):
|
|
|
此前群聊页手写了独立气泡样式(蓝底、62% 宽、贴边),
|
|
|
与日常对话页的气泡(surface 底、留白、居中限宽)视觉不一致。
|
|
|
日常页也是「同一个组件 + own 标记」,这里对齐。
|
|
|
-->
|
|
|
- <app-message-bubble [message]="t.userMessage" [own]="true" />
|
|
|
+ @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) {
|
|
|
+ @for (f of t.userFiles; track f.id) {
|
|
|
<div class="user-files">
|
|
|
- <app-message-bubble [message]="f" [own]="true" />
|
|
|
+ <app-message-bubble [message]="f" [own]="true" (preview)="openFile($event)" />
|
|
|
</div>
|
|
|
}
|
|
|
- <article class="turn">
|
|
|
- <div class="turn-head">
|
|
|
- @if (!t.closed) {
|
|
|
- <i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>
|
|
|
- 执行中…
|
|
|
- } @else {
|
|
|
- 已完成 · {{ t.blocks.length }} 个容器
|
|
|
- }
|
|
|
- </div>
|
|
|
- @for (b of t.blocks; track b.agentId) {
|
|
|
- <section class="turn-block" [style.border-left-color]="colorOf(b.agentId)">
|
|
|
- <header class="turn-block-name">
|
|
|
- <span class="dot" [style.background]="colorOf(b.agentId)" aria-hidden="true"></span>
|
|
|
- <span class="ellipsis">{{ memberName(b.agentId) }}</span>
|
|
|
- <span class="blk-phase" [class.busy]="b.typing">{{ phaseText(b.agentId) }}</span>
|
|
|
- @if (blockDuration(b) > 0) {
|
|
|
- <span class="blk-time">{{ (blockDuration(b) / 1000).toFixed(0) }}s</span>
|
|
|
- }
|
|
|
- </header>
|
|
|
- @if (b.messages.length > 0) {
|
|
|
- <!-- 同一容器的多段回复合成一条(2026-11-06,规则见 core/message-merge) -->
|
|
|
- @for (m of mergeBlock(b); track m.id) {
|
|
|
- <app-message-bubble [message]="m" />
|
|
|
- }
|
|
|
- } @else if (b.typing) {
|
|
|
- <p class="blk-wait">正在思考…</p>
|
|
|
- } @else {
|
|
|
- <p class="blk-wait">未响应</p>
|
|
|
- }
|
|
|
- </section>
|
|
|
- }
|
|
|
- </article>
|
|
|
+ <!--
|
|
|
+ 回合主体(2026-11-06 用户需求):顶部过程区可折叠 + 按容器分块 + 文件宫格。
|
|
|
+ 统一走 app-assistant-turn,与单聊/移动端同一套视图模型。
|
|
|
+ -->
|
|
|
+ <app-assistant-turn
|
|
|
+ [turn]="t"
|
|
|
+ [grouped]="true"
|
|
|
+ (preview)="openFile($event)"
|
|
|
+ (openTask)="openTask($event)"
|
|
|
+ />
|
|
|
} @empty {
|
|
|
<!-- 与日常页空态同一位置与尺度:整块垂直居中,而不是贴着标题栏 -->
|
|
|
<div class="empty-hold">
|
|
|
@@ -178,18 +168,6 @@ import { ComposerComponent } from '../../../components/domain/composer/composer'
|
|
|
}
|
|
|
</div>
|
|
|
|
|
|
- <!-- @ 候选(2026-11-05):与移动端同一套 core/mention-autocomplete -->
|
|
|
- @if (candidates().length > 0) {
|
|
|
- <div class="mention-pop" role="listbox" aria-label="@ 选择">
|
|
|
- @for (c of candidates(); track c.id) {
|
|
|
- <button type="button" role="option" class="mention-item" (click)="pickMention(c)">
|
|
|
- <span class="dot" [style.background]="colorOf(c.id)" aria-hidden="true"></span>
|
|
|
- {{ c.name }}
|
|
|
- </button>
|
|
|
- }
|
|
|
- </div>
|
|
|
- }
|
|
|
-
|
|
|
@if (sendError(); as err) {
|
|
|
<div class="send-err" role="alert">
|
|
|
<i class="fa-solid fa-circle-exclamation" aria-hidden="true"></i>
|
|
|
@@ -206,6 +184,28 @@ import { ComposerComponent } from '../../../components/domain/composer/composer'
|
|
|
}
|
|
|
|
|
|
<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="待发送附件">
|
|
|
@@ -246,9 +246,10 @@ import { ComposerComponent } from '../../../components/domain/composer/composer'
|
|
|
[pct]="sender.uploadPct()"
|
|
|
[hasAttachments]="sender.pendingFiles().length > 0"
|
|
|
[placeholder]="'发消息,@ 需要响应的容器…'"
|
|
|
- (draftChange)="draft.set($event); sendError.set(null)"
|
|
|
+ (draftChange)="draft.set($event); sendError.set(null); mentionDismissed.set(false)"
|
|
|
(send)="send($event)"
|
|
|
(attach)="sender.stageFiles($event)"
|
|
|
+ [keyHandler]="onComposerKey"
|
|
|
/>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -286,11 +287,27 @@ import { ComposerComponent } from '../../../components/domain/composer/composer'
|
|
|
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: [
|
|
|
`
|
|
|
@@ -354,9 +371,12 @@ import { ComposerComponent } from '../../../components/domain/composer/composer'
|
|
|
.rail-resizer.active::after {
|
|
|
background: var(--primary);
|
|
|
}
|
|
|
- .rail-resizer:focus-visible {
|
|
|
- outline: none;
|
|
|
- }
|
|
|
+ /*
|
|
|
+ * 手柄本身是 1px 分隔线,默认焦点环会把整条线包围一圈、观感突兀,
|
|
|
+ * 故把焦点指示放到那条线上(变主色 + 加粗),可访问性不丢:
|
|
|
+ * 键盘聚焦时有明确可见的焦点反馈(见上方 :focus-visible::after)。
|
|
|
+ */
|
|
|
+ .rail-resizer:focus-visible { outline: none; }
|
|
|
/* 与日常页 .chat-head 同构:固定高度单行,内容变化不推高消息区 */
|
|
|
.gchat-head {
|
|
|
flex: none;
|
|
|
@@ -421,84 +441,82 @@ import { ComposerComponent } from '../../../components/domain/composer/composer'
|
|
|
grid-template-rows: minmax(100%, 1fr);
|
|
|
}
|
|
|
/* grid 下子项已撑满列,这条不再需要 */
|
|
|
- .messages > .turn,
|
|
|
+ .messages > app-assistant-turn,
|
|
|
.messages > app-message-bubble {
|
|
|
min-width: 0;
|
|
|
}
|
|
|
/*
|
|
|
- * 子元素撑满限宽列(2026-11-06):
|
|
|
- * flex column 下子项默认按内容收缩,导致气泡轨道不占满 768px 列,
|
|
|
- * 用户气泡右对齐时会跑到列外、容器块也不与列左边缘对齐。
|
|
|
+ * @ 候选浮层(2026-11-07 用户要求「类似微信 @ 人的效果」):
|
|
|
+ * 绝对定位浮在输入框上方,不占文档流、不推挤消息区。
|
|
|
+ * **没有背景条** —— 只有候选框自身带底与阴影。
|
|
|
*/
|
|
|
- .turn {
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
- gap: var(--sp-2);
|
|
|
- max-width: 100%;
|
|
|
+ .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-06 对齐日常页):
|
|
|
- * 裸文字混在消息流里像一条被误发的正文;胶囊一眼看出它是状态而不是内容。
|
|
|
+ * 排队提示(2026-11-07 用户反馈「不要完整一行」):
|
|
|
+ * 原来是一条横贯整屏的色带,太抢眼、也把输入区顶得更高。
|
|
|
+ * 改成**浮在输入框正上方的紧凑胶囊**,宽度随文字收缩;
|
|
|
+ * 多条时纵向堆叠,仍不占满整行。
|
|
|
*/
|
|
|
- .turn-head {
|
|
|
- display: inline-flex;
|
|
|
- align-items: center;
|
|
|
- gap: var(--sp-1);
|
|
|
- align-self: flex-start;
|
|
|
- padding: 2px var(--sp-2);
|
|
|
- border-radius: var(--r-tag);
|
|
|
- background: var(--surface-2);
|
|
|
- color: var(--muted);
|
|
|
- font-size: var(--fs-11);
|
|
|
- }
|
|
|
- /* 左侧色条只用来区分「是哪个容器在说」,不加边框色块 */
|
|
|
- .turn-block {
|
|
|
+ .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);
|
|
|
- padding-left: var(--sp-3);
|
|
|
- border-left: 2px solid var(--edge);
|
|
|
+ z-index: var(--z-popover);
|
|
|
+ pointer-events: none;
|
|
|
}
|
|
|
- /* 容器名行与消息正文同宽同缩进,不额外加背景 */
|
|
|
- .turn-block-name {
|
|
|
- display: flex;
|
|
|
+ /* 有 @ 候选时把排队条再抬高,避免两层叠在一起 */
|
|
|
+ .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);
|
|
|
- color: var(--ink-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);
|
|
|
- font-weight: 600;
|
|
|
}
|
|
|
- .dot {
|
|
|
- width: 8px;
|
|
|
- height: 8px;
|
|
|
- border-radius: var(--r-tag);
|
|
|
+ .queue-text {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
}
|
|
|
- .mention-pop {
|
|
|
+ .queue-recall {
|
|
|
flex: none;
|
|
|
- display: flex;
|
|
|
- flex-wrap: wrap;
|
|
|
- gap: var(--sp-2);
|
|
|
- padding: var(--sp-2) max(var(--sp-4), calc((100% - 768px) / 2));
|
|
|
- border-top: 1px solid var(--edge);
|
|
|
- background: var(--surface-2);
|
|
|
- }
|
|
|
- .mention-item {
|
|
|
- display: inline-flex;
|
|
|
- align-items: center;
|
|
|
- gap: var(--sp-2);
|
|
|
- min-height: 32px;
|
|
|
- padding: 0 var(--sp-3);
|
|
|
- border: 1px solid var(--edge);
|
|
|
- border-radius: var(--r-tag);
|
|
|
- background: var(--surface);
|
|
|
- color: var(--ink);
|
|
|
+ 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-13);
|
|
|
+ font-size: var(--fs-11);
|
|
|
cursor: pointer;
|
|
|
}
|
|
|
- .mention-item:hover {
|
|
|
- background: var(--primary-subtle);
|
|
|
- border-color: var(--primary);
|
|
|
+ .queue-recall:hover {
|
|
|
+ background: var(--warning);
|
|
|
+ color: var(--inverse);
|
|
|
}
|
|
|
/* 用户本回合额外附件:与用户气泡同一条右对齐轨道 */
|
|
|
.user-files {
|
|
|
@@ -581,6 +599,8 @@ import { ComposerComponent } from '../../../components/domain/composer/composer'
|
|
|
/* 与日常页完全一致:无分界线、无底色,输入卡片浮在画布上 */
|
|
|
.composer-hold {
|
|
|
flex: none;
|
|
|
+ /* @ 候选浮层以它为定位容器(浮在输入卡片上方) */
|
|
|
+ position: relative;
|
|
|
padding: 0 max(var(--sp-4), calc((100% - 768px) / 2)) var(--sp-4);
|
|
|
}
|
|
|
/*
|
|
|
@@ -607,23 +627,6 @@ import { ComposerComponent } from '../../../components/domain/composer/composer'
|
|
|
color: var(--inverse);
|
|
|
font-weight: 600;
|
|
|
}
|
|
|
- .blk-phase {
|
|
|
- color: var(--muted);
|
|
|
- font-size: var(--fs-11);
|
|
|
- }
|
|
|
- .blk-phase.busy {
|
|
|
- color: var(--info);
|
|
|
- }
|
|
|
- .blk-time {
|
|
|
- margin-left: auto;
|
|
|
- color: var(--faint);
|
|
|
- font-size: var(--fs-11);
|
|
|
- }
|
|
|
- .blk-wait {
|
|
|
- margin: 0;
|
|
|
- color: var(--faint);
|
|
|
- font-size: var(--fs-13);
|
|
|
- }
|
|
|
.ellipsis {
|
|
|
overflow: hidden;
|
|
|
text-overflow: ellipsis;
|
|
|
@@ -635,8 +638,11 @@ import { ComposerComponent } from '../../../components/domain/composer/composer'
|
|
|
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);
|
|
|
@@ -780,6 +786,55 @@ export class DkGroupChatPage implements OnDestroy {
|
|
|
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);
|
|
|
@@ -833,9 +888,41 @@ export class DkGroupChatPage implements OnDestroy {
|
|
|
}),
|
|
|
);
|
|
|
|
|
|
+ /**
|
|
|
+ * 回合视图(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() {
|
|
|
// 从 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` 里做这件事,群聊页此前漏了 ——
|
|
|
@@ -884,6 +971,13 @@ export class DkGroupChatPage implements OnDestroy {
|
|
|
effect(() => {
|
|
|
this.messages();
|
|
|
this.turns();
|
|
|
+ /*
|
|
|
+ * 🔴 必须依赖 `aiTurns` 而不是 `turns`(2026-11-07):
|
|
|
+ * 新结构下「内容增长」体现在视图模型里(正文合并、步骤累加),
|
|
|
+ * 而 `turns()` 只在消息条数变化时才变。只依赖 `turns` 会让
|
|
|
+ * 「同一条消息被续写」的场景不触发滚动 —— 表现为发送后不自动到底。
|
|
|
+ */
|
|
|
+ this.aiTurns();
|
|
|
if (this.userScrolledAway) return;
|
|
|
this.stickToBottom();
|
|
|
});
|
|
|
@@ -899,6 +993,15 @@ export class DkGroupChatPage implements OnDestroy {
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
@@ -910,6 +1013,8 @@ export class DkGroupChatPage implements OnDestroy {
|
|
|
});
|
|
|
}
|
|
|
|
|
|
+ private lastStickAt = 0;
|
|
|
+
|
|
|
ngOnDestroy(): void {
|
|
|
const s = this.session();
|
|
|
if (s) this.groupLive.release(s.id);
|
|
|
@@ -936,40 +1041,6 @@ export class DkGroupChatPage implements OnDestroy {
|
|
|
return p === 'thinking' || p === 'replying';
|
|
|
}
|
|
|
|
|
|
- protected phaseText(id: string): string {
|
|
|
- switch (this.groupLive.phaseOf(id)) {
|
|
|
- case 'thinking':
|
|
|
- return '思考中';
|
|
|
- case 'replying':
|
|
|
- return '输出中';
|
|
|
- case 'done':
|
|
|
- return '已完成';
|
|
|
- case 'failed':
|
|
|
- return '无响应';
|
|
|
- default:
|
|
|
- return '待命';
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- protected openOf(t: { id: string; closed: boolean }): boolean {
|
|
|
- return !t.closed;
|
|
|
- }
|
|
|
-
|
|
|
- protected blockDuration(b: GroupBlock): number {
|
|
|
- return blockDuration(b);
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 把同一容器的多段回复合成一条消息再渲染。
|
|
|
- *
|
|
|
- * 容器常把一条回答拆成多条发(超长分段 / 先结论后细节),
|
|
|
- * 逐条渲染会让「一个容器的回答」变成一串碎片气泡。
|
|
|
- * 工具过程、附件、失败消息各自断开 —— 规则与单聊共用 `mergeConsecutiveBodies`。
|
|
|
- */
|
|
|
- protected mergeBlock(b: GroupBlock): MergedMessage[] {
|
|
|
- return mergeConsecutiveBodies(b.messages);
|
|
|
- }
|
|
|
-
|
|
|
protected memberName(agentId?: string): string {
|
|
|
if (!agentId) return '';
|
|
|
return this.live.agentById(agentId)?.name ?? agentId;
|
|
|
@@ -990,20 +1061,88 @@ export class DkGroupChatPage implements OnDestroy {
|
|
|
return palette[h % palette.length];
|
|
|
}
|
|
|
|
|
|
- /** @ 候选(只 @ 成员容器) */
|
|
|
- protected readonly candidates = computed<{ id: string; name: string }[]>(() => {
|
|
|
+ /**
|
|
|
+ * @ 候选(只 @ 成员容器)+ **状态**(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 [];
|
|
|
- const list = this.members().map((a) => ({ id: a.id, name: a.name }));
|
|
|
+ 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: { id: string; name: string }): void {
|
|
|
+ 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();
|
|
|
@@ -1015,6 +1154,47 @@ export class DkGroupChatPage implements OnDestroy {
|
|
|
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('');
|