优化: 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
+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>