From 66c6553cdff6b59c578b05003a79f6616b0fb05e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com>
Date: Thu, 1 Oct 2026 19:35:20 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20ai-chat=20=E5=AF=B9?=
=?UTF-8?q?=E8=AF=9D=E6=B5=81=E5=B8=83=E5=B1=80=E4=B8=8E=E8=BD=A8=E8=BF=B9?=
=?UTF-8?q?=E4=BF=A1=E6=81=AF=E5=AF=86=E5=BA=A6=E6=94=B9=E9=80=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 分组行去统计化,调用统计归并轨迹卡头部
- 相对时间改时间分隔线,跨日显绝对时间
- 思考过程单行 pill 化,答案卡加 accent 边
- 工具卡短路径展示与耗时提示统一
- 高风险工具名单启动时同步后端注册表
- 自动重试改琥珀细条,红卡与手动重试仅终态
- 轨迹卡头并时间线行,显轮次号与绝对时间
- 运行中摘要补当前工具名与失败计数
- 执行中工具卡加实时计时,耗时统一人话口径
- 重试残留清理改标记匹配,替代中文子串硬编码
---
src/api/types.ts | 7 +
src/components/ToolCard.vue | 63 ++-
src/components/ToolCardList.vue | 94 +----
src/components/ToolResultBody.vue | 13 +-
src/components/ai/MessageList.vue | 442 ++++++++++++++++++---
src/composables/ai/useAiLifecycleEvents.ts | 5 +-
src/composables/ai/useAiStreamingEvents.ts | 6 +-
src/composables/ai/useToolCard.ts | 19 +-
src/composables/ai/useTurnTrace.ts | 47 ++-
src/i18n/en/ai.ts | 7 +-
src/i18n/en/aiTool.ts | 14 +-
src/i18n/zh-CN/ai.ts | 7 +-
src/i18n/zh-CN/aiTool.ts | 12 +-
src/utils/time.ts | 35 ++
14 files changed, 562 insertions(+), 209 deletions(-)
diff --git a/src/api/types.ts b/src/api/types.ts
index fb8b0e7..c2bc93b 100644
--- a/src/api/types.ts
+++ b/src/api/types.ts
@@ -528,6 +528,13 @@ export interface AiMessage {
role: 'user' | 'assistant' | 'tool' | 'system'
content: string
isError?: boolean
+ /**
+ * 流式重试过渡标记(AiStreamRetry 推送的过渡气泡,前端内存专用不落库)。
+ * true=重试进行中(MessageList 渲染为琥珀单行细条,不走错误红卡);终态错误
+ * (AiError)不带此标记,仍走错误卡与重试按钮。会话收尾清残留按此标记匹配,
+ * 不再依赖文案子串(多语言切换不影响清理)。
+ */
+ isRetry?: boolean
/**
* 消息上下文状态(F-15 手动上下文管理)。
* null | undefined | 'active' → 正常消息
diff --git a/src/components/ToolCard.vue b/src/components/ToolCard.vue
index 51c191e..7819b75 100644
--- a/src/components/ToolCard.vue
+++ b/src/components/ToolCard.vue
@@ -14,10 +14,10 @@
-
+
diff --git a/src/composables/ai/useAiLifecycleEvents.ts b/src/composables/ai/useAiLifecycleEvents.ts
index ef178ab..2c619c4 100644
--- a/src/composables/ai/useAiLifecycleEvents.ts
+++ b/src/composables/ai/useAiLifecycleEvents.ts
@@ -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)
}
}
diff --git a/src/composables/ai/useAiStreamingEvents.ts b/src/composables/ai/useAiStreamingEvents.ts
index 0ee723f..16a53d9 100644
--- a/src/composables/ai/useAiStreamingEvents.ts
+++ b/src/composables/ai/useAiStreamingEvents.ts
@@ -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)
}
diff --git a/src/composables/ai/useToolCard.ts b/src/composables/ai/useToolCard.ts
index 7ee0475..e7d2d09 100644
--- a/src/composables/ai/useToolCard.ts
+++ b/src/composables/ai/useToolCard.ts
@@ -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 })
}
diff --git a/src/composables/ai/useTurnTrace.ts b/src/composables/ai/useTurnTrace.ts
index b0ef05a..2c94370 100644
--- a/src/composables/ai/useTurnTrace.ts
+++ b/src/composables/ai/useTurnTrace.ts
@@ -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)
+}
diff --git a/src/i18n/en/ai.ts b/src/i18n/en/ai.ts
index c2ca450..d6f0f03 100644
--- a/src/i18n/en/ai.ts
+++ b/src/i18n/en/ai.ts
@@ -22,9 +22,9 @@ export default {
errorAuth: 'Request failed: the API key is invalid or lacks permission.',
errorTimeout: 'The response timed out. Please try again.',
errorNetwork: 'Network connection failed. Please check your network.',
- // CR-30-2 / F-260616-07(d) / decision a1: stream pre-failure retry bubble copy
- // clarify this is an AI response (network/rate-limit) retry, not a tool failure (shown in card)
- aiStreamRetry: '⚠ AI response failed, retrying ({attempt}/{max})…',
+ // CR-30-2 / F-260616-07(d) / decision a1: copy for the auto-retry transition line
+ // (neutral wording: a retry is transient, not a terminal failure; exhausted retries still surface the AiError card)
+ aiStreamRetry: 'Connection interrupted, auto-retrying ({attempt}/{max})…',
// Fatal errors (4xx/auth) are not retryable — prompt user to check settings instead
notRetryable: 'This error cannot be resolved by retrying. Please check the configuration.',
streamInterruptedAfterTool: '⚠ The tool finished, but the following reply was interrupted (no data stream for a long time). You can click Continue to retry.',
@@ -67,6 +67,7 @@ export default {
duration: 'total {time}',
expand: 'Expand trace',
collapse: 'Collapse trace',
+ roundN: 'Round {n}',
processOnly: 'Process log',
stepDone: '✓ {n} calls done',
stepRunning: 'running {name}',
diff --git a/src/i18n/en/aiTool.ts b/src/i18n/en/aiTool.ts
index 1af321a..999cc55 100644
--- a/src/i18n/en/aiTool.ts
+++ b/src/i18n/en/aiTool.ts
@@ -15,14 +15,18 @@ export default {
// tool execution failure marker (AR-6 keeps status=completed, red to distinguish from success)
executionFailed: 'Failed',
executionFailedHint: 'Tool execution failed, see details below',
- // Singular expand/collapse (ToolCard single-card content, command output toggle; expandAll/collapseAll are for ToolCardList global multi-group)
+ // Singular expand/collapse (ToolCard single-card content, command output toggle)
expand: 'Expand',
collapse: 'Collapse',
- expandAll: 'Expand all',
- collapseAll: 'Collapse all',
written: 'written',
items: 'items',
lines: 'lines',
+ // Status dot tooltips (ToolCard green/red/blue dot hover, zh-en paired)
+ statusOk: 'Success',
+ statusFailed: 'Failed',
+ statusRunning: 'Running',
+ statusPending: 'Pending approval',
+ statusRejected: 'Rejected',
// UX-260618-08/11: truncation hints (read_file paged truncation / list_directory 1000-entry cap)
linesTruncated: '{shown}/{total} lines, truncated',
dirTruncated: 'Truncated, first {n} items only',
@@ -74,7 +78,7 @@ export default {
advancedTask: 'Task「{title}」 advanced to {status}',
// advance_task return-round accumulation (shown only when >0); {title} = task name (TaskRecord), fallback id
advancedTaskWithRounds: 'Task「{title}」 advanced to {status} (returned {n} times)',
- commandOk: 'Command succeeded ({ms} ms)',
+ commandOk: 'Command succeeded ({time})',
commandFailed: 'Command failed (exit code {code})',
commandOutputLines: '{n} lines of output',
patchedFile: 'Patched {path} ({diff} bytes)',
@@ -90,7 +94,7 @@ export default {
writeOverwrite: 'overwrote {old} → {neww}',
encodingLabel: 'encoding: {enc}',
// http_request result summary (method + status + elapsed; body in expanded view)
- httpOk: '{method} {status} OK ({ms} ms)',
+ httpOk: '{method} {status} OK ({time})',
httpFailed: '{method} {status} failed',
httpTruncated: 'Response truncated ({bytes}), see details',
httpRequestFallback: 'HTTP Request',
diff --git a/src/i18n/zh-CN/ai.ts b/src/i18n/zh-CN/ai.ts
index 987b8f8..77b3fa3 100644
--- a/src/i18n/zh-CN/ai.ts
+++ b/src/i18n/zh-CN/ai.ts
@@ -22,9 +22,9 @@ export default {
errorAuth: '调用失败:API Key 无效或无权限',
errorTimeout: '响应超时,请重试',
errorNetwork: '网络连接失败,请检查网络',
- // CR-30-2 / F-260616-07(d) / 决策 a1: 流前失败重试中错误气泡内更新文案
- // 明确这是 AI 响应(网络/限流)重试,非工具执行失败(工具失败在卡片内显示)
- aiStreamRetry: '⚠ AI 响应失败,正在重试({attempt}/{max})…',
+ // CR-30-2 / F-260616-07(d) / 决策 a1: 流中断自动重试的过渡细条文案(中性描述:
+ // 重试是过渡态非终态失败,不用错误警示语气;重试耗尽的终态失败仍走 AiError 错误卡)
+ aiStreamRetry: '与上游连接中断,自动重试中({attempt}/{max})…',
// Fatal 类错误(4xx/鉴权)不可重试,提示用户去设置而非重试
notRetryable: '此错误无法通过重试解决,请检查配置',
streamInterruptedAfterTool: '⚠ 工具已执行完成,后续回复中断(长时间无数据流)。可点继续重试。',
@@ -67,6 +67,7 @@ export default {
duration: '总耗时 {time}',
expand: '展开轨迹',
collapse: '收起轨迹',
+ roundN: '第 {n} 轮',
processOnly: '过程记录',
stepDone: '✓ {n} 次调用完成',
stepRunning: '正在 {name}',
diff --git a/src/i18n/zh-CN/aiTool.ts b/src/i18n/zh-CN/aiTool.ts
index 288eb72..56b4901 100644
--- a/src/i18n/zh-CN/aiTool.ts
+++ b/src/i18n/zh-CN/aiTool.ts
@@ -16,12 +16,16 @@ export default {
executionFailed: '执行失败',
executionFailedHint: '工具执行失败,详情见下方',
collapse: '收起',
- expandAll: '展开全部',
expand: '展开',
- collapseAll: '全部收起',
written: '已写入',
items: '项',
lines: '行',
+ // 状态点 tooltip(ToolCard 绿/红/蓝点 hover,中英成对)
+ statusOk: '成功',
+ statusFailed: '失败',
+ statusRunning: '进行中',
+ statusPending: '待审批',
+ statusRejected: '已拒绝',
// UX-260618-08/11:截断提示(read_file 分页截断 / list_directory 1000 上限截断)
linesTruncated: '{shown}/{total} 行,已截断',
dirTruncated: '已截断,仅前 {n} 项',
@@ -75,7 +79,7 @@ export default {
advancedTask: '任务「{title}」已推进至 {status}',
// advance_task 退回累加轮数(>0 才显);{title} 显任务名(后端返 TaskRecord),回退 id
advancedTaskWithRounds: '任务「{title}」已推进至 {status}(已退回 {n} 次)',
- commandOk: '命令成功({ms} 毫秒)',
+ commandOk: '命令成功({time})',
commandFailed: '命令失败(退出码 {code})',
commandOutputLines: '{n} 行输出',
patchedFile: '已修改 {path}(增减 {diff} 字节)',
@@ -91,7 +95,7 @@ export default {
writeOverwrite: '覆盖 {old} → {neww}',
encodingLabel: '编码:{enc}',
// http_request 结果摘要(method + 状态码 + 耗时;body 在展开态显示)
- httpOk: '{method} {status} 成功({ms} 毫秒)',
+ httpOk: '{method} {status} 成功({time})',
httpFailed: '{method} {status} 失败',
httpTruncated: '响应已截断({bytes}),详见下方',
httpRequestFallback: 'HTTP 请求',
diff --git a/src/utils/time.ts b/src/utils/time.ts
index 7ab48ea..55bdd3d 100644
--- a/src/utils/time.ts
+++ b/src/utils/time.ts
@@ -76,6 +76,41 @@ export function formatRelative(v: string | number | null | undefined, opts?: For
return t('common.dayAgo', { n: diffD })
}
+/** 消息间隔分隔线用绝对时间:当天只 HH:mm,跨天 MM-DD HH:mm;无效返回 '—'(格式定宽不随 locale) */
+export function formatAbsolute(v: string | number | null | undefined): string {
+ const ms = parseTs(v)
+ if (ms == null) return '—'
+ const d = new Date(ms)
+ const now = new Date()
+ const sameDay = d.getFullYear() === now.getFullYear()
+ && d.getMonth() === now.getMonth()
+ && d.getDate() === now.getDate()
+ const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
+ if (sameDay) return hm
+ return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${hm}`
+}
+
+/**
+ * 时间跨度格式化。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`
+}
+
// ── 审批等待时长(ToolCard / ApprovalPopup 双写收敛) ──
export type WaitLevel = 'normal' | 'long' | 'urgent'