<template>
  <view :key="layoutKey" :class="pageRootClass" class="assistant-page">
    <view v-if="disclaimer" class="assistant-disclaimer">
      <text class="text-body assistant-disclaimer__tag">{{ $t('aiOnlineConsult.disclaimerTitle') }}</text>
      <text class="text-body assistant-disclaimer__txt">{{ disclaimer }}</text>
    </view>

    <scroll-view
      ref="feedScroll"
      id="assistant-feed"
      class="assistant-feed"
      :style="feedScrollStyle"
      scroll-y
      enable-back-to-top
      :scroll-top="feedScrollTopBind"
      :scroll-into-view="feedScrollIntoView"
      :scroll-with-animation="!sending"
      :upper-threshold="80"
      @scrolltoupper="onScrollToUpper"
      @scroll="onFeedScroll"
    >
      <view
        id="assistant-feed-inner"
        class="assistant-feed__inner"
        :class="{ 'assistant-feed__inner--pending': pendingAttachments.length }"
      >
        <view v-if="loadingOlder" class="feed-hint">
          <text class="text-body feed-hint__txt">{{ $t('aiOnlineConsult.loadingMore') }}</text>
        </view>
        <view v-if="noMoreOlder && messages.length" class="feed-hint">
          <text class="text-body feed-hint__txt">{{ $t('aiOnlineConsult.noMoreHistory') }}</text>
        </view>

        <view v-if="messagesLoading && !messages.length" class="feed-hint">
          <text class="text-body feed-hint__txt">{{ $t('aiOnlineConsult.creatingSession') }}</text>
        </view>

        <block v-if="messageGroups.length">
          <block v-for="(group, gi) in messageGroups" :key="'g-' + gi">
            <view class="chat-date">
              <text class="text-body chat-date__txt">{{ group.dateLabel }}</text>
            </view>
            <view
              v-for="m in group.items"
              :key="m.id"
              :id="'msg-' + m.id"
              class="bubble-row"
              :class="isUserMessage(m) ? 'bubble-row--user' : 'bubble-row--bot'"
            >
              <view v-if="!isUserMessage(m)" class="bubble-avatar bubble-avatar--ai">
                <up-icon name="star-fill" color="#22C55E" :size="18" />
              </view>
              <view
                class="bubble"
                :class="[
                  isUserMessage(m) ? 'bubble--user' : 'bubble--bot',
                  m.msgType === 2 ? 'bubble--image' : ''
                ]"
              >
                <text v-if="!isUserMessage(m)" class="text-body bubble__who">{{ $t('aiAssistantPage.assistantBubble') }}</text>
                <text v-else class="text-body bubble__who">{{ $t('aiAssistantPage.userBubble') }}</text>

                <text
                  v-if="m.msgType === 1 && isUserMessage(m)"
                  class="text-body bubble__txt"
                >{{ userTextContent(m) }}</text>
                <text
                  v-else-if="m.msgType === 1 && !isUserMessage(m) && m.streaming"
                  :key="'stream-' + m.id + '-' + (m.streamRenderKey || 0)"
                  class="text-body bubble__txt bubble__txt--stream"
                >{{ plainTextContent(m) }}</text>
                <view
                  v-else-if="m.msgType === 1 && !isUserMessage(m)"
                  class="bubble__rich"
                >
                  <mp-html
                    :key="m.id"
                    :content="aiRichHtml(m)"
                    :tag-style="mpHtmlTagStyle"
                    :container-style="mpHtmlContainerStyle"
                    :scroll-table="true"
                    :selectable="true"
                    :preview-img="true"
                    @load="onBubbleMediaLoad"
                  />
                </view>
                <image
                  v-else-if="m.msgType === 2"
                  class="bubble__img"
                  :src="mediaUrl(m.content)"
                  mode="widthFix"
                  @load="onBubbleMediaLoad"
                  @click="previewImage(m.content)"
                />
                <video
                  v-else-if="m.msgType === 3"
                  class="bubble__video"
                  :src="mediaUrl(m.content)"
                  controls
                />
                <view
                  v-else-if="m.msgType === 4"
                  class="voice-msg"
                  :class="{ 'voice-msg--on': playingVoiceId === m.id }"
                  role="button"
                  @click="toggleVoice(m)"
                >
                  <text class="text-body voice-msg__dur">{{ formatVoiceDuration(m.mediaDuration) }}</text>
                  <text class="voice-msg__label">{{ $t('aiOnlineConsult.msgVoice') }}</text>
                </view>
                <text v-else class="text-body bubble__txt">{{ plainTextContent(m) }}</text>

                <text class="text-body bubble__time">{{ formatMsgTime(m.sendTime) }}</text>
              </view>
              <view v-if="isUserMessage(m)" class="bubble-avatar bubble-avatar--user">
                <up-icon name="account-fill" color="#16a34a" :size="18" />
              </view>
            </view>
          </block>
        </block>

        <view v-else-if="sessionReady && !messagesLoading" class="feed-empty">
          <text class="text-body feed-empty__txt">{{ $t('aiOnlineConsult.emptyChat') }}</text>
        </view>

        <view v-if="showThinking" class="bubble-row bubble-row--bot">
          <view class="bubble-avatar bubble-avatar--ai">
            <up-icon name="star-fill" color="#22C55E" :size="18" />
          </view>
          <view class="bubble bubble--bot bubble--thinking">
            <text class="text-body bubble__who">{{ $t('aiAssistantPage.assistantBubble') }}</text>
            <view class="thinking-status">
              <text class="text-body thinking-txt">{{ $t('aiOnlineConsult.thinking') }}</text>
              <view class="thinking-dots" aria-hidden="true">
                <view class="thinking-dots__dot" />
                <view class="thinking-dots__dot" />
                <view class="thinking-dots__dot" />
              </view>
            </view>
          </view>
        </view>

        <view id="msg-tail" class="assistant-feed__tail" />
      </view>
    </scroll-view>

    <view
      v-if="sessionReady"
      class="assistant-composer"
      :class="{ 'assistant-composer--has-pending': pendingAttachments.length }"
    >
      <!-- 待发送预览（在模型/工具行上方） -->
      <scroll-view
        v-if="pendingAttachments.length"
        class="composer-pending"
        scroll-x
        :show-scrollbar="false"
      >
        <view class="composer-pending__track">
          <view
            v-for="(a, i) in pendingAttachments"
            :key="a.id"
            class="composer-pending__item"
          >
            <image
              v-if="a.kind === 'image'"
              class="composer-pending__thumb composer-pending__thumb--image"
              :src="a.localPath"
              mode="widthFix"
            />
            <view v-else-if="a.kind === 'video'" class="composer-pending__thumb composer-pending__thumb--video">
              <image
                v-if="a.poster"
                class="composer-pending__thumb"
                :src="a.poster"
                mode="aspectFill"
              />
              <view class="composer-pending__video-mask">
                <up-icon name="play-circle" color="#ffffff" :size="28" />
              </view>
            </view>
            <view v-else class="composer-pending__thumb composer-pending__thumb--voice">
              <up-icon name="mic" color="#16a34a" :size="28" />
              <text class="composer-pending__voice-name">{{ pendingShortName(a.name) }}</text>
            </view>
            <view class="composer-pending__close" role="button" @click.stop="removeAttachment(i)">
              <up-icon name="close" color="#ffffff" :size="12" />
            </view>
          </view>
        </view>
      </scroll-view>

      <!-- 模型 + 图片 / 视频 / 语音 -->
      <view class="composer-tools">
        <view class="composer-model" role="button" @click="openModelPicker">
          <up-icon :name="currentModelOption.icon" color="#16a34a" :size="20" />
          <text class="composer-model__txt">{{ currentModelOption.short }}</text>
          <up-icon name="arrow-down" color="#6b7f72" :size="12" />
        </view>
        <view class="composer-icon-btn" role="button" @click="pickImage">
          <up-icon name="photo" color="#22C55E" :size="24" />
        </view>
        <!-- <view class="composer-icon-btn" role="button" @click="pickVideo">
          <up-icon name="play-circle" color="#22C55E" :size="24" />
        </view>
        <view class="composer-icon-btn" role="button" @click="pickVoice">
          <up-icon name="mic" color="#22C55E" :size="24" />
        </view> -->
      </view>

      <view class="composer-input-row">
        <view class="composer-input-shell">
          <textarea
            v-model="draft"
            class="composer-textarea"
            :placeholder="$t('aiOnlineConsult.draftPlaceholder')"
            placeholder-class="composer-textarea-ph"
            maxlength="2000"
            :disabled="sending"
            :cursor-spacing="16"
            :show-confirm-bar="false"
            auto-height
          />
        </view>
        <view
          :class="['composer-send', { 'composer-send--disabled': sendDisabled }]"
          role="button"
          @click="handleSend"
        >
          <text class="text-body composer-send__txt">
            {{ sending ? $t('aiOnlineConsult.thinking') : $t('aiOnlineConsult.send') }}
          </text>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
