taiji_caozhaorui 1 月之前
父节点
当前提交
38f2db9ec9

+ 2 - 0
package.json

@@ -13,12 +13,14 @@
     "@types/node": "^20.17.11",
     "axios": "^1.7.9",
     "default-passive-events": "^2.0.0",
+    "dompurify": "^3.2.5",
     "echarts": "^5.6.0",
     "element-plus": "^2.9.7",
     "fast-glob": "^3.3.3",
     "highlight.js": "^11.11.1",
     "lodash-es": "^4.17.21",
     "markdown-it": "^14.1.0",
+    "marked": "^15.0.8",
     "pinia": "^3.0.1",
     "rollup-plugin-visualizer": "^5.14.0",
     "sass": "^1.83.1",

+ 2 - 2
src/main.ts

@@ -1,6 +1,6 @@
 import { createApp } from 'vue'
 import App from './App.vue'
-import router, {initRoutes} from './router'
+import router, {beforeInit} from './router'
 import './style/index.scss'
 import 'virtual:svg-icons-register'    // 【svg-icons相关】
 import initComponent from '@/plugins/initComponent'
@@ -16,7 +16,7 @@ const app = createApp(App)
 app.use(createPinia())
 await initProperties(app)
 initComponent(app)
-await initRoutes()
+await beforeInit()
 app.use(router)
 app.use(ElementPlus as any)
 app.mount('#app')

+ 32 - 9
src/router/index.ts

@@ -5,6 +5,7 @@ import RouterView from '@/layout/router-view.vue'
 // @ts-ignore
 import Layout from '@/layout/index.vue'
 import {useMenuStore} from "@/stores";
