<script setup lang="ts">
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { useAuthStore, useUserStore } from '@/stores'
import { getCurrentLocale } from '@/plugins/i18n'
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
import AgentMarkdownContent from './AgentMarkdownContent.vue'

type AgentMessageRole = 'user' | 'assistant'
type AgentMessageStatus = 'idle' | 'streaming' | 'done' | 'error'
type AgentAttachmentKind = 'audio' | 'file' | 'image'
type AgentChoiceStatus = 'pending' | 'selected' | 'expired'
type InfiniteScrollSide = 'start' | 'end' | 'both'
type InfiniteScrollStatus = 'empty' | 'error' | 'loading' | 'ok'

interface AgentToolCall {
  id: string
  message: string
  status: 'running' | 'done'
}

interface AgentMessageTextSegment {
  type: 'text'
  content: string
}

interface AgentMessageToolSegment {
  type: 'tool'
  toolIndex: number
}

type AgentMessageSegment = AgentMessageTextSegment | AgentMessageToolSegment

type AgentRenderableMessageSegment =
  (AgentMessageTextSegment & { key: string }) | { type: 'tool'; key: string; tool: AgentToolCall }

interface AgentMessageAttachment {
  kind: AgentAttachmentKind
  url: string
  download_url?: string
  name?: string
  mime_type?: string
  size?: number
  local_path?: string
}

interface AgentChoiceButton {
  label: string
  callback_data: string
  description?: string
}

interface AgentChoiceCard {
  id: string
  title?: string
  prompt: string
  buttons: AgentChoiceButton[]
  button_rows?: AgentChoiceButton[][]
  status: AgentChoiceStatus
  selected_label?: string
  selected_value?: string
  selected_description?: string
}

interface AgentChoiceSelection {
  choice_id: string
  title?: string
  prompt: string
  buttons: AgentChoiceButton[]
  button_rows?: AgentChoiceButton[][]
  selected_label?: string
  selected_value?: string
  selected_description?: string
}

interface AgentChatMessage {
  id: string
  role: AgentMessageRole
  content: string
  createdAt: number
  status: AgentMessageStatus
  tools: AgentToolCall[]
  segments: AgentMessageSegment[]
  attachments: AgentMessageAttachment[]
  choices: AgentChoiceCard[]
  choice_selection?: AgentChoiceSelection
}

interface AgentSessionHistoryItem {
  sessionId: string
  clientSessionId?: string
  title: string
  preview?: string
  channel?: string
  source?: string
  isProcessing?: boolean
  createdAt: number
  updatedAt: number
  messages: AgentChatMessage[]
}

interface AgentStreamEvent {
  type: 'start' | 'delta' | 'tool' | 'attachment' | 'choice' | 'message_update' | 'done' | 'error'
  attachment?: AgentMessageAttachment
  choice?: Omit<AgentChoiceCard, 'status'>
  content?: string
  message?: string
  message_i18n?: string
  message_id?: string
  target_message?: Partial<AgentChatMessage> & { id?: string }
  session_id?: string
}

interface AgentPendingAttachment {
  id: string
  file: File
  kind: AgentAttachmentKind
  name: string
  mime_type: string
  size: number
  preview_url?: string
}

interface AgentOutgoingFile {
  ref: string
  name?: string
  mime_type?: string
  size?: number
  local_path?: string
  status?: string
}

interface PreparedAgentAttachments {
  images: string[]
  files: AgentOutgoingFile[]
  audioRefs: string[]
  userAttachments: AgentMessageAttachment[]
}

interface AgentStreamMessageOptions {
  echoUser?: boolean
  displayText?: string
  choiceSelection?: AgentChoiceSelection
  originalMessageId?: string
  originalChatId?: string
}

interface AgentPendingStreamRecovery {
  sessionId: string
  startedAt: number
  attempts: number
}

interface AgentStreamReadResult {
  receivedTerminalEvent: boolean
}

interface AgentSlashCommand {
  command: string
  description: string
  category?: string
  type?: string
  pid?: string
}

interface AgentServerSession {
  session_id: string
  client_session_id?: string
  title?: string
  preview?: string
  channel?: string
  source?: string
  created_at?: string
  updated_at?: string
  is_processing?: boolean
  messages?: AgentChatMessage[]
}

const { t } = useI18n()
const display = useDisplay()
const authStore = useAuthStore()
const userStore = useUserStore()

const props = withDefaults(
  defineProps<{
    modelValue?: boolean
    /** 是否允许面板的装饰性动效，不影响消息流、thinking 或输入反馈。 */
    motionActive?: boolean
  }>(),
  {
    modelValue: false,
    motionActive: true,
  },
)

const emit = defineEmits<{
  'assistant-preview': [value: string]
  'thinking-change': [value: boolean]
  'update:modelValue': [value: boolean]
}>()

const STORAGE_KEY = 'moviepilot-agent-assistant-state'
const HISTORY_STORAGE_KEY = 'moviepilot-agent-assistant-history'
const HISTORY_PAGE_SIZE = 30
const MAX_LOCAL_HISTORY_SESSIONS = 120
const MAX_PERSISTED_MESSAGES = 30
const HISTORY_TITLE_LENGTH = 36
const HISTORY_ITEM_HEIGHT = 76
const MESSAGE_SCROLL_FOLLOW_THRESHOLD = 96
const STREAM_STATE_PERSIST_DELAY = 1000

const inputText = ref('')
const messages = ref<AgentChatMessage[]>([])
const historySessions = ref<AgentSessionHistoryItem[]>([])
const sessionId = ref('')
const sending = ref(false)
const isComposing = ref(false)
const streamError = ref('')
const historyMenuOpen = ref(false)
const messageListRef = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLTextAreaElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const pendingAttachments = ref<AgentPendingAttachment[]>([])
const recording = ref(false)
const recordingStartedAt = ref(0)
const recordingDuration = ref(0)
const historyLoading = ref(false)
const historyLoadingMore = ref(false)
const historyPage = ref(1)
const historyHasMore = ref(true)
const fullscreen = ref(false)
const slashCommands = ref<AgentSlashCommand[]>([])
const slashCommandsLoading = ref(false)
const slashCommandsLoaded = ref(false)
const pendingStreamRecovery = ref<AgentPendingStreamRecovery | null>(null)
let abortController: AbortController | null = null
let mediaRecorder: MediaRecorder | null = null
let mediaRecorderStream: MediaStream | null = null
let recordingTimer: number | null = null
let recordingChunks: BlobPart[] = []
let messageScrollFrame: number | null = null
let pendingMessageScrollToBottom = false
let streamPersistTimer: number | null = null
let streamPersistLastRunAt = 0
let messageScrollerShouldFollow = true
let streamDeltaFrame: number | null = null
let pendingStreamDelta = ''
let pendingStreamDeltaMessage: AgentChatMessage | null = null
let userAbortRequested = false
let streamRecoveryAbortRequested = false
let streamRecoveryTimer: number | null = null
let activeStreamStartedAt = 0

// 汇总实时请求与后台恢复状态，保证恢复期间仍展示处理中并锁定会话操作。
const isBusy = computed(() => sending.value || Boolean(pendingStreamRecovery.value))
const canSend = computed(
  () => (inputText.value.trim().length > 0 || pendingAttachments.value.length > 0) && !isBusy.value && !recording.value,
)
const canRecord = computed(() => !isBusy.value && !recording.value)
// 获取当前输入对应的斜杠命令查询词。
const slashCommandQuery = computed(() => {
  const text = inputText.value.trimStart()
  if (!text.startsWith('/')) return ''

  return text.slice(1).toLowerCase()
})
// 根据输入内容过滤可用斜杠命令。
const filteredSlashCommands = computed(() => {
  const query = slashCommandQuery.value
  if (!query) return slashCommands.value

  return slashCommands.value.filter(command => {
    const haystack = `${command.command} ${command.description} ${command.category || ''}`.toLowerCase()

    return haystack.includes(query)
  })
})
// 判断是否展示命令建议浮层。
const showSlashCommandMenu = computed(
  () =>
    inputText.value.trimStart().startsWith('/') &&
    !isBusy.value &&
    !recording.value &&
    (filteredSlashCommands.value.length > 0 || slashCommandsLoading.value),
)
// 根据智能体处理状态切换输入框背景提示。
const inputPlaceholder = computed(() =>
  isBusy.value ? t('agentAssistant.processingPlaceholder') : t('agentAssistant.placeholder'),
)
const recordingTimeText = computed(() => {
  const seconds = Math.max(0, recordingDuration.value)
  const minutes = Math.floor(seconds / 60)
  const remainSeconds = seconds % 60

  return `${minutes}:${String(remainSeconds).padStart(2, '0')}`
})
// 窄屏下直接全屏，桌面端则允许用户按需扩展对话区域。
const drawerWidth = computed(() => (display.mdAndDown.value || fullscreen.value ? '100vw' : '30rem'))
// 仅桌面宽屏展示全屏开关，窄屏已默认占满视口。
const canToggleFullscreen = computed(() => !display.mdAndDown.value)
const hasMessages = computed(() => messages.value.length > 0)
const hasHistorySessions = computed(() => historySessions.value.length > 0)
const currentUserName = computed(() => userStore.getUserName || t('common.user'))
const isOpen = computed({
  get: () => props.modelValue,
  set: value => emit('update:modelValue', value),
})
const drawerStyle = computed(() => ({
  '--agent-assistant-panel-width': drawerWidth.value,
  zIndex: AGENT_ASSISTANT_LAYER_Z_INDEX.panel,
}))

// 创建前端展示用的临时 ID。
function createId(prefix: string) {
  return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
}

// 创建 Web 智能助手本地会话 ID。
function createSessionId() {
  return `web-${Date.now()}-${Math.random().toString(16).slice(2)}`
}

// 将未知字段安全转换为可展示文本。
function stringifyChoiceField(value: unknown) {
  if (typeof value === 'string') return value.trim()
  if (typeof value === 'number' || typeof value === 'boolean') return String(value)

  return ''
}

// 规范化消息状态，避免历史脏数据影响渲染。
function normalizeMessageStatus(value: unknown): AgentMessageStatus {
  const status = stringifyChoiceField(value)

  return ['idle', 'streaming', 'done', 'error'].includes(status) ? (status as AgentMessageStatus) : 'done'
}

// 规范化选择卡片状态，兼容旧版本本地历史。
function normalizeChoiceStatus(value: unknown): AgentChoiceStatus {
  const status = stringifyChoiceField(value)

  return ['pending', 'selected', 'expired'].includes(status) ? (status as AgentChoiceStatus) : 'pending'
}

// 规范化单个选择按钮，兼容后端与旧历史的不同字段名。
function normalizeChoiceButton(value: unknown): AgentChoiceButton | null {
  if (!value || typeof value !== 'object' || Array.isArray(value)) return null

  const item = value as Record<string, unknown>
  const callbackData =
    stringifyChoiceField(item.callback_data) ||
    stringifyChoiceField(item.callbackData) ||
    stringifyChoiceField(item.value) ||
    stringifyChoiceField(item.id)
  const label =
    stringifyChoiceField(item.label) ||
    stringifyChoiceField(item.text) ||
    stringifyChoiceField(item.title) ||
    callbackData
  const description = stringifyChoiceField(item.description) || stringifyChoiceField(item.desc)

  if (!label && !callbackData) return null

  return {
    label: label || callbackData,
    callback_data: callbackData || label,
    ...(description ? { description } : {}),
  }
}

// 规范化选择按钮行，保留 Telegram 式二维键盘布局。
function normalizeChoiceButtonRows(value: unknown): AgentChoiceButton[][] {
  if (!Array.isArray(value)) return []

  const rawRows = value.some(Array.isArray) ? value : value.map(item => [item])

  return rawRows
    .map(row => {
      const rowItems = Array.isArray(row) ? row : [row]

      return rowItems.map(normalizeChoiceButton).filter(Boolean) as AgentChoiceButton[]
    })
    .filter(row => row.length > 0)
}

// 规范化选择卡片，保证历史数据和 SSE 新事件使用同一结构。
function normalizeChoiceCard(value: unknown): AgentChoiceCard | null {
  if (!value || typeof value !== 'object' || Array.isArray(value)) return null

  const item = value as Record<string, unknown>
  const snakeCaseRows = normalizeChoiceButtonRows(item.button_rows)
  const camelCaseRows = normalizeChoiceButtonRows(item.buttonRows)
  const flatButtonRows = normalizeChoiceButtonRows(item.buttons)
  const buttonRows =
    snakeCaseRows.length > 0 ? snakeCaseRows : camelCaseRows.length > 0 ? camelCaseRows : flatButtonRows
  const buttons = buttonRows.flat()
  const prompt =
    stringifyChoiceField(item.prompt) || stringifyChoiceField(item.message) || stringifyChoiceField(item.text)
  const id = stringifyChoiceField(item.id) || createId('choice')

  if (!prompt && buttons.length === 0) return null

  return {
    id,
    title: stringifyChoiceField(item.title) || undefined,
    prompt,
    buttons,
    button_rows: buttonRows,
    status: normalizeChoiceStatus(item.status),
    selected_label: stringifyChoiceField(item.selected_label) || stringifyChoiceField(item.selectedLabel) || undefined,
    selected_value: stringifyChoiceField(item.selected_value) || stringifyChoiceField(item.selectedValue) || undefined,
    selected_description:
      stringifyChoiceField(item.selected_description) || stringifyChoiceField(item.selectedDescription) || undefined,
  }
}

// 获取选择按钮行，旧历史只有一维按钮时回退为单列布局。
function getChoiceButtonRows(choice: AgentChoiceCard | AgentChoiceSelection) {
  if (Array.isArray(choice.button_rows) && choice.button_rows.length) return choice.button_rows

  return choice.buttons.map(button => [button])
}

// 获取用户消息中应展示的选项文字。
function getChoiceButtonSelectionText(button: AgentChoiceButton) {
  return button.label || button.description || button.callback_data
}

// 构建用户选择快照，用于历史存储和刷新后恢复上下文。
function buildChoiceSelection(choice: AgentChoiceCard, button: AgentChoiceButton): AgentChoiceSelection {
  return {
    choice_id: choice.id,
    title: choice.title,
    prompt: choice.prompt,
    buttons: [...choice.buttons],
    button_rows: getChoiceButtonRows(choice).map(row => [...row]),
    selected_label: button.label,
    selected_value: button.callback_data,
    selected_description: getChoiceButtonSelectionText(button),
  }
}

// 规范化用户选择快照，兼容后端返回和本地历史。
function normalizeChoiceSelection(value: unknown): AgentChoiceSelection | undefined {
  if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined

  const item = value as Record<string, unknown>
  const choice = normalizeChoiceCard({
    id: item.choice_id || item.choiceId || item.id,
    title: item.title,
    prompt: item.prompt,
    buttons: item.button_rows || item.buttonRows || item.buttons,
    button_rows: item.button_rows || item.buttonRows,
    status: 'selected',
    selected_label: item.selected_label || item.selectedLabel,
    selected_value: item.selected_value || item.selectedValue,
    selected_description: item.selected_description || item.selectedDescription,
  })

  if (!choice) return undefined

  return {
    choice_id: choice.id,
    title: choice.title,
    prompt: choice.prompt,
    buttons: choice.buttons,
    button_rows: getChoiceButtonRows(choice),
    selected_label: choice.selected_label,
    selected_value: choice.selected_value,
    selected_description: choice.selected_description,
  }
}

