优化: 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:
+194
-107
@@ -22,7 +22,7 @@ import { useMessageScroll } from '../../composables/ai/useMessageScroll'
|
||||
import { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
|
||||
import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations'
|
||||
import { collectTurnStats, turnSummaryText, sumTurnTokens, formatTokensK, type TurnCallStats } from '../../composables/ai/useTurnTrace'
|
||||
import { getConvAgentFinished, type AgentFinishedCard } from '../../composables/ai/aiShared'
|
||||
import { getConvAgentFinished, toolEpoch, type AgentFinishedCard } from '../../composables/ai/aiShared'
|
||||
import { aiApi } from '../../api'
|
||||
import ToolCardList from '../ToolCardList.vue'
|
||||
import MessageItem from './MessageItem.vue'
|
||||
@@ -420,22 +420,16 @@ function onPendingOutOfView(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 deep:true,改在 watch source 中计算指纹(消息数 + toolCalls 的 id/status),
|
||||
* 避免 deep 每帧全量深遍历长会话数组引起掉帧。流式 delta 只改 content 不影响指纹,
|
||||
* 不触发无意义的收起重算。length 变化(新消息/截断)和 toolCall 状态翻转(新卡/完成/拒绝)
|
||||
* 均能正确触发——与 body 的快照收缩逻辑等价,无需 deep 兜底。
|
||||
* 工具卡自动收起:工具状态翻转(新卡/完成/待审批/拒绝)时收起旧的已完成/已拒绝卡片,
|
||||
* 保留 running/pending/write_file 活跃卡片。
|
||||
*
|
||||
* P0 渲染层:源从 `map(toolCalls→{id,s}) + JSON.stringify` 的 O(n) 指纹改为 toolEpoch 版本计数(O(1))。
|
||||
* 版本号在工具态突变点(aiShared.bumpToolEpoch)自增,免每次工具事件全量遍历长会话数组。
|
||||
* messages.length 变化(新消息/截断/切会话)同样触发(messages 长度与 toolEpoch 任一变即回调)。
|
||||
*/
|
||||
let lastMsgSnapshot = ''
|
||||
watch(
|
||||
() => JSON.stringify({
|
||||
n: store.state.messages.length,
|
||||
tc: store.state.messages.map(m => (m.toolCalls || []).map(t => ({ id: t.id, s: t.status }))),
|
||||
}),
|
||||
(snap) => {
|
||||
if (snap === lastMsgSnapshot) return
|
||||
const isFirst = lastMsgSnapshot === ''
|
||||
lastMsgSnapshot = snap
|
||||
if (isFirst) return
|
||||
[() => store.state.messages.length, toolEpoch],
|
||||
() => {
|
||||
// R10 同门:非跟随态(上滚阅读中)不强制收起正浏览的工具卡
|
||||
if (isFollowingBottom.value) collapseAllToolLists(buildActiveToolIds(store.state.messages))
|
||||
},
|
||||
@@ -683,7 +677,12 @@ const DECO_CACHE_LIMIT = 800
|
||||
function decorateCodeHtml(raw: string): string {
|
||||
if (!raw || !raw.includes('<pre><code')) return raw
|
||||
const hit = _decoCache.get(raw)
|
||||
if (hit !== undefined) return hit
|
||||
if (hit !== undefined) {
|
||||
// LRU:命中即置为最近使用(delete+set 移到 Map 末尾),淘汰时优先保留热项
|
||||
_decoCache.delete(raw)
|
||||
_decoCache.set(raw, hit)
|
||||
return hit
|
||||
}
|
||||
// marked 的 code renderer 统一输出 <pre><code class="hljs ...">…</code></pre>
|
||||
//(含缩进码块),代码内容已 HTML 转义,正则非贪婪到首个闭合标签不会越界。
|
||||
// R4 语言角标:code class 形如 hljs language-ts,捕获 language-xxx 输出到 .md-code-block
|
||||
@@ -696,7 +695,11 @@ function decorateCodeHtml(raw: string): string {
|
||||
return `<div class="md-code-block"${langAttr}><button type="button" class="md-code-copy">复制</button><pre><code${attrs}>${body}</code></pre></div>`
|
||||
},
|
||||
)
|
||||
if (_decoCache.size > DECO_CACHE_LIMIT) _decoCache.clear()
|
||||
// 有界 LRU 淘汰:超限删最久未用项(Map 首项)后插入,替代原整体 clear
|
||||
if (_decoCache.size >= DECO_CACHE_LIMIT) {
|
||||
const oldest = _decoCache.keys().next().value
|
||||
if (oldest !== undefined) _decoCache.delete(oldest)
|
||||
}
|
||||
_decoCache.set(raw, out)
|
||||
return out
|
||||
}
|
||||
@@ -1029,117 +1032,173 @@ type RenderItem =
|
||||
* 无过程消息的轮次(单轮问答)零改动原样渲染。
|
||||
*
|
||||
* UX 密度改造:相邻消息间隔 ≥30 分钟或跨天时插居中时间分隔线(timesep 项),
|
||||
* per-block 相对时间已移除。轨距追踪 lastMsgTs 只在消息桶边界推进,
|
||||
* per-block 相对时间已移除。轨距追踪 lastTs(renderItems 局部)只在消息桶边界推进,
|
||||
* 轨迹卡与其内部轮次之间不插线(同轮间隔无叙事价值)。轨迹桶自身也不再发
|
||||
* timesep 行:卡头「第 N 轮」+ 绝对起始时刻已承载时间行职责(两行合一)。
|
||||
*/
|
||||
const TIME_SEP_GAP_MS = 30 * 60 * 1000
|
||||
let lastMsgTs = 0
|
||||
|
||||
/** 与上一条已渲染消息是否需要时间分隔线(间隔 ≥30 分钟或跨天;首条不插) */
|
||||
function needsTimeSep(ts: number): boolean {
|
||||
if (!lastMsgTs || !ts) return false
|
||||
if (Math.abs(ts - lastMsgTs) >= TIME_SEP_GAP_MS) return true
|
||||
const a = new Date(lastMsgTs)
|
||||
/** 与上一时刻是否需要时间分隔线(间隔 ≥30 分钟或跨天;首条不插)。
|
||||
* prevTs 由调用方传入(桶级缓存不持跨桶可变态,改参数化保纯)。 */
|
||||
function needsTimeSep(ts: number, prevTs: number): boolean {
|
||||
if (!prevTs || !ts) return false
|
||||
if (Math.abs(ts - prevTs) >= TIME_SEP_GAP_MS) return true
|
||||
const a = new Date(prevTs)
|
||||
const b = new Date(ts)
|
||||
return a.getFullYear() !== b.getFullYear()
|
||||
|| a.getMonth() !== b.getMonth()
|
||||
|| a.getDate() !== b.getDate()
|
||||
}
|
||||
|
||||
/** 需要时插 timesep 项并推进 lastMsgTs(同一出口防漏插/重插) */
|
||||
function pushTimeSepIfNeeded(items: RenderItem[], ts: number): void {
|
||||
if (needsTimeSep(ts)) {
|
||||
items.push({ kind: 'timesep', key: 'tsep-' + ts + '-' + items.length, ts })
|
||||
// ── 桶级 memo(P0 渲染层):同签名桶复用同一批 item 对象 ──
|
||||
// 两重收益:①工具事件时 O(n)→O(变更桶)(其余桶命中缓存不重算);②item 对象引用稳定
|
||||
// → 模板 v-memo 的 [item] dep 生效前提(桶未变 → item 同引用 → v-memo 跳过该 item 的 patch)。
|
||||
// 签名覆盖 flushBucket 产出真正消费的全部输入(消息身份/文本/token + 工具态 + 轮号 + 生成态 +
|
||||
// 前桶时刻 + 轨迹展开态);漏字段 → 命中陈旧 item → 局部不刷新,故从宽纳入。LRU 有界(沿用 _decoCache 范式)。
|
||||
const _bucketCache = new Map<string, { items: RenderItem[]; endTs: number }>()
|
||||
const BUCKET_CACHE_LIMIT = 128
|
||||
|
||||
function bucketSig(msgs: AiMessage[], round: number, executing: boolean, prevTs: number, traceExpanded: boolean): string {
|
||||
const parts: string[] = [round + '#' + (executing ? 1 : 0) + '#' + prevTs + '#' + (traceExpanded ? 1 : 0)]
|
||||
for (const m of msgs) {
|
||||
let s = m.id + ':' + m.role + ':' + (m.status ?? '') + ':' + (m.isError ? 1 : 0) + ':' + (m.isRetry ? 1 : 0)
|
||||
+ ':' + (m.timestamp ?? 0) + ':' + (m.model ?? '')
|
||||
+ ':' + (m.content ?? '') + ':' + (m.reasoning_content ?? '')
|
||||
+ ':' + (m.tokenUsage?.prompt ?? m.prompt_tokens ?? '')
|
||||
+ ':' + (m.tokenUsage?.completion ?? m.completion_tokens ?? '')
|
||||
+ ':' + (m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? '')
|
||||
+ ':' + (m.tokenUsage?.cache_miss ?? m.prompt_cache_miss_tokens ?? '')
|
||||
+ ':' + (m.tokenUsage?.reasoning ?? m.reasoning_tokens ?? '')
|
||||
+ ':' + (m.tokenUsage?.is_estimated ? 1 : 0)
|
||||
+ ':' + (m.parts?.length ?? 0) + ':' + (m.mentionSpans?.length ?? 0)
|
||||
for (const t of m.toolCalls ?? []) {
|
||||
s += ',' + t.id + ':' + t.status + ':' + (t.completedAt ?? 0)
|
||||
}
|
||||
parts.push(s)
|
||||
}
|
||||
lastMsgTs = ts
|
||||
return parts.join('|')
|
||||
}
|
||||
|
||||
/** 无缓存构建一个桶的 item(重活:collectTurnStats/sumTurnTokens/isToolResultJson 均在此)。
|
||||
* 返回 endTs(末条时刻)= 该桶结束后应作为下一桶 prevTs 的值。 */
|
||||
function genBucketItems(
|
||||
msgs: AiMessage[],
|
||||
round: number,
|
||||
executing: boolean,
|
||||
prevTs: number,
|
||||
traceKey: string,
|
||||
traceExpanded: boolean,
|
||||
): { items: RenderItem[]; endTs: number } {
|
||||
const items: RenderItem[] = []
|
||||
const cur = msgs[msgs.length - 1]
|
||||
const pushSep = (ts: number) => {
|
||||
if (needsTimeSep(ts, prevTs)) items.push({ kind: 'timesep', key: 'tsep-' + ts + '-' + items.length, ts })
|
||||
}
|
||||
// BUG-261001:执行中分支仅作用于「正在生成」的轮次桶(含 store 末条消息的桶)。
|
||||
// 原实现只判 isViewingGenerating——只要会话在生成,全部轮次桶(含早已完成的历史轮次)
|
||||
// 都被降级为不可交互的 step 摘要行,历史轨迹卡展开/折叠点击全部失效
|
||||
// (用户表象:新一轮执行时,上一轮轨迹也"不可点击")。历史桶保持完成态轨迹卡可点。
|
||||
if (executing) {
|
||||
const prev = msgs.slice(0, -1)
|
||||
if (prev.length > 0) {
|
||||
pushSep(msgs[0].timestamp)
|
||||
// 闪烁修复(2026-10-09):step 项 key 与下方完成态 trace 项对齐(同为 trace-{首条id}),
|
||||
// 使「执行中 step → 完成态 trace」切换时 Vue 复用同一 DOM 节点(key 稳定),不销毁重建。
|
||||
items.push({ kind: 'step', key: 'trace-' + prev[0].id, round, stats: collectTurnStats(msgs) })
|
||||
} else {
|
||||
pushSep(cur.timestamp)
|
||||
}
|
||||
items.push({ kind: 'msg', key: 'm-' + cur.id, msg: cur, final: true })
|
||||
return { items, endTs: cur.timestamp }
|
||||
}
|
||||
// 最终答复:末条有正文且非纯 JSON 工具结果的消息;全无正文时保底末条(工具轮可见)
|
||||
let fi = -1
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
const c = msgs[i].content
|
||||
if (c && c.trim() && !isToolResultJson(c)) { fi = i; break }
|
||||
}
|
||||
const finalMsg = fi >= 0 ? msgs[fi] : msgs[msgs.length - 1]
|
||||
// 过程消息 = finalMsg 之外全部(含其后尾气泡):异常收尾(错误/停止/流中断)时末轮气泡
|
||||
// 常只有 toolCalls 无正文,按「finalMsg 之前」切片会把尾气泡丢出渲染与轨迹卡,
|
||||
// 执行中可见的工具卡在收尾瞬间整块消失(用户实测「空白覆盖一大堆内容」)。
|
||||
const traceMsgs = msgs.filter(m => m !== finalMsg)
|
||||
// 完成:轨迹折叠为一张卡,轮次内容在卡内 rail 面板展开(不再摊平成独立消息项)
|
||||
if (traceMsgs.length > 0) {
|
||||
// 终点计入末轮时长:过程消息里最晚的工具完成时刻与末轮开始时刻取较大者
|
||||
let lastCompleted = 0
|
||||
for (const m of traceMsgs) {
|
||||
for (const tc of m.toolCalls || []) {
|
||||
if (tc.completedAt && tc.completedAt > lastCompleted) lastCompleted = tc.completedAt
|
||||
}
|
||||
}
|
||||
// 轨迹桶不再发 timesep 行:卡头「第 N 轮」+ 绝对起始时刻已承载时间行职责;
|
||||
// 仅推进轨距,保持出口语义(后续消息的间隔判定从本桶起点起算)
|
||||
items.push({
|
||||
kind: 'trace', key: traceKey,
|
||||
round,
|
||||
stats: collectTurnStats(traceMsgs),
|
||||
startTs: traceMsgs[0].timestamp,
|
||||
endTs: Math.max(lastCompleted, finalMsg.timestamp),
|
||||
tokens: sumTurnTokens(traceMsgs),
|
||||
traceMsgs, expanded: traceExpanded,
|
||||
})
|
||||
} else {
|
||||
pushSep(finalMsg.timestamp)
|
||||
}
|
||||
// 结果/过程分离(2026-10-10 用户口径:过程太多压住结果、密度过大):
|
||||
// 气泡**只放最终结果**(finalMsg 正文);本桶其余轮的叙述与工具卡全收进「执行轨迹卡」(默认一行
|
||||
// 收起,卡内展开可看逐轮全文与工具卡)——既不再"流式后正文消失",又不再"一条消息回 N 个气泡"。
|
||||
items.push({ kind: 'msg', key: 'm-' + finalMsg.id, msg: finalMsg, final: true })
|
||||
return { items, endTs: finalMsg.timestamp }
|
||||
}
|
||||
|
||||
/** 桶 memo 取用:签名命中回同一对象数组(引用稳定);未命中构建 + 有界 LRU 写入(命中置末)。 */
|
||||
function buildBucket(msgs: AiMessage[], round: number, executing: boolean, prevTs: number): { items: RenderItem[]; endTs: number } {
|
||||
const traceKey = 'trace-' + msgs[0].id
|
||||
const traceExpanded = expandedTraceIds.value.has(traceKey)
|
||||
const sig = bucketSig(msgs, round, executing, prevTs, traceExpanded)
|
||||
const hit = _bucketCache.get(sig)
|
||||
if (hit) {
|
||||
_bucketCache.delete(sig)
|
||||
_bucketCache.set(sig, hit)
|
||||
return hit
|
||||
}
|
||||
const built = genBucketItems(msgs, round, executing, prevTs, traceKey, traceExpanded)
|
||||
if (_bucketCache.size >= BUCKET_CACHE_LIMIT) {
|
||||
const oldest = _bucketCache.keys().next().value
|
||||
if (oldest !== undefined) _bucketCache.delete(oldest)
|
||||
}
|
||||
_bucketCache.set(sig, built)
|
||||
return built
|
||||
}
|
||||
|
||||
const renderItems = computed<RenderItem[]>(() => {
|
||||
const items: RenderItem[] = []
|
||||
let bucket: AiMessage[] = []
|
||||
lastMsgTs = 0
|
||||
let lastTs = 0
|
||||
// 轨迹序号计数:轨迹卡头「第 N 轮」与执行中步骤行同一来源(computed 重算从 0 起,
|
||||
// 计数即轨迹卡出现顺序,与轮次导航条序号一致)
|
||||
let traceCount = 0
|
||||
|
||||
const pushSep = (ts: number) => {
|
||||
if (needsTimeSep(ts, lastTs)) {
|
||||
items.push({ kind: 'timesep', key: 'tsep-' + ts + '-' + items.length, ts })
|
||||
}
|
||||
lastTs = ts
|
||||
}
|
||||
|
||||
const flushBucket = () => {
|
||||
const msgs = bucket.filter(shouldRenderMsg)
|
||||
bucket = []
|
||||
if (msgs.length === 0) return
|
||||
|
||||
// BUG-261001:执行中分支仅作用于「正在生成」的轮次桶(含 store 末条消息的桶)。
|
||||
// 原实现只判 isViewingGenerating——只要会话在生成,全部轮次桶(含早已完成的历史轮次)
|
||||
// 都被降级为不可交互的 step 摘要行,历史轨迹卡展开/折叠点击全部失效
|
||||
// (用户表象:新一轮执行时,上一轮轨迹也"不可点击")。历史桶保持完成态轨迹卡可点。
|
||||
const lastStoreId = store.state.messages[store.state.messages.length - 1]?.id
|
||||
const cur = msgs[msgs.length - 1]
|
||||
if (isViewingGenerating.value && cur.id === lastStoreId) {
|
||||
const prev = msgs.slice(0, -1)
|
||||
if (prev.length > 0) {
|
||||
pushTimeSepIfNeeded(items, msgs[0].timestamp)
|
||||
traceCount++
|
||||
// 闪烁修复(2026-10-09):step 项 key 与下方完成态 trace 项对齐(同为 trace-{首条id}),
|
||||
// 使「执行中 step → 完成态 trace」切换时 Vue 复用同一 DOM 节点(key 稳定),不销毁重建。
|
||||
// 原 step-{id} 与 trace-{id} key 不同 → 完成瞬间整块轨迹 DOM 重建 → 闪烁。
|
||||
items.push({ kind: 'step', key: 'trace-' + prev[0].id, round: traceCount, stats: collectTurnStats(msgs) })
|
||||
} else {
|
||||
pushTimeSepIfNeeded(items, cur.timestamp)
|
||||
}
|
||||
items.push({ kind: 'msg', key: 'm-' + cur.id, msg: cur, final: true })
|
||||
lastMsgTs = cur.timestamp
|
||||
return
|
||||
}
|
||||
// 最终答复:末条有正文且非纯 JSON 工具结果的消息;全无正文时保底末条(工具轮可见)
|
||||
let fi = -1
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
const c = msgs[i].content
|
||||
if (c && c.trim() && !isToolResultJson(c)) { fi = i; break }
|
||||
}
|
||||
const finalMsg = fi >= 0 ? msgs[fi] : msgs[msgs.length - 1]
|
||||
// 过程消息 = finalMsg 之外全部(含其后尾气泡):异常收尾(错误/停止/流中断)时末轮气泡
|
||||
// 常只有 toolCalls 无正文,按「finalMsg 之前」切片会把尾气泡丢出渲染与轨迹卡,
|
||||
// 执行中可见的工具卡在收尾瞬间整块消失(用户实测「空白覆盖一大堆内容」)。
|
||||
const traceMsgs = msgs.filter(m => m !== finalMsg)
|
||||
// 完成:轨迹折叠为一张卡,轮次内容在卡内 rail 面板展开(不再摊平成独立消息项)
|
||||
if (traceMsgs.length > 0) {
|
||||
const key = 'trace-' + traceMsgs[0].id
|
||||
const expanded = expandedTraceIds.value.has(key)
|
||||
// 终点计入末轮时长:过程消息里最晚的工具完成时刻与末轮开始时刻取较大者
|
||||
let lastCompleted = 0
|
||||
for (const m of traceMsgs) {
|
||||
for (const tc of m.toolCalls || []) {
|
||||
if (tc.completedAt && tc.completedAt > lastCompleted) lastCompleted = tc.completedAt
|
||||
}
|
||||
}
|
||||
// 轨迹桶不再发 timesep 行:卡头「第 N 轮」+ 绝对起始时刻已承载时间行职责;
|
||||
// 仅推进轨距,保持出口语义(后续消息的间隔判定从本桶起点起算)
|
||||
lastMsgTs = traceMsgs[0].timestamp
|
||||
traceCount++
|
||||
items.push({
|
||||
kind: 'trace', key,
|
||||
round: traceCount,
|
||||
stats: collectTurnStats(traceMsgs),
|
||||
startTs: traceMsgs[0].timestamp,
|
||||
endTs: Math.max(lastCompleted, finalMsg.timestamp),
|
||||
tokens: sumTurnTokens(traceMsgs),
|
||||
traceMsgs, expanded,
|
||||
})
|
||||
} else {
|
||||
pushTimeSepIfNeeded(items, finalMsg.timestamp)
|
||||
}
|
||||
// 正文不折叠(2026-10-10 根因修复):此前整轮(含模型正文)折进轨迹卡默认收起 → 流式期间
|
||||
// 能看到正文、轮末正文随卡收起,用户表象为「气泡被清空/进入等待」,历次在 flush/清空/骨架层
|
||||
// 修补均无效。现把过程消息里**有正文**的各成一个常显气泡(剥掉 toolCalls——工具卡仍归轨迹卡,
|
||||
// 不重复渲染),轨迹卡只折叠工具步骤与思考。
|
||||
for (const m of traceMsgs) {
|
||||
const c = m.content
|
||||
if (m.role === 'assistant' && c && c.trim() && !isToolResultJson(c)) {
|
||||
items.push({ kind: 'msg', key: 'm-' + m.id, msg: { ...m, toolCalls: undefined } })
|
||||
}
|
||||
}
|
||||
items.push({ kind: 'msg', key: 'm-' + finalMsg.id, msg: finalMsg, final: true })
|
||||
lastMsgTs = finalMsg.timestamp
|
||||
const executing = isViewingGenerating.value && msgs[msgs.length - 1].id === lastStoreId
|
||||
// 轨迹/步骤项产生时轮号自增(仅 msgs.length>1 会产出 step/trace;轮号计入桶签名,
|
||||
// 前序桶变化致轮号位移时该桶正确重算)
|
||||
const round = msgs.length > 1 ? ++traceCount : traceCount
|
||||
const built = buildBucket(msgs, round, executing, lastTs)
|
||||
for (const it of built.items) items.push(it)
|
||||
lastTs = built.endTs
|
||||
}
|
||||
|
||||
for (const seg of messageSegments.value) {
|
||||
@@ -1149,7 +1208,7 @@ const renderItems = computed<RenderItem[]>(() => {
|
||||
items.push({ kind: 'sep', key: seg.key, seg })
|
||||
if (expandedSegmentIds.value.has(seg.key)) {
|
||||
for (const m of seg.msgs) {
|
||||
pushTimeSepIfNeeded(items, m.timestamp)
|
||||
pushSep(m.timestamp)
|
||||
items.push({ kind: 'msg', key: 'e-' + seg.key + '-' + m.id, msg: m })
|
||||
}
|
||||
}
|
||||
@@ -1161,7 +1220,7 @@ const renderItems = computed<RenderItem[]>(() => {
|
||||
bucket.push(m)
|
||||
} else {
|
||||
flushBucket()
|
||||
pushTimeSepIfNeeded(items, m.timestamp)
|
||||
pushSep(m.timestamp)
|
||||
items.push({ kind: 'msg', key: 'm-' + m.id, msg: m })
|
||||
}
|
||||
}
|
||||
@@ -1186,6 +1245,23 @@ const renderItems = computed<RenderItem[]>(() => {
|
||||
return items
|
||||
})
|
||||
|
||||
// ── v-memo 依赖数组(P0 渲染层,内联在模板 v-for 的 v-memo 处) ──
|
||||
// renderItems(computed)已读源经 [item] 自动失效(桶 memo 保证桶未变时 item 引用稳定 → 跳过 patch);
|
||||
// v-memo 额外补「renderItems 未读、但渲染该 item 时读了」的兄弟响应式源 —— 漏一个 → 该项局部不刷新:
|
||||
// - expandedThinkIds / expandedJsonIds / expandedLongIds:思考 pill / JSON 块 / 长文折叠展开态
|
||||
// (整体 Set 引用纳入;useToggleSet 每次 toggle 换新 Set → 引用变 → 该项重渲);
|
||||
// - tokenPopoverMsgId / editingMsgId / agentPeekLoadingId:token 面板 / user 编辑按钮 / 子代理 peek 转圈;
|
||||
// - store.state.streaming / isViewingGenerating:光标、user 编辑按钮 can-edit、流式块闸直接读的源
|
||||
// (非 renderItems 已读,须显式纳入,否则流式启停该 item 不刷新);
|
||||
// - _mdRenderKey:marked 就绪后 forceUpdate 递增、经 renderMdDecorated 读;taskTitleCache:任务 chip 标题懒加载。
|
||||
// - 流式文本源(定向 dep):流式 delta 只改 currentText→streamingBlocks→decoratedStreamingBlocks,
|
||||
// 不改任何消息 content、不改桶签名 → item 引用稳定。若此处不纳入流式源,流式 item 的 v-memo 恒命中,
|
||||
// 子树跳过 patch,气泡冻结在首个空帧(要等工具事件/收尾才整段出现)。
|
||||
// 只对「正在渲染流式块的 item」(isLastAi + streaming,即模板内 v-if 渲染 decoratedStreamingBlocks 的那一项)
|
||||
// 纳入 decoratedStreamingBlocks(每 rAF 换新数组引用 → dep 失效 → 该项重渲),其余 item 取常量 '' 占位,
|
||||
// 使 v-memo 仅在流式项失效,空闲/历史项不重渲的 P0 收益不受影响。
|
||||
// 注:vue-tsc 不解析 v-memo 表达式,抽独立函数会触发 noUnusedLocals 误报,故直接内联数组。
|
||||
|
||||
// ── S1 子代理完成卡(AiAgentFinished) ──
|
||||
// 状态→色调三态:completed 中性(常态结果不抢视线)/ truncated 警示(结果不完整)/
|
||||
// failed 错误(需关注);未知的 status 字面量兜底中性,不误报成功也不误报失败。
|
||||
@@ -1502,7 +1578,7 @@ defineExpose({
|
||||
每项外层包 .ai-msg-slot(虚拟滚动已移除,现恒渲染;
|
||||
slot 仅作 flex 子占位 + 消息分组的语义容器,gap 由 .ai-messages 承担)。
|
||||
UX 密度改造:timesep 项=相邻消息间隔 ≥30 分钟或跨天的居中时间分隔线。 -->
|
||||
<template v-for="item in renderItems" :key="item.key">
|
||||
<template v-for="item in renderItems" :key="item.key" v-memo="[item, store.state.streaming, isViewingGenerating, expandedThinkIds, expandedJsonIds, expandedLongIds, tokenPopoverMsgId, editingMsgId, agentPeekLoadingId, _mdRenderKey, taskTitleCache, item.kind === 'msg' && isLastAi(item.msg) && store.state.streaming ? decoratedStreamingBlocks : '']">
|
||||
<!-- 执行轨迹卡(完成轮次:过程内容折叠为一张,默认一行聚合,点开在卡内 rail 面板回看
|
||||
每轮思考 pill + 工具卡;头部聚合行含调用/耗时/全程 token 汇总) -->
|
||||
<div v-if="item.kind === 'trace'" class="ai-msg-slot ai-msg-slot--proc">
|
||||
@@ -1526,8 +1602,12 @@ defineExpose({
|
||||
v-for="ref in traceTaskRefs(item.traceMsgs)"
|
||||
:key="ref.taskId"
|
||||
class="ai-trace-task-chip"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:title="$t('ai.trace.taskRef', { name: taskTitleOf(ref.taskId) })"
|
||||
@click.stop="openTask(ref.taskId)"
|
||||
@keydown.enter.stop.prevent="openTask(ref.taskId)"
|
||||
@keydown.space.stop.prevent="openTask(ref.taskId)"
|
||||
>🔗 {{ taskTitleOf(ref.taskId) }}</span>
|
||||
<!-- 绝对起始时刻(mono 小字):轨迹桶已不发独立时间分隔线,时刻职责由卡头承载 -->
|
||||
<span class="ai-trace-tokens">{{ formatAbsolute(item.startTs) }}</span>
|
||||
@@ -1550,11 +1630,18 @@ defineExpose({
|
||||
<span v-if="roundTokenText(m)" class="ai-think-tokens">{{ roundTokenText(m) }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="expandedThinkIds.has(m.id) && m.content?.trim()"
|
||||
v-if="expandedThinkIds.has(m.id) && m.reasoning_content?.trim()"
|
||||
class="ai-think-full ai-md"
|
||||
v-html="renderMdDecorated(m.content)"
|
||||
v-html="renderMdDecorated(m.reasoning_content)"
|
||||
></div>
|
||||
</template>
|
||||
<!-- 轮内正文(该轮叙述):随卡默认收起(结果优先、控密度),展开轨迹卡即见全文。
|
||||
复用 .ai-think-full 的弱化样式(12px/次要色/左缩进),零新增 CSS。 -->
|
||||
<div
|
||||
v-if="m.role === 'assistant' && m.content && m.content.trim()"
|
||||
class="ai-think-full ai-md"
|
||||
v-html="renderMdDecorated(m.content)"
|
||||
></div>
|
||||
<ToolCardList
|
||||
v-if="m.toolCalls && m.toolCalls.length > 0"
|
||||
ref="toolCardListRef"
|
||||
|
||||
Reference in New Issue
Block a user