Files
DevFlow/src/components/ToolCard.vue
绝尘 1dc8b12ec4 修复: aichat advance_task渲染显任务名 + 审批防双击 + time.ts销账
- UX-260618-14: advance_task 工具结果/审批卡显任务名替代UUID
  前端 ToolCard 取 r.title fallback r.id + TASK_ID_TOOL_ARG 审批卡 task_id→title 回显 + i18n
  后端 audit resolve_task_label 查 tasks 表(原误查 projects恒报不存在) + 删 tool_display_hint 短路让模板生效
- UX-260618-16: time.ts formatDate .value 已修(CR-24/25闭环),漏销账补登
- UX-260618-17: ProjectDetail handleApprovalMulti 补 submitting try/finally + 模板联动防双击
2026-06-19 00:52:20 +08:00

1528 lines
60 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': isFailed },
]"
>
<!-- 卡片头部图标 + 名称 + 状态 -->
<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="isFailed" 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--' + (isFailed ? '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?.has_more
? $t('aiTool.linesTruncated', { shown: parsed?.returned_lines ?? 0, total: parsed?.lines ?? 0 })
: `${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.expand') }}
</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>
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.dirTruncated', { n: 1000 }) }}</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>
<!-- search_files 结果:匹配文件列表(对齐 list_directory 渲染, results 被丢弃致 body 仅显找到 N ) -->
<div v-else-if="tc.name === 'search_files' && 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 v-if="parsed?.pattern" class="ai-tool-file-meta">{{ parsed?.pattern }}</span>
<span class="ai-tool-file-meta">{{ $t('aiTool.foundN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.results?.length || 0) }) }}</span>
</div>
<div class="ai-tool-dir-entries">
<div v-for="(entry, i) in parsed?.results" :key="i" class="ai-tool-dir-entry">
<span class="ai-tool-dir-icon ai-tool-dir-icon--file" v-html="fileIcon"></span>
<span class="ai-tool-dir-name">{{ entry.path }}</span>
<span 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>
<span v-if="typeof parsed?.old_size === 'number' && parsed.old_size > 0" class="ai-tool-write-meta">{{ $t('aiTool.writeOverwrite', { old: formatBytes(parsed.old_size), neww: formatBytes(parsed?.bytes_written) }) }}</span>
<span v-if="typeof parsed?.encoding === 'string' && parsed.encoding !== 'utf-8'" class="ai-tool-write-meta">{{ $t('aiTool.encodingLabel', { enc: parsed.encoding }) }}</span>
</div>
<!-- patch_file 结果:文件路径/字节增减 + unified diff 红删绿增行级预览(复用 ai-tool-diff-pre/--add/--del/--ctx 既有样式)
UX-260618-06:后端 generate_diff 产完整 diff,result.diff 字段此前被丢弃,用户无法验证改了什么 此分支渲染 diff -->
<div v-else-if="tc.name === 'patch_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-patch-result">
<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">{{ formatSizeDiff(parsed?.size_diff) }} {{ $t('aiTool.bytesUnit') }}</span>
<span v-if="parsed?.matches_found && parsed.matches_found > 1" class="ai-tool-file-meta">{{ $t('aiTool.patchedMatches', { n: parsed.matches_found }) }}</span>
</div>
<div v-if="parsed?.warning" class="ai-tool-approval-reason"> {{ parsed.warning }}</div>
<div v-if="resultDiffLines.length" class="ai-tool-approval-diff">
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
</div>
</div>
<!-- run_command 结果:命令行常显 + 可折叠输出(执行后仍能看到跑了什么命令) -->
<div
v-else-if="tc.name === 'run_command' && tc.result && tc.status === 'completed'"
class="ai-tool-cmd-result"
>
<div class="ai-tool-cmd-line" :class="{ 'ai-tool-cmd-line--failed': isFailed }">
<span class="ai-tool-cmd-prompt">$</span>
<code class="ai-tool-cmd-text">{{ argString(tc.args, 'command') }}</code>
<button
v-if="cmdOutput"
class="ai-tool-cmd-toggle"
:title="cmdOutputExpanded ? $t('aiTool.collapse') : $t('aiTool.expand')"
@click="cmdOutputExpanded = !cmdOutputExpanded"
>{{ cmdOutputExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}</button>
</div>
<pre v-if="cmdOutputExpanded && cmdOutput" class="ai-tool-cmd-output"><code>{{ cmdOutput }}</code></pre>
</div>
<!-- 通用 CRUD 结果 -->
<div
v-else-if="tc.result && tc.status === 'completed'"
class="ai-tool-result"
:class="{ 'ai-tool-result--failed': isFailed }"
>
<div v-if="isFailed" 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"
/>
<!-- UX-260617-14:审批 loading 超时兜底 toast(零依赖 reactive,复用 AiChat 同款样式) -->
<Transition name="ai-tool-toast">
<div v-if="approveToast.visible" class="ai-tool-toast ai-tool-toast--warning">{{ approveToast.msg }}</div>
</Transition>
</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
pattern?: 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
// UX-260618-05~12:补全后端返回前端消费的字段(review_rounds/truncated/bytes_moved/has_more/total/entries 已存在)
task_id?: string
execution_id?: string
target_status?: string
note?: string
diff?: string
matches_found?: number
backup_path?: string
returned_lines?: number
items?: Array<Record<string, unknown>>
modified?: number | null
old_size?: number | null
encoding?: string
cross_volume?: boolean
}
/** 内联 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 文本兜底,无结构信号
* 此时按行首前缀 "执行失败"/"Error:"/"Failed:" 启发式判定(对齐后端 err_msg 格式,
* 行首锚定避免误伤含这些子串的正常产出,见 UX-260617-18)。
*
* 注意: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
// UX-260617-18:收窄为行首锚定(原 /执行失败|failed|error[:\s]/i 匹配任意位置的子串,
// 误伤 search_files 命中 error.log / run_command stderr 含 "warning" 等正常产出)。
// 仅当某行以「执行失败」(中文 err_msg 前缀)/「Error:」/「Failed:」开头才判失败。
return /^(执行失败|Error:|Failed:)/m.test(raw)
}
return false
}
function formatToolName(name: string): string {
if (!name) return ''
return name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
/** run_command 命令首行截断(头部展示;多行命令取首行,超长省略) */
function shortCmd(cmd: string, max = 48): string {
const firstLine = cmd.split('\n')[0].replace(/\s+/g, ' ').trim()
return firstLine.length > max ? firstLine.slice(0, max) + '…' : firstLine
}
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 'list_projects':
case 'list_tasks':
case 'list_ideas':
case 'list_trash': {
// UX-260618-12: list_* 后端返 {items,total,has_more} 对象(非数组),渲染结构化列表替代裸 JSON
const items = Array.isArray(r) ? r : (Array.isArray(r.items) ? r.items : [])
const total = typeof r.total === 'number' ? r.total : items.length
const head = tc.name === 'list_projects' ? t('aiTool.projectCount', { n: total })
: tc.name === 'list_tasks' ? t('aiTool.taskCount', { n: total })
: tc.name === 'list_ideas' ? t('aiTool.ideaCount', { n: total })
: t('aiTool.trashCount', { n: total })
const lines = items.slice(0, 10).map((it: Record<string, unknown>) => {
const label = typeof it.name === 'string' ? it.name
: typeof it.title === 'string' ? it.title
: typeof it.id === 'string' ? it.id
: ''
const status = typeof it.status === 'string' ? ` [${it.status}]` : ''
const stack = typeof it.stack === 'string' ? ` (${it.stack})` : ''
return label ? `- ${label}${status}${stack}` : ''
}).filter(Boolean)
const parts = [head, ...lines]
if (r.has_more) parts.push(t('aiTool.listHasMore', { n: items.length }))
return parts.join('\n')
}
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':
// UX-260618-09: review_rounds>0 显退回累加轮数,=0 回退原文案(首推无噪音)
// UX-260618-14: 后端返 TaskRecord(含 title),优先显 title,无则回退 id(防裸 UUID)
return typeof r.review_rounds === 'number' && r.review_rounds > 0
? t('aiTool.advancedTaskWithRounds', { title: r.title || r.id || '', status: r.status || '', n: r.review_rounds })
: t('aiTool.advancedTask', { title: r.title || 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 || '', backup_path: r.backup_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
? (r.cross_volume
? t('aiTool.renamedCrossVolume', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) })
: t('aiTool.renamedWithBytes', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) }))
: 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)
case 'run_workflow': {
// UX-260618-05: 后端返 {task_id,target_status,execution_id,status,note},显 execution_id 对应执行实例
const execId = typeof r.execution_id === 'string' && r.execution_id
? r.execution_id
: (typeof r.id === 'string' ? r.id : '')
if (!execId) return formatJson(r)
return t('aiTool.workflowTriggered', { id: r.task_id || '', status: r.target_status || '', exec: execId })
}
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')
}
/** SW-260618-19: 合并 stdout/stderr(空串跳过),供 cmdOutput 展开 + combineAndTruncateLines 截断共享 DRY */
function combineOutputs(stdout: string | undefined, stderr: string | undefined): string {
const parts: string[] = []
if (stdout) parts.push(stdout)
if (stderr) parts.push(stderr)
return parts.join('\n')
}
/** 合并 stdout/stderr 取前 N 行,返回截断后文本 + 是否截断 + 总行数(合并复用 combineOutputs) */
function combineAndTruncateLines(stdout: string | undefined, stderr: string | undefined, maxLines: number): { text: string; truncated: boolean; total: number } {
const combined = combineOutputs(stdout, stderr)
if (!combined) return { text: '', truncated: false, total: 0 }
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[] = []
// UX-260618-12: file_info modified 时间(epoch_ms → 客户端 toLocaleString)
if (typeof r.modified === 'number' && r.modified > 0) {
extras.push(t('aiTool.fileInfoModified', { time: new Date(r.modified).toLocaleString() }))
}
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, reactive, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useProjectStore } from '@/stores/project'
import { useConfirm } from '@/composables/useConfirm'
import { STREAM_TIMEOUT_MS } from '@/composables/ai/useAiStream'
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 }]
}>()
// run_command 输出折叠态:失败命令默认展开(便于看 stderr),成功默认折叠
// 注:此处仅状态翻转时调一次,不进模板高频路径,沿用模块级 isToolFailure(props.tc) 无性能问题。
const cmdOutputExpanded = ref(false)
// SW-260618-06: cmdOutputExpanded 的 status watch 合并到下方 approving 复位 watch
// (approving/approvingTimer 在下方定义,此处 immediate 会 TDZ,合并 watch 放三 ref 都已定义后)。
/**
* 审批按钮 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 让用户能重试/改判。
*/
// UX-260617-14:复用 useAiStream 共享常量(原硬编码 130000,单一来源防漂移)。
const APPROVE_LOADING_TIMEOUT_MS = STREAM_TIMEOUT_MS
const approving = ref(false)
let approvingTimer: ReturnType<typeof setTimeout> | null = null
// ── 审批超时兜底 toast(零依赖,复用 AiChat/Settings 同款 reactive toast 模式) ──
// ToolCard 是子组件无根 toast 挂载点,且可能以分离窗口运行(App.vue 根 toast 不在 DOM),
// 故本卡自管一个最小 toast;仅在审批 loading 超时兜底时短暂提示。
const approveToast = reactive({ visible: false, msg: '' })
let _approveToastTimer: ReturnType<typeof setTimeout> | null = null
function showApproveTimeoutToast(): void {
approveToast.msg = t('aiTool.approveLoadingTimeout')
approveToast.visible = true
if (_approveToastTimer) clearTimeout(_approveToastTimer)
_approveToastTimer = setTimeout(() => { approveToast.visible = false }, 3000)
}
/**
* 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 时给用户重试入口)
// UX-260617-14:超时复位同时给 warning toast + 控制台日志,原仅静默 approving=false 用户无感知。
approvingTimer = setTimeout(() => {
approving.value = false
approvingTimer = null
console.warn('[AI] 审批 loading 超时,后端可能未回执,已复位允许重试:', props.tc.id)
showApproveTimeoutToast()
}, APPROVE_LOADING_TIMEOUT_MS)
emit('approve', { id: props.tc.id, approved })
}
// SW-260618-06: 合并双 watch(原 cmdOutputExpanded watch + approving 复位 watch,同源 props.tc.status)。
// immediate 时 s=初始 status:cmdOutputExpanded 仅 completed 初始化(初始非 completed 无副作用);
// approving 初始 false + approvingTimer 初始 null(if null 跳过 clearTimeout),immediate 安全。
watch(() => props.tc.status, (s) => {
if (s === 'completed') cmdOutputExpanded.value = isToolFailure(props.tc)
if (s !== 'pending_approval') {
approving.value = false
if (approvingTimer) { clearTimeout(approvingTimer); approvingTimer = null }
}
}, { immediate: true })
// 组件卸载清计时器防泄漏
onBeforeUnmount(() => {
if (approvingTimer) clearTimeout(approvingTimer)
if (_approveToastTimer) clearTimeout(_approveToastTimer)
})
/** 一次解析结果供模板多次读(模板引用 6 次,computed 避免每次 patch 重 parse) */
const parsed = computed(() => parseResult(props.tc.result))
/**
* 是否工具失败(completed 语义)。模板多处复用(.ai-tool-card--failed / 状态点 / 失败横幅等,
* 6+ 引用)。原每处直调 isToolFailure(tc) → 每次 patch 重新 parseResult + 正则,
* 现下沉为 computed 复用 parsed,patch 间不重算。逻辑与模块级 isToolFailure 同源。
*/
const isFailed = computed(() => {
const tc = props.tc
if (tc.status !== 'completed') return false
const r = parsed.value
if (tc.name === 'run_command') {
if (r && typeof r.exit_code === 'number') return r.exit_code !== 0
if (r) return true
}
if (!r) {
const raw = typeof tc.result === 'string' ? tc.result : ''
if (!raw) return false
return /^(执行失败|Error:|Failed:)/m.test(raw)
}
return false
})
/** run_command 展开态全量输出(stdout+stderr 合并);空串隐藏 toggle。复用 parsed 不重 parse */
const cmdOutput = computed(() => {
const r = parsed.value
if (!r) return ''
return combineOutputs(r.stdout, r.stderr)
})
/**
* 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)
})
/**
* UX-260618-06: patch_file 结果 diff 行拆解(复用 diffLines 同款 +add/-del/ ctx 着色)。
* 数据源不同:diffLines 取 props.tc.diff(审批事件注入,仅 write_file);此 computed 取
* parsed.diff(patch_file 工具执行结果字段)。两条路径互不干扰。
* 长 diff 截断到前 120 行防撑爆卡片(unified diff 含上下文行,大改可能很长)。
*/
const resultDiffLines = computed(() => {
const d = parsed.value?.diff
if (!d) return []
const MAX = 120
const lines = d.split('\n').map(line => {
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)
if (lines.length > MAX) {
lines.splice(MAX)
lines.push({ kind: 'ctx', text: '…' })
}
return lines
})
/**
* 按 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 查任务标题:审批 advance_task/run_workflow 的 task_id 时,把裸 UUID 转标题。
* 复用 project store 已加载的 state.tasks(仅当前项目范围),不新增网络请求。
* state.tasks 非「全量」(按当前项目/筛选加载),查不到时调用方走裸 id fallback——
* 审批卡 task_id 多属当前活动项目,命中率足够,跨项目任务降级显 id 可接受。
*/
function taskNameById(id: string): string | undefined {
return projectStore.tasks.find(t => t.id === id)?.title
}
/** 工具名 → 该工具中代表项目 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',
}
/**
* 工具名 → 该工具中代表任务 id 的参数 key(任务类工具特化)。
* advance_task 的任务 id 在 "id" key,run_workflow 在 "task_id" key。
*/
const TASK_ID_TOOL_ARG: Record<string, 'id' | 'task_id'> = {
advance_task: 'id',
run_workflow: 'task_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 })
}
}
// UX-260618-14:advance_task/run_workflow 的 task_id → 任务标题(store.tasks 查,无则标 id)
const taskArgKey = TASK_ID_TOOL_ARG[props.tc.name]
if (taskArgKey && arg.key === taskArgKey) {
const id = typeof arg.val === 'string' ? arg.val : ''
if (id) {
const name = taskNameById(id)
if (name) return t('aiTool.taskLabel', { name })
return t('aiTool.taskIdNotFound', { 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')
case 'run_command': {
// 折叠态头部也显命令(取首行+截断),无 command 回退通用名
const cmd = argString(tc.args, 'command')
return cmd ? `$ ${shortCmd(cmd)}` : formatToolName(tc.name)
}
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':
case 'list_projects':
case 'list_ideas':
case 'list_trash': {
// UX-260618-10/12: 后端返 {items,total,has_more} 对象(非数组),原 Array.isArray(r) 恒 false→折叠态无计数
const n = Array.isArray(r) ? r.length
: (typeof r.total === 'number' ? r.total
: (Array.isArray(r.items) ? r.items.length : 0))
if (!n) return ''
const key = tc.name === 'list_projects' ? 'projectCount'
: tc.name === 'list_tasks' ? 'taskCount'
: tc.name === 'list_ideas' ? 'ideaCount'
: 'trashCount'
return t(`aiTool.${key}`, { n })
}
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 typeof r.review_rounds === 'number' && r.review_rounds > 0
// UX-260618-14: 优先显 title(后端返 TaskRecord),无则回退 id,防裸 UUID
? t('aiTool.advancedTaskWithRounds', { title: r.title || r.id || '', status: r.status || '', n: r.review_rounds })
: t('aiTool.advancedTask', { title: r.title || 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 || '', backup_path: r.backup_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
? (r.cross_volume
? t('aiTool.renamedCrossVolume', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) })
: t('aiTool.renamedWithBytes', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) }))
: ''
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': {
// UX-260618-05: header 副标题显 execution_id 概要(原 workflowHint「请到工作流页面运行」误导——工作流已触发)
const execId = typeof r.execution_id === 'string' && r.execution_id
? r.execution_id
: (typeof r.id === 'string' ? r.id : '')
return execId ? t('aiTool.workflowTriggeredShort', { exec: execId }) : ''
}
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-260617-14:相对定位锚定本卡 toast(absolute 子元素) */
position: relative;
/* 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;
}
/* -- run_command 结果:命令行(常显) + 可折叠输出 -- */
.ai-tool-cmd-result {
margin: 0;
border-top: 0.5px solid var(--df-border);
font-family: var(--df-font-mono);
font-size: 11px;
}
.ai-tool-cmd-line {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: rgba(61,219,160,0.04);
}
.ai-tool-cmd-line--failed {
background: var(--df-danger-bg);
}
.ai-tool-cmd-prompt {
flex-shrink: 0;
font-weight: 600;
color: var(--df-success);
}
.ai-tool-cmd-line--failed .ai-tool-cmd-prompt {
color: var(--df-danger);
}
.ai-tool-cmd-text {
flex: 1;
min-width: 0;
white-space: pre-wrap;
word-break: break-all;
color: var(--df-text);
}
.ai-tool-cmd-toggle {
flex-shrink: 0;
padding: 2px 4px;
border: none;
border-radius: var(--df-radius-sm);
background: transparent;
color: var(--df-text-dim);
font-size: 10.5px;
cursor: pointer;
}
.ai-tool-cmd-toggle:hover {
color: var(--df-text);
background: var(--df-bg-hover, rgba(255,255,255,0.06));
}
.ai-tool-cmd-output {
margin: 0;
padding: 8px 10px;
max-height: 160px;
overflow-y: auto;
background: rgba(0,0,0,0.18);
color: var(--df-text-dim);
white-space: pre-wrap;
word-break: break-all;
border-top: 0.5px solid var(--df-border);
}
/* -- UX-260617-14: 审批 loading 超时兜底 toast(复用 AiChat 同款视觉 token) -- */
.ai-tool-toast {
position: absolute;
top: 6px;
left: 50%;
transform: translateX(-50%);
padding: 6px 12px;
border-radius: var(--df-radius-sm);
font-size: 11px;
z-index: 100;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
max-width: 90%;
overflow-wrap: anywhere;
pointer-events: none;
}
.ai-tool-toast--warning {
background: var(--df-warning-bg);
color: var(--df-warning);
border: 0.5px solid var(--df-warning);
}
.ai-tool-toast-enter-active, .ai-tool-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
.ai-tool-toast-enter-from, .ai-tool-toast-leave-to { opacity: 0; transform: translate(-50%, -6px); }
</style>