重构: ToolCard.vue 拆分(1167→374 行,REFACTOR-260619-04)
纯结构拆分,逻辑等价,渲染/交互/接口契约不变: - 抽 ToolResultBody.vue(545 行,8 工具结果渲染分支:read_file/list_directory/ search_files/write_file/patch_file/run_command/http_request/通用 CRUD) - 抽 useToolCardRender.ts(45 行,parseDiffLines 渲染纯函数) - 抽 useToolCardHeader.ts(154 行,toolDisplayName/displayArgValue/projectNameById 头部显示) - ToolCard.vue 瘦身至 374 行(容器+头部+审批区+状态机,结果展示委托 ToolResultBody) - props/emits 契约不变(ToolCardList/AiChat/MessageList 调用零改动) vue-tsc EXIT 0 + 8/8 渲染分支完整核对
This commit is contained in:
File diff suppressed because it is too large
Load Diff
545
src/components/ToolResultBody.vue
Normal file
545
src/components/ToolResultBody.vue
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 结果展示区(从 ToolCard.vue 抽离,纯渲染,逻辑等价)。
|
||||||
|
分支顺序/条件/文案与原 ToolCard.vue 一字不变,仅搬运行内联模板。 -->
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- http_request 结果:method + url + 状态码徽标 + 可折叠响应 body(对齐 run_command 模式 UX-260618-08) -->
|
||||||
|
<div
|
||||||
|
v-else-if="tc.name === 'http_request' && tc.result && tc.status === 'completed' && parsed"
|
||||||
|
class="ai-tool-http-result"
|
||||||
|
>
|
||||||
|
<div class="ai-tool-http-line" :class="{ 'ai-tool-http-line--failed': isFailed }">
|
||||||
|
<span class="ai-tool-http-method">{{ parsed?.method || 'GET' }}</span>
|
||||||
|
<code class="ai-tool-http-url">{{ parsed?.url }}</code>
|
||||||
|
<span class="ai-tool-http-status" :class="{ 'ai-tool-http-status--ok': !isFailed }">{{ parsed?.status }}</span>
|
||||||
|
<button
|
||||||
|
v-if="parsed?.body"
|
||||||
|
class="ai-tool-cmd-toggle"
|
||||||
|
:title="httpBodyExpanded ? $t('aiTool.collapse') : $t('aiTool.expand')"
|
||||||
|
@click="httpBodyExpanded = !httpBodyExpanded"
|
||||||
|
>{{ httpBodyExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="parsed?.truncated && !httpBodyExpanded" class="ai-tool-http-trunc-warn">
|
||||||
|
⚠ {{ $t('aiTool.httpTruncated', { bytes: formatBytes(parsed?.body_bytes) }) }}
|
||||||
|
</div>
|
||||||
|
<pre v-if="httpBodyExpanded && parsed?.body" class="ai-tool-cmd-output"><code>{{ parsed.body }}</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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
isToolFailure,
|
||||||
|
parseResult,
|
||||||
|
formatBytes,
|
||||||
|
argString,
|
||||||
|
formatSizeDiff,
|
||||||
|
combineOutputs,
|
||||||
|
formatToolResult,
|
||||||
|
dirIcon,
|
||||||
|
fileIcon,
|
||||||
|
} from '@/composables/ai/useToolCard'
|
||||||
|
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
|
||||||
|
import type { AiToolCallInfo } from '@/api/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 单条工具调用数据(结果展示只读消费) */
|
||||||
|
tc: AiToolCallInfo
|
||||||
|
/** read_file 内容级展开(来自父级 ToolCard,原样透传) */
|
||||||
|
isContentExpanded: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/** read_file "展开全部/收起" → 透传父级(由 ToolCard 继续冒泡) */
|
||||||
|
'expand-content': [id: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// run_command 输出折叠态:失败命令默认展开(便于看 stderr),成功默认折叠
|
||||||
|
const cmdOutputExpanded = ref(false)
|
||||||
|
// http_request 响应 body 折叠态:失败(非 2xx)默认展开便于看错误,成功默认折叠
|
||||||
|
const httpBodyExpanded = ref(false)
|
||||||
|
|
||||||
|
/** 一次解析结果供模板多次读(模板引用多次,computed 避免每次 patch 重 parse) */
|
||||||
|
const parsed = computed(() => parseResult(props.tc.result))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否工具失败(completed 语义)。复用 parsed 不重 parse。逻辑与模块级 isToolFailure 同源,
|
||||||
|
* 但因 run_command 失败判定依赖 parsed.exit_code(本组件已 computed),此处就近复用避免重复 parse。
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UX-260618-06: patch_file 结果 diff 行拆解(复用 parseDiffLines +add/-del/ ctx 着色)。
|
||||||
|
* 数据源:parsed.diff(patch_file 工具执行结果字段)。长 diff 截断到前 120 行防撑爆卡片。
|
||||||
|
*/
|
||||||
|
const resultDiffLines = computed(() => parseDiffLines(parsed.value?.diff, 120))
|
||||||
|
|
||||||
|
// SW-260618-06: status 切到 completed 时按成功/失败初始化折叠态(失败默认展开便于看 stderr/错误 body)。
|
||||||
|
// immediate 时若初始非 completed,两 ref 保持默认 false,无副作用。
|
||||||
|
watch(() => props.tc.status, (s) => {
|
||||||
|
if (s === 'completed') {
|
||||||
|
cmdOutputExpanded.value = isToolFailure(props.tc)
|
||||||
|
httpBodyExpanded.value = isToolFailure(props.tc)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 结果展示区样式(从 ToolCard.vue 迁移,仅含结果分支用到的 class;卡片容器/header/审批/骨架
|
||||||
|
等样式留 ToolCard.vue)。失败横幅/通用结果失败态样式一并迁移,因结果区独立判定 isFailed。 */
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- 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 / search_files) -- */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- patch_file diff 预览(复用审批 diff 同款 token) -- */
|
||||||
|
.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-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); }
|
||||||
|
|
||||||
|
/* -- 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- http_request 结果:method 徽标 + url + 状态码(复用 cmd-line 视觉 token) -- */
|
||||||
|
.ai-tool-http-result {
|
||||||
|
margin: 0;
|
||||||
|
border-top: 0.5px solid var(--df-border);
|
||||||
|
font-family: var(--df-font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.ai-tool-http-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(61,219,160,0.04); /* 成功默认(对齐 cmd-line);失败由 --failed 覆盖 */
|
||||||
|
}
|
||||||
|
.ai-tool-http-line--failed {
|
||||||
|
background: var(--df-danger-bg);
|
||||||
|
}
|
||||||
|
.ai-tool-http-method {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
color: var(--df-text);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.ai-tool-http-url {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
}
|
||||||
|
.ai-tool-http-status {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--df-danger);
|
||||||
|
}
|
||||||
|
.ai-tool-http-status--ok {
|
||||||
|
color: var(--df-success);
|
||||||
|
}
|
||||||
|
.ai-tool-http-trunc-warn {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--df-warning, #f0c75e);
|
||||||
|
border-top: 0.5px solid var(--df-border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
154
src/composables/ai/useToolCardHeader.ts
Normal file
154
src/composables/ai/useToolCardHeader.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* ToolCard 头部显示逻辑(从 ToolCard.vue 抽离)。
|
||||||
|
*
|
||||||
|
* 职责:工具显示名 + 审批参数值的「语义化回显」(裸 id → 项目名/任务标题),
|
||||||
|
* 以及 http url → host 精简。这些依赖 project store(响应式),故以 composable 形式封装,
|
||||||
|
* 传入 tc 即得 { toolDisplayName, displayArgValue }。
|
||||||
|
*
|
||||||
|
* 不含响应式状态(纯函数 + store 读),逻辑与原 ToolCard.vue 内联实现一字等价。
|
||||||
|
*/
|
||||||
|
import { useProjectStore } from '@/stores/project'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import {
|
||||||
|
formatToolName,
|
||||||
|
shortCmd,
|
||||||
|
toolArgsEntries,
|
||||||
|
formatArgValue,
|
||||||
|
shortPath,
|
||||||
|
argString,
|
||||||
|
} from '@/composables/ai/useToolCard'
|
||||||
|
import type { AiToolCallInfo } from '@/api/types'
|
||||||
|
|
||||||
|
/** 工具名 → 该工具中代表项目 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',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ToolCard 头部/审批参数显示辅助。
|
||||||
|
* @param getTc 取当前 tc 的函数(避免 props.tc 在 composable 调用时机脱节,由调用方透传)
|
||||||
|
*/
|
||||||
|
export function useToolCardHeader(getTc: () => AiToolCallInfo) {
|
||||||
|
const t = useI18n().t
|
||||||
|
const projectStore = useProjectStore()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 id 查项目名(覆盖活跃 + 回收站):审批 delete/restore/purge 各阶段都可能引用项目,
|
||||||
|
* 故先查 projects 再查 deletedProjects,找到即返回(提前退出),无则返回 undefined(调用方走 fallback)。
|
||||||
|
* 复用 project store 已加载列表,不新增网络请求;响应式由 store 数组保证。
|
||||||
|
*/
|
||||||
|
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(仅当前项目范围),不新增网络请求。
|
||||||
|
*/
|
||||||
|
function taskNameById(id: string): string | undefined {
|
||||||
|
return projectStore.tasks.find(tk => tk.id === id)?.title
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批参数值展示(AR-3):对项目类工具的 id/project_id 特化——优先回显项目名,查不到则显示
|
||||||
|
* 「项目已不存在」提示 + 原 id。非项目工具或其他 key 走通用 formatArgValue。
|
||||||
|
*/
|
||||||
|
function displayArgValue(arg: { key: string; val: unknown }): string {
|
||||||
|
const tc = getTc()
|
||||||
|
const projectArgKey = PROJECT_ID_TOOL_ARG[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 → 任务标题
|
||||||
|
const taskArgKey = TASK_ID_TOOL_ARG[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,项目类按 id 解析项目名,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': {
|
||||||
|
const cmd = argString(tc.args, 'command')
|
||||||
|
return cmd ? `$ ${shortCmd(cmd)}` : formatToolName(tc.name)
|
||||||
|
}
|
||||||
|
case 'http_request': {
|
||||||
|
const method = argString(tc.args, 'method') || 'GET'
|
||||||
|
const url = argString(tc.args, 'url')
|
||||||
|
const host = httpHost(url)
|
||||||
|
return host ? `${method} ${host}` : t('aiTool.httpRequestFallback')
|
||||||
|
}
|
||||||
|
default: return formatToolName(tc.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审批参数键值对(头部 toolArgsEntries 透传,本 composable 仅暴露便于复用) */
|
||||||
|
function argsEntries() {
|
||||||
|
return toolArgsEntries(getTc().args)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { toolDisplayName, displayArgValue, argsEntries }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* http_request url → host 显示用(折叠态头部精简,不全显 url)。
|
||||||
|
* 取 scheme://host[:port],剥 path/query;非 http(s) url 返回空串回退通用名。
|
||||||
|
* 注:用 URL 构造器容错,非法 url 抛错时回退原 url 截断。
|
||||||
|
*/
|
||||||
|
export function httpHost(raw: string): string {
|
||||||
|
if (!raw) return ''
|
||||||
|
try {
|
||||||
|
const u = new URL(raw)
|
||||||
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') return ''
|
||||||
|
const portPart = u.port ? `:${u.port}` : ''
|
||||||
|
return `${u.hostname}${portPart}`
|
||||||
|
} catch {
|
||||||
|
return raw.length > 40 ? raw.slice(0, 40) + '…' : raw
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/composables/ai/useToolCardRender.ts
Normal file
45
src/composables/ai/useToolCardRender.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* ToolCard 渲染层纯函数辅助(从 ToolCard.vue 抽离)。
|
||||||
|
*
|
||||||
|
* 职责:diff 行解析等无状态渲染纯函数。响应式 computed/ref 仍留各自组件,
|
||||||
|
* 这里只提供「输入→输出」的确定性变换,便于单测与跨组件复用(write_file 审批 diff
|
||||||
|
* 与 patch_file 结果 diff 共用同一解析逻辑)。
|
||||||
|
*
|
||||||
|
* 注:策略表(parseResult/formatBytes/formatSizeDiff 等带语义的)仍在 useToolCard.ts,
|
||||||
|
* 本文件仅收「与模板渲染强耦合、原散落在 ToolCard.vue 内联」的纯展示函数。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** diff 行种类:增/删/上下文 */
|
||||||
|
export type DiffLineKind = 'add' | 'del' | 'ctx'
|
||||||
|
|
||||||
|
/** 一行 diff 解析结果(kind 用于模板着色,text 原样显示) */
|
||||||
|
export interface DiffLine {
|
||||||
|
kind: DiffLineKind
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 unified diff 文本 → DiffLine[]。
|
||||||
|
*
|
||||||
|
* 来源(AE-2025-03 / UX-260618-06):后端 generate_diff 输出 '+新行/-旧行/(空格)上下文',
|
||||||
|
* 逐行拆 → {kind, text} 供模板按 kind 着色(add 绿/del 红/ctx 灰)。
|
||||||
|
*
|
||||||
|
* - 空行过滤(generate_diff 末尾保留单尾换行 → split 末尾有空串元素)
|
||||||
|
* - maxLines 截断:超长 diff 截到前 N 行并追加「…」占位(防撑爆卡片);undefined 不截断
|
||||||
|
*
|
||||||
|
* 纯函数,无副作用,相同输入恒定输出。
|
||||||
|
*/
|
||||||
|
export function parseDiffLines(text: string | undefined | null, maxLines?: number): DiffLine[] {
|
||||||
|
if (!text) return []
|
||||||
|
const lines = text.split('\n').map(line => {
|
||||||
|
if (line === '') return null
|
||||||
|
if (line.startsWith('+')) return { kind: 'add' as const, text: line }
|
||||||
|
if (line.startsWith('-')) return { kind: 'del' as const, text: line }
|
||||||
|
return { kind: 'ctx' as const, text: line }
|
||||||
|
}).filter((x): x is DiffLine => x !== null)
|
||||||
|
if (maxLines !== undefined && lines.length > maxLines) {
|
||||||
|
lines.splice(maxLines)
|
||||||
|
lines.push({ kind: 'ctx', text: '…' })
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user