// 将选择卡片标记为已选择，并写入可读选择文字。
function markChoiceSelected(choice: AgentChoiceCard, button: AgentChoiceButton, selection?: AgentChoiceSelection) {
  choice.status = 'selected'
  choice.selected_label = selection?.selected_label || button.label
  choice.selected_value = selection?.selected_value || button.callback_data
  choice.selected_description = selection?.selected_description || getChoiceButtonSelectionText(button)
}

// 将旧历史中保存成 callback_data 的用户选择消息修正为可读选项文字。
function normalizeChoiceSelectionMessages(sessionMessages: AgentChatMessage[]) {
  const choiceLookup = new Map<string, { choice: AgentChoiceCard; button: AgentChoiceButton }>()

  // 选中后清理同一组按钮索引，避免后续普通文本误判为旧选择。
  const forgetChoice = (choice: AgentChoiceCard) => {
    choice.buttons.forEach(button => {
      choiceLookup.delete(button.callback_data)
      choiceLookup.delete(button.label)
      if (button.description) choiceLookup.delete(button.description)
    })
  }

  sessionMessages.forEach(message => {
    if (message.role === 'assistant') {
      message.choices.forEach(choice => {
        choice.buttons.forEach(button => {
          choiceLookup.set(button.callback_data, { choice, button })
          choiceLookup.set(button.label, { choice, button })
          if (button.description) choiceLookup.set(button.description, { choice, button })
        })
      })

      return
    }

    const content = message.content.trim()
    const selectedText =
      message.choice_selection?.selected_label || message.choice_selection?.selected_description || ''

    if (message.choice_selection && selectedText && choiceLookup.has(content)) {
      message.content = selectedText
      forgetChoice(choiceLookup.get(content)!.choice)
      return
    }

    const matchedChoice = choiceLookup.get(content)
    if (!matchedChoice) return

    message.content = getChoiceButtonSelectionText(matchedChoice.button)
    message.choice_selection = buildChoiceSelection(matchedChoice.choice, matchedChoice.button)
    markChoiceSelected(matchedChoice.choice, matchedChoice.button)
    forgetChoice(matchedChoice.choice)
  })

  return sessionMessages
}

// 规范化消息的有序片段；旧历史按原来的工具在前、文本在后布局回退。
function normalizeMessageSegments(value: unknown, content: string, tools: AgentToolCall[]) {
  const normalizedSegments: AgentMessageSegment[] = []

  if (Array.isArray(value)) {
    value.forEach(rawSegment => {
      if (!rawSegment || typeof rawSegment !== 'object' || Array.isArray(rawSegment)) return

      const segment = rawSegment as Record<string, unknown>
      if (segment.type === 'text' && typeof segment.content === 'string' && segment.content.trim()) {
        normalizedSegments.push({ type: 'text', content: segment.content })
        return
      }

      const toolIndex = Number(segment.toolIndex ?? segment.tool_index)
      if (segment.type === 'tool' && Number.isInteger(toolIndex) && toolIndex >= 0 && toolIndex < tools.length) {
        normalizedSegments.push({ type: 'tool', toolIndex })
      }
    })
  }

  if (normalizedSegments.length) return normalizedSegments

  tools.forEach((_tool, toolIndex) => normalizedSegments.push({ type: 'tool', toolIndex }))
  if (content.trim()) normalizedSegments.push({ type: 'text', content })
  return normalizedSegments
}

// 规范化历史消息，补齐附件、工具、有序片段和选择项等可选数组。
function normalizeStoredMessages(value: unknown) {
  if (!Array.isArray(value)) return []

  const normalizedMessages = value.slice(-MAX_PERSISTED_MESSAGES).map(rawMessage => {
    const message = rawMessage && typeof rawMessage === 'object' ? (rawMessage as Record<string, unknown>) : {}
    const role = message.role === 'assistant' ? 'assistant' : 'user'
    const content = typeof message.content === 'string' ? message.content : stringifyChoiceField(message.content)
    const tools = Array.isArray(message.tools) ? (message.tools as AgentToolCall[]) : []

    return {
      ...message,
      id: stringifyChoiceField(message.id) || createId(role),
      role,
      content,
      createdAt: Number(message.createdAt) || Number(message.created_at) || Date.now(),
      status: normalizeMessageStatus(message.status),
      attachments: Array.isArray(message.attachments) ? message.attachments : [],
      choices: Array.isArray(message.choices)
        ? (message.choices.map(normalizeChoiceCard).filter(Boolean) as AgentChoiceCard[])
        : [],
      tools,
      segments: normalizeMessageSegments(message.segments, content, tools),
      choice_selection: normalizeChoiceSelection(message.choice_selection || message.choiceSelection),
    } as AgentChatMessage
  })

  return normalizeChoiceSelectionMessages(normalizedMessages)
}

// 解析服务端时间字符串，失败时回退到当前时间。
function parseServerTime(value?: string) {
  if (!value) return Date.now()
  const parsed = Date.parse(value.replace(' ', 'T'))

  return Number.isFinite(parsed) ? parsed : Date.now()
}

// 规范化本地历史会话，过滤无效数据并按最近更新时间排序。
function normalizeHistorySessions(value: unknown) {
  if (!Array.isArray(value)) return []

  return value
    .map(item => {
      const messages = normalizeStoredMessages(item?.messages)
      const sessionIdValue = typeof item?.sessionId === 'string' ? item.sessionId : ''
      if (!sessionIdValue || (messages.length === 0 && !item?.title && !item?.preview)) return null

      const firstMessageTime = messages[0]?.createdAt || Date.now()
      const lastMessageTime = messages.at(-1)?.createdAt || firstMessageTime

      return {
        sessionId: sessionIdValue,
        clientSessionId: typeof item?.clientSessionId === 'string' ? item.clientSessionId : undefined,
        title:
          typeof item?.title === 'string' && item.title.trim() ? item.title.trim() : buildSessionHistoryTitle(messages),
        preview: typeof item?.preview === 'string' ? item.preview : undefined,
        channel: typeof item?.channel === 'string' ? item.channel : undefined,
        source: typeof item?.source === 'string' ? item.source : undefined,
        isProcessing: Boolean(item?.isProcessing),
        createdAt: Number(item?.createdAt) || firstMessageTime,
        updatedAt: Number(item?.updatedAt) || lastMessageTime,
        messages,
      }
    })
    .filter(Boolean)
    .sort((a, b) => b!.updatedAt - a!.updatedAt)
    .slice(0, MAX_LOCAL_HISTORY_SESSIONS) as AgentSessionHistoryItem[]
}

// 规范化服务端历史会话摘要或详情。
function normalizeServerSession(item: AgentServerSession, withMessages = false): AgentSessionHistoryItem | null {
  const sessionIdValue = typeof item?.session_id === 'string' ? item.session_id : ''
  if (!sessionIdValue) return null

  const messages = normalizeStoredMessages(item.messages || [])
  const createdAt = parseServerTime(item.created_at)
  const updatedAt = parseServerTime(item.updated_at)

  return {
    sessionId: sessionIdValue,
    clientSessionId: item.client_session_id,
    title:
      item.title?.trim() ||
      (messages.length ? buildSessionHistoryTitle(messages) : t('agentAssistant.untitledSession')),
    preview: item.preview,
    channel: item.channel,
    source: item.source,
    isProcessing: Boolean(item.is_processing),
    createdAt,
    updatedAt,
    messages: withMessages ? messages : [],
  }
}

// 获取历史会话去重使用的稳定标识。
function getHistoryIdentity(session: AgentSessionHistoryItem) {
  return session.clientSessionId || session.sessionId
}

// 合并本地和服务端历史会话，优先保留服务端记录。
function dedupeHistorySessions(sessions: AgentSessionHistoryItem[]) {
  const serverSessions = sessions.filter(item => item.sessionId.startsWith('web-agent:'))
  const serverClientIds = new Set(serverSessions.map(getHistoryIdentity))
  const seen = new Set<string>()
  const deduped: AgentSessionHistoryItem[] = []

  for (const session of sessions) {
    const identity = getHistoryIdentity(session)
    if (!session.sessionId.startsWith('web-agent:') && serverClientIds.has(identity)) continue
    if (seen.has(identity) || seen.has(session.sessionId)) continue
    seen.add(identity)
    seen.add(session.sessionId)
    deduped.push(session)
  }

  return deduped.sort((a, b) => b.updatedAt - a.updatedAt)
}

// 调用智能助手接口，并统一处理鉴权和标准响应格式。
async function fetchAgentApi(path: string, options: RequestInit = {}) {
  const response = await fetch(resolveApiUrl(path), {
    ...options,
    headers: buildAgentRequestHeaders(options.headers),
    credentials: 'include',
  })

  if (!response.ok) throw new Error(await resolveAgentResponseErrorMessage(response))

  const result = await response.json()
  if (!result?.success) throw new Error(result?.message_i18n || result?.message || t('agentAssistant.error'))

  return result.data
}

// 从 localStorage 读取历史会话索引，读取失败时回退为空列表。
function restoreHistorySessions() {
  try {
    historySessions.value = normalizeHistorySessions(JSON.parse(localStorage.getItem(HISTORY_STORAGE_KEY) || '[]'))
  } catch (error) {
    historySessions.value = []
  }
}

// 加载第一页服务端历史会话。
async function loadServerHistorySessions() {
  historyPage.value = 1
  historyHasMore.value = true
  historyLoading.value = true
  try {
    const data = await fetchAgentApi(`message/agent/sessions?page=1&count=${HISTORY_PAGE_SIZE}`)
    const sessions = Array.isArray(data)
      ? (data
          .map(item => normalizeServerSession(item as AgentServerSession))
          .filter(Boolean) as AgentSessionHistoryItem[])
      : []
    historySessions.value = dedupeHistorySessions(sessions)
    historyHasMore.value = sessions.length >= HISTORY_PAGE_SIZE
    persistHistorySessions()
  } catch (error) {
    restoreHistorySessions()
    historyHasMore.value = false
  } finally {
    historyLoading.value = false
  }
}

// 加载下一页服务端历史会话。
async function loadMoreServerHistorySessions(options?: { done?: (status: InfiniteScrollStatus) => void }) {
  if (historyLoading.value || historyLoadingMore.value || !historyHasMore.value) {
    options?.done?.(historyHasMore.value ? 'ok' : 'empty')
    return
  }

  historyLoadingMore.value = true
  try {
    const nextPage = historyPage.value + 1
    const data = await fetchAgentApi(`message/agent/sessions?page=${nextPage}&count=${HISTORY_PAGE_SIZE}`)
    const sessions = Array.isArray(data)
      ? (data
          .map(item => normalizeServerSession(item as AgentServerSession))
          .filter(Boolean) as AgentSessionHistoryItem[])
      : []
    const existingIds = new Set(historySessions.value.map(item => item.sessionId))
    historySessions.value = dedupeHistorySessions([
      ...historySessions.value,
      ...sessions.filter(item => !existingIds.has(item.sessionId)),
    ]).slice(0, MAX_LOCAL_HISTORY_SESSIONS)
    historyPage.value = nextPage
    historyHasMore.value = sessions.length >= HISTORY_PAGE_SIZE
    persistHistorySessions()
    options?.done?.(sessions.length ? 'ok' : 'empty')
  } catch (error) {
    options?.done?.('error')
  } finally {
    historyLoadingMore.value = false
  }
}

// 加载 Web 智能助手可补全的斜杠命令列表。
async function loadSlashCommands() {
  if (slashCommandsLoaded.value || slashCommandsLoading.value) return

  slashCommandsLoading.value = true
  try {
    const data = await fetchAgentApi('message/agent/commands')
    slashCommands.value = Array.isArray(data)
      ? data
          .map(item => ({
            command: stringifyChoiceField(item?.command),
            description: stringifyChoiceField(item?.description),
            category: stringifyChoiceField(item?.category) || undefined,
            type: stringifyChoiceField(item?.type) || undefined,
            pid: stringifyChoiceField(item?.pid) || undefined,
          }))
          .filter(item => item.command.startsWith('/'))
      : []
    slashCommandsLoaded.value = true
  } catch (error: any) {
    streamError.value = error?.message || t('agentAssistant.commandLoadFailed')
  } finally {
    slashCommandsLoading.value = false
  }
}

// 选择命令建议并写入输入框。
function selectSlashCommand(command: AgentSlashCommand) {
  inputText.value = `${command.command} `
  nextTick(() => {
    syncInputHeight()
    inputRef.value?.focus()
  })
}

// 输入内容变化时同步高度，并按需预加载命令建议。
function handleInputChange() {
  syncInputHeight()
  if (inputText.value.trimStart().startsWith('/')) loadSlashCommands()
}

// 处理历史菜单的无限滚动加载事件。
async function handleHistoryInfiniteLoad({
  done,
}: {
  side: InfiniteScrollSide
  done: (status: InfiniteScrollStatus) => void
}) {
  await loadMoreServerHistorySessions({ done })
}

// 加载服务端历史会话详情，并更新本地缓存。
async function loadServerHistorySession(targetSessionId: string) {
  const data = await fetchAgentApi(`message/agent/sessions/${encodeURIComponent(targetSessionId)}`)
  const session = normalizeServerSession(data as AgentServerSession, true)
  if (!session) throw new Error(t('agentAssistant.historyLoadFailed'))

  historySessions.value = dedupeHistorySessions([
    session,
    ...historySessions.value.filter(item => item.sessionId !== targetSessionId),
  ]).slice(0, MAX_LOCAL_HISTORY_SESSIONS)
  persistHistorySessions()

  return session
}

// 清理等待中的 WebAgent 断流恢复轮询。
function clearStreamRecoveryTimer() {
  if (streamRecoveryTimer === null) return

  window.clearTimeout(streamRecoveryTimer)
  streamRecoveryTimer = null
}

// 记录当前流的恢复上下文，并立即持久化，供 PWA 后台冻结或重载后继续轮询。
function beginStreamRecovery(targetSessionId: string, startedAt: number) {
  const current = pendingStreamRecovery.value
  pendingStreamRecovery.value = {
    sessionId: targetSessionId,
    startedAt,
    attempts: current?.sessionId === targetSessionId ? current.attempts : 0,
  }
  persistState({ syncHistory: false })
}

// 判断服务端正式会话 ID 或客户端会话 ID 是否属于当前恢复任务。
function matchesStreamRecoverySession(session: AgentSessionHistoryItem, targetSessionId: string) {
  const requestedIds = new Set([sessionId.value, targetSessionId].filter(Boolean))

  return [session.sessionId, session.clientSessionId].some(id => Boolean(id && requestedIds.has(id)))
}

// 将无法继续恢复的流式占位转换成明确错误，避免界面永久停留在空白或加载态。
function failStreamRecovery() {
  pendingStreamRecovery.value = null
  const assistantMessage = [...messages.value]
    .reverse()
    .find(message => message.role === 'assistant' && message.status === 'streaming')
  if (assistantMessage) {
    assistantMessage.status = 'error'
    if (!assistantMessage.content) appendAssistantTextSegment(assistantMessage, t('agentAssistant.recoveryFailed'))
    markToolsDone(assistantMessage)
    refreshMessageList()
  } else {
    streamError.value = t('agentAssistant.recoveryFailed')
  }
  persistState()
}

