<template>
  <div class="app-container ai-diagnosis">
    <el-card shadow="never" class="ai-diagnosis__toolbar">
      <el-form :inline="true" size="small" @submit.native.prevent>
        <el-form-item :label="dtT('queryContent')">
          <el-input
            v-model="filterContent"
            :placeholder="dtT('queryContentPh')"
            clearable
            style="width: 280px"
            @keyup.enter.native="handleSearch"
          />
        </el-form-item>
        <el-form-item>
          <el-button type="primary" icon="el-icon-search" @click="handleSearch">{{ dtCommon("search") }}</el-button>
          <el-button icon="el-icon-refresh" @click="handleReset">{{ dtCommon("reset") }}</el-button>
          <el-button
            v-hasPermi="['diseaseTreatment:aiOnlineConsult:add']"
            type="primary"
            icon="el-icon-plus"
            @click="handleNewSession"
          >{{ dtT("newSessionBtn") }}</el-button>
        </el-form-item>
      </el-form>
      <el-alert
        v-if="disclaimer"
        class="ai-diagnosis__disclaimer"
        type="warning"
        :closable="false"
        show-icon
        :title="dtT('disclaimerTitle')"
      >
        <span class="ai-diagnosis__disclaimer-text">{{ disclaimer }}</span>
      </el-alert>
    </el-card>

    <el-card shadow="never" class="ai-diagnosis__main">
      <div class="ai-diagnosis__layout">
        <aside class="ai-diagnosis__sessions">
          <div class="ai-diagnosis__sessions-head">
            <div class="ai-diagnosis__sessions-title">{{ dtT("listTitle") }}</div>
            <div class="ai-diagnosis__sessions-sub">{{ dtT("listSubDefault") }}</div>
          </div>
          <div v-loading="listLoading" class="ai-diagnosis__sessions-scroll">
            <div
              v-for="s in sessions"
              :key="resolveSessionId(s)"
              class="session-item"
              :class="{ 'is-active': resolveSessionId(s) === activeSessionId }"
              @click="openSession(s)"
            >
              <el-avatar :size="40" icon="el-icon-chat-dot-round" class="session-item__avatar" />
              <div class="session-item__text">
                <div class="session-item__row">
                  <span class="session-item__name">{{ sessionTitle(s) }}</span>
                  <span class="session-item__date">{{ formatListDate(s.lastMessageTime) }}</span>
                </div>
                <div class="session-item__preview">{{ s.lastMessagePreview || dash }}</div>
              </div>
              <el-button
                v-hasPermi="['diseaseTreatment:aiOnlineConsult:remove']"
                type="text"
                icon="el-icon-delete"
                class="session-item__delete"
                :title="dtT('deleteSession')"
                @click.stop="handleDeleteSession(s)"
              />
            </div>
            <el-empty
              v-if="!listLoading && !sessions.length"
              :description="listEmptyText"
              :image-size="72"
            />
          </div>
        </aside>

        <section class="ai-diagnosis__chat">
          <header class="ai-diagnosis__chat-head">
            {{ activeSession ? sessionTitle(activeSession) : dtT("selectSession") }}
          </header>
          <div
            ref="chatScroll"
            v-loading="messagesLoading"
            class="ai-diagnosis__chat-body"
            @scroll.passive="onChatScroll"
          >
            <div v-if="activeSessionId && loadingOlder" class="chat-load-hint">{{ dtT("loadingMore") }}</div>
            <div v-if="activeSessionId && noMoreOlder && messages.length" class="chat-load-hint">{{ dtT("noMoreHistory") }}</div>
            <div class="chat-messages">
              <template v-if="activeSessionId && messages.length">
                <template v-for="(group, gi) in messageGroups">
                  <div :key="'d-' + gi" class="chat-date-divider">{{ group.dateLabel }}</div>
                  <div
                    v-for="m in group.items"
                    :key="m.id"
                    class="chat-row"
                    :class="isUserMessage(m) ? 'is-user' : 'is-ai'"
                  >
                    <el-avatar
                      v-if="!isUserMessage(m)"
                      :size="36"
                      icon="el-icon-s-opportunity"
                      class="chat-row__avatar"
                    />
                    <div class="chat-bubble" :class="isUserMessage(m) ? 'is-user' : 'is-ai'">
                      <div
                        v-if="m.msgType === 1 && !isUserMessage(m)"
                        class="chat-bubble__text chat-bubble__md"
                        :class="{ 'is-streaming': m.streaming }"
                        v-html="renderChatMarkdown(m.content)"
                      />
                      <div
                        v-else-if="m.msgType === 1 && isUserMessage(m)"
                        class="chat-bubble__user-body"
                      >
                        <el-image
                          v-for="(img, ii) in userMessageImages(m)"
                          :key="'uimg-' + ii"
                          :src="mediaUrl(img)"
                          :preview-src-list="userMessageImages(m).map((u) => mediaUrl(u))"
                          fit="cover"
                          class="chat-bubble__img chat-bubble__img--inline"
                        />
                        <div v-if="userMessageText(m)" class="chat-bubble__text">{{ userMessageText(m) }}</div>
                      </div>
                      <div v-else-if="m.msgType === 1" class="chat-bubble__text">{{ m.content }}</div>
                      <el-image
                        v-else-if="m.msgType === 2"
                        :src="mediaUrl(m.content)"
                        :preview-src-list="[mediaUrl(m.content)]"
                        fit="cover"
                        class="chat-bubble__img"
                      />
                      <video
                        v-else-if="m.msgType === 3"
                        :src="mediaUrl(m.content)"
                        controls
                        class="chat-bubble__video"
                      />
                      <div
                        v-else-if="m.msgType === 4"
                        class="voice-msg"
                        :class="{ 'is-playing': playingVoiceId === m.id }"
                        @click="toggleVoice(m)"
                      >
                        <span class="voice-msg__dur">{{ formatVoiceDuration(m.mediaDuration) }}</span>
                        <span class="voice-msg__waves"><i /><i /><i /></span>
                      </div>
                      <div v-else class="chat-bubble__text">{{ m.content }}</div>
                      <div class="chat-bubble__time">{{ formatMsgTime(m.sendTime) }}</div>
                    </div>
                    <el-avatar
                      v-if="isUserMessage(m)"
                      :size="36"
                      icon="el-icon-user-solid"
                      class="chat-row__avatar"
                    />
                  </div>
                </template>
                <div v-if="showThinking" class="chat-row is-ai">
                  <el-avatar :size="36" icon="el-icon-s-opportunity" class="chat-row__avatar" />
                  <div class="chat-bubble is-ai chat-bubble--thinking" aria-live="polite">
                    <span class="thinking-status">
                      <span class="thinking-status__label">{{ dtT("thinking") }}</span>
                      <span class="thinking-dots" aria-hidden="true">
                        <i class="thinking-dots__dot" />
                        <i class="thinking-dots__dot" />
                        <i class="thinking-dots__dot" />
                      </span>
                    </span>
                  </div>
                </div>
              </template>
              <el-empty
                v-else-if="activeSessionId && !messagesLoading"
                :description="dtT('emptyChat')"
                :image-size="80"
              />
              <el-empty v-else-if="!activeSessionId" :description="dtT('selectSession')" :image-size="80" />
            </div>
          </div>

          <footer v-if="activeSessionId" class="ai-diagnosis__composer">
            <div class="composer-tools">
              <el-upload
                class="composer-upload"
                :action="uploadAction"
                :headers="uploadHeaders"
                :show-file-list="false"
                accept=".jpg,.jpeg,.png,.gif"
                :before-upload="(f) => beforeMediaUpload(f, 'image')"
                :on-success="(res, file) => onMediaUploadSuccess(res, file)"
                :on-error="onUploadError"
              >
                <el-button
                  type="text"
                  icon="el-icon-picture-outline"
                  :disabled="sending"
                  :title="dtT('uploadImage')"
                />
              </el-upload>
              <!-- <el-upload
                class="composer-upload"
                :action="uploadAction"
                :headers="uploadHeaders"
                :show-file-list="false"
                accept=".mp4,.mov"
                :before-upload="(f) => beforeMediaUpload(f, 'video')"
                :on-success="(res, file) => onMediaUploadSuccess(res, file, 3)"
                :on-error="onUploadError"
              >
                <el-button
                  type="text"
                  icon="el-icon-video-camera"
                  :disabled="sending"
                  :title="dtT('uploadVideo')"
                />
              </el-upload> -->
              <!-- <el-upload
                class="composer-upload"
                :action="uploadAction"
                :headers="uploadHeaders"
                :show-file-list="false"
                accept=".mp3,.m4a,.wav"
                :before-upload="(f) => beforeMediaUpload(f, 'voice')"
                :on-success="(res, file) => onMediaUploadSuccess(res, file, 4)"
                :on-error="onUploadError"
              >
                <el-button
                  type="text"
                  icon="el-icon-microphone"
                  :disabled="sending"
                  :title="dtT('uploadVoice')"
                />
              </el-upload> -->
              <el-popover
                v-model="modelPickerVisible"
                placement="top-end"
                width="280"
                trigger="click"
                popper-class="ai-diagnosis-model-popover"
                class="composer-model"
              >
                <div class="model-picker">
                  <div
                    v-for="opt in modelOptions"
                    :key="opt.value"
                    class="model-picker__item"
                    :class="{ 'is-active': model === opt.value }"
                    @click="pickModel(opt.value)"
                  >
                    <span class="model-picker__icon"><i :class="opt.icon" /></span>
                    <span class="model-picker__body">
                      <span class="model-picker__name">{{ opt.label }}</span>
                      <span class="model-picker__desc">{{ opt.desc }}</span>
                    </span>
                    <i v-if="model === opt.value" class="el-icon-check model-picker__check" />
                  </div>
                </div>
                <button
                  slot="reference"
                  type="button"
                  class="composer-model-trigger"
                  :class="{ 'is-open': modelPickerVisible }"
                  :title="currentModelOption.label"
                >
                  <i :class="currentModelOption.icon" class="composer-model-trigger__icon" />
                  <span class="composer-model-trigger__text">{{ currentModelOption.short }}</span>
                  <i class="el-icon-arrow-down composer-model-trigger__arrow" />
                </button>
              </el-popover>
            </div>
            <div v-if="pendingAttachments.length" class="composer-pending">
              <div
                v-for="(a, i) in pendingAttachments"
                :key="a.id || i"
                class="composer-pending__item"
              >
                <el-image
                  v-if="a.kind === 'image'"
                  class="composer-pending__thumb"
                  :src="a.previewUrl || mediaUrl(a.url)"
                  fit="cover"
                />
                <div v-if="a.uploading" class="composer-pending__loading">
                  <i class="el-icon-loading" />
                </div>
                <el-button
                  v-if="!a.uploading"
                  type="text"
                  icon="el-icon-close"
                  class="composer-pending__remove"
                  @click="removeAttachment(i)"
                />
              </div>
            </div>
            <div class="composer-input-row">
              <el-input
                v-model="draft"
                type="textarea"
                :rows="3"
                resize="none"
                :placeholder="dtT('draftPlaceholder')"
                :disabled="sending"
                @keydown.native="onDraftKeydown"
              />
              <el-button
                type="primary"
                class="composer-send"
                :loading="sending"
                :disabled="sendDisabled"
                @click="sendText"
              >{{ dtT("send") }}</el-button>
            </div>
          </footer>
        </section>
      </div>
    </el-card>
  </div>