import UIcon from 'uview-plus/components/u-icon/u-icon.vue'
import tabPage from '@/mixins/tabPage'
import { ensureApiToken } from '@/utils/apiAuth'
import { uploadFile } from '@/utils/upload'
import { useUserStore } from '@/store/user'
import {
  MSG_TYPE_TEXT,
  MSG_TYPE_IMAGE,
  MSG_TYPE_VIDEO,
  MSG_TYPE_VOICE,
  MESSAGE_PAGE_SIZE,
  resolveSessionId,
  resolveConsultSessionId,
  resolveLlmSessionIdFromRow,
  resolveLlmSessionId as resolveLlmSessionIdUtil,
  normalizeSessionRow,
  isUserMessage,
  mediaUrl,
  normalizeMessage,
  formatMsgTime,
  formatVoiceDuration,
  buildMessageGroups
} from '@/utils/aiConsult'
import {
  MODEL_OPTION_DEFS,
  MEDIA_RULES,
  SENDER_ROLE_USER,
  SENDER_ROLE_AI,
  genLocalId,
  extOf,
  buildUserContentForLlm,
  buildMessagesForLlm,
  resolvePayloadUserContent,
  buildPersistSendBody,
  findLastLocalExchangeIndexes
} from '@/utils/aiLlmChat'
import {
  createAiConsultSession,
  listAiConsultMessages,
  sendAiConsultMessage,
  sendChatMessage
} from '@/api/diseaseTreatment/aiOnlineConsult'
import {
  markdownToHtml,
  MP_HTML_TAG_STYLE,
  MP_HTML_CONTAINER_STYLE
} from '@/utils/chatMarkdown'

const LLM_SESSION_STORAGE_PREFIX = 'ai_consult_llm_session_'