// 从服务端拉取当前会话展示快照，用于移动端后台断开 SSE 后恢复最终结果。
async function restoreCurrentSessionFromServer(targetSessionId: string, startedAt: number) {
  const session = await loadServerHistorySession(targetSessionId)
  const activeRecovery = pendingStreamRecovery.value
  if (!activeRecovery || activeRecovery.sessionId !== targetSessionId) return { restored: false, pending: false }
  if (!matchesStreamRecoverySession(session, targetSessionId)) return { restored: false, pending: false }
  if (session.updatedAt < startedAt - 1000) return { restored: false, pending: Boolean(session.isProcessing) }
  if (session.isProcessing) return { restored: false, pending: true }
  if (!session.messages.length) return { restored: false, pending: false }

  const restoredMessages = normalizeStoredMessages(session.messages)
  restoredMessages.forEach(message => {
    if (message.status !== 'streaming') return

    message.status = 'done'
    markToolsDone(message)
  })
  messages.value = restoredMessages
  sessionId.value = session.sessionId
  pendingStreamRecovery.value = null
  clearStreamRecoveryTimer()
  persistState({ syncHistory: false })
  scrollToBottom()
  if (abortController) {
    streamRecoveryAbortRequested = true
    abortController.abort()
  }
  return { restored: true, pending: false }
}

// WebAgent SSE 断流后，后台任务仍会完成并保存快照；前端回到前台后轮询拉取。
function scheduleStreamRecovery(delay = 1200) {
  if (!pendingStreamRecovery.value || typeof window === 'undefined') return

  clearStreamRecoveryTimer()
  streamRecoveryTimer = window.setTimeout(async () => {
    streamRecoveryTimer = null
    if (!pendingStreamRecovery.value) return
    if (document.visibilityState === 'hidden') return

    const recovery = pendingStreamRecovery.value
    try {
      const result = await restoreCurrentSessionFromServer(recovery.sessionId, recovery.startedAt)
      if (result.restored) return
      if (result.pending) {
        scheduleStreamRecovery(Math.min(8000, 1200 + recovery.attempts * 900))
        return
      }
    } catch (error) {
      // 会话快照可能还未写入，继续按退避间隔等待。
    }

    if (!pendingStreamRecovery.value || pendingStreamRecovery.value.sessionId !== recovery.sessionId) return
    pendingStreamRecovery.value.attempts += 1
    if (pendingStreamRecovery.value.attempts > 8) {
      failStreamRecovery()
      return
    }

    persistState({ syncHistory: false })
    scheduleStreamRecovery(Math.min(8000, 1200 + pendingStreamRecovery.value.attempts * 900))
  }, delay)
}

// 从持久化元数据或最后一条 streaming 助手消息重建后台恢复任务。
function restorePendingStreamRecovery(value: unknown) {
  const stored = value && typeof value === 'object' ? (value as Record<string, unknown>) : null
  const latestAssistantMessage = messages.value.at(-1)
  const recoverableAssistantMessage =
    latestAssistantMessage?.role === 'assistant' &&
    (latestAssistantMessage.status === 'streaming' ||
      (latestAssistantMessage.status === 'done' && isEmptyAssistantMessage(latestAssistantMessage)))
      ? latestAssistantMessage
      : undefined
  const recoverySessionId = stringifyChoiceField(stored?.sessionId) || sessionId.value
  const startedAt = Number(stored?.startedAt) || recoverableAssistantMessage?.createdAt || 0
  if (!recoverySessionId || !startedAt || (!stored && !recoverableAssistantMessage)) {
    pendingStreamRecovery.value = null
    return
  }

  if (recoverableAssistantMessage) recoverableAssistantMessage.status = 'streaming'
  pendingStreamRecovery.value = {
    sessionId: recoverySessionId,
    startedAt,
    attempts: Math.max(0, Number(stored?.attempts) || 0),
  }
}

// 恢复当前会话状态，优先使用本地状态，缺失时使用最近历史。
function restoreState() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY)
    if (!raw) {
      const latestSession = historySessions.value[0]
      if (latestSession?.messages.length) {
        sessionId.value = latestSession.sessionId
        messages.value = normalizeStoredMessages(latestSession.messages)
      } else {
        sessionId.value = createSessionId()
      }

      return
    }

    const state = JSON.parse(raw)
    sessionId.value = state.sessionId || createSessionId()
    messages.value = normalizeStoredMessages(state.messages)
    restorePendingStreamRecovery(state.streamRecovery)
    upsertCurrentSessionHistory()
  } catch (error) {
    sessionId.value = createSessionId()
    pendingStreamRecovery.value = null
  }
}

// 将历史会话列表写入 localStorage，空间不足时保留最近的一半会话重试。
function persistHistorySessions() {
  const sessions = dedupeHistorySessions(historySessions.value).slice(0, MAX_LOCAL_HISTORY_SESSIONS)
  historySessions.value = sessions

  try {
    localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(sessions))
  } catch (error) {
    const retainedCount = Math.max(1, Math.ceil(sessions.length / 2))
    historySessions.value = sessions.slice(0, retainedCount)

    try {
      localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(historySessions.value))
    } catch (retryError) {
      // 浏览器本地空间不足时放弃本次历史写入，不影响当前对话继续使用。
    }
  }
}

// 生成会话列表里显示的短标题，优先取第一条用户消息。
function buildSessionHistoryTitle(sessionMessages: AgentChatMessage[]) {
  const firstUserMessage = sessionMessages.find(message => message.role === 'user' && getMessageSummaryText(message))
  const firstReadableMessage = firstUserMessage || sessionMessages.find(message => getMessageSummaryText(message))
  const title = firstReadableMessage ? getMessageSummaryText(firstReadableMessage) : ''

  return truncateHistoryText(title || t('agentAssistant.untitledSession'), HISTORY_TITLE_LENGTH)
}

// 获取历史会话来源展示文本。
function getSessionChannelLabel(session: AgentSessionHistoryItem) {
  if (session.channel === 'WebAgent') return t('agentAssistant.webAgentChannel')

  const parts = [session.channel, session.source].filter(Boolean)
  if (!parts.length) return t('agentAssistant.unknownChannel')

  return parts.join(' / ')
}

// 提取消息的可读摘要，纯附件消息会使用附件名称或附件占位文本。
function getMessageSummaryText(message: AgentChatMessage) {
  const text = message.content.replace(/\s+/g, ' ').trim()
  if (text) return text

  const firstAttachment = message.attachments[0]
  if (firstAttachment) return firstAttachment.name || t('agentAssistant.attachmentMessage')

  return ''
}

// 按指定长度截断历史列表文本，避免长提示词撑开面板。
function truncateHistoryText(value: string, maxLength: number) {
  if (value.length <= maxLength) return value

  return `${value.slice(0, maxLength).trim()}...`
}

// 将当前会话同步到历史索引，空会话不会进入历史列表。
function upsertCurrentSessionHistory() {
  if (!sessionId.value || messages.value.length === 0) return

  const storedMessages = normalizeStoredMessages(messages.value)
  const existingSession = historySessions.value.find(item => item.sessionId === sessionId.value)
  const createdAt = existingSession?.createdAt || storedMessages[0]?.createdAt || Date.now()
  const updatedAt = storedMessages.at(-1)?.createdAt || Date.now()
  const nextSession: AgentSessionHistoryItem = {
    sessionId: sessionId.value,
    clientSessionId: existingSession?.clientSessionId || sessionId.value,
    title: buildSessionHistoryTitle(storedMessages),
    preview: existingSession?.preview,
    channel: existingSession?.channel || 'WebAgent',
    source: existingSession?.source || 'web-agent',
    createdAt,
    updatedAt,
    messages: storedMessages,
  }

  historySessions.value = dedupeHistorySessions([
    nextSession,
    ...historySessions.value.filter(item => item.sessionId !== sessionId.value),
  ]).slice(0, MAX_LOCAL_HISTORY_SESSIONS)

  persistHistorySessions()
}

// 将当前会话展示消息保存到服务端历史。
async function saveCurrentSessionToServer() {
  if (!sessionId.value || messages.value.length === 0) return

  await fetchAgentApi(`message/agent/sessions/${encodeURIComponent(sessionId.value)}/display`, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      title: buildSessionHistoryTitle(messages.value),
      messages: normalizeStoredMessages(messages.value),
    }),
  })
}

// 持久化当前会话状态，并按需同步到历史会话列表。
function persistState(options: { syncHistory?: boolean } = {}) {
  const { syncHistory = true } = options

  try {
    localStorage.setItem(
      STORAGE_KEY,
      JSON.stringify({
        sessionId: sessionId.value,
        messages: messages.value.slice(-MAX_PERSISTED_MESSAGES),
        streamRecovery: pendingStreamRecovery.value,
      }),
    )
  } catch (error) {
    // 浏览器本地空间不足时保留内存态，避免发送过程被持久化异常打断。
  }

  if (syncHistory) upsertCurrentSessionHistory()
}

// 拼接后端 API 地址。
function resolveApiUrl(path: string) {
  const baseUrl = import.meta.env.VITE_API_BASE_URL || '/'
  return `${baseUrl.replace(/\/?$/, '/')}${path.replace(/^\//, '')}`
}

// 构造智能助手 fetch 请求头，补齐 Axios 拦截器无法覆盖的鉴权和语言信息。
function buildAgentRequestHeaders(headers?: HeadersInit) {
  const requestHeaders = new Headers(headers || {})
  const locale = getCurrentLocale()

  if (authStore.token) requestHeaders.set('Authorization', `Bearer ${authStore.token}`)
  requestHeaders.set('X-MoviePilot-Locale', locale)
  requestHeaders.set('Accept-Language', locale)

  return requestHeaders
}

// 解析智能助手 fetch 失败响应，优先使用后端返回的本地化错误文本。
async function resolveAgentResponseErrorMessage(response: Response) {
  try {
    const payload = await response.clone().json()
    const message = payload?.detail_i18n || payload?.message_i18n || payload?.detail || payload?.message
    if (typeof message === 'string' && message) return message
  } catch {
    // 非 JSON 错误响应保留 HTTP 状态文本，避免吞掉原始错误。
  }

  return `${response.status} ${response.statusText}`.trim()
}

// 消息主列表使用原生滚动，避免流式回复时 JS 滚动库频繁测量影响手感。
function getMessageScrollerElement() {
  return messageListRef.value
}

// 判断消息列表是否停留在底部附近，用于流式输出自动跟随。
function isMessageScrollerNearBottom() {
  const scroller = getMessageScrollerElement()
  if (!scroller) return true

  return scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= MESSAGE_SCROLL_FOLLOW_THRESHOLD
}

// 只在滚动事件中更新自动跟随意图，避免每个流式事件触发布局读取。
function handleMessageScrollerScroll() {
  messageScrollerShouldFollow = isMessageScrollerNearBottom()
}

// 合并滚动更新请求，降低流式输出时的布局测量频率。
function scheduleMessageScrollerUpdate(options: { toBottom?: boolean } = {}) {
  const { toBottom = false } = options
  pendingMessageScrollToBottom ||= toBottom
  if (messageScrollFrame !== null) return

  messageScrollFrame = window.requestAnimationFrame(() => {
    messageScrollFrame = null
    const scroller = getMessageScrollerElement()
    if (!scroller) return

    if (pendingMessageScrollToBottom) scroller.scrollTop = scroller.scrollHeight
    pendingMessageScrollToBottom = false
  })
}

// 将消息列表滚动到底部。
function scrollToBottom(options: { smooth?: boolean } = {}) {
  const { smooth = false } = options
  messageScrollerShouldFollow = true
  nextTick(() => {
    const scroller = getMessageScrollerElement()
    if (!scroller) return

    if (smooth) {
      scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' })
      scheduleMessageScrollerUpdate()
      return
    }

    scheduleMessageScrollerUpdate({ toBottom: true })
  })
}

// 新会话展示空态时必须回到顶部，避免复用上一段长会话的滚动位置导致空白。
function scrollToTop() {
  nextTick(() => {
    requestAnimationFrame(() => {
      const scroller = getMessageScrollerElement()
      if (!scroller) return

      scroller.scrollTop = 0
    })
  })
}

// 清理流式消息延迟持久化计时器。
function clearStreamPersistTimer() {
  if (streamPersistTimer === null) return

  window.clearTimeout(streamPersistTimer)
  streamPersistTimer = null
}

// 清理等待执行的消息滚动动画帧。
function clearMessageScrollFrame() {
  if (messageScrollFrame === null) return

  window.cancelAnimationFrame(messageScrollFrame)
  messageScrollFrame = null
  pendingMessageScrollToBottom = false
}

// 流式期间至多每秒保存一次轻量当前态，终态再同步完整历史。
function scheduleStreamPersist() {
  if (streamPersistTimer !== null) return

  const elapsed = Date.now() - streamPersistLastRunAt
  const delay = Math.max(0, STREAM_STATE_PERSIST_DELAY - elapsed)
  streamPersistTimer = window.setTimeout(() => {
    streamPersistTimer = null
    streamPersistLastRunAt = Date.now()
    persistState({ syncHistory: false })
  }, delay)
}

// 同步输入框高度，使多行输入不撑破底部布局。
function syncInputHeight() {
  nextTick(() => {
    const input = inputRef.value
    if (!input) return

    input.style.blockSize = 'auto'
    input.style.blockSize = `${Math.min(input.scrollHeight, 120)}px`
  })
}

// 刷新消息数组引用，确保流式回调中的消息字段变更能稳定触发当前会话视图更新。
function refreshMessageList() {
  messages.value = [...messages.value]
}

// 添加一条聊天消息，并返回消息列表中的响应式对象供后续流式更新使用。
function addMessage(
  role: AgentMessageRole,
  content: string,
  status: AgentMessageStatus = 'idle',
  attachments: AgentMessageAttachment[] = [],
  choiceSelection?: AgentChoiceSelection,
) {
  const message: AgentChatMessage = {
    id: createId(role),
    role,
    content,
    createdAt: Date.now(),
    status,
    attachments,
    choices: [],
    tools: [],
    segments: role === 'assistant' && content ? [{ type: 'text', content }] : [],
    choice_selection: choiceSelection,
  }
  messages.value.push(message)
  const reactiveMessage = messages.value[messages.value.length - 1]

  persistState()
  scrollToBottom()
  return reactiveMessage
}

// 清理后端工具提示前缀。
function normalizeToolMessage(message: string) {
  return message.replace(/^=>\s*/, '').trim()
}

// 解析非啰嗦模式的工具汇总，供相邻工具状态按类别累计次数。
function parseToolSummary(message: string) {
  const summaryMatch = message.match(/^（(.+)）$/)
  if (!summaryMatch) return null

  const parts = summaryMatch[1].split('，').map(part => {
    const countMatch = part.trim().match(/^(.*?\D)(\d+)(\D.*)$/)
    if (!countMatch) return null
    return {
      prefix: countMatch[1],
      count: Number(countMatch[2]),
      suffix: countMatch[3],
    }
  })
  return parts.every(Boolean) ? (parts as Array<{ prefix: string; count: number; suffix: string }>) : null
}

