优化: AI模块(provider重试兼容+aichat交互+工具卡片可读化+diff高亮)

This commit is contained in:
2026-06-16 02:33:16 +08:00
parent d2cb38cdac
commit 10e4945e5a
26 changed files with 2337 additions and 332 deletions

View File

@@ -1,24 +1,54 @@
<template>
<div class="ai-tool-calls">
<ToolCard
v-for="tc in toolCalls"
:key="tc.id"
:tc="tc"
:isExpanded="expandedCards.has(tc.id)"
:isContentExpanded="expandedTools.has(tc.id)"
@toggle="toggleCardExpand"
@expand-content="toggleExpand"
@approve="(e) => emit('approve', e)"
/>
<div class="ai-tool-calls" ref="rootEl">
<!-- 批量操作栏存在待审批项时显示 -->
<div v-if="pendingCount > 0" 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 v-if="collapsibleGroupCount > 0" class="ai-tool-global-toggle" @click="toggleAllGroups">
<span class="ai-tool-global-toggle-icon">{{ allGroupsExpanded ? '▾' : '▸' }}</span>
<span>{{ allGroupsExpanded ? $t('aiTool.collapseAll') : $t('aiTool.expandAll') }}</span>
</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>
</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"
@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 } from 'vue'
import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import ToolCard from './ToolCard.vue'
import type { AiToolCallInfo } from '@/api/types'
defineProps<{
// useI18n needed for $t() in template (collapseAll / expandAll)
useI18n()
const props = defineProps<{
/** 当前消息的工具调用列表 */
toolCalls: AiToolCallInfo[]
}>()
@@ -26,12 +56,126 @@ defineProps<{
const emit = defineEmits<{
/** 审批按钮 → 父组件转发 store.approveToolCall */
approve: [{ id: string; approved: boolean }]
/** 批量审批 → 父组件调用 approveAll/rejectAll */
'batch-approve': [decision: 'approve' | 'reject']
}>()
// 根元素 ref(scrollToFirstPending 查 DOM 用)
const rootEl = ref<HTMLElement>()
// 卡片级折叠(整卡 body 显隐,区别于 read_file 内容级)
const expandedCards = 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
)
/** 待审批工具数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
}
/**
* 单卡是否展开:
* - 分组未折叠 → 用户手动 expandedCards 或首卡默认展开
* - 分组已折叠 → false整组隐藏
* - 多卡片分组 + 未手动操作过 → 仅首卡(ci===0)展开
*/
function isCardExpanded(tc: AiToolCallInfo, gi: number, ci: number): boolean {
if (isGroupCollapsed(gi)) return false
// 用户手动切换过该卡
if (expandedCards.value.has(tc.id)) return true
// 多卡片分组:仅首卡默认展开
if (groupedToolCalls.value[gi]?.calls.length >= 2) return ci === 0
// 单卡组:默认展开
return true
}
function toggleCardExpand(id: string) {
if (expandedCards.value.has(id)) expandedCards.value.delete(id)
@@ -54,7 +198,61 @@ function collapseInactive(activeIds: Set<string>): void {
}
}
defineExpose({ collapseInactive })
/**
* 滚动到第一个 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>'
}
/** 分组显示名:工具名 + 数量提示 */
function groupDisplayName(group: ToolCallGroup): string {
const nameMap: Record<string, string> = {
read_file: '读取文件',
write_file: '写入文件',
list_directory: '列出目录',
create_task: '创建任务',
create_project: '创建项目',
create_idea: '创建灵感',
list_tasks: '列出任务',
list_projects: '列出项目',
list_ideas: '列出灵感',
delete_project: '删除项目',
restore_project: '恢复项目',
purge_project: '彻底删除项目',
update_project: '更新项目',
run_workflow: '运行工作流',
}
const label = nameMap[group.name] || group.name.replace(/_/g, ' ')
return label
}
</script>
<style scoped>
@@ -63,4 +261,95 @@ defineExpose({ collapseInactive })
flex-direction: column;
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;
}
/* -- 全部展开/收起按钮 -- */
.ai-tool-global-toggle {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
cursor: pointer;
user-select: none;
font-size: 11px;
color: var(--df-text-dim);
border-radius: var(--df-radius-sm);
transition: background 0.15s var(--df-ease);
width: fit-content;
}
.ai-tool-global-toggle:hover {
background: rgba(255,255,255,0.03);
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;
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 {
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);
}
/* -- 分组隐藏态的卡片 -- */
.ai-tool-card--group-hidden {
display: none;
}
</style>