优化: AI Chat全栈多批审查修复与架构清理(risk_level清理/路由解耦/工具渲染/测试补测/死代码)
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
class="ai-tool-card"
|
||||
:class="[
|
||||
'ai-tool-card--' + tc.status,
|
||||
{ 'ai-tool-card--collapsed': !isExpanded && !shouldKeepOpen(tc), 'ai-tool-card--failed': isToolFailure(tc) },
|
||||
{ 'ai-tool-card--collapsed': !isExpanded && !shouldKeepOpen(tc), 'ai-tool-card--failed': isFailed },
|
||||
]"
|
||||
>
|
||||
<!-- 卡片头部:图标 + 名称 + 状态 -->
|
||||
@@ -14,10 +14,10 @@
|
||||
<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="isToolFailure(tc)" class="ai-tool-sub ai-tool-sub--failed">{{ $t('aiTool.executionFailed') }}</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--' + (isToolFailure(tc) ? 'failed' : tc.status)"></span>
|
||||
<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>
|
||||
|
||||
@@ -73,9 +73,11 @@
|
||||
<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>
|
||||
<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.expandAll') }}
|
||||
{{ 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>
|
||||
@@ -87,6 +89,7 @@
|
||||
<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
|
||||
@@ -103,20 +106,72 @@
|
||||
</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': isToolFailure(tc) }"
|
||||
:class="{ 'ai-tool-result--failed': isFailed }"
|
||||
>
|
||||
<div v-if="isToolFailure(tc)" class="ai-tool-failed-banner">⚠ {{ $t('aiTool.executionFailedHint') }}</div>
|
||||
<div v-if="isFailed" class="ai-tool-failed-banner">⚠ {{ $t('aiTool.executionFailedHint') }}</div>
|
||||
<code>{{ formatToolResult(tc) }}</code>
|
||||
</div>
|
||||
</div><!-- /.ai-tool-body -->
|
||||
@@ -146,6 +201,7 @@ const t = ((i18n as any).global.t as (k: string, p?: Record<string, unknown>) =>
|
||||
/** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */
|
||||
interface ToolResult {
|
||||
path?: string
|
||||
pattern?: string
|
||||
from?: string
|
||||
to?: string
|
||||
content?: string
|
||||
@@ -184,6 +240,20 @@ interface ToolResult {
|
||||
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 注入,常量安全;图标库改造见决策记录) */
|
||||
@@ -239,6 +309,12 @@ function formatToolName(name: string): string {
|
||||
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)
|
||||
@@ -365,6 +441,30 @@ function formatToolResult(tc: AiToolCallInfo): string {
|
||||
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':
|
||||
@@ -372,13 +472,16 @@ function formatToolResult(tc: AiToolCallInfo): string {
|
||||
? t('aiTool.updatedTaskField', { id: r.id || '', field: r.field || '' })
|
||||
: t('aiTool.updated')
|
||||
case 'advance_task':
|
||||
return t('aiTool.advancedTask', { id: r.id || '', status: r.status || '' })
|
||||
// UX-260618-09: review_rounds>0 显退回累加轮数,=0 回退原文案(首推无噪音)
|
||||
return typeof r.review_rounds === 'number' && r.review_rounds > 0
|
||||
? t('aiTool.advancedTaskWithRounds', { id: r.id || '', status: r.status || '', n: r.review_rounds })
|
||||
: t('aiTool.advancedTask', { id: 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 || '' })
|
||||
: t('aiTool.deleteFileSoft', { path: r.path || '', backup_path: r.backup_path || '' })
|
||||
case 'run_command':
|
||||
return formatCommandResult(r)
|
||||
case 'patch_file':
|
||||
@@ -389,7 +492,9 @@ function formatToolResult(tc: AiToolCallInfo): string {
|
||||
return t('aiTool.appendedTo', { path: r.path || '', bytes: formatBytes(r.bytes_written) })
|
||||
case 'rename_file':
|
||||
return r.renamed
|
||||
? t('aiTool.renamedFile', { from: r.from || '', to: r.to || '' })
|
||||
? (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)
|
||||
@@ -401,6 +506,14 @@ function formatToolResult(tc: AiToolCallInfo): string {
|
||||
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)
|
||||
}
|
||||
@@ -435,13 +548,18 @@ function formatCommandResult(r: ToolResult): string {
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
/** 合并 stdout/stderr 取前 N 行,返回截断后文本 + 是否截断 + 总行数 */
|
||||
function combineAndTruncateLines(stdout: string | undefined, stderr: string | undefined, maxLines: number): { text: string; truncated: boolean; total: number } {
|
||||
/** 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)
|
||||
if (!parts.length) return { text: '', truncated: false, total: 0 }
|
||||
const combined = parts.join('\n')
|
||||
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) {
|
||||
@@ -458,6 +576,10 @@ function formatFileInfoResult(r: ToolResult): string {
|
||||
}
|
||||
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 {
|
||||
@@ -505,6 +627,12 @@ const emit = defineEmits<{
|
||||
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
|
||||
@@ -570,13 +698,16 @@ async function onApprove(approved: boolean) {
|
||||
}, APPROVE_LOADING_TIMEOUT_MS)
|
||||
emit('approve', { id: props.tc.id, approved })
|
||||
}
|
||||
// status 离开 pending_approval → 后端已回事件 → 复位 loading 并清计时器
|
||||
// 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)
|
||||
@@ -586,6 +717,34 @@ onBeforeUnmount(() => {
|
||||
/** 一次解析结果供模板多次读(模板引用 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 着色。
|
||||
@@ -603,6 +762,29 @@ const diffLines = computed(() => {
|
||||
}).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)。
|
||||
@@ -665,6 +847,11 @@ function toolDisplayName(tc: AiToolCallInfo): string {
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -679,9 +866,21 @@ function toolResultSummary(tc: AiToolCallInfo): string {
|
||||
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 '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')
|
||||
@@ -689,12 +888,14 @@ function toolResultSummary(tc: AiToolCallInfo): string {
|
||||
case 'update_task': return r.updated !== false
|
||||
? t('aiTool.updatedTaskField', { id: r.id || '', field: r.field || '' })
|
||||
: t('aiTool.updated')
|
||||
case 'advance_task': return t('aiTool.advancedTask', { id: r.id || '', status: r.status || '' })
|
||||
case 'advance_task': return typeof r.review_rounds === 'number' && r.review_rounds > 0
|
||||
? t('aiTool.advancedTaskWithRounds', { id: r.id || '', status: r.status || '', n: r.review_rounds })
|
||||
: t('aiTool.advancedTask', { id: 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 || '' })
|
||||
: 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': {
|
||||
@@ -707,7 +908,9 @@ function toolResultSummary(tc: AiToolCallInfo): string {
|
||||
: 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
|
||||
? t('aiTool.renamedFile', { from: r.from || '', to: r.to || '' })
|
||||
? (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)
|
||||
@@ -719,7 +922,13 @@ function toolResultSummary(tc: AiToolCallInfo): string {
|
||||
case 'bind_directory': return r.bound
|
||||
? t('aiTool.boundDir', { path: r.path || '', stack: r.stack || '' })
|
||||
: ''
|
||||
case 'run_workflow': return t('aiTool.workflowHint')
|
||||
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 ''
|
||||
}
|
||||
}
|
||||
@@ -1204,6 +1413,64 @@ function toolResultSummary(tc: AiToolCallInfo): string {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user