// 仅合并相邻的非啰嗦工具汇总；正文或具体工具提示会自然终止当前聚合组。
function mergeToolSummaries(currentMessage: string, nextMessage: string) {
  const currentParts = parseToolSummary(currentMessage)
  const nextParts = parseToolSummary(nextMessage)
  if (!currentParts || !nextParts) return null

  const mergedParts = currentParts.map(part => ({ ...part }))
  const partIndexes = new Map(mergedParts.map((part, index) => [`${part.prefix}\u0000${part.suffix}`, index]))
  nextParts.forEach(part => {
    const key = `${part.prefix}\u0000${part.suffix}`
    const existingIndex = partIndexes.get(key)
    if (existingIndex === undefined) {
      partIndexes.set(key, mergedParts.length)
      mergedParts.push({ ...part })
      return
    }
    mergedParts[existingIndex].count += part.count
  })

  return `（${mergedParts.map(part => `${part.prefix}${part.count}${part.suffix}`).join('，')}）`
}

// 将当前消息里的运行中工具标记为完成。
function markToolsDone(message: AgentChatMessage) {
  message.tools.forEach(tool => {
    tool.status = 'done'
  })
}

// 追加助手文本，并只合并紧邻的文本片段以保留工具事件边界。
function appendAssistantTextSegment(message: AgentChatMessage, content: string) {
  if (!content) return

  message.content += content
  const lastSegment = message.segments.at(-1)
  if (lastSegment?.type === 'text') {
    lastSegment.content += content
  } else if (content.trim()) {
    message.segments.push({ type: 'text', content })
  }
}

// 替换助手文本但保留工具片段，用于无法继续流式处理时显示错误。
function replaceAssistantTextSegments(message: AgentChatMessage, content: string) {
  message.content = content
  message.segments = message.segments.filter(segment => segment.type === 'tool')
  if (content.trim()) message.segments.push({ type: 'text', content })
}

// 按 SSE 事件顺序渲染文本与工具，只跳过无法产生可见内容的空白文本。
function getRenderableMessageSegments(message: AgentChatMessage): AgentRenderableMessageSegment[] {
  return message.segments.reduce<AgentRenderableMessageSegment[]>((renderableSegments, segment, index) => {
    if (segment.type === 'text') {
      if (segment.content.trim()) renderableSegments.push({ ...segment, key: `text-${index}` })
      return renderableSegments
    }

    const tool = message.tools[segment.toolIndex]
    if (tool) {
      const previousSegment = renderableSegments.at(-1)
      const mergedMessage =
        previousSegment?.type === 'tool' ? mergeToolSummaries(previousSegment.tool.message, tool.message) : null
      if (previousSegment?.type === 'tool' && mergedMessage) {
        previousSegment.tool = {
          ...previousSegment.tool,
          message: mergedMessage,
          status: previousSegment.tool.status === 'running' || tool.status === 'running' ? 'running' : 'done',
        }
      } else {
        renderableSegments.push({ type: 'tool', key: `tool-${tool.id}`, tool })
      }
    }
    return renderableSegments
  }, [])
}

// 判断消息是否没有任何可展示内容，可用于清理编辑回调产生的占位回复。
function isEmptyAssistantMessage(message: AgentChatMessage) {
  return (
    message.role === 'assistant' &&
    !message.content.trim() &&
    message.attachments.length === 0 &&
    message.choices.length === 0 &&
    message.tools.length === 0
  )
}

// 将服务端编辑事件应用到原助手消息卡片。
function applyMessageUpdate(event: AgentStreamEvent) {
  const target = event.target_message
  const targetId = String(target?.id || event.message_id || '')
  if (!targetId) return false

  const message = messages.value.find(item => item.id === targetId)
  if (!message || message.role !== 'assistant') return false

  message.content = typeof target?.content === 'string' ? target.content : ''
  message.attachments = Array.isArray(target?.attachments) ? target.attachments : []
  message.tools = Array.isArray(target?.tools) ? target.tools : []
  message.segments = normalizeMessageSegments(target?.segments, message.content, message.tools)
  message.choices = Array.isArray(target?.choices)
    ? (target.choices.map(normalizeChoiceCard).filter(Boolean) as AgentChoiceCard[])
    : []
  message.status = normalizeMessageStatus(target?.status || 'done')
  refreshMessageList()
  persistState()
  return true
}

// 将单个 SSE 事件应用到正在流式输出的助手消息。
function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMessage) {
  switch (event.type) {
    case 'delta':
      appendAssistantTextSegment(assistantMessage, event.content || '')
      emit('assistant-preview', assistantMessage.content)
      break
    case 'tool':
      markToolsDone(assistantMessage)
      assistantMessage.tools.push({
        id: createId('tool'),
        message: normalizeToolMessage(event.message || ''),
        status: 'running',
      })
      assistantMessage.segments.push({ type: 'tool', toolIndex: assistantMessage.tools.length - 1 })
      break
    case 'attachment':
      if (event.attachment?.url) {
        assistantMessage.attachments.push(event.attachment)
      }
      break
    case 'choice':
      if (event.choice?.id) {
        assistantMessage.choices.push({
          ...event.choice,
          status: 'pending',
        })
      }
      break
    case 'message_update':
      applyMessageUpdate(event)
      break
    case 'done':
      if (assistantMessage.status !== 'error') {
        assistantMessage.status = 'done'
      }
      markToolsDone(assistantMessage)
      break
    case 'error':
      assistantMessage.status = 'error'
      // 后端流式错误已经以 AI 消息展示，避免底部提示条重复且持续占位。
      if (!assistantMessage.content) {
        appendAssistantTextSegment(assistantMessage, event.message_i18n || event.message || t('agentAssistant.error'))
      }
      emit('assistant-preview', assistantMessage.content)
      markToolsDone(assistantMessage)
      break
    case 'start':
      if (event.session_id) {
        sessionId.value = event.session_id
        if (pendingStreamRecovery.value) pendingStreamRecovery.value.sessionId = event.session_id
      }
      break
    default:
      break
  }

  scheduleStreamPersist()
  nextTick(() => {
    scheduleMessageScrollerUpdate({ toBottom: messageScrollerShouldFollow })
  })
}

// 将同一条助手消息的连续文本增量合并到一个动画帧，语义事件到来前会同步冲刷。
function flushPendingStreamDelta() {
  if (streamDeltaFrame !== null) {
    window.cancelAnimationFrame(streamDeltaFrame)
    streamDeltaFrame = null
  }
  if (!pendingStreamDeltaMessage || !pendingStreamDelta) return

  const assistantMessage = pendingStreamDeltaMessage
  const content = pendingStreamDelta
  pendingStreamDeltaMessage = null
  pendingStreamDelta = ''
  applyStreamEvent({ type: 'delta', content }, assistantMessage)
}

function clearPendingStreamDelta() {
  if (streamDeltaFrame !== null) window.cancelAnimationFrame(streamDeltaFrame)
  streamDeltaFrame = null
  pendingStreamDeltaMessage = null
  pendingStreamDelta = ''
}

function schedulePendingStreamDeltaFlush() {
  if (streamDeltaFrame !== null) return

  streamDeltaFrame = window.requestAnimationFrame(() => {
    streamDeltaFrame = null
    flushPendingStreamDelta()
  })
}

function queueStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMessage) {
  if (event.type !== 'delta') {
    flushPendingStreamDelta()
    applyStreamEvent(event, assistantMessage)
    return
  }

  if (pendingStreamDeltaMessage && pendingStreamDeltaMessage !== assistantMessage) flushPendingStreamDelta()
  pendingStreamDeltaMessage = assistantMessage
  pendingStreamDelta += event.content || ''
  schedulePendingStreamDeltaFlush()
}

// 解析一个 SSE 数据块。
function parseSseBlock(block: string) {
  const data = block
    .split('\n')
    .filter(line => line.startsWith('data:'))
    .map(line => line.slice(5).trimStart())
    .join('\n')

  if (!data) return null
  return JSON.parse(data) as AgentStreamEvent
}

// 读取并应用智能助手 SSE 响应流。
async function readAgentStream(response: Response, assistantMessage: AgentChatMessage): Promise<AgentStreamReadResult> {
  if (!response.body) {
    throw new Error(t('agentAssistant.noStream'))
  }

  const reader = response.body.getReader()
  const decoder = new TextDecoder('utf-8')
  let buffer = ''
  let receivedTerminalEvent = false

  // 应用事件并记录服务端是否明确结束本轮流，区分正常完成与无异常的后台断流。
  const consumeEvent = (event: AgentStreamEvent | null) => {
    if (!event) return

    queueStreamEvent(event, assistantMessage)
    if (event.type === 'done' || event.type === 'error') receivedTerminalEvent = true
  }

  try {
    while (true) {
      const { value, done } = await reader.read()
      if (done) break

      buffer += decoder.decode(value, { stream: true })
      const blocks = buffer.split(/\r?\n\r?\n/)
      buffer = blocks.pop() || ''

      for (const block of blocks) {
        consumeEvent(parseSseBlock(block))
      }
    }

    buffer += decoder.decode()
    if (buffer.trim()) {
      consumeEvent(parseSseBlock(buffer))
    }
  } finally {
    flushPendingStreamDelta()
  }

  return { receivedTerminalEvent }
}

// 移动端浏览器退到后台时，SSE/fetch 可能以 TypeError: Load failed 等形式被动断开。
function isRecoverableStreamDisconnect(error: any) {
  if (userAbortRequested) return false

  const message = String(error?.message || error || '').toLowerCase()
  if (document.visibilityState === 'hidden') return true

  return (
    error?.name === 'AbortError' ||
    message.includes('load failed') ||
    message.includes('failed to fetch') ||
    message.includes('networkerror') ||
    message.includes('network error')
  )
}

// 解析附件地址，支持相对 API 路径。
function resolveAttachmentUrl(url?: string) {
  if (!url) return ''
  if (/^(https?:|data:|blob:|\/)/.test(url)) return url

  return resolveApiUrl(url)
}

// 获取附件下载地址。
function getAttachmentDownloadUrl(attachment: AgentMessageAttachment) {
  return resolveAttachmentUrl(attachment.download_url || attachment.url)
}

// 获取附件展示名称。
function getAttachmentName(attachment: AgentMessageAttachment) {
  return attachment.name || (attachment.kind === 'image' ? 'image' : 'attachment')
}

// 获取附件类型图标。
function getAttachmentIcon(attachment: { kind: AgentAttachmentKind }) {
  if (attachment.kind === 'audio') return 'mdi-volume-high'
  if (attachment.kind === 'image') return 'mdi-image-outline'
  return 'mdi-file-outline'
}

// 格式化附件大小。
function formatAttachmentSize(size?: number) {
  if (!size) return ''
  if (size < 1024) return `${size} B`
  if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
  return `${(size / 1024 / 1024).toFixed(1)} MB`
}

// 根据浏览器文件 MIME 类型判断附件类别。
function getFileKind(file: File): AgentAttachmentKind {
  if (file.type.startsWith('image/')) return 'image'
  if (file.type.startsWith('audio/')) return 'audio'
  return 'file'
}

// 打开系统文件选择器。
function openFilePicker() {
  fileInputRef.value?.click()
}

// 处理文件选择并生成待发送附件预览。
function handleFileSelection(event: Event) {
  const input = event.target as HTMLInputElement
  const files = Array.from(input.files || [])
  const nextAttachments = files.map(file => {
    const kind = getFileKind(file)

    return {
      id: createId('attachment'),
      file,
      kind,
      name: file.name,
      mime_type: file.type || 'application/octet-stream',
      size: file.size,
      preview_url: kind === 'image' ? URL.createObjectURL(file) : undefined,
    }
  })

  pendingAttachments.value.push(...nextAttachments)
  input.value = ''
}

// 移除一条待发送附件。
function removePendingAttachment(id: string) {
  const attachment = pendingAttachments.value.find(item => item.id === id)
  if (attachment?.preview_url) URL.revokeObjectURL(attachment.preview_url)
  pendingAttachments.value = pendingAttachments.value.filter(item => item.id !== id)
}

// 清理全部待发送附件和临时预览地址。
function clearPendingAttachments() {
  pendingAttachments.value.forEach(item => {
    if (item.preview_url) URL.revokeObjectURL(item.preview_url)
  })
  pendingAttachments.value = []
}

// 将图片文件读取为 data URL，供多模态输入和本地展示使用。
function readFileAsDataUrl(file: File) {
  return new Promise<string>((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = () => resolve(String(reader.result || ''))
    reader.onerror = () => reject(reader.error || new Error(t('agentAssistant.uploadFailed')))
    reader.readAsDataURL(file)
  })
}

// 上传智能助手附件，返回后端可引用的附件描述。
async function uploadAgentAttachment(file: File) {
  const formData = new FormData()
  formData.append('file', file)
  formData.append('session_id', sessionId.value)

  const response = await fetch(resolveApiUrl('message/agent/upload'), {
    method: 'POST',
    headers: buildAgentRequestHeaders(),
    body: formData,
    credentials: 'include',
  })

  if (!response.ok) throw new Error(await resolveAgentResponseErrorMessage(response))

  const result = await response.json()
  if (!result?.success) throw new Error(result?.message_i18n || result?.message || t('agentAssistant.uploadFailed'))

  return result.data as AgentMessageAttachment & AgentOutgoingFile
}

// 准备本轮发送给 Agent 的图片、文件、音频和展示附件。
async function prepareAgentAttachments(items: AgentPendingAttachment[]): Promise<PreparedAgentAttachments> {
  const images: string[] = []
  const files: AgentOutgoingFile[] = []
  const audioRefs: string[] = []
  const userAttachments: AgentMessageAttachment[] = []

  for (const item of items) {
    const imageDataUrl = item.kind === 'image' ? await readFileAsDataUrl(item.file) : ''
    const uploaded = await uploadAgentAttachment(item.file)
    const displayAttachment: AgentMessageAttachment = {
      kind: item.kind,
      url: item.kind === 'image' ? imageDataUrl : uploaded.url,
      download_url: uploaded.download_url || uploaded.url,
      name: item.name,
      mime_type: item.mime_type,
      size: item.size,
    }

    if (imageDataUrl) images.push(imageDataUrl)
    const outgoingFile = {
      ref: uploaded.ref || uploaded.url,
      name: uploaded.name || item.name,
      mime_type: uploaded.mime_type || item.mime_type,
      size: uploaded.size || item.size,
      local_path: uploaded.local_path,
      status: uploaded.status || 'ready',
    }

    if (item.kind === 'audio') {
      audioRefs.push(outgoingFile.ref)
    } else {
      files.push(outgoingFile)
    }
    userAttachments.push(displayAttachment)
  }

  return { images, files, audioRefs, userAttachments }
}

