Files
DevFlow/src/components/ToolCardList.vue
绝尘 bd6a41fe6e 新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理
后端:
- 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展
- 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通
- 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦
- AI planner/plan_hint/intent:aichat B 路线并行多轮基础
- patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环
- 压缩超时兜底(F-15 卡死根治)
- F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本
- 知识注入 DRY/skills/audit 扩展

清理:
- aichat 技术债(误报 allow/死导入/过时注释 30 项)
- URGENT.md 删除(11 项加急全解决/迁 todo)
- 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
2026-06-21 20:51:26 +08:00

427 lines
16 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 个时显示 -->
<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-count">{{ group.calls.length }}</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, ci) in group.calls"
:key="tc.id"
:tc="tc"
:isExpanded="isCardExpanded(tc, gi, ci)"
:isContentExpanded="expandedTools.has(tc.id)"
@toggle="() => toggleCardExpand(tc, gi, ci)"
@expand-content="toggleExpand"
@approve="(e) => emit('approve', e)"
:class="{ 'ai-tool-card--group-hidden': group.calls.length >= 2 && isGroupCollapsed(gi) }"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import ToolCard from './ToolCard.vue'
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'|'always'|'deny'),risk 类仅 approved boolean。
*/
approve: [{ id: string; approved: boolean; decision?: 'once' | 'always' | 'deny' }]
/** 批量审批 → 父组件调用 approveAll/rejectAll */
'batch-approve': [decision: 'approve' | 'reject']
}>()
// 根元素 ref(scrollToFirstPending 查 DOM 用)
const rootEl = ref<HTMLElement>()
// 卡片级折叠(整卡 body 显隐,区别于 read_file 内容级)
const expandedCards = ref(new Set<string>())
// UX-260616-03:用户主动展开的卡片(记忆态,不被自动收起清除)。
// 与 expandedCards 区分:expandedCards 是"展开"语义的当前态(含默认首卡),
// userExpandedCards 是"用户明确点开"的持久标记,collapseInactive 跳过这些卡。
// 语义:用户读过的卡不强制折叠,符合"跟随阅读位置"的增强目标。
const userExpandedCards = ref(new Set<string>())
// UX-260617-23:用户主动折叠记忆(修复首卡 ci===0 兜底 return true 导致无法折叠)。
// isCardExpanded 最优先判断,覆盖默认首卡展开 + userExpandedCards 记忆。
const userCollapsedCards = ref(new Set<string>())
// 内容级展开read_file "展开全部"
const expandedTools = ref(new Set<string>())
// 分组折叠状态(按分组索引);多卡片分组默认收起
const collapsedGroups = ref<Set<number>>(new Set())
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
)
/** 首个可折叠分组索引(U-260618:全部展开/收起按钮下沉到此分组标题行尾巴) */
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默认收起首条不参与 group-hidden 但跟随折叠态 */
function initDefaultCollapse(): void {
const set = new Set<number>()
for (let i = 0; i < groupedToolCalls.value.length; i++) {
if (groupedToolCalls.value[i].calls.length >= 2) {
set.add(i)
}
}
collapsedGroups.value = set
}
/** 首次有数据时执行默认折叠初始化watch 防重复,修复异步加载竞态 BUG-B */
let initialized = false
watch(
() => props.toolCalls.length,
(len) => {
if (len > 0 && !initialized) {
initialized = true
initDefaultCollapse()
}
},
{ immediate: true }
)
function isGroupCollapsed(gi: number): boolean {
return collapsedGroups.value.has(gi)
}
function toggleGroupCollapse(gi: number): void {
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>()
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 友好标签(英文化,原硬编码中文)。
* UX-260617-17:用 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, ' ')
}
/**
* 单卡是否展开:
* - 分组未折叠 → 用户手动 expandedCards 或首卡默认展开
* - 分组已折叠 → false整组隐藏
* - 多卡片分组 + 未手动操作过 → 仅首卡(ci===0)展开
* - UX-260616-03:userExpandedCards(用户主动展开记忆)优先,即使父级 collapseInactive
* 清了 expandedCards,记忆态仍保持展开(用户读过的卡不强制折叠)。
*/
function isCardExpanded(tc: AiToolCallInfo, gi: number, ci: number): boolean {
if (isGroupCollapsed(gi)) return false
// UX-260617-23:用户主动折叠最优先(覆盖默认首卡 ci===0 兜底展开 + 用户展开记忆)
if (userCollapsedCards.value.has(tc.id)) return false
// UX-260616-03:用户主动展开的卡片记忆态优先(不被自动收起影响)
if (userExpandedCards.value.has(tc.id)) return true
// 用户手动切换过该卡(当前态)
if (expandedCards.value.has(tc.id)) return true
// 多卡片分组:仅首卡默认展开
if (groupedToolCalls.value[gi]?.calls.length >= 2) return ci === 0
// 单卡组:默认展开
return true
}
function toggleCardExpand(tc: AiToolCallInfo, gi: number, ci: number) {
const id = tc.id
// 用 isCardExpanded 判当前真实态(含默认首卡展开),而非 expandedCards(默认首卡不在其中,
// 否则首卡点 toggle 会误判"当前折叠"反向展开)
const currentlyExpanded = isCardExpanded(tc, gi, ci)
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 调用)。
* UX-260616-03:userExpandedCards(用户主动展开记忆)不被清除——用户读过的卡
* 即使不再 active(running/pending/write_file)也保持展开,避免"我刚才在看,突然被收起"。
* 仅清 expandedCards(默认态/首卡)与 expandedTools(内容级展开,跟随卡片折叠)。
*/
function collapseInactive(activeIds: Set<string>): void {
// CR-260615-31:对称过滤 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)))
}
// UX-260616-03:不清 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) {
// 处于折叠分组内 → 展开后下一帧重查
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' })
}
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;
}
/* -- 顶部操作栏:批量审批 + 全局收起合并同行(UX-260616-02) -- */
.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-batch-approve .ai-btn--sm {
font-size: 11px;
}
/* -- 全部展开/收起(U-260618:从 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;
/* U-260618: 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-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
font-size: 10px;
font-weight: 600;
background: var(--df-accent-bg);
color: var(--df-accent);
}
.ai-tool-group-chevron {
/* U-260618: 移除 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>