优化: 工具失败视觉区分(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

@@ -1743,6 +1743,10 @@ function isNearBottom(): boolean {
// 回到底部按钮:内容溢出且不在底部时显示
const showBackToBottom = ref(false)
// UX-260616-03: 跟随阅读位置自动收起 —— 用户滚近底部(在读最新内容)时,
// 收起不再活跃的旧卡(视为已读)。用 wasNearBottom 边沿检测避免每个 scroll tick 都触发:
// 仅在"远离底部 → 滚近底部"的转换瞬间收起一次,已在底部继续滚动不重复触发。
let wasNearBottom = true
function refreshBackToBottom() {
const el = messagesContainer.value
if (!el) { showBackToBottom.value = false; return }
@@ -1751,6 +1755,12 @@ function refreshBackToBottom() {
}
function onMessagesScroll() {
refreshBackToBottom()
// UX-260616-03: 滚近底部 → 收起已读旧卡(边沿触发,防抖)
const near = isNearBottom()
if (near && !wasNearBottom) {
collapseAllToolLists(buildActiveToolIds(store.state.messages))
}
wasNearBottom = near
}
function scrollToBottom(smooth = false) {
@@ -1789,7 +1799,15 @@ watch(mdReady, (ready) => {
})
watch(() => store.state.streaming, (s) => {
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
if (!s) {
if (s) {
// UX-260616-03: 新一轮生成开始 → 收起上一轮已完成的旧工具卡,聚焦当前生成内容。
// 仅当前视图正在生成时触发(generatingConvId 对齐 activeConversationId),
// 切走后台生成/恢复会话(streaming 初值已 true 的 onMounted 路径)不误触。
// buildActiveToolIds/collapseAllToolLists 是 function 声明,提升可用。
if (store.state.generatingConvId === store.state.activeConversationId) {
collapseAllToolLists(buildActiveToolIds(store.state.messages))
}
} else {
// UX-2025-01: 流式→完成切换,模板从 v-for 分块切到 v-html 整段,DOM 重建
saveSelection()
streamingBlocks.value = []

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;

View File

@@ -64,6 +64,11 @@ const emit = defineEmits<{
const rootEl = ref<HTMLElement>()
// 卡片级折叠(整卡 body 显隐,区别于 read_file 内容级)
const expandedCards = ref(new Set<string>())
// UX-260616-03:用户主动展开的卡片(记忆态,不被自动收起清除)。
// 与 expandedCards 区分:expandedCards 是"展开"语义的当前态(含默认首卡),
// userExpandedCards 是"用户明确点开"的持久标记,collapseInactive 跳过这些卡。
// 语义:用户读过的卡不强制折叠,符合"跟随阅读位置"的增强目标。
const userExpandedCards = ref(new Set<string>())
// 内容级展开read_file "展开全部"
const expandedTools = ref(new Set<string>())
// 分组折叠状态(按分组索引);多卡片分组默认收起
@@ -166,10 +171,14 @@ function toggleAllGroups(): void {
* - 分组未折叠 → 用户手动 expandedCards 或首卡默认展开
* - 分组已折叠 → false整组隐藏
* - 多卡片分组 + 未手动操作过 → 仅首卡(ci===0)展开
* - UX-260616-03:userExpandedCards(用户主动展开记忆)优先,即使父级 collapseInactive
* 清了 expandedCards,记忆态仍保持展开(用户读过的卡不强制折叠)。
*/
function isCardExpanded(tc: AiToolCallInfo, gi: number, ci: number): boolean {
if (isGroupCollapsed(gi)) return false
// 用户手动切换过该卡
// UX-260616-03:用户主动展开的卡片记忆态优先(不被自动收起影响)
if (userExpandedCards.value.has(tc.id)) return true
// 用户手动切换过该卡(当前态)
if (expandedCards.value.has(tc.id)) return true
// 多卡片分组:仅首卡默认展开
if (groupedToolCalls.value[gi]?.calls.length >= 2) return ci === 0
@@ -178,8 +187,15 @@ function isCardExpanded(tc: AiToolCallInfo, gi: number, ci: number): boolean {
}
function toggleCardExpand(id: string) {
if (expandedCards.value.has(id)) expandedCards.value.delete(id)
else expandedCards.value.add(id)
if (expandedCards.value.has(id)) {
expandedCards.value.delete(id)
// 收起时同步清记忆(下次自动收起可作用于它)
userExpandedCards.value.delete(id)
} else {
expandedCards.value.add(id)
// UX-260616-03:展开即记忆(用户主动展开,后续不被自动收起)
userExpandedCards.value.add(id)
}
}
function toggleExpand(id: string) {
@@ -187,7 +203,12 @@ function toggleExpand(id: string) {
else expandedTools.value.add(id)
}
/** 收起不在 activeIds 中的已完成/已拒绝卡片(供父组件 auto-collapse watch 调用) */
/**
* 收起不在 activeIds 中的已完成/已拒绝卡片(供父组件 auto-collapse watch 调用)。
* UX-260616-03:userExpandedCards(用户主动展开记忆)不被清除——用户读过的卡
* 即使不再 active(running/pending/write_file)也保持展开,避免"我刚才在看,突然被收起"。
* 仅清 expandedCards(默认态/首卡)与 expandedTools(内容级展开,跟随卡片折叠)。
*/
function collapseInactive(activeIds: Set<string>): void {
// CR-260615-31:对称过滤 expandedTools(原只清 expandedCards,expandedTools 残留致折叠后内容展开态)
if (expandedCards.value.size > 0) {
@@ -196,6 +217,7 @@ function collapseInactive(activeIds: Set<string>): void {
if (expandedTools.value.size > 0) {
expandedTools.value = new Set([...expandedTools.value].filter(id => activeIds.has(id)))
}
// UX-260616-03:不清 userExpandedCards(记忆态),isCardExpanded 优先读它保持展开。
}
/**

View File

@@ -23,7 +23,8 @@ export default {
errorTimeout: 'The response timed out. Please try again.',
errorNetwork: 'Network connection failed. Please check your network.',
// CR-30-2 / F-260616-07(d) / decision a1: stream pre-failure retry bubble copy
aiStreamRetry: '⚠ Request failed, retrying ({attempt}/{max})…',
// UX-260616-01: clarify this is an AI response (network/rate-limit) retry, not a tool failure (shown in card)
aiStreamRetry: '⚠ AI response failed, retrying ({attempt}/{max})…',
// UX-260616-01: Fatal errors (4xx/auth) are not retryable — prompt user to check settings instead
notRetryable: 'This error cannot be resolved by retrying. Please check the configuration.',
streamInterruptedAfterTool: '⚠ The tool finished, but the following reply was interrupted (no data stream for a long time). You can click Continue to retry.',

View File

@@ -8,6 +8,9 @@ export default {
rejectedHint: 'User rejected this action',
// B-260616-12: tool execution slow hint (frontend fallback, no backend cancel IPC)
executionSlow: 'Tool "{name}" is taking longer than expected, please wait…',
// UX-260616-01: tool execution failure marker (AR-6 keeps status=completed, red to distinguish from success)
executionFailed: 'Failed',
executionFailedHint: 'Tool execution failed, see details below',
collapse: 'Collapse',
expandAll: 'Expand all',
collapseAll: 'Collapse all',

View File

@@ -23,7 +23,8 @@ export default {
errorTimeout: '响应超时,请重试',
errorNetwork: '网络连接失败,请检查网络',
// CR-30-2 / F-260616-07(d) / 决策 a1: 流前失败重试中错误气泡内更新文案
aiStreamRetry: '⚠ 调用失败,正在重试({attempt}/{max})…',
// UX-260616-01: 明确这是 AI 响应(网络/限流)重试,非工具执行失败(工具失败在卡片内显示)
aiStreamRetry: '⚠ AI 响应失败,正在重试({attempt}/{max})…',
// UX-260616-01: Fatal 类错误(4xx/鉴权)不可重试,提示用户去设置而非重试
notRetryable: '此错误无法通过重试解决,请检查配置',
streamInterruptedAfterTool: '⚠ 工具已执行完成,后续回复中断(长时间无数据流)。可点继续重试。',

View File

@@ -8,6 +8,9 @@ export default {
rejectedHint: '用户拒绝了此操作',
// B-260616-12:工具执行超时提示(前端降级,无后端取消 IPC)
executionSlow: '工具「{name}」执行较久,请稍候…',
// UX-260616-01:工具执行失败视觉标识(AR-6 下 status=completed,需红色区分于成功)
executionFailed: '执行失败',
executionFailedHint: '工具执行失败,详情见下方',
collapse: '收起',
expandAll: '展开全部',
collapseAll: '全部收起',