优化: 对话执行轨迹聚合与工具卡交互

前端聚合当前轮次工具调用数,替代每轮清零的完成计数,进度文案改轮次+累计口径
完成聚合语义由执行轨迹卡承接,工具卡复读修复与默认折叠收敛
流式与工具事件配套调整,新增轮次轨迹收集 composable
This commit is contained in:
lxy
2026-10-01 02:41:05 +08:00
parent e7cd035c62
commit 04acc4297d
15 changed files with 482 additions and 95 deletions
+13 -11
View File
@@ -46,15 +46,13 @@
<!-- Input -->
<div class="ai-input-area">
<!-- AE-2025-07: Agentic 循环进度条
<!-- AE-2025-07: Agentic 循环进度条(C 状态语义化)
仅 agentic 多轮(streaming 且收到过 AiAgentRound round>0 事件)显示;
单轮问答 agentRound 恒 0,不显示(避免误显)。
可用字段:round(后端 AiAgentRound.round)、pendingApprovals(前端 state)。
降级省略:
- max(总轮次):后端 AiAgentRound 事件未透传 max_rounds,本轮不显;
TODO 后端 AiAgentRound 补 max_rounds 字段后新增 *WithMax i18n key + 条件渲染。
- completed(已完成工具数):前端无累计计数器,本轮不显;
TODO 加 completed counter(AiToolCallCompleted 时按当前对话累计)。 -->
文案:第 {round} 轮 · 累计 {total} 次调用 · 进行中/待审批;
total 由前端聚合当前轮次 toolCalls(useTurnTrace),完成后进度条隐藏,
完成聚合语义由 MessageList 的执行轨迹卡承接。
降级省略:max(总轮次)后端 AiAgentRound 未透传 max_rounds,不显。 -->
<div v-if="showAgenticProgress" class="ai-agentic-progress">
<span class="ai-agentic-progress-dot"></span>
<span class="ai-agentic-progress-text">{{ agenticProgressText }}</span>
@@ -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) */
+29
View File
@@ -18,6 +18,13 @@
<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--' + (isFailed ? 'failed' : tc.status)"></span>
<!-- 统一卡形态第4要素:耗时(前端 startedAt/completedAt 差;历史消息无时间戳隐藏不显 0)。
hover 显绝对起止时刻,相对时间不再占卡片行(B 聚合卡统一)。 -->
<span
v-if="durationText"
class="ai-tool-duration"
:title="durationTitle"
>{{ durationText }}</span>
<span v-if="!shouldKeepOpen(tc)" class="ai-tool-collapse-hint" :class="{ 'is-open': isExpanded }">▸</span>
</div>
@@ -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)}`
})
</script>
<style scoped>
@@ -299,6 +317,17 @@ const diffLines = computed(() => parseDiffLines(props.tc.diff))
/* -- Status dot (脉冲动画) -- */
.ai-tool-status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
/* -- 卡片行耗时(统一卡形态第4要素:弱化等宽小字,不与工具名/摘要争焦点) -- */
.ai-tool-duration {
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
opacity: 0.75;
flex-shrink: 0;
cursor: default;
user-select: none;
}
.ai-tool-status-dot--running {
background: var(--df-info); animation: pulseGlow 1.5s ease-in-out infinite; box-shadow: 0 0 6px var(--df-info);
}
+17 -32
View File
@@ -14,12 +14,12 @@
</div>
<template v-for="(group, gi) in groupedToolCalls" :key="group.name">
<!-- 分组标题:同名工具 >=2 个时显示 -->
<!-- 分组标题:同名工具 >=2 个时显示(B 聚合卡统一:默认行「N 次调用 · 成功 x 失败 y」,
点开逐条;成功/失败与状态点同一套 isToolFailure 判定,文案走 ai.trace 同源 key) -->
<div v-if="group.calls.length >= 2" class="ai-tool-group-header" @click="toggleGroupCollapse(gi)">
<span class="ai-tool-group-icon" v-html="groupIcon(group.name)"></span>
<span class="ai-tool-group-name">{{ groupDisplayName(group) }}</span>
<span class="ai-tool-group-count">{{ group.calls.length }}</span>
<span class="ai-tool-group-files">{{ groupFileSummary(group) }}</span>
<span class="ai-tool-group-agg">{{ groupAggText(group) }}</span>
<span class="ai-tool-group-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }">▸</span>
<!-- 全部展开/收起(U-260618:从 topbar 下沉到首个可折叠分组标题行尾巴,与 run command 同行) -->
<div
@@ -52,6 +52,7 @@ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import ToolCard from './ToolCard.vue'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { aggregateCalls, statsTail } from '@/composables/ai/useTurnTrace'
import type { AiToolCallInfo } from '@/api/types'
// useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key)
@@ -227,24 +228,12 @@ function groupDisplayName(group: ToolCallGroup): string {
return group.name.replace(/_/g, ' ')
}
/** 从工具调用参数中提取路径摘要(取前 3 个文件名)。
* 供分组标题行展示,让用户一眼知道影响了哪些文件。*/
function groupFileSummary(group: ToolCallGroup): string {
const paths: string[] = []
for (const tc of group.calls) {
const args = tc.args as Record<string, unknown> | undefined
const p = args?.path
if (typeof p === 'string' && p.trim()) {
// 取最后一段文件名(或最后两级目录)
const parts = p.trim().split(/[/\\]/)
const short = parts.length >= 2
? parts.slice(-2).join('/')
: parts[parts.length - 1]
if (!paths.includes(short)) paths.push(short)
if (paths.length >= 3) break
}
}
return paths.length ? ' · ' + paths.join(' · ') : ''
/** 分组聚合行文案:「N 次调用 · 成功 x 失败 y」(零值项省略;与单卡状态点同源 isToolFailure 判定) */
function groupAggText(group: ToolCallGroup): string {
const s = aggregateCalls(group.calls)
const tail = statsTail(s)
const head = t('ai.trace.calls', { n: s.total })
return tail ? `${head} · ${tail}` : head
}
/**
@@ -478,18 +467,14 @@ function groupIcon(name: string): string {
font-weight: 500;
color: var(--df-text-secondary);
}
.ai-tool-group-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
/* 聚合行「N 次调用 · 成功 x 失败 y」:弱化等宽,与单卡耗时同视觉层级 */
.ai-tool-group-agg {
font-family: var(--df-font-mono);
font-size: 10px;
font-weight: 600;
background: var(--df-accent-bg);
color: var(--df-accent);
color: var(--df-text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-tool-group-chevron {
/* 移除 margin-left:auto,让全部展开/收起按钮(global-toggle)占据行尾右对齐 */
+5 -4
View File
@@ -283,16 +283,17 @@ async function onDrop(e: DragEvent): Promise<void> {
/** 发送可用:有文本或图片或已选技能(纯技能调用允许空文本) */
const canSend = computed(() => inputText.value.trim().length > 0 || pendingImages.value.length > 0 || !!pendingSkill.value)
// 按钮状态:streaming=true 且 textIdle=false(最近 300ms 有新 delta) → 'stop'(红);
// 按钮状态:streaming=true 且(本会话 conv_state='generating' 或最近 300ms 有文本 delta)→ 'stop'(红);
// 否则 → 'idle'(白)。textIdle 是 reactive ref,在 AiTextDelta 中每 delta 复位 false,
// 300ms 无新 delta 后 setTimeout 置 true。不与 streaming 绑定——文本显示完后 300ms 按钮白,
// 不等 AiCompleted,streaming 持续 true 也不影响。
// 300ms 无新 delta 后 setTimeout 置 true。仅靠 textIdle 会漏掉工具执行/轮间 LLM 等待期
// (30s heartbeat 无 delta,按钮回落发送图标,生成失控不可干预),故补 conv_state 判定;
// textIdle 兜底保留,防 conv_state 尚未追踪(null)瞬间按钮闪断。
const stopBtnState = computed<'idle' | 'stop' | 'stopping' | 'retry'>(() => {
const convId = store.state.activeConversationId
const cs = getConvState(convId)
if (cs === 'stopping') return 'stopping'
if (cs === 'error') return 'retry'
const textActive = store.state.streaming && !textIdle.value
const textActive = store.state.streaming && (cs === 'generating' || !textIdle.value)
return textActive ? 'stop' : 'idle'
})
+12 -3
View File
@@ -15,10 +15,13 @@ const { t } = useI18n()
defineProps<{
msg: AiMessage
/** 是否显示编辑按钮(父合成:末条 user + 非生成中 + 非编辑中) */
canEdit?: boolean
}>()
const emit = defineEmits<{
(e: 'copy', msg: AiMessage): void
(e: 'start-edit', msg: AiMessage): void
}>()
// ── Input Augmentation: 按 mention 区间切段 ──
@@ -95,6 +98,10 @@ function imgAlt(img: ImageContentPart): string {
<img v-for="(img, idx) in msgImageParts(msg)" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" />
</div>
</template>
<!-- UX-09:末条 user 消息编辑按钮(hover 显示),点击经 start-edit 链回填输入框编辑重发 -->
<button v-if="canEdit" class="ai-msg-edit-btn" :title="t('aiChat.editMessage')" @click.stop="emit('start-edit', msg)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button class="ai-copy-btn" :title="t('aiChat.copyMsg')" @click.stop="emit('copy', msg)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
@@ -158,8 +165,9 @@ function imgAlt(img: ImageContentPart): string {
max-height: 200px;
border-radius: 6px;
}
/* 复制按钮(hover 显示) */
.ai-copy-btn {
/* 复制/编辑按钮(hover 显示,同栏同视觉;编辑按钮仅 canEdit 时渲染) */
.ai-copy-btn,
.ai-msg-edit-btn {
opacity: 0;
transition: opacity 0.15s;
background: none;
@@ -168,7 +176,8 @@ function imgAlt(img: ImageContentPart): string {
padding: 2px;
color: var(--df-text-dim);
}
.ai-msg-user:hover .ai-copy-btn {
.ai-msg-user:hover .ai-copy-btn,
.ai-msg-user:hover .ai-msg-edit-btn {
opacity: 1;
}
</style>
+218 -29
View File
@@ -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>