Files
DevFlow/src/components/ToolCard.vue
绝尘 2196c7748f 优化: P0 shell stdout修复+灵感路由+3工具新增+ai-md全局CSS+import路径统一
P0 修复:
- B-260615-37: shell.rs 补 Stdio::piped() 修复 run_command stdout/stderr 恒空
  (tokio 默认 inherit 导致 wait_with_output 读不到 pipe)

P1 功能:
- B-260615-36: 路由加 /ideas/:id + Ideas.vue 接 route.params.id + watch
  (修复项目详情点「来源灵感」跳空白页)
- F-260615-08: 新增 file_info 工具(元信息: exists/size/lines/binary/dir)
- F-260615-09: 新增 append_file 工具(追加写入, RiskLevel::Medium)
- F-260615-12: 新增 search_files 工具(文件名 glob 搜索,递归,限50条)
  + search_files_recursive 辅助函数 + i18n zh/en 3工具×2命名空间

DRY/重构:
- CR-260615-09: .ai-md 样式5份→全局 src/styles/ai-md.css(75行)
  AiChat.vue 删71行重复 + main.ts 引入 + typo修正(.a-md→.ai-md)
- 全量 import 路径统一为 @/ 别名(stores/composables/views ~28文件)
  vite.config.ts 加 resolve.alias.{ '@': '/src' }
- i18n 批量改进: store error fallback 11处中文→t() / ToolCard ARG_LABEL
  / useAiSend queue文案 / Dashboard 空态 / Ideas.vue null守卫
- cargo check 0 error / vue-tsc 0 error
2026-06-15 16:40:09 +08:00

