优化: AI Chat 渲染与无障碍 + 文件查看 + 同步 IO 收口

- 渲染: 结果/过程分离(气泡只放最终结果,过程轮叙述+工具卡收进轨迹卡默认收起可展开——治『流式后正文消失』且不再 N 气泡打扰)
- 渲染 P0: v-memo 定向 dep(只有真正渲染流式块的 item 失效,其余取占位) + renderItems 桶 memo(签名覆盖全消费输入,item 引用稳定) + 指纹 watch 降为 O(1) toolEpoch
- TopBar 三处 O(n) messages computed 合并单次遍历;md 缓存改有界 LRU(除满即全清抖动);useTurnTrace 补 never 穷尽 + default;MessageItem 模板去重
- 无障碍(四批累计): 可点容器键盘化(含 role/tabindex/keydown/aria-expanded) + icon-only aria-label 约 60 处 + 装饰图标 aria-hidden + 52 个滚动容器 overscroll-behavior:contain + 设置开关聚焦可见环 + outline 零补偿站点补 :focus-visible
- UX 走查项: 灵感 IDEA-10/11、知识 KNOW-6/7/9、项目详情 O8、审计 SA-4、设置 SA-6/26/27/28(脏表单/冗余 save/clamp/删除二次确认)
- 文件查看专项: 切工程状态残留 F5/F6、FileTree 去重与展开反馈 F7/F15/F17、切文件头部残留 F18、R 状态着色 F19;FilePreview diff 文件头(---/+++)误当增删渲染修复 + 大纲行高亮死样式修复 + 图片失败文案 i18n 化
- 同步 IO 收口(异步上下文内阻塞): unattended 看板读写改 tokio::fs、fetch_url 探活改 spawn_blocking、http/generate_image 清理改 tokio::fs
- 子代理 P3 批量: 任务书钉扎上限/截断文案区分父子语境/环境画像日期/temperature 可覆盖/model 回退上报/lang 透传
- crate 清理: df-execute·df-types 注释与格式、df-ideas 过时注释、df-nodes subflow 夸大描述、df-project 死变量
This commit is contained in:
lxy
2026-10-10 08:09:45 +08:00
parent 8df4e33656
commit 39e0eac39d
67 changed files with 1344 additions and 364 deletions
+48 -5
View File
@@ -24,7 +24,7 @@
<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="isFailed" class="ai-tool-sub ai-tool-sub--failed" :title="summaryTitle">{{ $t('aiTool.executionFailed') }}</span>
<span v-else-if="toolResultSummary(tc)" class="ai-tool-sub" :title="summaryTitle">{{ toolResultSummary(tc) }}</span>
<span v-else-if="resultSummary" class="ai-tool-sub" :title="summaryTitle">{{ resultSummary }}</span>
</div>
<span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + (isFailed ? 'failed' : tc.status)" :title="statusTitle"></span>
<!-- 统一卡形态第4要素:耗时(前端 startedAt/completedAt 差;历史消息无时间戳隐藏不显 0)。
@@ -70,14 +70,14 @@
{{ argsExpanded ? $t('aiTool.argsCollapse') : $t('aiTool.argsMore', { n: argsCount - 1 }) }}
</button>
</div>
<div v-if="tc.reason" class="ai-tool-approval-reason">⚠ {{ tc.reason }}</div>
<div v-if="tc.reason" class="ai-tool-approval-reason"><span aria-hidden="true">⚠</span> {{ tc.reason }}</div>
<!-- BUG-260624-02: 审批到期倒计时(与前端自动拒绝时刻精确对齐,≤60s 红色脉冲);
无活跃计时器(不限时/已到期)回退显挂起时长(>2分钟橙色,>5分钟红色) -->
<div v-if="expireSecs !== null" class="ai-tool-wait-time" :class="'is-' + expireLevel">
{{ $t('aiTool.expireIn', { time: formatWaitTime(expireSecs) }) }}
</div>
<div v-else-if="waitSecs > 0" class="ai-tool-wait-time" :class="'is-' + waitLevel">
⏱ {{ $t('aiTool.waitTime', { time: formatWaitTime(waitSecs) }) }}
<span aria-hidden="true">⏱</span> {{ $t('aiTool.waitTime', { time: formatWaitTime(waitSecs) }) }}
</div>
<!-- AE-2025-03: write_file 审批 diff 预览(红删绿增,行级)。旧文件不存在→tc.diff 为空,回退显上方 args content -->
<div v-if="tc.diff" class="ai-tool-approval-diff">
@@ -137,14 +137,32 @@
<!-- 已拒绝提示 -->
<div v-if="tc.status === 'rejected'" class="ai-tool-rejected">
<span class="ai-tool-rejected-icon">✕</span>
<span class="ai-tool-rejected-icon" aria-hidden="true">✕</span>
{{ tc.result || $t('aiTool.rejectedHint') }}
</div>
<!-- D2a 外置结果(句柄 JSON):显「结果已外置」+ 展开;读回原文后复用 ToolResultBody 渲染 -->
<div v-if="tc.status === 'completed' && offloadHandle" class="ai-tool-offloaded">
<OffloadedResultBar
:handle="offloadHandle"
:expanded="offloadExpanded"
:loading="offloadLoading"
:error="offloadError"
@toggle="toggleOffload"
/>
<ToolResultBody
v-if="offloadExpanded && offloadResolved != null"
:tc="resolvedOffloadTc"
:isContentExpanded="isContentExpanded"
@expand-content="(id) => emit('expand-content', id)"
@install-obscura="() => emit('install-obscura')"
/>
</div>
<!-- 各工具结果展示(read_file/list_directory/search_files/write_file/patch_file/run_command/http_request/fetch_url/通用)
委托 ToolResultBody 子组件,透传 tc + 内容展开态 + expand-content/install-obscura 冒泡 -->
<ToolResultBody
v-if="tc.status === 'completed'"
v-else-if="tc.status === 'completed'"
:tc="tc"
:isContentExpanded="isContentExpanded"
@expand-content="(id) => emit('expand-content', id)"
@@ -179,6 +197,7 @@ import {
argString,
} from '@/composables/ai/useToolCard'
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
import { useOffloadedResult } from '@/composables/ai/useOffloadedResult'
import { useToolCardHeader } from '@/composables/ai/useToolCardHeader'
import { useToolApproval, toolRiskLevel } from '@/composables/ai/useToolApproval'
import { useTimerOwnership } from '@/composables/useTimerOwnership'
@@ -187,6 +206,7 @@ import { formatDate, formatDuration } from '@/utils/time'
import type { AiToolCallInfo } from '@/api/types'
import ConfirmDialog from './ConfirmDialog.vue'
import ToolResultBody from './ToolResultBody.vue'
import OffloadedResultBar from './OffloadedResultBar.vue'
// 审批按钮图标 SVG 常量(消除模板 6 处内联重复;v-html 渲染完整 svg 元素)
const ICON_CHECK = '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'
@@ -246,6 +266,27 @@ const {
/** 一次解析结果供失败判定复用(头部 isFailed 用于卡片 border/status-dot 着色) */
const parsed = computed(() => parseResult(props.tc.result))
/**
* D2a 外置句柄:completed 结果若是句柄 JSON(超 8KB 落盘、DB 只留句柄),
* 头部摘要与结果区改走「结果已外置」+ 展开读回,不把句柄当普通结果解析渲染。
*/
const {
handle: offloadHandle,
expanded: offloadExpanded,
loading: offloadLoading,
error: offloadError,
resolved: offloadResolved,
toggle: toggleOffload,
} = useOffloadedResult(() => (props.tc.status === 'completed' ? props.tc.result : null))
/** 读回后把原文塞回 tc.result,交 ToolResultBody 按工具名走既有渲染分支(零重复渲染逻辑) */
const resolvedOffloadTc = computed(() => ({ ...props.tc, result: offloadResolved.value ?? props.tc.result }))
/** 头部副标题:外置句柄显「结果已外置」,其余走既有摘要函数 */
const resultSummary = computed(() =>
offloadHandle.value ? t('aiTool.resultOffloaded') : toolResultSummary(props.tc),
)
/**
* 是否工具失败(completed 语义)。头部多处复用(.ai-tool-card--failed / 状态点 / sub 文案)。
* 逻辑与模块级 isToolFailure 同源;结果展示区的 isFailed 已迁入 ToolResultBody(各自 computed)。
@@ -253,6 +294,8 @@ const parsed = computed(() => parseResult(props.tc.result))
const isFailed = computed(() => {
const tc = props.tc
if (tc.status !== 'completed') return false
// 外置句柄是「结果留了指针」非执行失败,不参与失败判定
if (offloadHandle.value) return false
const r = parsed.value
if (tc.name === 'run_command') {
if (r && typeof r.exit_code === 'number') return r.exit_code !== 0