// 发送一轮智能助手消息，并处理本地展示、SSE 回复和历史同步。
async function streamAgentMessage(
  text: string,
  images: string[] = [],
  files: AgentOutgoingFile[] = [],
  audioRefs: string[] = [],
  userAttachments: AgentMessageAttachment[] = [],
  options: AgentStreamMessageOptions | boolean = {},
) {
  const content = text.trim()
  const streamOptions = typeof options === 'boolean' ? { echoUser: options } : options
  const { echoUser = true, displayText, choiceSelection, originalMessageId, originalChatId } = streamOptions
  const displayContent = (displayText ?? content).trim()
  if (!content && !images.length && !files.length && !audioRefs.length) return

  if (echoUser) addMessage('user', displayContent || content, 'done', userAttachments, choiceSelection)
  const assistantMessage = addMessage('assistant', '', 'streaming')

  abortController = new AbortController()
  userAbortRequested = false
  streamRecoveryAbortRequested = false
  const streamStartedAt = Date.now()
  activeStreamStartedAt = streamStartedAt
  let shouldFollowBottomAfterStream = true
  let shouldSaveClientSnapshot = true

  try {
    const response = await fetch(resolveApiUrl('message/agent/stream'), {
      method: 'POST',
      headers: buildAgentRequestHeaders({
        'Content-Type': 'application/json',
      }),
      body: JSON.stringify({
        text: content,
        display_text: displayContent || content,
        session_id: sessionId.value,
        images,
        files,
        audio_refs: audioRefs,
        choice_selection: choiceSelection,
        original_message_id: originalMessageId,
        original_chat_id: originalChatId,
        echo_user: echoUser,
      }),
      credentials: 'include',
      signal: abortController.signal,
    })

    if (!response.ok) {
      throw new Error(await resolveAgentResponseErrorMessage(response))
    }

    const streamResult = await readAgentStream(response, assistantMessage)
    shouldFollowBottomAfterStream = isMessageScrollerNearBottom()
    if (!streamResult.receivedTerminalEvent) {
      shouldSaveClientSnapshot = false
      beginStreamRecovery(sessionId.value, streamStartedAt)
      refreshMessageList()
      if (document.visibilityState === 'visible') scheduleStreamRecovery(0)
      return
    }

    pendingStreamRecovery.value = null
    clearStreamRecoveryTimer()
    if (isEmptyAssistantMessage(assistantMessage)) {
      messages.value = messages.value.filter(message => message.id !== assistantMessage.id)
      refreshMessageList()
      return
    }
    if (assistantMessage.status === 'streaming') {
      assistantMessage.status = 'done'
      markToolsDone(assistantMessage)
      refreshMessageList()
    }
  } catch (error: any) {
    if (error?.name === 'AbortError' && streamRecoveryAbortRequested) return

    if (error?.name === 'AbortError' && userAbortRequested) {
      assistantMessage.status = 'done'
      markToolsDone(assistantMessage)
      refreshMessageList()
      return
    }

    if (isRecoverableStreamDisconnect(error)) {
      shouldSaveClientSnapshot = false
      beginStreamRecovery(sessionId.value, streamStartedAt)
      assistantMessage.status = 'streaming'
      refreshMessageList()
      if (document.visibilityState === 'visible') scheduleStreamRecovery(1200)
      return
    }

    assistantMessage.status = 'error'
    replaceAssistantTextSegments(assistantMessage, error?.message || t('agentAssistant.error'))
    markToolsDone(assistantMessage)
    refreshMessageList()
  } finally {
    abortController = null
    activeStreamStartedAt = 0
    userAbortRequested = false
    streamRecoveryAbortRequested = false
    clearStreamPersistTimer()
    persistState()
    if (shouldSaveClientSnapshot) {
      try {
        await saveCurrentSessionToServer()
        await loadServerHistorySessions()
      } catch (error) {
        // 服务端历史保存失败时保留本地兜底历史，不影响当前会话继续交互。
      }
    }
    if (shouldFollowBottomAfterStream) scrollToBottom()
  }
}

// 发送输入框中的文本和附件。
async function sendMessage() {
  const text = inputText.value.trim()
  const attachments = [...pendingAttachments.value]
  if ((!text && !attachments.length) || isBusy.value) return

  streamError.value = ''
  inputText.value = ''
  clearPendingAttachments()
  syncInputHeight()
  sending.value = true

  try {
    const prepared = await prepareAgentAttachments(attachments)
    await streamAgentMessage(text, prepared.images, prepared.files, prepared.audioRefs, prepared.userAttachments)
  } catch (error: any) {
    // 附件准备失败同样落到对话消息里，底部提示条只保留给没有消息承载的本地错误。
    addMessage('assistant', error?.message || t('agentAssistant.uploadFailed'), 'error')
  } finally {
    sending.value = false
  }
}

// 获取当前浏览器支持的录音 MIME 类型。
function getRecorderMimeType() {
  const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus']
  return candidates.find(type => MediaRecorder.isTypeSupported(type)) || ''
}

// 根据录音 MIME 类型推断文件扩展名。
function getRecordingFileExtension(mimeType: string) {
  if (mimeType.includes('mp4')) return 'm4a'
  if (mimeType.includes('ogg')) return 'ogg'
  return 'webm'
}

// 停止录音媒体流，释放麦克风。
function stopRecordingStream() {
  mediaRecorderStream?.getTracks().forEach(track => track.stop())
  mediaRecorderStream = null
}

// 清理录音计时器。
function clearRecordingTimer() {
  if (recordingTimer === null) return

  window.clearInterval(recordingTimer)
  recordingTimer = null
}

// 结束录音状态并释放相关资源。
function finishRecordingState() {
  recording.value = false
  recordingStartedAt.value = 0
  recordingDuration.value = 0
  mediaRecorder = null
  clearRecordingTimer()
  stopRecordingStream()
}

// 开始录制语音消息。
async function startVoiceRecording() {
  if (!canRecord.value) return
  if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
    streamError.value = t('agentAssistant.recordUnsupported')
    return
  }

  try {
    streamError.value = ''
    recordingChunks = []
    const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
    const mimeType = getRecorderMimeType()
    const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream)

    mediaRecorderStream = stream
    mediaRecorder = recorder
    recording.value = true
    recordingStartedAt.value = Date.now()
    recordingDuration.value = 0
    recordingTimer = window.setInterval(() => {
      recordingDuration.value = Math.floor((Date.now() - recordingStartedAt.value) / 1000)
    }, 500)

    recorder.ondataavailable = event => {
      if (event.data.size > 0) recordingChunks.push(event.data)
    }
    recorder.onstop = () => {
      const recordedMimeType = recorder.mimeType || mimeType || 'audio/webm'
      const audioBlob = new Blob(recordingChunks, { type: recordedMimeType })
      const extension = getRecordingFileExtension(recordedMimeType)
      const file = new File([audioBlob], `voice-${Date.now()}.${extension}`, { type: recordedMimeType })

      finishRecordingState()
      recordingChunks = []

      if (audioBlob.size <= 0) {
        streamError.value = t('agentAssistant.recordFailed')
        return
      }

      pendingAttachments.value.push({
        id: createId('recording'),
        file,
        kind: 'audio',
        name: file.name,
        mime_type: recordedMimeType,
        size: file.size,
      })
      sendMessage()
    }
    recorder.onerror = () => {
      finishRecordingState()
      recordingChunks = []
      streamError.value = t('agentAssistant.recordFailed')
    }
    recorder.start()
  } catch (error: any) {
    finishRecordingState()
    recordingChunks = []
    streamError.value = error?.message || t('agentAssistant.recordPermissionDenied')
  }
}

// 停止当前录音并触发文件生成。
function stopVoiceRecording() {
  if (!mediaRecorder || mediaRecorder.state === 'inactive') {
    finishRecordingState()
    return
  }

  mediaRecorder.stop()
}

// 取消当前录音并丢弃已录制内容。
function cancelVoiceRecording() {
  if (mediaRecorder) {
    mediaRecorder.ondataavailable = null
    mediaRecorder.onstop = null
    mediaRecorder.onerror = null
    if (mediaRecorder.state !== 'inactive') mediaRecorder.stop()
  }
  mediaRecorder = null
  recordingChunks = []
  finishRecordingState()
}

// 切换语音录制状态。
function toggleVoiceRecording() {
  if (recording.value) {
    stopVoiceRecording()
    return
  }

  startVoiceRecording()
}

// 处理选择按钮点击，保存可读选择描述并把真实值发给 Agent。
async function handleChoiceClick(message: AgentChatMessage, choice: AgentChoiceCard, button: AgentChoiceButton) {
  if (isBusy.value || choice.status !== 'pending') return

  sending.value = true
  streamError.value = ''

  try {
    const response = await fetch(resolveApiUrl('message/agent/callback'), {
      method: 'POST',
      headers: buildAgentRequestHeaders({
        'Content-Type': 'application/json',
      }),
      body: JSON.stringify({
        session_id: sessionId.value,
        callback_data: button.callback_data,
        original_message_id: message.id,
        original_chat_id: sessionId.value,
      }),
      credentials: 'include',
    })

    if (!response.ok) throw new Error(await resolveAgentResponseErrorMessage(response))

    const result = await response.json()
    if (!result?.success) throw new Error(result?.message_i18n || result?.message || t('agentAssistant.choiceExpired'))

    const agentMessage = String(result.data?.message || '')
    if (result.data?.traditional) {
      const choiceSelection = buildChoiceSelection(choice, button)
      choiceSelection.selected_description = getChoiceButtonSelectionText(button)
      markChoiceSelected(choice, button, choiceSelection)
      refreshMessageList()
      persistState()
      await streamAgentMessage(agentMessage, [], [], [], [], {
        echoUser: false,
        displayText: choiceSelection.selected_label || choiceSelection.selected_description,
        choiceSelection,
        originalMessageId: String(result.data?.original_message_id || message.id),
        originalChatId: String(result.data?.original_chat_id || sessionId.value),
      })
      return
    }

    const backendSelection = normalizeChoiceSelection(result.data?.choice_selection)
    const choiceSelection = backendSelection || buildChoiceSelection(choice, button)
    choiceSelection.selected_label =
      result.data?.feedback?.selected_label || choiceSelection.selected_label || button.label
    choiceSelection.selected_value =
      result.data?.feedback?.selected_value || choiceSelection.selected_value || agentMessage
    choiceSelection.selected_description =
      result.data?.display_message ||
      result.data?.feedback?.selected_description ||
      choiceSelection.selected_description ||
      getChoiceButtonSelectionText(button)

    markChoiceSelected(choice, button, choiceSelection)
    refreshMessageList()
    persistState()

    await streamAgentMessage(agentMessage, [], [], [], [], {
      echoUser: false,
      displayText: choiceSelection.selected_label || choiceSelection.selected_description,
      choiceSelection,
    })
  } catch (error: any) {
    choice.status = 'expired'
    streamError.value = error?.message || t('agentAssistant.choiceExpired')
    refreshMessageList()
    persistState()
  } finally {
    sending.value = false
  }
}

// 中止当前流式回复。
function stopGeneration() {
  userAbortRequested = true
  pendingStreamRecovery.value = null
  clearStreamRecoveryTimer()
  const assistantMessage = [...messages.value]
    .reverse()
    .find(message => message.role === 'assistant' && message.status === 'streaming')
  if (assistantMessage) {
    if (isEmptyAssistantMessage(assistantMessage)) {
      messages.value = messages.value.filter(message => message.id !== assistantMessage.id)
    } else {
      assistantMessage.status = 'done'
      markToolsDone(assistantMessage)
    }
    refreshMessageList()
  }
  persistState()
  if (sessionId.value) {
    fetchAgentApi(`message/agent/sessions/${encodeURIComponent(sessionId.value)}/stop`, {
      method: 'POST',
    }).catch(() => {
      // 本地中止优先，停止接口失败不阻塞用户操作。
    })
  }
  abortController?.abort()
}

// 开始新的空白会话。
function startNewSession() {
  stopGeneration()
  sessionId.value = createSessionId()
  messages.value = []
  streamError.value = ''
  historyMenuOpen.value = false
  clearPendingAttachments()
  persistState()
  scrollToTop()
}

// 从历史列表恢复指定会话，同时把它设为当前本地会话。
async function loadHistorySession(targetSessionId: string) {
  if (isBusy.value) return

  let historySession = historySessions.value.find(item => item.sessionId === targetSessionId)
  if (!historySession) return

  try {
    stopGeneration()
    if (!historySession.messages.length) {
      historySession = await loadServerHistorySession(targetSessionId)
    }
    sessionId.value = historySession.sessionId
    messages.value = normalizeStoredMessages(historySession.messages)
    streamError.value = ''
    historyMenuOpen.value = false
    clearPendingAttachments()
    persistState({ syncHistory: false })
    scrollToBottom()
  } catch (error: any) {
    streamError.value = error?.message || t('agentAssistant.historyLoadFailed')
  }
}

// 删除指定历史会话；若删除的是当前会话，则切换到新的空会话。
async function deleteHistorySession(targetSessionId: string) {
  if (isBusy.value && targetSessionId === sessionId.value) return

  try {
    await fetchAgentApi(`message/agent/sessions/${encodeURIComponent(targetSessionId)}`, {
      method: 'DELETE',
    })
  } catch (error) {
    // 删除接口失败时仍允许清理本地兜底历史，避免坏记录一直挡在列表里。
  } finally {
    historySessions.value = historySessions.value.filter(item => item.sessionId !== targetSessionId)
    persistHistorySessions()
  }

  if (targetSessionId === sessionId.value) startNewSession()
}

// 判断历史项是否为当前打开的会话，用于高亮列表状态。
function isCurrentHistorySession(targetSessionId: string) {
  return targetSessionId === sessionId.value
}

// 格式化历史会话时间，显示为本地日期和时间。
function formatHistoryTime(timestamp: number) {
  if (!timestamp) return ''

  return new Intl.DateTimeFormat(undefined, {
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
  }).format(new Date(timestamp))
}

// 关闭智能助手面板。
function closeDrawer() {
  isOpen.value = false
}

// 在桌面侧栏与全屏对话模式之间切换。
function toggleFullscreen() {
  fullscreen.value = !fullscreen.value
}

// 同步面板打开状态到全局 DOM，供悬浮入口避让面板宽度。
function syncAgentAssistantOpenState(isOpen: boolean) {
  if (typeof document === 'undefined') return

  const roots = [document.documentElement, document.body]
  roots.forEach(root => {
    if (isOpen) {
      root.setAttribute('data-agent-assistant-open', 'true')
      return
    }

    root.removeAttribute('data-agent-assistant-open')
  })

  if (isOpen) {
    document.documentElement.style.setProperty('--agent-assistant-fab-offset', drawerWidth.value)
  } else {
    document.documentElement.style.removeProperty('--agent-assistant-fab-offset')
  }
}

// 清理面板打开状态的全局 DOM 标记。
function clearAgentAssistantOpenState() {
  syncAgentAssistantOpenState(false)
}

// 处理全局快捷键。
function handleGlobalKeydown(event: KeyboardEvent) {
  if (event.key === 'Escape' && isOpen.value) closeDrawer()
}

// 页面进入后台时保存流式占位，恢复可见时尝试拉取 WebAgent 后台完成后的会话快照。
function handleVisibilityChange() {
  if (document.visibilityState === 'hidden') {
    if (sending.value && activeStreamStartedAt && sessionId.value) {
      beginStreamRecovery(sessionId.value, activeStreamStartedAt)
    } else if (pendingStreamRecovery.value) {
      clearStreamPersistTimer()
      persistState({ syncHistory: false })
    }
    return
  }

  scheduleStreamRecovery(0)
}