774 lines
27 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) }]"
>
<!-- 卡片头部图标 + 名称 + 状态 -->
<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="toolResultSummary(tc)" class="ai-tool-sub">{{ toolResultSummary(tc) }}</span>
</div>
<span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + 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>
<div class="ai-tool-actions">
<button class="ai-tool-btn ai-tool-btn--approve" @click="emit('approve', { id: tc.id, approved: true })">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiTool.approve') }}
</button>
<button class="ai-tool-btn ai-tool-btn--reject" @click="emit('approve', { id: tc.id, approved: false })">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ $t('aiTool.reject') }}
</button>
</div>
</div>
<!-- 已拒绝提示 -->
<div v-if="tc.status === 'rejected'" class="ai-tool-rejected">
<span class="ai-tool-rejected-icon"></span>
{{ tc.result || $t('aiTool.rejectedHint') }}
</div>
<!-- read_file 结果代码预览 -->
<div v-if="tc.name === 'read_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-file-content">
<div class="ai-tool-file-bar">
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
<span class="ai-tool-file-meta">{{ parsed?.lines || 0 }} {{ $t('aiTool.lines') }} · {{ formatBytes(parsed?.size) }}</span>
<button class="ai-tool-expand-btn" @click="emit('expand-content', tc.id)">
{{ isContentExpanded ? $t('aiTool.collapse') : $t('aiTool.expandAll') }}
</button>
</div>
<pre class="ai-tool-file-pre" :class="{ 'ai-tool-file-pre--collapsed': !isContentExpanded }"><code>{{ parsed?.content }}</code></pre>
</div>
<!-- list_directory 结果文件树 -->
<div v-else-if="tc.name === 'list_directory' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
<div class="ai-tool-file-bar">
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
<span class="ai-tool-file-meta">{{ parsed?.entries?.length || 0 }} {{ $t('aiTool.items') }}</span>
</div>
<div class="ai-tool-dir-entries">
<div
v-for="(entry, i) in parsed?.entries"
:key="i"
class="ai-tool-dir-entry"
:class="'ai-tool-dir-entry--' + entry.type"
:style="{ paddingLeft: `${12 + (entry.depth || 0) * 16}px` }"
>
<span class="ai-tool-dir-icon" :class="entry.type === 'directory' ? 'ai-tool-dir-icon--folder' : 'ai-tool-dir-icon--file'" v-html="entry.type === 'directory' ? dirIcon : fileIcon"></span>
<span class="ai-tool-dir-name">{{ entry.name }}</span>
<span v-if="entry.type === 'file'" class="ai-tool-dir-size">{{ formatBytes(entry.size) }}</span>
</div>
</div>
</div>
<!-- write_file 结果 -->
<div v-else-if="tc.name === 'write_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-write-result">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--df-success)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<span class="ai-tool-write-path">{{ parsed?.path }}</span>
<span class="ai-tool-write-meta">{{ formatBytes(parsed?.bytes_written) }} {{ $t('aiTool.written') }}</span>
</div>
<!-- 通用 CRUD 结果 -->
<div v-else-if="tc.result && tc.status === 'completed'" class="ai-tool-result">
<code>{{ formatToolResult(tc) }}</code>
</div>
</div><!-- /.ai-tool-body -->
</div>
</template>
<script lang="ts">
import type { AiToolCallInfo } from '@/api/types'
/** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */
interface ToolResult {
path?: string
content?: string
lines?: number
size?: number
bytes_written?: number
entries?: Array<{ name: string; type: string; size?: number; depth?: number }>
name?: string
title?: string
field?: string
deleted?: boolean
restored?: boolean
purged?: 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'
}
function formatToolName(name: string): string {
if (!name) return ''
return name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function formatJson(data: unknown): string {
try {
return JSON.stringify(data, null, 2)
} catch {
return String(data)
}
}
/** 审批参数 key → 友好标签映射(静态,不查 store);未命中回退原 key */
const ARG_LABEL_MAP: Record<string, string> = {
id: 'ID',
project_id: '项目 ID',
name: '名称',
title: '标题',
description: '描述',
field: '字段',
value: '新值',
path: '路径',
priority: '优先级',
tags: '标签',
source: '来源',
}
/** 审批参数转键值对数组(非对象/空时返回空数组,防 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: ARG_LABEL_MAP[key] || 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 ''
}
/** 通用工具结果格式化(非文件类工具) */
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 `${raw}${detail}`
}
return raw
}
if (tc.name === 'write_file') {
return `${r.path} (${formatBytes(r.bytes_written)})`
}
return formatJson(r)
}
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useProjectStore } from '@/stores/project'
const { t } = useI18n()
const projectStore = useProjectStore()
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 }]
}>()
/** 一次解析结果供模板多次读(模板引用 6 次,computed 避免每次 patch 重 parse) */
const parsed = computed(() => parseResult(props.tc.result))
/**
* id → 项目名映射(覆盖活跃 + 回收站):审批 delete/restore/purge 各阶段都可能引用项目,
* 故同时查 projects 与 deletedProjects,无则返回 undefined(调用方走 fallback)。
* 复用 project store 已加载列表,不新增网络请求。
*/
const projectNameById = computed<Record<string, string>>(() => {
const map: Record<string, string> = {}
for (const p of projectStore.projects) map[p.id] = p.name
for (const p of projectStore.deletedProjects) if (!(p.id in map)) map[p.id] = p.name
return map
})
/** 工具名 → 该工具中代表项目 id 的参数 key(仅项目类工具特化;delete_task 的 id 是任务 id,不在此列) */
const PROJECT_ID_TOOL_ARG: Record<string, 'id' | 'project_id'> = {
delete_project: 'id',
restore_project: 'id',
purge_project: 'id',
update_project: 'id',
bind_directory: 'id',
create_task: 'project_id',
}
/**
* 审批参数值展示(AR-3):对项目类工具的 id/project_id 特化——优先回显项目名,查不到则显示
* 「项目已不存在」提示 + 原 id,避免用户只看到一串裸 id 不知道是什么数据。
* 非项目工具(如 delete_task 的 id)或其他 key 走通用 formatArgValue,避免误判。
*/
function displayArgValue(arg: { key: string; val: unknown }): string {
const projectArgKey = PROJECT_ID_TOOL_ARG[props.tc.name]
if (projectArgKey && arg.key === projectArgKey) {
const id = typeof arg.val === 'string' ? arg.val : ''
if (id) {
const name = projectNameById.value[id]
if (name) return t('aiTool.projectLabel', { name })
return t('aiTool.projectIdNotFound', { id })
}
}
return formatArgValue(arg.val)
}
/**
* 工具显示名称(含目标摘要);文件类按 path,项目类(delete/restore/purge/update_project)按 id
* 解析项目名(复用 projectNameById,无则走 fallback),create_* 用固定文案。
*/
function toolDisplayName(tc: AiToolCallInfo): string {
const p = argString(tc.args, 'path')
switch (tc.name) {
case 'read_file': return p ? `${t('aiTool.readPrefix')} ${shortPath(p)}` : t('aiTool.readFallback')
case 'list_directory': return p ? `${t('aiTool.dirPrefix')} ${shortPath(p)}` : t('aiTool.dirFallback')
case 'write_file': return p ? `${t('aiTool.writePrefix')} ${shortPath(p)}` : t('aiTool.writeFallback')
case 'delete_project':
case 'restore_project':
case 'purge_project':
case 'update_project': {
const idKey = PROJECT_ID_TOOL_ARG[tc.name]
const id = idKey ? argString(tc.args, idKey) : ''
const name = id ? projectNameById.value[id] : ''
const prefixKey = `${tc.name.replace('_project', '')}Prefix` as 'deletePrefix' | 'restorePrefix' | 'purgePrefix' | 'updatePrefix'
const fallbackKey = `${tc.name.replace('_project', '')}Fallback` as 'deleteFallback' | 'restoreFallback' | 'purgeFallback' | 'updateFallback'
return name ? `${t('aiTool.' + prefixKey)}${name}` : t('aiTool.' + fallbackKey)
}
case 'create_task': return t('aiTool.createTask')
case 'create_project': return t('aiTool.createProject')
default: return formatToolName(tc.name)
}
}
/** completed 工具结果的一句话摘要(折叠态 header 也能看到,避免光秃秃) */
function toolResultSummary(tc: AiToolCallInfo): string {
if (tc.status !== 'completed') return ''
const r = parseResult(tc.result)
if (!r) return ''
switch (tc.name) {
case 'list_tasks': return Array.isArray(r) ? t('aiTool.taskCount', { n: r.length }) : ''
case 'list_projects': return Array.isArray(r) ? t('aiTool.projectCount', { n: r.length }) : ''
case 'list_ideas': return Array.isArray(r) ? t('aiTool.ideaCount', { n: r.length }) : ''
case 'create_project': return r.name ? t('aiTool.createdWithName', { name: r.name }) : t('aiTool.created')
case 'create_idea':
case 'create_task': return r.title ? t('aiTool.createdWithName', { name: r.title }) : t('aiTool.created')
case 'update_project': return r.field ? t('aiTool.updatedField', { field: r.field }) : t('aiTool.updated')
case 'delete_project': return r.deleted ? t('aiTool.deleted') : t('aiTool.deleteIneffective')
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_workflow': return t('aiTool.workflowHint')
default: return ''
}
}
</script>
<style scoped>
.ai-tool-card {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
padding: 0;
overflow: hidden;
max-width: var(--df-msg-max-width);
animation: fadeInUp 0.2s var(--df-ease);
transition: border-color 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;
}
.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);
}
/* -- 审批按钮 -- */
.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);
}
/* -- File Content (read_file) -- */
.ai-tool-file-content {
border-top: 0.5px solid var(--df-border);
}
.ai-tool-file-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
background: color-mix(in srgb, var(--df-bg) 80%, transparent);
border-bottom: 0.5px solid var(--df-border);
}
.ai-tool-file-icon {
display: flex;
align-items: center;
color: var(--df-info);
flex-shrink: 0;
}
.ai-tool-file-icon--dir {
color: var(--df-warning);
}
.ai-tool-file-path {
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-accent);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-tool-file-meta {
font-family: var(--df-font-mono);
font-size: 9px;
color: var(--df-text-dim);
flex-shrink: 0;
}
.ai-tool-expand-btn {
font-family: var(--df-font-sans);
font-size: 10px;
color: var(--df-text-dim);
background: rgba(255,255,255,0.04);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
padding: 2px 8px;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s var(--df-ease);
}
.ai-tool-expand-btn:hover {
color: var(--df-text-secondary);
background: rgba(255,255,255,0.08);
border-color: var(--df-border-strong);
}
.ai-tool-file-pre {
margin: 0;
padding: 10px 12px;
background: color-mix(in srgb, var(--df-bg) 60%, transparent);
font-family: var(--df-font-mono);
font-size: 11px;
line-height: 1.6;
color: var(--df-text-secondary);
overflow-x: auto;
tab-size: 4;
}
.ai-tool-file-pre--collapsed {
max-height: 180px;
/* hidden→auto: 默认高度内可直接滚轮看完整内容,不必先点"展开全部" */
overflow-y: auto;
position: relative;
}
.ai-tool-file-pre--collapsed::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 48px;
background: linear-gradient(transparent, color-mix(in srgb, var(--df-bg) 90%, transparent));
pointer-events: none;
}
/* -- Directory List (list_directory) -- */
.ai-tool-dir-list {
border-top: 0.5px solid var(--df-border);
}
.ai-tool-dir-entries {
padding: 4px 0;
max-height: 220px;
overflow-y: auto;
}
.ai-tool-dir-entry {
display: flex;
align-items: center;
gap: 7px;
padding: 3px 12px;
font-size: 11px;
transition: background 0.1s;
}
.ai-tool-dir-entry:hover {
background: rgba(255,255,255,0.03);
}
.ai-tool-dir-entry--directory .ai-tool-dir-name {
font-weight: 500;
color: var(--df-text);
}
.ai-tool-dir-icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
.ai-tool-dir-icon--folder { color: var(--df-warning); }
.ai-tool-dir-icon--file { color: var(--df-text-dim); }
.ai-tool-dir-name {
font-family: var(--df-font-mono);
color: var(--df-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.ai-tool-dir-size {
font-family: var(--df-font-mono);
font-size: 9px;
color: var(--df-text-dim);
flex-shrink: 0;
opacity: 0.7;
}
/* -- Write File Result -- */
.ai-tool-write-result {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-top: 0.5px solid var(--df-border);
background: rgba(61,219,160,0.04);
}
.ai-tool-write-path {
font-family: var(--df-font-mono);
font-size: 11px;
color: var(--df-accent);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-tool-write-meta {
font-size: 10px;
color: var(--df-success);
flex-shrink: 0;
}
/* -- Generic Result -- */
.ai-tool-result {
margin: 0;
padding: 8px 10px;
background: rgba(61,219,160,0.04);
border-top: 0.5px solid var(--df-border);
border-radius: 0;
font-family: var(--df-font-mono);
font-size: 11px;
color: var(--df-success);
max-height: 100px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
</style>