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 } /** 回合:一次提问的全部 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 }; 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: `
@if (chat.connection() === 'disconnected' && chat.state() !== 'loading') { } @if (loop.paused()) {
检测到自动对话循环,已暂停:{{ loop.pauseDetail() }}
}
@switch (chat.state()) { @case ('loading') {
} @case ('error') { } @case ('empty') { @if (chat.emptyReason() === 'removed') { } @else if (chat.emptyReason() === 'unloaded') { } @else { } } @default { @if (chat.paginating()) {

正在加载更早的消息…

} @for (row of aiRows(); track row.key) { @if (row.kind === 'turn') { } @else { } } } } @if (chat.peerTyping() && aiRows().length === 0) {

{{ agentName() }} 正在输入…

}
@if (chat.pendingFiles().length > 0) {
@for (f of chat.pendingFiles(); track f.id) { @if (f.previewUrl) {
} @else { {{ f.file.name }} } }
}
@if (sheetOpen()) { } `, 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('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(() => { 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(); /** * 把回合内**连续的 agent 正文**合成一个块(2026-11-06 用户要求)。 * * 容器常把一条回答拆成多条消息;此前每段各占一个块,读起来是碎片。 * 工具块、附件、失败消息各自断开,规则见 `core/message-merge.ts`。 */ /** * 时钟计数器:每秒 +1,**只用于驱动依赖它的 computed 重算**(2026-11-10)。 * * 与桌面端 `clockTick` 同口径。不要改成「读一个 Date.now() 快照信号」—— * 那样整页回合数据每秒重建,流式动画会被打断。 */ private readonly clockTick = signal(0); private nowTimer: ReturnType | 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(() => { 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 => !!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([]); 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>('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 | 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 { 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 { 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 { 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; }