优化: 聊天体验批与Web规范合规
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user