<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
}

// 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
}

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, 'ai-msg-bubble--collapsed': shouldCollapseLong(item.msg) }"
            :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>
            <!-- 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>
          </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 消息底部,有数据时)。
               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>
            <span>{{ formatTokens(item.msg.tokenUsage?.prompt ?? item.msg.prompt_tokens ?? 0) }} in · {{ formatTokens(item.msg.tokenUsage?.completion ?? item.msg.completion_tokens ?? 0) }} 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;
}
/* 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);
}

/* ── 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>