export default {
  components: {
    'up-icon': UIcon
  },
  mixins: [tabPage],
  data() {
    return {
      navTitleKey: 'aiAssistantPage.title',
      sessionId: '',
      sessionRow: null,
      sessionReady: false,
      disclaimer: '',
      messages: [],
      draft: '',
      model: 'auto',
      pendingAttachments: [],
      pendingPersist: null,
      messagesLoading: false,
      loadingOlder: false,
      noMoreOlder: false,
      sending: false,
      feedScrollTop: 0,
      feedScrollTopFrozen: null,
      feedScrollIntoView: '',
      _pageAlive: true,
      scrollTopNonce: 0,
      scrollBottomTimer: null,
      feedHeightPx: 0,
      _pinScrollRaf: null,
      _lastStreamScrollAt: 0,
      _streamRevealTimer: null,
      stickToBottom: true,
      /** 首次滚底完成前，不因 scroll 事件把 stickToBottom 置为 false */
      scrollPinReady: false,
      playingVoiceId: null,
      innerAudio: null,
      mpHtmlTagStyle: MP_HTML_TAG_STYLE,
      mpHtmlContainerStyle: MP_HTML_CONTAINER_STYLE,
      streamRequestTask: null
    }
  },
  computed: {
    feedScrollStyle() {
      if (this.feedHeightPx > 0) {
        return { height: this.feedHeightPx + 'px' }
      }
      return { height: '100%' }
    },
    /** 流式期间冻结 scroll-top，避免重渲染把列表拽回顶部 */
    feedScrollTopBind() {
      if (typeof this.feedScrollTopFrozen === 'number') {
        return this.feedScrollTopFrozen
      }
      return this.feedScrollTop
    },
    modelOptions() {
      return MODEL_OPTION_DEFS.map((o) => ({
        value: o.value,
        icon: o.icon,
        label: this.$t('aiOnlineConsult.' + o.labelKey),
        short: this.$t('aiOnlineConsult.' + o.shortKey),
        desc: this.$t('aiOnlineConsult.' + o.descKey)
      }))
    },
    currentModelOption() {
      return this.modelOptions.find((o) => o.value === this.model) || this.modelOptions[0]
    },
    messageGroups() {
      return buildMessageGroups(this.messages, (k, p) => this.$t(k, p))
    },
    sendDisabled() {
      const t = (this.draft || '').trim()
      return this.sending || !this.sessionId || (!t && !this.pendingAttachments.length)
    },
    showThinking() {
      if (!this.sending) {
        return false
      }
      return !this.messages.some((m) => m.senderRole === SENDER_ROLE_AI && m.streaming)
    },
    llmUserId() {
      const store = useUserStore()
      return store.state.name ? String(store.state.name) : String(store.state.id || 'user')
    }
  },
  watch: {
    sessionReady() {
      this.$nextTick(() => this.updateFeedHeight())
    },
    disclaimer() {
      this.$nextTick(() => this.updateFeedHeight())
    },
    feedHeightPx(val) {
      if (val > 0 && !this.sending) {
        this.$nextTick(() => this.pinScrollToBottom(true))
      }
    },
    'messages.length'(len) {
      if (len > 0 && this.stickToBottom && !this.loadingOlder && !this.sending) {
        this.$nextTick(() => this.scheduleScrollToBottom())
      }
    }
  },
  onLoad(query) {
    this.stickToBottom = true
    this.scrollPinReady = false
    if (!ensureApiToken()) return
    const raw = query && (query.sessionId || query.id || '')
    if (raw) {
      try {
        this.sessionId = decodeURIComponent(raw)
      } catch (e) {
        this.sessionId = raw
      }
      let llmFromQuery = ''
      if (query && query.llmSessionId) {
        try {
          llmFromQuery = decodeURIComponent(query.llmSessionId)
        } catch (e) {
          llmFromQuery = String(query.llmSessionId)
        }
      }
      this.sessionRow = normalizeSessionRow(
        {
          id: this.sessionId,
          realSessionId: this.sessionId,
          llmSessionId: llmFromQuery || null
        },
        llmFromQuery ? { [this.sessionId]: llmFromQuery } : null
      )
      if (llmFromQuery) {
        this.saveLlmSessionId(this.sessionId, llmFromQuery)
      }
      this.hydrateLlmSessionId(this.sessionRow)
      this.sessionId = this.consultSessionIdForApi() || this.sessionId
      this.sessionReady = !!this.sessionId
      this.loadMessages(false)
      this.$nextTick(() => this.updateFeedHeight())
      return
    }
    this.initNewSession()
  },
  onShow() {
    this.stickToBottom = true
    this.updateFeedHeight()
    if (this.sessionReady && this.messages.length && !this.messagesLoading && !this.loadingOlder) {
      this.scheduleScrollToBottom()
    }
  },
  onReady() {
    this.updateFeedHeight()
    this.$nextTick(() => {
      if (this.messages.length && !this.messagesLoading) {
        this.scheduleScrollToBottom()
      }
    })
  },
  mounted() {
    this.bindH5WheelScroll()
  },
  // #ifdef VUE3
  beforeUnmount() {
    this._pageAlive = false
    this.unbindH5WheelScroll()
  },
  // #endif
  // #ifndef VUE3
  beforeDestroy() {
    this._pageAlive = false
    this.unbindH5WheelScroll()
  },
  // #endif
  onUnload() {
    this._pageAlive = false
    if (this.scrollBottomTimer) {
      clearTimeout(this.scrollBottomTimer)
      this.scrollBottomTimer = null
    }
    this.clearStreamRevealTimer()
    this.abortStreamRequest()
    this.flushPendingPersist()
    this.stopVoice()
    this.unbindH5WheelScroll()
  },
  methods: {
    isUserMessage,
    mediaUrl,
    formatMsgTime,
    formatVoiceDuration,
    userTextContent(m) {
      if (!m || m.content == null) {
        return ''
      }
      return typeof m.content === 'string' ? m.content : String(m.content)
    },
    plainTextContent(m) {
      if (!m || m.content == null) {
        return ''
      }
      if (typeof m.content === 'string') {
        return m.content
      }
      if (Array.isArray(m.content)) {
        const part = m.content.find((p) => p && p.type === 'text' && p.text)
        return part ? part.text : ''
      }
      return String(m.content)
    },
    aiRichHtml(m) {
      const raw = this.plainTextContent(m)
      return markdownToHtml(raw)
    },
    openModelPicker() {
      const labels = this.modelOptions.map((o) => o.label)
      uni.showActionSheet({
        title: this.$t('aiOnlineConsult.pickModel'),
        itemList: labels,
        success: (res) => {
          const opt = this.modelOptions[res.tapIndex]
          if (opt) {
            this.model = opt.value
          }
        }
      })
    },
    pendingShortName(name) {
      const n = String(name || '')
      if (n.length <= 12) return n
      return n.slice(-12)
    },
    revokeAttachmentPreview(a) {
      if (a && a.objectUrl && typeof URL !== 'undefined') {
        try {
          URL.revokeObjectURL(a.objectUrl)
        } catch (e) {
          /* ignore */
        }
      }
    },
    removeAttachment(i) {
      const a = this.pendingAttachments[i]
      this.revokeAttachmentPreview(a)
      this.pendingAttachments.splice(i, 1)
    },
    setPendingAttachment(item) {
      this.pendingAttachments.forEach((a) => this.revokeAttachmentPreview(a))
      this.pendingAttachments = [
        {
          id: genLocalId(),
          kind: item.kind,
          msgType: item.msgType,
          name: item.name,
          localPath: item.localPath,
          poster: item.poster || '',
          nativeFile: item.nativeFile || null,
          objectUrl: item.objectUrl || '',
          mediaDuration: item.mediaDuration,
          url: ''
        }
      ]
    },
    uploadAllPending() {
      const tasks = this.pendingAttachments.map((a) => {
        if (a.url) {
          return Promise.resolve(a.url)
        }
        return uploadFile(a.localPath, a.nativeFile).then((url) => {
          a.url = url
          return url
        })
      })
      return Promise.all(tasks)
    },
    initNewSession() {
      uni.showLoading({ mask: true })
      createAiConsultSession({})
        .then((res) => {
          const data = res.data || {}
          this.sessionId = resolveSessionId(data)
          this.clearLlmSessionId(this.sessionId)
          this.sessionRow = normalizeSessionRow({
            ...data,
            id: this.sessionId,
            realSessionId: this.sessionId,
            llmSessionId: null,
            sessionTitle: data.sessionTitle
          })
          this.sessionReady = !!this.sessionId
          if (data.disclaimer) {
            this.disclaimer = data.disclaimer
          }
          this.$nextTick(() => this.updateFeedHeight())
        })
        .catch(() => {
          uni.navigateBack()
        })
        .finally(() => {
          uni.hideLoading()
        })
    },
    consultSessionIdForApi() {
      return resolveConsultSessionId(this.sessionRow) || this.sessionId || null
    },
    loadMessages(older) {
      const consultSid = this.consultSessionIdForApi()
      if (!consultSid) return
      const beforeId = older && this.messages.length ? this.messages[0].id : undefined
      if (older) {
        this.loadingOlder = true
      } else {
        this.messagesLoading = true
      }
      listAiConsultMessages(consultSid, {
        beforeId,
        pageSize: MESSAGE_PAGE_SIZE
      })
        .then((res) => {
          const batch = (res.data || []).map((m) => normalizeMessage(m))
          if (older) {
            if (!batch.length) {
              this.noMoreOlder = true
            } else {
              const feedEl = this.getH5ScrollableEl()
              const prevScrollHeight = feedEl ? feedEl.scrollHeight : 0
              const prevScrollTop = feedEl ? feedEl.scrollTop : 0
              this.messages = batch.concat(this.messages)
              if (batch.length < MESSAGE_PAGE_SIZE) {
                this.noMoreOlder = true
              }
              if (feedEl && prevScrollHeight > 0) {
                this.$nextTick(() => {
                  feedEl.scrollTop = feedEl.scrollHeight - prevScrollHeight + prevScrollTop
                })
              }
            }
          } else {
            this.messages = batch
            if (!batch.length) {
              this.noMoreOlder = true
            }
            this.$nextTick(() => {
              this.scheduleScrollToBottom()
            })
          }
        })
        .finally(() => {
          this.messagesLoading = false
          this.loadingOlder = false
          if (!older) {
            this.$nextTick(() => {
              this.scheduleScrollToBottom()
            })
          }
        })
    },
    onScrollToUpper() {
      if (this.loadingOlder || this.noMoreOlder || !this.messages.length) return
      this.loadMessages(true)
    },
    bindH5WheelScroll() {
      // #ifdef H5
      if (this._h5DocWheel) {
        return
      }
      this._h5DocWheel = (e) => this.handleH5DocumentWheel(e)
      document.addEventListener('wheel', this._h5DocWheel, { passive: false, capture: true })
      window.addEventListener('resize', this.updateFeedHeight)
      // #endif
    },
    unbindH5WheelScroll() {
      // #ifdef H5
      if (this._h5DocWheel) {
        document.removeEventListener('wheel', this._h5DocWheel, { capture: true })
        this._h5DocWheel = null
      }
      window.removeEventListener('resize', this.updateFeedHeight)
      // #endif
    },
    updateFeedHeight() {
      if (!this._pageAlive) {
        return
      }
      uni.getSystemInfo({
        success: (sys) => {
          if (!this._pageAlive) {
            return
          }
          const winH = sys.windowHeight || sys.screenHeight || 600
          const discEstimate = this.disclaimer ? 56 : 0
          const compEstimate = this.sessionReady ? 150 : 0
          this.feedHeightPx = Math.max(160, Math.floor(winH - discEstimate - compEstimate))
          this.$nextTick(() => {
            if (!this._pageAlive) {
              return
            }
            try {
              const q = uni.createSelectorQuery().in(this)
              q.select('.assistant-disclaimer').boundingClientRect()
              if (this.sessionReady) {
                q.select('.assistant-composer').boundingClientRect()
              }
              q.exec((rects) => {
                if (!this._pageAlive || !rects) {
                  return
                }
                const discH = (rects[0] && rects[0].height) || discEstimate
                const compH = this.sessionReady ? (rects[1] && rects[1].height) || compEstimate : 0
                const next = Math.max(160, Math.floor(winH - discH - compH))
                if (next !== this.feedHeightPx) {
                  this.feedHeightPx = next
                  if (!this.sending) {
                    this.$nextTick(() => this.pinScrollToBottom(true))
                  }
                }
              })
            } catch (e) {
              /* H5 节点未挂载时 query 可能抛错，保留估算高度 */
            }
          })
        }
      })
    },
    requestPinScroll() {
      if (!this._pageAlive || (!this.stickToBottom && !this.sending)) {
        return
      }
      if (this._pinScrollRaf) {
        return
      }
      const raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (fn) => setTimeout(fn, 16)
      this._pinScrollRaf = raf(() => {
        this._pinScrollRaf = null
        if (this.sending) {
          this.scrollStreamToBottom()
        } else {
          this.pinScrollToBottom()
        }
      })
    },
    /** 流式输出：仅改原生滚动位置，不动 feedScrollTop，避免与 scroll-view 打架抖动 */
    scrollStreamToBottom() {
      if (!this._pageAlive) {
        return
      }
      const el = this.getH5ScrollableEl()
      if (el) {
        const maxTop = this.getH5FeedMaxScrollTop()
        const target = maxTop != null ? maxTop : Math.max(0, el.scrollHeight - el.clientHeight)
        if (Math.abs(el.scrollTop - target) > 2) {
          el.scrollTop = target
        }
        return
      }
      const now = Date.now()
      if (this._lastStreamScrollAt && now - this._lastStreamScrollAt < 150) {
        return
      }
      this._lastStreamScrollAt = now
      this.syncFeedScrollTop()
    },
    getH5FeedMaxScrollTop() {
      const el = this.getH5ScrollableEl()
      if (!el) {
        return null
      }
      const root = this.getFeedScrollEl()
      const content = root && root.querySelector ? root.querySelector('.uni-scroll-view-content') : null
      const scrollHeight = content ? Math.max(content.scrollHeight, el.scrollHeight) : el.scrollHeight
      const clientHeight = el.clientHeight || (content && content.clientHeight) || 0
      if (clientHeight <= 0) {
        return null
      }
      return Math.max(0, Math.ceil(scrollHeight - clientHeight))
    },
    applyFeedScrollTop(maxTop, force) {
      this.scrollTopNonce = this.scrollTopNonce ? 0 : 1
      const nextTop = maxTop + this.scrollTopNonce
      if (force || this.feedScrollTop !== nextTop) {
        this.feedScrollTop = nextTop
      } else {
        this.scrollTopNonce = this.scrollTopNonce ? 0 : 1
        this.feedScrollTop = maxTop + this.scrollTopNonce
      }
      if (this.stickToBottom) {
        this.scrollPinReady = true
      }
    },
    scrollIntoViewTail() {
      if (!this._pageAlive) {
        return
      }
      this.feedScrollIntoView = ''
      this.$nextTick(() => {
        if (!this._pageAlive) {
          return
        }
        this.feedScrollIntoView = 'msg-tail'
        setTimeout(() => {
          if (this._pageAlive) {
            this.feedScrollIntoView = ''
          }
        }, 120)
      })
    },
    freezeFeedScrollTopForStream() {
      const el = this.getH5ScrollableEl()
      if (el) {
        this.feedScrollTopFrozen = el.scrollTop
        return
      }
      this.feedScrollTopFrozen = this.feedScrollTop
    },
    releaseFeedScrollTopFrozen() {
      this.feedScrollTopFrozen = null
    },
    syncFeedScrollTop(force) {
      if (!this._pageAlive || typeof this.feedScrollTopFrozen === 'number') {
        return
      }
      const h5Max = this.getH5FeedMaxScrollTop()
      if (h5Max != null) {
        this.applyFeedScrollTop(h5Max, !!force)
        return
      }
      try {
        uni.createSelectorQuery()
          .in(this)
          .select('#assistant-feed-inner')
          .boundingClientRect()
          .select('#assistant-feed')
          .boundingClientRect()
          .exec((res) => {
            if (!this._pageAlive || !res) {
              return
            }
            const inner = res[0]
            const viewport = res[1]
            if (!inner || !viewport || inner.height <= 0 || viewport.height <= 0) {
              return
            }
            const maxTop = Math.max(0, Math.ceil(inner.height - viewport.height))
            this.applyFeedScrollTop(maxTop, !!force)
          })
      } catch (e) {
        /* ignore */
      }
    },
    pinScrollToBottom(force) {
      if (!this._pageAlive) {
        return
      }
      if (this.sending) {
        this.scrollStreamToBottom()
        return
      }
      const run = () => {
        if (!this._pageAlive) {
          return
        }
        this.scrollIntoViewTail()
        const apply = () => this.syncFeedScrollTop(!!force)
        apply()
        this.$nextTick(apply)
        const raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : null
        if (raf) {
          raf(() => raf(apply))
        }
      }
      if (force) {
        run()
        return
      }
      this.$nextTick(run)
    },
    getFeedScrollEl() {
      const ref = this.$refs.feedScroll
      if (!ref) {
        return null
      }
      if (ref.$el) {
        return ref.$el
      }
      return ref
    },
    getH5ScrollableEl() {
      const root = this.getFeedScrollEl()
      if (!root || typeof document === 'undefined') {
        return null
      }
      const prefer = root.querySelector('.uni-scroll-view') || root.querySelector('.uni-scroll-view-content')
      if (prefer) {
        return prefer
      }
      const nodes = root.querySelectorAll('*')
      for (let i = 0; i < nodes.length; i++) {
        const el = nodes[i]
        if (el.scrollHeight > el.clientHeight + 1) {
          return el
        }
      }
      return root
    },
    handleH5DocumentWheel(e) {
      if (!e) {
        return
      }
      const root = this.getFeedScrollEl()
      if (!root || !e.target || !root.contains(e.target)) {
        return
      }
      const el = this.getH5ScrollableEl()
      if (!el || el.scrollHeight <= el.clientHeight + 1) {
        return
      }
      const maxTop = el.scrollHeight - el.clientHeight
      const next = Math.max(0, Math.min(maxTop, el.scrollTop + e.deltaY))
      if (Math.abs(next - el.scrollTop) < 0.5) {
        return
      }
      el.scrollTop = next
      e.preventDefault()
      e.stopPropagation()
    },
    onBubbleMediaLoad() {
      if (this.sending || !this.stickToBottom) {
        return
      }
      this.pinScrollToBottom(true)
    },
    onFeedScroll(e) {
      if (e && e.detail && e.detail.scrollTop != null && e.detail.scrollTop < 40) {
        this.onScrollToUpper()
      }
      if (this.sending || this.messagesLoading || this.loadingOlder || !this.scrollPinReady) {
        return
      }
      let scrollTop = e.detail && e.detail.scrollTop
      const el = this.getH5ScrollableEl()
      if (el && el.scrollHeight > el.clientHeight + 20) {
        if (scrollTop == null) {
          scrollTop = el.scrollTop
        }
        const dist = el.scrollHeight - el.clientHeight - (scrollTop || 0)
        this.stickToBottom = dist < 100
      }
    },
    scheduleScrollToBottom(immediate = true) {
      if (!this._pageAlive) {
        return
      }
      this.stickToBottom = true
      if (immediate) {
        this.pinScrollToBottom(true)
      }
      if (this.sending) {
        return
      }
      const delays =
        typeof document !== 'undefined' ? [80, 250, 600, 1200, 2000] : [120, 400, 900, 1500]
      delays.forEach((ms) => {
        setTimeout(() => {
          if (!this._pageAlive || this.sending) {
            return
          }
          if (!this.stickToBottom && this.scrollPinReady) {
            return
          }
          this.pinScrollToBottom(true)
        }, ms)
      })
    },
    clearComposer() {
      this.draft = ''
      this.pendingAttachments.forEach((a) => this.revokeAttachmentPreview(a))
      this.pendingAttachments = []
    },
    llmSessionStorageKey(consultSessionId) {
      return LLM_SESSION_STORAGE_PREFIX + consultSessionId
    },
    loadLlmSessionId(consultSessionId) {
      if (!consultSessionId) {
        return null
      }
      try {
        return uni.getStorageSync(this.llmSessionStorageKey(consultSessionId)) || null
      } catch (e) {
        return null
      }
    },
    saveLlmSessionId(consultSessionId, llmSessionId) {
      if (!consultSessionId || !llmSessionId) {
        return
      }
      try {
        uni.setStorageSync(this.llmSessionStorageKey(consultSessionId), String(llmSessionId))
      } catch (e) {
        /* ignore */
      }
    },
    clearLlmSessionId(consultSessionId) {
      if (!consultSessionId) {
        return
      }
      try {
        uni.removeStorageSync(this.llmSessionStorageKey(consultSessionId))
      } catch (e) {
        /* ignore */
      }
    },
    hydrateLlmSessionId(sessionRow) {
      if (!sessionRow) {
        return
      }
      const sid = resolveConsultSessionId(sessionRow)
      const fromList = resolveLlmSessionIdFromRow(sessionRow)
      if (fromList && !sessionRow.llmSessionId) {
        sessionRow.llmSessionId = fromList
        this.saveLlmSessionId(sid, fromList)
      }
      const stored = this.loadLlmSessionId(sid)
      if (stored && !sessionRow.llmSessionId) {
        sessionRow.llmSessionId = stored
      }
    },
    resolveLlmSessionId() {
      return resolveLlmSessionIdUtil(this.sessionRow, (consultId) => this.loadLlmSessionId(consultId))
    },
    rememberLlmSessionId(llmSessionId) {
      if (!llmSessionId || !this.sessionRow) {
        return
      }
      const v = String(llmSessionId)
      this.sessionRow.llmSessionId = v
      this.saveLlmSessionId(resolveConsultSessionId(this.sessionRow), v)
    },
    abortStreamRequest() {
      if (this.streamRequestTask && typeof this.streamRequestTask.abort === 'function') {
        try {
          this.streamRequestTask.abort()
        } catch (e) {
          /* ignore */
        }
      }
      this.streamRequestTask = null
    },
    clearStreamRevealTimer() {
      if (this._streamRevealTimer) {
        clearTimeout(this._streamRevealTimer)
        this._streamRevealTimer = null
      }
    },
    /** 流式正文：小步即时更新；网关缓冲导致一次写入很多字时做打字机展开 */
    pushStreamText(aiMsg, targetText) {
      if (!aiMsg) {
        return
      }
      const next = targetText == null ? '' : String(targetText)
      const prev = aiMsg.content == null ? '' : String(aiMsg.content)
      if (next === prev) {
        return
      }
      if (!next.startsWith(prev)) {
        this.$set(aiMsg, 'content', next)
        this.$set(aiMsg, 'streamRenderKey', (aiMsg.streamRenderKey || 0) + 1)
        this.requestPinScroll()
        return
      }
      const jump = next.length - prev.length
      if (jump <= 8) {
        this.$set(aiMsg, 'content', next)
        this.$set(aiMsg, 'streamRenderKey', (aiMsg.streamRenderKey || 0) + 1)
        this.requestPinScroll()
        return
      }
      this.clearStreamRevealTimer()
      let pos = prev.length
      const step = () => {
        if (!this._pageAlive || !aiMsg.streaming) {
          this._streamRevealTimer = null
          this.$set(aiMsg, 'content', next)
          this.$set(aiMsg, 'streamRenderKey', (aiMsg.streamRenderKey || 0) + 1)
          return
        }
        pos = Math.min(pos + 3, next.length)
        this.$set(aiMsg, 'content', next.slice(0, pos))
        this.$set(aiMsg, 'streamRenderKey', (aiMsg.streamRenderKey || 0) + 1)
        this.requestPinScroll()
        if (pos < next.length) {
          this._streamRevealTimer = setTimeout(step, 20)
        } else {
          this._streamRevealTimer = null
        }
      }
      step()
    },
    appendLocalMessage(senderRole, msgType, content, extra) {
      const msg = {
        id: genLocalId(),
        senderRole,
        msgType: msgType || MSG_TYPE_TEXT,
        content,
        sendTime: new Date().toISOString(),
        ...(extra || {})
      }
      this.messages.push(msg)
      return msg
    },
    mergePersistResult(data, aiReplyContent) {
      if (!data) return
      const { userIdx, aiIdx } = findLastLocalExchangeIndexes(this.messages)
      if (data.userMessage && userIdx >= 0) {
        const norm = normalizeMessage(data.userMessage)
        const row = this.messages[userIdx]
        row.id = norm.id
        if (norm.sendTime) row.sendTime = norm.sendTime
      }
      if (data.aiMessage && aiIdx >= 0) {
        const norm = normalizeMessage(data.aiMessage)
        const row = this.messages[aiIdx]
        row.id = norm.id
        if (norm.sendTime) row.sendTime = norm.sendTime
      }
    },
    queuePendingPersist(consultSessionId, persistBody, aiReplyContent, llmSessionId, costTime, aiCategory) {
      this.pendingPersist = {
        consultSessionId,
        persistBody: { ...persistBody },
        aiReplyContent,
        llmSessionId: llmSessionId != null ? llmSessionId : null,
        costTime: costTime != null ? costTime : null,
        aiCategory: aiCategory != null && aiCategory !== '' ? aiCategory : null
      }
    },
    flushPendingPersist() {
      const pending = this.pendingPersist
      if (!pending || !pending.consultSessionId || !pending.persistBody || !pending.aiReplyContent) {
        return Promise.resolve()
      }
      return sendAiConsultMessage(pending.consultSessionId, {
        ...pending.persistBody,
        aiReplyContent: pending.aiReplyContent,
        realSessionId: pending.llmSessionId != null ? pending.llmSessionId : null,
        costTime: pending.costTime != null ? pending.costTime : undefined,
        aiCategory: pending.aiCategory != null ? pending.aiCategory : undefined
      })
        .then(() => {
          this.pendingPersist = null
        })
        .catch(() => {})
    },
    persistConsultExchange(persistBody, aiReplyContent, costTime, aiCategory) {
      const reply = (aiReplyContent || '').trim()
      const consultSessionId = this.consultSessionIdForApi()
      if (!consultSessionId || !persistBody || !reply) {
        return Promise.resolve(true)
      }
      const llmSessionId = this.resolveLlmSessionId()
      return sendAiConsultMessage(consultSessionId, {
        ...persistBody,
        aiReplyContent: reply,
        realSessionId: llmSessionId || null,
        costTime: costTime != null ? costTime : undefined,
        aiCategory: aiCategory != null && aiCategory !== '' ? aiCategory : undefined
      })
        .then((res) => {
          this.mergePersistResult(res.data, reply)
          if (
            this.pendingPersist &&
            this.pendingPersist.consultSessionId === consultSessionId &&
            this.pendingPersist.aiReplyContent === reply
          ) {
            this.pendingPersist = null
          }
          return true
        })
        .catch((err) => {
          this.queuePendingPersist(consultSessionId, persistBody, reply, llmSessionId, costTime, aiCategory)
          const msg = (err && err.message) || this.$t('aiOnlineConsult.saveSessionFailed')
          uni.showToast({ title: msg, icon: 'none' })
          return false
        })
    },
    async handleSend() {
      if (this.sendDisabled) return
      const text = (this.draft || '').trim()
      if (!text && !this.pendingAttachments.length) {
        uni.showToast({ title: this.$t('aiOnlineConsult.inputOrAttach'), icon: 'none' })
        return
      }
      if (this.pendingAttachments.some((a) => !a.url)) {
        uni.showLoading({ title: this.$t('aiOnlineConsult.uploading'), mask: true })
        try {
          await this.uploadAllPending()
        } catch (e) {
          uni.showToast({ title: this.$t('aiOnlineConsult.uploadFail'), icon: 'none' })
          return
        } finally {
          uni.hideLoading()
        }
      }
      const atts = this.pendingAttachments
      const img = atts.find((a) => a.kind === 'image' && a.url)
      const video = atts.find((a) => a.kind === 'video' && a.url)
      if (img) {
        await this.sendViaLlm({ msgType: MSG_TYPE_IMAGE, content: img.url })
        return
      }
      if (video) {
        await this.sendViaLlm({
          msgType: MSG_TYPE_VIDEO,
          content: video.url,
          mediaDuration: video.mediaDuration
        })
        return
      }
      await this.sendViaLlm({ msgType: MSG_TYPE_TEXT, content: text })
    },
    async sendViaLlm(payload) {
      if (!this.consultSessionIdForApi() || this.sending) return

      let userContent
      if (payload && payload.msgType && payload.msgType !== MSG_TYPE_TEXT) {
        userContent = resolvePayloadUserContent(payload, this.draft)
      } else {
        userContent = buildUserContentForLlm((payload && payload.content) || this.draft, this.pendingAttachments)
      }
      if (!userContent) {
        uni.showToast({ title: this.$t('aiOnlineConsult.inputOrAttach'), icon: 'none' })
        return
      }

      const msgType = (payload && payload.msgType) || MSG_TYPE_TEXT
      const draftText = (this.draft || '').trim()
      const pendingSnapshot = this.pendingAttachments.slice()
      const displayContent =
        msgType !== MSG_TYPE_TEXT
          ? payload.content
          : typeof userContent === 'string'
            ? userContent
            : pendingSnapshot.find((a) => a.kind === 'image' && a.url)
              ? pendingSnapshot.find((a) => a.kind === 'image').url
              : userContent
      const persistBody = buildPersistSendBody(msgType, payload, draftText, pendingSnapshot)

      this.appendLocalMessage(SENDER_ROLE_USER, msgType, displayContent)
      this.clearComposer()
      this.stickToBottom = true
      this.pinScrollToBottom(true)

      const llmSessionId = this.resolveLlmSessionId()
      const body = {
        model: this.model,
        messages: buildMessagesForLlm(this.messages, llmSessionId),
        user: this.llmUserId
      }
      if (llmSessionId) {
        body.session_id = llmSessionId
      }

      let aiReplyContent = ''
      let aiMsg = null
      let costTime = null
      let aiCategory = null
      this.abortStreamRequest()
      this.freezeFeedScrollTopForStream()
      this.sending = true
      this.stickToBottom = true
      try {
        const streamResult = await sendChatMessage(body, {
          onReadyTask: (task) => {
            this.streamRequestTask = task
          },
          onSessionId: (llmSid) => {
            this.rememberLlmSessionId(llmSid)
          },
          onModel: (model) => {
            aiCategory = model
          },
          onDelta: (fullText) => {
            aiReplyContent = fullText
            if (!aiMsg) {
              aiMsg = this.appendLocalMessage(SENDER_ROLE_AI, MSG_TYPE_TEXT, fullText, {
                streaming: true
              })
              this.requestPinScroll()
            } else {
              this.pushStreamText(aiMsg, fullText)
            }
          }
        })
        const llmSid = streamResult.sessionId || null
        if (llmSid) {
          this.rememberLlmSessionId(llmSid)
        }
        costTime = streamResult.durationMs != null ? streamResult.durationMs : null
        if (streamResult.model) {
          aiCategory = streamResult.model
        }
        this.clearStreamRevealTimer()
        aiReplyContent = (streamResult.content || aiReplyContent || '').trim() || this.$t('aiOnlineConsult.noContent')
        if (!aiMsg) {
          aiMsg = this.appendLocalMessage(SENDER_ROLE_AI, MSG_TYPE_TEXT, aiReplyContent)
        } else {
          this.$set(aiMsg, 'content', aiReplyContent)
        }
        if (aiMsg) {
          this.$set(aiMsg, 'streaming', false)
        }
      } catch (err) {
        let msg = this.$t('aiOnlineConsult.requestFailed')
        if (err && err.message) {
          msg = err.message
        }
        uni.showToast({ title: msg, icon: 'none', duration: 3000 })
        aiReplyContent = this.$t('aiOnlineConsult.callFailed', { msg })
        if (!aiMsg) {
          aiMsg = this.appendLocalMessage(SENDER_ROLE_AI, MSG_TYPE_TEXT, aiReplyContent)
        } else {
          this.$set(aiMsg, 'content', aiReplyContent)
          this.$set(aiMsg, 'streaming', false)
        }
      } finally {
        this.clearStreamRevealTimer()
        this.streamRequestTask = null
        this.sending = false
        this.releaseFeedScrollTopFrozen()
        if (aiMsg && aiMsg.streaming) {
          this.$set(aiMsg, 'streaming', false)
        }
        this.$nextTick(() => this.scheduleScrollToBottom())
      }
      await this.persistConsultExchange(
        persistBody,
        (aiReplyContent || '').trim(),
        costTime,
        aiCategory
      )
    },
    validateMediaFile(filePath, fileName, kind) {
      const rule = MEDIA_RULES[kind]
      const ext = extOf(fileName || filePath)
      if (!rule.exts.includes(ext)) {
        uni.showToast({ title: this.$t('aiOnlineConsult.' + rule.errFmt), icon: 'none' })
        return false
      }
      if ((fileName || filePath).includes(',')) {
        uni.showToast({ title: this.$t('aiOnlineConsult.errComma'), icon: 'none' })
        return false
      }
      return true
    },
    checkFileSize(filePath, kind) {
      const rule = MEDIA_RULES[kind]
      return new Promise((resolve) => {
        uni.getFileInfo({
          filePath,
          success: (res) => {
            if (res.size / 1024 / 1024 >= rule.maxMb) {
              uni.showToast({ title: this.$t('aiOnlineConsult.' + rule.errMb), icon: 'none' })
              resolve(false)
              return
            }
            resolve(true)
          },
          fail: () => resolve(true)
        })
      })
    },
    /** 语音：选完即上传并发送（与 PC 端上传语音一致，不走待发送预览） */
    async sendVoiceImmediately(filePath, fileName, nativeFile, mediaDuration) {
      if (!this.consultSessionIdForApi() || this.sending) return
      uni.showLoading({ title: this.$t('aiOnlineConsult.uploading'), mask: true })
      try {
        const url = await uploadFile(filePath, nativeFile)
        await this.sendViaLlm({
          msgType: MSG_TYPE_VOICE,
          content: url,
          mediaDuration: mediaDuration != null ? mediaDuration : undefined
        })
      } catch (e) {
        uni.showToast({ title: this.$t('aiOnlineConsult.uploadFail'), icon: 'none' })
      } finally {
        uni.hideLoading()
      }
    },
    pickImage() {
      uni.chooseImage({
        count: 1,
        sizeType: ['compressed'],
        sourceType: ['album', 'camera'],
        success: async (res) => {
          const path = res.tempFilePaths && res.tempFilePaths[0]
          if (!path) return
          const name = (res.tempFiles && res.tempFiles[0] && res.tempFiles[0].name) || path
          if (!this.validateMediaFile(path, name, 'image')) return
          if (!(await this.checkFileSize(path, 'image'))) return
          this.setPendingAttachment({
            kind: 'image',
            msgType: MSG_TYPE_IMAGE,
            name,
            localPath: path
          })
        },
        fail: () => {
          uni.showToast({ title: this.$t('aiAssistantPage.imagePickFail'), icon: 'none' })
        }
      })
    },
    pickVideo() {
      uni.chooseVideo({
        sourceType: ['album', 'camera'],
        maxDuration: 60,
        success: async (res) => {
          const path = res.tempFilePath
          if (!path) return
          const name = path
          if (!this.validateMediaFile(path, name, 'video')) return
          if (!(await this.checkFileSize(path, 'video'))) return
          this.setPendingAttachment({
            kind: 'video',
            msgType: MSG_TYPE_VIDEO,
            name,
            localPath: path,
            poster: res.thumbTempFilePath || '',
            mediaDuration: res.duration ? Math.round(res.duration) : undefined
          })
        },
        fail: () => {
          uni.showToast({ title: this.$t('aiAssistantPage.videoPickFail'), icon: 'none' })
        }
      })
    },
    pickVoice() {
      // #ifdef H5
      this.pickVoiceH5()
      // #endif
      // #ifndef H5
      if (typeof uni.chooseFile === 'function') {
        uni.chooseFile({
          count: 1,
          extension: ['.mp3', '.m4a', '.wav'],
          success: async (res) => {
            const f = res.tempFiles && res.tempFiles[0]
            if (!f || !f.path) return
            if (!this.validateMediaFile(f.path, f.name || f.path, 'voice')) return
            if (!(await this.checkFileSize(f.path, 'voice'))) return
            this.sendVoiceImmediately(f.path, f.name || f.path, null, undefined)
          },
          fail: () => {
            uni.showToast({ title: this.$t('aiAssistantPage.recordUnsupported'), icon: 'none' })
          }
        })
      } else {
        uni.showToast({ title: this.$t('aiAssistantPage.recordUnsupported'), icon: 'none' })
      }
      // #endif
    },
    pickVoiceH5() {
      const input = document.createElement('input')
      input.type = 'file'
      input.accept = '.mp3,.m4a,.wav,audio/*'
      input.style.display = 'none'
      input.onchange = async (e) => {
        const file = e.target && e.target.files && e.target.files[0]
        if (!file) return
        if (!this.validateMediaFile(file.name, file.name, 'voice')) return
        if (file.size / 1024 / 1024 >= MEDIA_RULES.voice.maxMb) {
          uni.showToast({ title: this.$t('aiOnlineConsult.errVoiceMb'), icon: 'none' })
          return
        }
        this.sendVoiceImmediately('', file.name, file, undefined)
      }
      document.body.appendChild(input)
      input.click()
      setTimeout(() => {
        if (input.parentNode) {
          input.parentNode.removeChild(input)
        }
      }, 1000)
    },
    previewImage(path) {
      const url = mediaUrl(path)
      if (url) {
        uni.previewImage({ urls: [url] })
      }
    },
    stopVoice() {
      if (this.innerAudio) {
        this.innerAudio.stop()
        this.innerAudio.destroy()
        this.innerAudio = null
      }
      this.playingVoiceId = null
    },
    toggleVoice(m) {
      if (!m || !m.content) return
      if (this.playingVoiceId === m.id) {
        this.stopVoice()
        return
      }
      this.stopVoice()
      const audio = uni.createInnerAudioContext()
      audio.src = mediaUrl(m.content)
      audio.onEnded(() => this.stopVoice())
      audio.onError(() => {
        uni.showToast({ title: this.$t('aiOnlineConsult.voicePlayFail'), icon: 'none' })
        this.stopVoice()
      })
      this.innerAudio = audio
      this.playingVoiceId = m.id
      audio.play()
    }
  }
}
</script>

