chat.ts 59 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604
  1. import {
  2. ChangeDetectionStrategy,
  3. Component,
  4. ElementRef,
  5. OnDestroy,
  6. computed,
  7. effect,
  8. inject,
  9. signal,
  10. untracked,
  11. viewChild,
  12. } from '@angular/core';
  13. import { ActivatedRoute, Router } from '@angular/router';
  14. import { MAppBarComponent } from '../../components/m-app-bar/m-app-bar';
  15. import { MDockComponent } from '../../components/m-dock/m-dock';
  16. import { MDrawerComponent } from '../../components/m-drawer/m-drawer';
  17. import { MEmptyComponent } from '../../components/m-empty/m-empty';
  18. import { MMsgComponent } from '../../components/m-msg/m-msg';
  19. import { FilePreviewComponent } from '../../../components/preview/file-preview/file-preview';
  20. import { ImageLightboxComponent } from '../../../components/preview/image-lightbox/image-lightbox';
  21. import { previewKindOf } from '../../../components/preview/file-kind';
  22. import { LockScrollDirective } from '../../directives/lock-scroll.directive';
  23. import { BadgeTone, MBadgeComponent, TASK_TONE, TASK_STATUS_TEXT } from '../../components/m-badge/m-badge';
  24. import { ChatService } from '../../../core/chat.service';
  25. import { PENDING_DRAFT_KEY } from '../home/home';
  26. import { LoopGuardService } from '../../../core/loop-guard.service';
  27. import { Deliverable, Task } from '../../../core/api/models';
  28. import { ChatSessionService } from '../../../core/chat-session.service';
  29. import { TasksService } from '../../../core/tasks.service';
  30. import { fmtSize } from '../../../core/format';
  31. import { MobileChatViewService } from '../../../core/mobile-chat-view.service';
  32. import { DeliverablesService } from '../../../core/deliverables.service';
  33. import { buildTurns, QUIET_MS } from '../../../core/turn-aggregator';
  34. import {
  35. Block,
  36. isToolActivityMessage,
  37. toolActivitySummary,
  38. } from '../../../core/message-segments';
  39. import { Message } from '../../../core/api/models';
  40. import { sessionPresence } from '../../../core/agent-liveness';
  41. import { fileIdentity } from '../../../core/s3-link';
  42. import { mergeConsecutiveBodies } from '../../../core/message-merge';
  43. import { ToolActivityComponent } from '../../../components/domain/tool-activity/tool-activity';
  44. import {
  45. AssistantTurn,
  46. isTurnStalled,
  47. isTurnTimedOut,
  48. STALL_MS,
  49. TURN_TIMEOUT_MS,
  50. turnsFromDm,
  51. } from '../../../core/assistant-turn';
  52. import { AlertService } from '../../../core/alert.service';
  53. import { AssistantTurnComponent } from '../../../components/domain/assistant-turn/assistant-turn';
  54. /**
  55. * 会话消息行:普通消息 or 已合并的终端活动组。
  56. * 与桌面端 `desktop/pages/chat/chat.ts` 的 `MessageRow` 同构。
  57. */
  58. /** 触顶阈值:滚动到距顶部 80px 内即自动加载更早消息(与 PC 一致) */
  59. const TOP_LOAD_PX = 80;
  60. type MessageRow =
  61. | { kind: 'msg'; key: string; message: Message }
  62. | { kind: 'tool'; key: string; block: Extract<Block, { kind: 'process' }> }
  63. /** 回合:一次提问的全部 agent 输出合成一条气泡(2026-11-05) */
  64. | {
  65. kind: 'turn';
  66. key: string;
  67. blocks: TurnBlock[];
  68. open: boolean;
  69. collapsed: boolean;
  70. seconds: number;
  71. };
  72. /** 卡片渲染行(2026-11-07):用户消息一行,AI 一整轮一张卡 */
  73. type AiRow =
  74. | { kind: 'msg'; key: string; message: Message }
  75. | { kind: 'turn'; key: string; turn: AssistantTurn };
  76. /** 回合内部的一块:正文一条,或一组工具执行过程 */
  77. type TurnBlock =
  78. | { kind: 'msg'; key: string; message: Message }
  79. | { kind: 'tool'; key: string; block: Extract<Block, { kind: 'process' }> };
  80. type SheetTab = 'task' | 'file' | 'deliver' | 'state';
  81. /**
  82. * 会话详情(Demo 06 + 07 成果抽屉)
  83. *
  84. * 全屏路由(无 TabBar):顶栏固定 + 消息区独立滚动 + 底部输入坞固定。
  85. * 所有收发状态来自 `ChatService`(与桌面同一状态机),本页只做编排:
  86. * · `:id` = agentId;`:sessionId` 缺省 = 打开该容器最近一条会话
  87. * · 无 sessionId 且容器下没有会话 → `startBlank()` 建一条「新建对话」
  88. */
  89. @Component({
  90. selector: 'app-mb-chat-page',
  91. changeDetection: ChangeDetectionStrategy.OnPush,
  92. imports: [
  93. MAppBarComponent,
  94. MDockComponent,
  95. MDrawerComponent,
  96. MEmptyComponent,
  97. MMsgComponent,
  98. MBadgeComponent,
  99. ToolActivityComponent,
  100. FilePreviewComponent,
  101. ImageLightboxComponent,
  102. LockScrollDirective,
  103. AssistantTurnComponent,
  104. ],
  105. template: `
  106. <div class="m-page m-page--fixed">
  107. <app-m-app-bar
  108. [title]="title()"
  109. [subtitle]="subtitle()"
  110. [back]="true"
  111. [rightCount]="2"
  112. (backTap)="back()"
  113. >
  114. <!--
  115. 会话切换(2026-11-09):抽屉组件早就引入了却没用,导致移动端进了
  116. 一条会话就出不去 —— 只能退回消息列表再进,等于不能切会话。
  117. -->
  118. <button barRight type="button" class="m-fab" aria-label="切换会话" (click)="drawerOpen.set(true)">
  119. <i class="fa-solid fa-bars-staggered" aria-hidden="true"></i>
  120. </button>
  121. <button barRight type="button" class="m-fab" aria-label="查看成果与状态" (click)="sheetOpen.set(true)">
  122. <i class="fa-solid fa-folder-open" aria-hidden="true"></i>
  123. </button>
  124. </app-m-app-bar>
  125. <app-m-drawer
  126. [open]="drawerOpen()"
  127. [activeSessionId]="chat.sessionId()"
  128. (close)="drawerOpen.set(false)"
  129. (newChat)="drawerOpen.set(false); back()"
  130. (newChatWithWs)="drawerOpen.set(false); back()"
  131. />
  132. @if (chat.connection() === 'disconnected' && chat.state() !== 'loading') {
  133. <button type="button" class="m-conn-bar" (click)="reconnect()">
  134. <i class="fa-solid fa-plug-circle-exclamation" aria-hidden="true"></i>
  135. 连接已断开,点击重连
  136. </button>
  137. }
  138. <!--
  139. 回环提示(2026-11-09):桌面早就有,移动端一直没有 ——
  140. 手机上看不到「为什么没回复」,只会一脸茫然。
  141. -->
  142. @if (loop.paused()) {
  143. <div class="m-loop-bar" role="status">
  144. <div class="m-loop-text">
  145. <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
  146. <span>检测到自动对话循环,已暂停:{{ loop.pauseDetail() }}</span>
  147. </div>
  148. <button type="button" class="m-loop-act" (click)="loop.resume()">继续协作</button>
  149. </div>
  150. }
  151. <div class="m-chat-body" #scroller (scroll)="onScroll($event)">
  152. @switch (chat.state()) {
  153. @case ('loading') {
  154. <div class="m-loading" aria-busy="true" aria-label="正在加载消息">
  155. <span class="m-typing"><i></i><i></i><i></i></span>
  156. </div>
  157. }
  158. @case ('error') {
  159. <app-m-empty
  160. icon="fa-solid fa-triangle-exclamation"
  161. title="消息加载失败"
  162. [description]="chat.error() ?? ''"
  163. [retryable]="true"
  164. (retry)="reconnect()"
  165. />
  166. }
  167. @case ('empty') {
  168. @if (chat.emptyReason() === 'removed') {
  169. <!-- 已软删会话(2026-11-09 用户裁决):URL 直达也不认领 -->
  170. <app-m-empty
  171. icon="fa-solid fa-trash-can"
  172. title="该会话已删除"
  173. description="这条对话已经被删除,无法再查看或继续发送消息。"
  174. />
  175. } @else if (chat.emptyReason() === 'blank') {
  176. <!--
  177. 空壳会话(2026-11-12):已翻到房间最早、目录里有这条、但一条消息都没有。
  178. 必须与「暂时取不到」分开说,否则用户会一直等、以为数据丢了。
  179. -->
  180. <app-m-empty
  181. icon="fa-solid fa-inbox"
  182. title="这条对话没有保存任何消息"
  183. description="会话记录还在,但服务器上找不到它的任何消息内容。"
  184. />
  185. } @else if (chat.emptyReason() === 'unloaded') {
  186. <!--
  187. 有会话但历史取不到(2026-11-09):不能伪装成「开始新的对话」。
  188. 🔴 2026-11-13 补「重试」(真机实测):移动网络下回溯请求可能失败,
  189. 原先没有重试入口,用户只能一直看着这句话。
  190. -->
  191. <app-m-empty
  192. icon="fa-solid fa-inbox"
  193. title="这个会话的消息暂时没加载出来"
  194. description="可能是网络波动导致历史读取失败。点下面重试,或直接发消息继续对话。"
  195. [retryable]="true"
  196. (retry)="retry()"
  197. />
  198. } @else {
  199. <app-m-empty
  200. icon="fa-solid fa-comments"
  201. title="开始新的对话"
  202. description="发一条消息,数字生命就会开始工作"
  203. />
  204. }
  205. }
  206. @default {
  207. <!-- 触顶自动加载(不再需要按钮);这里只显示加载指示 -->
  208. @if (chat.paginating()) {
  209. <p class="m-load-more" aria-live="polite">
  210. <i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>
  211. 正在加载更早的消息…
  212. </p>
  213. }
  214. <!--
  215. 回合聚合(2026-11-05):一次提问产生的多条 agent 消息合成**一条气泡**,
  216. 后到内容往里追加。过程行折叠在气泡顶部,执行中自动展开、完成后收起。
  217. -->
  218. <!--
  219. 卡片渲染(2026-11-07 用户需求):一轮 = 一张卡,
  220. 与桌面端共用 app-assistant-turn。用户消息仍走 app-m-msg,保持移动观感。
  221. -->
  222. @for (row of aiRows(); track row.key) {
  223. @if (row.kind === 'turn') {
  224. <app-assistant-turn
  225. [turn]="row.turn"
  226. [grouped]="false"
  227. (preview)="openFile($event)"
  228. (openTask)="goTask($event)"
  229. />
  230. } @else {
  231. <app-m-msg
  232. [message]="row.message"
  233. (retry)="retryMessage($event)"
  234. (openTask)="goTask($event)"
  235. />
  236. }
  237. }
  238. }
  239. }
  240. <!-- 与桌面同口径:回合卡已经在显示等待文案时,这里不再重复一条(2026-11-09) -->
  241. @if (chat.peerTyping() && aiRows().length === 0) {
  242. <p class="m-typing-line">{{ agentName() }} 正在输入…</p>
  243. }
  244. </div>
  245. <div class="m-composer-area">
  246. <!-- 待发附件:图片出缩略图(与 PC 端同构),其余出文件名 chip -->
  247. @if (chat.pendingFiles().length > 0) {
  248. <div class="m-pending" role="list" aria-label="待发送附件">
  249. @for (f of chat.pendingFiles(); track f.id) {
  250. @if (f.previewUrl) {
  251. <div class="m-pending-card" role="listitem">
  252. <img [src]="f.previewUrl" [alt]="f.file.name" class="m-pending-img" />
  253. <button
  254. type="button"
  255. class="m-pending-x"
  256. [attr.aria-label]="'移除 ' + f.file.name"
  257. (click)="chat.unstageFile(f.id)"
  258. >
  259. <i class="fa-solid fa-xmark" aria-hidden="true"></i>
  260. </button>
  261. </div>
  262. } @else {
  263. <span class="m-pending-item" role="listitem">
  264. <i class="fa-solid fa-paperclip" aria-hidden="true"></i>
  265. <span class="m-pending-name">{{ f.file.name }}</span>
  266. <button
  267. type="button"
  268. [attr.aria-label]="'移除 ' + f.file.name"
  269. (click)="chat.unstageFile(f.id)"
  270. >
  271. <i class="fa-solid fa-xmark" aria-hidden="true"></i>
  272. </button>
  273. </span>
  274. }
  275. }
  276. </div>
  277. }
  278. <app-m-dock
  279. [draft]="chat.draft()"
  280. [sending]="chat.sending()"
  281. [uploading]="chat.uploading()"
  282. [pct]="chat.uploadPct()"
  283. [stage]="chat.uploadPhase()"
  284. [target]="agentName()"
  285. [hasAttachments]="chat.pendingFiles().length > 0"
  286. [placeholder]="'发消息给 ' + agentName()"
  287. (draftChange)="chat.setDraft($event)"
  288. (send)="send($event)"
  289. (attach)="chat.stageFiles($event)"
  290. />
  291. </div>
  292. </div>
  293. <!-- 全屏预览层(2026-11-03):点会话内文件/成果都走这里,不再新开标签页 -->
  294. <app-image-lightbox
  295. [open]="!!lightbox()"
  296. [url]="lightbox()?.url ?? ''"
  297. [name]="lightbox()?.name ?? ''"
  298. [size]="lightbox()?.size ?? 0"
  299. (closed)="lightbox.set(null)"
  300. />
  301. <app-file-preview
  302. [open]="!!preview()"
  303. [url]="preview()?.url ?? ''"
  304. [name]="preview()?.name ?? ''"
  305. [mimeType]="preview()?.mimeType ?? ''"
  306. mode="modal"
  307. (closed)="preview.set(null)"
  308. />
  309. <!-- 成果抽屉(Demo 07:任务 / 文件 / 成果 / 状态) -->
  310. @if (sheetOpen()) {
  311. <div class="m-sheet-root" role="dialog" aria-modal="true" aria-label="会话面板" [appLockScroll]="true">
  312. <div class="m-sheet-mask" (click)="sheetOpen.set(false)"></div>
  313. <section class="m-sheet">
  314. <div class="m-grabber" aria-hidden="true"></div>
  315. <header class="m-sheet-head">
  316. <h2>{{ title() }}</h2>
  317. <button type="button" class="m-sheet-x" aria-label="关闭" (click)="sheetOpen.set(false)">
  318. <i class="fa-solid fa-xmark" aria-hidden="true"></i>
  319. </button>
  320. </header>
  321. <div class="m-sheet-tabs" role="tablist">
  322. @for (t of sheetTabs; track t.value) {
  323. <button
  324. type="button"
  325. role="tab"
  326. class="m-chip"
  327. [class.on]="sheetTab() === t.value"
  328. [attr.aria-selected]="sheetTab() === t.value"
  329. (click)="sheetTab.set(t.value)"
  330. >
  331. {{ t.label }}
  332. </button>
  333. }
  334. </div>
  335. <div class="m-sheet-body">
  336. @switch (sheetTab()) {
  337. @case ('task') {
  338. @if (relatedTasks().length > 0) {
  339. <ul class="m-list">
  340. @for (t of relatedTasks(); track t.id) {
  341. <li>
  342. <button
  343. type="button"
  344. class="m-row"
  345. [class.hl]="railHighlight() === t.id"
  346. (click)="goTask(t.id)"
  347. >
  348. <span class="m-row-body">
  349. <span class="m-row-t1">{{ t.title }}</span>
  350. <span class="m-row-t2">{{ t.updatedAt || '—' }}</span>
  351. </span>
  352. <app-m-badge [tone]="taskTone(t)">{{ taskText(t) }}</app-m-badge>
  353. </button>
  354. </li>
  355. }
  356. </ul>
  357. } @else {
  358. <app-m-empty title="暂无任务" [compact]="true" />
  359. }
  360. }
  361. @case ('file') {
  362. @if (files().length > 0) {
  363. <ul class="m-list">
  364. @for (f of files(); track f.url + f.name) {
  365. <li>
  366. <!-- 点开走全屏预览(2026-11-03),不再新开标签页 -->
  367. <button type="button" class="m-row" (click)="openFile(f)">
  368. <i class="fa-solid fa-file" aria-hidden="true"></i>
  369. <span class="m-row-body">
  370. <span class="m-row-t1">{{ f.name }}</span>
  371. <span class="m-row-t2">{{ fmtSize(f.size) }}</span>
  372. </span>
  373. <i class="fa-solid fa-chevron-right m-chev" aria-hidden="true"></i>
  374. </button>
  375. </li>
  376. }
  377. </ul>
  378. } @else {
  379. <app-m-empty title="暂无文件" [compact]="true" />
  380. }
  381. }
  382. @case ('deliver') {
  383. <!--
  384. 未上云提示(2026-11-09),与桌面 session-rail 同口径。
  385. 附件没有 url 时原来静默不显示,用户以为文件丢了。
  386. -->
  387. @if (notOnCloudCount() > 0) {
  388. <p class="m-rail-warn" role="status">
  389. <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
  390. 有 {{ notOnCloudCount() }} 个成果文件未上传云盘,暂时无法查看
  391. </p>
  392. }
  393. @if (deliverables().length > 0) {
  394. @for (d of deliverables(); track d.id) {
  395. <div class="m-deliver">
  396. <div class="m-deliver-head">
  397. <span class="m-row-t1">{{ d.title }}</span>
  398. <span class="m-row-t2">{{ d.version || '' }} · {{ d.deliveredAt }}</span>
  399. </div>
  400. @if (d.artifacts.length > 0) {
  401. <ul class="m-list">
  402. <!-- 遍历全部产物(原实现只取 artifacts[0],其余看不到) -->
  403. @for (a of d.artifacts; track a.url || a.name) {
  404. <li>
  405. <button type="button" class="m-row" (click)="openFile(a)">
  406. <i class="fa-solid fa-paperclip" aria-hidden="true"></i>
  407. <span class="m-row-body">
  408. <span class="m-row-t1">{{ a.name }}</span>
  409. <span class="m-row-t2">{{ a.type || '' }}</span>
  410. </span>
  411. <i class="fa-solid fa-chevron-right m-chev" aria-hidden="true"></i>
  412. </button>
  413. </li>
  414. }
  415. </ul>
  416. } @else {
  417. <p class="m-deliver-empty">暂无可用产物</p>
  418. }
  419. </div>
  420. }
  421. } @else {
  422. <app-m-empty title="暂无成果" [compact]="true" />
  423. }
  424. }
  425. @default {
  426. <dl class="m-kv">
  427. <dt>状态</dt>
  428. <!-- 统一口径(2026-11-08):原先直接渲染 statusText ?? status,英文 id 会露出 -->
  429. <dd>{{ agentState()?.text ?? '—' }}</dd>
  430. <dt>当前任务</dt>
  431. <dd>{{ agent()?.currentTask || '—' }}</dd>
  432. <dt>上次心跳</dt>
  433. <dd>{{ heartbeatText() }}</dd>
  434. <dt>连接</dt>
  435. <dd>{{ chat.connection() === 'connected' ? '已连接' : '未连接' }}</dd>
  436. <dt>会话</dt>
  437. <dd>{{ title() }}</dd>
  438. </dl>
  439. }
  440. }
  441. </div>
  442. </section>
  443. </div>
  444. }
  445. `,
  446. styles: [
  447. `
  448. :host {
  449. display: block;
  450. }
  451. .m-page {
  452. display: flex;
  453. flex-direction: column;
  454. height: 100dvh;
  455. background: var(--canvas);
  456. }
  457. .m-fab {
  458. display: grid;
  459. place-items: center;
  460. width: var(--touch-min);
  461. height: var(--touch-min);
  462. border: 0;
  463. border-radius: var(--r-tag);
  464. background: var(--surface);
  465. box-shadow: var(--shadow-soft);
  466. color: var(--ink);
  467. font-size: var(--fs-17);
  468. cursor: pointer;
  469. }
  470. .m-conn-bar {
  471. display: flex;
  472. align-items: center;
  473. justify-content: center;
  474. gap: var(--sp-2);
  475. width: calc(100% - var(--mob-gutter) * 2);
  476. margin: 0 var(--mob-gutter);
  477. padding: var(--sp-2) var(--sp-3);
  478. border: 1px solid var(--warning);
  479. border-radius: var(--r-md);
  480. background: var(--warning-subtle);
  481. color: var(--warning);
  482. font: inherit;
  483. font-size: var(--fs-12);
  484. cursor: pointer;
  485. }
  486. /* 回环提示条(与 m-conn-bar 同一套 warning 令牌,但主体是说明 + 独立动作按钮) */
  487. .m-loop-bar {
  488. display: flex;
  489. align-items: center;
  490. gap: var(--sp-2);
  491. width: calc(100% - var(--mob-gutter) * 2);
  492. margin: 0 var(--mob-gutter) var(--sp-2);
  493. padding: var(--sp-2) var(--sp-3);
  494. border: 1px solid var(--warning);
  495. border-radius: var(--r-md);
  496. background: var(--warning-subtle);
  497. color: var(--warning); font-size: var(--fs-12);
  498. }
  499. .m-loop-text {
  500. display: flex;
  501. align-items: center;
  502. gap: var(--sp-2);
  503. flex: 1;
  504. min-width: 0;
  505. } .m-loop-text span {
  506. /* 提示较长,允许换行但不挤掉按钮 */
  507. overflow-wrap: anywhere;
  508. }
  509. /* 未上云的成果提示(2026-11-09),与桌面 rail-warn 同口径 */
  510. .m-rail-warn {
  511. display: flex;
  512. align-items: flex-start;
  513. gap: var(--sp-1);
  514. margin: var(--sp-2) var(--mob-gutter) 0;
  515. padding: var(--sp-2) var(--sp-3);
  516. border-radius: var(--r-tag);
  517. background: var(--warning-subtle);
  518. color: var(--warning);
  519. font-size: var(--fs-13);
  520. line-height: 1.5;
  521. }
  522. .m-loop-act {
  523. flex: none;
  524. /* 触达区 ≥44px(WCAG 2.2 AA) */
  525. min-height: 44px;
  526. padding: 0 var(--sp-3);
  527. border: 1px solid var(--warning);
  528. border-radius: var(--r-sm);
  529. background: transparent;
  530. color: var(--warning);
  531. font: inherit;
  532. font-size: var(--fs-13);
  533. cursor: pointer;
  534. }
  535. .m-turn {
  536. display: flex;
  537. flex-direction: column;
  538. gap: var(--sp-3);
  539. }
  540. .m-turn-head {
  541. display: inline-flex;
  542. align-items: center;
  543. gap: var(--sp-2);
  544. align-self: flex-start;
  545. min-height: 28px;
  546. padding: 0 var(--sp-3);
  547. border: 1px solid var(--edge);
  548. border-radius: var(--r-tag);
  549. background: var(--surface-2);
  550. color: var(--muted);
  551. font: inherit;
  552. font-size: var(--fs-12);
  553. cursor: pointer;
  554. }
  555. .m-exec {
  556. color: var(--info);
  557. }
  558. .m-chat-body {
  559. flex: 1;
  560. min-height: 0;
  561. overflow-y: auto;
  562. -webkit-overflow-scrolling: touch;
  563. display: flex;
  564. flex-direction: column;
  565. gap: var(--sp-4);
  566. padding: var(--sp-3) var(--mob-gutter) var(--sp-6);
  567. }
  568. .m-load-more {
  569. display: flex;
  570. align-items: center;
  571. justify-content: center;
  572. gap: var(--sp-2);
  573. margin: 0;
  574. padding: var(--sp-2);
  575. color: var(--muted);
  576. font-size: var(--fs-12);
  577. }
  578. .m-typing-line {
  579. margin: 0;
  580. color: var(--muted);
  581. font-size: var(--fs-12);
  582. }
  583. .m-loading {
  584. display: grid;
  585. place-items: center;
  586. padding: var(--sp-8);
  587. }
  588. .m-typing {
  589. display: inline-flex;
  590. gap: 4px;
  591. }
  592. .m-typing i {
  593. width: 6px;
  594. height: 6px;
  595. border-radius: var(--r-tag);
  596. background: var(--edge-strong);
  597. animation: m-bounce 1s infinite ease-in-out;
  598. }
  599. .m-typing i:nth-child(2) {
  600. animation-delay: 0.15s;
  601. }
  602. .m-typing i:nth-child(3) {
  603. animation-delay: 0.3s;
  604. }
  605. @keyframes m-bounce {
  606. 0%,
  607. 80%,
  608. 100% {
  609. transform: translateY(0);
  610. opacity: 0.4;
  611. }
  612. 40% {
  613. transform: translateY(-4px);
  614. opacity: 1;
  615. }
  616. }
  617. .m-composer-area {
  618. flex: none;
  619. padding-bottom: calc(var(--mob-safe-bottom) + var(--sp-3));
  620. }
  621. .m-pending {
  622. display: flex;
  623. gap: var(--sp-2);
  624. padding: 0 var(--mob-gutter) var(--sp-2);
  625. overflow-x: auto;
  626. scrollbar-width: none;
  627. }
  628. .m-pending::-webkit-scrollbar {
  629. display: none;
  630. }
  631. /* 图片待发卡:缩略图 + 右上角移除 */
  632. .m-pending-card {
  633. position: relative;
  634. flex: none;
  635. width: 72px;
  636. height: 72px;
  637. border-radius: var(--r-md);
  638. overflow: hidden;
  639. border: 1px solid var(--edge);
  640. background: var(--surface-2);
  641. }
  642. .m-pending-img {
  643. width: 100%;
  644. height: 100%;
  645. object-fit: cover;
  646. display: block;
  647. }
  648. .m-pending-x {
  649. position: absolute;
  650. top: 2px;
  651. right: 2px;
  652. display: grid;
  653. place-items: center;
  654. width: 20px;
  655. height: 20px;
  656. padding: 0;
  657. border: 0;
  658. border-radius: var(--r-tag);
  659. background: color-mix(in srgb, var(--ink) 70%, transparent);
  660. color: var(--surface);
  661. font-size: var(--fs-10);
  662. cursor: pointer;
  663. }
  664. .m-pending-item {
  665. display: inline-flex;
  666. align-items: center;
  667. gap: var(--sp-2);
  668. max-width: 100%;
  669. padding: var(--sp-1) var(--sp-2);
  670. border: 1px solid var(--edge);
  671. border-radius: var(--r-sm);
  672. background: var(--surface);
  673. color: var(--ink-2);
  674. font-size: var(--fs-12);
  675. }
  676. .m-pending-name {
  677. max-width: 160px;
  678. overflow: hidden;
  679. text-overflow: ellipsis;
  680. white-space: nowrap;
  681. }
  682. .m-pending-item button {
  683. border: 0;
  684. background: none;
  685. color: var(--muted);
  686. cursor: pointer;
  687. padding: 0;
  688. }
  689. .m-sheet-root {
  690. position: fixed;
  691. inset: 0;
  692. z-index: var(--z-modal);
  693. }
  694. .m-sheet-mask {
  695. position: absolute;
  696. inset: 0;
  697. background: var(--overlay);
  698. }
  699. .m-sheet {
  700. position: absolute;
  701. left: 0;
  702. right: 0;
  703. bottom: 0;
  704. display: flex;
  705. flex-direction: column;
  706. /* 默认全屏(2026-11-03 用户要求):原来是 max-height:82%,内容矮时只占一小块 */
  707. height: 100dvh;
  708. max-height: 100dvh;
  709. padding-top: var(--mob-safe-top);
  710. padding-bottom: var(--mob-safe-bottom);
  711. border-radius: var(--r-lg) var(--r-lg) 0 0;
  712. box-sizing: border-box;
  713. background: var(--surface);
  714. box-shadow: var(--shadow-overlay);
  715. animation: m-sheet-in var(--motion-panel) var(--ease-standard);
  716. }
  717. .m-grabber {
  718. width: 36px;
  719. height: 4px;
  720. margin: var(--sp-2) auto;
  721. border-radius: var(--r-tag);
  722. background: var(--edge-strong);
  723. }
  724. .m-sheet-head {
  725. display: flex;
  726. align-items: center;
  727. gap: var(--sp-3);
  728. padding: var(--sp-2) var(--mob-gutter) var(--sp-3);
  729. }
  730. .m-sheet-head h2 {
  731. flex: 1;
  732. margin: 0;
  733. overflow: hidden;
  734. text-overflow: ellipsis;
  735. white-space: nowrap;
  736. color: var(--ink);
  737. font-size: var(--fs-17);
  738. }
  739. .m-sheet-x {
  740. display: grid;
  741. place-items: center;
  742. width: var(--touch-min);
  743. height: var(--touch-min);
  744. border: 0;
  745. border-radius: var(--r-tag);
  746. background: none;
  747. color: var(--muted);
  748. font-size: var(--fs-17);
  749. cursor: pointer;
  750. }
  751. .m-sheet-tabs {
  752. display: flex;
  753. gap: var(--sp-2);
  754. padding: 0 var(--mob-gutter) var(--sp-3);
  755. overflow-x: auto;
  756. }
  757. .m-chip {
  758. flex: none;
  759. height: 34px;
  760. padding: 0 var(--sp-3);
  761. border: 1px solid var(--edge);
  762. border-radius: var(--r-tag);
  763. background: var(--surface);
  764. color: var(--ink-2);
  765. font: inherit;
  766. font-size: var(--fs-13);
  767. cursor: pointer;
  768. }
  769. .m-chip.on {
  770. border-color: var(--primary);
  771. background: var(--primary);
  772. color: var(--primary-content);
  773. font-weight: 600;
  774. }
  775. .m-sheet-body {
  776. overflow-y: auto;
  777. padding: 0 var(--mob-gutter) var(--sp-6);
  778. }
  779. .m-list {
  780. margin: 0;
  781. padding: 0;
  782. list-style: none;
  783. border: 1px solid var(--edge);
  784. border-radius: var(--r-lg);
  785. overflow: hidden;
  786. }
  787. .m-list > li + li {
  788. border-top: 1px solid var(--edge);
  789. }
  790. .m-row {
  791. display: flex;
  792. align-items: center;
  793. gap: var(--sp-3);
  794. width: 100%;
  795. min-height: 56px;
  796. padding: var(--sp-3) var(--sp-4);
  797. box-sizing: border-box;
  798. border: 0;
  799. background: var(--surface);
  800. color: var(--ink);
  801. font: inherit;
  802. text-align: left;
  803. text-decoration: none;
  804. cursor: pointer;
  805. }
  806. /* 深链定位高亮(2026-11-11):任务引用点进来时短暂标出是哪条 */
  807. .m-row.hl {
  808. background: var(--surface-hover);
  809. box-shadow: inset 3px 0 0 var(--primary);
  810. }
  811. .m-row-body {
  812. flex: 1;
  813. min-width: 0;
  814. }
  815. .m-row-t1,
  816. .m-row-t2 {
  817. display: block;
  818. overflow: hidden;
  819. text-overflow: ellipsis;
  820. white-space: nowrap;
  821. }
  822. .m-row-t1 {
  823. font-size: var(--fs-15);
  824. }
  825. .m-row-t2 {
  826. color: var(--muted);
  827. font-size: var(--fs-13);
  828. }
  829. .m-kv {
  830. display: grid;
  831. grid-template-columns: 96px 1fr;
  832. gap: var(--sp-2) var(--sp-3);
  833. margin: 0;
  834. padding: var(--sp-4);
  835. border: 1px solid var(--edge);
  836. border-radius: var(--r-lg);
  837. font-size: var(--fs-13);
  838. }
  839. .m-kv dt {
  840. color: var(--muted);
  841. }
  842. .m-kv dd {
  843. margin: 0;
  844. color: var(--ink);
  845. word-break: break-all;
  846. }
  847. @keyframes m-sheet-in {
  848. from {
  849. transform: translateY(100%);
  850. }
  851. to {
  852. transform: translateY(0);
  853. }
  854. }
  855. @media (prefers-reduced-motion: reduce) {
  856. .m-sheet,
  857. .m-typing i {
  858. animation: none;
  859. }
  860. }
  861. `,
  862. ],
  863. })
  864. export class MbChatPage implements OnDestroy {
  865. private readonly route = inject(ActivatedRoute);
  866. private readonly router = inject(Router);
  867. private readonly tasksSvc = inject(TasksService);
  868. private readonly sessionsSvc = inject(ChatSessionService);
  869. private readonly view = inject(MobileChatViewService);
  870. /** 成果列表构造(从 agent 消息附件生成) */
  871. private readonly deliverablesSvc = inject(DeliverablesService);
  872. protected readonly chat = inject(ChatService);
  873. protected readonly loop = inject(LoopGuardService);
  874. protected readonly sheetOpen = signal(false);
  875. /** 图片走灯箱 */
  876. protected readonly lightbox = signal<{ url: string; name: string; size: number } | null>(null);
  877. /** 其余文件走全屏预览 */
  878. protected readonly preview = signal<{ url: string; name: string; mimeType: string } | null>(null);
  879. protected readonly sheetTab = signal<SheetTab>('task');
  880. /**
  881. * 抽屉里需要高亮定位的任务 id(2026-11-11)。
  882. *
  883. * 「任务」页路由下线后,消息里的任务引用改为「打开抽屉 + 定位」,
  884. * 与桌面 `openRailAt()` 同口径,2 秒后自动清空。
  885. */
  886. protected readonly railHighlight = signal<string | null>(null);
  887. private railHighlightTimer: ReturnType<typeof setTimeout> | null = null;
  888. /** 抽屉在本页不使用(返回即可),保留导入以便后续「会话切换」扩展 */
  889. protected readonly drawerOpen = signal(false);
  890. protected readonly sheetTabs: { value: SheetTab; label: string }[] = [
  891. { value: 'task', label: '任务' },
  892. { value: 'file', label: '文件' },
  893. { value: 'deliver', label: '成果' },
  894. { value: 'state', label: '状态' },
  895. ];
  896. protected readonly fmtSize = fmtSize;
  897. protected readonly agent = computed(() => this.chat.agent());
  898. protected readonly agentName = computed(() => this.chat.agent()?.name ?? '数字生命');
  899. /**
  900. * 消息 → 渲染行:**连续的终端活动消息合并成一个折叠组**。
  901. *
  902. * 与桌面端同一套规则(`desktop/pages/chat/chat.ts` 的 `messageRows`):
  903. * · 容器把 Hermes 每一步都作为独立消息发出(💻 terminal + 命令)
  904. * · 相邻且都属于终端活动 → 合成一组;遇到正常对话立即断开
  905. *
  906. * 🔴 组 key 用**组内第一条消息的 id**,不能用下标:
  907. * 流式追加时下标会变,导致同组被销毁重建(内容闪烁)。
  908. */
  909. /**
  910. * 消息 → 渲染行(2026-11-05 改为**回合聚合**)。
  911. *
  912. * ## 变化
  913. *
  914. * 原来:每条 agent 消息一个气泡 → 一条回答被容器拆成 2~3 条,读起来断续。
  915. * 现在:**一次提问的所有 agent 输出合成一个回合(一条气泡)**,内容往里追加。
  916. *
  917. * ## 边界
  918. *
  919. * `buildTurns` 负责切分(用户消息锚点 + typing 结束静默 3 秒),
  920. * 本方法只负责把回合映射成渲染行 —— 逻辑分离,便于单测。
  921. *
  922. * ## 兼容
  923. *
  924. * 用户消息仍逐条渲染;回合内的过程行(💻 terminal 等)折叠到回合顶部。
  925. */
  926. /**
  927. * 渲染行(2026-11-07 用户需求):AI 的一整轮合并成一张卡。
  928. *
  929. * 与桌面端共用 `app-assistant-turn` —— 用户裁决「复用同一个组件」,
  930. * 移动端差异(触达区 44px、字号 ≥16px、段间距)由组件内的
  931. * `bp.below-mobile` 分支处理,两处渲染逻辑保持单一来源。
  932. */
  933. protected readonly aiRows = computed<AiRow[]>(() => {
  934. const out: AiRow[] = [];
  935. /*
  936. * 🔴 依赖 `clockTick` 而不是 `now`(2026-11-10,与桌面端同口径)。
  937. *
  938. * 原来这里读 `this.now()`,于是时钟每秒推进都会让**整页 aiRows 重算**,
  939. * 每个 turn 对象都是新引用 → `app-assistant-turn` 的 input 变化 →
  940. * 流式推进的 `shownMap` 可能被重置,正文从头重放。
  941. * `clockTick` 是个计数器:每秒变一次,但只让**读它的地方**重算,
  942. * 回合数据本身只在消息变化时重建。
  943. */
  944. this.clockTick();
  945. const now = Date.now();
  946. for (const t of turnsFromDm(
  947. buildTurns({ messages: this.chat.messages(), typing: this.chat.peerTyping(), now }),
  948. now,
  949. this.chat.peerTyping(),
  950. /* 与桌面同口径(2026-11-08):25 秒静默窗口 + 容器明确空闲时立即收尾 */
  951. QUIET_MS,
  952. this.chat.agentIdle(),
  953. /* 容器结束信号(2026-11-11):`m.typing=false` 的本地接收时刻,与桌面同口径 */
  954. this.chat.turnEndedAfter(),
  955. )) {
  956. /* 连发的前置提问先展示(2026-11-11,与桌面同口径) */
  957. for (const prevAsk of t.priorAsks ?? []) {
  958. out.push({ kind: 'msg', key: prevAsk.id, message: prevAsk });
  959. }
  960. if (t.userMessage) {
  961. out.push({ kind: 'msg', key: t.userMessage.id, message: t.userMessage });
  962. }
  963. // 同一轮输入里的附件紧跟文字(2026-11-10,与桌面同口径)
  964. for (const f of t.userFiles ?? []) {
  965. out.push({ kind: 'msg', key: f.id, message: f });
  966. }
  967. // 已发出等待回复 / 有用户附件 也要出卡(2026-11-09 / 2026-11-10,与桌面同口径)
  968. if (t.merged || t.live || t.userMessage || (t.userFiles?.length ?? 0) > 0) {
  969. out.push({ kind: 'turn', key: `turn-${t.id}`, turn: t });
  970. }
  971. }
  972. return out;
  973. });
  974. /*
  975. * 超时未响应的全局提示(2026-11-10 用户裁决,与桌面同口径)。
  976. *
  977. * 🔴 不自动消失,必须用户手动关闭。
  978. * 🔴 不只要弹一次:**当前会话只要存在超时回合,就要一直显示**;
  979. * 条件消失(收到回复)后自动撤掉,避免残留。
  980. */
  981. private readonly alert = inject(AlertService);
  982. private static readonly TIMEOUT_TITLE = '容器长时间未响应';
  983. private watchTurnTimeout(turns: readonly AssistantTurn[]): void {
  984. /*
  985. * 🔴 只看最后一轮(2026-11-10,与桌面同口径):更早的回合不论有没有回复,
  986. * 都已经过去了,不该弹提示。
  987. */
  988. const last = turns[turns.length - 1];
  989. if (!last) {
  990. this.alert.syncState(MbChatPage.TIMEOUT_TITLE, false);
  991. return;
  992. }
  993. /* 已处理过的超时 → 直接抛弃,不再判定(用户裁决 2026-11-10) */
  994. if (this._handledTimeouts.has(last.id)) {
  995. this.alert.syncState(MbChatPage.TIMEOUT_TITLE, false);
  996. return;
  997. }
  998. const userMs = last.userMessage ? Date.parse(last.userMessage.ts) || 0 : 0;
  999. const hasOutput = last.blocks.some((b) => b.body.length > 0 || b.steps.length > 0);
  1000. const timedOut = userMs > 0 && isTurnTimedOut(Date.now() - userMs, hasOutput, last.live);
  1001. /* 卡住检测(2026-11-11,与桌面同口径):回了一部分就停住的情况 */
  1002. const lastActivity = last.blocks.reduce((max, b) => Math.max(max, b.updatedAt), 0);
  1003. const idleMs = lastActivity > 0 ? Date.now() - lastActivity : 0;
  1004. const stalled = isTurnStalled(idleMs, hasOutput, last.live);
  1005. if (timedOut || stalled) {
  1006. this._handledTimeouts.add(last.id);
  1007. this.alert.resetDismiss(MbChatPage.TIMEOUT_TITLE);
  1008. this.alert.syncState(
  1009. MbChatPage.TIMEOUT_TITLE,
  1010. true,
  1011. stalled
  1012. ? `容器已停止响应超过 ${Math.round(STALL_MS / 1000)} 秒,任务可能已中断。可点「重新发送」把刚才的消息放回输入框。`
  1013. : `最近一条消息已超过 ${Math.round(TURN_TIMEOUT_MS / 1000)} 秒没有回复。请确认容器是否在线,或点「重新发送」重试。`,
  1014. { label: '重新发送' },
  1015. );
  1016. return;
  1017. }
  1018. this.alert.syncState(MbChatPage.TIMEOUT_TITLE, false);
  1019. }
  1020. /** 把最后一条用户消息放回输入框(2026-11-11,与桌面同口径,不自动发送) */
  1021. private resendLast(): void {
  1022. const mine = [...this.chat.messages()].reverse().find((m) => m.sender === 'me' && m.text?.trim());
  1023. if (!mine?.text) return;
  1024. this.chat.setDraft(mine.text);
  1025. this.alert.show('已放回输入框', '确认内容无误后再点发送即可。', 'warning');
  1026. }
  1027. /**
  1028. * 已处理过(提示过)的超时回合 id。
  1029. *
  1030. * 🔴 用户裁决:超时一次性处理,之后直接抛弃。
  1031. * 只存内存、不落盘 —— 落盘会跨账号串数据(用户明确否决)。
  1032. */
  1033. private readonly _handledTimeouts = new Set<string>();
  1034. /**
  1035. * 把回合内**连续的 agent 正文**合成一个块(2026-11-06 用户要求)。
  1036. *
  1037. * 容器常把一条回答拆成多条消息;此前每段各占一个块,读起来是碎片。
  1038. * 工具块、附件、失败消息各自断开,规则见 `core/message-merge.ts`。
  1039. */
  1040. /**
  1041. * 时钟计数器:每秒 +1,**只用于驱动依赖它的 computed 重算**(2026-11-10)。
  1042. *
  1043. * 与桌面端 `clockTick` 同口径。不要改成「读一个 Date.now() 快照信号」——
  1044. * 那样整页回合数据每秒重建,流式动画会被打断。
  1045. */
  1046. private readonly clockTick = signal(0);
  1047. private nowTimer: ReturnType<typeof setInterval> | null = null;
  1048. /** 回合折叠状态(用户手动切换覆盖默认) */
  1049. protected readonly title = computed(
  1050. () => this.chat.currentSession()?.title ?? this.chat.agent()?.name ?? '会话',
  1051. );
  1052. /**
  1053. * 容器名 + 状态(2026-11-11 收敛)。
  1054. *
  1055. * 与 PC 会话页头同一判据 `sessionPresence`:
  1056. * 在线/离线走 `livenessOf`,本会话是否在回复单独叠加。
  1057. * 离线时相位一律不显示(不能说明明断了还在回复)。
  1058. */
  1059. protected readonly presence = computed(() =>
  1060. sessionPresence(this.chat.agent(), this.chat.peerTyping() ? 'replying' : undefined),
  1061. );
  1062. protected readonly subtitle = computed(() => {
  1063. const a = this.chat.agent();
  1064. if (!a) return '';
  1065. return `${a.name} · ${this.presence().text}`;
  1066. });
  1067. /** 状态抽屉用同一个口径 */
  1068. protected readonly agentState = computed(() => {
  1069. const a = this.chat.agent();
  1070. return a ? { text: this.presence().text, tone: this.presence().tone } : null;
  1071. });
  1072. /**
  1073. * 心跳时间 → 人话(2026-11-08)。
  1074. * 原先直接渲染 ISO 原文(`2026-11-08T03:12:44.000Z`),用户在手机上没法读。
  1075. */
  1076. protected readonly heartbeatText = computed(() => {
  1077. const a = this.chat.agent();
  1078. const sec = a?.heartbeatAgeSec;
  1079. if (typeof sec === 'number' && Number.isFinite(sec)) {
  1080. if (sec < 60) return `${Math.max(0, Math.round(sec))} 秒前`;
  1081. if (sec < 3600) return `${Math.round(sec / 60)} 分钟前`;
  1082. if (sec < 86400) return `${Math.round(sec / 3600)} 小时前`;
  1083. return `${Math.round(sec / 86400)} 天前`;
  1084. }
  1085. const t = a?.heartbeatAt ? Date.parse(a.heartbeatAt) : NaN;
  1086. if (!Number.isFinite(t)) return '—';
  1087. return new Date(t).toLocaleString('zh-CN', { hour12: false });
  1088. });
  1089. /** 该会话下、该数字生命的任务(Demo 抽屉「任务」页签) */
  1090. protected readonly relatedTasks = computed<Task[]>(() => {
  1091. const id = this.chat.agentId();
  1092. return id ? this.tasksSvc.all().filter((t) => t.agentId === id) : [];
  1093. });
  1094. /** 会话内出现过的全部附件(Demo 抽屉「文件」页签) */
  1095. /**
  1096. * 「文件」页签 = 本会话里**用户自己上传的**附件(2026-11-09 与桌面同口径)。
  1097. *
  1098. * 🔴 容器产出的不算「文件」,那是「成果」—— 见 `deliverables`。
  1099. * 原来不看 sender,同一份成果在「文件」和「成果」各出现一次。
  1100. */
  1101. protected readonly files = computed(() => {
  1102. const out: NonNullable<Message['file']>[] = [];
  1103. const seen = new Set<string>();
  1104. /*
  1105. * ① 消息流里我发的附件 —— **先走这条**(2026-11-11,与桌面同口径):
  1106. * 带真实 size 与 cloudUrl,信息最全。
  1107. */
  1108. for (const m of this.chat.messages()) {
  1109. const f = m.file;
  1110. if (m.sender !== 'me' || !f) continue;
  1111. const id = fileIdentity(f.name, f.size);
  1112. if (seen.has(id)) continue;
  1113. seen.add(id);
  1114. out.push(f);
  1115. }
  1116. /*
  1117. * ② 台账里 `uploadedBy:'user'` 的记录:刷新后仍在,补上消息流翻页取不到的历史。
  1118. *
  1119. * 🔴 去重不能比 URL:台账存 S3 地址、消息流 `f.url` 是 `mxc://`,永远不等
  1120. * → 同一个文件会出现两条(用户实测)。改按「名字+大小」。
  1121. */
  1122. for (const d of this._dbUserFiles()) {
  1123. const a = d.artifacts[0];
  1124. if (!a?.url) continue;
  1125. const bytes = d.sizeBytes ?? 0;
  1126. const id = fileIdentity(a.name, bytes);
  1127. if (seen.has(id)) continue;
  1128. seen.add(id);
  1129. out.push({ kind: 'file', name: a.name, url: a.url, size: bytes > 0 ? bytes : -1, mimeType: a.type ?? '' });
  1130. }
  1131. return out;
  1132. });
  1133. /**
  1134. * 成果抽屉「成果」页签 = **本会话里容器产出的文件**(2026-11-09 与桌面同口径)。
  1135. *
  1136. * 语义分界:
  1137. * · 文件(`files`)= 我上传的
  1138. * · 成果(这里) = 容器产出的
  1139. *
  1140. * 两个来源合并去重:会话记录 `deliverableUrls` + 消息流里 agent 发的文件。
  1141. */
  1142. protected readonly deliverables = computed(() => {
  1143. const sess = this.chat.currentSession();
  1144. /* 台账已按来源分流:成果只吃「容器产出」那半边(2026-11-11) */
  1145. const fromDb = this.dbFiles();
  1146. const agentFiles = this.chat
  1147. .messages()
  1148. .filter((m) => m.sender === 'agent' && m.file?.url)
  1149. .map((m) => m.file!);
  1150. const fromMessages = this.deliverablesSvc.fromAgentFiles(agentFiles);
  1151. const fromRecord = this.view.bySessionUrlsOf(sess?.deliverableUrls);
  1152. return this.deliverablesSvc.merge(fromDb, fromMessages, fromRecord);
  1153. });
  1154. /**
  1155. * 按会话从数据库加载的文件(2026-11-10:与桌面端同口径)。
  1156. * 2026-11-11 起按来源分流,见 `_dbUserFiles` / `_dbFiles` 注释。
  1157. */
  1158. private readonly _dbUserFiles = signal<Deliverable[]>([]);
  1159. private readonly _dbFiles = signal<Deliverable[]>([]);
  1160. readonly dbFiles = this._dbFiles.asReadonly();
  1161. /** 拉当前会话的文件台账;会话切换时重跑 */
  1162. private loadSessionFiles(): void {
  1163. const sid = this.chat.currentSession()?.id;
  1164. if (!sid) {
  1165. this._dbUserFiles.set([]);
  1166. this._dbFiles.set([]);
  1167. return;
  1168. }
  1169. void this.deliverablesSvc
  1170. .loadSplitForSession(sid)
  1171. .then(({ userFiles, agentFiles }) => {
  1172. if (this.chat.currentSession()?.id !== sid) return;
  1173. this._dbUserFiles.set(userFiles);
  1174. this._dbFiles.set(agentFiles);
  1175. })
  1176. .catch(() => {
  1177. if (this.chat.currentSession()?.id === sid) {
  1178. this._dbUserFiles.set([]);
  1179. this._dbFiles.set([]);
  1180. }
  1181. });
  1182. }
  1183. /** 有多少成果附件还没上云(与桌面 `session-rail` 同口径) */
  1184. protected readonly notOnCloudCount = computed(() =>
  1185. this.deliverables().reduce((n, d) => n + d.artifacts.filter((a) => !a.url).length, 0),
  1186. );
  1187. private readonly scroller = viewChild<ElementRef<HTMLDivElement>>('scroller');
  1188. /** 用户是否停在底部附近(不在底部时不自动滚动,避免打断向上翻历史) */
  1189. private stickBottom = true;
  1190. /**
  1191. * 用户是否**主动**上翻离开了底部(2026-11-10,与桌面端 `userScrolledAway` 同口径)。
  1192. *
  1193. * 🔴 为什么 `stickBottom` 不够:
  1194. * `stickBottom` 只记「此刻离底部多远」,而程序滚动本身也会触发 scroll 事件 ——
  1195. * 用户上翻 → 新消息到达把列表拉回底部 → scroll 事件又把 `stickBottom` 置回 true →
  1196. * 下一次新消息继续拉回。形成「上翻 → 被拽回 → 再也翻不上去」的死循环
  1197. * (两轮回复交替到达时最明显,用户实测)。
  1198. * 这里单独记「用户是不是主动走的」,且只在**用户自己滚动**时改变。
  1199. */
  1200. private userScrolledAway = false;
  1201. /** 正在加载更早消息(防重入 + 分页期间不贴底) */
  1202. private loadingEarlier = false;
  1203. /** 上次贴底的时刻(节流用) */
  1204. private lastStickAt = 0;
  1205. protected retryMessage(id: string): void {
  1206. void this.chat.retry(id);
  1207. }
  1208. constructor() {
  1209. /* 提示条「重新发送」(2026-11-11,与桌面同口径) */
  1210. this.alert.onAction((a) => {
  1211. if (a.title === MbChatPage.TIMEOUT_TITLE) this.resendLast();
  1212. });
  1213. /*
  1214. * 右侧栏:会话切换或消息数变化时重拉文件台账(2026-11-10,与桌面端同口径)。
  1215. * 容器产出的文件要等登记完才进台账,回合结束消息数变化正好触发刷新。
  1216. */
  1217. effect(() => {
  1218. this.chat.currentSession()?.id;
  1219. this.chat.messages().length;
  1220. this.loadSessionFiles();
  1221. });
  1222. // 路由参数 → 打开会话;参数变化时重开
  1223. this.route.paramMap.subscribe((pm) => {
  1224. const agentId = pm.get('id');
  1225. if (!agentId) return;
  1226. const sid = pm.get('sessionId') ?? undefined;
  1227. void this.openSession(agentId, sid);
  1228. void this.tasksSvc.ensureAllLoaded();
  1229. });
  1230. // 新消息到达:贴底时滚到底(用户主动上翻期间不打扰,2026-11-10)
  1231. effect(() => {
  1232. this.chat.messages();
  1233. if (this.loadingEarlier) return;
  1234. if (this.stickBottom && !this.userScrolledAway) this.stickToBottom();
  1235. });
  1236. // 有活着的回合时才跑每秒时钟(见 `syncClock` 注释)
  1237. effect(() => {
  1238. this.aiRows();
  1239. untracked(() => this.syncClock());
  1240. });
  1241. /*
  1242. * 超时未响应 → 全局提示(2026-11-10,A2 重构,与桌面同口径)。
  1243. *
  1244. * 🔴 不再依赖 `now`。
  1245. * 每秒时钟在「没有任何 live 回合」时停表,而超时恰恰发生在 live
  1246. * 已变 false 之后 → 判定永远跑不到 → 提示不弹。
  1247. * 现在只在这里同步看门狗开关,周期判定交给独立 interval。
  1248. */
  1249. effect(() => {
  1250. this.aiRows();
  1251. untracked(() => this.syncTimeoutWatch());
  1252. });
  1253. }
  1254. ngOnDestroy(): void {
  1255. // 离开会话不 close:Matrix 连接与未读计数需要保持(与桌面一致)
  1256. if (this.nowTimer !== null) {
  1257. clearInterval(this.nowTimer);
  1258. this.nowTimer = null;
  1259. }
  1260. this.stopTimeoutWatch();
  1261. }
  1262. /*
  1263. * 超时看门狗(2026-11-10,A2)。
  1264. * 与 `syncClock` 分开:后者随 live 停表,前者只看「有无未回复的回合」。
  1265. */
  1266. private timeoutHandle: ReturnType<typeof setInterval> | null = null;
  1267. private syncTimeoutWatch(): void {
  1268. const waiting = this.hasPendingTurn();
  1269. if (waiting && this.timeoutHandle === null) {
  1270. this.timeoutHandle = setInterval(() => {
  1271. this.evaluateTimeout();
  1272. if (!this.hasPendingTurn()) this.stopTimeoutWatch();
  1273. }, 5000);
  1274. this.evaluateTimeout();
  1275. } else if (!waiting && this.timeoutHandle !== null) {
  1276. this.stopTimeoutWatch();
  1277. }
  1278. }
  1279. private stopTimeoutWatch(): void {
  1280. if (this.timeoutHandle !== null) {
  1281. clearInterval(this.timeoutHandle);
  1282. this.timeoutHandle = null;
  1283. }
  1284. }
  1285. /** 是否存在「已发出、容器还没有任何输出」的回合(与 live 无关) */
  1286. private hasPendingTurn(): boolean {
  1287. /*
  1288. * 🔴 2026-11-10 用户裁决:超时即终态,之后直接抛弃,不再监听。
  1289. *
  1290. * 只在「距发出还没超过超时窗口 + 10 秒」时才盯着。
  1291. * 刷新页面时老消息早已超出窗口 → 看门狗不会启动 → 不会再弹。
  1292. */
  1293. const now = Date.now();
  1294. return this.aiRows().some((r) => {
  1295. if (r.kind !== 'turn') return false;
  1296. const t = r.turn;
  1297. if (!t.userMessage) return false;
  1298. if (this._handledTimeouts.has(t.id)) return false;
  1299. const userMs = Date.parse(t.userMessage.ts) || 0;
  1300. if (!userMs) return false;
  1301. if (now - userMs >= TURN_TIMEOUT_MS + 10_000) return false;
  1302. return !t.blocks.some((b) => b.body.length > 0 || b.steps.length > 0);
  1303. });
  1304. }
  1305. private evaluateTimeout(): void {
  1306. const turns = this.aiRows().filter((r) => r.kind === 'turn').map((r) => r.turn);
  1307. this.watchTurnTimeout(turns);
  1308. }
  1309. /*
  1310. * 🔴 时钟驱动(2026-11-09,C2)。
  1311. *
  1312. * `now` signal 原先只有声明、没有任何地方 set —— `aiRows` 只在
  1313. * `messages()` / `peerTyping()` 变化时重算。容器若一直报 typing、
  1314. * 又不再发新消息,回合的「25 秒静默」永远等不到,卡片会一直挂在
  1315. * 「正在输出…」(用户实测 163 秒)。
  1316. *
  1317. * 每秒推进一次 `now`,让静默判定真正随时间走。只在有活着的回合时起表,
  1318. * 空闲页面不白跑定时器;离页即清。
  1319. */
  1320. private syncClock(): void {
  1321. const needsTick = this.aiRows().some((r) => r.kind === 'turn' && r.turn.live);
  1322. if (needsTick && this.nowTimer === null) {
  1323. this.nowTimer = setInterval(() => {
  1324. this.clockTick.update((n) => n + 1);
  1325. if (!this.aiRows().some((r) => r.kind === 'turn' && r.turn.live)) {
  1326. if (this.nowTimer !== null) clearInterval(this.nowTimer);
  1327. this.nowTimer = null;
  1328. }
  1329. }, 1000);
  1330. } else if (!needsTick && this.nowTimer !== null) {
  1331. clearInterval(this.nowTimer);
  1332. this.nowTimer = null;
  1333. }
  1334. }
  1335. /**
  1336. * 打开会话;`sessionId === 'new'` 为「新建对话」入口(与桌面 `/chat/:id/new` 同口径)。
  1337. *
  1338. * 🔴 建出会话后立刻 replaceUrl 成真实会话地址,避免刷新回到 `new`;
  1339. * 这里**不调用 pruneEmpty**(桌面为此踩过坑:刚建的空会话被当成空壳删掉)。
  1340. */
  1341. private async openSession(agentId: string, sessionId?: string): Promise<void> {
  1342. if (sessionId === 'new') {
  1343. const ws = this.route.snapshot.queryParamMap.get('ws');
  1344. /* 一房间一会话:建房完成后再进入(2026-11-12,与桌面端同口径) */
  1345. const created = await this.chat.startBlankWithRoom(agentId, ws || null);
  1346. if (created) {
  1347. /*
  1348. * 首页输入坞带过来的草稿(2026-11-09):用户先在首页打字、再选人,
  1349. * 字不能丢。取回后立刻清掉,避免下次新建时又冒出来。
  1350. */
  1351. const carryKey = PENDING_DRAFT_KEY(agentId);
  1352. const carried = sessionStorage.getItem(carryKey);
  1353. if (carried) {
  1354. sessionStorage.removeItem(carryKey);
  1355. this.chat.setDraft(carried);
  1356. }
  1357. void this.router.navigateByUrl(
  1358. `/mobile/chat/${agentId}/${encodeURIComponent(created.id)}`,
  1359. { replaceUrl: true },
  1360. );
  1361. return;
  1362. }
  1363. } else {
  1364. await this.chat.open(agentId, sessionId);
  1365. this.sessionsSvc.pruneEmpty(this.chat.sessionId() ?? undefined);
  1366. }
  1367. this.stickBottom = true;
  1368. this.userScrolledAway = false;
  1369. this.lastStickAt = 0;
  1370. queueMicrotask(() => this.scrollToBottom());
  1371. void this.fillViewportIfNeeded();
  1372. }
  1373. protected send(text: string): void {
  1374. /*
  1375. * 自己发消息 = 明确想看结果 → 解除「用户上翻」保护并直接贴底。
  1376. * 否则用户之前上翻过、发给新消息后还停在半空,看不到自己的消息和目标容器状态。
  1377. */
  1378. this.userScrolledAway = false;
  1379. this.stickBottom = true;
  1380. this.lastStickAt = 0;
  1381. void this.chat.sendText(text);
  1382. this.stickToBottom();
  1383. }
  1384. protected retry(): void {
  1385. const agentId = this.route.snapshot.paramMap.get('id');
  1386. const sid = this.route.snapshot.paramMap.get('sessionId') ?? undefined;
  1387. if (agentId) void this.chat.open(agentId, sid, true);
  1388. }
  1389. protected reconnect(): void {
  1390. this.retry();
  1391. }
  1392. protected loadMore(): void {
  1393. void this.loadEarlier();
  1394. }
  1395. /**
  1396. * 滚动处理:
  1397. * ① 更新「是否贴底」(新消息到达时决定要不要自动滚到底)
  1398. * ② **触顶自动加载更早消息**(与 PC 端 `desktop/pages/chat/chat.ts` 同口径)
  1399. */
  1400. protected onScroll(e: Event): void {
  1401. const el = e.target as HTMLElement;
  1402. const gap = el.scrollHeight - el.scrollTop - el.clientHeight;
  1403. this.stickBottom = gap < 80;
  1404. /*
  1405. * 用户上翻的判定阈值比贴底恢复的阈值大(与桌面端 160px 同口径):
  1406. * 只有明显离开底部才算「主动走开」,避免图片异步加载把高度撑大后误判。
  1407. * 滚回接近底部时自动恢复贴底。
  1408. */
  1409. this.userScrolledAway = gap > 160;
  1410. if (el.scrollTop > TOP_LOAD_PX) return;
  1411. if (this.loadingEarlier || this.chat.paginating() || !this.chat.hasMoreHistory()) return;
  1412. void this.loadEarlier();
  1413. }
  1414. /**
  1415. * 加载更早的消息,并**保持视口位置不变**。
  1416. *
  1417. * 🔴 不做补偿的话,分页插入后 `scrollTop` 仍是原值,
  1418. * 用户眼前的内容会整体往下窜(PC 端早期踩过,见 desktop chat.ts:2921-2940)。
  1419. *
  1420. * 做法与 PC 一致:记下加载前的 scrollHeight / scrollTop,等两帧让新内容完成布局,
  1421. * 再把 scrollTop 加上新增的高度差。
  1422. */
  1423. private async loadEarlier(): Promise<void> {
  1424. if (this.loadingEarlier) return;
  1425. const el = this.scroller()?.nativeElement;
  1426. if (!el || !this.chat.hasMoreHistory()) return;
  1427. this.loadingEarlier = true;
  1428. const beforeHeight = el.scrollHeight;
  1429. const beforeTop = el.scrollTop;
  1430. try {
  1431. await this.chat.paginate();
  1432. // 等两帧:一帧提交 DOM、一帧完成布局(图片/表格撑高)
  1433. await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))));
  1434. const after = this.scroller()?.nativeElement;
  1435. if (after) {
  1436. after.scrollTop = beforeTop + (after.scrollHeight - beforeHeight);
  1437. }
  1438. } finally {
  1439. this.loadingEarlier = false;
  1440. }
  1441. }
  1442. /**
  1443. * 首屏不满一屏且还有更早消息时自动补齐(最多 3 轮)。
  1444. * 否则用户看到内容没填满、却没有可滚动空间,永远触发不到「触顶加载」。
  1445. *
  1446. * 🔴 新建态不补齐(2026-11-08):新建会话本来就没有更早的消息,
  1447. * 而 `hasMoreHistory` 取自房间级 token —— 不挡就会自动翻出整个房间的旧历史。
  1448. */
  1449. private async fillViewportIfNeeded(): Promise<void> {
  1450. if (this.chat.state() === 'empty') return;
  1451. for (let i = 0; i < 3; i++) {
  1452. const el = this.scroller()?.nativeElement;
  1453. if (!el || !this.chat.hasMoreHistory()) return;
  1454. if (el.scrollHeight > el.clientHeight + 8) return;
  1455. await this.loadEarlier();
  1456. }
  1457. }
  1458. protected back(): void {
  1459. void this.router.navigateByUrl('/mobile/messages');
  1460. }
  1461. /**
  1462. * 任务深链(2026-11-11 改)。
  1463. *
  1464. * 「任务」页路由已下线(用户裁决:入口和路由一起关),原来跳
  1465. * `/mobile/tasks/:id` 会落到消息列表,用户以为点坏了。
  1466. * 改为**留在当前页**:关掉抽屉、打开成果/状态抽屉的任务页,并高亮该条。
  1467. */
  1468. protected goTask(id: string): void {
  1469. this.sheetTab.set('task');
  1470. this.railHighlight.set(id);
  1471. this.sheetOpen.set(true);
  1472. if (this.railHighlightTimer) clearTimeout(this.railHighlightTimer);
  1473. this.railHighlightTimer = setTimeout(() => this.railHighlight.set(null), 2000);
  1474. }
  1475. /**
  1476. * 打开文件预览(2026-11-03)。
  1477. *
  1478. * 与 PC 端 `openFile()` 同口径:按类型分流 —— 图片进灯箱,其余进通用预览器。
  1479. * 原实现用 `window.open` 新开标签页,移动端体验割裂(离开 App、失去返回路径)。
  1480. */
  1481. protected openFile(f: { name: string; url?: string; mimeType?: string; size?: number }): void {
  1482. const url = f.url;
  1483. if (!url) return;
  1484. const mime = f.mimeType ?? '';
  1485. if (previewKindOf(f.name || url, mime) === 'image') {
  1486. this.lightbox.set({ url, name: f.name, size: f.size ?? 0 });
  1487. return;
  1488. }
  1489. this.preview.set({ url, name: f.name, mimeType: mime });
  1490. }
  1491. protected taskTone(t: Task): BadgeTone {
  1492. return TASK_TONE[t.status] ?? 'neutral';
  1493. }
  1494. protected taskText(t: Task): string {
  1495. return TASK_STATUS_TEXT[t.status] ?? t.status;
  1496. }
  1497. private scrollToBottom(): void {
  1498. const el = this.scroller()?.nativeElement;
  1499. if (el) el.scrollTop = el.scrollHeight;
  1500. }
  1501. /**
  1502. * 贴底(2026-11-10,与桌面端 `stickToBottom` 同口径)。
  1503. *
  1504. * · 用户主动上翻 → 不拽回
  1505. * · 正在加载更早消息 → 不贴底(那是往上插入)
  1506. * · 200ms 节流:流式输出时每帧都在长高,不节流会和渲染抢主线程
  1507. * · 补一次 rAF + 一次延时:图片 / Markdown 异步渲染后高度还会变
  1508. */
  1509. private stickToBottom(): void {
  1510. if (this.userScrolledAway) return;
  1511. if (this.loadingEarlier) return;
  1512. const now = Date.now();
  1513. if (now - this.lastStickAt < 200) return;
  1514. this.lastStickAt = now;
  1515. queueMicrotask(() => this.scrollToBottom());
  1516. requestAnimationFrame(() => {
  1517. this.scrollToBottom();
  1518. setTimeout(() => this.scrollToBottom(), 120);
  1519. });
  1520. }
  1521. }
  1522. /** 渲染行的时间戳(用于按时间排序) */
  1523. function keyTs(r: MessageRow): number {
  1524. const ts = (m?: Message): number => (m ? Date.parse(m.ts) || 0 : 0);
  1525. if (r.kind === 'msg') return ts(r.message);
  1526. if (r.kind === 'turn') {
  1527. for (const b of r.blocks) {
  1528. if (b.kind === 'msg') return ts(b.message);
  1529. }
  1530. }
  1531. return 0;
  1532. }