ProcessTimeline.vue 21 KB

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