1746 lines
73 KiB
Vue
1746 lines
73 KiB
Vue
<script setup lang="ts">
|
||
//! 消息列表子组件 — 第四批抽自 src/components/AiChat.vue(零行为变更)。
|
||
//!
|
||
//! 职责:消息列表渲染(.ai-messages 容器 + 空状态 + renderItems 扁平渲染 +
|
||
//! 消息气泡 user/ai/system + 流式 Markdown 分块渲染 + 工具卡 ToolCardList 转发审批 +
|
||
//! 折叠归档段 + 选区保持 + 滚动跟随/回底部)。
|
||
//!
|
||
//! 共享:store(useAiStore 单例)/ Markdown 渲染器(useMarkdown 模块级单例)与父同实例,
|
||
//! currentText/streamingBlocks/messages 经 store,流式行为零变化。
|
||
//!
|
||
//! 与父交互(emit/expose):
|
||
//! - 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 源)
|
||
//! - expose(scrollToBottom):父 ChatInput @sent 后调,滚动到底
|
||
import { ref, computed, nextTick, onBeforeUnmount, watch } from 'vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { useAiStore } from '../../stores/ai'
|
||
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, aggText, 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 type { AiMessage } from '../../api/types'
|
||
|
||
defineProps<{
|
||
/** 当前正在编辑的消息 id(父持有,经 ChatInput 透传);末条 user 编辑按钮据此隐藏 */
|
||
editingMsgId?: string | null
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'start-edit', msg: AiMessage): void
|
||
(e: 'send-example-prompt', i18nKey: string): void
|
||
(e: 'go-to-settings'): void
|
||
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
|
||
}>()
|
||
|
||
const store = useAiStore()
|
||
const { t } = useI18n()
|
||
|
||
// ═══ Markdown 渲染自研块级 memo,借鉴方案D流式核心) ═══
|
||
// 渲染基础设施(_marked/_purify/mdReady/_mdCache/loadMarkdown/escapeFallback/
|
||
// renderMd)抽至 composables/useMarkdown(模块级单例),与 TaskDetail/AiChat 共享。
|
||
// 流式核心(splitBlocks/parseBlock/renderStreamingBlocks/scheduleStreamParse)抽至
|
||
// composables/ai/useStreamRenderer(本组件经 opts 注入 saveSelection/restoreSelection/
|
||
// isFollowingBottom/scrollToBottom 回调),行为零变化。
|
||
//
|
||
// useStreamRenderer 的初始化延后到 saveSelection/restoreSelection 定义之后(下方),
|
||
// 因 composable 的 opts 依赖这两个函数。mdReady/renderMd/escapeFallback/streamingBlocks/
|
||
// scheduleStreamParse/renderStreamingBlocks/clearStreamingState 均由 composable 返回。
|
||
const { loadMarkdown } = useMarkdown()
|
||
|
||
// ── 空状态示例问题(examplePrompts 已随 EmptyState.vue 提取) ──
|
||
|
||
const messagesContainer = ref<HTMLDivElement>()
|
||
// 工具卡片列表(子组件 ToolCardList 自治折叠态,父级仅经 expose 调 collapseInactive 收起旧卡)
|
||
const toolCardListRef = ref<InstanceType<typeof ToolCardList>>()
|
||
|
||
// 当前视图是否正在生成(切走后台生成时光标不显示)
|
||
// 多会话并发 — isGenerating(active) 替代单值比对
|
||
const isViewingGenerating = computed(() =>
|
||
store.state.streaming && store.isGenerating(store.state.activeConversationId)
|
||
)
|
||
|
||
function isLastAi(msg: AiMessage): boolean {
|
||
const msgs = store.state.messages
|
||
return msg.role === 'assistant' && msgs[msgs.length - 1]?.id === msg.id
|
||
}
|
||
|
||
// UX-09:末条 user 用倒序判定而非"最后一条消息是否 user"——AI 已回复后编辑按钮仍应挂在触发消息上
|
||
function isLastUser(msg: AiMessage): boolean {
|
||
if (msg.role !== 'user') return false
|
||
const msgs = store.state.messages
|
||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||
if (msgs[i].role === 'user') return msgs[i].id === msg.id
|
||
}
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* 是否渲染某条消息(供 .ai-msg-slot v-if 守卫)。
|
||
*
|
||
* 背景:agentic 多轮下,LLM 某轮只发 tool_calls 不输出文本时(GLM anthropic_compat 常见),
|
||
* 该轮 assistant 消息 content='' + toolCalls=[...];变中间条后,旧 v-else-if
|
||
* 漏判 toolCalls 把它整条过滤 → 工具卡片消失,且外层 .ai-msg-slot 恒渲染留空 div,
|
||
* flex gap 堆积成大片空白,末条光标气泡随每轮 AiAgentRound push 往下移。
|
||
*
|
||
* 现仅过滤「纯空」assistant(无 content / 无 error / 无 toolCalls / 非末条流式):
|
||
* - 有 toolCalls → 渲染(工具卡可见,即便本轮 LLM 无文本)
|
||
* - 末条 + 流式 → 渲染(光标气泡占位,接收后续 delta)
|
||
* - 其余(user/system/有内容 AI)→ 渲染
|
||
* - 纯空中间条 → 不渲染(连 slot 一起省,消除空白)
|
||
*/
|
||
function shouldRenderMsg(msg: AiMessage): boolean {
|
||
if (msg.role === 'assistant'
|
||
&& !msg.content?.trim() // 根因修复:!content 只拦空串漏 whitespace(空气泡)
|
||
&& !msg.isError
|
||
&& !(msg.toolCalls && msg.toolCalls.length)
|
||
&& !(isLastAi(msg) && store.state.streaming)) {
|
||
return false
|
||
}
|
||
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 分项显示(2026-08-02):in/cache/out/reason 分计费 + 详情面板 ──
|
||
//
|
||
// 各 provider 计费不同(deepseek:cache 命中低价/未命中全价/输出价高/reasoning 隐藏输出),
|
||
// 故不显 total(三者相加无意义)。in=cache_miss(全价真实),cache=cache_hit(命中),out=completion,
|
||
// reasoning > 0 才显(reason 后缀)。
|
||
//
|
||
// 详情面板(tokenPopoverMsgId 控制显隐):点击 token 区弹出,含完整 usage + 流程(模型/缓存命中率)。
|
||
// 流程数据从 message 取(model 在 msg.model;cache 命中率 = hit/(hit+miss))。
|
||
const tokenPopoverMsgId = ref<string | null>(null)
|
||
|
||
/** 取消息的 in token(全价输入)= prompt_tokens(总) - cache_hit(命中低价)。
|
||
* 统一 GLM(无 cache,prompt_tokens 即全价)与 deepseek(prompt=hit+miss,全价=miss=prompt-hit)。
|
||
* 原 cache_miss ?? prompt 因 agent 设 cache_miss=0(GLM 不报)致 ?? 不触发返 0,改减法。 */
|
||
function tokenInOf(m: AiMessage): number {
|
||
const prompt = m.tokenUsage?.prompt ?? m.prompt_tokens ?? 0
|
||
const hit = m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? 0
|
||
return Math.max(0, prompt - hit)
|
||
}
|
||
/** 取消息的 cache hit token */
|
||
function tokenCacheOf(m: AiMessage): number {
|
||
return m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? 0
|
||
}
|
||
/** 取消息的 out token */
|
||
function tokenOutOf(m: AiMessage): number {
|
||
return m.tokenUsage?.completion ?? m.completion_tokens ?? 0
|
||
}
|
||
/** 取消息的 reasoning token */
|
||
function tokenReasonOf(m: AiMessage): number {
|
||
return m.tokenUsage?.reasoning ?? m.reasoning_tokens ?? 0
|
||
}
|
||
/** G4.3:该消息 token 用量是否为估算值(后端 AiCompleted.is_estimated 透传:
|
||
* round_usage.prompt_tokens==0 时用 estimated_prompt 兜底)。true 时主显示/详情标注『估算』,
|
||
* 让用户区分估算值 vs 真实用量。 */
|
||
function tokenEstimated(m: AiMessage): boolean {
|
||
return m.tokenUsage?.is_estimated === true
|
||
}
|
||
/** 计算 cache 命中率(0-100),hit+miss=0 时返回 null(无 cache 数据) */
|
||
function cacheHitRate(m: AiMessage): number | null {
|
||
const hit = tokenCacheOf(m)
|
||
const miss = tokenInOf(m)
|
||
const sum = hit + miss
|
||
return sum > 0 ? Math.round((hit / sum) * 100) : null
|
||
}
|
||
/** 切换 token 详情面板显隐(同消息再点关,不同消息切) */
|
||
function toggleTokenPopover(m: AiMessage, e: Event): void {
|
||
e.stopPropagation()
|
||
const id = m.id
|
||
tokenPopoverMsgId.value = tokenPopoverMsgId.value === id ? null : id
|
||
}
|
||
|
||
// ── 滚动跟随 / 回到底部(跟随意图锁存,已抽取至 useMessageScroll) ──
|
||
const {
|
||
showBackToBottom,
|
||
isFollowingBottom,
|
||
onMessagesScroll,
|
||
scrollToBottom,
|
||
onContentChange,
|
||
setFollowing,
|
||
} = useMessageScroll(messagesContainer)
|
||
|
||
// ═══ 流式 Markdown 块级 memo 渲染(抽至 composables/ai/useStreamRenderer) ═══
|
||
// opts 注入 saveSelection/restoreSelection(function 声明,提升可用,定义在下方)、
|
||
// isFollowingBottom/scrollToBottom(来自上方 useMessageScroll)。
|
||
const {
|
||
mdReady,
|
||
renderMd,
|
||
streamingBlocks,
|
||
scheduleStreamParse,
|
||
clearStreamingState,
|
||
cancelPendingRaf,
|
||
resetStreamCache,
|
||
} = useStreamRenderer({
|
||
saveSelection,
|
||
restoreSelection,
|
||
isFollowingBottom,
|
||
scrollToBottom,
|
||
})
|
||
|
||
// ── G3.5:滚顶加载更早历史(load_more 分页) ──
|
||
// 后端 ai_conversation_switch 已返 has_more/earliest_seq 游标(useAiConversations 模块持有,
|
||
// 本组件经 getLoadMoreState 读同一引用)。滚顶到阈值触发 loadMoreHistory → IPC → prepend +
|
||
// 按 id 去重 + scrollTop 恢复(视口停留在原消息处不闪跳)。最小接线,不重构本组件。
|
||
const loadMoreCursor = getLoadMoreState()
|
||
const LOAD_MORE_TOP_PX = 60
|
||
|
||
async function handleMessagesScroll(): Promise<void> {
|
||
// 原 @scroll 职责(回底按钮显隐 + 底部边沿检测回调收起旧工具卡)保持
|
||
onMessagesScroll(() => collapseAllToolLists(buildActiveToolIds(store.state.messages)))
|
||
// G3.5:滚顶阈值触发加载更早历史
|
||
const cur = loadMoreCursor
|
||
if (!cur.hasMore || cur.loading || cur.earliestSeq == null) return
|
||
const el = messagesContainer.value
|
||
if (!el) return
|
||
if (el.scrollTop > LOAD_MORE_TOP_PX) return
|
||
const prevScrollHeight = el.scrollHeight
|
||
const inserted = await loadMoreHistory()
|
||
if (!inserted) return
|
||
const container = messagesContainer.value
|
||
if (!container) return
|
||
await nextTick()
|
||
// 恢复滚动:内容增高差 = 新 scrollHeight - 旧 scrollHeight,视口停留在原消息处不闪跳
|
||
const diff = container.scrollHeight - prevScrollHeight
|
||
container.scrollTop = Math.max(diff, 0)
|
||
}
|
||
|
||
watch(() => store.state.messages.length, onContentChange)
|
||
// currentText → onContentChange 合并到下方 scheduleStreamParse watch(同源 currentText,单 callback 顺序执行)
|
||
|
||
// 流式 Markdown 渲染驱动(ARC-260615-08 块级 memo):
|
||
// - currentText 变化(每 delta):rAF 节流 scheduleStreamParse,块级 memo 只重 parse 末块
|
||
// - streaming 翻转:结束清 rAF + 清 streamingBlocks(回 renderMd 走 final marked + 缓存)
|
||
// - mdReady 翻转首屏 marked 慢 + 暂无新 delta 时末段停留 escapeFallback
|
||
// 纯文本;marked 就绪后主动重算一次,使纯文本转格式化
|
||
// 合并 currentText 双 watch(原 :2040 onContentChange 滚动 + 本 watch scheduleStreamParse 分块)。
|
||
// 顺序:onContentChange(滚动跟随)先 → scheduleStreamParse(rAF 分块渲染)后,保持原注册顺序。
|
||
watch(() => store.state.currentText, (text) => {
|
||
// 流式滚动跟随由 scheduleStreamParse 的 rAF 回调承接(DOM 高度就绪后滚),
|
||
// 不再用 onContentChange 的 nextTick(微任务先于 rAF,滚到旧 scrollHeight 致错位抖动)。
|
||
// 仅流式渲染触发;非流式 currentText 变化(罕见)走 onContentChange 兜底。
|
||
if (store.state.streaming && text) scheduleStreamParse(text)
|
||
else {
|
||
// BUG-260624-01(消息重叠根治·诊断 workflow 确认):currentText 归零(新轮 AiAgentRound
|
||
// 清空 / 生成结束)时,同步清上一轮流式分块缓存 streamingBlocks + lastStreamText。
|
||
// 原:streaming watch(:435)仅在 streaming 翻 false 时清;但 agent 多轮连续生成时
|
||
// streaming 保持 true,新轮 AiAgentRound push 新空气泡 + currentText='',streamingBlocks
|
||
// 残留上一轮 → 新气泡 isLastAi 命中(:1017)渲染残留分块 → 与上一条回复重叠堆叠。
|
||
// 在 currentText 归零处清,精确覆盖"新轮切换"与"生成结束"两个时机。
|
||
if (!text) {
|
||
clearStreamingState()
|
||
}
|
||
onContentChange()
|
||
}
|
||
})
|
||
watch(mdReady, (ready) => {
|
||
if (ready && store.state.streaming && store.state.currentText) {
|
||
scheduleStreamParse(store.state.currentText)
|
||
}
|
||
})
|
||
watch(() => store.state.streaming, (s) => {
|
||
// 原行为:两分支均先取消 pending rAF(streaming 中途切换帧防泄漏);s===true 不清
|
||
// streamingBlocks(依赖 currentText watch 的 !text 分支按新轮时机清,BUG-260624-01)。
|
||
cancelPendingRaf()
|
||
if (s) {
|
||
// 新一轮生成开始 → 收起上一轮已完成的旧工具卡,聚焦当前生成内容。
|
||
// 仅当前视图正在生成时触发(isGenerating(activeConversationId)),
|
||
// 切走后台生成/恢复会话(streaming 初值已 true 的 onMounted 路径)不误触。
|
||
// buildActiveToolIds/collapseAllToolLists 是 function 声明,提升可用。
|
||
if (store.isGenerating(store.state.activeConversationId)) {
|
||
collapseAllToolLists(buildActiveToolIds(store.state.messages))
|
||
}
|
||
} else {
|
||
// 流式→完成切换,模板从 v-for 分块切到 v-html 整段,DOM 重建
|
||
saveSelection()
|
||
clearStreamingState()
|
||
nextTick(() => restoreSelection())
|
||
}
|
||
})
|
||
|
||
// ── 工具卡片自动收起:新内容追加(新消息/toolCall 状态变化)时,
|
||
// 收起旧的已完成/已拒绝卡片,保留 running/pending/write_file 活跃卡片。
|
||
// 折叠态由 ToolCardList 自治,父级仅经 expose 调 collapseInactive ──
|
||
//
|
||
// B-260616-13 性能:快照仅含 toolCalls 的 (id,status)+消息条数,剔除 content.length。
|
||
// 收起判定只依赖 active Set(由 toolCalls 状态推导),与文本长度无关。
|
||
// 原快照含 len → 流式每 token 增长末条消息 content → snap 变 → body 每帧重算
|
||
// (deep watch 检测仍每帧跑,但 body 跳过)。剔除 len 后流式 delta 不再触发收起重算,
|
||
// 只在 toolCall 状态翻转(新卡片/完成/拒绝)时触发,与折叠语义精确对齐。
|
||
// snap/MESSAGE_CAP 兜底机制保持不变(deep watch 仍做变化检测,只是 body 短路掉无意义重算)。
|
||
//
|
||
// 触发时机扩展。除原 messages deep watch(toolCall 状态翻转/新消息条数),
|
||
// 另加 agentRound watch(AiAgentRound 新轮次 → 收起上一轮工具卡,聚焦当前轮)。
|
||
// active Set 推导逻辑复用 buildActiveToolIds,两 watch 共用。
|
||
function buildActiveToolIds(msgs: typeof store.state.messages): Set<string> {
|
||
const active = new Set<string>()
|
||
for (const m of msgs) {
|
||
for (const t of (m.toolCalls || [])) {
|
||
// 与 ToolCard.shouldKeepOpen 同源:running/pending_approval/rejected/write_file 保持展开
|
||
if (t.status === 'running' || t.status === 'pending_approval'
|
||
|| t.status === 'rejected' || t.name === 'write_file') {
|
||
active.add(t.id)
|
||
}
|
||
}
|
||
}
|
||
return active
|
||
}
|
||
|
||
/** 逐实例调 collapseInactive(v-for 内 ref 收集为数组,逐实例调否则报 not a function) */
|
||
function collapseAllToolLists(active: Set<string>): void {
|
||
const tcl = toolCardListRef.value as unknown
|
||
const refs = Array.isArray(tcl) ? tcl : (tcl ? [tcl] : [])
|
||
refs.forEach((r: any) => r?.collapseInactive?.(active))
|
||
}
|
||
|
||
/** 点击徽标:滚动到第一个 pending_approval 卡片(委托 ToolCardList.scrollToFirstPending)。
|
||
* v-for 内 ref 数组,取第一个有该方法的实例。 */
|
||
function scrollToFirstPending(): void {
|
||
const tcl = toolCardListRef.value as unknown
|
||
const refs = Array.isArray(tcl) ? tcl : (tcl ? [tcl] : [])
|
||
// BUG-260624-01 残留修复:遍历所有实例调 scrollToFirstPending,各实例内部 querySelector 无
|
||
// pending 卡则 no-op,含 pending 的实例滚到其首张待审批卡。原 `return` 首个有方法的实例即停,
|
||
// pending 若落在后续实例(头部徽标点击场景)滚不到。移除 return 让所有实例尝试,no-op 实例零成本。
|
||
for (const r of refs) {
|
||
if (typeof (r as any)?.scrollToFirstPending === 'function') {
|
||
;(r as any).scrollToFirstPending()
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 移除 deep:true,改在 watch source 中计算指纹(消息数 + toolCalls 的 id/status),
|
||
* 避免 deep 每帧全量深遍历长会话数组引起掉帧。流式 delta 只改 content 不影响指纹,
|
||
* 不触发无意义的收起重算。length 变化(新消息/截断)和 toolCall 状态翻转(新卡/完成/拒绝)
|
||
* 均能正确触发——与 body 的快照收缩逻辑等价,无需 deep 兜底。
|
||
*/
|
||
let lastMsgSnapshot = ''
|
||
watch(
|
||
() => JSON.stringify({
|
||
n: store.state.messages.length,
|
||
tc: store.state.messages.map(m => (m.toolCalls || []).map(t => ({ id: t.id, s: t.status }))),
|
||
}),
|
||
(snap) => {
|
||
if (snap === lastMsgSnapshot) return
|
||
const isFirst = lastMsgSnapshot === ''
|
||
lastMsgSnapshot = snap
|
||
if (isFirst) return
|
||
collapseAllToolLists(buildActiveToolIds(store.state.messages))
|
||
},
|
||
)
|
||
|
||
// AiAgentRound 新轮次到达时收起上一轮工具卡(round 递增)。
|
||
// 首次加载不触发(等真到达更"新"的轮次才收起,避免历史会话加载即折叠)。
|
||
watch(
|
||
() => store.state.agentRound,
|
||
(cur, prev) => {
|
||
if (cur <= (prev ?? 0)) return
|
||
collapseAllToolLists(buildActiveToolIds(store.state.messages))
|
||
},
|
||
)
|
||
|
||
// mdReady 就绪后主动触发首屏 Markdown 重渲染
|
||
// (marked 加载慢 + 无新 delta 时末段停留纯文本,不触发模板重算)
|
||
const _mdRenderKey = ref(0)
|
||
function forceUpdate() { _mdRenderKey.value++ }
|
||
|
||
// ── 选文字保持(save/restore Selection) ──
|
||
// v-html 整体替换 innerHTML 会销毁子 DOM 节点 → Node 引用断连 → 选区丢失。
|
||
// 方案: 保存为「气泡内文本偏移量 + 选中文字」,重建后按偏移量重新定位。
|
||
// 偏移量对 Markdown 渲染结果稳定(同内容→同DOM文本节点序列→同偏移)。
|
||
|
||
interface SavedSelection {
|
||
/** 选区所在消息的 msg.id (用于重建后定位目标 DOM) */
|
||
msgId: string
|
||
/** 选区起始在气泡文本内容中的字符偏移 */
|
||
startOffset: number
|
||
/** 选区结束在气泡文本内容中的字符偏移 */
|
||
endOffset: number
|
||
/** 选中的原始文字(模糊回退:偏移漂移时用文本搜索重新定位) */
|
||
selectedText: string
|
||
}
|
||
let _savedSel: SavedSelection | null = null
|
||
|
||
/**
|
||
* 计算一个 offset 在 DOM 子树文本内容中对应的 (textNode, nodeOffset) 位置。
|
||
* 遍历 Text 子节点累加长度,找到 offset 落入的节点。
|
||
*/
|
||
function offsetToDOMPosition(root: Element, offset: number): { node: Text; offsetInNode: number } | null {
|
||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||
let currentOffset = 0
|
||
let node: Node | null
|
||
// 每轮 walker.nextNode() 推进游标(createTreeWalker 后 currentNode===root 是 Element,
|
||
// SHOW_TEXT 过滤下 nextNode() 直接跳到第一个 Text 后代,root 本身不会被当作 Text)。
|
||
// 不能用 `walker.currentNode as Text ?? nextNode()` —— as 仅类型断言不改变运行时值,
|
||
// root 非 null 恒走 ?? 左分支,nextNode() 永不执行,walker 不前进。
|
||
while ((node = walker.nextNode())) {
|
||
const textNode = node as Text
|
||
const len = textNode.textContent!.length
|
||
if (currentOffset + len >= offset) {
|
||
return { node: textNode, offsetInNode: offset - currentOffset }
|
||
}
|
||
currentOffset += len
|
||
}
|
||
return null
|
||
}
|
||
|
||
/** 保存当前文字选区(仅在 .ai-messages 内有效),转为文本偏移量 */
|
||
function saveSelection(): void {
|
||
const sel = window.getSelection()
|
||
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { _savedSel = null; return }
|
||
const range = sel.getRangeAt(0)
|
||
const container = messagesContainer.value
|
||
if (!container || !container.contains(range.commonAncestorContainer)) { _savedSel = null; return }
|
||
|
||
// 向上查找所属 .ai-msg-bubble.ai-md 气泡
|
||
let bubble = range.commonAncestorContainer as Element
|
||
while (bubble && !bubble.classList?.contains('ai-md')) {
|
||
bubble = bubble.parentElement!
|
||
}
|
||
if (!bubble) { _savedSel = null; return }
|
||
|
||
// 从消息行取 msgId
|
||
const msgItem = bubble.closest('.ai-msg[data-msg-id]')
|
||
const msgId = msgItem?.getAttribute('data-msg-id') ?? ''
|
||
if (!msgId) { _savedSel = null; return }
|
||
|
||
// 将选区的 Node 偏移转换为气泡内全局文本偏移
|
||
function toTextOffset(node: Node, nodeOffset: number): number {
|
||
const walker = document.createTreeWalker(bubble, NodeFilter.SHOW_TEXT)
|
||
let accumulated = 0
|
||
let cur: Node | null
|
||
while ((cur = walker.nextNode())) {
|
||
if (cur === node) return accumulated + nodeOffset
|
||
accumulated += (cur as Text).textContent!.length
|
||
}
|
||
return -1
|
||
}
|
||
|
||
const startOff = toTextOffset(range.startContainer, range.startOffset)
|
||
const endOff = toTextOffset(range.endContainer, range.endOffset)
|
||
if (startOff < 0 || endOff < 0) { _savedSel = null; return }
|
||
|
||
_savedSel = {
|
||
msgId,
|
||
startOffset: startOff,
|
||
endOffset: endOff,
|
||
selectedText: sel.toString(),
|
||
}
|
||
}
|
||
|
||
/** 恢复之前保存的选区(nextTick 后 DOM 已更新,用偏移量重新定位) */
|
||
function restoreSelection(): void {
|
||
if (!_savedSel) return
|
||
|
||
// 找到目标消息的 .ai-md 气泡(DOM 已重建,是新的节点)
|
||
const msgItem = messagesContainer.value?.querySelector(`[data-msg-id="${_savedSel.msgId}"]`)
|
||
const bubble = msgItem?.querySelector('.ai-md') as Element | null
|
||
if (!bubble) { _savedSel = null; return }
|
||
|
||
try {
|
||
// 策略1: 按偏移量直接定位
|
||
const startPos = offsetToDOMPosition(bubble, _savedSel.startOffset)
|
||
const endPos = offsetToDOMPosition(bubble, _savedSel.endOffset)
|
||
|
||
if (startPos && endPos) {
|
||
const range = new Range()
|
||
range.setStart(startPos.node, startPos.offsetInNode)
|
||
range.setEnd(endPos.node, endPos.offsetInNode)
|
||
const sel = window.getSelection()
|
||
sel?.removeAllRanges()
|
||
sel?.addRange(range)
|
||
} else if (_savedSel.selectedText) {
|
||
// 策略2: 偏移越界时用文本搜索模糊回退(处理 Markdown 渲染后微小的结构差异)
|
||
const bubbleText = bubble.textContent ?? ''
|
||
const idx = bubbleText.indexOf(_savedSel.selectedText)
|
||
if (idx >= 0) {
|
||
const startPos2 = offsetToDOMPosition(bubble, idx)
|
||
const endPos2 = offsetToDOMPosition(bubble, idx + _savedSel.selectedText.length)
|
||
if (startPos2 && endPos2) {
|
||
const range = new Range()
|
||
range.setStart(startPos2.node, startPos2.offsetInNode)
|
||
range.setEnd(endPos2.node, endPos2.offsetInNode)
|
||
const sel = window.getSelection()
|
||
sel?.removeAllRanges()
|
||
sel?.addRange(range)
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// 定位失败,静默忽略
|
||
} finally {
|
||
_savedSel = null
|
||
}
|
||
}
|
||
|
||
watch(mdReady, (ready) => {
|
||
if (ready) {
|
||
saveSelection()
|
||
nextTick(() => {
|
||
forceUpdate()
|
||
nextTick(() => restoreSelection())
|
||
})
|
||
}
|
||
})
|
||
|
||
// ── 消息气泡拷贝──
|
||
// 必须在 click 事件内(或其同步调用链)调 clipboard API (浏览器安全限制)
|
||
// navigator.clipboard 在非 secure context (Electron file:// / 旧版 webview) 不可用,
|
||
// 回退到 execCommand('copy') 兜底;两者均失败时提示复制失败而非假成功。
|
||
async function copyMsgContent(msg: AiMessage): Promise<void> {
|
||
const text = msg.content || ''
|
||
try {
|
||
if (navigator.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(text)
|
||
emit('toast', { msg: t('aiChat.copied'), type: 'info' })
|
||
return
|
||
}
|
||
} catch {
|
||
// 权限拒绝/中断,落入下方兜底
|
||
}
|
||
// fallback: 隐藏 textarea + execCommand('copy')
|
||
const ta = document.createElement('textarea')
|
||
ta.value = text
|
||
ta.style.position = 'fixed'
|
||
ta.style.top = '0'
|
||
ta.style.left = '0'
|
||
ta.style.opacity = '0'
|
||
document.body.appendChild(ta)
|
||
ta.focus()
|
||
ta.select()
|
||
let ok = false
|
||
try {
|
||
ok = document.execCommand('copy')
|
||
} catch {
|
||
ok = false
|
||
}
|
||
document.body.removeChild(ta)
|
||
emit('toast', {
|
||
msg: ok ? t('aiChat.copied') : t('aiChat.copyFailed'),
|
||
type: ok ? 'info' : 'error',
|
||
})
|
||
}
|
||
|
||
// ── 消息气泡复制为图片(html2canvas 渲染 PNG 写入剪贴板) ──
|
||
// 原生 ClipboardItem 即可写图片到剪贴板,故不加 Tauri 剪贴板插件(少装依赖)
|
||
async function copyMsgAsImage(msg: AiMessage): Promise<void> {
|
||
const bubble = messagesContainer.value?.querySelector<HTMLElement>(
|
||
`.ai-msg[data-msg-id="${msg.id}"] .ai-msg-bubble`,
|
||
)
|
||
if (!bubble) {
|
||
emit('toast', { msg: t('aiChat.copyAsImageFailed'), type: 'error' })
|
||
return
|
||
}
|
||
try {
|
||
const { default: html2canvas } = await import('html2canvas')
|
||
const canvas = await html2canvas(bubble, { backgroundColor: null, useCORS: true, scale: 2 })
|
||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'))
|
||
if (!blob) throw new Error('toBlob failed')
|
||
if (typeof ClipboardItem !== 'undefined') {
|
||
try {
|
||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||
emit('toast', { msg: t('aiChat.copiedAsImage'), type: 'info' })
|
||
return
|
||
} catch {
|
||
// 剪贴板被拒/不可用时降级下载,保证用户仍能拿到图片
|
||
}
|
||
}
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = 'message.png'
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
emit('toast', { msg: t('aiChat.copiedAsImage'), type: 'info' })
|
||
} catch {
|
||
emit('toast', { msg: t('aiChat.copyAsImageFailed'), type: 'error' })
|
||
}
|
||
}
|
||
|
||
// ── 错误气泡操作入口 ──
|
||
// errorType 经 useAiEvents push 错误消息时附带(types.ts AiMessage 未含此字段,
|
||
// 经 cast 读写闭环在 AiChat.vue / useAiEvents 两端,不污染 types.ts 定义)。
|
||
interface AiMessageWithErrorType extends AiMessage {
|
||
errorType?: import('../../api/types').AiErrorType
|
||
}
|
||
|
||
/** error_type ∈ {auth, provider_config} → 显示「去设置」按钮(其余隐藏) */
|
||
function canOpenSettings(msg: AiMessage): boolean {
|
||
const et = (msg as AiMessageWithErrorType).errorType
|
||
return et === 'auth' || et === 'provider_config'
|
||
}
|
||
|
||
/**
|
||
* 错误是否可重试(显隐「重试」按钮)。
|
||
* Fatal 类错误(鉴权/Provider 配置错——4xx/401/403/invalid_api_key)重试必再败,
|
||
* 隐藏重试按钮,引导用户走「去设置」。network/timeout/unknown 为瞬时错误,可重试。
|
||
* retryable 推断依据后端 error_type(本批前端推断;后端 AiStreamRetry/AiError 加 retryable 字段下批 df-ai 增强)。
|
||
*/
|
||
function canRetry(msg: AiMessage): boolean {
|
||
const et = (msg as AiMessageWithErrorType).errorType
|
||
// auth/provider_config = Fatal(鉴权/配置错),重试无意义
|
||
if (et === 'auth' || et === 'provider_config') return false
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* 错误气泡「重试」:取错误前的末尾 user 消息重发。
|
||
* 复用 regenerate 链路(后端弹末尾 AI 回复——错误消息本身——取触发它的 user 消息重跑)。
|
||
* 语义等价"重新生成",但语义场景是错误恢复,文案/入口独立。
|
||
*/
|
||
async function handleRetry(): Promise<void> {
|
||
try {
|
||
await store.regenerate()
|
||
await nextTick()
|
||
scrollToBottom()
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e)
|
||
emit('toast', { msg: t('aiChat.regenerateFailed', { msg }), type: 'error' })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* fetch_url obscura 未装时"AI 帮装"按钮:自动触发 AI 对话让 AI run_command 装流式。
|
||
* 提示语足够具体(给出 cargo install + GitHub releases 两种装法 + 装完告知用户重试),
|
||
* AI 拿到能直接 run_command 不需再问;装完 obscura 即可用,用户重试 fetch_url 即走 JS 渲染。
|
||
* 复用 store.sendMessage(经 useAiSend 暴露,触发 ai_chat_send → agentic loop)。
|
||
* 失败 toast(对齐 handleRetry 错误处理模式)。
|
||
*/
|
||
async function onInstallObscura(): Promise<void> {
|
||
try {
|
||
await store.sendMessage(t('aiTool.obscuraInstallPrompt'))
|
||
await nextTick()
|
||
scrollToBottom()
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e)
|
||
emit('toast', { msg: t('aiChat.sendFailed', { msg }), type: 'error' })
|
||
}
|
||
}
|
||
|
||
// ── F-15 阶段2: 消息分段(归档段折叠) ──
|
||
// ChatMessage.status 渲染语义(types.ts AiMessage.status 字面量联合定义):
|
||
// null | undefined | 'active' → 正常消息(现状)
|
||
// 'archived_segment' → 清空上下文归档标记 → 折叠成"已归档 N 条"分隔条
|
||
// 'compressed' → 压缩标记 → 走 normal 照常展开(界面不藏消息)
|
||
// 'truncated' → switchConversation 已过滤,不到这里
|
||
// 连续同 status 合并一条分隔条(避免每条都折叠条 → N 条归档只显 1 条分隔条)。
|
||
|
||
type MessageSegment =
|
||
| { kind: 'normal'; msg: AiMessage }
|
||
| { kind: 'archived'; key: string; msgs: AiMessage[] }
|
||
| { kind: 'compressed'; key: string; msgs: AiMessage[] }
|
||
|
||
const messageSegments = computed<MessageSegment[]>(() => {
|
||
const msgs = store.state.messages
|
||
const segments: MessageSegment[] = []
|
||
let i = 0
|
||
while (i < msgs.length) {
|
||
const m = msgs[i]
|
||
const status = m.status
|
||
// F-15 改(2026-06-18):compressed 消息不再折叠,走 normal 照常展开渲染(用户诉求:压缩
|
||
// 只影响后端 LLM context 省 token,界面不藏消息)。仅 archived_segment(清空归档)折叠。
|
||
// 摘要由后端插首位 system 消息携带,模板 role==='system' 分支置顶渲染。
|
||
if (status === 'archived_segment') {
|
||
// 连续 archived_segment 合并
|
||
const group: AiMessage[] = []
|
||
const key = `seg-${m.id}`
|
||
while (i < msgs.length && msgs[i].status === 'archived_segment') {
|
||
group.push(msgs[i])
|
||
i++
|
||
}
|
||
segments.push({ kind: 'archived', key, msgs: group })
|
||
} else {
|
||
segments.push({ kind: 'normal', msg: m })
|
||
i++
|
||
}
|
||
}
|
||
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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
function isLongMsg(msg: AiMessage): boolean {
|
||
return msg.role === 'assistant' && !!msg.content && msg.content.length > LONG_MSG_THRESHOLD
|
||
}
|
||
/// 是否折叠展示该 assistant 文本气泡:长消息 + 未手动展开 + 非流式(当前未在流式生成)。
|
||
function shouldCollapseLong(msg: AiMessage): boolean {
|
||
if (!isLongMsg(msg)) return false
|
||
if (expandedLongIds.value.has(msg.id)) return false
|
||
// 流式约束:正在生成的当前消息不折叠(避免 delta 重排抖动),参考 L92 同款 streaming 判定。
|
||
if (isLastAi(msg) && store.state.streaming) return false
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* JSON 工具结果折叠防御(对齐 miniapp apps/df-miniapp/src/pages/chat/index.vue:344-354
|
||
* isToolResultJson):assistant content 为纯 JSON 工具结果(trim 后以 {/[ 开头且 JSON.parse
|
||
* 成功)时,弱化为灰小字折叠块(.ai-msg-json),不渲染 markdown 大气泡刷屏。
|
||
* 仅完成/历史态生效:isLastAi && streaming 时 content 是流式累积的截断 JSON(JSON.parse
|
||
* 必失败/误判),仍走 currentText 分块渲染,本检测对流式态短路。
|
||
*/
|
||
function isToolResultJson(content: string | undefined | null): boolean {
|
||
if (!content) return false
|
||
const t = content.trim()
|
||
if (!(t.startsWith('{') || t.startsWith('['))) return false
|
||
try {
|
||
JSON.parse(t)
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
/// 展开的 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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
/** 轨迹卡聚合行:「N 次调用 · 成功 x 失败 y · 总耗时 T」(拼装统一走 useTurnTrace.aggText) */
|
||
function traceAggText(stats: TurnCallStats, startTs: number, endTs: number): string {
|
||
return aggText(stats, endTs - startTs)
|
||
}
|
||
|
||
/** 轨迹卡 hover:绝对起止时刻(相对时长之外可溯源具体时间) */
|
||
function traceHoverTitle(startTs: number, endTs: number): string {
|
||
return `${formatDate(startTs)} ~ ${formatDate(endTs)}`
|
||
}
|
||
|
||
/** 执行中实时步骤摘要:「✓ N 次调用完成 · 正在 {工具} · ⏳ N 项待审批」(由最近工具事件聚合) */
|
||
function stepText(s: TurnCallStats): string {
|
||
const parts: string[] = []
|
||
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 }))
|
||
if (s.rejected > 0) parts.push(t('ai.trace.rejected', { n: s.rejected }))
|
||
if (s.running > 0 && s.runningName) parts.push(t('ai.trace.stepRunning', { name: s.runningName }))
|
||
if (s.pending > 0) parts.push(t('ai.trace.stepPending', { n: s.pending }))
|
||
return parts.join(' · ') || t('ai.trace.processOnly')
|
||
}
|
||
|
||
type RenderItem =
|
||
| { kind: 'msg'; key: string; msg: AiMessage }
|
||
| { 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 过程压缩):在归档分段之上叠加轮次折叠。
|
||
*
|
||
* 轮次 = 末条 user 之后的连续 assistant 消息(错误气泡/压缩摘要/归档分段不进桶,原样渲染)。
|
||
* - 执行中:仅末条消息(当前步骤)展开,先前步骤收进一行实时摘要(step 项,由工具事件聚合)。
|
||
* - 完成:轮次内除最终答复外的过程消息折叠为一张执行轨迹卡(trace 项,默认一行聚合),
|
||
* 点开回看;用户手动展开记入 expandedTraceIds 不被收回。正文区只留最终答复。
|
||
* 无过程消息的轮次(单轮问答)零改动原样渲染。
|
||
*/
|
||
const renderItems = computed<RenderItem[]>(() => {
|
||
const items: RenderItem[] = []
|
||
let bucket: AiMessage[] = []
|
||
|
||
const flushBucket = () => {
|
||
const msgs = bucket.filter(shouldRenderMsg)
|
||
bucket = []
|
||
if (msgs.length === 0) return
|
||
|
||
if (isViewingGenerating.value) {
|
||
// 执行中:当前步骤锚定真实末条消息(agentic 新一轮 push 新空气泡,若按「末条有正文」
|
||
// 找锚点会错挂上一轮,致当前轮打字机/光标/工具卡整条不渲染);先前步骤收进一行实时摘要
|
||
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) })
|
||
}
|
||
items.push({ kind: 'msg', key: 'm-' + cur.id, msg: cur })
|
||
return
|
||
}
|
||
// 最终答复:末条有正文且非纯 JSON 工具结果的消息;全无正文时保底末条(工具轮可见)
|
||
let fi = -1
|
||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||
const c = msgs[i].content
|
||
if (c && c.trim() && !isToolResultJson(c)) { fi = i; break }
|
||
}
|
||
const finalMsg = fi >= 0 ? msgs[fi] : msgs[msgs.length - 1]
|
||
const traceMsgs = msgs.slice(0, msgs.indexOf(finalMsg))
|
||
// 完成:轨迹折叠为一张卡,展开时再 emit 段内过程消息(与归档分段同一扁平化模式)
|
||
if (traceMsgs.length > 0) {
|
||
const key = 'trace-' + traceMsgs[0].id
|
||
const expanded = expandedTraceIds.value.has(key)
|
||
// 终点计入末轮时长:过程消息里最晚的工具完成时刻与末轮开始时刻取较大者
|
||
let lastCompleted = 0
|
||
for (const m of traceMsgs) {
|
||
for (const tc of m.toolCalls || []) {
|
||
if (tc.completedAt && tc.completedAt > lastCompleted) lastCompleted = tc.completedAt
|
||
}
|
||
}
|
||
items.push({
|
||
kind: 'trace', key,
|
||
stats: collectTurnStats(traceMsgs),
|
||
startTs: traceMsgs[0].timestamp,
|
||
endTs: Math.max(lastCompleted, finalMsg.timestamp),
|
||
traceMsgs, expanded,
|
||
})
|
||
if (expanded) {
|
||
for (const m of traceMsgs) {
|
||
items.push({ kind: 'msg', key: 'tm-' + m.id, msg: m })
|
||
}
|
||
}
|
||
}
|
||
items.push({ kind: 'msg', key: 'm-' + finalMsg.id, msg: finalMsg })
|
||
}
|
||
|
||
for (const seg of messageSegments.value) {
|
||
if (seg.kind !== 'normal') {
|
||
flushBucket()
|
||
// 折叠段:总 emit sep;展开时再 emit 段内每条 msg
|
||
items.push({ kind: 'sep', key: seg.key, seg })
|
||
if (expandedSegmentIds.value.has(seg.key)) {
|
||
for (const m of seg.msgs) {
|
||
items.push({ kind: 'msg', key: 'e-' + seg.key + '-' + m.id, msg: m })
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
const m = seg.msg
|
||
// assistant 非错误非压缩进轮次桶(compressed 用户诉求不折叠,原样展开)
|
||
if (m.role === 'assistant' && !m.isError && m.status !== 'compressed') {
|
||
bucket.push(m)
|
||
} else {
|
||
flushBucket()
|
||
items.push({ kind: 'msg', key: 'm-' + m.id, msg: m })
|
||
}
|
||
}
|
||
flushBucket()
|
||
return items
|
||
})
|
||
|
||
// 切换对话/新对话时由父取消编辑态;滚动边沿/跟随意图重置在此自管。
|
||
watch(() => store.state.activeConversationId, () => {
|
||
// 切会话重置滚动边沿检测 + 跟随意图(新会话默认跟随底部)
|
||
setFollowing(true)
|
||
// 切会话重置执行轨迹展开态(展开记忆按会话隔离,防跨会话 key 撞车误展开)
|
||
expandedTraceIds.value = new Set()
|
||
// BUG-260624-01(消息重叠根治·论证 workflow 完整性高优):MessageList 是持久单实例(AiChat.vue
|
||
// 无 :key/v-if 卸载),实例级 streamingBlocks(:69)跨会话/跨轮残留是消息重叠主根因之一。
|
||
// 钉在「会话切换」这一确切时机清,根治两条残留路径:① switchConversation(useAiConversations)
|
||
// 清 currentText 但实例级 streamingBlocks 未同步;② restoreGeneratingState(useAiWindow)
|
||
// 切到正在生成的 conv / 分离窗口挂载。比靠 currentText 副作用间接触发更精确,不依赖
|
||
// switchConversation 内部语句顺序(对齐 no-patch-groundwork:状态变更点收敛,非渲染侧补丁)。
|
||
if (streamingBlocks.value.length) {
|
||
clearStreamingState()
|
||
} else {
|
||
cancelPendingRaf()
|
||
}
|
||
// 切会话同步清流式块级 memo 缓存(_blockCache),防旧会话已缓存块被新会话命中。
|
||
resetStreamCache()
|
||
})
|
||
|
||
// 流式 rAF 清理:组件卸载时若仍有 pending rAF(streaming 中途切走/关窗),取消防泄漏。
|
||
// streaming 翻 false 的 watch 已清 rafId,此为中途卸载兜底。
|
||
onBeforeUnmount(() => {
|
||
cancelPendingRaf()
|
||
document.removeEventListener('click', closeTokenPopoverOnOutsideClick)
|
||
})
|
||
|
||
// token 详情面板:点击外部关闭(token 区内的 @click.stop 已阻冒泡,故文档级点击必为外部)
|
||
function closeTokenPopoverOnOutsideClick(): void {
|
||
tokenPopoverMsgId.value = null
|
||
}
|
||
document.addEventListener('click', closeTokenPopoverOnOutsideClick)
|
||
|
||
// Markdown 预热(父原 loadMarkdown 在 onMounted 调,子组件同样幂等——useMarkdown 单例,
|
||
// 多次调用安全,确保子组件挂载即预热)。
|
||
loadMarkdown()
|
||
|
||
defineExpose({
|
||
scrollToBottom,
|
||
scrollToFirstPending,
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<!-- Messages -->
|
||
<div class="ai-messages" ref="messagesContainer" @scroll="handleMessagesScroll">
|
||
<!-- 空状态 (UX-2025-20: 示例问题卡片 + 无 provider 引导跳 Settings) — 提取至 EmptyState.vue -->
|
||
<EmptyState
|
||
v-if="store.state.messages.length === 0 && !store.state.streaming"
|
||
:has-provider="store.state.providers.length > 0"
|
||
@send-example-prompt="(i18nKey) => emit('send-example-prompt', i18nKey)"
|
||
@go-to-settings="emit('go-to-settings')"
|
||
/>
|
||
|
||
<!-- 消息列表(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 承担)。 -->
|
||
<template v-for="item in renderItems" :key="item.key">
|
||
<!-- 执行轨迹卡(完成轮次:过程文本+工具卡折叠为一张,默认一行聚合,点开回看;
|
||
用户手动展开记入 expandedTraceIds 不被自动收回) -->
|
||
<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-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-agg" :title="traceHoverTitle(item.startTs, item.endTs)">{{ traceAggText(item.stats, item.startTs, item.endTs) }}</span>
|
||
<span class="ai-trace-toggle">{{ item.expanded ? $t('ai.trace.collapse') : $t('ai.trace.expand') }}</span>
|
||
</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>
|
||
</div>
|
||
</div>
|
||
<!-- B-260618-19: 纯空 assistant(无 content/error/toolCalls 且非末条流式)整 slot 不渲染,
|
||
消除空 div + flex gap 堆积的大片空白;sep 始终渲染;有 toolCalls 的空 assistant
|
||
仍渲染(工具卡可见——修复 agentic 工具轮 LLM 无文本时工具卡消失)。 -->
|
||
<div
|
||
v-else-if="item.kind === 'sep' ? true : shouldRenderMsg(item.msg)"
|
||
class="ai-msg-slot"
|
||
>
|
||
<!-- 折叠分隔条(归档段)— 轻量,始终渲染 -->
|
||
<div
|
||
v-if="item.kind === 'sep'"
|
||
class="ai-msg-segment"
|
||
: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">
|
||
<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>
|
||
<template v-else>
|
||
<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/>
|
||
</template>
|
||
</svg>
|
||
<span class="ai-msg-segment-text">
|
||
<template v-if="item.seg.kind === 'archived'">{{ $t('aiChat.contextArchived', { n: item.seg.msgs.length }) }}</template>
|
||
<template v-else>{{ $t('aiChat.compressed') }}</template>
|
||
</span>
|
||
<span class="ai-msg-segment-toggle">
|
||
{{ expandedSegmentIds.has(item.seg.key) ? $t('aiChat.collapse') : $t('aiChat.expand') }}
|
||
</span>
|
||
</div>
|
||
|
||
<!-- 消息(正常段 + 展开的折叠段内消息)— 虚拟滚动已移除(UX-260617-01:IO/RO 时序致重叠),消息恒渲染 -->
|
||
<!-- B-260618-19: 纯空 assistant 过滤已上提到外层 .ai-msg-slot 的 v-if(shouldRenderMsg),
|
||
此处 v-else 恒渲染 .ai-msg(sep 已在上方 v-if 分支处理,此处必为 msg)。 -->
|
||
<div
|
||
v-else
|
||
class="ai-msg"
|
||
:class="'ai-msg--' + item.msg.role"
|
||
:data-msg-id="item.msg.id"
|
||
>
|
||
<!-- F-15:压缩摘要 system 消息置顶显示(compressed 原文照常展开不折叠,见 messageSegments) -->
|
||
<div v-if="item.msg.role === 'system'" class="ai-msg-system">
|
||
<span class="ai-msg-system-label">{{ $t('aiChat.compressedSummaryLabel') }}</span>
|
||
<div class="ai-msg-system-summary ai-md" v-html="renderMd(item.msg.content)"></div>
|
||
</div>
|
||
<!-- 用户消息(提取至 MessageItem.vue,仅 user 角色;assistant/system 由本组件内联渲染) -->
|
||
<MessageItem
|
||
v-else-if="item.msg.role === 'user'"
|
||
:msg="item.msg"
|
||
:can-edit="isLastUser(item.msg) && !store.state.streaming && item.msg.id !== editingMsgId"
|
||
@copy="copyMsgContent"
|
||
@start-edit="(m) => emit('start-edit', m)"
|
||
/>
|
||
|
||
<!-- 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>
|
||
</div>
|
||
<div class="ai-msg-content">
|
||
<!-- 文本内容(流式或固定,Markdown 渲染) -->
|
||
<!-- JSON 工具结果折叠防御(对齐 miniapp apps/df-miniapp/src/pages/chat/index.vue:344-354):
|
||
assistant content 为纯 JSON 工具结果时弱化为灰小字折叠块(.ai-msg-json),
|
||
不渲染 markdown 大气泡刷屏。仅完成/历史态生效:isLastAi && streaming 时 content
|
||
是流式累积的截断 JSON(JSON.parse 必失败/误判),仍走下方流式分块渲染。
|
||
UX-2025-01:流式分块v-for,已完成块DOM稳定不重建→选文字保持 -->
|
||
<div
|
||
v-if="isToolResultJson(item.msg.content) && !(isLastAi(item.msg) && store.state.streaming)"
|
||
class="ai-msg-json"
|
||
:class="{ 'ai-msg-json--expanded': expandedJsonIds.has(item.msg.id) }"
|
||
@click="toggleJson(item.msg.id)"
|
||
><pre>{{ item.msg.content }}</pre></div>
|
||
<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) }"
|
||
:key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)"
|
||
>
|
||
<!-- 流式:分块渲染(已完成块key稳定→DOM不重建)。
|
||
AC-EFF-S2-1(2026-08-09):条件改用 streamingBlocks.length 而非 store.state.currentText,
|
||
消除「currentText 变化触发模板重渲染 + rAF 更新 streamingBlocks 触发重渲染」的双渲染
|
||
(每次 delta 只经 rAF 更新 streamingBlocks 触发一次渲染)。 -->
|
||
<template v-if="isLastAi(item.msg) && store.state.streaming && streamingBlocks.length > 0">
|
||
<div v-for="block in streamingBlocks" :key="block.key" v-html="block.html"></div>
|
||
<span v-if="isViewingGenerating" class="ai-cursor"></span>
|
||
</template>
|
||
<!-- 完成/历史:整段v-html(不变) -->
|
||
<div v-else v-html="renderMd(item.msg.content)"></div>
|
||
<!-- M1:超长 assistant 文本折叠后的底部展开按钮(仅非流式 + 长消息显示) -->
|
||
<button
|
||
v-if="isLongMsg(item.msg) && !(isLastAi(item.msg) && store.state.streaming)"
|
||
class="ai-msg-bubble-toggle"
|
||
@click.stop="toggleLong(item.msg.id)"
|
||
>{{ expandedLongIds.has(item.msg.id) ? $t('aiChat.collapse') : $t('aiChat.expand') }}</button>
|
||
<button
|
||
v-if="item.msg.content"
|
||
class="ai-copy-btn ai-copy-btn--ai"
|
||
: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>
|
||
</button>
|
||
<button
|
||
v-if="item.msg.content"
|
||
class="ai-copy-btn ai-copy-btn--ai ai-copy-btn--image"
|
||
: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>
|
||
</button>
|
||
</div>
|
||
<!-- 流式开始但尚无文本时显示光标 -->
|
||
<div v-else-if="isLastAi(item.msg) && isViewingGenerating" class="ai-msg-bubble ai-msg-bubble--ai">
|
||
<span class="ai-cursor"></span>
|
||
</div>
|
||
|
||
<!-- UX-03 / UX-260616-01:错误气泡操作入口(重试 / 去设置) -->
|
||
<div v-if="item.msg.isError" class="ai-msg-actions ai-msg-actions--error">
|
||
<span v-if="!canRetry(item.msg)" class="ai-msg-not-retryable">{{ $t('ai.notRetryable') }}</span>
|
||
<button
|
||
v-if="canRetry(item.msg)"
|
||
class="ai-msg-act ai-msg-act--primary"
|
||
: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>
|
||
<span>{{ $t('aiChat.retry') }}</span>
|
||
</button>
|
||
<button
|
||
v-if="canOpenSettings(item.msg)"
|
||
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>
|
||
<span>{{ $t('aiChat.goToSettingsAction') }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
|
||
<!-- token 分项显示(2026-08-02):in/cache/out/reason 分计费,不显 total(相加无意义)。
|
||
in=cache_miss(全价真实,非 prompt 总),cache=cache_hit(命中低价),out=completion,
|
||
reasoning > 0 才显。点击 token 区弹详情面板(完整 usage + 流程:模型/缓存命中率)。
|
||
tokenUsage(内存,AiCompleted 实时)优先;fallback prompt_tokens/completion_tokens
|
||
(DB 持久化字段,reload 自动有)—— 不依赖某个 reload 映射点,压缩/切会话都生效。 -->
|
||
<div
|
||
v-if="item.msg.role === 'assistant' && (item.msg.tokenUsage || item.msg.prompt_tokens != null)"
|
||
class="ai-token-usage"
|
||
>
|
||
<span class="ai-token-usage-icon">🔣</span>
|
||
<button
|
||
class="ai-token-usage-trigger"
|
||
: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 class="ai-token-sep">·</span>
|
||
<span>{{ formatTokens(tokenCacheOf(item.msg)) }} cache</span>
|
||
<span class="ai-token-sep">·</span>
|
||
<span>{{ formatTokens(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>
|
||
</template>
|
||
</button>
|
||
<!-- 详情面板(absolute/right 0/popout 样式,复用 TopBar 风格):完整 usage + 流程 -->
|
||
<div
|
||
v-if="tokenPopoverMsgId === item.msg.id"
|
||
class="ai-token-popover"
|
||
@click.stop
|
||
>
|
||
<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>
|
||
</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>
|
||
</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>
|
||
</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>
|
||
</div>
|
||
<div v-if="cacheHitRate(item.msg) != null" class="ai-token-popover-row">
|
||
<span class="ai-token-popover-label">{{ $t('aiChat.tokenCacheRateLabel') }}</span>
|
||
<span class="ai-token-popover-val">{{ cacheHitRate(item.msg) }}%</span>
|
||
</div>
|
||
<div v-if="item.msg.model" class="ai-token-popover-row">
|
||
<span class="ai-token-popover-label">{{ $t('aiChat.tokenModelLabel') }}</span>
|
||
<span class="ai-token-popover-val">{{ item.msg.model }}</span>
|
||
</div>
|
||
<div v-if="tokenEstimated(item.msg)" class="ai-token-popover-row">
|
||
<span class="ai-token-popover-label">{{ $t('aiChat.tokenUsageNoteLabel') }}</span>
|
||
<span class="ai-token-popover-val">{{ $t('aiChat.tokenUsageNote') }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 工具调用卡片(渲染/折叠/审批全下沉到 ToolCardList+ToolCard 子组件,MessageList 仅转发审批) -->
|
||
<!-- path_auth 审批链阶段3b:approve 事件透传 decision(path 类 once/always/deny),
|
||
store.approveToolCall 据 decision 走 authorizeDir,缺省走 approve。 -->
|
||
<ToolCardList
|
||
v-if="item.msg.toolCalls && item.msg.toolCalls.length > 0"
|
||
ref="toolCardListRef"
|
||
:toolCalls="item.msg.toolCalls"
|
||
@approve="({ id, approved, decision }) => store.approveToolCall(id, approved, decision)"
|
||
@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>
|
||
</div><!-- /.ai-msg-slot(B-260618-01:虚拟滚动已移除,恒渲染容器) -->
|
||
</template>
|
||
</div>
|
||
|
||
<!-- 回到底部(上滑看历史 / 有新内容时显示) -->
|
||
<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>
|
||
</Transition>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* ═══ Messages ═══ */
|
||
.ai-messages {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 14px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
/* .ai-messages 的直接 flex 子虚拟滚动 sentinel 已移除,
|
||
现仅作消息分组的语义容器,gap:14px 由 .ai-messages 承担)。 */
|
||
.ai-msg-slot { display: flex; flex-direction: column; }
|
||
|
||
/* ── 回到底部按钮(浮动,绝对定位于 .ai-chat-area) ── */
|
||
.ai-back-to-bottom {
|
||
position: absolute;
|
||
right: 18px;
|
||
bottom: 88px;
|
||
width: 30px;
|
||
height: 30px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: 0.5px solid var(--df-border);
|
||
border-radius: 50%;
|
||
background: var(--df-bg-card);
|
||
color: var(--df-text-dim);
|
||
cursor: pointer;
|
||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
|
||
z-index: 15;
|
||
transition: color 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
|
||
}
|
||
.ai-back-to-bottom:hover {
|
||
color: var(--df-accent);
|
||
border-color: var(--df-accent);
|
||
}
|
||
.back-to-bottom-enter-active,
|
||
.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); }
|
||
|
||
/* -- Empty State 样式已随 EmptyState.vue 提取 -- */
|
||
|
||
/* -- Message Layout -- */
|
||
.ai-msg-user {
|
||
position: relative;
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: flex-start;
|
||
flex-direction: row-reverse; /* 头像在右:DOM 仍 avatar 在先,视觉反转成【气泡】【头像】靠右 */
|
||
margin-top: -3px; /* user 与上方消息间距略收(全局 gap 14 → 视觉 11),更紧凑 */
|
||
}
|
||
.ai-msg-ai {
|
||
position: relative;
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: flex-start;
|
||
}
|
||
/* token 详情 popover 展开时,提升本消息 z-index 盖住后续消息气泡
|
||
(后续 .ai-msg-user 也是 relative 同级 context,DOM 在后会遮挡 popover) */
|
||
.ai-msg-ai:has(.ai-token-popover) { z-index: 30; }
|
||
.ai-msg-content {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
/* -- Avatar -- */
|
||
.ai-msg-avatar {
|
||
width: 24px;
|
||
height: 24px;
|
||
border-radius: var(--df-radius);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.ai-msg-avatar--user {
|
||
background: var(--df-info-bg);
|
||
color: var(--df-info);
|
||
}
|
||
.ai-msg-avatar--ai {
|
||
background: var(--df-accent-bg);
|
||
color: var(--df-accent);
|
||
}
|
||
|
||
/* -- Bubble -- */
|
||
.ai-msg-bubble {
|
||
position: relative;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
max-width: var(--df-msg-max-width);
|
||
padding: 8px 12px;
|
||
border-radius: var(--df-radius-lg);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.ai-msg-bubble--user {
|
||
background: var(--df-accent);
|
||
color: #fff;
|
||
border-bottom-right-radius: var(--df-radius-sm);
|
||
}
|
||
.ai-msg-bubble--ai {
|
||
background: var(--df-bg-card);
|
||
color: var(--df-text);
|
||
border: 0.5px solid var(--df-border);
|
||
border-bottom-left-radius: var(--df-radius-sm);
|
||
}
|
||
.ai-msg-bubble--error {
|
||
background: var(--df-danger-bg);
|
||
color: var(--df-danger);
|
||
border-color: rgba(240,101,101,0.3);
|
||
}
|
||
/* 修正 UX-260617-21 的 display:block 方案——display:block 让 table 失去表格
|
||
布局上下文,tr/td 脱离 table 上下文塌成块级堆叠丢列对齐(用户截图反馈测试报告四列表格错乱)。
|
||
正解:table 保持默认 display:table(列对齐恢复)+ max-width:100%;宽表格横向滚动交由气泡
|
||
容器 .ai-msg-bubble--ai.ai-md 的 overflow-x:auto 承载(CSS spec 中 table-display 下
|
||
overflow 被浏览器忽略,滚动须由 block-layout 父容器承担)。:deep() 因 table 经 v-html
|
||
注入非 scoped 受控节点。 */
|
||
.ai-msg-bubble.ai-md :deep(table) {
|
||
max-width: 100%;
|
||
}
|
||
.ai-msg-bubble--ai.ai-md {
|
||
overflow-x: auto;
|
||
}
|
||
/* M1:超长 assistant 文本折叠——max-height 截断 + 底部渐变遮罩 + 展开按钮。
|
||
背景 var(--df-bg-card) 对齐 .ai-msg-bubble--ai(L876):渐变淡出到气泡背景,
|
||
视觉上内容「沉入」气泡而非被硬切。overflow:hidden 覆盖 .ai-md 的 overflow-x:auto。 */
|
||
.ai-msg-bubble--collapsed {
|
||
max-height: 400px;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
.ai-msg-bubble--collapsed::after {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 48px;
|
||
background: linear-gradient(transparent, var(--df-bg-card));
|
||
pointer-events: none; /* 让点击穿透到下方展开按钮 */
|
||
}
|
||
/* 展开按钮:气泡底部居中,置于渐变遮罩之上 */
|
||
.ai-msg-bubble-toggle {
|
||
position: absolute;
|
||
bottom: 6px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
z-index: 1;
|
||
padding: 2px 12px;
|
||
font-size: 11px;
|
||
line-height: 1.4;
|
||
color: var(--df-accent);
|
||
background: var(--df-bg-card);
|
||
border: 0.5px solid var(--df-border);
|
||
border-radius: var(--df-radius);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.ai-msg-bubble-toggle:hover {
|
||
background: var(--df-bg-card-hover);
|
||
}
|
||
|
||
/* ── JSON 工具结果折叠防御(对齐 miniapp .msg-toolresult 视觉:灰 #b0b0b0 / 11px /
|
||
等宽 / max-height 80px 裁剪):assistant content 为纯 JSON 工具结果时弱化为折叠块,
|
||
不渲染 markdown 大气泡刷屏。默认 max-height 80px + overflow-y:auto 可滚动,
|
||
点击整块切换 --expanded(移除高度限制)展开/收起完整内容。 ── */
|
||
.ai-msg-json {
|
||
max-height: 80px;
|
||
overflow-y: auto;
|
||
padding: 6px 10px;
|
||
border-radius: var(--df-radius);
|
||
background: var(--df-bg-card);
|
||
border: 0.5px solid var(--df-border);
|
||
font-family: var(--df-font-mono);
|
||
font-size: 11px;
|
||
line-height: 1.5;
|
||
color: #b0b0b0;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.ai-msg-json pre {
|
||
margin: 0;
|
||
font: inherit;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
.ai-msg-json--expanded {
|
||
max-height: none;
|
||
overflow: visible;
|
||
}
|
||
.ai-msg-json:hover {
|
||
border-color: var(--df-accent);
|
||
}
|
||
|
||
/* ── Input Augmentation: 用户气泡内 mention chip(圆角徽标,复用 ChatInput .ai-skill-chip-name 风格) ──
|
||
用户气泡背景 = --df-accent(主题强调色,如深色蓝/紫),chip 用半透明白底+深字 保证对比度;
|
||
按 kind 区分颜色变体(project/task/idea/skill),与 @ mention 浮层 .ai-mention-item-type--* 视觉呼应。 */
|
||
.ai-msg-chip {
|
||
display: inline-block;
|
||
padding: 1px 6px;
|
||
margin: 0 1px;
|
||
border-radius: var(--df-radius);
|
||
font-size: 0.9em;
|
||
font-weight: 600;
|
||
line-height: 1.4;
|
||
background: rgba(255, 255, 255, 0.22);
|
||
color: #fff;
|
||
vertical-align: baseline;
|
||
white-space: nowrap;
|
||
}
|
||
/* kind 颜色变体(用更亮的底色区分,但保持白字可读) */
|
||
.ai-msg-chip--project { background: rgba(255, 255, 255, 0.32); }
|
||
.ai-msg-chip--task { background: rgba(255, 255, 255, 0.26); }
|
||
.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 {
|
||
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;
|
||
}
|
||
.ai-msg-time--user {
|
||
align-self: flex-end; /* 用户消息:靠气泡右对齐 */
|
||
}
|
||
|
||
/* ── 消息气泡拷贝按钮── */
|
||
.ai-copy-btn {
|
||
position: absolute;
|
||
right: 4px;
|
||
bottom: 4px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 22px;
|
||
height: 22px;
|
||
border: none;
|
||
border-radius: var(--df-radius-sm);
|
||
background: rgba(0, 0, 0, 0.35);
|
||
color: #fff;
|
||
cursor: pointer;
|
||
opacity: 0;
|
||
transition: opacity 0.2s;
|
||
flex-shrink: 0;
|
||
}
|
||
.ai-msg-user:hover .ai-copy-btn,
|
||
.ai-msg-ai:hover .ai-copy-btn--ai {
|
||
opacity: 1;
|
||
}
|
||
.ai-copy-btn:hover {
|
||
background: rgba(0, 0, 0, 0.55);
|
||
}
|
||
/* 图片复制按钮排在文本复制按钮左侧(right 让位 22px 按钮 + 4px 间距) */
|
||
.ai-copy-btn--image {
|
||
right: 30px;
|
||
}
|
||
|
||
/* ── UX-02/消息操作栏(hover 显示 / 错误气泡常显) ── */
|
||
.ai-msg-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
margin-top: 4px;
|
||
opacity: 0;
|
||
transition: opacity 0.15s var(--df-ease);
|
||
}
|
||
/* AI 消息操作栏:hover 整条 AI 消息行时显;错误气泡操作栏(--error)常显 */
|
||
.ai-msg-ai:hover .ai-msg-actions,
|
||
.ai-msg-actions--error {
|
||
opacity: 1;
|
||
}
|
||
/* Fatal 错误(不可重试)的提示文案——替代隐藏的「重试」按钮,
|
||
说明为何无重试入口,引导走「去设置」。 */
|
||
.ai-msg-not-retryable {
|
||
font-size: 10.5px;
|
||
color: var(--df-text-dim);
|
||
font-style: italic;
|
||
align-self: center;
|
||
}
|
||
.ai-msg-act {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
padding: 2px 7px;
|
||
border: none;
|
||
border-radius: var(--df-radius-sm);
|
||
background: transparent;
|
||
color: var(--df-text-dim);
|
||
font-size: 10.5px;
|
||
line-height: 1.4;
|
||
cursor: pointer;
|
||
transition: background 0.15s, color 0.15s;
|
||
flex-shrink: 0;
|
||
}
|
||
.ai-msg-act:hover {
|
||
background: var(--df-bg-card);
|
||
color: var(--df-text);
|
||
}
|
||
.ai-msg-act:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
/* 错误气泡重试主按钮:accent 强调(错误恢复引导主操作) */
|
||
.ai-msg-act--primary {
|
||
background: var(--df-accent-bg);
|
||
color: var(--df-accent);
|
||
}
|
||
.ai-msg-act--primary:hover {
|
||
background: color-mix(in srgb, var(--df-accent) 18%, transparent);
|
||
color: var(--df-accent);
|
||
}
|
||
|
||
/* -- Streaming Cursor -- */
|
||
.ai-cursor {
|
||
display: inline-block;
|
||
width: 2px;
|
||
height: 14px;
|
||
background: var(--df-accent);
|
||
margin-left: 2px;
|
||
vertical-align: text-bottom;
|
||
animation: blink 0.8s step-end infinite;
|
||
}
|
||
@keyframes blink {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0; }
|
||
}
|
||
|
||
/* ── token 用量条(克制:右对齐 / 最小字号 / dim / 分隔线,不抢正文焦点) ── */
|
||
.ai-token-usage {
|
||
position: relative;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
align-items: center;
|
||
gap: 4px;
|
||
margin-top: 6px;
|
||
padding-top: 6px;
|
||
border-top: 0.5px solid var(--df-border);
|
||
font-family: var(--df-font-mono);
|
||
font-size: 9px;
|
||
color: var(--df-text-dim);
|
||
opacity: 0.7;
|
||
}
|
||
.ai-token-usage-icon { font-size: 9px; }
|
||
/* token 分项显示(2026-08-02):trigger 是可点击按钮(无背景,继承 dim 样式) */
|
||
.ai-token-usage-trigger {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
padding: 0;
|
||
border: none;
|
||
background: transparent;
|
||
font: inherit;
|
||
color: inherit;
|
||
cursor: pointer;
|
||
opacity: 1;
|
||
}
|
||
.ai-token-usage-trigger:hover {
|
||
color: var(--df-text);
|
||
opacity: 1;
|
||
}
|
||
.ai-token-sep {
|
||
opacity: 0.5;
|
||
}
|
||
/* G4.3:估算值标注(估算非真实,弱化 dim + warning 色调,不喧宾夺主) */
|
||
.ai-token-est {
|
||
color: var(--df-warning, #f0c75e);
|
||
opacity: 0.85;
|
||
}
|
||
/* 详情面板:复用 TopBar popout 风格(absolute / right 0 / bg-card / border / shadow) */
|
||
.ai-token-popover {
|
||
position: absolute;
|
||
top: 100%;
|
||
right: 0;
|
||
margin-top: 4px;
|
||
padding: 8px 10px;
|
||
min-width: 200px;
|
||
background: var(--df-bg-card, var(--df-bg));
|
||
border: 0.5px solid var(--df-border);
|
||
border-radius: 4px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||
font-family: var(--df-font-mono);
|
||
font-size: 10px;
|
||
color: var(--df-text);
|
||
z-index: 10;
|
||
text-align: left;
|
||
}
|
||
.ai-token-popover-title {
|
||
font-weight: 600;
|
||
margin-bottom: 6px;
|
||
padding-bottom: 4px;
|
||
border-bottom: 0.5px solid var(--df-border);
|
||
}
|
||
.ai-token-popover-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 2px 0;
|
||
}
|
||
.ai-token-popover-label {
|
||
color: var(--df-text-dim);
|
||
}
|
||
.ai-token-popover-val {
|
||
color: var(--df-text);
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* 用户气泡内的图片(多模态消息渲染) */
|
||
.ai-msg-images {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
margin-top: 6px;
|
||
}
|
||
.ai-msg-image {
|
||
max-width: 200px;
|
||
max-height: 200px;
|
||
width: auto;
|
||
height: auto;
|
||
border-radius: var(--df-radius);
|
||
object-fit: contain;
|
||
display: block;
|
||
background: var(--df-bg);
|
||
}
|
||
|
||
/* ═══ 压缩摘要 system 块(compressed 原文已展开,摘要置顶提示发给 AI 的是它) ═══
|
||
用户诉求:压缩只后端省 token,界面不藏消息。原文 compressed 照常 user/ai 气泡展开,
|
||
摘要 system 块置顶 accent 提示,让用户知情"AI 看到的是摘要"。 */
|
||
.ai-msg-system {
|
||
margin: 6px 0;
|
||
padding: 8px 12px;
|
||
border-left: 3px solid var(--df-accent);
|
||
background: color-mix(in srgb, var(--df-accent) 6%, transparent);
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
}
|
||
.ai-msg-system-label {
|
||
display: block;
|
||
font-weight: 600;
|
||
color: var(--df-accent);
|
||
margin-bottom: 4px;
|
||
font-size: 11px;
|
||
}
|
||
.ai-msg-system-summary { color: var(--df-text-secondary); line-height: 1.5; }
|
||
.ai-msg-system-summary :is(h1, h2, h3) { font-size: 12px; margin: 4px 0 2px; }
|
||
|
||
/* ═══ F-15 阶段2: 折叠分隔条(归档段 / 压缩段) ═══
|
||
连续 archived_segment/compressed 消息合并成一条分隔条;点击展开看原文。
|
||
风格对齐 .ai-conv-group-title(dim 小字 + 上下留白),不抢正常消息视觉焦点。 */
|
||
.ai-msg-segment {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 10px;
|
||
margin: 2px 0;
|
||
background: color-mix(in srgb, var(--df-text-dim) 8%, transparent);
|
||
border: 0.5px dashed color-mix(in srgb, var(--df-text-dim) 35%, transparent);
|
||
border-radius: var(--df-radius);
|
||
font-size: 11px;
|
||
color: var(--df-text-dim);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
transition: background 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
|
||
}
|
||
.ai-msg-segment:hover {
|
||
background: color-mix(in srgb, var(--df-text-dim) 14%, transparent);
|
||
border-color: color-mix(in srgb, var(--df-text-dim) 50%, transparent);
|
||
color: var(--df-text-secondary);
|
||
}
|
||
.ai-msg-segment--compressed {
|
||
/* 压缩段与归档段视觉区分:accent 色调(压缩是 LLM 产出的摘要,更"主动") */
|
||
background: color-mix(in srgb, var(--df-accent) 8%, transparent);
|
||
border-color: color-mix(in srgb, var(--df-accent) 30%, transparent);
|
||
color: var(--df-accent);
|
||
}
|
||
.ai-msg-segment--compressed:hover {
|
||
background: color-mix(in srgb, var(--df-accent) 14%, transparent);
|
||
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
|
||
}
|
||
.ai-msg-segment-icon { flex-shrink: 0; }
|
||
.ai-msg-segment-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.ai-msg-segment-toggle { flex-shrink: 0; font-size: 10px; opacity: 0.8; }
|
||
|
||
/* A 过程压缩:执行轨迹卡(完成轮次过程内容折叠为一张,视觉对齐 .ai-msg-segment 弱化风格) */
|
||
.ai-trace-card {
|
||
max-width: var(--df-msg-max-width);
|
||
border: 0.5px dashed color-mix(in srgb, var(--df-text-dim) 35%, transparent);
|
||
border-radius: var(--df-radius);
|
||
background: color-mix(in srgb, var(--df-text-dim) 6%, transparent);
|
||
overflow: hidden;
|
||
transition: border-color 0.15s var(--df-ease), background 0.15s var(--df-ease);
|
||
}
|
||
.ai-trace-card:hover {
|
||
border-color: color-mix(in srgb, var(--df-accent) 40%, transparent);
|
||
background: color-mix(in srgb, var(--df-accent) 5%, transparent);
|
||
}
|
||
.ai-trace-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
padding: 6px 10px;
|
||
font-size: 11px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
color: var(--df-text-secondary);
|
||
}
|
||
.ai-trace-chevron {
|
||
font-size: 10px;
|
||
color: var(--df-text-dim);
|
||
transition: transform 0.15s var(--df-ease);
|
||
flex-shrink: 0;
|
||
}
|
||
.ai-trace-chevron.is-open { transform: rotate(90deg); }
|
||
.ai-trace-title { font-weight: 600; flex-shrink: 0; }
|
||
.ai-trace-agg {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-family: var(--df-font-mono);
|
||
font-size: 10px;
|
||
color: var(--df-text-dim);
|
||
}
|
||
.ai-trace-toggle { flex-shrink: 0; font-size: 10px; color: var(--df-accent); opacity: 0.85; }
|
||
|
||
/* 执行中实时步骤摘要(已完成步骤收进一行,accent 呼吸点对齐状态栏 .ai-agentic-progress) */
|
||
.ai-step-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-accent) 7%, transparent);
|
||
font-size: 11px;
|
||
color: var(--df-text-secondary);
|
||
user-select: none;
|
||
}
|
||
.ai-step-dot {
|
||
width: 5px;
|
||
height: 5px;
|
||
border-radius: 50%;
|
||
background: var(--df-accent);
|
||
flex-shrink: 0;
|
||
animation: ai-step-pulse 1.2s ease-in-out infinite;
|
||
}
|
||
.ai-step-text {
|
||
font-family: var(--df-font-mono);
|
||
font-size: 10.5px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
@keyframes ai-step-pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.3; }
|
||
}
|
||
</style>
|