优化: 工具失败视觉区分(UX-01)+旧卡自动收起增强(UX-03)
This commit is contained in:
@@ -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 = []
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 优先读它保持展开。
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '⚠ 工具已执行完成,后续回复中断(长时间无数据流)。可点继续重试。',
|
||||
|
||||
@@ -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: '全部收起',
|
||||
|
||||
Reference in New Issue
Block a user