Files
DevFlow/src/components/ToolCardList.vue
T
lxy 04acc4297d 优化: 对话执行轨迹聚合与工具卡交互
前端聚合当前轮次工具调用数,替代每轮清零的完成计数,进度文案改轮次+累计口径
完成聚合语义由执行轨迹卡承接,工具卡复读修复与默认折叠收敛
流式与工具事件配套调整,新增轮次轨迹收集 composable
2026-10-01 02:41:05 +08:00

501 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="ai-tool-calls" ref="rootEl">
<!-- 顶部操作栏:批量审批(全局收起已下沉到首个可折叠分组标题行,U-260618) -->
<div v-if="pendingCount > 0" class="ai-tool-topbar">
<!-- 批量操作栏(存在待审批项时显示) -->
<div class="ai-batch-approve">
<button class="ai-btn ai-btn--sm" @click="$emit('batch-approve', 'approve')">
{{ $t('aiTool.approveAll', { n: pendingCount }) }}
</button>
<button class="ai-btn ai-btn--sm ai-btn--danger" @click="$emit('batch-approve', 'reject')">
{{ $t('aiTool.rejectAll') }}
</button>
</div>
</div>
<template v-for="(group, gi) in groupedToolCalls" :key="group.name">
<!-- 分组标题:同名工具 >=2 个时显示(B 聚合卡统一:默认行「N 次调用 · 成功 x 失败 y」,
点开逐条;成功/失败与状态点同一套 isToolFailure 判定,文案走 ai.trace 同源 key) -->
<div v-if="group.calls.length >= 2" class="ai-tool-group-header" @click="toggleGroupCollapse(gi)">
<span class="ai-tool-group-icon" v-html="groupIcon(group.name)"></span>
<span class="ai-tool-group-name">{{ groupDisplayName(group) }}</span>
<span class="ai-tool-group-agg">{{ groupAggText(group) }}</span>
<span class="ai-tool-group-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }">▸</span>
<!-- 全部展开/收起(U-260618:从 topbar 下沉到首个可折叠分组标题行尾巴,与 run command 同行) -->
<div
v-if="gi === firstCollapsibleGi"
class="ai-tool-global-toggle"
@click.stop="toggleAllGroups"
>
<span class="ai-tool-global-toggle-icon">{{ allGroupsExpanded ? '▾' : '▸' }}</span>
<span>{{ allGroupsExpanded ? $t('aiTool.collapseAll') : $t('aiTool.expandAll') }}</span>
</div>
</div>
<ToolCard
v-for="tc in group.calls"
:key="tc.id"
:tc="tc"
:isExpanded="isCardExpanded(tc, gi)"
:isContentExpanded="expandedTools.has(tc.id)"
@toggle="() => toggleCardExpand(tc, gi)"
@expand-content="toggleExpand"
@approve="(e) => emit('approve', e)"
@install-obscura="() => emit('install-obscura')"
:class="{ 'ai-tool-card--group-hidden': group.calls.length >= 2 && isGroupCollapsed(gi) }"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import ToolCard from './ToolCard.vue'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { aggregateCalls, statsTail } from '@/composables/ai/useTurnTrace'
import type { AiToolCallInfo } from '@/api/types'
// useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key)
const { t } = useI18n()
const props = defineProps<{
/** 当前消息的工具调用列表 */
toolCalls: AiToolCallInfo[]
}>()
const emit = defineEmits<{
/**
* 审批按钮 → 父组件转发 store.approveToolCall。
* path_auth 审批链阶段3b:path 类带 decision('once'|'session'|'always'|'deny'),risk 类仅 approved boolean。
*/
approve: [{ id: string; approved: boolean; decision?: 'once' | 'session' | 'always' | 'deny' }]
/** 批量审批 → 父组件调用 approveAll/rejectAll */
'batch-approve': [decision: 'approve' | 'reject']
/**
* fetch_url static_fallback(obscura 未装)时"AI 帮装"按钮 → 透传父级 MessageList,
* 调 store.sendMessage 触发 AI 装流式。仅 fetch_url 工具会发此事件。
*/
'install-obscura': []
}>()
// 根元素 ref(scrollToFirstPending 查 DOM 用)
const rootEl = ref<HTMLElement>()
// 卡片级折叠(整卡 body 显隐,区别于 read_file 内容级)
const expandedCards = ref(new Set<string>())
// 用户主动展开的卡片(记忆态,不被自动收起清除)。
// 与 expandedCards 区分:expandedCards 是"展开"语义的当前态(含默认首卡),
// userExpandedCards 是"用户明确点开"的持久标记,collapseInactive 跳过这些卡。
// 语义:用户读过的卡不强制折叠,符合"跟随阅读位置"的增强目标。
const userExpandedCards = ref(new Set<string>())
// 用户主动折叠记忆(与 userExpandedCards 对偶,记录显式收起过的卡)。
// isCardExpanded 最优先判断,覆盖用户展开记忆。
const userCollapsedCards = ref(new Set<string>())
// 内容级展开(read_file "展开全部")
const expandedTools = ref(new Set<string>())
// 分组折叠状态(按分组索引);多卡片分组默认收起
const collapsedGroups = ref<Set<number>>(new Set())
// 用户手动操作过折叠的组(按组名记忆)。流式期间组卡片数增长会反复触发默认收起补齐,
// 用户手动展开过的组不再被自动收回——名字比索引稳(分组只追加不重排,但语义上名字更直白)。
const userTouchedGroups = ref(new Set<string>())
interface ToolCallGroup {
name: string
calls: AiToolCallInfo[]
}
/** 按 tool.name 全局分组:同名工具归为一组(不要求相邻),保持原始顺序 */
const groupedToolCalls = computed<ToolCallGroup[]>(() => {
const map = new Map<string, AiToolCallInfo[]>()
// 第一遍:按 name 收集
for (const tc of props.toolCalls) {
const arr = map.get(tc.name)
if (arr) arr.push(tc)
else map.set(tc.name, [tc])
}
// 第二遍:按 toolCalls 首次出现顺序输出(保证显示顺序与调用顺序一致)
const seen = new Set<string>()
const groups: ToolCallGroup[] = []
for (const tc of props.toolCalls) {
if (!seen.has(tc.name)) {
seen.add(tc.name)
groups.push({ name: tc.name, calls: map.get(tc.name)! })
}
}
return groups
})
/** 可折叠分组数(calls >= 2 的组),控制全局按钮显隐 */
const collapsibleGroupCount = computed(() =>
groupedToolCalls.value.filter(g => g.calls.length >= 2).length
)
/** 首个可折叠分组索引全部展开/收起按钮下沉到此分组标题行尾巴) */
const firstCollapsibleGi = computed(() => {
for (let i = 0; i < groupedToolCalls.value.length; i++) {
if (groupedToolCalls.value[i].calls.length >= 2) return i
}
return -1
})
/** 待审批工具数(status === 'pending_approval'),控制批量操作栏显隐 */
const pendingCount = computed(() =>
props.toolCalls.filter(tc => tc.status === 'pending_approval').length
)
/** 是否所有可折叠分组都已展开 */
const allGroupsExpanded = computed(() => {
if (collapsibleGroupCount.value === 0) return true
return groupedToolCalls.value.every((g, gi) =>
g.calls.length < 2 || !collapsedGroups.value.has(gi)
)
})
/**
* 默认收起补齐:达到多卡阈值(>=2)且用户未手动操作过的组自动收起。
* 响应式而非一次性初始化——流式场景第一张卡到达即触发旧一次性逻辑,
* 此后组从 1 卡长到 N 卡不再折叠,批量读取的 N 张单行卡全展开刷屏;
* 签名(组名:卡数)变化即幂等补齐,新达到阈值的组收起,已 touched 的组不动。
*/
watch(
() => groupedToolCalls.value.map(g => `${g.name}:${g.calls.length}`).join('|'),
() => {
const next = new Set(collapsedGroups.value)
groupedToolCalls.value.forEach((g, gi) => {
if (g.calls.length >= 2 && !userTouchedGroups.value.has(g.name)) next.add(gi)
})
collapsedGroups.value = next
},
{ immediate: true }
)
function isGroupCollapsed(gi: number): boolean {
return collapsedGroups.value.has(gi)
}
function toggleGroupCollapse(gi: number): void {
// 手动操作记 touched:此后该组不被默认收起补齐自动收回
userTouchedGroups.value.add(groupedToolCalls.value[gi]?.name ?? String(gi))
const next = new Set(collapsedGroups.value)
if (next.has(gi)) next.delete(gi)
else next.add(gi)
collapsedGroups.value = next
}
/** 全部展开 / 全部收起切换 */
function toggleAllGroups(): void {
const next = new Set<number>()
// 全部记 touched:批量操作针对当前可见组,流式新增组仍走默认收起,
// 但已有组不得被补齐逻辑收回(否则"全部展开"点完即被回退)
for (const g of groupedToolCalls.value) {
if (g.calls.length >= 2) userTouchedGroups.value.add(g.name)
}
if (allGroupsExpanded.value) {
// 当前全展开 → 全部收起
for (let i = 0; i < groupedToolCalls.value.length; i++) {
if (groupedToolCalls.value[i].calls.length >= 2) next.add(i)
}
}
// 当前非全展开 → 全部展开(next 保持空 Set)
collapsedGroups.value = next
}
/**
* 分组显示名:工具名 → i18n 友好标签(英文化,原硬编码中文)。
* 用 aiTool.toolGroup.* 子键查表;未命中的工具回退 _ → 空格风格。
*/
// tool.name → aiTool.toolGroup.* 子键(与后端 tool_registry 工具名一一对应)
const TOOL_GROUP_I18N_KEY: Record<string, string> = {
read_file: 'readFile',
write_file: 'writeFile',
list_directory: 'listDirectory',
grep: 'grep',
create_task: 'createTask',
create_project: 'createProject',
create_idea: 'createIdea',
list_tasks: 'listTasks',
list_projects: 'listProjects',
list_ideas: 'listIdeas',
delete_project: 'deleteProject',
restore_project: 'restoreProject',
purge_project: 'purgeProject',
update_project: 'updateProject',
run_workflow: 'runWorkflow',
}
function groupDisplayName(group: ToolCallGroup): string {
const subKey = TOOL_GROUP_I18N_KEY[group.name]
if (subKey) return t(`aiTool.toolGroup.${subKey}`)
return group.name.replace(/_/g, ' ')
}
/** 分组聚合行文案:「N 次调用 · 成功 x 失败 y」(零值项省略;与单卡状态点同源 isToolFailure 判定) */
function groupAggText(group: ToolCallGroup): string {
const s = aggregateCalls(group.calls)
const tail = statsTail(s)
const head = t('ai.trace.calls', { n: s.total })
return tail ? `${head} · ${tail}` : head
}
/**
* 单卡是否展开:
* - 分组已折叠 → false(整组隐藏)
* - 未手动操作过 → 默认折叠(U-260629):completed 卡一律单行 header(工具名+目标+结果摘要),
* 杜绝"AI 一轮多工具各铺开一整卡"刷屏;点 header 才展开。
* running/pending/rejected/write_file 不受此值影响——ToolCard 内部
* v-show="isExpanded || shouldKeepOpen(tc)" 强制展开 body。
* - userExpandedCards(用户主动展开记忆)优先,即使父级 collapseInactive
* 清了 expandedCards,记忆态仍保持展开(用户读过的卡不强制折叠)。
*/
function isCardExpanded(tc: AiToolCallInfo, gi: number): boolean {
if (isGroupCollapsed(gi)) return false
// 用户主动折叠最优先(覆盖用户展开记忆)
if (userCollapsedCards.value.has(tc.id)) return false
// 用户主动展开的卡片记忆态优先(不被自动收起影响)
if (userExpandedCards.value.has(tc.id)) return true
// 用户手动切换过该卡(当前态)
if (expandedCards.value.has(tc.id)) return true
// 兜底:默认折叠(单行 header 已有 toolDisplayName 路径/pattern/host + toolResultSummary 摘要)
return false
}
function toggleCardExpand(tc: AiToolCallInfo, gi: number) {
const id = tc.id
// 用 isCardExpanded 判当前真实态(含用户操作记忆),而非 expandedCards(记忆态不在其中,
// 否则已展开卡点 toggle 会误判"当前折叠"反向展开)
const currentlyExpanded = isCardExpanded(tc, gi)
if (currentlyExpanded) {
// 展开 → 折叠
expandedCards.value.delete(id)
userExpandedCards.value.delete(id)
userCollapsedCards.value.add(id)
} else {
// 折叠 → 展开
expandedCards.value.add(id)
userExpandedCards.value.add(id)
userCollapsedCards.value.delete(id)
}
}
function toggleExpand(id: string) {
if (expandedTools.value.has(id)) expandedTools.value.delete(id)
else expandedTools.value.add(id)
}
/**
* 收起不在 activeIds 中的已完成/已拒绝卡片(供父组件 auto-collapse watch 调用)。
* userExpandedCards(用户主动展开记忆)不被清除——用户读过的卡
* 即使不再 active(running/pending/write_file)也保持展开,避免"我刚才在看,突然被收起"。
* 仅清 expandedCards(默认态/首卡)与 expandedTools(内容级展开,跟随卡片折叠)。
*/
function collapseInactive(activeIds: Set<string>): void {
// 对称过滤 expandedTools(原只清 expandedCards,expandedTools 残留致折叠后内容展开态)
if (expandedCards.value.size > 0) {
expandedCards.value = new Set([...expandedCards.value].filter(id => activeIds.has(id)))
}
if (expandedTools.value.size > 0) {
expandedTools.value = new Set([...expandedTools.value].filter(id => activeIds.has(id)))
}
// 不清 userExpandedCards(记忆态),isCardExpanded 优先读它保持展开。
}
/**
* 滚动到第一个 pending_approval 卡片。
* 若该卡处于折叠分组(display:none → offsetParent 为 null),先展开所有折叠组,
* 下一帧重查并滚动。供 AiChat 头部审批徽标点击调用。
*/
function scrollToFirstPending(): void {
const root = rootEl.value
if (!root) return
const card = root.querySelector<HTMLElement>('.ai-tool-card--pending_approval')
if (!card) return
if (card.offsetParent === null) {
// 处于折叠分组内 → 展开后下一帧重查。
// 展开组记 touched:审批卡可见性优先于默认收起,否则流式新卡一触发补齐,
// 刚展开的含 pending 卡的组又被收回,BUG-260624-02(审批不可见)复发。
groupedToolCalls.value.forEach(g => {
if (g.calls.length >= 2) userTouchedGroups.value.add(g.name)
})
collapsedGroups.value = new Set()
nextTick(() => {
root.querySelector<HTMLElement>('.ai-tool-card--pending_approval')
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
return
}
card.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
// BUG-260624-02(授权弹窗卡死根治·诊断 workflow high 置信):监听 pending 审批卡到达事件,
// 自动展开折叠分组 + scroll 到 pending 卡。根因:统一审批开关 df-ai-unified-approval 默认开 →
// path 审批走 ToolCard 内联,但同名工具≥2 分组默认折叠(initDefaultCollapse)+ group-hidden
// (display:none)→ 审批按钮对用户不可见 → 5min 超时(aiShared APPROVAL_TIMEOUT_MS)静默
// authorizeDir/approve(deny)→ 误显"用户拒绝" → 用户全程未见审批入口即"卡死/被拒"。
// scrollToFirstPending(:285)已能自动展开折叠组(offsetParent===null → collapsedGroups 清空),
// 原仅 AiChat 头部徽标手动触发(:230);此处 pending 到达即自动调用,让审批卡必可见可操作。
let _unlistenPending: UnlistenFn | null = null
// 论证 workflow 回归中优:防卸载竞态——await listen 解析前组件已卸载(HMR/快速切会话)时,
// onBeforeUnmount 跑到 _unlistenPending 仍 null 跳过 unlisten → 孤儿监听器泄漏。disposed 标志兜底。
let _disposed = false
onMounted(async () => {
const fn = await listen('ai-pending-arrived', () => {
// BUG-260624-01 残留优化:本实例无 pending 卡时短路返回,避免大列表 N 个 ToolCardList 实例
// 每次广播都跑 querySelector(N 实例 × M 次 emit = N×M 次 DOM 查询)。原靠 scrollToFirstPending
// 内 querySelector 返 null no-op 天然过滤(功能正确),此处显式 props 过滤把无 pending 实例挡在
// 进入函数前。含 pending 的实例再走 nextTick + scrollToFirstPending(展开折叠组 + scroll)。
if (!props.toolCalls.some(tc => tc.status === 'pending_approval')) return
nextTick(() => scrollToFirstPending())
})
// 解析时若已卸载(HMR/极速 mount/unmount),立即 unlisten 刚拿到的句柄,防孤儿监听器。
if (_disposed) { fn(); return }
_unlistenPending = fn
})
onBeforeUnmount(() => {
_disposed = true
_unlistenPending?.()
_unlistenPending = null
})
defineExpose({ collapseInactive, scrollToFirstPending })
/* -- 分组图标(复用 ToolCard 的 toolCategory 逻辑) -- */
function groupIcon(name: string): string {
if (name === 'read_file') return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>'
if (name === 'write_file') return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>'
if (name === 'list_directory') return '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>'
if (name.includes('create')) return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>'
if (name.includes('delete')) return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>'
return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
}
</script>
<style scoped>
.ai-tool-calls {
display: flex;
flex-direction: column;
gap: 8px;
}
/* -- 顶部操作栏:批量审批 + 全局收起合并同行-- */
.ai-tool-topbar {
display: flex;
align-items: center;
gap: 8px;
}
/* -- 批量操作栏 -- */
.ai-batch-approve {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: var(--df-radius-sm);
background: rgba(255,255,255,0.02);
}
/* .ai-btn 基类:批量审批按钮(历史重构遗漏定义,补全治本) */
.ai-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 12px;
border: none;
border-radius: var(--df-radius-sm);
background: var(--df-accent);
color: #fff;
font-family: var(--df-font-sans);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: filter 0.15s, background 0.15s;
white-space: nowrap;
}
.ai-btn:hover { filter: brightness(1.1); }
.ai-btn--sm { padding: 4px 10px; font-size: 11px; }
.ai-btn--danger {
background: var(--df-danger);
color: #fff;
}
.ai-btn--danger:hover { filter: brightness(1.1); }
/* -- 全部展开/收起从 topbar 下沉到分组标题行,移除独立按钮 padding/背景,
高度=文字行高,不撑高分组标题行;融入标题视觉,仅 hover 变色) -- */
.ai-tool-global-toggle {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
user-select: none;
font-size: 11px;
color: var(--df-text-dim);
transition: color 0.15s var(--df-ease);
/* 推到分组标题行尾巴右对齐 */
margin-left: auto;
}
.ai-tool-global-toggle:hover {
color: var(--df-text-secondary);
}
.ai-tool-global-toggle-icon {
font-size: 10px;
transition: transform 0.15s var(--df-ease);
}
/* -- 分组标题 -- */
.ai-tool-group-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
/* max-width 对齐气泡/卡片(90%),不拉满 ai-msg-content 右侧 */
max-width: var(--df-msg-max-width);
cursor: pointer;
user-select: none;
border-radius: var(--df-radius-sm);
transition: background 0.15s var(--df-ease);
font-size: 11px;
color: var(--df-text-secondary);
}
.ai-tool-group-header:hover {
background: rgba(255,255,255,0.03);
}
.ai-tool-group-icon {
display: flex;
align-items: center;
flex-shrink: 0;
color: var(--df-text-dim);
}
.ai-tool-group-name {
font-weight: 500;
color: var(--df-text-secondary);
}
/* 聚合行「N 次调用 · 成功 x 失败 y」:弱化等宽,与单卡耗时同视觉层级 */
.ai-tool-group-agg {
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-tool-group-chevron {
/* 移除 margin-left:auto,让全部展开/收起按钮(global-toggle)占据行尾右对齐 */
font-size: 10px;
color: var(--df-text-dim);
transition: transform 0.15s var(--df-ease);
flex-shrink: 0;
}
.ai-tool-group-chevron.is-open {
transform: rotate(90deg);
}
/* -- 分组隐藏态的卡片。
UX-260616-03: 保持 display:none(原行为)。
不改 max-height/opacity 动画的原因:flex 容器 gap:8px 对零高 item 仍贡献 gap,
会留双倍间距致布局回归;display:none 移出流才无 gap。
真正的高度动画需 <Transition> JS 钩子(高度 auto→0 不可纯 CSS transition),
本轮野人 lite 不重构 ToolCard 单卡级折叠(v-show 同因)。
触发时机扩展(新消息/新轮次)已落地,见 AiChat.vue watch。 -- */
.ai-tool-card--group-hidden {
display: none;
}
</style>