- 全局样式类(df-sec-h/df-toast族)替换散装实现,主题token去硬编码兜底 - 拆分子组件:ai-chat/queue-panel(队列时钟与编辑随迁)、 conversation-sidebar/ConversationList、settings/provider-panel - 父组件死副本清理(队列编辑/时钟/键位表,TS6133清零)并补 fill-draft父侧转发(重构半成品断链修复) - 验收:vue-tsc EXIT 0
550 lines
25 KiB
Vue
550 lines
25 KiB
Vue
<template>
|
||
<div class="ai-tool-calls" ref="rootEl">
|
||
<!-- 顶部操作栏:批量审批(轨迹卡 toggle 已覆盖整体展开/收起,分组行仅单组导航) -->
|
||
<div v-if="pendingCount > 0" class="ai-tool-topbar">
|
||
<!-- 批量操作栏(存在待审批项时显示) -->
|
||
<div class="ai-batch-approve">
|
||
<!-- 批量批准含 High 风险工具时前置二次确认(与单卡 onApprove 同门槛;
|
||
纯低风险批量保持一键直批) -->
|
||
<button class="ai-btn ai-btn--sm" :disabled="batchProcessing" @click="onBatchApprove">
|
||
<span v-if="batchProcessing" class="ai-btn-spinner" />
|
||
{{ $t('aiTool.approveAll', { n: pendingCount }) }}
|
||
</button>
|
||
<button class="ai-btn ai-btn--sm ai-btn--danger" :disabled="batchProcessing" @click="$emit('batch-approve', 'reject')">
|
||
{{ $t('aiTool.rejectAll') }}
|
||
</button>
|
||
<!-- High 风险计数徽标:批量批准将二次确认的项数,提示操作后果(wave2 迁 .df-badge danger) -->
|
||
<span v-if="highRiskPendingCount > 0" class="ai-batch-high-risk-hint df-badge df-badge--danger">
|
||
{{ $t('aiTool.batchHighRiskHint', { n: highRiskPendingCount }) }}
|
||
</span>
|
||
</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>
|
||
<!-- R7 组头失败徽标:折叠组内失败卡不展开也能从组头发现(wave2 迁 .df-badge danger) -->
|
||
<span v-if="groupFailCounts[gi] > 0" class="ai-tool-group-fail df-badge df-badge--danger">{{ $t('aiTool.groupFailCount', { n: groupFailCounts[gi] }) }}</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': isGroupHidden(tc, group, gi) }"
|
||
/>
|
||
</template>
|
||
|
||
<!-- 批量批准 High 风险二次确认(本组件自治 useConfirm,不与父级串台) -->
|
||
<ConfirmDialog
|
||
:visible="confirmState.visible"
|
||
:msg="confirmState.msg"
|
||
:danger-label="t('common.confirm')"
|
||
@result="answerConfirm"
|
||
/>
|
||
</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 ConfirmDialog from './ConfirmDialog.vue'
|
||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||
import type { AiToolCallInfo } from '@/api/types'
|
||
import { useConfirm } from '@/composables/useConfirm'
|
||
import { toolRiskLevel } from '@/composables/ai/useToolApproval'
|
||
import { isToolFailure } from '@/composables/ai/useToolCard'
|
||
|
||
// 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': []
|
||
/**
|
||
* 审批卡到达但用户不在底部附近(上滚读历史中):不强拉滚动,
|
||
* 由父(MessageList)出「有新审批」轻提示,尊重用户阅读位置。
|
||
*/
|
||
'pending-out-of-view': []
|
||
}>()
|
||
|
||
// 根元素 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>())
|
||
|
||
// ── R7 组头失败徽标:折叠组内失败卡扫组头即可发现 ──
|
||
// 病灶(R7 单行折叠信息量缺口):工具失败信号散在单卡红边,同名工具组默认折叠后
|
||
// 失败卡被 display:none 压住,10+ 卡需逐组展开逐行扫。轨迹卡聚合行/执行中步骤行
|
||
// 已有「失败 N」计数,唯一盲区是组头。此处补:组内失败卡 ≥1 时组头显红徽标
|
||
// 「{n} 失败」,折叠展开都可见。失败判定复用 useToolCard.isToolFailure 单点(与
|
||
// 单卡红边/轨迹卡统计同源,防双写判定漂移)。
|
||
const groupFailCounts = computed<number[]>(() =>
|
||
groupedToolCalls.value.map(g => g.calls.reduce((n, tc) => n + (isToolFailure(tc) ? 1 : 0), 0)),
|
||
)
|
||
|
||
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
|
||
)
|
||
|
||
// -- 批量批准 High 风险拦截(治安全缺口:原批量批准绕过单卡二次确认直接放行) --
|
||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||
/** 待审批中的 High 风险项数(徽标提示 + 确认门槛判定) */
|
||
const highRiskPendingCount = computed(() =>
|
||
props.toolCalls.filter(tc => tc.status === 'pending_approval' && toolRiskLevel(tc.name) === 'high').length
|
||
)
|
||
/** 批量处理中(透传 isBatchProcessing 不便——本组件直接用 pendingCount 收敛到 0 判断,简单可靠) */
|
||
const batchProcessing = ref(false)
|
||
|
||
/**
|
||
* 批量批准入口:含 High 风险项 → 先二次确认(文案对齐单卡 highRiskConfirmText 语义,
|
||
* 列出将放行的高危工具数);确认后才向父级派发 batch-approve('approve')。
|
||
* 拒绝批量无破坏性,不拦。
|
||
*/
|
||
async function onBatchApprove() {
|
||
if (highRiskPendingCount.value > 0) {
|
||
const ok = await confirmDialog(
|
||
t('aiTool.batchHighRiskConfirm', { n: highRiskPendingCount.value }),
|
||
)
|
||
if (!ok) return
|
||
}
|
||
batchProcessing.value = true
|
||
emit('batch-approve', 'approve')
|
||
// emit 后父级异步批量处理;本组件无完成回执,以 pendingCount 归零兜底复位
|
||
const stop = watch(pendingCount, (n, old) => {
|
||
if (n === 0 || n < old) { batchProcessing.value = false; stop() }
|
||
}, { flush: 'post' })
|
||
// 兜底:pendingCount 因新增挂起不清零时 3s 后自动复位
|
||
setTimeout(() => { batchProcessing.value = false }, 3000)
|
||
}
|
||
|
||
/**
|
||
* 默认收起补齐:达到多卡阈值(>=2)且用户未手动操作过的组自动收起。
|
||
* 响应式而非一次性初始化——流式场景第一张卡到达即触发旧一次性逻辑,
|
||
* 此后组从 1 卡长到 N 卡不再折叠,批量读取的 N 张单行卡全展开刷屏;
|
||
* 签名(组名:卡数)变化即幂等补齐,新达到阈值的组收起,已 touched 的组不动。
|
||
*
|
||
* 活组豁免:含 running/pending_approval 卡或用户展开过卡的组(groupHasLiveCard)
|
||
* 不自动收;已被收的活组自动复活展开。治"执行轮整组(读取文件/搜索内容)被收只剩组头,
|
||
* running 卡强制展开也被组 hidden 压住,用户想看进度要点两层"。
|
||
*/
|
||
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)) return
|
||
if (groupHasLiveCard(g)) next.delete(gi)
|
||
else next.add(gi)
|
||
})
|
||
collapsedGroups.value = next
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
/** 组内是否存在"活着"的卡:running/pending_approval 进行中 或 用户主动展开过(userExpandedCards)。
|
||
* 自动收起补齐对这类组跳过(不误收用户正在看的/正在执行的),
|
||
* 且补齐时自动展开复活——治"组从 1 卡长到 2 卡瞬间整组被收、用户点开也被压回去"。 */
|
||
function groupHasLiveCard(g: ToolCallGroup): boolean {
|
||
return g.calls.some(tc =>
|
||
tc.status === 'running'
|
||
|| tc.status === 'pending_approval'
|
||
|| userExpandedCards.value.has(tc.id)
|
||
)
|
||
}
|
||
|
||
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(整组隐藏)。**例外**: 用户主动展开过的卡(userExpandedCards)
|
||
* 不被组折叠压制()——组收起只隐藏"无用户记忆"的卡,
|
||
* 用户点开过的卡保持展开,否则"点开又被组补齐自动压回去"死循环。
|
||
* - 未手动操作过 → 默认折叠(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 (userCollapsedCards.value.has(tc.id)) return false
|
||
// 用户主动展开记忆次优先:压过组折叠(),点开过的卡组收起时仍展开
|
||
if (userExpandedCards.value.has(tc.id)) return true
|
||
// 组折叠(只影响无用户记忆的卡)
|
||
if (isGroupCollapsed(gi)) return false
|
||
// 用户手动切换过该卡(当前态)
|
||
if (expandedCards.value.has(tc.id)) return true
|
||
// 兜底:默认折叠(单行 header 已有 toolDisplayName 路径/pattern/host + toolResultSummary 摘要)
|
||
return false
|
||
}
|
||
|
||
/** 组收起时卡片是否整卡隐藏(display:none)。
|
||
* 用户主动展开过的卡豁免——组收起只隐藏无用户记忆的卡,
|
||
* 否则模板 class 的 display:none 会把 isCardExpanded 已放行的展开卡重新藏掉。 */
|
||
function isGroupHidden(tc: AiToolCallInfo, group: ToolCallGroup, gi: number): boolean {
|
||
if (group.calls.length < 2 || !isGroupCollapsed(gi)) return false
|
||
return !userExpandedCards.value.has(tc.id)
|
||
}
|
||
|
||
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
|
||
|
||
/** 距滚动容器底部 <150px 视为"在底部附近",审批卡到达才跟随滚动(宽于 useMessageScroll 的 80px 在底判定) */
|
||
const PENDING_FOLLOW_PX = 150
|
||
/** 轻提示去重阈值:审批卡短时间连发多条时 5s 内只提示一次 */
|
||
const PENDING_HINT_INTERVAL_MS = 5000
|
||
// 模块级(跨实例共享):ai-pending-arrived 广播所有 ToolCardList 实例都会收到,去重须超组件实例
|
||
let _lastPendingHintTs = 0
|
||
|
||
/** 滚动容器(.ai-messages,MessageList 持有)是否在底部附近 */
|
||
function isContainerNearBottom(el: Element): boolean {
|
||
const he = el as HTMLElement
|
||
return he.scrollHeight - he.scrollTop - he.clientHeight < PENDING_FOLLOW_PX
|
||
}
|
||
|
||
/** 用户不在底部时的轻提示:不拉滚,经父转发 toast(5s 去重防连发刷屏) */
|
||
function notifyPendingOutOfView(): void {
|
||
const now = Date.now()
|
||
if (now - _lastPendingHintTs < PENDING_HINT_INTERVAL_MS) return
|
||
_lastPendingHintTs = now
|
||
emit('pending-out-of-view')
|
||
}
|
||
|
||
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(() => {
|
||
// UX 走查-对话域 B1:尊重用户滚动意图。nextTick 等审批卡渲染完再测距底距离:
|
||
// 在底部附近(含本就贴底)才跟随滚到审批卡;用户上滚读历史时不强拉,改轻提示。
|
||
// 容器找不到(极端:分离窗口 DOM 层级变化)按可滚处理,保留原展开+滚动兜底。
|
||
const container = rootEl.value?.closest('.ai-messages')
|
||
if (!container || isContainerNearBottom(container)) {
|
||
scrollToFirstPending()
|
||
} else {
|
||
notifyPendingOutOfView()
|
||
}
|
||
})
|
||
})
|
||
// 解析时若已卸载(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 aria-hidden="true" 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 aria-hidden="true" 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 aria-hidden="true" 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 aria-hidden="true" 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 aria-hidden="true" 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 aria-hidden="true" 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;
|
||
}
|
||
|
||
/* -- 批量操作栏(wave2:白透明底残留改 token,浅色主题成立) -- */
|
||
.ai-batch-approve {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 10px;
|
||
border-radius: var(--df-radius-sm);
|
||
background: var(--df-bg-card);
|
||
}
|
||
/* 批量批准将二次确认的 High 风险项数徽标:配色由 df-badge--danger 接管,此处只留布局 */
|
||
.ai-batch-high-risk-hint {
|
||
white-space: nowrap;
|
||
}
|
||
/* .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: var(--df-bg-card-hover);
|
||
}
|
||
/* R7 组头失败徽标:配色由 df-badge--danger 接管,此处只留布局(右对齐推到 chevron 前) */
|
||
.ai-tool-group-fail {
|
||
margin-left: auto;
|
||
margin-right: 2px;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
.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);
|
||
}
|
||
|
||
/* -- 分组隐藏态的卡片。
|
||
保持 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>
|