优化: ai-chat 对话流布局与轨迹信息密度改造

- 分组行去统计化,调用统计归并轨迹卡头部
- 相对时间改时间分隔线,跨日显绝对时间
- 思考过程单行 pill 化,答案卡加 accent 边
- 工具卡短路径展示与耗时提示统一
- 高风险工具名单启动时同步后端注册表
- 自动重试改琥珀细条,红卡与手动重试仅终态
- 轨迹卡头并时间线行,显轮次号与绝对时间
- 运行中摘要补当前工具名与失败计数
- 执行中工具卡加实时计时,耗时统一人话口径
- 重试残留清理改标记匹配,替代中文子串硬编码
This commit is contained in:
lxy
2026-10-01 19:35:20 +08:00
parent 0560137840
commit 66c6553cdf
14 changed files with 562 additions and 209 deletions
+7
View File
@@ -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' → 正常消息
+54 -9
View File
@@ -14,10 +14,10 @@
<div class="ai-tool-header-text">
<span class="ai-tool-name">{{ toolDisplayName(tc) }}</span>
<span v-if="tc.status === 'running'" class="ai-tool-sub">{{ $t('aiTool.running') }}</span>
<span v-else-if="isFailed" class="ai-tool-sub ai-tool-sub--failed">{{ $t('aiTool.executionFailed') }}</span>
<span v-else-if="toolResultSummary(tc)" class="ai-tool-sub">{{ toolResultSummary(tc) }}</span>
<span v-else-if="isFailed" class="ai-tool-sub ai-tool-sub--failed" :title="summaryTitle">{{ $t('aiTool.executionFailed') }}</span>
<span v-else-if="toolResultSummary(tc)" class="ai-tool-sub" :title="summaryTitle">{{ toolResultSummary(tc) }}</span>
</div>
<span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + (isFailed ? 'failed' : tc.status)"></span>
<span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + (isFailed ? 'failed' : tc.status)" :title="statusTitle"></span>
<!-- 统一卡形态第4要素:耗时(前端 startedAt/completedAt 差;历史消息无时间戳隐藏不显 0)。
hover 显绝对起止时刻,相对时间不再占卡片行(B 聚合卡统一)。 -->
<span
@@ -144,7 +144,7 @@
</template>
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { computed, ref, toRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
shouldKeepOpen,
@@ -152,12 +152,14 @@ import {
toolCategory,
toolIcon,
toolResultSummary,
argString,
} from '@/composables/ai/useToolCard'
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
import { useToolCardHeader } from '@/composables/ai/useToolCardHeader'
import { useToolApproval } from '@/composables/ai/useToolApproval'
import { useTimerOwnership } from '@/composables/useTimerOwnership'
import { callDurationText } from '@/composables/ai/useTurnTrace'
import { formatDate } from '@/utils/time'
import { formatDate, formatDuration } from '@/utils/time'
import type { AiToolCallInfo } from '@/api/types'
import ConfirmDialog from './ConfirmDialog.vue'
import ToolResultBody from './ToolResultBody.vue'
@@ -241,14 +243,57 @@ const isFailed = computed(() => {
/** write_file 审批 diff 行解析(复用 parseDiffLines 纯函数,数据源 props.tc.diff) */
const diffLines = computed(() => parseDiffLines(props.tc.diff))
/** 单卡耗时(前端时间差;历史消息无 startedAt/completedAt 返空串隐藏) */
const durationText = computed(() => callDurationText(props.tc.startedAt, props.tc.completedAt))
/** hover 提示:绝对起止时刻(相对时长 hover 可溯具体时间) */
// 执行中实时走表:进入 running 每秒刷新 nowTick,离开时清理(定时器经 useTimerOwnership
// 注册,onUnmounted 自动全清,防组件卸载后计时器仍触发)
const { setOwnInterval, clearOwnInterval } = useTimerOwnership()
const nowTick = ref(Date.now())
let runTimerId: ReturnType<typeof setInterval> | null = null
watch(() => props.tc.status, (s) => {
if (s === 'running') {
nowTick.value = Date.now()
if (runTimerId === null) {
runTimerId = setOwnInterval(() => { nowTick.value = Date.now() }, 1000)
}
} else if (runTimerId !== null) {
clearOwnInterval(runTimerId)
runTimerId = null
}
}, { immediate: true })
/** 单卡耗时(前端时间差;历史消息无 startedAt/completedAt 返空串隐藏);
* running 态以 nowTick 实时走表(需 startedAt,与完成态共用同一时间差口径) */
const durationText = computed(() => {
const tc = props.tc
if (tc.status === 'running' && tc.startedAt != null) {
return formatDuration(nowTick.value - tc.startedAt, true)
}
return callDurationText(tc.startedAt, tc.completedAt)
})
/** hover 提示:后端实测毫秒 + 绝对起止时刻(run_command 取 duration_ms、http_request 取
* elapsed_ms,与前端时间差口径互证;无后端耗时的工具只显起止时刻) */
const durationTitle = computed(() => {
const tc = props.tc
if (tc.startedAt == null || tc.completedAt == null) return ''
return `${formatDate(tc.startedAt)} ~ ${formatDate(tc.completedAt)}`
const r = parsed.value
const backendMs = tc.name === 'run_command'
? (typeof r?.duration_ms === 'number' ? r.duration_ms : null)
: (tc.name === 'http_request' && typeof r?.elapsed_ms === 'number' ? r.elapsed_ms : null)
const span = `${formatDate(tc.startedAt)} ~ ${formatDate(tc.completedAt)}`
return backendMs != null ? `${backendMs}ms · ${span}` : span
})
/** 状态点 tooltip:成功/失败/进行中/待审批/已拒绝(绿红蓝点 hover 可读,不再靠颜色猜) */
const statusTitle = computed(() => {
const s = props.tc.status
if (s === 'running') return t('aiTool.statusRunning')
if (s === 'pending_approval') return t('aiTool.statusPending')
if (s === 'rejected') return t('aiTool.statusRejected')
if (s === 'completed') return isFailed.value ? t('aiTool.statusFailed') : t('aiTool.statusOk')
return ''
})
/** 头部摘要 hover 回看完整路径(摘要统一缩略尾段,全路径走 title;路径类工具才有值) */
const summaryTitle = computed(() => argString(props.tc.args, 'path'))
</script>
<style scoped>
+4 -90
View File
@@ -1,6 +1,6 @@
<template>
<div class="ai-tool-calls" ref="rootEl">
<!-- 顶部操作栏:批量审批(全局收起已下沉到首个可折叠分组标题行,U-260618) -->
<!-- 顶部操作栏:批量审批(轨迹卡 toggle 已覆盖整体展开/收起,分组行仅单组导航) -->
<div v-if="pendingCount > 0" class="ai-tool-topbar">
<!-- 批量操作栏(存在待审批项时显示) -->
<div class="ai-batch-approve">
@@ -14,22 +14,12 @@
</div>
<template v-for="(group, gi) in groupedToolCalls" :key="group.name">
<!-- 分组标题:同名工具 >=2 个时显示(B 聚合卡统一:默认行「N 次调用 · 成功 x 失败 y」,
点开逐条;成功/失败与状态点同一套 isToolFailure 判定,文案走 ai.trace 同源 key) -->
<!-- 分组标题:同名工具 >=2 个时显示(仅分组导航语义:点开逐条。
调用统计收敛到执行轨迹卡头部一处,分组行不再重复计数) -->
<div v-if="group.calls.length >= 2" class="ai-tool-group-header" @click="toggleGroupCollapse(gi)">
<span class="ai-tool-group-icon" v-html="groupIcon(group.name)"></span>
<span class="ai-tool-group-name">{{ groupDisplayName(group) }}</span>
<span class="ai-tool-group-agg">{{ groupAggText(group) }}</span>
<span class="ai-tool-group-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }">▸</span>
<!-- 全部展开/收起(U-260618:从 topbar 下沉到首个可折叠分组标题行尾巴,与 run command 同行) -->
<div
v-if="gi === firstCollapsibleGi"
class="ai-tool-global-toggle"
@click.stop="toggleAllGroups"
>
<span class="ai-tool-global-toggle-icon">{{ allGroupsExpanded ? '▾' : '▸' }}</span>
<span>{{ allGroupsExpanded ? $t('aiTool.collapseAll') : $t('aiTool.expandAll') }}</span>
</div>
</div>
<ToolCard
v-for="tc in group.calls"
@@ -52,7 +42,6 @@ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import ToolCard from './ToolCard.vue'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { aggregateCalls, turnSummaryText } from '@/composables/ai/useTurnTrace'
import type { AiToolCallInfo } from '@/api/types'
// useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key)
@@ -124,32 +113,11 @@ const groupedToolCalls = computed<ToolCallGroup[]>(() => {
return groups
})
/** 可折叠分组数(calls >= 2 的组),控制全局按钮显隐 */
const collapsibleGroupCount = computed(() =>
groupedToolCalls.value.filter(g => g.calls.length >= 2).length
)
/** 首个可折叠分组索引全部展开/收起按钮下沉到此分组标题行尾巴) */
const firstCollapsibleGi = computed(() => {
for (let i = 0; i < groupedToolCalls.value.length; i++) {
if (groupedToolCalls.value[i].calls.length >= 2) return i
}
return -1
})
/** 待审批工具数(status === 'pending_approval'),控制批量操作栏显隐 */
const pendingCount = computed(() =>
props.toolCalls.filter(tc => tc.status === 'pending_approval').length
)
/** 是否所有可折叠分组都已展开 */
const allGroupsExpanded = computed(() => {
if (collapsibleGroupCount.value === 0) return true
return groupedToolCalls.value.every((g, gi) =>
g.calls.length < 2 || !collapsedGroups.value.has(gi)
)
})
/**
* 默认收起补齐:达到多卡阈值(>=2)且用户未手动操作过的组自动收起。
* 响应式而非一次性初始化——流式场景第一张卡到达即触发旧一次性逻辑,
@@ -181,24 +149,6 @@ function toggleGroupCollapse(gi: number): void {
collapsedGroups.value = next
}
/** 全部展开 / 全部收起切换 */
function toggleAllGroups(): void {
const next = new Set<number>()
// 全部记 touched:批量操作针对当前可见组,流式新增组仍走默认收起,
// 但已有组不得被补齐逻辑收回(否则"全部展开"点完即被回退)
for (const g of groupedToolCalls.value) {
if (g.calls.length >= 2) userTouchedGroups.value.add(g.name)
}
if (allGroupsExpanded.value) {
// 当前全展开 → 全部收起
for (let i = 0; i < groupedToolCalls.value.length; i++) {
if (groupedToolCalls.value[i].calls.length >= 2) next.add(i)
}
}
// 当前非全展开 → 全部展开(next 保持空 Set)
collapsedGroups.value = next
}
/**
* 分组显示名:工具名 → i18n 友好标签(英文化,原硬编码中文)。
* 用 aiTool.toolGroup.* 子键查表;未命中的工具回退 _ → 空格风格。
@@ -228,11 +178,6 @@ function groupDisplayName(group: ToolCallGroup): string {
return group.name.replace(/_/g, ' ')
}
/** 分组聚合行文案:「N 次调用 · 成功 x 失败 y」(零值项省略;与单卡状态点同源 isToolFailure 判定) */
function groupAggText(group: ToolCallGroup): string {
return turnSummaryText(aggregateCalls(group.calls))
}
/**
* 单卡是否展开:
* - 分组已折叠 → false(整组隐藏)
@@ -414,28 +359,6 @@ function groupIcon(name: string): string {
}
.ai-btn--danger:hover { filter: brightness(1.1); }
/* -- 全部展开/收起从 topbar 下沉到分组标题行,移除独立按钮 padding/背景,
高度=文字行高,不撑高分组标题行;融入标题视觉,仅 hover 变色) -- */
.ai-tool-global-toggle {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
user-select: none;
font-size: 11px;
color: var(--df-text-dim);
transition: color 0.15s var(--df-ease);
/* 推到分组标题行尾巴右对齐 */
margin-left: auto;
}
.ai-tool-global-toggle:hover {
color: var(--df-text-secondary);
}
.ai-tool-global-toggle-icon {
font-size: 10px;
transition: transform 0.15s var(--df-ease);
}
/* -- 分组标题 -- */
.ai-tool-group-header {
display: flex;
@@ -464,17 +387,8 @@ function groupIcon(name: string): string {
font-weight: 500;
color: var(--df-text-secondary);
}
/* 聚合行「N 次调用 · 成功 x 失败 y」:弱化等宽,与单卡耗时同视觉层级 */
.ai-tool-group-agg {
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-tool-group-chevron {
/* 移除 margin-left:auto,让全部展开/收起按钮(global-toggle)占据行尾右对齐 */
margin-left: auto;
font-size: 10px;
color: var(--df-text-dim);
transition: transform 0.15s var(--df-ease);
+7 -6
View File
@@ -6,7 +6,7 @@
<div v-if="tc.name === 'read_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-file-content">
<div class="ai-tool-file-bar">
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
<span class="ai-tool-file-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
<!-- BUG-260625-02: search 模式返回 matches(命中行)非 content,
旧模板取 lines/returned_lines 显「0 行」+ 空 content 区,用户误以为读取失败。
search 模式显搜索词 + 命中数(复用 grepHitsN 文案);默认模式显行数/大小 + 展开。 -->
@@ -36,7 +36,7 @@
<div v-else-if="tc.name === 'list_directory' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
<div class="ai-tool-file-bar">
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
<span class="ai-tool-file-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
<span class="ai-tool-file-meta">{{ parsed?.entries?.length || 0 }} {{ $t('aiTool.items') }}</span>
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.dirTruncated', { n: DIR_TRUNCATE_LIMIT }) }}</span>
</div>
@@ -59,7 +59,7 @@
<div v-else-if="tc.name === 'search_files' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
<div class="ai-tool-file-bar">
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
<span class="ai-tool-file-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
<span v-if="parsed?.pattern" class="ai-tool-file-meta">「{{ parsed?.pattern }}」</span>
<span class="ai-tool-file-meta">{{ $t('aiTool.foundN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.results?.length || 0) }) }}</span>
</div>
@@ -77,7 +77,7 @@
<div v-else-if="tc.name === 'grep' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-grep-result">
<div class="ai-tool-file-bar">
<span class="ai-tool-file-icon" v-html="searchIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
<span class="ai-tool-file-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
<span v-if="parsed?.pattern" class="ai-tool-file-meta">「{{ parsed?.pattern }}」</span>
<span class="ai-tool-file-meta">{{ grepHitLabel }}</span>
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.grepTruncated') }}</span>
@@ -114,7 +114,7 @@
<!-- write_file 结果 -->
<div v-else-if="tc.name === 'write_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-write-result">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--df-success)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<span class="ai-tool-write-path">{{ parsed?.path }}</span>
<span class="ai-tool-write-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
<span class="ai-tool-write-meta">{{ formatBytes(parsed?.bytes_written) }} {{ $t('aiTool.written') }}</span>
<span v-if="typeof parsed?.old_size === 'number' && parsed.old_size > 0" class="ai-tool-write-meta">{{ $t('aiTool.writeOverwrite', { old: formatBytes(parsed.old_size), neww: formatBytes(parsed?.bytes_written) }) }}</span>
<span v-if="typeof parsed?.encoding === 'string' && parsed.encoding !== 'utf-8'" class="ai-tool-write-meta">{{ $t('aiTool.encodingLabel', { enc: parsed.encoding }) }}</span>
@@ -125,7 +125,7 @@
<div v-else-if="tc.name === 'patch_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-patch-result">
<div class="ai-tool-file-bar">
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
<span class="ai-tool-file-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
<span class="ai-tool-file-meta">{{ formatSizeDiff(parsed?.size_diff) }} {{ $t('aiTool.bytesUnit') }}</span>
<span v-if="parsed?.matches_found && parsed.matches_found > 1" class="ai-tool-file-meta">{{ $t('aiTool.patchedMatches', { n: parsed.matches_found }) }}</span>
</div>
@@ -231,6 +231,7 @@ import {
combineOutputs,
formatToolResult,
formatGrep,
shortPath,
dirIcon,
fileIcon,
searchIcon,
+384 -58
View File
@@ -21,11 +21,11 @@ import { useMarkdown } from '../../composables/useMarkdown'
import { useMessageScroll } from '../../composables/ai/useMessageScroll'
import { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations'
import { collectTurnStats, turnSummaryText, type TurnCallStats } from '../../composables/ai/useTurnTrace'
import { collectTurnStats, turnSummaryText, sumTurnTokens, formatTokensK, type TurnCallStats } from '../../composables/ai/useTurnTrace'
import ToolCardList from '../ToolCardList.vue'
import MessageItem from './MessageItem.vue'
import EmptyState from './EmptyState.vue'
import { formatRelative, formatDate } from '../../utils/time'
import { formatDate, formatAbsolute } from '../../utils/time'
import type { AiMessage } from '../../api/types'
defineProps<{
@@ -107,10 +107,7 @@ function shouldRenderMsg(msg: AiMessage): boolean {
return true
}
/** token 数格式化:<1000 原值,≥1000 用 k(如 1234→1.2k) */
function formatTokens(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
}
/** token 数格式化收敛到 useTurnTrace.formatTokensK(与轨迹卡头部汇总同一格式化出口) */
// ── token 分项显示(2026-08-02):in/cache/out/reason 分计费 + 详情面板 ──
//
@@ -589,6 +586,8 @@ function canOpenSettings(msg: AiMessage): boolean {
* retryable 推断依据后端 error_type(本批前端推断;后端 AiStreamRetry/AiError 加 retryable 字段下批 df-ai 增强)。
*/
function canRetry(msg: AiMessage): boolean {
// 重试过渡气泡(isRetry)非终态错误,不提供重试入口(终态 AiError 才有重试按钮)
if (msg.isRetry) return false
const et = (msg as AiMessageWithErrorType).errorType
// auth/provider_config = Fatal(鉴权/配置错),重试无意义
if (et === 'auth' || et === 'provider_config') return false
@@ -741,6 +740,44 @@ function toggleTrace(key: string): void {
expandedTraceIds.value = next
}
// UX 密度改造:轨迹内每轮思考单行 pill(灯泡+单行截断+右端 token 小字),点击展开完整思考段落。
// 展开态按 msg.id 记忆,与轨迹卡展开态(expandedTraceIds)互不影响。
const expandedThinkIds = ref<Set<string>>(new Set())
function toggleThink(id: string): void {
const next = new Set(expandedThinkIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
expandedThinkIds.value = next
}
/** 轮次思考纯文本(压平 markdown 换行;空内容回退「过程记录」,纯工具轮也有锚点) */
function thinkPlainText(m: AiMessage): string {
return (m.content || '').replace(/\s+/g, ' ').trim()
}
/** pill 单行文本:纯文本截断 + 空轮回退文案(title 承载全文,此处只管单行) */
function thinkSnippet(m: AiMessage): string {
const txt = thinkPlainText(m)
return txt || t('ai.trace.processOnly')
}
/** pill hover title:完整思考文本(超长截断防 tooltip 卡顿) */
function thinkFullText(m: AiMessage): string {
const txt = thinkPlainText(m)
if (!txt) return t('ai.trace.processOnly')
return txt.length > 500 ? txt.slice(0, 500) + '…' : txt
}
/** 思考 pill 右端 token 小字:「30.3k in(估算) · 0 cache · 157 out」(无 token 数据返回空串隐藏) */
function roundTokenText(m: AiMessage): string {
if (!m.tokenUsage && m.prompt_tokens == null) return ''
const est = tokenEstimated(m) ? `(${t('aiChat.estimated')})` : ''
const parts = [
`${formatTokensK(tokenInOf(m))} in${est}`,
`${formatTokensK(tokenCacheOf(m))} cache`,
`${formatTokensK(tokenOutOf(m))} out`,
]
if (tokenReasonOf(m) > 0) parts.push(`${formatTokensK(tokenReasonOf(m))} reason`)
return parts.join(' · ')
}
/** 轨迹卡聚合行:「N 次调用 · 成功 x 失败 y · 总耗时 T」(拼装统一走 useTurnTrace.turnSummaryText) */
function traceAggText(stats: TurnCallStats, startTs: number, endTs: number): string {
return turnSummaryText(stats, endTs - startTs)
@@ -751,9 +788,13 @@ function traceHoverTitle(startTs: number, endTs: number): string {
return `${formatDate(startTs)} ~ ${formatDate(endTs)}`
}
/** 执行中实时步骤摘要:「✓ N 次调用完成 · 正在 {工具} · ⏳ N 项待审批」(由最近工具事件聚合) */
function stepText(s: TurnCallStats): string {
const parts: string[] = []
/**
* 执行中实时步骤摘要:「第 N 轮 · ✓ N 次调用完成 · 失败 N · 正在 {工具} · ⏳ N 项待审批」。
* 轮次号与轨迹卡同源(flushBucket 的 traceCount,执行中=将成为的轨迹序号);
* 失败/拒绝/待审批段仅非零时显示;全部无数据回退「过程记录」。
*/
function stepText(s: TurnCallStats, round: number): string {
const parts: string[] = [t('ai.trace.roundN', { n: round })]
const done = s.ok + s.fail
if (done > 0) parts.push(t('ai.trace.stepDone', { n: done }))
if (s.fail > 0) parts.push(t('ai.trace.fail', { n: s.fail }))
@@ -764,11 +805,14 @@ function stepText(s: TurnCallStats): string {
}
type RenderItem =
| { kind: 'msg'; key: string; msg: AiMessage }
| { kind: 'msg'; key: string; msg: AiMessage; final?: boolean }
| { kind: 'sep'; key: string; seg: MessageSegment & { key: string } }
// A 过程压缩:完成轮次的执行轨迹卡(traceMsgs 为折叠在内的过程消息)与执行中的实时步骤摘要
| { kind: 'trace'; key: string; stats: TurnCallStats; startTs: number; endTs: number; traceMsgs: AiMessage[]; expanded: boolean }
| { kind: 'step'; key: string; stats: TurnCallStats }
// A 过程压缩:完成轮次的执行轨迹卡(traceMsgs 为折叠在内的过程消息)与执行中的实时步骤摘要;
// round=轨迹序号(卡头「第 N 轮」,与执行中步骤行、轮次导航条同一计数来源)
| { kind: 'trace'; key: string; round: number; stats: TurnCallStats; startTs: number; endTs: number; tokens: number; traceMsgs: AiMessage[]; expanded: boolean }
| { kind: 'step'; key: string; round: number; stats: TurnCallStats }
// UX 密度改造:相邻消息间隔 ≥30 分钟或跨天的居中时间分隔线(替代 per-block 相对时间)
| { kind: 'timesep'; key: string; ts: number }
/**
* 渲染项(A 过程压缩):在归档分段之上叠加轮次折叠。
@@ -776,12 +820,44 @@ type RenderItem =
* 轮次 = 末条 user 之后的连续 assistant 消息(错误气泡/压缩摘要/归档分段不进桶,原样渲染)。
* - 执行中:仅末条消息(当前步骤)展开,先前步骤收进一行实时摘要(step 项,由工具事件聚合)。
* - 完成:轮次内除最终答复外的过程消息折叠为一张执行轨迹卡(trace 项,默认一行聚合),
* 点开回看;用户手动展开记入 expandedTraceIds 不被收回。正文区只留最终答复。
* 点开在卡内 rail 面板回看(每轮思考 pill + 工具卡);用户手动展开记入 expandedTraceIds
* 不被收回。正文区只留最终答复。
* 无过程消息的轮次(单轮问答)零改动原样渲染。
*
* UX 密度改造:相邻消息间隔 ≥30 分钟或跨天时插居中时间分隔线(timesep 项),
* per-block 相对时间已移除。轨距追踪 lastMsgTs 只在消息桶边界推进,
* 轨迹卡与其内部轮次之间不插线(同轮间隔无叙事价值)。轨迹桶自身也不再发
* timesep 行:卡头「第 N 轮」+ 绝对起始时刻已承载时间行职责(两行合一)。
*/
const TIME_SEP_GAP_MS = 30 * 60 * 1000
let lastMsgTs = 0
/** 与上一条已渲染消息是否需要时间分隔线(间隔 ≥30 分钟或跨天;首条不插) */
function needsTimeSep(ts: number): boolean {
if (!lastMsgTs || !ts) return false
if (Math.abs(ts - lastMsgTs) >= TIME_SEP_GAP_MS) return true
const a = new Date(lastMsgTs)
const b = new Date(ts)
return a.getFullYear() !== b.getFullYear()
|| a.getMonth() !== b.getMonth()
|| a.getDate() !== b.getDate()
}
/** 需要时插 timesep 项并推进 lastMsgTs(同一出口防漏插/重插) */
function pushTimeSepIfNeeded(items: RenderItem[], ts: number): void {
if (needsTimeSep(ts)) {
items.push({ kind: 'timesep', key: 'tsep-' + ts + '-' + items.length, ts })
}
lastMsgTs = ts
}
const renderItems = computed<RenderItem[]>(() => {
const items: RenderItem[] = []
let bucket: AiMessage[] = []
lastMsgTs = 0
// 轨迹序号计数:轨迹卡头「第 N 轮」与执行中步骤行同一来源(computed 重算从 0 起,
// 计数即轨迹卡出现顺序,与轮次导航条序号一致)
let traceCount = 0
const flushBucket = () => {
const msgs = bucket.filter(shouldRenderMsg)
@@ -790,13 +866,20 @@ const renderItems = computed<RenderItem[]>(() => {
if (isViewingGenerating.value) {
// 执行中:当前步骤锚定真实末条消息(agentic 新一轮 push 新空气泡,若按「末条有正文」
// 找锚点会错挂上一轮,致当前轮打字机/光标/工具卡整条不渲染);先前步骤收进一行实时摘要
// 找锚点会错挂上一轮,致当前轮打字机/光标/工具卡整条不渲染);先前步骤收进一行实时摘要。
// 摘要统计取整个轮次桶(含当前消息):执行中工具挂在末条消息上(useAiToolEvents 取
// state.messages 末条),只统计 prev 拿不到 runningName,完成计数也滞后一轮(黑盒主因)
const cur = msgs[msgs.length - 1]
const prev = msgs.slice(0, -1)
if (prev.length > 0) {
items.push({ kind: 'step', key: 'step-' + prev[0].id, stats: collectTurnStats(prev) })
pushTimeSepIfNeeded(items, msgs[0].timestamp)
traceCount++
items.push({ kind: 'step', key: 'step-' + prev[0].id, round: traceCount, stats: collectTurnStats(msgs) })
} else {
pushTimeSepIfNeeded(items, cur.timestamp)
}
items.push({ kind: 'msg', key: 'm-' + cur.id, msg: cur })
items.push({ kind: 'msg', key: 'm-' + cur.id, msg: cur, final: true })
lastMsgTs = cur.timestamp
return
}
// 最终答复:末条有正文且非纯 JSON 工具结果的消息;全无正文时保底末条(工具轮可见)
@@ -807,7 +890,7 @@ const renderItems = computed<RenderItem[]>(() => {
}
const finalMsg = fi >= 0 ? msgs[fi] : msgs[msgs.length - 1]
const traceMsgs = msgs.slice(0, msgs.indexOf(finalMsg))
// 完成:轨迹折叠为一张卡,展开时再 emit 段内过程消息(与归档分段同一扁平化模式)
// 完成:轨迹折叠为一张卡,轮次内容在卡内 rail 面板展开(不再摊平成独立消息项)
if (traceMsgs.length > 0) {
const key = 'trace-' + traceMsgs[0].id
const expanded = expandedTraceIds.value.has(key)
@@ -818,29 +901,34 @@ const renderItems = computed<RenderItem[]>(() => {
if (tc.completedAt && tc.completedAt > lastCompleted) lastCompleted = tc.completedAt
}
}
// 轨迹桶不再发 timesep 行:卡头「第 N 轮」+ 绝对起始时刻已承载时间行职责;
// 仅推进轨距,保持出口语义(后续消息的间隔判定从本桶起点起算)
lastMsgTs = traceMsgs[0].timestamp
traceCount++
items.push({
kind: 'trace', key,
round: traceCount,
stats: collectTurnStats(traceMsgs),
startTs: traceMsgs[0].timestamp,
endTs: Math.max(lastCompleted, finalMsg.timestamp),
tokens: sumTurnTokens(traceMsgs),
traceMsgs, expanded,
})
if (expanded) {
for (const m of traceMsgs) {
items.push({ kind: 'msg', key: 'tm-' + m.id, msg: m })
} else {
pushTimeSepIfNeeded(items, finalMsg.timestamp)
}
}
}
items.push({ kind: 'msg', key: 'm-' + finalMsg.id, msg: finalMsg })
items.push({ kind: 'msg', key: 'm-' + finalMsg.id, msg: finalMsg, final: true })
lastMsgTs = finalMsg.timestamp
}
for (const seg of messageSegments.value) {
if (seg.kind !== 'normal') {
flushBucket()
// 折叠段:总 emit sep;展开时再 emit 段内每条 msg
// 折叠段:总 emit sep;展开时再 emit 段内每条 msg(同样按间隔插时间线)
items.push({ kind: 'sep', key: seg.key, seg })
if (expandedSegmentIds.value.has(seg.key)) {
for (const m of seg.msgs) {
pushTimeSepIfNeeded(items, m.timestamp)
items.push({ kind: 'msg', key: 'e-' + seg.key + '-' + m.id, msg: m })
}
}
@@ -852,6 +940,7 @@ const renderItems = computed<RenderItem[]>(() => {
bucket.push(m)
} else {
flushBucket()
pushTimeSepIfNeeded(items, m.timestamp)
items.push({ kind: 'msg', key: 'm-' + m.id, msg: m })
}
}
@@ -859,6 +948,18 @@ const renderItems = computed<RenderItem[]>(() => {
return items
})
// UX 密度改造:轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转不做统计)
const traceNav = computed(() =>
renderItems.value.filter((it): it is Extract<RenderItem, { kind: 'trace' }> => it.kind === 'trace')
)
/** 点击轮次 chip 滚动到对应轨迹卡(block:start 卡头对齐视口顶) */
function jumpToTrace(key: string): void {
messagesContainer.value
?.querySelector(`[data-trace-key="${key}"]`)
?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
// 切换对话/新对话时由父取消编辑态;滚动边沿/跟随意图重置在此自管。
watch(() => store.state.activeConversationId, () => {
// 切会话重置滚动边沿检测 + 跟随意图(新会话默认跟随底部)
@@ -914,38 +1015,96 @@ defineExpose({
@go-to-settings="emit('go-to-settings')"
/>
<!-- 轮次导航条(UX 密度改造:仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做轮次跳转。
调用统计收敛到轨迹卡头部,此处不放数字) -->
<div v-if="traceNav.length >= 3" class="ai-trace-nav">
<button
v-for="tr in traceNav"
:key="tr.key"
class="ai-trace-nav-chip"
:title="$t('ai.trace.roundN', { n: tr.round })"
@click="jumpToTrace(tr.key)"
>{{ tr.round }}</button>
</div>
<!-- 消息列表(F-15 阶段2:按 renderItems 扁平渲染 — 折叠段 emit 'sep' 项,
正常消息 + 展开的折叠段内消息 emit 'msg' 项。连续 archived_segment/compressed
合并一条分隔条,点击展开/收起看原文)
A 过程压缩:trace 项=完成轮次执行轨迹卡,step 项=执行中实时步骤摘要,均先于 msg 分支拦截。
UX-2025-19: 每项外层包 .ai-msg-slot(B-260618-01 虚拟滚动已移除,现恒渲染;
slot 仅作 flex 子占位 + 消息分组的语义容器,gap 由 .ai-messages 承担)。 -->
slot 仅作 flex 子占位 + 消息分组的语义容器,gap 由 .ai-messages 承担)。
UX 密度改造:timesep 项=相邻消息间隔 ≥30 分钟或跨天的居中时间分隔线。 -->
<template v-for="item in renderItems" :key="item.key">
<!-- 执行轨迹卡(完成轮次:过程文本+工具卡折叠为一张,默认一行聚合,点开回看;
用户手动展开记入 expandedTraceIds 不被自动收回) -->
<!-- 执行轨迹卡(完成轮次:过程内容折叠为一张,默认一行聚合,点开在卡内 rail 面板回看
每轮思考 pill + 工具卡;头部聚合行含调用/耗时/全程 token 汇总) -->
<div v-if="item.kind === 'trace'" class="ai-msg-slot">
<div class="ai-trace-card" :class="{ 'ai-trace-card--open': item.expanded }">
<div class="ai-trace-card" :class="{ 'ai-trace-card--open': item.expanded }" :data-trace-key="item.key">
<div class="ai-trace-head" @click="toggleTrace(item.key)">
<span class="ai-trace-chevron" :class="{ 'is-open': item.expanded }">▸</span>
<span class="ai-trace-title">{{ $t('ai.trace.title') }}</span>
<span class="ai-trace-title">{{ $t('ai.trace.roundN', { n: item.round }) }}</span>
<span class="ai-trace-agg" :title="traceHoverTitle(item.startTs, item.endTs)">{{ traceAggText(item.stats, item.startTs, item.endTs) }}</span>
<span v-if="item.tokens > 0" class="ai-trace-tokens">≈{{ formatTokensK(item.tokens) }} tokens</span>
<!-- 绝对起始时刻(mono 小字):轨迹桶已不发独立时间分隔线,时刻职责由卡头承载 -->
<span class="ai-trace-tokens">{{ formatAbsolute(item.startTs) }}</span>
<span class="ai-trace-toggle">{{ item.expanded ? $t('ai.trace.collapse') : $t('ai.trace.expand') }}</span>
</div>
<!-- 展开态 rail 面板:每轮=思考单行 pill(点击展开全文)+工具卡,图标锚定左轨连线贯通 -->
<div v-if="item.expanded" class="ai-trace-body">
<div v-for="m in item.traceMsgs" :key="m.id" class="ai-trace-round">
<button
class="ai-think-pill"
:title="thinkFullText(m)"
@click="toggleThink(m.id)"
>
<span class="ai-think-ic">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 00-4 12.7c.6.5 1 1.4 1 2.3h6c0-.9.4-1.8 1-2.3A7 7 0 0012 2z"/></svg>
</span>
<span class="ai-think-text">{{ thinkSnippet(m) }}</span>
<span v-if="roundTokenText(m)" class="ai-think-tokens">{{ roundTokenText(m) }}</span>
</button>
<div
v-if="expandedThinkIds.has(m.id) && m.content?.trim()"
class="ai-think-full ai-md"
v-html="renderMd(m.content)"
></div>
<ToolCardList
v-if="m.toolCalls && m.toolCalls.length > 0"
ref="toolCardListRef"
:toolCalls="m.toolCalls"
@approve="({ id, approved, decision }) => store.approveToolCall(id, approved, decision)"
@batch-approve="(decision) => store.batchApprove(decision)"
@install-obscura="onInstallObscura"
/>
</div>
</div>
</div>
</div>
<!-- 执行中实时步骤摘要(已完成步骤收进一行,由最近工具事件聚合;仅当前步骤展开) -->
<div v-else-if="item.kind === 'step'" class="ai-msg-slot">
<div class="ai-step-line">
<span class="ai-step-dot"></span>
<span class="ai-step-text">{{ stepText(item.stats) }}</span>
<span class="ai-step-text">{{ stepText(item.stats, item.round) }}</span>
</div>
</div>
<!-- 居中时间分隔线(UX 密度改造:相邻消息间隔 ≥30 分钟或跨天;当天只 HH:mm,跨天 MM-DD HH:mm) -->
<div v-else-if="item.kind === 'timesep'" class="ai-time-sep">
<span>{{ formatAbsolute(item.ts) }}</span>
</div>
<!-- 重试过渡细条(AiStreamRetry 过渡气泡,isRetry 标记):琥珀点+单行中性文案,
不走 avatar/错误红卡/操作条;重试耗尽的终态错误(AiError,无 isRetry)仍走红卡 -->
<div v-else-if="item.kind === 'msg' && item.msg.isRetry" class="ai-msg-slot">
<div class="ai-retry-line">
<span class="ai-retry-dot"></span>
<span class="ai-retry-text">{{ item.msg.content }}</span>
</div>
</div>
<!-- B-260618-19: 纯空 assistant(无 content/error/toolCalls 且非末条流式)整 slot 不渲染,
消除空 div + flex gap 堆积的大片空白;sep 始终渲染;有 toolCalls 的空 assistant
仍渲染(工具卡可见——修复 agentic 工具轮 LLM 无文本时工具卡消失)。 -->
仍渲染(工具卡可见——修复 agentic 工具轮 LLM 无文本时工具卡消失)。
UX 密度改造:per-block 相对时间已移除,精确时刻上移到消息容器 title(hover 可查)。 -->
<div
v-else-if="item.kind === 'sep' ? true : shouldRenderMsg(item.msg)"
class="ai-msg-slot"
:title="item.kind === 'msg' ? formatDate(item.msg.timestamp) : undefined"
>
<!-- 折叠分隔条(归档段)— 轻量,始终渲染 -->
<div
@@ -1015,7 +1174,7 @@ defineExpose({
<div
v-else-if="(item.msg.content && item.msg.content.trim()) || (isLastAi(item.msg) && store.state.streaming && streamingBlocks.length > 0)"
class="ai-msg-bubble ai-msg-bubble--ai ai-md"
:class="{ 'ai-msg-bubble--error': item.msg.isError, 'ai-msg-bubble--collapsed': shouldCollapseLong(item.msg) }"
:class="{ 'ai-msg-bubble--error': item.msg.isError, 'ai-msg-bubble--collapsed': shouldCollapseLong(item.msg), 'ai-msg-bubble--answer': item.final === true && !item.msg.isError }"
:key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)"
>
<!-- 流式:分块渲染(已完成块key稳定→DOM不重建)。
@@ -1094,14 +1253,14 @@ defineExpose({
:aria-expanded="tokenPopoverMsgId === item.msg.id"
@click="toggleTokenPopover(item.msg, $event)"
>
<span>{{ formatTokens(tokenInOf(item.msg)) }} in<template v-if="tokenEstimated(item.msg)"><span class="ai-token-est">({{ $t('aiChat.estimated') }})</span></template></span>
<span>{{ formatTokensK(tokenInOf(item.msg)) }} in<template v-if="tokenEstimated(item.msg)"><span class="ai-token-est">({{ $t('aiChat.estimated') }})</span></template></span>
<span class="ai-token-sep">·</span>
<span>{{ formatTokens(tokenCacheOf(item.msg)) }} cache</span>
<span>{{ formatTokensK(tokenCacheOf(item.msg)) }} cache</span>
<span class="ai-token-sep">·</span>
<span>{{ formatTokens(tokenOutOf(item.msg)) }} out</span>
<span>{{ formatTokensK(tokenOutOf(item.msg)) }} out</span>
<template v-if="tokenReasonOf(item.msg) > 0">
<span class="ai-token-sep">·</span>
<span>{{ formatTokens(tokenReasonOf(item.msg)) }} reason</span>
<span>{{ formatTokensK(tokenReasonOf(item.msg)) }} reason</span>
</template>
</button>
<!-- 详情面板(absolute/right 0/popout 样式,复用 TopBar 风格):完整 usage + 流程 -->
@@ -1113,19 +1272,19 @@ defineExpose({
<div class="ai-token-popover-title">{{ $t('aiChat.tokenDetailTitle') }}<template v-if="tokenEstimated(item.msg)"><span class="ai-token-est">({{ $t('aiChat.estimated') }})</span></template></div>
<div class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenInLabel') }}</span>
<span class="ai-token-popover-val">{{ formatTokens(tokenInOf(item.msg)) }} ({{ tokenInOf(item.msg) }})</span>
<span class="ai-token-popover-val">{{ formatTokensK(tokenInOf(item.msg)) }} ({{ tokenInOf(item.msg) }})</span>
</div>
<div class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenCacheLabel') }}</span>
<span class="ai-token-popover-val">{{ formatTokens(tokenCacheOf(item.msg)) }} ({{ tokenCacheOf(item.msg) }})</span>
<span class="ai-token-popover-val">{{ formatTokensK(tokenCacheOf(item.msg)) }} ({{ tokenCacheOf(item.msg) }})</span>
</div>
<div class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenOutLabel') }}</span>
<span class="ai-token-popover-val">{{ formatTokens(tokenOutOf(item.msg)) }} ({{ tokenOutOf(item.msg) }})</span>
<span class="ai-token-popover-val">{{ formatTokensK(tokenOutOf(item.msg)) }} ({{ tokenOutOf(item.msg) }})</span>
</div>
<div v-if="tokenReasonOf(item.msg) > 0" class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenReasonLabel') }}</span>
<span class="ai-token-popover-val">{{ formatTokens(tokenReasonOf(item.msg)) }} ({{ tokenReasonOf(item.msg) }})</span>
<span class="ai-token-popover-val">{{ formatTokensK(tokenReasonOf(item.msg)) }} ({{ tokenReasonOf(item.msg) }})</span>
</div>
<div v-if="cacheHitRate(item.msg) != null" class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenCacheRateLabel') }}</span>
@@ -1153,13 +1312,6 @@ defineExpose({
@batch-approve="(decision) => store.batchApprove(decision)"
@install-obscura="onInstallObscura"
/>
<!-- UX-2025-13:消息时间戳(相对时间+hover绝对时间),流式生成中不显示避免抖动 -->
<span
v-if="!(isLastAi(item.msg) && store.state.streaming)"
class="ai-msg-time"
:title="formatDate(item.msg.timestamp)"
>{{ formatRelative(item.msg.timestamp) }}</span>
</div>
</div>
</div>
@@ -1395,19 +1547,64 @@ defineExpose({
.ai-msg-chip--idea { background: rgba(255, 255, 255, 0.22); border: 1px solid rgba(255, 255, 255, 0.35); }
.ai-msg-chip--skill { background: rgba(0, 0, 0, 0.18); }
/* ── 消息时间戳── */
.ai-msg-time {
/* ── UX 密度改造:居中时间分隔线(相邻消息间隔 ≥30 分钟或跨天;
per-block 相对时间漂移无价值已移除,精确时刻在消息容器 title) ── */
.ai-time-sep {
display: flex;
align-items: center;
gap: 10px;
margin: 2px 0;
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
user-select: none;
white-space: nowrap;
}
.ai-time-sep::before,
.ai-time-sep::after {
content: '';
flex: 1;
height: 1px;
background: color-mix(in srgb, var(--df-text-dim) 22%, transparent);
}
/* ── 最终回答卡:左 2px 品牌色强调边条 + 背景微抬升(与轨迹内思考 pill 明确分层) ── */
.ai-msg-bubble--answer {
border-left: 2px solid var(--df-accent);
background: color-mix(in srgb, var(--df-accent) 4%, var(--df-bg-card));
}
/* ── 轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转) ── */
.ai-trace-nav {
position: sticky;
top: 0;
z-index: 6;
display: flex;
align-items: center;
gap: 4px;
margin: -6px -2px 0;
padding: 5px 6px;
background: color-mix(in srgb, var(--df-bg) 86%, transparent);
backdrop-filter: blur(6px);
border-bottom: 0.5px solid color-mix(in srgb, var(--df-border) 70%, transparent);
}
.ai-trace-nav-chip {
min-width: 20px;
height: 18px;
padding: 0 5px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg-card);
color: var(--df-text-dim);
font-family: var(--df-font-mono);
font-size: 10px;
line-height: 1;
color: var(--df-text-dim);
margin-top: 2px;
align-self: flex-start; /* AI 消息:靠气泡左对齐 */
flex-shrink: 0;
cursor: default;
user-select: none;
cursor: pointer;
transition: color 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
}
.ai-msg-time--user {
align-self: flex-end; /* 用户消息:靠气泡右对齐 */
.ai-trace-nav-chip:hover {
color: var(--df-accent);
border-color: var(--df-accent);
}
/* ── 消息气泡拷贝按钮── */
@@ -1709,6 +1906,105 @@ defineExpose({
color: var(--df-text-dim);
}
.ai-trace-toggle { flex-shrink: 0; font-size: 10px; color: var(--df-accent); opacity: 0.85; }
/* 全程 token 汇总(轨迹卡头部一处,per-round 明细下沉思考 pill 行) */
.ai-trace-tokens {
flex-shrink: 0;
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
}
/* ── 轨迹展开态 rail 面板:左轨竖线贯通,思考 pill 与工具卡图标锚定其上(git-graph 式轮次结构)。
轨 x=22px 对齐工具卡图标中心(卡内 padding 10px + 24px 图标位一半),pill 灯泡图标同 x。 ── */
.ai-trace-body {
position: relative;
padding: 2px 10px 8px 0;
}
.ai-trace-body::before {
content: '';
position: absolute;
left: 22px;
top: 0;
bottom: 0;
width: 1px;
background: color-mix(in srgb, var(--df-text-dim) 22%, transparent);
}
.ai-trace-round {
position: relative;
padding: 2px 0;
}
.ai-trace-round + .ai-trace-round { margin-top: 2px; }
/* ── 思考单行 pill:灯泡锚轨 + 单行截断 + 右端 token 小字(空间不足先吃 token 尾段) ── */
.ai-think-pill {
position: relative;
display: flex;
align-items: center;
gap: 7px;
width: 100%;
min-height: 24px;
padding: 3px 8px 3px 38px;
border: none;
border-radius: var(--df-radius-sm);
background: transparent;
color: var(--df-text-secondary);
font-size: 11px;
line-height: 1.5;
text-align: left;
cursor: pointer;
user-select: none;
transition: background 0.15s var(--df-ease);
}
.ai-think-pill:hover {
background: color-mix(in srgb, var(--df-text-dim) 8%, transparent);
}
.ai-think-ic {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
border-radius: var(--df-radius-sm);
display: flex;
align-items: center;
justify-content: center;
background: var(--df-accent-bg);
color: var(--df-accent);
flex-shrink: 0;
}
.ai-think-text {
flex: 1 1 auto;
min-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-think-tokens {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--df-font-mono);
font-size: 9.5px;
color: var(--df-text-dim);
opacity: 0.75;
margin-left: auto;
}
/* 展开的完整思考段落:保持既有卡片样式(气泡卡同底),挂在 pill 下方缩进对齐正文 */
.ai-think-full {
margin: 2px 0 6px 38px;
padding: 8px 12px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
font-size: 12px;
line-height: 1.6;
color: var(--df-text-secondary);
max-height: 320px;
overflow-y: auto;
}
/* 执行中实时步骤摘要(已完成步骤收进一行,accent 呼吸点对齐状态栏 .ai-agentic-progress) */
.ai-step-line {
@@ -1742,4 +2038,34 @@ defineExpose({
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* 重试过渡细条(isRetry 过渡气泡):琥珀点呼吸+单行中性文案,骨架对齐 .ai-step-line。
warning 色调与错误红卡分层:重试是过渡态非终态失败,终态仍走 .ai-msg-bubble--error */
.ai-retry-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-warning, #f0c75e) 8%, transparent);
font-size: 11px;
color: var(--df-text-secondary);
user-select: none;
}
.ai-retry-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--df-warning, #f0c75e);
flex-shrink: 0;
animation: ai-step-pulse 1.2s ease-in-out infinite;
}
.ai-retry-text {
font-family: var(--df-font-mono);
font-size: 10.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+3 -2
View File
@@ -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)
}
}
+5 -1
View File
@@ -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)
}
+12 -7
View File
@@ -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 })
}
+26 -21
View File
@@ -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)
}
+4 -3
View File
@@ -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}',
+9 -5
View File
@@ -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',
+4 -3
View File
@@ -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}',
+8 -4
View File
@@ -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 请求',
+35
View File
@@ -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'