修复: 对话轨迹生成锚点回归与耗时口径
生成中锚点改真实末条,修第二轮打字机与工具卡不渲染回归 轨迹总耗时计入末轮时长,单卡耗时剔除审批等待区间 聚合文案与截断常量下沉共用,时长进位边界钳制
This commit is contained in:
@@ -52,7 +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 { aggregateCalls, aggText } 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)
|
||||||
@@ -230,10 +230,7 @@ function groupDisplayName(group: ToolCallGroup): string {
|
|||||||
|
|
||||||
/** 分组聚合行文案:「N 次调用 · 成功 x 失败 y」(零值项省略;与单卡状态点同源 isToolFailure 判定) */
|
/** 分组聚合行文案:「N 次调用 · 成功 x 失败 y」(零值项省略;与单卡状态点同源 isToolFailure 判定) */
|
||||||
function groupAggText(group: ToolCallGroup): string {
|
function groupAggText(group: ToolCallGroup): string {
|
||||||
const s = aggregateCalls(group.calls)
|
return aggText(aggregateCalls(group.calls))
|
||||||
const tail = statsTail(s)
|
|
||||||
const head = t('ai.trace.calls', { n: s.total })
|
|
||||||
return tail ? `${head} · ${tail}` : head
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
|
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
|
||||||
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
|
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
|
||||||
<span class="ai-tool-file-meta">{{ parsed?.entries?.length || 0 }} {{ $t('aiTool.items') }}</span>
|
<span class="ai-tool-file-meta">{{ parsed?.entries?.length || 0 }} {{ $t('aiTool.items') }}</span>
|
||||||
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.dirTruncated', { n: 1000 }) }}</span>
|
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.dirTruncated', { n: DIR_TRUNCATE_LIMIT }) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-tool-dir-entries">
|
<div class="ai-tool-dir-entries">
|
||||||
<div
|
<div
|
||||||
@@ -234,6 +234,7 @@ import {
|
|||||||
dirIcon,
|
dirIcon,
|
||||||
fileIcon,
|
fileIcon,
|
||||||
searchIcon,
|
searchIcon,
|
||||||
|
DIR_TRUNCATE_LIMIT,
|
||||||
} from '@/composables/ai/useToolCard'
|
} from '@/composables/ai/useToolCard'
|
||||||
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
|
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
|
||||||
import type { AiToolCallInfo } from '@/api/types'
|
import type { AiToolCallInfo } from '@/api/types'
|
||||||
|
|||||||
@@ -21,7 +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 { collectTurnStats, aggText, 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'
|
||||||
@@ -741,15 +741,9 @@ function toggleTrace(key: string): void {
|
|||||||
expandedTraceIds.value = next
|
expandedTraceIds.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 轨迹卡聚合行:「N 次调用 · 成功 x 失败 y · 总耗时 T」(零值段省略;无工具调用显「过程记录」) */
|
/** 轨迹卡聚合行:「N 次调用 · 成功 x 失败 y · 总耗时 T」(拼装统一走 useTurnTrace.aggText) */
|
||||||
function traceAggText(stats: TurnCallStats, startTs: number, endTs: number): string {
|
function traceAggText(stats: TurnCallStats, startTs: number, endTs: number): string {
|
||||||
const parts: string[] = []
|
return aggText(stats, endTs - startTs)
|
||||||
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:绝对起止时刻(相对时长之外可溯源具体时间) */
|
/** 轨迹卡 hover:绝对起止时刻(相对时长之外可溯源具体时间) */
|
||||||
@@ -793,6 +787,18 @@ const renderItems = computed<RenderItem[]>(() => {
|
|||||||
const msgs = bucket.filter(shouldRenderMsg)
|
const msgs = bucket.filter(shouldRenderMsg)
|
||||||
bucket = []
|
bucket = []
|
||||||
if (msgs.length === 0) return
|
if (msgs.length === 0) return
|
||||||
|
|
||||||
|
if (isViewingGenerating.value) {
|
||||||
|
// 执行中:当前步骤锚定真实末条消息(agentic 新一轮 push 新空气泡,若按「末条有正文」
|
||||||
|
// 找锚点会错挂上一轮,致当前轮打字机/光标/工具卡整条不渲染);先前步骤收进一行实时摘要
|
||||||
|
const cur = msgs[msgs.length - 1]
|
||||||
|
const prev = msgs.slice(0, -1)
|
||||||
|
if (prev.length > 0) {
|
||||||
|
items.push({ kind: 'step', key: 'step-' + prev[0].id, stats: collectTurnStats(prev) })
|
||||||
|
}
|
||||||
|
items.push({ kind: 'msg', key: 'm-' + cur.id, msg: cur })
|
||||||
|
return
|
||||||
|
}
|
||||||
// 最终答复:末条有正文且非纯 JSON 工具结果的消息;全无正文时保底末条(工具轮可见)
|
// 最终答复:末条有正文且非纯 JSON 工具结果的消息;全无正文时保底末条(工具轮可见)
|
||||||
let fi = -1
|
let fi = -1
|
||||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||||
@@ -801,24 +807,22 @@ const renderItems = computed<RenderItem[]>(() => {
|
|||||||
}
|
}
|
||||||
const finalMsg = fi >= 0 ? msgs[fi] : msgs[msgs.length - 1]
|
const finalMsg = fi >= 0 ? msgs[fi] : msgs[msgs.length - 1]
|
||||||
const traceMsgs = msgs.slice(0, msgs.indexOf(finalMsg))
|
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 段内过程消息(与归档分段同一扁平化模式)
|
// 完成:轨迹折叠为一张卡,展开时再 emit 段内过程消息(与归档分段同一扁平化模式)
|
||||||
if (traceMsgs.length > 0) {
|
if (traceMsgs.length > 0) {
|
||||||
const key = 'trace-' + traceMsgs[0].id
|
const key = 'trace-' + traceMsgs[0].id
|
||||||
const expanded = expandedTraceIds.value.has(key)
|
const expanded = expandedTraceIds.value.has(key)
|
||||||
|
// 终点计入末轮时长:过程消息里最晚的工具完成时刻与末轮开始时刻取较大者
|
||||||
|
let lastCompleted = 0
|
||||||
|
for (const m of traceMsgs) {
|
||||||
|
for (const tc of m.toolCalls || []) {
|
||||||
|
if (tc.completedAt && tc.completedAt > lastCompleted) lastCompleted = tc.completedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
items.push({
|
items.push({
|
||||||
kind: 'trace', key,
|
kind: 'trace', key,
|
||||||
stats: collectTurnStats(traceMsgs),
|
stats: collectTurnStats(traceMsgs),
|
||||||
startTs: traceMsgs[0].timestamp,
|
startTs: traceMsgs[0].timestamp,
|
||||||
endTs: finalMsg.timestamp,
|
endTs: Math.max(lastCompleted, finalMsg.timestamp),
|
||||||
traceMsgs, expanded,
|
traceMsgs, expanded,
|
||||||
})
|
})
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
|
|||||||
@@ -95,8 +95,11 @@ export function handleToolEvent(event: AiChatEvent): boolean {
|
|||||||
clearToolSlowTimer(event.id)
|
clearToolSlowTimer(event.id)
|
||||||
clearApprovalTimer(event.id)
|
clearApprovalTimer(event.id)
|
||||||
} else {
|
} else {
|
||||||
|
const tc = findToolCall(event.id)
|
||||||
|
// 后端对整批工具先批量发 Started 再走审批,审批等待不算执行耗时:通过时重置开始时刻
|
||||||
|
if (tc) tc.startedAt = Date.now()
|
||||||
// 审批通过 → 工具重回执行态,重启慢执行计时器;对称清审批超时计时器防到点误拒
|
// 审批通过 → 工具重回执行态,重启慢执行计时器;对称清审批超时计时器防到点误拒
|
||||||
startToolSlowTimer(event.id, findToolCall(event.id)?.name || '')
|
startToolSlowTimer(event.id, tc?.name || '')
|
||||||
clearApprovalTimer(event.id)
|
clearApprovalTimer(event.id)
|
||||||
}
|
}
|
||||||
// 路径授权挂起的工具收到 ApprovalResult → 按 id 移除该条(数组化后只清这一条)
|
// 路径授权挂起的工具收到 ApprovalResult → 按 id 移除该条(数组化后只清这一条)
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ export const fileIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="n
|
|||||||
/** grep 工具放大镜图标(跨文件内容搜索,F-260621) */
|
/** grep 工具放大镜图标(跨文件内容搜索,F-260621) */
|
||||||
export const searchIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
|
export const searchIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
|
||||||
|
|
||||||
|
/** list_directory 条目截断上限(与后端截断行为对齐,截断标注文案取此值) */
|
||||||
|
export const DIR_TRUNCATE_LIMIT = 1000
|
||||||
|
|
||||||
/** 默认保持展开:执行中/待审批/已拒绝/write_file——折叠无收益 */
|
/** 默认保持展开:执行中/待审批/已拒绝/write_file——折叠无收益 */
|
||||||
export function shouldKeepOpen(tc: AiToolCallInfo): boolean {
|
export function shouldKeepOpen(tc: AiToolCallInfo): boolean {
|
||||||
return tc.status === 'running'
|
return tc.status === 'running'
|
||||||
@@ -660,7 +663,7 @@ function summaryListDir(r: ToolResult): string {
|
|||||||
const n = Array.isArray(r.entries) ? r.entries.length : 0
|
const n = Array.isArray(r.entries) ? r.entries.length : 0
|
||||||
if (!n) return ''
|
if (!n) return ''
|
||||||
return r.truncated
|
return r.truncated
|
||||||
? t('aiTool.dirTruncated', { n: 1000 })
|
? t('aiTool.dirTruncated', { n: DIR_TRUNCATE_LIMIT })
|
||||||
: `${n} ${t('aiTool.items')}`
|
: `${n} ${t('aiTool.items')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,21 @@ export function statsTail(s: TurnCallStats): string {
|
|||||||
return parts.join(' · ')
|
return parts.join(' · ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聚合行整句:「N 次调用 · 成功 x 失败 y · 总耗时 T」。
|
||||||
|
* 头部 total=0 显「过程记录」;零值段省略;durationMs 可选(轨迹卡传起止差,分组行不传)。
|
||||||
|
* 供 MessageList 轨迹卡与 ToolCardList 聚合分组行共用,拼装语义单一出口。
|
||||||
|
*/
|
||||||
|
export function aggText(s: TurnCallStats, durationMs?: number): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
parts.push(s.total > 0 ? t('ai.trace.calls', { n: s.total }) : t('ai.trace.processOnly'))
|
||||||
|
const tail = statsTail(s)
|
||||||
|
if (tail) parts.push(tail)
|
||||||
|
const dur = formatDuration(durationMs)
|
||||||
|
if (dur) parts.push(t('ai.trace.duration', { time: dur }))
|
||||||
|
return parts.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 时间跨度格式化。precise=true(单卡耗时)亚秒显毫秒;false(轨迹总耗时)亚秒归 <1s。
|
* 时间跨度格式化。precise=true(单卡耗时)亚秒显毫秒;false(轨迹总耗时)亚秒归 <1s。
|
||||||
* 超时跨度进位到分/时。负值/非有限值返回空串(调用方隐藏该段)。
|
* 超时跨度进位到分/时。负值/非有限值返回空串(调用方隐藏该段)。
|
||||||
@@ -100,8 +115,13 @@ export function formatDuration(ms: number | undefined | null, precise = false):
|
|||||||
if (ms < 1000) return precise ? `${Math.round(ms)}ms` : '<1s'
|
if (ms < 1000) return precise ? `${Math.round(ms)}ms` : '<1s'
|
||||||
const sec = ms / 1000
|
const sec = ms / 1000
|
||||||
if (sec < 60) return `${sec.toFixed(sec < 10 ? 1 : 0)}s`
|
if (sec < 60) return `${sec.toFixed(sec < 10 ? 1 : 0)}s`
|
||||||
const min = Math.floor(sec / 60)
|
let min = Math.floor(sec / 60)
|
||||||
const remainSec = Math.round(sec % 60)
|
let remainSec = Math.round(sec % 60)
|
||||||
|
// 秒四舍五入进位(sec∈[119.5,120) 得 60s):并入分,避免显「1m 60s」
|
||||||
|
if (remainSec === 60) {
|
||||||
|
min++
|
||||||
|
remainSec = 0
|
||||||
|
}
|
||||||
if (min < 60) return `${min}m ${remainSec}s`
|
if (min < 60) return `${min}m ${remainSec}s`
|
||||||
const hour = Math.floor(min / 60)
|
const hour = Math.floor(min / 60)
|
||||||
return `${hour}h ${min % 60}m`
|
return `${hour}h ${min % 60}m`
|
||||||
|
|||||||
Reference in New Issue
Block a user