|
|
@@ -22,6 +22,7 @@ import {
|
|
|
import { ComposerComponent } from '../../../components/domain/composer/composer';
|
|
|
import { MessageBubbleComponent } from '../../../components/domain/message-bubble/message-bubble';
|
|
|
import { TypingIndicatorComponent } from '../../../components/domain/typing-indicator/typing-indicator';
|
|
|
+import { ToolActivityComponent } from '../../../components/domain/tool-activity/tool-activity';
|
|
|
import { FilePreviewComponent } from '../../../components/preview/file-preview/file-preview';
|
|
|
import { PresenceBadgeComponent } from '../../../components/domain/presence-badge/presence-badge';
|
|
|
import { AgentPresence, agentPresence } from '../../../core/api/models';
|
|
|
@@ -31,6 +32,12 @@ import { Deliverable, TASK_STATUS_TEXT } from '../../../core/api/models';
|
|
|
import { DeliverablesService } from '../../../core/deliverables.service';
|
|
|
import { DeviceService } from '../../../core/device.service';
|
|
|
import { fmtPercent, fmtTokens, usageOf } from '../../../core/format';
|
|
|
+import { TaskLine, TaskPulseService } from '../../../core/task-pulse.service';
|
|
|
+import { ArtifactSyncService } from '../../../core/artifact-sync.service';
|
|
|
+import { isToolActivityMessage, toolActivitySummary } from '../../../core/message-segments';
|
|
|
+import { Block } from '../../../core/message-segments';
|
|
|
+import { Message } from '../../../core/api/models';
|
|
|
+import { fmtTime } from '../../../core/time';
|
|
|
import { LiveService } from '../../../core/live.service';
|
|
|
import { UnreadService } from '../../../core/unread.service';
|
|
|
import { TasksService } from '../../../core/tasks.service';
|
|
|
@@ -47,6 +54,11 @@ import { TasksService } from '../../../core/tasks.service';
|
|
|
* AC:04.1 四栏 / 04.2 发送 / 04.3 上传进度 / 04.5 自增高 / 04.6 状态不重复
|
|
|
* / 04.8 失败可重试 / 04.9 连接状态 / 04.10 超长截断 / 04.11 三态
|
|
|
*/
|
|
|
+/** 一条消息的渲染形态:普通气泡 or 终端活动折叠组 */
|
|
|
+type MessageRow =
|
|
|
+ | { kind: 'msg'; key: string; message: Message }
|
|
|
+ | { kind: 'tool'; key: string; block: Extract<Block, { kind: 'process' }> };
|
|
|
+
|
|
|
@Component({
|
|
|
selector: 'app-dk-chat-page',
|
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
|
@@ -62,6 +74,7 @@ import { TasksService } from '../../../core/tasks.service';
|
|
|
ComposerComponent,
|
|
|
MessageBubbleComponent,
|
|
|
TypingIndicatorComponent,
|
|
|
+ ToolActivityComponent,
|
|
|
FilePreviewComponent,
|
|
|
PresenceBadgeComponent,
|
|
|
StatusBadgeComponent,
|
|
|
@@ -119,16 +132,29 @@ import { TasksService } from '../../../core/tasks.service';
|
|
|
<span class="conv-info">
|
|
|
<span class="conv-line">
|
|
|
<strong class="ellipsis">{{ c.name }}</strong>
|
|
|
- <!--
|
|
|
- 可对话状态:**配色 + 文字**胶囊(用户要求,比圆点清晰)。
|
|
|
- 判定唯一实现在 models.agentPresence()(以接入态为准)。
|
|
|
- -->
|
|
|
- <app-presence-badge [agent]="live.agentById(c.id)" />
|
|
|
@if (unreadOf(c.id) > 0) {
|
|
|
<span class="unread">{{ unreadOf(c.id) }}</span>
|
|
|
}
|
|
|
+ <!--
|
|
|
+ 运行态是**最该被一眼看到**的信息(用户要求左右同步):
|
|
|
+ 有任务时显示实心小圆点 + 文字,空闲时只留灰点,不抢注意力。
|
|
|
+ 接入态/未同步等次要信息下沉到第二行常驻,不在标题行堆芯片。
|
|
|
+ -->
|
|
|
+ <span class="run-chip" [class]="'tone-' + taskLine(c.id).tone">
|
|
|
+ <i class="dot" aria-hidden="true"></i>{{ runLabel(c.id) }}
|
|
|
+ </span>
|
|
|
+ </span>
|
|
|
+ <span class="conv-subline">
|
|
|
+ <!--
|
|
|
+ 第二行只放「和运行态**不同**的信息」。
|
|
|
+ 🔴 曾出现「空闲」显示两次(用户实测):标题行的 run-chip
|
|
|
+ 和第二行的任务描述都写「空闲」—— 同一份数据渲染两遍。
|
|
|
+ 现在:标题行答「在不在干活」,第二行只在有具体任务时显示
|
|
|
+ 任务内容,否则退回展示专长,不再重复状态词。
|
|
|
+ -->
|
|
|
+ <span class="ellipsis conv-task">{{ convSubline(c.id, c.specialty) }}</span>
|
|
|
+ <app-presence-badge [agent]="live.agentById(c.id)" />
|
|
|
</span>
|
|
|
- <span class="conv-preview ellipsis">{{ c.currentTask || c.specialty }}</span>
|
|
|
</span>
|
|
|
</button>
|
|
|
}
|
|
|
@@ -152,8 +178,31 @@ import { TasksService } from '../../../core/tasks.service';
|
|
|
<strong>{{ a.name }}</strong>
|
|
|
<small class="ellipsis">{{ a.specialty || '数字生命' }}</small>
|
|
|
</div>
|
|
|
- <!-- AC-04.6:状态**只在此处出现一次**,不在右栏重复 -->
|
|
|
- <app-status-badge [agent]="a" />
|
|
|
+ <!--
|
|
|
+ AC-04.6 的修正(2026-10-21):原先在此放运行态徽标,但下方任务行
|
|
|
+ 已经表达了「在不在干活」→ 同一信息出现两次,且 idle 用 warning 色
|
|
|
+ 看起来像故障(用户实测:「空闲 空闲」两行、橙色像告警)。
|
|
|
+ 这里只保留任务行**不表达**的内容:容器不可用 / 通道未上报。
|
|
|
+ -->
|
|
|
+ @if (a.status === 'error' || a.status === 'offline' || a.status === 'stopped') {
|
|
|
+ <app-status-badge [agent]="a" />
|
|
|
+ }
|
|
|
+ }
|
|
|
+ <!--
|
|
|
+ 当前任务条(用户要求:先看到「它在做什么」,再往下看消息)。
|
|
|
+ 文案唯一实现是 TaskPulseService.describe(),列表/头部/右栏同一份。
|
|
|
+ 独立一行 → 任务名再长也不再挤压头像与标题。
|
|
|
+ -->
|
|
|
+ @if (chat.agent(); as a) {
|
|
|
+ <div
|
|
|
+ class="task-line"
|
|
|
+ [class]="'tone-' + taskLine(a.id).tone"
|
|
|
+ [class.busy]="taskLine(a.id).active"
|
|
|
+ [title]="taskLine(a.id).text"
|
|
|
+ >
|
|
|
+ <span class="spin" aria-hidden="true"></span>
|
|
|
+ <span class="task-text ellipsis">{{ taskLine(a.id).text }}</span>
|
|
|
+ </div>
|
|
|
}
|
|
|
<div class="chat-actions">
|
|
|
<!-- 任务:点开右栏并切到任务面板;再点隐藏 -->
|
|
|
@@ -240,14 +289,23 @@ import { TasksService } from '../../../core/tasks.service';
|
|
|
<span class="history-end">已到最早</span>
|
|
|
}
|
|
|
</div>
|
|
|
- @for (m of chat.messages(); track m.id) {
|
|
|
- <app-message-bubble
|
|
|
- [message]="m"
|
|
|
- [own]="m.sender === 'me'"
|
|
|
- (retry)="chat.retry($event)"
|
|
|
- (openTask)="openTaskRail()"
|
|
|
- (preview)="openFile($event)"
|
|
|
- />
|
|
|
+ @for (row of messageRows(); track row.key) {
|
|
|
+ @if (row.kind === 'tool') {
|
|
|
+ <!--
|
|
|
+ 连续的终端活动消息(容器把 Hermes 每一步都单独发一条)
|
|
|
+ 合并成一个折叠组:默认收起,点开才看命令细节。
|
|
|
+ 用户实测反馈:这些内容把会话完全淹没了。
|
|
|
+ -->
|
|
|
+ <app-tool-activity [block]="row.block" />
|
|
|
+ } @else {
|
|
|
+ <app-message-bubble
|
|
|
+ [message]="row.message!"
|
|
|
+ [own]="row.message!.sender === 'me'"
|
|
|
+ (retry)="chat.retry($event)"
|
|
|
+ (openTask)="openTaskRail()"
|
|
|
+ (preview)="openFile($event)"
|
|
|
+ />
|
|
|
+ }
|
|
|
}
|
|
|
<!-- 对方正在输入(FR-04;容器侧已实测在发 m.typing) -->
|
|
|
@if (chat.peerTyping()) {
|
|
|
@@ -288,13 +346,41 @@ import { TasksService } from '../../../core/tasks.service';
|
|
|
<section class="rail-block">
|
|
|
<h3>运行状态</h3>
|
|
|
<dl class="kv">
|
|
|
- <div><dt>当前任务</dt><dd>{{ a.currentTask || '—' }}</dd></div>
|
|
|
+ <div>
|
|
|
+ <dt>当前任务</dt>
|
|
|
+ <dd [class]="'tone-ink-' + taskLine(a.id).tone">{{ taskLine(a.id).text }}</dd>
|
|
|
+ </div>
|
|
|
<div><dt>CPU</dt><dd>{{ pct(a.host?.cpuPercent) }}</dd></div>
|
|
|
<div><dt>内存</dt><dd>{{ pct(a.host?.memPercent) }}</dd></div>
|
|
|
<div><dt>Token 用量</dt><dd>{{ tokens(a) }}</dd></div>
|
|
|
</dl>
|
|
|
</section>
|
|
|
|
|
|
+ <!--
|
|
|
+ 最近动态:只记录**观测到的**开始/结束(每次轮询对比得出)。
|
|
|
+ 只列本会话 —— 用户在这里关心的是「它刚才干了什么」。
|
|
|
+ -->
|
|
|
+ @if (pulses().length > 0) {
|
|
|
+ <section class="rail-block">
|
|
|
+ <h3>最近动态</h3>
|
|
|
+ <ol class="pulse-list">
|
|
|
+ @for (p of pulses(); track p.at + p.kind) {
|
|
|
+ <li class="pulse-item">
|
|
|
+ <i
|
|
|
+ class="fa-solid"
|
|
|
+ [class.fa-play]="p.kind === 'started'"
|
|
|
+ [class.fa-check]="p.kind === 'finished'"
|
|
|
+ aria-hidden="true"
|
|
|
+ ></i>
|
|
|
+ <span class="pulse-text ellipsis">
|
|
|
+ {{ p.kind === 'started' ? (p.task || '开始处理') : '完成' }}
|
|
|
+ </span>
|
|
|
+ <small>{{ clock(p.at) }}</small> </li>
|
|
|
+ }
|
|
|
+ </ol>
|
|
|
+ </section>
|
|
|
+ }
|
|
|
+
|
|
|
@if (railTab() === 'tasks') {
|
|
|
<section class="rail-block">
|
|
|
<h3>相关任务 <span class="count">{{ agentTasks().length }}</span></h3>
|
|
|
@@ -339,6 +425,21 @@ import { TasksService } from '../../../core/tasks.service';
|
|
|
· {{ d.size }}
|
|
|
}
|
|
|
</small>
|
|
|
+ <!--
|
|
|
+ 附件还在容器里(未上云盘):如实说明,不给假下载按钮。
|
|
|
+ 「正在同步到云盘」= 已登记;有同步失败原因时直接展示原因。
|
|
|
+ -->
|
|
|
+ @if (pendingOf(d); as a) {
|
|
|
+ <small class="ellipsis sync-hint" [title]="syncError(d.id, a) || ''">
|
|
|
+ @if (syncError(d.id, a); as err) {
|
|
|
+ <i class="fa-solid fa-circle-exclamation" aria-hidden="true"></i>
|
|
|
+ 未上云:{{ err }}
|
|
|
+ } @else {
|
|
|
+ <i class="fa-solid fa-cloud-arrow-up" aria-hidden="true"></i>
|
|
|
+ 排队搬往云盘…
|
|
|
+ }
|
|
|
+ </small>
|
|
|
+ }
|
|
|
</span>
|
|
|
<span class="rail-actions">
|
|
|
<ui-button
|
|
|
@@ -349,11 +450,11 @@ import { TasksService } from '../../../core/tasks.service';
|
|
|
ariaLabel="预览"
|
|
|
(click)="preview(d)"
|
|
|
/>
|
|
|
- @if (d.artifacts[0]; as art) {
|
|
|
+ @if (d.artifacts[0]?.url; as url) {
|
|
|
<a
|
|
|
class="dl-btn"
|
|
|
- [href]="art.url"
|
|
|
- [attr.download]="art.name"
|
|
|
+ [href]="url"
|
|
|
+ [attr.download]="d.artifacts[0].name"
|
|
|
aria-label="下载"
|
|
|
>
|
|
|
<i class="fa-solid fa-download" aria-hidden="true"></i>
|
|
|
@@ -370,44 +471,20 @@ import { TasksService } from '../../../core/tasks.service';
|
|
|
</aside>
|
|
|
}
|
|
|
</div>
|
|
|
- <!-- 右栏成果预览:底座 ui-modal -->
|
|
|
- <ui-modal
|
|
|
+ <!--
|
|
|
+ 右栏成果预览。
|
|
|
+
|
|
|
+ 🔴 以前这里**另写了一套 ui-modal**,只认 demo 数据里的 content 字段;
|
|
|
+ 真实成果没有 content → 弹窗里只剩一个下载按钮(用户实测:「点击预览无法预览」)。
|
|
|
+ 现在统一走 app-file-preview(图片/视频/PDF/DOCX/XLSX/HTML/MD/文本 都支持)。
|
|
|
+ -->
|
|
|
+ <app-file-preview
|
|
|
[open]="!!previewing()"
|
|
|
- [title]="previewing()?.title ?? ''"
|
|
|
- size="lg"
|
|
|
- (openChange)="previewing.set(null)"
|
|
|
- >
|
|
|
- @if (previewing(); as d) {
|
|
|
- <p class="preview-meta">{{ d.filename }} · {{ d.size }} · {{ d.version }}</p>
|
|
|
- <div class="preview-body">
|
|
|
- @if (d.fileType === 'CSV') {
|
|
|
- <div class="csv-wrap">
|
|
|
- <table class="csv">
|
|
|
- <tbody>
|
|
|
- @for (row of csvRows(d); track $index) {
|
|
|
- <tr>@for (cell of row; track $index) { <td>{{ cell }}</td> }</tr>
|
|
|
- }
|
|
|
- </tbody>
|
|
|
- </table>
|
|
|
- </div>
|
|
|
- } @else {
|
|
|
- <pre class="doc">{{ d.content || d.summary }}</pre>
|
|
|
- }
|
|
|
- </div>
|
|
|
- <div class="preview-actions">
|
|
|
- <!--
|
|
|
- 一个成果可能有多个附件(如 EN 版 / ZH 版),
|
|
|
- 按钮**必须带文件名**,否则用户不知道下的是哪一个(实测反馈:两个一样的「下载」)。
|
|
|
- -->
|
|
|
- @for (art of d.artifacts; track art.name) {
|
|
|
- <a class="download" [href]="art.url" [attr.download]="art.name" [title]="art.name">
|
|
|
- <i class="fa-solid fa-download" aria-hidden="true"></i>
|
|
|
- <span class="dl-name">{{ art.name }}</span>
|
|
|
- </a>
|
|
|
- }
|
|
|
- </div>
|
|
|
- }
|
|
|
- </ui-modal>
|
|
|
+ [url]="previewing()?.artifacts?.[0]?.url ?? ''"
|
|
|
+ [name]="previewing()?.filename ?? previewing()?.title ?? ''"
|
|
|
+ [mimeType]="''"
|
|
|
+ (closed)="previewing.set(null)"
|
|
|
+ />
|
|
|
|
|
|
<!-- 消息附件预览(按类型分发:图片/视频/pdf/md/文本/office) -->
|
|
|
<app-file-preview
|
|
|
@@ -425,6 +502,9 @@ import { TasksService } from '../../../core/tasks.service';
|
|
|
:host {
|
|
|
display: block;
|
|
|
height: 100%;
|
|
|
+ /* 参与父级网格时,默认的 min-height:auto 会被内容撑高 → 整页滚动 */
|
|
|
+ min-height: 0;
|
|
|
+ overflow: hidden;
|
|
|
}
|
|
|
|
|
|
/*
|
|
|
@@ -589,6 +669,19 @@ import { TasksService } from '../../../core/tasks.service';
|
|
|
font-size: var(--fs-12);
|
|
|
color: var(--muted);
|
|
|
}
|
|
|
+ /* 第二行:任务描述占满剩余宽度,接入态徽标靠右不换行 */
|
|
|
+ .conv-subline {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: var(--sp-2);
|
|
|
+ min-width: 0;
|
|
|
+ }
|
|
|
+ .conv-task {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ font-size: var(--fs-12);
|
|
|
+ color: var(--muted);
|
|
|
+ }
|
|
|
.unread {
|
|
|
min-width: 18px;
|
|
|
padding: 0 5px;
|
|
|
@@ -601,13 +694,40 @@ import { TasksService } from '../../../core/tasks.service';
|
|
|
flex-shrink: 0;
|
|
|
}
|
|
|
|
|
|
+ /*
|
|
|
+ 头部两行:① 头像 + 名称 + 状态徽标 + 操作 ② 当前任务整行。
|
|
|
+ 用 grid 显式分行,任务名再长也不会挤压头像/标题(此前挤成一排)。
|
|
|
+ */
|
|
|
.chat-head {
|
|
|
- display: flex;
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: auto minmax(0, 1fr) auto auto;
|
|
|
+ grid-template-areas:
|
|
|
+ 'avatar title status actions'
|
|
|
+ 'task task task task';
|
|
|
align-items: center;
|
|
|
- gap: var(--sp-3);
|
|
|
+ column-gap: var(--sp-3);
|
|
|
+ row-gap: var(--sp-2);
|
|
|
padding: var(--sp-3) var(--sp-4);
|
|
|
border-bottom: 1px solid var(--edge);
|
|
|
}
|
|
|
+ .chat-head > ui-avatar {
|
|
|
+ grid-area: avatar;
|
|
|
+ }
|
|
|
+ .chat-head > .chat-title {
|
|
|
+ grid-area: title;
|
|
|
+ }
|
|
|
+ /* 🔴 徽标与操作区各占一格 —— 曾经共用 status 格,操作按钮把徽标压住了 */
|
|
|
+ .chat-head > app-status-badge {
|
|
|
+ grid-area: status;
|
|
|
+ justify-self: end;
|
|
|
+ }
|
|
|
+ .chat-head > .chat-actions {
|
|
|
+ grid-area: actions;
|
|
|
+ justify-self: end;
|
|
|
+ }
|
|
|
+ .chat-head > .task-line {
|
|
|
+ grid-area: task;
|
|
|
+ }
|
|
|
.chat-title {
|
|
|
display: grid;
|
|
|
gap: 2px;
|
|
|
@@ -626,6 +746,159 @@ import { TasksService } from '../../../core/tasks.service';
|
|
|
display: flex;
|
|
|
gap: var(--sp-1);
|
|
|
}
|
|
|
+ /*
|
|
|
+ 当前任务整行:一个会话一屏只出现一次「它在做什么」。
|
|
|
+ 左侧细色条 + 会转的小点表达「正在做」,完成态不转。
|
|
|
+ 用整行而非胶囊:任务描述通常较长,胶囊装不下只能省略。
|
|
|
+ */
|
|
|
+ .task-line {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: var(--sp-2);
|
|
|
+ min-width: 0;
|
|
|
+ padding: var(--sp-1) var(--sp-3);
|
|
|
+ border-left: 3px solid var(--edge-strong);
|
|
|
+ border-radius: var(--r-sm);
|
|
|
+ background: var(--surface-2);
|
|
|
+ font-size: var(--fs-12);
|
|
|
+ color: var(--ink-2);
|
|
|
+ }
|
|
|
+ .task-line .spin {
|
|
|
+ width: 8px;
|
|
|
+ height: 8px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: var(--muted);
|
|
|
+ }
|
|
|
+ .task-line.busy .spin {
|
|
|
+ background: var(--success);
|
|
|
+ animation: pulse-dot 1.4s var(--ease-standard) infinite;
|
|
|
+ }
|
|
|
+ .task-line.tone-warning {
|
|
|
+ border-left-color: var(--warning);
|
|
|
+ color: var(--warning);
|
|
|
+ }
|
|
|
+ .task-line.tone-warning .spin {
|
|
|
+ background: var(--warning);
|
|
|
+ }
|
|
|
+ .task-line.tone-danger {
|
|
|
+ border-left-color: var(--danger);
|
|
|
+ color: var(--danger);
|
|
|
+ }
|
|
|
+ .task-line.tone-danger .spin {
|
|
|
+ background: var(--danger);
|
|
|
+ }
|
|
|
+ .task-line.tone-success,
|
|
|
+ .task-line.tone-info {
|
|
|
+ border-left-color: var(--success);
|
|
|
+ color: var(--success);
|
|
|
+ }
|
|
|
+ .task-line.tone-success .spin,
|
|
|
+ .task-line.tone-info .spin {
|
|
|
+ background: var(--success);
|
|
|
+ }
|
|
|
+ @keyframes pulse-dot {
|
|
|
+ 0%,
|
|
|
+ 100% {
|
|
|
+ opacity: 1;
|
|
|
+ transform: scale(1);
|
|
|
+ }
|
|
|
+ 50% {
|
|
|
+ opacity: 0.35;
|
|
|
+ transform: scale(0.8);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ @media (prefers-reduced-motion: reduce) {
|
|
|
+ .task-line.busy .spin {
|
|
|
+ animation: none;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /*
|
|
|
+ 运行态小标签:圆点 + 文字(不用底色块,避免标题行三个彩色块打架)。
|
|
|
+ 只有「执行中」的圆点会呼吸,其余是静态灰/黄点。
|
|
|
+ */
|
|
|
+ .run-chip {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ font-size: var(--fs-11);
|
|
|
+ color: var(--muted);
|
|
|
+ white-space: nowrap;
|
|
|
+ }
|
|
|
+ .run-chip .dot {
|
|
|
+ width: 6px;
|
|
|
+ height: 6px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: var(--edge-strong);
|
|
|
+ }
|
|
|
+ .run-chip.tone-success .dot,
|
|
|
+ .run-chip.tone-info .dot {
|
|
|
+ background: var(--success);
|
|
|
+ }
|
|
|
+ .run-chip.tone-success,
|
|
|
+ .run-chip.tone-info {
|
|
|
+ color: var(--success);
|
|
|
+ }
|
|
|
+ .run-chip.tone-success .dot,
|
|
|
+ .run-chip.tone-info .dot {
|
|
|
+ animation: pulse-dot 1.4s var(--ease-standard) infinite;
|
|
|
+ } .run-chip.tone-warning {
|
|
|
+ color: var(--warning);
|
|
|
+ }
|
|
|
+ .run-chip.tone-danger {
|
|
|
+ color: var(--danger);
|
|
|
+ }
|
|
|
+ .run-chip.tone-danger .dot {
|
|
|
+ background: var(--danger);
|
|
|
+ }
|
|
|
+ .run-chip.tone-warning .dot {
|
|
|
+ background: var(--warning);
|
|
|
+ }
|
|
|
+ @media (prefers-reduced-motion: reduce) {
|
|
|
+ .run-chip .dot {
|
|
|
+ animation: none !important;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 最近动态 */
|
|
|
+ .pulse-list {
|
|
|
+ margin: 0;
|
|
|
+ padding: 0;
|
|
|
+ list-style: none;
|
|
|
+ display: grid;
|
|
|
+ gap: var(--sp-2);
|
|
|
+ }
|
|
|
+ .pulse-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: var(--sp-2);
|
|
|
+ min-width: 0;
|
|
|
+ font-size: var(--fs-12);
|
|
|
+ color: var(--ink-2);
|
|
|
+ }
|
|
|
+ .pulse-item i {
|
|
|
+ width: 12px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ color: var(--muted);
|
|
|
+ font-size: var(--fs-11);
|
|
|
+ }
|
|
|
+ .pulse-text {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ }
|
|
|
+ .pulse-item small {
|
|
|
+ flex-shrink: 0;
|
|
|
+ color: var(--muted);
|
|
|
+ font-size: var(--fs-11);
|
|
|
+ }
|
|
|
+ .tone-ink-success {
|
|
|
+ color: var(--success);
|
|
|
+ }
|
|
|
+ .tone-ink-warning {
|
|
|
+ color: var(--warning);
|
|
|
+ }
|
|
|
/* 右栏切换按钮的激活态 */
|
|
|
.chat-actions ui-button.on button {
|
|
|
background: var(--primary-subtle);
|
|
|
@@ -761,6 +1034,12 @@ import { TasksService } from '../../../core/tasks.service';
|
|
|
min-width: 0;
|
|
|
flex: 1;
|
|
|
}
|
|
|
+ .sync-hint {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: var(--sp-1);
|
|
|
+ color: var(--muted);
|
|
|
+ }
|
|
|
.rail-meta strong {
|
|
|
font-size: var(--fs-13);
|
|
|
color: var(--ink);
|
|
|
@@ -784,6 +1063,8 @@ export class DkChatPage {
|
|
|
private readonly unread = inject(UnreadService);
|
|
|
private readonly deliverables = inject(DeliverablesService);
|
|
|
private readonly tasks = inject(TasksService);
|
|
|
+ private readonly pulse = inject(TaskPulseService);
|
|
|
+ private readonly artifactSync = inject(ArtifactSyncService);
|
|
|
private readonly route = inject(ActivatedRoute);
|
|
|
private readonly router = inject(Router);
|
|
|
private readonly device = inject(DeviceService);
|
|
|
@@ -803,6 +1084,94 @@ export class DkChatPage {
|
|
|
readonly pct = (v?: number) => fmtPercent(v);
|
|
|
readonly tokens = (a: { usage?: { totalTokens?: number } }) => fmtTokens(usageOf(a as never).totalTokens);
|
|
|
|
|
|
+ /**
|
|
|
+ * 「当前任务」文案 —— **唯一实现**在 `TaskPulseService.describe()`。
|
|
|
+ * 列表行、头部任务条、右栏运行状态**三处共用同一份**,保证左右同步。
|
|
|
+ */
|
|
|
+ taskLine(agentId: string): TaskLine {
|
|
|
+ return this.pulse.describe(this.live.agentById(agentId));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 列表行紧凑标签:一次只答一个问题 —— 在不在干活 */
|
|
|
+ runLabel(agentId: string): string {
|
|
|
+ const line = this.taskLine(agentId);
|
|
|
+ if (line.tone === 'danger') return '异常';
|
|
|
+ if (line.active) return '执行中';
|
|
|
+ if (line.tone === 'warning') return '未同步';
|
|
|
+ return '空闲';
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 列表第二行文案 —— **不与标题行的运行态重复**。
|
|
|
+ *
|
|
|
+ * 有具体任务时显示任务;否则显示专长(更适合"没在忙"的时候告知用户它是谁)。
|
|
|
+ */
|
|
|
+ convSubline(agentId: string, specialty?: string): string {
|
|
|
+ const line = this.taskLine(agentId);
|
|
|
+ // 状态词(空闲/执行中/未同步/异常)已经在标题行,这里不再重复
|
|
|
+ if (!line.active && (line.tone === 'neutral' || line.tone === 'danger')) {
|
|
|
+ return specialty || '数字生命';
|
|
|
+ }
|
|
|
+ return line.text || specialty || '数字生命';
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 本会话最近动态 */
|
|
|
+ readonly pulses = computed(() => this.pulse.pulsesFor(this.chat.agentId()));
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 消息 → 渲染行:**连续的终端活动消息合并成一个折叠组**。
|
|
|
+ *
|
|
|
+ * 容器把 Hermes 的每一步(`💻 terminal` + 命令、`✍️ Writing ...`)
|
|
|
+ * 都作为独立消息发出来,一条回复能产生十几条 —— 会话被淹没。
|
|
|
+ *
|
|
|
+ * 规则:相邻且都属于终端活动的消息合成一组;遇到正常对话立即断开。
|
|
|
+ */
|
|
|
+ readonly messageRows = computed<MessageRow[]>(() => {
|
|
|
+ const rows: MessageRow[] = [];
|
|
|
+ let group: { text: string; summary: string }[] = [];
|
|
|
+ const flush = (): void => {
|
|
|
+ if (group.length === 0) return;
|
|
|
+ const first = group[0];
|
|
|
+ rows.push({
|
|
|
+ kind: 'tool',
|
|
|
+ key: `tool-${rows.length}-${first.summary}`,
|
|
|
+ block: {
|
|
|
+ kind: 'process',
|
|
|
+ steps: group.map((g) => ({ kind: 'tool' as const, label: g.summary, detail: g.text })),
|
|
|
+ },
|
|
|
+ });
|
|
|
+ group = [];
|
|
|
+ };
|
|
|
+ for (const m of this.chat.messages()) {
|
|
|
+ // 空消息(撤回 / 表情回应 / 空 body)不渲染 —— 实测占 7/12
|
|
|
+ if (!m.text?.trim() && !m.file) continue;
|
|
|
+ if (m.sender === 'agent' && isToolActivityMessage(m.text)) {
|
|
|
+ group.push({ text: m.text!.slice(0, 400), summary: toolActivitySummary(m.text!) });
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ flush();
|
|
|
+ rows.push({ kind: 'msg', key: m.id, message: m });
|
|
|
+ }
|
|
|
+ flush();
|
|
|
+ return rows;
|
|
|
+ });
|
|
|
+
|
|
|
+ /** 某个成果里「还在容器里、未上云盘」的首个附件(没有则 undefined) */
|
|
|
+ pendingOf(d: Deliverable): { name: string; source: string } | undefined {
|
|
|
+ const a = d.artifacts.find((x) => !x.url && x.source);
|
|
|
+ return a ? { name: a.name, source: a.source! } : undefined;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 附件同步失败原因(UI 如实展示,不假装正在同步) */
|
|
|
+ syncError(deliverableId: string, a: { source: string }): string | undefined {
|
|
|
+ return this.artifactSync.errorOf(deliverableId, a.source);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 动态里的时刻(本地毫秒 → HH:MM) */
|
|
|
+ clock(ms: number): string {
|
|
|
+ return fmtTime(ms);
|
|
|
+ }
|
|
|
+
|
|
|
private readonly messagesEl = viewChild<ElementRef<HTMLElement>>('messages');
|
|
|
|
|
|
/** 会话列表:仅已接入的数字生命 */
|
|
|
@@ -834,6 +1203,10 @@ export class DkChatPage {
|
|
|
* 用于区分「尾部新增」(要滚底)与「头部插入」(分页,保持锚点)。
|
|
|
*/
|
|
|
private lastTailId = '';
|
|
|
+ /** 用户是否主动上翻离开了底部(true 时新消息不强行拉回) */
|
|
|
+ private userScrolledAway = false;
|
|
|
+ /** 正在加载更早历史:期间禁止任何「贴底」动作,否则锚点会被破坏 */
|
|
|
+ private loadingEarlier = false;
|
|
|
|
|
|
/** 打开消息附件预览(按类型分发由 app-file-preview 负责) */
|
|
|
openFile(f: { url: string; name: string; mimeType: string }): void {
|
|
|
@@ -884,7 +1257,7 @@ export class DkChatPage {
|
|
|
this.lastTailId = tailId;
|
|
|
// 首次(prev 为空)或尾部确实变了 → 滚底
|
|
|
if (prev === '' || tailId !== prev) {
|
|
|
- queueMicrotask(() => this.scrollToBottom());
|
|
|
+ this.stickToBottom();
|
|
|
}
|
|
|
});
|
|
|
|
|
|
@@ -898,6 +1271,16 @@ export class DkChatPage {
|
|
|
this.route.paramMap.subscribe((params) => {
|
|
|
void this.onRouteChange(params.get('id'));
|
|
|
});
|
|
|
+
|
|
|
+ /*
|
|
|
+ * 成果附件同步:读到成果列表后,把「只有容器路径、没有云盘地址」的附件登记去同步。
|
|
|
+ * 幂等(服务内部去重),每次列表刷新都可以安全调用。
|
|
|
+ */
|
|
|
+ effect(() => {
|
|
|
+ const list = this.deliverables.all();
|
|
|
+ if (list.length === 0) return;
|
|
|
+ void this.artifactSync.syncPending(list);
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
private async onRouteChange(id: string | null): Promise<void> {
|
|
|
@@ -987,6 +1370,29 @@ export class DkChatPage {
|
|
|
void this.router.navigate(['/', this.device.mode(), 'tasks', id]);
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 贴底:**滚动两次**。
|
|
|
+ *
|
|
|
+ * 🔴 踩坑(2026-10-21 实测):只滚一次时,消息已进 DOM 但 **markdown 还没渲染完**,
|
|
|
+ * 那一刻 `scrollHeight` 偏小,滚完仍差 1700+ px —— 用户打开会话看到的是中间某处,
|
|
|
+ * 而且之后**再也不动**(因为尾部 id 没再变,effect 不再触发)。
|
|
|
+ *
|
|
|
+ * 做法:立即滚一次(同步可见),下一个宏任务再滚一次(吃掉 md 渲染后的增量)。
|
|
|
+ * 用 `requestAnimationFrame` 而非 `queueMicrotask`:后者在渲染前执行,量不到最终高度。
|
|
|
+ */
|
|
|
+ private stickToBottom(): void {
|
|
|
+ // 用户主动上翻看历史时不要把他拽回来(但首次进入要贴底)
|
|
|
+ if (this.userScrolledAway && this.lastTailId !== '') return;
|
|
|
+ // 正在往前翻页:此时 layout 变化属于「向上插入」,绝不能贴底
|
|
|
+ if (this.loadingEarlier) return;
|
|
|
+ this.scrollToBottom();
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ this.scrollToBottom();
|
|
|
+ // 图片/mermaid 等异步内容会继续长高,再补一次
|
|
|
+ setTimeout(() => this.scrollToBottom(), 120);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
private scrollToBottom(): void {
|
|
|
const el = this.messagesEl()?.nativeElement;
|
|
|
if (el) el.scrollTop = el.scrollHeight;
|
|
|
@@ -1000,20 +1406,47 @@ export class DkChatPage {
|
|
|
*/
|
|
|
onMessagesScroll(_e: Event): void {
|
|
|
const el = this.messagesEl()?.nativeElement;
|
|
|
- if (!el || el.scrollTop > 80) return;
|
|
|
+ if (!el) return;
|
|
|
+ /*
|
|
|
+ * 记住「用户是否主动离开底部」。
|
|
|
+ * 距底 > 160px 视为用户在往上翻 —— 此时新消息到达**不要**把他拽回底部
|
|
|
+ * (否则翻历史翻到一半就被弹走);滚回接近底部时自动恢复贴底。
|
|
|
+ */
|
|
|
+ const gap = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
|
+ this.userScrolledAway = gap > 160;
|
|
|
+ if (el.scrollTop > 80) return;
|
|
|
if (this.chat.paginating() || !this.chat.hasMoreHistory()) return;
|
|
|
void this.loadEarlier();
|
|
|
}
|
|
|
|
|
|
/** 加载更早历史,并保持滚动锚点 */
|
|
|
+ /**
|
|
|
+ * 加载更早历史,并**保持滚动锚点**(用户要求:位置不能动)。
|
|
|
+ *
|
|
|
+ * 🔴 踩坑:原实现用 `queueMicrotask` 补偿 —— 那一刻新消息还没渲染完,
|
|
|
+ * 量到的 `scrollHeight` 仍是旧值,补偿量算成 0 → 视口被顶到上面去了。
|
|
|
+ *
|
|
|
+ * 正确做法:等**两帧**再量(首帧是布局,次帧含 markdown 渲染后的增量),
|
|
|
+ * 用 `新高度 - 旧高度` 作为补偿量。两次测量都拿实时的 `scrollHeight`。
|
|
|
+ */
|
|
|
async loadEarlier(): Promise<void> {
|
|
|
const el = this.messagesEl()?.nativeElement;
|
|
|
- const beforeHeight = el?.scrollHeight ?? 0;
|
|
|
+ if (!el) return;
|
|
|
+ const beforeHeight = el.scrollHeight;
|
|
|
+ const beforeTop = el.scrollTop;
|
|
|
+ this.loadingEarlier = true;
|
|
|
const grew = await this.chat.paginate();
|
|
|
- if (!grew || !el) return;
|
|
|
- // 内容变高后,把视口下移同样的增量 → 视觉上停在原位置
|
|
|
- queueMicrotask(() => {
|
|
|
- el.scrollTop = el.scrollHeight - beforeHeight;
|
|
|
+ if (!grew) {
|
|
|
+ this.loadingEarlier = false;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // 两帧之后再量:一帧可能还没完成 markdown 渲染
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ const added = el.scrollHeight - beforeHeight;
|
|
|
+ if (added > 0) el.scrollTop = beforeTop + added;
|
|
|
+ this.loadingEarlier = false;
|
|
|
+ });
|
|
|
});
|
|
|
}
|
|
|
}
|