- 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 + 模板联动防双击
1528 lines
60 KiB
Vue
1528 lines
60 KiB
Vue
<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>
|