capsuleData.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. import type { Task, Message, CapsuleFileItem, CapsuleLinkItem, CapsuleAgentItem } from './types';
  2. import { extractMarkdownLinks, groupStepsByAgent } from './utils';
  3. // 胶囊聚合数据构建(纯函数):由主弹框的 computed 接线调用,
  4. // 与响应式状态管理解耦,便于单独测试
  5. // 聚合文件:当前对话下用户上传的附件 + AI 返回的下载链接(含 Word 报告,按 url 去重)
  6. export const buildCapsuleFiles = (task: Task | undefined, messages: Message[]): CapsuleFileItem[] => {
  7. if (!task) return [];
  8. const items: CapsuleFileItem[] = [];
  9. const seenUrls = new Set<string>();
  10. task.attachedFiles.forEach((f) => {
  11. items.push({ kind: 'upload', name: f.name, size: f.size, file: f });
  12. });
  13. for (const msg of messages) {
  14. if (msg.type !== 'ai') continue;
  15. msg.downloadFiles?.forEach((d) => {
  16. if (!d.url || seenUrls.has(d.url)) return;
  17. seenUrls.add(d.url);
  18. items.push({ kind: 'download', name: d.filename || '文件', url: d.url });
  19. });
  20. if (msg.wordDownloadUrl && !seenUrls.has(msg.wordDownloadUrl)) {
  21. seenUrls.add(msg.wordDownloadUrl);
  22. items.push({ kind: 'download', name: '报告文件.docx', url: msg.wordDownloadUrl });
  23. }
  24. }
  25. if (task.wordUrl && !seenUrls.has(task.wordUrl)) {
  26. seenUrls.add(task.wordUrl);
  27. items.push({ kind: 'download', name: '报告文件.docx', url: task.wordUrl });
  28. }
  29. return items;
  30. };
  31. // 聚合链接:AI 回复文本中的可跳转链接(与消息内 markdown 渲染共用同一套解析,按 href 去重)。
  32. // 已在"文件"区展示的下载地址不再重复列入链接区;
  33. // 除精确匹配外,还按"路径+查询+哈希"匹配,覆盖正文相对路径与后端完整地址写法不一致的情况
  34. export const buildCapsuleLinks = (files: CapsuleFileItem[], messages: Message[]): CapsuleLinkItem[] => {
  35. const normalize = (u: string) => u.trim().replace(/&amp;/g, '&');
  36. const pathKey = (u: string): string => {
  37. try {
  38. const parsed = new URL(u, window.location.href);
  39. return `${parsed.pathname}${parsed.search}${parsed.hash}`;
  40. } catch {
  41. return '';
  42. }
  43. };
  44. const seenDownload = new Set<string>();
  45. const seenDownloadPath = new Set<string>();
  46. for (const item of files) {
  47. if (!item.url) continue;
  48. const href = normalize(item.url);
  49. seenDownload.add(href);
  50. const key = pathKey(href);
  51. if (key) seenDownloadPath.add(key);
  52. }
  53. const seenLink = new Set<string>();
  54. const links: CapsuleLinkItem[] = [];
  55. for (const msg of messages) {
  56. if (msg.type !== 'ai' || !msg.content) continue;
  57. for (const link of extractMarkdownLinks(msg.content)) {
  58. const href = normalize(link.href);
  59. if (!href || seenLink.has(href)) continue;
  60. const key = pathKey(href);
  61. if (seenDownload.has(href) || (key && seenDownloadPath.has(key))) continue;
  62. seenLink.add(href);
  63. links.push({ title: link.title, href });
  64. }
  65. }
  66. return links;
  67. };
  68. // 聚合智能体:thinking 卡片中的智能体调用记录,按(消息、智能体)聚合,
  69. // 每次调用一个条目、状态独立(start→done 生命周期),条目身份与右侧详情面板对齐。
  70. // 仅收录有实际执行活动的组(含 start/done/工具调用/执行中),纯思考 token 的"系统"组不进入。
  71. // isAnswerVisible 由调用方传入(多回答折叠组中该消息是否为当前显示的回答)
  72. export const buildCapsuleAgents = (messages: Message[], isAnswerVisible?: (index: number) => boolean | undefined): CapsuleAgentItem[] => {
  73. const agents: CapsuleAgentItem[] = [];
  74. for (let i = 0; i < messages.length; i++) {
  75. const msg = messages[i];
  76. if (msg.type !== 'ai' || !msg.thinkingSteps?.length) continue;
  77. // 该消息所属问答轮次:数到当前消息为止(含本轮提问)的用户消息条数,
  78. // 无前置提问的 AI 消息兜底为第 1 轮
  79. let round = 0;
  80. for (let j = 0; j <= i; j++) {
  81. if (messages[j].type === 'user') round++;
  82. }
  83. round = Math.max(1, round);
  84. for (const group of groupStepsByAgent(msg.thinkingSteps)) {
  85. const hasActivity = group.steps.some((s) => s.type !== 'token');
  86. if (!hasActivity) continue;
  87. const done = group.steps.some((s) => s.type === 'agent_done');
  88. const toolCount = group.steps.filter((s) => s.type === 'tool_call').length;
  89. // 状态优先级:完成 > 审批等待(暂停) > 流式中 > 已中断
  90. const status: CapsuleAgentItem['status'] = done ? 'done' : msg.isPendingApproval ? 'waiting' : msg.isLoading ? 'running' : 'interrupted';
  91. agents.push({
  92. messageIndex: i,
  93. agent: group.agent,
  94. agentId: group.agentId,
  95. status,
  96. round,
  97. toolCount,
  98. stepCount: group.steps.length,
  99. // 多回答折叠组中被隐藏的历史回答(重新生成产生),供胶囊面板标注
  100. hiddenAnswer: isAnswerVisible ? isAnswerVisible(i) === false : false,
  101. });
  102. }
  103. }
  104. return agents;
  105. };