优化: 对话执行轨迹聚合与工具卡交互
前端聚合当前轮次工具调用数,替代每轮清零的完成计数,进度文案改轮次+累计口径 完成聚合语义由执行轨迹卡承接,工具卡复读修复与默认折叠收敛 流式与工具事件配套调整,新增轮次轨迹收集 composable
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
// 工具结束(无论审批通过后执行还是被拒),清审批超时计时器
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user