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

前端聚合当前轮次工具调用数,替代每轮清零的完成计数,进度文案改轮次+累计口径
完成聚合语义由执行轨迹卡承接,工具卡复读修复与默认折叠收敛
流式与工具事件配套调整,新增轮次轨迹收集 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
@@ -47,7 +47,6 @@ export function handleStreamingEvent(event: AiChatEvent): boolean {
flushCurrentText()
state.currentText = ''
clearTextIdleTimer()
state.completedTools = 0 // 新轮重置工具计数器
state.messages.push({
id: `ai-${nextMsgId()}` as MessageId,
role: 'assistant',
+3 -1
View File
@@ -20,6 +20,8 @@ export function handleToolEvent(event: AiChatEvent): boolean {
name: event.name,
args: event.args,
status: 'running',
// 后端事件不带耗时,前端记开始时刻,Completed 时差值即单卡耗时
startedAt: Date.now(),
}
// 同 id 重复 Started 不重复 push(provider id 不稳,防残留 running 空卡)
if (!findToolCall(event.id)) {
@@ -39,8 +41,8 @@ export function handleToolEvent(event: AiChatEvent): boolean {
if (tc) {
tc.status = 'completed'
tc.result = event.result
tc.completedAt = Date.now()
}
state.completedTools++ // 进度条计数器
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
clearToolSlowTimer(event.id)
// 工具结束(无论审批通过后执行还是被拒),清审批超时计时器
+29
View File
@@ -637,6 +637,33 @@ function summaryList(r: ToolResult, name: string): string {
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 一行(不带输出) */
function summaryRunCommand(r: ToolResult): string {
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)在此登记。 */
const TOOL_SUMMARIES: Record<string, (r: ToolResult) => string> = {
read_file: summaryReadFile,
list_directory: summaryListDir,
list_projects: r => summaryList(r, 'list_projects'),
list_tasks: r => summaryList(r, 'list_tasks'),
list_ideas: r => summaryList(r, 'list_ideas'),
+114
View File
@@ -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)
}