| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013 |
- import {
- ChangeDetectionStrategy,
- Component,
- computed,
- effect,
- ElementRef,
- inject,
- signal,
- viewChild,
- } from '@angular/core';
- import { ActivatedRoute, Router } from '@angular/router';
- import {
- UiAlert,
- UiAvatar,
- UiButton,
- UiEmpty,
- UiInput,
- UiModal,
- UiSkeleton,
- UiTooltip,
- } from '../../../ui';
- 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 { FilePreviewComponent } from '../../../components/preview/file-preview/file-preview';
- import { PresenceBadgeComponent } from '../../../components/domain/presence-badge/presence-badge';
- import { AgentPresence, agentPresence } from '../../../core/api/models';
- import { StatusBadgeComponent } from '../../../components/domain/status-badge/status-badge';
- import { ChatService } from '../../../core/chat.service';
- 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 { LiveService } from '../../../core/live.service';
- import { UnreadService } from '../../../core/unread.service';
- import { TasksService } from '../../../core/tasks.service';
- /**
- * 桌面·会话(SPEC FR-04)—— 四栏(导航 + 会话列表 + 聊天区 + 成果栏)
- *
- * 实现纪律(2026-10-15 返工):
- * · 列表/空态/加载/提示条全部走底座组件(`ui-list` / `ui-empty` / `ui-skeleton` / `ui-alert`)
- * · **流动布局**:栏宽用比例 + 最小宽兜底,窗口变宽时主区跟着变宽;
- * 仅当可用宽不足以容纳右栏时才降级(换形态,不删功能)
- * · 无 demo 件
- *
- * AC:04.1 四栏 / 04.2 发送 / 04.3 上传进度 / 04.5 自增高 / 04.6 状态不重复
- * / 04.8 失败可重试 / 04.9 连接状态 / 04.10 超长截断 / 04.11 三态
- */
- @Component({
- selector: 'app-dk-chat-page',
- changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [
- UiAlert,
- UiAvatar,
- UiButton,
- UiEmpty,
- UiInput,
- UiModal,
- UiSkeleton,
- UiTooltip,
- ComposerComponent,
- MessageBubbleComponent,
- TypingIndicatorComponent,
- FilePreviewComponent,
- PresenceBadgeComponent,
- StatusBadgeComponent,
- ],
- template: `
- <div class="conversation-layout">
- <!-- ===== 会话列表 ===== -->
- <aside class="col list-col" aria-label="会话列表">
- <header class="col-head">
- <h2>会话</h2>
- <span class="count">{{ conversations().length }}</span>
- </header>
- <div class="search-hold">
- <ui-input
- prefixIcon="fa-solid fa-magnifying-glass"
- [clearable]="true"
- placeholder="搜索会话"
- [value]="keyword()"
- (valueChange)="keyword.set($any($event))"
- />
- </div>
- <div class="list-scroll">
- @switch (live.state()) {
- @case ('loading') {
- <div class="skeleton-hold">
- @for (i of [1, 2, 3, 4]; track i) {
- <ui-skeleton variant="rect" height="60px" />
- }
- </div>
- }
- @case ('error') {
- <ui-empty
- icon="fa-solid fa-triangle-exclamation"
- title="会话列表加载失败"
- [description]="live.error() ?? ''"
- />
- <div class="center">
- <ui-button variant="outline" size="sm" (click)="reload()">重新加载</ui-button>
- </div>
- }
- @default {
- @if (filteredConversations().length === 0) {
- <ui-empty title="没有匹配的会话" />
- } @else {
- @for (c of filteredConversations(); track c.id) {
- <button
- type="button"
- class="conv-row"
- [class.active]="chat.agentId() === c.id"
- (click)="openConversation(c.id)"
- >
- <ui-avatar [name]="c.name" />
- <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>
- <span class="conv-preview ellipsis">{{ c.currentTask || c.specialty }}</span>
- </span>
- </button>
- }
- }
- }
- }
- </div>
- </aside>
- <!-- ===== 聊天区 ===== -->
- <section class="col main-col" aria-label="消息">
- @if (!chat.agentId()) {
- <div class="center-fill">
- <ui-empty title="选择一个会话" description="从左侧选择数字生命开始对话。" />
- </div>
- } @else {
- <header class="chat-head">
- @if (chat.agent(); as a) {
- <ui-avatar [name]="a.name" [size]="'md'" />
- <div class="chat-title">
- <strong>{{ a.name }}</strong>
- <small class="ellipsis">{{ a.specialty || '数字生命' }}</small>
- </div>
- <!-- AC-04.6:状态**只在此处出现一次**,不在右栏重复 -->
- <app-status-badge [agent]="a" />
- }
- <div class="chat-actions">
- <!-- 任务:点开右栏并切到任务面板;再点隐藏 -->
- <ui-tooltip content="任务">
- <ui-button
- variant="ghost"
- shape="circle"
- icon="fa-solid fa-list-check"
- ariaLabel="任务"
- [class.on]="railOpen() && railTab() === 'tasks'"
- (click)="toggleRail('tasks')"
- />
- </ui-tooltip>
- <!-- 成果:点开右栏并切到成果面板;再点隐藏 -->
- <ui-tooltip content="成果">
- <ui-button
- variant="ghost"
- shape="circle"
- icon="fa-solid fa-box"
- ariaLabel="成果"
- [class.on]="railOpen() && railTab() === 'results'"
- (click)="toggleRail('results')"
- />
- </ui-tooltip>
- </div>
- </header>
- <!--
- 连接状态提示(AC-04.9)。
- 🔴 区分两种「连不上」:容器离线 vs 自己在连接中 ——
- 否则用户分不清是网络问题还是对方容器没跑(实测反馈)。
- -->
- @if (chat.connection() !== 'connected' || currentContainerState() === 'unreported') {
- <div class="banner-hold">
- @if (currentContainerState() === 'unreported' && chat.connection() === 'connected') {
- <ui-alert type="warning" title="容器状态未知,可能无法应答">
- 已连接矩阵服务,但该容器的接入通道未上报在线状态,消息可能无人应答。
- </ui-alert>
- } @else {
- <ui-alert
- [type]="chat.connection() === 'connecting' ? 'info' : 'warning'"
- [title]="chat.connection() === 'connecting' ? '正在连接…' : '连接已断开'"
- >
- @if (chat.connection() === 'disconnected') {
- <ui-button variant="outline" size="sm" (click)="reconnect()">重新连接</ui-button>
- }
- </ui-alert>
- }
- </div>
- }
- <div class="messages" #messages (scroll)="onMessagesScroll($event)">
- @switch (chat.state()) {
- @case ('loading') {
- <div class="skeleton-hold">
- @for (i of [1, 2, 3]; track i) {
- <ui-skeleton variant="rect" height="64px" />
- }
- </div>
- }
- @case ('error') {
- <ui-empty
- icon="fa-solid fa-triangle-exclamation"
- title="消息加载失败"
- [description]="chat.error() ?? ''"
- />
- <div class="center">
- <ui-button variant="outline" size="sm" (click)="retryOpen()">重新加载</ui-button>
- </div>
- }
- @case ('empty') {
- <ui-empty title="还没有消息" description="发送第一条消息开始协作。" />
- }
- @default {
- <!-- AC-04.4:向上翻历史(滚动到顶部自动加载) -->
- <div class="history-hold">
- @if (chat.paginating()) {
- <ui-skeleton variant="rect" height="40px" />
- } @else if (chat.hasMoreHistory()) {
- <ui-button variant="ghost" size="sm" (click)="loadEarlier()">
- 加载更早的消息
- </ui-button>
- } @else {
- <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)"
- />
- }
- <!-- 对方正在输入(FR-04;容器侧已实测在发 m.typing) -->
- @if (chat.peerTyping()) {
- <app-typing-indicator [name]="chat.agent()?.name ?? '对方'" />
- }
- }
- }
- </div>
- <app-composer
- [draft]="chat.draft()"
- [sending]="chat.sending()"
- [uploading]="chat.uploading()"
- [pct]="chat.uploadPct()"
- [disabled]="chat.connection() !== 'connected'"
- (draftChange)="chat.setDraft($event)"
- (send)="chat.sendText($event)"
- (attach)="chat.sendFiles($event)"
- />
- }
- </section>
- <!-- ===== 右栏(宽不足时折叠为浮层,不删功能)===== -->
- @if (chat.agentId() && railOpen()) {
- <aside class="col rail-col" aria-label="侧栏面板">
- <header class="col-head">
- <h2>{{ railTab() === 'tasks' ? '任务' : '成果' }}</h2>
- <ui-button
- variant="ghost"
- shape="circle"
- icon="fa-solid fa-xmark"
- ariaLabel="收起面板"
- (click)="railOpen.set(false)"
- />
- </header>
- <div class="rail-body">
- @if (chat.agent(); as a) {
- <section class="rail-block">
- <h3>运行状态</h3>
- <dl class="kv">
- <div><dt>当前任务</dt><dd>{{ a.currentTask || '—' }}</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 (railTab() === 'tasks') {
- <section class="rail-block">
- <h3>相关任务 <span class="count">{{ agentTasks().length }}</span></h3>
- @if (agentTasks().length === 0) {
- <p class="rail-empty">该数字生命暂无任务。</p>
- } @else {
- @for (t of agentTasks(); track t.id) {
- <button type="button" class="rail-item task" (click)="goTask(t.id)">
- <span class="file-icon task-ico" aria-hidden="true">
- <i class="fa-solid fa-list-check"></i>
- </span>
- <span class="rail-meta">
- <strong class="ellipsis">{{ t.title }}</strong>
- <small class="ellipsis">
- {{ t.id }} · {{ statusText(t.status) }}
- </small>
- </span>
- <i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
- </button>
- }
- }
- </section>
- }
- @if (railTab() === 'results') {
- <section class="rail-block">
- <h3>相关成果 <span class="count">{{ agentDeliverables().length }}</span></h3>
- @if (agentDeliverables().length === 0) {
- <p class="rail-empty">该数字生命暂无交付成果。</p>
- } @else {
- @for (d of agentDeliverables(); track d.id) {
- <div class="rail-item">
- <span class="file-icon" aria-hidden="true">
- <i class="fa-solid" [class]="fileIcon(d.fileType)"></i>
- </span>
- <span class="rail-meta">
- <strong class="ellipsis">{{ d.title }}</strong>
- <small class="ellipsis">{{ d.filename }} · {{ d.size }}</small>
- </span>
- <span class="rail-actions">
- <ui-button
- variant="ghost"
- shape="circle"
- size="sm"
- icon="fa-solid fa-eye"
- ariaLabel="预览"
- (click)="preview(d)"
- />
- @if (d.artifacts[0]; as art) {
- <a
- class="dl-btn"
- [href]="art.url"
- [attr.download]="art.name"
- aria-label="下载"
- >
- <i class="fa-solid fa-download" aria-hidden="true"></i>
- </a>
- }
- </span>
- </div>
- }
- }
- </section>
- }
- }
- </div>
- </aside>
- }
- </div>
- <!-- 右栏成果预览:底座 ui-modal -->
- <ui-modal
- [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>
- <!-- 消息附件预览(按类型分发:图片/视频/pdf/md/文本/office) -->
- <app-file-preview
- [open]="!!previewFile()"
- [url]="previewFile()?.url ?? ''"
- [name]="previewFile()?.name ?? ''"
- [mimeType]="previewFile()?.mimeType ?? ''"
- (closed)="previewFile.set(null)"
- />
- `,
- styles: [
- `
- @use '../../../core/styles/breakpoints' as bp;
- :host {
- display: block;
- height: 100%;
- }
- /*
- 四栏:列表固定可读宽(min~max),主区吃掉全部剩余,右栏按比例但设上下限。
- —— 窗口变宽时主区跟着变宽(不是内容整体缩放),这是"流动布局"的要点。
- */
- /*
- 列表栏用**固定可读宽**(280px)而非比例:列表项是纵向排列,
- 宽度随窗口缩放会把名字/预览压到不可读。右栏才用比例。
- */
- /*
- 列数随右栏显隐**动态变化** —— 否则右栏隐藏后那一列仍占位,聊天区不填满。
- 用 :has() 检测 .rail-col 是否存在(存在才保留第三列)。
- */
- .conversation-layout {
- display: grid;
- grid-template-columns: var(--desk-list-w) minmax(0, 1fr);
- height: 100%;
- min-height: 0;
- }
- .conversation-layout:has(> .rail-col) {
- grid-template-columns: var(--desk-list-w) minmax(0, 1fr) minmax(280px, 24%);
- }
- /* 可用宽不足以同时容纳列表+主区+右栏时,右栏让位(由按钮切换显示) */
- @include bp.dk-below-4col {
- .conversation-layout,
- .conversation-layout:has(> .rail-col) {
- grid-template-columns: var(--desk-list-w-compact) minmax(0, 1fr);
- }
- .rail-col {
- display: none;
- }
- }
- .col {
- min-width: 0;
- min-height: 0;
- display: flex;
- flex-direction: column;
- background: var(--surface);
- }
- .list-col {
- background: var(--surface-2);
- border-right: 1px solid var(--edge);
- overflow: hidden;
- }
- .main-col {
- background: var(--surface);
- border-right: 1px solid var(--edge);
- }
- .rail-col {
- background: var(--rail-surface);
- }
- .col-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: var(--sp-2);
- padding: var(--sp-4);
- border-bottom: 1px solid var(--edge);
- }
- .col-head h2 {
- margin: 0;
- font-size: var(--fs-15);
- color: var(--ink);
- }
- .count {
- font-size: var(--fs-11);
- color: var(--muted);
- }
- .search-hold {
- padding: var(--sp-3) var(--sp-4);
- }
- .list-scroll {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- padding: 0 var(--sp-2) var(--sp-3);
- }
- .skeleton-hold {
- display: grid;
- gap: var(--sp-2);
- padding: var(--sp-2);
- }
- .center {
- display: flex;
- justify-content: center;
- padding: var(--sp-3);
- }
- .center-fill {
- flex: 1;
- display: grid;
- place-content: center;
- }
- .conv-row {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- width: 100%;
- min-height: 60px;
- padding: var(--sp-2) var(--sp-3);
- border: 0;
- border-radius: var(--r-md);
- background: transparent;
- text-align: left;
- cursor: pointer;
- }
- .conv-row:hover {
- background: var(--surface-hover);
- }
- /* 选中态:仅用底色,不加竖条(与导航选中态统一) */
- .conv-row.active {
- background: var(--surface);
- }
- .conv-info {
- display: grid;
- gap: 2px;
- flex: 1;
- min-width: 0;
- }
- .conv-line {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- }
- .conv-line strong {
- font-size: var(--fs-15);
- color: var(--ink);
- }
- /* 预览操作区:多个附件时按钮带文件名,过长截断 */
- .preview-actions {
- display: flex;
- flex-wrap: wrap;
- gap: var(--sp-2);
- margin-top: var(--sp-3);
- }
- .download {
- display: inline-flex;
- align-items: center;
- gap: var(--sp-1);
- max-width: 240px;
- min-height: var(--touch-min);
- padding: 0 var(--sp-3);
- border-radius: var(--r-sm);
- background: var(--primary);
- color: var(--primary-content);
- font-size: var(--fs-13);
- text-decoration: none;
- }
- .dl-name {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .conv-preview {
- font-size: var(--fs-12);
- color: var(--muted);
- }
- .unread {
- min-width: 18px;
- padding: 0 5px;
- border-radius: var(--r-tag);
- background: var(--danger);
- color: var(--on-danger);
- font-size: var(--fs-11);
- line-height: 18px;
- text-align: center;
- flex-shrink: 0;
- }
- .chat-head {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- padding: var(--sp-3) var(--sp-4);
- border-bottom: 1px solid var(--edge);
- }
- .chat-title {
- display: grid;
- gap: 2px;
- flex: 1;
- min-width: 0;
- }
- .chat-title strong {
- font-size: var(--fs-15);
- color: var(--ink);
- }
- .chat-title small {
- font-size: var(--fs-12);
- color: var(--muted);
- }
- .chat-actions {
- display: flex;
- gap: var(--sp-1);
- }
- /* 右栏切换按钮的激活态 */
- .chat-actions ui-button.on button {
- background: var(--primary-subtle);
- color: var(--primary);
- }
- .rail-item.task .task-ico {
- background: var(--info-subtle);
- color: var(--info);
- }
- .rail-item > i {
- color: var(--muted);
- flex-shrink: 0;
- }
- .banner-hold {
- padding: var(--sp-3) var(--sp-4) 0;
- }
- .messages {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- padding: var(--sp-4);
- display: grid;
- gap: var(--sp-3);
- /* 消息**自上而下排列**(用户反馈:不要从下往上) */
- align-content: start;
- }
- /* AC-04.4:历史加载区(顶部) */
- .history-hold {
- display: flex;
- justify-content: center;
- padding: var(--sp-1) 0;
- }
- .history-end {
- font-size: var(--fs-12);
- color: var(--muted);
- }
- .rail-body {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- padding: var(--sp-4);
- display: grid;
- gap: var(--sp-5);
- align-content: start;
- }
- .rail-block h3 {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- margin: 0 0 var(--sp-3);
- font-size: var(--fs-13);
- color: var(--ink-2);
- }
- .kv {
- margin: 0;
- display: grid;
- gap: var(--sp-2);
- }
- .kv > div {
- display: flex;
- justify-content: space-between;
- gap: var(--sp-3);
- font-size: var(--fs-12);
- }
- .kv dt {
- color: var(--muted);
- flex-shrink: 0;
- }
- .kv dd {
- margin: 0;
- color: var(--ink);
- text-align: right;
- min-width: 0;
- overflow-wrap: anywhere;
- }
- .rail-empty {
- margin: 0;
- font-size: var(--fs-12);
- color: var(--muted);
- }
- .rail-item {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- width: 100%;
- min-height: var(--touch-min);
- padding: var(--sp-2);
- margin-bottom: var(--sp-2);
- border: 1px solid var(--edge);
- border-radius: var(--r-md);
- background: var(--surface);
- text-align: left;
- cursor: pointer;
- }
- .rail-item:hover {
- background: var(--surface-hover);
- }
- .rail-actions {
- display: flex;
- align-items: center;
- gap: 2px;
- flex-shrink: 0;
- }
- .dl-btn {
- display: grid;
- place-items: center;
- width: 32px;
- height: 32px;
- border-radius: var(--r-sm);
- color: var(--primary);
- text-decoration: none;
- }
- .dl-btn:hover {
- background: var(--surface-active);
- }
- .file-icon {
- display: grid;
- place-items: center;
- width: 32px;
- height: 32px;
- flex-shrink: 0;
- border-radius: var(--r-sm);
- background: var(--info-subtle);
- color: var(--info);
- }
- .rail-meta {
- display: grid;
- gap: 2px;
- min-width: 0;
- flex: 1;
- }
- .rail-meta strong {
- font-size: var(--fs-13);
- color: var(--ink);
- }
- .rail-meta small {
- font-size: var(--fs-11);
- color: var(--muted);
- }
- .ellipsis {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- `,
- ],
- })
- export class DkChatPage {
- readonly chat = inject(ChatService);
- readonly live = inject(LiveService);
- private readonly unread = inject(UnreadService);
- private readonly deliverables = inject(DeliverablesService);
- private readonly tasks = inject(TasksService);
- private readonly route = inject(ActivatedRoute);
- private readonly router = inject(Router);
- private readonly device = inject(DeviceService);
- readonly keyword = signal('');
- readonly railOpen = signal(true);
- /** 右栏内联预览的成果 */
- readonly previewing = signal<Deliverable | null>(null);
- /** 右栏当前面板:任务 / 成果 */
- readonly railTab = signal<'tasks' | 'results'>('results');
- /** 当前数字生命的任务(右栏任务面板用) */
- readonly agentTasks = computed(() => {
- const id = this.chat.agentId();
- return id ? this.tasks.all().filter((t) => t.agentId === id) : [];
- });
- readonly pct = (v?: number) => fmtPercent(v);
- readonly tokens = (a: { usage?: { totalTokens?: number } }) => fmtTokens(usageOf(a as never).totalTokens);
- private readonly messagesEl = viewChild<ElementRef<HTMLElement>>('messages');
- /** 会话列表:仅已接入的数字生命 */
- readonly conversations = computed(() =>
- this.live
- .agents()
- .filter((a) => a.matrixReady)
- .map((a) => ({
- id: a.id,
- name: a.name,
- specialty: a.specialty,
- currentTask: a.currentTask,
- unread: a.unread,
- }))
- /*
- * 🔴 **固定顺序**(用户实测反馈:选中项位置会乱跳)。
- * 曾按未读数排序 → 未读一变整列重排,用户正在看的会话会跳位。
- * 改为按 name 稳定排序(同 name 再按 id),顺序**不随运行期数据变化**。
- */
- .sort((x, y) => x.name.localeCompare(y.name, 'zh') || x.id.localeCompare(y.id)),
- );
- /** 待预览的消息附件(null = 关闭) */
- readonly previewFile = signal<{ url: string; name: string; mimeType: string } | null>(null);
- /**
- * 上一次「最后一条消息」的 id。
- *
- * 用于区分「尾部新增」(要滚底)与「头部插入」(分页,保持锚点)。
- */
- private lastTailId = '';
- /** 打开消息附件预览(按类型分发由 app-file-preview 负责) */
- openFile(f: { url: string; name: string; mimeType: string }): void {
- this.previewFile.set(f);
- }
- /** 真实未读数(UnreadService 唯一来源;不用 Agent.unread 的占位值) */
- unreadOf(agentId: string): number {
- return this.unread.countOf(agentId);
- }
- /** 可对话状态(判定唯一实现在 models.agentPresence) */
- containerState(agentId: string): AgentPresence {
- return agentPresence(this.live.agentById(agentId));
- }
- /** 当前打开会话的容器状态(用于区分「容器离线」与「自己在连接中」) */
- readonly currentContainerState = computed<AgentPresence>(() => {
- const id = this.chat.agentId();
- return id ? agentPresence(this.live.agentById(id)) : 'offline';
- });
- readonly filteredConversations = computed(() => {
- const q = this.keyword().trim().toLowerCase();
- if (!q) return this.conversations();
- return this.conversations().filter(
- (c) => c.name.toLowerCase().includes(q) || (c.specialty ?? '').toLowerCase().includes(q),
- );
- });
- readonly agentDeliverables = computed(() => {
- const id = this.chat.agentId();
- return id ? this.deliverables.byAgent(id) : [];
- });
- constructor() {
- /*
- * AC-04.2:新消息到达后滚动到底。
- *
- * 🔴 **只在「尾部新增了消息」时滚底**(用户实测反馈:上翻历史时也被拉到底)。
- * 上翻历史走的是 `paginate()` → `messages` 变长但**尾部未变** →
- * 此处按「最后一条消息 id 是否变化」判定,分页时保持锚点不动。
- */
- effect(() => {
- const list = this.chat.messages();
- const tailId = list.length ? list[list.length - 1].id : '';
- const prev = this.lastTailId;
- this.lastTailId = tailId;
- // 首次(prev 为空)或尾部确实变了 → 滚底
- if (prev === '' || tailId !== prev) {
- queueMicrotask(() => this.scrollToBottom());
- }
- });
- // 窄窗默认收起右栏(<1150)
- if (typeof window !== 'undefined' && window.innerWidth < 1150) {
- this.railOpen.set(false);
- }
- // 🔴 监听**路由参数流**而非 snapshot —— 同一组件在 chat/:id 间复用时
- // 构造函数不会重跑,只读一次 snapshot 会导致「切换会话无反应」。
- this.route.paramMap.subscribe((params) => {
- void this.onRouteChange(params.get('id'));
- });
- }
- private async onRouteChange(id: string | null): Promise<void> {
- await Promise.all([
- this.live.ensureLoaded(),
- this.deliverables.ensureLoaded(),
- // 右栏任务面板依赖全量任务(按 agentId 过滤)
- this.tasks.ensureAllLoaded(),
- ]);
- if (id) {
- await this.chat.open(id);
- } else {
- const first = this.conversations()[0];
- if (first) void this.openConversation(first.id, true);
- }
- }
- openConversation(id: string, replace = false): void {
- void this.router.navigateByUrl(`/${this.device.mode()}/chat/${id}`, { replaceUrl: replace });
- }
- async reload(): Promise<void> {
- await this.live.refresh();
- }
- reconnect(): void {
- const id = this.chat.agentId();
- if (id) void this.chat.open(id);
- }
- retryOpen(): void {
- const id = this.chat.agentId();
- if (id) void this.chat.open(id);
- }
- /** 切换右栏面板:同面板再点则隐藏 */
- toggleRail(tab: 'tasks' | 'results'): void {
- if (this.railOpen() && this.railTab() === tab) {
- this.railOpen.set(false);
- return;
- }
- this.railTab.set(tab);
- this.railOpen.set(true);
- }
- /** 任务状态中文(右栏用) */
- statusText(status: string): string {
- return TASK_STATUS_TEXT[status as keyof typeof TASK_STATUS_TEXT] ?? status;
- }
- /** 消息里的「查看任务」→ 打开右栏任务面板 */
- openTaskRail(): void {
- this.railTab.set('tasks');
- this.railOpen.set(true);
- }
- goTasks(): void {
- const id = this.chat.agentId();
- if (id) void this.router.navigate(['/', this.device.mode(), 'tasks', id]);
- }
- /** 右栏内联预览 */
- preview(d: Deliverable): void {
- this.previewing.set(d);
- }
- fileIcon(type?: string): string {
- return type === 'CSV' ? 'fa-file-csv' : 'fa-file-lines';
- }
- /** CSV 内容转表格行 */
- csvRows(d: Deliverable): string[][] {
- if (!d.content) return [];
- return d.content
- .split(/\r?\n/)
- .filter((l) => l.trim().length > 0)
- .map((l) => l.split(','));
- }
- goArtifact(id: string): void {
- void this.router.navigate(['/', this.device.mode(), 'deliverables'], {
- queryParams: { focus: id },
- });
- }
- goTask(id: string): void {
- void this.router.navigate(['/', this.device.mode(), 'tasks', id]);
- }
- private scrollToBottom(): void {
- const el = this.messagesEl()?.nativeElement;
- if (el) el.scrollTop = el.scrollHeight;
- }
- /**
- * AC-04.4:滚动到顶部附近(<80px)自动加载更早历史。
- *
- * 加载前记录 `scrollHeight`,加载后按差值补偿 `scrollTop`,
- * 使视口**锚定在原来的消息上**(不跳变)。
- */
- onMessagesScroll(_e: Event): void {
- const el = this.messagesEl()?.nativeElement;
- if (!el || el.scrollTop > 80) return;
- if (this.chat.paginating() || !this.chat.hasMoreHistory()) return;
- void this.loadEarlier();
- }
- /** 加载更早历史,并保持滚动锚点 */
- async loadEarlier(): Promise<void> {
- const el = this.messagesEl()?.nativeElement;
- const beforeHeight = el?.scrollHeight ?? 0;
- const grew = await this.chat.paginate();
- if (!grew || !el) return;
- // 内容变高后,把视口下移同样的增量 → 视觉上停在原位置
- queueMicrotask(() => {
- el.scrollTop = el.scrollHeight - beforeHeight;
- });
- }
- }
|