+
+
+
+
+ ▸
+ {{ $t('ai.trace.title') }}
+ {{ traceAggText(item.stats, item.startTs, item.endTs) }}
+ {{ item.expanded ? $t('ai.trace.collapse') : $t('ai.trace.expand') }}
+
+
+
+
+
+
+
+ {{ stepText(item.stats) }}
+
+
@@ -849,7 +985,9 @@ defineExpose({
emit('start-edit', m)"
/>
@@ -1299,31 +1437,6 @@ defineExpose({
right: 30px;
}
-/* ── 末条 user 消息编辑按钮(hover 显示,回填输入框编辑重生成) ── */
-.ai-msg-edit-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 20px;
- height: 20px;
- border: none;
- border-radius: var(--df-radius-sm);
- background: transparent;
- color: var(--df-text-dim);
- cursor: pointer;
- opacity: 0;
- transition: opacity 0.2s, background 0.2s;
- flex-shrink: 0;
- align-self: flex-end;
-}
-.ai-msg-user:hover .ai-msg-edit-btn {
- opacity: 1;
-}
-.ai-msg-edit-btn:hover {
- background: var(--df-bg-hover, rgba(0, 0, 0, 0.06));
- color: var(--df-text);
-}
-
/* ── UX-02/消息操作栏(hover 显示 / 错误气泡常显) ── */
.ai-msg-actions {
display: flex;
@@ -1549,4 +1662,80 @@ defineExpose({
.ai-msg-segment-icon { flex-shrink: 0; }
.ai-msg-segment-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ai-msg-segment-toggle { flex-shrink: 0; font-size: 10px; opacity: 0.8; }
+
+/* A 过程压缩:执行轨迹卡(完成轮次过程内容折叠为一张,视觉对齐 .ai-msg-segment 弱化风格) */
+.ai-trace-card {
+ max-width: var(--df-msg-max-width);
+ border: 0.5px dashed color-mix(in srgb, var(--df-text-dim) 35%, transparent);
+ border-radius: var(--df-radius);
+ background: color-mix(in srgb, var(--df-text-dim) 6%, transparent);
+ overflow: hidden;
+ transition: border-color 0.15s var(--df-ease), background 0.15s var(--df-ease);
+}
+.ai-trace-card:hover {
+ border-color: color-mix(in srgb, var(--df-accent) 40%, transparent);
+ background: color-mix(in srgb, var(--df-accent) 5%, transparent);
+}
+.ai-trace-head {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ padding: 6px 10px;
+ font-size: 11px;
+ cursor: pointer;
+ user-select: none;
+ color: var(--df-text-secondary);
+}
+.ai-trace-chevron {
+ font-size: 10px;
+ color: var(--df-text-dim);
+ transition: transform 0.15s var(--df-ease);
+ flex-shrink: 0;
+}
+.ai-trace-chevron.is-open { transform: rotate(90deg); }
+.ai-trace-title { font-weight: 600; flex-shrink: 0; }
+.ai-trace-agg {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--df-font-mono);
+ font-size: 10px;
+ color: var(--df-text-dim);
+}
+.ai-trace-toggle { flex-shrink: 0; font-size: 10px; color: var(--df-accent); opacity: 0.85; }
+
+/* 执行中实时步骤摘要(已完成步骤收进一行,accent 呼吸点对齐状态栏 .ai-agentic-progress) */
+.ai-step-line {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ max-width: var(--df-msg-max-width);
+ padding: 4px 10px;
+ border-radius: var(--df-radius);
+ background: color-mix(in srgb, var(--df-accent) 7%, transparent);
+ font-size: 11px;
+ color: var(--df-text-secondary);
+ user-select: none;
+}
+.ai-step-dot {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: var(--df-accent);
+ flex-shrink: 0;
+ animation: ai-step-pulse 1.2s ease-in-out infinite;
+}
+.ai-step-text {
+ font-family: var(--df-font-mono);
+ font-size: 10.5px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+@keyframes ai-step-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.3; }
+}
diff --git a/src/composables/ai/useAiStreamingEvents.ts b/src/composables/ai/useAiStreamingEvents.ts
index e83b58d..0ee723f 100644
--- a/src/composables/ai/useAiStreamingEvents.ts
+++ b/src/composables/ai/useAiStreamingEvents.ts
@@ -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',
diff --git a/src/composables/ai/useAiToolEvents.ts b/src/composables/ai/useAiToolEvents.ts
index d7a352b..b4629ee 100644
--- a/src/composables/ai/useAiToolEvents.ts
+++ b/src/composables/ai/useAiToolEvents.ts
@@ -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)
// 工具结束(无论审批通过后执行还是被拒),清审批超时计时器
diff --git a/src/composables/ai/useToolCard.ts b/src/composables/ai/useToolCard.ts
index 730ae70..a53dd2a 100644
--- a/src/composables/ai/useToolCard.ts
+++ b/src/composables/ai/useToolCard.ts
@@ -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> = {
+ 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'),
diff --git a/src/composables/ai/useTurnTrace.ts b/src/composables/ai/useTurnTrace.ts
new file mode 100644
index 0000000..a4f71d7
--- /dev/null
+++ b/src/composables/ai/useTurnTrace.ts
@@ -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)
+}
diff --git a/src/i18n/en/ai.ts b/src/i18n/en/ai.ts
index 32d5a81..c2ca450 100644
--- a/src/i18n/en/ai.ts
+++ b/src/i18n/en/ai.ts
@@ -52,10 +52,25 @@ export default {
unarchiveAction: 'unarchive',
pinAction: 'pin',
unpinAction: 'unpin',
- // Agentic loop progress bar copy
- // Note: max round / completed tool count are not surfaced by backend this round;
- // template conditionally omits them. Add *WithMax keys once backend exposes them.
- agenticProgress: '🔄 Round {round} · ⏳{pending} pending',
- agenticProgressNoPending: '🔄 Round {round} · ✅{completed} tools done',
+ // Agentic loop progress bar copy (status semantics: round N · M calls total · running/pending)
+ // total is aggregated on the frontend from current-turn toolCalls (useTurnTrace), not a backend field
+ // Note: max round is not surfaced by backend; template conditionally omits it.
+ agenticProgress: 'Round {round} · {total} calls total · ⏳{pending} pending',
+ agenticProgressNoPending: 'Round {round} · {total} calls total · running',
+ // Shared semantics for execution trace card / live step summary (A) and aggregate group row (B)
+ trace: {
+ title: 'Execution Trace',
+ calls: '{n} calls',
+ ok: '{n} ok',
+ fail: '{n} failed',
+ rejected: '{n} rejected',
+ duration: 'total {time}',
+ expand: 'Expand trace',
+ collapse: 'Collapse trace',
+ processOnly: 'Process log',
+ stepDone: '✓ {n} calls done',
+ stepRunning: 'running {name}',
+ stepPending: '⏳ {n} pending approval',
+ },
},
}
diff --git a/src/i18n/en/aiChat.ts b/src/i18n/en/aiChat.ts
index 3a84089..e6ece6c 100644
--- a/src/i18n/en/aiChat.ts
+++ b/src/i18n/en/aiChat.ts
@@ -190,7 +190,7 @@ export default {
// M5:path deny second confirmation (deny is irreversible — tool returns Err, loop continues without retry; asymmetry with approve, prevents mis-clicks)
dirAuthDenyConfirm: 'Deny authorization? The tool will fail immediately and will not be retried this round.',
// Persistent bypass banner shown at the top of the chat area while "trust all directories" is on
- pathAuthBypassBanner: 'Authorization bypassed: AI can access files directly',
+ pathAuthBypassBanner: 'Authorization fully bypassed (including sensitive directories): AI can access any file directly',
// ── Input Augmentation layer: mention/skill injection feedback ──
// search_files blind search denied by whitelist fallback (no auth dialog; prompt user to use @mention or absolute path)
diff --git a/src/i18n/zh-CN/ai.ts b/src/i18n/zh-CN/ai.ts
index b664558..987b8f8 100644
--- a/src/i18n/zh-CN/ai.ts
+++ b/src/i18n/zh-CN/ai.ts
@@ -52,9 +52,25 @@ export default {
unarchiveAction: '取消归档',
pinAction: '置顶',
unpinAction: '取消置顶',
- // Agentic 循环进度条文案
- // 注:max 轮次/已完成工具数本轮后端暂不透传,模板条件渲染省略;后端补全后新增 *WithMax key
- agenticProgress: '🔄 循环 {round} · ⏳{pending}待审批',
- agenticProgressNoPending: '🔄 循环 {round} · ✅{completed}个工具完成',
+ // Agentic 循环进度条文案(C 状态语义化:第 N 轮 · 累计 M 次调用 · 进行中/待审批)
+ // total 由前端聚合当前轮次 toolCalls(useTurnTrace),非后端字段
+ // 注:max 总轮次后端暂不透传,模板条件渲染省略;后端补全后新增 *WithMax key
+ agenticProgress: '第 {round} 轮 · 累计 {total} 次调用 · ⏳{pending} 项待审批',
+ agenticProgressNoPending: '第 {round} 轮 · 累计 {total} 次调用 · 进行中',
+ // 执行轨迹卡/实时步骤摘要(A 过程压缩)与聚合分组行(B 聚合卡统一)共用语义
+ trace: {
+ title: '执行轨迹',
+ calls: '{n} 次调用',
+ ok: '成功 {n}',
+ fail: '失败 {n}',
+ rejected: '拒绝 {n}',
+ duration: '总耗时 {time}',
+ expand: '展开轨迹',
+ collapse: '收起轨迹',
+ processOnly: '过程记录',
+ stepDone: '✓ {n} 次调用完成',
+ stepRunning: '正在 {name}',
+ stepPending: '⏳ {n} 项待审批',
+ },
},
}
diff --git a/src/i18n/zh-CN/aiChat.ts b/src/i18n/zh-CN/aiChat.ts
index db596a5..2ea3131 100644
--- a/src/i18n/zh-CN/aiChat.ts
+++ b/src/i18n/zh-CN/aiChat.ts
@@ -191,7 +191,7 @@ export default {
// M5:path 类拒绝二次确认(拒绝不可逆——工具返 Err、loop 续跑不再重试,与 approve 不对称,防误点)
dirAuthDenyConfirm: '确定拒绝授权吗?该工具将立即失败,且本轮不会重试。',
// 「信任所有目录」总开关开启期间的常驻放行横幅(对话区顶部)
- pathAuthBypassBanner: '授权已放行:AI 可直接访问文件',
+ pathAuthBypassBanner: '授权已完全放行(含敏感目录):AI 可直接访问任意文件',
// ── Input Augmentation 层: mention/skill 注入反馈 ──
// search_files 盲搜被白名单兜底拒绝(不应弹授权窗,直接友好提示用户改用 @引用或绝对路径)
diff --git a/src/stores/ai.ts b/src/stores/ai.ts
index 08ab0be..043d4a8 100644
--- a/src/stores/ai.ts
+++ b/src/stores/ai.ts
@@ -103,8 +103,6 @@ const _stateBase: {
skills: SkillInfo[]
queue: { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[]; spans?: MentionSpan[]; conversationId?: string | null }[]
agentRound: number
- /// 已完成工具调用数(本轮累计,AiToolCallCompleted 时 +1,AiAgentRound 时重置)
- completedTools: number
archivedCollapsed: boolean
foldedGroups: Record
searchQuery: string
@@ -139,7 +137,6 @@ const _stateBase: {
// Agentic 循环当前轮次(0=非agentic/未开始;>0=收到后端 AiAgentRound 事件的 round)
// 进度条用; AiCompleted/AiError 复位为 0
agentRound: 0,
- completedTools: 0,
// 归档分组折叠态(默认折叠)
archivedCollapsed: true,
// 时间分组折叠态(默认:更早折叠,今天/昨天展开;持久化进 df-ai-ui)