<style lang="scss" scoped>
@import '@/styles/morandi.scss';
@import '@/styles/tab-page.scss';

.assistant-page {
  display: flex;
  flex-direction: column;
  min-width: 0;
  min-height: 100vh;
  height: 100vh;
  overflow: hidden;
  box-sizing: border-box;
  background: $morandi-bg-page;
}

.assistant-disclaimer {
  flex-shrink: 0;
  display: flex;
  flex-direction: column;
  gap: 8rpx;
  padding: 16rpx 20rpx;
  background: #fffbeb;
  border-bottom: 1rpx solid #fde68a;
}

.assistant-disclaimer__tag {
  font-size: 24rpx;
  font-weight: 600;
  color: #b45309;
}

.assistant-disclaimer__txt {
  font-size: 22rpx;
  color: #92400e;
  line-height: 1.45;
}

.assistant-feed {
  flex: 1 1 0;
  min-height: 0;
  min-width: 0;
  width: 100%;
  box-sizing: border-box;
  background: $morandi-feed;
}

/* #ifdef H5 */
.assistant-feed ::v-deep .uni-scroll-view,
.assistant-feed ::v-deep .uni-scroll-view-content {
  height: 100% !important;
}
/* #endif */

.assistant-feed__inner {
  display: flex;
  flex-direction: column;
  gap: 16rpx;
  min-width: 0;
  padding: 16rpx 16rpx 24rpx;
  padding-bottom: calc(340rpx + env(safe-area-inset-bottom));
  box-sizing: border-box;
}

