chat.ts 33 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013
  1. import {
  2. ChangeDetectionStrategy,
  3. Component,
  4. computed,
  5. effect,
  6. ElementRef,
  7. inject,
  8. signal,
  9. viewChild,
  10. } from '@angular/core';
  11. import { ActivatedRoute, Router } from '@angular/router';
  12. import {
  13. UiAlert,
  14. UiAvatar,
  15. UiButton,
  16. UiEmpty,
  17. UiInput,
  18. UiModal,
  19. UiSkeleton,
  20. UiTooltip,
  21. } from '../../../ui';
  22. import { ComposerComponent } from '../../../components/domain/composer/composer';
  23. import { MessageBubbleComponent } from '../../../components/domain/message-bubble/message-bubble';
  24. import { TypingIndicatorComponent } from '../../../components/domain/typing-indicator/typing-indicator';
  25. import { FilePreviewComponent } from '../../../components/preview/file-preview/file-preview';
  26. import { PresenceBadgeComponent } from '../../../components/domain/presence-badge/presence-badge';
  27. import { AgentPresence, agentPresence } from '../../../core/api/models';
  28. import { StatusBadgeComponent } from '../../../components/domain/status-badge/status-badge';
  29. import { ChatService } from '../../../core/chat.service';
  30. import { Deliverable, TASK_STATUS_TEXT } from '../../../core/api/models';
  31. import { DeliverablesService } from '../../../core/deliverables.service';
  32. import { DeviceService } from '../../../core/device.service';
  33. import { fmtPercent, fmtTokens, usageOf } from '../../../core/format';
  34. import { LiveService } from '../../../core/live.service';
  35. import { UnreadService } from '../../../core/unread.service';
  36. import { TasksService } from '../../../core/tasks.service';
  37. /**
  38. * 桌面·会话(SPEC FR-04)—— 四栏(导航 + 会话列表 + 聊天区 + 成果栏)
  39. *
  40. * 实现纪律(2026-10-15 返工):
  41. * · 列表/空态/加载/提示条全部走底座组件(`ui-list` / `ui-empty` / `ui-skeleton` / `ui-alert`)
  42. * · **流动布局**:栏宽用比例 + 最小宽兜底,窗口变宽时主区跟着变宽;
  43. * 仅当可用宽不足以容纳右栏时才降级(换形态,不删功能)
  44. * · 无 demo 件
  45. *
  46. * AC:04.1 四栏 / 04.2 发送 / 04.3 上传进度 / 04.5 自增高 / 04.6 状态不重复
  47. * / 04.8 失败可重试 / 04.9 连接状态 / 04.10 超长截断 / 04.11 三态
  48. */
  49. @Component({
  50. selector: 'app-dk-chat-page',
  51. changeDetection: ChangeDetectionStrategy.OnPush,
  52. imports: [
  53. UiAlert,
  54. UiAvatar,
  55. UiButton,
  56. UiEmpty,
  57. UiInput,
  58. UiModal,
  59. UiSkeleton,
  60. UiTooltip,
  61. ComposerComponent,
  62. MessageBubbleComponent,
  63. TypingIndicatorComponent,
  64. FilePreviewComponent,
  65. PresenceBadgeComponent,
  66. StatusBadgeComponent,
  67. ],
  68. template: `
  69. <div class="conversation-layout">
  70. <!-- ===== 会话列表 ===== -->
  71. <aside class="col list-col" aria-label="会话列表">
  72. <header class="col-head">
  73. <h2>会话</h2>
  74. <span class="count">{{ conversations().length }}</span>
  75. </header>
  76. <div class="search-hold">
  77. <ui-input
  78. prefixIcon="fa-solid fa-magnifying-glass"
  79. [clearable]="true"
  80. placeholder="搜索会话"
  81. [value]="keyword()"
  82. (valueChange)="keyword.set($any($event))"
  83. />
  84. </div>
  85. <div class="list-scroll">
  86. @switch (live.state()) {
  87. @case ('loading') {
  88. <div class="skeleton-hold">
  89. @for (i of [1, 2, 3, 4]; track i) {
  90. <ui-skeleton variant="rect" height="60px" />
  91. }
  92. </div>
  93. }
  94. @case ('error') {
  95. <ui-empty
  96. icon="fa-solid fa-triangle-exclamation"
  97. title="会话列表加载失败"
  98. [description]="live.error() ?? ''"
  99. />
  100. <div class="center">
  101. <ui-button variant="outline" size="sm" (click)="reload()">重新加载</ui-button>
  102. </div>
  103. }
  104. @default {
  105. @if (filteredConversations().length === 0) {
  106. <ui-empty title="没有匹配的会话" />
  107. } @else {
  108. @for (c of filteredConversations(); track c.id) {
  109. <button
  110. type="button"
  111. class="conv-row"
  112. [class.active]="chat.agentId() === c.id"
  113. (click)="openConversation(c.id)"
  114. >
  115. <ui-avatar [name]="c.name" />
  116. <span class="conv-info">
  117. <span class="conv-line">
  118. <strong class="ellipsis">{{ c.name }}</strong>
  119. <!--
  120. 可对话状态:**配色 + 文字**胶囊(用户要求,比圆点清晰)。
  121. 判定唯一实现在 models.agentPresence()(以接入态为准)。
  122. -->
  123. <app-presence-badge [agent]="live.agentById(c.id)" />
  124. @if (unreadOf(c.id) > 0) {
  125. <span class="unread">{{ unreadOf(c.id) }}</span>
  126. }
  127. </span>
  128. <span class="conv-preview ellipsis">{{ c.currentTask || c.specialty }}</span>
  129. </span>
  130. </button>
  131. }
  132. }
  133. }
  134. }
  135. </div>
  136. </aside>
  137. <!-- ===== 聊天区 ===== -->
  138. <section class="col main-col" aria-label="消息">
  139. @if (!chat.agentId()) {
  140. <div class="center-fill">
  141. <ui-empty title="选择一个会话" description="从左侧选择数字生命开始对话。" />
  142. </div>
  143. } @else {
  144. <header class="chat-head">
  145. @if (chat.agent(); as a) {
  146. <ui-avatar [name]="a.name" [size]="'md'" />
  147. <div class="chat-title">
  148. <strong>{{ a.name }}</strong>
  149. <small class="ellipsis">{{ a.specialty || '数字生命' }}</small>
  150. </div>
  151. <!-- AC-04.6:状态**只在此处出现一次**,不在右栏重复 -->
  152. <app-status-badge [agent]="a" />
  153. }
  154. <div class="chat-actions">
  155. <!-- 任务:点开右栏并切到任务面板;再点隐藏 -->
  156. <ui-tooltip content="任务">
  157. <ui-button
  158. variant="ghost"
  159. shape="circle"
  160. icon="fa-solid fa-list-check"
  161. ariaLabel="任务"
  162. [class.on]="railOpen() && railTab() === 'tasks'"
  163. (click)="toggleRail('tasks')"
  164. />
  165. </ui-tooltip>
  166. <!-- 成果:点开右栏并切到成果面板;再点隐藏 -->
  167. <ui-tooltip content="成果">
  168. <ui-button
  169. variant="ghost"
  170. shape="circle"
  171. icon="fa-solid fa-box"
  172. ariaLabel="成果"
  173. [class.on]="railOpen() && railTab() === 'results'"
  174. (click)="toggleRail('results')"
  175. />
  176. </ui-tooltip>
  177. </div>
  178. </header>
  179. <!--
  180. 连接状态提示(AC-04.9)。
  181. 🔴 区分两种「连不上」:容器离线 vs 自己在连接中 ——
  182. 否则用户分不清是网络问题还是对方容器没跑(实测反馈)。
  183. -->
  184. @if (chat.connection() !== 'connected' || currentContainerState() === 'unreported') {
  185. <div class="banner-hold">
  186. @if (currentContainerState() === 'unreported' && chat.connection() === 'connected') {
  187. <ui-alert type="warning" title="容器状态未知,可能无法应答">
  188. 已连接矩阵服务,但该容器的接入通道未上报在线状态,消息可能无人应答。
  189. </ui-alert>
  190. } @else {
  191. <ui-alert
  192. [type]="chat.connection() === 'connecting' ? 'info' : 'warning'"
  193. [title]="chat.connection() === 'connecting' ? '正在连接…' : '连接已断开'"
  194. >
  195. @if (chat.connection() === 'disconnected') {
  196. <ui-button variant="outline" size="sm" (click)="reconnect()">重新连接</ui-button>
  197. }
  198. </ui-alert>
  199. }
  200. </div>
  201. }
  202. <div class="messages" #messages (scroll)="onMessagesScroll($event)">
  203. @switch (chat.state()) {
  204. @case ('loading') {
  205. <div class="skeleton-hold">
  206. @for (i of [1, 2, 3]; track i) {
  207. <ui-skeleton variant="rect" height="64px" />
  208. }
  209. </div>
  210. }
  211. @case ('error') {
  212. <ui-empty
  213. icon="fa-solid fa-triangle-exclamation"
  214. title="消息加载失败"
  215. [description]="chat.error() ?? ''"
  216. />
  217. <div class="center">
  218. <ui-button variant="outline" size="sm" (click)="retryOpen()">重新加载</ui-button>
  219. </div>
  220. }
  221. @case ('empty') {
  222. <ui-empty title="还没有消息" description="发送第一条消息开始协作。" />
  223. }
  224. @default {
  225. <!-- AC-04.4:向上翻历史(滚动到顶部自动加载) -->
  226. <div class="history-hold">
  227. @if (chat.paginating()) {
  228. <ui-skeleton variant="rect" height="40px" />
  229. } @else if (chat.hasMoreHistory()) {
  230. <ui-button variant="ghost" size="sm" (click)="loadEarlier()">
  231. 加载更早的消息
  232. </ui-button>
  233. } @else {
  234. <span class="history-end">已到最早</span>
  235. }
  236. </div>
  237. @for (m of chat.messages(); track m.id) {
  238. <app-message-bubble
  239. [message]="m"
  240. [own]="m.sender === 'me'"
  241. (retry)="chat.retry($event)"
  242. (openTask)="openTaskRail()"
  243. (preview)="openFile($event)"
  244. />
  245. }
  246. <!-- 对方正在输入(FR-04;容器侧已实测在发 m.typing) -->
  247. @if (chat.peerTyping()) {
  248. <app-typing-indicator [name]="chat.agent()?.name ?? '对方'" />
  249. }
  250. }
  251. }
  252. </div>
  253. <app-composer
  254. [draft]="chat.draft()"
  255. [sending]="chat.sending()"
  256. [uploading]="chat.uploading()"
  257. [pct]="chat.uploadPct()"
  258. [disabled]="chat.connection() !== 'connected'"
  259. (draftChange)="chat.setDraft($event)"
  260. (send)="chat.sendText($event)"
  261. (attach)="chat.sendFiles($event)"
  262. />
  263. }
  264. </section>
  265. <!-- ===== 右栏(宽不足时折叠为浮层,不删功能)===== -->
  266. @if (chat.agentId() && railOpen()) {
  267. <aside class="col rail-col" aria-label="侧栏面板">
  268. <header class="col-head">
  269. <h2>{{ railTab() === 'tasks' ? '任务' : '成果' }}</h2>
  270. <ui-button
  271. variant="ghost"
  272. shape="circle"
  273. icon="fa-solid fa-xmark"
  274. ariaLabel="收起面板"
  275. (click)="railOpen.set(false)"
  276. />
  277. </header>
  278. <div class="rail-body">
  279. @if (chat.agent(); as a) {
  280. <section class="rail-block">
  281. <h3>运行状态</h3>
  282. <dl class="kv">
  283. <div><dt>当前任务</dt><dd>{{ a.currentTask || '—' }}</dd></div>
  284. <div><dt>CPU</dt><dd>{{ pct(a.host?.cpuPercent) }}</dd></div>
  285. <div><dt>内存</dt><dd>{{ pct(a.host?.memPercent) }}</dd></div>
  286. <div><dt>Token 用量</dt><dd>{{ tokens(a) }}</dd></div>
  287. </dl>
  288. </section>
  289. @if (railTab() === 'tasks') {
  290. <section class="rail-block">
  291. <h3>相关任务 <span class="count">{{ agentTasks().length }}</span></h3>
  292. @if (agentTasks().length === 0) {
  293. <p class="rail-empty">该数字生命暂无任务。</p>
  294. } @else {
  295. @for (t of agentTasks(); track t.id) {
  296. <button type="button" class="rail-item task" (click)="goTask(t.id)">
  297. <span class="file-icon task-ico" aria-hidden="true">
  298. <i class="fa-solid fa-list-check"></i>
  299. </span>
  300. <span class="rail-meta">
  301. <strong class="ellipsis">{{ t.title }}</strong>
  302. <small class="ellipsis">
  303. {{ t.id }} · {{ statusText(t.status) }}
  304. </small>
  305. </span>
  306. <i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
  307. </button>
  308. }
  309. }
  310. </section>
  311. }
  312. @if (railTab() === 'results') {
  313. <section class="rail-block">
  314. <h3>相关成果 <span class="count">{{ agentDeliverables().length }}</span></h3>
  315. @if (agentDeliverables().length === 0) {
  316. <p class="rail-empty">该数字生命暂无交付成果。</p>
  317. } @else {
  318. @for (d of agentDeliverables(); track d.id) {
  319. <div class="rail-item">
  320. <span class="file-icon" aria-hidden="true">
  321. <i class="fa-solid" [class]="fileIcon(d.fileType)"></i>
  322. </span>
  323. <span class="rail-meta">
  324. <strong class="ellipsis">{{ d.title }}</strong>
  325. <small class="ellipsis">{{ d.filename }} · {{ d.size }}</small>
  326. </span>
  327. <span class="rail-actions">
  328. <ui-button
  329. variant="ghost"
  330. shape="circle"
  331. size="sm"
  332. icon="fa-solid fa-eye"
  333. ariaLabel="预览"
  334. (click)="preview(d)"
  335. />
  336. @if (d.artifacts[0]; as art) {
  337. <a
  338. class="dl-btn"
  339. [href]="art.url"
  340. [attr.download]="art.name"
  341. aria-label="下载"
  342. >
  343. <i class="fa-solid fa-download" aria-hidden="true"></i>
  344. </a>
  345. }
  346. </span>
  347. </div>
  348. }
  349. }
  350. </section>
  351. }
  352. }
  353. </div>
  354. </aside>
  355. }
  356. </div>
  357. <!-- 右栏成果预览:底座 ui-modal -->
  358. <ui-modal
  359. [open]="!!previewing()"
  360. [title]="previewing()?.title ?? ''"
  361. size="lg"
  362. (openChange)="previewing.set(null)"
  363. >
  364. @if (previewing(); as d) {
  365. <p class="preview-meta">{{ d.filename }} · {{ d.size }} · {{ d.version }}</p>
  366. <div class="preview-body">
  367. @if (d.fileType === 'CSV') {
  368. <div class="csv-wrap">
  369. <table class="csv">
  370. <tbody>
  371. @for (row of csvRows(d); track $index) {
  372. <tr>@for (cell of row; track $index) { <td>{{ cell }}</td> }</tr>
  373. }
  374. </tbody>
  375. </table>
  376. </div>
  377. } @else {
  378. <pre class="doc">{{ d.content || d.summary }}</pre>
  379. }
  380. </div>
  381. <div class="preview-actions">
  382. <!--
  383. 一个成果可能有多个附件(如 EN 版 / ZH 版),
  384. 按钮**必须带文件名**,否则用户不知道下的是哪一个(实测反馈:两个一样的「下载」)。
  385. -->
  386. @for (art of d.artifacts; track art.name) {
  387. <a class="download" [href]="art.url" [attr.download]="art.name" [title]="art.name">
  388. <i class="fa-solid fa-download" aria-hidden="true"></i>
  389. <span class="dl-name">{{ art.name }}</span>
  390. </a>
  391. }
  392. </div>
  393. }
  394. </ui-modal>
  395. <!-- 消息附件预览(按类型分发:图片/视频/pdf/md/文本/office) -->
  396. <app-file-preview
  397. [open]="!!previewFile()"
  398. [url]="previewFile()?.url ?? ''"
  399. [name]="previewFile()?.name ?? ''"
  400. [mimeType]="previewFile()?.mimeType ?? ''"
  401. (closed)="previewFile.set(null)"
  402. />
  403. `,
  404. styles: [
  405. `
  406. @use '../../../core/styles/breakpoints' as bp;
  407. :host {
  408. display: block;
  409. height: 100%;
  410. }
  411. /*
  412. 四栏:列表固定可读宽(min~max),主区吃掉全部剩余,右栏按比例但设上下限。
  413. —— 窗口变宽时主区跟着变宽(不是内容整体缩放),这是"流动布局"的要点。
  414. */
  415. /*
  416. 列表栏用**固定可读宽**(280px)而非比例:列表项是纵向排列,
  417. 宽度随窗口缩放会把名字/预览压到不可读。右栏才用比例。
  418. */
  419. /*
  420. 列数随右栏显隐**动态变化** —— 否则右栏隐藏后那一列仍占位,聊天区不填满。
  421. 用 :has() 检测 .rail-col 是否存在(存在才保留第三列)。
  422. */
  423. .conversation-layout {
  424. display: grid;
  425. grid-template-columns: var(--desk-list-w) minmax(0, 1fr);
  426. height: 100%;
  427. min-height: 0;
  428. }
  429. .conversation-layout:has(> .rail-col) {
  430. grid-template-columns: var(--desk-list-w) minmax(0, 1fr) minmax(280px, 24%);
  431. }
  432. /* 可用宽不足以同时容纳列表+主区+右栏时,右栏让位(由按钮切换显示) */
  433. @include bp.dk-below-4col {
  434. .conversation-layout,
  435. .conversation-layout:has(> .rail-col) {
  436. grid-template-columns: var(--desk-list-w-compact) minmax(0, 1fr);
  437. }
  438. .rail-col {
  439. display: none;
  440. }
  441. }
  442. .col {
  443. min-width: 0;
  444. min-height: 0;
  445. display: flex;
  446. flex-direction: column;
  447. background: var(--surface);
  448. }
  449. .list-col {
  450. background: var(--surface-2);
  451. border-right: 1px solid var(--edge);
  452. overflow: hidden;
  453. }
  454. .main-col {
  455. background: var(--surface);
  456. border-right: 1px solid var(--edge);
  457. }
  458. .rail-col {
  459. background: var(--rail-surface);
  460. }
  461. .col-head {
  462. display: flex;
  463. align-items: center;
  464. justify-content: space-between;
  465. gap: var(--sp-2);
  466. padding: var(--sp-4);
  467. border-bottom: 1px solid var(--edge);
  468. }
  469. .col-head h2 {
  470. margin: 0;
  471. font-size: var(--fs-15);
  472. color: var(--ink);
  473. }
  474. .count {
  475. font-size: var(--fs-11);
  476. color: var(--muted);
  477. }
  478. .search-hold {
  479. padding: var(--sp-3) var(--sp-4);
  480. }
  481. .list-scroll {
  482. flex: 1;
  483. min-height: 0;
  484. overflow-y: auto;
  485. padding: 0 var(--sp-2) var(--sp-3);
  486. }
  487. .skeleton-hold {
  488. display: grid;
  489. gap: var(--sp-2);
  490. padding: var(--sp-2);
  491. }
  492. .center {
  493. display: flex;
  494. justify-content: center;
  495. padding: var(--sp-3);
  496. }
  497. .center-fill {
  498. flex: 1;
  499. display: grid;
  500. place-content: center;
  501. }
  502. .conv-row {
  503. display: flex;
  504. align-items: center;
  505. gap: var(--sp-3);
  506. width: 100%;
  507. min-height: 60px;
  508. padding: var(--sp-2) var(--sp-3);
  509. border: 0;
  510. border-radius: var(--r-md);
  511. background: transparent;
  512. text-align: left;
  513. cursor: pointer;
  514. }
  515. .conv-row:hover {
  516. background: var(--surface-hover);
  517. }
  518. /* 选中态:仅用底色,不加竖条(与导航选中态统一) */
  519. .conv-row.active {
  520. background: var(--surface);
  521. }
  522. .conv-info {
  523. display: grid;
  524. gap: 2px;
  525. flex: 1;
  526. min-width: 0;
  527. }
  528. .conv-line {
  529. display: flex;
  530. align-items: center;
  531. gap: var(--sp-2);
  532. }
  533. .conv-line strong {
  534. font-size: var(--fs-15);
  535. color: var(--ink);
  536. }
  537. /* 预览操作区:多个附件时按钮带文件名,过长截断 */
  538. .preview-actions {
  539. display: flex;
  540. flex-wrap: wrap;
  541. gap: var(--sp-2);
  542. margin-top: var(--sp-3);
  543. }
  544. .download {
  545. display: inline-flex;
  546. align-items: center;
  547. gap: var(--sp-1);
  548. max-width: 240px;
  549. min-height: var(--touch-min);
  550. padding: 0 var(--sp-3);
  551. border-radius: var(--r-sm);
  552. background: var(--primary);
  553. color: var(--primary-content);
  554. font-size: var(--fs-13);
  555. text-decoration: none;
  556. }
  557. .dl-name {
  558. overflow: hidden;
  559. text-overflow: ellipsis;
  560. white-space: nowrap;
  561. }
  562. .conv-preview {
  563. font-size: var(--fs-12);
  564. color: var(--muted);
  565. }
  566. .unread {
  567. min-width: 18px;
  568. padding: 0 5px;
  569. border-radius: var(--r-tag);
  570. background: var(--danger);
  571. color: var(--on-danger);
  572. font-size: var(--fs-11);
  573. line-height: 18px;
  574. text-align: center;
  575. flex-shrink: 0;
  576. }
  577. .chat-head {
  578. display: flex;
  579. align-items: center;
  580. gap: var(--sp-3);
  581. padding: var(--sp-3) var(--sp-4);
  582. border-bottom: 1px solid var(--edge);
  583. }
  584. .chat-title {
  585. display: grid;
  586. gap: 2px;
  587. flex: 1;
  588. min-width: 0;
  589. }
  590. .chat-title strong {
  591. font-size: var(--fs-15);
  592. color: var(--ink);
  593. }
  594. .chat-title small {
  595. font-size: var(--fs-12);
  596. color: var(--muted);
  597. }
  598. .chat-actions {
  599. display: flex;
  600. gap: var(--sp-1);
  601. }
  602. /* 右栏切换按钮的激活态 */
  603. .chat-actions ui-button.on button {
  604. background: var(--primary-subtle);
  605. color: var(--primary);
  606. }
  607. .rail-item.task .task-ico {
  608. background: var(--info-subtle);
  609. color: var(--info);
  610. }
  611. .rail-item > i {
  612. color: var(--muted);
  613. flex-shrink: 0;
  614. }
  615. .banner-hold {
  616. padding: var(--sp-3) var(--sp-4) 0;
  617. }
  618. .messages {
  619. flex: 1;
  620. min-height: 0;
  621. overflow-y: auto;
  622. padding: var(--sp-4);
  623. display: grid;
  624. gap: var(--sp-3);
  625. /* 消息**自上而下排列**(用户反馈:不要从下往上) */
  626. align-content: start;
  627. }
  628. /* AC-04.4:历史加载区(顶部) */
  629. .history-hold {
  630. display: flex;
  631. justify-content: center;
  632. padding: var(--sp-1) 0;
  633. }
  634. .history-end {
  635. font-size: var(--fs-12);
  636. color: var(--muted);
  637. }
  638. .rail-body {
  639. flex: 1;
  640. min-height: 0;
  641. overflow-y: auto;
  642. padding: var(--sp-4);
  643. display: grid;
  644. gap: var(--sp-5);
  645. align-content: start;
  646. }
  647. .rail-block h3 {
  648. display: flex;
  649. align-items: center;
  650. gap: var(--sp-2);
  651. margin: 0 0 var(--sp-3);
  652. font-size: var(--fs-13);
  653. color: var(--ink-2);
  654. }
  655. .kv {
  656. margin: 0;
  657. display: grid;
  658. gap: var(--sp-2);
  659. }
  660. .kv > div {
  661. display: flex;
  662. justify-content: space-between;
  663. gap: var(--sp-3);
  664. font-size: var(--fs-12);
  665. }
  666. .kv dt {
  667. color: var(--muted);
  668. flex-shrink: 0;
  669. }
  670. .kv dd {
  671. margin: 0;
  672. color: var(--ink);
  673. text-align: right;
  674. min-width: 0;
  675. overflow-wrap: anywhere;
  676. }
  677. .rail-empty {
  678. margin: 0;
  679. font-size: var(--fs-12);
  680. color: var(--muted);
  681. }
  682. .rail-item {
  683. display: flex;
  684. align-items: center;
  685. gap: var(--sp-3);
  686. width: 100%;
  687. min-height: var(--touch-min);
  688. padding: var(--sp-2);
  689. margin-bottom: var(--sp-2);
  690. border: 1px solid var(--edge);
  691. border-radius: var(--r-md);
  692. background: var(--surface);
  693. text-align: left;
  694. cursor: pointer;
  695. }
  696. .rail-item:hover {
  697. background: var(--surface-hover);
  698. }
  699. .rail-actions {
  700. display: flex;
  701. align-items: center;
  702. gap: 2px;
  703. flex-shrink: 0;
  704. }
  705. .dl-btn {
  706. display: grid;
  707. place-items: center;
  708. width: 32px;
  709. height: 32px;
  710. border-radius: var(--r-sm);
  711. color: var(--primary);
  712. text-decoration: none;
  713. }
  714. .dl-btn:hover {
  715. background: var(--surface-active);
  716. }
  717. .file-icon {
  718. display: grid;
  719. place-items: center;
  720. width: 32px;
  721. height: 32px;
  722. flex-shrink: 0;
  723. border-radius: var(--r-sm);
  724. background: var(--info-subtle);
  725. color: var(--info);
  726. }
  727. .rail-meta {
  728. display: grid;
  729. gap: 2px;
  730. min-width: 0;
  731. flex: 1;
  732. }
  733. .rail-meta strong {
  734. font-size: var(--fs-13);
  735. color: var(--ink);
  736. }
  737. .rail-meta small {
  738. font-size: var(--fs-11);
  739. color: var(--muted);
  740. }
  741. .ellipsis {
  742. overflow: hidden;
  743. text-overflow: ellipsis;
  744. white-space: nowrap;
  745. }
  746. `,
  747. ],
  748. })
  749. export class DkChatPage {
  750. readonly chat = inject(ChatService);
  751. readonly live = inject(LiveService);
  752. private readonly unread = inject(UnreadService);
  753. private readonly deliverables = inject(DeliverablesService);
  754. private readonly tasks = inject(TasksService);
  755. private readonly route = inject(ActivatedRoute);
  756. private readonly router = inject(Router);
  757. private readonly device = inject(DeviceService);
  758. readonly keyword = signal('');
  759. readonly railOpen = signal(true);
  760. /** 右栏内联预览的成果 */
  761. readonly previewing = signal<Deliverable | null>(null);
  762. /** 右栏当前面板:任务 / 成果 */
  763. readonly railTab = signal<'tasks' | 'results'>('results');
  764. /** 当前数字生命的任务(右栏任务面板用) */
  765. readonly agentTasks = computed(() => {
  766. const id = this.chat.agentId();
  767. return id ? this.tasks.all().filter((t) => t.agentId === id) : [];
  768. });
  769. readonly pct = (v?: number) => fmtPercent(v);
  770. readonly tokens = (a: { usage?: { totalTokens?: number } }) => fmtTokens(usageOf(a as never).totalTokens);
  771. private readonly messagesEl = viewChild<ElementRef<HTMLElement>>('messages');
  772. /** 会话列表:仅已接入的数字生命 */
  773. readonly conversations = computed(() =>
  774. this.live
  775. .agents()
  776. .filter((a) => a.matrixReady)
  777. .map((a) => ({
  778. id: a.id,
  779. name: a.name,
  780. specialty: a.specialty,
  781. currentTask: a.currentTask,
  782. unread: a.unread,
  783. }))
  784. /*
  785. * 🔴 **固定顺序**(用户实测反馈:选中项位置会乱跳)。
  786. * 曾按未读数排序 → 未读一变整列重排,用户正在看的会话会跳位。
  787. * 改为按 name 稳定排序(同 name 再按 id),顺序**不随运行期数据变化**。
  788. */
  789. .sort((x, y) => x.name.localeCompare(y.name, 'zh') || x.id.localeCompare(y.id)),
  790. );
  791. /** 待预览的消息附件(null = 关闭) */
  792. readonly previewFile = signal<{ url: string; name: string; mimeType: string } | null>(null);
  793. /**
  794. * 上一次「最后一条消息」的 id。
  795. *
  796. * 用于区分「尾部新增」(要滚底)与「头部插入」(分页,保持锚点)。
  797. */
  798. private lastTailId = '';
  799. /** 打开消息附件预览(按类型分发由 app-file-preview 负责) */
  800. openFile(f: { url: string; name: string; mimeType: string }): void {
  801. this.previewFile.set(f);
  802. }
  803. /** 真实未读数(UnreadService 唯一来源;不用 Agent.unread 的占位值) */
  804. unreadOf(agentId: string): number {
  805. return this.unread.countOf(agentId);
  806. }
  807. /** 可对话状态(判定唯一实现在 models.agentPresence) */
  808. containerState(agentId: string): AgentPresence {
  809. return agentPresence(this.live.agentById(agentId));
  810. }
  811. /** 当前打开会话的容器状态(用于区分「容器离线」与「自己在连接中」) */
  812. readonly currentContainerState = computed<AgentPresence>(() => {
  813. const id = this.chat.agentId();
  814. return id ? agentPresence(this.live.agentById(id)) : 'offline';
  815. });
  816. readonly filteredConversations = computed(() => {
  817. const q = this.keyword().trim().toLowerCase();
  818. if (!q) return this.conversations();
  819. return this.conversations().filter(
  820. (c) => c.name.toLowerCase().includes(q) || (c.specialty ?? '').toLowerCase().includes(q),
  821. );
  822. });
  823. readonly agentDeliverables = computed(() => {
  824. const id = this.chat.agentId();
  825. return id ? this.deliverables.byAgent(id) : [];
  826. });
  827. constructor() {
  828. /*
  829. * AC-04.2:新消息到达后滚动到底。
  830. *
  831. * 🔴 **只在「尾部新增了消息」时滚底**(用户实测反馈:上翻历史时也被拉到底)。
  832. * 上翻历史走的是 `paginate()` → `messages` 变长但**尾部未变** →
  833. * 此处按「最后一条消息 id 是否变化」判定,分页时保持锚点不动。
  834. */
  835. effect(() => {
  836. const list = this.chat.messages();
  837. const tailId = list.length ? list[list.length - 1].id : '';
  838. const prev = this.lastTailId;
  839. this.lastTailId = tailId;
  840. // 首次(prev 为空)或尾部确实变了 → 滚底
  841. if (prev === '' || tailId !== prev) {
  842. queueMicrotask(() => this.scrollToBottom());
  843. }
  844. });
  845. // 窄窗默认收起右栏(<1150)
  846. if (typeof window !== 'undefined' && window.innerWidth < 1150) {
  847. this.railOpen.set(false);
  848. }
  849. // 🔴 监听**路由参数流**而非 snapshot —— 同一组件在 chat/:id 间复用时
  850. // 构造函数不会重跑,只读一次 snapshot 会导致「切换会话无反应」。
  851. this.route.paramMap.subscribe((params) => {
  852. void this.onRouteChange(params.get('id'));
  853. });
  854. }
  855. private async onRouteChange(id: string | null): Promise<void> {
  856. await Promise.all([
  857. this.live.ensureLoaded(),
  858. this.deliverables.ensureLoaded(),
  859. // 右栏任务面板依赖全量任务(按 agentId 过滤)
  860. this.tasks.ensureAllLoaded(),
  861. ]);
  862. if (id) {
  863. await this.chat.open(id);
  864. } else {
  865. const first = this.conversations()[0];
  866. if (first) void this.openConversation(first.id, true);
  867. }
  868. }
  869. openConversation(id: string, replace = false): void {
  870. void this.router.navigateByUrl(`/${this.device.mode()}/chat/${id}`, { replaceUrl: replace });
  871. }
  872. async reload(): Promise<void> {
  873. await this.live.refresh();
  874. }
  875. reconnect(): void {
  876. const id = this.chat.agentId();
  877. if (id) void this.chat.open(id);
  878. }
  879. retryOpen(): void {
  880. const id = this.chat.agentId();
  881. if (id) void this.chat.open(id);
  882. }
  883. /** 切换右栏面板:同面板再点则隐藏 */
  884. toggleRail(tab: 'tasks' | 'results'): void {
  885. if (this.railOpen() && this.railTab() === tab) {
  886. this.railOpen.set(false);
  887. return;
  888. }
  889. this.railTab.set(tab);
  890. this.railOpen.set(true);
  891. }
  892. /** 任务状态中文(右栏用) */
  893. statusText(status: string): string {
  894. return TASK_STATUS_TEXT[status as keyof typeof TASK_STATUS_TEXT] ?? status;
  895. }
  896. /** 消息里的「查看任务」→ 打开右栏任务面板 */
  897. openTaskRail(): void {
  898. this.railTab.set('tasks');
  899. this.railOpen.set(true);
  900. }
  901. goTasks(): void {
  902. const id = this.chat.agentId();
  903. if (id) void this.router.navigate(['/', this.device.mode(), 'tasks', id]);
  904. }
  905. /** 右栏内联预览 */
  906. preview(d: Deliverable): void {
  907. this.previewing.set(d);
  908. }
  909. fileIcon(type?: string): string {
  910. return type === 'CSV' ? 'fa-file-csv' : 'fa-file-lines';
  911. }
  912. /** CSV 内容转表格行 */
  913. csvRows(d: Deliverable): string[][] {
  914. if (!d.content) return [];
  915. return d.content
  916. .split(/\r?\n/)
  917. .filter((l) => l.trim().length > 0)
  918. .map((l) => l.split(','));
  919. }
  920. goArtifact(id: string): void {
  921. void this.router.navigate(['/', this.device.mode(), 'deliverables'], {
  922. queryParams: { focus: id },
  923. });
  924. }
  925. goTask(id: string): void {
  926. void this.router.navigate(['/', this.device.mode(), 'tasks', id]);
  927. }
  928. private scrollToBottom(): void {
  929. const el = this.messagesEl()?.nativeElement;
  930. if (el) el.scrollTop = el.scrollHeight;
  931. }
  932. /**
  933. * AC-04.4:滚动到顶部附近(<80px)自动加载更早历史。
  934. *
  935. * 加载前记录 `scrollHeight`,加载后按差值补偿 `scrollTop`,
  936. * 使视口**锚定在原来的消息上**(不跳变)。
  937. */
  938. onMessagesScroll(_e: Event): void {
  939. const el = this.messagesEl()?.nativeElement;
  940. if (!el || el.scrollTop > 80) return;
  941. if (this.chat.paginating() || !this.chat.hasMoreHistory()) return;
  942. void this.loadEarlier();
  943. }
  944. /** 加载更早历史,并保持滚动锚点 */
  945. async loadEarlier(): Promise<void> {
  946. const el = this.messagesEl()?.nativeElement;
  947. const beforeHeight = el?.scrollHeight ?? 0;
  948. const grew = await this.chat.paginate();
  949. if (!grew || !el) return;
  950. // 内容变高后,把视口下移同样的增量 → 视觉上停在原位置
  951. queueMicrotask(() => {
  952. el.scrollTop = el.scrollHeight - beforeHeight;
  953. });
  954. }
  955. }