优化: ai-chat 对话流布局与轨迹信息密度改造
- 分组行去统计化,调用统计归并轨迹卡头部 - 相对时间改时间分隔线,跨日显绝对时间 - 思考过程单行 pill 化,答案卡加 accent 边 - 工具卡短路径展示与耗时提示统一 - 高风险工具名单启动时同步后端注册表 - 自动重试改琥珀细条,红卡与手动重试仅终态 - 轨迹卡头并时间线行,显轮次号与绝对时间 - 运行中摘要补当前工具名与失败计数 - 执行中工具卡加实时计时,耗时统一人话口径 - 重试残留清理改标记匹配,替代中文子串硬编码
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user