// PWA 从页面缓存或系统挂起状态返回时补触发恢复，兼容未派发 visibilitychange 的浏览器。
function handlePageShow() {
  if (document.visibilityState === 'visible') scheduleStreamRecovery(0)
}

// 处理输入框回车发送。
function handleInputKeydown(event: KeyboardEvent) {
  if (event.key !== 'Enter' || event.shiftKey || isComposing.value || event.isComposing || event.keyCode === 229) return
  event.preventDefault()
  sendMessage()
}

// 开始输入法组合输入，避免回车确认候选词时误发送消息。
function handleCompositionStart() {
  isComposing.value = true
}

// 结束输入法组合输入，恢复回车发送能力。
function handleCompositionEnd() {
  isComposing.value = false
}

watch(isOpen, syncAgentAssistantOpenState, { immediate: true })
watch(drawerWidth, () => {
  if (isOpen.value) syncAgentAssistantOpenState(true)
})

watch(isOpen, open => {
  if (open) scrollToBottom()
})

watch(isBusy, value => emit('thinking-change', value), { immediate: true })

onMounted(() => {
  restoreHistorySessions()
  restoreState()
  loadServerHistorySessions()
  if (pendingStreamRecovery.value && document.visibilityState === 'visible') scheduleStreamRecovery(0)
  syncInputHeight()
  window.addEventListener('keydown', handleGlobalKeydown)
  window.addEventListener('pageshow', handlePageShow)
  document.addEventListener('visibilitychange', handleVisibilityChange)
})

onScopeDispose(clearAgentAssistantOpenState)
onScopeDispose(clearPendingAttachments)
onScopeDispose(cancelVoiceRecording)
onScopeDispose(clearMessageScrollFrame)
onScopeDispose(clearStreamPersistTimer)
onScopeDispose(clearPendingStreamDelta)
onScopeDispose(clearStreamRecoveryTimer)
onScopeDispose(() => {
  if (typeof window === 'undefined') return

  window.removeEventListener('keydown', handleGlobalKeydown)
  window.removeEventListener('pageshow', handlePageShow)
  document.removeEventListener('visibilitychange', handleVisibilityChange)
})
</script>

<template>
  <aside
    v-show="isOpen"
    class="agent-assistant-panel"
    :class="{
      'is-fullscreen': fullscreen,
      'is-motion-paused': !props.motionActive,
      'is-open': isOpen,
    }"
    :style="drawerStyle"
    role="dialog"
    :aria-label="t('agentAssistant.title')"
    @focusin.stop
  >
    <div class="agent-assistant-shell">
      <header class="agent-assistant-header">
        <div class="agent-assistant-title">
          <div class="agent-assistant-title__mark">
            <span class="agent-assistant-mini-bot" aria-hidden="true">
              <span class="agent-assistant-mini-bot__antenna" />
              <span class="agent-assistant-mini-bot__head">
                <span class="agent-assistant-mini-bot__face">
                  <span class="agent-assistant-mini-bot__eye agent-assistant-mini-bot__eye--left" />
                  <span class="agent-assistant-mini-bot__eye agent-assistant-mini-bot__eye--right" />
                </span>
              </span>
              <span class="agent-assistant-mini-bot__body" />
            </span>
          </div>
          <div>
            <div class="text-subtitle-1 font-weight-semibold">{{ t('agentAssistant.title') }}</div>
            <div class="agent-assistant-status">
              {{ isBusy ? t('agentAssistant.thinking') : t('agentAssistant.ready') }}
            </div>
          </div>
        </div>
        <div class="d-flex align-center gap-1">
          <VMenu
            v-model="historyMenuOpen"
            :close-on-content-click="false"
            location="bottom end"
            offset="8"
            max-width="360"
            :style="{ zIndex: AGENT_ASSISTANT_LAYER_Z_INDEX.overlay }"
            :z-index="AGENT_ASSISTANT_LAYER_Z_INDEX.overlay"
          >
            <template #activator="{ props }">
              <IconBtn v-bind="props" :title="t('agentAssistant.history')" :aria-label="t('agentAssistant.history')">
                <VIcon icon="mdi-history" />
              </IconBtn>
            </template>
            <VCard class="agent-assistant-history-menu" elevation="10">
              <div class="agent-assistant-history-menu__header">
                <span>{{ t('agentAssistant.history') }}</span>
              </div>
              <VDivider />
              <div
                class="agent-assistant-history-list"
                :class="{ 'agent-assistant-history-list--empty': historyLoading || !hasHistorySessions }"
              >
                <div v-if="historyLoading" class="agent-assistant-history-empty">
                  {{ t('agentAssistant.historyLoading') }}
                </div>
                <div v-else-if="!hasHistorySessions" class="agent-assistant-history-empty">
                  {{ t('agentAssistant.noHistory') }}
                </div>
                <VInfiniteScroll
                  v-else
                  mode="intersect"
                  side="end"
                  :items="historySessions"
                  class="agent-assistant-history-infinite"
                  @load="handleHistoryInfiniteLoad"
                >
                  <VVirtualScroll renderless :items="historySessions" :item-height="HISTORY_ITEM_HEIGHT">
                    <template #default="{ item: historySession, itemRef }">
                      <button
                        :ref="itemRef"
                        :key="historySession.sessionId"
                        class="agent-assistant-history-item"
                        :class="{ 'is-active': isCurrentHistorySession(historySession.sessionId) }"
                        type="button"
                        :disabled="isBusy"
                        @click="loadHistorySession(historySession.sessionId)"
                      >
                        <span class="agent-assistant-history-item__content">
                          <span class="agent-assistant-history-item__title">{{ historySession.title }}</span>
                          <span class="agent-assistant-history-item__channel">
                            {{ getSessionChannelLabel(historySession) }}
                          </span>
                          <span class="agent-assistant-history-item__time">
                            {{ formatHistoryTime(historySession.updatedAt) }}
                          </span>
                        </span>
                        <IconBtn
                          size="x-small"
                          :disabled="isBusy"
                          :title="t('agentAssistant.deleteHistory')"
                          :aria-label="t('agentAssistant.deleteHistory')"
                          @click.stop="deleteHistorySession(historySession.sessionId)"
                        >
                          <VIcon icon="mdi-delete-outline" size="16" />
                        </IconBtn>
                      </button>
                    </template>
                  </VVirtualScroll>
                  <template #empty />
                  <template #loading>
                    <div class="agent-assistant-history-loading">
                      {{ t('agentAssistant.historyLoading') }}
                    </div>
                  </template>
                </VInfiniteScroll>
              </div>
            </VCard>
          </VMenu>
          <IconBtn
            :disabled="isBusy"
            :title="t('agentAssistant.newChat')"
            :aria-label="t('agentAssistant.newChat')"
            @click="startNewSession"
          >
            <VIcon icon="mdi-message-plus-outline" />
          </IconBtn>
          <IconBtn
            v-if="canToggleFullscreen"
            class="agent-assistant-fullscreen-toggle"
            :title="t(fullscreen ? 'agentAssistant.exitFullscreen' : 'agentAssistant.enterFullscreen')"
            :aria-label="t(fullscreen ? 'agentAssistant.exitFullscreen' : 'agentAssistant.enterFullscreen')"
            :aria-pressed="fullscreen"
            @click="toggleFullscreen"
          >
            <VIcon :icon="fullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'" />
          </IconBtn>
          <IconBtn :title="t('common.close')" :aria-label="t('common.close')" @click="closeDrawer">
            <VIcon icon="mdi-close" />
          </IconBtn>
        </div>
      </header>

      <main
        ref="messageListRef"
        class="agent-assistant-messages"
        :class="{ 'agent-assistant-messages--has-content': hasMessages }"
        @scroll.passive="handleMessageScrollerScroll"
      >
        <div class="agent-assistant-messages__content">
          <div v-if="!hasMessages" class="agent-assistant-empty">
            <div class="agent-assistant-empty__mark">
              <VIcon icon="lucide:sparkles" size="28" />
            </div>
            <div class="agent-assistant-empty__title">{{ t('agentAssistant.emptyTitle') }}</div>
            <div class="agent-assistant-empty__subtitle">{{ t('agentAssistant.emptySubtitle') }}</div>
          </div>

          <div
            v-for="message in messages"
            :key="message.id"
            class="agent-assistant-message"
            :class="`agent-assistant-message--${message.role}`"
          >
            <div class="agent-assistant-message__meta">
              <VIcon :icon="message.role === 'user' ? 'mdi-account-circle-outline' : 'lucide:bot'" size="16" />
              <span>{{ message.role === 'user' ? currentUserName : t('agentAssistant.assistant') }}</span>
            </div>

            <div
              v-if="message.role === 'assistant' && (message.tools.length || message.content.trim())"
              class="agent-assistant-segments"
            >
              <template v-for="segment in getRenderableMessageSegments(message)" :key="segment.key">
                <AgentMarkdownContent
                  v-if="segment.type === 'text'"
                  :content="segment.content"
                  :streaming="message.status === 'streaming'"
                />
                <div v-else class="agent-assistant-tool">
                  <VIcon
                    :icon="
                      segment.tool.status === 'running' && message.status === 'streaming'
                        ? 'line-md:loading-twotone-loop'
                        : 'mdi-check-circle-outline'
                    "
                    size="16"
                  />
                  <span>{{ segment.tool.message }}</span>
                </div>
              </template>
            </div>

            <AgentMarkdownContent
              v-else-if="message.content"
              :content="message.content"
              :streaming="message.status === 'streaming'"
            />

            <div v-if="message.choices.length" class="agent-assistant-choices">
              <div v-for="choice in message.choices" :key="choice.id" class="agent-assistant-choice">
                <div class="agent-assistant-choice__bubble">
                  <div v-if="choice.title" class="agent-assistant-choice__title">{{ choice.title }}</div>
                  <AgentMarkdownContent :content="choice.prompt" variant="choice" />
                  <div v-if="choice.status === 'selected'" class="agent-assistant-choice__selected">
                    <VIcon icon="mdi-check-circle-outline" size="16" />
                    <span>{{
                      t('agentAssistant.choiceSelected', {
                        option: choice.selected_label || choice.selected_description,
                      })
                    }}</span>
                  </div>
                  <div v-else-if="choice.status === 'expired'" class="agent-assistant-choice__selected is-expired">
                    <VIcon icon="mdi-alert-circle-outline" size="16" />
                    <span>{{ t('agentAssistant.choiceExpired') }}</span>
                  </div>
                </div>
                <div class="agent-assistant-choice__buttons">
                  <VBtn
                    v-for="button in choice.buttons"
                    :key="button.callback_data"
                    class="agent-assistant-choice__button"
                    size="small"
                    variant="flat"
                    :disabled="isBusy || choice.status !== 'pending'"
                    @click="handleChoiceClick(message, choice, button)"
                  >
                    {{ button.label }}
                  </VBtn>
                </div>
              </div>
            </div>

            <div v-if="message.attachments.length" class="agent-assistant-attachments">
              <div
                v-for="attachment in message.attachments"
                :key="`${message.id}-${attachment.url}`"
                class="agent-assistant-attachment"
                :class="`agent-assistant-attachment--${attachment.kind}`"
              >
                <img
                  v-if="attachment.kind === 'image'"
                  class="agent-assistant-attachment__image"
                  :src="resolveAttachmentUrl(attachment.url)"
                  :alt="getAttachmentName(attachment)"
                  loading="lazy"
                />

                <template v-else-if="attachment.kind === 'audio'">
                  <div class="agent-assistant-attachment__meta">
                    <VIcon :icon="getAttachmentIcon(attachment)" size="18" />
                    <span>{{ getAttachmentName(attachment) }}</span>
                  </div>
                  <audio
                    class="agent-assistant-attachment__audio"
                    controls
                    :src="resolveAttachmentUrl(attachment.url)"
                  />
                  <VBtn
                    class="agent-assistant-surface-btn"
                    :href="getAttachmentDownloadUrl(attachment)"
                    :download="getAttachmentName(attachment)"
                    size="small"
                    variant="tonal"
                    color="primary"
                    prepend-icon="mdi-download"
                  >
                    {{ t('agentAssistant.download') }}
                  </VBtn>
                </template>

                <template v-else>
                  <div class="agent-assistant-attachment__file">
                    <VIcon :icon="getAttachmentIcon(attachment)" size="22" />
                    <div class="agent-assistant-attachment__file-text">
                      <span>{{ getAttachmentName(attachment) }}</span>
                      <small>{{ attachment.mime_type || formatAttachmentSize(attachment.size) }}</small>
                    </div>
                    <VBtn
                      class="agent-assistant-surface-btn"
                      :href="getAttachmentDownloadUrl(attachment)"
                      :download="getAttachmentName(attachment)"
                      icon
                      variant="text"
                      color="primary"
                      :aria-label="t('agentAssistant.download')"
                    >
                      <VIcon icon="mdi-download" />
                    </VBtn>
                  </div>
                </template>
              </div>
            </div>

            <div
              v-if="
                !message.content &&
                !message.segments.length &&
                !message.attachments.length &&
                !message.choices.length &&
                message.status === 'streaming'
              "
              class="agent-assistant-typing"
            >
              <span />
              <span />
              <span />
            </div>
          </div>
        </div>
      </main>

      <footer class="agent-assistant-composer">
        <VAlert v-if="streamError" type="error" variant="tonal" density="compact" class="mb-3">
          {{ streamError }}
        </VAlert>
        <PerfectScrollbar
          v-if="pendingAttachments.length"
          class="agent-assistant-pending-files"
          :options="{ wheelPropagation: false }"
        >
          <div v-for="attachment in pendingAttachments" :key="attachment.id" class="agent-assistant-pending-file">
            <img
              v-if="attachment.kind === 'image' && attachment.preview_url"
              class="agent-assistant-pending-file__preview"
              :src="attachment.preview_url"
              :alt="attachment.name"
            />
            <VIcon v-else :icon="getAttachmentIcon(attachment)" size="18" />
            <div class="agent-assistant-pending-file__text">
              <span>{{ attachment.name }}</span>
              <small>{{ formatAttachmentSize(attachment.size) || attachment.mime_type }}</small>
            </div>
            <IconBtn
              class="agent-assistant-surface-btn"
              size="x-small"
              :disabled="isBusy"
              :title="t('agentAssistant.removeAttachment')"
              :aria-label="t('agentAssistant.removeAttachment')"
              @click="removePendingAttachment(attachment.id)"
            >
              <VIcon icon="mdi-close" size="16" />
            </IconBtn>
          </div>
        </PerfectScrollbar>
        <div v-if="showSlashCommandMenu" class="agent-assistant-command-menu">
          <div
            v-for="command in filteredSlashCommands"
            :key="command.command"
            class="agent-assistant-command"
            role="button"
            tabindex="0"
            @click="selectSlashCommand(command)"
            @keydown.enter.prevent="selectSlashCommand(command)"
          >
            <span class="agent-assistant-command__name">{{ command.command }}</span>
            <span class="agent-assistant-command__desc">{{ command.description }}</span>
          </div>
          <div v-if="slashCommandsLoading" class="agent-assistant-command is-loading">
            {{ t('agentAssistant.commandLoading') }}
          </div>
        </div>
        <div class="agent-assistant-input">
          <input
            ref="fileInputRef"
            class="agent-assistant-file-input"
            type="file"
            multiple
            :disabled="isBusy"
            @change="handleFileSelection"
          />
          <IconBtn
            class="agent-assistant-attach agent-assistant-surface-btn"
            :disabled="isBusy || recording"
            :title="t('agentAssistant.attachFile')"
            :aria-label="t('agentAssistant.attachFile')"
            @click="openFilePicker"
          >
            <VIcon icon="mdi-plus" />
          </IconBtn>
          <textarea
            ref="inputRef"
            v-model="inputText"
            class="agent-assistant-textarea"
            rows="1"
            :disabled="isBusy || recording"
            :placeholder="inputPlaceholder"
            @input="handleInputChange"
            @keydown="handleInputKeydown"
            @compositionstart="handleCompositionStart"
            @compositionend="handleCompositionEnd"
          />
          <IconBtn
            class="agent-assistant-record agent-assistant-surface-btn"
            :class="{ 'is-recording': recording }"
            :disabled="!recording && !canRecord"
            :title="
              recording
                ? t('agentAssistant.stopRecording', { time: recordingTimeText })
                : t('agentAssistant.recordVoice')
            "
            :aria-label="
              recording
                ? t('agentAssistant.stopRecording', { time: recordingTimeText })
                : t('agentAssistant.recordVoice')
            "
            @click="toggleVoiceRecording"
          >
            <VIcon :icon="recording ? 'mdi-stop-circle-outline' : 'mdi-microphone-outline'" />
          </IconBtn>
          <IconBtn
            class="agent-assistant-send agent-assistant-surface-btn"
            :disabled="!isBusy && !canSend"
            :title="isBusy ? t('agentAssistant.stop') : t('common.send')"
            :aria-label="isBusy ? t('agentAssistant.stop') : t('common.send')"
            @click="isBusy ? stopGeneration() : sendMessage()"
          >
            <VIcon :icon="isBusy ? 'mdi-stop' : 'mdi-send'" />
          </IconBtn>
        </div>
      </footer>
    </div>
  </aside>
