<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { marked } from 'marked'
import { styleMarkdown } from '@/utils/mdRenderer'
import { uploadImage } from '@/utils/fileUpload'
import { useAiChat } from '@/composables/useAiChat'
import type { MentionSpan, MiniContentPart } from '@/types/relay'
import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo, ChatMessage, TokenUsage } from '@/types/events'

// 组件内联(绕过微信工具 3.15.2 组件解析缓存 bug):独立组件恒报 Component not found,
// inline 进页面后 chat/index.json usingComponents 空,工具无需解析 components/,问题消失。
// MdView/MentionInput 源保留(src/components/ 下,工具 bug 修复后可拆回)。
/**
 * md→html 渲染 + 缓存(避免 v-for 每次 render 重算 marked.parse 同步阻塞)。
 * mdCache 上限 300(P0-2 走查):tabBar 页常驻,无上限会随长会话/切会话 HTML 累积
 * (旧会话 trimmed 后 HTML 仍留内存),加 FIFO 淘汰防内存泄漏。
 */
const mdCache = new Map<string, string>()
const MD_CACHE_LIMIT = 300
function renderMd(src: string): string {
  if (!src) return ''
  const cached = mdCache.get(src)
  if (cached !== undefined) return cached
  let html: string
  try {
    html = marked.parse(src, { breaks: true, async: false }) as string
    html = styleMarkdown(html) // P1-G:代码块/链接/表格 inline style(rich-text 不认 class)
  } catch {
    html = src
  }
  if (mdCache.size >= MD_CACHE_LIMIT) {
    mdCache.delete(mdCache.keys().next().value!)
  }
  mdCache.set(src, html)
  return html
}

// ────────────────────────────────────────────────────────────────
// 流式 Markdown 块级 memo 渲染(对齐桌面 useStreamRenderer.ts)。
//
// 核心设计(单缓存,存"最终 styled html"):
// - splitStreamBlocks:marked.lexer 围栏感知切块(code token 整段,非 code 按 \n\n 切段)
// - 单一缓存 Map<blockText, styledHtml>:已完成块命中即返回(parse+style 都缓存了),
//   末块每次重算 → 每 delta 成本 O(末块),而非 O(全文)
// - 未闭合代码围栏末块降级转义(流式写入中不渲染半截代码)
//
// 与桌面端差异:rich-text 只能整体替换 nodes(非 v-html 分块),故"分块 DOM 稳定"价值
// 不存在,这里缓存的是**渲染结果**而非中间态,减少重复 parse+style 即可。
// ────────────────────────────────────────────────────────────────
/** 单一缓存:块文本 → 最终 styled html。已完成块命中,末块重算。上限 300,超限删最旧 */
const streamBlockCache = new Map<string, string>()
const STREAM_BLOCK_CACHE_LIMIT = 300

/** HTML 转义 + 换行转 <br>(miniapp 无现成实现;未闭合围栏降级用) */
function escapeFallback(text: string): string {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;')
    .replace(/\n/g, '<br>')
}

/** 切块:marked.lexer() 围栏感知(code token 整段一块,非 code 按 \n\n 切段) */
function splitStreamBlocks(text: string): string[] {
  if (!text) return []
  const blocks: string[] = []
  const tokens = marked.lexer(text)
  for (const tok of tokens) {
    if (!tok || typeof (tok as { raw?: string }).raw !== 'string') continue
    const raw = (tok as { raw: string }).raw
    if ((tok as { type: string }).type === 'code') {
      if (raw.trim()) blocks.push(raw)
      continue
    }
    for (const b of raw.split(/\n{2,}/)) if (b.trim()) blocks.push(b)
  }
  return blocks.length ? blocks : [text]
}

