Files
DevFlow/src/components/ai/MessageList.vue
T
lxy 7f0edced01 修复: UX扩展审查P0批(后端git/路径确定性bug + 前端UI + 编译阻断)
后端(src-tauri):

- project.rs: 4处空if目录校验补return Err(import/relocate/scan_directory/scan_project_with_ai),目录不存在不再静默放行

- ai/tools/git.rs: exec_git改返(String,bool)治git_commit·git_branch create/switch假成功 + git_merge补失败分支 + 加CREATE_NO_WINDOW

- module.rs: run_git_cmd加CREATE_NO_WINDOW,治Windows cmd黑窗闪烁(原问题9)

前端:

- DependencyGraph.vue: 环检测高亮注入renderGraph,原被fromJSON重建抹除致功能失效

- FileExplorer.vue: 工程下拉closeDropdown加closest判定,原打不开

附(预存编译阻断顺带修):

- df-nodes ai_node_helpers.rs: extract_first_json_object临时值借用悬垂E0716

- MessageList.vue: 删isLastUser死代码(vue-tsc TS6133)

审查产出: docs/05-代码审查/UIUX扩展审查-2026-08-02.md(80条发现,8高grep核验全属实)+ docs/todo.md批次段
2026-08-02 12:19:40 +08:00

1150 lines
46 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 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<{
/** UX-09:当前正在编辑的消息 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 渲染(ARC-260615-08:自研块级 memo,借鉴方案D流式核心) ═══
// B-23:渲染基础设施(_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()
// ── UX-2025-20: 空状态示例问题(examplePrompts 已随 EmptyState.vue 提取) ──
const messagesContainer = ref<HTMLDivElement>()
// 工具卡片列表(子组件 ToolCardList 自治折叠态,父级仅经 expose 调 collapseInactive 收起旧卡)
const toolCardListRef = ref<InstanceType<typeof ToolCardList>>()
// 当前视图是否正在生成(切走后台生成时光标不显示)
// F-09: 多会话并发 — 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
}
/**
* B-260618-19: 是否渲染某条消息(供 .ai-msg-slot v-if 守卫)。
*
* 背景:agentic 多轮下,LLM 某轮只发 tool_calls 不输出文本时(GLM anthropic_compat 常见),
* 该轮 assistant 消息 content='' + toolCalls=[...];变中间条后,旧 v-else-if(UX-260617-22)
* 漏判 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
&& !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)
}
// ── 滚动跟随 / 回到底部(B-260618-24 跟随意图锁存,已抽取至 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,
} = useStreamRenderer({
saveSelection,
restoreSelection,
isFollowingBottom,
scrollToBottom,
})
watch(() => store.state.messages.length, onContentChange)
// SW-260618-07: currentText → onContentChange 合并到下方 scheduleStreamParse watch(同源 currentText,单 callback 顺序执行)
// 流式 Markdown 渲染驱动(ARC-260615-08 块级 memo):
// - currentText 变化(每 delta):rAF 节流 scheduleStreamParse,块级 memo 只重 parse 末块
// - streaming 翻转:结束清 rAF + 清 streamingBlocks(回 renderMd 走 final marked + 缓存)
// - mdReady 翻转(CR-260615-06):首屏 marked 慢 + 暂无新 delta 时末段停留 escapeFallback
// 纯文本;marked 就绪后主动重算一次,使纯文本转格式化
// SW-260618-07: 合并 currentText 双 watch(原 :2040 onContentChange 滚动 + 本 watch scheduleStreamParse 分块)。
// 顺序:onContentChange(滚动跟随)先 → scheduleStreamParse(rAF 分块渲染)后,保持原注册顺序。
watch(() => store.state.currentText, (text) => {
// B-260618-24: 流式滚动跟随由 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) {
// UX-260616-03: 新一轮生成开始 → 收起上一轮已完成的旧工具卡,聚焦当前生成内容。
// 仅当前视图正在生成时触发(isGenerating(activeConversationId)),
// 切走后台生成/恢复会话(streaming 初值已 true 的 onMounted 路径)不误触。
// buildActiveToolIds/collapseAllToolLists 是 function 声明,提升可用。
if (store.isGenerating(store.state.activeConversationId)) {
collapseAllToolLists(buildActiveToolIds(store.state.messages))
}
} else {
// UX-2025-01: 流式→完成切换,模板从 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 短路掉无意义重算)。
//
// UX-260616-03: 触发时机扩展。除原 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()
}
}
}
let lastMsgSnapshot = ''
watch(
() => store.state.messages,
(msgs) => {
const snap = JSON.stringify({
n: msgs.length,
tc: msgs.map(m => (m.toolCalls || []).map(t => ({ id: t.id, s: t.status }))),
})
if (snap === lastMsgSnapshot) return
const isFirst = lastMsgSnapshot === ''
lastMsgSnapshot = snap
if (isFirst) return // 首次加载/切换对话不触发收起
collapseAllToolLists(buildActiveToolIds(msgs))
},
{ deep: true },
)
// UX-260616-03: AiAgentRound 新轮次到达时收起上一轮工具卡(round 递增)。
// 首次加载不触发(等真到达更"新"的轮次才收起,避免历史会话加载即折叠)。
watch(
() => store.state.agentRound,
(cur, prev) => {
if (cur <= (prev ?? 0)) return
collapseAllToolLists(buildActiveToolIds(store.state.messages))
},
)
// CR-260615-06: mdReady 就绪后主动触发首屏 Markdown 重渲染
// marked 加载慢 + 无新 delta 时末段停留纯文本,不触发模板重算)
const _mdRenderKey = ref(0)
function forceUpdate() { _mdRenderKey.value++ }
// ── UX-2025-01: 选文字保持(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())
})
}
})
// ── 消息气泡拷贝(F-260615-13) ──
// 必须在 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',
})
}
// ── UX-03: 错误气泡操作入口 ──
// 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'
}
/**
* UX-260616-01: 错误是否可重试(显隐「重试」按钮)。
* 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 消息重跑)。
* 语义等价"重新生成",但语义场景是错误恢复,文案/入口独立(UX-03)。
*/
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
}
type RenderItem =
| { kind: 'msg'; key: string; msg: AiMessage }
| { kind: 'sep'; key: string; seg: MessageSegment & { key: string } }
const renderItems = computed<RenderItem[]>(() => {
const items: RenderItem[] = []
for (const seg of messageSegments.value) {
if (seg.kind === 'normal') {
items.push({ kind: 'msg', key: 'm-' + seg.msg.id, msg: seg.msg })
} else {
// 折叠段:总 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 })
}
}
}
}
return items
})
// UX-09:切换对话/新对话时由父取消编辑态;滚动边沿/跟随意图重置在此自管(SW-260618-18/B-260618-24)。
watch(() => store.state.activeConversationId, () => {
// SW-260618-18: 切会话重置滚动边沿检测 + 跟随意图(新会话默认跟随底部)
setFollowing(true)
// 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()
}
})
// 流式 rAF 清理:组件卸载时若仍有 pending rAF(streaming 中途切走/关窗),取消防泄漏。
// streaming 翻 false 的 watch 已清 rafId,此为中途卸载兜底。
onBeforeUnmount(() => {
cancelPendingRaf()
})
// Markdown 预热(父原 loadMarkdown 在 onMounted 调,子组件同样幂等——useMarkdown 单例,
// 多次调用安全,确保子组件挂载即预热)。
loadMarkdown()
defineExpose({
scrollToBottom,
scrollToFirstPending,
})
</script>
<template>
<!-- Messages -->
<div class="ai-messages" ref="messagesContainer" @scroll="onMessagesScroll(() => collapseAllToolLists(buildActiveToolIds(store.state.messages)))">
<!-- 空状态 (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
合并一条分隔条,点击展开/收起看原文)
UX-2025-19: 每项外层包 .ai-msg-slot(B-260618-01 虚拟滚动已移除,现恒渲染;
slot 仅作 flex 子占位 + 消息分组的语义容器,gap 由 .ai-messages 承担)。 -->
<template v-for="item in renderItems" :key="item.key">
<!-- B-260618-19: 纯空 assistant(无 content/error/toolCalls 且非末条流式)整 slot 不渲染,
消除空 div + flex gap 堆积的大片空白;sep 始终渲染;有 toolCalls 的空 assistant
仍渲染(工具卡可见——修复 agentic 工具轮 LLM 无文本时工具卡消失)。 -->
<div
v-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"
@copy="copyMsgContent"
/>
<!-- 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 渲染) -->
<!-- UX-2025-01:流式分块v-for,已完成块DOM稳定不重建→选文字保持 -->
<div
v-if="item.msg.content || (isLastAi(item.msg) && store.state.streaming && store.state.currentText)"
class="ai-msg-bubble ai-msg-bubble--ai ai-md"
:class="{ 'ai-msg-bubble--error': item.msg.isError }"
:key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)"
>
<!-- 流式:分块渲染(已完成块key稳定→DOM不重建) -->
<template v-if="isLastAi(item.msg) && store.state.streaming && store.state.currentText">
<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>
<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>
</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 用量(显示在每条 assistant 消息底部,非流式且有数据时) -->
<div
v-if="item.msg.role === 'assistant' && !store.state.streaming && item.msg.tokenUsage"
class="ai-token-usage"
>
<span class="ai-token-usage-icon">🔣</span>
<span>{{ formatTokens(item.msg.tokenUsage.prompt) }} in · {{ formatTokens(item.msg.tokenUsage.completion) }} out</span>
</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;
}
/* UX-2025-19: .ai-messages 的直接 flex 子(B-260618-01:虚拟滚动 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 在先,视觉反转成【气泡】【头像】靠右 */
}
.ai-msg-ai {
position: relative;
display: flex;
gap: 8px;
align-items: flex-start;
}
.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);
}
/* B-260618-06:修正 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;
}
/* ── 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); }
/* ── 消息时间戳(UX-2025-13) ── */
.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; /* 用户消息:靠气泡右对齐 */
}
/* ── 消息气泡拷贝按钮(F-260615-13) ── */
.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);
}
/* ── UX-09: 末条 user 消息编辑按钮(hover 显示,回填输入框编辑重生成) ── */
.ai-msg-edit-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
border-radius: var(--df-radius-sm);
background: transparent;
color: var(--df-text-dim);
cursor: pointer;
opacity: 0;
transition: opacity 0.2s, background 0.2s;
flex-shrink: 0;
align-self: flex-end;
}
.ai-msg-user:hover .ai-msg-edit-btn {
opacity: 1;
}
.ai-msg-edit-btn:hover {
background: var(--df-bg-hover, rgba(0, 0, 0, 0.06));
color: var(--df-text);
}
/* ── UX-02/UX-03: 消息操作栏(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;
}
/* UX-260616-01: 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 {
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; }
/* 用户气泡内的图片(多模态消息渲染) */
.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);
}
/* ═══ F-15: 压缩摘要 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; }
</style>