</template>

<script>
import { mapGetters } from "vuex"
import { getToken } from "@/utils/auth"
import diseaseTreatmentLocaleMixin from "@/mixins/diseaseTreatmentLocaleMixin"
import {
  listAiConsultSessions,
  createAiConsultSession,
  listAiConsultMessages,
  sendAiConsultMessage,
  hideAiConsultSession,
  sendChatMessage,
  extractLlmSessionIdFromJson,
  getModelList
} from "@/api/diseaseTreatment/aiOnlineConsult"
import { renderChatMarkdown } from "@/utils/markdownRender"

const SENDER_ROLE_USER = 1
const SENDER_ROLE_AI = 3
const POLL_MS = 2000
const MESSAGE_PAGE_SIZE = 50
const DEFAULT_SESSION_TITLE = "新会话"
const LLM_SESSION_STORAGE_PREFIX = "ai_consult_llm_session_"

const MODEL_OPTION_DEFS = [
  { value: "auto", labelKey: "modelAuto", shortKey: "modelAutoShort", descKey: "modelAutoDesc", icon: "el-icon-cpu" },
  { value: "yak-general", labelKey: "modelGeneral", shortKey: "modelGeneralShort", descKey: "modelGeneralDesc", icon: "el-icon-chat-dot-round" },
  { value: "yak-feeding", labelKey: "modelFeeding", shortKey: "modelFeedingShort", descKey: "modelFeedingDesc", icon: "el-icon-s-goods" },
  { value: "yak-disease", labelKey: "modelDisease", shortKey: "modelDiseaseShort", descKey: "modelDiseaseDesc", icon: "el-icon-s-flag" },
  { value: "yak-growth", labelKey: "modelGrowth", shortKey: "modelGrowth", descKey: "modelGrowthDesc", icon: "el-icon-s-flag" },
]


function genLocalId() {
  return "m_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 9)
}

const MEDIA_RULES = {
  image: { exts: ["jpg", "jpeg", "png", "gif"], maxMb: 10, errFmt: "errImageFmt", errMb: "errImageMb" },
  video: { exts: ["mp4", "mov"], maxMb: 50, errFmt: "errVideoFmt", errMb: "errVideoMb" },
  voice: { exts: ["mp3", "m4a", "wav"], maxMb: 10, errFmt: "errVoiceFmt", errMb: "errVoiceMb" }
}