/** 末块未闭合代码围栏检测:行首 ≤3 空格 + 3+ 反引号/波浪计数,奇数=未闭合 */
function isUnclosedCodeFence(block: string): boolean {
  let fenceCount = 0
  for (const line of block.split('\n')) {
    if (/^[ ]{0,3}(`{3,}|~{3,})/.exec(line)) fenceCount++
  }
  return fenceCount % 2 === 1
}

/** 单块 → styled html(parse + styleMarkdown),未闭合围栏降级转义 */
function parseStreamBlock(block: string): string {
  if (isUnclosedCodeFence(block)) return escapeFallback(block)
  try {
    return styleMarkdown(marked.parse(block, { breaks: true, async: false }) as string)
  } catch {
    return escapeFallback(block)
  }
}

/**
 * 流式渲染入口:切块 → 单缓存命中/写入 → 拼接。
 * 已完成块命中缓存(parse+style 都缓存了),末块重算,每 delta O(末块)。
 */
function renderStreamingMd(src: string): string {
  if (!src) return ''
  const blocks = splitStreamBlocks(src)
  const n = blocks.length
  const parts: string[] = []
  for (let i = 0; i < n; i++) {
    const b = blocks[i]
    if (i === n - 1) {
      // 末块:重算(内容在变),不缓存
      parts.push(parseStreamBlock(b))
      continue
    }
    // 已完成块:缓存命中直接取,未命中算并写
    const cached = streamBlockCache.get(b)
    if (cached !== undefined) {
      parts.push(cached)
    } else {
      const styled = parseStreamBlock(b)
      if (streamBlockCache.size >= STREAM_BLOCK_CACHE_LIMIT) {
        streamBlockCache.delete(streamBlockCache.keys().next().value!)
      }
      streamBlockCache.set(b, styled)
      parts.push(styled)
    }
  }
  return parts.join('')
}

const ai = useAiChat()
const inputText = ref('')

// Ai.messages 是 ref,用计数器 key 强制模板刷新(mp-weixin 数组 computed 追踪不可靠)
const messagesKey = ref(0)
const messages = computed(() => {
  messagesKey.value // 依赖计数器
  return ai.messages.value
})
// 每次 messages 长度变化,计数器递增
watch(() => ai.messages.value.length, () => { messagesKey.value++ }, { immediate: true })

// —— 计算属性:解包 useAiChat 的 ref 供模板使用 ——
const pendingApprovals = computed(() => ai.pendingApprovals.value)
const skills = computed(() => ai.skills.value)
const entities = computed(() => ai.entities.value)
const tokenUsage = computed(() => ai.tokenUsage.value)
const maxRoundsActive = computed(() => ai.maxRoundsActive.value)
const generating = computed(() => ai.generating.value)
const currentText = computed(() => ai.currentText.value)
const deviceOnline = computed(() => ai.deviceOnline.value)
const wsStatus = computed(() => ai.wsStatus.value)
const isWsConnected = computed(() => ai.isWsConnected.value)
const loading = computed(() => ai.loading.value)

/**
 * 节流 ref composable(优雅化):源变化 → 节流窗口合并,输出节流后值。
 * 首帧立即(低延迟),窗口内合并,窗口末取最新。返回 { throttled, cancel, flush }。
 *
 * 用途:流式 currentText 高频 delta → 节流渲染源,降低 rich-text 重建频率。
 * 根因(用户报"桌面端已答完,小程序端还一个字一个字蹦"):JS 单线程,每 delta 全量
 * lexer+style+rich-text 重建打爆主线程 → onMessage 接收排队(假"传输慢")。
 * 节流合并后 rich-text 重建降频,主线程释放,接收跟上 → 流式顺滑。
 */
function useThrottledRef<T>(source: () => T, ms: number) {
  const throttled = ref<T>()
  let timer: ReturnType<typeof setTimeout> | null = null
  watch(source, (v) => {
    if (timer) return
    throttled.value = v // 首帧立即
    timer = setTimeout(() => {
      timer = null
      throttled.value = source() // 窗口末取最新
    }, ms)
  })
  return {
    throttled,
    cancel: () => {
      if (timer) { clearTimeout(timer); timer = null }
      throttled.value = undefined
    },
    flush: () => {
      if (timer) { clearTimeout(timer); timer = null }
      throttled.value = source()
    },
  }
}

// 流式渲染节流:currentText → 200ms 节流合并(rich-text 重建降频,主线程释放)
const { throttled: streamRenderText, cancel: cancelStreamThrottle } = useThrottledRef(
  () => currentText.value, 200,
)
// 生成结束:清节流(完成态走 renderMd 完整渲染,无需流式块缓存)
watch(() => generating.value, (g) => { if (!g) cancelStreamThrottle() })
// 每会话独立模型:可选模型列表 + 当前选中 + 默认(顶栏模型选择器)
const models = computed(() => ai.models.value)
const activeModel = computed(() => ai.activeModel.value)

/** 当前模型展示名(label 优先,缺省 model_id;未选显默认模型) */
const currentModelLabel = computed(() => {
  const id = activeModel.value
  if (!id) return '默认模型'
  const m = models.value.find((m) => m.model_id === id)
  return m?.label || id
})

/** 点击模型选择器:弹 ActionSheet 选模型。选中置 activeModel,send 时透传 model_override */
function onModelSelect(): void {
  if (models.value.length === 0) {
    // 未拉到模型列表:重拉一次(可能 device 未在线/未响应)
    ai.listModels()
    uni.showToast({ title: '暂无模型列表,重试中…', icon: 'none' })
    return
  }
  const itemList = models.value.map((m) => m.label || m.model_id)
  uni.showActionSheet({
    itemList,
    success: (r) => {
      const picked = models.value[r.tapIndex]
      if (picked) ai.setActiveModel(picked.model_id)
    },
  })
}

// —— 联想弹层状态(技能 / 与 @ 实体复用同一浮层结构,detectTrigger 决定 trigger) ——
// 弹层可见 + 当前触发类型。trigger='skill' = `/` 开头技能联想;trigger='entity' = 末尾 `@` 实体联想。
const mentionVisible = ref(false)
const mentionTrigger = ref<'skill' | 'entity'>('skill')

// —— pendingSkill(`/<skill>` 选中后 chip 展示,handleSend 时透传 ai.send skill 参数) ——
// 选中技能后清 inputText 并置 chip,后续用户输入技能参数;发送时 skill.name 透传后端注入 SKILL.md。
// 类型与 useAiChat.skills 元素一致(对齐 relay.ts SkillInfo)。
type SkillInfoLike = { name: string; description: string; argument_hint?: string }
const pendingSkill = ref<SkillInfoLike | null>(null)

// —— pendingMentionSpans(@项目/@任务/@灵感 选中后累积,handleSend 时透传 ai.send spans 参数) ——
// 每条对应 inputText 中一段 `[类型:名]` 文本区间(start/length 为字符偏移)。
// 用户可连续 @ 多个实体,发送时整体透传,后端 resolve 投影成 Augmentation 注入。
const pendingMentionSpans = ref<MentionSpan[]>([])

// —— 图片输入(2026-08-05):选图后上传 file.1216.top 拿 URL / 失败回退 base64,发送时透传 ai.send parts 参数 ——
// 存临时文件路径(缩略图预览) + ContentPart(发送),对齐后端 df-ai-core ContentPart serde 内部标签 tag=type。
interface PendingImage {
  tempPath: string
  part: MiniContentPart
}
const pendingImages = ref<PendingImage[]>([])

/** 临时文件扩展名 → MIME 映射(fileType 是类别 'image' 非 MIME,须按扩展名推断) */
function toMime(file: { tempFilePath?: string; fileType?: string }): string {
  const ft = file.fileType
  if (ft && /^image\//i.test(ft)) return ft // 真 MIME 优先
  const p = file.tempFilePath || ''
  if (/\.png$/i.test(p)) return 'image/png'
  if (/\.(jpe?g)$/i.test(p)) return 'image/jpeg'
  if (/\.webp$/i.test(p)) return 'image/webp'
  if (/\.gif$/i.test(p)) return 'image/gif'
  return 'image/jpeg'
}

/** 选图:uni.chooseMedia(compressed)→ 上传 file.1216.top 拿 URL;失败回退 base64。 */
function onPickImage(): void {
  uni.chooseMedia({
    count: 1,
    mediaType: ['image'],
    sizeType: ['compressed'],
    success: (res) => {
      const file = res.tempFiles && res.tempFiles[0]
      if (!file) return
      const mt = toMime(file)
      // 先尝试上传 file.1216.top → URL 引用(公开可达,provider 直传)
      uploadImage(file.tempFilePath)
        .then(({ url }) => {
          pendingImages.value.push({ tempPath: file.tempFilePath, part: { type: 'image', url, media_type: mt } })
        })
        .catch(() => {
          // 上传失败回退 base64(Phase 0 修复后 base64 可真正到达 provider,不阻断)
          uni.getFileSystemManager().readFile({
            filePath: file.tempFilePath,
            encoding: 'base64',
            success: (r) => {
              const b64 = r.data as string
              if (!b64) return
              pendingImages.value.push({ tempPath: file.tempFilePath, part: { type: 'image', base64: b64, media_type: mt } })
            },
            fail: () => uni.showToast({ title: '图片读取失败', icon: 'none' }),
          })
          uni.showToast({ title: '上传失败,已改用本地直传', icon: 'none' })
        })
    },
  })
}

/** 移除已选图片(预览 × 删除)。 */
function removeImage(idx: number): void {
  pendingImages.value.splice(idx, 1)
}

// —— P1-12 自动滚底 + P1-7 生成中关联想 + P1-13 渲染上限 ——
/** scroll-into-view 锚点(同值不重滚,故 toggle ''→id 触发) */
const scrollAnchor = ref('')
function scrollToBottom(): void {
  scrollAnchor.value = ''
  // setTimeout(0) 等新消息 DOM 渲染完(mp-weixin nextTick 兼容)
  setTimeout(() => { scrollAnchor.value = 'list-bottom-anchor' }, 0)
}
let scrollTimer: ReturnType<typeof setTimeout> | null = null
/** 流式增量高频,throttle 防滚底抖动 */
function throttledScrollToBottom(): void {
  if (scrollTimer) return
  scrollTimer = setTimeout(() => {
    scrollTimer = null
    scrollToBottom()
  }, 120)
}
// 新消息 push / 切会话历史回流 → 立即滚底;流式增量 → throttled 滚底
watch(() => messages.value.length, () => scrollToBottom())
watch(() => currentText.value, throttledScrollToBottom)
// P1-7:生成中关闭联想弹层,防 input disabled 时仍点插入(input disabled 不阻止弹层点击)
watch(() => generating.value, (g) => { if (g) mentionVisible.value = false })
/**
 * P1-13 渲染上限兜底:仅渲染最近 N 条,防长会话(100+)rich-text 全量渲染卡顿/内存膨胀。
 * TODO 真虚拟化(滚动入视口才 marked.parse / 窗口化),当前截断旧消息简化兜底。
 */
const MAX_RENDER_MSGS = 200
const visibleMessages = computed(() => messages.value.slice(-MAX_RENDER_MSGS))

/**
 * 纯空 assistant 占位气泡过滤(对齐桌面 MessageList.shouldRenderMsg:87-96)。
 *
 * send()/AiAgentRound 会先推空 assistant 占位(content=''),流式文本在独立气泡渲染。
 * 工具轮(本轮 LLM 无文本,只有工具调用)结束后,这些空气泡残留 → 深灰空白长条刷屏。
 * 过滤规则(与桌面一致):
 * - 有 toolCalls → 渲染(工具卡可见)
 * - 有 content/isError → 渲染
 * - 纯空中间条 → 不渲染
 * 注:miniapp 无桌面「末条+流式占位」概念(流式文本走独立 currentText 气泡),故不需该分支。
 */
function shouldRenderMsg(m: ChatMessage): boolean {
  if (
    m.role === 'assistant' &&
    !m.content?.trim() &&
    !m.isError &&
    !(m.toolCalls && m.toolCalls.length)
  ) {
    return false
  }
  return true
}

/**
 * 输入变化时检测联想触发条件。
 *
 * 两类触发:
 * - 技能(`skill`):inputText 以 `/` 开头,且尚未选 pendingSkill(已选则不再触发,
 *   因 chip 已在输入栏,用户输入的是技能参数)→ listSkills() 拉技能 + 显技能浮层。
 * - 实体(`entity`):末尾连续非空白段恰好为单字符 `@`(避免匹配 url/邮箱)→
 *   listEntities() 拉实体 + 显实体浮层。
 *
 * 二者优先级:技能(行首 /)优先于实体(末尾 @),因 `/` 开头整行属技能模式。
 * 已选 pendingSkill 时屏蔽 `/` 重复触发(同上,用户在输入参数)。
 */
/**
 * 输入变化时检测联想触发条件。
 *
 * P2-12 防抖:每按键 detectTrigger 会重复发 listSkills/listEntities,WS 命令+数组
 * splice 换刷每键一次浪费。用 lastMentionFetch 记录最近一次触发的技能/实体拉取,
 * 同一触发类型不重复发(浮层数据已拉到,复用即可)。
 */
let lastMentionFetch: '' | 'skill' | 'entity' = ''
function detectTrigger(): void {
  const text = inputText.value
  if (!text) {
    mentionVisible.value = false
    lastMentionFetch = ''
    return
  }
  // 技能触发:行首 / 且未选技能。空格后(/file xxx)不再是行首 /,自动关闭浮层。
  if (text.startsWith('/') && !pendingSkill.value) {
    mentionTrigger.value = 'skill'
    mentionVisible.value = true
    if (lastMentionFetch !== 'skill') { lastMentionFetch = 'skill'; ai.listSkills() }
    return
  }
  // 实体触发:末尾连续非空白段恰好单字符 @
  const match = text.match(/([^\s]+)\s*$/)
  const last = match ? match[1] : ''
  if (last === '@') {
    mentionTrigger.value = 'entity'
    mentionVisible.value = true
    if (lastMentionFetch !== 'entity') { lastMentionFetch = 'entity'; ai.listEntities() }
    return
  }
  mentionVisible.value = false
  if (lastMentionFetch !== '') lastMentionFetch = ''
}

/** 监听输入:mp-weixin textarea 的 @input 在 v-model 后触发,可直接检测(P3-A input→textarea) */
function onInput(): void {
  detectTrigger()
}

/**
 * 选中技能(技能浮层):置 pendingSkill chip + 清 inputText。
 *
 * 清 inputText 因技能模式语义是「选技能后输参数」,选完即重置输入框等用户输参数文本。
 * 技能参数提示(argument_hint,如 "<url>")随 chip 展示,引导用户输入。
 * 不透传 skill 到 inputText(避免 `/技能名` 文本污染参数输入),skill 仅经 handleSend 透传。
 */
function onSkillSelect(skill: SkillInfoLike): void {
  pendingSkill.value = skill
  inputText.value = ''
  mentionVisible.value = false
}

/**
 * 选中实体(实体浮层):替换末尾 `@` 为 `[类型:名]` 文本 + 累积 MentionSpan。
 *
 * span.start/length:实体插入文本(inputText 中 `[类型:名]` 段)的字符偏移,
 * 后端据 start/length 在原文中切出区间注入对应实体上下文。
 * kind/refId/label 对齐后端 MentionSpanDto:project/task/idea + id + 展示名。
 * 连续 @ 多个实体时累积(每次 push 一条),发送时整体透传。
 */
function onEntitySelect(
  entity: ProjectRecord | TaskRecord | IdeaRecord,
  kind: 'project' | 'task' | 'idea',
): void {
  const text = inputText.value
  // 去末尾触发字符 `@`(detectTrigger 已保证末段是 @,这里兜底 replace 防御)
  const stripped = text.replace(/@\s*$/, '')
  // 类型标签中文映射(对齐桌面端 chip 展示习惯:项目/任务/灵感)
  const kindLabel = kind === 'project' ? '项目' : kind === 'task' ? '任务' : '灵感'
  // 名:project 用 name,task/idea 用 title
  const name = (entity as ProjectRecord).name || (entity as TaskRecord | IdeaRecord).title
  const insertText = `[${kindLabel}:${name}]`
  const start = stripped.length
  inputText.value = stripped + insertText
  // 累积 mention span(字符偏移,后端透传不解释单位)
  pendingMentionSpans.value.push({
    start,
    length: insertText.length,
    kind,
    refId: entity.id,
    label: name,
  })
  mentionVisible.value = false
}

/** 清除已选技能 chip(handleSend 后或用户点 × 清除) */
function clearPendingSkill(): void {
  pendingSkill.value = null
}

/** 关闭弹层(点遮罩) */
function onMentionClose(): void {
  mentionVisible.value = false
}

/**
 * 发送:对齐 useAiChat.send(text, skill?, spans?) 新签名。
 *
 * - skill:已选 pendingSkill 时透传 name(后端注入 SKILL.md);否则 undefined(普通对话)。
 * - spans:有 pendingMentionSpans 时透传(后端 resolve 实体上下文);否则 undefined。
 * - 发送后清 inputText + pendingSkill + pendingMentionSpans,复位待下一次输入。
 *
 * 保留原有守卫:空文本忽略 / generating 中忽略 / 关闭联想浮层。
 */
function handleSend(): void {
  console.log('[v2:send] enter text=', JSON.stringify(inputText.value), 'gen=', generating.value, 'imgs=', pendingImages.value.length)
  const text = inputText.value.trim()
  const hasImage = pendingImages.value.length > 0
  if (!text && !hasImage) {
    console.log('[v2:send] 空文本且无图片,忽略')
    return
  }
  if (generating.value) {
    // 审批挂起/流式生成中后端拒新 send:提示用户(去审批或等待),避免点了没反应困惑
    uni.showToast({
      title: pendingApprovals.value.length > 0 ? '有待审批项,请先处理' : '生成中,请稍候',
      icon: 'none',
    })
    return
  }
  mentionVisible.value = false
  const skill = pendingSkill.value ? pendingSkill.value.name : undefined
  const spans = pendingMentionSpans.value.length > 0 ? pendingMentionSpans.value : undefined
  // 图片输入:有已选图则构造 parts(ContentPart Image base64),无则 undefined 走纯文本零回归
  const parts = hasImage ? pendingImages.value.map((p) => p.part) : undefined
  ai.send(text, skill, spans, undefined, parts)
  inputText.value = ''
  pendingSkill.value = null
  pendingMentionSpans.value = []
  pendingImages.value = []
}

// —— 工具卡片 + 审批 UI(对齐桌面端 ToolCard,适配 mp-weixin 深色风格) ——
// 后端推 AiToolCallStarted/AiApprovalRequired/AiDirAuthRequired → useAiChat 收进
// 消息 m.toolCalls(状态机)+ pendingApprovals。消息内联渲染工具状态 + 待审批按钮,
// miniapp 成轻量审批终端(单一渲染源,对齐桌面 ToolCard 嵌消息气泡)。

// 高危工具白名单(approve 前二次确认,对齐桌面 ToolCard.vue HIGH_RISK_TOOLS)
const HIGH_RISK_TOOLS = new Set<string>([
  'delete_task',
  'delete_project',
  'restore_project',
  'purge_project',
  'delete_file',
  'run_command',
  'http_request',
])

// 工具状态 → 中文文案 + 颜色 class(渲染状态点/标签)
const STATUS_META: Record<string, { label: string; cls: string }> = {
  running: { label: '执行中', cls: 'st-running' },
  completed: { label: '已完成', cls: 'st-completed' },
  failed: { label: '失败', cls: 'st-failed' },
  pending_approval: { label: '待审批', cls: 'st-pending' },
  rejected: { label: '已拒绝', cls: 'st-rejected' },
}

/** 截断长字符串(参数值/结果展示防撑爆气泡) */
function truncate(s: string, max = 120): string {
  return s.length > max ? s.slice(0, max) + '…' : s
}

/** 格式化工具参数为 [{key,val}] 行数组(args 任意结构,最多 4 项防长) */
function formatArgs(args: unknown): { key: string; val: string }[] {
  if (!args || typeof args !== 'object') return []
  const obj = args as Record<string, unknown>
  return Object.entries(obj)
    .filter(([, v]) => v !== undefined && v !== null && v !== '')
    .slice(0, 4)
    .map(([k, v]) => ({
      key: k,
      val: truncate(typeof v === 'string' ? v : JSON.stringify(v)),
    }))
}

/** 格式化执行结果摘要(completed/failed 展示,result 可能任意结构) */
function formatResult(result: unknown): string {
  if (result === undefined || result === null) return ''
  if (typeof result === 'string') return truncate(result)
  try {
    return truncate(JSON.stringify(result))
  } catch {
    return ''
  }
}

/**
 * 解析统一 diff 文本为行数组(P0-2:工具卡 diff 渲染,对齐桌面 parseDiffLines)。
 *
 * 写文件审批的 tc.diff 是行级 diff,按行前缀分类着色:
 * - `+` 新增(绿) / `-` 删除(红) / `@@` 块头(蓝) / 其余上下文(灰)
 * 原生 view 渲染(非 rich-text),滚动/颜色均可用。
 */
type DiffLine = { type: 'add' | 'del' | 'hdr' | 'ctx'; text: string }
function parseDiffLines(diff: string): DiffLine[] {
  return diff.split('\n').map((line) => {
    if (line.startsWith('+')) return { type: 'add', text: line }
    if (line.startsWith('-')) return { type: 'del', text: line }
    if (line.startsWith('@@')) return { type: 'hdr', text: line }
    return { type: 'ctx', text: line }
  })
}

/**
 * 识别后端审批占位 message(对齐 crates/df-ai/context_helpers.rs:498 PENDING_MARKER_PREFIX)。
 *
 * 后端工具挂起审批时往消息流写占位 assistant message,content 形如
 * 「需要用户审批,等待确认__PENDING__:call_xxx」。桌面端识别占位转工具审批卡片;
 * miniapp 不识别会原样渲染成气泡(暴露 __PENDING__ 技术标识)。此处识别后隐藏文本,
 * 审批 UI 由消息内联 m.toolCalls 工具卡片单独渲染(单一渲染源,不重复)。
 */
const PENDING_MARKER = '__PENDING__:'
const PENDING_PLACEHOLDER_LEGACY = '需要用户审批,等待确认'
function isPendingPlaceholder(content: string | undefined | null): boolean {
  if (!content) return false
  if (content.includes(PENDING_MARKER)) return true
  return content === PENDING_PLACEHOLDER_LEGACY
}

/**
 * 识别纯 JSON 工具结果 message(content 是工具执行结果 JSON,如 list_tasks 的
 * {"has_more":false,"items":[],...})。弱化为灰小字折叠,不占主气泡,不刺眼。
 */
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
  }
}

/** 审批通过(risk 类):高危工具先 uni.showModal 二次确认 */
function onApprove(tc: AiToolCallInfo): void {
  if (HIGH_RISK_TOOLS.has(tc.name)) {
    uni.showModal({
      title: '高危操作确认',
      content: `「${tc.name}」属高危操作,确认批准执行?`,
      confirmColor: '#e85a4f',
      success: (r) => {
        if (r.confirm) ai.approve(tc.id, true)
      },
    })
    return
  }
  ai.approve(tc.id, true)
}

/** 审批拒绝(risk 类) */
function onReject(tc: AiToolCallInfo): void {
  ai.approve(tc.id, false)
}

/** 路径授权(path 类:once=本次 / always=永久 / deny=拒绝) */
function onAuthorize(tc: AiToolCallInfo, decision: 'once' | 'always' | 'deny'): void {
  ai.authorizeDir(tc.id, decision)
}

/** 连接状态中文文案(F21:裸枚举字符串 disconnected/connecting 不可读) */
const STATUS_TEXT: Record<string, string> = {
  connecting: '连接中…',
  handshaking: '握手中…',
  reconnecting: '重连中…·点此立即重连',
  disconnected: '已断开·点此重连',
}
const statusText = computed(() => {
  if (isWsConnected.value) {
    return deviceOnline.value ? '已连接桌面端' : '已连接中继'
  }
  return STATUS_TEXT[wsStatus.value] || wsStatus.value
})
const canManualReconnect = computed(
  () => wsStatus.value === 'disconnected' || wsStatus.value === 'reconnecting',
)
function onStatusTap(): void {
  if (canManualReconnect.value) ai.resumeIfDisconnected()
}

/** 停止生成(wires F9 终态兜底;生成中 send 禁用无停止入口,用户无法中断) */
function onStop(): void {
  ai.stop()
}

/** 继续循环(P1-E:MaxRounds 挂起后再跑一轮) */
function onContinueLoop(): void {
  ai.continueLoop()
}

/** 停止循环(P1-E:MaxRounds 挂起后彻底终止多轮 agent) */
function onStopLoop(): void {
  ai.stopLoop()
}

/** 重发最后一条 user 消息(P1-F:regenerate 入口) */
function onRegenerate(): void {
  ai.regenerate()
}

/** 长按消息复制全文(P1-F:assistant 复制 markdown 源,user 复制原文) */
function onCopyMessage(m: { content?: string }): void {
  if (!m.content) return
  uni.setClipboardData({
    data: m.content,
    success: () => uni.showToast({ title: '已复制', icon: 'none' }),
  })
}

/**
 * 输入 token 计算(对齐桌面 tokenInOf,MessageList.vue:116-120)。
 * cache_miss 存在直接用;否则(provider 不报 cache_miss,如 GLM)回退 prompt-cache_hit
 * (全价真实输入),两者都无才 fallback prompt。防顶栏 in 为空。
 */
function tokenInOf(t: TokenUsage): number {
  if (t.cache_miss !== undefined && t.cache_miss > 0) return t.cache_miss
  if (t.cache_hit !== undefined && t.prompt !== undefined) {
    return Math.max(0, t.prompt - t.cache_hit)
  }
  return t.prompt
}
</script>

<template>
  <view class="page">
    <!-- 状态栏 + 待审批徽标(F21:断开/重连中可点状态栏手动重连) -->
    <view class="top">
      <text class="st" :class="{ 'st-tap': canManualReconnect }" @tap="onStatusTap">{{ statusText }}</text>
      <view class="top-right">
        <!-- 每会话独立模型:模型选择器(点击弹 ActionSheet 选模型) -->
        <text class="model-select" @tap="onModelSelect">🧠 {{ currentModelLabel }}</text>
        <text v-if="tokenUsage" class="token-usage">
        🪙 {{ tokenUsage.total }}
        <text v-if="tokenUsage.cache_miss !== undefined || tokenUsage.cache_hit !== undefined || tokenUsage.reasoning !== undefined" class="token-breakdown">
          <text class="token-in">in:{{ tokenInOf(tokenUsage) }}</text>
          <text v-if="tokenUsage.cache_hit !== undefined" class="token-cache">cache:{{ tokenUsage.cache_hit }}</text>
          <text v-if="tokenUsage.reasoning !== undefined" class="token-think">思考:{{ tokenUsage.reasoning }}</text>
          <text class="token-out">out:{{ tokenUsage.completion }}</text>
        </text>
      </text>
        <view v-if="pendingApprovals.length > 0" class="pending-badge">
          <text>⏳ {{ pendingApprovals.length }} 待审批</text>
        </view>
      </view>
    </view>

    <!-- 达最大轮次面板(P1-E:继续/停止循环,对齐桌面 MaxRounds) -->
    <view v-if="maxRoundsActive" class="maxrounds-panel">
      <text class="maxrounds-text">⚠ 已达最大轮次</text>
      <view class="maxrounds-actions">
        <button class="approval-btn approve" size="mini" @tap="onContinueLoop">继续一轮</button>
        <button class="approval-btn reject" size="mini" @tap="onStopLoop">停止</button>
      </view>
    </view>

    <!-- 待审批面板(从 pendingApprovals 渲染,与 messages 解耦:req3 重连 load_messages 替换 messages 不影响审批卡) -->
    <view v-if="pendingApprovals.length > 0" class="pending-panel">
      <view class="pending-panel-head">
        <text>⏳ {{ pendingApprovals.length }} 项待审批</text>
      </view>
      <view v-for="p in pendingApprovals" :key="p.id" class="tool-card">
        <view class="tool-head">
          <text class="tool-name">🔧 {{ p.name }}</text>
          <text class="tool-status st-pending">待审批</text>
        </view>
        <view v-if="formatArgs(p.args).length > 0" class="tool-args">
          <view v-for="a in formatArgs(p.args)" :key="a.key" class="tool-arg">
            <text class="tool-arg-key">{{ a.key }}:</text>
            <text class="tool-arg-val">{{ a.val }}</text>
          </view>
        </view>
        <view v-if="p.kind === 'path' && p.dir" class="tool-dir">
          <text>📁 {{ p.dir }}</text>
        </view>
        <view v-if="p.reason" class="tool-reason">
          <text>⚠ {{ p.reason }}</text>
        </view>
        <!-- risk 类:批准/拒绝 -->
        <view v-if="p.kind !== 'path'" class="approval-actions">
          <button class="approval-btn approve" size="mini" @tap="onApprove(p)">批准</button>
          <button class="approval-btn reject" size="mini" @tap="onReject(p)">拒绝</button>
        </view>
        <!-- path 类:本次/永久/拒绝 -->
        <view v-else class="approval-actions">
          <button class="approval-btn approve" size="mini" @tap="onAuthorize(p, 'once')">本次</button>
          <button class="approval-btn always" size="mini" @tap="onAuthorize(p, 'always')">永久</button>
          <button class="approval-btn reject" size="mini" @tap="onAuthorize(p, 'deny')">拒绝</button>
        </view>
      </view>
    </view>

    <!-- 消息列表(scroll-view 标准滚动,纯 text 渲染) -->
    <scroll-view class="list" scroll-y :scroll-into-view="scrollAnchor" :scroll-with-animation="true">
      <!-- 加载中骨架 → 切会话/冷启动占位 -->
      <view v-if="loading" class="loading-skeleton">
        <view class="sk-item"><view class="sk-line sk-w80"></view></view>
        <view class="sk-item"><view class="sk-line sk-w60"></view><view class="sk-line sk-w90"></view></view>
        <view class="sk-item"><view class="sk-line sk-w70"></view><view class="sk-line sk-w50"></view></view>
      </view>
      <view v-else-if="messages.length === 0" class="empty">
        <text>暂无消息</text>
      </view>
      <template v-for="m in visibleMessages" :key="m.id">
        <view v-if="shouldRenderMsg(m)" class="msg" :class="[m.role, m.isError ? 'msg-error' : '']" @longpress="onCopyMessage(m)">
        <view v-if="m.role === 'user'">
          <!-- 图片预览(消息带图时) -->
          <image v-for="(img, i) in m.images || []" :key="i" :src="img" mode="widthFix" class="msg-user-img" />
          <!-- 文本(有文本才显,图片+文本共存;纯图片则只有图,不空白) -->
          <text v-if="m.content" user-select>{{ m.content }}</text>
        </view>
        <template v-else>
          <!-- P0-3:错误气泡纯文本渲染(不走 markdown 二次解析,错误串含 `**`/`#` 时不会被解析成格式) -->
          <text v-if="m.isError" user-select>{{ m.content }}</text>
          <!-- 后端审批占位 message(「需要…__PENDING__:call_xxx」)隐藏文本,审批走工具卡片 -->
          <rich-text
            v-else-if="m.content && !isPendingPlaceholder(m.content) && !isToolResultJson(m.content)"
            :nodes="renderMd(m.content)"
            selectable
          />
          <!-- 纯 JSON 工具结果弱化(灰小字折叠,不占主气泡) -->
          <view v-else-if="m.content && isToolResultJson(m.content)" class="msg-toolresult">
            <text>📋 {{ m.content }}</text>
          </view>
          <!-- 占位 message(isPendingPlaceholder)不渲染文本,仅下方工具卡片承载审批 UI -->
          <!-- 工具调用卡片(对齐桌面 ToolCard,assistant 消息内联渲染状态 + 审批按钮) -->
          <view v-for="tc in m.toolCalls || []" :key="tc.id" class="tool-card">
            <view class="tool-head">
              <text class="tool-name">🔧 {{ tc.name }}</text>
              <text class="tool-status" :class="(STATUS_META[tc.status] || {}).cls">
                {{ (STATUS_META[tc.status] || {}).label || tc.status }}
              </text>
            </view>
            <!-- 参数 -->
            <view v-if="formatArgs(tc.args).length > 0" class="tool-args">
              <view v-for="a in formatArgs(tc.args)" :key="a.key" class="tool-arg">
                <text class="tool-arg-key">{{ a.key }}:</text>
                <text class="tool-arg-val">{{ a.val }}</text>
              </view>
            </view>
            <!-- path 授权待授权目录 -->
            <view v-if="tc.kind === 'path' && tc.status === 'pending_approval' && tc.dir" class="tool-dir">
              <text>📁 {{ tc.dir }}</text>
            </view>
            <!-- 风险说明 -->
            <view v-if="tc.reason" class="tool-reason">
              <text>⚠ {{ tc.reason }}</text>
            </view>
            <!-- P0-2:写文件审批 diff 行级渲染(红删/绿增/蓝块头,对齐桌面行级 diff 预览)。
                 pending_approval 时给审批提供变更内容,completed 时展示落盘结果。 -->
            <scroll-view v-if="tc.diff" class="tool-diff" scroll-y>
              <view v-for="(dl, i) in parseDiffLines(tc.diff)" :key="i" class="tool-diff-line" :class="dl.type">
                <text>{{ dl.text }}</text>
              </view>
            </scroll-view>
            <!-- 审批按钮已移至顶部 pendingApprovals 面板(与 messages 解耦,req3 重连恢复) -->
            <!-- 执行结果摘要(completed/failed) -->
            <view
              v-if="(tc.status === 'completed' || tc.status === 'failed') && formatResult(tc.result)"
              class="tool-result"
            >
              <text>{{ formatResult(tc.result) }}</text>
            </view>
          </view>
          <!-- P0-3:错误气泡操作行(重试 = 重发最后一条 user,对齐桌面错误气泡重试入口) -->
          <view v-if="m.isError" class="msg-actions">
            <button class="msg-retry-btn" size="mini" @tap="onRegenerate">重试</button>
          </view>
        </template>
      </view>
      </template>
      <!-- 流式生成中的当前文本(rich-text 流式 markdown 渲染,块级 memo 防全文重 parse) -->
      <view v-if="generating && streamRenderText" class="msg assistant">
        <rich-text :nodes="renderStreamingMd(streamRenderText)" selectable />
      </view>
      <!-- P1-12 滚底锚点(scroll-into-view 目标) -->
      <view id="list-bottom-anchor"></view>
    </scroll-view>

    <!-- 联想弹层(技能 / 与 @ 实体复用同一浮层结构,detectTrigger 决定 trigger) -->
    <view v-if="mentionVisible && !generating" class="mention-mask" @tap="onMentionClose">
      <view class="mention-pop" @tap.stop>
        <view class="mention-head">
          <text class="mention-title">{{ mentionTrigger === 'skill' ? '技能' : '提及' }}</text>
          <text class="mention-hint">点击选中</text>
        </view>
        <scroll-view class="mention-list" scroll-y>
          <!-- 技能列表(`/` 触发,name/description/argument_hint 展示) -->
          <template v-if="mentionTrigger === 'skill'">
            <view v-if="skills.length === 0" class="mention-empty">
              <text>加载中或无技能...</text>
            </view>
            <view
              v-for="(s, idx) in skills"
              :key="'sk-' + idx"
              class="mention-item"
              @tap="onSkillSelect(s)"
            >
              <view class="mention-item-main">
                <text class="mention-item-label">/{{ s.name }}</text>
                <text class="mention-item-source">{{ s.source }}</text>
              </view>
              <text class="mention-item-desc">{{ s.description }}</text>
              <text v-if="s.argument_hint" class="mention-item-insert">参数:{{ s.argument_hint }}</text>
            </view>
          </template>
          <!-- 实体列表(`@` 触发,按 项目/任务/灵感 分组) -->
          <template v-else>
            <view v-if="entities.projects.length === 0 && entities.tasks.length === 0 && entities.ideas.length === 0" class="mention-empty">
              <text>加载中或无数据...</text>
            </view>
            <view v-if="entities.projects.length > 0" class="mention-group-title">
              <text>项目</text>
            </view>
            <view
              v-for="(p, idx) in entities.projects"
              :key="'p-' + idx"
              class="mention-item"
              @tap="onEntitySelect(p, 'project')"
            >
              <text class="mention-item-label">{{ p.name }}</text>
              <text class="mention-item-desc">{{ p.description }}</text>
            </view>
            <view v-if="entities.tasks.length > 0" class="mention-group-title">
              <text>任务</text>
            </view>
            <view
              v-for="(t, idx) in entities.tasks"
              :key="'t-' + idx"
              class="mention-item"
              @tap="onEntitySelect(t, 'task')"
            >
              <text class="mention-item-label">{{ t.title }}</text>
              <text class="mention-item-desc">{{ t.description }}</text>
            </view>
            <view v-if="entities.ideas.length > 0" class="mention-group-title">
              <text>灵感</text>
            </view>
            <view
              v-for="(i, idx) in entities.ideas"
              :key="'i-' + idx"
              class="mention-item"
              @tap="onEntitySelect(i, 'idea')"
            >
              <text class="mention-item-label">{{ i.title }}</text>
              <text class="mention-item-desc">{{ i.description }}</text>
            </view>
          </template>
        </scroll-view>
      </view>
    </view>

    <!-- 输入 + 发送(原生 button @tap) -->
    <view class="bar">
      <!-- 已选图片预览(选图后显示,缩略图 + ×删除) -->
      <view v-if="pendingImages.length > 0" class="img-preview-row">
        <view v-for="(img, idx) in pendingImages" :key="idx" class="img-preview-item">
          <image :src="img.tempPath" mode="aspectFill" class="img-preview-thumb" />
          <text class="img-preview-close" @tap="removeImage(idx)">×</text>
        </view>
      </view>
      <view class="bar-row">
        <!-- 选图按钮(左侧 +,mp-weixin button 默认 block 需样式收敛) -->
        <button class="img-pick-btn" @tap="onPickImage">＋</button>
        <!-- pendingSkill chip(选中技能后展示名+参数提示,× 清除) -->
        <view v-if="pendingSkill" class="skill-chip">
          <text class="skill-chip-name">/{{ pendingSkill.name }}</text>
          <text v-if="pendingSkill.argument_hint" class="skill-chip-hint">{{ pendingSkill.argument_hint }}</text>
          <text class="skill-chip-close" @tap="clearPendingSkill">×</text>
        </view>
        <textarea
          v-model="inputText"
          class="ipt"
          :placeholder="generating ? '生成中,完成后发送…' : pendingSkill ? '输入技能参数' : '输入消息(输入 / 或 @ 触发联想)'"
          :adjust-position="true"
          :disable-default-padding="true"
          :cursor-spacing="8"
          confirm-type="send"
          @confirm="handleSend"
          @input="onInput"
        />
        <!-- 生成中显停止按钮(wires F9),否则发送。无常驻"重发"按钮(对齐桌面:regenerate 仅
            错误气泡"重试"入口,onRegenerate 已由 P0-3 错误气泡重试按钮复用) -->
        <button v-if="generating" class="send-btn stop-btn" @tap="onStop">停止</button>
        <button v-else class="send-btn" @tap="handleSend">发送</button>
      </view>
    </view>
  </view>
</template>

<style scoped>
.page {
  display: flex;
  flex-direction: column;
  height: 100vh;
  background-color: #0f0f0f;
  overflow-x: hidden; /* 防内容(长URL/表格)撑开页面级水平滚动条 */
}
.top {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 12px;
  background-color: #1a1a1a;
}
.st {
  color: #999999;
  font-size: 12px;
}
.st-tap {
  color: #4a9eff;
}
.top-right {
  display: flex;
  flex-direction: row;
  align-items: center;
}
.token-usage {
  color: #777777;
  font-size: 11px;
  margin-right: 8px;
}
/* 每会话独立模型:顶栏模型选择器 chip(点击弹 ActionSheet) */
.model-select {
  color: #6bb6ff;
  font-size: 11px;
  margin-right: 8px;
  padding: 2px 8px;
  background-color: #1f3a5f;
  border: 1px solid #4a9eff;
  border-radius: 10px;
}
.token-breakdown {
  display: inline;
  margin-left: 4px;
}
.token-in {
  color: #777777;
  font-size: 10px;
  margin-left: 4px;
}
.token-cache {
  color: #6bd99a;
  font-size: 10px;
  margin-left: 4px;
}
.token-think {
  color: #ccaa88;
  font-size: 10px;
  margin-left: 4px;
}
.token-out {
  color: #6bb6ff;
  font-size: 10px;
  margin-left: 4px;
}
.list {
  flex: 1;
  min-height: 0;
  padding: 12px;
  overflow-x: hidden; /* 消息区只纵向滚,横向内容裁剪防水平滚动条 */
}
.empty {
  text-align: center;
  padding: 80px 0;
}
.empty text {
  color: #666666;
  font-size: 13px;
}
.msg {
  margin-bottom: 12px;
  padding: 10px 14px;
  border-radius: 10px;
  background-color: #2a2a2a;
  overflow: hidden; /* 气泡内超宽内容(长URL/表格)裁剪,防撑开消息区横向滚动 */
  word-break: break-all; /* 长单词/URL 强制断行(rich-text 内容不继承外层文本换行) */
}
.msg text {
  color: #e0e0e0;
  font-size: 14px;
  line-height: 1.5;
  word-break: break-all; /* 直接 text 子元素(user/error)长 URL 断行,防撑开 */
}
.msg.user {
  background-color: #4a9eff;
}
.msg.user text {
  color: #ffffff;
}
/* 用户消息图片预览(2026-08-06):120px 宽 + 圆角,与气泡风格一致;mode=widthFix 高自适应 */
.msg-user-img {
  display: block;
  width: 120px;
  border-radius: 6px;
  margin-bottom: 6px;
}
.msg.system text {
  color: #999999;
  font-size: 12px;
}
/* P0-3:错误气泡(红底红边,与正常回复区分;纯文本渲染不走 markdown) */
.msg.msg-error {
  background-color: #3a1f1f;
  border: 1px solid #e85a4f;
}
.msg.msg-error > text {
  color: #ff8a80;
}
/* 错误气泡操作行:重试按钮 */
.msg-actions {
  margin-top: 8px;
}
.msg-retry-btn {
  display: inline-block;
  margin: 0;
  padding: 0 20rpx;
  height: 52rpx;
  line-height: 52rpx;
  font-size: 24rpx;
  color: #e85a4f;
  background-color: #2a2a2a;
  border: 1px solid #e85a4f;
  border-radius: 8rpx;
}
.bar {
  flex-shrink: 0;
  display: flex;
  flex-direction: column; /* 预览行 + 输入行(2026-08-05 图片输入:已选图预览在上,输入行在下) */
  padding: 8px 12px;
  background-color: #1a1a1a;
}
.bar-row {
  display: flex;
  align-items: flex-end; /* 输入行:选图按钮/技能chip/输入框/发送按钮底部对齐 */
}
/* 图片输入预览(2026-08-05) */
.img-preview-row {
  display: flex;
  flex-direction: row;
  padding-bottom: 8px;
}
.img-preview-item {
  position: relative;
  margin-right: 8px;
}
.img-preview-thumb {
  width: 64px;
  height: 64px;
  border-radius: 6px;
  background-color: #2a2a2a;
}
.img-preview-close {
  position: absolute;
  top: -6px;
  right: -6px;
  width: 18px;
  height: 18px;
  line-height: 18px;
  text-align: center;
  font-size: 12px;
  color: #ffffff;
  background-color: rgba(0, 0, 0, 0.6);
  border-radius: 50%;
}
.img-pick-btn {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  margin: 0 6px 0 0;
  padding: 0;
  font-size: 20px;
  color: #e0e0e0;
  background-color: #2a2a2a;
  border: 1px solid #3a3a3a;
  border-radius: 6px;
  box-sizing: border-box;
  line-height: 1;
}
/* mp-weixin button 默认 ::after 边框去掉,防 img-pick-btn 双框 */
.img-pick-btn::after {
  border: none;
}
.ipt {
  flex: 1;
  background-color: #2a2a2a;
  color: #e0e0e0;
  padding: 8px 12px;
  min-height: 36px;
  height: 36px; /* 去掉 auto-height 后固定高度(聚焦不再重算变高);超长内容 max-height 内滚动 */
  max-height: 120px;
  /* BUG-260805-03:mp-weixin textarea 默认边框(1px)撑高,与按钮 36px 高度不一致。
     补 border:none 消边框差;box-sizing 已在(见下),min-height 含 padding 与按钮语义对齐。 */
  border: none;
  box-sizing: border-box; /* min-height 含 padding,与 send-btn(36px border-box) 语义对齐 */
  overflow-y: auto;
  border-radius: 6px;
  font-size: 14px;
  line-height: 20px;
}
.send-btn {
  display: inline-block; /* mp-weixin button 默认 block,flex 里会占满/拉伸 */
  flex-shrink: 0; /* 防输入框 flex 挤压按钮 */
  margin: 0 0 0 8px;
  height: 36px; /* 对齐输入框 min-height(36px),视觉齐平 */
  line-height: 34px; /* height-2px 边框余量,文字垂直居中 */
  padding: 0 16px;
  background-color: #4a9eff;
  color: #ffffff;
  font-size: 14px;
  border-radius: 6px; /* 与输入框圆角一致 */
  border: none;
  box-sizing: border-box; /* padding 不撑高,保 36px */
}
/* mp-weixin button 默认 ::after 边框(1px 淡边框)去掉,防 send-btn 双框 */
.send-btn::after {
  border: none;
}
.send-btn.stop-btn {
  background-color: #5a2a2a;
  color: #e85a4f;
  border: 1px solid #e85a4f; /* 停止按钮红框,与普通发送区分 */
}
/* @/ 联想弹层(内联自 MentionInput,绕过工具组件解析 bug) */
.mention-mask {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  top: 0;
  background-color: rgba(0, 0, 0, 0.3);
  z-index: 999;
}
.mention-pop {
  position: absolute;
  left: 12px;
  right: 12px;
  bottom: 56px;
  max-height: 260px;
  background-color: #1f1f1f;
  border: 1px solid #333333;
  border-radius: 10px;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}
.mention-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 12px;
  background-color: #262626;
  border-bottom: 1px solid #2e2e2e;
}
.mention-title {
  color: #e0e0e0;
  font-size: 13px;
  font-weight: bold;
}
.mention-hint {
  color: #777777;
  font-size: 11px;
}
.mention-list {
  max-height: 220px;
}
.mention-item {
  display: flex;
  flex-direction: column;
  padding: 10px 12px;
  border-bottom: 1px solid #2a2a2a;
}
.mention-item-main {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
}
.mention-item-label {
  color: #e0e0e0;
  font-size: 14px;
}
.mention-item-source {
  color: #777777;
  font-size: 10px;
  margin-left: 8px;
}
.mention-item-desc {
  color: #999999;
  font-size: 12px;
  margin-top: 2px;
}
.mention-item-insert {
  color: #4a9eff;
  font-size: 12px;
  margin-top: 2px;
}
.mention-empty {
  padding: 24px 12px;
  text-align: center;
}
.mention-empty text {
  color: #666666;
  font-size: 12px;
}
.mention-group-title {
  padding: 6px 12px;
  background-color: #232323;
  border-bottom: 1px solid #2a2a2a;
}
.mention-group-title text {
  color: #888888;
  font-size: 11px;
  font-weight: bold;
}
/* pendingSkill chip(选中技能后展示名+参数提示+× 清除) */
.skill-chip {
  flex-shrink: 0;
  display: flex;
  flex-direction: row;
  align-items: center;
  padding: 0 8px;
  height: 28px;
  margin-right: 6px;
  background-color: #1f3a5f;
  border: 1px solid #4a9eff;
  border-radius: 14px;
}
.skill-chip-name {
  color: #4a9eff;
  font-size: 12px;
}
.skill-chip-hint {
  color: #7799bb;
  font-size: 10px;
  margin-left: 6px;
}
.skill-chip-close {
  color: #4a9eff;
  font-size: 16px;
  margin-left: 6px;
  padding: 0 2px;
}
/* 工具卡片 + 审批 UI(对齐桌面 ToolCard,深色风格) */
.pending-badge {
  flex-shrink: 0;
  margin: 0 8px;
  padding: 2px 8px;
  background-color: #3a2f1f;
  border: 1px solid #f0c75e;
  border-radius: 10px;
}
.pending-badge text {
  color: #f0c75e;
  font-size: 11px;
}
/* 待审批面板(从 pendingApprovals 渲染,与 messages 解耦避重连竞态) */
.pending-panel {
  flex-shrink: 0;
  max-height: 40vh;
  overflow-y: auto;
  padding: 4px 12px 8px;
  background-color: #1a1a1a;
  border-bottom: 1px solid #2a2a2a;
}
.pending-panel-head {
  padding: 4px 0;
}
.pending-panel-head text {
  color: #f0c75e;
  font-size: 12px;
  font-weight: bold;
}
/* 达最大轮次面板(P1-E:继续/停止循环) */
.maxrounds-panel {
  flex-shrink: 0;
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
  padding: 8px 12px;
  background-color: #2a2218;
  border-bottom: 1px solid #3a2f1f;
}
.maxrounds-text {
  color: #f0c75e;
  font-size: 13px;
}
.maxrounds-actions {
  display: flex;
  flex-direction: row;
}
.tool-card {
  margin-top: 8px;
  padding: 8px 10px;
  background-color: #1f1f1f;
  border: 1px solid #333333;
  border-radius: 8px;
}
.tool-head {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
}
.tool-name {
  color: #e0e0e0;
  font-size: 13px;
  font-weight: bold;
}
.tool-status {
  padding: 1px 6px;
  border-radius: 8px;
  font-size: 10px;
}
.tool-status.st-running {
  background-color: #2a3a5a;
  color: #6bb6ff;
}
.tool-status.st-completed {
  background-color: #1f3a2a;
  color: #6bd99a;
}
.tool-status.st-failed {
  background-color: #3a1f1f;
  color: #e85a4f;
}
.tool-status.st-pending {
  background-color: #3a2f1f;
  color: #f0c75e;
}
.tool-status.st-rejected {
  background-color: #2a2a2a;
  color: #999999;
}
.tool-args {
  margin-top: 6px;
}
.tool-arg {
  display: flex;
  flex-direction: row;
  margin-top: 2px;
}
.tool-arg-key {
  color: #7799bb;
  font-size: 12px;
  margin-right: 4px;
}
.tool-arg-val {
  color: #c0c0c0;
  font-size: 12px;
  flex: 1;
  word-break: break-all; /* 工具参数值(路径/长字符串)断行防撑开 */
}
.tool-dir {
  margin-top: 4px;
}
.tool-dir text {
  color: #6bd99a;
  font-size: 12px;
}
.tool-reason {
  margin-top: 6px;
  padding: 4px 6px;
  background-color: #2a2218;
  border-radius: 4px;
}
.tool-reason text {
  color: #f0c75e;
  font-size: 12px;
}
.approval-actions {
  display: flex;
  flex-direction: row;
  margin-top: 8px;
}
.approval-btn {
  margin-right: 6px;
  padding: 0 12px;
  height: 30px;
  line-height: 30px;
  font-size: 12px;
  border-radius: 6px;
}
.approval-btn.approve {
  background-color: #2a5a3a;
  color: #6bd99a;
}
.approval-btn.always {
  background-color: #2a3a5a;
  color: #6bb6ff;
}
.approval-btn.reject {
  background-color: #5a2a2a;
  color: #e85a4f;
  margin-right: 0;
}
.tool-result {
  margin-top: 6px;
  padding: 4px 6px;
  background-color: #181818;
  border-radius: 4px;
}
.tool-result text {
  color: #999999;
  font-size: 11px;
}
/* P0-2:写文件审批 diff(行级红删绿增蓝块头,scroll-view 限高可滚) */
.tool-diff {
  margin-top: 6px;
  max-height: 200px;
  background-color: #121212;
  border-radius: 4px;
  box-sizing: border-box;
}
.tool-diff-line {
  padding: 0 6px;
  line-height: 18px;
}
.tool-diff-line text {
  font-family: monospace;
  font-size: 11px;
  word-break: break-all;
}
.tool-diff-line.add {
  background-color: rgba(107, 217, 154, 0.08);
}
.tool-diff-line.add text {
  color: #6bd99a;
}
.tool-diff-line.del {
  background-color: rgba(232, 90, 79, 0.08);
}
.tool-diff-line.del text {
  color: #e85a4f;
}
.tool-diff-line.hdr text {
  color: #6bb6ff;
}
.tool-diff-line.ctx text {
  color: #999999;
}
/* 纯 JSON 工具结果 message 弱化(灰小字折叠,避免刺眼占用主气泡) */
.msg-toolresult {
  margin-top: 4px;
  padding: 6px 8px;
  background-color: #181818;
  border-radius: 6px;
  max-height: 80px;
  overflow: hidden;
  word-break: break-all; /* 长 JSON 强制断行,防撑开气泡(真机水平滚动条根因之一) */
}
.msg-toolresult text {
  color: #b0b0b0;
  font-size: 11px;
}
/* 骨架屏(切会话/冷启动加载占位,闪烁动画模拟内容) */
.loading-skeleton {
  padding: 12px 0;
}
.sk-line {
  display: block;
  height: 12px;
  margin: 6px 0;
  background: linear-gradient(90deg, #2a2a2a 25%, #333333 50%, #2a2a2a 75%);
  background-size: 200% 100%;
  animation: sk-shimmer 1.5s infinite;
  border-radius: 6px;
}
.sk-w80 { width: 80% }
.sk-w90 { width: 90% }
.sk-w60 { width: 60% }
.sk-w70 { width: 70% }
.sk-w50 { width: 50% }
.sk-item {
  margin-bottom: 16px;
  padding: 10px 14px;
  border-radius: 10px;
  background-color: #2a2a2a;
}
@keyframes sk-shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}
</style>