</template>

<style lang="scss" scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
/* stylelint-disable no-descending-specificity */

.agent-assistant-panel {
  position: fixed;
  overflow: hidden;
  background: rgb(var(--v-theme-surface));

  /* 背景层必须覆盖完整视口，不能跟随 iOS 键盘后的 visual viewport 缩短。 */
  block-size: 100vh !important;
  border-inline-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
  box-shadow: var(--app-surface-shadow);
  inline-size: var(--agent-assistant-panel-width, 30rem);
  inset-block-start: 0;
  inset-inline-end: 0;
  max-block-size: none !important;
  min-block-size: 100vh !important;
  overscroll-behavior: contain;
}

.agent-assistant-panel.is-fullscreen {
  border-inline-start: 0;
  box-shadow: none;

  .agent-assistant-message__bubble {
    max-inline-size: min(100%, 64rem);
  }

  .agent-assistant-segments,
  .agent-assistant-choices,
  .agent-assistant-attachments {
    inline-size: min(100%, 64rem);
  }

  .agent-assistant-composer {
    inline-size: min(calc(100% - 2rem), 72rem);
    inset-inline: 0;
    margin-inline: auto;
  }
}

@supports (block-size: 100lvh) {
  .agent-assistant-panel {
    block-size: 100lvh !important;
    min-block-size: 100lvh !important;
  }
}

.agent-assistant-shell {
  position: relative;
  display: grid;
  block-size: 100vh;
  grid-template-rows: auto minmax(0, 1fr);
  min-block-size: 0;

  --agent-assistant-assistant-bg: rgba(var(--v-theme-surface), 0.92);
  --agent-assistant-assistant-border: rgba(var(--v-theme-on-surface), 0.08);
  --agent-assistant-panel-bg: rgba(var(--v-theme-surface), 0.94);
  --agent-assistant-panel-blur: 10px;
}

@supports (block-size: 100dvh) {
  .agent-assistant-shell {
    block-size: 100dvh;
  }
}

.agent-assistant-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
  padding-block: calc(env(safe-area-inset-top, 0px) + 0.8rem) 0.8rem;
  padding-inline: 1rem;
}

.agent-assistant-title {
  display: flex;
  align-items: center;
  column-gap: 0.75rem;
  min-inline-size: 0;
}

