优化: AI模块(provider重试兼容+aichat交互+工具卡片可读化+diff高亮)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -37,12 +37,18 @@
|
||||
</div>
|
||||
<div v-if="tc.reason" class="ai-tool-approval-reason">⚠ {{ tc.reason }}</div>
|
||||
<div class="ai-tool-actions">
|
||||
<button class="ai-tool-btn ai-tool-btn--approve" @click="emit('approve', { id: tc.id, approved: true })">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<button class="ai-tool-btn ai-tool-btn--approve"
|
||||
:disabled="approving"
|
||||
@click="onApprove(true)">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
{{ $t('aiTool.approve') }}
|
||||
</button>
|
||||
<button class="ai-tool-btn ai-tool-btn--reject" @click="emit('approve', { id: tc.id, approved: false })">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
<button class="ai-tool-btn ai-tool-btn--reject"
|
||||
:disabled="approving"
|
||||
@click="onApprove(false)">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
{{ $t('aiTool.reject') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -101,11 +107,23 @@
|
||||
<code>{{ formatToolResult(tc) }}</code>
|
||||
</div>
|
||||
</div><!-- /.ai-tool-body -->
|
||||
|
||||
<!-- AE-2025-05:High 风险审批二次确认(本卡自治 useConfirm,不与 AiChat 父级 confirmState 串台) -->
|
||||
<ConfirmDialog
|
||||
:visible="confirmState.visible"
|
||||
:msg="confirmState.msg"
|
||||
:danger-label="t('common.confirm')"
|
||||
@result="answerConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
// composable 外全局 i18n 实例(非 setup 上下文,供纯函数 argLabel/formatToolResult 使用)
|
||||
const t = ((i18n as any).global.t as (k: string, p?: Record<string, unknown>) => string).bind((i18n as any).global)
|
||||
|
||||
/** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */
|
||||
interface ToolResult {
|
||||
@@ -148,19 +166,22 @@ function formatJson(data: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** 审批参数 key → 友好标签映射(静态,不查 store);未命中回退原 key */
|
||||
const ARG_LABEL_MAP: Record<string, string> = {
|
||||
id: 'ID',
|
||||
project_id: '项目 ID',
|
||||
name: '名称',
|
||||
title: '标题',
|
||||
description: '描述',
|
||||
field: '字段',
|
||||
value: '新值',
|
||||
path: '路径',
|
||||
priority: '优先级',
|
||||
tags: '标签',
|
||||
source: '来源',
|
||||
/** 审批参数 key → 友好标签映射(动态 i18n);未命中回退原 key */
|
||||
function argLabel(key: string): string {
|
||||
const map: Record<string, string> = {
|
||||
id: t('aiTool.argLabel.id'),
|
||||
project_id: t('aiTool.argLabel.projectId'),
|
||||
name: t('aiTool.argLabel.name'),
|
||||
title: t('aiTool.argLabel.title'),
|
||||
description: t('aiTool.argLabel.description'),
|
||||
field: t('aiTool.argLabel.field'),
|
||||
value: t('aiTool.argLabel.value'),
|
||||
path: t('aiTool.argLabel.path'),
|
||||
priority: t('aiTool.argLabel.priority'),
|
||||
tags: t('aiTool.argLabel.tags'),
|
||||
source: t('aiTool.argLabel.source'),
|
||||
}
|
||||
return map[key] || key
|
||||
}
|
||||
|
||||
/** 审批参数转键值对数组(非对象/空时返回空数组,防 args 非 object 时 v-for 报错) */
|
||||
@@ -168,7 +189,7 @@ function toolArgsEntries(args: unknown): Array<{ key: string; label: string; val
|
||||
if (args && typeof args === 'object' && !Array.isArray(args)) {
|
||||
return Object.entries(args as Record<string, unknown>).map(([key, val]) => ({
|
||||
key,
|
||||
label: ARG_LABEL_MAP[key] || key,
|
||||
label: argLabel(key),
|
||||
val,
|
||||
}))
|
||||
}
|
||||
@@ -254,7 +275,7 @@ function formatToolResult(tc: AiToolCallInfo): string {
|
||||
: typeof a.name === 'string' ? a.name
|
||||
: typeof a.path === 'string' ? a.path
|
||||
: ''
|
||||
if (detail) return `${raw}(${detail})`
|
||||
if (detail) return t('aiTool.resultDetail', { raw, detail })
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -266,13 +287,19 @@ function formatToolResult(tc: AiToolCallInfo): string {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
// AE-2025-05:本卡自治二次确认状态机。每张 ToolCard 各持一份 confirmState,与 AiChat
|
||||
// 父级 useConfirm 实例互不影响(并发弹层天然隔离,无需共享)。
|
||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
|
||||
const props = defineProps<{
|
||||
/** 单条工具调用数据 */
|
||||
tc: AiToolCallInfo
|
||||
@@ -291,6 +318,67 @@ const emit = defineEmits<{
|
||||
approve: [{ id: string; approved: boolean }]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* 审批按钮 loading 态(B-260616-08)。
|
||||
* 点击后置 true,禁用两按钮防重复点击并显示 spinner。后端回事件 useAiEvents 把 tc.status
|
||||
* 离开 pending_approval(转 completed/rejected)→ watch 自动复位 false,卡片同步切态。
|
||||
*
|
||||
* 兜底:approve 后状态保持 pending_approval,若后端既不回事件也不转 running(仅在
|
||||
* AiApprovalResult/approved 后 hang 在工具执行前),全局看门狗只回滚 running 不会触及本卡。
|
||||
* 故此处加本地兜底计时(对齐全局看门狗 STREAM_TIMEOUT_MS),到点复位 loading 让用户能重试/改判。
|
||||
*/
|
||||
const APPROVE_LOADING_TIMEOUT_MS = 130000 // 对齐 useAiStream.STREAM_TIMEOUT_MS
|
||||
const approving = ref(false)
|
||||
let approvingTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/**
|
||||
* AE-2025-05:High 风险工具白名单(后端 tool_registry.rs RiskLevel::High 对齐)。
|
||||
* AiToolCallInfo 无 risk 字段(风险仅存后端),故前端按工具名判定。新增 High 工具时同步加这里。
|
||||
* - delete 类(delete_project/purge_project/delete_task/delete_file/restore_project)→ 删除/不可恢复文案
|
||||
* - run_command → 执行命令文案
|
||||
* - 其余 High(未来扩展) → 通用高危文案
|
||||
*/
|
||||
const HIGH_RISK_TOOLS = new Set<string>([
|
||||
'delete_task', 'delete_project', 'restore_project', 'purge_project', 'delete_file', 'run_command',
|
||||
])
|
||||
|
||||
/** High 风险工具的二次确认文案(按操作类型) */
|
||||
function highRiskConfirmMsg(name: string): string {
|
||||
if (name === 'run_command') return t('aiTool.confirmHighExec')
|
||||
if (name === 'delete_task' || name === 'delete_project' || name === 'purge_project' || name === 'delete_file' || name === 'restore_project') {
|
||||
return t('aiTool.confirmHighDelete')
|
||||
}
|
||||
return t('aiTool.confirmHighGeneric')
|
||||
}
|
||||
|
||||
async function onApprove(approved: boolean) {
|
||||
if (approving.value) return // 防重入
|
||||
// AE-2025-05:High 风险 + 批准 → 置 approving 前先二次确认,误点可取消。
|
||||
// 拒绝(approved=false)不再二次确认(拒绝本就无害,加确认反增摩擦)。
|
||||
if (approved && HIGH_RISK_TOOLS.has(props.tc.name)) {
|
||||
const ok = await confirmDialog(highRiskConfirmMsg(props.tc.name))
|
||||
if (!ok) return
|
||||
}
|
||||
approving.value = true
|
||||
// 本地兜底:到点强制复位 loading(后端无回执、卡片仍 pending_approval 时给用户重试入口)
|
||||
approvingTimer = setTimeout(() => {
|
||||
approving.value = false
|
||||
approvingTimer = null
|
||||
}, APPROVE_LOADING_TIMEOUT_MS)
|
||||
emit('approve', { id: props.tc.id, approved })
|
||||
}
|
||||
// status 离开 pending_approval → 后端已回事件 → 复位 loading 并清计时器
|
||||
watch(() => props.tc.status, (s) => {
|
||||
if (s !== 'pending_approval') {
|
||||
approving.value = false
|
||||
if (approvingTimer) { clearTimeout(approvingTimer); approvingTimer = null }
|
||||
}
|
||||
})
|
||||
// 组件卸载清计时器防泄漏
|
||||
onBeforeUnmount(() => {
|
||||
if (approvingTimer) clearTimeout(approvingTimer)
|
||||
})
|
||||
|
||||
/** 一次解析结果供模板多次读(模板引用 6 次,computed 避免每次 patch 重 parse) */
|
||||
const parsed = computed(() => parseResult(props.tc.result))
|
||||
|
||||
@@ -602,6 +690,35 @@ function toolResultSummary(tc: AiToolCallInfo): string {
|
||||
color: #fff;
|
||||
border-color: var(--df-danger);
|
||||
}
|
||||
/* 审批 loading(B-260616-08):IPC 送达待后端回执期间禁用两按钮防重复点击,spinner 转在原位 */
|
||||
.ai-tool-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ai-tool-btn--approve:disabled:hover,
|
||||
.ai-tool-btn--reject:disabled:hover {
|
||||
/* 覆盖 hover 高亮,disabled 态不闪色 */
|
||||
background: var(--df-success-bg);
|
||||
color: var(--df-success);
|
||||
border-color: var(--df-border);
|
||||
}
|
||||
.ai-tool-btn--reject:disabled:hover {
|
||||
background: var(--df-danger-bg);
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.ai-tool-btn-spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1.5px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
animation: aiToolBtnSpin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes aiToolBtnSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* -- File Content (read_file) -- */
|
||||
.ai-tool-file-content {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user