assistant-turn.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785
  1. import { FileCard, Message } from './api/models';
  2. import { GroupTurn } from './group-turns';
  3. import { mergeConsecutiveBodies, MergedMessage } from './message-merge';
  4. import { isToolActivityMessage, ProcessStep, splitMessage } from './message-segments';
  5. import { Turn } from './turn-aggregator';
  6. /**
  7. * AI 输出的回合视图模型(2026-11-06 用户需求)
  8. *
  9. * ## 为什么要有这一层
  10. *
  11. * 用户要求「所有输出在一个固定区域内持续追加,顶部过程区可折叠,
  12. * 下半部分是正文与文档宫格」,并且**双端、单聊与群聊效果一致**。
  13. *
  14. * 但四个页面原本各写一套:桌面单聊是扁平流、桌面群聊是 turn → 容器块、
  15. * 移动两页又是一套。规则一旦分叉就会漂移(右栏预览就是前车之鉴)。
  16. *
  17. * 所以把「回合长什么样」收敛到这里:**纯函数,不依赖 Angular**,
  18. * 两端共用同一份数据,只在渲染层保留与端相关的样式差异。
  19. *
  20. * ## 单聊与群聊的统一
  21. *
  22. * - 群聊:一个回合有 N 个块(每个被 @ 的容器一块)
  23. * - 单聊:同样用块表达,但**固定只有 1 块**(`agentId: null`)
  24. *
  25. * 这样上层模板只认 `blocks[]`,不必区分单聊群聊。
  26. *
  27. * ## 顶部过程区从哪来
  28. *
  29. * 容器的回复把「过程行」和「正文」混在**同一条消息文本**里:
  30. *
  31. * ```
  32. * 我先看一下环境。
  33. * · bash
  34. * 找到了,正在改。
  35. * ```
  36. *
  37. * 两步取过程:
  38. * ① 纯终端消息(整条都是 `· bash` 这类)→ 整条进过程区
  39. * ② 混在正文里的过程行 → 由 `splitMessage` 拆出来
  40. *
  41. * 判定全部复用 `message-segments.ts` 的既有口径,**不在本文件新造正则**。
  42. */
  43. /** 下半区的内容块(顶部过程区已单独抽出,不在此列) */
  44. export type TurnBodyBlock =
  45. | { kind: 'text'; key: string; message: MergedMessage }
  46. | { kind: 'file'; key: string; file: FileCard; message: Message }
  47. | { kind: 'task'; key: string; taskId: string; message: Message };
  48. /** 一个「说话方」在回合内的呈现数据 */
  49. export interface TurnBlockView {
  50. key: string;
  51. /** 单聊为 null;群聊为容器 id */
  52. agentId: string | null;
  53. /** 群聊成员显示名(由宿主传入 `nameOf` 时才有) */
  54. agentName?: string;
  55. /**
  56. * 容器真实处理耗时(秒):首条 → 末条 agent 消息。
  57. *
  58. * 🔴 UI **不再直接读取**它(读数走 `elapsedSeconds()`),仅保留作数据/诊断用,
  59. * 以及 `updatedAt <= startAt` 这种退化情形下的兜底。
  60. */
  61. seconds: number;
  62. /**
  63. * 本块开始时刻(epoch ms;0 = 无锚点)。
  64. *
  65. * 2026-11-10 新增:秒数改为**逐秒现算**,锚点必须是时刻而不是秒数,
  66. * 否则只能靠容器发新事件来「跳一下」。见 `elapsedSeconds()`。
  67. */
  68. startAt: number;
  69. /** 本块最后活动时刻(epoch ms,0 = 无消息)—— 结束态定格用 */
  70. updatedAt: number;
  71. /** 顶部过程区 */
  72. steps: ProcessStep[];
  73. /** 下半区正文 / 文件 / 任务 */
  74. body: TurnBodyBlock[];
  75. /** 文件宫格的数据(从 body 的 file 项抽出,便于模板直接栅格渲染) */
  76. files: { key: string; file: FileCard }[];
  77. /** 本块是否仍在输出 */
  78. live: boolean;
  79. /** 本块是否无内容且未在输出(群聊未响应) */
  80. empty: boolean;
  81. /**
  82. * 本块自身合并后的正文(2026-11-07)。
  83. *
  84. * 群聊多容器时,**每个容器单独渲染自己的正文**,不能跨容器拼接 ——
  85. * 用户要求「@ 了三个人就要看出谁说了什么」,拼在一起等于丢失归属。
  86. * 单聊只有一块,用回合级的 `merged` 即可。
  87. */
  88. merged: TurnMergedBody | null;
  89. /** 用户发出这条消息的时刻(毫秒)。0 = 无锚点(容器主动发言) */
  90. waitingSince: number;
  91. /**
  92. * 构建这一帧时,距用户发消息已经等了多久(毫秒)。
  93. *
  94. * 🔴 组件渲染用 `waitingSince` + 实时时钟现算,`waitMs` 只是构建快照,
  95. * 供纯函数与测试使用(2026-11-09)。
  96. */
  97. waitMs: number;
  98. }
  99. export type TurnPhase = 'thinking' | 'replying' | 'done' | 'failed' | 'idle' | 'progressOnly';
  100. /**
  101. * 整轮合并后的正文(2026-11-07 用户需求)。
  102. *
  103. * 用户要求「这一轮对话的所有输出融合到**一张卡片**里,不再是每段单独复制,
  104. * 顶部折叠思考和操作」。所以正文本不该按容器/消息分段,而应拼成一份。
  105. */
  106. export interface TurnMergedBody {
  107. /** 稳定 key(首条正文消息 id),用于模板 track */
  108. key: string;
  109. /** 合并后的正文(Markdown) */
  110. text: string;
  111. /** 参与合并的消息(末尾那条用于取时间戳、任务引用等元信息) */
  112. message: Message;
  113. }
  114. /** 一个完整回合:一条用户消息 + 它的全部回复 */
  115. export interface AssistantTurn {
  116. id: string;
  117. userMessage?: Message;
  118. /** 用户在同回合内额外发的文件(群聊「文字 + 多个文件」时) */
  119. userFiles: Message[];
  120. blocks: TurnBlockView[];
  121. phase: TurnPhase;
  122. /** 容器真实处理耗时(秒,诊断用;UI 读数走 `elapsedSeconds(blocks[i], now)`) */
  123. seconds: number;
  124. live: boolean;
  125. closed: boolean;
  126. /** 过程步骤总数,用于折叠头「运行了 N 步」 */
  127. stepCount: number;
  128. /** 文件总数,用于宫格标题 */
  129. fileCount: number;
  130. /**
  131. * 整轮合并后的正文与文件(2026-11-07)。
  132. *
  133. * 「一整轮 = 一张卡」:不管中间隔了多少次工具调用,正文全部拼成一份
  134. * 给卡片渲染,过程进顶部折叠区,文件进底部宫格。
  135. *
  136. * 🔴 **单聊用这个**。群聊多容器时各自独立,见 `blocks[].merged`。
  137. */
  138. merged: TurnMergedBody | null;
  139. /** 整轮的全部文件(跨容器聚合,宫格统一渲染) */
  140. files: { key: string; file: FileCard }[];
  141. }
  142. /** 构建一块所需的原料(由单聊/群聊两种输入各转一份) */
  143. interface BlockSource {
  144. agentId: string | null;
  145. messages: Message[];
  146. startedAt: number;
  147. updatedAt: number;
  148. typing: boolean;
  149. closed: boolean;
  150. }
  151. /* ------------------------------------------------------------------ */
  152. /* 从原始消息派生「过程」与「正文」 */
  153. /* ------------------------------------------------------------------ */
  154. /**
  155. * 把一组消息拆成「顶部过程步骤」与「下半区内容块」。
  156. *
  157. * 处理顺序很关键:
  158. * 1. 先按纯终端消息把整条消息**提走**进过程区(它不该出现在正文里)
  159. * 2. 剩下的正文消息交给 `mergeConsecutiveBodies` 合并连续片段
  160. * 3. 合并后再用 `splitMessage` 把夹在正文里的过程行拆出来
  161. *
  162. * 🔴 这里不能只认 `block.kind === 'process'`。
  163. * `toBlocks()` 有个既有设计:**孤立单步(前后无空行)会被内联成 text**
  164. * (`message-segments.ts:281-285`),为的是「只有一个步骤时不必多点一次」。
  165. * 但在本视图模型里,顶部过程区与下半区必须**严格互斥**,否则
  166. * 「过程区折叠了、正文里却还留着那一行」。
  167. * 所以这里按**行**再判一次:`· 工具名` 形态的行一律归过程区。
  168. */
  169. function splitIntoParts(messages: Message[]): {
  170. steps: ProcessStep[];
  171. body: TurnBodyBlock[];
  172. } {
  173. const steps: ProcessStep[] = [];
  174. const bodies: Message[] = [];
  175. for (const m of messages) {
  176. const text = m.text ?? '';
  177. // ① 整条都是终端活动 → 过程区,不进正文
  178. if (text && isToolActivityMessage(text) && !proseOf(text)) {
  179. steps.push(...stepsOf(text));
  180. continue;
  181. }
  182. if (text.trim()) bodies.push(m);
  183. }
  184. /*
  185. * ② 合并连续正文。
  186. * 带附件的消息由 `mergeConsecutiveBodies` 内部规则挡住(见 message-merge.ts 注释),
  187. * 它们不进 `bodies`,单独成块。
  188. */
  189. const merged = mergeConsecutiveBodies(bodies);
  190. const body: TurnBodyBlock[] = [];
  191. for (const mm of merged) {
  192. const { steps: s, text } = divideText(mm.text ?? '');
  193. steps.push(...s);
  194. if (text.trim()) {
  195. body.push({ kind: 'text', key: mm.mergedIds[0], message: { ...mm, text: text.trim() } });
  196. }
  197. if (mm.file) {
  198. body.push({ kind: 'file', key: `${mm.mergedIds[0]}-f`, file: mm.file, message: mm });
  199. }
  200. if (mm.taskId) {
  201. body.push({ kind: 'task', key: `${mm.mergedIds[0]}-t`, taskId: mm.taskId, message: mm });
  202. }
  203. }
  204. /*
  205. * ③ 只有附件、没有正文的消息不会被 `mergeConsecutiveBodies` 输出(它只处理 text),
  206. * 这里补回来,否则「容器只回了一个文件」会整个消失。
  207. */
  208. for (const m of messages) {
  209. if (!m.file && !m.taskId) continue;
  210. const covered = body.some((b) => b.message.id === m.id);
  211. if (covered) continue;
  212. if (m.file) body.push({ kind: 'file', key: `${m.id}-f`, file: m.file, message: m });
  213. if (m.taskId) body.push({ kind: 'task', key: `${m.id}-t`, taskId: m.taskId, message: m });
  214. }
  215. return { steps, body };
  216. }
  217. /**
  218. * 从一段文本里取过程行(返回 steps)。
  219. * 纯终端文本直接用 `splitMessage` 的 process 块。
  220. */
  221. function stepsOf(text: string): ProcessStep[] {
  222. const out: ProcessStep[] = [];
  223. for (const b of splitMessage(text)) {
  224. if (b.kind === 'process') out.push(...b.steps);
  225. }
  226. return out;
  227. }
  228. /**
  229. * 把一段**混合**文本拆成「过程步骤」与「纯正文」。
  230. *
  231. * 两步:
  232. * ① `splitMessage` 能识别的 process 块 → 步骤
  233. * ② 剩下的 text 块里,仍可能有被 `toBlocks` 内联的单步行(`· bash`),按行再剥一次
  234. *
  235. * 返回的 text 已不含过程行,顶部折叠后正文不会再出现一遍。
  236. */function divideText(text: string): { steps: ProcessStep[]; text: string } {
  237. const steps: ProcessStep[] = [];
  238. const keep: string[] = [];
  239. for (const b of splitMessage(text)) {
  240. if (b.kind === 'process') {
  241. /*
  242. * 🔴 `splitMessage` 也会把「行首 emoji + 成句中文」解析成 process 块
  243. * (`💾 自我升级小结:…了。` 实测如此)。这类是**结论**不是过程,
  244. * 交还正文,否则折叠区里会出现半句话。
  245. */
  246. const prose = b.steps.filter((s) => proseOf(s.label));
  247. const tools = b.steps.filter((s) => !proseOf(s.label));
  248. steps.push(...tools);
  249. for (const p of prose) keep.push(p.label);
  250. continue;
  251. }
  252. if (b.kind !== 'text') continue;
  253. for (const line of b.text.split('\n')) {
  254. const inline = inlineStepOf(line);
  255. if (inline && !proseOf(line)) steps.push(inline);
  256. else keep.push(line);
  257. }
  258. }
  259. return { steps, text: keep.join('\n').replace(/\n{3,}/g, '\n\n') };
  260. }
  261. /** 行首 `· 工具名` 形态 → 一步;否则 null */
  262. const INLINE_STEP = /^\s*[·•]\s*([a-zA-Z][\w.\-]{0,40})(?:\s*[::]?\s*(.+))?$/;
  263. function inlineStepOf(line: string): ProcessStep | null {
  264. const m = INLINE_STEP.exec(line);
  265. if (!m) return null;
  266. return { kind: 'tool', label: m[1], detail: m[2]?.trim() || undefined };
  267. }
  268. /**
  269. * 这条「看起来像终端活动」的文本,其实是一句**结论性叙述**吗?
  270. *
  271. * ## 为什么需要(2026-11-07 实测)
  272. *
  273. * 容器会这样汇报一次自我升级:
  274. *
  275. * ```
  276. * 💾 自我升级小结:🔧 「multi-artifact-delivery」磨得更锋利了。
  277. * ```
  278. *
  279. * 行首 `💾` 命中了 `TOOL_EMOJI_LEAD`,于是整条被当成工具活动,
  280. * 折叠头上显示成「运行了 1 步 · 自我升级小结:…」——
  281. * 用户要读的**结论**被折进了过程区。
  282. *
  283. * ## 判定口径(宁可放过、不可误折)
  284. *
  285. * 同时满足才算叙述:
  286. * ① 以中文句末标点收尾(。!?)
  287. * ② 去掉行首 emoji 后,剩下的是一段**中文句子**(至少 6 个汉字)
  288. *
  289. * 真正的工具行(`💻 bash`、`✍️ Writing /path`)主体是英文工具名与路径,
  290. * 不含成句中文,不会被误判。
  291. */
  292. function proseOf(text: string): boolean {
  293. const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
  294. if (lines.length === 0) return false;
  295. // 只要有一条是「成句中文」,整条就当正文 —— 终端活动不会夹杂叙述
  296. return lines.some((line) => {
  297. if (!/[。!?]$/.test(line)) return false;
  298. // 剥掉行首的项目符号与 emoji(含变体选择符 / 零宽连接符)
  299. const body = line
  300. .replace(/^[\s*·•-]+/, '')
  301. .replace(/^[\p{Extended_Pictographic}️‍\u{1F3FB}-\u{1F3FF}]+\s*/u, '');
  302. return (body.match(/[一-龥]/g)?.length ?? 0) >= 6;
  303. });
  304. }
  305. /* ------------------------------------------------------------------ */
  306. /* 块 → 视图 */
  307. /* ------------------------------------------------------------------ */
  308. /**
  309. * 默认「还在输出」的静默窗口 —— 与 `turn-aggregator.QUIET_MS` 同源。
  310. *
  311. * 🔴 为什么不能用一个短窗口(2026-11-08 用户反馈「只闪一下『正在回复』就消失」):
  312. * 容器跑工具时,两段正文之间常有 5–20 秒空档。窗口短于一拍,
  313. * 回合就被判成「已结束」,界面的「正在思考…」随之消失 ——
  314. * 而容器其实还在干活,用户无从得知。
  315. *
  316. * 调用方应优先传 `QUIET_MS`;这里的字面量只是兜底,两处必须同步修改。
  317. */
  318. /**
  319. * 判定「超时」的时长门槛。
  320. *
  321. * 用户裁决(2026-11-10 第二次调整):**发出消息后 3 分钟内没有任何回复**才算异常。
  322. *
  323. * 演进:5 分钟太长(干等)→ 1 分钟太短(容器跑长任务时误报,用户实测被误判)
  324. * → 定为 3 分钟。容器正常回一轮 10~60 秒,但要跑任务时可能几分钟。
  325. *
  326. * ⚠️ 注意:这个值同时参与 `waitingTooLong`(超时后 `live` 转 false、占位消失)。
  327. * 改大意味着占位会多挂一段时间,这是有意的 —— 用户宁可看「正在思考」
  328. * 也不愿意 1 分钟就被告知「异常」。
  329. */
  330. export const TURN_TIMEOUT_MS = 180_000;
  331. const DEFAULT_QUIET_MS = 25_000;
  332. function toBlockView(
  333. src: BlockSource,
  334. now: number,
  335. quietMs: number,
  336. waitingSince = 0,
  337. /**
  338. * 用户已发出消息、但容器还一条都没回(2026-11-09)。
  339. *
  340. * 🔴 为什么需要独立标志:
  341. * 这类回合 `blocks` 为空 → `updatedAt = 0` → `idleFor` 极大 → `live` 恒 false,
  342. * 于是回合卡根本不会生成,界面在发送后**什么状态都不显示**,
  343. * 只能干等容器约 10 秒后第一次回话(用户实测「10 秒后才显示正在思考」)。
  344. *
  345. * 但「已发出」本身就是一个确定的状态,应该立刻可见。
  346. */
  347. awaitingFirstReply = false,
  348. ): TurnBlockView {
  349. const { steps, body } = splitIntoParts(src.messages);
  350. const files = body
  351. .filter((b): b is Extract<TurnBodyBlock, { kind: 'file' }> => b.kind === 'file')
  352. .map((b) => ({ key: b.key, file: b.file }));
  353. /*
  354. * live 判定与 `isTurnOpen` 同口径:容器正在 typing,或末条消息至今未超过静默窗口。
  355. * 群聊里某个成员可能先回完,所以按**块**判断而不是按回合。
  356. *
  357. * 🔴 typing 不能无限期作数(2026-11-09)。
  358. *
  359. * 实测:容器一轮结束后**不会**发 `m.typing:false`,`peerTyping` 一直停在 true。
  360. * 这时 `live` 恒为真 → 回合永远「输出中」、永远不会自动折叠
  361. * (用户实测:内容早已输出完,界面挂着「运行了 2 步 · 163s 输出中」)。
  362. *
  363. * 所以 typing 也受静默窗口约束:已经很久没有新内容,就不再信它。
  364. */
  365. const idleFor = now - src.updatedAt;
  366. const typingAlive = src.typing && idleFor < quietMs * 2;
  367. /*
  368. * 等待超时收尾(2026-11-10,用户裁决保留):
  369. *
  370. * `awaitingFirstReply` 若恒为真 → `live` 永远 true → 界面永远挂着
  371. * 「已送达,等待回复…(253s)」,`isTurnTimedOut` 要求 `!live` 永远不成立,
  372. * 全局提示也就永远不弹。所以等待超过 `TURN_TIMEOUT_MS` 后不再算「还在等」,
  373. * 交给超时逻辑收尾 → 占位消失、全局提示出现。
  374. */
  375. const waitingTooLong =
  376. awaitingFirstReply && waitingSince > 0 && now - waitingSince >= TURN_TIMEOUT_MS;
  377. const live =
  378. !src.closed && !waitingTooLong && (awaitingFirstReply || typingAlive || idleFor < quietMs);
  379. /*
  380. * 用时读数(2026-11-10 合并口径):
  381. *
  382. * | 字段 | 锚点 | 用途 |
  383. * |---|---|---|
  384. * | `startAt` | **用户发出问话的时刻** | 头部与段头的实时读数(1→2→3… 每秒加) |
  385. * | `seconds` | 首条 → 末条 agent 消息 | 容器**真实处理耗时**,结束时定格,不跳变 |
  386. *
  387. * 进行中的读数由渲染层用每秒时钟现算(`elapsedSeconds()`),
  388. * 不再用「消息事件时间戳相减」——那样只会 5s → 18s → 30s 地跳。
  389. */
  390. const startAt = waitingSince > 0 ? waitingSince : src.startedAt;
  391. return {
  392. key: src.agentId ?? 'agent',
  393. agentId: src.agentId,
  394. /** 容器真实处理耗时(首条 → 末条 agent 消息),结束才有意义 */
  395. seconds:
  396. src.startedAt > 0 && src.updatedAt >= src.startedAt
  397. ? Math.max(0, Math.round((src.updatedAt - src.startedAt) / 1000))
  398. : 0,
  399. /** 本块开始时刻(epoch ms,0 = 无锚点)—— 逐秒读数的锚点 */
  400. startAt,
  401. updatedAt: src.updatedAt,
  402. steps,
  403. body,
  404. files,
  405. live,
  406. empty: !live && body.length === 0 && steps.length === 0,
  407. merged: mergeBodies([{ body }]),
  408. waitingSince,
  409. waitMs: waitingSince > 0 ? Math.max(0, now - waitingSince) : 0,
  410. };
  411. }
  412. /**
  413. * 由各块合成回合阶段。
  414. *
  415. * 🔴 2026-11-10:「已完成」必须真的代表**有东西交付给用户**。
  416. *
  417. * 原判据是「只要不是全空就算 done」,于是容器只回了一串内部进度
  418. * (`🐍 Running code…` + 一句日志)时,卡片照样标「已完成」,
  419. * 用户打开一看什么也没有(实测截图)。
  420. *
  421. * 现在区分三档:
  422. * · 有正文 或 有文件 → `done`(真的交付了)
  423. * · 只有过程步骤、没有正文也没有文件 → `progressOnly`(干了活,但没交付)
  424. * · 什么都没有 → `idle`
  425. */
  426. function phaseOf(blocks: TurnBlockView[], closed: boolean): TurnPhase {
  427. if (blocks.some((b) => b.live && (b.body.length > 0 || b.steps.length > 0))) return 'replying';
  428. if (blocks.some((b) => b.live)) return 'thinking';
  429. if (blocks.length === 0) return closed ? 'idle' : 'thinking';
  430. const delivered = blocks.some((b) => b.body.length > 0 || b.files.length > 0);
  431. if (delivered) return 'done';
  432. // 只剩过程步骤:做过事,但没有可交付的内容
  433. return blocks.some((b) => b.steps.length > 0) ? 'progressOnly' : 'idle';
  434. }
  435. /**
  436. * 一块的**当前**用时秒数(2026-11-10 用户要求:一秒一秒叠加)。
  437. *
  438. * | 状态 | 返回值 |
  439. * |---|---|
  440. * | 无锚点(`startAt <= 0`) | 0,不显示 |
  441. * | 已结束(`!live`) | 从用户发问到结束的总用时(定格) |
  442. * | 进行中 | `floor((now - startAt) / 1000)`,**逐秒递增** |
  443. *
  444. * 🔴 进行中必须用实时时钟现算。原来直接用结束时刻相减,
  445. * 只在容器发新事件那一跳才变,用户看到的是 5s → 18s → 30s 的跳变。
  446. *
  447. * 取整一律 `floor`:进行中与结束态用同一种口径,结束瞬间才不会倒退 1 秒。
  448. *
  449. * @param block 块视图
  450. * @param now 当前时间(组件每秒刷新一次的那个时钟)
  451. */
  452. export function elapsedSeconds(block: TurnBlockView, now: number): number {
  453. if (block.startAt <= 0) return 0;
  454. /*
  455. * 结束态 = 用户发问 → 末条 agent 消息(体感用时)。
  456. * `block.seconds` 是「首条 → 末条 agent」,起点不同,不可与之相加。
  457. */
  458. if (!block.live) {
  459. return block.updatedAt > block.startAt
  460. ? Math.max(0, Math.floor((block.updatedAt - block.startAt) / 1000))
  461. : block.seconds;
  462. }
  463. return Math.max(0, Math.floor((now - block.startAt) / 1000));
  464. }
  465. /**
  466. * 等待中的占位文案(2026-11-09 用户裁决)。
  467. *
  468. * | 阶段 | 判据 | 文案 |
  469. * |---|---|---|
  470. * | 已发出,还没有任何输出 | `live` 且无内容 | 8 秒内「正在思考…」;之后「已送达,等待回复…(Ns)」 |
  471. * | 开始输出 | `live` 且有内容 | 「正在输出…(Ns)」 |
  472. * | 结束且全空 | 非 live 且 empty | **空**(不再显示「未响应」气泡) |
  473. *
  474. * 🔴 文案要**一直兜底到回复真的回来**,中途不能消失:
  475. * 容器回一轮要十几秒,界面空白会被理解成「发了没反应」。
  476. *
  477. * 🔴 2026-11-10 用户裁决:**超时不再在会话里留「未响应」气泡**。
  478. *
  479. * 原实现回合结束后恒显示一个「⊙ 未响应」的小气泡,永远留在消息流里,
  480. * 看起来像「每条都失败了」。改为:
  481. * · 会话内**完全移除**该占位(返回空串,段级判据同步不再渲染)
  482. * · 由页面层弹**全局提示**(可手动关闭、不自动消失),见 `useTurnTimeout()`
  483. *
  484. * @param waitMs 距用户发出消息已经过了多久
  485. * @param hasOutput 是否已经开始输出内容
  486. * @param live 是否仍在进行中
  487. * @param empty 是否已结束且什么也没有
  488. */
  489. export function waitText(waitMs: number, hasOutput: boolean, live: boolean, empty: boolean): string {
  490. // 已结束:无论有没有内容,都不再留等待占位(超时提示交给全局 toast)
  491. if (!live) return '';
  492. const sec = Math.max(0, Math.round(waitMs / 1000));
  493. if (hasOutput) return sec > 0 ? `正在输出…(${sec}s)` : '正在输出…';
  494. if (waitMs < WAIT_HINT_MS) return '正在思考…';
  495. return sec > 0 ? `已送达,等待回复…(${sec}s)` : '已送达,等待回复…';
  496. }
  497. /** 超过这个时长就明确告诉用户「发出了、在等」,不再只是「正在思考」 */
  498. export const WAIT_HINT_MS = 8_000;
  499. /**
  500. * 判定「这一轮是否已经超时未响应」(2026-11-10)。
  501. *
  502. * 超时 = 用户已发出消息、但经过 `timeoutMs` 仍**没有任何 agent 输出**,且回合已结束。
  503. * 页面据此弹全局提示(手动关闭)。
  504. *
  505. * @param waitMs 距用户发出消息的毫秒数
  506. * @param hasOutput agent 是否已经有正文或步骤
  507. * @param live 回合是否仍在进行
  508. */
  509. export function isTurnTimedOut(
  510. waitMs: number,
  511. hasOutput: boolean,
  512. live: boolean,
  513. timeoutMs = TURN_TIMEOUT_MS,
  514. ): boolean {
  515. return !live && !hasOutput && waitMs >= timeoutMs;
  516. }
  517. /* ------------------------------------------------------------------ */
  518. /* 两种输入 → 统一的回合视图 */
  519. /* ------------------------------------------------------------------ */
  520. /**
  521. * 群聊:`buildGroupTurns` 的结果 → 回合视图。
  522. *
  523. * 每个 `GroupBlock` 对应一个块;块顺序保持 `targets` 的声明顺序。
  524. */
  525. export function turnsFromGroup(
  526. turns: GroupTurn[],
  527. now: number,
  528. quietMs = DEFAULT_QUIET_MS,
  529. nameOf?: (agentId: string) => string,
  530. ): AssistantTurn[] {
  531. return turns.map((t) => {
  532. const blocks = t.blocks.map((b) => {
  533. const view = toBlockView(
  534. {
  535. agentId: b.agentId,
  536. messages: b.messages,
  537. startedAt: b.startedAt,
  538. updatedAt: b.updatedAt,
  539. typing: b.typing,
  540. closed: b.closed,
  541. },
  542. now,
  543. quietMs,
  544. t.userMessage ? Date.parse(t.userMessage.ts) || 0 : 0,
  545. /*
  546. * 群聊里「这一块还没收到任何回复」= 等待中(2026-11-09,与单聊同口径)。
  547. *
  548. * 原来空块的 `updatedAt = 0` 会让 `idleFor` 极大,`live` 恒 false,
  549. * 于是卡片是个空壳、没有文字 —— 用户看不到「谁在思考」(实测)。
  550. */
  551. b.messages.length === 0 && !b.closed,
  552. );
  553. const name = nameOf?.(b.agentId);
  554. return name ? { ...view, agentName: name } : view;
  555. });
  556. const live = blocks.some((b) => b.live);
  557. return {
  558. id: t.id,
  559. userMessage: t.userMessage,
  560. userFiles: t.userFiles ?? [],
  561. blocks,
  562. phase: phaseOf(blocks, t.closed),
  563. // 与块级同口径:从用户发消息起算,live 时走当前时钟(时间戳取自原始块)
  564. seconds: roundSeconds(t.blocks, now, live, t.userMessage ? Date.parse(t.userMessage.ts) || 0 : 0),
  565. live,
  566. closed: t.closed,
  567. stepCount: blocks.reduce((n, b) => n + b.steps.length, 0),
  568. fileCount: blocks.reduce((n, b) => n + b.files.length, 0),
  569. merged: mergeBodies(blocks),
  570. files: collectFiles(blocks),
  571. };
  572. });
  573. }
  574. /**
  575. * 单聊:`buildTurns` 的结果 → 回合视图(固定 1 块)。
  576. *
  577. * @param typing 容器是否在 typing(容器大多不发,通常为 false)
  578. * @param quietMs 「还在输出」的静默窗口,调用方传 `QUIET_MS`
  579. * @param agentIdle 容器**明确上报**的空闲态。为 true 时忽略静默窗口直接收尾 ——
  580. * 只有容器能宣布「我干完了」,比任何时间窗都准。
  581. */
  582. export function turnsFromDm(
  583. turns: Turn[],
  584. now: number,
  585. typing: boolean,
  586. quietMs = DEFAULT_QUIET_MS,
  587. agentIdle = false,
  588. ): AssistantTurn[] {
  589. return turns.map((t) => {
  590. /*
  591. * 「已发出、容器还没回一个字」——这是发送后立刻可见的状态(2026-11-09)。
  592. *
  593. * 判据:有用户消息、没有 agent 消息、回合还没结束。
  594. * 注意要用 `t.userMessage` 的时间做锚点,不能用 `t.updatedAt`
  595. * (后者在无 agent 消息时等于用户消息时间,但语义上不是「最后活动」)。
  596. */
  597. const awaitingFirstReply = !!t.userMessage && t.blocks.length === 0 && !t.closed;
  598. const userAsk = t.userMessage ? Date.parse(t.userMessage.ts) || 0 : 0;
  599. /*
  600. * 🔴 按「轮次」拆块(2026-11-10)。
  601. *
  602. * 容器对「图 + 文字」会各跑一轮,两轮在消息流里没有分界标记。
  603. * 每轮单独成块 → 各自独立判断 live、独立计时、占位提示只出现在
  604. * 「正在跑的那一轮」下面,不再挂到上一轮已答完的正文后面。
  605. */
  606. const rounds = t.rounds && t.rounds.length > 0 ? t.rounds : t.blocks.length > 0 ? [t.blocks] : [];
  607. /*
  608. * 没有 agent 消息时也要有**一个**占位块,否则「已发出、等回复」的标志
  609. * (`awaitingFirstReply`)无处挂载,卡片会整张消失。
  610. */
  611. const blocks = rounds.length
  612. ? rounds.map((msgs) => {
  613. const times = msgs.map((m) => Date.parse(m.ts) || 0).filter((n) => n > 0);
  614. const first = times.length ? Math.min(...times) : 0;
  615. const last = times.length ? Math.max(...times) : 0;
  616. /*
  617. * 本轮读秒锚点 = **不晚于本轮首条消息的最后一个用户提问时刻**。
  618. * 图文被拆成两条时:图片那轮用图片的发出时刻,文字那轮用文字的发出时刻,
  619. * 两轮读数各算各的,不会互相拉高,也不会在结束瞬间倒退。
  620. */
  621. const asks = (t.userAskTimes ?? []).filter((a) => a > 0 && (first === 0 || a <= first));
  622. const startAt = asks.length ? Math.max(...asks) : first || userAsk;
  623. return toBlockView(
  624. {
  625. agentId: null,
  626. messages: msgs,
  627. startedAt: first,
  628. updatedAt: last,
  629. typing,
  630. closed: t.closed,
  631. },
  632. now,
  633. agentIdle ? 0 : quietMs,
  634. startAt,
  635. false,
  636. );
  637. })
  638. : [
  639. toBlockView(
  640. { agentId: null, messages: [], startedAt: 0, updatedAt: 0, typing, closed: t.closed },
  641. now,
  642. agentIdle ? 0 : quietMs,
  643. userAsk,
  644. awaitingFirstReply,
  645. ),
  646. ];
  647. const live = blocks.some((b) => b.live);
  648. return {
  649. id: t.id,
  650. userMessage: t.userMessage,
  651. userFiles: t.userFiles ?? [],
  652. blocks,
  653. phase: phaseOf(blocks, t.closed),
  654. /* 回合用时取最长的块(各轮并行跑,按最长那轮算体感) */
  655. seconds: blocks.reduce((max, b) => Math.max(max, b.seconds), 0),
  656. live,
  657. closed: t.closed,
  658. stepCount: blocks.reduce((n, b) => n + b.steps.length, 0),
  659. fileCount: blocks.reduce((n, b) => n + b.files.length, 0),
  660. /*
  661. * 单聊的正文仍**合并成一份**(2026-11-07 用户要求「一整轮一张卡」)。
  662. * 拆轮只为了分别计时与分别判定状态,不改变阅读形态。
  663. */
  664. merged: mergeBodies(blocks),
  665. files: collectFiles(blocks),
  666. };
  667. });
  668. }
  669. /** 整轮的全部文件(跨容器聚合,宫格统一渲染) */
  670. function collectFiles(blocks: TurnBlockView[]): { key: string; file: FileCard }[] {
  671. const seen = new Set<string>();
  672. const out: { key: string; file: FileCard }[] = [];
  673. for (const b of blocks) {
  674. for (const f of b.files) {
  675. // 同名同大小的重复文件只出一张卡(多容器转发同一份时)
  676. const sig = `${f.file.name}|${f.file.size ?? 0}|${f.file.url}`;
  677. if (seen.has(sig)) continue;
  678. seen.add(sig);
  679. out.push(f);
  680. }
  681. }
  682. return out;
  683. }
  684. /**
  685. * 把整轮所有块的正文拼成一份。
  686. *
  687. * 块之间用空行分隔(Markdown 段落边界),保持可读;
  688. * key 取首条正文消息 id,**不随内容增长而变** —— 否则流式时模板会重建 DOM。
  689. */
  690. function mergeBodies(blocks: { body: TurnBodyBlock[] }[]): TurnMergedBody | null {
  691. const parts: string[] = [];
  692. let first: Message | null = null;
  693. let last: Message | null = null;
  694. for (const b of blocks) {
  695. for (const part of b.body) {
  696. if (part.kind !== 'text') continue;
  697. const t = (part.message.text ?? '').trim();
  698. if (!t) continue;
  699. parts.push(t);
  700. if (!first) first = part.message;
  701. last = part.message;
  702. }
  703. }
  704. if (!first || !last) return null;
  705. return { key: first.id, text: parts.join('\n\n'), message: last };
  706. }
  707. /** 回合耗时:首条 agent 消息到末条(各块取并集,避免多块重复计算) */
  708. /**
  709. * 回合级耗时(群聊汇总用)。
  710. *
  711. * 🔴 必须与块级 `toBlockView` 同一口径(2026-11-10),否则同一张卡片上
  712. * 段头写一个数、汇总写另一个数,用户会以为界面在乱跳。
  713. *
  714. * 口径:从**用户发出消息**起算。live 时取当前时钟(稳定递增),
  715. * 结束后冻结在最晚的一条消息时间。多块之间取「最大结束 - 用户消息」,
  716. * 也就是「这一轮最忙的那个成员干完为止」。
  717. */
  718. function roundSeconds(
  719. blocks: { startedAt: number; updatedAt: number }[],
  720. now: number,
  721. live: boolean,
  722. waitingSince: number,
  723. ): number {
  724. const starts = blocks.map((b) => b.startedAt).filter((n) => n > 0);
  725. const ends = blocks.map((b) => b.updatedAt).filter((n) => n > 0);
  726. // 锚点优先取用户消息,没有才回落到最早的一条 agent 消息
  727. const anchor = waitingSince > 0 ? waitingSince : starts.length ? Math.min(...starts) : 0;
  728. if (anchor <= 0) return 0;
  729. const endAt = live ? now : ends.length ? Math.max(...ends) : 0;
  730. if (endAt < anchor) return 0;
  731. return Math.max(0, Math.round((endAt - anchor) / 1000));
  732. }
  733. /** 便捷入口:判断回合里是否有该文件(避免模板里重复过滤) */
  734. export function hasFiles(t: AssistantTurn): boolean {
  735. return t.fileCount > 0;
  736. }