assistant-turn.ts 55 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289
  1. import { FileCard, Message } from './api/models';
  2. import { GroupTurn } from './group-turns';
  3. import { mergeConsecutiveBodies, MergedMessage } from './message-merge';
  4. import {
  5. isCommandLine,
  6. isProseLine,
  7. isToolActivityMessage,
  8. ProcessStep,
  9. splitMessage,
  10. } from './message-segments';
  11. import { fmtTime } from './time';
  12. import { Turn } from './turn-aggregator';
  13. /**
  14. * AI 输出的回合视图模型(2026-11-06 用户需求)
  15. *
  16. * ## 为什么要有这一层
  17. *
  18. * 用户要求「所有输出在一个固定区域内持续追加,顶部过程区可折叠,
  19. * 下半部分是正文与文档宫格」,并且**双端、单聊与群聊效果一致**。
  20. *
  21. * 但四个页面原本各写一套:桌面单聊是扁平流、桌面群聊是 turn → 容器块、
  22. * 移动两页又是一套。规则一旦分叉就会漂移(右栏预览就是前车之鉴)。
  23. *
  24. * 所以把「回合长什么样」收敛到这里:**纯函数,不依赖 Angular**,
  25. * 两端共用同一份数据,只在渲染层保留与端相关的样式差异。
  26. *
  27. * ## 单聊与群聊的统一
  28. *
  29. * - 群聊:一个回合有 N 个块(每个被 @ 的容器一块)
  30. * - 单聊:同样用块表达,但**固定只有 1 块**(`agentId: null`)
  31. *
  32. * 这样上层模板只认 `blocks[]`,不必区分单聊群聊。
  33. *
  34. * ## 顶部过程区从哪来
  35. *
  36. * 容器的回复把「过程行」和「正文」混在**同一条消息文本**里:
  37. *
  38. * ```
  39. * 我先看一下环境。
  40. * · bash
  41. * 找到了,正在改。
  42. * ```
  43. *
  44. * 两步取过程:
  45. * ① 纯终端消息(整条都是 `· bash` 这类)→ 整条进过程区
  46. * ② 混在正文里的过程行 → 由 `splitMessage` 拆出来
  47. *
  48. * 判定全部复用 `message-segments.ts` 的既有口径,**不在本文件新造正则**。
  49. */
  50. /** 下半区的内容块(顶部过程区已单独抽出,不在此列) */
  51. export type TurnBodyBlock =
  52. | { kind: 'text'; key: string; message: MergedMessage }
  53. | { kind: 'file'; key: string; file: FileCard; message: Message }
  54. | { kind: 'task'; key: string; taskId: string; message: Message };
  55. /** 一个「说话方」在回合内的呈现数据 */
  56. export interface TurnBlockView {
  57. key: string;
  58. /** 单聊为 null;群聊为容器 id */
  59. agentId: string | null;
  60. /** 群聊成员显示名(由宿主传入 `nameOf` 时才有) */
  61. agentName?: string;
  62. /**
  63. * 容器真实处理耗时(秒):首条 → 末条 agent 消息。
  64. *
  65. * 🔴 UI **不再直接读取**它(读数走 `elapsedSeconds()`),仅保留作数据/诊断用,
  66. * 以及 `updatedAt <= startAt` 这种退化情形下的兜底。
  67. */
  68. seconds: number;
  69. /**
  70. * 本块开始时刻(epoch ms;0 = 无锚点)。
  71. *
  72. * 2026-11-10 新增:秒数改为**逐秒现算**,锚点必须是时刻而不是秒数,
  73. * 否则只能靠容器发新事件来「跳一下」。见 `elapsedSeconds()`。
  74. */
  75. startAt: number;
  76. /** 本块最后活动时刻(epoch ms,0 = 无消息)—— 结束态定格用 */
  77. updatedAt: number;
  78. /** 顶部过程区 */
  79. steps: ProcessStep[];
  80. /** 下半区正文 / 文件 / 任务 */
  81. body: TurnBodyBlock[];
  82. /** 文件宫格的数据(从 body 的 file 项抽出,便于模板直接栅格渲染) */
  83. files: { key: string; file: FileCard }[];
  84. /** 本块是否仍在输出 */
  85. live: boolean;
  86. /**
  87. * 容器是否**明确宣告本轮结束**(2026-11-11)。
  88. *
  89. * 为 true 时 `live` 必定为 false —— 结束信号优先于所有静默窗口。
  90. * 渲染层可据此把「已完成」写得更肯定(可选,不影响原有判据)。
  91. */
  92. turnEnded?: boolean;
  93. /**
  94. * 是否仍在**等第一口气**(2026-11-11)。
  95. *
  96. * 与 `live` 解耦:`live` 受 25 秒静默窗口约束会提前转 false,
  97. * 而用户要一直等到 180 秒超时才该失去等待指示。
  98. * 渲染层应把 `live || waiting` 当作「还在进行中」。
  99. */
  100. waiting: boolean;
  101. /** 本块是否无内容且未在输出(群聊未响应) */
  102. empty: boolean;
  103. /**
  104. * 本块自身合并后的正文(2026-11-07)。
  105. *
  106. * 群聊多容器时,**每个容器单独渲染自己的正文**,不能跨容器拼接 ——
  107. * 用户要求「@ 了三个人就要看出谁说了什么」,拼在一起等于丢失归属。
  108. * 单聊只有一块,用回合级的 `merged` 即可。
  109. */
  110. merged: TurnMergedBody | null;
  111. /** 用户发出这条消息的时刻(毫秒)。0 = 无锚点(容器主动发言) */
  112. waitingSince: number;
  113. /**
  114. * 构建这一帧时,距用户发消息已经等了多久(毫秒)。
  115. *
  116. * 🔴 组件渲染用 `waitingSince` + 实时时钟现算,`waitMs` 只是构建快照,
  117. * 供纯函数与测试使用(2026-11-09)。
  118. */
  119. waitMs: number;
  120. }
  121. export type TurnPhase = 'thinking' | 'replying' | 'done' | 'failed' | 'idle' | 'progressOnly';
  122. /**
  123. * 整轮合并后的正文(2026-11-07 用户需求)。
  124. *
  125. * 用户要求「这一轮对话的所有输出融合到**一张卡片**里,不再是每段单独复制,
  126. * 顶部折叠思考和操作」。所以正文本不该按容器/消息分段,而应拼成一份。
  127. */
  128. export interface TurnMergedBody {
  129. /** 稳定 key(首条正文消息 id),用于模板 track */
  130. key: string;
  131. /** 合并后的正文(Markdown) */
  132. text: string;
  133. /** 参与合并的消息(末尾那条用于取时间戳、任务引用等元信息) */
  134. message: Message;
  135. }
  136. /** 一个完整回合:一条用户消息 + 它的全部回复 */
  137. export interface AssistantTurn {
  138. id: string;
  139. userMessage?: Message;
  140. /** 用户在同回合内额外发的文件(群聊「文字 + 多个文件」时) */
  141. userFiles: Message[];
  142. blocks: TurnBlockView[];
  143. phase: TurnPhase;
  144. /** 容器真实处理耗时(秒,诊断用;UI 读数走 `elapsedSeconds(blocks[i], now)`) */
  145. seconds: number;
  146. live: boolean;
  147. closed: boolean;
  148. /** 过程步骤总数,用于折叠头「运行了 N 步」 */
  149. stepCount: number;
  150. /** 文件总数,用于宫格标题 */
  151. fileCount: number;
  152. /**
  153. * 整轮合并后的正文与文件(2026-11-07)。
  154. *
  155. * 「一整轮 = 一张卡」:不管中间隔了多少次工具调用,正文全部拼成一份
  156. * 给卡片渲染,过程进顶部折叠区,文件进底部宫格。
  157. *
  158. * 🔴 **单聊用这个**。群聊多容器时各自独立,见 `blocks[].merged`。
  159. */
  160. merged: TurnMergedBody | null;
  161. /** 整轮的全部文件(跨容器聚合,宫格统一渲染) */
  162. files: { key: string; file: FileCard }[];
  163. }
  164. /** 构建一块所需的原料(由单聊/群聊两种输入各转一份) */
  165. interface BlockSource {
  166. agentId: string | null;
  167. /**
  168. * 显式块 key(2026-11-11)。
  169. *
  170. * 单聊按轮次拆块后,每块 `agentId` 都是 null,若仍用 `agentId ?? 'agent'`
  171. * 生成 key,多块就会**全部叫 'agent'** → 模板 `@for track` 报 NG0955,
  172. * 且 `segTime`/`waitLabels`/`targetsOf` 以 key 为字典键,后一轮覆盖前一轮,
  173. * 读数在 39s / 9s / 43s 之间乱跳(用户实测)。
  174. *
  175. * 拆轮时由调用方传 `agent:<首条消息 id>`;不传则回落到旧逻辑。
  176. */
  177. key?: string;
  178. messages: Message[];
  179. startedAt: number;
  180. updatedAt: number;
  181. typing: boolean;
  182. closed: boolean;
  183. /**
  184. * 容器**明确宣告本轮结束**(2026-11-11)。
  185. *
  186. * 来源:容器在 `post_llm_call` 发 `m.typing=false`,App 侧
  187. * `chat.service` 记录该事件的时刻,再按「是否晚于本块末条消息」判定。
  188. *
  189. * 语义与 `!typing` 不同:`typing=false` 只是「此刻没在打字」,
  190. * 两条消息之间的空档也会出现;本字段要求信号**确实在本轮末条消息之后**,
  191. * 才算「这一轮干完了」。
  192. */
  193. turnEnded?: boolean;
  194. }
  195. /* ------------------------------------------------------------------ */
  196. /* 从原始消息派生「过程」与「正文」 */
  197. /* ------------------------------------------------------------------ */
  198. /**
  199. * 把一组消息拆成「顶部过程步骤」与「下半区内容块」。
  200. *
  201. * 处理顺序很关键:
  202. * 1. 先按纯终端消息把整条消息**提走**进过程区(它不该出现在正文里)
  203. * 2. 剩下的正文消息交给 `mergeConsecutiveBodies` 合并连续片段
  204. * 3. 合并后再用 `splitMessage` 把夹在正文里的过程行拆出来
  205. *
  206. * 🔴 这里不能只认 `block.kind === 'process'`。
  207. * `toBlocks()` 有个既有设计:**孤立单步(前后无空行)会被内联成 text**
  208. * (`message-segments.ts:281-285`),为的是「只有一个步骤时不必多点一次」。
  209. * 但在本视图模型里,顶部过程区与下半区必须**严格互斥**,否则
  210. * 「过程区折叠了、正文里却还留着那一行」。
  211. * 所以这里按**行**再判一次:`· 工具名` 形态的行一律归过程区。
  212. */
  213. function splitIntoParts(messages: Message[]): {
  214. steps: ProcessStep[];
  215. body: TurnBodyBlock[];
  216. } {
  217. const steps: ProcessStep[] = [];
  218. const bodies: Message[] = [];
  219. /**
  220. * 容器声明为 `final` 的消息 id(2026-11-11)。
  221. *
  222. * 这些消息在 ② 步**不能再被 `divideText` 拆一次** —— 容器已经明确说
  223. * 「整条是正文」,正文里出现 `cd /opt/...` 这类行也不该被当成命令挪进过程区。
  224. */
  225. const forcedFinal = new Set<string>();
  226. for (const m of messages) {
  227. const text = m.text ?? '';
  228. if (!text.trim()) continue;
  229. /*
  230. * 🔴 一律按行拆分,不再「整条判成工具活动就丢弃」(2026-11-11 修复)。
  231. *
  232. * 原实现:
  233. * if (isToolActivityMessage(text) && !proseOf(text)) {
  234. * steps.push(...stepsOf(text)); // stepsOf 只取 process 块
  235. * continue; // ← 同条消息里的正文永远消失
  236. * }
  237. *
  238. * 后果(用户实测):容器回一段混排消息,比如
  239. * 「有的,共 69 行…」 + `**已上云(可直接下载)**` + 两行 `🐍 Running code…`
  240. * 工具行严格过半 → 整条进过程区 → 那句结论和文件名**再也看不到**。
  241. *
  242. * 现在交给 `divideText` 逐行判:工具行 / 命令行进过程区,
  243. * 其余(含结论、markdown 加粗、列表)留在正文。
  244. */
  245. /*
  246. * 🔴 容器声明了类别就**完全听它的**(2026-11-11)。
  247. *
  248. * 这是根治「日志吞正文」的路径:文本启发式永远有误判(正文里出现代码块、
  249. * 命令行、英文列表都可能被当成过程)。容器自己知道哪条是过程、哪条是结论。
  250. *
  251. * 容器改造完成前该字段缺失 → 自动落回下面的启发式,行为不变。
  252. */
  253. if (m.fmodeKind === 'process') {
  254. /* 整条进过程区:不再逐行猜,避免把里面的中文切成「正文」 */
  255. for (const line of text.split('\n')) {
  256. const t = line.trim();
  257. if (t) steps.push({ kind: 'tool', label: t });
  258. }
  259. continue;
  260. }
  261. if (m.fmodeKind === 'final') {
  262. /* 整条是正文:即使里面看起来像命令行,也不拆(②' 步会跳过它) */
  263. bodies.push(m);
  264. forcedFinal.add(m.id);
  265. continue;
  266. }
  267. const { steps: s, text: kept } = divideText(text);
  268. steps.push(...s);
  269. if (kept.trim()) bodies.push(kept.trim() === text.trim() ? m : { ...m, text: kept.trim() });
  270. }
  271. /*
  272. * ② 合并连续正文。
  273. * 带附件的消息由 `mergeConsecutiveBodies` 内部规则挡住(见 message-merge.ts 注释),
  274. * 它们不进 `bodies`,单独成块。
  275. */
  276. const merged = mergeConsecutiveBodies(bodies);
  277. const body: TurnBodyBlock[] = [];
  278. for (const mm of merged) {
  279. /*
  280. * 容器声明为 final 的正文**不做二次拆分**(2026-11-11)。
  281. * 合并后仍按 `mergedIds` 判断:只要**所有**参与合并的消息都是 final,
  282. * 整段就当正文;否则走启发式(混合情况下以启发式为准更安全)。
  283. */
  284. const allFinal = mm.mergedIds.length > 0 && mm.mergedIds.every((id) => forcedFinal.has(id));
  285. if (allFinal) {
  286. if ((mm.text ?? '').trim()) {
  287. body.push({ kind: 'text', key: mm.mergedIds[0], message: { ...mm, text: (mm.text ?? '').trim() } });
  288. }
  289. if (mm.file) body.push({ kind: 'file', key: `${mm.mergedIds[0]}-f`, file: mm.file, message: mm });
  290. if (mm.taskId) body.push({ kind: 'task', key: `${mm.mergedIds[0]}-t`, taskId: mm.taskId, message: mm });
  291. continue;
  292. }
  293. const { steps: s, text } = divideText(mm.text ?? '');
  294. steps.push(...s);
  295. if (text.trim()) {
  296. body.push({ kind: 'text', key: mm.mergedIds[0], message: { ...mm, text: text.trim() } });
  297. }
  298. if (mm.file) {
  299. body.push({ kind: 'file', key: `${mm.mergedIds[0]}-f`, file: mm.file, message: mm });
  300. }
  301. if (mm.taskId) {
  302. body.push({ kind: 'task', key: `${mm.mergedIds[0]}-t`, taskId: mm.taskId, message: mm });
  303. }
  304. }
  305. /*
  306. * ③ 只有附件、没有正文的消息不会被 `mergeConsecutiveBodies` 输出(它只处理 text),
  307. * 这里补回来,否则「容器只回了一个文件」会整个消失。
  308. */
  309. for (const m of messages) {
  310. if (!m.file && !m.taskId) continue;
  311. const covered = body.some((b) => b.message.id === m.id);
  312. if (covered) continue;
  313. if (m.file) body.push({ kind: 'file', key: `${m.id}-f`, file: m.file, message: m });
  314. if (m.taskId) body.push({ kind: 'task', key: `${m.id}-t`, taskId: m.taskId, message: m });
  315. }
  316. return { steps, body };
  317. }
  318. /**
  319. * 从一段文本里取过程行(返回 steps)。
  320. * 纯终端文本直接用 `splitMessage` 的 process 块。
  321. */
  322. function stepsOf(text: string): ProcessStep[] {
  323. const out: ProcessStep[] = [];
  324. for (const b of splitMessage(text)) {
  325. if (b.kind === 'process') out.push(...b.steps);
  326. }
  327. return out;
  328. }
  329. /**
  330. * 把一段**混合**文本拆成「过程步骤」与「纯正文」。
  331. *
  332. * 两步:
  333. * ① `splitMessage` 能识别的 process 块 → 步骤
  334. * ② 剩下的 text 块里,仍可能有被 `toBlocks` 内联的单步行(`· bash`),按行再剥一次
  335. *
  336. * 返回的 text 已不含过程行,顶部折叠后正文不会再出现一遍。
  337. */function divideText(text: string): { steps: ProcessStep[]; text: string } {
  338. const steps: ProcessStep[] = [];
  339. const keep: string[] = [];
  340. /*
  341. * 🔴 先按代码围栏切段,再在段内逐行拆(2026-11-11 修复空代码块)。
  342. *
  343. * 原来直接对整条文本逐行判:闭合围栏 ``` 被 `isCommandLine` 当成命令
  344. * 挪进过程区,正文里只剩开围栏 → markdown 解析出一个**空 `<pre>`**,
  345. * 叠加 `md-render` 的「复制」按钮,界面上就是一个黑色空块。
  346. *
  347. * 现在围栏内的所有行**原样进正文**,不参与任何命令行/步骤判定。
  348. */
  349. for (const piece of splitByFences(text)) {
  350. if (piece.fence) {
  351. keep.push(...piece.lines);
  352. continue;
  353. }
  354. for (const b of splitMessage(piece.lines.join('\n'))) {
  355. if (b.kind === 'process') {
  356. /*
  357. * 🔴 `splitMessage` 也会把「行首 emoji + 成句中文」解析成 process 块
  358. * (`💾 自我升级小结:…了。` 实测如此)。这类是**结论**不是过程,
  359. * 交还正文,否则折叠区里会出现半句话。
  360. */
  361. const prose = b.steps.filter((s) => isProseLine(s.label));
  362. const tools = b.steps.filter((s) => !isProseLine(s.label));
  363. steps.push(...tools);
  364. for (const p of prose) keep.push(p.label);
  365. continue;
  366. }
  367. if (b.kind !== 'text') continue;
  368. for (const line of b.text.split('\n')) {
  369. const inline = inlineStepOf(line);
  370. if (inline && !isProseLine(line)) {
  371. steps.push(inline);
  372. continue;
  373. }
  374. /*
  375. * 裸命令行(无 emoji、无 `·` 前缀)也要进过程区(2026-11-11)。
  376. * 容器会把 `cd /opt/data && node x.js` 直接贴出来 ——
  377. * 它既不是正文也不带工具标记,光靠 `inlineStepOf` 识别不到。
  378. */
  379. if (isCommandLine(line)) {
  380. steps.push({ kind: 'tool', label: line.trim() });
  381. continue;
  382. }
  383. keep.push(line);
  384. }
  385. }
  386. }
  387. return { steps, text: keep.join('\n').replace(/\n{3,}/g, '\n\n') };
  388. }
  389. /**
  390. * 按**成对**代码围栏切成若干段(2026-11-11)。
  391. *
  392. * - 围栏行本身与其内部内容 → `fence: true` 段,调用方应整段当正文
  393. * - 围栏外 → `fence: false` 段,调用方可继续逐行拆
  394. * - **未闭合**的围栏延续到文末,整段当正文(宁可多显示代码,不要吞内容)
  395. */
  396. function splitByFences(text: string): { fence: boolean; lines: string[] }[] {
  397. const FENCE = /^\s*(`{3,}|~{3,})/;
  398. const out: { fence: boolean; lines: string[] }[] = [];
  399. let cur: { fence: boolean; lines: string[] } = { fence: false, lines: [] };
  400. let open: string | null = null;
  401. const flush = (): void => {
  402. if (cur.lines.length > 0) out.push(cur);
  403. };
  404. for (const line of text.split('\n')) {
  405. const fm = FENCE.exec(line);
  406. if (fm) {
  407. const mark = fm[1][0];
  408. if (open === null) {
  409. /* 开围栏:把围栏行放进一个新的 fence 段 */
  410. flush();
  411. cur = { fence: true, lines: [line] };
  412. open = mark;
  413. } else if (mark === open) {
  414. /* 闭围栏:收尾当前 fence 段 */
  415. cur.lines.push(line);
  416. flush();
  417. cur = { fence: false, lines: [] };
  418. open = null;
  419. } else {
  420. /* 围栏内出现另一种记号 → 当内容 */
  421. cur.lines.push(line);
  422. }
  423. continue;
  424. }
  425. cur.lines.push(line);
  426. }
  427. flush();
  428. return out;
  429. }
  430. /** 行首 `· 工具名` 形态 → 一步;否则 null */
  431. const INLINE_STEP = /^\s*[·•]\s*([a-zA-Z][\w.\-]{0,40})(?:\s*[::]?\s*(.+))?$/;
  432. function inlineStepOf(line: string): ProcessStep | null {
  433. const m = INLINE_STEP.exec(line);
  434. if (!m) return null;
  435. return { kind: 'tool', label: m[1], detail: m[2]?.trim() || undefined };
  436. }
  437. /**
  438. * 这条「看起来像终端活动」的文本,其实是一句**结论性叙述**吗?
  439. *
  440. * ## 为什么需要(2026-11-07 实测)
  441. *
  442. * 容器会这样汇报一次自我升级:
  443. *
  444. * ```
  445. * 💾 自我升级小结:🔧 「multi-artifact-delivery」磨得更锋利了。
  446. * ```
  447. *
  448. * 行首 `💾` 命中了 `TOOL_EMOJI_LEAD`,于是整条被当成工具活动,
  449. * 折叠头上显示成「运行了 1 步 · 自我升级小结:…」——
  450. * 用户要读的**结论**被折进了过程区。
  451. *
  452. * ## 判定口径(宁可放过、不可误折)
  453. *
  454. * 同时满足才算叙述:
  455. * ① 以中文句末标点收尾(。!?)
  456. * ② 去掉行首 emoji 后,剩下的是一段**中文句子**(至少 6 个汉字)
  457. *
  458. * 真正的工具行(`💻 bash`、`✍️ Writing /path`)主体是英文工具名与路径,
  459. * 不含成句中文,不会被误判。
  460. */
  461. function proseOf(text: string): boolean {
  462. const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
  463. if (lines.length === 0) return false;
  464. // 只要有一条是「成句中文」,整条就当正文 —— 终端活动不会夹杂叙述
  465. return lines.some((line) => {
  466. if (!/[。!?]$/.test(line)) return false;
  467. // 剥掉行首的项目符号与 emoji(含变体选择符 / 零宽连接符)
  468. const body = line
  469. .replace(/^[\s*·•-]+/, '')
  470. .replace(/^[\p{Extended_Pictographic}️‍\u{1F3FB}-\u{1F3FF}]+\s*/u, '');
  471. return (body.match(/[一-龥]/g)?.length ?? 0) >= 6;
  472. });
  473. }
  474. /* ------------------------------------------------------------------ */
  475. /* 块 → 视图 */
  476. /* ------------------------------------------------------------------ */
  477. /**
  478. * 默认「还在输出」的静默窗口 —— 与 `turn-aggregator.QUIET_MS` 同源。
  479. *
  480. * 🔴 为什么不能用一个短窗口(2026-11-08 用户反馈「只闪一下『正在回复』就消失」):
  481. * 容器跑工具时,两段正文之间常有 5–20 秒空档。窗口短于一拍,
  482. * 回合就被判成「已结束」,界面的「正在思考…」随之消失 ——
  483. * 而容器其实还在干活,用户无从得知。
  484. *
  485. * 调用方应优先传 `QUIET_MS`;这里的字面量只是兜底,两处必须同步修改。
  486. */
  487. /**
  488. * 判定「超时」的时长门槛。
  489. *
  490. * ## 演进
  491. *
  492. * | 时间 | 值 | 原因 |
  493. * |---|---|---|
  494. * | 2026-11-10 | 5 分钟 | 初值 |
  495. * | 2026-11-10 | 3 分钟 | 5 分钟太长(干等),1 分钟太短(误报)→ 折中 |
  496. * | **2026-11-11** | **5 分钟** | 见下(**语义变了,不能直接沿用旧结论**) |
  497. *
  498. * ## 为什么又回到 5 分钟
  499. *
  500. * 旧结论「5 分钟太长」成立的前提是:**超时会强行把界面改成「已结束」**,
  501. * 用户干等 5 分钟看到的是错误的完成状态,所以越短越好。
  502. *
  503. * 2026-11-11 用户裁决「应该保持到会话结束」之后,这个前提没了:
  504. * · 超时**不再改写状态** —— 长任务跑多久就如实显示多久「输出中」
  505. * · 超时只决定**什么时候弹一条提示**,告诉用户「好像有点久」
  506. *
  507. * 在这种情况下,短反而有害:
  508. * · 容器自己实测跑过 3 分钟以上(它还说过「3 分钟了,值得等」)
  509. * · 180 秒会在正常长任务上频繁误报,把提示变成狼来了
  510. *
  511. * 5 分钟能覆盖实测到的长任务,同时容器真挂了也不会让用户无限茫然。
  512. *
  513. * @see isTurnStalled —— 另一条提示路径:**有输出但长时间无新增**,
  514. * 门槛是 `STALL_MS`(60 秒),与这里互补,不会重复打扰。
  515. */
  516. export const TURN_TIMEOUT_MS = 300_000;
  517. const DEFAULT_QUIET_MS = 25_000;
  518. /**
  519. * 「卡住」门槛:**最后一条输出之后**静止多久算异常。
  520. *
  521. * 取 60 秒:容器跑一轮工具调用常有 10~30 秒空档,
  522. * 1 分钟毫无动静基本可以认定停下了,同时不至于误报。
  523. *
  524. * 🔴 位置必须在 `toBlockView` **之前**:它用这个常量做「长任务宽限」,
  525. * 而模块级 `const` 是从上往下求值的,放到后面会触发 TDZ 报错。
  526. */
  527. export const STALL_MS = 60_000;
  528. function toBlockView(
  529. src: BlockSource,
  530. now: number,
  531. quietMs: number,
  532. waitingSince = 0,
  533. /**
  534. * 用户已发出消息、但容器还一条都没回(2026-11-09)。
  535. *
  536. * 🔴 为什么需要独立标志:
  537. * 这类回合 `blocks` 为空 → `updatedAt = 0` → `idleFor` 极大 → `live` 恒 false,
  538. * 于是回合卡根本不会生成,界面在发送后**什么状态都不显示**,
  539. * 只能干等容器约 10 秒后第一次回话(用户实测「10 秒后才显示正在思考」)。
  540. *
  541. * 但「已发出」本身就是一个确定的状态,应该立刻可见。
  542. */
  543. awaitingFirstReply = false,
  544. ): TurnBlockView {
  545. const { steps, body } = splitIntoParts(src.messages);
  546. const files = body
  547. .filter((b): b is Extract<TurnBodyBlock, { kind: 'file' }> => b.kind === 'file')
  548. .map((b) => ({ key: b.key, file: b.file }));
  549. /*
  550. * live 判定与 `isTurnOpen` 同口径:容器正在 typing,或末条消息至今未超过静默窗口。
  551. * 群聊里某个成员可能先回完,所以按**块**判断而不是按回合。
  552. *
  553. * 🔴 typing 不能无限期作数(2026-11-09)。
  554. *
  555. * 实测:容器一轮结束后**不会**发 `m.typing:false`,`peerTyping` 一直停在 true。
  556. * 这时 `live` 恒为真 → 回合永远「输出中」、永远不会自动折叠
  557. * (用户实测:内容早已输出完,界面挂着「运行了 2 步 · 163s 输出中」)。
  558. *
  559. * 所以 typing 也受静默窗口约束:已经很久没有新内容,就不再信它。
  560. */
  561. const idleFor = now - src.updatedAt;
  562. /*
  563. * 🔴 typing 窗口收紧为 `quietMs`(2026-11-11 用户实测)。
  564. *
  565. * 原来是 `quietMs * 2`(50 秒):容器明明已经把正文输出完、也不再发新消息,
  566. * 界面却要继续挂 50 秒「输出中」,用户以为卡住了(截图:20s 那次读数,
  567. * 实际等了 56 秒才变「已完成」)。
  568. *
  569. * 容器结束后**不会发 `m.typing:false`**,所以 typing 迟早要过期;
  570. * 既然过期后由「静默窗口」接管(同样的 quietMs),就没必要给 typing 双倍宽限 ——
  571. * 两个窗口对齐后,正文一停、读数也就跟着停,不会多挂 25 秒。
  572. */
  573. const typingAlive = src.typing && idleFor < quietMs;
  574. /*
  575. * 等待超时收尾(2026-11-10,用户裁决保留):
  576. *
  577. * `awaitingFirstReply` 若恒为真 → `live` 永远 true → 界面永远挂着
  578. * 「已送达,等待回复…(253s)」,`isTurnTimedOut` 要求 `!live` 永远不成立,
  579. * 全局提示也就永远不弹。所以等待超过 `TURN_TIMEOUT_MS` 后不再算「还在等」,
  580. * 交给超时逻辑收尾 → 占位消失、全局提示出现。
  581. */
  582. const waitingTooLong =
  583. awaitingFirstReply && waitingSince > 0 && now - waitingSince >= TURN_TIMEOUT_MS;
  584. /*
  585. * 🔴 用户级超时(2026-11-11 口径定稿后必须对**所有**未结束回合生效)。
  586. *
  587. * 结束判据改成「只看容器信号」之后,`live` 会在容器不吭声时一直为真。
  588. * 如果只有「还没回第一条」的回合能超时,那么「回了一半、容器挂了」的回合
  589. * 会永远转圈 —— 必须有出口,否则是把「提前完成」换成了「永远转圈」。
  590. *
  591. * 这是**异常态**出口:页面层据此弹「容器无响应」提示,
  592. * 与「正常完成」在语义上分得清清楚楚。
  593. */
  594. const hardTimedOut =
  595. waitingSince > 0 && now - waitingSince >= TURN_TIMEOUT_MS;
  596. /*
  597. * 🔴 长任务宽限(2026-11-11 用户实测修复)。
  598. *
  599. * 用户看到「运行了 2 步 · Writing ... 9s · 已结束 · 无交付」,
  600. * 而容器其实还在跑。
  601. *
  602. * 根因:`idleFor` 用的是**最后一条 agent 消息**的时间,而容器跑长工具时
  603. * 恰恰不产生消息 —— 20~25 秒空档就足以让 `live` 转 false,
  604. * 于是「有过程步骤、但没正文」的块被判成 `progressOnly`(已结束)。
  605. *
  606. * 现在:**已有过程步骤**说明容器真的在干活,宽限到 `STALL_MS`(60 秒)。
  607. * 超过 60 秒仍无动静,就交给 `isTurnStalled` 的全局提示(「容器已停止响应」),
  608. * 两个信号同一常量、互补而不冲突。
  609. */
  610. const hasSteps = steps.length > 0;
  611. const graceMs = hasSteps ? Math.max(quietMs, STALL_MS) : quietMs;
  612. /*
  613. * 🔴 「先显示已完成、过很久正文才出来」(2026-11-11 用户实测第二大问题)。
  614. *
  615. * ## 现象
  616. *
  617. * 用户问天气 → 容器先回一句「我先查一下这三地的实时天气。」→ 然后跑工具、
  618. * 几十秒后才发真正的结果。第一条消息到达时静默窗口开始倒计时,宽限一过
  619. * 就被判成 `done`(「已完成」),用户看到「已完成」却没有答案。
  620. *
  621. * ## 为什么不能只靠时间窗口
  622. *
  623. * 容器是**分条发送**的:它可能在任何时刻停顿 60 秒以上(跑长工具、查网络)。
  624. * 从「有没有消息」推断「有没有干完」,本质上信息不足。
  625. *
  626. * ## 现在的判据(三档,优先级从高到低)
  627. *
  628. * ① 容器**明确宣告结束**(`turnEnded`:✅ reaction / typing=false)
  629. * → 立即收尾,这是唯一「确定干完了」的信号
  630. * ② 没有任何结束信号,但**本轮已经从用户提问算起跑完了整个超时窗口**
  631. * → 才允许判结束(兜底老容器,行为与改造前一致)
  632. * ③ 其余情况 → 保持「输出中」,不提前说完成
  633. *
  634. * 这样长任务不会再出现「先完成后输出」:只要容器没说结束,
  635. * 界面就一直如实显示在干活。
  636. */
  637. const silentLongEnough =
  638. waitingSince > 0 && now - waitingSince >= TURN_TIMEOUT_MS;
  639. /*
  640. * 🔴 `src.closed` 在有步骤时也要让路。
  641. *
  642. * `closed` 是聚合层在静默 `QUIET_MS` 后打的「这轮不再有新消息」标记 ——
  643. * 它恰恰会在容器跑长工具时打上(跑工具不产生消息)。
  644. * 若这里仍然 `!src.closed`,宽限就永远用不上,用户照样看到「已结束」。
  645. *
  646. * 所以:**没有步骤时**照旧尊重 closed(正常收尾);
  647. * **有步骤时**以时间为准(宽限到 STALL_MS)。
  648. */
  649. const closedBlocks = !hasSteps && src.closed;
  650. /*
  651. * 🔴 容器明确宣告结束(2026-11-11,`turnEnded`)—— **优先级高于一切时间窗口**。
  652. *
  653. * 实测(`scripts/.probe-typing.mjs`,lj-test):容器 16.8 秒就把正文发完并打了 ✅,
  654. * 但整轮 105 秒发不出一条结束信号,App 只能等 25 秒(有步骤时 60 秒)静默窗口,
  655. * 于是「20 秒的活」显示到 50 多秒还在「输出中」(用户实测截图)。
  656. *
  657. * 现在容器侧插件在 `post_llm_call` 发 `m.typing=false`,App 侧收到即停:
  658. * · 有结束信号 → `live` 立刻为 false,读数定格在末条消息时刻
  659. * · 无结束信号(未改造容器)→ 照旧走下面的静默窗口兜底,行为不变
  660. *
  661. * ⚠️ 只认「**确认结束**」,不认「没在打字」。容器在两条消息之间的空档也可能发
  662. * `typing=false`(本轮结束刷新),若把它当成结束,长工具跑一半就会被切断。
  663. * 所以信号必须带**轮次标识**,由 `turnEndedFor()` 按「是否晚于本块末条消息」判定。
  664. */
  665. const turnEnded = src.turnEnded === true;
  666. /*
  667. * 🔴 「还没收到结束信号」→ 不许判完成(2026-11-11)。
  668. *
  669. * 这是「先完成后输出」的根治点:容器分条发送时,第一条消息到达后
  670. * 静默窗口就开始了,宽限一过就显示「已完成」,而真正的答案还在后面。
  671. *
  672. * 判据:**有消息、没结束信号、整个超时窗口还没跑完** → 继续算「输出中」。
  673. * · 老容器(无信号):最多多挂到 180 秒超时,之后照常收尾 → 不会永远转
  674. * · 新容器:收到 ✅/typing=false 立刻收尾 → 不残留
  675. *
  676. * 注意这里**不覆盖** `turnEnded` 和 `waitingTooLong`:
  677. * 有结束信号就正常结束;超过 180 秒必须有出口,否则卡片永远停不下来。
  678. */
  679. /*
  680. * 🔴 结束以**容器信号**为准,不以时间猜(2026-11-11 用户裁决)。
  681. *
  682. * 用户原话:「为什么保持到 90s,应该保持到会话结束」。
  683. *
  684. * 他说得对。90 秒是我拍的兜底值,本质还是「用时间猜」——
  685. * 容器跑 2 分钟的长任务,到 90 秒照样会被标成「已结束」,
  686. * 这正是用户一直在抱怨的那个毛病。把 60 秒换成 90 秒,只是把问题推迟了 30 秒。
  687. *
  688. * 正确判据只有一条:**容器说结束(✅/❌ reaction)才算结束**。
  689. * 在那之前,只要这一轮**确实在干活**(有过程步骤),就一直算「输出中」。
  690. *
  691. * 唯一的出口是用户级超时 `TURN_TIMEOUT_MS`(180 秒):
  692. * · 它由页面层弹「容器无响应」提示,是**明确的异常态**,不是"正常完成"
  693. * · 没有它,容器真挂了界面会永远转圈
  694. *
  695. * ⚠️ 只在**有过程步骤**时启用:没有步骤的纯文本回合(容器只回一句话),
  696. * 仍按静默窗口收尾 —— 那种回合发完就是发完了,不该无限挂着。
  697. */
  698. const heldOpenByNoSignal =
  699. hasSteps && !turnEnded && waitingSince > 0;
  700. /*
  701. * 🔴 超时**不再强行改写状态**(2026-11-11 用户裁决「应该保持到会话结束」)。
  702. *
  703. * 上一版把 `hardTimedOut` 直接塞进 `live`,到点就把回合从「输出中」改成
  704. * 「已结束」—— 这跟「60 秒猜完成」是同一个错误,只是把猜的时间点挪后。
  705. * 用户要的是:**干活期间始终如实显示在干活**。
  706. *
  707. * 现在超时的作用只剩一个:**让页面层弹「容器无响应」提示**
  708. * (`isTurnTimedOut` 消费 `waitMs`),状态本身继续如实显示。
  709. * 用户看到「跑了 8 分钟 + 顺便弹个提示」比「跑到 3 分钟突然说完成」合理得多。
  710. */
  711. const live =
  712. !closedBlocks &&
  713. !(waitingTooLong && !hasSteps) &&
  714. !turnEnded &&
  715. (awaitingFirstReply || typingAlive || idleFor < graceMs || heldOpenByNoSignal);
  716. /*
  717. * 「还在等回复」——与 `live` **解耦**(2026-11-11 用户实测修复)。
  718. *
  719. * ## 为什么要独立于 live
  720. *
  721. * `live` 回答的是「是不是在流式输出」,它受 25 秒静默窗口约束
  722. * (`QUIET_MS`)—— 容器不回 typing 事件,不这样收尾回合就永远不结束。
  723. *
  724. * 但用户看到的是:发完消息,等 25 秒,界面**变成一片空白**
  725. * (段落被跳过、等待文案返回空串),然后一直空白到第 180 秒才弹超时提示。
  726. * 中间这 155 秒里没有任何「我在等」的指示(用户实测反馈)。
  727. *
  728. * `waiting` 回答的是「用户还在等第一口气」:只要还没超时,就该一直有提示。
  729. */
  730. /*
  731. * 「还在等第一口气」(2026-11-11)—— 与 `live` / `closed` 都解耦。
  732. *
  733. * 判据只看三件事:
  734. * ① 用户确实发过消息(`waitingSince > 0`)
  735. * ② 容器**一条 agent 消息都没回**(`src.messages` 为空)
  736. * ③ 还没到 180 秒超时
  737. *
  738. * 🔴 为什么不能复用 `awaitingFirstReply`:它在 `turnsFromDm` 里带了
  739. * `!t.closed`,而 `closed` 是聚合层在 25 秒静默后打的标记,远早于用户的
  740. * 超时判定。复用它会让「回合收尾 → live=false → waiting=false」同时成立,
  741. * 界面正好掉进那段纯空白(用户实测反馈的正是这个)。
  742. *
  743. * 🔴 为什么不能写成 `!src.closed`:`toBlockView` 拿不到 `t.closed`,
  744. * 而且上一条的理由说明这个标记本来就不该参与用户等待的语义。
  745. */
  746. const waiting =
  747. awaitingFirstReply && src.messages.length === 0 && waitingSince > 0 && !waitingTooLong;
  748. /*
  749. * 用时读数(2026-11-10 合并口径):
  750. *
  751. * | 字段 | 锚点 | 用途 |
  752. * |---|---|---|
  753. * | `startAt` | **用户发出问话的时刻** | 头部与段头的实时读数(1→2→3… 每秒加) |
  754. * | `seconds` | 首条 → 末条 agent 消息 | 容器**真实处理耗时**,结束时定格,不跳变 |
  755. *
  756. * 进行中的读数由渲染层用每秒时钟现算(`elapsedSeconds()`),
  757. * 不再用「消息事件时间戳相减」——那样只会 5s → 18s → 30s 地跳。
  758. */
  759. const startAt = waitingSince > 0 ? waitingSince : src.startedAt;
  760. return {
  761. key: src.key ?? src.agentId ?? 'agent',
  762. agentId: src.agentId,
  763. /** 容器真实处理耗时(首条 → 末条 agent 消息),结束才有意义 */
  764. seconds:
  765. src.startedAt > 0 && src.updatedAt >= src.startedAt
  766. ? Math.max(0, Math.round((src.updatedAt - src.startedAt) / 1000))
  767. : 0,
  768. /** 本块开始时刻(epoch ms,0 = 无锚点)—— 逐秒读数的锚点 */
  769. startAt,
  770. updatedAt: src.updatedAt,
  771. steps,
  772. body,
  773. files,
  774. live,
  775. turnEnded,
  776. waiting,
  777. empty: !live && !waiting && body.length === 0 && steps.length === 0,
  778. merged: mergeBodies([{ body }]),
  779. waitingSince,
  780. waitMs: waitingSince > 0 ? Math.max(0, now - waitingSince) : 0,
  781. };
  782. }
  783. /**
  784. * 由各块合成回合阶段。
  785. *
  786. * 🔴 2026-11-10:「已完成」必须真的代表**有东西交付给用户**。
  787. *
  788. * 原判据是「只要不是全空就算 done」,于是容器只回了一串内部进度
  789. * (`🐍 Running code…` + 一句日志)时,卡片照样标「已完成」,
  790. * 用户打开一看什么也没有(实测截图)。
  791. *
  792. * 现在区分三档:
  793. * · 有正文 或 有文件 → `done`(真的交付了)
  794. * · 只有过程步骤、没有正文也没有文件 → `progressOnly`(干了活,但没交付)
  795. * · 什么都没有 → `idle`
  796. */
  797. function phaseOf(blocks: TurnBlockView[], closed: boolean): TurnPhase {
  798. if (blocks.some((b) => b.live && (b.body.length > 0 || b.steps.length > 0))) return 'replying';
  799. if (blocks.some((b) => b.live)) return 'thinking';
  800. if (blocks.length === 0) return closed ? 'idle' : 'thinking';
  801. const delivered = blocks.some((b) => b.body.length > 0 || b.files.length > 0);
  802. if (delivered) return 'done';
  803. // 只剩过程步骤:做过事,但没有可交付的内容
  804. return blocks.some((b) => b.steps.length > 0) ? 'progressOnly' : 'idle';
  805. }
  806. /**
  807. * 一块的**当前**用时秒数(2026-11-10 用户要求:一秒一秒叠加)。
  808. *
  809. * | 状态 | 返回值 |
  810. * |---|---|
  811. * | 无锚点(`startAt <= 0`) | 0,不显示 |
  812. * | 已结束(`!live`) | 从用户发问到结束的总用时(定格) |
  813. * | 进行中 | `floor((now - startAt) / 1000)`,**逐秒递增** |
  814. *
  815. * 🔴 进行中必须用实时时钟现算。原来直接用结束时刻相减,
  816. * 只在容器发新事件那一跳才变,用户看到的是 5s → 18s → 30s 的跳变。
  817. *
  818. * 取整一律 `floor`:进行中与结束态用同一种口径,结束瞬间才不会倒退 1 秒。
  819. *
  820. * @param block 块视图
  821. * @param now 当前时间(组件每秒刷新一次的那个时钟)
  822. */
  823. export function elapsedSeconds(block: TurnBlockView, now: number): number {
  824. if (block.startAt <= 0) return 0;
  825. /*
  826. * 🔴 结束态与进行中必须**同口径**,否则读数会倒退(2026-11-11 用户实测)。
  827. *
  828. * 进行中显示 `now - startAt` —— 这是用户体感的等待时长,包含容器思考的空档。
  829. * 原来结束后切成 `updatedAt - startAt`(末条 agent 消息时刻),
  830. * 但 `updatedAt` 只到「最后一条 agent 消息」,比用户实际等的短:
  831. * 用户等了 56 秒,一结束读数瞬间掉回 20 秒,看起来像计时坏了。
  832. *
  833. * 🔴 2026-11-11 二次修正:有**结束信号**时定格在末条消息时刻。
  834. *
  835. * 容器侧插件现在会发 `m.typing=false`,所以「正文发完」到「界面收尾」之间
  836. * 不再有 25/60 秒的空转。此时若仍读到 `now`,末条消息之后到信号到达的那一两秒
  837. * 会被算进读数,与 `seconds`(容器真实处理耗时)对不上。
  838. * 有信号时用 `updatedAt`(末条消息),读数即为容器真实耗时,精确且不再倒退。
  839. *
  840. * 只有在**没有结束信号**(未改造的老容器)时,才保留「用户体感时长」口径 ——
  841. * 因为那种情况下收尾是猜的,读数取 `now` 才不会在判定结束前突然跳跃。
  842. */
  843. if (!block.live) {
  844. if (block.updatedAt <= block.startAt) return block.seconds;
  845. /*
  846. * 定格到「末条活动时刻」。`now` 只用来兜住时钟尚未推进的边界,
  847. * 不额外加宽限 —— 宽限值是拍脑袋的,会让读数与测试/实际都对不上。
  848. */
  849. const endAt = Math.min(now, block.updatedAt);
  850. return Math.max(0, Math.floor((endAt - block.startAt) / 1000));
  851. }
  852. return Math.max(0, Math.floor((now - block.startAt) / 1000));
  853. }
  854. /**
  855. * 等待中的占位文案(2026-11-09 用户裁决)。
  856. *
  857. * | 阶段 | 判据 | 文案 |
  858. * |---|---|---|
  859. * | 已发出,还没有任何输出 | `live` 且无内容 | 8 秒内「正在思考…」;之后「已送达,等待回复…(Ns)」 |
  860. * | 开始输出 | `live` 且有内容 | 「正在输出…(Ns)」 |
  861. * | 结束且全空 | 非 live 且 empty | **空**(不再显示「未响应」气泡) |
  862. *
  863. * 🔴 文案要**一直兜底到回复真的回来**,中途不能消失:
  864. * 容器回一轮要十几秒,界面空白会被理解成「发了没反应」。
  865. *
  866. * 🔴 2026-11-10 用户裁决:**超时不再在会话里留「未响应」气泡**。
  867. *
  868. * 原实现回合结束后恒显示一个「⊙ 未响应」的小气泡,永远留在消息流里,
  869. * 看起来像「每条都失败了」。改为:
  870. * · 会话内**完全移除**该占位(返回空串,段级判据同步不再渲染)
  871. * · 由页面层弹**全局提示**(可手动关闭、不自动消失),见 `useTurnTimeout()`
  872. *
  873. * @param waitMs 距用户发出消息已经过了多久
  874. * @param hasOutput 是否已经开始输出内容
  875. * @param live 是否仍在进行中
  876. * @param empty 是否已结束且什么也没有
  877. */
  878. export function waitText(
  879. waitMs: number,
  880. hasOutput: boolean,
  881. live: boolean,
  882. empty: boolean,
  883. /**
  884. * 锚点时刻(epoch ms):用户发出这条问话的时刻(2026-11-11)。
  885. *
  886. * 传了就带上起始时刻,让用户知道「从几点开始等的」——
  887. * 只显示「正在思考…」时,用户分不清是刚发出还是已经卡了五分钟,
  888. * 也不知道该不该刷新。0 / 缺省 = 不显示时刻(保持旧行为,老调用点不受影响)。
  889. */
  890. startAt = 0,
  891. ): string {
  892. // 已结束:无论有没有内容,都不再留等待占位(超时提示交给全局 toast)
  893. if (!live) return '';
  894. const sec = Math.max(0, Math.round(waitMs / 1000));
  895. const at = startAt > 0 ? `${fmtTime(startAt)} ` : '';
  896. if (hasOutput) return sec > 0 ? `正在输出…(${sec}s)` : '正在输出…';
  897. if (waitMs < WAIT_HINT_MS) {
  898. return at ? `已送达 ${at}· 正在思考…(${sec}s)` : '正在思考…';
  899. }
  900. return sec > 0
  901. ? `${at ? `${at}已送达` : '已送达'},等待回复…(${sec}s)`
  902. : '已送达,等待回复…';
  903. }
  904. /** 超过这个时长就明确告诉用户「发出了、在等」,不再只是「正在思考」 */
  905. export const WAIT_HINT_MS = 8_000;
  906. /**
  907. * 判定「这一轮是否已经超时未响应」(2026-11-10)。
  908. *
  909. * 超时 = 用户已发出消息、但经过 `timeoutMs` 仍**没有任何 agent 输出**,且回合已结束。
  910. * 页面据此弹全局提示(手动关闭)。
  911. *
  912. * @param waitMs 距用户发出消息的毫秒数
  913. * @param hasOutput agent 是否已经有正文或步骤
  914. * @param live 回合是否仍在进行
  915. */
  916. export function isTurnTimedOut(
  917. waitMs: number,
  918. hasOutput: boolean,
  919. live: boolean,
  920. timeoutMs = TURN_TIMEOUT_MS,
  921. ): boolean {
  922. return !live && !hasOutput && waitMs >= timeoutMs;
  923. }
  924. /**
  925. * 判定「容器卡住了」(2026-11-11)。
  926. *
  927. * ## 与 `isTurnTimedOut` 的分工
  928. *
  929. * | 情况 | 判据 | 函数 |
  930. * |---|---|---|
  931. * | 发了之后**一个字都没回**,超过 3 分钟 | `!live && !hasOutput && wait >= 180s` | `isTurnTimedOut` |
  932. * | 回了**一部分**就停住,不再有任何新增 | `hasOutput && 静止 >= 60s` | `isTurnStalled` |
  933. *
  934. * 🔴 为什么必须有第二个:容器常常先回一句「我先看一下这个文件」、
  935. * 跑了两步工具,然后就再没有下文了(用户实测截图 09:51 之后一片空白)。
  936. * 这时 `hasOutput === true`,`isTurnTimedOut` **永远返回 false** ——
  937. * 界面停在「已结束」但没有任何提示,用户只能干等。
  938. *
  939. * 这里的「静止」用**最后一条消息到现在的时长**,不是用户发问时长:
  940. * 容器跑长任务时应该一直在产生工具活动,持续 1 分钟毫无动静即为异常。
  941. *
  942. * @param idleMs 距本回合**最后一次** agent 活动的毫秒数
  943. * @param hasOutput agent 是否已经有正文或步骤
  944. * @param live 回合是否仍在进行(还在 live 说明 Quartz 静默窗口还没过)
  945. */
  946. export function isTurnStalled(
  947. idleMs: number,
  948. hasOutput: boolean,
  949. live: boolean,
  950. stallMs = STALL_MS,
  951. ): boolean {
  952. /*
  953. * 🔴 2026-11-11 口径定稿后修正:**不再要求 `!live`**。
  954. *
  955. * 旧判据假设「live 说明还在动」,但现在 `live` 的语义变成了
  956. * 「这一轮还没收到结束信号」—— 长任务卡死时它会一直为真,
  957. * 于是「容器无响应」提示永远不会弹,用户只能干等。
  958. *
  959. * 正确的判据是**有没有新东西进来**:已经有输出、但连续 `stallMs` 毫无新增,
  960. * 无论 live 与否都该提醒用户「可能卡住了」。
  961. * 它只是**提示**,不改状态 —— 容器真有动静了,下一次渲染提示自然消失。
  962. */
  963. /* 一个字都没回的由 `isTurnTimedOut` 负责,两处不重叠 */
  964. if (!hasOutput) return false;
  965. return idleMs >= stallMs;
  966. }
  967. /* ------------------------------------------------------------------ */
  968. /* 两种输入 → 统一的回合视图 */
  969. /* ------------------------------------------------------------------ */
  970. /**
  971. * 群聊:`buildGroupTurns` 的结果 → 回合视图。
  972. *
  973. * 每个 `GroupBlock` 对应一个块;块顺序保持 `targets` 的声明顺序。
  974. */
  975. export function turnsFromGroup(
  976. turns: GroupTurn[],
  977. now: number,
  978. quietMs = DEFAULT_QUIET_MS,
  979. nameOf?: (agentId: string) => string,
  980. ): AssistantTurn[] {
  981. return turns.map((t) => {
  982. const blocks = t.blocks.map((b) => {
  983. const view = toBlockView(
  984. {
  985. agentId: b.agentId,
  986. messages: b.messages,
  987. startedAt: b.startedAt,
  988. updatedAt: b.updatedAt,
  989. typing: b.typing,
  990. closed: b.closed,
  991. },
  992. now,
  993. quietMs,
  994. t.userMessage ? Date.parse(t.userMessage.ts) || 0 : 0,
  995. /*
  996. * 群聊里「这一块还没收到任何回复」= 等待中(2026-11-09,与单聊同口径)。
  997. *
  998. * 原来空块的 `updatedAt = 0` 会让 `idleFor` 极大,`live` 恒 false,
  999. * 于是卡片是个空壳、没有文字 —— 用户看不到「谁在思考」(实测)。
  1000. */
  1001. b.messages.length === 0 && !b.closed,
  1002. );
  1003. const name = nameOf?.(b.agentId);
  1004. return name ? { ...view, agentName: name } : view;
  1005. });
  1006. const live = blocks.some((b) => b.live);
  1007. return {
  1008. id: t.id,
  1009. userMessage: t.userMessage,
  1010. userFiles: t.userFiles ?? [],
  1011. blocks,
  1012. phase: phaseOf(blocks, t.closed),
  1013. // 与块级同口径:从用户发消息起算,live 时走当前时钟(时间戳取自原始块)
  1014. seconds: roundSeconds(t.blocks, now, live, t.userMessage ? Date.parse(t.userMessage.ts) || 0 : 0),
  1015. live,
  1016. closed: t.closed,
  1017. stepCount: blocks.reduce((n, b) => n + b.steps.length, 0),
  1018. fileCount: blocks.reduce((n, b) => n + b.files.length, 0),
  1019. merged: mergeBodies(blocks),
  1020. files: collectFiles(blocks),
  1021. };
  1022. });
  1023. }
  1024. /**
  1025. * 单聊:`buildTurns` 的结果 → 回合视图(固定 1 块)。
  1026. *
  1027. * @param typing 容器是否在 typing(容器大多不发,通常为 false)
  1028. * @param quietMs 「还在输出」的静默窗口,调用方传 `QUIET_MS`
  1029. * @param agentIdle 容器**明确上报**的空闲态。为 true 时忽略静默窗口直接收尾 ——
  1030. * 只有容器能宣布「我干完了」,比任何时间窗都准。
  1031. * @param turnEndedAfter 收到容器 `m.typing=false` 时,本会话最后一条 agent 消息的 id
  1032. * (null = 无信号)。只有「本块末条消息就是这个 id」才认它结束。
  1033. */
  1034. export function turnsFromDm(
  1035. turns: Turn[],
  1036. now: number,
  1037. typing: boolean,
  1038. quietMs = DEFAULT_QUIET_MS,
  1039. agentIdle = false,
  1040. turnEndedAfter: string | null = null,
  1041. ): AssistantTurn[] {
  1042. return turns.map((t) => {
  1043. /*
  1044. * 🔴 拆成两个概念(2026-11-11):
  1045. *
  1046. * `awaitingFirstReply`(live 用):要求 `!t.closed` —— 回合静默收尾后
  1047. * `live` 该转 false,否则卡片永远不收尾(2026-11-10 的裁决,保留)。
  1048. *
  1049. * `pendingReply`(waiting 用):**不看 closed** —— `closed` 是聚合层在
  1050. * 25 秒静默后打的标记,远早于用户的 180 秒超时。若它也否认「还在等」,
  1051. * 界面从第 25 秒起就是一片空白,直到第 180 秒才弹提示(用户实测反馈)。
  1052. *
  1053. * 所以「有用户提问 + 容器一条都没回 + 没超时」就是还在等,与 closed 无关。
  1054. */
  1055. const hasUserAsk = !!t.userMessage;
  1056. const noAgentReply = t.blocks.length === 0;
  1057. const awaitingFirstReply = hasUserAsk && noAgentReply && !t.closed;
  1058. const pendingReply = hasUserAsk && noAgentReply;
  1059. const userAsk = t.userMessage ? Date.parse(t.userMessage.ts) || 0 : 0;
  1060. /*
  1061. * 🔴 按「轮次」拆块(2026-11-10)—— **收窄触发条件**(2026-11-11)。
  1062. *
  1063. * 拆轮原本是为了「图 + 文字各跑一轮时分别计时」。
  1064. * 但它按「相邻消息间隔 > 20 秒」(`ROUND_GAP_MS`)切分,而容器跑一个
  1065. * 长工具时**本来就不产生消息** —— 于是普通长任务被误切成 2~4 轮,
  1066. * 每轮各自判断 live,出现「有的块已完成、有的块还在跑」的混乱观感,
  1067. * 且所有块 key 都叫 `agent` 导致 NG0955。
  1068. *
  1069. * 现在只在**确实有多个用户提问**(图文各发一条)时才拆轮;
  1070. * 单次提问不论间隔多久都只出 1 块。
  1071. */
  1072. const askCount = (t.userAskTimes ?? []).filter((a) => a > 0).length;
  1073. const canSplit = askCount > 1 && (t.rounds?.length ?? 0) > 1;
  1074. const rounds: Message[][] = canSplit
  1075. ? t.rounds!
  1076. : t.blocks.length > 0
  1077. ? [t.blocks]
  1078. : [];
  1079. /*
  1080. * 没有 agent 消息时也要有**一个**占位块,否则「已发出、等回复」的标志
  1081. * (`awaitingFirstReply`)无处挂载,卡片会整张消失。
  1082. */
  1083. const usedKeys = new Set<string>();
  1084. const blocks = rounds.length
  1085. ? rounds.map((msgs, idx) => {
  1086. const times = msgs.map((m) => Date.parse(m.ts) || 0).filter((n) => n > 0);
  1087. const first = times.length ? Math.min(...times) : 0;
  1088. const last = times.length ? Math.max(...times) : 0;
  1089. /*
  1090. * 本轮读秒锚点 = **不晚于本轮首条消息的最后一个用户提问时刻**。
  1091. * 图文被拆成两条时:图片那轮用图片的发出时刻,文字那轮用文字的发出时刻,
  1092. * 两轮读数各算各的,不会互相拉高,也不会在结束瞬间倒退。
  1093. */
  1094. const asks = (t.userAskTimes ?? []).filter((a) => a > 0 && (first === 0 || a <= first));
  1095. const startAt = asks.length ? Math.max(...asks) : first || userAsk;
  1096. /* key 必须唯一:多块共用 'agent' 会触发 NG0955 且让读数互相覆盖 */
  1097. const base = `agent:${msgs[0]?.id ?? `r${idx}`}`;
  1098. const key = usedKeys.has(base) ? `${base}:${idx}` : base;
  1099. usedKeys.add(key);
  1100. return toBlockView(
  1101. {
  1102. agentId: null,
  1103. key,
  1104. messages: msgs,
  1105. startedAt: first,
  1106. updatedAt: last,
  1107. typing,
  1108. closed: t.closed,
  1109. /*
  1110. * 结束信号按**消息 id** 认领(2026-11-11):
  1111. * 收到 `typing=false` 时记下的末条 agent 消息 id,必须正好是本块的末条。
  1112. * 不跨时钟比较(服务端 ts vs 本地时钟会差 1~2 秒),
  1113. * 也天然挡住了「上一轮的信号误伤这一轮」。
  1114. */
  1115. turnEnded:
  1116. turnEndedAfter != null &&
  1117. msgs.length > 0 &&
  1118. msgs[msgs.length - 1]?.id === turnEndedAfter,
  1119. },
  1120. now,
  1121. agentIdle ? 0 : quietMs,
  1122. startAt,
  1123. false,
  1124. );
  1125. })
  1126. : [
  1127. toBlockView(
  1128. {
  1129. agentId: null,
  1130. key: 'agent:wait',
  1131. messages: [],
  1132. startedAt: 0,
  1133. updatedAt: 0,
  1134. typing,
  1135. closed: t.closed,
  1136. },
  1137. now,
  1138. agentIdle ? 0 : quietMs,
  1139. userAsk,
  1140. pendingReply,
  1141. ),
  1142. ];
  1143. const live = blocks.some((b) => b.live);
  1144. return {
  1145. id: t.id,
  1146. userMessage: t.userMessage,
  1147. userFiles: t.userFiles ?? [],
  1148. blocks,
  1149. phase: phaseOf(blocks, t.closed),
  1150. /* 回合用时取最长的块(各轮并行跑,按最长那轮算体感) */
  1151. seconds: blocks.reduce((max, b) => Math.max(max, b.seconds), 0),
  1152. live,
  1153. closed: t.closed,
  1154. stepCount: blocks.reduce((n, b) => n + b.steps.length, 0),
  1155. fileCount: blocks.reduce((n, b) => n + b.files.length, 0),
  1156. /*
  1157. * 单聊的正文仍**合并成一份**(2026-11-07 用户要求「一整轮一张卡」)。
  1158. * 拆轮只为了分别计时与分别判定状态,不改变阅读形态。
  1159. */
  1160. merged: mergeBodies(blocks),
  1161. files: collectFiles(blocks),
  1162. };
  1163. });
  1164. }
  1165. /** 整轮的全部文件(跨容器聚合,宫格统一渲染) */
  1166. function collectFiles(blocks: TurnBlockView[]): { key: string; file: FileCard }[] {
  1167. const seen = new Set<string>();
  1168. const out: { key: string; file: FileCard }[] = [];
  1169. for (const b of blocks) {
  1170. for (const f of b.files) {
  1171. // 同名同大小的重复文件只出一张卡(多容器转发同一份时)
  1172. const sig = `${f.file.name}|${f.file.size ?? 0}|${f.file.url}`;
  1173. if (seen.has(sig)) continue;
  1174. seen.add(sig);
  1175. out.push(f);
  1176. }
  1177. }
  1178. return out;
  1179. }
  1180. /**
  1181. * 把整轮所有块的正文拼成一份。
  1182. *
  1183. * 块之间用空行分隔(Markdown 段落边界),保持可读;
  1184. * key 取首条正文消息 id,**不随内容增长而变** —— 否则流式时模板会重建 DOM。
  1185. */
  1186. function mergeBodies(blocks: { body: TurnBodyBlock[] }[]): TurnMergedBody | null {
  1187. const parts: string[] = [];
  1188. let first: Message | null = null;
  1189. let last: Message | null = null;
  1190. for (const b of blocks) {
  1191. for (const part of b.body) {
  1192. if (part.kind !== 'text') continue;
  1193. const t = (part.message.text ?? '').trim();
  1194. if (!t) continue;
  1195. parts.push(t);
  1196. if (!first) first = part.message;
  1197. last = part.message;
  1198. }
  1199. }
  1200. if (!first || !last) return null;
  1201. return { key: first.id, text: parts.join('\n\n'), message: last };
  1202. }
  1203. /** 回合耗时:首条 agent 消息到末条(各块取并集,避免多块重复计算) */
  1204. /**
  1205. * 回合级耗时(群聊汇总用)。
  1206. *
  1207. * 🔴 必须与块级 `toBlockView` 同一口径(2026-11-10),否则同一张卡片上
  1208. * 段头写一个数、汇总写另一个数,用户会以为界面在乱跳。
  1209. *
  1210. * 口径:从**用户发出消息**起算。live 时取当前时钟(稳定递增),
  1211. * 结束后冻结在最晚的一条消息时间。多块之间取「最大结束 - 用户消息」,
  1212. * 也就是「这一轮最忙的那个成员干完为止」。
  1213. */
  1214. function roundSeconds(
  1215. blocks: { startedAt: number; updatedAt: number }[],
  1216. now: number,
  1217. live: boolean,
  1218. waitingSince: number,
  1219. ): number {
  1220. const starts = blocks.map((b) => b.startedAt).filter((n) => n > 0);
  1221. const ends = blocks.map((b) => b.updatedAt).filter((n) => n > 0);
  1222. // 锚点优先取用户消息,没有才回落到最早的一条 agent 消息
  1223. const anchor = waitingSince > 0 ? waitingSince : starts.length ? Math.min(...starts) : 0;
  1224. if (anchor <= 0) return 0;
  1225. const endAt = live ? now : ends.length ? Math.max(...ends) : 0;
  1226. if (endAt < anchor) return 0;
  1227. return Math.max(0, Math.round((endAt - anchor) / 1000));
  1228. }
  1229. /** 便捷入口:判断回合里是否有该文件(避免模板里重复过滤) */
  1230. export function hasFiles(t: AssistantTurn): boolean {
  1231. return t.fileCount > 0;
  1232. }