Files
DevFlow/src/components/ToolCard.vue

1178 lines
44 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-card"
:class="[
'ai-tool-card--' + tc.status,
{ 'ai-tool-card--collapsed': !isExpanded && !shouldKeepOpen(tc), 'ai-tool-card--failed': isToolFailure(tc) },
]"
>
<!-- 卡片头部图标 + 名称 + 状态 -->
<div class="ai-tool-header" @click="emit('toggle', tc.id)">
<span class="ai-tool-icon-wrap" :class="'ai-tool-icon-wrap--' + toolCategory(tc.name)">
<span v-html="toolIcon(tc.name)"></span>
</span>
<div class="ai-tool-header-text">
<span class="ai-tool-name">{{ toolDisplayName(tc) }}</span>
<span v-if="tc.status === 'running'" class="ai-tool-sub">{{ $t('aiTool.running') }}</span>
<span v-else-if="isToolFailure(tc)" class="ai-tool-sub ai-tool-sub--failed">{{ $t('aiTool.executionFailed') }}</span>
<span v-else-if="toolResultSummary(tc)" class="ai-tool-sub">{{ toolResultSummary(tc) }}</span>
</div>
<span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + (isToolFailure(tc) ? 'failed' : tc.status)"></span>
<span v-if="!shouldKeepOpen(tc)" class="ai-tool-collapse-hint" :class="{ 'is-open': isExpanded }"></span>
</div>
<!-- Body: 收起态隐藏(running/pending 强制展开) -->
<div v-show="isExpanded || shouldKeepOpen(tc)" class="ai-tool-body">
<!-- Running 骨架屏 -->
<div v-if="tc.status === 'running'" class="ai-tool-skeleton">
<div class="ai-tool-skeleton-line" style="width:60%"></div>
<div class="ai-tool-skeleton-line" style="width:85%"></div>
<div class="ai-tool-skeleton-line" style="width:40%"></div>
</div>
<!-- 审批参数内容 + 风险提示 + 按钮 -->
<div v-if="tc.status === 'pending_approval'" class="ai-tool-approval">
<div v-if="toolArgsEntries(tc.args).length" class="ai-tool-approval-args">
<div v-for="arg in toolArgsEntries(tc.args)" :key="arg.key" class="ai-tool-arg">
<span class="ai-tool-arg-key">{{ arg.label }}</span>
<span class="ai-tool-arg-val">{{ displayArgValue(arg) }}</span>
</div>
</div>
<div v-if="tc.reason" class="ai-tool-approval-reason"> {{ tc.reason }}</div>
<!-- AE-2025-03: write_file 审批 diff 预览红删绿增行级旧文件不存在tc.diff 为空回退显上方 args content -->
<div v-if="tc.diff" class="ai-tool-approval-diff">
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in diffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
</div>
<div class="ai-tool-actions">
<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"
: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>
</div>
<!-- 已拒绝提示 -->
<div v-if="tc.status === 'rejected'" class="ai-tool-rejected">
<span class="ai-tool-rejected-icon"></span>
{{ tc.result || $t('aiTool.rejectedHint') }}
</div>
<!-- read_file 结果代码预览 -->
<div v-if="tc.name === 'read_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-file-content">
<div class="ai-tool-file-bar">
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
<span class="ai-tool-file-meta">{{ parsed?.lines || 0 }} {{ $t('aiTool.lines') }} · {{ formatBytes(parsed?.size) }}</span>
<button class="ai-tool-expand-btn" @click="emit('expand-content', tc.id)">
{{ isContentExpanded ? $t('aiTool.collapse') : $t('aiTool.expandAll') }}
</button>
</div>
<pre class="ai-tool-file-pre" :class="{ 'ai-tool-file-pre--collapsed': !isContentExpanded }"><code>{{ parsed?.content }}</code></pre>
</div>
<!-- list_directory 结果文件树 -->
<div v-else-if="tc.name === 'list_directory' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
<div class="ai-tool-file-bar">
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
<span class="ai-tool-file-meta">{{ parsed?.entries?.length || 0 }} {{ $t('aiTool.items') }}</span>
</div>
<div class="ai-tool-dir-entries">
<div
v-for="(entry, i) in parsed?.entries"
:key="i"
class="ai-tool-dir-entry"
:class="'ai-tool-dir-entry--' + entry.type"
:style="{ paddingLeft: `${12 + (entry.depth || 0) * 16}px` }"
>
<span class="ai-tool-dir-icon" :class="entry.type === 'directory' ? 'ai-tool-dir-icon--folder' : 'ai-tool-dir-icon--file'" v-html="entry.type === 'directory' ? dirIcon : fileIcon"></span>
<span class="ai-tool-dir-name">{{ entry.name }}</span>
<span v-if="entry.type === 'file'" class="ai-tool-dir-size">{{ formatBytes(entry.size) }}</span>
</div>
</div>
</div>
<!-- write_file 结果 -->
<div v-else-if="tc.name === 'write_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-write-result">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--df-success)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<span class="ai-tool-write-path">{{ parsed?.path }}</span>
<span class="ai-tool-write-meta">{{ formatBytes(parsed?.bytes_written) }} {{ $t('aiTool.written') }}</span>
</div>
<!-- 通用 CRUD 结果 -->
<div
v-else-if="tc.result && tc.status === 'completed'"
class="ai-tool-result"
:class="{ 'ai-tool-result--failed': isToolFailure(tc) }"
>
<div v-if="isToolFailure(tc)" class="ai-tool-failed-banner"> {{ $t('aiTool.executionFailedHint') }}</div>
<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 {
path?: string
from?: string
to?: string
content?: string
lines?: number | null
size?: number | null
bytes_written?: number
new_size?: number
bytes_moved?: number
entries?: Array<{ name: string; type: string; size?: number; depth?: number }>
name?: string
title?: string
field?: string
id?: string
status?: string
stack?: string
bound?: boolean
deleted?: boolean
restored?: boolean
purged?: boolean
updated?: boolean
changed?: boolean
renamed?: boolean
exists?: boolean
is_binary?: boolean
is_dir?: boolean
permanent?: boolean
backed_up?: boolean
size_diff?: number
exit_code?: number
duration_ms?: number
stdout?: string
stderr?: string
truncated?: boolean
results?: Array<{ path?: string; size?: number }>
total?: number
has_more?: boolean
warning?: string
review_rounds?: number
}
/** 内联 SVG 图标字符串(经 v-html 注入,常量安全;图标库改造见决策记录) */
const dirIcon = '<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>'
const fileIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><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"/></svg>'
/** 默认保持展开:执行中/待审批/已拒绝/write_file——折叠无收益 */
function shouldKeepOpen(tc: AiToolCallInfo): boolean {
return tc.status === 'running'
|| tc.status === 'pending_approval'
|| tc.status === 'rejected'
|| tc.name === 'write_file'
}
/**
* UX-260616-01: 工具执行失败检测(completed 状态下的失败语义)。
*
* 后端 AR-6:Low 风险工具失败不 emit AiError,改 emit AiToolCallCompleted(result=错误),
* 故失败工具 status 仍为 'completed',与成功工具视觉同态(原绿色边框+绿色结果框)。
* 此函数区分两类失败:
* - run_command exit_code≠0:结果 JSON 合法,parseResult 命中,看 exit_code 判失败
* - 通用工具失败:AR-6 路径后端塞 serde_json::Value::String("工具 X 执行失败: ..."),
* 前端 parseResult 返回 null(非 JSON 对象)→ 走 raw 文本兜底,无结构信号
* 此时按字符串前缀 "执行失败"/"failed" 启发式判定(对齐后端 err_msg 格式)。
*
* 注意:status==='rejected'(用户拒绝)走独立分支,不算工具失败,不进此函数。
* running/pending_approval 自然 false(无 result 或 result 未稳定)。
*/
function isToolFailure(tc: AiToolCallInfo): boolean {
if (tc.status !== 'completed') return false
// run_command:结构化 exit_code 判定(可靠)
if (tc.name === 'run_command') {
const r = parseResult(tc.result)
if (r && typeof r.exit_code === 'number') return r.exit_code !== 0
// run_command 但无 exit_code(异常)→ 保守判失败
if (r) return true
}
// 通用工具:parseResult 返回 null(非 JSON,AR-6 错误字符串路径)→ 看文本前缀
if (!parseResult(tc.result)) {
const raw = typeof tc.result === 'string' ? tc.result : ''
if (!raw) return false
return /执行失败|failed|error[:\s]/i.test(raw)
}
return false
}
function formatToolName(name: string): string {
if (!name) return ''
return name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function formatJson(data: unknown): string {
try {
return JSON.stringify(data, null, 2)
} catch {
return String(data)
}
}
/** 审批参数 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 报错) */
function toolArgsEntries(args: unknown): Array<{ key: string; label: string; val: unknown }> {
if (args && typeof args === 'object' && !Array.isArray(args)) {
return Object.entries(args as Record<string, unknown>).map(([key, val]) => ({
key,
label: argLabel(key),
val,
}))
}
return []
}
/** 格式化单个审批参数值:字符串直接展示(超长截断),对象/数组 JSON 化后截断 */
function formatArgValue(val: unknown): string {
if (val == null) return ''
if (typeof val === 'string') return val.length > 300 ? val.slice(0, 300) + '…' : val
if (typeof val === 'object') {
try {
const s = JSON.stringify(val)
return s.length > 300 ? s.slice(0, 300) + '…' : s
} catch {
return String(val)
}
}
return String(val)
}
/** 安全解析工具结果 JSON;list_* 运行时是数组,类型归 ToolResult 靠 Array.isArray 区分 */
function parseResult(result: unknown): ToolResult | null {
if (!result) return null
try {
return (typeof result === 'string' ? JSON.parse(result) : result) as ToolResult
} catch {
return null
}
}
function toolCategory(name: string): string {
if (!name) return 'default'
if (name === 'read_file') return 'file-read'
if (name === 'write_file') return 'file-write'
if (name === 'list_directory') return 'dir'
if (name.includes('create')) return 'create'
if (name.includes('delete')) return 'delete'
if (name.includes('list')) return 'list'
return 'default'
}
function toolIcon(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 dirIcon
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 shortPath(p: string): string {
const parts = p.replace(/\\/g, '/').split('/')
return parts.length <= 2 ? p : '.../' + parts.slice(-2).join('/')
}
/** 格式化字节数(兜底:undefined/null/负/NaN/Infinity 统一降级为 '0 B',防渲染异常) */
function formatBytes(bytes: number | undefined | null): string {
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '0 B'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1048576).toFixed(1) + ' MB'
}
/** 从工具 args(unknown)中安全取字符串字段;非对象/字段非 string 时返回空串 */
function argString(args: unknown, key: string): string {
if (args && typeof args === 'object' && !Array.isArray(args)) {
const v = (args as Record<string, unknown>)[key]
return typeof v === 'string' ? v : ''
}
return ''
}
/**
* 通用工具结果格式化(非文件类工具)。
* UX-260616-04:统一 switch 覆盖全部工具产出人类可读摘要,不再走裸 JSON 兜底。
* read_file/list_directory/write_file 有模板特化分支,不进此函数。
*/
function formatToolResult(tc: AiToolCallInfo): string {
const r = parseResult(tc.result)
if (!r) {
// 非 JSON(纯文本:审批占位/错误/拒绝)→ 原样显示,审批占位时补拟执行参数避免空白
const raw = typeof tc.result === 'string' ? tc.result : ''
if (raw && tc.args && typeof tc.args === 'object') {
const a = tc.args as Record<string, unknown>
const detail = typeof a.title === 'string' ? a.title
: typeof a.name === 'string' ? a.name
: typeof a.path === 'string' ? a.path
: ''
if (detail) return t('aiTool.resultDetail', { raw, detail })
}
return raw
}
switch (tc.name) {
case 'write_file':
return `${r.path} (${formatBytes(r.bytes_written)})`
case 'update_task':
return r.updated !== false
? t('aiTool.updatedTaskField', { id: r.id || '', field: r.field || '' })
: t('aiTool.updated')
case 'advance_task':
return t('aiTool.advancedTask', { id: r.id || '', status: r.status || '' })
case 'delete_task':
return r.deleted ? t('aiTool.deleted') : t('aiTool.deleteIneffective')
case 'delete_file':
return r.permanent
? t('aiTool.deleteFilePermanent', { path: r.path || '' })
: t('aiTool.deleteFileSoft', { path: r.path || '' })
case 'run_command':
return formatCommandResult(r)
case 'patch_file':
return r.changed === false
? t('aiTool.patchedNoChange', { path: r.path || '' })
: t('aiTool.patchedFile', { path: r.path || '', diff: formatSizeDiff(r.size_diff) })
case 'append_file':
return t('aiTool.appendedTo', { path: r.path || '', bytes: formatBytes(r.bytes_written) })
case 'rename_file':
return r.renamed
? t('aiTool.renamedFile', { from: r.from || '', to: r.to || '' })
: formatJson(r)
case 'search_files': {
const n = typeof r.total === 'number' ? r.total : (Array.isArray(r.results) ? r.results.length : 0)
return t('aiTool.foundN', { n })
}
case 'file_info':
return formatFileInfoResult(r)
case 'bind_directory':
return r.bound
? t('aiTool.boundDir', { path: r.path || '', stack: r.stack || '' })
: formatJson(r)
default:
return formatJson(r)
}
}
/** size_diff 数字 → "+N"/"-N"/"+0" 字符串(负数补减号,正数补加号) */
function formatSizeDiff(diff: number | undefined | null): string {
const n = typeof diff === 'number' ? diff : 0
return n >= 0 ? `+${n}` : `${n}`
}
/**
* run_command 结果摘要:exit_code 标题 + stdout/stderr 前 N 行 + 总行数提示(截断,不全量显示)。
* 输出可能很大(编译/find/cat 大文件),仅显示前若干行 + 总行数提示,防撑爆卡片。
*/
function formatCommandResult(r: ToolResult): string {
const code = typeof r.exit_code === 'number' ? r.exit_code : -1
const ms = typeof r.duration_ms === 'number' ? r.duration_ms : 0
const head = code === 0
? t('aiTool.commandOk', { ms })
: t('aiTool.commandFailed', { code })
// 合并 stdout + stderr(实际 stderr 通常含报错),各自取前若干行
const MAX_LINES = 20
const out = combineAndTruncateLines(r.stdout, r.stderr, MAX_LINES)
const parts = [head]
if (out.text) {
parts.push(out.text)
if (out.truncated) {
parts.push(t('aiTool.commandOutputLines', { n: out.total }))
}
}
return parts.join('\n')
}
/** 合并 stdout/stderr 取前 N 行,返回截断后文本 + 是否截断 + 总行数 */
function combineAndTruncateLines(stdout: string | undefined, stderr: string | undefined, maxLines: number): { text: string; truncated: boolean; total: number } {
const parts: string[] = []
if (stdout) parts.push(stdout)
if (stderr) parts.push(stderr)
if (!parts.length) return { text: '', truncated: false, total: 0 }
const combined = parts.join('\n')
const allLines = combined.split('\n')
const total = allLines.length
if (total <= maxLines) {
return { text: combined, truncated: false, total }
}
// 取前 N 行 + 省略提示
return { text: allLines.slice(0, maxLines).join('\n'), truncated: true, total }
}
/** file_info 结果摘要:存在性 + 大小 + 行数(文本)/二进制/目录 标注 */
function formatFileInfoResult(r: ToolResult): string {
if (r.exists === false) {
return t('aiTool.fileInfoMissing', { path: r.path || '' })
}
let base = t('aiTool.fileInfoSize', { path: r.path || '', size: formatBytes(typeof r.size === 'number' ? r.size : 0) })
const extras: string[] = []
if (r.is_dir) {
extras.push(t('aiTool.fileInfoDir'))
} else {
if (typeof r.lines === 'number') {
extras.push(t('aiTool.fileInfoLines', { n: r.lines }))
}
if (r.is_binary) {
extras.push(t('aiTool.fileInfoBinary'))
}
}
return base + extras.join('')
}
</script>
<script setup lang="ts">
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
/** 卡片级展开(来自父级 expandedCards) */
isExpanded: boolean
/** 内容级展开(read_file 的"展开全部",来自父级 expandedTools) */
isContentExpanded: boolean
}>()
const emit = defineEmits<{
/** header 点击 → 卡片折叠/展开 */
toggle: [id: string]
/** read_file "展开全部/收起" → 内容级展开 */
'expand-content': [id: string]
/** 审批按钮 → 父级转发 store.approveToolCall */
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))
/**
* AE-2025-03: write_file 审批 diff 行解析。
* 后端 generate_diff 输出 '+新行/-旧行/ 上下文行',逐行拆 → {kind, text} 供模板按 kind 着色。
* kind: 'add'(+, 绿)/'del'(-, 红)/'ctx'(空格, 灰)。computed 在 diff 变化(新审批)时重算。
*/
const diffLines = computed(() => {
const d = props.tc.diff
if (!d) return []
return d.split('\n').map(line => {
// 注意 generate_diff 末尾保留单尾换行 → split 末尾有空串元素,过滤
if (line === '') return null
if (line.startsWith('+')) return { kind: 'add', text: line }
if (line.startsWith('-')) return { kind: 'del', text: line }
return { kind: 'ctx', text: line }
}).filter((x): x is { kind: string; text: string } => x !== null)
})
/**
* 按 id 查项目名(覆盖活跃 + 回收站):审批 delete/restore/purge 各阶段都可能引用项目,
* 故先查 projects 再查 deletedProjects,找到即返回(提前退出),无则返回 undefined(调用方走 fallback)。
* 复用 project store 已加载列表,不新增网络请求;响应式由 store 数组保证(find 在 computed/渲染上下文中触发依赖追踪)。
* 审批卡通常仅 1-2 行引用项目 id,find 比 computed 全量建 Map 更省。
*/
function projectNameById(id: string): string | undefined {
return projectStore.projects.find(p => p.id === id)?.name
?? projectStore.deletedProjects.find(p => p.id === id)?.name
}
/** 工具名 → 该工具中代表项目 id 的参数 key(仅项目类工具特化;delete_task 的 id 是任务 id,不在此列) */
const PROJECT_ID_TOOL_ARG: Record<string, 'id' | 'project_id'> = {
delete_project: 'id',
restore_project: 'id',
purge_project: 'id',
update_project: 'id',
bind_directory: 'id',
create_task: 'project_id',
}
/**
* 审批参数值展示(AR-3):对项目类工具的 id/project_id 特化——优先回显项目名,查不到则显示
* 「项目已不存在」提示 + 原 id,避免用户只看到一串裸 id 不知道是什么数据。
* 非项目工具(如 delete_task 的 id)或其他 key 走通用 formatArgValue,避免误判。
*/
function displayArgValue(arg: { key: string; val: unknown }): string {
const projectArgKey = PROJECT_ID_TOOL_ARG[props.tc.name]
if (projectArgKey && arg.key === projectArgKey) {
const id = typeof arg.val === 'string' ? arg.val : ''
if (id) {
const name = projectNameById(id)
if (name) return t('aiTool.projectLabel', { name })
return t('aiTool.projectIdNotFound', { id })
}
}
return formatArgValue(arg.val)
}
/**
* 工具显示名称(含目标摘要);文件类按 path,项目类(delete/restore/purge/update_project)按 id
* 解析项目名(复用 projectNameById,无则走 fallback),create_* 用固定文案。
*/
function toolDisplayName(tc: AiToolCallInfo): string {
const p = argString(tc.args, 'path')
switch (tc.name) {
case 'read_file': return p ? `${t('aiTool.readPrefix')} ${shortPath(p)}` : t('aiTool.readFallback')
case 'list_directory': return p ? `${t('aiTool.dirPrefix')} ${shortPath(p)}` : t('aiTool.dirFallback')
case 'write_file': return p ? `${t('aiTool.writePrefix')} ${shortPath(p)}` : t('aiTool.writeFallback')
case 'delete_project':
case 'restore_project':
case 'purge_project':
case 'update_project': {
const idKey = PROJECT_ID_TOOL_ARG[tc.name]
const id = idKey ? argString(tc.args, idKey) : ''
const name = id ? projectNameById(id) : ''
const prefixKey = `${tc.name.replace('_project', '')}Prefix` as 'deletePrefix' | 'restorePrefix' | 'purgePrefix' | 'updatePrefix'
const fallbackKey = `${tc.name.replace('_project', '')}Fallback` as 'deleteFallback' | 'restoreFallback' | 'purgeFallback' | 'updateFallback'
return name ? `${t('aiTool.' + prefixKey)}${name}` : t('aiTool.' + fallbackKey)
}
case 'create_task': return t('aiTool.createTask')
case 'create_project': return t('aiTool.createProject')
default: return formatToolName(tc.name)
}
}
/**
* completed 工具结果的一句话摘要(折叠态 header 也能看到,避免光秃秃)。
* UX-260616-04:覆盖范围与 formatToolResult 对齐(header+body 一致);
* read_file/list_directory/write_file 有模板特化分支,header 摘要仍显示关键计数。
*/
function toolResultSummary(tc: AiToolCallInfo): string {
if (tc.status !== 'completed') return ''
const r = parseResult(tc.result)
if (!r) return ''
switch (tc.name) {
case 'list_tasks': return Array.isArray(r) ? t('aiTool.taskCount', { n: r.length }) : ''
case 'list_projects': return Array.isArray(r) ? t('aiTool.projectCount', { n: r.length }) : ''
case 'list_ideas': return Array.isArray(r) ? t('aiTool.ideaCount', { n: r.length }) : ''
case 'create_project': return r.name ? t('aiTool.createdWithName', { name: r.name }) : t('aiTool.created')
case 'create_idea':
case 'create_task': return r.title ? t('aiTool.createdWithName', { name: r.title }) : t('aiTool.created')
case 'update_project': return r.field ? t('aiTool.updatedField', { field: r.field }) : t('aiTool.updated')
case 'update_task': return r.updated !== false
? t('aiTool.updatedTaskField', { id: r.id || '', field: r.field || '' })
: t('aiTool.updated')
case 'advance_task': return t('aiTool.advancedTask', { id: r.id || '', status: r.status || '' })
case 'delete_project':
case 'delete_task': return r.deleted ? t('aiTool.deleted') : t('aiTool.deleteIneffective')
case 'delete_file': return r.permanent
? t('aiTool.deleteFilePermanent', { path: r.path || '' })
: t('aiTool.deleteFileSoft', { path: r.path || '' })
case 'restore_project': return r.restored ? t('aiTool.restored') : t('aiTool.restoreIneffective')
case 'purge_project': return r.purged ? t('aiTool.purged') : t('aiTool.purgeIneffective')
case 'run_command': {
const code = typeof r.exit_code === 'number' ? r.exit_code : -1
const ms = typeof r.duration_ms === 'number' ? r.duration_ms : 0
return code === 0 ? t('aiTool.commandOk', { ms }) : t('aiTool.commandFailed', { code })
}
case 'patch_file': return r.changed === false
? t('aiTool.patchedNoChange', { path: r.path || '' })
: t('aiTool.patchedFile', { path: r.path || '', diff: formatSizeDiff(r.size_diff) })
case 'append_file': return t('aiTool.appendedTo', { path: r.path || '', bytes: formatBytes(r.bytes_written) })
case 'rename_file': return r.renamed
? t('aiTool.renamedFile', { from: r.from || '', to: r.to || '' })
: ''
case 'search_files': {
const n = typeof r.total === 'number' ? r.total : (Array.isArray(r.results) ? r.results.length : 0)
return t('aiTool.foundN', { n })
}
case 'file_info': return r.exists === false
? t('aiTool.fileInfoMissing', { path: r.path || '' })
: t('aiTool.fileInfoSize', { path: r.path || '', size: formatBytes(typeof r.size === 'number' ? r.size : 0) })
case 'bind_directory': return r.bound
? t('aiTool.boundDir', { path: r.path || '', stack: r.stack || '' })
: ''
case 'run_workflow': return t('aiTool.workflowHint')
default: return ''
}
}
</script>
<style scoped>
.ai-tool-card {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
padding: 0;
overflow: hidden;
max-width: var(--df-msg-max-width);
animation: fadeInUp 0.2s var(--df-ease);
/* UX-260616-03: border/shadow/opacity 过渡让折叠/失败态切换不突兀。
不做 body 高度动画(v-show display 切换 + flex gap 会留双倍间距致布局跳变,
见 ToolCardList 注释);opacity 过渡覆盖自动收起时的视觉跳变(约 200ms)。 */
transition: border-color 0.2s var(--df-ease), box-shadow 0.2s var(--df-ease), opacity 0.2s var(--df-ease);
}
.ai-tool-card--pending_approval {
border-color: rgba(240,199,94,0.3);
box-shadow: 0 0 0 1px rgba(240,199,94,0.06);
}
.ai-tool-card--completed {
border-color: rgba(61,219,160,0.2);
}
.ai-tool-card--rejected {
border-color: rgba(240,101,101,0.2);
opacity: 0.55;
}
/* UX-260616-01: 工具失败态——AR-6 下 status 仍为 completed,故单独覆盖边框/底色与 completed 区分 */
.ai-tool-card--failed {
border-color: rgba(240,101,101,0.35);
box-shadow: 0 0 0 1px rgba(240,101,101,0.06);
}
.ai-tool-status-dot--failed {
background: var(--df-danger);
}
.ai-tool-sub--failed {
color: var(--df-danger);
}
.ai-tool-failed-banner {
padding: 4px 10px 2px;
font-size: 11px;
font-weight: 500;
color: var(--df-danger);
background: var(--df-danger-bg);
border-bottom: 0.5px solid rgba(240,101,101,0.2);
}
.ai-tool-result--failed {
background: var(--df-danger-bg);
border-top-color: rgba(240,101,101,0.2);
}
.ai-tool-result--failed code {
color: var(--df-danger);
}
.ai-tool-rejected {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px 8px 42px;
font-size: 11px;
color: var(--df-danger);
}
.ai-tool-rejected-icon {
font-weight: 700;
}
/* -- 卡片折叠态(running/pending 始终展开,completed/rejected 默认折叠为单行 header) -- */
.ai-tool-card--collapsed .ai-tool-header {
cursor: pointer;
}
.ai-tool-card--collapsed .ai-tool-header:hover {
background: rgba(255,255,255,0.03);
}
.ai-tool-body {
overflow: hidden;
}
.ai-tool-collapse-hint {
font-size: 10px;
color: var(--df-text-dim);
margin-left: auto;
flex-shrink: 0;
transition: transform 0.15s var(--df-ease);
}
.ai-tool-collapse-hint.is-open {
transform: rotate(90deg);
}
/* -- Header -- */
.ai-tool-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
}
.ai-tool-icon-wrap {
width: 24px;
height: 24px;
border-radius: var(--df-radius-sm);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ai-tool-icon-wrap--file-read { background: rgba(94,175,240,0.12); color: var(--df-info); }
.ai-tool-icon-wrap--file-write { background: var(--df-warning-bg); color: var(--df-warning); }
.ai-tool-icon-wrap--dir { background: rgba(240,199,94,0.12); color: var(--df-warning); }
.ai-tool-icon-wrap--create { background: var(--df-success-bg); color: var(--df-success); }
.ai-tool-icon-wrap--delete { background: var(--df-danger-bg); color: var(--df-danger); }
.ai-tool-icon-wrap--list { background: var(--df-accent-bg); color: var(--df-accent); }
.ai-tool-icon-wrap--default { background: var(--df-accent-bg); color: var(--df-accent); }
.ai-tool-header-text {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
column-gap: 8px;
row-gap: 1px;
flex: 1;
min-width: 0;
}
.ai-tool-name {
font-size: 12px;
font-weight: 500;
color: var(--df-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
}
.ai-tool-sub {
font-size: 10px;
color: var(--df-text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* -- Status dot (脉冲动画) -- */
.ai-tool-status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.ai-tool-status-dot--running {
background: var(--df-info);
animation: pulseGlow 1.5s ease-in-out infinite;
box-shadow: 0 0 6px var(--df-info);
}
.ai-tool-status-dot--pending_approval {
background: var(--df-warning);
animation: pulseGlow 1.5s ease-in-out infinite;
box-shadow: 0 0 6px rgba(240,199,94,0.3);
}
.ai-tool-status-dot--completed { background: var(--df-success); }
.ai-tool-status-dot--rejected { background: var(--df-danger); }
/* -- Skeleton (running 状态) -- */
.ai-tool-skeleton {
padding: 4px 10px 10px 42px;
display: flex;
flex-direction: column;
gap: 5px;
}
.ai-tool-skeleton-line {
height: 8px;
border-radius: var(--df-radius-xs);
background: linear-gradient(90deg, var(--df-bg) 25%, var(--df-bg-card-hover) 50%, var(--df-bg) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* -- 审批参数展示 -- */
.ai-tool-approval-args {
padding: 4px 10px 6px;
margin-bottom: 6px;
border-top: 0.5px solid var(--df-border);
border-bottom: 0.5px solid var(--df-border);
background: var(--df-bg-elevated, rgba(0, 0, 0, 0.03));
}
.ai-tool-arg {
display: flex;
gap: 8px;
padding: 2px 0;
font-size: 11px;
line-height: 1.5;
}
.ai-tool-arg-key {
flex-shrink: 0;
min-width: 64px;
color: var(--df-text-secondary);
font-family: var(--df-font-mono);
}
.ai-tool-arg-val {
flex: 1;
word-break: break-all;
white-space: pre-wrap;
font-family: var(--df-font-mono);
color: var(--df-text-primary);
}
.ai-tool-approval-reason {
padding: 4px 10px 6px;
margin-bottom: 6px;
font-size: 11px;
color: var(--df-warning, #d97706);
background: var(--df-warning-bg, rgba(217, 119, 6, 0.08));
border-radius: var(--df-radius-sm, 4px);
}
/* AE-2025-03: write_file 审批 diff 预览区(红删绿增行级,复用 --df-danger/--df-success token */
.ai-tool-approval-diff {
margin: 0 10px 8px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm, 4px);
overflow: hidden;
}
.ai-tool-diff-pre {
margin: 0;
padding: 6px 0;
max-height: 240px;
overflow: auto;
font-family: var(--df-font-mono);
font-size: 11px;
line-height: 1.5;
background: var(--df-bg-card);
}
.ai-tool-diff-pre code {
display: block;
white-space: pre;
}
.ai-tool-diff-line {
display: block;
padding: 0 10px;
white-space: pre-wrap;
word-break: break-all;
}
.ai-tool-diff-line--add { color: var(--df-success, #3ddb9c); background: var(--df-success-bg, rgba(61, 219, 160, 0.08)); }
.ai-tool-diff-line--del { color: var(--df-danger, #f06565); background: var(--df-danger-bg, rgba(240, 101, 101, 0.08)); }
.ai-tool-diff-line--ctx { color: var(--df-text-dim, #888); }
/* -- 审批按钮 -- */
.ai-tool-actions {
display: flex;
gap: 6px;
padding: 0 10px 8px;
}
.ai-tool-btn {
padding: 5px 12px;
font-size: 11px;
font-weight: 500;
border-radius: var(--df-radius-sm);
border: 0.5px solid var(--df-border);
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
transition: all 0.15s var(--df-ease);
font-family: var(--df-font-sans);
}
.ai-tool-btn--approve {
background: var(--df-success-bg);
color: var(--df-success);
}
.ai-tool-btn--approve:hover {
background: var(--df-success);
color: #fff;
border-color: var(--df-success);
}
.ai-tool-btn--reject {
background: var(--df-danger-bg);
color: var(--df-danger);
}
.ai-tool-btn--reject:hover {
background: var(--df-danger);
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 {
border-top: 0.5px solid var(--df-border);
}
.ai-tool-file-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
background: color-mix(in srgb, var(--df-bg) 80%, transparent);
border-bottom: 0.5px solid var(--df-border);
}
.ai-tool-file-icon {
display: flex;
align-items: center;
color: var(--df-info);
flex-shrink: 0;
}
.ai-tool-file-icon--dir {
color: var(--df-warning);
}
.ai-tool-file-path {
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-accent);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-tool-file-meta {
font-family: var(--df-font-mono);
font-size: 9px;
color: var(--df-text-dim);
flex-shrink: 0;
}
.ai-tool-expand-btn {
font-family: var(--df-font-sans);
font-size: 10px;
color: var(--df-text-dim);
background: rgba(255,255,255,0.04);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
padding: 2px 8px;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s var(--df-ease);
}
.ai-tool-expand-btn:hover {
color: var(--df-text-secondary);
background: rgba(255,255,255,0.08);
border-color: var(--df-border-strong);
}
.ai-tool-file-pre {
margin: 0;
padding: 10px 12px;
background: color-mix(in srgb, var(--df-bg) 60%, transparent);
font-family: var(--df-font-mono);
font-size: 11px;
line-height: 1.6;
color: var(--df-text-secondary);
overflow-x: auto;
tab-size: 4;
}
.ai-tool-file-pre--collapsed {
max-height: 180px;
/* hidden→auto: 默认高度内可直接滚轮看完整内容,不必先点"展开全部" */
overflow-y: auto;
position: relative;
}
.ai-tool-file-pre--collapsed::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 48px;
background: linear-gradient(transparent, color-mix(in srgb, var(--df-bg) 90%, transparent));
pointer-events: none;
}
/* -- Directory List (list_directory) -- */
.ai-tool-dir-list {
border-top: 0.5px solid var(--df-border);
}
.ai-tool-dir-entries {
padding: 4px 0;
max-height: 220px;
overflow-y: auto;
}
.ai-tool-dir-entry {
display: flex;
align-items: center;
gap: 7px;
padding: 3px 12px;
font-size: 11px;
transition: background 0.1s;
}
.ai-tool-dir-entry:hover {
background: rgba(255,255,255,0.03);
}
.ai-tool-dir-entry--directory .ai-tool-dir-name {
font-weight: 500;
color: var(--df-text);
}
.ai-tool-dir-icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
.ai-tool-dir-icon--folder { color: var(--df-warning); }
.ai-tool-dir-icon--file { color: var(--df-text-dim); }
.ai-tool-dir-name {
font-family: var(--df-font-mono);
color: var(--df-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.ai-tool-dir-size {
font-family: var(--df-font-mono);
font-size: 9px;
color: var(--df-text-dim);
flex-shrink: 0;
opacity: 0.7;
}
/* -- Write File Result -- */
.ai-tool-write-result {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-top: 0.5px solid var(--df-border);
background: rgba(61,219,160,0.04);
}
.ai-tool-write-path {
font-family: var(--df-font-mono);
font-size: 11px;
color: var(--df-accent);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-tool-write-meta {
font-size: 10px;
color: var(--df-success);
flex-shrink: 0;
}
/* -- Generic Result -- */
.ai-tool-result {
margin: 0;
padding: 8px 10px;
background: rgba(61,219,160,0.04);
border-top: 0.5px solid var(--df-border);
border-radius: 0;
font-family: var(--df-font-mono);
font-size: 11px;
color: var(--df-success);
max-height: 100px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
</style>