Files
DevFlow/src/components/ToolCardList.vue
T
lxy 66c6553cdf 优化: ai-chat 对话流布局与轨迹信息密度改造
- 分组行去统计化,调用统计归并轨迹卡头部
- 相对时间改时间分隔线,跨日显绝对时间
- 思考过程单行 pill 化,答案卡加 accent 边
- 工具卡短路径展示与耗时提示统一
- 高风险工具名单启动时同步后端注册表
- 自动重试改琥珀细条,红卡与手动重试仅终态
- 轨迹卡头并时间线行,显轮次号与绝对时间
- 运行中摘要补当前工具名与失败计数
- 执行中工具卡加实时计时,耗时统一人话口径
- 重试残留清理改标记匹配,替代中文子串硬编码
2026-10-01 19:35:20 +08:00

412 lines
17 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">
<!-- 顶部操作栏:批量审批(轨迹卡 toggle 已覆盖整体展开/收起,分组行仅单组导航) -->
<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 个时显示(仅分组导航语义:点开逐条。
调用统计收敛到执行轨迹卡头部一处,分组行不再重复计数) -->
<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-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }">▸</span>
</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 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
})
/** 待审批工具数(status === 'pending_approval'),控制批量操作栏显隐 */
const pendingCount = computed(() =>
props.toolCalls.filter(tc => tc.status === 'pending_approval').length
)
/**
* 默认收起补齐:达到多卡阈值(>=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
}
/**
* 分组显示名:工具名 → 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, ' ')
}
/**
* 单卡是否展开:
* - 分组已折叠 → 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); }
/* -- 分组标题 -- */
.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);
}
.ai-tool-group-chevron {
margin-left: auto;
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>