| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604 |
- import {
- ChangeDetectionStrategy,
- Component,
- ElementRef,
- OnDestroy,
- computed,
- effect,
- inject,
- signal,
- untracked,
- viewChild,
- } from '@angular/core';
- import { ActivatedRoute, Router } from '@angular/router';
- import { MAppBarComponent } from '../../components/m-app-bar/m-app-bar';
- import { MDockComponent } from '../../components/m-dock/m-dock';
- import { MDrawerComponent } from '../../components/m-drawer/m-drawer';
- import { MEmptyComponent } from '../../components/m-empty/m-empty';
- import { MMsgComponent } from '../../components/m-msg/m-msg';
- import { FilePreviewComponent } from '../../../components/preview/file-preview/file-preview';
- import { ImageLightboxComponent } from '../../../components/preview/image-lightbox/image-lightbox';
- import { previewKindOf } from '../../../components/preview/file-kind';
- import { LockScrollDirective } from '../../directives/lock-scroll.directive';
- import { BadgeTone, MBadgeComponent, TASK_TONE, TASK_STATUS_TEXT } from '../../components/m-badge/m-badge';
- import { ChatService } from '../../../core/chat.service';
- import { PENDING_DRAFT_KEY } from '../home/home';
- import { LoopGuardService } from '../../../core/loop-guard.service';
- import { Deliverable, Task } from '../../../core/api/models';
- import { ChatSessionService } from '../../../core/chat-session.service';
- import { TasksService } from '../../../core/tasks.service';
- import { fmtSize } from '../../../core/format';
- import { MobileChatViewService } from '../../../core/mobile-chat-view.service';
- import { DeliverablesService } from '../../../core/deliverables.service';
- import { buildTurns, QUIET_MS } from '../../../core/turn-aggregator';
- import {
- Block,
- isToolActivityMessage,
- toolActivitySummary,
- } from '../../../core/message-segments';
- import { Message } from '../../../core/api/models';
- import { sessionPresence } from '../../../core/agent-liveness';
- import { fileIdentity } from '../../../core/s3-link';
- import { mergeConsecutiveBodies } from '../../../core/message-merge';
- import { ToolActivityComponent } from '../../../components/domain/tool-activity/tool-activity';
- import {
- AssistantTurn,
- isTurnStalled,
- isTurnTimedOut,
- STALL_MS,
- TURN_TIMEOUT_MS,
- turnsFromDm,
- } from '../../../core/assistant-turn';
- import { AlertService } from '../../../core/alert.service';
- import { AssistantTurnComponent } from '../../../components/domain/assistant-turn/assistant-turn';
- /**
- * 会话消息行:普通消息 or 已合并的终端活动组。
- * 与桌面端 `desktop/pages/chat/chat.ts` 的 `MessageRow` 同构。
- */
- /** 触顶阈值:滚动到距顶部 80px 内即自动加载更早消息(与 PC 一致) */
- const TOP_LOAD_PX = 80;
- type MessageRow =
- | { kind: 'msg'; key: string; message: Message }
- | { kind: 'tool'; key: string; block: Extract<Block, { kind: 'process' }> }
- /** 回合:一次提问的全部 agent 输出合成一条气泡(2026-11-05) */
- | {
- kind: 'turn';
- key: string;
- blocks: TurnBlock[];
- open: boolean;
- collapsed: boolean;
- seconds: number;
- };
- /** 卡片渲染行(2026-11-07):用户消息一行,AI 一整轮一张卡 */
- type AiRow =
- | { kind: 'msg'; key: string; message: Message }
- | { kind: 'turn'; key: string; turn: AssistantTurn };
- /** 回合内部的一块:正文一条,或一组工具执行过程 */
- type TurnBlock =
- | { kind: 'msg'; key: string; message: Message }
- | { kind: 'tool'; key: string; block: Extract<Block, { kind: 'process' }> };
- type SheetTab = 'task' | 'file' | 'deliver' | 'state';
- /**
- * 会话详情(Demo 06 + 07 成果抽屉)
- *
- * 全屏路由(无 TabBar):顶栏固定 + 消息区独立滚动 + 底部输入坞固定。
- * 所有收发状态来自 `ChatService`(与桌面同一状态机),本页只做编排:
- * · `:id` = agentId;`:sessionId` 缺省 = 打开该容器最近一条会话
- * · 无 sessionId 且容器下没有会话 → `startBlank()` 建一条「新建对话」
- */
- @Component({
- selector: 'app-mb-chat-page',
- changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [
- MAppBarComponent,
- MDockComponent,
- MDrawerComponent,
- MEmptyComponent,
- MMsgComponent,
- MBadgeComponent,
- ToolActivityComponent,
- FilePreviewComponent,
- ImageLightboxComponent,
- LockScrollDirective,
- AssistantTurnComponent,
- ],
- template: `
- <div class="m-page m-page--fixed">
- <app-m-app-bar
- [title]="title()"
- [subtitle]="subtitle()"
- [back]="true"
- [rightCount]="2"
- (backTap)="back()"
- >
- <!--
- 会话切换(2026-11-09):抽屉组件早就引入了却没用,导致移动端进了
- 一条会话就出不去 —— 只能退回消息列表再进,等于不能切会话。
- -->
- <button barRight type="button" class="m-fab" aria-label="切换会话" (click)="drawerOpen.set(true)">
- <i class="fa-solid fa-bars-staggered" aria-hidden="true"></i>
- </button>
- <button barRight type="button" class="m-fab" aria-label="查看成果与状态" (click)="sheetOpen.set(true)">
- <i class="fa-solid fa-folder-open" aria-hidden="true"></i>
- </button>
- </app-m-app-bar>
- <app-m-drawer
- [open]="drawerOpen()"
- [activeSessionId]="chat.sessionId()"
- (close)="drawerOpen.set(false)"
- (newChat)="drawerOpen.set(false); back()"
- (newChatWithWs)="drawerOpen.set(false); back()"
- />
- @if (chat.connection() === 'disconnected' && chat.state() !== 'loading') {
- <button type="button" class="m-conn-bar" (click)="reconnect()">
- <i class="fa-solid fa-plug-circle-exclamation" aria-hidden="true"></i>
- 连接已断开,点击重连
- </button>
- }
- <!--
- 回环提示(2026-11-09):桌面早就有,移动端一直没有 ——
- 手机上看不到「为什么没回复」,只会一脸茫然。
- -->
- @if (loop.paused()) {
- <div class="m-loop-bar" role="status">
- <div class="m-loop-text">
- <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
- <span>检测到自动对话循环,已暂停:{{ loop.pauseDetail() }}</span>
- </div>
- <button type="button" class="m-loop-act" (click)="loop.resume()">继续协作</button>
- </div>
- }
- <div class="m-chat-body" #scroller (scroll)="onScroll($event)">
- @switch (chat.state()) {
- @case ('loading') {
- <div class="m-loading" aria-busy="true" aria-label="正在加载消息">
- <span class="m-typing"><i></i><i></i><i></i></span>
- </div>
- }
- @case ('error') {
- <app-m-empty
- icon="fa-solid fa-triangle-exclamation"
- title="消息加载失败"
- [description]="chat.error() ?? ''"
- [retryable]="true"
- (retry)="reconnect()"
- />
- }
- @case ('empty') {
- @if (chat.emptyReason() === 'removed') {
- <!-- 已软删会话(2026-11-09 用户裁决):URL 直达也不认领 -->
- <app-m-empty
- icon="fa-solid fa-trash-can"
- title="该会话已删除"
- description="这条对话已经被删除,无法再查看或继续发送消息。"
- />
- } @else if (chat.emptyReason() === 'blank') {
- <!--
- 空壳会话(2026-11-12):已翻到房间最早、目录里有这条、但一条消息都没有。
- 必须与「暂时取不到」分开说,否则用户会一直等、以为数据丢了。
- -->
- <app-m-empty
- icon="fa-solid fa-inbox"
- title="这条对话没有保存任何消息"
- description="会话记录还在,但服务器上找不到它的任何消息内容。"
- />
- } @else if (chat.emptyReason() === 'unloaded') {
- <!--
- 有会话但历史取不到(2026-11-09):不能伪装成「开始新的对话」。
- 🔴 2026-11-13 补「重试」(真机实测):移动网络下回溯请求可能失败,
- 原先没有重试入口,用户只能一直看着这句话。
- -->
- <app-m-empty
- icon="fa-solid fa-inbox"
- title="这个会话的消息暂时没加载出来"
- description="可能是网络波动导致历史读取失败。点下面重试,或直接发消息继续对话。"
- [retryable]="true"
- (retry)="retry()"
- />
- } @else {
- <app-m-empty
- icon="fa-solid fa-comments"
- title="开始新的对话"
- description="发一条消息,数字生命就会开始工作"
- />
- }
- }
- @default {
- <!-- 触顶自动加载(不再需要按钮);这里只显示加载指示 -->
- @if (chat.paginating()) {
- <p class="m-load-more" aria-live="polite">
- <i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>
- 正在加载更早的消息…
- </p>
- }
- <!--
- 回合聚合(2026-11-05):一次提问产生的多条 agent 消息合成**一条气泡**,
- 后到内容往里追加。过程行折叠在气泡顶部,执行中自动展开、完成后收起。
- -->
- <!--
- 卡片渲染(2026-11-07 用户需求):一轮 = 一张卡,
- 与桌面端共用 app-assistant-turn。用户消息仍走 app-m-msg,保持移动观感。
- -->
- @for (row of aiRows(); track row.key) {
- @if (row.kind === 'turn') {
- <app-assistant-turn
- [turn]="row.turn"
- [grouped]="false"
- (preview)="openFile($event)"
- (openTask)="goTask($event)"
- />
- } @else {
- <app-m-msg
- [message]="row.message"
- (retry)="retryMessage($event)"
- (openTask)="goTask($event)"
- />
- }
- }
- }
- }
- <!-- 与桌面同口径:回合卡已经在显示等待文案时,这里不再重复一条(2026-11-09) -->
- @if (chat.peerTyping() && aiRows().length === 0) {
- <p class="m-typing-line">{{ agentName() }} 正在输入…</p>
- }
- </div>
- <div class="m-composer-area">
- <!-- 待发附件:图片出缩略图(与 PC 端同构),其余出文件名 chip -->
- @if (chat.pendingFiles().length > 0) {
- <div class="m-pending" role="list" aria-label="待发送附件">
- @for (f of chat.pendingFiles(); track f.id) {
- @if (f.previewUrl) {
- <div class="m-pending-card" role="listitem">
- <img [src]="f.previewUrl" [alt]="f.file.name" class="m-pending-img" />
- <button
- type="button"
- class="m-pending-x"
- [attr.aria-label]="'移除 ' + f.file.name"
- (click)="chat.unstageFile(f.id)"
- >
- <i class="fa-solid fa-xmark" aria-hidden="true"></i>
- </button>
- </div>
- } @else {
- <span class="m-pending-item" role="listitem">
- <i class="fa-solid fa-paperclip" aria-hidden="true"></i>
- <span class="m-pending-name">{{ f.file.name }}</span>
- <button
- type="button"
- [attr.aria-label]="'移除 ' + f.file.name"
- (click)="chat.unstageFile(f.id)"
- >
- <i class="fa-solid fa-xmark" aria-hidden="true"></i>
- </button>
- </span>
- }
- }
- </div>
- }
- <app-m-dock
- [draft]="chat.draft()"
- [sending]="chat.sending()"
- [uploading]="chat.uploading()"
- [pct]="chat.uploadPct()"
- [stage]="chat.uploadPhase()"
- [target]="agentName()"
- [hasAttachments]="chat.pendingFiles().length > 0"
- [placeholder]="'发消息给 ' + agentName()"
- (draftChange)="chat.setDraft($event)"
- (send)="send($event)"
- (attach)="chat.stageFiles($event)"
- />
- </div>
- </div>
- <!-- 全屏预览层(2026-11-03):点会话内文件/成果都走这里,不再新开标签页 -->
- <app-image-lightbox
- [open]="!!lightbox()"
- [url]="lightbox()?.url ?? ''"
- [name]="lightbox()?.name ?? ''"
- [size]="lightbox()?.size ?? 0"
- (closed)="lightbox.set(null)"
- />
- <app-file-preview
- [open]="!!preview()"
- [url]="preview()?.url ?? ''"
- [name]="preview()?.name ?? ''"
- [mimeType]="preview()?.mimeType ?? ''"
- mode="modal"
- (closed)="preview.set(null)"
- />
- <!-- 成果抽屉(Demo 07:任务 / 文件 / 成果 / 状态) -->
- @if (sheetOpen()) {
- <div class="m-sheet-root" role="dialog" aria-modal="true" aria-label="会话面板" [appLockScroll]="true">
- <div class="m-sheet-mask" (click)="sheetOpen.set(false)"></div>
- <section class="m-sheet">
- <div class="m-grabber" aria-hidden="true"></div>
- <header class="m-sheet-head">
- <h2>{{ title() }}</h2>
- <button type="button" class="m-sheet-x" aria-label="关闭" (click)="sheetOpen.set(false)">
- <i class="fa-solid fa-xmark" aria-hidden="true"></i>
- </button>
- </header>
- <div class="m-sheet-tabs" role="tablist">
- @for (t of sheetTabs; track t.value) {
- <button
- type="button"
- role="tab"
- class="m-chip"
- [class.on]="sheetTab() === t.value"
- [attr.aria-selected]="sheetTab() === t.value"
- (click)="sheetTab.set(t.value)"
- >
- {{ t.label }}
- </button>
- }
- </div>
- <div class="m-sheet-body">
- @switch (sheetTab()) {
- @case ('task') {
- @if (relatedTasks().length > 0) {
- <ul class="m-list">
- @for (t of relatedTasks(); track t.id) {
- <li>
- <button
- type="button"
- class="m-row"
- [class.hl]="railHighlight() === t.id"
- (click)="goTask(t.id)"
- >
- <span class="m-row-body">
- <span class="m-row-t1">{{ t.title }}</span>
- <span class="m-row-t2">{{ t.updatedAt || '—' }}</span>
- </span>
- <app-m-badge [tone]="taskTone(t)">{{ taskText(t) }}</app-m-badge>
- </button>
- </li>
- }
- </ul>
- } @else {
- <app-m-empty title="暂无任务" [compact]="true" />
- }
- }
- @case ('file') {
- @if (files().length > 0) {
- <ul class="m-list">
- @for (f of files(); track f.url + f.name) {
- <li>
- <!-- 点开走全屏预览(2026-11-03),不再新开标签页 -->
- <button type="button" class="m-row" (click)="openFile(f)">
- <i class="fa-solid fa-file" aria-hidden="true"></i>
- <span class="m-row-body">
- <span class="m-row-t1">{{ f.name }}</span>
- <span class="m-row-t2">{{ fmtSize(f.size) }}</span>
- </span>
- <i class="fa-solid fa-chevron-right m-chev" aria-hidden="true"></i>
- </button>
- </li>
- }
- </ul>
- } @else {
- <app-m-empty title="暂无文件" [compact]="true" />
- }
- }
- @case ('deliver') {
- <!--
- 未上云提示(2026-11-09),与桌面 session-rail 同口径。
- 附件没有 url 时原来静默不显示,用户以为文件丢了。
- -->
- @if (notOnCloudCount() > 0) {
- <p class="m-rail-warn" role="status">
- <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
- 有 {{ notOnCloudCount() }} 个成果文件未上传云盘,暂时无法查看
- </p>
- }
- @if (deliverables().length > 0) {
- @for (d of deliverables(); track d.id) {
- <div class="m-deliver">
- <div class="m-deliver-head">
- <span class="m-row-t1">{{ d.title }}</span>
- <span class="m-row-t2">{{ d.version || '' }} · {{ d.deliveredAt }}</span>
- </div>
- @if (d.artifacts.length > 0) {
- <ul class="m-list">
- <!-- 遍历全部产物(原实现只取 artifacts[0],其余看不到) -->
- @for (a of d.artifacts; track a.url || a.name) {
- <li>
- <button type="button" class="m-row" (click)="openFile(a)">
- <i class="fa-solid fa-paperclip" aria-hidden="true"></i>
- <span class="m-row-body">
- <span class="m-row-t1">{{ a.name }}</span>
- <span class="m-row-t2">{{ a.type || '' }}</span>
- </span>
- <i class="fa-solid fa-chevron-right m-chev" aria-hidden="true"></i>
- </button>
- </li>
- }
- </ul>
- } @else {
- <p class="m-deliver-empty">暂无可用产物</p>
- }
- </div>
- }
- } @else {
- <app-m-empty title="暂无成果" [compact]="true" />
- }
- }
- @default {
- <dl class="m-kv">
- <dt>状态</dt>
- <!-- 统一口径(2026-11-08):原先直接渲染 statusText ?? status,英文 id 会露出 -->
- <dd>{{ agentState()?.text ?? '—' }}</dd>
- <dt>当前任务</dt>
- <dd>{{ agent()?.currentTask || '—' }}</dd>
- <dt>上次心跳</dt>
- <dd>{{ heartbeatText() }}</dd>
- <dt>连接</dt>
- <dd>{{ chat.connection() === 'connected' ? '已连接' : '未连接' }}</dd>
- <dt>会话</dt>
- <dd>{{ title() }}</dd>
- </dl>
- }
- }
- </div>
- </section>
- </div>
- }
- `,
- styles: [
- `
- :host {
- display: block;
- }
- .m-page {
- display: flex;
- flex-direction: column;
- height: 100dvh;
- background: var(--canvas);
- }
- .m-fab {
- display: grid;
- place-items: center;
- width: var(--touch-min);
- height: var(--touch-min);
- border: 0;
- border-radius: var(--r-tag);
- background: var(--surface);
- box-shadow: var(--shadow-soft);
- color: var(--ink);
- font-size: var(--fs-17);
- cursor: pointer;
- }
- .m-conn-bar {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: var(--sp-2);
- width: calc(100% - var(--mob-gutter) * 2);
- margin: 0 var(--mob-gutter);
- padding: var(--sp-2) var(--sp-3);
- border: 1px solid var(--warning);
- border-radius: var(--r-md);
- background: var(--warning-subtle);
- color: var(--warning);
- font: inherit;
- font-size: var(--fs-12);
- cursor: pointer;
- }
- /* 回环提示条(与 m-conn-bar 同一套 warning 令牌,但主体是说明 + 独立动作按钮) */
- .m-loop-bar {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- width: calc(100% - var(--mob-gutter) * 2);
- margin: 0 var(--mob-gutter) var(--sp-2);
- padding: var(--sp-2) var(--sp-3);
- border: 1px solid var(--warning);
- border-radius: var(--r-md);
- background: var(--warning-subtle);
- color: var(--warning); font-size: var(--fs-12);
- }
- .m-loop-text {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- flex: 1;
- min-width: 0;
- } .m-loop-text span {
- /* 提示较长,允许换行但不挤掉按钮 */
- overflow-wrap: anywhere;
- }
- /* 未上云的成果提示(2026-11-09),与桌面 rail-warn 同口径 */
- .m-rail-warn {
- display: flex;
- align-items: flex-start;
- gap: var(--sp-1);
- margin: var(--sp-2) var(--mob-gutter) 0;
- padding: var(--sp-2) var(--sp-3);
- border-radius: var(--r-tag);
- background: var(--warning-subtle);
- color: var(--warning);
- font-size: var(--fs-13);
- line-height: 1.5;
- }
- .m-loop-act {
- flex: none;
- /* 触达区 ≥44px(WCAG 2.2 AA) */
- min-height: 44px;
- padding: 0 var(--sp-3);
- border: 1px solid var(--warning);
- border-radius: var(--r-sm);
- background: transparent;
- color: var(--warning);
- font: inherit;
- font-size: var(--fs-13);
- cursor: pointer;
- }
- .m-turn {
- display: flex;
- flex-direction: column;
- gap: var(--sp-3);
- }
- .m-turn-head {
- display: inline-flex;
- align-items: center;
- gap: var(--sp-2);
- align-self: flex-start;
- min-height: 28px;
- padding: 0 var(--sp-3);
- border: 1px solid var(--edge);
- border-radius: var(--r-tag);
- background: var(--surface-2);
- color: var(--muted);
- font: inherit;
- font-size: var(--fs-12);
- cursor: pointer;
- }
- .m-exec {
- color: var(--info);
- }
- .m-chat-body {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- -webkit-overflow-scrolling: touch;
- display: flex;
- flex-direction: column;
- gap: var(--sp-4);
- padding: var(--sp-3) var(--mob-gutter) var(--sp-6);
- }
- .m-load-more {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: var(--sp-2);
- margin: 0;
- padding: var(--sp-2);
- color: var(--muted);
- font-size: var(--fs-12);
- }
- .m-typing-line {
- margin: 0;
- color: var(--muted);
- font-size: var(--fs-12);
- }
- .m-loading {
- display: grid;
- place-items: center;
- padding: var(--sp-8);
- }
- .m-typing {
- display: inline-flex;
- gap: 4px;
- }
- .m-typing i {
- width: 6px;
- height: 6px;
- border-radius: var(--r-tag);
- background: var(--edge-strong);
- animation: m-bounce 1s infinite ease-in-out;
- }
- .m-typing i:nth-child(2) {
- animation-delay: 0.15s;
- }
- .m-typing i:nth-child(3) {
- animation-delay: 0.3s;
- }
- @keyframes m-bounce {
- 0%,
- 80%,
- 100% {
- transform: translateY(0);
- opacity: 0.4;
- }
- 40% {
- transform: translateY(-4px);
- opacity: 1;
- }
- }
- .m-composer-area {
- flex: none;
- padding-bottom: calc(var(--mob-safe-bottom) + var(--sp-3));
- }
- .m-pending {
- display: flex;
- gap: var(--sp-2);
- padding: 0 var(--mob-gutter) var(--sp-2);
- overflow-x: auto;
- scrollbar-width: none;
- }
- .m-pending::-webkit-scrollbar {
- display: none;
- }
- /* 图片待发卡:缩略图 + 右上角移除 */
- .m-pending-card {
- position: relative;
- flex: none;
- width: 72px;
- height: 72px;
- border-radius: var(--r-md);
- overflow: hidden;
- border: 1px solid var(--edge);
- background: var(--surface-2);
- }
- .m-pending-img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- display: block;
- }
- .m-pending-x {
- position: absolute;
- top: 2px;
- right: 2px;
- display: grid;
- place-items: center;
- width: 20px;
- height: 20px;
- padding: 0;
- border: 0;
- border-radius: var(--r-tag);
- background: color-mix(in srgb, var(--ink) 70%, transparent);
- color: var(--surface);
- font-size: var(--fs-10);
- cursor: pointer;
- }
- .m-pending-item {
- display: inline-flex;
- align-items: center;
- gap: var(--sp-2);
- max-width: 100%;
- padding: var(--sp-1) var(--sp-2);
- border: 1px solid var(--edge);
- border-radius: var(--r-sm);
- background: var(--surface);
- color: var(--ink-2);
- font-size: var(--fs-12);
- }
- .m-pending-name {
- max-width: 160px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .m-pending-item button {
- border: 0;
- background: none;
- color: var(--muted);
- cursor: pointer;
- padding: 0;
- }
- .m-sheet-root {
- position: fixed;
- inset: 0;
- z-index: var(--z-modal);
- }
- .m-sheet-mask {
- position: absolute;
- inset: 0;
- background: var(--overlay);
- }
- .m-sheet {
- position: absolute;
- left: 0;
- right: 0;
- bottom: 0;
- display: flex;
- flex-direction: column;
- /* 默认全屏(2026-11-03 用户要求):原来是 max-height:82%,内容矮时只占一小块 */
- height: 100dvh;
- max-height: 100dvh;
- padding-top: var(--mob-safe-top);
- padding-bottom: var(--mob-safe-bottom);
- border-radius: var(--r-lg) var(--r-lg) 0 0;
- box-sizing: border-box;
- background: var(--surface);
- box-shadow: var(--shadow-overlay);
- animation: m-sheet-in var(--motion-panel) var(--ease-standard);
- }
- .m-grabber {
- width: 36px;
- height: 4px;
- margin: var(--sp-2) auto;
- border-radius: var(--r-tag);
- background: var(--edge-strong);
- }
- .m-sheet-head {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- padding: var(--sp-2) var(--mob-gutter) var(--sp-3);
- }
- .m-sheet-head h2 {
- flex: 1;
- margin: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- color: var(--ink);
- font-size: var(--fs-17);
- }
- .m-sheet-x {
- display: grid;
- place-items: center;
- width: var(--touch-min);
- height: var(--touch-min);
- border: 0;
- border-radius: var(--r-tag);
- background: none;
- color: var(--muted);
- font-size: var(--fs-17);
- cursor: pointer;
- }
- .m-sheet-tabs {
- display: flex;
- gap: var(--sp-2);
- padding: 0 var(--mob-gutter) var(--sp-3);
- overflow-x: auto;
- }
- .m-chip {
- flex: none;
- height: 34px;
- padding: 0 var(--sp-3);
- border: 1px solid var(--edge);
- border-radius: var(--r-tag);
- background: var(--surface);
- color: var(--ink-2);
- font: inherit;
- font-size: var(--fs-13);
- cursor: pointer;
- }
- .m-chip.on {
- border-color: var(--primary);
- background: var(--primary);
- color: var(--primary-content);
- font-weight: 600;
- }
- .m-sheet-body {
- overflow-y: auto;
- padding: 0 var(--mob-gutter) var(--sp-6);
- }
- .m-list {
- margin: 0;
- padding: 0;
- list-style: none;
- border: 1px solid var(--edge);
- border-radius: var(--r-lg);
- overflow: hidden;
- }
- .m-list > li + li {
- border-top: 1px solid var(--edge);
- }
- .m-row {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- width: 100%;
- min-height: 56px;
- padding: var(--sp-3) var(--sp-4);
- box-sizing: border-box;
- border: 0;
- background: var(--surface);
- color: var(--ink);
- font: inherit;
- text-align: left;
- text-decoration: none;
- cursor: pointer;
- }
- /* 深链定位高亮(2026-11-11):任务引用点进来时短暂标出是哪条 */
- .m-row.hl {
- background: var(--surface-hover);
- box-shadow: inset 3px 0 0 var(--primary);
- }
- .m-row-body {
- flex: 1;
- min-width: 0;
- }
- .m-row-t1,
- .m-row-t2 {
- display: block;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .m-row-t1 {
- font-size: var(--fs-15);
- }
- .m-row-t2 {
- color: var(--muted);
- font-size: var(--fs-13);
- }
- .m-kv {
- display: grid;
- grid-template-columns: 96px 1fr;
- gap: var(--sp-2) var(--sp-3);
- margin: 0;
- padding: var(--sp-4);
- border: 1px solid var(--edge);
- border-radius: var(--r-lg);
- font-size: var(--fs-13);
- }
- .m-kv dt {
- color: var(--muted);
- }
- .m-kv dd {
- margin: 0;
- color: var(--ink);
- word-break: break-all;
- }
- @keyframes m-sheet-in {
- from {
- transform: translateY(100%);
- }
- to {
- transform: translateY(0);
- }
- }
- @media (prefers-reduced-motion: reduce) {
- .m-sheet,
- .m-typing i {
- animation: none;
- }
- }
- `,
- ],
- })
- export class MbChatPage implements OnDestroy {
- private readonly route = inject(ActivatedRoute);
- private readonly router = inject(Router);
- private readonly tasksSvc = inject(TasksService);
- private readonly sessionsSvc = inject(ChatSessionService);
- private readonly view = inject(MobileChatViewService);
- /** 成果列表构造(从 agent 消息附件生成) */
- private readonly deliverablesSvc = inject(DeliverablesService);
- protected readonly chat = inject(ChatService);
- protected readonly loop = inject(LoopGuardService);
- protected readonly sheetOpen = signal(false);
- /** 图片走灯箱 */
- protected readonly lightbox = signal<{ url: string; name: string; size: number } | null>(null);
- /** 其余文件走全屏预览 */
- protected readonly preview = signal<{ url: string; name: string; mimeType: string } | null>(null);
- protected readonly sheetTab = signal<SheetTab>('task');
- /**
- * 抽屉里需要高亮定位的任务 id(2026-11-11)。
- *
- * 「任务」页路由下线后,消息里的任务引用改为「打开抽屉 + 定位」,
- * 与桌面 `openRailAt()` 同口径,2 秒后自动清空。
- */
- protected readonly railHighlight = signal<string | null>(null);
- private railHighlightTimer: ReturnType<typeof setTimeout> | null = null;
- /** 抽屉在本页不使用(返回即可),保留导入以便后续「会话切换」扩展 */
- protected readonly drawerOpen = signal(false);
- protected readonly sheetTabs: { value: SheetTab; label: string }[] = [
- { value: 'task', label: '任务' },
- { value: 'file', label: '文件' },
- { value: 'deliver', label: '成果' },
- { value: 'state', label: '状态' },
- ];
- protected readonly fmtSize = fmtSize;
- protected readonly agent = computed(() => this.chat.agent());
- protected readonly agentName = computed(() => this.chat.agent()?.name ?? '数字生命');
- /**
- * 消息 → 渲染行:**连续的终端活动消息合并成一个折叠组**。
- *
- * 与桌面端同一套规则(`desktop/pages/chat/chat.ts` 的 `messageRows`):
- * · 容器把 Hermes 每一步都作为独立消息发出(💻 terminal + 命令)
- * · 相邻且都属于终端活动 → 合成一组;遇到正常对话立即断开
- *
- * 🔴 组 key 用**组内第一条消息的 id**,不能用下标:
- * 流式追加时下标会变,导致同组被销毁重建(内容闪烁)。
- */
- /**
- * 消息 → 渲染行(2026-11-05 改为**回合聚合**)。
- *
- * ## 变化
- *
- * 原来:每条 agent 消息一个气泡 → 一条回答被容器拆成 2~3 条,读起来断续。
- * 现在:**一次提问的所有 agent 输出合成一个回合(一条气泡)**,内容往里追加。
- *
- * ## 边界
- *
- * `buildTurns` 负责切分(用户消息锚点 + typing 结束静默 3 秒),
- * 本方法只负责把回合映射成渲染行 —— 逻辑分离,便于单测。
- *
- * ## 兼容
- *
- * 用户消息仍逐条渲染;回合内的过程行(💻 terminal 等)折叠到回合顶部。
- */
- /**
- * 渲染行(2026-11-07 用户需求):AI 的一整轮合并成一张卡。
- *
- * 与桌面端共用 `app-assistant-turn` —— 用户裁决「复用同一个组件」,
- * 移动端差异(触达区 44px、字号 ≥16px、段间距)由组件内的
- * `bp.below-mobile` 分支处理,两处渲染逻辑保持单一来源。
- */
- protected readonly aiRows = computed<AiRow[]>(() => {
- const out: AiRow[] = [];
- /*
- * 🔴 依赖 `clockTick` 而不是 `now`(2026-11-10,与桌面端同口径)。
- *
- * 原来这里读 `this.now()`,于是时钟每秒推进都会让**整页 aiRows 重算**,
- * 每个 turn 对象都是新引用 → `app-assistant-turn` 的 input 变化 →
- * 流式推进的 `shownMap` 可能被重置,正文从头重放。
- * `clockTick` 是个计数器:每秒变一次,但只让**读它的地方**重算,
- * 回合数据本身只在消息变化时重建。
- */
- this.clockTick();
- const now = Date.now();
- for (const t of turnsFromDm(
- buildTurns({ messages: this.chat.messages(), typing: this.chat.peerTyping(), now }),
- now,
- this.chat.peerTyping(),
- /* 与桌面同口径(2026-11-08):25 秒静默窗口 + 容器明确空闲时立即收尾 */
- QUIET_MS,
- this.chat.agentIdle(),
- /* 容器结束信号(2026-11-11):`m.typing=false` 的本地接收时刻,与桌面同口径 */
- this.chat.turnEndedAfter(),
- )) {
- /* 连发的前置提问先展示(2026-11-11,与桌面同口径) */
- for (const prevAsk of t.priorAsks ?? []) {
- out.push({ kind: 'msg', key: prevAsk.id, message: prevAsk });
- }
- if (t.userMessage) {
- out.push({ kind: 'msg', key: t.userMessage.id, message: t.userMessage });
- }
- // 同一轮输入里的附件紧跟文字(2026-11-10,与桌面同口径)
- for (const f of t.userFiles ?? []) {
- out.push({ kind: 'msg', key: f.id, message: f });
- }
- // 已发出等待回复 / 有用户附件 也要出卡(2026-11-09 / 2026-11-10,与桌面同口径)
- if (t.merged || t.live || t.userMessage || (t.userFiles?.length ?? 0) > 0) {
- out.push({ kind: 'turn', key: `turn-${t.id}`, turn: t });
- }
- }
- return out;
- });
- /*
- * 超时未响应的全局提示(2026-11-10 用户裁决,与桌面同口径)。
- *
- * 🔴 不自动消失,必须用户手动关闭。
- * 🔴 不只要弹一次:**当前会话只要存在超时回合,就要一直显示**;
- * 条件消失(收到回复)后自动撤掉,避免残留。
- */
- private readonly alert = inject(AlertService);
- private static readonly TIMEOUT_TITLE = '容器长时间未响应';
- private watchTurnTimeout(turns: readonly AssistantTurn[]): void {
- /*
- * 🔴 只看最后一轮(2026-11-10,与桌面同口径):更早的回合不论有没有回复,
- * 都已经过去了,不该弹提示。
- */
- const last = turns[turns.length - 1];
- if (!last) {
- this.alert.syncState(MbChatPage.TIMEOUT_TITLE, false);
- return;
- }
- /* 已处理过的超时 → 直接抛弃,不再判定(用户裁决 2026-11-10) */
- if (this._handledTimeouts.has(last.id)) {
- this.alert.syncState(MbChatPage.TIMEOUT_TITLE, false);
- return;
- }
- const userMs = last.userMessage ? Date.parse(last.userMessage.ts) || 0 : 0;
- const hasOutput = last.blocks.some((b) => b.body.length > 0 || b.steps.length > 0);
- const timedOut = userMs > 0 && isTurnTimedOut(Date.now() - userMs, hasOutput, last.live);
- /* 卡住检测(2026-11-11,与桌面同口径):回了一部分就停住的情况 */
- const lastActivity = last.blocks.reduce((max, b) => Math.max(max, b.updatedAt), 0);
- const idleMs = lastActivity > 0 ? Date.now() - lastActivity : 0;
- const stalled = isTurnStalled(idleMs, hasOutput, last.live);
- if (timedOut || stalled) {
- this._handledTimeouts.add(last.id);
- this.alert.resetDismiss(MbChatPage.TIMEOUT_TITLE);
- this.alert.syncState(
- MbChatPage.TIMEOUT_TITLE,
- true,
- stalled
- ? `容器已停止响应超过 ${Math.round(STALL_MS / 1000)} 秒,任务可能已中断。可点「重新发送」把刚才的消息放回输入框。`
- : `最近一条消息已超过 ${Math.round(TURN_TIMEOUT_MS / 1000)} 秒没有回复。请确认容器是否在线,或点「重新发送」重试。`,
- { label: '重新发送' },
- );
- return;
- }
- this.alert.syncState(MbChatPage.TIMEOUT_TITLE, false);
- }
- /** 把最后一条用户消息放回输入框(2026-11-11,与桌面同口径,不自动发送) */
- private resendLast(): void {
- const mine = [...this.chat.messages()].reverse().find((m) => m.sender === 'me' && m.text?.trim());
- if (!mine?.text) return;
- this.chat.setDraft(mine.text);
- this.alert.show('已放回输入框', '确认内容无误后再点发送即可。', 'warning');
- }
- /**
- * 已处理过(提示过)的超时回合 id。
- *
- * 🔴 用户裁决:超时一次性处理,之后直接抛弃。
- * 只存内存、不落盘 —— 落盘会跨账号串数据(用户明确否决)。
- */
- private readonly _handledTimeouts = new Set<string>();
- /**
- * 把回合内**连续的 agent 正文**合成一个块(2026-11-06 用户要求)。
- *
- * 容器常把一条回答拆成多条消息;此前每段各占一个块,读起来是碎片。
- * 工具块、附件、失败消息各自断开,规则见 `core/message-merge.ts`。
- */
- /**
- * 时钟计数器:每秒 +1,**只用于驱动依赖它的 computed 重算**(2026-11-10)。
- *
- * 与桌面端 `clockTick` 同口径。不要改成「读一个 Date.now() 快照信号」——
- * 那样整页回合数据每秒重建,流式动画会被打断。
- */
- private readonly clockTick = signal(0);
- private nowTimer: ReturnType<typeof setInterval> | null = null;
- /** 回合折叠状态(用户手动切换覆盖默认) */
- protected readonly title = computed(
- () => this.chat.currentSession()?.title ?? this.chat.agent()?.name ?? '会话',
- );
- /**
- * 容器名 + 状态(2026-11-11 收敛)。
- *
- * 与 PC 会话页头同一判据 `sessionPresence`:
- * 在线/离线走 `livenessOf`,本会话是否在回复单独叠加。
- * 离线时相位一律不显示(不能说明明断了还在回复)。
- */
- protected readonly presence = computed(() =>
- sessionPresence(this.chat.agent(), this.chat.peerTyping() ? 'replying' : undefined),
- );
- protected readonly subtitle = computed(() => {
- const a = this.chat.agent();
- if (!a) return '';
- return `${a.name} · ${this.presence().text}`;
- });
- /** 状态抽屉用同一个口径 */
- protected readonly agentState = computed(() => {
- const a = this.chat.agent();
- return a ? { text: this.presence().text, tone: this.presence().tone } : null;
- });
- /**
- * 心跳时间 → 人话(2026-11-08)。
- * 原先直接渲染 ISO 原文(`2026-11-08T03:12:44.000Z`),用户在手机上没法读。
- */
- protected readonly heartbeatText = computed(() => {
- const a = this.chat.agent();
- const sec = a?.heartbeatAgeSec;
- if (typeof sec === 'number' && Number.isFinite(sec)) {
- if (sec < 60) return `${Math.max(0, Math.round(sec))} 秒前`;
- if (sec < 3600) return `${Math.round(sec / 60)} 分钟前`;
- if (sec < 86400) return `${Math.round(sec / 3600)} 小时前`;
- return `${Math.round(sec / 86400)} 天前`;
- }
- const t = a?.heartbeatAt ? Date.parse(a.heartbeatAt) : NaN;
- if (!Number.isFinite(t)) return '—';
- return new Date(t).toLocaleString('zh-CN', { hour12: false });
- });
- /** 该会话下、该数字生命的任务(Demo 抽屉「任务」页签) */
- protected readonly relatedTasks = computed<Task[]>(() => {
- const id = this.chat.agentId();
- return id ? this.tasksSvc.all().filter((t) => t.agentId === id) : [];
- });
- /** 会话内出现过的全部附件(Demo 抽屉「文件」页签) */
- /**
- * 「文件」页签 = 本会话里**用户自己上传的**附件(2026-11-09 与桌面同口径)。
- *
- * 🔴 容器产出的不算「文件」,那是「成果」—— 见 `deliverables`。
- * 原来不看 sender,同一份成果在「文件」和「成果」各出现一次。
- */
- protected readonly files = computed(() => {
- const out: NonNullable<Message['file']>[] = [];
- const seen = new Set<string>();
- /*
- * ① 消息流里我发的附件 —— **先走这条**(2026-11-11,与桌面同口径):
- * 带真实 size 与 cloudUrl,信息最全。
- */
- for (const m of this.chat.messages()) {
- const f = m.file;
- if (m.sender !== 'me' || !f) continue;
- const id = fileIdentity(f.name, f.size);
- if (seen.has(id)) continue;
- seen.add(id);
- out.push(f);
- }
- /*
- * ② 台账里 `uploadedBy:'user'` 的记录:刷新后仍在,补上消息流翻页取不到的历史。
- *
- * 🔴 去重不能比 URL:台账存 S3 地址、消息流 `f.url` 是 `mxc://`,永远不等
- * → 同一个文件会出现两条(用户实测)。改按「名字+大小」。
- */
- for (const d of this._dbUserFiles()) {
- const a = d.artifacts[0];
- if (!a?.url) continue;
- const bytes = d.sizeBytes ?? 0;
- const id = fileIdentity(a.name, bytes);
- if (seen.has(id)) continue;
- seen.add(id);
- out.push({ kind: 'file', name: a.name, url: a.url, size: bytes > 0 ? bytes : -1, mimeType: a.type ?? '' });
- }
- return out;
- });
- /**
- * 成果抽屉「成果」页签 = **本会话里容器产出的文件**(2026-11-09 与桌面同口径)。
- *
- * 语义分界:
- * · 文件(`files`)= 我上传的
- * · 成果(这里) = 容器产出的
- *
- * 两个来源合并去重:会话记录 `deliverableUrls` + 消息流里 agent 发的文件。
- */
- protected readonly deliverables = computed(() => {
- const sess = this.chat.currentSession();
- /* 台账已按来源分流:成果只吃「容器产出」那半边(2026-11-11) */
- const fromDb = this.dbFiles();
- const agentFiles = this.chat
- .messages()
- .filter((m) => m.sender === 'agent' && m.file?.url)
- .map((m) => m.file!);
- const fromMessages = this.deliverablesSvc.fromAgentFiles(agentFiles);
- const fromRecord = this.view.bySessionUrlsOf(sess?.deliverableUrls);
- return this.deliverablesSvc.merge(fromDb, fromMessages, fromRecord);
- });
- /**
- * 按会话从数据库加载的文件(2026-11-10:与桌面端同口径)。
- * 2026-11-11 起按来源分流,见 `_dbUserFiles` / `_dbFiles` 注释。
- */
- private readonly _dbUserFiles = signal<Deliverable[]>([]);
- private readonly _dbFiles = signal<Deliverable[]>([]);
- readonly dbFiles = this._dbFiles.asReadonly();
- /** 拉当前会话的文件台账;会话切换时重跑 */
- private loadSessionFiles(): void {
- const sid = this.chat.currentSession()?.id;
- if (!sid) {
- this._dbUserFiles.set([]);
- this._dbFiles.set([]);
- return;
- }
- void this.deliverablesSvc
- .loadSplitForSession(sid)
- .then(({ userFiles, agentFiles }) => {
- if (this.chat.currentSession()?.id !== sid) return;
- this._dbUserFiles.set(userFiles);
- this._dbFiles.set(agentFiles);
- })
- .catch(() => {
- if (this.chat.currentSession()?.id === sid) {
- this._dbUserFiles.set([]);
- this._dbFiles.set([]);
- }
- });
- }
- /** 有多少成果附件还没上云(与桌面 `session-rail` 同口径) */
- protected readonly notOnCloudCount = computed(() =>
- this.deliverables().reduce((n, d) => n + d.artifacts.filter((a) => !a.url).length, 0),
- );
- private readonly scroller = viewChild<ElementRef<HTMLDivElement>>('scroller');
- /** 用户是否停在底部附近(不在底部时不自动滚动,避免打断向上翻历史) */
- private stickBottom = true;
- /**
- * 用户是否**主动**上翻离开了底部(2026-11-10,与桌面端 `userScrolledAway` 同口径)。
- *
- * 🔴 为什么 `stickBottom` 不够:
- * `stickBottom` 只记「此刻离底部多远」,而程序滚动本身也会触发 scroll 事件 ——
- * 用户上翻 → 新消息到达把列表拉回底部 → scroll 事件又把 `stickBottom` 置回 true →
- * 下一次新消息继续拉回。形成「上翻 → 被拽回 → 再也翻不上去」的死循环
- * (两轮回复交替到达时最明显,用户实测)。
- * 这里单独记「用户是不是主动走的」,且只在**用户自己滚动**时改变。
- */
- private userScrolledAway = false;
- /** 正在加载更早消息(防重入 + 分页期间不贴底) */
- private loadingEarlier = false;
- /** 上次贴底的时刻(节流用) */
- private lastStickAt = 0;
- protected retryMessage(id: string): void {
- void this.chat.retry(id);
- }
- constructor() {
- /* 提示条「重新发送」(2026-11-11,与桌面同口径) */
- this.alert.onAction((a) => {
- if (a.title === MbChatPage.TIMEOUT_TITLE) this.resendLast();
- });
- /*
- * 右侧栏:会话切换或消息数变化时重拉文件台账(2026-11-10,与桌面端同口径)。
- * 容器产出的文件要等登记完才进台账,回合结束消息数变化正好触发刷新。
- */
- effect(() => {
- this.chat.currentSession()?.id;
- this.chat.messages().length;
- this.loadSessionFiles();
- });
- // 路由参数 → 打开会话;参数变化时重开
- this.route.paramMap.subscribe((pm) => {
- const agentId = pm.get('id');
- if (!agentId) return;
- const sid = pm.get('sessionId') ?? undefined;
- void this.openSession(agentId, sid);
- void this.tasksSvc.ensureAllLoaded();
- });
- // 新消息到达:贴底时滚到底(用户主动上翻期间不打扰,2026-11-10)
- effect(() => {
- this.chat.messages();
- if (this.loadingEarlier) return;
- if (this.stickBottom && !this.userScrolledAway) this.stickToBottom();
- });
- // 有活着的回合时才跑每秒时钟(见 `syncClock` 注释)
- effect(() => {
- this.aiRows();
- untracked(() => this.syncClock());
- });
- /*
- * 超时未响应 → 全局提示(2026-11-10,A2 重构,与桌面同口径)。
- *
- * 🔴 不再依赖 `now`。
- * 每秒时钟在「没有任何 live 回合」时停表,而超时恰恰发生在 live
- * 已变 false 之后 → 判定永远跑不到 → 提示不弹。
- * 现在只在这里同步看门狗开关,周期判定交给独立 interval。
- */
- effect(() => {
- this.aiRows();
- untracked(() => this.syncTimeoutWatch());
- });
- }
- ngOnDestroy(): void {
- // 离开会话不 close:Matrix 连接与未读计数需要保持(与桌面一致)
- if (this.nowTimer !== null) {
- clearInterval(this.nowTimer);
- this.nowTimer = null;
- }
- this.stopTimeoutWatch();
- }
- /*
- * 超时看门狗(2026-11-10,A2)。
- * 与 `syncClock` 分开:后者随 live 停表,前者只看「有无未回复的回合」。
- */
- private timeoutHandle: ReturnType<typeof setInterval> | null = null;
- private syncTimeoutWatch(): void {
- const waiting = this.hasPendingTurn();
- if (waiting && this.timeoutHandle === null) {
- this.timeoutHandle = setInterval(() => {
- this.evaluateTimeout();
- if (!this.hasPendingTurn()) this.stopTimeoutWatch();
- }, 5000);
- this.evaluateTimeout();
- } else if (!waiting && this.timeoutHandle !== null) {
- this.stopTimeoutWatch();
- }
- }
- private stopTimeoutWatch(): void {
- if (this.timeoutHandle !== null) {
- clearInterval(this.timeoutHandle);
- this.timeoutHandle = null;
- }
- }
- /** 是否存在「已发出、容器还没有任何输出」的回合(与 live 无关) */
- private hasPendingTurn(): boolean {
- /*
- * 🔴 2026-11-10 用户裁决:超时即终态,之后直接抛弃,不再监听。
- *
- * 只在「距发出还没超过超时窗口 + 10 秒」时才盯着。
- * 刷新页面时老消息早已超出窗口 → 看门狗不会启动 → 不会再弹。
- */
- const now = Date.now();
- return this.aiRows().some((r) => {
- if (r.kind !== 'turn') return false;
- const t = r.turn;
- if (!t.userMessage) return false;
- if (this._handledTimeouts.has(t.id)) return false;
- const userMs = Date.parse(t.userMessage.ts) || 0;
- if (!userMs) return false;
- if (now - userMs >= TURN_TIMEOUT_MS + 10_000) return false;
- return !t.blocks.some((b) => b.body.length > 0 || b.steps.length > 0);
- });
- }
- private evaluateTimeout(): void {
- const turns = this.aiRows().filter((r) => r.kind === 'turn').map((r) => r.turn);
- this.watchTurnTimeout(turns);
- }
- /*
- * 🔴 时钟驱动(2026-11-09,C2)。
- *
- * `now` signal 原先只有声明、没有任何地方 set —— `aiRows` 只在
- * `messages()` / `peerTyping()` 变化时重算。容器若一直报 typing、
- * 又不再发新消息,回合的「25 秒静默」永远等不到,卡片会一直挂在
- * 「正在输出…」(用户实测 163 秒)。
- *
- * 每秒推进一次 `now`,让静默判定真正随时间走。只在有活着的回合时起表,
- * 空闲页面不白跑定时器;离页即清。
- */
- private syncClock(): void {
- const needsTick = this.aiRows().some((r) => r.kind === 'turn' && r.turn.live);
- if (needsTick && this.nowTimer === null) {
- this.nowTimer = setInterval(() => {
- this.clockTick.update((n) => n + 1);
- if (!this.aiRows().some((r) => r.kind === 'turn' && r.turn.live)) {
- if (this.nowTimer !== null) clearInterval(this.nowTimer);
- this.nowTimer = null;
- }
- }, 1000);
- } else if (!needsTick && this.nowTimer !== null) {
- clearInterval(this.nowTimer);
- this.nowTimer = null;
- }
- }
- /**
- * 打开会话;`sessionId === 'new'` 为「新建对话」入口(与桌面 `/chat/:id/new` 同口径)。
- *
- * 🔴 建出会话后立刻 replaceUrl 成真实会话地址,避免刷新回到 `new`;
- * 这里**不调用 pruneEmpty**(桌面为此踩过坑:刚建的空会话被当成空壳删掉)。
- */
- private async openSession(agentId: string, sessionId?: string): Promise<void> {
- if (sessionId === 'new') {
- const ws = this.route.snapshot.queryParamMap.get('ws');
- /* 一房间一会话:建房完成后再进入(2026-11-12,与桌面端同口径) */
- const created = await this.chat.startBlankWithRoom(agentId, ws || null);
- if (created) {
- /*
- * 首页输入坞带过来的草稿(2026-11-09):用户先在首页打字、再选人,
- * 字不能丢。取回后立刻清掉,避免下次新建时又冒出来。
- */
- const carryKey = PENDING_DRAFT_KEY(agentId);
- const carried = sessionStorage.getItem(carryKey);
- if (carried) {
- sessionStorage.removeItem(carryKey);
- this.chat.setDraft(carried);
- }
- void this.router.navigateByUrl(
- `/mobile/chat/${agentId}/${encodeURIComponent(created.id)}`,
- { replaceUrl: true },
- );
- return;
- }
- } else {
- await this.chat.open(agentId, sessionId);
- this.sessionsSvc.pruneEmpty(this.chat.sessionId() ?? undefined);
- }
- this.stickBottom = true;
- this.userScrolledAway = false;
- this.lastStickAt = 0;
- queueMicrotask(() => this.scrollToBottom());
- void this.fillViewportIfNeeded();
- }
- protected send(text: string): void {
- /*
- * 自己发消息 = 明确想看结果 → 解除「用户上翻」保护并直接贴底。
- * 否则用户之前上翻过、发给新消息后还停在半空,看不到自己的消息和目标容器状态。
- */
- this.userScrolledAway = false;
- this.stickBottom = true;
- this.lastStickAt = 0;
- void this.chat.sendText(text);
- this.stickToBottom();
- }
- protected retry(): void {
- const agentId = this.route.snapshot.paramMap.get('id');
- const sid = this.route.snapshot.paramMap.get('sessionId') ?? undefined;
- if (agentId) void this.chat.open(agentId, sid, true);
- }
- protected reconnect(): void {
- this.retry();
- }
- protected loadMore(): void {
- void this.loadEarlier();
- }
- /**
- * 滚动处理:
- * ① 更新「是否贴底」(新消息到达时决定要不要自动滚到底)
- * ② **触顶自动加载更早消息**(与 PC 端 `desktop/pages/chat/chat.ts` 同口径)
- */
- protected onScroll(e: Event): void {
- const el = e.target as HTMLElement;
- const gap = el.scrollHeight - el.scrollTop - el.clientHeight;
- this.stickBottom = gap < 80;
- /*
- * 用户上翻的判定阈值比贴底恢复的阈值大(与桌面端 160px 同口径):
- * 只有明显离开底部才算「主动走开」,避免图片异步加载把高度撑大后误判。
- * 滚回接近底部时自动恢复贴底。
- */
- this.userScrolledAway = gap > 160;
- if (el.scrollTop > TOP_LOAD_PX) return;
- if (this.loadingEarlier || this.chat.paginating() || !this.chat.hasMoreHistory()) return;
- void this.loadEarlier();
- }
- /**
- * 加载更早的消息,并**保持视口位置不变**。
- *
- * 🔴 不做补偿的话,分页插入后 `scrollTop` 仍是原值,
- * 用户眼前的内容会整体往下窜(PC 端早期踩过,见 desktop chat.ts:2921-2940)。
- *
- * 做法与 PC 一致:记下加载前的 scrollHeight / scrollTop,等两帧让新内容完成布局,
- * 再把 scrollTop 加上新增的高度差。
- */
- private async loadEarlier(): Promise<void> {
- if (this.loadingEarlier) return;
- const el = this.scroller()?.nativeElement;
- if (!el || !this.chat.hasMoreHistory()) return;
- this.loadingEarlier = true;
- const beforeHeight = el.scrollHeight;
- const beforeTop = el.scrollTop;
- try {
- await this.chat.paginate();
- // 等两帧:一帧提交 DOM、一帧完成布局(图片/表格撑高)
- await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))));
- const after = this.scroller()?.nativeElement;
- if (after) {
- after.scrollTop = beforeTop + (after.scrollHeight - beforeHeight);
- }
- } finally {
- this.loadingEarlier = false;
- }
- }
- /**
- * 首屏不满一屏且还有更早消息时自动补齐(最多 3 轮)。
- * 否则用户看到内容没填满、却没有可滚动空间,永远触发不到「触顶加载」。
- *
- * 🔴 新建态不补齐(2026-11-08):新建会话本来就没有更早的消息,
- * 而 `hasMoreHistory` 取自房间级 token —— 不挡就会自动翻出整个房间的旧历史。
- */
- private async fillViewportIfNeeded(): Promise<void> {
- if (this.chat.state() === 'empty') return;
- for (let i = 0; i < 3; i++) {
- const el = this.scroller()?.nativeElement;
- if (!el || !this.chat.hasMoreHistory()) return;
- if (el.scrollHeight > el.clientHeight + 8) return;
- await this.loadEarlier();
- }
- }
- protected back(): void {
- void this.router.navigateByUrl('/mobile/messages');
- }
- /**
- * 任务深链(2026-11-11 改)。
- *
- * 「任务」页路由已下线(用户裁决:入口和路由一起关),原来跳
- * `/mobile/tasks/:id` 会落到消息列表,用户以为点坏了。
- * 改为**留在当前页**:关掉抽屉、打开成果/状态抽屉的任务页,并高亮该条。
- */
- protected goTask(id: string): void {
- this.sheetTab.set('task');
- this.railHighlight.set(id);
- this.sheetOpen.set(true);
- if (this.railHighlightTimer) clearTimeout(this.railHighlightTimer);
- this.railHighlightTimer = setTimeout(() => this.railHighlight.set(null), 2000);
- }
- /**
- * 打开文件预览(2026-11-03)。
- *
- * 与 PC 端 `openFile()` 同口径:按类型分流 —— 图片进灯箱,其余进通用预览器。
- * 原实现用 `window.open` 新开标签页,移动端体验割裂(离开 App、失去返回路径)。
- */
- protected openFile(f: { name: string; url?: string; mimeType?: string; size?: number }): void {
- const url = f.url;
- if (!url) return;
- const mime = f.mimeType ?? '';
- if (previewKindOf(f.name || url, mime) === 'image') {
- this.lightbox.set({ url, name: f.name, size: f.size ?? 0 });
- return;
- }
- this.preview.set({ url, name: f.name, mimeType: mime });
- }
- protected taskTone(t: Task): BadgeTone {
- return TASK_TONE[t.status] ?? 'neutral';
- }
- protected taskText(t: Task): string {
- return TASK_STATUS_TEXT[t.status] ?? t.status;
- }
- private scrollToBottom(): void {
- const el = this.scroller()?.nativeElement;
- if (el) el.scrollTop = el.scrollHeight;
- }
- /**
- * 贴底(2026-11-10,与桌面端 `stickToBottom` 同口径)。
- *
- * · 用户主动上翻 → 不拽回
- * · 正在加载更早消息 → 不贴底(那是往上插入)
- * · 200ms 节流:流式输出时每帧都在长高,不节流会和渲染抢主线程
- * · 补一次 rAF + 一次延时:图片 / Markdown 异步渲染后高度还会变
- */
- private stickToBottom(): void {
- if (this.userScrolledAway) return;
- if (this.loadingEarlier) return;
- const now = Date.now();
- if (now - this.lastStickAt < 200) return;
- this.lastStickAt = now;
- queueMicrotask(() => this.scrollToBottom());
- requestAnimationFrame(() => {
- this.scrollToBottom();
- setTimeout(() => this.scrollToBottom(), 120);
- });
- }
- }
- /** 渲染行的时间戳(用于按时间排序) */
- function keyTs(r: MessageRow): number {
- const ts = (m?: Message): number => (m ? Date.parse(m.ts) || 0 : 0);
- if (r.kind === 'msg') return ts(r.message);
- if (r.kind === 'turn') {
- for (const b of r.blocks) {
- if (b.kind === 'msg') return ts(b.message);
- }
- }
- return 0;
- }
|