.assistant-feed__inner--pending {
  padding-bottom: calc(460rpx + env(safe-area-inset-bottom));
}

.assistant-feed__tail {
  height: 1px;
  width: 100%;
}

.feed-hint,
.feed-empty {
  padding: 16rpx 0;
  display: flex;
  justify-content: center;
}

.feed-hint__txt,
.feed-empty__txt {
  font-size: 24rpx;
  color: $morandi-text-muted;
}

.chat-date {
  display: flex;
  justify-content: center;
  padding: 8rpx 0;
}

.chat-date__txt {
  font-size: 22rpx;
  color: $morandi-text-soft;
  padding: 6rpx 20rpx;
  border-radius: 999rpx;
  background: rgba(255, 255, 255, 0.6);
}

.bubble-row {
  display: flex;
  flex-direction: row;
  align-items: flex-end;
  gap: 12rpx;
  width: 100%;
  min-width: 0;
}

.bubble-row--bot {
  justify-content: flex-start;
}

.bubble-row--user {
  justify-content: flex-end;
}

.bubble-avatar {
  flex-shrink: 0;
  width: 56rpx;
  height: 56rpx;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
}

.bubble-avatar--ai {
  background: rgba(34, 197, 94, 0.15);
}

.bubble-avatar--user {
  background: rgba(34, 197, 94, 0.22);
}

