ProcessTimeline.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595
  1. <template>
  2. <div v-if="items.length" class="ai-trace" :class="{ 'is-active': active }">
  3. <!-- 汇总行:折叠态只留这一行 -->
  4. <button class="ai-trace__head" type="button" :aria-expanded="expanded" @click="toggle">
  5. <span class="ai-trace__caret" :class="{ 'is-open': expanded }">
  6. <Icon icon="mdi:chevron-right" :size="14" />
  7. </span>
  8. <span class="ai-trace__title">{{ headText }}</span>
  9. <span v-if="metaText" class="ai-trace__meta">{{ metaText }}</span>
  10. <span class="ai-trace__spacer"></span>
  11. <span v-if="failed" class="ai-trace__badge">有失败步骤</span>
  12. </button>
  13. <!-- 流水:左侧一条细线贯穿,思考与工具按执行顺序逐行排列 -->
  14. <div v-show="expanded" class="ai-trace__body">
  15. <template v-for="item in items" :key="item.key">
  16. <!-- 深度思考:默认整段展开(输出过程中不要折起来),点行可收起 -->
  17. <template v-if="item.kind === 'think'">
  18. <button class="ai-trace__row is-think" type="button" @click="toggleThink(item.key)">
  19. <span class="ai-trace__icon" :class="{ 'is-running': item.running }">
  20. <Icon icon="mdi:brain" :size="13" />
  21. </span>
  22. <span class="ai-trace__name">{{ thinkLabel(item) }}</span>
  23. <span class="ai-trace__arg">{{ item.text.length }} 字</span>
  24. <span class="ai-trace__spacer"></span>
  25. <span class="ai-trace__caret-sm" :class="{ 'is-open': isThinkOpen(item.key) }">
  26. <Icon icon="mdi:chevron-right" :size="13" />
  27. </span>
  28. </button>
  29. <pre v-if="isThinkOpen(item.key)" class="ai-trace__think ai-scroll-area">{{ item.text }}</pre>
  30. </template>
  31. <!-- 工具调用:整行可点,展开即看「入参 + 输出」 -->
  32. <template v-else>
  33. <button
  34. class="ai-trace__row is-tool"
  35. type="button"
  36. :class="{ 'is-open': isStepOpen(item.key) }"
  37. :aria-expanded="isStepOpen(item.key)"
  38. @click="toggleStep(item.key)"
  39. >
  40. <span class="ai-trace__icon" :class="`is-${item.step.status || 'done'}`">
  41. <Icon :icon="toolIcon(item.step.toolName)" :size="13" />
  42. </span>
  43. <span class="ai-trace__name">{{ toolLabel(item.step.toolName) }}</span>
  44. <span v-if="summarize(item.step)" class="ai-trace__arg">{{ summarize(item.step) }}</span>
  45. <span class="ai-trace__spacer"></span>
  46. <span v-if="item.step.status === 'error'" class="ai-trace__badge is-error">失败</span>
  47. <span v-if="detailHint(item.step)" class="ai-trace__hint">{{ detailHint(item.step) }}</span>
  48. <span v-if="detailHint(item.step)" class="ai-trace__caret-sm" :class="{ 'is-open': isStepOpen(item.key) }">
  49. <Icon icon="mdi:chevron-right" :size="13" />
  50. </span>
  51. </button>
  52. <div v-if="isStepOpen(item.key)" class="ai-trace__detail">
  53. <ToolCallPanel :step="item.step" :message-id="messageId" />
  54. </div>
  55. </template>
  56. </template>
  57. </div>
  58. </div>
  59. </template>
  60. <script lang="ts" setup>
  61. /**
  62. * 执行过程时间线(思考 + 工具调用)
  63. *
  64. * 形态对齐主流 AI 客户端的"过程流水":折叠态只有一行汇总(已完成 · 3 步 · 12s),
  65. * 展开后是一条细线串起来的逐行记录 —— 每段思考和它引发的那次工具调用相邻成组,
  66. * 行内是「图标 + 动作 + 参数摘要 + 可展开提示」。
  67. *
  68. * ★ 展开策略(用户明确要求:思考内容输出时默认展开,不要叠起来):
  69. * - 过程区**默认展开**;思考段**默认整段展开**;流式结束后**不自动收起**
  70. * (刚输出的过程结束后立刻折起来,等于没展开)。只有用户手动点过才收起,
  71. * 手动状态优先,不被后续流式事件覆盖。
  72. * - 工具行**整行可点**,展开后直接是「入参 + 输出」,不需要再点第二层看不见的小箭头。
  73. * - 工具详情默认展开一个:历史消息展开首个有结果的调用;流式期间跟随最新一次调用。
  74. *
  75. * 顺序来源分两种:
  76. * 1. 本次流式对话 —— 用 store 记录的真实时序(`trace`,按 SSE 到达顺序),严格准确;
  77. * 2. 历史消息 —— 后端只存了"思考全文 + 工具事件数组",没有时序字段,按 ReAct 常见形态
  78. * (思考 i → 工具 i,多余的顺序接到末尾)近似排列。要精确还原需后端落库时一并保存事件顺序。
  79. */
  80. import { computed, onBeforeUnmount, ref, watch } from 'vue';
  81. import type { ToolStep } from '../api/types';
  82. import type { TraceNode } from '../store/chatStream';
  83. import { toolLabel } from '../utils/constants';
  84. import { parseJsonPayload } from '../utils/parseMessageContent';
  85. import Icon from '@/core/components/Icon/src/Icon.vue';
  86. import ToolCallPanel from './ToolCallPanel.vue';
  87. const props = withDefaults(
  88. defineProps<{
  89. /** 思考全文(多段之间以空行分隔)—— 历史消息的近似排列用 */
  90. thinking?: string;
  91. /** 是否仍在输出思考内容 */
  92. thinkingActive?: boolean;
  93. /** 工具调用步骤 */
  94. steps?: ToolStep[];
  95. /** 执行时序(本次流式期间由 store 按 SSE 到达顺序记录) */
  96. trace?: TraceNode[];
  97. /** 所属消息是否仍在流式输出 */
  98. active?: boolean;
  99. /** 所属消息 ID(产物卡片预览/定位用) */
  100. messageId?: string;
  101. }>(),
  102. { thinking: '', thinkingActive: false, steps: () => [], trace: () => [], active: false },
  103. );
  104. /* ---------------- 条目组装 ---------------- */
  105. interface ThinkItem {
  106. kind: 'think';
  107. key: string;
  108. text: string;
  109. running: boolean;
  110. /** 第几段(从 0 开始),多段时用于展示序号 */
  111. index: number;
  112. }
  113. interface ToolItem {
  114. kind: 'tool';
  115. key: string;
  116. step: ToolStep;
  117. }
  118. type TraceItem = ThinkItem | ToolItem;
  119. const thinks = computed(() =>
  120. (props.thinking || '')
  121. .split(/\n{2,}/)
  122. .map((s) => s.trim())
  123. .filter(Boolean),
  124. );
  125. const items = computed<TraceItem[]>(() => {
  126. const steps = props.steps || [];
  127. const nodes = props.trace || [];
  128. // 1) 有真实时序(本次流式):严格按到达顺序排列 ——
  129. // 每段思考后面紧跟它引发的那次工具调用,不做任何猜测
  130. if (nodes.length) {
  131. const totalThinks = nodes.filter((n) => n.kind === 'think').length;
  132. const out: TraceItem[] = [];
  133. let seq = 0;
  134. nodes.forEach((n, i) => {
  135. if (n.kind === 'think') {
  136. const idx = seq++;
  137. out.push({
  138. kind: 'think',
  139. key: `think-${idx}`,
  140. text: n.text || '',
  141. // 只有最后一段思考可能正在输出
  142. running: !!props.thinkingActive && idx === totalThinks - 1,
  143. index: idx,
  144. });
  145. return;
  146. }
  147. const step = steps.find((s) => s.id === n.toolCallId);
  148. if (step) out.push({ kind: 'tool', key: `tool-${i}`, step });
  149. });
  150. return out;
  151. }
  152. // 2) 历史消息(落库后重新拉取)没有时序字段,按 ReAct 的常见形态近似:
  153. // 思考 i 后面跟工具 i,多出来的部分顺序接到末尾 —— 绝不把思考与工具分成两堆
  154. const segs = thinks.value;
  155. const out: TraceItem[] = [];
  156. const total = Math.max(segs.length, steps.length);
  157. for (let i = 0; i < total; i++) {
  158. if (segs[i]) {
  159. out.push({
  160. kind: 'think',
  161. key: `think-${i}`,
  162. text: segs[i],
  163. running: !!props.thinkingActive && i === segs.length - 1,
  164. index: i,
  165. });
  166. }
  167. if (steps[i]) out.push({ kind: 'tool', key: `tool-${i}`, step: steps[i] });
  168. }
  169. return out;
  170. });
  171. const thinkTotal = computed(() => items.value.filter((i) => i.kind === 'think').length);
  172. /** 多段思考时带序号,单段就只叫「深度思考」 */
  173. function thinkLabel(item: ThinkItem): string {
  174. if (item.running) return '正在深度思考…';
  175. return thinkTotal.value > 1 ? `深度思考 ${item.index + 1}` : '深度思考';
  176. }
  177. /* ---------------- 折叠状态 ---------------- */
  178. /**
  179. * 过程区默认展开;流式结束时**不自动收起**(用户要求过程/思考默认可见)。
  180. * 只有用户手动点过才收起,且用户状态优先。
  181. */
  182. const expanded = ref(true);
  183. const userToggled = ref(false);
  184. /**
  185. * 思考段的收起集合(★ 语义是"被收起",不是"已展开")。
  186. * 这样新出现的思考段天然处于展开态,多段思考也不会被折成一坨。
  187. */
  188. const collapsedThinks = ref<Set<string>>(new Set());
  189. /** 工具行的展开集合(默认由下面 watch 自动选一个) */
  190. const openSteps = ref<Set<string>>(new Set());
  191. /** 用户手动点过工具行后,不再自动切换展开项 */
  192. const stepTouched = ref(false);
  193. function toggle() {
  194. userToggled.value = true;
  195. expanded.value = !expanded.value;
  196. }
  197. /** 思考段是否展开(默认展开,只有用户收起过才折叠) */
  198. function isThinkOpen(key: string) {
  199. return !collapsedThinks.value.has(key);
  200. }
  201. function toggleThink(key: string) {
  202. const next = new Set(collapsedThinks.value);
  203. if (next.has(key)) next.delete(key);
  204. else next.add(key);
  205. collapsedThinks.value = next;
  206. }
  207. function isStepOpen(key: string) {
  208. return openSteps.value.has(key);
  209. }
  210. function toggleStep(key: string) {
  211. const item = items.value.find((i) => i.key === key);
  212. if (!item || item.kind !== 'tool') return;
  213. // 既没有入参也没有输出 → 没有可看的内容,不做展开
  214. if (!item.step.input && !item.step.result) return;
  215. stepTouched.value = true;
  216. const next = new Set(openSteps.value);
  217. if (next.has(key)) next.delete(key);
  218. else next.add(key);
  219. openSteps.value = next;
  220. }
  221. /**
  222. * 工具详情默认展开一个:
  223. * - 历史消息(已落库)→ 首个有结果的调用;
  224. * - 本次流式 → 跟随最新一次有结果的调用(便于盯着刚跑完的那条)。
  225. * 用户手动点过之后就不再自动切换。
  226. */
  227. watch(
  228. // 只依赖"工具条目 + 是否已有结果",思考文本变化不触发
  229. () =>
  230. items.value
  231. .filter((i) => i.kind === 'tool')
  232. .map((i) => `${i.key}:${(i as ToolItem).step.result ? 1 : 0}`)
  233. .join('|'),
  234. () => {
  235. if (stepTouched.value) return;
  236. const tools = items.value.filter((i): i is ToolItem => i.kind === 'tool' && !!i.step.result);
  237. if (!tools.length) return;
  238. const key = props.active ? tools[tools.length - 1].key : tools[0].key;
  239. if (openSteps.value.size === 1 && openSteps.value.has(key)) return;
  240. openSteps.value = new Set([key]);
  241. },
  242. { immediate: true },
  243. );
  244. // 过程区在流式开始时自动展开(结束后保持,不自动收起)
  245. watch(
  246. () => props.active,
  247. (val) => {
  248. if (val && !userToggled.value) expanded.value = true;
  249. },
  250. { immediate: true },
  251. );
  252. watch(
  253. () => [props.steps?.length, thinks.value.length] as const,
  254. () => {
  255. if (props.active && !userToggled.value) expanded.value = true;
  256. },
  257. );
  258. /* ---------------- 头部文案与计时 ---------------- */
  259. const elapsed = ref(0);
  260. let timer: ReturnType<typeof setInterval> | null = null;
  261. function stopTimer() {
  262. if (timer) {
  263. clearInterval(timer);
  264. timer = null;
  265. }
  266. }
  267. watch(
  268. () => props.active,
  269. (val) => {
  270. stopTimer();
  271. if (val) {
  272. elapsed.value = 0;
  273. timer = setInterval(() => (elapsed.value += 1), 1000);
  274. }
  275. },
  276. { immediate: true },
  277. );
  278. onBeforeUnmount(stopTimer);
  279. const failed = computed(() => (props.steps || []).some((s) => s.status === 'error'));
  280. const headText = computed(() => {
  281. if (failed.value) return '执行过程(含失败步骤)';
  282. if (props.active) return '正在分析…';
  283. return '已完成';
  284. });
  285. const elapsedText = computed(() => {
  286. const s = elapsed.value;
  287. if (s <= 0) return '';
  288. return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${s % 60}s`;
  289. });
  290. const metaText = computed(() => {
  291. const parts: string[] = [];
  292. const toolCount = (props.steps || []).length;
  293. if (toolCount) parts.push(`${toolCount} 步工具调用`);
  294. if (elapsedText.value) parts.push(elapsedText.value);
  295. return parts.join(' · ');
  296. });
  297. /* ---------------- 图标与摘要 ---------------- */
  298. const TOOL_ICONS: Record<string, string> = {
  299. execute_sql: 'mdi:database-search-outline',
  300. list_tables: 'mdi:format-list-bulleted',
  301. search_table_schema: 'mdi:table-search',
  302. execute_python: 'mdi:language-python',
  303. render_chart: 'mdi:chart-box-outline',
  304. render_graph: 'mdi:graph-outline',
  305. get_current_workspace: 'mdi:folder-search-outline',
  306. get_user_selected_workspace: 'mdi:folder-search-outline',
  307. };
  308. function toolIcon(name?: string): string {
  309. return TOOL_ICONS[name || ''] || 'mdi:tools';
  310. }
  311. /** 摘要优先取这些字段(覆盖本项目全部工具的入参形态) */
  312. const ARG_KEYS = ['sql', 'query', 'keyword', 'tableName', 'path', 'file', 'filePath', 'fileName', 'name', 'code'];
  313. /** 从入参 JSON 里抽一个能说明"这次调了什么"的短摘要 */
  314. function summarize(step: ToolStep): string {
  315. if (!step.input) return '';
  316. const obj = parseJsonPayload(step.input);
  317. if (obj === undefined) return clip(step.input);
  318. if (!obj || typeof obj !== 'object') return clip(String(obj ?? ''));
  319. const pick = (v: unknown) => typeof v === 'string' && v.trim().length > 0;
  320. const rec = obj as Record<string, any>;
  321. for (const k of ARG_KEYS) {
  322. if (pick(rec[k])) return clip(rec[k]);
  323. }
  324. for (const v of Object.values(rec)) {
  325. if (pick(v)) return clip(v as string);
  326. }
  327. return '';
  328. }
  329. function clip(raw: string): string {
  330. const flat = raw.replace(/\s+/g, ' ').trim();
  331. return flat.length > 72 ? `${flat.slice(0, 72)}…` : flat;
  332. }
  333. /**
  334. * 行尾提示「入参 · 输出 N 行」:让用户一眼看出这行能展开、里面有什么。
  335. * 没有入参也没有输出时返回空串(该行不可展开)。
  336. */
  337. function detailHint(step: ToolStep): string {
  338. const parts: string[] = [];
  339. if (step.input) parts.push('入参');
  340. if (step.result) {
  341. const payload = parseJsonPayload(step.result);
  342. if (payload && typeof payload === 'object' && !Array.isArray(payload) && payload.artifact) {
  343. // 表格类结果已落 CSV 产物,行数取产物元数据里的全量行数
  344. const n = payload.artifact.totalRows ?? (payload.rows || []).length;
  345. parts.push(`输出 ${Number(n).toLocaleString()} 行`);
  346. } else if (Array.isArray(payload)) {
  347. parts.push(`输出 ${payload.length} 条`);
  348. } else {
  349. parts.push('输出');
  350. }
  351. }
  352. return parts.join(' · ');
  353. }
  354. </script>
  355. <style lang="less" scoped>
  356. .ai-trace {
  357. margin-bottom: var(--ai-sp-3);
  358. &__head {
  359. display: flex;
  360. align-items: center;
  361. gap: 6px;
  362. width: 100%;
  363. height: 26px;
  364. padding: 0;
  365. font-size: 12px;
  366. color: var(--ai-text-2);
  367. text-align: left;
  368. background: transparent;
  369. border: 0;
  370. cursor: pointer;
  371. transition: color 0.15s ease-out;
  372. &:hover {
  373. color: var(--ai-text);
  374. }
  375. &:focus-visible {
  376. outline: 2px solid var(--ai-primary);
  377. outline-offset: 2px;
  378. border-radius: 4px;
  379. }
  380. }
  381. &__caret {
  382. flex: 0 0 auto;
  383. display: inline-flex;
  384. transition: transform 0.18s ease-out;
  385. &.is-open {
  386. transform: rotate(90deg);
  387. }
  388. }
  389. &__title {
  390. flex: 0 0 auto;
  391. font-weight: 500;
  392. }
  393. &__meta {
  394. flex: 0 0 auto;
  395. font-variant-numeric: tabular-nums;
  396. opacity: 0.75;
  397. }
  398. /* 流水主体:一条细线贯穿,行内容整体缩进在线右侧 */
  399. &__body {
  400. margin: 2px 0 0 6px;
  401. padding-left: 14px;
  402. border-left: 1px solid var(--ai-border);
  403. }
  404. /* 行本身就是按钮:整行可点,不再依赖 hover 才显形的小箭头 */
  405. &__row {
  406. display: flex;
  407. align-items: center;
  408. gap: 6px;
  409. width: 100%;
  410. min-height: 28px;
  411. padding: 0;
  412. font-size: 12px;
  413. font-family: inherit;
  414. color: var(--ai-text-2);
  415. text-align: left;
  416. background: transparent;
  417. border: 0;
  418. border-radius: 4px;
  419. cursor: pointer;
  420. &:hover {
  421. color: var(--ai-text);
  422. background: var(--ai-bg-subtle);
  423. }
  424. &.is-open {
  425. color: var(--ai-text);
  426. }
  427. &:focus-visible {
  428. outline: 2px solid var(--ai-primary);
  429. outline-offset: 1px;
  430. }
  431. }
  432. &__icon {
  433. flex: 0 0 auto;
  434. display: inline-flex;
  435. color: var(--ai-text-2);
  436. opacity: 0.9;
  437. &.is-running {
  438. color: var(--ai-primary);
  439. animation: ai-trace-pulse 1.2s ease-in-out infinite;
  440. }
  441. &.is-error {
  442. color: @error-color;
  443. opacity: 1;
  444. }
  445. }
  446. &__name {
  447. flex: 0 0 auto;
  448. color: var(--ai-text-2);
  449. }
  450. &__arg {
  451. flex: 0 1 auto;
  452. min-width: 0;
  453. overflow: hidden;
  454. white-space: nowrap;
  455. text-overflow: ellipsis;
  456. opacity: 0.7;
  457. font-variant-numeric: tabular-nums;
  458. }
  459. &__spacer {
  460. flex: 1 1 auto;
  461. }
  462. /* 行尾提示:入参 · 输出 N 行 */
  463. &__hint {
  464. flex: 0 0 auto;
  465. font-size: 11px;
  466. opacity: 0.6;
  467. font-variant-numeric: tabular-nums;
  468. }
  469. /* 常驻可见(不再 opacity:0 + hover 才显形),保证"看得到、点得到" */
  470. &__caret-sm {
  471. flex: 0 0 auto;
  472. display: inline-flex;
  473. opacity: 0.5;
  474. transition: transform 0.18s ease-out;
  475. &.is-open {
  476. transform: rotate(90deg);
  477. opacity: 1;
  478. }
  479. }
  480. &__badge {
  481. flex: 0 0 auto;
  482. padding: 0 6px;
  483. font-size: 11px;
  484. line-height: 17px;
  485. color: var(--ai-text-2);
  486. background: var(--ai-bg-subtle);
  487. border-radius: var(--ai-radius-sm);
  488. &.is-error {
  489. color: @error-color;
  490. background: fade(@error-color, 8%);
  491. }
  492. }
  493. /* 思考全文:默认展开,多段之间由序号 + 各自成块区隔 */
  494. &__think {
  495. margin: 0 0 var(--ai-sp-1);
  496. padding: 6px 0 6px 10px;
  497. max-height: 320px;
  498. overflow: auto;
  499. font-family: inherit;
  500. font-size: 12px;
  501. line-height: 1.75;
  502. color: var(--ai-text-2);
  503. white-space: pre-wrap;
  504. word-break: break-word;
  505. border-left: 2px solid var(--ai-border);
  506. }
  507. /* 详情区(工具入参 / 输出)由 ToolCallPanel 渲染,这里只负责缩进与左轴 */
  508. &__detail {
  509. margin: 2px 0 var(--ai-sp-2);
  510. padding-left: 10px;
  511. border-left: 2px solid var(--ai-border);
  512. }
  513. }
  514. @keyframes ai-trace-pulse {
  515. 0%,
  516. 100% {
  517. opacity: 1;
  518. }
  519. 50% {
  520. opacity: 0.3;
  521. }
  522. }
  523. </style>