xyc
5 天以前 f620fcd86c8f389f6ca24023f36ddbb4fc70a671
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
<template>
  <el-dialog title="AI 解释分析" :visible="visible" width="860px" top="5vh" custom-class="ai-dialog"
             :close-on-click-modal="false" @open="onOpen" @update:visible="onDialogClose">
    <div class="ai-meta" v-if="context">
      <span v-if="context.enterpriseName">企业:{{ context.enterpriseName }}</span>
      <span v-if="context.reportPeriod">报表期:{{ context.reportPeriod }}</span>
      <span v-if="context.verifyExplanation" class="ai-meta-explain" :title="context.verifyExplanation">企业解释:{{ context.verifyExplanation }}</span>
    </div>
    <div class="chat-box" ref="chatBox" @scroll="onChatScroll">
      <div v-for="(msg, i) in chatMessages" :key="i" :class="['chat-msg', msg.role]">
        <template v-if="msg.role === 'assistant'">
          <ai-mascot :bouncing="msg.typing"></ai-mascot>
          <div class="bubble">
            <div class="md-body" v-html="renderMd(msg.content.slice(0, msg.displayLen), msg.typing)"></div>
            <div v-if="!msg.typing && msg.content" class="msg-tools">
              <button class="msg-speak" :class="{ playing: msg.speaking }"
                      :title="msg.speaking ? '停止朗读' : '朗读本条回复'" @click="speakMsg(msg)">
                {{ msg.speaking ? '⏹' : '🔊' }}<span v-if="msg.speaking"> 朗读中</span>
              </button>
            </div>
          </div>
        </template>
        <div v-else class="bubble">{{ msg.content }}</div>
      </div>
      <div v-if="chatLoading" class="chat-msg assistant">
        <ai-mascot :bouncing="true"></ai-mascot>
        <div class="bubble thinking-bubble">
          <span class="thinking-dot"></span>
          <span class="thinking-dot"></span>
          <span class="thinking-dot"></span>
          <span class="thinking-text">AI 思考中(如需会查询数据库)…</span>
        </div>
      </div>
      <div v-if="!chatLoading && chatMessages.length === 0" class="chat-empty">正在准备分析…</div>
    </div>
    <div class="chat-input">
      <el-input v-model="chatInput" type="textarea" :rows="2" resize="none"
                placeholder="继续追问,例如:这家企业车辆数是多少?与运政数据一致吗?"
                @keydown.enter.native="onChatKeydown"></el-input>
      <div class="chat-input-actions">
        <div class="chat-input-left">
          <button v-if="speechSupported" class="voice-btn" :class="{ listening: speechListening }"
                  :title="speechListening ? '点击结束语音输入' : '语音输入(说完自动发送)'" @click="toggleVoiceInput">
            <span v-if="speechListening" class="voice-ripple"></span>{{ speechListening ? '⏹' : '🎤' }}
          </button>
          <button class="voice-btn speak-toggle" :class="{ muted: !voiceEnabled }"
                  :title="voiceEnabled ? '关闭 AI 语音播报' : '开启 AI 语音播报'" @click="toggleVoice">
            {{ voiceEnabled ? '🔊' : '🔇' }}
          </button>
          <span class="chat-tip">Enter 发送,Shift+Enter 换行;语音对话:点 🎤 说话,AI 将语音回答</span>
        </div>
        <el-button type="primary" :loading="chatLoading" @click="sendChat()">发送</el-button>
      </div>
      <div v-if="speechListening" class="speech-hint"><span class="dot"></span>正在聆听,请说话…</div>
    </div>
  </el-dialog>
</template>
<script>
import api from '@/api'
 
const AiMascot = {
  name: 'AiMascot',
  props: { bouncing: { type: Boolean, default: false } },
  render(h) {
    return h('div', { class: ['ai-mascot', { 'is-bouncing': this.bouncing }] }, [
      h('div', { class: 'mascot-sprout' }),
      h('div', { class: 'mascot-head' }, [
        h('div', { class: 'mascot-eye left' }),
        h('div', { class: 'mascot-eye right' }),
        h('div', { class: 'mascot-blush left' }),
        h('div', { class: 'mascot-blush right' }),
        h('div', { class: 'mascot-mouth' })
      ]),
      h('div', { class: 'mascot-body' }),
      h('div', { class: 'mascot-feet' })
    ])
  }
}
 
