优化: 聊天体验批与Web规范合规

This commit is contained in:
lxy
2026-10-05 23:41:32 +08:00
parent 9e81182b4f
commit 28e6acbf3f
41 changed files with 1518 additions and 570 deletions
+146 -65
View File
@@ -12,9 +12,9 @@
//! - emit('start-edit', msg):UX-09 编辑末条 user,父置 editingMsgId + 委托 ChatInput.startEditFocus
//! - emit('send-example-prompt', i18nKey):空状态示例问题,父委托 ChatInput.sendExamplePrompt
//! - emit('go-to-settings'):无 provider 引导 / 错误气泡「去设置」,父走 router/toast(detached 降级)
//! - emit('toast', {msg, type}):copy/regenerate/retry 等的 toast 转发(父持单一 toast 源)
//! - emit('toast', {msg, type}):copy/retry 等的 toast 转发(父持单一 toast 源)
//! - expose(scrollToBottom):父 ChatInput @sent 后调,滚动到底
import { ref, computed, nextTick, onBeforeUnmount, watch } from 'vue'
import { ref, computed, nextTick, onBeforeUnmount, watch, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { useMarkdown } from '../../composables/useMarkdown'
@@ -206,7 +206,12 @@ const LOAD_MORE_TOP_PX = 60
async function handleMessagesScroll(): Promise<void> {
// 原 @scroll 职责(回底按钮显隐 + 底部边沿检测回调收起旧工具卡)保持
onMessagesScroll(() => collapseAllToolLists(buildActiveToolIds(store.state.messages)))
// R10(2026-10-05):收起旧卡加 isFollowingBottom 门——上滚读历史时新轮次/新卡到达
// 不再强制收起正浏览的工具卡(跟随意图锁存语义对齐 useMessageScroll.isFollowingBottom);
// 用户在底部跟随态时行为不变(自动收起聚焦新生成)。
onMessagesScroll(() => {
if (isFollowingBottom.value) collapseAllToolLists(buildActiveToolIds(store.state.messages))
})
// G3.5:滚顶阈值触发加载更早历史
const cur = loadMoreCursor
if (!cur.hasMore || cur.loading || cur.earliestSeq == null) return
@@ -373,7 +378,8 @@ watch(
const isFirst = lastMsgSnapshot === ''
lastMsgSnapshot = snap
if (isFirst) return
collapseAllToolLists(buildActiveToolIds(store.state.messages))
// R10 同门:非跟随态(上滚阅读中)不强制收起正浏览的工具卡
if (isFollowingBottom.value) collapseAllToolLists(buildActiveToolIds(store.state.messages))
},
)
@@ -383,7 +389,8 @@ watch(
() => store.state.agentRound,
(cur, prev) => {
if (cur <= (prev ?? 0)) return
collapseAllToolLists(buildActiveToolIds(store.state.messages))
// R10 同门:非跟随态(上滚阅读中)新轮次到达不强制收起正浏览的工具卡
if (isFollowingBottom.value) collapseAllToolLists(buildActiveToolIds(store.state.messages))
},
)
@@ -724,15 +731,6 @@ async function handleRetry(): Promise<void> {
}
}
/**
* I6 重新生成按钮:与错误气泡「重试」同链路(store.regenerate,后端弹末尾 AI 回复取触发
* user 消息重跑),入口场景不同——用户对已有回复不满意主动重问,挂在末条 user 气泡 hover。
* 与隐藏快捷键 Ctrl+R(AiChat.vue)等价,补齐 UI 入口(i18n aiChat.regenerate 文案已备)。
*/
async function handleRegenerate(): Promise<void> {
await handleRetry()
}
/**
* fetch_url obscura 未装时"AI 帮装"按钮:自动触发 AI 对话让 AI run_command 装流式。
* 提示语足够具体(给出 cargo install + GitHub releases 两种装法 + 装完告知用户重试),
@@ -791,28 +789,34 @@ const messageSegments = computed<MessageSegment[]>(() => {
return segments
})
/// 展开的折叠段 key 集合(点击分隔条切换);默认折叠
const expandedSegmentIds = ref<Set<string>>(new Set())
function toggleSegment(key: string): void {
const next = new Set(expandedSegmentIds.value)
if (next.has(key)) next.delete(key)
else next.add(key)
expandedSegmentIds.value = next
// ── 展开/切换态集合工厂(DRY 2026-10-05) ──
// 五组「Set 集合 + 同构 toggle」(折叠段/长消息/JSON 块/轨迹卡/思考 pill)此前五连逐字重复,
// 收敛本工厂。ids 返回 Ref<Set> 本体:script 侧 .value.has / 整体重置赋值 / 模板侧自动解包
// .has() 三种既有用法全部零改动,仅收敛重复的 toggle 实现。
// 不抽独立 composable 文件:五处仅本组件消费,文件内 DRY 即可,跨文件反增跳转成本。
function useToggleSet(): { ids: Ref<Set<string>>; toggle: (id: string) => void } {
const s = ref<Set<string>>(new Set())
return {
ids: s,
toggle: (id) => {
const next = new Set(s.value)
if (next.has(id)) next.delete(id)
else next.add(id)
s.value = next
},
}
}
/// 展开的折叠段 key 集合(点击分隔条切换);默认折叠
const { ids: expandedSegmentIds, toggle: toggleSegment } = useToggleSet()
// M1:assistant 纯文本气泡超长折叠,避免单条 AI 回复撑爆对话区。
// 仅对「已完成(非流式)」assistant 文本生效;流式中的消息不折叠(delta 重排抖动)。
// 阈值 1500 字符:约 markdown 渲染后一屏(400px@13px/1.6≈35行)的源文本量,超此即判长。
// 折叠 max-height 同步 400px(见 CSS .ai-msg-bubble--collapsed,两处一致)。
const LONG_MSG_THRESHOLD = 1500
/// 展开的长消息 id 集合(点击底部按钮切换);默认折叠
const expandedLongIds = ref<Set<string>>(new Set())
function toggleLong(id: string): void {
const next = new Set(expandedLongIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
expandedLongIds.value = next
}
const { ids: expandedLongIds, toggle: toggleLong } = useToggleSet()
function isLongMsg(msg: AiMessage): boolean {
return msg.role === 'assistant' && !!msg.content && msg.content.length > LONG_MSG_THRESHOLD
}
@@ -825,6 +829,15 @@ function shouldCollapseLong(msg: AiMessage): boolean {
return true
}
/// 折叠态是否跳过 markdown 渲染(B5 惰性渲染):折叠气泡视觉上只有 400px 高的 CSS 裁剪,
/// 但 v-html 仍全量渲染整段 markdown(150+ msg 长会话切换/挂载成本随消息数线性涨的主因)。
/// 跳过渲染后折叠态只显示首屏预览(前 800 字符)——展开瞬间才付全量渲染成本(一次性,可接受)。
/// 注意:预览截断可能断在 markdown 语法中间(半代码栅栏/半表格),渲染器按普通文本处理无副作用,
/// 仅视觉上「预览尾部可能带半个语法符号」;展开即见完整内容,不误导。
function collapsedPreviewHtml(msg: AiMessage): string {
return renderMdDecorated(msg.content.slice(0, 800))
}
// R3:折叠条带量 —— 折叠态按钮显示「已折叠 N 字符 / 约 M tokens,点击展开」。
// 折叠量按 M1 的标定估算(400px 视口 ≈ 阈值字符量,见 LONG_MSG_THRESHOLD 注),
// 即 content.length - 阈值;token 为粗估(中英混合 1 token ≈ 2.5 字符),只供量级感知。
@@ -856,34 +869,16 @@ function isToolResultJson(content: string | undefined | null): boolean {
}
}
/// 展开的 JSON 折叠块 id 集合(点击切换);默认折叠(max-height 80px 裁剪,可滚动)
const expandedJsonIds = ref<Set<string>>(new Set())
function toggleJson(id: string): void {
const next = new Set(expandedJsonIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
expandedJsonIds.value = next
}
const { ids: expandedJsonIds, toggle: toggleJson } = useToggleSet()
// A 过程压缩:执行轨迹展开态
// 完成轮次的过程文本+工具卡默认折叠为一张轨迹卡;用户手动展开后记入此集合,
// 不再被自动收回(换轮次/切会话回来都保持,会话切换时整体重置)。
const expandedTraceIds = ref<Set<string>>(new Set())
function toggleTrace(key: string): void {
const next = new Set(expandedTraceIds.value)
if (next.has(key)) next.delete(key)
else next.add(key)
expandedTraceIds.value = next
}
const { ids: expandedTraceIds, toggle: toggleTrace } = useToggleSet()
// 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
}
const { ids: expandedThinkIds, toggle: toggleThink } = useToggleSet()
/** 轮次思考纯文本:reasoning_content 优先(DeepSeek/Anthropic thinking 同通道),content 兜底;
* 压平 markdown 换行;两者皆空回退「过程记录」占位(纯工具轮也有锚点) */
@@ -1126,6 +1121,8 @@ function agentFinTone(status: AgentFinishedCard['status']): 'ok' | 'warn' | 'err
// 未知值兜底中性 completed 词不误报。无字段(旧后端)回退 summary 启发式:用户停止
// 级联取消子代理时 stop_reason=「已被取消(...)」并经 summary 终止说明透出(后端两处
// 取消检查点均写此中文字面量),按该特征分流为「已取消」;无此特征才是系统截断。
// A-3(2026-10-05):并行多子代理卡序号身份——从 agentId 尾段提 agent-{n} 序号,
// 并行交错完成时卡面可区分「哪张卡是哪个代理」,防「3 轮/2 轮」乱序观感误读。
function agentFinStatusText(card: AgentFinishedCard): string {
const k = card.stop_kind
if (k) {
@@ -1145,6 +1142,13 @@ function agentFinStatusText(card: AgentFinishedCard): string {
if (card.status === 'failed') return t('ai.agentFinished.failed')
return t('ai.agentFinished.completed')
}
// A-3(2026-10-05):并行多子代理卡序号身份——从 agentId 尾段提取 agent-{n} 的 n,
// 并行交错完成时卡面可区分「哪张卡是哪个代理」,防多卡「N 轮」乱序观感误读;
// 解析失败(形态不符)返回 null,模板不渲染序号徽标。
function agentFinSeq(card: AgentFinishedCard): number | null {
const m = /::agent-(\d+)$/.exec(card.agentId || '')
return m ? Number(m[1]) : null
}
// 启动失败型子代理(并发占满/父会话已删/provider 缺失)在进循环前就返回,子会话未落库,
// 点卡 peek 必报「对话不存在」死胡同;rounds=0 是前端可得的最稳特征(进循环后的失败
// 也至少有一轮落库记录,可正常 peek)。
@@ -1311,7 +1315,19 @@ function jumpToTrace(key: string): void {
}
// 切换对话/新对话时由父取消编辑态;滚动边沿/跟随意图重置在此自管。
watch(() => store.state.activeConversationId, () => {
// V12 per-conv 滚动位置记忆:换出会话存 scrollTop,换入会话有存档则恢复(不再一律回底)。
// 容量收敛:仅记最近 20 个会话(Map 超限删最早),防长会话列表无限增长。
const _scrollTopMemo = new Map<string, number>()
const _SCROLL_MEMO_LIMIT = 20
watch(() => store.state.activeConversationId, (newId, oldId) => {
// 换出:记旧会话滚动位置(容器就绪且旧 id 有效才记)
if (oldId && messagesContainer.value) {
if (_scrollTopMemo.size >= _SCROLL_MEMO_LIMIT) {
_scrollTopMemo.delete(_scrollTopMemo.keys().next().value!)
}
_scrollTopMemo.set(oldId, messagesContainer.value.scrollTop)
}
// 切会话重置滚动边沿检测 + 跟随意图(新会话默认跟随底部)
setFollowing(true)
// 切会话重置执行轨迹展开态(展开记忆按会话隔离,防跨会话 key 撞车误展开)
@@ -1334,7 +1350,18 @@ watch(() => store.state.activeConversationId, () => {
// UX 走查-对话域 B2:切会话显式回底。仅 watch messages.length 时,两个等长消息数的会话
// 互切不触发任何滚动,scrollTop 残留旧会话位置(可能停在中段)。setFollowing(true)已声明
// 跟随意图,此处补上实际滚动;nextTick 等新会话消息渲染出真实 scrollHeight 再滚。
nextTick(() => scrollToBottom())
// V12(2026-10-05):有滚动位置存档的会话恢复存档位置(回读不被打断),无存档(首次/新会话)
// 保持回底。跟随意图仅在回底路径置位;恢复路径置 setFollowing(false) 尊重用户阅读位置。
nextTick(() => {
const saved = newId ? _scrollTopMemo.get(newId) : undefined
const el = messagesContainer.value
if (saved != null && el) {
el.scrollTop = Math.min(saved, el.scrollHeight)
setFollowing(false)
} else {
scrollToBottom()
}
})
})
// 流式 rAF 清理:组件卸载时若仍有 pending rAF(streaming 中途切走/关窗),取消防泄漏。
@@ -1369,7 +1396,8 @@ defineExpose({
<template>
<!-- Messages -->
<div class="ai-messages" ref="messagesContainer" @scroll="handleMessagesScroll" @click="onMdCodeCopyClick">
<!-- UX第五轮 P2-G1:role="log"+aria-live=polite,读屏用户可感知流式新消息与回复更新 -->
<div class="ai-messages" ref="messagesContainer" role="log" aria-live="polite" @scroll="handleMessagesScroll" @click="onMdCodeCopyClick">
<!-- 空状态 (UX-2025-20: 示例问题卡片 + 无 provider 引导跳 Settings) — 提取至 EmptyState.vue -->
<EmptyState
v-if="store.state.messages.length === 0 && !store.state.streaming"
@@ -1421,7 +1449,7 @@ defineExpose({
@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>
<svg aria-hidden="true" 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>
@@ -1465,6 +1493,8 @@ defineExpose({
@click="openAgentTrace(item.card)"
>
<span class="ai-agent-fin-dot"></span>
<!-- A-3 并行多子代理卡序号身份:agent-{n} 解析成功才渲染,防多卡轮次乱序误读 -->
<span v-if="agentFinSeq(item.card) !== null" class="ai-agent-fin-seq">#{{ agentFinSeq(item.card) }}</span>
<span class="ai-agent-fin-label">{{ $t('ai.agentFinished.title') }}</span>
<span class="ai-agent-fin-status">{{ agentFinStatusText(item.card) }}</span>
<!-- 被拒工具 chip(P2-1):非空才渲染,琥珀警示层与 truncated 同色系;
@@ -1509,7 +1539,7 @@ defineExpose({
:class="'ai-msg-segment--' + item.seg.kind"
@click="toggleSegment(item.seg.key)"
>
<svg class="ai-msg-segment-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<svg aria-hidden="true" class="ai-msg-segment-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<template v-if="item.seg.kind === 'archived'">
<polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/>
</template>
@@ -1545,16 +1575,14 @@ defineExpose({
v-else-if="item.msg.role === 'user'"
:msg="item.msg"
:can-edit="isLastUser(item.msg) && !store.state.streaming && item.msg.id !== editingMsgId"
:can-regenerate="isLastUser(item.msg) && !store.state.streaming"
@copy="copyMsgContent"
@start-edit="(m) => emit('start-edit', m)"
@regenerate="handleRegenerate"
/>
<!-- AI 消息 -->
<div v-else class="ai-msg-ai">
<div class="ai-msg-avatar ai-msg-avatar--ai">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/></svg>
<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/></svg>
</div>
<div class="ai-msg-content">
<!-- 文本内容(流式或固定,Markdown 渲染) -->
@@ -1583,7 +1611,10 @@ defineExpose({
<div v-for="block in decoratedStreamingBlocks" :key="block.key" v-html="block.html"></div>
<span v-if="isViewingGenerating" class="ai-cursor"></span>
</template>
<!-- 完成/历史:整段v-html(不变,经装饰层注入代码块复制按钮) -->
<!-- 完成/历史:整段v-html(不变,经装饰层注入代码块复制按钮)。
B5 惰性渲染:折叠态仅渲染前 800 字符预览(全量渲染延迟到展开瞬间),
长(>1500字)历史消息的 markdown 渲染成本不再随会话长度线性累积。 -->
<div v-else-if="shouldCollapseLong(item.msg)" v-html="collapsedPreviewHtml(item.msg)"></div>
<div v-else v-html="renderMdDecorated(item.msg.content)"></div>
<!-- M1/R3:超长 assistant 文本折叠条(折叠态带量:已折叠 N 字符/约 M tokens) -->
<button
@@ -1597,7 +1628,7 @@ defineExpose({
:title="$t('aiChat.copyMsg')"
@click.stop="copyMsgContent(item.msg)"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
<button
v-if="item.msg.content"
@@ -1605,7 +1636,7 @@ defineExpose({
:title="$t('aiChat.copyAsImage')"
@click.stop="copyMsgAsImage(item.msg)"
>
<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="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
</button>
</div>
<!-- 流式开始但尚无文本时显示光标 -->
@@ -1622,7 +1653,7 @@ defineExpose({
:disabled="store.state.streaming"
@click.stop="handleRetry"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
<span>{{ $t('aiChat.retry') }}</span>
</button>
<button
@@ -1630,7 +1661,7 @@ defineExpose({
class="ai-msg-act"
@click.stop="emit('go-to-settings')"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
<span>{{ $t('aiChat.goToSettingsAction') }}</span>
</button>
</div>
@@ -1687,6 +1718,23 @@ defineExpose({
<span class="ai-token-popover-label">{{ $t('aiChat.tokenReasonLabel') }}</span>
<span class="ai-token-popover-val">{{ formatTokensK(tokenReasonOf(item.msg)) }} ({{ tokenReasonOf(item.msg) }})</span>
</div>
<!-- #12(2026-10-05):会话累计(convTokenTotal)入面板——数据只写不读的闲置数据源接上消费;
累计为前端内存态(切换会话由 switchConversation 重算),会话末条 AI 回复的 popover 展示 -->
<template v-if="isLastAi(item.msg) && store.state.convTokenTotal">
<div class="ai-token-popover-sep"></div>
<div class="ai-token-popover-row ai-token-popover-row--total">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenConvTotal') }}</span>
<span class="ai-token-popover-val">{{ formatTokensK(store.state.convTokenTotal.total) }} ({{ store.state.convTokenTotal.total }})</span>
</div>
<div class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenConvTotalIn') }}</span>
<span class="ai-token-popover-val">{{ formatTokensK(store.state.convTokenTotal.prompt) }} ({{ store.state.convTokenTotal.prompt }})</span>
</div>
<div class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenConvTotalOut') }}</span>
<span class="ai-token-popover-val">{{ formatTokensK(store.state.convTokenTotal.completion) }} ({{ store.state.convTokenTotal.completion }})</span>
</div>
</template>
<!-- 命中率与 cache 段同闸:无缓存口径的 provider 不显 0%,防「无数据」误读成「全未命中」 -->
<div v-if="tokenCacheVisible(item.msg) && cacheHitRate(item.msg) != null" class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenCacheRateLabel') }}</span>
@@ -1723,9 +1771,11 @@ defineExpose({
</div>
<!-- 回到底部(上滑看历史 / 有新内容时显示) -->
<!-- R11(2026-10-05):生成中且非跟随态 → 按钮加脉冲点,提示「下面有新内容」而非仅导航 -->
<Transition name="back-to-bottom">
<button v-if="showBackToBottom" class="ai-back-to-bottom" @click="() => scrollToBottom()" :title="$t('aiChat.backToBottom')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
<button v-if="showBackToBottom" class="ai-back-to-bottom" :class="{ 'has-new': store.state.streaming && !isFollowingBottom }" @click="() => scrollToBottom()" :title="$t('aiChat.backToBottom')">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
<span v-if="store.state.streaming && !isFollowingBottom" class="ai-btb-new-dot"></span>
</button>
</Transition>
@@ -1744,7 +1794,7 @@ defineExpose({
</div>
</div>
<button class="ai-agent-peek-close" :title="$t('common.close')" @click="closeAgentTrace">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="ai-agent-peek-body">
@@ -1809,6 +1859,21 @@ defineExpose({
.back-to-bottom-leave-active { transition: opacity 0.15s, transform 0.15s; }
.back-to-bottom-enter-from,
.back-to-bottom-leave-to { opacity: 0; transform: translateY(8px); }
/* R11 新内容脉冲点:生成中非跟随态提示下方有新内容(amber 脉冲,2s 循环) */
.ai-btb-new-dot {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--df-warning, #e8a33d);
animation: ai-btb-pulse 2s ease-in-out infinite;
}
@keyframes ai-btb-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.45; transform: scale(1.25); }
}
/* -- Empty State 样式已随 EmptyState.vue 提取 -- */
@@ -2285,6 +2350,15 @@ defineExpose({
gap: 12px;
padding: 2px 0;
}
/* #12 会话累计段分隔线 + 总计行加重 */
.ai-token-popover-sep {
height: 0.5px;
background: var(--df-border);
margin: 4px 0;
}
.ai-token-popover-row--total .ai-token-popover-label {
font-weight: 600;
}
.ai-token-popover-label {
color: var(--df-text-dim);
}
@@ -2615,6 +2689,13 @@ defineExpose({
flex-shrink: 0;
font-weight: 600;
}
/* A-3 并行多子代理卡序号徽标:等宽小号弱化展示,解析失败不渲染(v-if 已守) */
.ai-agent-fin-seq {
flex-shrink: 0;
font-family: var(--df-font-mono, monospace);
font-size: 11px;
opacity: 0.65;
}
.ai-agent-fin-status {
flex-shrink: 0;
}