优化: 对话执行轨迹聚合与工具卡交互
前端聚合当前轮次工具调用数,替代每轮清零的完成计数,进度文案改轮次+累计口径 完成聚合语义由执行轨迹卡承接,工具卡复读修复与默认折叠收敛 流式与工具事件配套调整,新增轮次轨迹收集 composable
This commit is contained in:
+13
-11
@@ -46,15 +46,13 @@
|
|||||||
|
|
||||||
<!-- Input -->
|
<!-- Input -->
|
||||||
<div class="ai-input-area">
|
<div class="ai-input-area">
|
||||||
<!-- AE-2025-07: Agentic 循环进度条
|
<!-- AE-2025-07: Agentic 循环进度条(C 状态语义化)
|
||||||
仅 agentic 多轮(streaming 且收到过 AiAgentRound round>0 事件)显示;
|
仅 agentic 多轮(streaming 且收到过 AiAgentRound round>0 事件)显示;
|
||||||
单轮问答 agentRound 恒 0,不显示(避免误显)。
|
单轮问答 agentRound 恒 0,不显示(避免误显)。
|
||||||
可用字段:round(后端 AiAgentRound.round)、pendingApprovals(前端 state)。
|
文案:第 {round} 轮 · 累计 {total} 次调用 · 进行中/待审批;
|
||||||
降级省略:
|
total 由前端聚合当前轮次 toolCalls(useTurnTrace),完成后进度条隐藏,
|
||||||
- max(总轮次):后端 AiAgentRound 事件未透传 max_rounds,本轮不显;
|
完成聚合语义由 MessageList 的执行轨迹卡承接。
|
||||||
TODO 后端 AiAgentRound 补 max_rounds 字段后新增 *WithMax i18n key + 条件渲染。
|
降级省略:max(总轮次)后端 AiAgentRound 未透传 max_rounds,不显。 -->
|
||||||
- completed(已完成工具数):前端无累计计数器,本轮不显;
|
|
||||||
TODO 加 completed counter(AiToolCallCompleted 时按当前对话累计)。 -->
|
|
||||||
<div v-if="showAgenticProgress" class="ai-agentic-progress">
|
<div v-if="showAgenticProgress" class="ai-agentic-progress">
|
||||||
<span class="ai-agentic-progress-dot"></span>
|
<span class="ai-agentic-progress-dot"></span>
|
||||||
<span class="ai-agentic-progress-text">{{ agenticProgressText }}</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 { useConfirm } from '../composables/useConfirm'
|
||||||
import { useToast } from '../composables/useToast'
|
import { useToast } from '../composables/useToast'
|
||||||
import { useProjectStore } from '../stores/project'
|
import { useProjectStore } from '../stores/project'
|
||||||
|
import { collectTurnStats, turnMessages } from '../composables/ai/useTurnTrace'
|
||||||
import type { AiMessage } from '../api/types'
|
import type { AiMessage } from '../api/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -220,13 +219,16 @@ const pendingApprovalCount = computed(() => store.state.pendingApprovals.length)
|
|||||||
const showAgenticProgress = computed(() =>
|
const showAgenticProgress = computed(() =>
|
||||||
store.state.streaming && store.state.agentRound > 0,
|
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 agenticProgressText = computed(() => {
|
||||||
const pending = store.state.pendingApprovals.length
|
const pending = store.state.pendingApprovals.length
|
||||||
const completed = store.state.completedTools
|
const total = collectTurnStats(turnMessages(store.state.messages)).total
|
||||||
return pending > 0
|
return pending > 0
|
||||||
? t('ai.agenticProgress', { round: store.state.agentRound, pending })
|
? t('ai.agenticProgress', { round: store.state.agentRound, total, pending })
|
||||||
: t('ai.agenticProgressNoPending', { round: store.state.agentRound, completed })
|
: t('ai.agenticProgressNoPending', { round: store.state.agentRound, total })
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 点击徽标:滚动到第一个 pending_approval 卡片(委托 MessageList.scrollToFirstPending) */
|
/** 点击徽标:滚动到第一个 pending_approval 卡片(委托 MessageList.scrollToFirstPending) */
|
||||||
|
|||||||
@@ -18,6 +18,13 @@
|
|||||||
<span v-else-if="toolResultSummary(tc)" class="ai-tool-sub">{{ toolResultSummary(tc) }}</span>
|
<span v-else-if="toolResultSummary(tc)" class="ai-tool-sub">{{ toolResultSummary(tc) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + (isFailed ? 'failed' : tc.status)"></span>
|
<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>
|
<span v-if="!shouldKeepOpen(tc)" class="ai-tool-collapse-hint" :class="{ 'is-open': isExpanded }">▸</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -149,6 +156,8 @@ import {
|
|||||||
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
|
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
|
||||||
import { useToolCardHeader } from '@/composables/ai/useToolCardHeader'
|
import { useToolCardHeader } from '@/composables/ai/useToolCardHeader'
|
||||||
import { useToolApproval } from '@/composables/ai/useToolApproval'
|
import { useToolApproval } from '@/composables/ai/useToolApproval'
|
||||||
|
import { callDurationText } from '@/composables/ai/useTurnTrace'
|
||||||
|
import { formatDate } from '@/utils/time'
|
||||||
import type { AiToolCallInfo } from '@/api/types'
|
import type { AiToolCallInfo } from '@/api/types'
|
||||||
import ConfirmDialog from './ConfirmDialog.vue'
|
import ConfirmDialog from './ConfirmDialog.vue'
|
||||||
import ToolResultBody from './ToolResultBody.vue'
|
import ToolResultBody from './ToolResultBody.vue'
|
||||||
@@ -231,6 +240,15 @@ const isFailed = computed(() => {
|
|||||||
|
|
||||||
/** write_file 审批 diff 行解析(复用 parseDiffLines 纯函数,数据源 props.tc.diff) */
|
/** write_file 审批 diff 行解析(复用 parseDiffLines 纯函数,数据源 props.tc.diff) */
|
||||||
const diffLines = computed(() => 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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -299,6 +317,17 @@ const diffLines = computed(() => parseDiffLines(props.tc.diff))
|
|||||||
|
|
||||||
/* -- Status dot (脉冲动画) -- */
|
/* -- Status dot (脉冲动画) -- */
|
||||||
.ai-tool-status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
.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 {
|
.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);
|
background: var(--df-info); animation: pulseGlow 1.5s ease-in-out infinite; box-shadow: 0 0 6px var(--df-info);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-for="(group, gi) in groupedToolCalls" :key="group.name">
|
<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)">
|
<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-icon" v-html="groupIcon(group.name)"></span>
|
||||||
<span class="ai-tool-group-name">{{ groupDisplayName(group) }}</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-agg">{{ groupAggText(group) }}</span>
|
||||||
<span class="ai-tool-group-files">{{ groupFileSummary(group) }}</span>
|
|
||||||
<span class="ai-tool-group-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }">▸</span>
|
<span class="ai-tool-group-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }">▸</span>
|
||||||
<!-- 全部展开/收起(U-260618:从 topbar 下沉到首个可折叠分组标题行尾巴,与 run command 同行) -->
|
<!-- 全部展开/收起(U-260618:从 topbar 下沉到首个可折叠分组标题行尾巴,与 run command 同行) -->
|
||||||
<div
|
<div
|
||||||
@@ -52,6 +52,7 @@ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import ToolCard from './ToolCard.vue'
|
import ToolCard from './ToolCard.vue'
|
||||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||||
|
import { aggregateCalls, statsTail } from '@/composables/ai/useTurnTrace'
|
||||||
import type { AiToolCallInfo } from '@/api/types'
|
import type { AiToolCallInfo } from '@/api/types'
|
||||||
|
|
||||||
// useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key)
|
// 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, ' ')
|
return group.name.replace(/_/g, ' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从工具调用参数中提取路径摘要(取前 3 个文件名)。
|
/** 分组聚合行文案:「N 次调用 · 成功 x 失败 y」(零值项省略;与单卡状态点同源 isToolFailure 判定) */
|
||||||
* 供分组标题行展示,让用户一眼知道影响了哪些文件。*/
|
function groupAggText(group: ToolCallGroup): string {
|
||||||
function groupFileSummary(group: ToolCallGroup): string {
|
const s = aggregateCalls(group.calls)
|
||||||
const paths: string[] = []
|
const tail = statsTail(s)
|
||||||
for (const tc of group.calls) {
|
const head = t('ai.trace.calls', { n: s.total })
|
||||||
const args = tc.args as Record<string, unknown> | undefined
|
return tail ? `${head} · ${tail}` : head
|
||||||
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(' · ') : ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -478,18 +467,14 @@ function groupIcon(name: string): string {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--df-text-secondary);
|
color: var(--df-text-secondary);
|
||||||
}
|
}
|
||||||
.ai-tool-group-count {
|
/* 聚合行「N 次调用 · 成功 x 失败 y」:弱化等宽,与单卡耗时同视觉层级 */
|
||||||
display: inline-flex;
|
.ai-tool-group-agg {
|
||||||
align-items: center;
|
font-family: var(--df-font-mono);
|
||||||
justify-content: center;
|
|
||||||
min-width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
padding: 0 4px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
color: var(--df-text-dim);
|
||||||
background: var(--df-accent-bg);
|
overflow: hidden;
|
||||||
color: var(--df-accent);
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.ai-tool-group-chevron {
|
.ai-tool-group-chevron {
|
||||||
/* 移除 margin-left:auto,让全部展开/收起按钮(global-toggle)占据行尾右对齐 */
|
/* 移除 margin-left:auto,让全部展开/收起按钮(global-toggle)占据行尾右对齐 */
|
||||||
|
|||||||
@@ -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)
|
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,
|
// 否则 → 'idle'(白)。textIdle 是 reactive ref,在 AiTextDelta 中每 delta 复位 false,
|
||||||
// 300ms 无新 delta 后 setTimeout 置 true。不与 streaming 绑定——文本显示完后 300ms 按钮白,
|
// 300ms 无新 delta 后 setTimeout 置 true。仅靠 textIdle 会漏掉工具执行/轮间 LLM 等待期
|
||||||
// 不等 AiCompleted,streaming 持续 true 也不影响。
|
// (30s heartbeat 无 delta,按钮回落发送图标,生成失控不可干预),故补 conv_state 判定;
|
||||||
|
// textIdle 兜底保留,防 conv_state 尚未追踪(null)瞬间按钮闪断。
|
||||||
const stopBtnState = computed<'idle' | 'stop' | 'stopping' | 'retry'>(() => {
|
const stopBtnState = computed<'idle' | 'stop' | 'stopping' | 'retry'>(() => {
|
||||||
const convId = store.state.activeConversationId
|
const convId = store.state.activeConversationId
|
||||||
const cs = getConvState(convId)
|
const cs = getConvState(convId)
|
||||||
if (cs === 'stopping') return 'stopping'
|
if (cs === 'stopping') return 'stopping'
|
||||||
if (cs === 'error') return 'retry'
|
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'
|
return textActive ? 'stop' : 'idle'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,13 @@ const { t } = useI18n()
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
msg: AiMessage
|
msg: AiMessage
|
||||||
|
/** 是否显示编辑按钮(父合成:末条 user + 非生成中 + 非编辑中) */
|
||||||
|
canEdit?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'copy', msg: AiMessage): void
|
(e: 'copy', msg: AiMessage): void
|
||||||
|
(e: 'start-edit', msg: AiMessage): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// ── Input Augmentation: 按 mention 区间切段 ──
|
// ── 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" />
|
<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>
|
</div>
|
||||||
</template>
|
</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)">
|
<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>
|
<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>
|
</button>
|
||||||
@@ -158,8 +165,9 @@ function imgAlt(img: ImageContentPart): string {
|
|||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
/* 复制按钮(hover 显示) */
|
/* 复制/编辑按钮(hover 显示,同栏同视觉;编辑按钮仅 canEdit 时渲染) */
|
||||||
.ai-copy-btn {
|
.ai-copy-btn,
|
||||||
|
.ai-msg-edit-btn {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
background: none;
|
background: none;
|
||||||
@@ -168,7 +176,8 @@ function imgAlt(img: ImageContentPart): string {
|
|||||||
padding: 2px;
|
padding: 2px;
|
||||||
color: var(--df-text-dim);
|
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;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { useMarkdown } from '../../composables/useMarkdown'
|
|||||||
import { useMessageScroll } from '../../composables/ai/useMessageScroll'
|
import { useMessageScroll } from '../../composables/ai/useMessageScroll'
|
||||||
import { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
|
import { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
|
||||||
import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations'
|
import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations'
|
||||||
|
import { collectTurnStats, statsTail, formatDuration, type TurnCallStats } from '../../composables/ai/useTurnTrace'
|
||||||
import ToolCardList from '../ToolCardList.vue'
|
import ToolCardList from '../ToolCardList.vue'
|
||||||
import MessageItem from './MessageItem.vue'
|
import MessageItem from './MessageItem.vue'
|
||||||
import EmptyState from './EmptyState.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
|
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 守卫)。
|
* 是否渲染某条消息(供 .ai-msg-slot v-if 守卫)。
|
||||||
*
|
*
|
||||||
@@ -719,16 +730,109 @@ function toggleJson(id: string): void {
|
|||||||
expandedJsonIds.value = next
|
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 =
|
type RenderItem =
|
||||||
| { kind: 'msg'; key: string; msg: AiMessage }
|
| { kind: 'msg'; key: string; msg: AiMessage }
|
||||||
| { kind: 'sep'; key: string; seg: MessageSegment & { key: string } }
|
| { 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 renderItems = computed<RenderItem[]>(() => {
|
||||||
const items: 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) {
|
for (const seg of messageSegments.value) {
|
||||||
if (seg.kind === 'normal') {
|
if (seg.kind !== 'normal') {
|
||||||
items.push({ kind: 'msg', key: 'm-' + seg.msg.id, msg: seg.msg })
|
flushBucket()
|
||||||
} else {
|
|
||||||
// 折叠段:总 emit sep;展开时再 emit 段内每条 msg
|
// 折叠段:总 emit sep;展开时再 emit 段内每条 msg
|
||||||
items.push({ kind: 'sep', key: seg.key, seg })
|
items.push({ kind: 'sep', key: seg.key, seg })
|
||||||
if (expandedSegmentIds.value.has(seg.key)) {
|
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 })
|
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
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -745,6 +859,8 @@ const renderItems = computed<RenderItem[]>(() => {
|
|||||||
watch(() => store.state.activeConversationId, () => {
|
watch(() => store.state.activeConversationId, () => {
|
||||||
// 切会话重置滚动边沿检测 + 跟随意图(新会话默认跟随底部)
|
// 切会话重置滚动边沿检测 + 跟随意图(新会话默认跟随底部)
|
||||||
setFollowing(true)
|
setFollowing(true)
|
||||||
|
// 切会话重置执行轨迹展开态(展开记忆按会话隔离,防跨会话 key 撞车误展开)
|
||||||
|
expandedTraceIds.value = new Set()
|
||||||
// BUG-260624-01(消息重叠根治·论证 workflow 完整性高优):MessageList 是持久单实例(AiChat.vue
|
// BUG-260624-01(消息重叠根治·论证 workflow 完整性高优):MessageList 是持久单实例(AiChat.vue
|
||||||
// 无 :key/v-if 卸载),实例级 streamingBlocks(:69)跨会话/跨轮残留是消息重叠主根因之一。
|
// 无 :key/v-if 卸载),实例级 streamingBlocks(:69)跨会话/跨轮残留是消息重叠主根因之一。
|
||||||
// 钉在「会话切换」这一确切时机清,根治两条残留路径:① switchConversation(useAiConversations)
|
// 钉在「会话切换」这一确切时机清,根治两条残留路径:① switchConversation(useAiConversations)
|
||||||
@@ -797,14 +913,34 @@ defineExpose({
|
|||||||
<!-- 消息列表(F-15 阶段2:按 renderItems 扁平渲染 — 折叠段 emit 'sep' 项,
|
<!-- 消息列表(F-15 阶段2:按 renderItems 扁平渲染 — 折叠段 emit 'sep' 项,
|
||||||
正常消息 + 展开的折叠段内消息 emit 'msg' 项。连续 archived_segment/compressed
|
正常消息 + 展开的折叠段内消息 emit 'msg' 项。连续 archived_segment/compressed
|
||||||
合并一条分隔条,点击展开/收起看原文)
|
合并一条分隔条,点击展开/收起看原文)
|
||||||
|
A 过程压缩:trace 项=完成轮次执行轨迹卡,step 项=执行中实时步骤摘要,均先于 msg 分支拦截。
|
||||||
UX-2025-19: 每项外层包 .ai-msg-slot(B-260618-01 虚拟滚动已移除,现恒渲染;
|
UX-2025-19: 每项外层包 .ai-msg-slot(B-260618-01 虚拟滚动已移除,现恒渲染;
|
||||||
slot 仅作 flex 子占位 + 消息分组的语义容器,gap 由 .ai-messages 承担)。 -->
|
slot 仅作 flex 子占位 + 消息分组的语义容器,gap 由 .ai-messages 承担)。 -->
|
||||||
<template v-for="item in renderItems" :key="item.key">
|
<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 不渲染,
|
<!-- B-260618-19: 纯空 assistant(无 content/error/toolCalls 且非末条流式)整 slot 不渲染,
|
||||||
消除空 div + flex gap 堆积的大片空白;sep 始终渲染;有 toolCalls 的空 assistant
|
消除空 div + flex gap 堆积的大片空白;sep 始终渲染;有 toolCalls 的空 assistant
|
||||||
仍渲染(工具卡可见——修复 agentic 工具轮 LLM 无文本时工具卡消失)。 -->
|
仍渲染(工具卡可见——修复 agentic 工具轮 LLM 无文本时工具卡消失)。 -->
|
||||||
<div
|
<div
|
||||||
v-if="item.kind === 'sep' ? true : shouldRenderMsg(item.msg)"
|
v-else-if="item.kind === 'sep' ? true : shouldRenderMsg(item.msg)"
|
||||||
class="ai-msg-slot"
|
class="ai-msg-slot"
|
||||||
>
|
>
|
||||||
<!-- 折叠分隔条(归档段)— 轻量,始终渲染 -->
|
<!-- 折叠分隔条(归档段)— 轻量,始终渲染 -->
|
||||||
@@ -849,7 +985,9 @@ defineExpose({
|
|||||||
<MessageItem
|
<MessageItem
|
||||||
v-else-if="item.msg.role === 'user'"
|
v-else-if="item.msg.role === 'user'"
|
||||||
:msg="item.msg"
|
:msg="item.msg"
|
||||||
|
:can-edit="isLastUser(item.msg) && !store.state.streaming && item.msg.id !== editingMsgId"
|
||||||
@copy="copyMsgContent"
|
@copy="copyMsgContent"
|
||||||
|
@start-edit="(m) => emit('start-edit', m)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- AI 消息 -->
|
<!-- AI 消息 -->
|
||||||
@@ -1299,31 +1437,6 @@ defineExpose({
|
|||||||
right: 30px;
|
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 显示 / 错误气泡常显) ── */
|
/* ── UX-02/消息操作栏(hover 显示 / 错误气泡常显) ── */
|
||||||
.ai-msg-actions {
|
.ai-msg-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1549,4 +1662,80 @@ defineExpose({
|
|||||||
.ai-msg-segment-icon { flex-shrink: 0; }
|
.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-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; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export function handleStreamingEvent(event: AiChatEvent): boolean {
|
|||||||
flushCurrentText()
|
flushCurrentText()
|
||||||
state.currentText = ''
|
state.currentText = ''
|
||||||
clearTextIdleTimer()
|
clearTextIdleTimer()
|
||||||
state.completedTools = 0 // 新轮重置工具计数器
|
|
||||||
state.messages.push({
|
state.messages.push({
|
||||||
id: `ai-${nextMsgId()}` as MessageId,
|
id: `ai-${nextMsgId()}` as MessageId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export function handleToolEvent(event: AiChatEvent): boolean {
|
|||||||
name: event.name,
|
name: event.name,
|
||||||
args: event.args,
|
args: event.args,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
// 后端事件不带耗时,前端记开始时刻,Completed 时差值即单卡耗时
|
||||||
|
startedAt: Date.now(),
|
||||||
}
|
}
|
||||||
// 同 id 重复 Started 不重复 push(provider id 不稳,防残留 running 空卡)
|
// 同 id 重复 Started 不重复 push(provider id 不稳,防残留 running 空卡)
|
||||||
if (!findToolCall(event.id)) {
|
if (!findToolCall(event.id)) {
|
||||||
@@ -39,8 +41,8 @@ export function handleToolEvent(event: AiChatEvent): boolean {
|
|||||||
if (tc) {
|
if (tc) {
|
||||||
tc.status = 'completed'
|
tc.status = 'completed'
|
||||||
tc.result = event.result
|
tc.result = event.result
|
||||||
|
tc.completedAt = Date.now()
|
||||||
}
|
}
|
||||||
state.completedTools++ // 进度条计数器
|
|
||||||
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
|
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
|
||||||
clearToolSlowTimer(event.id)
|
clearToolSlowTimer(event.id)
|
||||||
// 工具结束(无论审批通过后执行还是被拒),清审批超时计时器
|
// 工具结束(无论审批通过后执行还是被拒),清审批超时计时器
|
||||||
|
|||||||
@@ -637,6 +637,33 @@ function summaryList(r: ToolResult, name: string): string {
|
|||||||
return t(`aiTool.${key}`, { n })
|
return t(`aiTool.${key}`, { n })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* read_file summary 版:行数(截断时 shown/total)· 大小;search 模式返命中数。
|
||||||
|
* 字段语义与 ToolResultBody.vue read_file 分支 meta 对齐(同源 has_more/returned_lines/size),
|
||||||
|
* 单行折叠态 header 可直接判断"读了多少/是否截断"。
|
||||||
|
*/
|
||||||
|
function summaryReadFile(r: ToolResult): string {
|
||||||
|
if (r.search) {
|
||||||
|
const n = typeof r.total === 'number' ? r.total : (Array.isArray(r.matches) ? r.matches.length : 0)
|
||||||
|
return t('aiTool.grepHitsN', { n })
|
||||||
|
}
|
||||||
|
const lines = typeof r.lines === 'number' ? r.lines : 0
|
||||||
|
const shown = typeof r.returned_lines === 'number' ? r.returned_lines : lines
|
||||||
|
const linePart = r.has_more
|
||||||
|
? t('aiTool.linesTruncated', { shown, total: lines })
|
||||||
|
: `${lines} ${t('aiTool.lines')}`
|
||||||
|
return `${linePart} · ${formatBytes(r.size)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** list_directory summary 版:N 项;截断时显截断标注;空目录返空串 */
|
||||||
|
function summaryListDir(r: ToolResult): string {
|
||||||
|
const n = Array.isArray(r.entries) ? r.entries.length : 0
|
||||||
|
if (!n) return ''
|
||||||
|
return r.truncated
|
||||||
|
? t('aiTool.dirTruncated', { n: 1000 })
|
||||||
|
: `${n} ${t('aiTool.items')}`
|
||||||
|
}
|
||||||
|
|
||||||
/** run_command summary 版:仅 exit_code + duration_ms 一行(不带输出) */
|
/** run_command summary 版:仅 exit_code + duration_ms 一行(不带输出) */
|
||||||
function summaryRunCommand(r: ToolResult): string {
|
function summaryRunCommand(r: ToolResult): string {
|
||||||
const code = typeof r.exit_code === 'number' ? r.exit_code : -1
|
const code = typeof r.exit_code === 'number' ? r.exit_code : -1
|
||||||
@@ -704,6 +731,8 @@ function summaryFetchUrl(r: ToolResult): string {
|
|||||||
|
|
||||||
/** 工具结果摘要策略表(name → 纯函数)。仅 summary 独有的工具(create/update_project/restore/purge)在此登记。 */
|
/** 工具结果摘要策略表(name → 纯函数)。仅 summary 独有的工具(create/update_project/restore/purge)在此登记。 */
|
||||||
const TOOL_SUMMARIES: Record<string, (r: ToolResult) => string> = {
|
const TOOL_SUMMARIES: Record<string, (r: ToolResult) => string> = {
|
||||||
|
read_file: summaryReadFile,
|
||||||
|
list_directory: summaryListDir,
|
||||||
list_projects: r => summaryList(r, 'list_projects'),
|
list_projects: r => summaryList(r, 'list_projects'),
|
||||||
list_tasks: r => summaryList(r, 'list_tasks'),
|
list_tasks: r => summaryList(r, 'list_tasks'),
|
||||||
list_ideas: r => summaryList(r, 'list_ideas'),
|
list_ideas: r => summaryList(r, 'list_ideas'),
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
//! 轮次轨迹聚合 — 工具执行过程的统计与耗时格式化(纯函数,无响应式状态)
|
||||||
|
//!
|
||||||
|
//! 背景:后端事件协议只发单点事件(AiToolCallStarted/Completed/AiAgentRound),无累计计数
|
||||||
|
//! 也无耗时字段。过程压缩(执行轨迹卡/实时步骤摘要)与状态栏「累计 M 次调用」所需的聚合值
|
||||||
|
//! 全部由前端对已有事件落下的消息流自行统计,不改后端协议。
|
||||||
|
//!
|
||||||
|
//! 轮次(turn)定义:末条 user 消息之后的连续 assistant 消息(一次发送触发的完整执行轨迹)。
|
||||||
|
//! 每轮 agentic round 对应一条 assistant 消息(AiAgentRound push 新空气泡,文本与工具卡挂其上)。
|
||||||
|
//!
|
||||||
|
//! 消费方:MessageList(执行轨迹卡/步骤摘要)/ AiChat(状态栏)/ ToolCardList(聚合分组行)。
|
||||||
|
|
||||||
|
import { t } from '@/i18n/i18n-helpers'
|
||||||
|
import { isToolFailure } from '@/composables/ai/useToolCard'
|
||||||
|
import { toolDisplayName } from '@/composables/ai/useToolCardHeader'
|
||||||
|
import type { AiMessage, AiToolCallInfo } from '@/api/types'
|
||||||
|
|
||||||
|
/** 一次执行轨迹(轮次)的工具调用聚合统计 */
|
||||||
|
export interface TurnCallStats {
|
||||||
|
/** 调用总数(含 running/pending/failed/rejected) */
|
||||||
|
total: number
|
||||||
|
/** 成功数(completed 且结果非失败) */
|
||||||
|
ok: number
|
||||||
|
/** 失败数(completed 但结果判定失败,判定同 ToolCard isFailed) */
|
||||||
|
fail: number
|
||||||
|
/** 用户拒绝数 */
|
||||||
|
rejected: number
|
||||||
|
/** 待审批数 */
|
||||||
|
pending: number
|
||||||
|
/** 执行中数 */
|
||||||
|
running: number
|
||||||
|
/** 最近一个执行中工具的显示名(无执行中时为空串) */
|
||||||
|
runningName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 末条 user 消息下标(轮次起点);无 user 消息返回 -1(整段视为一个轮次) */
|
||||||
|
export function lastUserIndex(msgs: AiMessage[]): number {
|
||||||
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||||
|
if (msgs[i].role === 'user') return i
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 轮次起点之后的 assistant 消息(含最终答复所在消息;错误气泡由调用方剔除) */
|
||||||
|
export function turnMessages(msgs: AiMessage[]): AiMessage[] {
|
||||||
|
const start = lastUserIndex(msgs) + 1
|
||||||
|
return msgs.slice(start).filter(m => m.role === 'assistant')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 聚合一批工具调用状态(历史回载调用均为 completed,同样适用) */
|
||||||
|
export function aggregateCalls(calls: AiToolCallInfo[]): TurnCallStats {
|
||||||
|
const s: TurnCallStats = { total: 0, ok: 0, fail: 0, rejected: 0, pending: 0, running: 0, runningName: '' }
|
||||||
|
for (const tc of calls) {
|
||||||
|
s.total++
|
||||||
|
switch (tc.status) {
|
||||||
|
case 'running':
|
||||||
|
s.running++
|
||||||
|
// 取最近一个执行中工具(调用序即时间序,后写覆盖前写)
|
||||||
|
s.runningName = toolDisplayName(tc)
|
||||||
|
break
|
||||||
|
case 'pending_approval':
|
||||||
|
s.pending++
|
||||||
|
break
|
||||||
|
case 'rejected':
|
||||||
|
s.rejected++
|
||||||
|
break
|
||||||
|
case 'completed':
|
||||||
|
if (isToolFailure(tc)) s.fail++
|
||||||
|
else s.ok++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 聚合一组消息上的全部工具调用状态 */
|
||||||
|
export function collectTurnStats(msgs: AiMessage[]): TurnCallStats {
|
||||||
|
const calls: AiToolCallInfo[] = []
|
||||||
|
for (const m of msgs) calls.push(...(m.toolCalls || []))
|
||||||
|
return aggregateCalls(calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「成功 x 失败 y」尾巴(零值项省略,全零返回空串)。
|
||||||
|
* 供执行轨迹卡/聚合分组行拼「N 次调用 · 成功 x 失败 y」统一语义。
|
||||||
|
*/
|
||||||
|
export function statsTail(s: TurnCallStats): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (s.ok > 0) parts.push(t('ai.trace.ok', { n: s.ok }))
|
||||||
|
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 }))
|
||||||
|
return parts.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 时间跨度格式化。precise=true(单卡耗时)亚秒显毫秒;false(轨迹总耗时)亚秒归 <1s。
|
||||||
|
* 超时跨度进位到分/时。负值/非有限值返回空串(调用方隐藏该段)。
|
||||||
|
*/
|
||||||
|
export function formatDuration(ms: number | undefined | null, precise = false): string {
|
||||||
|
if (ms == null || !Number.isFinite(ms) || ms < 0) return ''
|
||||||
|
if (ms < 1000) return precise ? `${Math.round(ms)}ms` : '<1s'
|
||||||
|
const sec = ms / 1000
|
||||||
|
if (sec < 60) return `${sec.toFixed(sec < 10 ? 1 : 0)}s`
|
||||||
|
const min = Math.floor(sec / 60)
|
||||||
|
const remainSec = Math.round(sec % 60)
|
||||||
|
if (min < 60) return `${min}m ${remainSec}s`
|
||||||
|
const hour = Math.floor(min / 60)
|
||||||
|
return `${hour}h ${min % 60}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单次调用耗时文本(前端 startedAt/completedAt 差;历史消息无时间戳返回空串隐藏) */
|
||||||
|
export function callDurationText(startedAt?: number, completedAt?: number): string {
|
||||||
|
if (startedAt == null || completedAt == null) return ''
|
||||||
|
return formatDuration(completedAt - startedAt, true)
|
||||||
|
}
|
||||||
+20
-5
@@ -52,10 +52,25 @@ export default {
|
|||||||
unarchiveAction: 'unarchive',
|
unarchiveAction: 'unarchive',
|
||||||
pinAction: 'pin',
|
pinAction: 'pin',
|
||||||
unpinAction: 'unpin',
|
unpinAction: 'unpin',
|
||||||
// Agentic loop progress bar copy
|
// Agentic loop progress bar copy (status semantics: round N · M calls total · running/pending)
|
||||||
// Note: max round / completed tool count are not surfaced by backend this round;
|
// total is aggregated on the frontend from current-turn toolCalls (useTurnTrace), not a backend field
|
||||||
// template conditionally omits them. Add *WithMax keys once backend exposes them.
|
// Note: max round is not surfaced by backend; template conditionally omits it.
|
||||||
agenticProgress: '🔄 Round {round} · ⏳{pending} pending',
|
agenticProgress: 'Round {round} · {total} calls total · ⏳{pending} pending',
|
||||||
agenticProgressNoPending: '🔄 Round {round} · ✅{completed} tools done',
|
agenticProgressNoPending: 'Round {round} · {total} calls total · running',
|
||||||
|
// Shared semantics for execution trace card / live step summary (A) and aggregate group row (B)
|
||||||
|
trace: {
|
||||||
|
title: 'Execution Trace',
|
||||||
|
calls: '{n} calls',
|
||||||
|
ok: '{n} ok',
|
||||||
|
fail: '{n} failed',
|
||||||
|
rejected: '{n} rejected',
|
||||||
|
duration: 'total {time}',
|
||||||
|
expand: 'Expand trace',
|
||||||
|
collapse: 'Collapse trace',
|
||||||
|
processOnly: 'Process log',
|
||||||
|
stepDone: '✓ {n} calls done',
|
||||||
|
stepRunning: 'running {name}',
|
||||||
|
stepPending: '⏳ {n} pending approval',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export default {
|
|||||||
// M5:path deny second confirmation (deny is irreversible — tool returns Err, loop continues without retry; asymmetry with approve, prevents mis-clicks)
|
// M5:path deny second confirmation (deny is irreversible — tool returns Err, loop continues without retry; asymmetry with approve, prevents mis-clicks)
|
||||||
dirAuthDenyConfirm: 'Deny authorization? The tool will fail immediately and will not be retried this round.',
|
dirAuthDenyConfirm: 'Deny authorization? The tool will fail immediately and will not be retried this round.',
|
||||||
// Persistent bypass banner shown at the top of the chat area while "trust all directories" is on
|
// Persistent bypass banner shown at the top of the chat area while "trust all directories" is on
|
||||||
pathAuthBypassBanner: 'Authorization bypassed: AI can access files directly',
|
pathAuthBypassBanner: 'Authorization fully bypassed (including sensitive directories): AI can access any file directly',
|
||||||
|
|
||||||
// ── Input Augmentation layer: mention/skill injection feedback ──
|
// ── Input Augmentation layer: mention/skill injection feedback ──
|
||||||
// search_files blind search denied by whitelist fallback (no auth dialog; prompt user to use @mention or absolute path)
|
// search_files blind search denied by whitelist fallback (no auth dialog; prompt user to use @mention or absolute path)
|
||||||
|
|||||||
+20
-4
@@ -52,9 +52,25 @@ export default {
|
|||||||
unarchiveAction: '取消归档',
|
unarchiveAction: '取消归档',
|
||||||
pinAction: '置顶',
|
pinAction: '置顶',
|
||||||
unpinAction: '取消置顶',
|
unpinAction: '取消置顶',
|
||||||
// Agentic 循环进度条文案
|
// Agentic 循环进度条文案(C 状态语义化:第 N 轮 · 累计 M 次调用 · 进行中/待审批)
|
||||||
// 注:max 轮次/已完成工具数本轮后端暂不透传,模板条件渲染省略;后端补全后新增 *WithMax key
|
// total 由前端聚合当前轮次 toolCalls(useTurnTrace),非后端字段
|
||||||
agenticProgress: '🔄 循环 {round} · ⏳{pending}待审批',
|
// 注:max 总轮次后端暂不透传,模板条件渲染省略;后端补全后新增 *WithMax key
|
||||||
agenticProgressNoPending: '🔄 循环 {round} · ✅{completed}个工具完成',
|
agenticProgress: '第 {round} 轮 · 累计 {total} 次调用 · ⏳{pending} 项待审批',
|
||||||
|
agenticProgressNoPending: '第 {round} 轮 · 累计 {total} 次调用 · 进行中',
|
||||||
|
// 执行轨迹卡/实时步骤摘要(A 过程压缩)与聚合分组行(B 聚合卡统一)共用语义
|
||||||
|
trace: {
|
||||||
|
title: '执行轨迹',
|
||||||
|
calls: '{n} 次调用',
|
||||||
|
ok: '成功 {n}',
|
||||||
|
fail: '失败 {n}',
|
||||||
|
rejected: '拒绝 {n}',
|
||||||
|
duration: '总耗时 {time}',
|
||||||
|
expand: '展开轨迹',
|
||||||
|
collapse: '收起轨迹',
|
||||||
|
processOnly: '过程记录',
|
||||||
|
stepDone: '✓ {n} 次调用完成',
|
||||||
|
stepRunning: '正在 {name}',
|
||||||
|
stepPending: '⏳ {n} 项待审批',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export default {
|
|||||||
// M5:path 类拒绝二次确认(拒绝不可逆——工具返 Err、loop 续跑不再重试,与 approve 不对称,防误点)
|
// M5:path 类拒绝二次确认(拒绝不可逆——工具返 Err、loop 续跑不再重试,与 approve 不对称,防误点)
|
||||||
dirAuthDenyConfirm: '确定拒绝授权吗?该工具将立即失败,且本轮不会重试。',
|
dirAuthDenyConfirm: '确定拒绝授权吗?该工具将立即失败,且本轮不会重试。',
|
||||||
// 「信任所有目录」总开关开启期间的常驻放行横幅(对话区顶部)
|
// 「信任所有目录」总开关开启期间的常驻放行横幅(对话区顶部)
|
||||||
pathAuthBypassBanner: '授权已放行:AI 可直接访问文件',
|
pathAuthBypassBanner: '授权已完全放行(含敏感目录):AI 可直接访问任意文件',
|
||||||
|
|
||||||
// ── Input Augmentation 层: mention/skill 注入反馈 ──
|
// ── Input Augmentation 层: mention/skill 注入反馈 ──
|
||||||
// search_files 盲搜被白名单兜底拒绝(不应弹授权窗,直接友好提示用户改用 @引用或绝对路径)
|
// search_files 盲搜被白名单兜底拒绝(不应弹授权窗,直接友好提示用户改用 @引用或绝对路径)
|
||||||
|
|||||||
@@ -103,8 +103,6 @@ const _stateBase: {
|
|||||||
skills: SkillInfo[]
|
skills: SkillInfo[]
|
||||||
queue: { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[]; spans?: MentionSpan[]; conversationId?: string | null }[]
|
queue: { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[]; spans?: MentionSpan[]; conversationId?: string | null }[]
|
||||||
agentRound: number
|
agentRound: number
|
||||||
/// 已完成工具调用数(本轮累计,AiToolCallCompleted 时 +1,AiAgentRound 时重置)
|
|
||||||
completedTools: number
|
|
||||||
archivedCollapsed: boolean
|
archivedCollapsed: boolean
|
||||||
foldedGroups: Record<string, boolean>
|
foldedGroups: Record<string, boolean>
|
||||||
searchQuery: string
|
searchQuery: string
|
||||||
@@ -139,7 +137,6 @@ const _stateBase: {
|
|||||||
// Agentic 循环当前轮次(0=非agentic/未开始;>0=收到后端 AiAgentRound 事件的 round)
|
// Agentic 循环当前轮次(0=非agentic/未开始;>0=收到后端 AiAgentRound 事件的 round)
|
||||||
// 进度条用; AiCompleted/AiError 复位为 0
|
// 进度条用; AiCompleted/AiError 复位为 0
|
||||||
agentRound: 0,
|
agentRound: 0,
|
||||||
completedTools: 0,
|
|
||||||
// 归档分组折叠态(默认折叠)
|
// 归档分组折叠态(默认折叠)
|
||||||
archivedCollapsed: true,
|
archivedCollapsed: true,
|
||||||
// 时间分组折叠态(默认:更早折叠,今天/昨天展开;持久化进 df-ai-ui)
|
// 时间分组折叠态(默认:更早折叠,今天/昨天展开;持久化进 df-ai-ui)
|
||||||
|
|||||||
Reference in New Issue
Block a user