优化: 对话执行轨迹聚合与工具卡交互
前端聚合当前轮次工具调用数,替代每轮清零的完成计数,进度文案改轮次+累计口径 完成聚合语义由执行轨迹卡承接,工具卡复读修复与默认折叠收敛 流式与工具事件配套调整,新增轮次轨迹收集 composable
This commit is contained in:
@@ -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<Set<string>>(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<RenderItem[]>(() => {
|
||||
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<RenderItem[]>(() => {
|
||||
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<RenderItem[]>(() => {
|
||||
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({
|
||||
<!-- 消息列表(F-15 阶段2:按 renderItems 扁平渲染 — 折叠段 emit 'sep' 项,
|
||||
正常消息 + 展开的折叠段内消息 emit 'msg' 项。连续 archived_segment/compressed
|
||||
合并一条分隔条,点击展开/收起看原文)
|
||||
A 过程压缩:trace 项=完成轮次执行轨迹卡,step 项=执行中实时步骤摘要,均先于 msg 分支拦截。
|
||||
UX-2025-19: 每项外层包 .ai-msg-slot(B-260618-01 虚拟滚动已移除,现恒渲染;
|
||||
slot 仅作 flex 子占位 + 消息分组的语义容器,gap 由 .ai-messages 承担)。 -->
|
||||
<template v-for="item in renderItems" :key="item.key">
|
||||
<!-- 执行轨迹卡(完成轮次:过程文本+工具卡折叠为一张,默认一行聚合,点开回看;
|
||||
用户手动展开记入 expandedTraceIds 不被自动收回) -->
|
||||
<div v-if="item.kind === 'trace'" class="ai-msg-slot">
|
||||
<div class="ai-trace-card" :class="{ 'ai-trace-card--open': item.expanded }">
|
||||
<div class="ai-trace-head" @click="toggleTrace(item.key)">
|
||||
<span class="ai-trace-chevron" :class="{ 'is-open': item.expanded }">▸</span>
|
||||
<span class="ai-trace-title">{{ $t('ai.trace.title') }}</span>
|
||||
<span class="ai-trace-agg" :title="traceHoverTitle(item.startTs, item.endTs)">{{ traceAggText(item.stats, item.startTs, item.endTs) }}</span>
|
||||
<span class="ai-trace-toggle">{{ item.expanded ? $t('ai.trace.collapse') : $t('ai.trace.expand') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 执行中实时步骤摘要(已完成步骤收进一行,由最近工具事件聚合;仅当前步骤展开) -->
|
||||
<div v-else-if="item.kind === 'step'" class="ai-msg-slot">
|
||||
<div class="ai-step-line">
|
||||
<span class="ai-step-dot"></span>
|
||||
<span class="ai-step-text">{{ stepText(item.stats) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- B-260618-19: 纯空 assistant(无 content/error/toolCalls 且非末条流式)整 slot 不渲染,
|
||||
消除空 div + flex gap 堆积的大片空白;sep 始终渲染;有 toolCalls 的空 assistant
|
||||
仍渲染(工具卡可见——修复 agentic 工具轮 LLM 无文本时工具卡消失)。 -->
|
||||
<div
|
||||
v-if="item.kind === 'sep' ? true : shouldRenderMsg(item.msg)"
|
||||
v-else-if="item.kind === 'sep' ? true : shouldRenderMsg(item.msg)"
|
||||
class="ai-msg-slot"
|
||||
>
|
||||
<!-- 折叠分隔条(归档段)— 轻量,始终渲染 -->
|
||||
@@ -849,7 +985,9 @@ defineExpose({
|
||||
<MessageItem
|
||||
v-else-if="item.msg.role === 'user'"
|
||||
:msg="item.msg"
|
||||
:can-edit="isLastUser(item.msg) && !store.state.streaming && item.msg.id !== editingMsgId"
|
||||
@copy="copyMsgContent"
|
||||
@start-edit="(m) => emit('start-edit', m)"
|
||||
/>
|
||||
|
||||
<!-- AI 消息 -->
|
||||
@@ -1299,31 +1437,6 @@ defineExpose({
|
||||
right: 30px;
|
||||
}
|
||||
|
||||
/* ── 末条 user 消息编辑按钮(hover 显示,回填输入框编辑重生成) ── */
|
||||
.ai-msg-edit-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s, background 0.2s;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-end;
|
||||
}
|
||||
.ai-msg-user:hover .ai-msg-edit-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
.ai-msg-edit-btn:hover {
|
||||
background: var(--df-bg-hover, rgba(0, 0, 0, 0.06));
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
/* ── UX-02/消息操作栏(hover 显示 / 错误气泡常显) ── */
|
||||
.ai-msg-actions {
|
||||
display: flex;
|
||||
@@ -1549,4 +1662,80 @@ defineExpose({
|
||||
.ai-msg-segment-icon { flex-shrink: 0; }
|
||||
.ai-msg-segment-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ai-msg-segment-toggle { flex-shrink: 0; font-size: 10px; opacity: 0.8; }
|
||||
|
||||
/* A 过程压缩:执行轨迹卡(完成轮次过程内容折叠为一张,视觉对齐 .ai-msg-segment 弱化风格) */
|
||||
.ai-trace-card {
|
||||
max-width: var(--df-msg-max-width);
|
||||
border: 0.5px dashed color-mix(in srgb, var(--df-text-dim) 35%, transparent);
|
||||
border-radius: var(--df-radius);
|
||||
background: color-mix(in srgb, var(--df-text-dim) 6%, transparent);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s var(--df-ease), background 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-trace-card:hover {
|
||||
border-color: color-mix(in srgb, var(--df-accent) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--df-accent) 5%, transparent);
|
||||
}
|
||||
.ai-trace-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--df-text-secondary);
|
||||
}
|
||||
.ai-trace-chevron {
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
transition: transform 0.15s var(--df-ease);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-trace-chevron.is-open { transform: rotate(90deg); }
|
||||
.ai-trace-title { font-weight: 600; flex-shrink: 0; }
|
||||
.ai-trace-agg {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
.ai-trace-toggle { flex-shrink: 0; font-size: 10px; color: var(--df-accent); opacity: 0.85; }
|
||||
|
||||
/* 执行中实时步骤摘要(已完成步骤收进一行,accent 呼吸点对齐状态栏 .ai-agentic-progress) */
|
||||
.ai-step-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: var(--df-msg-max-width);
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--df-radius);
|
||||
background: color-mix(in srgb, var(--df-accent) 7%, transparent);
|
||||
font-size: 11px;
|
||||
color: var(--df-text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
.ai-step-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-accent);
|
||||
flex-shrink: 0;
|
||||
animation: ai-step-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.ai-step-text {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10.5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@keyframes ai-step-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user