diff --git a/src/components/ToolCardList.vue b/src/components/ToolCardList.vue index 31cee21..50c4685 100644 --- a/src/components/ToolCardList.vue +++ b/src/components/ToolCardList.vue @@ -52,7 +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 { aggregateCalls, aggText } from '@/composables/ai/useTurnTrace' import type { AiToolCallInfo } from '@/api/types' // useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key) @@ -230,10 +230,7 @@ function groupDisplayName(group: ToolCallGroup): string { /** 分组聚合行文案:「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 + return aggText(aggregateCalls(group.calls)) } /** diff --git a/src/components/ToolResultBody.vue b/src/components/ToolResultBody.vue index a693894..ba6cb04 100644 --- a/src/components/ToolResultBody.vue +++ b/src/components/ToolResultBody.vue @@ -38,7 +38,7 @@ {{ parsed?.path }} {{ parsed?.entries?.length || 0 }} {{ $t('aiTool.items') }} - {{ $t('aiTool.dirTruncated', { n: 1000 }) }} + {{ $t('aiTool.dirTruncated', { n: DIR_TRUNCATE_LIMIT }) }}
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(' · ') + return aggText(stats, endTs - startTs) } /** 轨迹卡 hover:绝对起止时刻(相对时长之外可溯源具体时间) */ @@ -793,6 +787,18 @@ const renderItems = computed(() => { const msgs = bucket.filter(shouldRenderMsg) bucket = [] 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 工具结果的消息;全无正文时保底末条(工具轮可见) let fi = -1 for (let i = msgs.length - 1; i >= 0; i--) { @@ -801,24 +807,22 @@ const renderItems = computed(() => { } 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) + // 终点计入末轮时长:过程消息里最晚的工具完成时刻与末轮开始时刻取较大者 + 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({ kind: 'trace', key, stats: collectTurnStats(traceMsgs), startTs: traceMsgs[0].timestamp, - endTs: finalMsg.timestamp, + endTs: Math.max(lastCompleted, finalMsg.timestamp), traceMsgs, expanded, }) if (expanded) { diff --git a/src/composables/ai/useAiToolEvents.ts b/src/composables/ai/useAiToolEvents.ts index b4629ee..b49b79c 100644 --- a/src/composables/ai/useAiToolEvents.ts +++ b/src/composables/ai/useAiToolEvents.ts @@ -95,8 +95,11 @@ export function handleToolEvent(event: AiChatEvent): boolean { clearToolSlowTimer(event.id) clearApprovalTimer(event.id) } 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) } // 路径授权挂起的工具收到 ApprovalResult → 按 id 移除该条(数组化后只清这一条) diff --git a/src/composables/ai/useToolCard.ts b/src/composables/ai/useToolCard.ts index a53dd2a..7ee0475 100644 --- a/src/composables/ai/useToolCard.ts +++ b/src/composables/ai/useToolCard.ts @@ -122,6 +122,9 @@ export const fileIcon = '' +/** list_directory 条目截断上限(与后端截断行为对齐,截断标注文案取此值) */ +export const DIR_TRUNCATE_LIMIT = 1000 + /** 默认保持展开:执行中/待审批/已拒绝/write_file——折叠无收益 */ export function shouldKeepOpen(tc: AiToolCallInfo): boolean { return tc.status === 'running' @@ -660,7 +663,7 @@ 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 }) + ? t('aiTool.dirTruncated', { n: DIR_TRUNCATE_LIMIT }) : `${n} ${t('aiTool.items')}` } diff --git a/src/composables/ai/useTurnTrace.ts b/src/composables/ai/useTurnTrace.ts index a4f71d7..acde9fc 100644 --- a/src/composables/ai/useTurnTrace.ts +++ b/src/composables/ai/useTurnTrace.ts @@ -91,6 +91,21 @@ export function statsTail(s: TurnCallStats): string { 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。 * 超时跨度进位到分/时。负值/非有限值返回空串(调用方隐藏该段)。 @@ -100,8 +115,13 @@ export function formatDuration(ms: number | undefined | null, precise = false): 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) + let min = Math.floor(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` const hour = Math.floor(min / 60) return `${hour}h ${min % 60}m`