+import {login} from "@/views/smart-ask-answer/assistant/dify/common";
 
 const routes = [
     demoRouter,
@@ -13,7 +14,7 @@ const routes = [
         name: 'root',
         path: '/',
         component: Layout,
-        redirect: '/chat',
+        redirect: '/assistant',
         children: [
         ]
     },
@@ -32,13 +33,35 @@ const router = createRouter({
 router.beforeEach((to, from , next) => {
     next()
 })
-export const initRoutes = () => {
-    const MenuStore = useMenuStore()
-    return new Promise((resolve, reject) => {
-        setTimeout(() => {
-            MenuStore.initRoutes(MenuStore.mockApi)
-            resolve(null)
-        }, 1000)
-    })
+// export const initRoutes = () => {
+//     const MenuStore = useMenuStore()
+//     return new Promise((resolve, reject) => {
+//         setTimeout(() => {
+//             MenuStore.initRoutes(MenuStore.mockApi)
+//             resolve(null)
+//         }, 1000)
+//     })
+// }
+export const beforeInit = () => {
+    if (location.pathname === import.meta.env.BASE_URL + 'assistant') {
+        const loginData = {
+            email: 'guest@qq.com',
+            password: 'tj123456',
+            language: 'zh-Hans',
+            remember_me: true,
+            anonymous: true
+        }
+        login({
+            url: '/login',
+            body: loginData,
+        }).then((res: any) => {
+            localStorage.setItem('console_token', res.data.access_token)
+            localStorage.setItem('refresh_token', res.data.refresh_token)
+            const l = document.getElementById('loader')
+            if (l) {
+                l.style.display = 'none'
+            }
+        })
+    }
 }
 export default router;

+ 0 - 65
src/views/smart-ask-answer/assistant/answer/index.vue

@@ -1,65 +0,0 @@
-<template>
-  <div class="answer">
-    <div class="answer-avatar">
-      <img src="../imgs/avatar.png"/>
-    </div>
-    <div class="answer-content">
-      <template v-if="item.welcome">下午好,i口岸智行官为您服务,有什么可以帮您?</template>
-      <div class="markdown-content" v-html="contentCpt"></div>
-    </div>
-  </div>
-
-</template>
-
-<script setup lang="ts">
-import {computed, getCurrentInstance, onMounted, reactive, watch} from "vue";
-import MarkdownIt from 'markdown-it';
-import hljs from 'highlight.js';
-
-const md = new MarkdownIt({
-  html: false,        // 在源码中启用 HTML 标签
-  highlight: (str, lang) => {
-    if (lang && hljs.getLanguage(lang)) {
-      try {
-        return `<pre class="hljs"><code>${hljs.highlight(str, { language: lang }).value}</code></pre>`;
-      } catch (__) {}
-    }
-    return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`;
-  }
-});
-// md.renderer.rules.html_block = (tokens, idx) => {
-//   const content = tokens[idx].content;
-//   if (content.startsWith('<think>')) {
-//     return `<div class="think-box">${content.replace(/<\/?think>/g, '')}</div>`;
-//   }
-//   return content;
-// };
-const props = defineProps({
-  item: {},
-})
-const state: any = reactive({
-})
-const contentCpt = computed(() => {
-  return props.item.content
-})
-const markdownCpt = computed(() => md.render(props.item.content))
-onMounted(() => {
-  state.token_ = localStorage.getItem('difyToken')
-})
-</script>
-
-<style lang="scss" scoped>
-.answer {
-  width: 100%;
-  display: flex;
-  .answer-avatar {
-    margin-right: 16px;
-  }
-  .answer-content {
-    margin-top: 16px;
-    font-weight: 400;
-    font-size: 16px;
-    color: #111111;
-  }
-}
-</style>

文件差异内容过多而无法显示
+ 144 - 80
src/views/smart-ask-answer/assistant/chat.vue


+ 112 - 0
src/views/smart-ask-answer/assistant/component/answer/index.vue

@@ -0,0 +1,112 @@
+<template>
+  <div class="answer">
+    <div class="answer-avatar">
+      <img src="@/views/smart-ask-answer/assistant/imgs/avatar.png"/>
+    </div>
+    <div class="answer-content">
+      <template v-if="item.welcome">下午好,i口岸智行官为您服务,有什么可以帮您?</template>
+      <template v-else>
+        <template v-for="part in contentCpt">
+          <template v-if="part.type === 'think'">
+            <thinkCom :content="part.content"/>
+          </template>
+          <template v-else>
+<!--            <div class="answer-markdown" v-html="md.render(part.content)"/>-->
+            <div class="answer-markdown" v-html="DOMPurify.sanitize(marked.parse(part.content))"/>
+          </template>
+        </template>
+      </template>
+    </div>
+  </div>
+
+</template>
+
+<script setup lang="ts">
+import {computed, getCurrentInstance, onMounted, reactive, watch} from "vue";
+import MarkdownIt from 'markdown-it';
+import hljs from 'highlight.js';
+import { marked } from 'marked';
+import DOMPurify from 'dompurify';
+import thinkCom from './think.vue'
+
+const md = new MarkdownIt({
+  html: false,        // 在源码中启用 HTML 标签
+  highlight: (str, lang) => {
+    if (lang && hljs.getLanguage(lang)) {
+      try {
+        return `<pre class="hljs"><code>${hljs.highlight(str, { language: lang }).value}</code></pre>`;
+      } catch (__) {}
+    }
+    return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`;
+  }
+});
+const props = defineProps({
+  item: {} as any,
+})
+const state: any = reactive({
+})
+
+const parsedContent = computed(() => {
+  // 先解析Markdown,再净化HTML
+  return DOMPurify.sanitize(marked.parse(props.item.content));
+});
+
+const contentCpt = computed(() => {
+  const segments: any = [];
+  const rawContent = props.item.content
+
+  // 正则表达式匹配<think>标签及其内容
+  const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
+
+  let match;
+  let lastIndex = 0;
+
+  while ((match = thinkRegex.exec(rawContent)) !== null) {
+    // 添加think标签前的普通内容
+    if (match.index > lastIndex) {
+      segments.push({
+        type: 'response',
+        content: rawContent.substring(lastIndex, match.index)
+      });
+    }
+
+    // 添加think内容
+    segments.push({
+      type: 'think',
+      content: match[1].trim()
+    });
+
+    lastIndex = thinkRegex.lastIndex;
+  }
+
+  // 添加剩余内容
+  if (lastIndex < rawContent.length) {
+    segments.push({
+      type: 'response',
+      content: rawContent.substring(lastIndex).trim()
+    });
+  }
+  console.log(segments)
+  return segments;
+})
+</script>
+
+<style lang="scss" scoped>
+.answer {
+  width: 100%;
+  display: flex;
+  .answer-avatar {
+    margin-right: 16px;
+  }
+  .answer-content {
+    flex: 1;
+    overflow: hidden;
+    margin-top: 16px;
+    font-weight: 400;
+    font-size: 16px;
+    color: #111111;
+    .answer-markdown {
+    }
+  }
+}
+</style>

+ 53 - 0
src/views/smart-ask-answer/assistant/component/answer/think.vue

@@ -0,0 +1,53 @@
+<template>
+  <div v-if="content" class="think-process">
+    <details class="think-details">
+      <summary class="think-summary">深度思考</summary>
+      <div class="think-content" v-html="parsedContent"></div>
+    </details>
+  </div>
+</template>
+
+<script setup lang="ts">
+import {computed, getCurrentInstance, onMounted, reactive, watch} from "vue";
+import { marked } from 'marked';
+import DOMPurify from 'dompurify';
+
+const props = defineProps({
+  content: String
+});
+const state: any = reactive({
+})
+
+const parsedContent = computed(() => {
+  if (!props.content) return '';
+  return DOMPurify.sanitize(marked.parse(props.content));
+});
+</script>
+
+<style lang="scss" scoped>
+.think-process {
+  margin: 1rem 0;
+  border-left: 3px solid #e5e7eb;
+  padding-left: 1rem;
+  .think-details {
+    background-color: #f9fafb;
+    border-radius: 0.5rem;
+    padding: 0.5rem 1rem;
+    .think-summary {
+      cursor: pointer;
+      font-weight: 500;
+      color: #4b5563;
+      outline: none;
+    }
+    .think-content {
+      margin-top: 0.5rem;
+      padding: 0.5rem;
+      color: #374151;
+      white-space: pre-wrap;
+      font-family: 'Menlo', 'Monaco', 'Consolas', monospace;
+      font-size: 0.875rem;
+      line-height: 1.5;
+    }
+  }
+}
+</style>

+ 14 - 4
src/views/smart-ask-answer/assistant/ask/index.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="ask">
-    {{content}}
+    {{contentCpt}}
   </div>
 </template>
 
@@ -8,17 +8,27 @@
 import {computed, getCurrentInstance, onMounted, reactive, watch} from "vue";
 
 const props = defineProps({
-  content: {},
+  item: {} as any,
+})
+const contentCpt = computed(() => {
+  return props.item.content
 })
 const state: any = reactive({
 })
 onMounted(() => {
-  state.token_ = localStorage.getItem('difyToken')
 })
 </script>
 
 <style lang="scss" scoped>
 .ask {
-  border: 1px solid red;
+  background: #F2F2F5;
+  border-radius: 8px;
+  padding: 16px;
+  font-family: Microsoft YaHei;
+  font-weight: 400;
+  font-size: 16px;
+  color: #111111;
+  width: auto;
+  margin-left: auto;
 }
 </style>

+ 196 - 0
src/views/smart-ask-answer/assistant/component/audio/index.vue

@@ -0,0 +1,196 @@
+<template>
+  <div class="audio">
+    <template v-if="state.isStart">
+      <el-tooltip content="停止语音输入" placement="top">
+        <div class="audio-main-voice __hover" @click="onStop">
+          <div v-for="item in 4" ref="ref_bars"/>
+        </div>
+      </el-tooltip>
+    </template>
+    <template v-else>
+      <el-tooltip content="语音输入" placement="top">
+        <div class="audio-main __hover" @click="onStart">
+          <img src="@/views/smart-ask-answer/assistant/imgs/audio.png"/>
+        </div>
+      </el-tooltip>
+    </template>
+    <template v-if="state.isStart">
+      <div class="duration">
+        {{durationCpt}}
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup lang="ts">
+import {computed, getCurrentInstance, onMounted, reactive, ref, watch} from "vue";
+import {ElMessage} from "element-plus";
+import {audioToText} from "@/views/smart-ask-answer/assistant/dify/share";
+
+const emit = defineEmits(['onLoading', 'onAudio'])
+const props = defineProps({})
+const state: any = reactive({
+  isStart: false,
+  duration: 0,
+  mediaRecorder: null,
+  audioBlob: null,
+  timer: null,
+  analyser: null,
+  animationId: null,
+  phase: 0,
+})
+const ref_bars = ref()
+const durationCpt = computed(() => {
+  const minutes = Math.floor(state.duration / 60)
+  const seconds = Math.floor(state.duration % 60)
+  return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
+})
+const onStart = async () => {
+  state.isStart = true
+  try {
+    // 请求麦克风权限
+    const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+
+    const audioContext = new (window.AudioContext || window.webkitAudioContext)();
+    state.analyser = audioContext.createAnalyser();
+    state.analyser.fftSize = 256;
+    const microphone = audioContext.createMediaStreamSource(stream);
+    microphone.connect(state.analyser);
+    updateVolumeBars();
+
+    state.mediaRecorder = new MediaRecorder(stream)
+    const audioChunks: any = []
+    state.mediaRecorder.ondataavailable = (event) => {
+      audioChunks.push(event.data)
+    }
+    state.mediaRecorder.onstop = async () => {
+      clearInterval(state.timer)
+      state.audioBlob = new Blob(audioChunks, {type: 'audio/mp3'})
+      // this.audioUrl = URL.createObjectURL(this.audioBlob)
+      stream.getTracks().forEach(track => track.stop())
+      if (microphone) {
+        microphone.disconnect();
+      }
+      if (audioContext && audioContext.state !== 'closed') {
+        audioContext.close();
+      }
+      cancelAnimationFrame(state.animationId);
+      // 重置柱状图
+      ref_bars.value.forEach(bar => {
+        bar.style.height = '4px';
+      });
+      if (!state.audioBlob) {
+        ElMessage.error('没有可上传的录音文件')
+        return
+      }
+      try {
+        const formData = new FormData()
+        formData.append('file', state.audioBlob)
+        const audioResponse = await audioToText(`/installed-apps/${window.czrConfig.dify.appId}/audio-to-text`, false, formData)
+        emit('onAudio', audioResponse.text)
+      } catch (err) {
+        emit('onAudio', '')
+        ElMessage.error('上传错误:' + err)
+      } finally {
+        emit('onAudio', '')
+      }
+    }
+
+    state.mediaRecorder.start()
+    const startTime = Date.now()
+    state.duration = 0
+    // 更新录音时长
+    state.timer = setInterval(() => {
+      state.duration = Math.floor((Date.now() - startTime) / 1000)
+    }, 1000)
+
+  } catch (err: any) {
+    ElMessage.error('无法访问麦克风: ' + err.message)
+    console.error('录音错误:', err)
+  }
+}
+const onStop = async () => {
+  emit('onLoading')
+  state.isStart = false
+  if (state.mediaRecorder) {
+    state.mediaRecorder.stop()
+  }
+}
+const updateVolumeBars = () => {
+  if (!state.isStart) return;
+
+  const array = new Uint8Array(state.analyser.frequencyBinCount);
+  state.analyser.getByteFrequencyData(array);
+  let sum = 0;
+  for (let i = 0; i < array.length; i++) {
+    sum += array[i];
+  }
+
+  const average = sum / array.length;
+  const baseVolume = Math.min(1, average / 70);
+
+  // 更新相位
+  state.phase += 0.2;
+  if (state.phase > Math.PI * 2) state.phase -= Math.PI * 2;
+
+  // 更新每个柱子的高度
+  ref_bars.value.forEach((bar, index) => {
+    // 每个柱子有轻微相位差
+    const barPhase = state.phase + (index * Math.PI / 3);
+    // 波浪因子 (0.5-1.5范围)
+    const waveFactor = 0.8 + Math.sin(barPhase) * 0.2;
+
+    // 基础高度 + 音量影响 * 波浪因子
+    const height = 4 + (baseVolume * ((index > 0 && index < ref_bars.value.length - 1) ? 15 : 5)) * waveFactor;
+    bar.style.height = `${height}px`;
+  });
+
+  state.animationId = requestAnimationFrame(updateVolumeBars);
+}
+const calculateBarLevels = (volume) => {
+  // 根据音量计算4个柱子的高度比例
+  // 无声音时全部为0,有声音时从低到高依次点亮
+  const thresholds = [0.25, 0.5, 0.75, 1.0];
+  return thresholds.map(t => Math.max(0, Math.min(1, (volume - (t - 0.25)) * 4)));
+}
+onMounted(() => {
+})
+</script>
+
+<style lang="scss" scoped>
+.audio {
+  display: flex;
+  align-items: center;
+  .audio-main, .audio-main-voice {
+    width: 32px;
+    height: 32px;
+    border-radius: 8px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  .audio-main {
+    &:hover {
+      background-color: rgba(0, 0, 0, 0.04);
+    }
+  }
+  .audio-main-voice {
+    background-color: rgba(0,87,255,0.1);
+    gap: 2px;
+    >div {
+      width: 2px;
+      height: 4px;
+      background-color: #06f;
+      transition: height 0.15s ease-out;
+      &:nth-child(2) {
+        margin-right: 1px;
+      }
+    }
+  }
+  .duration {
+    color: #4f4f4f;
+    font-size: 14px;
+    margin-left: 6px;
+  }
+}
+</style>

+ 0 - 243
src/views/smart-ask-answer/assistant/component/voice/index.vue

@@ -1,243 +0,0 @@
-<template>
-  <div class="recorder-container">
-    <button @click="toggleRecording" :class="{ recording: isRecording }">
-      {{ isRecording ? '停止录音' : '开始录音' }}
-    </button>
-
-    <div v-if="duration > 0" class="duration-display">
-      录音时长: {{ formattedDuration }}
-    </div>
-
-    <audio v-if="audioUrl" :src="audioUrl" controls class="audio-player"></audio>
-
-    <button
-        v-if="audioUrl && !isUploading"
-        @click="uploadRecording"
-        class="upload-button"
-    >
-      上传录音
-    </button>
-
-    <div v-if="isUploading" class="upload-status">
-      <span>上传中...</span>
-      <progress v-if="uploadProgress > 0" :value="uploadProgress" max="100"></progress>
-    </div>
-
-    <div v-if="uploadSuccess" class="success-message">
-      上传成功!{{ uploadResponse }}
-    </div>
-
-    <div v-if="error" class="error-message">{{ error }}</div>
-  </div>
-</template>
-
-<script>
-import { audioToText } from '@/views/smart-ask-answer/chat/dify/share'
-
-export default {
-  name: 'AudioRecorderWithUpload',
-  data() {
-    return {
-      isRecording: false,
-      mediaRecorder: null,
-      audioChunks: [],
-      audioBlob: null,
-      audioUrl: null,
-      startTime: null,
-      duration: 0,
-      timer: null,
-      error: null,
-      isUploading: false,
-      uploadProgress: 0,
-      uploadSuccess: false,
-      uploadResponse: null
-    }
-  },
-  computed: {
-    formattedDuration() {
-      const minutes = Math.floor(this.duration / 60)
-      const seconds = Math.floor(this.duration % 60)
-      return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
-    }
-  },
-  methods: {
-    async toggleRecording() {
-      if (this.isRecording) {
-        this.stopRecording()
-      } else {
-        await this.startRecording()
-      }
-    },
-
-    async startRecording() {
-      try {
-        // 重置上传状态
-        this.uploadSuccess = false
-        this.uploadResponse = null
-
-        // 请求麦克风权限
-        const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
-
-        this.mediaRecorder = new MediaRecorder(stream)
-        this.audioChunks = []
-
-        this.mediaRecorder.ondataavailable = event => {
-          this.audioChunks.push(event.data)
-        }
-
-        this.mediaRecorder.onstop = () => {
-          this.audioBlob = new Blob(this.audioChunks, { type: 'audio/mp3' })
-          this.audioUrl = URL.createObjectURL(this.audioBlob)
-          stream.getTracks().forEach(track => track.stop())
-        }
-
-        this.mediaRecorder.start()
-        this.isRecording = true
-        this.startTime = Date.now()
-        this.duration = 0
-        this.error = null
-
-        // 更新录音时长
-        this.timer = setInterval(() => {
-          this.duration = Math.floor((Date.now() - this.startTime) / 1000)
-        }, 1000)
-
-      } catch (err) {
-        this.error = '无法访问麦克风: ' + err.message
-        console.error('录音错误:', err)
-      }
-    },
-
-    stopRecording() {
-      if (this.mediaRecorder && this.isRecording) {
-        this.mediaRecorder.stop()
-        clearInterval(this.timer)
-        this.isRecording = false
-      }
-    },
-
-    async uploadRecording() {
-      if (!this.audioBlob) {
-        this.error = '没有可上传的录音文件'
-        return
-      }
-
-      this.isUploading = true
-      this.uploadProgress = 0
-      this.error = null
-      this.uploadSuccess = false
-
-      try {
-        const formData = new FormData()
-        formData.append('file', this.audioBlob)
-
-        // 替换为你的实际上传接口
-        // const response = await this.$axios.post('/api/upload-audio', formData, {
-        //   headers: {
-        //     'Content-Type': 'multipart/form-data'
-        //   },
-        //   onUploadProgress: (progressEvent) => {
-        //     if (progressEvent.total) {
-        //       this.uploadProgress = Math.round(
-        //           (progressEvent.loaded * 100) / progressEvent.total
-        //       )
-        //     }
-        //   }
-        // })
-        // const audioResponse = await audioToText('/audio-to-text', false, formData)
-        const audioResponse = await audioToText(`/installed-apps/${window.czrConfig.dify.appId}/audio-to-text`, false, formData)
-        console.log(audioResponse)
-        this.uploadSuccess = true
-        this.uploadResponse = audioResponse.text || '文件已上传'
-        console.log('上传成功:', audioResponse)
-
-      } catch (err) {
-        this.error = '上传失败: ' + (err.response?.data?.message || err.message)
-        console.error('上传错误:', err)
-      } finally {
-        this.isUploading = false
-      }
-    }
-  },
-
-  beforeUnmount() {
-    // 组件销毁前清理资源
-    if (this.isRecording) {
-      this.stopRecording()
-    }
-    if (this.audioUrl) {
-      URL.revokeObjectURL(this.audioUrl)
-    }
-  }
-}
-</script>
-
-<style scoped>
-.recorder-container {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  gap: 1rem;
-  padding: 1rem;
-  border: 1px solid #eee;
-  border-radius: 8px;
-  max-width: 400px;
-  margin: 0 auto;
-}
-
-button {
-  padding: 0.5rem 1rem;
-  background-color: #42b983;
-  color: white;
-  border: none;
-  border-radius: 4px;
-  cursor: pointer;
-  font-size: 1rem;
-  transition: background-color 0.3s;
-}
-
-button.recording {
-  background-color: #ff4d4f;
-}
-
-button:hover {
-  opacity: 0.9;
-}
-
-.upload-button {
-  background-color: #1890ff;
-}
-
-.duration-display {
-  font-size: 0.9rem;
-  color: #666;
-}
-
-.audio-player {
-  width: 100%;
-}
-
-.upload-status {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  gap: 0.5rem;
-  width: 100%;
-}
-
-progress {
-  width: 100%;
-  height: 6px;
-  border-radius: 3px;
-}
-
-.error-message {
-  color: #ff4d4f;
-  font-size: 0.9rem;
-}
-
-.success-message {
-  color: #52c41a;
-  font-size: 0.9rem;
-}
-</style>

+ 0 - 204
src/views/smart-ask-answer/assistant/component/voice/index_1.vue

@@ -1,204 +0,0 @@
-<template>
-  <div class="voice-recognition">
-    <button @click="toggleRecognition" :class="{ active: isListening }">
-      {{ isListening ? '停止识别' : '开始语音识别' }}
-    </button>
-
-    <div class="volume-feedback" :style="{ height: volumeLevel + '%' }"></div>
-
-    <div class="transcript">
-      <p>{{ transcript }}</p>
-    </div>
-  </div>
-</template>
-
-<script>
-// chrome 翻墙错误
-export default {
-  name: 'VoiceRecognition',
-  data() {
-    return {
-      isListening: false,
-      recognition: null,
-      transcript: '',
-      audioContext: null,
-      analyser: null,
-      microphone: null,
-      volumeLevel: 0,
-      animationId: null
-    }
-  },
-  methods: {
-    toggleRecognition() {
-      if (this.isListening) {
-        this.stopRecognition();
-      } else {
-        this.startRecognition();
-      }
-    },
-
-    startRecognition() {
-      // 检查浏览器是否支持语音识别
-      if (!('webkitSpeechRecognition' in window)) {
-        alert('您的浏览器不支持语音识别功能,请使用Chrome浏览器');
-        return;
-      }
-
-      // 初始化语音识别
-      this.recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
-      this.recognition.continuous = true;
-      this.recognition.interimResults = true;
-
-      this.recognition.onstart = () => {
-        this.isListening = true;
-        this.initAudioAnalysis();
-      };
-
-      this.recognition.onresult = (event) => {
-        let interimTranscript = '';
-        let finalTranscript = '';
-
-        for (let i = event.resultIndex; i < event.results.length; i++) {
-          const transcript = event.results[i][0].transcript;
-          if (event.results[i].isFinal) {
-            finalTranscript += transcript;
-          } else {
-            interimTranscript += transcript;
-          }
-        }
-
-        this.transcript = finalTranscript || interimTranscript;
-      };
-
-      this.recognition.onerror = (event) => {
-        console.error('语音识别错误:', event.error);
-        this.stopRecognition();
-      };
-
-      this.recognition.onend = () => {
-        if (this.isListening) {
-          // 如果仍在监听状态但识别意外结束,重新开始
-          this.recognition.start();
-        }
-      };
-
-      this.recognition.start();
-    },
-
-    initAudioAnalysis() {
-      // 初始化音频分析用于音量反馈
-      navigator.mediaDevices.getUserMedia({ audio: true, video: false })
-          .then((stream) => {
-            this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
-            this.analyser = this.audioContext.createAnalyser();
-            this.microphone = this.audioContext.createMediaStreamSource(stream);
-
-            this.microphone.connect(this.analyser);
-            this.analyser.fftSize = 32;
-
-            this.updateVolumeMeter();
-          })
-          .catch((err) => {
-            console.error('无法访问麦克风:', err);
-          });
-    },
-
-    updateVolumeMeter() {
-      const dataArray = new Uint8Array(this.analyser.frequencyBinCount);
-      this.analyser.getByteFrequencyData(dataArray);
-
-      // 计算平均音量
-      let sum = 0;
-      for (let i = 0; i < dataArray.length; i++) {
-        sum += dataArray[i];
-      }
-      const average = sum / dataArray.length;
-
-      // 映射到0-100范围
-      this.volumeLevel = Math.min(100, Math.max(0, average * 1.5));
-
-      // 继续动画循环
-      if (this.isListening) {
-        this.animationId = requestAnimationFrame(() => this.updateVolumeMeter());
-      }
-    },
-
-    stopRecognition() {
-      this.isListening = false;
-
-      if (this.recognition) {
-        this.recognition.stop();
-      }
-
-      if (this.animationId) {
-        cancelAnimationFrame(this.animationId);
-        this.animationId = null;
-      }
-
-      if (this.microphone) {
-        this.microphone.disconnect();
-        const tracks = this.microphone.mediaStream.getTracks();
-        tracks.forEach(track => track.stop());
-        this.microphone = null;
-      }
-
-      if (this.audioContext) {
-        this.audioContext.close();
-        this.audioContext = null;
-      }
-
-      this.volumeLevel = 0;
-    }
-  },
-
-  beforeUnmount() {
-    // 组件卸载前确保停止所有活动
-    this.stopRecognition();
-  }
-}
-</script>
-
-<style scoped>
-.voice-recognition {
-  max-width: 600px;
-  margin: 0 auto;
-  padding: 20px;
-  text-align: center;
-}
-
-button {
-  padding: 10px 20px;
-  font-size: 16px;
-  background-color: #4CAF50;
-  color: white;
-  border: none;
-  border-radius: 4px;
-  cursor: pointer;
-  transition: background-color 0.3s;
-}
-
-button:hover {
-  background-color: #45a049;
-}
-
-button.active {
-  background-color: #f44336;
-}
-
-.volume-feedback {
-  width: 30px;
-  height: 0;
-  margin: 20px auto;
-  background-color: #4CAF50;
-  transition: height 0.1s;
-}
-
-.transcript {
-  margin-top: 20px;
-  padding: 15px;
-  border: 1px solid #ddd;
-  border-radius: 4px;
-  min-height: 100px;
-  text-align: left;
-}
-</style>

+ 0 - 216
src/views/smart-ask-answer/assistant/component/voice/index_2.vue

@@ -1,216 +0,0 @@
-<template>
-  <div class="voice-recorder">
-    <button
-        @mousedown="startRecording"
-        @mouseup="stopRecording"
-        @touchstart="startRecording"
-        @touchend="stopRecording"
-        :disabled="isRecording"
-    >
-      {{ isRecording ? '录音中...' : '按住说话' }}
-    </button>
-
-    <div class="volume-indicator" :style="{ width: volume + '%' }"></div>
-
-    <div v-if="transcript" class="transcript">
-      <h3>识别结果:</h3>
-      <p>{{ transcript }}</p>
-    </div>
-
-    <div v-if="error" class="error">{{ error }}</div>
-  </div>
-</template>
-
-<script>
-import { ref } from 'vue';
-
-export default {
-  name: 'VoiceRecorder',
-  setup() {
-    const isRecording = ref(false);
-    const volume = ref(0);
-    const transcript = ref('');
-    const error = ref('');
-    let mediaStream = null;
-    let audioContext = null;
-    let analyser = null;
-    let microphone = null;
-    let scriptProcessor = null;
-    let recognition = null;
-
-    const startRecording = async () => {
-      try {
-        // 重置状态
-        transcript.value = '';
-        error.value = '';
-        isRecording.value = true;
-        volume.value = 0;
-
-        // 获取麦克风权限
-        mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
-
-        // 设置音频分析
-        audioContext = new (window.AudioContext || window.webkitAudioContext)();
-        analyser = audioContext.createAnalyser();
-        microphone = audioContext.createMediaStreamSource(mediaStream);
-        scriptProcessor = audioContext.createScriptProcessor(2048, 1, 1);
-
-        analyser.smoothingTimeConstant = 0.8;
-        analyser.fftSize = 1024;
-
-        microphone.connect(analyser);
-        analyser.connect(scriptProcessor);
-        scriptProcessor.connect(audioContext.destination);
-
-        // 音量分析
-        scriptProcessor.onaudioprocess = () => {
-          const array = new Uint8Array(analyser.frequencyBinCount);
-          analyser.getByteFrequencyData(array);
-          let values = 0;
-
-          const length = array.length;
-          for (let i = 0; i < length; i++) {
-            values += array[i];
-          }
-
-          const average = values / length;
-          volume.value = Math.min(100, Math.max(0, average * 0.5));
-        };
-
-        // 语音识别
-        if ('webkitSpeechRecognition' in window) {
-          recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
-          recognition.continuous = true;
-          recognition.interimResults = true;
-
-          recognition.onresult = (event) => {
-            let interimTranscript = '';
-            let finalTranscript = '';
-
-            for (let i = event.resultIndex; i < event.results.length; i++) {
-              const transcript = event.results[i][0].transcript;
-              if (event.results[i].isFinal) {
-                finalTranscript += transcript;
-              } else {
-                interimTranscript += transcript;
-              }
-            }
-
-            transcript.value = finalTranscript || interimTranscript;
-          };
-
-          recognition.onerror = (event) => {
-            console.log(event)
-            error.value = `识别错误: ${event.error}`;
-          };
-
-          recognition.start();
-        } else {
-          error.value = '您的浏览器不支持语音识别';
-        }
-      } catch (err) {
-        error.value = `错误: ${err.message}`;
-        isRecording.value = false;
-      }
-    };
-
-    const stopRecording = () => {
-      isRecording.value = false;
-      volume.value = 0;
-
-      // 停止音频分析
-      if (scriptProcessor) {
-        scriptProcessor.disconnect();
-        scriptProcessor = null;
-      }
-
-      if (microphone) {
-        microphone.disconnect();
-        microphone = null;
-      }
-
-      if (analyser) {
-        analyser.disconnect();
-        analyser = null;
-      }
-
-      // 停止媒体流
-      if (mediaStream) {
-        mediaStream.getTracks().forEach(track => track.stop());
-        mediaStream = null;
-      }
-
-      // 停止语音识别
-      if (recognition) {
-        recognition.stop();
-        recognition = null;
-      }
-
-      // 关闭音频上下文
-      if (audioContext && audioContext.state !== 'closed') {
-        audioContext.close();
-        audioContext = null;
-      }
-    };
-
-    return {
-      isRecording,
-      volume,
-      transcript,
-      error,
-      startRecording,
-      stopRecording
-    };
-  }
-};
-</script>
-
-<style scoped>
-.voice-recorder {
-  max-width: 500px;
-  margin: 0 auto;
-  padding: 20px;
-  text-align: center;
-}
-
-button {
-  padding: 12px 24px;
-  font-size: 16px;
-  background-color: #4CAF50;
-  color: white;
-  border: none;
-  border-radius: 4px;
-  cursor: pointer;
-  transition: background-color 0.3s;
-}
-
-button:disabled {
-  background-color: #cccccc;
-  cursor: not-allowed;
-}
-
-button:hover:not(:disabled) {
-  background-color: #45a049;
-}
-
-.volume-indicator {
-  height: 10px;
-  background-color: #4CAF50;
-  margin: 15px 0;
-  transition: width 0.1s;
-  border-radius: 5px;
-}
-
-.transcript {
-  margin-top: 20px;
-  padding: 15px;
-  background-color: #f5f5f5;
-  border-radius: 4px;
-  text-align: left;
-}
-
-.error {
-  color: #f44336;
-  margin-top: 15px;
-}
-</style>

+ 0 - 53
src/views/smart-ask-answer/assistant/component/voice/recoder.ts

@@ -1,53 +0,0 @@
-import Recorder from 'recorder-core'
-import 'recorder-core/src/engine/wav'
-import 'recorder-core/src/extensions/lib.fft.js'
-import 'recorder-core/src/extensions/frequency.histogram.view'
-interface RecorderConfig {
-    onProcess?: Promise<any> | Function
-    [keyname: string]: any
-}
-interface FrequencyHistogramViewConfig {
-    [keyname: string]: any
-}
-let recorderInstance: any = null
-export const RecorderContructor = Recorder
-export const createRecorder = (config?: RecorderConfig) => {
-    if (recorderInstance) {
-        return recorderInstance
-    }
-    recorderInstance = Recorder({
-        type: 'wav', // 录音格式,可以换成wav等其他格式
-        sampleRate: 16000, // 录音的采样率,越大细节越丰富越细腻
-        bitRate: 16, // 录音的比特率,越大音质越好
-        ...(config || {})
-        // onProcess: (buffers, powerLevel, bufferDuration, bufferSampleRate, newBufferIdx, asyncEnd) => {
-        //   // 录音实时回调,大约1秒调用12次本回调
-        //   // 可实时绘制波形,实时上传(发送)数据
-        //   if (this.wave) {
-        //     this.wave.input(buffers[buffers.length - 1], powerLevel, bufferSampleRate)
-        //   }
-        // }
-    })
-    return recorderInstance
-}
-export const destoryRecorder = () => {
-    if (recorderInstance) {
-        recorderInstance.close()
-        recorderInstance = null
-        Recorder.Destroy()
-    }
-}
-export const createRecorderWithWaveView = (el: HTMLElement, config?: FrequencyHistogramViewConfig) => {
-    return Recorder.FrequencyHistogramView({
-        elem: el,
-        lineCount: 30,
-        position: 0,
-        minHeight: 1,
-        fallDuration: 400,
-        stripeEnable: false,
-        mirrorEnable: true,
-        linear: [0, '#fff', 1, '#fff'],
-        ...(config || {})
-    })
-}
-

+ 1 - 2
src/views/smart-ask-answer/assistant/dify/base.ts

@@ -217,7 +217,6 @@ const handleStream = (
             }
             if (bufferObj.event === 'message' || bufferObj.event === 'agent_message') {
               // can not use format here. Because message is splitted.
-                console.log(bufferObj.answer)
               onData(unicodeToChar(bufferObj.answer), isFirstMessage, {
                 conversationId: bufferObj.conversation_id,
                 taskId: bufferObj.task_id,
@@ -585,7 +584,7 @@ export const request = async<T>(url: string, options = {}, otherOptions?: IOther
       //   return Promise.reject(err)
       // }
       // special code
-      const { code, message } = errRespData
+      const { code, message }: any = errRespData
       // webapp sso
       if (code === 'web_sso_auth_required') {
         requiredWebSSOLogin()

+ 4 - 0
src/views/smart-ask-answer/assistant/dify/common.ts

@@ -0,0 +1,4 @@
+import { del, get, patch, post, put } from './base'
+export const login = ({ url, body }) => {
+  return post(url, { body })
+}

二进制
src/views/smart-ask-answer/assistant/imgs/audio.png


二进制
src/views/smart-ask-answer/assistant/imgs/send.png


+ 8 - 7
src/views/smart-ask-answer/assistant/index.vue

@@ -15,7 +15,7 @@
           <div class="title_1">
             提问记录
             <div class="clear __hover">
-              <img src="./imgs/clear.png"/>清空
+              <img src="@/views/smart-ask-answer/assistant/imgs/clear.png"/>清空
             </div>
           </div>
           <div class="list_1">
@@ -79,7 +79,7 @@
     >
       <div class="dialog">
         <div class="dialog-title" style="background: linear-gradient(90deg, #677AFD, #8695FD);">
-          <img src="./imgs/icon-1.png"/>使用帮助<SvgIcon name="czr_close_1" color="#ffffff" size="16" class="__hover" @click="state.showHelp = false"/>
+          <img src="@/views/smart-ask-answer/assistant/imgs/icon-1.png"/>使用帮助<SvgIcon name="czr_close_1" color="#ffffff" size="16" class="__hover" @click="state.showHelp = false"/>
         </div>
         <div class="dialog-content help">
           <div><div>我学习了<em>口岸知识</em>,能帮您解决口岸相关问题。</div></div>
@@ -101,7 +101,7 @@
     >
       <div class="dialog">
         <div class="dialog-title" style="background: linear-gradient(90deg, #2780FD, #579BFC);">
-          <img src="./imgs/icon-2.png"/>用户协议<SvgIcon name="czr_close_1" color="#ffffff" size="16" class="__hover" @click="state.showAgreement = false"/>
+          <img src="@/views/smart-ask-answer/assistant/imgs/icon-2.png"/>用户协议<SvgIcon name="czr_close_1" color="#ffffff" size="16" class="__hover" @click="state.showAgreement = false"/>
         </div>
         <div class="dialog-content agreement">
           <div>
@@ -134,7 +134,7 @@
     >
       <div class="dialog">
         <div class="dialog-title" style="background: linear-gradient(90deg, #F95955, #FE817E);">
-          <img src="./imgs/icon-3.png"/>免责声明<SvgIcon name="czr_close_1" color="#ffffff" size="16" class="__hover" @click="state.showDisclaimers = false"/>
+          <img src="@/views/smart-ask-answer/assistant/imgs/icon-3.png"/>免责声明<SvgIcon name="czr_close_1" color="#ffffff" size="16" class="__hover" @click="state.showDisclaimers = false"/>
         </div>
         <div class="dialog-content disclaimers">
           <div>
@@ -321,19 +321,19 @@ onMounted(() => {
           &:nth-child(1) {
             background: linear-gradient(0deg, #677AFD, #8695FD);
             &:before {
-              background-image: url("./imgs/icon-1.png");
+              background-image: url("@/views/smart-ask-answer/assistant/imgs/icon-1.png");
             }
           }
           &:nth-child(2) {
             background: linear-gradient(0deg, #2780FD, #579BFC);
             &:before {
-              background-image: url("./imgs/icon-2.png");
+              background-image: url("@/views/smart-ask-answer/assistant/imgs/icon-2.png");
             }
           }
           &:nth-child(3) {
             background: linear-gradient(0deg, #F95955, #FE817E);
             &:before {
-              background-image: url("./imgs/icon-3.png");
+              background-image: url("@/views/smart-ask-answer/assistant/imgs/icon-3.png");
             }
           }
         }
@@ -417,6 +417,7 @@ onMounted(() => {
         .title_1 {
           .options {
             display: flex;
+            align-items: center;
             gap: 20px;
             font-family: Microsoft YaHei;
             font-weight: 400;

文件差异内容过多而无法显示
+ 0 - 197
src/views/smart-ask-answer/assistant/index_test.vue


+ 82 - 3
yarn.lock

@@ -526,6 +526,11 @@
   dependencies:
     "@types/node" "*"
 
+"@types/trusted-types@^2.0.7":
+  version "2.0.7"
+  resolved "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
+  integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
+
 "@types/web-bluetooth@^0.0.16":
   version "0.0.16"
   resolved "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz#1d12873a8e49567371f2a75fe3e7f7edca6662d8"
@@ -740,6 +745,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0:
   dependencies:
     color-convert "^2.0.1"
 
+argparse@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
+  integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
+
 arr-diff@^4.0.0:
   version "4.0.0"
   resolved "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
@@ -817,6 +827,15 @@ axios@^1.7.9:
     form-data "^4.0.0"
     proxy-from-env "^1.1.0"
 
+axios@^1.8.3:
+  version "1.8.4"
+  resolved "https://registry.npmmirror.com/axios/-/axios-1.8.4.tgz#78990bb4bc63d2cae072952d374835950a82f447"
+  integrity sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==
+  dependencies:
+    follow-redirects "^1.15.6"
+    form-data "^4.0.0"
+    proxy-from-env "^1.1.0"
+
 balanced-match@^1.0.0:
   version "1.0.2"
   resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
@@ -1113,7 +1132,7 @@ debug@^2.2.0, debug@^2.3.3:
   dependencies:
     ms "2.0.0"
 
-debug@^4.3.3:
+debug@^4.3.3, debug@^4.4.0:
   version "4.4.0"
   resolved "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz#2b3f2aea2ffeb776477460267377dc8710faba8a"
   integrity sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==
@@ -1226,6 +1245,13 @@ domhandler@^4.2.0, domhandler@^4.3.1:
   dependencies:
     domelementtype "^2.2.0"
 
+dompurify@^3.2.5:
+  version "3.2.5"
+  resolved "https://registry.npmmirror.com/dompurify/-/dompurify-3.2.5.tgz#11b108656a5fb72b24d916df17a1421663d7129c"
+  integrity sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==
+  optionalDependencies:
+    "@types/trusted-types" "^2.0.7"
+
 domutils@^1.5.1:
   version "1.7.0"
   resolved "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a"
@@ -1301,7 +1327,7 @@ entities@^2.0.0:
   resolved "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55"
   integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==
 
-entities@^4.5.0:
+entities@^4.4.0, entities@^4.5.0:
   version "4.5.0"
   resolved "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
   integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
@@ -1760,6 +1786,11 @@ he@^1.1.1, he@^1.2.0:
   resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
   integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
 
+highlight.js@^11.11.1:
+  version "11.11.1"
+  resolved "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz#fca06fa0e5aeecf6c4d437239135fabc15213585"
+  integrity sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==
+
 hookable@^5.5.3:
   version "5.5.3"
   resolved "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz#6cfc358984a1ef991e2518cb9ed4a778bbd3215d"
@@ -2130,6 +2161,13 @@ kind-of@^6.0.2:
   resolved "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
   integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
 
+linkify-it@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz#9ef238bfa6dc70bd8e7f9572b52d369af569b421"
+  integrity sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==
+  dependencies:
+    uc.micro "^2.0.0"
+
 loader-utils@^1.1.0:
   version "1.4.2"
   resolved "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz#29a957f3a63973883eb684f10ffd3d151fec01a3"
@@ -2141,7 +2179,7 @@ loader-utils@^1.1.0:
 
 lodash-es@^4.17.21:
   version "4.17.21"
-  resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
+  resolved "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
   integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
 
 lodash-unified@^1.0.2:
@@ -2173,6 +2211,23 @@ map-visit@^1.0.0:
   dependencies:
     object-visit "^1.0.0"
 
+markdown-it@^14.1.0:
+  version "14.1.0"
+  resolved "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.0.tgz#3c3c5992883c633db4714ccb4d7b5935d98b7d45"
+  integrity sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==
+  dependencies:
+    argparse "^2.0.1"
+    entities "^4.4.0"
+    linkify-it "^5.0.0"
+    mdurl "^2.0.0"
+    punycode.js "^2.3.1"
+    uc.micro "^2.1.0"
+
+marked@^15.0.8:
+  version "15.0.8"
+  resolved "https://registry.npmmirror.com/marked/-/marked-15.0.8.tgz#39873a3fdf91a520111e48aeb2ef3746d58d7166"
+  integrity sha512-rli4l2LyZqpQuRve5C0rkn6pj3hT8EWPC+zkAxFTAJLxRbENfTAhEQq9itrmf1Y81QtAX5D/MYlGlIomNgj9lA==
+
 math-intrinsics@^1.1.0:
   version "1.1.0"
   resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
@@ -2183,6 +2238,11 @@ mdn-data@2.0.14:
   resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50"
   integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==
 
+mdurl@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0"
+  integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==
+
 memoize-one@^6.0.0:
   version "6.0.0"
   resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045"
@@ -2505,6 +2565,11 @@ proxy-from-env@^1.1.0:
   resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
   integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
 
+punycode.js@^2.3.1:
+  version "2.3.1"
+  resolved "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
+  integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
+
 query-string@^4.3.2:
   version "4.3.4"
   resolved "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb"
@@ -3109,6 +3174,11 @@ typescript@^5.7.2:
   resolved "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz#3169cf8c4c8a828cde53ba9ecb3d2b1d5dd67be6"
   integrity sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==
 
+uc.micro@^2.0.0, uc.micro@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
+  integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
+
 unbox-primitive@^1.1.0:
   version "1.1.0"
   resolved "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz#8d9d2c9edeea8460c7f35033a88867944934d1e2"
@@ -3196,6 +3266,15 @@ vite-plugin-html-env@^1.2.8:
   resolved "https://registry.npmjs.org/vite-plugin-html-env/-/vite-plugin-html-env-1.2.8.tgz#c4907e1222b773fc53e2c3567c17cf1f8c86f30c"
   integrity sha512-Uqo2NqcmhVjuXv83oyRC80mZF+B2J3pcon8Bnm9F3T67EvkPYf64Gk0NReAE4MRGElt8pCGpCMQDIzs12NWUAA==
 
+vite-plugin-mkcert@^1.17.8:
+  version "1.17.8"
+  resolved "https://registry.npmmirror.com/vite-plugin-mkcert/-/vite-plugin-mkcert-1.17.8.tgz#7ddbd62a7b7941a57dfdf73cb19d6c39c3967e96"
+  integrity sha512-S+4tNEyGqdZQ3RLAG54ETeO2qyURHWrVjUWKYikLAbmhh/iJ+36gDEja4OWwFyXNuvyXcZwNt5TZZR9itPeG5Q==
+  dependencies:
+    axios "^1.8.3"
+    debug "^4.4.0"
+    picocolors "^1.1.1"
+
 vite-plugin-svg-icons@^2.0.1:
   version "2.0.1"
   resolved "https://registry.npmjs.org/vite-plugin-svg-icons/-/vite-plugin-svg-icons-2.0.1.tgz#7269a0962593509f371b9e2bb344d469db2c6df9"