优化: ai-chat 对话流布局与轨迹信息密度改造
- 分组行去统计化,调用统计归并轨迹卡头部 - 相对时间改时间分隔线,跨日显绝对时间 - 思考过程单行 pill 化,答案卡加 accent 边 - 工具卡短路径展示与耗时提示统一 - 高风险工具名单启动时同步后端注册表 - 自动重试改琥珀细条,红卡与手动重试仅终态 - 轨迹卡头并时间线行,显轮次号与绝对时间 - 运行中摘要补当前工具名与失败计数 - 执行中工具卡加实时计时,耗时统一人话口径 - 重试残留清理改标记匹配,替代中文子串硬编码
This commit is contained in:
@@ -56,10 +56,11 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
|
||||
clearTextIdleTimer() // 清文本空闲定时器 + 置 textIdle=true(整轮结束不该有活跃信号残留)
|
||||
flushCurrentText()
|
||||
state.currentText = ''
|
||||
// 清「重试中」过渡气泡(成功后残留根治;AiStreamRetry 推的 retry 气泡是过渡态,终态不清会永久残留)
|
||||
// 清「重试中」过渡气泡(成功后残留根治;AiStreamRetry 推的 retry 气泡是过渡态,终态不清会永久残留)。
|
||||
// 按 isRetry 标记匹配,替代旧中文文案子串匹配(en locale 文案同样生效,文案改版不再漏清)
|
||||
for (let i = state.messages.length - 1; i >= 0; i--) {
|
||||
const m = state.messages[i]
|
||||
if (m.isError && typeof m.content === 'string' && m.content.includes('正在重试(')) {
|
||||
if (m.isRetry === true) {
|
||||
state.messages.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,17 +63,21 @@ export function handleStreamingEvent(event: AiChatEvent): boolean {
|
||||
return true
|
||||
|
||||
case 'AiStreamRetry': {
|
||||
// 重试中——首条重试推新错误气泡,后续仅更新其 content(单气泡聚合,根治 N+1 气泡)
|
||||
// 重试中——首条重试推过渡气泡(isRetry 标记,MessageList 渲染为琥珀细条而非错误红卡),
|
||||
// 后续仅更新其 content(单气泡聚合,根治 N+1 气泡)。isError 保持 true:
|
||||
// flushCurrentText 靠它跳过本气泡,部分流式回复仍回填前一条 assistant 消息。
|
||||
const lastMsg = state.messages[state.messages.length - 1]
|
||||
const retryText = t('ai.aiStreamRetry', { attempt: event.attempt, max: event.max_attempts })
|
||||
if (lastMsg && lastMsg.isError) {
|
||||
lastMsg.content = retryText
|
||||
lastMsg.isRetry = true
|
||||
} else {
|
||||
state.messages.push({
|
||||
id: `err-${nextMsgId()}` as MessageId,
|
||||
role: 'assistant',
|
||||
content: retryText,
|
||||
isError: true,
|
||||
isRetry: true,
|
||||
timestamp: Date.now(),
|
||||
} as AiMessage)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
//! - AiToolCallInfo(@/api/types)
|
||||
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { formatDuration } from '@/utils/time'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
|
||||
/** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */
|
||||
@@ -326,8 +327,9 @@ export function combineAndTruncateLines(stdout: string | undefined, stderr: stri
|
||||
export function formatCommandResult(r: ToolResult): string {
|
||||
const code = typeof r.exit_code === 'number' ? r.exit_code : -1
|
||||
const ms = typeof r.duration_ms === 'number' ? r.duration_ms : 0
|
||||
// 耗时预格式化为人话时长(与卡片右侧耗时同源 formatDuration),不再双显两种口径
|
||||
const head = code === 0
|
||||
? t('aiTool.commandOk', { ms })
|
||||
? t('aiTool.commandOk', { time: formatDuration(ms) })
|
||||
: t('aiTool.commandFailed', { code })
|
||||
// 合并 stdout + stderr(实际 stderr 通常含报错),各自取前若干行
|
||||
const MAX_LINES = 20
|
||||
@@ -554,8 +556,9 @@ export function formatHttpResult(r: ToolResult): string {
|
||||
const url = typeof r.url === 'string' ? r.url : ''
|
||||
const ms = typeof r.elapsed_ms === 'number' ? r.elapsed_ms : 0
|
||||
const ok = status >= 200 && status < 300
|
||||
// 耗时预格式化为人话时长(与卡片右侧耗时同源 formatDuration),不再双显两种口径
|
||||
const head = ok
|
||||
? t('aiTool.httpOk', { method, status, ms })
|
||||
? t('aiTool.httpOk', { method, status, time: formatDuration(ms) })
|
||||
: t('aiTool.httpFailed', { method, status })
|
||||
const parts: string[] = [head, url]
|
||||
const body = typeof r.body === 'string' ? r.body : ''
|
||||
@@ -671,14 +674,15 @@ function summaryListDir(r: ToolResult): string {
|
||||
function summaryRunCommand(r: ToolResult): string {
|
||||
const code = typeof r.exit_code === 'number' ? r.exit_code : -1
|
||||
const ms = typeof r.duration_ms === 'number' ? r.duration_ms : 0
|
||||
return code === 0 ? t('aiTool.commandOk', { ms }) : t('aiTool.commandFailed', { code })
|
||||
// 耗时预格式化为人话时长(与卡片右侧耗时同源 formatDuration),不再双显两种口径
|
||||
return code === 0 ? t('aiTool.commandOk', { time: formatDuration(ms) }) : t('aiTool.commandFailed', { code })
|
||||
}
|
||||
|
||||
/** file_info summary 版:存在性 + 大小(不带 modified/lines/binary extras) */
|
||||
/** file_info summary 版:存在性 + 大小(不带 modified/lines/binary extras;路径与其他卡统一缩略尾段,全路径走头部 title) */
|
||||
function summaryFileInfo(r: ToolResult): string {
|
||||
return r.exists === false
|
||||
? t('aiTool.fileInfoMissing', { path: r.path || '' })
|
||||
: t('aiTool.fileInfoSize', { path: r.path || '', size: formatBytes(typeof r.size === 'number' ? r.size : 0) })
|
||||
? t('aiTool.fileInfoMissing', { path: shortPath(r.path || '') })
|
||||
: t('aiTool.fileInfoSize', { path: shortPath(r.path || ''), size: formatBytes(typeof r.size === 'number' ? r.size : 0) })
|
||||
}
|
||||
|
||||
/** rename_file summary 版:renamed===false 返空串(body 版走 formatJson) */
|
||||
@@ -712,8 +716,9 @@ function summaryHttpRequest(r: ToolResult): string {
|
||||
const method = typeof r.method === 'string' ? r.method : 'HTTP'
|
||||
const ms = typeof r.elapsed_ms === 'number' ? r.elapsed_ms : 0
|
||||
const ok = status >= 200 && status < 300
|
||||
// 耗时预格式化为人话时长(与卡片右侧耗时同源 formatDuration),不再双显两种口径
|
||||
return ok
|
||||
? t('aiTool.httpOk', { method, status, ms })
|
||||
? t('aiTool.httpOk', { method, status, time: formatDuration(ms) })
|
||||
: t('aiTool.httpFailed', { method, status })
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,13 @@
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { isToolFailure } from '@/composables/ai/useToolCard'
|
||||
import { toolDisplayName } from '@/composables/ai/useToolCardHeader'
|
||||
import { formatDuration } from '@/utils/time'
|
||||
import type { AiMessage, AiToolCallInfo } from '@/api/types'
|
||||
|
||||
// formatDuration 下沉至 utils/time.ts(纯格式化函数归属);此处 re-export 保持既有引用兼容
|
||||
// (本模块已 import useToolCard 的 isToolFailure,反向提供 formatDuration 会成循环依赖)
|
||||
export { formatDuration } from '@/utils/time'
|
||||
|
||||
/** 一次执行轨迹(轮次)的工具调用聚合统计 */
|
||||
export interface TurnCallStats {
|
||||
/** 调用总数(含 running/pending/failed/rejected) */
|
||||
@@ -106,29 +111,29 @@ export function turnSummaryText(s: TurnCallStats, durationMs?: number): string {
|
||||
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`
|
||||
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`
|
||||
}
|
||||
|
||||
/** 单次调用耗时文本(前端 startedAt/completedAt 差;历史消息无时间戳返回空串隐藏) */
|
||||
export function callDurationText(startedAt?: number, completedAt?: number): string {
|
||||
if (startedAt == null || completedAt == null) return ''
|
||||
return formatDuration(completedAt - startedAt, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 全程 token 汇总:一组消息的 in(prompt 全量)+cache+out+reason 求和(与 MessageList
|
||||
* token 分项同源 fallback 链 tokenUsage → prompt_tokens 系列字段,不改后端协议)。
|
||||
* 供执行轨迹卡头部「≈61k tokens」一处汇总,per-round 明细归思考 pill 行。
|
||||
*/
|
||||
export function sumTurnTokens(msgs: AiMessage[]): number {
|
||||
let sum = 0
|
||||
for (const m of msgs) {
|
||||
sum += m.tokenUsage?.prompt ?? m.prompt_tokens ?? 0
|
||||
sum += m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? 0
|
||||
sum += m.tokenUsage?.completion ?? m.completion_tokens ?? 0
|
||||
sum += m.tokenUsage?.reasoning ?? m.reasoning_tokens ?? 0
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
/** token 数格式化:<1000 原值,≥1000 用 k 保留一位小数(1234→1.2k) */
|
||||
export function formatTokensK(n: number): string {
|
||||
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user