Files
DevFlow/src/components/ToolCard.vue
T
lxy 25ec9cf69a 优化: 前端u-agent令牌对齐与组件拆分续批
- 全局样式类(df-sec-h/df-toast族)替换散装实现,主题token去硬编码兜底
- 拆分子组件:ai-chat/queue-panel(队列时钟与编辑随迁)、
  conversation-sidebar/ConversationList、settings/provider-panel
- 父组件死副本清理(队列编辑/时钟/键位表,TS6133清零)并补
  fill-draft父侧转发(重构半成品断链修复)
- 验收:vue-tsc EXIT 0
2026-10-18 23:15:57 +08:00

655 lines
31 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 },
]"
:data-risk="riskLevel"
>
<!-- 卡片头部:图标 + 名称 + 状态 -->
<div
class="ai-tool-header"
role="button"
tabindex="0"
:aria-expanded="isExpanded || shouldKeepOpen(tc)"
@click="emit('toggle', tc.id)"
@keydown.enter.prevent="emit('toggle', tc.id)"
@keydown.space.prevent="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 df-mono">{{ toolDisplayName(tc) }}</span>
<span v-if="tc.status === 'running'" class="ai-tool-sub df-badge df-badge--info ai-tool-chip-run">{{ $t('aiTool.running') }}</span>
<span v-else-if="isFailed" class="ai-tool-sub ai-tool-sub--failed df-badge df-badge--danger" :title="summaryTitle">{{ $t('aiTool.executionFailed') }}</span>
<span v-else-if="resultSummary" class="ai-tool-sub" :title="summaryTitle">{{ resultSummary }}</span>
</div>
<span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + (isFailed ? 'failed' : tc.status)" :title="statusTitle"></span>
<!-- 统一卡形态第4要素:耗时(前端 startedAt/completedAt 差;历史消息无时间戳隐藏不显 0)。
hover 显绝对起止时刻,相对时间不再占卡片行(B 聚合卡统一)。 -->
<span
v-if="durationText"
class="ai-tool-duration df-mono"
:title="durationTitle"
>{{ durationText }}</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' && !tc.streamLines?.length" 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>
<!-- 执行中实时流式输出(run_command shell 行 / ask_uclaw 进度块):
尾部最多 8 行(溢出折叠提示),Completed 后清空走 result 渲染 -->
<div v-if="tc.status === 'running' && tc.streamLines?.length" class="ai-tool-stream">
<div v-for="(ln, i) in tc.streamLines.slice(-8)" :key="tc.streamLines.length - 8 + i" class="ai-tool-stream-line">{{ ln }}</div>
<div v-if="tc.streamLines.length > 8" class="ai-tool-stream-more">{{ $t('aiTool.streamMore', { n: tc.streamLines.length - 8 }) }}</div>
</div>
<!-- 审批:参数内容 + 风险提示 + 按钮 -->
<div v-if="tc.status === 'pending_approval'" class="ai-tool-approval">
<!-- 风险徽章:High 橙红/Medium 黄,一眼识别操作等级(与左侧着色条同色系)
wave2:迁 .df-badge 透明彩底三件套(danger/warning 变体) -->
<div v-if="riskLevel !== 'low'" class="ai-tool-risk-badge df-badge" :class="riskLevel === 'high' ? 'df-badge--danger' : 'df-badge--warning'">
{{ riskLevel === 'high' ? $t('aiTool.riskHigh') : $t('aiTool.riskMedium') }}
</div>
<div v-if="visibleArgs.length" class="ai-tool-approval-args">
<div v-for="arg in visibleArgs" :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>
<!-- args 折叠:多参数默认仅显首条,「更多 N 项」展开(治超长 content/task 刷屏) -->
<button v-if="argsCount > 1" class="ai-tool-args-toggle" @click="argsExpanded = !argsExpanded">
{{ argsExpanded ? $t('aiTool.argsCollapse') : $t('aiTool.argsMore', { n: argsCount - 1 }) }}
</button>
</div>
<div v-if="tc.reason" class="ai-tool-approval-reason"><span aria-hidden="true">⚠</span> {{ tc.reason }}</div>
<!-- BUG-260624-02: 审批到期倒计时(与前端自动拒绝时刻精确对齐,≤60s 红色脉冲);
无活跃计时器(不限时/已到期)回退显挂起时长(>2分钟橙色,>5分钟红色) -->
<div v-if="expireSecs !== null" class="ai-tool-wait-time" :class="'is-' + expireLevel">
{{ $t('aiTool.expireIn', { time: formatWaitTime(expireSecs) }) }}
</div>
<div v-else-if="waitSecs > 0" class="ai-tool-wait-time" :class="'is-' + waitLevel">
<span aria-hidden="true">⏱</span> {{ $t('aiTool.waitTime', { time: formatWaitTime(waitSecs) }) }}
</div>
<!-- AE-2025-03: write_file 审批 diff 预览(红删绿增,行级)。旧文件不存在→tc.diff 为空,回退显上方 args content -->
<div v-if="tc.diff" class="ai-tool-approval-diff">
<pre class="df-diff-pre"><code><span v-for="(ln, idx) in diffLines" :key="idx" class="df-diff-line" :class="'df-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
</div>
<!-- path_auth 审批链阶段3b:按 tc.kind 分两组按钮。
- kind='path'(路径授权挂起):once/session/always/deny 四选项,调 authorizeDir
(与 DirAuthDialog.vue 同款四按钮;IPC authorizeDir 后端 once/session/always/deny 全支持)。
- 其余(risk/缺省,普通 RiskLevel 审批):approve/reject 两选项,调 approve。 -->
<div v-if="tc.kind === 'path'" class="ai-tool-actions ai-tool-actions--path">
<button class="ai-tool-btn ai-tool-btn--approve"
:disabled="approving"
@click="onAuthorizeOnce">
<span v-if="approving" class="ai-tool-btn-spinner" />
<span v-else class="ai-tool-btn-icon" v-html="ICON_CHECK" />
{{ $t('aiChat.dirAuthOnce') }}
</button>
<button class="ai-tool-btn ai-tool-btn--session"
:disabled="approving"
@click="onAuthorizeSession">
<span v-if="approving" class="ai-tool-btn-spinner" />
<span v-else class="ai-tool-btn-icon" v-html="ICON_CHECK" />
{{ $t('aiChat.dirAuthSession') }}
</button>
<button class="ai-tool-btn ai-tool-btn--always"
:disabled="approving"
@click="onAuthorizeAlways">
<span v-if="approving" class="ai-tool-btn-spinner" />
<span v-else class="ai-tool-btn-icon" v-html="ICON_CHECK" />
{{ $t('aiChat.dirAuthAlways') }}
</button>
<button class="ai-tool-btn ai-tool-btn--reject"
:disabled="approving"
@click="onDeny">
<span v-if="approving" class="ai-tool-btn-spinner" />
<span v-else class="ai-tool-btn-icon" v-html="ICON_CROSS" />
{{ $t('aiChat.dirAuthDeny') }}
</button>
</div>
<div v-else class="ai-tool-actions">
<button class="ai-tool-btn ai-tool-btn--approve"
:disabled="approving"
@click="onApprove">
<span v-if="approving" class="ai-tool-btn-spinner" />
<span v-else class="ai-tool-btn-icon" v-html="ICON_CHECK" />
{{ $t('aiTool.approve') }}
</button>
<button class="ai-tool-btn ai-tool-btn--reject"
:disabled="approving"
@click="onReject">
<span v-if="approving" class="ai-tool-btn-spinner" />
<span v-else class="ai-tool-btn-icon" v-html="ICON_CROSS" />
{{ $t('aiTool.reject') }}
</button>
</div>
</div>
<!-- 已拒绝提示 -->
<div v-if="tc.status === 'rejected'" class="ai-tool-rejected">
<span class="ai-tool-rejected-icon" aria-hidden="true">✕</span>
{{ tc.result || $t('aiTool.rejectedHint') }}
</div>
<!-- D2a 外置结果(句柄 JSON):显「结果已外置」+ 展开;读回原文后复用 ToolResultBody 渲染 -->
<div v-if="tc.status === 'completed' && offloadHandle" class="ai-tool-offloaded">
<OffloadedResultBar
:handle="offloadHandle"
:expanded="offloadExpanded"
:loading="offloadLoading"
:error="offloadError"
@toggle="toggleOffload"
/>
<ToolResultBody
v-if="offloadExpanded && offloadResolved != null"
:tc="resolvedOffloadTc"
:isContentExpanded="isContentExpanded"
@expand-content="(id) => emit('expand-content', id)"
@install-obscura="() => emit('install-obscura')"
/>
</div>
<!-- 各工具结果展示(read_file/list_directory/search_files/write_file/patch_file/run_command/http_request/fetch_url/通用)
委托 ToolResultBody 子组件,透传 tc + 内容展开态 + expand-content/install-obscura 冒泡 -->
<ToolResultBody
v-else-if="tc.status === 'completed'"
:tc="tc"
:isContentExpanded="isContentExpanded"
@expand-content="(id) => emit('expand-content', id)"
@install-obscura="() => emit('install-obscura')"
/>
</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"
/>
<!-- 审批 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 setup lang="ts">
import { computed, ref, toRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
shouldKeepOpen,
parseResult,
toolCategory,
toolIcon,
toolResultSummary,
argString,
} from '@/composables/ai/useToolCard'
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
import { useOffloadedResult } from '@/composables/ai/useOffloadedResult'
import { useToolCardHeader } from '@/composables/ai/useToolCardHeader'
import { useToolApproval, toolRiskLevel } from '@/composables/ai/useToolApproval'
import { useTimerOwnership } from '@/composables/useTimerOwnership'
import { callDurationText } from '@/composables/ai/useTurnTrace'
import { formatDate, formatDuration } from '@/utils/time'
import type { AiToolCallInfo } from '@/api/types'
import ConfirmDialog from './ConfirmDialog.vue'
import ToolResultBody from './ToolResultBody.vue'
import OffloadedResultBar from './OffloadedResultBar.vue'
// 审批按钮图标 SVG 常量(消除模板 6 处内联重复;v-html 渲染完整 svg 元素)
const ICON_CHECK = '<svg aria-hidden="true" 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>'
const ICON_CROSS = '<svg aria-hidden="true" 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>'
const { t } = useI18n()
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]
/**
* fetch_url static_fallback(obscura 未装)时"AI 帮装"按钮 → 透传父级 → MessageList
* 调 store.sendMessage 触发 AI 装流式。仅 fetch_url 工具会发此事件。
*/
'install-obscura': []
/**
* 审批按钮 → 父级转发 store.approveToolCall。
* path_auth 审批链阶段3b:risk 类带 approved boolean;path 类带 decision('once'|'session'|'always'|'deny')。
* 调用方(store.approveToolCall)按是否有 decision 分派 approve / authorizeDir。
*/
approve: [{ id: string; approved: boolean; decision?: 'once' | 'session' | 'always' | 'deny' }]
}>()
// 头部显示逻辑(工具名/审批参数语义化回显)抽离至 composable,传入 tc getter 保持响应式。
const { toolDisplayName, displayArgValue, argsEntries } = useToolCardHeader(() => props.tc)
// 审批状态机(按钮 loading/挂起时长/toast/approve+reject+path_auth 三选项)抽离至 composable。
const {
approving,
approveToast,
waitSecs,
waitLevel,
formatWaitTime,
expireSecs,
expireLevel,
onApprove,
onReject,
onAuthorizeOnce,
onAuthorizeSession,
onAuthorizeAlways,
onDeny,
confirmState,
answerConfirm,
} = useToolApproval(toRef(props, 'tc'), emit)
/** 一次解析结果供失败判定复用(头部 isFailed 用于卡片 border/status-dot 着色) */
const parsed = computed(() => parseResult(props.tc.result))
/**
* D2a 外置句柄:completed 结果若是句柄 JSON(超 8KB 落盘、DB 只留句柄),
* 头部摘要与结果区改走「结果已外置」+ 展开读回,不把句柄当普通结果解析渲染。
*/
const {
handle: offloadHandle,
expanded: offloadExpanded,
loading: offloadLoading,
error: offloadError,
resolved: offloadResolved,
toggle: toggleOffload,
} = useOffloadedResult(() => (props.tc.status === 'completed' ? props.tc.result : null))
/** 读回后把原文塞回 tc.result,交 ToolResultBody 按工具名走既有渲染分支(零重复渲染逻辑) */
const resolvedOffloadTc = computed(() => ({ ...props.tc, result: offloadResolved.value ?? props.tc.result }))
/** 头部副标题:外置句柄显「结果已外置」,其余走既有摘要函数 */
const resultSummary = computed(() =>
offloadHandle.value ? t('aiTool.resultOffloaded') : toolResultSummary(props.tc),
)
/**
* 是否工具失败(completed 语义)。头部多处复用(.ai-tool-card--failed / 状态点 / sub 文案)。
* 逻辑与模块级 isToolFailure 同源;结果展示区的 isFailed 已迁入 ToolResultBody(各自 computed)。
*/
const isFailed = computed(() => {
const tc = props.tc
if (tc.status !== 'completed') return false
// 外置句柄是「结果留了指针」非执行失败,不参与失败判定
if (offloadHandle.value) 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
}
// git 只读工具失败返 {success:false,error}(结构化非 Err),通用分支识别
if (r && r.success === false) return true
if (!r) {
const raw = typeof tc.result === 'string' ? tc.result : ''
if (!raw) return false
return /^(执行失败|Error:|Failed:)/m.test(raw)
}
return false
})
/** write_file 审批 diff 行解析(复用 parseDiffLines 纯函数,数据源 props.tc.diff) */
const diffLines = computed(() => parseDiffLines(props.tc.diff))
// 执行中实时走表:进入 running 每秒刷新 nowTick,离开时清理(定时器经 useTimerOwnership
// 注册,onUnmounted 自动全清,防组件卸载后计时器仍触发)
const { setOwnInterval, clearOwnInterval } = useTimerOwnership()
const nowTick = ref(Date.now())
let runTimerId: ReturnType<typeof setInterval> | null = null
watch(() => props.tc.status, (s) => {
if (s === 'running') {
nowTick.value = Date.now()
if (runTimerId === null) {
runTimerId = setOwnInterval(() => { nowTick.value = Date.now() }, 1000)
}
} else if (runTimerId !== null) {
clearOwnInterval(runTimerId)
runTimerId = null
}
}, { immediate: true })
/** 单卡耗时(前端时间差;历史消息无 startedAt/completedAt 返空串隐藏);
* running 态以 nowTick 实时走表(需 startedAt,与完成态共用同一时间差口径) */
const durationText = computed(() => {
const tc = props.tc
if (tc.status === 'running' && tc.startedAt != null) {
return formatDuration(nowTick.value - tc.startedAt, true)
}
return callDurationText(tc.startedAt, tc.completedAt)
})
/** hover 提示:后端实测毫秒 + 绝对起止时刻(run_command 取 duration_ms、http_request 取
* elapsed_ms,与前端时间差口径互证;无后端耗时的工具只显起止时刻) */
const durationTitle = computed(() => {
const tc = props.tc
if (tc.startedAt == null || tc.completedAt == null) return ''
const r = parsed.value
const backendMs = tc.name === 'run_command'
? (typeof r?.duration_ms === 'number' ? r.duration_ms : null)
: (tc.name === 'http_request' && typeof r?.elapsed_ms === 'number' ? r.elapsed_ms : null)
const span = `${formatDate(tc.startedAt)} ~ ${formatDate(tc.completedAt)}`
return backendMs != null ? `${backendMs}ms · ${span}` : span
})
/** 状态点 tooltip:成功/失败/进行中/待审批/已拒绝(绿红蓝点 hover 可读,不再靠颜色猜) */
const statusTitle = computed(() => {
const s = props.tc.status
if (s === 'running') return t('aiTool.statusRunning')
if (s === 'pending_approval') return t('aiTool.statusPending')
if (s === 'rejected') return t('aiTool.statusRejected')
if (s === 'completed') return isFailed.value ? t('aiTool.statusFailed') : t('aiTool.statusOk')
return ''
})
/** 头部摘要 hover 回看关键参数全值(看板 945,按工具类型取参):
* run_command→command(命令行是唯一关键参数);其余 path 优先(覆盖 read_file/grep/
* search_files 等路径类),path 缺省回退首个非空字符串标量参数(hover 总有内容可看)。
* 摘要统一缩略尾段,全值走 title。 */
const summaryTitle = computed(() => {
const args = props.tc.args
if (props.tc.name === 'run_command') return argString(args, 'command')
const byPath = argString(args, 'path')
if (byPath) return byPath
if (args && typeof args === 'object' && !Array.isArray(args)) {
for (const v of Object.values(args as Record<string, unknown>)) {
if (typeof v === 'string' && v) return v
}
}
return ''
})
/**
* 风险等级(high/medium/low):审批挂起时卡片左侧着色条 + 风险徽章数据源。
* 复用 useToolApproval 注册表投影(权威源=后端 risk_level,启动期 loadToolRisks 同步),
* 仅 pending_approval 态展示——执行中/完成后风险条无操作意义,不抢视觉焦点。
*/
const riskLevel = computed(() =>
props.tc.status === 'pending_approval' ? toolRiskLevel(props.tc.name) : 'low',
)
/** 审批参数折叠态:默认收起仅显首条(args 通常含超长 content/task),点「更多」展开全部 */
const argsExpanded = ref(false)
/** args 条数(供「更多 N 项」文案;无参数时 0) */
const argsCount = computed(() => argsEntries().length)
/** 折叠态展示条数:展开显全部;收起显首条(最重要的参数,如 path/command) */
const visibleArgs = computed(() => {
const all = argsEntries()
return argsExpanded.value ? all : all.slice(0, 1)
})
</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);
/* 相对定位锚定本卡 toast(absolute 子元素) */
position: relative;
/* border/shadow/opacity 过渡让折叠/失败态切换不突兀。
不做 body 高度动画(v-show display 切换 + flex gap 会留双倍间距致布局跳变,见 ToolCardList 注释)。 */
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: var(--df-warning-bd); 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; }
/* 工具失败态——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-rejected {
display: flex; align-items: center; gap: 6px;
padding: 4px 10px 8px var(--df-icon-gutter); font-size: 11px; color: var(--df-danger);
}
.ai-tool-rejected-icon { font-weight: 500; }
/* wave2:折叠轨迹卡的展开态左边 2px 强调边(对齐 workbench cv.on 范式);
只作用于可折叠卡(completed),pending 的 data-risk 左缘与 running 强制展开态不参与 */
.ai-tool-card--completed:not(.ai-tool-card--collapsed) { border-left: 2px solid var(--df-info); }
/* -- 卡片折叠态(running/pending 始终展开,completed/rejected 默认折叠为单行 header) -- */
.ai-tool-card--collapsed .ai-tool-header { cursor: pointer; }
.ai-tool-card--collapsed .ai-tool-header:hover { background: var(--df-bg-card-hover); }
.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: var(--df-info-bg); 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: var(--df-warning-bg); 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;
}
/* wave2:工具名迁 mono 元数据(df-mono 接管字体,此处 10px dim 收敛视觉) */
.ai-tool-name {
font-size: 10px; font-weight: 500; color: var(--df-text-dim);
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;
}
/* wave2:状态 chip 迁 .df-badge(running=info+脉冲点 / failed=danger);
scoped 双类压过 .ai-tool-sub 灰字,色板与 df-badge 变体对齐(token 化) */
.ai-tool-sub.df-badge {
font-size: 10px;
padding: 1px 6px;
line-height: 16px;
}
.ai-tool-sub.df-badge--info {
background: var(--df-info-bg);
color: var(--df-info);
border-color: color-mix(in srgb, var(--df-info) 30%, transparent);
}
.ai-tool-sub.df-badge--danger {
background: var(--df-danger-bg);
color: var(--df-danger);
border-color: color-mix(in srgb, var(--df-danger) 30%, transparent);
}
/* running chip 内嵌脉冲点(全局 df-pulse 节奏,对齐 df-dot--run 活性语义) */
.ai-tool-chip-run::before {
content: '';
width: 5px; height: 5px; border-radius: 50%;
background: var(--df-info);
flex-shrink: 0;
animation: df-pulse 1.5s infinite;
}
/* -- Status dot (脉冲动画) -- */
.ai-tool-status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
/* -- 卡片行耗时(统一卡形态第4要素:弱化等宽小字,不与工具名/摘要争焦点) -- */
.ai-tool-duration {
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
opacity: 0.75;
flex-shrink: 0;
cursor: default;
user-select: none;
}
.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 var(--df-icon-gutter); 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: df-shimmer 1.5s infinite;
}
/* -- 实时流式输出(running 态:run_command 行 / ask_uclaw 进度块) -- */
.ai-tool-stream {
padding: 4px 10px 10px var(--df-icon-gutter);
display: flex; flex-direction: column; gap: 2px;
max-height: 160px; overflow-y: auto;
}
.ai-tool-stream-line {
font-family: var(--df-font-mono, monospace);
font-size: 11px; line-height: 1.5;
color: var(--df-text-secondary);
white-space: pre-wrap; word-break: break-all;
}
.ai-tool-stream-more {
font-size: 10px; color: var(--df-text-dim);
padding-top: 2px; font-style: italic;
}
/* -- 审批参数展示 -- */
.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);
}
.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); }
.ai-tool-approval-reason {
padding: 4px 10px 6px; margin-bottom: 6px; font-size: 11px;
color: var(--df-warning); background: var(--df-warning-bg);
border-radius: var(--df-radius-sm);
}
/* 审批挂起时长提示(>2分钟橙色,>5分钟红色) */
.ai-tool-wait-time {
padding: 3px 10px; margin-bottom: 6px; font-size: 11px;
font-family: var(--df-font-mono);
color: var(--df-text-dim);
border-radius: var(--df-radius-sm);
}
.ai-tool-wait-time.is-long {
color: var(--df-warning);
animation: pulseGlow 1.5s ease-in-out infinite;
}
.ai-tool-wait-time.is-urgent {
color: var(--df-danger);
animation: pulseGlow 1s ease-in-out infinite;
}
/* write_file 审批 diff 预览区(红删绿增行级);
基类 .df-diff-pre(+code)/.df-diff-line 消费 global.css 共享定义,此处仅留行配色变体 */
.ai-tool-approval-diff { margin: 0 10px 8px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm, 4px); overflow: hidden; }
.df-diff-line--add { color: var(--df-success); background: var(--df-success-bg); }
.df-diff-line--del { color: var(--df-danger); background: var(--df-danger-bg); }
.df-diff-line--ctx { color: var(--df-text-dim); }
/* -- 审批按钮 -- */
.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: background 0.15s var(--df-ease), color 0.15s var(--df-ease), border-color 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); }
/* M4:session 按钮(accent 描边态,介于 approve 弱背景与 always 强 warning 之间;对齐 DirAuthDialog session 语义) */
.ai-tool-btn--session {
background: var(--df-accent-bg);
color: var(--df-accent);
border-color: color-mix(in srgb, var(--df-accent) 40%, transparent);
}
.ai-tool-btn--session:hover { background: var(--df-accent); color: #fff; border-color: var(--df-accent); }
/* path_auth 审批链阶段3b:always 按钮(warning 色调,与 DirAuthDialog.vue 同款语义) */
.ai-tool-btn--always { background: var(--df-warning-bg); color: var(--df-warning); }
.ai-tool-btn--always:hover { background: var(--df-warning); color: #000; border-color: var(--df-warning); }
.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禁用两按钮防重复点击,spinner 转在原位 */
.ai-tool-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.ai-tool-btn--approve:disabled:hover { 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); border-color: var(--df-border); }
.ai-tool-btn-icon {
display: inline-flex; align-items: center; justify-content: center;
width: 12px; height: 12px; flex-shrink: 0;
}
.ai-tool-btn-icon :deep(svg) { display: block; }
.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: df-spin 0.7s linear infinite;
}
/* -- 审批 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: var(--df-shadow-sm); 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); }
/* -- 风险着色条(审批挂起时卡片左缘 2px 竖条,High 橙红/Medium 黄;data-risk 属性驱动) -- */
.ai-tool-card[data-risk='high'] { border-left: 2px solid var(--df-danger); }
.ai-tool-card[data-risk='medium'] { border-left: 2px solid var(--df-warning); }
/* -- 风险徽章(审批区首行):wave2 迁 .df-badge 透明彩底(danger/warning 变体接管配色),
此处只留布局(独立成行+外边距) -- */
.ai-tool-risk-badge {
align-self: flex-start;
margin: 6px 10px 0;
font-weight: 600; letter-spacing: 0.5px;
}
/* 审批区改纵向 flex(徽章需独立成行,原块级布局不变) */
.ai-tool-approval { display: flex; flex-direction: column; }
.ai-tool-approval-args { margin-bottom: 0; }
/* -- args 折叠切换按钮(「更多 N 项」/「收起」;弱化灰字,hover 变主文字色) -- */
.ai-tool-args-toggle {
display: block; width: 100%; text-align: center;
padding: 3px 0; margin-top: 2px;
font-size: 10px; color: var(--df-text-dim);
background: none; border: none; border-top: 0.5px dashed var(--df-border);
cursor: pointer; transition: color 0.15s var(--df-ease);
font-family: var(--df-font-sans);
}
.ai-tool-args-toggle:hover { color: var(--df-text); }
</style>