.agent-assistant-title__mark {
  display: inline-flex;
  flex: 0 0 auto;
  align-items: center;
  justify-content: center;
  border-radius: var(--app-control-radius);

  --agent-assistant-mini-robot-outline: color-mix(in srgb, rgb(var(--v-theme-primary)) 72%, #090510 28%);
  --agent-assistant-mini-robot-outline-soft: color-mix(in srgb, rgb(var(--v-theme-primary)) 84%, #090510 16%);
  --agent-assistant-mini-robot-shell-start: color-mix(in srgb, rgb(var(--v-theme-primary)) 38%, white 62%);
  --agent-assistant-mini-robot-shell-mid: color-mix(in srgb, rgb(var(--v-theme-primary)) 74%, white 26%);
  --agent-assistant-mini-robot-shell-end: rgb(var(--v-theme-primary));
  --agent-assistant-mini-robot-face-start: color-mix(in srgb, rgb(var(--v-theme-primary)) 30%, #080a12 70%);
  --agent-assistant-mini-robot-face-end: color-mix(in srgb, rgb(var(--v-theme-primary)) 14%, #03050a 86%);
  --agent-assistant-mini-robot-eye: color-mix(in srgb, rgb(var(--v-theme-primary)) 12%, white 88%);
  --agent-assistant-mini-robot-shade: color-mix(in srgb, rgb(var(--v-theme-primary)) 22%, transparent);

  background: rgba(var(--v-theme-primary), 0.12);
  block-size: 2.5rem;
  color: rgb(var(--v-theme-primary));
  inline-size: 2.5rem;
}

.agent-assistant-mini-bot,
.agent-assistant-mini-bot span {
  box-sizing: border-box;
}

.agent-assistant-mini-bot {
  position: relative;
  display: block;
  block-size: 1.85rem;
  inline-size: 1.85rem;
}

.agent-assistant-mini-bot__antenna {
  position: absolute;
  display: block;
  border-radius: 999px;
  background: var(--agent-assistant-mini-robot-outline);
  block-size: 0.42rem;
  inline-size: 0.12rem;
  inset-block-start: 0;
  inset-inline-start: 1.18rem;
  transform: rotate(20deg);
  transform-origin: bottom center;
}

.agent-assistant-mini-bot__antenna::after {
  position: absolute;
  border: 1.5px solid var(--agent-assistant-mini-robot-outline);
  border-radius: 999px;
  background: var(--agent-assistant-mini-robot-shell-start);
  block-size: 0.28rem;
  content: '';
  inline-size: 0.28rem;
  inset-block-start: -0.24rem;
  inset-inline-start: -0.09rem;
}

.agent-assistant-mini-bot__head {
  position: absolute;
  display: block;
  border: 1.5px solid var(--agent-assistant-mini-robot-outline);
  border-radius: 8px;
  background: linear-gradient(
    145deg,
    var(--agent-assistant-mini-robot-shell-start) 0%,
    var(--agent-assistant-mini-robot-shell-end) 100%
  );
  block-size: 1.04rem;
  box-shadow:
    inset 0 -0.12rem 0 var(--agent-assistant-mini-robot-shade),
    inset 0.08rem 0.08rem 0 rgba(255, 255, 255, 22%);
  inline-size: 1.45rem;
  inset-block-start: 0.42rem;
  inset-inline-start: 0.2rem;
}

.agent-assistant-mini-bot__face {
  position: absolute;
  display: block;
  border: 1.5px solid var(--agent-assistant-mini-robot-outline-soft);
  border-radius: 6px;
  background: linear-gradient(
    180deg,
    var(--agent-assistant-mini-robot-face-start) 0%,
    var(--agent-assistant-mini-robot-face-end) 100%
  );
  block-size: 0.62rem;
  inline-size: 1rem;
  inset-block-start: 0.18rem;
  inset-inline-start: 0.16rem;
}

.agent-assistant-mini-bot__eye {
  position: absolute;
  display: block;
  border-radius: 0 0 999px 999px;
  block-size: 0.24rem;
  border-block-end: 0.1rem solid var(--agent-assistant-mini-robot-eye);
  inline-size: 0.22rem;
  inset-block-start: 0.16rem;
}

.agent-assistant-panel.is-open:not(.is-motion-paused) .agent-assistant-mini-bot__eye {
  animation: agent-fab-blink 4.8s ease-in-out 1;
}

.agent-assistant-mini-bot__eye--left {
  inset-inline-start: 0.22rem;
}

.agent-assistant-mini-bot__eye--right {
  inset-inline-end: 0.22rem;
}

.agent-assistant-mini-bot__body {
  position: absolute;
  display: block;
  border: 1.5px solid var(--agent-assistant-mini-robot-outline);
  border-radius: 0.4rem;
  background: linear-gradient(
    145deg,
    var(--agent-assistant-mini-robot-shell-mid) 0%,
    var(--agent-assistant-mini-robot-shell-end) 82%
  );
  block-size: 0.54rem;
  inline-size: 0.98rem;
  inset-block-start: 1.3rem;
  inset-inline-start: 0.44rem;
}

.agent-assistant-status {
  color: rgba(var(--v-theme-on-surface), 0.62);
  font-size: 0.78rem;
}

.agent-assistant-history-menu {
  overflow: hidden;
  border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
  border-radius: var(--app-overlay-radius);
  inline-size: min(22rem, calc(100vw - 2rem));
}

.agent-assistant-history-menu__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  color: rgba(var(--v-theme-on-surface), 0.9);
  font-size: 0.9rem;
  font-weight: 700;
  min-block-size: 3rem;
  padding-block: 0.45rem;
  padding-inline: 0.9rem 0.55rem;
}

.agent-assistant-history-list {
  block-size: min(26rem, calc(100vh - 7rem));
  max-block-size: min(26rem, calc(100vh - 7rem));
  overflow-y: auto;
  overscroll-behavior: contain;
  padding-block: 0.35rem;
}

.agent-assistant-history-list--empty {
  block-size: auto;
  max-block-size: none;
}

@supports (block-size: 100lvh) {
  .agent-assistant-history-list {
    block-size: min(26rem, calc(100lvh - 7rem));
    max-block-size: min(26rem, calc(100lvh - 7rem));
  }

  .agent-assistant-history-list--empty {
    block-size: auto;
    max-block-size: none;
  }
}

.agent-assistant-history-infinite {
  gap: 0.25rem;
  min-block-size: 100%;
}

.agent-assistant-history-empty {
  color: rgba(var(--v-theme-on-surface), 0.58);
  font-size: 0.85rem;
  padding-block: 1.5rem;
  padding-inline: 1rem;
  text-align: center;
}

.agent-assistant-history-loading {
  color: rgba(var(--v-theme-on-surface), 0.48);
  font-size: 0.75rem;
  padding-block: 0.75rem;
  text-align: center;
}

.agent-assistant-history-item {
  display: grid;
  align-items: center;
  border: 0;
  border-radius: var(--app-control-radius);
  background: transparent;
  color: inherit;
  column-gap: 0.4rem;
  cursor: pointer;
  grid-template-columns: minmax(0, 1fr) auto;
  inline-size: calc(100% - 0.7rem);
  margin-inline: 0.35rem;
  min-block-size: 4.75rem;
  padding-block: 0.55rem;
  padding-inline: 0.65rem 0.25rem;
  text-align: start;
}

.agent-assistant-history-item:hover,
.agent-assistant-history-item.is-active {
  background: rgba(var(--v-theme-primary), 0.1);
}

.agent-assistant-history-item:disabled {
  cursor: default;
  opacity: 0.62;
}

.agent-assistant-history-item__content {
  display: grid;
  min-inline-size: 0;
  row-gap: 0.15rem;
}

.agent-assistant-history-item__title,
.agent-assistant-history-item__channel,
.agent-assistant-history-item__time {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.agent-assistant-history-item__title {
  color: rgba(var(--v-theme-on-surface), 0.9);
  font-size: 0.86rem;
  font-weight: 700;
}

.agent-assistant-history-item__channel {
  display: inline-flex;
  border-radius: 999px;
  background: rgba(var(--v-theme-primary), 0.1);
  color: rgba(var(--v-theme-primary), 0.9);
  font-size: 0.68rem;
  font-weight: 700;
  inline-size: fit-content;
  max-inline-size: 100%;
  padding-block: 0.1rem;
  padding-inline: 0.4rem;
}

.agent-assistant-history-item__time {
  color: rgba(var(--v-theme-on-surface), 0.48);
  font-size: 0.7rem;
}

.agent-assistant-messages {
  box-sizing: border-box;
  block-size: 100%;
  min-block-size: 0;
  -webkit-overflow-scrolling: touch;
  -ms-overflow-style: none;
  overflow-y: auto;
  overscroll-behavior: contain;
  overscroll-behavior-y: contain;
  padding-block: 1rem;
  padding-inline: 1rem;
  scroll-behavior: auto;
  scrollbar-width: none;

  &::-webkit-scrollbar {
    display: none;
  }
}

.agent-assistant-messages__content {
  display: flex;
  flex-direction: column;
  min-block-size: 100%;
}

.agent-assistant-panel.is-fullscreen .agent-assistant-messages__content {
  inline-size: min(100%, 72rem);
  margin-inline: auto;
}

/* 只有消息态预留输入框空间，避免 iOS 空态被 padding 撑出不可滚动的滚动条。 */
.agent-assistant-messages--has-content {
  padding-block-end: calc(env(safe-area-inset-bottom, 0px) + 8.75rem);
}

.agent-assistant-empty {
  display: flex;
  box-sizing: border-box;
  flex: 1 1 auto;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  color: rgba(var(--v-theme-on-surface), 0.7);
  min-block-size: 0;
  padding-block: 2rem 1.25rem;
  padding-inline: 0.25rem;
  text-align: center;
}

.agent-assistant-empty__mark {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: rgb(var(--v-theme-primary));
  margin-block-end: 1rem;
}

.agent-assistant-empty__title {
  color: rgba(var(--v-theme-on-surface), 0.9);
  font-size: 1.1rem;
  font-weight: 700;
  line-height: 1.35;
}

.agent-assistant-empty__subtitle {
  color: rgba(var(--v-theme-on-surface), 0.58);
  font-size: 0.86rem;
  line-height: 1.55;
  margin-block-start: 0.4rem;
  max-inline-size: 21rem;
}

.agent-assistant-message {
  display: flex;
  flex: 0 0 auto;
  flex-direction: column;
  margin-block-end: 1rem;
}

.agent-assistant-message--user {
  align-items: flex-end;
}

.agent-assistant-message--assistant {
  align-items: flex-start;
}

.agent-assistant-message__meta {
  display: inline-flex;
  align-items: center;
  color: rgba(var(--v-theme-on-surface), 0.56);
  column-gap: 0.3rem;
  font-size: 0.75rem;
  margin-block-end: 0.35rem;
}

.agent-assistant-message__bubble {
  border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
  border-radius: var(--app-surface-radius);
  font-size: 0.92rem;
  line-height: 1.55;
  max-inline-size: min(100%, 34rem);
  min-inline-size: 0;
  padding-block: 0.75rem;
  padding-inline: 0.85rem;
}

.agent-assistant-message--user .agent-assistant-message__bubble {
  border-color: rgba(var(--v-theme-primary), 0.18);
  background: rgba(var(--v-theme-primary), 0.12);
}

.agent-assistant-message--assistant .agent-assistant-message__bubble {
  border-color: var(--agent-assistant-assistant-border);
  backdrop-filter: blur(var(--agent-assistant-panel-blur));
  background: var(--agent-assistant-assistant-bg);
}

.agent-assistant-segments {
  display: grid;
  gap: 0.5rem;
  inline-size: min(100%, 34rem);
}

.agent-assistant-segments .agent-assistant-message__bubble {
  inline-size: 100%;
}

.agent-assistant-tool {
  display: flex;
  align-items: center;
  border: 1px solid rgba(25, 178, 160, 28%);
  border-radius: var(--app-control-radius);
  background: rgba(25, 178, 160, 8%);
  color: rgba(var(--v-theme-on-surface), 0.78);
  column-gap: 0.45rem;
  font-size: 0.8rem;
  line-height: 1.35;
  padding-block: 0.45rem;
  padding-inline: 0.6rem;
}

.agent-assistant-choices {
  display: grid;
  gap: 0.35rem;
  inline-size: min(100%, 34rem);
  margin-block-start: 0.5rem;
}

.agent-assistant-choice {
  display: grid;
  gap: 0.35rem;
}

.agent-assistant-choice__bubble {
  display: grid;
  border: 1px solid var(--agent-assistant-assistant-border);
  border-radius: var(--app-surface-radius);
  backdrop-filter: blur(var(--agent-assistant-panel-blur));
  background: var(--agent-assistant-assistant-bg);
  gap: 0.65rem;
  padding-block: 0.75rem;
  padding-inline: 0.8rem;
}

.agent-assistant-choice__title {
  color: rgba(var(--v-theme-on-surface), 0.82);
  font-size: 0.78rem;
  font-weight: 700;
  line-height: 1.3;
}

.agent-assistant-choice__prompt {
  color: rgba(var(--v-theme-on-surface), 0.9);
  font-size: 0.92rem;
  line-height: 1.45;
  overflow-wrap: anywhere;
}

.agent-assistant-choice__selected {
  display: inline-flex;
  align-items: flex-start;
  border: 1px solid rgba(25, 178, 160, 28%);
  border-radius: var(--app-control-radius);
  background: rgba(25, 178, 160, 10%);
  color: rgba(var(--v-theme-on-surface), 0.78);
  column-gap: 0.4rem;
  font-size: 0.8rem;
  inline-size: fit-content;
  max-inline-size: 100%;
  min-inline-size: 0;
  padding-block: 0.35rem;
  padding-inline: 0.5rem;
  white-space: normal;
}

.agent-assistant-choice__selected .v-icon {
  flex: 0 0 auto;
  margin-block-start: 0.08rem;
}

.agent-assistant-choice__selected span {
  line-height: 1.4;
  min-inline-size: 0;
  overflow-wrap: anywhere;
  white-space: normal;
}

.agent-assistant-choice__selected.is-expired {
  border-color: rgba(var(--v-theme-warning), 0.28);
  background: rgba(var(--v-theme-warning), 0.1);
}

.agent-assistant-choice__buttons {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
}

.agent-assistant-choice__button {
  flex: 1 1 max-content;
  border: 1px solid rgba(var(--v-theme-on-surface), 0.08) !important;
  border-radius: var(--app-control-radius) !important;
  backdrop-filter: blur(var(--agent-assistant-panel-blur));
  background: rgba(var(--v-theme-surface), 0.9) !important;
  box-shadow: none !important;
  color: rgb(var(--v-theme-primary)) !important;
  max-inline-size: 100%;
  min-block-size: 2.7rem;
  min-inline-size: max-content;
}

.agent-assistant-choice__button:disabled {
  color: rgba(var(--v-theme-on-surface), 0.46) !important;
}

.agent-assistant-choice__button :deep(.v-btn__content) {
  overflow: hidden;
  min-inline-size: 0;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.agent-assistant-typing {
  display: inline-flex;
  border: 1px solid var(--agent-assistant-assistant-border);
  border-radius: 999px;
  backdrop-filter: blur(var(--agent-assistant-panel-blur));
  background: var(--agent-assistant-assistant-bg);
  gap: 0.28rem;
  padding-block: 0.7rem;
  padding-inline: 0.8rem;
}

.agent-assistant-typing span {
  border-radius: 999px;
  animation: agent-typing 1s infinite ease-in-out;
  background: rgba(var(--v-theme-on-surface), 0.54);
  block-size: 0.35rem;
  inline-size: 0.35rem;
}

.agent-assistant-typing span:nth-child(2) {
  animation-delay: 0.15s;
}

.agent-assistant-typing span:nth-child(3) {
  animation-delay: 0.3s;
}

.agent-assistant-composer {
  position: absolute;
  z-index: 2;
  inset-block-end: calc(env(safe-area-inset-bottom, 0px) + 0.85rem);
  inset-inline: 1rem;
  pointer-events: none;
}

.agent-assistant-composer > * {
  pointer-events: auto;
}

.agent-assistant-pending-files {
  display: grid;
  padding: 0.55rem;
  border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
  border-radius: var(--app-surface-radius);
  backdrop-filter: blur(var(--agent-assistant-panel-blur));
  background: var(--agent-assistant-panel-bg);
  box-shadow: var(--app-surface-shadow);
  gap: 0.45rem;
  margin-block-end: 0.55rem;
  max-block-size: 8rem;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
}

.agent-assistant-pending-file {
  display: grid;
  align-items: center;
  border-radius: var(--app-control-radius);
  column-gap: 0.55rem;
  grid-template-columns: auto 1fr auto;
  min-inline-size: 0;
  padding-block: 0.25rem;
  padding-inline: 0.35rem 0.15rem;
}

.agent-assistant-pending-file__preview {
  border-radius: var(--app-control-radius);
  block-size: 2.1rem;
  inline-size: 2.1rem;
  object-fit: cover;
}

.agent-assistant-pending-file__text {
  display: grid;
  min-inline-size: 0;
}

.agent-assistant-pending-file__text span,
.agent-assistant-pending-file__text small {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.agent-assistant-pending-file__text small {
  color: rgba(var(--v-theme-on-surface), 0.58);
  font-size: 0.72rem;
}

.agent-assistant-command-menu {
  display: grid;
  padding: 0.35rem;
  border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
  border-radius: var(--app-overlay-radius);
  backdrop-filter: blur(var(--agent-assistant-panel-blur));
  background: var(--agent-assistant-panel-bg);
  box-shadow: var(--app-surface-shadow);
  gap: 0.25rem;
  margin-block-end: 0.55rem;
  max-block-size: 15rem;
  overflow-y: auto;
  pointer-events: auto;
  scrollbar-width: thin;
}

.agent-assistant-command {
  display: grid;
  align-items: center;
  border-radius: var(--app-control-radius);
  column-gap: 0.7rem;
  cursor: pointer;
  grid-template-columns: minmax(6.8rem, auto) minmax(0, 1fr);
  min-block-size: 2.45rem;
  padding-block: 0.35rem;
  padding-inline: 0.55rem;
}

.agent-assistant-command:hover,
.agent-assistant-command:focus-visible {
  background: rgba(var(--v-theme-primary), 0.1);
  outline: none;
}

.agent-assistant-command.is-loading {
  color: rgba(var(--v-theme-on-surface), 0.58);
  cursor: default;
  grid-template-columns: 1fr;
}

.agent-assistant-command__name {
  overflow: hidden;
  color: rgb(var(--v-theme-primary));
  font-family: var(--v-font-family-monospace, monospace);
  font-size: 0.86rem;
  font-weight: 700;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.agent-assistant-command__desc {
  overflow: hidden;
  color: rgba(var(--v-theme-on-surface), 0.68);
  font-size: 0.82rem;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.agent-assistant-input {
  display: grid;
  align-items: center;
  border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
  border-radius: var(--app-field-radius);
  backdrop-filter: blur(var(--agent-assistant-panel-blur));
  background: var(--agent-assistant-panel-bg);
  box-shadow: var(--app-surface-shadow);
  column-gap: 0.25rem;
  grid-template-columns: auto 1fr auto auto;
  min-block-size: 3.25rem;
  padding-inline: 0.35rem;
  pointer-events: auto;
  transition:
    border-radius 0.2s ease,
    box-shadow 0.2s ease;
}

.agent-assistant-file-input {
  display: none;
}

// 面板内文件下载、附件和发送按钮使用全局阴影 token，跟随主题阴影设置即时变化。
.agent-assistant-surface-btn {
  transition: box-shadow 0.2s ease;
}

.agent-assistant-attach {
  align-self: center;
}

.agent-assistant-record {
  align-self: center;
}

.agent-assistant-record.is-recording {
  color: rgb(var(--v-theme-error));
}

.agent-assistant-textarea {
  box-sizing: border-box;
  align-self: center;
  padding: 0;
  border: 0;
  background: transparent;
  block-size: 1.5rem;
  color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
  font: inherit;
  inline-size: 100%;
  line-height: 1.5rem;
  max-block-size: 7.5rem;
  min-block-size: 1.5rem;
  outline: none;
  overflow-y: auto;
  resize: none;
}

.agent-assistant-textarea::placeholder {
  color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
  opacity: 1;
}

.agent-assistant-send {
  align-self: center;
}

.agent-assistant-attachments {
  display: grid;
  gap: 0.55rem;
  inline-size: min(100%, 34rem);
  margin-block-start: 0.5rem;
}

.agent-assistant-attachment {
  overflow: hidden;
  border: 1px solid var(--agent-assistant-assistant-border);
  border-radius: var(--app-surface-radius);
  backdrop-filter: blur(var(--agent-assistant-panel-blur));
  background: var(--agent-assistant-assistant-bg);
}

.agent-assistant-attachment__image {
  display: block;
  block-size: auto;
  inline-size: 100%;
  max-block-size: 18rem;
  object-fit: contain;
}

.agent-assistant-attachment--audio {
  display: grid;
  padding: 0.75rem;
  gap: 0.65rem;
}

.agent-assistant-attachment__meta {
  display: flex;
  align-items: center;
  color: rgba(var(--v-theme-on-surface), 0.76);
  column-gap: 0.45rem;
  font-size: 0.82rem;
  min-inline-size: 0;
}

.agent-assistant-attachment__meta span {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.agent-assistant-attachment__audio {
  inline-size: 100%;
}

.agent-assistant-attachment__file {
  display: grid;
  align-items: center;
  column-gap: 0.65rem;
  grid-template-columns: auto 1fr auto;
  min-inline-size: 0;
  padding-block: 0.65rem;
  padding-inline: 0.75rem 0.45rem;
}

.agent-assistant-attachment__file-text {
  display: grid;
  min-inline-size: 0;
}

.agent-assistant-attachment__file-text span,
.agent-assistant-attachment__file-text small {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.agent-assistant-attachment__file-text small {
  color: rgba(var(--v-theme-on-surface), 0.58);
}

.markdown-body {
  overflow-wrap: anywhere;
  word-break: break-word;

  :deep(h1),
  :deep(h2),
  :deep(h3) {
    font-weight: 600;
    line-height: 1.3;
    margin-block: 0.5rem;
  }

  :deep(h1) {
    font-size: 1.5rem;
  }

  :deep(h2) {
    font-size: 1.25rem;
  }

  :deep(h3) {
    font-size: 1.1rem;
  }

  :deep(p) {
    margin-block-end: 0.5rem;
  }

  :deep(p:last-child) {
    margin-block-end: 0;
  }

  :deep(a) {
    color: rgb(var(--v-theme-primary));
    text-decoration: underline;
  }

  :deep(code) {
    border-radius: var(--app-control-radius);
    background: rgba(var(--v-theme-on-surface), 0.08);
    font-family: monospace;
    padding-block: 0.1rem;
    padding-inline: 0.3rem;
  }

  :deep(pre) {
    overflow: auto;
    padding: 0.75rem;
    border-radius: var(--app-surface-radius);
    background: rgba(var(--v-theme-on-surface), 0.08);
    margin-block: 0.5rem;
    max-inline-size: 100%;
  }

  :deep(pre code) {
    padding: 0;
    background: transparent;
  }

  :deep(ul),
  :deep(ol) {
    margin-block-end: 0.5rem;
    padding-inline-start: 1.5rem;
  }

  :deep(ul) {
    list-style-type: disc;
  }

  :deep(ol) {
    list-style-type: decimal;
  }

  :deep(li) {
    display: list-item;
    margin-block: 0.25rem;
  }

  :deep(blockquote) {
    border-inline-start: 4px solid rgba(var(--v-border-color), 0.2);
    color: rgba(var(--v-theme-on-surface), 0.74);
    font-style: italic;
    margin-block: 0.5rem;
    padding-inline-start: 1rem;
  }

  :deep(table) {
    display: block;
    border-collapse: collapse;
    inline-size: max-content;
    margin-block: 0.5rem;
    max-inline-size: 100%;
    overflow-x: auto;
  }

  :deep(th),
  :deep(td) {
    border: 1px solid rgba(var(--v-border-color), 0.16);
    padding-block: 0.4rem;
    padding-inline: 0.65rem;
    text-align: start;
  }

  :deep(th) {
    background: rgba(var(--v-border-color), 0.08);
    font-weight: 600;
  }

  :deep(hr) {
    border: none;
    border-block-start: 1px solid rgba(var(--v-border-color), 0.24);
    margin-block: 1rem;
  }

  :deep(img) {
    block-size: auto;
    max-inline-size: 100%;
  }
}

@keyframes agent-typing {
  0%,
  80%,
  100% {
    opacity: 0.35;
    transform: translateY(0);
  }

  40% {
    opacity: 1;
    transform: translateY(-0.18rem);
  }
}

@keyframes agent-fab-blink {
  0%,
  4%,
  8%,
  100% {
    opacity: 1;
    scale: 1 1;
  }

  6% {
    opacity: 0.45;
    scale: 1 0.15;
  }
}

@media (width <= 960px) {
  .agent-assistant-panel {
    inline-size: 100vw !important;
  }
}

@media (width <= 600px) {
  .agent-assistant-empty {
    justify-content: flex-start;
    padding-block-start: 2.75rem;
  }

  .agent-assistant-messages {
    padding-block: 0.85rem;
    padding-inline: 0.85rem;
  }

  .agent-assistant-messages--has-content {
    padding-block-end: calc(env(safe-area-inset-bottom, 0px) + 11.8rem);
  }

  .agent-assistant-composer {
    inset-block-end: calc(env(safe-area-inset-bottom, 0px) + 0.7rem);
    inset-inline: 0.85rem;
  }
}

@media (prefers-reduced-motion: reduce) {
  .agent-assistant-mini-bot__eye,
  .agent-assistant-typing span {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    scroll-behavior: auto !important;
    transition-duration: 0.01ms !important;
  }
}
</style>
