| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595 |
- <template>
- <div v-if="items.length" class="ai-trace" :class="{ 'is-active': active }">
- <!-- 汇总行:折叠态只留这一行 -->
- <button class="ai-trace__head" type="button" :aria-expanded="expanded" @click="toggle">
- <span class="ai-trace__caret" :class="{ 'is-open': expanded }">
- <Icon icon="mdi:chevron-right" :size="14" />
- </span>
- <span class="ai-trace__title">{{ headText }}</span>
- <span v-if="metaText" class="ai-trace__meta">{{ metaText }}</span>
- <span class="ai-trace__spacer"></span>
- <span v-if="failed" class="ai-trace__badge">有失败步骤</span>
- </button>
- <!-- 流水:左侧一条细线贯穿,思考与工具按执行顺序逐行排列 -->
- <div v-show="expanded" class="ai-trace__body">
- <template v-for="item in items" :key="item.key">
- <!-- 深度思考:默认整段展开(输出过程中不要折起来),点行可收起 -->
- <template v-if="item.kind === 'think'">
- <button class="ai-trace__row is-think" type="button" @click="toggleThink(item.key)">
- <span class="ai-trace__icon" :class="{ 'is-running': item.running }">
- <Icon icon="mdi:brain" :size="13" />
- </span>
- <span class="ai-trace__name">{{ thinkLabel(item) }}</span>
- <span class="ai-trace__arg">{{ item.text.length }} 字</span>
- <span class="ai-trace__spacer"></span>
- <span class="ai-trace__caret-sm" :class="{ 'is-open': isThinkOpen(item.key) }">
- <Icon icon="mdi:chevron-right" :size="13" />
- </span>
- </button>
- <pre v-if="isThinkOpen(item.key)" class="ai-trace__think ai-scroll-area">{{ item.text }}</pre>
- </template>
- <!-- 工具调用:整行可点,展开即看「入参 + 输出」 -->
- <template v-else>
- <button
- class="ai-trace__row is-tool"
- type="button"
- :class="{ 'is-open': isStepOpen(item.key) }"
- :aria-expanded="isStepOpen(item.key)"
- @click="toggleStep(item.key)"
- >
- <span class="ai-trace__icon" :class="`is-${item.step.status || 'done'}`">
- <Icon :icon="toolIcon(item.step.toolName)" :size="13" />
- </span>
- <span class="ai-trace__name">{{ toolLabel(item.step.toolName) }}</span>
- <span v-if="summarize(item.step)" class="ai-trace__arg">{{ summarize(item.step) }}</span>
- <span class="ai-trace__spacer"></span>
- <span v-if="item.step.status === 'error'" class="ai-trace__badge is-error">失败</span>
- <span v-if="detailHint(item.step)" class="ai-trace__hint">{{ detailHint(item.step) }}</span>
- <span v-if="detailHint(item.step)" class="ai-trace__caret-sm" :class="{ 'is-open': isStepOpen(item.key) }">
- <Icon icon="mdi:chevron-right" :size="13" />
- </span>
- </button>
- <div v-if="isStepOpen(item.key)" class="ai-trace__detail">
- <ToolCallPanel :step="item.step" :message-id="messageId" />
- </div>
- </template>
- </template>
- </div>
- </div>
- </template>
- <script lang="ts" setup>
- /**
- * 执行过程时间线(思考 + 工具调用)
- *
- * 形态对齐主流 AI 客户端的"过程流水":折叠态只有一行汇总(已完成 · 3 步 · 12s),
- * 展开后是一条细线串起来的逐行记录 —— 每段思考和它引发的那次工具调用相邻成组,
- * 行内是「图标 + 动作 + 参数摘要 + 可展开提示」。
- *
- * ★ 展开策略(用户明确要求:思考内容输出时默认展开,不要叠起来):
- * - 过程区**默认展开**;思考段**默认整段展开**;流式结束后**不自动收起**
- * (刚输出的过程结束后立刻折起来,等于没展开)。只有用户手动点过才收起,
- * 手动状态优先,不被后续流式事件覆盖。
- * - 工具行**整行可点**,展开后直接是「入参 + 输出」,不需要再点第二层看不见的小箭头。
- * - 工具详情默认展开一个:历史消息展开首个有结果的调用;流式期间跟随最新一次调用。
- *
- * 顺序来源分两种:
- * 1. 本次流式对话 —— 用 store 记录的真实时序(`trace`,按 SSE 到达顺序),严格准确;
- * 2. 历史消息 —— 后端只存了"思考全文 + 工具事件数组",没有时序字段,按 ReAct 常见形态
- * (思考 i → 工具 i,多余的顺序接到末尾)近似排列。要精确还原需后端落库时一并保存事件顺序。
- */
- import { computed, onBeforeUnmount, ref, watch } from 'vue';
- import type { ToolStep } from '../api/types';
- import type { TraceNode } from '../store/chatStream';
- import { toolLabel } from '../utils/constants';
- import { parseJsonPayload } from '../utils/parseMessageContent';
- import Icon from '@/core/components/Icon/src/Icon.vue';
- import ToolCallPanel from './ToolCallPanel.vue';
- const props = withDefaults(
- defineProps<{
- /** 思考全文(多段之间以空行分隔)—— 历史消息的近似排列用 */
- thinking?: string;
- /** 是否仍在输出思考内容 */
- thinkingActive?: boolean;
- /** 工具调用步骤 */
- steps?: ToolStep[];
- /** 执行时序(本次流式期间由 store 按 SSE 到达顺序记录) */
- trace?: TraceNode[];
- /** 所属消息是否仍在流式输出 */
- active?: boolean;
- /** 所属消息 ID(产物卡片预览/定位用) */
- messageId?: string;
- }>(),
- { thinking: '', thinkingActive: false, steps: () => [], trace: () => [], active: false },
- );
- /* ---------------- 条目组装 ---------------- */
- interface ThinkItem {
- kind: 'think';
- key: string;
- text: string;
- running: boolean;
- /** 第几段(从 0 开始),多段时用于展示序号 */
- index: number;
- }
- interface ToolItem {
- kind: 'tool';
- key: string;
- step: ToolStep;
- }
- type TraceItem = ThinkItem | ToolItem;
- const thinks = computed(() =>
- (props.thinking || '')
- .split(/\n{2,}/)
- .map((s) => s.trim())
- .filter(Boolean),
- );
- const items = computed<TraceItem[]>(() => {
- const steps = props.steps || [];
- const nodes = props.trace || [];
- // 1) 有真实时序(本次流式):严格按到达顺序排列 ——
- // 每段思考后面紧跟它引发的那次工具调用,不做任何猜测
- if (nodes.length) {
- const totalThinks = nodes.filter((n) => n.kind === 'think').length;
- const out: TraceItem[] = [];
- let seq = 0;
- nodes.forEach((n, i) => {
- if (n.kind === 'think') {
- const idx = seq++;
- out.push({
- kind: 'think',
- key: `think-${idx}`,
- text: n.text || '',
- // 只有最后一段思考可能正在输出
- running: !!props.thinkingActive && idx === totalThinks - 1,
- index: idx,
- });
- return;
- }
- const step = steps.find((s) => s.id === n.toolCallId);
- if (step) out.push({ kind: 'tool', key: `tool-${i}`, step });
- });
- return out;
- }
- // 2) 历史消息(落库后重新拉取)没有时序字段,按 ReAct 的常见形态近似:
- // 思考 i 后面跟工具 i,多出来的部分顺序接到末尾 —— 绝不把思考与工具分成两堆
- const segs = thinks.value;
- const out: TraceItem[] = [];
- const total = Math.max(segs.length, steps.length);
- for (let i = 0; i < total; i++) {
- if (segs[i]) {
- out.push({
- kind: 'think',
- key: `think-${i}`,
- text: segs[i],
- running: !!props.thinkingActive && i === segs.length - 1,
- index: i,
- });
- }
- if (steps[i]) out.push({ kind: 'tool', key: `tool-${i}`, step: steps[i] });
- }
- return out;
- });
- const thinkTotal = computed(() => items.value.filter((i) => i.kind === 'think').length);
- /** 多段思考时带序号,单段就只叫「深度思考」 */
- function thinkLabel(item: ThinkItem): string {
- if (item.running) return '正在深度思考…';
- return thinkTotal.value > 1 ? `深度思考 ${item.index + 1}` : '深度思考';
- }
- /* ---------------- 折叠状态 ---------------- */
- /**
- * 过程区默认展开;流式结束时**不自动收起**(用户要求过程/思考默认可见)。
- * 只有用户手动点过才收起,且用户状态优先。
- */
- const expanded = ref(true);
- const userToggled = ref(false);
- /**
- * 思考段的收起集合(★ 语义是"被收起",不是"已展开")。
- * 这样新出现的思考段天然处于展开态,多段思考也不会被折成一坨。
- */
- const collapsedThinks = ref<Set<string>>(new Set());
- /** 工具行的展开集合(默认由下面 watch 自动选一个) */
- const openSteps = ref<Set<string>>(new Set());
- /** 用户手动点过工具行后,不再自动切换展开项 */
- const stepTouched = ref(false);
- function toggle() {
- userToggled.value = true;
- expanded.value = !expanded.value;
- }
- /** 思考段是否展开(默认展开,只有用户收起过才折叠) */
- function isThinkOpen(key: string) {
- return !collapsedThinks.value.has(key);
- }
- function toggleThink(key: string) {
- const next = new Set(collapsedThinks.value);
- if (next.has(key)) next.delete(key);
- else next.add(key);
- collapsedThinks.value = next;
- }
- function isStepOpen(key: string) {
- return openSteps.value.has(key);
- }
- function toggleStep(key: string) {
- const item = items.value.find((i) => i.key === key);
- if (!item || item.kind !== 'tool') return;
- // 既没有入参也没有输出 → 没有可看的内容,不做展开
- if (!item.step.input && !item.step.result) return;
- stepTouched.value = true;
- const next = new Set(openSteps.value);
- if (next.has(key)) next.delete(key);
- else next.add(key);
- openSteps.value = next;
- }
- /**
- * 工具详情默认展开一个:
- * - 历史消息(已落库)→ 首个有结果的调用;
- * - 本次流式 → 跟随最新一次有结果的调用(便于盯着刚跑完的那条)。
- * 用户手动点过之后就不再自动切换。
- */
- watch(
- // 只依赖"工具条目 + 是否已有结果",思考文本变化不触发
- () =>
- items.value
- .filter((i) => i.kind === 'tool')
- .map((i) => `${i.key}:${(i as ToolItem).step.result ? 1 : 0}`)
- .join('|'),
- () => {
- if (stepTouched.value) return;
- const tools = items.value.filter((i): i is ToolItem => i.kind === 'tool' && !!i.step.result);
- if (!tools.length) return;
- const key = props.active ? tools[tools.length - 1].key : tools[0].key;
- if (openSteps.value.size === 1 && openSteps.value.has(key)) return;
- openSteps.value = new Set([key]);
- },
- { immediate: true },
- );
- // 过程区在流式开始时自动展开(结束后保持,不自动收起)
- watch(
- () => props.active,
- (val) => {
- if (val && !userToggled.value) expanded.value = true;
- },
- { immediate: true },
- );
- watch(
- () => [props.steps?.length, thinks.value.length] as const,
- () => {
- if (props.active && !userToggled.value) expanded.value = true;
- },
- );
- /* ---------------- 头部文案与计时 ---------------- */
- const elapsed = ref(0);
- let timer: ReturnType<typeof setInterval> | null = null;
- function stopTimer() {
- if (timer) {
- clearInterval(timer);
- timer = null;
- }
- }
- watch(
- () => props.active,
- (val) => {
- stopTimer();
- if (val) {
- elapsed.value = 0;
- timer = setInterval(() => (elapsed.value += 1), 1000);
- }
- },
- { immediate: true },
- );
- onBeforeUnmount(stopTimer);
- const failed = computed(() => (props.steps || []).some((s) => s.status === 'error'));
- const headText = computed(() => {
- if (failed.value) return '执行过程(含失败步骤)';
- if (props.active) return '正在分析…';
- return '已完成';
- });
- const elapsedText = computed(() => {
- const s = elapsed.value;
- if (s <= 0) return '';
- return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${s % 60}s`;
- });
- const metaText = computed(() => {
- const parts: string[] = [];
- const toolCount = (props.steps || []).length;
- if (toolCount) parts.push(`${toolCount} 步工具调用`);
- if (elapsedText.value) parts.push(elapsedText.value);
- return parts.join(' · ');
- });
- /* ---------------- 图标与摘要 ---------------- */
- const TOOL_ICONS: Record<string, string> = {
- execute_sql: 'mdi:database-search-outline',
- list_tables: 'mdi:format-list-bulleted',
- search_table_schema: 'mdi:table-search',
- execute_python: 'mdi:language-python',
- render_chart: 'mdi:chart-box-outline',
- render_graph: 'mdi:graph-outline',
- get_current_workspace: 'mdi:folder-search-outline',
- get_user_selected_workspace: 'mdi:folder-search-outline',
- };
- function toolIcon(name?: string): string {
- return TOOL_ICONS[name || ''] || 'mdi:tools';
- }
- /** 摘要优先取这些字段(覆盖本项目全部工具的入参形态) */
- const ARG_KEYS = ['sql', 'query', 'keyword', 'tableName', 'path', 'file', 'filePath', 'fileName', 'name', 'code'];
- /** 从入参 JSON 里抽一个能说明"这次调了什么"的短摘要 */
- function summarize(step: ToolStep): string {
- if (!step.input) return '';
- const obj = parseJsonPayload(step.input);
- if (obj === undefined) return clip(step.input);
- if (!obj || typeof obj !== 'object') return clip(String(obj ?? ''));
- const pick = (v: unknown) => typeof v === 'string' && v.trim().length > 0;
- const rec = obj as Record<string, any>;
- for (const k of ARG_KEYS) {
- if (pick(rec[k])) return clip(rec[k]);
- }
- for (const v of Object.values(rec)) {
- if (pick(v)) return clip(v as string);
- }
- return '';
- }
- function clip(raw: string): string {
- const flat = raw.replace(/\s+/g, ' ').trim();
- return flat.length > 72 ? `${flat.slice(0, 72)}…` : flat;
- }
- /**
- * 行尾提示「入参 · 输出 N 行」:让用户一眼看出这行能展开、里面有什么。
- * 没有入参也没有输出时返回空串(该行不可展开)。
- */
- function detailHint(step: ToolStep): string {
- const parts: string[] = [];
- if (step.input) parts.push('入参');
- if (step.result) {
- const payload = parseJsonPayload(step.result);
- if (payload && typeof payload === 'object' && !Array.isArray(payload) && payload.artifact) {
- // 表格类结果已落 CSV 产物,行数取产物元数据里的全量行数
- const n = payload.artifact.totalRows ?? (payload.rows || []).length;
- parts.push(`输出 ${Number(n).toLocaleString()} 行`);
- } else if (Array.isArray(payload)) {
- parts.push(`输出 ${payload.length} 条`);
- } else {
- parts.push('输出');
- }
- }
- return parts.join(' · ');
- }
- </script>
- <style lang="less" scoped>
- .ai-trace {
- margin-bottom: var(--ai-sp-3);
- &__head {
- display: flex;
- align-items: center;
- gap: 6px;
- width: 100%;
- height: 26px;
- padding: 0;
- font-size: 12px;
- color: var(--ai-text-2);
- text-align: left;
- background: transparent;
- border: 0;
- cursor: pointer;
- transition: color 0.15s ease-out;
- &:hover {
- color: var(--ai-text);
- }
- &:focus-visible {
- outline: 2px solid var(--ai-primary);
- outline-offset: 2px;
- border-radius: 4px;
- }
- }
- &__caret {
- flex: 0 0 auto;
- display: inline-flex;
- transition: transform 0.18s ease-out;
- &.is-open {
- transform: rotate(90deg);
- }
- }
- &__title {
- flex: 0 0 auto;
- font-weight: 500;
- }
- &__meta {
- flex: 0 0 auto;
- font-variant-numeric: tabular-nums;
- opacity: 0.75;
- }
- /* 流水主体:一条细线贯穿,行内容整体缩进在线右侧 */
- &__body {
- margin: 2px 0 0 6px;
- padding-left: 14px;
- border-left: 1px solid var(--ai-border);
- }
- /* 行本身就是按钮:整行可点,不再依赖 hover 才显形的小箭头 */
- &__row {
- display: flex;
- align-items: center;
- gap: 6px;
- width: 100%;
- min-height: 28px;
- padding: 0;
- font-size: 12px;
- font-family: inherit;
- color: var(--ai-text-2);
- text-align: left;
- background: transparent;
- border: 0;
- border-radius: 4px;
- cursor: pointer;
- &:hover {
- color: var(--ai-text);
- background: var(--ai-bg-subtle);
- }
- &.is-open {
- color: var(--ai-text);
- }
- &:focus-visible {
- outline: 2px solid var(--ai-primary);
- outline-offset: 1px;
- }
- }
- &__icon {
- flex: 0 0 auto;
- display: inline-flex;
- color: var(--ai-text-2);
- opacity: 0.9;
- &.is-running {
- color: var(--ai-primary);
- animation: ai-trace-pulse 1.2s ease-in-out infinite;
- }
- &.is-error {
- color: @error-color;
- opacity: 1;
- }
- }
- &__name {
- flex: 0 0 auto;
- color: var(--ai-text-2);
- }
- &__arg {
- flex: 0 1 auto;
- min-width: 0;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- opacity: 0.7;
- font-variant-numeric: tabular-nums;
- }
- &__spacer {
- flex: 1 1 auto;
- }
- /* 行尾提示:入参 · 输出 N 行 */
- &__hint {
- flex: 0 0 auto;
- font-size: 11px;
- opacity: 0.6;
- font-variant-numeric: tabular-nums;
- }
- /* 常驻可见(不再 opacity:0 + hover 才显形),保证"看得到、点得到" */
- &__caret-sm {
- flex: 0 0 auto;
- display: inline-flex;
- opacity: 0.5;
- transition: transform 0.18s ease-out;
- &.is-open {
- transform: rotate(90deg);
- opacity: 1;
- }
- }
- &__badge {
- flex: 0 0 auto;
- padding: 0 6px;
- font-size: 11px;
- line-height: 17px;
- color: var(--ai-text-2);
- background: var(--ai-bg-subtle);
- border-radius: var(--ai-radius-sm);
- &.is-error {
- color: @error-color;
- background: fade(@error-color, 8%);
- }
- }
- /* 思考全文:默认展开,多段之间由序号 + 各自成块区隔 */
- &__think {
- margin: 0 0 var(--ai-sp-1);
- padding: 6px 0 6px 10px;
- max-height: 320px;
- overflow: auto;
- font-family: inherit;
- font-size: 12px;
- line-height: 1.75;
- color: var(--ai-text-2);
- white-space: pre-wrap;
- word-break: break-word;
- border-left: 2px solid var(--ai-border);
- }
- /* 详情区(工具入参 / 输出)由 ToolCallPanel 渲染,这里只负责缩进与左轴 */
- &__detail {
- margin: 2px 0 var(--ai-sp-2);
- padding-left: 10px;
- border-left: 2px solid var(--ai-border);
- }
- }
- @keyframes ai-trace-pulse {
- 0%,
- 100% {
- opacity: 1;
- }
- 50% {
- opacity: 0.3;
- }
- }
- </style>
|