From 04acc4297d122c80d1c048d4fc8e0a602a4ee7d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Thu, 1 Oct 2026 02:41:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E8=BD=A8=E8=BF=B9=E8=81=9A=E5=90=88=E4=B8=8E?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=8D=A1=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端聚合当前轮次工具调用数,替代每轮清零的完成计数,进度文案改轮次+累计口径 完成聚合语义由执行轨迹卡承接,工具卡复读修复与默认折叠收敛 流式与工具事件配套调整,新增轮次轨迹收集 composable --- src/components/AiChat.vue | 24 +- src/components/ToolCard.vue | 29 +++ src/components/ToolCardList.vue | 49 ++-- src/components/ai/ChatInput.vue | 9 +- src/components/ai/MessageItem.vue | 15 +- src/components/ai/MessageList.vue | 247 ++++++++++++++++++--- src/composables/ai/useAiStreamingEvents.ts | 1 - src/composables/ai/useAiToolEvents.ts | 4 +- src/composables/ai/useToolCard.ts | 29 +++ src/composables/ai/useTurnTrace.ts | 114 ++++++++++ src/i18n/en/ai.ts | 25 ++- src/i18n/en/aiChat.ts | 2 +- src/i18n/zh-CN/ai.ts | 24 +- src/i18n/zh-CN/aiChat.ts | 2 +- src/stores/ai.ts | 3 - 15 files changed, 482 insertions(+), 95 deletions(-) create mode 100644 src/composables/ai/useTurnTrace.ts diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index 6e1e61c..699c198 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -46,15 +46,13 @@
- + 文案:第 {round} 轮 · 累计 {total} 次调用 · 进行中/待审批; + total 由前端聚合当前轮次 toolCalls(useTurnTrace),完成后进度条隐藏, + 完成聚合语义由 MessageList 的执行轨迹卡承接。 + 降级省略:max(总轮次)后端 AiAgentRound 未透传 max_rounds,不显。 -->
{{ agenticProgressText }} @@ -149,6 +147,7 @@ import DirAuthDialog from './ai/DirAuthDialog.vue' import { useConfirm } from '../composables/useConfirm' import { useToast } from '../composables/useToast' import { useProjectStore } from '../stores/project' +import { collectTurnStats, turnMessages } from '../composables/ai/useTurnTrace' import type { AiMessage } from '../api/types' const props = defineProps<{ @@ -220,13 +219,16 @@ const pendingApprovalCount = computed(() => store.state.pendingApprovals.length) const showAgenticProgress = computed(() => store.state.streaming && store.state.agentRound > 0, ) -// 进度文案:round 必显;pending>0 时附加待审批数(max/completed 本轮降级省略,见模板 TODO) +// 进度文案(C 状态语义化):「第 N 轮 · 累计 M 次调用 · 进行中/待审批」。 +// 累计调用数由前端聚合当前轮次消息流上的 toolCalls(后端事件无累计字段,不改协议); +// 替代旧「循环 N · X个工具完成」——旧 completedTools 每轮清零,轮初叙述期恒显 0 造成歧义。 +// 「已完成」态不出现在状态栏:完成后进度条隐藏(既有设计),完成聚合由执行轨迹卡承接。 const agenticProgressText = computed(() => { const pending = store.state.pendingApprovals.length - const completed = store.state.completedTools + const total = collectTurnStats(turnMessages(store.state.messages)).total return pending > 0 - ? t('ai.agenticProgress', { round: store.state.agentRound, pending }) - : t('ai.agenticProgressNoPending', { round: store.state.agentRound, completed }) + ? t('ai.agenticProgress', { round: store.state.agentRound, total, pending }) + : t('ai.agenticProgressNoPending', { round: store.state.agentRound, total }) }) /** 点击徽标:滚动到第一个 pending_approval 卡片(委托 MessageList.scrollToFirstPending) */ diff --git a/src/components/ToolCard.vue b/src/components/ToolCard.vue index 7ed3c8a..51c191e 100644 --- a/src/components/ToolCard.vue +++ b/src/components/ToolCard.vue @@ -18,6 +18,13 @@ {{ toolResultSummary(tc) }}
+ + {{ durationText }} ▸
@@ -149,6 +156,8 @@ import { import { parseDiffLines } from '@/composables/ai/useToolCardRender' import { useToolCardHeader } from '@/composables/ai/useToolCardHeader' import { useToolApproval } from '@/composables/ai/useToolApproval' +import { callDurationText } from '@/composables/ai/useTurnTrace' +import { formatDate } from '@/utils/time' import type { AiToolCallInfo } from '@/api/types' import ConfirmDialog from './ConfirmDialog.vue' import ToolResultBody from './ToolResultBody.vue' @@ -231,6 +240,15 @@ const isFailed = computed(() => { /** write_file 审批 diff 行解析(复用 parseDiffLines 纯函数,数据源 props.tc.diff) */ const diffLines = computed(() => parseDiffLines(props.tc.diff)) + +/** 单卡耗时(前端时间差;历史消息无 startedAt/completedAt 返空串隐藏) */ +const durationText = computed(() => callDurationText(props.tc.startedAt, props.tc.completedAt)) +/** hover 提示:绝对起止时刻(相对时长 hover 可溯具体时间) */ +const durationTitle = computed(() => { + const tc = props.tc + if (tc.startedAt == null || tc.completedAt == null) return '' + return `${formatDate(tc.startedAt)} ~ ${formatDate(tc.completedAt)}` +}) diff --git a/src/components/ai/MessageList.vue b/src/components/ai/MessageList.vue index b23150d..d9d8781 100644 --- a/src/components/ai/MessageList.vue +++ b/src/components/ai/MessageList.vue @@ -21,6 +21,7 @@ import { useMarkdown } from '../../composables/useMarkdown' import { useMessageScroll } from '../../composables/ai/useMessageScroll' import { useStreamRenderer } from '../../composables/ai/useStreamRenderer' import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations' +import { collectTurnStats, statsTail, formatDuration, type TurnCallStats } from '../../composables/ai/useTurnTrace' import ToolCardList from '../ToolCardList.vue' import MessageItem from './MessageItem.vue' import EmptyState from './EmptyState.vue' @@ -71,6 +72,16 @@ function isLastAi(msg: AiMessage): boolean { return msg.role === 'assistant' && msgs[msgs.length - 1]?.id === msg.id } +// UX-09:末条 user 用倒序判定而非"最后一条消息是否 user"——AI 已回复后编辑按钮仍应挂在触发消息上 +function isLastUser(msg: AiMessage): boolean { + if (msg.role !== 'user') return false + const msgs = store.state.messages + for (let i = msgs.length - 1; i >= 0; i--) { + if (msgs[i].role === 'user') return msgs[i].id === msg.id + } + return false +} + /** * 是否渲染某条消息(供 .ai-msg-slot v-if 守卫)。 * @@ -719,16 +730,109 @@ function toggleJson(id: string): void { expandedJsonIds.value = next } +// A 过程压缩:执行轨迹展开态 +// 完成轮次的过程文本+工具卡默认折叠为一张轨迹卡;用户手动展开后记入此集合, +// 不再被自动收回(换轮次/切会话回来都保持,会话切换时整体重置)。 +const expandedTraceIds = ref>(new Set()) +function toggleTrace(key: string): void { + const next = new Set(expandedTraceIds.value) + if (next.has(key)) next.delete(key) + else next.add(key) + expandedTraceIds.value = next +} + +/** 轨迹卡聚合行:「N 次调用 · 成功 x 失败 y · 总耗时 T」(零值段省略;无工具调用显「过程记录」) */ +function traceAggText(stats: TurnCallStats, startTs: number, endTs: number): string { + const parts: string[] = [] + parts.push(stats.total > 0 ? t('ai.trace.calls', { n: stats.total }) : t('ai.trace.processOnly')) + const tail = statsTail(stats) + if (tail) parts.push(tail) + const dur = formatDuration(endTs - startTs) + if (dur) parts.push(t('ai.trace.duration', { time: dur })) + return parts.join(' · ') +} + +/** 轨迹卡 hover:绝对起止时刻(相对时长之外可溯源具体时间) */ +function traceHoverTitle(startTs: number, endTs: number): string { + return `${formatDate(startTs)} ~ ${formatDate(endTs)}` +} + +/** 执行中实时步骤摘要:「✓ N 次调用完成 · 正在 {工具} · ⏳ N 项待审批」(由最近工具事件聚合) */ +function stepText(s: TurnCallStats): string { + const parts: string[] = [] + const done = s.ok + s.fail + if (done > 0) parts.push(t('ai.trace.stepDone', { n: done })) + if (s.fail > 0) parts.push(t('ai.trace.fail', { n: s.fail })) + if (s.rejected > 0) parts.push(t('ai.trace.rejected', { n: s.rejected })) + if (s.running > 0 && s.runningName) parts.push(t('ai.trace.stepRunning', { name: s.runningName })) + if (s.pending > 0) parts.push(t('ai.trace.stepPending', { n: s.pending })) + return parts.join(' · ') || t('ai.trace.processOnly') +} + type RenderItem = | { kind: 'msg'; key: string; msg: AiMessage } | { kind: 'sep'; key: string; seg: MessageSegment & { key: string } } + // A 过程压缩:完成轮次的执行轨迹卡(traceMsgs 为折叠在内的过程消息)与执行中的实时步骤摘要 + | { kind: 'trace'; key: string; stats: TurnCallStats; startTs: number; endTs: number; traceMsgs: AiMessage[]; expanded: boolean } + | { kind: 'step'; key: string; stats: TurnCallStats } +/** + * 渲染项(A 过程压缩):在归档分段之上叠加轮次折叠。 + * + * 轮次 = 末条 user 之后的连续 assistant 消息(错误气泡/压缩摘要/归档分段不进桶,原样渲染)。 + * - 执行中:仅末条消息(当前步骤)展开,先前步骤收进一行实时摘要(step 项,由工具事件聚合)。 + * - 完成:轮次内除最终答复外的过程消息折叠为一张执行轨迹卡(trace 项,默认一行聚合), + * 点开回看;用户手动展开记入 expandedTraceIds 不被收回。正文区只留最终答复。 + * 无过程消息的轮次(单轮问答)零改动原样渲染。 + */ const renderItems = computed(() => { const items: RenderItem[] = [] + let bucket: AiMessage[] = [] + + const flushBucket = () => { + const msgs = bucket.filter(shouldRenderMsg) + bucket = [] + if (msgs.length === 0) 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] + const traceMsgs = msgs.slice(0, msgs.indexOf(finalMsg)) + + if (isViewingGenerating.value) { + // 执行中:先前步骤收进一行实时摘要,仅当前步骤(末条)展开 + if (traceMsgs.length > 0) { + items.push({ kind: 'step', key: 'step-' + traceMsgs[0].id, stats: collectTurnStats(traceMsgs) }) + } + items.push({ kind: 'msg', key: 'm-' + finalMsg.id, msg: finalMsg }) + return + } + // 完成:轨迹折叠为一张卡,展开时再 emit 段内过程消息(与归档分段同一扁平化模式) + if (traceMsgs.length > 0) { + const key = 'trace-' + traceMsgs[0].id + const expanded = expandedTraceIds.value.has(key) + items.push({ + kind: 'trace', key, + stats: collectTurnStats(traceMsgs), + startTs: traceMsgs[0].timestamp, + endTs: finalMsg.timestamp, + traceMsgs, expanded, + }) + if (expanded) { + for (const m of traceMsgs) { + items.push({ kind: 'msg', key: 'tm-' + m.id, msg: m }) + } + } + } + items.push({ kind: 'msg', key: 'm-' + finalMsg.id, msg: finalMsg }) + } + for (const seg of messageSegments.value) { - if (seg.kind === 'normal') { - items.push({ kind: 'msg', key: 'm-' + seg.msg.id, msg: seg.msg }) - } else { + if (seg.kind !== 'normal') { + flushBucket() // 折叠段:总 emit sep;展开时再 emit 段内每条 msg items.push({ kind: 'sep', key: seg.key, seg }) if (expandedSegmentIds.value.has(seg.key)) { @@ -736,8 +840,18 @@ const renderItems = computed(() => { items.push({ kind: 'msg', key: 'e-' + seg.key + '-' + m.id, msg: m }) } } + continue + } + const m = seg.msg + // assistant 非错误非压缩进轮次桶(compressed 用户诉求不折叠,原样展开) + if (m.role === 'assistant' && !m.isError && m.status !== 'compressed') { + bucket.push(m) + } else { + flushBucket() + items.push({ kind: 'msg', key: 'm-' + m.id, msg: m }) } } + flushBucket() return items }) @@ -745,6 +859,8 @@ const renderItems = computed(() => { watch(() => store.state.activeConversationId, () => { // 切会话重置滚动边沿检测 + 跟随意图(新会话默认跟随底部) setFollowing(true) + // 切会话重置执行轨迹展开态(展开记忆按会话隔离,防跨会话 key 撞车误展开) + expandedTraceIds.value = new Set() // BUG-260624-01(消息重叠根治·论证 workflow 完整性高优):MessageList 是持久单实例(AiChat.vue // 无 :key/v-if 卸载),实例级 streamingBlocks(:69)跨会话/跨轮残留是消息重叠主根因之一。 // 钉在「会话切换」这一确切时机清,根治两条残留路径:① switchConversation(useAiConversations) @@ -797,14 +913,34 @@ defineExpose({