.bubble {
  display: flex;
  flex-direction: column;
  min-width: 0;
  max-width: 72%;
  padding: 16rpx 18rpx;
  border-radius: 16rpx;
  gap: 8rpx;
  box-sizing: border-box;
}

.bubble--bot {
  background: $morandi-bg-card-inner;
  border: 1rpx solid $morandi-border-soft;
}

.bubble--user {
  background: #bbf7d0;
  border: 1rpx solid #86efac;
}

.bubble--image {
  max-width: 85%;
}

.bubble--thinking {
  min-width: 120rpx;
}

.bubble__who {
  font-size: 22rpx;
  font-weight: 600;
  color: $morandi-text-muted;
}

.bubble--user .bubble__who {
  color: #166534;
}

.bubble__txt {
  font-size: 28rpx;
  line-height: 1.5;
  word-break: break-word;
  overflow-wrap: break-word;
  max-width: 100%;
  min-width: 0;
  color: $morandi-text;
}

.bubble__rich {
  display: block;
  width: 100%;
  min-width: 0;
  max-width: 100%;
  overflow: visible;
  box-sizing: border-box;
  font-size: 28rpx;
  line-height: 1.55;
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

/* iOS rich-text 会放大 h1/h2(2em)；兜底压回正文大小 */
.bubble__rich ::v-deep ._h1,
.bubble__rich ::v-deep ._h2,
.bubble__rich ::v-deep ._h3,
.bubble__rich ::v-deep ._h4,
.bubble__rich ::v-deep ._h5,
.bubble__rich ::v-deep ._h6 {
  font-size: 14px !important;
  font-weight: 600;
  line-height: 1.35;
  margin: 8px 0 6px;
}

.bubble__rich ::v-deep ._root,
.bubble__rich ::v-deep ._p,
.bubble__rich ::v-deep ._div {
  font-size: 14px;
  line-height: 1.55;
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

.bubble__rich ::v-deep ._big {
  font-size: 14px !important;
}

.bubble--user .bubble__rich {
  color: #166534;
}

.bubble--bot .bubble__rich {
  color: $morandi-text;
}

.bubble__img {
  display: block;
  width: 200rpx;
  max-width: 100%;
  height: auto;
  border-radius: 8rpx;
  overflow: hidden;
}

.bubble__video {
  width: 100%;
  max-width: 360rpx;
  min-height: 200rpx;
  border-radius: 12rpx;
}

.bubble__time {
  font-size: 20rpx;
  color: $morandi-text-soft;
  align-self: flex-end;
}

.thinking-status {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 16rpx;
  margin-top: 8rpx;
}

.thinking-txt {
  font-size: 26rpx;
  color: $morandi-text-muted;
}

.thinking-dots {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 8rpx;
}

.thinking-dots__dot {
  width: 12rpx;
  height: 12rpx;
  border-radius: 50%;
  background: #22c55e;
  animation: ai-thinking-dot 1.05s ease-in-out infinite both;

  &:nth-child(2) {
    animation-delay: 0.18s;
  }

  &:nth-child(3) {
    animation-delay: 0.36s;
  }
}

@keyframes ai-thinking-dot {
  0%,
  80%,
  100% {
    opacity: 0.35;
    transform: scale(0.85);
  }

  40% {
    opacity: 1;
    transform: scale(1);
  }
}

.bubble__txt--stream {
  white-space: pre-wrap;
  word-break: break-word;
}

.voice-msg {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 12rpx;
  padding: 12rpx 16rpx;
  border-radius: 12rpx;
  background: $morandi-bg-muted;
}

.voice-msg--on {
  background: rgba(34, 197, 94, 0.2);
}

.voice-msg__dur {
  font-size: 28rpx;
  font-weight: 600;
  color: $morandi-text;
}

.voice-msg__label {
  font-size: 24rpx;
  color: $morandi-text-muted;
}

.assistant-composer {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 200;
  display: flex;
  flex-direction: column;
  gap: 12rpx;
  min-width: 0;
  padding: 12rpx 16rpx;
  padding-bottom: calc(12rpx + env(safe-area-inset-bottom));
  background: $morandi-composer;
  border-top: 1rpx solid $morandi-border-soft;
  box-shadow: 0 -8rpx 24rpx rgba(74, 69, 66, 0.06);
}

.composer-tools {
  display: flex;
  flex-direction: row;
  flex-wrap: nowrap;
  align-items: center;
  width: 100%;
  min-width: 0;
  gap: 10rpx;
  box-sizing: border-box;
}

.composer-model {
  flex: 1;
  min-width: 0;
  height: 64rpx;
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  gap: 6rpx;
  padding: 0 16rpx;
  border-radius: 999rpx;
  background: $morandi-bg-card-inner;
  border: 1rpx solid $morandi-border-soft;
  box-sizing: border-box;
}

.composer-model__txt {
  flex: 1;
  min-width: 0;
  font-size: 24rpx;
  line-height: 1.2;
  color: #16a34a;
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.composer-icon-btn {
  flex-shrink: 0;
  width: 64rpx;
  height: 64rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 12rpx;
  background: $morandi-bg-card-inner;
  border: 1rpx solid $morandi-border-soft;
  box-sizing: border-box;
}

.assistant-composer--has-pending {
  gap: 10rpx;
}

.composer-pending {
  width: 100%;
  white-space: nowrap;
}

.composer-pending__track {
  display: inline-flex;
  flex-direction: row;
  align-items: center;
  gap: 12rpx;
  padding: 4rpx 0;
}

.composer-pending__item {
  position: relative;
  flex-shrink: 0;
  width: 200rpx;
  border-radius: 12rpx;
  overflow: hidden;
  background: $morandi-bg-muted;
  border: 1rpx solid $morandi-border-soft;
}

.composer-pending__thumb {
  display: block;
}

.composer-pending__thumb--image {
  width: 200rpx;
  max-width: 100%;
  height: auto;
}

.composer-pending__thumb--video {
  position: relative;
  width: 200rpx;
  height: 200rpx;
}

.composer-pending__thumb--video > .composer-pending__thumb {
  width: 100%;
  height: 100%;
}

.composer-pending__video-mask {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  bottom: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.35);
}

.composer-pending__thumb--voice {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8rpx;
  width: 200rpx;
  height: 200rpx;
  padding: 8rpx;
  box-sizing: border-box;
}

.composer-pending__voice-name {
  font-size: 18rpx;
  color: $morandi-text-muted;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.composer-pending__close {
  position: absolute;
  top: 0;
  right: 0;
  width: 40rpx;
  height: 40rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.5);
  border-bottom-left-radius: 12rpx;
  z-index: 2;
}

.composer-input-row {
  display: flex;
  flex-direction: row;
  flex-wrap: nowrap;
  align-items: flex-end;
  width: 100%;
  gap: 12rpx;
  min-width: 0;
  box-sizing: border-box;
}

.composer-input-shell {
  flex: 1;
  min-width: 0;
  min-height: 72rpx;
  max-height: 200rpx;
  padding: 10rpx 16rpx;
  border-radius: 12rpx;
  background: $morandi-bg-card-inner;
  border: 1rpx solid $morandi-border-soft;
  box-sizing: border-box;
}

.composer-textarea {
  width: 100%;
  min-height: 48rpx;
  max-height: 180rpx;
  font-size: 28rpx;
  line-height: 1.45;
  color: $morandi-text;
}

.composer-textarea-ph {
  color: $morandi-text-muted;
  font-size: 26rpx;
}

.composer-send {
  flex-shrink: 0;
  padding: 18rpx 24rpx;
  border-radius: 12rpx;
  background: linear-gradient(90deg, #16a34a, #22c55e);
  box-sizing: border-box;
}

.composer-send--disabled {
  opacity: 0.5;
}

.composer-send__txt {
  font-size: 26rpx;
  font-weight: 600;
  color: #fff;
  white-space: nowrap;
}
</style>