export default {
  name: "AiDiagnosis",
  mixins: [diseaseTreatmentLocaleMixin],
  data() {
    return {
      dtNs: "aiOnlineConsult",
      listLoading: false,
      messagesLoading: false,
      loadingOlder: false,
      noMoreOlder: false,
      sending: false,
      searchMode: false,
      filterContent: "",
      disclaimer: "",
      model: "auto",
      modelPickerVisible: false,
      pendingAttachments: [],
      sessions: [],
      activeSessionId: null,
      messages: [],
      draft: "",
      pollTimer: null,
      playingVoiceId: null,
      voiceAudio: null,
      listEmptyHint: "",
      pendingPersist: null,
      streamAbortController: null,
      _streamRevealTimer: null
    }
  },
  computed: {
    ...mapGetters(["id", "name"]),
    dash() {
      return this.$t("diseaseTreatment.status.dash")
    },
    activeSession() {
      return this.sessions.find((s) => this.resolveSessionId(s) === this.activeSessionId) || null
    },
    modelOptions() {
      return MODEL_OPTION_DEFS.map((o) => ({
        value: o.value,
        icon: o.icon,
        label: this.dtT(o.labelKey),
        short: this.dtT(o.shortKey),
        desc: this.dtT(o.descKey)
      }))
    },
    currentModelOption() {
      return this.modelOptions.find((o) => o.value === this.model) || this.modelOptions[0]
    },
    listEmptyText() {
      return this.listEmptyHint || this.dtT("emptySessions")
    },
    messageGroups() {
      const groups = []
      let currentKey = null
      let currentItems = []
      for (const m of this.messages) {
        const key = this.dateKey(m.sendTime)
        if (key !== currentKey) {
          if (currentItems.length) {
            groups.push({ dateLabel: this.formatDateDivider(currentKey), items: currentItems })
          }
          currentKey = key
          currentItems = [m]
        } else {
          currentItems.push(m)
        }
      }
      if (currentItems.length) {
        groups.push({ dateLabel: this.formatDateDivider(currentKey), items: currentItems })
      }
      return groups
    },
    uploadAction() {
      return process.env.VUE_APP_BASE_API + "/common/upload"
    },
    uploadHeaders() {
      return { Authorization: "Bearer " + getToken() }
    },
    sendDisabled() {
      const t = (this.draft || "").trim()
      if (this.sending) {
        return true
      }
      if (this.pendingAttachments.some((a) => a.uploading)) {
        return true
      }
      const hasReadyAttach = this.pendingAttachments.some((a) => a.url)
      return !t && !hasReadyAttach
    },
    showThinking() {
      if (!this.sending) {
        return false
      }
      // 仅显示等待动画；首包到达后由 streaming 气泡承接输出
      return !this.messages.some((m) => m.senderRole === SENDER_ROLE_AI && m.streaming)
    }
  },
  created() {
    this.loadSessions(false)
    this.getModelList()
  },
  beforeRouteLeave(to, from, next) {
    this.abortStreamRequest()
    this.flushPendingPersist()
      .finally(() => {
        this.stopPolling()
        this.stopVoice()
        next()
      })
  },
  beforeDestroy() {
    this.abortStreamRequest()
    this.stopPolling()
    this.stopVoice()
  },
  methods: {
    getModelList() {
      getModelList().then((res) => {
       
      })
    },
    renderChatMarkdown,
    /** 若依问诊主键：listAiConsultMessages / sendAiConsultMessage 路径参数（id，非 realSessionId） */
    resolveSessionId(session) {
      if (!session) {
        return null
      }
      if (typeof session === "string" || typeof session === "number") {
        return String(session)
      }
      if (session.id != null && session.id !== "") {
        return String(session.id)
      }
      if (session.sessionId != null && session.sessionId !== "") {
        return String(session.sessionId)
      }
      if (session.realSessionId != null && session.realSessionId !== "") {
        return String(session.realSessionId)
      }
      return null
    },
    /** 列表行中的大模型网关 sessionId（real_session_id / llmSessionId） */
    resolveLlmSessionIdFromRow(session) {
      if (!session) {
        return null
      }
      if (session.llmSessionId != null && session.llmSessionId !== "") {
        return String(session.llmSessionId)
      }
      const consultId = this.resolveSessionId(session)
      if (session.realSessionId != null && session.realSessionId !== "") {
        const rid = String(session.realSessionId)
        if (!consultId || rid !== consultId) {
          return rid
        }
      }
      if (session.sessionId != null && session.sessionId !== "") {
        const sid = String(session.sessionId)
        if (!consultId || sid !== consultId) {
          return sid
        }
      }
      return null
    },
    pickModel(value) {
      this.model = value
      this.modelPickerVisible = false
    },
    revokeAttachmentPreview(a) {
      if (a && a.objectUrl) {
        try {
          URL.revokeObjectURL(a.objectUrl)
        } catch (e) {
          /* ignore */
        }
      }
    },
    setPendingAttachment(item) {
      this.pendingAttachments.forEach((a) => this.revokeAttachmentPreview(a))
      this.pendingAttachments = [
        {
          id: genLocalId(),
          kind: item.kind,
          name: item.name,
          previewUrl: item.previewUrl,
          objectUrl: item.objectUrl || "",
          uploading: !!item.uploading,
          url: item.url || ""
        }
      ]
    },
    removeAttachment(i) {
      const a = this.pendingAttachments[i]
      this.revokeAttachmentPreview(a)
      this.pendingAttachments.splice(i, 1)
    },
    isUserMessage(m) {
      return m && m.senderRole === SENDER_ROLE_USER
    },
    sessionTitle(s) {
      if (!s) {
        return this.dtT("defaultSessionTitle")
      }
      const t = s.sessionTitle
      if (!t || t === DEFAULT_SESSION_TITLE) {
        return this.dtT("defaultSessionTitle")
      }
      return t
    },
    mediaUrl(url) {
      if (!url) {
        return ""
      }
      if (/^https?:\/\//i.test(url)) {
        return url
      }
      return process.env.VUE_APP_BASE_API + url
    },
    formatListDate(time) {
      if (!time) {
        return ""
      }
      const d = new Date(time)
      if (isNaN(d.getTime())) {
        return ""
      }
      return `${d.getMonth() + 1}月${d.getDate()}日`
    },
    formatMsgTime(time) {
      if (!time) {
        return ""
      }
      const d = new Date(time)
      if (isNaN(d.getTime())) {
        return ""
      }
      const h = String(d.getHours()).padStart(2, "0")
      const min = String(d.getMinutes()).padStart(2, "0")
      return `${h}:${min}`
    },
    dateKey(time) {
      if (!time) {
        return ""
      }
      const d = new Date(time)
      if (isNaN(d.getTime())) {
        return ""
      }
      return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`
    },
    formatDateDivider(key) {
      if (!key) {
        return ""
      }
      const parts = key.split("-")
      if (parts.length >= 3) {
        return `${Number(parts[1])}月${Number(parts[2])}日`
      }
      return key
    },
    formatVoiceDuration(sec) {
      const s = Math.max(1, Math.round(Number(sec) || 1))
      return s + '"'
    },
    applyDisclaimerFromResponse(res) {
      if (res && res.disclaimer) {
        this.disclaimer = res.disclaimer
      }
    },
    loadSessions(searchMode) {
      this.listLoading = true
      const params = {
        pageNum: 1,
        pageSize: 100,
        contentKeyword: (this.filterContent || "").trim() || undefined,
        searchMode: searchMode ? true : undefined
      }
      listAiConsultSessions(params)
        .then((res) => {
          this.applyDisclaimerFromResponse(res)
          if (searchMode && !(res.rows || []).length) {
            this.listEmptyHint = this.dtT("noSearchResult")
          } else {
            this.listEmptyHint = this.dtT("emptySessions")
          }
          this.sessions = this.mergeSessionsPreserveLlmId(res.rows || [])
          if (this.activeSessionId) {
            const active = this.sessions.find((s) => this.resolveSessionId(s) === this.activeSessionId)
            if (active) {
              this.hydrateLlmSessionId(active)
            }
          }
          if (this.activeSessionId && !this.sessions.some((s) => this.resolveSessionId(s) === this.activeSessionId)) {
            this.activeSessionId = null
            this.messages = []
            this.stopPolling()
          }
        })
        .catch(() => {
          this.sessions = []
        })
        .finally(() => {
          this.listLoading = false
        })
    },
    handleSearch() {
      const content = (this.filterContent || "").trim()
      if (!content) {
        this.$modal.msgWarning(this.dtT("searchNeedFilter"))
        return
      }
      this.searchMode = true
      this.loadSessions(true)
    },
    handleReset() {
      this.filterContent = ""
      this.searchMode = false
      this.loadSessions(false)
    },
    handleNewSession() {
      createAiConsultSession({})
        .then((res) => {
          const data = res.data || {}
          if (data.disclaimer) {
            this.disclaimer = data.disclaimer
          }
          if (data.id == null || data.id === "") {
            return
          }
          const consultId = String(data.id)
          this.clearLlmSessionId(consultId)
          const row = {
            id: consultId,
            realSessionId: data.realSessionId || null,
            llmSessionId: null,
            sessionTitle: data.sessionTitle || DEFAULT_SESSION_TITLE,
            lastMessagePreview: data.lastMessagePreview || "",
            lastMessageTime: data.lastMessageTime || new Date().toISOString()
          }
          const exists = this.sessions.some((s) => this.resolveSessionId(s) === consultId)
          if (!exists) {
            this.sessions.unshift(row)
          }
          this.openSession(row)
          this.loadSessions(this.searchMode)
        })
        .catch(() => {})
    },
    openSession(session) {
      const sid = this.resolveSessionId(session)
      if (!sid) {
        return
      }
      this.activeSessionId = sid
      this.hydrateLlmSessionId(session)
      this.noMoreOlder = false
      this.messages = []
      this.clearComposer()
      this.loadMessages(false)
      this.startPolling()
    },
    handleDeleteSession(session) {
      const sid = this.resolveSessionId(session)
      if (!sid) {
        return
      }
      const title = this.sessionTitle(session)
      this.$modal
        .confirm(this.dtT("confirmDeleteSession", { title }))
        .then(() => hideAiConsultSession(sid))
        .then(() => {
          this.$modal.msgSuccess(this.dtCommon("msgDelOk"))
          this.clearLlmSessionId(sid)
          this.sessions = this.sessions.filter((s) => this.resolveSessionId(s) !== sid)
          if (this.pendingPersist && this.pendingPersist.consultSessionId === sid) {
            this.pendingPersist = null
          }
          if (this.activeSessionId === sid) {
            this.abortStreamRequest()
            this.stopPolling()
            this.activeSessionId = ""
            this.messages = []
            this.draft = ""
            this.sending = false
          }
        })
        .catch(() => {})
    },
    loadMessages(older) {
      if (!this.activeSessionId) {
        return
      }
      const beforeId = older && this.messages.length ? this.messages[0].id : undefined
      if (older) {
        this.loadingOlder = true
      } else {
        this.messagesLoading = true
      }
      listAiConsultMessages(this.activeSessionId, {
        beforeId,
        pageSize: MESSAGE_PAGE_SIZE
      })
        .then((res) => {
          const batch = (res.data || []).map((m) => this.normalizeMessage(m))
          if (older) {
            if (!batch.length) {
              this.noMoreOlder = true
            } else {
              const el = this.$refs.chatScroll
              const prevHeight = el ? el.scrollHeight : 0
              this.messages = batch.concat(this.messages)
              this.$nextTick(() => {
                if (el) {
                  el.scrollTop = el.scrollHeight - prevHeight
                }
              })
              if (batch.length < MESSAGE_PAGE_SIZE) {
                this.noMoreOlder = true
              }
            }
          } else {
            this.messages = batch
            this.$nextTick(() => this.scrollChatBottom())
            if (!batch.length) {
              this.noMoreOlder = true
            }
          }
        })
        .finally(() => {
          this.messagesLoading = false
          this.loadingOlder = false
        })
    },
    normalizeMessage(m) {
      return {
        id: m.id || m.messageId,
        sessionId: m.sessionId,
        senderRole: m.senderRole,
        senderName: m.senderName,
        msgType: m.msgType,
        content: m.content,
        mediaDuration: m.mediaDuration,
        sendTime: m.sendTime
      }
    },
    contentForLlm(content) {
      if (typeof content === "string") {
        return content
      }
      if (!Array.isArray(content)) {
        return ""
      }
      return content
    },
    buildUserContentForLlm(text) {
      const parts = []
      const imgs = this.pendingAttachments.filter((a) => a.kind === "image")
      for (const im of imgs) {
        parts.push({ type: "image_url", image_url: { url: im.url } })
      }
      const body = (text || "").trim()
      if (body) {
        parts.push({ type: "text", text: body })
      }
      if (!parts.length) {
        return ""
      }
      if (parts.length === 1 && parts[0].type === "text") {
        return parts[0].text
      }
      return parts
    },
    llmSessionStorageKey(consultSessionId) {
      return LLM_SESSION_STORAGE_PREFIX + consultSessionId
    },
    loadLlmSessionId(consultSessionId) {
      if (!consultSessionId) {
        return null
      }
      try {
        return sessionStorage.getItem(this.llmSessionStorageKey(consultSessionId)) || null
      } catch (e) {
        return null
      }
    },
    saveLlmSessionId(consultSessionId, llmSessionId) {
      if (!consultSessionId || !llmSessionId) {
        return
      }
      try {
        sessionStorage.setItem(this.llmSessionStorageKey(consultSessionId), String(llmSessionId))
      } catch (e) {
        /* ignore */
      }
    },
    clearLlmSessionId(consultSessionId) {
      if (!consultSessionId) {
        return
      }
      try {
        sessionStorage.removeItem(this.llmSessionStorageKey(consultSessionId))
      } catch (e) {
        /* ignore */
      }
    },
    hydrateLlmSessionId(session) {
      if (!session) {
        return
      }
      const sid = this.resolveSessionId(session)
      const fromList = this.resolveLlmSessionIdFromRow(session)
      if (fromList && !session.llmSessionId) {
        this.$set(session, "llmSessionId", fromList)
        this.saveLlmSessionId(sid, fromList)
      }
      const stored = this.loadLlmSessionId(sid)
      if (stored && !session.llmSessionId) {
        this.$set(session, "llmSessionId", stored)
      }
    },
    /** 列表刷新后合并本地已记住的大模型 sessionId */
    mergeSessionsPreserveLlmId(rows) {
      const prevMap = {}
      ;(this.sessions || []).forEach((s) => {
        const id = this.resolveSessionId(s)
        const llm = this.resolveLlmSessionIdFromRow(s) || s.llmSessionId
        if (id && llm) {
          prevMap[id] = String(llm)
        }
      })
      return (rows || []).map((row) => {
        const consultId =
          row.id != null && row.id !== "" ? String(row.id) : this.resolveSessionId(row)
        if (!consultId) {
          return row
        }
        const llm =
          this.resolveLlmSessionIdFromRow(row) ||
          prevMap[consultId] ||
          this.loadLlmSessionId(consultId) ||
          null
        const next = {
          ...row,
          id: consultId
        }
        if (llm) {
          next.llmSessionId = String(llm)
        }
        return next
      })
    },
    resolveLlmSessionId(session) {
      if (!session) {
        return null
      }
      const sid = this.resolveSessionId(session)
      return (
        this.resolveLlmSessionIdFromRow(session) ||
        (session.llmSessionId && String(session.llmSessionId)) ||
        this.loadLlmSessionId(sid) ||
        null
      )
    },
    rememberLlmSessionId(session, llmSessionId) {
      if (!session || !llmSessionId) {
        return
      }
      const v = String(llmSessionId)
      this.$set(session, "llmSessionId", v)
      this.saveLlmSessionId(this.resolveSessionId(session), v)
    },
    messageToLlmItem(m) {
      const role = m.senderRole === SENDER_ROLE_AI ? "assistant" : "user"
      let content = m.content
      if (m.msgType === 2 && content) {
        content = [{ type: "image_url", image_url: { url: this.mediaUrl(content) } }]
      } else if (m.msgType === 1 && this.userMessageImages(m).length) {
        const parts = []
        for (const u of this.userMessageImages(m)) {
          parts.push({ type: "image_url", image_url: { url: this.mediaUrl(u) } })
        }
        const text = this.userMessageText(m)
        if (text) {
          parts.push({ type: "text", text })
        }
        content = parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts
      }
      return { role, content: this.contentForLlm(content) }
    },
    buildMessagesForLlm(llmSessionId) {
      if (llmSessionId) {
        for (let i = this.messages.length - 1; i >= 0; i--) {
          const m = this.messages[i]
          if (m.senderRole === SENDER_ROLE_USER) {
            return [this.messageToLlmItem(m)]
          }
        }
        return []
      }
      return this.messages.map((m) => this.messageToLlmItem(m))
    },
    extractLlmSessionId(data) {
      return extractLlmSessionIdFromJson(data)
    },
    extractAssistantText(data) {
      try {
        const choice = data.choices && data.choices[0]
        const msg = choice && choice.message
        return (msg && msg.content) || ""
      } catch (e) {
        return ""
      }
    },
    clearComposer() {
      this.pendingAttachments.forEach((a) => this.revokeAttachmentPreview(a))
      this.draft = ""
      this.pendingAttachments = []
    },
    userMessageText(m) {
      if (!m) {
        return ""
      }
      const c = m.content
      if (c && typeof c === "object" && !Array.isArray(c)) {
        return c.text || ""
      }
      if (typeof c === "string") {
        return c
      }
      return ""
    },
    userMessageImages(m) {
      if (!m) {
        return []
      }
      const c = m.content
      if (c && typeof c === "object" && !Array.isArray(c) && Array.isArray(c.imageUrls)) {
        return c.imageUrls.filter(Boolean)
      }
      return []
    },
    resolveUserDisplayMessage(draftText, pendingSnapshot) {
      const text = (draftText || "").trim()
      const imgs = pendingSnapshot.filter((a) => a.kind === "image" && a.url).map((a) => a.url)
      if (imgs.length && text) {
        return { msgType: 1, content: { text, imageUrls: imgs } }
      }
      if (imgs.length) {
        return { msgType: 2, content: imgs[0] }
      }
      return { msgType: 1, content: text }
    },
    resolvePayloadUserContent(payload) {
      if (payload.msgType === 1) {
        return payload.content
      }
      if (payload.msgType === 2) {
        const parts = [{ type: "image_url", image_url: { url: this.mediaUrl(payload.content) } }]
        const text = (this.draft || "").trim()
        if (text) {
          parts.push({ type: "text", text })
        }
        return parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts
      }
      return String(payload.content || "")
    },
    appendLocalMessage(senderRole, msgType, content) {
      const msg = {
        id: genLocalId(),
        senderRole,
        msgType: msgType || 1,
        content,
        sendTime: new Date().toISOString()
      }
      this.messages.push(msg)
      return msg
    },
    buildPersistSendBody(msgType, payload, draftText, attachments) {
      const body = { msgType: msgType || 1 }
      if (body.msgType === 1) {
        let text = (draftText || "").trim() || (payload && payload.content ? String(payload.content).trim() : "")
        if (!text && attachments && attachments.length) {
          const img = attachments.find((a) => a.kind === "image")
          if (img && img.url) {
            body.msgType = 2
            body.content = img.url
            return body
          }
        }
        body.content = text
      } else {
        body.content = (payload && payload.content) || ""
        if (payload && payload.mediaDuration != null) {
          body.mediaDuration = payload.mediaDuration
        }
      }
      return body
    },
    findLastLocalExchangeIndexes() {
      let userIdx = -1
      let aiIdx = -1
      for (let i = this.messages.length - 1; i >= 0; i--) {
        const m = this.messages[i]
        if (!m || !String(m.id).startsWith("m_")) {
          continue
        }
        if (aiIdx < 0 && m.senderRole === SENDER_ROLE_AI) {
          aiIdx = i
        } else if (userIdx < 0 && m.senderRole === SENDER_ROLE_USER) {
          userIdx = i
        }
        if (userIdx >= 0 && aiIdx >= 0) {
          break
        }
      }
      return { userIdx, aiIdx }
    },
    mergePersistResult(data, aiReplyContent) {
      if (!data) {
        return
      }
      const { userIdx, aiIdx } = this.findLastLocalExchangeIndexes()
      if (data.userMessage && userIdx >= 0) {
        const norm = this.normalizeMessage(data.userMessage)
        const row = this.messages[userIdx]
        this.$set(row, "id", norm.id)
        if (norm.sendTime) {
          this.$set(row, "sendTime", norm.sendTime)
        }
      }
      if (data.aiMessage && aiIdx >= 0) {
        const norm = this.normalizeMessage(data.aiMessage)
        const row = this.messages[aiIdx]
        this.$set(row, "id", norm.id)
        if (norm.sendTime) {
          this.$set(row, "sendTime", norm.sendTime)
        }
      }
      const session = this.activeSession
      const previewText = (aiReplyContent || "").trim()
      if (session && previewText) {
        this.$set(session, "lastMessageTime", new Date().toISOString())
        this.$set(
          session,
          "lastMessagePreview",
          previewText.length > 80 ? previewText.slice(0, 80) + "…" : previewText
        )
        const um = data.userMessage
        if (um && um.msgType === 1 && um.content) {
          const title = session.sessionTitle
          if (!title || title === DEFAULT_SESSION_TITLE) {
            const t = um.content.length > 30 ? um.content.slice(0, 30) + "…" : um.content
            this.$set(session, "sessionTitle", t)
          }
        }
      }
    },
    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
      }
    },
    async flushPendingPersist() {
      const pending = this.pendingPersist
      if (!pending || !pending.consultSessionId || !pending.persistBody || !pending.aiReplyContent) {
        return
      }
      try {
        await 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
        })
        this.pendingPersist = null
      } catch (err) {
        // 离开页面时仍失败则保留队列，下次进入同页可再试（仅内存，刷新会丢）
      }
    },
    async persistConsultExchange(persistBody, aiReplyContent, costTime, aiCategory) {
      const reply = (aiReplyContent || "").trim()
      if (!this.activeSessionId || !persistBody || !reply) {
        return true
      }
      const consultSessionId = this.activeSessionId
      const llmSessionId = this.resolveLlmSessionId(this.activeSession)
      const saveBody = {
        ...persistBody,
        aiReplyContent: reply,
        realSessionId: llmSessionId || null,
        costTime: costTime != null ? costTime : undefined,
        aiCategory: aiCategory != null && aiCategory !== "" ? aiCategory : undefined
      }
      try {
        const res = await sendAiConsultMessage(consultSessionId, saveBody)
        this.mergePersistResult(res.data, reply)
        this.refreshSessionListQuiet()
        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.dtT("saveSessionFailed")
        this.$modal.msgWarning(msg)
        return false
      }
    },
    refreshSessionListQuiet() {
      const params = {
        pageNum: 1,
        pageSize: 100,
        contentKeyword: (this.filterContent || "").trim() || undefined,
        searchMode: this.searchMode ? true : undefined
      }
      listAiConsultSessions(params)
        .then((res) => {
          this.applyDisclaimerFromResponse(res)
          this.sessions = this.mergeSessionsPreserveLlmId(res.rows || [])
          if (this.activeSessionId) {
            const active = this.sessions.find((s) => this.resolveSessionId(s) === this.activeSessionId)
            if (active) {
              this.hydrateLlmSessionId(active)
            }
          }
        })
        .catch(() => {})
    },
    onChatScroll(e) {
      const el = e.target
      if (el.scrollTop < 40 && !this.loadingOlder && !this.noMoreOlder && this.messages.length) {
        this.loadMessages(true)
      }
    },
    startPolling() {
      this.stopPolling()
      this.pollTimer = setInterval(() => {
        this.refreshSessionListQuiet()
      }, POLL_MS)
    },
    stopPolling() {
      if (this.pollTimer) {
        clearInterval(this.pollTimer)
        this.pollTimer = null
      }
    },
    abortStreamRequest() {
      if (this.streamAbortController) {
        this.streamAbortController.abort()
        this.streamAbortController = 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
      }
      const pin = () => this.$nextTick(() => this.scrollChatBottom())
      if (!next.startsWith(prev)) {
        this.$set(aiMsg, "content", next)
        pin()
        return
      }
      const jump = next.length - prev.length
      if (jump <= 8) {
        this.$set(aiMsg, "content", next)
        pin()
        return
      }
      this.clearStreamRevealTimer()
      let pos = prev.length
      const step = () => {
        if (!aiMsg.streaming) {
          this._streamRevealTimer = null
          this.$set(aiMsg, "content", next)
          pin()
          return
        }
        pos = Math.min(pos + 3, next.length)
        this.$set(aiMsg, "content", next.slice(0, pos))
        pin()
        if (pos < next.length) {
          this._streamRevealTimer = setTimeout(step, 20)
        } else {
          this._streamRevealTimer = null
        }
      }
      step()
    },
    scrollChatBottom() {
      const el = this.$refs.chatScroll
      if (!el) {
        return
      }
      el.scrollTop = el.scrollHeight
    },
    onDraftKeydown(e) {
      if (e.key === "Enter" && !e.shiftKey) {
        e.preventDefault()
        if (!this.sendDisabled) {
          this.sendText()
        }
      }
    },
    sendText() {
      const text = (this.draft || "").trim()
      if (this.pendingAttachments.some((a) => a.uploading)) {
        this.$modal.msgWarning(this.dtT("uploading"))
        return
      }
      if (!text && !this.pendingAttachments.some((a) => a.url)) {
        this.$modal.msgWarning(this.dtT("inputOrAttach"))
        return
      }
      this.sendViaLlm({ msgType: 1, content: text })
    },
    async sendViaLlm(payload) {
      if (!this.activeSessionId || this.sending) {
        return
      }
      const session = this.activeSession
      if (!session) {
        this.$modal.msgWarning(this.dtT("selectSession"))
        return
      }
      let userContent
      if (payload && payload.msgType && payload.msgType !== 1) {
        userContent = this.resolvePayloadUserContent(payload)
      } else {
        userContent = this.buildUserContentForLlm((payload && payload.content) || this.draft)
      }
      if (!userContent) {
        this.$modal.msgWarning(this.dtT("inputOrAttach"))
        return
      }
      const msgType = (payload && payload.msgType) || 1
      const draftText = (this.draft || "").trim()
      const pendingSnapshot = this.pendingAttachments.slice()
      const persistBody = this.buildPersistSendBody(msgType, payload, draftText, pendingSnapshot)
      const displayMsg = this.resolveUserDisplayMessage(draftText, pendingSnapshot)
      this.appendLocalMessage(SENDER_ROLE_USER, displayMsg.msgType, displayMsg.content)
      this.clearComposer()
      this.$nextTick(() => this.scrollChatBottom())

      const llmSessionId = this.resolveLlmSessionId(session)
      const body = {
        model: this.model,
        messages: this.buildMessagesForLlm(llmSessionId),
        user: this.name ? String(this.name) : String(this.id || "user")
      }
      // 继续对话：带上大模型 sessionId（SSE 的 id）；新建会话首次不传
      if (llmSessionId) {
        body.session_id = llmSessionId
      }

      let aiReplyContent = ""
      let aiMsg = null
      let costTime = null
      let aiCategory = null
      this.abortStreamRequest()
      const abortController = new AbortController()
      this.streamAbortController = abortController
      this.sending = true
      try {
        const streamResult = await sendChatMessage(body, {
          signal: abortController.signal,
          onSessionId: (llmSid) => {
            this.rememberLlmSessionId(session, llmSid)
          },
          onModel: (model) => {
            aiCategory = model
          },
          onDelta: (fullText) => {
            aiReplyContent = fullText
            if (!aiMsg) {
              aiMsg = this.appendLocalMessage(SENDER_ROLE_AI, 1, fullText)
              this.$set(aiMsg, "streaming", true)
              this.$nextTick(() => this.scrollChatBottom())
            } else {
              this.pushStreamText(aiMsg, fullText)
            }
          }
        })
        const llmSid = streamResult.sessionId || null
        if (llmSid) {
          this.rememberLlmSessionId(session, llmSid)
        }
        costTime = streamResult.durationMs != null ? streamResult.durationMs : null
        if (streamResult.model) {
          aiCategory = streamResult.model
        }
        this.clearStreamRevealTimer()
        aiReplyContent = (streamResult.content || aiReplyContent || "").trim() || this.dtT("noContent")
        if (!aiMsg) {
          aiMsg = this.appendLocalMessage(SENDER_ROLE_AI, 1, aiReplyContent)
        } else {
          this.$set(aiMsg, "content", aiReplyContent)
        }
        this.$set(aiMsg, "streaming", false)
      } catch (err) {
        if (err && (err.name === "AbortError" || err.name === "CanceledError")) {
          return
        }
        const msg = err.message || this.dtT("requestFailed")
        this.$modal.msgError(msg)
        aiReplyContent = this.dtT("callFailed", { msg })
        if (!aiMsg) {
          aiMsg = this.appendLocalMessage(SENDER_ROLE_AI, 1, aiReplyContent)
        } else {
          this.$set(aiMsg, "content", aiReplyContent)
          this.$set(aiMsg, "streaming", false)
        }
      } finally {
        this.clearStreamRevealTimer()
        if (aiMsg && aiMsg.streaming) {
          this.$set(aiMsg, "streaming", false)
        }
        this.streamAbortController = null
        this.sending = false
        this.$nextTick(() => this.scrollChatBottom())
      }
      await this.persistConsultExchange(
        persistBody,
        (aiReplyContent || "").trim(),
        costTime,
        aiCategory
      )
    },
    extOf(fileName) {
      if (!fileName || fileName.lastIndexOf(".") < 0) {
        return ""
      }
      return fileName.slice(fileName.lastIndexOf(".") + 1).toLowerCase()
    },
    beforeMediaUpload(file, kind) {
      const rule = MEDIA_RULES[kind]
      const ext = this.extOf(file.name)
      if (!rule.exts.includes(ext)) {
        this.$modal.msgError(this.dtT(rule.errFmt))
        return false
      }
      if (file.name.includes(",")) {
        this.$modal.msgError(this.dtT("errComma"))
        return false
      }
      if (file.size / 1024 / 1024 >= rule.maxMb) {
        this.$modal.msgError(this.dtT(rule.errMb))
        return false
      }
      const objectUrl = URL.createObjectURL(file)
      this.setPendingAttachment({
        kind,
        name: file.name,
        previewUrl: objectUrl,
        objectUrl,
        uploading: true,
        url: ""
      })
      return true
    },
    onMediaUploadSuccess(res, file) {
      if (res.code === 200 && (res.url || res.fileName)) {
        const url = res.url || res.fileName
        const pending = this.pendingAttachments[0]
        if (pending && pending.uploading) {
          this.$set(pending, "url", url)
          this.$set(pending, "uploading", false)
          this.$set(pending, "previewUrl", this.mediaUrl(url))
          if (pending.objectUrl) {
            this.revokeAttachmentPreview(pending)
            this.$set(pending, "objectUrl", "")
          }
        } else {
          this.setPendingAttachment({
            kind: "image",
            name: file.name,
            previewUrl: this.mediaUrl(url),
            url
          })
        }
      } else {
        this.pendingAttachments.forEach((a) => this.revokeAttachmentPreview(a))
        this.pendingAttachments = []
        this.$modal.msgError(res.msg || this.dtT("uploadFail"))
      }
    },
    onUploadError() {
      this.pendingAttachments.forEach((a) => this.revokeAttachmentPreview(a))
      this.pendingAttachments = []
      this.$modal.msgError(this.dtT("uploadFail"))
    },
    stopVoice() {
      if (this.voiceAudio) {
        this.voiceAudio.pause()
        this.voiceAudio = null
      }
      this.playingVoiceId = null
    },
    toggleVoice(m) {
      if (!m || !m.content) {
        return
      }
      if (this.playingVoiceId === m.id) {
        this.stopVoice()
        return
      }
      this.stopVoice()
      this.playingVoiceId = m.id
      const audio = new Audio(this.mediaUrl(m.content))
      this.voiceAudio = audio
      audio.onended = () => {
        if (this.playingVoiceId === m.id) {
          this.stopVoice()
        }
      }
      audio.onerror = () => {
        this.$modal.msgError(this.dtT("voicePlayFail"))
        this.stopVoice()
      }
      audio.play().catch(() => {
        this.$modal.msgError(this.dtT("voicePlayFail"))
        this.stopVoice()
      })
    }
  }
}
</script>

<style scoped lang="scss">
.ai-diagnosis {
  display: flex;
  flex-direction: column;
  height: calc(100vh - 84px - 10px);
  max-height: calc(100vh - 84px - 10px);
  overflow: hidden;
  box-sizing: border-box;
}

.ai-diagnosis__toolbar {
  flex-shrink: 0;
  margin-bottom: 12px;
}

.ai-diagnosis__disclaimer {
  margin-top: 8px;
}

.ai-diagnosis__disclaimer-text {
  font-size: 13px;
  line-height: 1.5;
}

.ai-diagnosis__main {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.ai-diagnosis__main.el-card ::v-deep .el-card__body {
  padding: 0;
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

.ai-diagnosis__layout {
  display: flex;
  flex: 1;
  min-height: 0;
  overflow: hidden;
}

.ai-diagnosis__sessions {
  width: 300px;
  flex-shrink: 0;
  min-height: 0;
  border-right: 1px solid #ebeef5;
  display: flex;
  flex-direction: column;
  background: #fafafa;
}

.ai-diagnosis__sessions-head {
  flex-shrink: 0;
  padding: 10px 14px 8px;
  border-bottom: 1px solid #ebeef5;
}

.ai-diagnosis__sessions-title {
  font-weight: 600;
  font-size: 15px;
  color: #303133;
}

.ai-diagnosis__sessions-sub {
  margin-top: 4px;
  font-size: 12px;
  color: #909399;
  line-height: 1.45;
}

.ai-diagnosis__sessions-scroll {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
}

.session-item {
  display: flex;
  align-items: flex-start;
  padding: 10px 12px;
  cursor: pointer;
  border-left: 3px solid transparent;
  transition: background 0.15s;

  &:hover {
    background: #f0f2f5;
  }

  &.is-active {
    background: #ecf5ff;
    border-left-color: #409eff;
  }
}

.session-item__delete {
  flex-shrink: 0;
  align-self: center;
  padding: 4px;
  margin-left: 4px;
  color: #909399;
  opacity: 0;
  transition: opacity 0.15s, color 0.15s;

  &:hover {
    color: #f56c6c;
  }
}

.session-item:hover .session-item__delete,
.session-item.is-active .session-item__delete {
  opacity: 1;
}

.session-item__avatar {
  flex-shrink: 0;
  background: #dcdfe6;
}

.session-item__text {
  margin-left: 10px;
  min-width: 0;
  flex: 1;
}

.session-item__row {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 8px;
}

.session-item__name {
  font-weight: 600;
  font-size: 13px;
  color: #303133;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.session-item__date {
  flex-shrink: 0;
  font-size: 12px;
  color: #909399;
}

.session-item__preview {
  margin-top: 4px;
  font-size: 12px;
  color: #909399;
  line-height: 1.4;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.ai-diagnosis__chat {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-width: 0;
  min-height: 0;
  overflow: hidden;
  background: #fff;
}

.ai-diagnosis__chat-head {
  flex-shrink: 0;
  text-align: center;
  padding: 14px 16px;
  font-weight: 600;
  font-size: 16px;
  color: #303133;
  border-bottom: 1px solid #ebeef5;
}

.ai-diagnosis__chat-body {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 12px 16px;
}

.chat-load-hint {
  text-align: center;
  font-size: 12px;
  color: #909399;
  padding: 6px 0 10px;
}

.chat-date-divider {
  text-align: center;
  font-size: 12px;
  color: #909399;
  margin: 12px 0 8px;
}

.chat-messages {
  min-height: 120px;
}

.chat-row {
  display: flex;
  align-items: flex-start;
  margin-bottom: 14px;
  gap: 10px;

  &.is-ai {
    justify-content: flex-start;
  }

  &.is-user {
    justify-content: flex-end;
  }
}

.chat-row__avatar {
  flex-shrink: 0;
}

.chat-row.is-ai .chat-row__avatar {
  background-color: #409eff !important;
  color: #fff !important;
}

.chat-row.is-user .chat-row__avatar {
  background-color: #52c41a !important;
  color: #fff !important;
}

.chat-bubble {
  box-sizing: border-box;
  padding: 8px 12px;
  border-radius: 10px;
  font-size: 14px;
  line-height: 1.5;

  &.is-ai {
    width: 680px;
    max-width: calc(100% - 46px);
    background: #e8f4ff;
    color: #1d3a5c;
    border: 1px solid #b3d8ff;
  }

  &.is-user {
    width: 360px;
    max-width: calc(100% - 46px);
    background: #52c41a;
    color: #fff;
  }

  &.chat-bubble--thinking {
    width: auto;
    max-width: none;
    min-width: 72px;
    min-height: 40px;
    display: flex;
    align-items: center;
  }
}

.chat-bubble__text {
  white-space: pre-wrap;
  word-break: break-word;
}

/* AI 回复：Markdown 排版（v-html），流式时同步渲染 */
.chat-bubble__md {
  white-space: normal;
  word-break: break-word;
  line-height: 1.6;

  &.is-streaming::after {
    content: "";
    display: inline-block;
    width: 2px;
    height: 1em;
    margin-left: 2px;
    vertical-align: text-bottom;
    background: currentColor;
    opacity: 0.7;
    animation: stream-cursor-blink 0.9s step-end infinite;
  }

  ::v-deep p {
    margin: 0 0 8px;
  }

  ::v-deep p:last-child {
    margin-bottom: 0;
  }

  ::v-deep ul,
  ::v-deep ol {
    margin: 0 0 8px;
    padding-left: 1.25em;
  }

  ::v-deep li {
    margin: 4px 0;
  }

  ::v-deep h1,
  ::v-deep h2,
  ::v-deep h3,
  ::v-deep h4 {
    margin: 12px 0 8px;
    font-weight: 600;
    line-height: 1.35;
  }

  ::v-deep h1 {
    font-size: 1.15em;
  }

  ::v-deep h2 {
    font-size: 1.08em;
  }

  ::v-deep blockquote {
    margin: 8px 0;
    padding: 6px 12px;
    border-left: 3px solid #409eff;
    background: rgba(255, 255, 255, 0.55);
  }

  ::v-deep pre {
    margin: 8px 0;
    padding: 10px 12px;
    border-radius: 6px;
    overflow-x: auto;
    background: #f6f8fa !important;
  }

  ::v-deep pre code {
    padding: 0;
    background: transparent;
  }

  ::v-deep code {
    font-family: Consolas, Monaco, monospace;
    font-size: 13px;
  }

  ::v-deep :not(pre) > code {
    padding: 2px 5px;
    border-radius: 4px;
    background: rgba(0, 0, 0, 0.06);
  }

  ::v-deep a {
    color: #409eff;
    text-decoration: underline;
  }

  ::v-deep table {
    border-collapse: collapse;
    margin: 8px 0;
    font-size: 13px;
    max-width: 100%;
  }

  ::v-deep th,
  ::v-deep td {
    border: 1px solid #d0d7de;
    padding: 6px 10px;
  }

  ::v-deep th {
    background: #f0f3f6;
  }

  ::v-deep hr {
    margin: 12px 0;
    border: none;
    border-top: 1px solid #d0d7de;
  }
}

@keyframes stream-cursor-blink {
  50% {
    opacity: 0;
  }
}

.chat-bubble__time {
  margin-top: 4px;
  font-size: 11px;
  opacity: 0.75;
  text-align: right;
}

.chat-bubble__img {
  max-width: 200px;
  max-height: 160px;
  border-radius: 6px;
}

.chat-bubble__user-body {
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 0;
}

.chat-bubble__img--inline {
  display: block;
}

.chat-bubble__video {
  max-width: 240px;
  max-height: 180px;
  border-radius: 6px;
}

.thinking-status {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}

.thinking-status__label {
  font-size: 14px;
  color: #606266;
  line-height: 1.4;
}

.thinking-dots {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 2px 0;
}

.thinking-dots__dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: #409eff;
  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%,
  100% {
    transform: translateY(0);
    opacity: 0.35;
  }

  40% {
    transform: translateY(-6px);
    opacity: 1;
  }
}

.voice-msg {
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 88px;
  cursor: pointer;
  user-select: none;
}

.chat-bubble.is-user .voice-msg {
  color: #fff;
}

.voice-msg__dur {
  font-size: 14px;
  font-weight: 500;
}

.voice-msg__waves {
  display: flex;
  align-items: flex-end;
  gap: 3px;
  height: 16px;

  i {
    display: block;
    width: 3px;
    border-radius: 2px;
    background: currentColor;
  }

  i:nth-child(1) {
    height: 6px;
  }

  i:nth-child(2) {
    height: 10px;
  }

  i:nth-child(3) {
    height: 14px;
  }
}

.voice-msg.is-playing .voice-msg__waves i {
  animation: ai-voice-bar 0.55s ease-in-out infinite alternate;

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

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

@keyframes ai-voice-bar {
  from {
    transform: scaleY(0.4);
  }

  to {
    transform: scaleY(1);
  }
}

.ai-diagnosis__composer {
  flex-shrink: 0;
  border-top: 1px solid #ebeef5;
  padding: 10px 14px 14px;
  background: #fafafa;
}

.composer-tools {
  display: flex;
  align-items: center;
  gap: 4px;
  margin-bottom: 6px;
}

.composer-model {
  margin-left: auto;
  padding-left: 8px;
}

.composer-model-trigger {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 30px;
  padding: 0 12px;
  border: 1px solid #dcdfe6;
  border-radius: 16px;
  background: #fff;
  color: #303133;
  font-size: 13px;
  cursor: pointer;

  &.is-open .composer-model-trigger__arrow {
    transform: rotate(180deg);
  }
}

.composer-model-trigger__icon {
  font-size: 15px;
  color: #409eff;
}

.composer-model-trigger__arrow {
  font-size: 12px;
  color: #909399;
  transition: transform 0.2s;
}

.composer-pending {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-bottom: 10px;
}

.composer-pending__item {
  position: relative;
  width: 72px;
  height: 72px;
  border-radius: 8px;
  overflow: hidden;
  background: #f0f2f5;
  flex-shrink: 0;
}

.composer-pending__thumb {
  width: 100%;
  height: 100%;
  display: block;
}

.composer-pending__loading {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.35);
  color: #fff;
  font-size: 22px;
}

.composer-pending__remove {
  position: absolute;
  top: 2px;
  right: 2px;
  padding: 0;
  width: 22px;
  height: 22px;
  min-height: 22px;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.45);
  color: #fff;

  ::v-deep i {
    color: #fff;
  }
}

.composer-upload {
  display: inline-block;
}

.composer-input-row {
  display: flex;
  align-items: flex-end;
  gap: 10px;

  ::v-deep .el-textarea__inner {
    background: #fff;
  }
}

.composer-send {
  flex-shrink: 0;
  margin-bottom: 4px;
}
</style>

<style lang="scss">
.ai-diagnosis-model-popover {
  padding: 8px !important;
}

.ai-diagnosis-model-popover .model-picker__item {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 12px;
  border-radius: 8px;
  cursor: pointer;

  &:hover {
    background: #f5f7fa;
  }

  &.is-active {
    background: #ecf5ff;
  }
}

.ai-diagnosis-model-popover .model-picker__icon {
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  background: #e8f4ff;
  color: #409eff;
}

.ai-diagnosis-model-popover .model-picker__body {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.ai-diagnosis-model-popover .model-picker__name {
  font-size: 13px;
  font-weight: 600;
  color: #303133;
}

.ai-diagnosis-model-popover .model-picker__desc {
  font-size: 12px;
  color: #909399;
}

.ai-diagnosis-model-popover .model-picker__check {
  color: #409eff;
}
</style>
