CzRger vor 2 Monaten
Ursprung
Commit
bb376cf3d9
45 geänderte Dateien mit 3828 neuen und 16 gelöschten Zeilen
  1. 4 4
      src/out/config.js
  2. 9 9
      src/router/index.ts
  3. 422 0
      src/views/smart-ask-answer/assistant-2/chat.vue
  4. 75 0
      src/views/smart-ask-answer/assistant-2/cms/api.ts
  5. 66 0
      src/views/smart-ask-answer/assistant-2/cms/crypto.ts
  6. 22 0
      src/views/smart-ask-answer/assistant-2/cms/jsencrypt.ts
  7. 385 0
      src/views/smart-ask-answer/assistant-2/cms/request.ts
  8. 219 0
      src/views/smart-ask-answer/assistant-2/component/answer/index.vue
  9. 53 0
      src/views/smart-ask-answer/assistant-2/component/answer/think.vue
  10. 56 0
      src/views/smart-ask-answer/assistant-2/component/answer/useTextToSpeech.ts
  11. 34 0
      src/views/smart-ask-answer/assistant-2/component/ask/index.vue
  12. 196 0
      src/views/smart-ask-answer/assistant-2/component/audio/index.vue
  13. 694 0
      src/views/smart-ask-answer/assistant-2/dify/base.ts
  14. 4 0
      src/views/smart-ask-answer/assistant-2/dify/common.ts
  15. 264 0
      src/views/smart-ask-answer/assistant-2/dify/config.ts
  16. 274 0
      src/views/smart-ask-answer/assistant-2/dify/fetch.ts
  17. 92 0
      src/views/smart-ask-answer/assistant-2/dify/refresh-token.ts
  18. 217 0
      src/views/smart-ask-answer/assistant-2/dify/share.ts
  19. 57 0
      src/views/smart-ask-answer/assistant-2/dify/utils.ts
  20. BIN
      src/views/smart-ask-answer/assistant-2/imgs/ask-bg.png
  21. BIN
      src/views/smart-ask-answer/assistant-2/imgs/ask-del.png
  22. BIN
      src/views/smart-ask-answer/assistant-2/imgs/ask-icon-2.png
  23. BIN
      src/views/smart-ask-answer/assistant-2/imgs/ask-icon.png
  24. BIN
      src/views/smart-ask-answer/assistant-2/imgs/audio.png
  25. BIN
      src/views/smart-ask-answer/assistant-2/imgs/avatar.png
  26. BIN
      src/views/smart-ask-answer/assistant-2/imgs/bad.png
  27. BIN
      src/views/smart-ask-answer/assistant-2/imgs/bg.png
  28. BIN
      src/views/smart-ask-answer/assistant-2/imgs/clear.png
  29. BIN
      src/views/smart-ask-answer/assistant-2/imgs/copy.png
  30. BIN
      src/views/smart-ask-answer/assistant-2/imgs/good.png
  31. BIN
      src/views/smart-ask-answer/assistant-2/imgs/hot-bg.png
  32. BIN
      src/views/smart-ask-answer/assistant-2/imgs/hot-icon.png
  33. BIN
      src/views/smart-ask-answer/assistant-2/imgs/icon-1.png
  34. BIN
      src/views/smart-ask-answer/assistant-2/imgs/icon-2.png
  35. BIN
      src/views/smart-ask-answer/assistant-2/imgs/icon-3.png
  36. BIN
      src/views/smart-ask-answer/assistant-2/imgs/left-arrow.png
  37. BIN
      src/views/smart-ask-answer/assistant-2/imgs/logo-1.png
  38. BIN
      src/views/smart-ask-answer/assistant-2/imgs/none.png
  39. BIN
      src/views/smart-ask-answer/assistant-2/imgs/play.png
  40. BIN
      src/views/smart-ask-answer/assistant-2/imgs/send.png
  41. 642 0
      src/views/smart-ask-answer/assistant-2/index.vue
  42. 3 1
      src/views/smart-ask-answer/assistant/component/answer/index.vue
  43. 1 1
      src/views/smart-ask-answer/assistant/component/answer/think.vue
  44. 10 1
      src/views/smart-ask-answer/assistant/index.vue
  45. 29 0
      yarn.lock

+ 4 - 4
src/out/config.js

@@ -1,9 +1,9 @@
 window.czrConfig = {
   menuLayout: 'top-left', //  'left'-左侧,'top-left'-顶部左侧
   dify: {
-    appId: 'b9aca5c8-f0bb-4313-965f-3375d7a55579',
-    suggestId: '5a3bc2a4-35a3-421c-aff9-07faf2558447',
-    // appId: '86945884-adf3-4e22-91b2-e48c38b44e9e',
-    // suggestId: '833b8e52-a909-4655-8e3d-22e7aaf13903',
+    // appId: 'b9aca5c8-f0bb-4313-965f-3375d7a55579',
+    // suggestId: '5a3bc2a4-35a3-421c-aff9-07faf2558447',
+    appId: '86945884-adf3-4e22-91b2-e48c38b44e9e',
+    suggestId: '833b8e52-a909-4655-8e3d-22e7aaf13903',
   }
 }

+ 9 - 9
src/router/index.ts

@@ -6,7 +6,6 @@ import RouterView from '@/layout/router-view.vue'
 import Layout from '@/layout/index.vue'
 import {useMenuStore} from "@/stores";
 import {login} from "@/views/smart-ask-answer/assistant/dify/common";
-import chatLogo from '@/views/smart-ask-answer/assistant/imgs/avatar.png'
 const routes = [
     demoRouter,
     { path: '/:pathMatch(.*)*', name: 'NotFound', component: Temp404 },
@@ -14,7 +13,7 @@ const routes = [
         name: 'root',
         path: '/',
         component: Layout,
-        redirect: '/assistant',
+        redirect: '/assistant2',
         children: [
         ]
     },
@@ -22,6 +21,13 @@ const routes = [
         name: 'assistant',
         path: '/assistant',
         component: () => import('@/views/smart-ask-answer/assistant/index.vue'),
+        meta: { dify: true }
+    },
+    {
+        name: 'assistant2',
+        path: '/assistant2',
+        component: () => import('@/views/smart-ask-answer/assistant-2/index.vue'),
+        meta: { dify: true }
     }
 ]
 
