优化: 工具失败视觉区分(UX-01)+旧卡自动收起增强(UX-03)

This commit is contained in:
2026-06-17 02:04:01 +08:00
parent 332a6bba1a
commit 31ea151bb2
7 changed files with 129 additions and 11 deletions

View File

@@ -1,7 +1,10 @@
<template>
<div
class="ai-tool-card"
:class="['ai-tool-card--' + tc.status, { 'ai-tool-card--collapsed': !isExpanded && !shouldKeepOpen(tc) }]"
:class="[
'ai-tool-card--' + tc.status,
{ 'ai-tool-card--collapsed': !isExpanded && !shouldKeepOpen(tc), 'ai-tool-card--failed': isToolFailure(tc) },
]"
>
<!-- 卡片头部图标 + 名称 + 状态 -->
<div class="ai-tool-header" @click="emit('toggle', tc.id)">
@@ -11,9 +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="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 class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + (isToolFailure(tc) ? 'failed' : tc.status)"></span>
<span v-if="!shouldKeepOpen(tc)" class="ai-tool-collapse-hint" :class="{ 'is-open': isExpanded }"></span>
</div>
@@ -107,7 +111,12 @@
</div>
<!-- 通用 CRUD 结果 -->
<div v-else-if="tc.result && tc.status === 'completed'" class="ai-tool-result">
<div
v-else-if="tc.result && tc.status === 'completed'"
class="ai-tool-result"
:class="{ 'ai-tool-result--failed': isToolFailure(tc) }"
>
<div v-if="isToolFailure(tc)" class="ai-tool-failed-banner"> {{ $t('aiTool.executionFailedHint') }}</div>
<code>{{ formatToolResult(tc) }}</code>
</div>
</div><!-- /.ai-tool-body -->
@@ -184,6 +193,38 @@ function shouldKeepOpen(tc: AiToolCallInfo): boolean {
|| tc.name === 'write_file'
}
/**
* UX-260616-01: 工具执行失败检测(completed 状态下的失败语义)。
*
* 后端 AR-6:Low 风险工具失败不 emit AiError,改 emit AiToolCallCompleted(result=错误),
* 故失败工具 status 仍为 'completed',与成功工具视觉同态(原绿色边框+绿色结果框)。
* 此函数区分两类失败:
* - run_command exit_code≠0:结果 JSON 合法,parseResult 命中,看 exit_code 判失败
* - 通用工具失败:AR-6 路径后端塞 serde_json::Value::String("工具 X 执行失败: ..."),
* 前端 parseResult 返回 null(非 JSON 对象)→ 走 raw 文本兜底,无结构信号
* 此时按字符串前缀 "执行失败"/"failed" 启发式判定(对齐后端 err_msg 格式)。
*
* 注意:status==='rejected'(用户拒绝)走独立分支,不算工具失败,不进此函数。
* running/pending_approval 自然 false(无 result 或 result 未稳定)。
*/
function isToolFailure(tc: AiToolCallInfo): boolean {
if (tc.status !== 'completed') return false
// run_command:结构化 exit_code 判定(可靠)
if (tc.name === 'run_command') {
const r = parseResult(tc.result)
if (r && typeof r.exit_code === 'number') return r.exit_code !== 0
// run_command 但无 exit_code(异常)→ 保守判失败
if (r) return true
}
// 通用工具:parseResult 返回 null(非 JSON,AR-6 错误字符串路径)→ 看文本前缀
if (!parseResult(tc.result)) {
const raw = typeof tc.result === 'string' ? tc.result : ''
if (!raw) return false
return /执行失败|failed|error[:\s]/i.test(raw)
}
return false
}
function formatToolName(name: string): string {
if (!name) return ''
return name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
@@ -666,7 +707,10 @@ function toolResultSummary(tc: AiToolCallInfo): string {
overflow: hidden;
max-width: var(--df-msg-max-width);
animation: fadeInUp 0.2s var(--df-ease);
transition: border-color 0.2s var(--df-ease);
/* UX-260616-03: border/shadow/opacity 过渡让折叠/失败态切换不突兀。
不做 body 高度动画(v-show display 切换 + flex gap 会留双倍间距致布局跳变,
见 ToolCardList 注释);opacity 过渡覆盖自动收起时的视觉跳变(约 200ms)。 */
transition: border-color 0.2s var(--df-ease), box-shadow 0.2s var(--df-ease), opacity 0.2s var(--df-ease);
}
.ai-tool-card--pending_approval {
border-color: rgba(240,199,94,0.3);
@@ -679,6 +723,32 @@ function toolResultSummary(tc: AiToolCallInfo): string {
border-color: rgba(240,101,101,0.2);
opacity: 0.55;
}
/* UX-260616-01: 工具失败态——AR-6 下 status 仍为 completed,故单独覆盖边框/底色与 completed 区分 */
.ai-tool-card--failed {
border-color: rgba(240,101,101,0.35);
box-shadow: 0 0 0 1px rgba(240,101,101,0.06);
}
.ai-tool-status-dot--failed {
background: var(--df-danger);
}
.ai-tool-sub--failed {
color: var(--df-danger);
}
.ai-tool-failed-banner {
padding: 4px 10px 2px;
font-size: 11px;
font-weight: 500;
color: var(--df-danger);
background: var(--df-danger-bg);
border-bottom: 0.5px solid rgba(240,101,101,0.2);
}
.ai-tool-result--failed {
background: var(--df-danger-bg);
border-top-color: rgba(240,101,101,0.2);
}
.ai-tool-result--failed code {
color: var(--df-danger);
}
.ai-tool-rejected {
display: flex;
align-items: center;