assistant-turn.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
  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. /** 块内消息(单位:毫秒);0 表示无数据,不显示 */
  56. seconds: number;
  57. /** 顶部过程区 */
  58. steps: ProcessStep[];
  59. /** 下半区正文 / 文件 / 任务 */
  60. body: TurnBodyBlock[];
  61. /** 文件宫格的数据(从 body 的 file 项抽出,便于模板直接栅格渲染) */
  62. files: { key: string; file: FileCard }[];
  63. /** 本块是否仍在输出 */
  64. live: boolean;
  65. /** 本块是否无内容且未在输出(群聊未响应) */
  66. empty: boolean;
  67. /**
  68. * 本块自身合并后的正文(2026-11-07)。
  69. *
  70. * 群聊多容器时,**每个容器单独渲染自己的正文**,不能跨容器拼接 ——
  71. * 用户要求「@ 了三个人就要看出谁说了什么」,拼在一起等于丢失归属。
  72. * 单聊只有一块,用回合级的 `merged` 即可。
  73. */
  74. merged: TurnMergedBody | null;
  75. }
  76. export type TurnPhase = 'thinking' | 'replying' | 'done' | 'failed' | 'idle';
  77. /**
  78. * 整轮合并后的正文(2026-11-07 用户需求)。
  79. *
  80. * 用户要求「这一轮对话的所有输出融合到**一张卡片**里,不再是每段单独复制,
  81. * 顶部折叠思考和操作」。所以正文本不该按容器/消息分段,而应拼成一份。
  82. */
  83. export interface TurnMergedBody {
  84. /** 稳定 key(首条正文消息 id),用于模板 track */
  85. key: string;
  86. /** 合并后的正文(Markdown) */
  87. text: string;
  88. /** 参与合并的消息(末尾那条用于取时间戳、任务引用等元信息) */
  89. message: Message;
  90. }
  91. /** 一个完整回合:一条用户消息 + 它的全部回复 */
  92. export interface AssistantTurn {
  93. id: string;
  94. userMessage?: Message;
  95. /** 用户在同回合内额外发的文件(群聊「文字 + 多个文件」时) */
  96. userFiles: Message[];
  97. blocks: TurnBlockView[];
  98. phase: TurnPhase;
  99. seconds: number;
  100. live: boolean;
  101. closed: boolean;
  102. /** 过程步骤总数,用于折叠头「运行了 N 步」 */
  103. stepCount: number;
  104. /** 文件总数,用于宫格标题 */
  105. fileCount: number;
  106. /**
  107. * 整轮合并后的正文与文件(2026-11-07)。
  108. *
  109. * 「一整轮 = 一张卡」:不管中间隔了多少次工具调用,正文全部拼成一份
  110. * 给卡片渲染,过程进顶部折叠区,文件进底部宫格。
  111. *
  112. * 🔴 **单聊用这个**。群聊多容器时各自独立,见 `blocks[].merged`。
  113. */
  114. merged: TurnMergedBody | null;
  115. /** 整轮的全部文件(跨容器聚合,宫格统一渲染) */
  116. files: { key: string; file: FileCard }[];
  117. }
  118. /** 构建一块所需的原料(由单聊/群聊两种输入各转一份) */
  119. interface BlockSource {
  120. agentId: string | null;
  121. messages: Message[];
  122. startedAt: number;
  123. updatedAt: number;
  124. typing: boolean;
  125. closed: boolean;
  126. }
  127. /* ------------------------------------------------------------------ */
  128. /* 从原始消息派生「过程」与「正文」 */
  129. /* ------------------------------------------------------------------ */
  130. /**
  131. * 把一组消息拆成「顶部过程步骤」与「下半区内容块」。
  132. *
  133. * 处理顺序很关键:
  134. * 1. 先按纯终端消息把整条消息**提走**进过程区(它不该出现在正文里)
  135. * 2. 剩下的正文消息交给 `mergeConsecutiveBodies` 合并连续片段
  136. * 3. 合并后再用 `splitMessage` 把夹在正文里的过程行拆出来
  137. *
  138. * 🔴 这里不能只认 `block.kind === 'process'`。
  139. * `toBlocks()` 有个既有设计:**孤立单步(前后无空行)会被内联成 text**
  140. * (`message-segments.ts:281-285`),为的是「只有一个步骤时不必多点一次」。
  141. * 但在本视图模型里,顶部过程区与下半区必须**严格互斥**,否则
  142. * 「过程区折叠了、正文里却还留着那一行」。
  143. * 所以这里按**行**再判一次:`· 工具名` 形态的行一律归过程区。
  144. */
  145. function splitIntoParts(messages: Message[]): {
  146. steps: ProcessStep[];
  147. body: TurnBodyBlock[];
  148. } {
  149. const steps: ProcessStep[] = [];
  150. const bodies: Message[] = [];
  151. for (const m of messages) {
  152. const text = m.text ?? '';
  153. // ① 整条都是终端活动 → 过程区,不进正文
  154. if (text && isToolActivityMessage(text) && !proseOf(text)) {
  155. steps.push(...stepsOf(text));
  156. continue;
  157. }
  158. if (text.trim()) bodies.push(m);
  159. }
  160. /*
  161. * ② 合并连续正文。
  162. * 带附件的消息由 `mergeConsecutiveBodies` 内部规则挡住(见 message-merge.ts 注释),
  163. * 它们不进 `bodies`,单独成块。
  164. */
  165. const merged = mergeConsecutiveBodies(bodies);
  166. const body: TurnBodyBlock[] = [];
  167. for (const mm of merged) {
  168. const { steps: s, text } = divideText(mm.text ?? '');
  169. steps.push(...s);
  170. if (text.trim()) {
  171. body.push({ kind: 'text', key: mm.mergedIds[0], message: { ...mm, text: text.trim() } });
  172. }
  173. if (mm.file) {
  174. body.push({ kind: 'file', key: `${mm.mergedIds[0]}-f`, file: mm.file, message: mm });
  175. }
  176. if (mm.taskId) {
  177. body.push({ kind: 'task', key: `${mm.mergedIds[0]}-t`, taskId: mm.taskId, message: mm });
  178. }
  179. }
  180. /*
  181. * ③ 只有附件、没有正文的消息不会被 `mergeConsecutiveBodies` 输出(它只处理 text),
  182. * 这里补回来,否则「容器只回了一个文件」会整个消失。
  183. */
  184. for (const m of messages) {
  185. if (!m.file && !m.taskId) continue;
  186. const covered = body.some((b) => b.message.id === m.id);
  187. if (covered) continue;
  188. if (m.file) body.push({ kind: 'file', key: `${m.id}-f`, file: m.file, message: m });
  189. if (m.taskId) body.push({ kind: 'task', key: `${m.id}-t`, taskId: m.taskId, message: m });
  190. }
  191. return { steps, body };
  192. }
  193. /**
  194. * 从一段文本里取过程行(返回 steps)。
  195. * 纯终端文本直接用 `splitMessage` 的 process 块。
  196. */
  197. function stepsOf(text: string): ProcessStep[] {
  198. const out: ProcessStep[] = [];
  199. for (const b of splitMessage(text)) {
  200. if (b.kind === 'process') out.push(...b.steps);
  201. }
  202. return out;
  203. }
  204. /**
  205. * 把一段**混合**文本拆成「过程步骤」与「纯正文」。
  206. *
  207. * 两步:
  208. * ① `splitMessage` 能识别的 process 块 → 步骤
  209. * ② 剩下的 text 块里,仍可能有被 `toBlocks` 内联的单步行(`· bash`),按行再剥一次
  210. *
  211. * 返回的 text 已不含过程行,顶部折叠后正文不会再出现一遍。
  212. */function divideText(text: string): { steps: ProcessStep[]; text: string } {
  213. const steps: ProcessStep[] = [];
  214. const keep: string[] = [];
  215. for (const b of splitMessage(text)) {
  216. if (b.kind === 'process') {
  217. /*
  218. * 🔴 `splitMessage` 也会把「行首 emoji + 成句中文」解析成 process 块
  219. * (`💾 自我升级小结:…了。` 实测如此)。这类是**结论**不是过程,
  220. * 交还正文,否则折叠区里会出现半句话。
  221. */
  222. const prose = b.steps.filter((s) => proseOf(s.label));
  223. const tools = b.steps.filter((s) => !proseOf(s.label));
  224. steps.push(...tools);
  225. for (const p of prose) keep.push(p.label);
  226. continue;
  227. }
  228. if (b.kind !== 'text') continue;
  229. for (const line of b.text.split('\n')) {
  230. const inline = inlineStepOf(line);
  231. if (inline && !proseOf(line)) steps.push(inline);
  232. else keep.push(line);
  233. }
  234. }
  235. return { steps, text: keep.join('\n').replace(/\n{3,}/g, '\n\n') };
  236. }
  237. /** 行首 `· 工具名` 形态 → 一步;否则 null */
  238. const INLINE_STEP = /^\s*[·•]\s*([a-zA-Z][\w.\-]{0,40})(?:\s*[::]?\s*(.+))?$/;
  239. function inlineStepOf(line: string): ProcessStep | null {
  240. const m = INLINE_STEP.exec(line);
  241. if (!m) return null;
  242. return { kind: 'tool', label: m[1], detail: m[2]?.trim() || undefined };
  243. }
  244. /**
  245. * 这条「看起来像终端活动」的文本,其实是一句**结论性叙述**吗?
  246. *
  247. * ## 为什么需要(2026-11-07 实测)
  248. *
  249. * 容器会这样汇报一次自我升级:
  250. *
  251. * ```
  252. * 💾 自我升级小结:🔧 「multi-artifact-delivery」磨得更锋利了。
  253. * ```
  254. *
  255. * 行首 `💾` 命中了 `TOOL_EMOJI_LEAD`,于是整条被当成工具活动,
  256. * 折叠头上显示成「运行了 1 步 · 自我升级小结:…」——
  257. * 用户要读的**结论**被折进了过程区。
  258. *
  259. * ## 判定口径(宁可放过、不可误折)
  260. *
  261. * 同时满足才算叙述:
  262. * ① 以中文句末标点收尾(。!?)
  263. * ② 去掉行首 emoji 后,剩下的是一段**中文句子**(至少 6 个汉字)
  264. *
  265. * 真正的工具行(`💻 bash`、`✍️ Writing /path`)主体是英文工具名与路径,
  266. * 不含成句中文,不会被误判。
  267. */
  268. function proseOf(text: string): boolean {
  269. const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
  270. if (lines.length === 0) return false;
  271. // 只要有一条是「成句中文」,整条就当正文 —— 终端活动不会夹杂叙述
  272. return lines.some((line) => {
  273. if (!/[。!?]$/.test(line)) return false;
  274. // 剥掉行首的项目符号与 emoji(含变体选择符 / 零宽连接符)
  275. const body = line
  276. .replace(/^[\s*·•-]+/, '')
  277. .replace(/^[\p{Extended_Pictographic}️‍\u{1F3FB}-\u{1F3FF}]+\s*/u, '');
  278. return (body.match(/[一-龥]/g)?.length ?? 0) >= 6;
  279. });
  280. }
  281. /* ------------------------------------------------------------------ */
  282. /* 块 → 视图 */
  283. /* ------------------------------------------------------------------ */
  284. function toBlockView(src: BlockSource, now: number, quietMs: number): TurnBlockView {
  285. const { steps, body } = splitIntoParts(src.messages);
  286. const files = body
  287. .filter((b): b is Extract<TurnBodyBlock, { kind: 'file' }> => b.kind === 'file')
  288. .map((b) => ({ key: b.key, file: b.file }));
  289. /*
  290. * live 判定与 `isTurnOpen` 同口径:容器正在 typing,或末条消息至今未超过静默窗口。
  291. * 群聊里某个成员可能先回完,所以按**块**判断而不是按回合。
  292. */
  293. const live = !src.closed && (src.typing || now - src.updatedAt < quietMs);
  294. const seconds =
  295. src.startedAt > 0 && src.updatedAt >= src.startedAt
  296. ? Math.max(0, Math.round((src.updatedAt - src.startedAt) / 1000))
  297. : 0;
  298. return {
  299. key: src.agentId ?? 'agent',
  300. agentId: src.agentId,
  301. seconds,
  302. steps,
  303. body,
  304. files,
  305. live,
  306. empty: !live && body.length === 0 && steps.length === 0,
  307. merged: mergeBodies([{ body }]),
  308. };
  309. }
  310. /** 由各块合成回合阶段 */
  311. function phaseOf(blocks: TurnBlockView[], closed: boolean): TurnPhase {
  312. if (blocks.some((b) => b.live && (b.body.length > 0 || b.steps.length > 0))) return 'replying';
  313. if (blocks.some((b) => b.live)) return 'thinking';
  314. if (blocks.length === 0) return closed ? 'idle' : 'thinking';
  315. // 全部结束:有内容就算完成,全空算未响应
  316. return blocks.some((b) => !b.empty) ? 'done' : 'idle';
  317. }
  318. /* ------------------------------------------------------------------ */
  319. /* 两种输入 → 统一的回合视图 */
  320. /* ------------------------------------------------------------------ */
  321. /**
  322. * 群聊:`buildGroupTurns` 的结果 → 回合视图。
  323. *
  324. * 每个 `GroupBlock` 对应一个块;块顺序保持 `targets` 的声明顺序。
  325. */
  326. export function turnsFromGroup(
  327. turns: GroupTurn[],
  328. now: number,
  329. quietMs = 3000,
  330. nameOf?: (agentId: string) => string,
  331. ): AssistantTurn[] {
  332. return turns.map((t) => {
  333. const blocks = t.blocks.map((b) => {
  334. const view = toBlockView(
  335. {
  336. agentId: b.agentId,
  337. messages: b.messages,
  338. startedAt: b.startedAt,
  339. updatedAt: b.updatedAt,
  340. typing: b.typing,
  341. closed: b.closed,
  342. },
  343. now,
  344. quietMs,
  345. );
  346. const name = nameOf?.(b.agentId);
  347. return name ? { ...view, agentName: name } : view;
  348. });
  349. const live = blocks.some((b) => b.live);
  350. return {
  351. id: t.id,
  352. userMessage: t.userMessage,
  353. userFiles: t.userFiles ?? [],
  354. blocks,
  355. phase: phaseOf(blocks, t.closed),
  356. seconds: roundSeconds(t.blocks),
  357. live,
  358. closed: t.closed,
  359. stepCount: blocks.reduce((n, b) => n + b.steps.length, 0),
  360. fileCount: blocks.reduce((n, b) => n + b.files.length, 0),
  361. merged: mergeBodies(blocks),
  362. files: collectFiles(blocks),
  363. };
  364. });
  365. }
  366. /**
  367. * 单聊:`buildTurns` 的结果 → 回合视图(固定 1 块)。
  368. */
  369. export function turnsFromDm(turns: Turn[], now: number, typing: boolean, quietMs = 3000): AssistantTurn[] {
  370. return turns.map((t) => {
  371. const block = toBlockView(
  372. {
  373. agentId: null,
  374. messages: t.blocks,
  375. startedAt: t.startedAt,
  376. updatedAt: t.updatedAt,
  377. typing,
  378. closed: t.closed,
  379. },
  380. now,
  381. quietMs,
  382. );
  383. const live = block.live;
  384. return {
  385. id: t.id,
  386. userMessage: t.userMessage,
  387. userFiles: [],
  388. blocks: [block],
  389. phase: phaseOf([block], t.closed),
  390. seconds: block.seconds,
  391. live,
  392. closed: t.closed,
  393. stepCount: block.steps.length,
  394. fileCount: block.files.length,
  395. merged: mergeBodies([block]),
  396. files: collectFiles([block]),
  397. };
  398. });
  399. }
  400. /** 整轮的全部文件(跨容器聚合,宫格统一渲染) */
  401. function collectFiles(blocks: TurnBlockView[]): { key: string; file: FileCard }[] {
  402. const seen = new Set<string>();
  403. const out: { key: string; file: FileCard }[] = [];
  404. for (const b of blocks) {
  405. for (const f of b.files) {
  406. // 同名同大小的重复文件只出一张卡(多容器转发同一份时)
  407. const sig = `${f.file.name}|${f.file.size ?? 0}|${f.file.url}`;
  408. if (seen.has(sig)) continue;
  409. seen.add(sig);
  410. out.push(f);
  411. }
  412. }
  413. return out;
  414. }
  415. /**
  416. * 把整轮所有块的正文拼成一份。
  417. *
  418. * 块之间用空行分隔(Markdown 段落边界),保持可读;
  419. * key 取首条正文消息 id,**不随内容增长而变** —— 否则流式时模板会重建 DOM。
  420. */
  421. function mergeBodies(blocks: { body: TurnBodyBlock[] }[]): TurnMergedBody | null {
  422. const parts: string[] = [];
  423. let first: Message | null = null;
  424. let last: Message | null = null;
  425. for (const b of blocks) {
  426. for (const part of b.body) {
  427. if (part.kind !== 'text') continue;
  428. const t = (part.message.text ?? '').trim();
  429. if (!t) continue;
  430. parts.push(t);
  431. if (!first) first = part.message;
  432. last = part.message;
  433. }
  434. }
  435. if (!first || !last) return null;
  436. return { key: first.id, text: parts.join('\n\n'), message: last };
  437. }
  438. /** 回合耗时:首条 agent 消息到末条(各块取并集,避免多块重复计算) */
  439. function roundSeconds(blocks: { startedAt: number; updatedAt: number }[]): number {
  440. const starts = blocks.map((b) => b.startedAt).filter((n) => n > 0);
  441. const ends = blocks.map((b) => b.updatedAt).filter((n) => n > 0);
  442. if (starts.length === 0 || ends.length === 0) return 0;
  443. return Math.max(0, Math.round((Math.max(...ends) - Math.min(...starts)) / 1000));
  444. }
  445. /** 便捷入口:判断回合里是否有该文件(避免模板里重复过滤) */
  446. export function hasFiles(t: AssistantTurn): boolean {
  447. return t.fileCount > 0;
  448. }