@@ -43,13 +49,7 @@ router.beforeEach((to, from , next) => {
 //     })
 // }
 export const beforeInit = () => {
-    if (location.pathname === (import.meta as any).env.BASE_URL + 'assistant') {
-        document.title = "“i口岸”通关小助理";
-        let link: any = document.querySelector("link[rel*='icon']") || document.createElement('link');
-        link.type = 'image/x-icon';
-        link.rel = 'shortcut icon';
-        link.href = chatLogo;
-        document.head.appendChild(link);
+    if (location.pathname.includes((import.meta as any).env.BASE_URL + 'assistant')) {
         const loginData = {
             email: 'guest@qq.com',
             password: 'tj123456',

+ 422 - 0
src/views/smart-ask-answer/assistant-2/chat.vue

@@ -0,0 +1,422 @@
+<template>
+  <div class="chat">
+    <div class="chat-msg" ref="ref_chatMsg">
+      <template v-for="(item, index) in state.chats">
+        <template v-if="item.type === 'ask'">
+          <askCom :item="item"/>
+        </template>
+        <template v-else>
+          <answerCom
+            :item="item"
+            :goodMap="state.goodMap"
+            :badMap="state.badMap"
+            @onGood="onGood"
+            @onBad="onBad"
+            @onNormal="onNormal"
+            @setText="val => setText(val.text, val.send)"
+          />
+        </template>
+      </template>
+    </div>
+    <div class="chat-input">
+      <div class="chat-input-block" v-loading="state.loading">
+        <div class="chat-input-block-main">
+          <div class="chat-input-block-main-auto" ref="ref_auto">
+            <div class="chat-input-block-main-auto-list">
+              <template v-for="item in state.autoList">
+                <div class="chat-input-block-main-auto-item __hover" @click="setText(item)" v-html="item.replace(new RegExp(state.text, 'g'), `<span style='color: #d32520;'>$&</span>`)"/>
+              </template>
+            </div>
+          </div>
+          <textarea
+            ref="ref_text"
+            placeholder="请输入您的问题"
+            :rows="3"
+            v-model="state.text"
+          />
+        </div>
+        <div class="chat-input-block-operations">
+          <div class="cibo-audio">
+            <audioCom @onLoading="onLoading" @onAudio="onAudio"/>
+          </div>
+          <div class="cibo-split"/>
+          <el-tooltip content="发送" placement="top">
+            <div class="cibo-send __hover" @click="onSend()">
+              <img src="@/views/smart-ask-answer/assistant-2/imgs/send.png"/>
+            </div>
+          </el-tooltip>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import {computed, getCurrentInstance, onMounted, reactive, ref, watch} from "vue";
+import askCom from './component/ask/index.vue';
+import answerCom from './component/answer/index.vue';
+import audioCom from './component/audio/index.vue'
+import {get, post, ssePost} from './dify/base'
+import {useRouter} from "vue-router";
+import {isValue, YMDHms} from "@/utils/czr-util";
+import {fetchSuggestedQuestions, updateFeedback} from "@/views/smart-ask-answer/assistant-2/dify/share";
+import {staticConQueryQuestionslist} from "@/views/smart-ask-answer/assistant-2/cms/api";
+
+const emit = defineEmits(['getText', 'getSuggest', 'loadingSuggest'])
+const router = useRouter()
+const state: any = reactive({
+  text: '',
+  loading: false,
+  params: {
+    response_mode: "streaming",
+    conversation_id: "",
+    files: [],
+    query: "",
+    inputs: {},
+    parent_message_id: null
+  },
+  chats: [],
+  goodMap: new Map(),
+  badMap: new Map(),
+  autoList: [],
+  chatConfig: {}
+})
+const ref_text = ref()
+const ref_chatMsg = ref()
+const ref_auto = ref()
+const onSend = (text = '') => {
+  if ((isValue(state.text.trim()) || text) && !state.loading) {
+    state.loading = true
+    if (text) {
+      state.params.query = text
+    } else {
+      state.params.query = state.text + ''
+      state.text = ''
+    }
+    emit('getText', state.params.query)
+    const ask = {
+      type: 'ask',
+      content: state.params.query + ''
+    }
+    state.chats.push(ask)
+    const answer = reactive({
+      type: 'answer',
+      content: '',
+      messageId: '',
+      suggest: []
+    })
+    state.chats.push(answer)
+    ssePost(`/installed-apps/${window.czrConfig.dify.appId}/chat-messages`, {
+      body: state.params,
+    }, {
+      onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: any) => {
+        answer.content += message
+        ref_chatMsg.value.scrollTo({
+          top: ref_chatMsg.value.scrollHeight,
+          behavior: 'smooth'
+        });
+      },
+      onFile(file) {},
+      onThought(thought) {},
+      onMessageEnd: (messageEnd) => {
+        if (!state.params.conversation_id) {
+          state.params.conversation_id = messageEnd.conversation_id
+          post(`/installed-apps/${window.czrConfig.dify.appId}/conversations/${state.params.conversation_id}/name`, {body: {auto_generate: false, name: YMDHms(new Date())}})
+        }
+        answer.messageId = messageEnd.message_id
+        state.params.parent_message_id = messageEnd.message_id
+        state.loading = false
+        if (state.chatConfig?.suggested_questions_after_answer?.enabled) {
+          fetchSuggestedQuestions(state.params.parent_message_id, true, window.czrConfig.dify.appId).then((res: any) => {
+            answer.suggest = res.data
+          })
+        }
+      },
+      onMessageReplace: (messageReplace) => {},
+      onError() {},
+      onWorkflowStarted: ({ workflow_run_id, task_id }) => {},
+      onWorkflowFinished: ({ data: workflowFinishedData }) => {},
+      onIterationStart: ({ data: iterationStartedData }) => {},
+      onIterationFinish: ({ data: iterationFinishedData }) => {},
+      onNodeStarted: ({ data: nodeStartedData }) => {},
+      onNodeFinished: ({ data: nodeFinishedData }) => {},
+      onTTSChunk: (messageId: string, audio: string) => {},
+      onTTSEnd: (messageId: string, audio: string) => {},
+    })
+    getRelation(state.params.query)
+  }
+}
+const getRelation = (text) => {
+  emit('loadingSuggest')
+  let relation = ''
+  ssePost(`/installed-apps/${window.czrConfig.dify.suggestId}/chat-messages`, {
+    body: {
+      response_mode: "blocking",
+      conversation_id: "",
+      files: [],
+      query: text,
+      inputs: {},
+      parent_message_id: null
+    },
+  }, {
+    onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: any) => {
+      relation += message
+    },
+    onFile(file) {},
+    onThought(thought) {},
+    onMessageEnd: (messageEnd) => {
+      try {
+        const json = JSON.parse(relation)
+        console.log('原始数据', json)
+        emit('getSuggest', json)
+      } catch (e) {
+        console.log(e)
+      }
+    },
+    onMessageReplace: (messageReplace) => {},
+    onError() {},
+    onWorkflowStarted: ({ workflow_run_id, task_id }) => {},
+    onWorkflowFinished: ({ data: workflowFinishedData }) => {},
+    onIterationStart: ({ data: iterationStartedData }) => {},
+    onIterationFinish: ({ data: iterationFinishedData }) => {},
+    onNodeStarted: ({ data: nodeStartedData }) => {},
+    onNodeFinished: ({ data: nodeFinishedData }) => {},
+    onTTSChunk: (messageId: string, audio: string) => {},
+    onTTSEnd: (messageId: string, audio: string) => {},
+  })
+}
+const onLoading = () => {
+  state.loading = true
+}
+const onAudio = (text) => {
+  state.text += text
+  state.loading = false
+}
+const initChat = () => {
+  get(`/installed-apps/${window.czrConfig.dify.appId}/parameters`).then((res: any) => {
+    state.chatConfig = res
+    if (res.opening_statement) {
+      state.chats = [
+        {
+          type: 'answer',
+          welcome: true,
+          content: res.opening_statement,
+          question: res.suggested_questions
+        },
+      ]
+    }
+  })
+}
+const setText = (text: string, send = false) => {
+  if (send) {
+    onSend(text)
+  } else {
+    state.text = text
+  }
+}
+const onGood = (item) => {
+  updateFeedback({
+    url: `/messages/${item.messageId}/feedbacks`,
+    body: {rating: 'like'},
+  }, true, window.czrConfig.dify.appId).then((res: any) => {
+    if (res.result === 'success') {
+      state.goodMap.set(item.messageId, item)
+    }
+  })
+}
+const onBad = (item) => {
+  updateFeedback({
+    url: `/messages/${item.messageId}/feedbacks`,
+    body: {rating: 'dislike'},
+  }, true, window.czrConfig.dify.appId).then((res: any) => {
+    if (res.result === 'success') {
+      state.badMap.set(item.messageId, item)
+    }
+  })
+}
+const onNormal = (item) => {
+  updateFeedback({
+    url: `/messages/${item.messageId}/feedbacks`,
+    body: {rating: null},
+  }, true, window.czrConfig.dify.appId).then((res: any) => {
+    if (res.result === 'success') {
+      state.badMap.delete(item.messageId)
+      state.goodMap.delete(item.messageId)
+    }
+  })
+}
+const initTextHandle = () => {
+  let debounceTimer;
+  const DEBOUNCE_TIME = 300;
+  const textarea = ref_text.value
+  const floatingDiv = ref_auto.value
+
+  textarea.addEventListener('keydown', (e) => {
+    if (e.ctrlKey && e.key === 'Enter') {
+      e.preventDefault()
+      state.text += '\n'
+      textarea.style.height = 'auto';
+      textarea.style.height = Math.min(textarea.scrollHeight + 2, 200) + 'px';
+    } else if (e.key === 'Enter') {
+      e.preventDefault()
+      onSend()
+    } else {
+      textarea.style.height = 'auto';
+      textarea.style.height = Math.min(textarea.scrollHeight + 2, 200) + 'px';
+    }
+  });
+  textarea.addEventListener('input', (e) => {
+    textarea.style.height = 'auto';
+    textarea.style.height = Math.min(textarea.scrollHeight + 2, 200) + 'px';
+    clearTimeout(debounceTimer);
+    floatingDiv.style.display = 'none';
+    if (e.target.value) {
+      debounceTimer = setTimeout(() => {
+        updateFloatingDivPosition(e.target);
+      }, DEBOUNCE_TIME)
+    } else {
+      floatingDiv.style.display = 'none';
+    }
+  });
+
+  textarea.addEventListener('blur', function() {
+    setTimeout(() => {
+      floatingDiv.style.display = 'none';
+    }, 200);
+  });
+
+  textarea.addEventListener('focus', function(e) {
+    floatingDiv.style.maxWidth = (textarea.clientWidth + 32) + 'px';
+    if (e.target.value) {
+      updateFloatingDivPosition(e.target);
+      floatingDiv.style.display = 'flex';
+    }
+  });
+
+  const updateFloatingDivPosition = (t) => {
+    floatingDiv.style.visibility = 'hidden';
+    floatingDiv.style.display = 'flex';
+    const params1 = {
+      data: {
+        pageIndex: 1,
+        pageSize: 20,
+        condition: {
+          title: t.value
+        }
+      }
+    }
+    staticConQueryQuestionslist(params1).then(res => {
+      state.autoList = res?.data?.list.map(v => v.title) || []
+      if (state.autoList.length > 0) {
+        setTimeout(() => {
+          floatingDiv.style.top = (-floatingDiv.clientHeight - 2 - 20) + 'px';
+          floatingDiv.style.visibility = 'visible';
+        }, 10)
+      }
+    })
+  }
+}
+onMounted(() => {
+  initChat()
+  initTextHandle()
+})
+defineExpose({setText})
+</script>
+
+<style lang="scss" scoped>
+$scrollRight: 16px;
+.chat {
+  width: 100%;
+  height: 100%;
+  padding: 32px calc(32px - $scrollRight) 0 32px;
+  background: #FFFFFF;
+  border-radius: 16px;
+  display: flex;
+  flex-direction: column;
+  .chat-msg {
+    flex: 1;
+    overflow-y: auto;
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+    padding-right: $scrollRight;
+  }
+  .chat-input {
+    width: 100%;
+    padding: 24px calc(5px + $scrollRight) 24px 5px;
+    .chat-input-block {
+      width: 100%;
+      height: 100%;
+      background: #FFFFFF;
+      box-shadow: 0px 8px 16px 0px rgba(210,219,232,0.35);
+      border-radius: 16px;
+      border: 1px solid #E6E7EE;
+      display: flex;
+      flex-direction: column;
+      padding: 16px;
+      .chat-input-block-main {
+        position: relative;
+        .chat-input-block-main-auto {
+          position: absolute;
+          padding: 12px 16px;
+          background: #FFFFFF;
+          box-shadow: 0px 8px 16px 0px rgba(210,219,232,0.35);
+          border-radius: 16px;
+          border: 1px solid #E6E7EE;
+          min-width: 200px;
+          display: none;
+          max-height: 200px;
+          left: -16px;
+          overflow: hidden;
+          width: max-content;
+          .chat-input-block-main-auto-list {
+            flex: 1;
+            overflow-y: auto;
+            .chat-input-block-main-auto-item {
+              padding: 6px 0;
+              border-bottom: 1px dashed #D8DAE5;
+              &:last-child {
+                border-bottom: none;
+              }
+            }
+          }
+        }
+        >textarea {
+          width: 100%;
+          resize: none;
+          border: none;
+        }
+      }
+      .chat-input-block-operations {
+        margin-top: 12px;
+        display: flex;
+        align-items: center;
+        justify-content: flex-end;
+        .cibo-split {
+          width: 1px;
+          height: 17px;
+          background: #E6E7EE;
+        }
+        .cibo-audio {
+          display: flex;
+          align-items: center;
+          margin-right: 8px;
+        }
+        .cibo-send {
+          margin-left: 16px;
+          width: 32px;
+          height: 32px;
+          background: #1D64FD;
+          border-radius: 50%;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          >img {
+            margin-right: 3px;
+          }
+        }
+      }
+    }
+  }
+}
+</style>

+ 75 - 0
src/views/smart-ask-answer/assistant-2/cms/api.ts

@@ -0,0 +1,75 @@
+import { post } from './request'
+
+const suffix = 'cms-api'
+
+// 热门推荐-热门推荐-主题信息
+export const cmsAiQueryHotThemelist = (params) => post(
+  `/${suffix}/szface/cmsAi/queryHotThemelist`,
+  params,
+  {
+    headers: {
+      isEncrypt: true
+    }
+  },
+  'json'
+)
+
+// 热门推荐-热门推荐-热点问题列表
+export const cmsAiQueryHotReclist = (params) => post(
+  `/${suffix}/szface/cmsAi/queryHotReclist`,
+  params,
+  {
+    headers: {
+      isEncrypt: true
+    }
+  },
+  'json'
+)
+
+// 个人通关-常见问题列表
+export const staticConQueryQuestionslist = (params) => post(
+  `/${suffix}/szface/staticCon/queryQuestionslist`,
+  params,
+  {
+    headers: {
+      isEncrypt: true
+    }
+  },
+  'json'
+)
+
+// 智能问答-问题推荐列表
+export const cmsAiQueryQuestionReclist = (params) => post(
+  `/${suffix}/szface/cmsAi/queryQuestionReclist`,
+  params,
+  {
+    headers: {
+      isEncrypt: true
+    }
+  },
+  'json'
+)
+
+// 政务服务-事项列表
+export const matterQueryMatterlist = (params) => post(
+  `/${suffix}/szface/matter/queryMatterlist`,
+  params,
+  {
+    headers: {
+      isEncrypt: true
+    }
+  },
+  'json'
+)
+
+// 政务资讯-政策文件列表
+export const policyInfoQueryPolicyInfolist = (params) => post(
+  `/${suffix}/szface/policyInfo/queryPolicyInfolist`,
+  params,
+  {
+    headers: {
+      isEncrypt: true
+    }
+  },
+  'json'
+)

+ 66 - 0
src/views/smart-ask-answer/assistant-2/cms/crypto.ts

@@ -0,0 +1,66 @@
+import CryptoJS from "crypto-js";
+
+/**
+ * 随机生成32位的字符串
+ * @returns {string}
+ */
+const generateRandomString = (): string => {
+  const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+  let result = "";
+  const charactersLength = characters.length;
+  for (let i = 0; i < 32; i++) {
+    result += characters.charAt(Math.floor(Math.random() * charactersLength));
+  }
+  return result;
+};
+
+/**
+ * 随机生成aes 密钥
+ * @returns {string}
+ */
+export const generateAesKey = (): CryptoJS.lib.WordArray => {
+  return CryptoJS.enc.Utf8.parse(generateRandomString());
+};
+
+/**
+ * 加密base64
+ * @returns {string}
+ */
+export const encryptBase64 = (str: CryptoJS.lib.WordArray): string => {
+  return CryptoJS.enc.Base64.stringify(str);
+};
+
+/**
+ * 解密base64
+ */
+export const decryptBase64 = (str: string) => {
+  return CryptoJS.enc.Base64.parse(str);
+};
+
+/**
+ * 使用密钥对数据进行加密
+ * @param message
+ * @param aesKey
+ * @returns {string}
+ */
+export const encryptWithAes = (message: string, aesKey: CryptoJS.lib.WordArray): string => {
+  const encrypted = CryptoJS.AES.encrypt(message, aesKey, {
+    mode: CryptoJS.mode.ECB,
+    padding: CryptoJS.pad.Pkcs7,
+  });
+  return encrypted.toString();
+};
+
+/**
+ * 使用密钥对数据进行解密
+ * @param message
+ * @param aesKey
+ * @returns {string}
+ */
+export const decryptWithAes = (message: string, aesKey: CryptoJS.lib.WordArray): string => {
+  const decrypted = CryptoJS.AES.decrypt(message, aesKey, {
+    mode: CryptoJS.mode.ECB,
+    padding: CryptoJS.pad.Pkcs7,
+  });
+  return decrypted.toString(CryptoJS.enc.Utf8);
+};

+ 22 - 0
src/views/smart-ask-answer/assistant-2/cms/jsencrypt.ts

@@ -0,0 +1,22 @@
+import { JSEncrypt } from "jsencrypt";
+
+// 密钥对生成 http://web.chacuo.net/netrsakeypair
+
+const publicKey: string = 'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAK5yfMEMCBDVsLL9j63VJ3tCqi8pUAyW+eDXuU4xbBe+78IbVmblZ3KBgGDcTjqnM2desI5ZitpLa2/jFXn5Mf0CAwEAAQ=='
+
+// 前端不建议存放私钥 不建议解密数据 因为都是透明的意义不大
+const privateKey: string = 'MIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEA7yLWhkYx6y/iQrgAj8cQVYIm9hKmdXlRZE8q1uP35rlVujnWM9bO3AIlDbEKzbmCnAJ68sr3Oj2zD3SAvN8SewIDAQABAkEAx3RhRaFqpWVM7KUYItO/9fIWmQu5NyY3EtlNO+rsq8ywrvSttVY2er3tBDoTVtAeOTkJWBwKoNi3u8FHFiOmoQIhAP1TWNTb2q5cd7wFK8itFuSei2A+WaUdvDNl8SaaMIyTAiEA8akkQTSZGBxO9hXChxI9wI5e74m3AVREwzi73dVWe3kCIB3EbnrMvtygRv2UCfoRxM/mhXAww23wmY3cm8KyeaP7AiEAhKZ5tikvGCMB3ObY3tfOedIsnoQTpnEhRZ/wz7X5QNECIBBc4WS/hqncMeNEC2v2vtYJAPX2UzLwV16o4GqJjgzF'
+
+// 加密
+export const encrypt = (txt: string) => {
+  const encryptor = new JSEncrypt();
+  encryptor.setPublicKey(publicKey); // 设置公钥
+  return encryptor.encrypt(txt); // 对数据进行加密
+};
+
+// 解密
+export const decrypt = (txt: string) => {
+  const encryptor = new JSEncrypt();
+  encryptor.setPrivateKey(privateKey); // 设置私钥
+  return encryptor.decrypt(txt); // 对数据进行解密
+};

+ 385 - 0
src/views/smart-ask-answer/assistant-2/cms/request.ts

@@ -0,0 +1,385 @@
+import axios, {
+  AxiosError,
+  AxiosInstance,
+  AxiosRequestHeaders,
+  AxiosResponse,
+  InternalAxiosRequestConfig,
+} from "axios";
+import qs from "qs";
+import JSONbig from "json-bigint";
+// import Vue from "vue";
+import { merge } from "lodash-es";
+
+import { encryptBase64, encryptWithAes, generateAesKey, decryptWithAes, decryptBase64 } from "./crypto";
+import { encrypt, decrypt } from "./jsencrypt";
+// mock 不需要加密请求
+const encryptHeader = process.env.VUE_APP_MOCK ? "" : "cprams-encrypt-key"; //
+JSONbig({ storeAsString: true }); //所有超出安全范围的整数以字符串的形式存储
+// import Logger from "@/utils/Logger";
+// import errorCode from "./errorCode";
+// 需要忽略的提示。忽略后,自动 Promise.reject('error')
+const ignoreMsgs = [
+  "无效的刷新令牌", // 刷新令牌被删除时,不用提示
+  "刷新令牌已过期", // 使用刷新令牌,刷新获取新的访问令牌时,结果因为过期失败,此时需要忽略。否则,会导致继续 401,无法跳转到登出界面
+];
+// 默认超时时间
+axios.defaults.timeout = process.env.NODE_ENV === "development" ? 60 * 1000 : 20 * 1000;
+// 相对路径设置
+axios.defaults.baseURL = process.env.VUE_APP_API_PREFIX;
+
+//数据未加密时生效
+axios.defaults.transformResponse = [
+  function (data) {
+    try {
+      // 如果转换成功则返回转换的数据结果
+      return JSONbig.parse(data);
+    } catch (err) {
+      // 如果转换失败,则包装为统一数据格式并返回
+      return data;
+    }
+  },
+];
+
+axios.defaults.transformRequest = [
+  function (data) {
+    return JSONbig.stringify(data);
+  },
+];
+//数据未加密时生效end
+
+// http request 拦截器
+axios.interceptors.request.use(
+  async (config) => {
+    // 设置参数
+    if (!config.headers["Content-Type"]) {
+      config.headers["Content-Type"] = "application/json";
+    }
+
+    const params = config.params || {};
+    const data = config.data || false;
+
+    // 是否需要加密
+    // Logger.log("请求拦截器: config", config);
+
+    let isEncrypt = (config.headers || {})?.isEncrypt === "true";
+    //  JSONbig.stringify(data);
+
+    if (
+      config.method?.toUpperCase() === "POST" &&
+      (config.headers as AxiosRequestHeaders)["Content-Type"] === "application/x-www-form-urlencoded"
+    ) {
+      config.data = qs.stringify(data);
+    }
+    // get参数编码
+    if (config.method?.toUpperCase() === "GET" && params) {
+      let url = config.url + "?";
+      for (const propName of Object.keys(params)) {
+        const value = params[propName];
+        if (value) {
+          if (typeof value === "object") {
+            for (const val of Object.keys(value)) {
+              const params = propName + "[" + val + "]";
+              const subPart = encodeURIComponent(params) + "=";
+              url += subPart + encodeURIComponent(value[val]) + "&";
+            }
+          } else {
+            url += `${propName}=${encodeURIComponent(value)}&`;
+          }
+        }
+      }
+      // 给 get 请求加上时间戳参数,避免从缓存中拿数据
+      // const now = new Date().getTime()
+      // params = params.substring(0, url.length - 1) + `?_t=${now}`
+      url = url.slice(0, -1);
+      config.params = {};
+      config.url = url;
+    }
+    // 当开启参数加密
+    // Logger.log("开启参数加密:isEncrypt:" + isEncrypt);
+    console.log(config.data);
+    if (isEncrypt && encryptHeader && (config.method === "post" || config.method === "put")) {
+      // 生成一个 AES 密钥
+      const aesKey = generateAesKey();
+      const mergeKey = encrypt(encryptBase64(aesKey)) as any;
+      config.headers[encryptHeader] = mergeKey;
+      config.data =
+        typeof config.data === "object"
+          ? encryptWithAes(JSONbig.stringify(config.data), aesKey)
+          : encryptWithAes(config.data, aesKey);
+    }
+    Reflect.deleteProperty(config.headers, "isEncrypt");
+
+    return config;
+  },
+  (err) => {
+    return Promise.reject(err);
+  }
+);
+// response 拦截器
+axios.interceptors.response.use(
+  async (response: AxiosResponse<any>) => {
+    const { data, status } = response;
+    // 加密后的 AES 秘钥
+    const keyStr = response.headers[encryptHeader];
+    // 加密
+    if (keyStr) {
+      const data = response.data;
+      // 请求体 AES 解密
+      const base64Str = decrypt(keyStr);
+      // base64 解码 得到请求头的 AES 秘钥
+      const aesKey = decryptBase64(base64Str.toString());
+      // console.log('---aesKey', base64Str,data, aesKey)
+      // aesKey 解码 data
+      const decryptData = decryptWithAes(data, aesKey);
+      // console.log('---decryptData', decryptData)
+      // 将结果 (得到的是 JSON 字符串) 转为 JSON
+      //转繁体
+      response.data = JSONbig.parse(decryptData);
+      console.log(response.config.url, response.data);
+    }
+    if (!data) {
+      // 返回“[HTTP]请求没有返回值”;
+      console.log("!data");
+      throw new Error();
+    }
+    // const { t } = useI18n()
+    // 未设置状态码则默认成功状态
+    const code = data.status || 200;
+    // 二进制数据则直接返回
+    if (response.request.responseType === "blob" || response.request.responseType === "arraybuffer") {
+      return response.data;
+    }
+    // 获取错误信息
+    const msg = data.msg
+      // || errorCode[code] || errorCode["default"];
+    if (ignoreMsgs.indexOf(msg) !== -1) {
+      // 如果是忽略的错误码,直接返回 msg 异常
+      return Promise.reject(msg);
+    } else if (code === 401) {
+      // 如果未认证,并且未进行刷新令牌,说明可能是访问令牌过期了
+      // Vue.prototype.$message({
+      //   message: msg || "令牌过期",
+      //   type: "error",
+      // });
+    } else if (code === 500) {
+      // ElMessage.error(t('sys.api.errMsg500'))
+      // Vue.prototype.$message({
+      //   message: msg || "接口异常",
+      //   type: "error",
+      // });
+      return Promise.reject(new Error(msg));
+    } else if (code === -2) {
+      return Promise.reject(new Error(code));
+    } else {
+      return response;
+    }
+  },
+  (error: any) => {
+    // 中断请求不提示
+    if (axios.isCancel(error)) {
+      return new Promise(() => {});
+    }
+    if (error.name === "AxiosError") {
+      console.error(error.message);
+    }
+    return Promise.reject({ status: 500 });
+
+    if (error.response) {
+      switch (error.response.status) {
+        case 401:
+          // router.replace({
+          //   path: publicPath + "login",
+          //   query: { redirect: router.currentRoute.fullPath }
+          // });
+          // window.localStorage.clear();
+          break;
+        case 500:
+          // Vue.prototype.$message({
+          //   message: error.response.data.resp_msg || "服务器异常",
+          //   type: "error",
+          // });
+          break;
+        case 502:
+          // Vue.prototype.$message({
+          //   message: error.response.data.resp_msg || "接口异常",
+          //   type: "error",
+          // });
+          break;
+        case 400:
+          // Vue.prototype.$message({
+          //   message: error.response.data.resp_msg || "请求失败",
+          //   type: "error",
+          //   timeout: 1300, // 显示毫秒数
+          // });
+          break;
+        default:
+      }
+    }
+    return Promise.reject(error);
+  }
+);
+export function put(url, data = {}, config = {}) {
+  return new Promise((resolve, reject) => {
+    axios.put(url, data, config).then(
+      (response) => {
+        if (response.status === 200) {
+          resolve(response.data);
+        } else {
+          // Vue.prototype.$message({
+          //   message: response.statusText || "接口异常",
+          //   type: "error",
+          // });
+          // Vue.prototype.$message.error(response.statusText)
+        }
+      },
+      (err) => {
+        return reject(err);
+      }
+    );
+  });
+}
+
+function initConfig({ config, reqType }) {
+  let contentType = ``;
+  switch (reqType) {
+    case "json":
+      contentType = "application/json";
+      break;
+    case "file":
+      contentType = "multipart/form-data";
+      break;
+    default:
+      contentType = "application/x-www-form-urlencoded;charset=utf-8";
+  }
+
+  config = merge(
+    {
+      headers: {
+        "Content-Type": contentType,
+      },
+    },
+    config
+  );
+  return config;
+}
+
+export function post(url, data = {}, config = {}, reqType) {
+  config = initConfig({ reqType, config });
+
+  if (data instanceof FormData === false) {
+    data = reqType === "json" ? JSONbig.stringify(data) : qs.stringify({ ...data });
+  }
+  return new Promise((resolve, reject) => {
+    axios.post(url, data, config).then(
+      (response) => {
+        if (response.status === 200) {
+          resolve(response.data);
+        } else {
+          // Vue.prototype.$message({
+          //   message: response.statusText || "接口异常",
+          //   type: "error",
+          // });
+          reject(response);
+          // Vue.prototype.$message.error(response.statusText)
+        }
+      },
+      (err) => {
+        reject(err);
+      }
+    );
+  });
+}
+
+export function postRaw(url, data = {}, config: any = {}, reqType = "json") {
+  config = initConfig({ reqType, config });
+  // JSONbig.stringify(data)
+  data = JSONbig.stringify(data);
+
+  return axios.post(url, data, config).then(
+    (response) => {
+      if (response.status === 200) {
+        return response.data;
+      } else {
+        // Vue.prototype.$message({
+        //   message: response.statusText || "接口异常",
+        //   type: "error",
+        // });
+        // Vue.prototype.$message.error(response.statusText)
+      }
+    },
+    (err) => {
+      // return Promise.reject(err);
+    }
+  );
+}
+
+export function get(url, data = {}, config = {}) {
+  return new Promise((resolve, reject) => {
+    axios.get(url + "?" + qs.stringify(data?.data || {}), config).then(
+      (response) => {
+        if (response.status === 200) {
+          resolve(response.data);
+        } else {
+          // Vue.prototype.$message({
+          //   message: response.statusText || "接口异常",
+          //   type: "error",
+          // });
+          // Vue.prototype.$message.error(response.statusText)
+        }
+      },
+      (err) => {
+        reject(err);
+      }
+    );
+  });
+}
+
+export function del(url, data = {}, config = {}) {
+  return new Promise((resolve, reject) => {
+    axios.delete(url, { data, ...config }).then(
+      (response) => {
+        if (response.status === 200) {
+          resolve(response.data);
+        } else {
+          // Vue.prototype.$message({
+          //   message: response.statusText || "接口异常",
+          //   type: "error",
+          // });
+          // Vue.prototype.$message.error(response.statusText)
+        }
+      },
+      (err) => {
+        reject(err);
+      }
+    );
+  });
+}
+
+export function form(url: string, formData: object = {}, config: object = {}) {
+  let allConfig = Object.assign(
+    {
+      headers: {
+        "Content-Type": "multipart/form-data",
+      },
+    },
+    config
+  );
+  return new Promise((resolve, reject) => {
+    axios.post(url, formData, allConfig).then(
+      (response) => {
+        if (response.status === 200) {
+          resolve(response.data);
+        } else {
+          // Vue.prototype.$message({
+          //   message: response.statusText || "接口异常",
+          //   type: "error",
+          // });
+          // Vue.prototype.$message.error(response.statusText)
+        }
+      },
+      (err) => {
+        return Promise.reject(err);
+      }
+    );
+  });
+}

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

@@ -0,0 +1,219 @@
+<template>
+  <div class="answer">
+    <div class="answer-avatar">
+      <img src="@/views/smart-ask-answer/assistant-2/imgs/avatar.png"/>
+    </div>
+    <div class="answer-content">
+      <template v-if="item.welcome">
+        <div class="answer-content-text">{{ item.content }}</div>
+        <div class="answer-content-question" v-if="item.question?.length > 0">
+          <template v-for="ques in item.question">
+            <div class="ques-item __hover" @click="$emit('setText', {text: ques, send: true})">{{ques}}</div>
+          </template>
+        </div>
+      </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.replace(/^<think>/, ''))"/>
+            <div class="answer-operation">
+              <el-tooltip content="播放" placement="top">
+                <div class="answer-operation-item __hover" @click="speak(part.content)">
+                  <img src="@/views/smart-ask-answer/assistant-2/imgs/play.png"/>
+                </div>
+              </el-tooltip>
+              <el-tooltip content="复制" placement="top">
+                <div class="answer-operation-item __hover" @click="onCopy(part.content)">
+                  <img src="@/views/smart-ask-answer/assistant-2/imgs/copy.png"/>
+                </div>
+              </el-tooltip>
+              <template v-if="item.messageId">
+                <template v-if="!badMap.has(item.messageId)">
+                  <template v-if="goodMap.has(item.messageId)">
+                    <el-tooltip content="取消点赞" placement="top">
+                      <div class="answer-operation-item __hover" @click="$emit('onNormal', item)">
+                        <SvgIcon name="good" color="#2264f0" size="20"/>
+                      </div>
+                    </el-tooltip>
+                  </template>
+                  <template v-else>
+                    <el-tooltip content="点赞" placement="top">
+                      <div class="answer-operation-item __hover" @click="$emit('onGood', item)">
+                        <SvgIcon name="good" color="#a6a6a6" size="20"/>
+                      </div>
+                    </el-tooltip>
+                  </template>
+                </template>
+                <template v-if="!goodMap.has(item.messageId)">
+                  <template v-if="badMap.has(item.messageId)">
+                    <el-tooltip content="取消点踩" placement="top">
+                      <div class="answer-operation-item __hover" @click="$emit('onNormal', item)">
+                        <SvgIcon name="good" color="#d92d20" size="20" rotate="180"/>
+                      </div>
+                    </el-tooltip>
+                  </template>
+                  <template v-else>
+                    <el-tooltip content="点踩" placement="top">
+                      <div class="answer-operation-item __hover" @click="$emit('onBad', item)">
+                        <SvgIcon name="good" color="#a6a6a6" size="20" rotate="180"/>
+                      </div>
+                    </el-tooltip>
+                  </template>
+                </template>
+              </template>
+            </div>
+            <template v-if="item.suggest?.length > 0">
+              <div class="answer-suggest">
+                相似问题
+                <template v-for="sug in item.suggest">
+                  <div class="__hover" @click="$emit('setText', {text: sug, send: true})">{{sug}}</div>
+                </template>
+              </div>
+            </template>
+<!--            <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'
+import {copy} from "@/utils/czr-util";
+import {ElMessage} from "element-plus";
+import useTextToSpeech from './useTextToSpeech';
+
+const { speak, stop } = useTextToSpeech();
+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,
+  goodMap: {} as any,
+  badMap: {} 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()
+    });
+  }
+  return segments;
+})
+const onCopy = (text) => {
+  copy(text)
+  ElMessage.success('复制成功!')
+}
+</script>
+
+<style lang="scss" scoped>
+.answer {
+  width: 100%;
+  display: flex;
+  .answer-avatar {
+    margin-right: 16px;
+  }
+  .answer-content {
+    flex: 1;
+    overflow: hidden;
+    font-weight: 400;
+    font-size: 16px;
+    color: #111111;
+    .answer-content-text {
+      margin-top: 16px;
+    }
+    .answer-content-question {
+      margin-top: 10px;
+      display: flex;
+      flex-wrap: wrap;
+      gap: 4px;
+      .ques-item {
+        padding: 6px 14px;
+        border-radius: 8px;
+        border: 1px solid #10182824;
+        font-size: 14px;
+        color: #1D64FD;
+      }
+    }
+  }
+  .answer-operation {
+    margin-top: 16px;
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    .answer-operation-item {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+  }
+  .answer-suggest {
+    margin-top: 14px;
+    padding-top: 14px;
+    border-top: 1px solid #D8DAE5;
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+    font-size: 14px;
+    color: #9A9CA6;
+    >div {
+      color: #1D64FD;
+    }
+  }
+}
+</style>

+ 53 - 0
src/views/smart-ask-answer/assistant-2/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: 0.35rem 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>

+ 56 - 0
src/views/smart-ask-answer/assistant-2/component/answer/useTextToSpeech.ts

@@ -0,0 +1,56 @@
+// src/composables/useTextToSpeech.ts
+import { onUnmounted } from 'vue';
+
+export default function useTextToSpeech() {
+  const speech = new SpeechSynthesisUtterance();
+  let isPlaying = false;
+
+  // 过滤HTML标签
+  const stripHtml = (html: string): string => {
+    const doc = new DOMParser().parseFromString(html, 'text/html');
+    return doc.body.textContent || '';
+  };
+
+  // 播放文本
+  const speak = (text: string) => {
+    stop(); // 停止当前播放
+
+    const cleanText = stripHtml(text).trim();
+    if (!cleanText) {
+      console.warn('No valid text to speak');
+      return;
+    }
+
+    speech.text = cleanText;
+    isPlaying = true;
+
+    speech.onend = () => {
+      isPlaying = false;
+    };
+
+    speech.onerror = (event) => {
+      console.error('Speech error:', event);
+      isPlaying = false;
+    };
+
+    window.speechSynthesis.speak(speech);
+  };
+
+  // 停止播放
+  const stop = () => {
+    if (isPlaying) {
+      window.speechSynthesis.cancel();
+      isPlaying = false;
+    }
+  };
+
+  // 组件卸载时自动停止
+  onUnmounted(() => {
+    stop();
+  });
+
+  return {
+    speak,
+    stop
+  };
+}

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

@@ -0,0 +1,34 @@
+<template>
+  <div class="ask">
+    {{contentCpt}}
+  </div>
+</template>
+
+<script setup lang="ts">
+import {computed, getCurrentInstance, onMounted, reactive, watch} from "vue";
+
+const props = defineProps({
+  item: {} as any,
+})
+const contentCpt = computed(() => {
+  return props.item.content
+})
+const state: any = reactive({
+})
+onMounted(() => {
+})
+</script>
+
+<style lang="scss" scoped>
+.ask {
+  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-2/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-2/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-2/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>

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

@@ -0,0 +1,694 @@
+import { refreshAccessTokenOrRelogin } from './refresh-token'
+import { API_PREFIX, IS_CE_EDITION, PUBLIC_API_PREFIX } from './config'
+import { asyncRunSafe } from './utils'
+import {ElNotification} from "element-plus";
+const TIME_OUT = 100000
+
+const CONVERSATION_ID_INFO = 'conversationIdInfo'
+const removeAccessToken = () => {
+  const sharedToken = globalThis.location.pathname.split('/').slice(-1)[0]
+  const accessToken = localStorage.getItem('token') || JSON.stringify({ [sharedToken]: '' })
+  let accessTokenJson = { [sharedToken]: '' }
+  try {
+    accessTokenJson = JSON.parse(accessToken)
+  }
+  catch (e) {
+  }
+  localStorage.removeItem(CONVERSATION_ID_INFO)
+  delete accessTokenJson[sharedToken]
+  localStorage.setItem('token', JSON.stringify(accessTokenJson))
+}
+const ContentType = {
+  json: 'application/json',
+  stream: 'text/event-stream',
+  audio: 'audio/mpeg',
+  form: 'application/x-www-form-urlencoded; charset=UTF-8',
+  download: 'application/octet-stream', // for download
+  upload: 'multipart/form-data', // for upload
+}
+
+const baseOptions = {
+  method: 'GET',
+  mode: 'cors',
+  credentials: 'include', // always send cookies、HTTP Basic authentication.
+  headers: new Headers({
+    'Content-Type': ContentType.json,
+  }),
+  redirect: 'follow',
+}
+
+export type IOnDataMoreInfo = {
+  conversationId?: string
+  taskId?: string
+  messageId: string
+  errorMessage?: string
+  errorCode?: string
+}
+
+export type IOnData = (message: string, isFirstMessage: boolean, moreInfo: IOnDataMoreInfo) => void
+export type IOnThought = (though) => void
+export type IOnFile = (file) => void
+export type IOnMessageEnd = (messageEnd) => void
+export type IOnMessageReplace = (messageReplace) => void
+export type IOnAnnotationReply = (messageReplace) => void
+export type IOnCompleted = (hasError?: boolean, errorMessage?: string) => void
+export type IOnError = (msg: string, code?: string) => void
+
+export type IOnWorkflowStarted = (workflowStarted) => void
+export type IOnWorkflowFinished = (workflowFinished) => void
+export type IOnNodeStarted = (nodeStarted) => void
+export type IOnNodeFinished = (nodeFinished) => void
+export type IOnIterationStarted = (workflowStarted) => void
+export type IOnIterationNext = (workflowStarted) => void
+export type IOnNodeRetry = (nodeFinished) => void
+export type IOnIterationFinished = (workflowFinished) => void
+export type IOnParallelBranchStarted = (parallelBranchStarted) => void
+export type IOnParallelBranchFinished = (parallelBranchFinished) => void
+export type IOnTextChunk = (textChunk) => void
+export type IOnTTSChunk = (messageId: string, audioStr: string, audioType?: string) => void
+export type IOnTTSEnd = (messageId: string, audioStr: string, audioType?: string) => void
+export type IOnTextReplace = (textReplace) => void
+
+export type IOtherOptions = {
+  isPublicAPI?: boolean
+  bodyStringify?: boolean
+  needAllResponseContent?: boolean
+  deleteContentType?: boolean
+  silent?: boolean
+  onData?: IOnData // for stream
+  onThought?: IOnThought
+  onFile?: IOnFile
+  onMessageEnd?: IOnMessageEnd
+  onMessageReplace?: IOnMessageReplace
+  onError?: IOnError
+  onCompleted?: IOnCompleted // for stream
+  getAbortController?: (abortController: AbortController) => void
+
+  onWorkflowStarted?: IOnWorkflowStarted
+  onWorkflowFinished?: IOnWorkflowFinished
+  onNodeStarted?: IOnNodeStarted
+  onNodeFinished?: IOnNodeFinished
+  onIterationStart?: IOnIterationStarted
+  onIterationNext?: IOnIterationNext
+  onIterationFinish?: IOnIterationFinished
+  onNodeRetry?: IOnNodeRetry
+  onParallelBranchStarted?: IOnParallelBranchStarted
+  onParallelBranchFinished?: IOnParallelBranchFinished
+  onTextChunk?: IOnTextChunk
+  onTTSChunk?: IOnTTSChunk
+  onTTSEnd?: IOnTTSEnd
+  onTextReplace?: IOnTextReplace
+}
+
+type ResponseError = {
+  code: string
+  message: string
+  status: number
+}
+
+type FetchOptionType = Omit<RequestInit, 'body'> & {
+  params?: Record<string, any>
+  body?: BodyInit | Record<string, any> | null
+}
+
+function unicodeToChar(text: string) {
+  if (!text)
+    return ''
+
+  return text.replace(/\\u[0-9a-f]{4}/g, (_match, p1) => {
+    return String.fromCharCode(parseInt(p1, 16))
+  })
+}
+
+function requiredWebSSOLogin() {
+  globalThis.location.href = `/webapp-signin?redirect_url=${globalThis.location.pathname}`
+}
+
+function getAccessToken(isPublicAPI?: boolean) {
+  if (isPublicAPI) {
+    const sharedToken = globalThis.location.pathname.split('/').slice(-1)[0]
+    const accessToken = localStorage.getItem('token') || JSON.stringify({ [sharedToken]: '' })
+    let accessTokenJson = { [sharedToken]: '' }
+    try {
+      accessTokenJson = JSON.parse(accessToken)
+    }
+    catch (e) {
+
+    }
+    return accessTokenJson[sharedToken]
+  }
+  else {
+    return localStorage.getItem('console_token') || ''
+  }
+}
+
+export function format(text: string) {
+  let res = text.trim()
+  if (res.startsWith('\n'))
+    res = res.replace('\n', '')
+
+  return res.replaceAll('\n', '<br/>').replaceAll('```', '')
+}
+
+const handleStream = (
+  response: Response,
+  onData: IOnData,
+  onCompleted?: IOnCompleted,
+  onThought?: IOnThought,
+  onMessageEnd?: IOnMessageEnd,
+  onMessageReplace?: IOnMessageReplace,
+  onFile?: IOnFile,
+  onWorkflowStarted?: IOnWorkflowStarted,
+  onWorkflowFinished?: IOnWorkflowFinished,
+  onNodeStarted?: IOnNodeStarted,
+  onNodeFinished?: IOnNodeFinished,
+  onIterationStart?: IOnIterationStarted,
+  onIterationNext?: IOnIterationNext,
+  onIterationFinish?: IOnIterationFinished,
+  onNodeRetry?: IOnNodeRetry,
+  onParallelBranchStarted?: IOnParallelBranchStarted,
+  onParallelBranchFinished?: IOnParallelBranchFinished,
+  onTextChunk?: IOnTextChunk,
+  onTTSChunk?: IOnTTSChunk,
+  onTTSEnd?: IOnTTSEnd,
+  onTextReplace?: IOnTextReplace,
+) => {
+  if (!response.ok)
+    throw new Error('Network response was not ok')
+
+  const reader = response.body?.getReader()
+  const decoder = new TextDecoder('utf-8')
+  let buffer = ''
+  let bufferObj: Record<string, any>
+  let isFirstMessage = true
+  function read() {
+    let hasError = false
+    reader?.read().then((result: any) => {
+      if (result.done) {
+        onCompleted && onCompleted()
+        return
+      }
+      buffer += decoder.decode(result.value, { stream: true })
+      const lines = buffer.split('\n')
+      try {
+        lines.forEach((message) => {
+          if (message.startsWith('data: ')) { // check if it starts with data:
+            try {
+              bufferObj = JSON.parse(message.substring(6)) as Record<string, any>// remove data: and parse as json
+            }
+            catch (e) {
+              // mute handle message cut off
+              onData('', isFirstMessage, {
+                conversationId: bufferObj?.conversation_id,
+                messageId: bufferObj?.message_id,
+              })
+              return
+            }
+            if (bufferObj.status === 400 || !bufferObj.event) {
+              onData('', false, {
+                conversationId: undefined,
+                messageId: '',
+                errorMessage: bufferObj?.message,
+                errorCode: bufferObj?.code,
+              })
+              hasError = true
+              onCompleted?.(true, bufferObj?.message)
+              return
+            }
+            if (bufferObj.event === 'message' || bufferObj.event === 'agent_message') {
+              // can not use format here. Because message is splitted.
+              onData(unicodeToChar(bufferObj.answer), isFirstMessage, {
+                conversationId: bufferObj.conversation_id,
+                taskId: bufferObj.task_id,
+                messageId: bufferObj.id,
+              })
+              isFirstMessage = false
+            }
+            else if (bufferObj.event === 'agent_thought') {
+              onThought?.(bufferObj)
+            }
+            else if (bufferObj.event === 'message_file') {
+              onFile?.(bufferObj)
+            }
+            else if (bufferObj.event === 'message_end') {
+              onMessageEnd?.(bufferObj)
+            }
+            else if (bufferObj.event === 'message_replace') {
+              onMessageReplace?.(bufferObj)
+            }
+            else if (bufferObj.event === 'workflow_started') {
+              onWorkflowStarted?.(bufferObj)
+            }
+            else if (bufferObj.event === 'workflow_finished') {
+              onWorkflowFinished?.(bufferObj)
+            }
+            else if (bufferObj.event === 'node_started') {
+              onNodeStarted?.(bufferObj)
+            }
+            else if (bufferObj.event === 'node_finished') {
+              onNodeFinished?.(bufferObj)
+            }
+            else if (bufferObj.event === 'iteration_started') {
+              onIterationStart?.(bufferObj)
+            }
+            else if (bufferObj.event === 'iteration_next') {
+              onIterationNext?.(bufferObj)
+            }
+            else if (bufferObj.event === 'iteration_completed') {
+              onIterationFinish?.(bufferObj)
+            }
+            else if (bufferObj.event === 'node_retry') {
+              onNodeRetry?.(bufferObj)
+            }
+            else if (bufferObj.event === 'parallel_branch_started') {
+              onParallelBranchStarted?.(bufferObj)
+            }
+            else if (bufferObj.event === 'parallel_branch_finished') {
+              onParallelBranchFinished?.(bufferObj)
+            }
+            else if (bufferObj.event === 'text_chunk') {
+              onTextChunk?.(bufferObj)
+            }
+            else if (bufferObj.event === 'text_replace') {
+              onTextReplace?.(bufferObj)
+            }
+            else if (bufferObj.event === 'tts_message') {
+              onTTSChunk?.(bufferObj.message_id, bufferObj.audio, bufferObj.audio_type)
+            }
+            else if (bufferObj.event === 'tts_message_end') {
+              onTTSEnd?.(bufferObj.message_id, bufferObj.audio)
+            }
+          }
+        })
+        buffer = lines[lines.length - 1]
+      }
+      catch (e) {
+        onData('', false, {
+          conversationId: undefined,
+          messageId: '',
+          errorMessage: `${e}`,
+        })
+        hasError = true
+        onCompleted?.(true, e as string)
+        return
+      }
+      if (!hasError)
+        read()
+    })
+  }
+  read()
+}
+
+const baseFetch = <T>(
+  url: string,
+  fetchOptions: FetchOptionType,
+  {
+    isPublicAPI = false,
+    bodyStringify = true,
+    needAllResponseContent,
+    deleteContentType,
+    getAbortController,
+    silent,
+  }: IOtherOptions,
+): Promise<T> => {
+  const options: typeof baseOptions & FetchOptionType = Object.assign({}, baseOptions, fetchOptions)
+  if (getAbortController) {
+    const abortController = new AbortController()
+    getAbortController(abortController)
+    options.signal = abortController.signal
+  }
+  const accessToken = getAccessToken(isPublicAPI)
+  options.headers.set('Authorization', `Bearer ${accessToken}`)
+  if (deleteContentType) {
+    options.headers.delete('Content-Type')
+  }
+  else {
+    const contentType = options.headers.get('Content-Type')
+    if (!contentType)
+      options.headers.set('Content-Type', ContentType.json)
+  }
+
+  const urlPrefix = isPublicAPI ? PUBLIC_API_PREFIX : API_PREFIX
+  let urlWithPrefix = (url.startsWith('http://') || url.startsWith('https://'))
+    ? url
+    : `${urlPrefix}${url.startsWith('/') ? url : `/${url}`}`
+
+  const { method, params, body } = options
+  // handle query
+  if (method === 'GET' && params) {
+    const paramsArray: string[] = []
+    Object.keys(params).forEach(key =>
+      paramsArray.push(`${key}=${encodeURIComponent(params[key])}`),
+    )
+    if (urlWithPrefix.search(/\?/) === -1)
+      urlWithPrefix += `?${paramsArray.join('&')}`
+
+    else
+      urlWithPrefix += `&${paramsArray.join('&')}`
+
+    delete options.params
+  }
+
+  if (body && bodyStringify)
+    options.body = JSON.stringify(body)
+
+  // Handle timeout
+  return Promise.race([
+    new Promise((resolve, reject) => {
+      setTimeout(() => {
+        reject(new Error('request timeout'))
+      }, TIME_OUT)
+    }),
+    new Promise((resolve, reject) => {
+      globalThis.fetch(urlWithPrefix, options as RequestInit)
+        .then((res) => {
+          const resClone = res.clone()
+          // Error handler
+          if (!/^(2|3)\d{2}$/.test(String(res.status))) {
+            const bodyJson = res.json()
+            switch (res.status) {
+              case 401:
+                return Promise.reject(resClone)
+              case 403:
+                bodyJson.then((data: ResponseError) => {
+                  if (!silent) {
+                    ElNotification({
+                      message: data.message,
+                      type: 'error',
+                    })
+                  }
+                  // if (data.code === 'already_setup')
+                    // globalThis.location.href = `${globalThis.location.origin}/signin`
+                })
+                break
+              // fall through
+              default:
+                bodyJson.then((data: ResponseError) => {
+                  if (!silent) {
+                    ElNotification({
+                      message: data.message,
+                      type: 'error',
+                    })
+                  }
+                })
+            }
+            return Promise.reject(resClone)
+          }
+
+          // handle delete api. Delete api not return content.
+          if (res.status === 204) {
+            resolve({ result: 'success' })
+            return
+          }
+
+          // return data
+          if (options.headers.get('Content-type') === ContentType.download || options.headers.get('Content-type') === ContentType.audio)
+            resolve(needAllResponseContent ? resClone : res.blob())
+
+          else resolve(needAllResponseContent ? resClone : res.json())
+        })
+        .catch((err) => {
+          if (!silent) {
+            ElNotification({
+              message: err,
+              type: 'error',
+            })
+          }
+          reject(err)
+        })
+    }),
+  ]) as Promise<T>
+}
+
+export const upload = (options: any, isPublicAPI?: boolean, url?: string, searchParams?: string): Promise<any> => {
+  const urlPrefix = isPublicAPI ? PUBLIC_API_PREFIX : API_PREFIX
+  const token = getAccessToken(isPublicAPI)
+  const defaultOptions = {
+    method: 'POST',
+    url: (url ? `${urlPrefix}${url}` : `${urlPrefix}/files/upload`) + (searchParams || ''),
+    headers: {
+      Authorization: `Bearer ${token}`,
+    },
+    data: {},
+  }
+  options = {
+    ...defaultOptions,
+    ...options,
+    headers: { ...defaultOptions.headers, ...options.headers },
+  }
+  return new Promise((resolve, reject) => {
+    const xhr = options.xhr
+    xhr.open(options.method, options.url)
+    for (const key in options.headers)
+      xhr.setRequestHeader(key, options.headers[key])
+
+    xhr.withCredentials = true
+    xhr.responseType = 'json'
+    xhr.onreadystatechange = function () {
+      if (xhr.readyState === 4) {
+        if (xhr.status === 201)
+          resolve(xhr.response)
+        else
+          reject(xhr)
+      }
+    }
+    xhr.upload.onprogress = options.onprogress
+    xhr.send(options.data)
+  })
+}
+
+export const ssePost = (
+  url: string,
+  fetchOptions: FetchOptionType,
+  otherOptions: IOtherOptions,
+) => {
+  const {
+    isPublicAPI = false,
+    onData,
+    onCompleted,
+    onThought,
+    onFile,
+    onMessageEnd,
+    onMessageReplace,
+    onWorkflowStarted,
+    onWorkflowFinished,
+    onNodeStarted,
+    onNodeFinished,
+    onIterationStart,
+    onIterationNext,
+    onIterationFinish,
+    onNodeRetry,
+    onParallelBranchStarted,
+    onParallelBranchFinished,
+    onTextChunk,
+    onTTSChunk,
+    onTTSEnd,
+    onTextReplace,
+    onError,
+    getAbortController,
+  } = otherOptions
+  const abortController = new AbortController()
+
+  const options = Object.assign({}, baseOptions, {
+    method: 'POST',
+    signal: abortController.signal,
+  }, fetchOptions)
+
+  const contentType = options.headers.get('Content-Type')
+  if (!contentType)
+    options.headers.set('Content-Type', ContentType.json)
+
+  getAbortController?.(abortController)
+
+  const urlPrefix = isPublicAPI ? PUBLIC_API_PREFIX : API_PREFIX
+  const urlWithPrefix = (url.startsWith('http://') || url.startsWith('https://'))
+    ? url
+    : `${urlPrefix}${url.startsWith('/') ? url : `/${url}`}`
+
+  const { body } = options
+  if (body)
+    options.body = JSON.stringify(body)
+
+  const accessToken = getAccessToken(isPublicAPI)
+  options.headers.set('Authorization', `Bearer ${accessToken}`)
+
+  globalThis.fetch(urlWithPrefix, options as RequestInit)
+    .then((res) => {
+      if (!/^(2|3)\d{2}$/.test(String(res.status))) {
+        if (res.status === 401) {
+          refreshAccessTokenOrRelogin(TIME_OUT).then(() => {
+            ssePost(url, fetchOptions, otherOptions)
+          }).catch(() => {
+            res.json().then((data: any) => {
+              if (isPublicAPI) {
+                if (data.code === 'web_sso_auth_required')
+                  requiredWebSSOLogin()
+
+                if (data.code === 'unauthorized') {
+                  removeAccessToken()
+                  globalThis.location.reload()
+                }
+              }
+            })
+          })
+        }
+        else {
+          res.json().then((data) => {
+            ElNotification({
+              message: data.message || 'Server Error',
+              type: 'error',
+            })
+          })
+          onError?.('Server Error')
+        }
+        return
+      }
+      return handleStream(res, (str: string, isFirstMessage: boolean, moreInfo: IOnDataMoreInfo) => {
+        if (moreInfo.errorMessage) {
+          onError?.(moreInfo.errorMessage, moreInfo.errorCode)
+          // TypeError: Cannot assign to read only property ... will happen in page leave, so it should be ignored.
+          if (moreInfo.errorMessage !== 'AbortError: The user aborted a request.' && !moreInfo.errorMessage.includes('TypeError: Cannot assign to read only property')) {
+            ElNotification({
+              message: moreInfo.errorMessage,
+              type: 'error',
+            })
+          }
+          return
+        }
+        onData?.(str, isFirstMessage, moreInfo)
+      }, onCompleted, onThought, onMessageEnd, onMessageReplace, onFile, onWorkflowStarted, onWorkflowFinished, onNodeStarted, onNodeFinished, onIterationStart, onIterationNext, onIterationFinish, onNodeRetry, onParallelBranchStarted, onParallelBranchFinished, onTextChunk, onTTSChunk, onTTSEnd, onTextReplace)
+    }).catch((e) => {
+      if (e.toString() !== 'AbortError: The user aborted a request.' && !e.toString().errorMessage.includes('TypeError: Cannot assign to read only property')) {
+        ElNotification({
+          message: e,
+          type: 'error',
+        })
+      }
+      onError?.(e)
+    })
+}
+
+// base request
+export const request = async<T>(url: string, options = {}, otherOptions?: IOtherOptions) => {
+  try {
+    const otherOptionsForBaseFetch = otherOptions || {}
+    const [err, resp] = await asyncRunSafe<T>(baseFetch(url, options, otherOptionsForBaseFetch))
+    if (err === null)
+      return resp
+    const errResp: Response = err as any
+    if (errResp.status === 401) {
+      const [parseErr, errRespData] = await asyncRunSafe<ResponseError>(errResp.json())
+      const loginUrl = `${globalThis.location.origin}/signin`
+      // if (parseErr) {
+      //   globalThis.location.href = loginUrl
+      //   return Promise.reject(err)
+      // }
+      // special code
+      const { code, message }: any = errRespData
+      // webapp sso
+      if (code === 'web_sso_auth_required') {
+        requiredWebSSOLogin()
+        return Promise.reject(err)
+      }
+      if (code === 'unauthorized_and_force_logout') {
+        localStorage.removeItem('console_token')
+        localStorage.removeItem('refresh_token')
+        globalThis.location.reload()
+        return Promise.reject(err)
+      }
+      const {
+        isPublicAPI = false,
+        silent,
+      } = otherOptionsForBaseFetch
+      if (isPublicAPI && code === 'unauthorized') {
+        removeAccessToken()
+        globalThis.location.reload()
+        return Promise.reject(err)
+      }
+      if (code === 'init_validate_failed' && IS_CE_EDITION && !silent) {
+        ElNotification({
+          message: message,
+          type: 'error',
+          duration: 4000
+        })
+        return Promise.reject(err)
+      }
+      if (code === 'not_init_validated' && IS_CE_EDITION) {
+        globalThis.location.href = `${globalThis.location.origin}/init`
+        return Promise.reject(err)
+      }
+      if (code === 'not_setup' && IS_CE_EDITION) {
+        globalThis.location.href = `${globalThis.location.origin}/install`
+        return Promise.reject(err)
+      }
+
+      // refresh token
+      const [refreshErr] = await asyncRunSafe(refreshAccessTokenOrRelogin(TIME_OUT))
+      if (refreshErr === null)
+        return baseFetch<T>(url, options, otherOptionsForBaseFetch)
+      if (location.pathname !== '/signin' || !IS_CE_EDITION) {
+        // globalThis.location.href = loginUrl
+        return Promise.reject(err)
+      }
+      if (!silent) {
+        ElNotification({
+          message: message,
+          type: 'error',
+        })
+        return Promise.reject(err)
+      }
+      // globalThis.location.href = loginUrl
+      return Promise.reject(err)
+
+    }
+    else {
+      return Promise.reject(err)
+    }
+  }
+  catch (error) {
+    console.error(error)
+    return Promise.reject(error)
+  }
+}
+
+// request methods
+export const get = <T>(url: string, options = {}, otherOptions?: IOtherOptions) => {
+  return request<T>(url, Object.assign({}, options, { method: 'GET' }), otherOptions)
+}
+
+// For public API
+export const getPublic = <T>(url: string, options = {}, otherOptions?: IOtherOptions) => {
+  return get<T>(url, options, { ...otherOptions, isPublicAPI: true })
+}
+
+export const post = <T>(url: string, options = {}, otherOptions?: IOtherOptions) => {
+  return request<T>(url, Object.assign({}, options, { method: 'POST' }), otherOptions)
+}
+
+export const postPublic = <T>(url: string, options = {}, otherOptions?: IOtherOptions) => {
+  return post<T>(url, options, { ...otherOptions, isPublicAPI: true })
+}
+
+export const put = <T>(url: string, options = {}, otherOptions?: IOtherOptions) => {
+  return request<T>(url, Object.assign({}, options, { method: 'PUT' }), otherOptions)
+}
+
+export const putPublic = <T>(url: string, options = {}, otherOptions?: IOtherOptions) => {
+  return put<T>(url, options, { ...otherOptions, isPublicAPI: true })
+}
+
+export const del = <T>(url: string, options = {}, otherOptions?: IOtherOptions) => {
+  return request<T>(url, Object.assign({}, options, { method: 'DELETE' }), otherOptions)
+}
+
+export const delPublic = <T>(url: string, options = {}, otherOptions?: IOtherOptions) => {
+  return del<T>(url, options, { ...otherOptions, isPublicAPI: true })
+}
+
+export const patch = <T>(url: string, options = {}, otherOptions?: IOtherOptions) => {
+  return request<T>(url, Object.assign({}, options, { method: 'PATCH' }), otherOptions)
+}
+
+export const patchPublic = <T>(url: string, options = {}, otherOptions?: IOtherOptions) => {
+  return patch<T>(url, options, { ...otherOptions, isPublicAPI: true })
+}

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

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

+ 264 - 0
src/views/smart-ask-answer/assistant-2/dify/config.ts

@@ -0,0 +1,264 @@
+/* eslint-disable import/no-mutable-exports */
+// import { InputVarType } from '@/app/components/workflow/types'
+// import { AgentStrategy } from '@/types/app'
+// import { PromptRole } from '@/models/debug'
+
+export let apiPrefix = ''
+export let publicApiPrefix = ''
+
+// NEXT_PUBLIC_API_PREFIX=/console/api NEXT_PUBLIC_PUBLIC_API_PREFIX=/api npm run start
+if (process.env.NEXT_PUBLIC_API_PREFIX && process.env.NEXT_PUBLIC_PUBLIC_API_PREFIX) {
+  apiPrefix = process.env.NEXT_PUBLIC_API_PREFIX
+  publicApiPrefix = process.env.NEXT_PUBLIC_PUBLIC_API_PREFIX
+}
+else if (
+  globalThis.document?.body?.getAttribute('data-api-prefix')
+  && globalThis.document?.body?.getAttribute('data-pubic-api-prefix')
+) {
+  // Not build can not get env from process.env.NEXT_PUBLIC_ in browser https://nextjs.org/docs/basic-features/environment-variables#exposing-environment-variables-to-the-browser
+  apiPrefix = globalThis.document.body.getAttribute('data-api-prefix') as string
+  publicApiPrefix = globalThis.document.body.getAttribute('data-pubic-api-prefix') as string
+}
+else {
+  // const domainParts = globalThis.location?.host?.split('.');
+  // in production env, the host is dify.app . In other env, the host is [dev].dify.app
+  // const env = domainParts.length === 2 ? 'ai' : domainParts?.[0];
+  // apiPrefix = '/dify-api/console/api'
+  // publicApiPrefix = '/dify-api/api' // avoid browser private mode api cross origin
+  apiPrefix = (import.meta as any).env.VITE_DIFY_API_PREFIX
+  publicApiPrefix = (import.meta as any).env.VITE_DIFY_PUBLIC_API_PREFIX // avoid browser private mode api cross origin
+}
+
+export const API_PREFIX: string = apiPrefix
+export const PUBLIC_API_PREFIX: string = publicApiPrefix
+
+const EDITION = process.env.NEXT_PUBLIC_EDITION || globalThis.document?.body?.getAttribute('data-public-edition') || 'SELF_HOSTED'
+export const IS_CE_EDITION = EDITION === 'SELF_HOSTED'
+
+export const SUPPORT_MAIL_LOGIN = !!(process.env.NEXT_PUBLIC_SUPPORT_MAIL_LOGIN || globalThis.document?.body?.getAttribute('data-public-support-mail-login'))
+
+export const TONE_LIST = [
+  {
+    id: 1,
+    name: 'Creative',
+    config: {
+      temperature: 0.8,
+      top_p: 0.9,
+      presence_penalty: 0.1,
+      frequency_penalty: 0.1,
+    },
+  },
+  {
+    id: 2,
+    name: 'Balanced',
+    config: {
+      temperature: 0.5,
+      top_p: 0.85,
+      presence_penalty: 0.2,
+      frequency_penalty: 0.3,
+    },
+  },
+  {
+    id: 3,
+    name: 'Precise',
+    config: {
+      temperature: 0.2,
+      top_p: 0.75,
+      presence_penalty: 0.5,
+      frequency_penalty: 0.5,
+    },
+  },
+  {
+    id: 4,
+    name: 'Custom',
+  },
+]
+
+// export const DEFAULT_CHAT_PROMPT_CONFIG = {
+//   prompt: [
+//     {
+//       role: PromptRole.system,
+//       text: '',
+//     },
+//   ],
+// }
+
+export const DEFAULT_COMPLETION_PROMPT_CONFIG = {
+  prompt: {
+    text: '',
+  },
+  conversation_histories_role: {
+    user_prefix: '',
+    assistant_prefix: '',
+  },
+}
+
+export const getMaxToken = (modelId: string) => {
+  return (modelId === 'gpt-4' || modelId === 'gpt-3.5-turbo-16k') ? 8000 : 4000
+}
+
+export const LOCALE_COOKIE_NAME = 'locale'
+
+export const DEFAULT_VALUE_MAX_LEN = 48
+export const DEFAULT_PARAGRAPH_VALUE_MAX_LEN = 1000
+
+export const zhRegex = /^[\u4E00-\u9FA5]$/m
+export const emojiRegex = /^[\uD800-\uDBFF][\uDC00-\uDFFF]$/m
+export const emailRegex = /^[\w.!#$%&'*+\-/=?^{|}~]+@([\w-]+\.)+[\w-]{2,}$/m
+const MAX_ZN_VAR_NAME_LENGTH = 8
+const MAX_EN_VAR_VALUE_LENGTH = 30
+export const getMaxVarNameLength = (value: string) => {
+  if (zhRegex.test(value))
+    return MAX_ZN_VAR_NAME_LENGTH
+
+  return MAX_EN_VAR_VALUE_LENGTH
+}
+
+export const MAX_VAR_KEY_LENGTH = 30
+
+export const MAX_PROMPT_MESSAGE_LENGTH = 10
+
+export const VAR_ITEM_TEMPLATE = {
+  key: '',
+  name: '',
+  type: 'string',
+  max_length: DEFAULT_VALUE_MAX_LEN,
+  required: true,
+}
+
+// export const VAR_ITEM_TEMPLATE_IN_WORKFLOW = {
+//   variable: '',
+//   label: '',
+//   type: InputVarType.textInput,
+//   max_length: DEFAULT_VALUE_MAX_LEN,
+//   required: true,
+//   options: [],
+// }
+
+export const appDefaultIconBackground = '#D5F5F6'
+
+export const NEED_REFRESH_APP_LIST_KEY = 'needRefreshAppList'
+
+export const DATASET_DEFAULT = {
+  top_k: 4,
+  score_threshold: 0.8,
+}
+
+export const APP_PAGE_LIMIT = 10
+
+export const ANNOTATION_DEFAULT = {
+  score_threshold: 0.9,
+}
+
+export const MAX_TOOLS_NUM = 10
+
+// export const DEFAULT_AGENT_SETTING = {
+//   enabled: false,
+//   max_iteration: 5,
+//   strategy: AgentStrategy.functionCall,
+//   tools: [],
+// }
+
+export const DEFAULT_AGENT_PROMPT = {
+  chat: `Respond to the human as helpfully and accurately as possible.
+
+  {{instruction}}
+
+  You have access to the following tools:
+
+  {{tools}}
+
+  Use a json blob to specify a tool by providing an {{TOOL_NAME_KEY}} key (tool name) and an {{ACTION_INPUT_KEY}} key (tool input).
+  Valid "{{TOOL_NAME_KEY}}" values: "Final Answer" or {{tool_names}}
+
+  Provide only ONE action per $JSON_BLOB, as shown:
+
+  \`\`\`
+  {
+    "{{TOOL_NAME_KEY}}": $TOOL_NAME,
+    "{{ACTION_INPUT_KEY}}": $ACTION_INPUT
+  }
+  \`\`\`
+
+  Follow this format:
+
+  Question: input question to answer
+  Thought: consider previous and subsequent steps
+  Action:
+  \`\`\`
+  $JSON_BLOB
+  \`\`\`
+  Observation: action result
+  ... (repeat Thought/Action/Observation N times)
+  Thought: I know what to respond
+  Action:
+  \`\`\`
+  {
+    "{{TOOL_NAME_KEY}}": "Final Answer",
+    "{{ACTION_INPUT_KEY}}": "Final response to human"
+  }
+  \`\`\`
+
+  Begin! Reminder to ALWAYS respond with a valid json blob of a single action. Use tools if necessary. Respond directly if appropriate. Format is Action:\`\`\`$JSON_BLOB\`\`\`then Observation:.`,
+  completion: `
+  Respond to the human as helpfully and accurately as possible.
+
+{{instruction}}
+
+You have access to the following tools:
+
+{{tools}}
+
+Use a json blob to specify a tool by providing an {{TOOL_NAME_KEY}} key (tool name) and an {{ACTION_INPUT_KEY}} key (tool input).
+Valid "{{TOOL_NAME_KEY}}" values: "Final Answer" or {{tool_names}}
+
+Provide only ONE action per $JSON_BLOB, as shown:
+
+\`\`\`
+{{{{
+  "{{TOOL_NAME_KEY}}": $TOOL_NAME,
+  "{{ACTION_INPUT_KEY}}": $ACTION_INPUT
+}}}}
+\`\`\`
+
+Follow this format:
+
+Question: input question to answer
+Thought: consider previous and subsequent steps
+Action:
+\`\`\`
+$JSON_BLOB
+\`\`\`
+Observation: action result
+... (repeat Thought/Action/Observation N times)
+Thought: I know what to respond
+Action:
+\`\`\`
+{{{{
+  "{{TOOL_NAME_KEY}}": "Final Answer",
+  "{{ACTION_INPUT_KEY}}": "Final response to human"
+}}}}
+\`\`\`
+
+Begin! Reminder to ALWAYS respond with a valid json blob of a single action. Use tools if necessary. Respond directly if appropriate. Format is Action:\`\`\`$JSON_BLOB\`\`\`then Observation:.
+Question: {{query}}
+Thought: {{agent_scratchpad}}
+  `,
+}
+
+export const VAR_REGEX = /\{\{(#[a-zA-Z0-9_-]{1,50}(\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){1,10}#)\}\}/gi
+
+export const resetReg = () => VAR_REGEX.lastIndex = 0
+
+export let textGenerationTimeoutMs = 60000
+
+if (process.env.NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS && process.env.NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS !== '')
+  textGenerationTimeoutMs = parseInt(process.env.NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS)
+else if (globalThis.document?.body?.getAttribute('data-public-text-generation-timeout-ms') && globalThis.document.body.getAttribute('data-public-text-generation-timeout-ms') !== '')
+  textGenerationTimeoutMs = parseInt(globalThis.document.body.getAttribute('data-public-text-generation-timeout-ms') as string)
+
+export const TEXT_GENERATION_TIMEOUT_MS = textGenerationTimeoutMs
+
+export const DISABLE_UPLOAD_IMAGE_AS_ICON = process.env.NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON === 'true'
+
+export const FULL_DOC_PREVIEW_LENGTH = 50

+ 274 - 0
src/views/smart-ask-answer/assistant-2/dify/fetch.ts

@@ -0,0 +1,274 @@
+import {ElMessage, ElNotification} from "element-plus";
+
+const ContentType = {
+    json: 'application/json',
+    stream: 'text/event-stream',
+    audio: 'audio/mpeg',
+    form: 'application/x-www-form-urlencoded; charset=UTF-8',
+    download: 'application/octet-stream', // for download
+    upload: 'multipart/form-data', // for upload
+}
+const baseOptions = {
+    method: 'GET',
+    mode: 'cors',
+    credentials: 'include', // always send cookies、HTTP Basic authentication.
+    headers: new Headers({
+        'Content-Type': ContentType.json,
+    }),
+    redirect: 'follow',
+}
+const unicodeToChar = (text: string) => {
+    if (!text)
+        return ''
+
+    return text.replace(/\\u[0-9a-f]{4}/g, (_match, p1) => {
+        return String.fromCharCode(parseInt(p1, 16))
+    })
+}
+const handleStream = (
+    response,
+    onData,
+    onCompleted?,
+    onThought?,
+    onMessageEnd?,
+    onMessageReplace?,
+    onFile?,
+    onWorkflowStarted?,
+    onWorkflowFinished?,
+    onNodeStarted?,
+    onNodeFinished?,
+    onIterationStart?,
+    onIterationNext?,
+    onIterationFinish?,
+    onNodeRetry?,
+    onParallelBranchStarted?,
+    onParallelBranchFinished?,
+    onTextChunk?,
+    onTTSChunk?,
+    onTTSEnd?,
+    onTextReplace?,
+) => {
+    if (!response.ok)
+        throw new Error('Network response was not ok')
+
+    const reader = response.body?.getReader()
+    const decoder = new TextDecoder('utf-8')
+    let buffer = ''
+    let bufferObj
+    let isFirstMessage = true
+    const read = () => {
+        let hasError = false
+        reader?.read().then((result: any) => {
+            if (result.done) {
+                onCompleted && onCompleted()
+                return
+            }
+            buffer += decoder.decode(result.value, { stream: true })
+            const lines = buffer.split('\n')
+            try {
+                lines.forEach((message) => {
+                    if (message.startsWith('data: ')) { // check if it starts with data:
+                        try {
+                            bufferObj = JSON.parse(message.substring(6))// remove data: and parse as json
+                        }
+                        catch (e) {
+                            // mute handle message cut off
+                            onData('', isFirstMessage, {
+                                conversationId: bufferObj?.conversation_id,
+                                messageId: bufferObj?.message_id,
+                            })
+                            return
+                        }
+                        if (bufferObj.status === 400 || !bufferObj.event) {
+                            onData('', false, {
+                                conversationId: undefined,
+                                messageId: '',
+                                errorMessage: bufferObj?.message,
+                                errorCode: bufferObj?.code,
+                            })
+                            hasError = true
+                            onCompleted?.(true, bufferObj?.message)
+                            return
+                        }
+                        if (bufferObj.event === 'message' || bufferObj.event === 'agent_message') {
+                            // can not use format here. Because message is splitted.
+                            onData(unicodeToChar(bufferObj.answer), isFirstMessage, {
+                                conversationId: bufferObj.conversation_id,
+                                taskId: bufferObj.task_id,
+                                messageId: bufferObj.id,
+                            })
+                            isFirstMessage = false
+                        }
+                        else if (bufferObj.event === 'agent_thought') {
+                            onThought?.(bufferObj)
+                        }
+                        else if (bufferObj.event === 'message_file') {
+                            onFile?.(bufferObj)
+                        }
+                        else if (bufferObj.event === 'message_end') {
+                            onMessageEnd?.(bufferObj)
+                        }
+                        else if (bufferObj.event === 'message_replace') {
+                            onMessageReplace?.(bufferObj)
+                        }
+                        else if (bufferObj.event === 'workflow_started') {
+                            onWorkflowStarted?.(bufferObj)
+                        }
+                        else if (bufferObj.event === 'workflow_finished') {
+                            onWorkflowFinished?.(bufferObj)
+                        }
+                        else if (bufferObj.event === 'node_started') {
+                            onNodeStarted?.(bufferObj)
+                        }
+                        else if (bufferObj.event === 'node_finished') {
+                            onNodeFinished?.(bufferObj)
+                        }
+                        else if (bufferObj.event === 'iteration_started') {
+                            onIterationStart?.(bufferObj)
+                        }
+                        else if (bufferObj.event === 'iteration_next') {
+                            onIterationNext?.(bufferObj)
+                        }
+                        else if (bufferObj.event === 'iteration_completed') {
+                            onIterationFinish?.(bufferObj)
+                        }
+                        else if (bufferObj.event === 'node_retry') {
+                            onNodeRetry?.(bufferObj)
+                        }
+                        else if (bufferObj.event === 'parallel_branch_started') {
+                            onParallelBranchStarted?.(bufferObj)
+                        }
+                        else if (bufferObj.event === 'parallel_branch_finished') {
+                            onParallelBranchFinished?.(bufferObj)
+                        }
+                        else if (bufferObj.event === 'text_chunk') {
+                            onTextChunk?.(bufferObj)
+                        }
+                        else if (bufferObj.event === 'text_replace') {
+                            onTextReplace?.(bufferObj)
+                        }
+                        else if (bufferObj.event === 'tts_message') {
+                            onTTSChunk?.(bufferObj.message_id, bufferObj.audio, bufferObj.audio_type)
+                        }
+                        else if (bufferObj.event === 'tts_message_end') {
+                            onTTSEnd?.(bufferObj.message_id, bufferObj.audio)
+                        }
+                    }
+                })
+                buffer = lines[lines.length - 1]
+            } catch (e) {
+                onData('', false, {
+                    conversationId: undefined,
+                    messageId: '',
+                    errorMessage: `${e}`,
+                })
+                hasError = true
+                onCompleted?.(true, e as string)
+                return
+            }
+            if (!hasError) {
+                read()
+            }
+        })
+    }
+    read()
+}
+export const ssePost = (url, fetchOptions, otherOptions,) => {
+    const {
+        onData,
+        onCompleted,
+        onThought,
+        onFile,
+        onMessageEnd,
+        onMessageReplace,
+        onWorkflowStarted,
+        onWorkflowFinished,
+        onNodeStarted,
+        onNodeFinished,
+        onIterationStart,
+        onIterationNext,
+        onIterationFinish,
+        onNodeRetry,
+        onParallelBranchStarted,
+        onParallelBranchFinished,
+        onTextChunk,
+        onTTSChunk,
+        onTTSEnd,
+        onTextReplace,
+        onError,
+        getAbortController,
+    } = otherOptions
+    const abortController = new AbortController()
+
+    const options = Object.assign({}, baseOptions, {
+        method: 'POST',
+        signal: abortController.signal,
+    }, fetchOptions)
+
+    const contentType = options.headers.get('Content-Type')
+    if (!contentType)
+        options.headers.set('Content-Type', ContentType.json)
+
+    getAbortController?.(abortController)
+
+    const { body } = options
+    if (body)
+        options.body = JSON.stringify(body)
+
+    const accessToken = localStorage.getItem('console_token')
+    options.headers.set('Authorization', `Bearer ${accessToken}`)
+    globalThis.fetch(url, options).then((res) => {
+        if (!/^(2|3)\d{2}$/.test(String(res.status))) {
+            if (res.status === 401) {
+                ElMessage.error('状态码错误,' + res)
+                // refreshAccessTokenOrRelogin(TIME_OUT).then(() => {
+                //     ssePost(url, fetchOptions, otherOptions)
+                // }).catch(() => {
+                //     res.json().then((data: any) => {
+                //         if (isPublicAPI) {
+                //             if (data.code === 'web_sso_auth_required')
+                //                 requiredWebSSOLogin()
+                //
+                //             if (data.code === 'unauthorized') {
+                //                 removeAccessToken()
+                //                 globalThis.location.reload()
+                //             }
+                //         }
+                //     })
+                // })
+            }
+            else {
+                res.json().then((data) => {
+                    ElNotification({
+                        message: data.message || 'Server Error',
+                        type: 'error',
+                    })
+                })
+                onError?.('Server Error')
+            }
+            return
+        }
+        return handleStream(res, (str: string, isFirstMessage: boolean, moreInfo) => {
+            if (moreInfo.errorMessage) {
+                onError?.(moreInfo.errorMessage, moreInfo.errorCode)
+                // TypeError: Cannot assign to read only property ... will happen in page leave, so it should be ignored.
+                if (moreInfo.errorMessage !== 'AbortError: The user aborted a request.' && !moreInfo.errorMessage.includes('TypeError: Cannot assign to read only property')) {
+                    ElNotification({
+                        message: moreInfo.errorMessage,
+                        type: 'error',
+                    })
+                }
+                return
+            }
+            onData?.(str, isFirstMessage, moreInfo)
+        }, onCompleted, onThought, onMessageEnd, onMessageReplace, onFile, onWorkflowStarted, onWorkflowFinished, onNodeStarted, onNodeFinished, onIterationStart, onIterationNext, onIterationFinish, onNodeRetry, onParallelBranchStarted, onParallelBranchFinished, onTextChunk, onTTSChunk, onTTSEnd, onTextReplace)
+    }).catch((e) => {
+        if (e.toString() !== 'AbortError: The user aborted a request.' && !e.toString().errorMessage.includes('TypeError: Cannot assign to read only property')) {
+            ElNotification({
+                message: e,
+                type: 'error',
+            })
+        }
+        onError?.(e)
+    })
+}

+ 92 - 0
src/views/smart-ask-answer/assistant-2/dify/refresh-token.ts

@@ -0,0 +1,92 @@
+import { apiPrefix } from './config'
+import { fetchWithRetry } from './utils'
+
+const LOCAL_STORAGE_KEY = 'is_other_tab_refreshing'
+
+let isRefreshing = false
+function waitUntilTokenRefreshed() {
+  return new Promise<void>((resolve, reject) => {
+    function _check() {
+      const isRefreshingSign = globalThis.localStorage.getItem(LOCAL_STORAGE_KEY)
+      if ((isRefreshingSign && isRefreshingSign === '1') || isRefreshing) {
+        setTimeout(() => {
+          _check()
+        }, 1000)
+      }
+      else {
+        resolve()
+      }
+    }
+    _check()
+  })
+}
+
+const isRefreshingSignAvailable = function (delta: number) {
+  const nowTime = new Date().getTime()
+  const lastTime = globalThis.localStorage.getItem('last_refresh_time') || '0'
+  return nowTime - parseInt(lastTime) <= delta
+}
+
+// only one request can send
+async function getNewAccessToken(timeout: number): Promise<void> {
+  try {
+    const isRefreshingSign = globalThis.localStorage.getItem(LOCAL_STORAGE_KEY)
+    if ((isRefreshingSign && isRefreshingSign === '1' && isRefreshingSignAvailable(timeout)) || isRefreshing) {
+      await waitUntilTokenRefreshed()
+    }
+    else {
+      isRefreshing = true
+      globalThis.localStorage.setItem(LOCAL_STORAGE_KEY, '1')
+      globalThis.localStorage.setItem('last_refresh_time', new Date().getTime().toString())
+      globalThis.addEventListener('beforeunload', releaseRefreshLock)
+      const refresh_token = globalThis.localStorage.getItem('refresh_token')
+
+      // Do not use baseFetch to refresh tokens.
+      // If a 401 response occurs and baseFetch itself attempts to refresh the token,
+      // it can lead to an infinite loop if the refresh attempt also returns 401.
+      // To avoid this, handle token refresh separately in a dedicated function
+      // that does not call baseFetch and uses a single retry mechanism.
+      const [error, ret] = await fetchWithRetry(globalThis.fetch(`${apiPrefix}/refresh-token`, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json;utf-8',
+        },
+        body: JSON.stringify({ refresh_token }),
+      }))
+      if (error) {
+        return Promise.reject(error)
+      }
+      else {
+        if (ret.status === 401)
+          return Promise.reject(ret)
+
+        const { data } = await ret.json()
+        globalThis.localStorage.setItem('console_token', data.access_token)
+        globalThis.localStorage.setItem('refresh_token', data.refresh_token)
+      }
+    }
+  }
+  catch (error) {
+    console.error(error)
+    return Promise.reject(error)
+  }
+  finally {
+    releaseRefreshLock()
+  }
+}
+
+function releaseRefreshLock() {
+  if (isRefreshing) {
+    isRefreshing = false
+    globalThis.localStorage.removeItem(LOCAL_STORAGE_KEY)
+    globalThis.localStorage.removeItem('last_refresh_time')
+    globalThis.removeEventListener('beforeunload', releaseRefreshLock)
+  }
+}
+
+export async function refreshAccessTokenOrRelogin(timeout: number) {
+  return Promise.race([new Promise<void>((resolve, reject) => setTimeout(() => {
+    releaseRefreshLock()
+    reject(new Error('request timeout'))
+  }, timeout)), getNewAccessToken(timeout)])
+}

+ 217 - 0
src/views/smart-ask-answer/assistant-2/dify/share.ts

@@ -0,0 +1,217 @@
+import {
+  del as consoleDel, get as consoleGet, patch as consolePatch, post as consolePost,
+  delPublic as del, getPublic as get, patchPublic as patch, postPublic as post, ssePost,
+} from './base'
+
+function getAction(action: 'get' | 'post' | 'del' | 'patch', isInstalledApp: boolean) {
+  switch (action) {
+    case 'get':
+      return isInstalledApp ? consoleGet : get
+    case 'post':
+      return isInstalledApp ? consolePost : post
+    case 'patch':
+      return isInstalledApp ? consolePatch : patch
+    case 'del':
+      return isInstalledApp ? consoleDel : del
+  }
+}
+
+export function getUrl(url: string, isInstalledApp: boolean, installedAppId: string) {
+  return isInstalledApp ? `installed-apps/${installedAppId}/${url.startsWith('/') ? url.slice(1) : url}` : url
+}
+
+export const sendChatMessage = async (body: Record<string, any>, { onData, onCompleted, onThought, onFile, onError, getAbortController, onMessageEnd, onMessageReplace, onTTSChunk, onTTSEnd }: {
+  onData
+  onCompleted
+  onFile
+  onThought
+  onError
+  onMessageEnd?
+  onMessageReplace?
+  getAbortController?: (abortController: AbortController) => void
+  onTTSChunk?
+  onTTSEnd?
+}, isInstalledApp: boolean, installedAppId = '') => {
+  return ssePost(getUrl('chat-messages', isInstalledApp, installedAppId), {
+    body: {
+      ...body,
+      response_mode: 'streaming',
+    },
+  }, { onData, onCompleted, onThought, onFile, isPublicAPI: !isInstalledApp, onError, getAbortController, onMessageEnd, onMessageReplace, onTTSChunk, onTTSEnd })
+}
+
+export const stopChatMessageResponding = async (appId: string, taskId: string, isInstalledApp: boolean, installedAppId = '') => {
+  return getAction('post', isInstalledApp)(getUrl(`chat-messages/${taskId}/stop`, isInstalledApp, installedAppId))
+}
+
+export const sendCompletionMessage = async (body: Record<string, any>, { onData, onCompleted, onError, onMessageReplace }: {
+  onData
+  onCompleted
+  onError
+  onMessageReplace
+}, isInstalledApp: boolean, installedAppId = '') => {
+  return ssePost(getUrl('completion-messages', isInstalledApp, installedAppId), {
+    body: {
+      ...body,
+      response_mode: 'streaming',
+    },
+  }, { onData, onCompleted, isPublicAPI: !isInstalledApp, onError, onMessageReplace })
+}
+
+export const sendWorkflowMessage = async (
+  body: Record<string, any>,
+  {
+    onWorkflowStarted,
+    onNodeStarted,
+    onNodeFinished,
+    onWorkflowFinished,
+    onIterationStart,
+    onIterationNext,
+    onIterationFinish,
+    onTextChunk,
+    onTextReplace,
+  }: {
+    onWorkflowStarted
+    onNodeStarted
+    onNodeFinished
+    onWorkflowFinished
+    onIterationStart
+    onIterationNext
+    onIterationFinish
+    onTextChunk
+    onTextReplace
+  },
+  isInstalledApp: boolean,
+  installedAppId = '',
+) => {
+  return ssePost(getUrl('workflows/run', isInstalledApp, installedAppId), {
+    body: {
+      ...body,
+      response_mode: 'streaming',
+    },
+  }, { onNodeStarted, onWorkflowStarted, onWorkflowFinished, isPublicAPI: !isInstalledApp, onNodeFinished, onIterationStart, onIterationNext, onIterationFinish, onTextChunk, onTextReplace })
+}
+
+export const fetchAppInfo = async () => {
+  return get('/site')
+}
+
+export const fetchConversations = async (isInstalledApp: boolean, installedAppId = '', last_id?: string, pinned?: boolean, limit?: number) => {
+  return getAction('get', isInstalledApp)(getUrl('conversations', isInstalledApp, installedAppId), { params: { ...{ limit: limit || 20 }, ...(last_id ? { last_id } : {}), ...(pinned !== undefined ? { pinned } : {}) } })
+}
+
+export const pinConversation = async (isInstalledApp: boolean, installedAppId = '', id: string) => {
+  return getAction('patch', isInstalledApp)(getUrl(`conversations/${id}/pin`, isInstalledApp, installedAppId))
+}
+
+export const unpinConversation = async (isInstalledApp: boolean, installedAppId = '', id: string) => {
+  return getAction('patch', isInstalledApp)(getUrl(`conversations/${id}/unpin`, isInstalledApp, installedAppId))
+}
+
+export const delConversation = async (isInstalledApp: boolean, installedAppId = '', id: string) => {
+  return getAction('del', isInstalledApp)(getUrl(`conversations/${id}`, isInstalledApp, installedAppId))
+}
+
+export const renameConversation = async (isInstalledApp: boolean, installedAppId = '', id: string, name: string) => {
+  return getAction('post', isInstalledApp)(getUrl(`conversations/${id}/name`, isInstalledApp, installedAppId), { body: { name } })
+}
+
+export const generationConversationName = async (isInstalledApp: boolean, installedAppId = '', id: string) => {
+  return getAction('post', isInstalledApp)(getUrl(`conversations/${id}/name`, isInstalledApp, installedAppId), { body: { auto_generate: true } })
+}
+
+export const fetchChatList = async (conversationId: string, isInstalledApp: boolean, installedAppId = '') => {
+  return getAction('get', isInstalledApp)(getUrl('messages', isInstalledApp, installedAppId), { params: { conversation_id: conversationId, limit: 20, last_id: '' } }) as any
+}
+
+// Abandoned API interface
+// export const fetchAppVariables = async () => {
+//   return get(`variables`)
+// }
+
+// init value. wait for server update
+export const fetchAppParams = async (isInstalledApp: boolean, installedAppId = '') => {
+  return (getAction('get', isInstalledApp))(getUrl('parameters', isInstalledApp, installedAppId))
+}
+
+export const fetchSystemFeatures = async () => {
+  return (getAction('get', false))(getUrl('system-features', false, ''))
+}
+
+export const fetchWebSAMLSSOUrl = async (appCode: string, redirectUrl: string) => {
+  return (getAction('get', false))(getUrl('/enterprise/sso/saml/login', false, ''), {
+    params: {
+      app_code: appCode,
+      redirect_url: redirectUrl,
+    },
+  }) as Promise<{ url: string }>
+}
+
+export const fetchWebOIDCSSOUrl = async (appCode: string, redirectUrl: string) => {
+  return (getAction('get', false))(getUrl('/enterprise/sso/oidc/login', false, ''), {
+    params: {
+      app_code: appCode,
+      redirect_url: redirectUrl,
+    },
+
+  }) as Promise<{ url: string }>
+}
+
+export const fetchWebOAuth2SSOUrl = async (appCode: string, redirectUrl: string) => {
+  return (getAction('get', false))(getUrl('/enterprise/sso/oauth2/login', false, ''), {
+    params: {
+      app_code: appCode,
+      redirect_url: redirectUrl,
+    },
+  }) as Promise<{ url: string }>
+}
+
+export const fetchAppMeta = async (isInstalledApp: boolean, installedAppId = '') => {
+  return (getAction('get', isInstalledApp))(getUrl('meta', isInstalledApp, installedAppId))
+}
+
+export const updateFeedback = async ({ url, body }: { url: string; body }, isInstalledApp: boolean, installedAppId = '') => {
+  return (getAction('post', isInstalledApp))(getUrl(url, isInstalledApp, installedAppId), { body })
+}
+
+export const fetchMoreLikeThis = async (messageId: string, isInstalledApp: boolean, installedAppId = '') => {
+  return (getAction('get', isInstalledApp))(getUrl(`/messages/${messageId}/more-like-this`, isInstalledApp, installedAppId), {
+    params: {
+      response_mode: 'blocking',
+    },
+  })
+}
+
+export const saveMessage = (messageId: string, isInstalledApp: boolean, installedAppId = '') => {
+  return (getAction('post', isInstalledApp))(getUrl('/saved-messages', isInstalledApp, installedAppId), { body: { message_id: messageId } })
+}
+
+export const fetchSavedMessage = async (isInstalledApp: boolean, installedAppId = '') => {
+  return (getAction('get', isInstalledApp))(getUrl('/saved-messages', isInstalledApp, installedAppId))
+}
+
+export const removeMessage = (messageId: string, isInstalledApp: boolean, installedAppId = '') => {
+  return (getAction('del', isInstalledApp))(getUrl(`/saved-messages/${messageId}`, isInstalledApp, installedAppId))
+}
+
+export const fetchSuggestedQuestions = (messageId: string, isInstalledApp: boolean, installedAppId = '') => {
+  return (getAction('get', isInstalledApp))(getUrl(`/messages/${messageId}/suggested-questions`, isInstalledApp, installedAppId))
+}
+
+export const audioToText = (url: string, isPublicAPI: boolean, body: FormData) => {
+  return (getAction('post', !isPublicAPI))(url, { body }, { bodyStringify: false, deleteContentType: true }) as Promise<{ text: string }>
+}
+
+export const textToAudio = (url: string, isPublicAPI: boolean, body: FormData) => {
+  return (getAction('post', !isPublicAPI))(url, { body }, { bodyStringify: false, deleteContentType: true }) as Promise<{ data: string }>
+}
+
+export const textToAudioStream = (url: string, isPublicAPI: boolean, header: { content_type: string }, body: { streaming: boolean; voice?: string; message_id?: string; text?: string | null | undefined }) => {
+  return (getAction('post', !isPublicAPI))(url, { body, header }, { needAllResponseContent: true })
+}
+
+export const fetchAccessToken = async (appCode: string) => {
+  const headers = new Headers()
+  headers.append('X-App-Code', appCode)
+  return get('/passport', { headers }) as Promise<{ access_token: string }>
+}

+ 57 - 0
src/views/smart-ask-answer/assistant-2/dify/utils.ts

@@ -0,0 +1,57 @@
+import { escape } from 'lodash-es'
+
+export const sleep = (ms: number) => {
+  return new Promise(resolve => setTimeout(resolve, ms))
+}
+
+export async function asyncRunSafe<T = any>(fn: Promise<T>): Promise<[Error] | [null, T]> {
+  try {
+    return [null, await fn]
+  }
+  catch (e: any) {
+    return [e || new Error('unknown error')]
+  }
+}
+
+export const getTextWidthWithCanvas = (text: string, font?: string) => {
+  const canvas = document.createElement('canvas')
+  const ctx = canvas.getContext('2d')
+  if (ctx) {
+    ctx.font = font ?? '12px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'
+    return Number(ctx.measureText(text).width.toFixed(2))
+  }
+  return 0
+}
+
+const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'
+
+export function randomString(length: number) {
+  let result = ''
+  for (let i = length; i > 0; --i) result += chars[Math.floor(Math.random() * chars.length)]
+  return result
+}
+
+export const getPurifyHref = (href: string) => {
+  if (!href)
+    return ''
+
+  return escape(href)
+}
+
+export async function fetchWithRetry<T = any>(fn: Promise<T>, retries = 3): Promise<[Error] | [null, T]> {
+  const [error, res] = await asyncRunSafe(fn)
+  if (error) {
+    if (retries > 0) {
+      const res = await fetchWithRetry(fn, retries - 1)
+      return res
+    }
+    else {
+      if (error instanceof Error)
+        return [error]
+      return [new Error('unknown error')]
+    }
+  }
+  else {
+    return [null, res]
+  }
+}

BIN
src/views/smart-ask-answer/assistant-2/imgs/ask-bg.png


BIN
src/views/smart-ask-answer/assistant-2/imgs/ask-del.png


BIN
src/views/smart-ask-answer/assistant-2/imgs/ask-icon-2.png


BIN
src/views/smart-ask-answer/assistant-2/imgs/ask-icon.png


BIN
src/views/smart-ask-answer/assistant-2/imgs/audio.png


BIN
src/views/smart-ask-answer/assistant-2/imgs/avatar.png


BIN
src/views/smart-ask-answer/assistant-2/imgs/bad.png


BIN
src/views/smart-ask-answer/assistant-2/imgs/bg.png


BIN
src/views/smart-ask-answer/assistant-2/imgs/clear.png


BIN
src/views/smart-ask-answer/assistant-2/imgs/copy.png


BIN
src/views/smart-ask-answer/assistant-2/imgs/good.png


BIN
src/views/smart-ask-answer/assistant-2/imgs/hot-bg.png


BIN
src/views/smart-ask-answer/assistant-2/imgs/hot-icon.png


BIN
src/views/smart-ask-answer/assistant-2/imgs/icon-1.png


BIN
src/views/smart-ask-answer/assistant-2/imgs/icon-2.png


BIN
src/views/smart-ask-answer/assistant-2/imgs/icon-3.png


BIN
src/views/smart-ask-answer/assistant-2/imgs/left-arrow.png


BIN
src/views/smart-ask-answer/assistant-2/imgs/logo-1.png


BIN
src/views/smart-ask-answer/assistant-2/imgs/none.png


BIN
src/views/smart-ask-answer/assistant-2/imgs/play.png


BIN
src/views/smart-ask-answer/assistant-2/imgs/send.png


Datei-Diff unterdrückt, da er zu groß ist
+ 642 - 0
src/views/smart-ask-answer/assistant-2/index.vue


+ 3 - 1
src/views/smart-ask-answer/assistant/component/answer/index.vue

@@ -171,10 +171,12 @@ const onCopy = (text) => {
   .answer-content {
     flex: 1;
     overflow: hidden;
-    margin-top: 16px;
     font-weight: 400;
     font-size: 16px;
     color: #111111;
+    .answer-content-text {
+      margin-top: 16px;
+    }
     .answer-content-question {
       margin-top: 10px;
       display: flex;

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

@@ -26,7 +26,7 @@ const parsedContent = computed(() => {
 
 <style lang="scss" scoped>
 .think-process {
-  margin: 1rem 0;
+  margin: 0.35rem 0;
   border-left: 3px solid #e5e7eb;
   padding-left: 1rem;
   .think-details {

+ 10 - 1
src/views/smart-ask-answer/assistant/index.vue

@@ -169,7 +169,7 @@
 </template>
 
 <script setup lang="ts">
-import {computed, getCurrentInstance, onMounted, reactive, ref, watch} from "vue";
+import {computed, getCurrentInstance, onBeforeMount, onMounted, reactive, ref, watch} from "vue";
 import CzrDialog from "@/components/czr-ui/CzrDialog.vue";
 import chatCom from './chat.vue'
 import {
@@ -177,6 +177,7 @@ import {
   cmsAiQueryHotThemelist,
   staticConQueryQuestionslist, matterQueryMatterlist, policyInfoQueryPolicyInfolist
 } from "@/views/smart-ask-answer/assistant/cms/api";
+import chatLogo from "@/views/smart-ask-answer/assistant/imgs/avatar.png";
 
 const askSplit = 'd95839a9-1b75-8ba3-06e7-8fc46aff233b'
 const askKey = 'assistant_askList'
@@ -298,6 +299,14 @@ const getSuggest = (json) => {
   state.adviseList.shixiang = json.shixiang || []
   state.adviseList.loading = false
 }
+onBeforeMount(() => {
+  document.title = "“i口岸”通关小助理";
+  let link: any = document.querySelector("link[rel*='icon']") || document.createElement('link');
+  link.type = 'image/x-icon';
+  link.rel = 'shortcut icon';
+  link.href = chatLogo;
+  document.head.appendChild(link);
+})
 onMounted(() => {
   initTheme()
   initRelation()

+ 29 - 0
yarn.lock

@@ -859,6 +859,11 @@ big.js@^5.2.2:
   resolved "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
   integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==
 
+bignumber.js@^9.0.0:
+  version "9.2.1"
+  resolved "https://registry.npmmirror.com/bignumber.js/-/bignumber.js-9.2.1.tgz#3ad0854ad933560a25bbc7c93bc3b7ea6edcad85"
+  integrity sha512-+NzaKgOUvInq9TIUZ1+DRspzf/HApkCwD4btfuasFTdrfnOxqx853TgDpMolp+uv4RpRp7bPcEU2zKr9+fRmyw==
+
 birpc@^0.2.19:
   version "0.2.19"
   resolved "https://registry.npmmirror.com/birpc/-/birpc-0.2.19.tgz#cdd183a4a70ba103127d49765b4a71349da5a0ca"
@@ -1052,6 +1057,11 @@ cors@^2.8.5:
     object-assign "^4"
     vary "^1"
 
+crypto-js@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.npmmirror.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631"
+  integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==
+
 css-select@^4.1.3:
   version "4.3.0"
   resolved "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz#db7129b2846662fd8628cfc496abb2b59e41529b"
@@ -2121,6 +2131,18 @@ js-base64@^2.1.9:
   resolved "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz#f4e686c5de1ea1f867dbcad3d46d969428df98c4"
   integrity sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==
 
+jsencrypt@^3.3.2:
+  version "3.3.2"
+  resolved "https://registry.npmmirror.com/jsencrypt/-/jsencrypt-3.3.2.tgz#b0f1a2278810c7ba1cb8957af11195354622df7c"
+  integrity sha512-arQR1R1ESGdAxY7ZheWr12wCaF2yF47v5qpB76TtV64H1pyGudk9Hvw8Y9tb/FiTIaaTRUyaSnm5T/Y53Ghm/A==
+
+json-bigint@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.npmmirror.com/json-bigint/-/json-bigint-1.0.0.tgz#ae547823ac0cad8398667f8cd9ef4730f5b01ff1"
+  integrity sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==
+  dependencies:
+    bignumber.js "^9.0.0"
+
 json5@^1.0.1:
   version "1.0.2"
   resolved "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593"
@@ -2570,6 +2592,13 @@ punycode.js@^2.3.1:
   resolved "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
   integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
 
+qs@^6.14.0:
+  version "6.14.0"
+  resolved "https://registry.npmmirror.com/qs/-/qs-6.14.0.tgz#c63fa40680d2c5c941412a0e899c89af60c0a930"
+  integrity sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==
+  dependencies:
+    side-channel "^1.1.0"
+
 query-string@^4.3.2:
   version "4.3.4"
   resolved "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb"