chat.ts 53 KB

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