export default {
  name: 'AiChatDialog',
  components: { AiMascot },
  props: {
    visible: { type: Boolean, default: false },
    context: { type: Object, default: () => ({}) }
  },
  data() {
    return {
      chatMessages: [],
      chatInput: '',
      chatLoading: false,
      typingTimer: null,
      userScrolledUp: false,
      speechSupported: false,
      speechRec: null,
      speechListening: false,
      voiceDraft: '',
      pendingVoiceText: '',
      voiceEnabled: true,
      lastInputWasVoice: false
    }
  },
  methods: {
    onOpen() {
      this.chatMessages = []
      this.chatInput = ''
      this.chatLoading = true
      this.userScrolledUp = false
      this.lastInputWasVoice = false
      this.stopSpeech()
      this.initSpeech()
      this.$nextTick(() => this.scrollToBottom())
      api.post('/llm/chat', {
        messages: [{ role: 'user', content: '请用结构化 Markdown(小节标题、加粗、列表)分析该企业的解释是否合理,先给结论,再列数据依据,最后给出处置建议。' }],
        context: this.context
      })
        .then(res => { this.pushAssistant(res.data || 'AI 未返回内容,请重试') })
        .catch(() => { this.pushAssistant('AI 分析失败,请稍后重试或人工判断') })
        .finally(() => { this.$nextTick(() => this.scrollToBottom()) })
    },
    onDialogClose(val) {
      this.clearTyping()
      this.stopSpeech()
      this.$emit('update:visible', val)
    },
    beforeDestroy() {
      this.clearTyping()
      this.stopSpeech()
    },
    onChatScroll() {
      const box = this.$refs.chatBox
      if (!box) return
      this.userScrolledUp = box.scrollTop + box.clientHeight < box.scrollHeight - 40
    },
    onChatKeydown(e) {
      if (e.shiftKey) return
      e.preventDefault()
      this.sendChat()
    },
    sendChat(fromVoice) {
      const text = (this.chatInput || '').trim()
      if (!text || this.chatLoading) return
      this.lastInputWasVoice = !!fromVoice
      this.stopSpeech()
      this.chatMessages.push({ role: 'user', content: text })
      this.chatInput = ''
      this.userScrolledUp = false
      this.chatLoading = true
      this.$nextTick(() => this.scrollToBottom())
      api.post('/llm/chat', { messages: this.chatMessages, context: this.context })
        .then(res => { this.pushAssistant(res.data || 'AI 未返回内容,请重试') })
        .catch(() => { this.pushAssistant('AI 分析失败,请稍后重试或人工判断') })
        .finally(() => { this.$nextTick(() => this.scrollToBottom()) })
    },
    pushAssistant(text) {
      this.chatLoading = false
      const msg = { role: 'assistant', content: text || '', displayLen: 0, typing: true, speaking: false }
      this.chatMessages.push(msg)
      this.startTyping(msg)
    },
    startTyping(msg) {
      this.clearTyping()
      const full = msg.content
      if (!full) { msg.typing = false; return }
      // 打字速度:匀速逐字输出(每秒约 70 字符),短回复至少 2.5 秒,长回复最多约 24 秒
      const cps = 70
      const tickMs = 40
      const totalMs = Math.max(2500, Math.min(24000, (full.length / cps) * 1000))
      const ticks = Math.max(1, Math.ceil(totalMs / tickMs))
      const chunk = Math.max(1, Math.ceil(full.length / ticks))
      this.typingTimer = setInterval(() => {
        msg.displayLen = Math.min(full.length, msg.displayLen + chunk)
        if (!this.userScrolledUp) this.$nextTick(() => this.scrollToBottom())
        if (msg.displayLen >= full.length) {
          msg.typing = false
          this.clearTyping()
          this.$nextTick(() => this.scrollToBottom())
          this.afterTypingDone(msg)
        }
      }, tickMs)
    },
    clearTyping() {
      if (this.typingTimer) { clearInterval(this.typingTimer); this.typingTimer = null }
    },
    afterTypingDone(msg) {
      // 语音对话模式下(本次由语音发起),AI 回答自动朗读
      if (this.voiceEnabled && this.lastInputWasVoice) {
        this.speakMsg(msg)
      }
    },
    scrollToBottom() {
      const box = this.$refs.chatBox
      if (box) box.scrollTop = box.scrollHeight
    },
    // ---------- 语音输入(浏览器自带语音识别) ----------
    initSpeech() {
      if (this.speechRec) return
      const SR = window.SpeechRecognition || window.webkitSpeechRecognition
      if (!SR) return
      this.speechSupported = true
      const rec = new SR()
      rec.lang = 'zh-CN'
      rec.interimResults = true
      rec.continuous = false
      rec.maxAlternatives = 1
      rec.onresult = e => {
        let interim = ''
        let final = ''
        for (let i = e.resultIndex; i < e.results.length; i++) {
          const r = e.results[i]
          if (r.isFinal) final += r[0].transcript
          else interim += r[0].transcript
        }
        const base = this.voiceDraft || ''
        this.chatInput = base + final + interim
        if (final) this.pendingVoiceText = base + final
      }
      rec.onerror = e => {
        if (e.error === 'not-allowed' || e.error === 'service-not-allowed') {
          this.$message.warning('未获得麦克风权限,请在浏览器地址栏允许使用麦克风')
        } else if (e.error !== 'aborted') {
          this.$message.warning('语音识别失败:' + e.error)
        }
      }
      rec.onend = () => {
        this.speechListening = false
        const t = this.pendingVoiceText
        this.pendingVoiceText = ''
        if (t && t.trim()) {
          this.chatInput = t
          this.$nextTick(() => this.sendChat(true))
        }
      }
      this.speechRec = rec
    },
    toggleVoiceInput() {
      if (!this.speechRec || this.chatLoading) return
      if (this.speechListening) {
        this.speechRec.stop()
        return
      }
      this.voiceDraft = this.chatInput || ''
      this.pendingVoiceText = ''
      this.speechListening = true
      try { this.speechRec.start() } catch (err) { this.speechListening = false }
    },
    // ---------- AI 语音播报(浏览器自带语音合成) ----------
    toggleVoice() {
      this.voiceEnabled = !this.voiceEnabled
      if (!this.voiceEnabled) this.stopSpeech()
      this.$message[this.voiceEnabled ? 'success' : 'warning'](this.voiceEnabled ? '已开启 AI 语音播报' : '已关闭 AI 语音播报')
    },
    speakMsg(msg) {
      if (!('speechSynthesis' in window)) {
        this.$message.warning('当前浏览器不支持语音播报')
        return
      }
      const synth = window.speechSynthesis
      if (msg.speaking) {
        synth.cancel()
        msg.speaking = false
        return
      }
      this.chatMessages.forEach(m => { if (m.speaking) m.speaking = false })
      synth.cancel()
      const plain = msg.content.replace(/[*#`>|~]/g, ' ').replace(/\s+/g, ' ').trim()
      if (!plain) return
      const u = new SpeechSynthesisUtterance(plain.slice(0, 3000))
      u.lang = 'zh-CN'
      const voices = synth.getVoices()
      const zhVoice = voices.find(v => /zh[-_]CN/i.test(v.lang)) || voices.find(v => /^zh/i.test(v.lang))
      if (zhVoice) u.voice = zhVoice
      u.rate = 1.05
      u.onend = () => { msg.speaking = false }
      u.onerror = () => { msg.speaking = false }
      msg.speaking = true
      synth.speak(u)
    },
    stopSpeech() {
      if ('speechSynthesis' in window) window.speechSynthesis.cancel()
      this.chatMessages.forEach(m => { m.speaking = false })
    },
    // ---------- 轻量 Markdown 渲染 ----------
    escapeHtml(s) {
      return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
    },
    inline(text) {
      let t = this.escapeHtml(text)
      t = t.replace(/`([^`]+)`/g, '<code class="ai-code">$1</code>')
      t = t.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
      t = t.replace(/(^|[^*\s])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>')
      return t
    },
    renderMd(text, showCaret) {
      if (!text && !showCaret) return ''
      const src = (text || '').replace(/\r\n/g, '\n')
      const lines = src.split('\n')
      let html = ''
      let i = 0
      const isBullet = l => /^\s*[-*•]\s+/.test(l)
      const isNumbered = l => /^\s*\d+[.、]\s+/.test(l)
      while (i < lines.length) {
        const t = lines[i].trim()
        if (!t) { html += '<div class="ai-blank"></div>'; i++; continue }
        if (/^-{3,}\s*$/.test(t)) { html += '<hr class="ai-hr"/>'; i++; continue }
        if (t.startsWith('|') && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1].trim()) && lines[i + 1].indexOf('-') >= 0) {
          html += this.renderTable(lines, i)
          while (i < lines.length && lines[i].trim().startsWith('|')) i++
          continue
        }
        const h = t.match(/^(#{1,4})\s+(.*)$/)
        if (h) {
          const lvl = h[1].length
          html += '<div class="' + (lvl <= 2 ? 'ai-h' : 'ai-h3') + '">' + this.inline(h[2]) + '</div>'
          i++; continue
        }
        if (/^>\s?/.test(t)) { html += '<div class="ai-quote">' + this.inline(t.replace(/^>\s?/, '')) + '</div>'; i++; continue }
        if (isBullet(t)) {
          let items = []
          while (i < lines.length && isBullet(lines[i].trim())) {
            items.push('<li>' + this.inline(lines[i].trim().replace(/^\s*[-*•]\s+/, '')) + '</li>')
            i++
          }
          html += '<ul class="ai-ul">' + items.join('') + '</ul>'
          continue
        }
        if (isNumbered(t)) {
          let items = []
          while (i < lines.length && isNumbered(lines[i].trim())) {
            items.push('<li>' + this.inline(lines[i].trim().replace(/^\s*\d+[.、]\s+/, '')) + '</li>')
            i++
          }
          html += '<ol class="ai-ol">' + items.join('') + '</ol>'
          continue
        }
        const kw = t.replace(/\*\*/g, '').match(/^(结论|判断|综合判断|核实结论|处置建议|改进建议|建议|下一步|风险|问题|注意|警告|提示|数据依据|依据|原因|说明|参考)[::]/)
        if (kw) {
          html += '<div class="ai-label ' + this.labelClass(kw[1]) + '">' + this.inline(t) + '</div>'
          i++; continue
        }
        html += '<p class="ai-p">' + this.inline(t) + '</p>'
        i++
      }
      if (showCaret) html += '<span class="ai-caret"></span>'
      return html
    },
    labelClass(kw) {
      if (/结论|判断|核实/.test(kw)) return 'ai-label-conclusion'
      if (/建议|下一步/.test(kw)) return 'ai-label-advice'
      if (/风险|问题|注意|警告|提示/.test(kw)) return 'ai-label-risk'
      return 'ai-label-basis'
    },
    renderTable(lines, start) {
      const cells = l => {
        let s = l.trim()
        if (s.startsWith('|')) s = s.slice(1)
        if (s.endsWith('|')) s = s.slice(0, -1)
        return s.split('|')
      }
      const header = cells(lines[start])
      let i = start + 2
      const body = []
      while (i < lines.length && lines[i].trim().startsWith('|')) { body.push(cells(lines[i])); i++ }
      let h = '<table class="ai-table"><thead><tr>'
      h += header.map(c => '<th>' + this.inline(c.trim()) + '</th>').join('')
      h += '</tr></thead><tbody>'
      h += body.map(r => '<tr>' + r.map(c => '<td>' + this.inline(c.trim()) + '</td>').join('') + '</tr>').join('')
      h += '</tbody></table>'
      return h
    }
  }
}
</script>
<style>
.ai-meta { display: flex; gap: 20px; color: #606266; font-size: 13px; padding: 6px 4px 10px; border-bottom: 1px solid #ebeef5; flex-shrink: 0; }
.ai-meta-explain { max-width: 60%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chat-box { height: 440px; flex: 1 1 440px; min-height: 120px; overflow-y: auto; background: #f5f7fa; border: 1px solid #ebeef5; border-radius: 14px; padding: 16px 12px 12px; margin-top: 10px; }
.chat-msg { display: flex; margin-bottom: 12px; }
.chat-msg.user { justify-content: flex-end; }
.chat-msg.assistant { justify-content: flex-start; align-items: flex-start; }
.bubble { max-width: 82%; padding: 10px 12px; border-radius: 14px; line-height: 1.6; font-size: 14px; word-break: break-word; }
.chat-msg.user .bubble { background: #409EFF; color: white; white-space: pre-line; border-radius: 14px 14px 4px 14px; }
.chat-msg.assistant .bubble { background: white; border: 1px solid #e4e7ed; color: #303133; border-radius: 4px 14px 14px 14px; }
.bubble.loading { color: #909399; }
.chat-empty { color: #909399; text-align: center; margin-top: 60px; font-size: 13px; }
.chat-input { margin-top: 12px; flex-shrink: 0; }
.chat-input-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; }
.chat-input-left { display: flex; align-items: center; gap: 10px; }
.chat-tip { color: #909399; font-size: 12px; }
 
/* 弹窗圆角矩形,圆润柔和;固定在屏幕中央(水平垂直居中),不随页面滚动 */
.ai-dialog {
  position: fixed !important;
  top: 50% !important;
  left: 50% !important;
  transform: translate(-50%, -50%) !important;
  margin: 0 !important;
  display: flex;
  flex-direction: column;
  max-height: calc(100vh - 32px);
  border-radius: 18px;
  overflow: hidden;
  box-shadow: 0 12px 40px rgba(0, 0, 0, .12);
}
.ai-dialog .el-dialog__header { padding: 18px 20px 10px; border-bottom: 1px solid #f0f2f5; flex-shrink: 0; }
.ai-dialog .el-dialog__title { font-weight: 700; color: #1f2d3d; }
.ai-dialog .el-dialog__body { padding: 12px 20px 18px; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
 
/* 语音输入按钮 */
.voice-btn {
  width: 34px; height: 34px; border-radius: 50%; border: 1px solid #dcdfe6;
  background: #fff; font-size: 16px; line-height: 1; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  transition: all .2s; position: relative; flex-shrink: 0;
}
.voice-btn:hover { border-color: #409EFF; }
.voice-btn.listening { background: #ff4d4f; border-color: #ff4d4f; color: #fff; animation: mic-pulse 1.2s infinite; }
.voice-btn .voice-ripple { position: absolute; inset: -4px; border-radius: 50%; border: 2px solid #ff4d4f; animation: mic-ripple 1.2s infinite; }
.speak-toggle { font-size: 15px; }
.speak-toggle.muted { opacity: .45; }
@keyframes mic-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 77, 79, .35); } 50% { box-shadow: 0 0 0 9px rgba(255, 77, 79, 0); } }
@keyframes mic-ripple { 0% { transform: scale(1); opacity: .8; } 100% { transform: scale(1.7); opacity: 0; } }
.speech-hint { margin-top: 6px; color: #ff4d4f; font-size: 12px; display: flex; align-items: center; gap: 6px; }
.speech-hint .dot { width: 8px; height: 8px; border-radius: 50%; background: #ff4d4f; animation: hint-blink 1s infinite; }
@keyframes hint-blink { 50% { opacity: .3; } }
 
/* 每条回复的朗读按钮 */
.msg-tools { margin-top: 6px; text-align: right; }
.msg-speak { border: none; background: transparent; cursor: pointer; font-size: 12px; padding: 2px 6px; border-radius: 4px; color: #909399; }
.msg-speak:hover { color: #409EFF; background: #f0f7ff; }
.msg-speak.playing { color: #409EFF; animation: speak-pulse 1s infinite; }
@keyframes speak-pulse { 50% { opacity: .5; } }
 
/* 思考动画:三个跳动的小圆点 */
.thinking-bubble { display: flex; align-items: center; }
.thinking-dot { width: 6px; height: 6px; border-radius: 50%; background: #409EFF; opacity: .55; animation: ai-dot 1.2s infinite ease-in-out; }
.thinking-dot:nth-child(2) { animation-delay: .15s; }
.thinking-dot:nth-child(3) { animation-delay: .3s; }
.thinking-text { margin-left: 10px; font-size: 13px; color: #909399; }
@keyframes ai-dot { 0%, 60%, 100% { transform: translateY(0); opacity: .45; } 30% { transform: translateY(-5px); opacity: 1; } }
 
/* 卡哇伊蹦跳小人 */
.ai-mascot { flex-shrink: 0; position: relative; width: 56px; height: 60px; margin: -2px 4px 0 -4px; }
.ai-mascot .mascot-head {
  position: absolute; top: 8px; left: 6px; width: 44px; height: 38px;
  background: radial-gradient(circle at 35% 28%, #fff6ea, #ffdfc4);
  border: 2px solid #f5b98a; border-radius: 50% 50% 46% 46%;
  box-shadow: 0 2px 5px rgba(0, 0, 0, .08); z-index: 2;
}
.ai-mascot .mascot-eye { position: absolute; top: 14px; width: 5px; height: 7px; background: #4a3228; border-radius: 50%; animation: ai-blink 4.2s infinite; }
.ai-mascot .mascot-eye.left { left: 12px; }
.ai-mascot .mascot-eye.right { right: 12px; }
.ai-mascot .mascot-eye::after { content: ''; position: absolute; top: 1px; left: 1px; width: 2px; height: 2px; background: #fff; border-radius: 50%; }
.ai-mascot .mascot-blush { position: absolute; top: 24px; width: 9px; height: 4px; background: rgba(255, 138, 138, .55); border-radius: 50%; }
.ai-mascot .mascot-blush.left { left: 5px; }
.ai-mascot .mascot-blush.right { right: 5px; }
.ai-mascot .mascot-mouth { position: absolute; top: 23px; left: 50%; transform: translateX(-50%); width: 9px; height: 7px; border-bottom: 2px solid #a0603a; border-radius: 0 0 50% 50%; }
.ai-mascot .mascot-sprout {
  position: absolute; top: 0; left: 22px; width: 13px; height: 13px; z-index: 3;
  background: radial-gradient(circle at 45% 35%, #c6f09a, #7ccb4a);
  border-radius: 90% 8% 90% 8%; transform: rotate(45deg);
}
.ai-mascot .mascot-body {
  position: absolute; top: 42px; left: 16px; width: 24px; height: 12px;
  background: linear-gradient(#7cc0ff, #5aa8f5); border-radius: 5px 5px 3px 3px;
}
.ai-mascot .mascot-feet { position: absolute; top: 52px; left: 20px; width: 16px; height: 5px; background: #f5b98a; border-radius: 0 0 4px 4px; }
.ai-mascot.is-bouncing { animation: ai-hop .65s ease-in-out infinite; }
@keyframes ai-hop {
  0%, 100% { transform: translateY(0) scale(1, 1); }
  25% { transform: translateY(-7px) scale(.97, 1.03); }
  45% { transform: translateY(-9px) scale(1, 1); }
  65% { transform: translateY(-3px) scale(1.03, .97); }
  80% { transform: translateY(-1px) scale(1, 1); }
}
.ai-mascot:not(.is-bouncing) { animation: ai-float 2.6s ease-in-out infinite; }
@keyframes ai-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
@keyframes ai-blink { 0%, 90%, 100% { transform: scaleY(1); } 93%, 96% { transform: scaleY(.1); } }
 
/* 打字光标 */
.ai-caret { display: inline-block; width: 2px; height: 15px; background: #409EFF; vertical-align: text-bottom; margin-left: 2px; animation: ai-caret-blink .8s steps(1) infinite; }
@keyframes ai-caret-blink { 50% { opacity: 0; } }
 
/* 结构化 Markdown 排版 */
.md-body { font-size: 14px; line-height: 1.75; color: #303133; white-space: normal; }
.md-body .ai-h { margin: 12px 0 6px; font-size: 16px; font-weight: 700; color: #1f2d3d; border-left: 4px solid #409EFF; padding: 5px 8px; background: #f0f7ff; border-radius: 0 4px 4px 0; }
.md-body .ai-h3 { margin: 10px 0 4px; font-size: 14.5px; font-weight: 700; color: #34495e; }
.md-body .ai-p { margin: 4px 0; }
.md-body .ai-blank { height: 6px; }
.md-body .ai-ul, .md-body .ai-ol { margin: 4px 0 4px 4px; padding-left: 20px; }
.md-body .ai-ul li, .md-body .ai-ol li { margin: 3px 0; }
.md-body .ai-code { background: #f0f2f5; border: 1px solid #e4e7ed; border-radius: 3px; padding: 1px 5px; font-family: Consolas, Menlo, monospace; font-size: 12.5px; color: #e34d59; }
.md-body strong { color: #1f6feb; font-weight: 700; }
.md-body em { color: #606266; }
.md-body .ai-quote { border-left: 3px solid #c0c4cc; background: #fafafa; padding: 6px 10px; margin: 6px 0; color: #606266; border-radius: 0 4px 4px 0; }
.md-body .ai-hr { border: none; border-top: 1px dashed #dcdfe6; margin: 8px 0; }
.md-body .ai-table { border-collapse: collapse; width: 100%; margin: 6px 0; font-size: 13px; }
.md-body .ai-table th, .md-body .ai-table td { border: 1px solid #dcdfe6; padding: 5px 8px; text-align: left; }
.md-body .ai-table th { background: #f0f7ff; color: #1f2d3d; font-weight: 700; }
.md-body .ai-table tr:nth-child(even) td { background: #fafbfc; }
.md-body .ai-label { margin: 8px 0; padding: 7px 10px; border-radius: 5px; font-weight: 700; font-size: 14px; border-left: 4px solid; }
.md-body .ai-label-conclusion { color: #1f6feb; background: #eaf2ff; border-color: #1f6feb; }
.md-body .ai-label-advice { color: #12805c; background: #e8faf1; border-color: #12805c; }
.md-body .ai-label-risk { color: #c95f0d; background: #fff4e5; border-color: #e8820c; }
.md-body .ai-label-basis { color: #0f7d86; background: #e6f7f8; border-color: #0f8a8a; }
</style>