| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476 |
- 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 { resolveAgentState } from '../../../core/agent-state';
- import { mergeConsecutiveBodies } from '../../../core/message-merge';
- import { ToolActivityComponent } from '../../../components/domain/tool-activity/tool-activity';
- import { AssistantTurn, isTurnTimedOut, 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() === 'unloaded') {
- <!-- 有会话但历史取不到(2026-11-09):不能伪装成「开始新的对话」 -->
- <app-m-empty
- icon="fa-solid fa-inbox"
- title="这个会话没有可加载的消息"
- description="会话记录还在,但关联的消息暂时取不到。可以直接发消息开始新的对话。"
- />
- } @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()"
- [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" (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;
- }
- .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');
- /** 抽屉在本页不使用(返回即可),保留导入以便后续「会话切换」扩展 */
- 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(),
- )) {
- 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);
- if (timedOut) {
- this._handledTimeouts.add(last.id);
- this.alert.resetDismiss(MbChatPage.TIMEOUT_TITLE);
- this.alert.syncState(
- MbChatPage.TIMEOUT_TITLE,
- true,
- `最近一条消息已超过 ${Math.round(TURN_TIMEOUT_MS / 1000)} 秒没有回复。请确认容器是否在线,或重试。`,
- );
- return;
- }
- this.alert.syncState(MbChatPage.TIMEOUT_TITLE, false);
- }
- /**
- * 已处理过(提示过)的超时回合 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-08 统一口径):全站同一套 resolveAgentState */
- protected readonly subtitle = computed(() => {
- const a = this.chat.agent();
- if (!a) return '';
- return `${a.name} · ${resolveAgentState({ agent: a }).text}`;
- });
- /** 状态抽屉用同一个口径(不自拼 statusText,英文 id 不外露) */
- protected readonly agentState = computed(() => {
- const a = this.chat.agent();
- return a ? resolveAgentState({ agent: a }) : 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(() =>
- this.chat
- .messages()
- .filter((m) => m.sender === 'me')
- .map((m) => m.file)
- .filter((f): f is NonNullable<typeof f> => !!f),
- );
- /**
- * 成果抽屉「成果」页签 = **本会话里容器产出的文件**(2026-11-09 与桌面同口径)。
- *
- * 语义分界:
- * · 文件(`files`)= 我上传的
- * · 成果(这里) = 容器产出的
- *
- * 两个来源合并去重:会话记录 `deliverableUrls` + 消息流里 agent 发的文件。
- */
- protected readonly deliverables = computed(() => {
- const sess = this.chat.currentSession();
- 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:与桌面端同口径) */
- private readonly _dbFiles = signal<Deliverable[]>([]);
- readonly dbFiles = this._dbFiles.asReadonly();
- /** 拉当前会话的文件台账;会话切换时重跑 */
- private loadSessionFiles(): void {
- const sid = this.chat.currentSession()?.id;
- if (!sid) {
- this._dbFiles.set([]);
- return;
- }
- void this.deliverablesSvc
- .loadForSession(sid)
- .then((files) => {
- if (this.chat.currentSession()?.id === sid) this._dbFiles.set(files);
- })
- .catch(() => {
- if (this.chat.currentSession()?.id === sid) 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-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');
- const created = await this.chat.startBlank(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');
- }
- protected goTask(id: string): void {
- this.sheetOpen.set(false);
- void this.router.navigateByUrl(`/mobile/tasks/${id}`);
- }
- /**
- * 打开文件预览(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;
- }
|