Selaa lähdekoodia

数字人问答、初始问题,弹幕消息

liyanbo 10 kuukautta sitten
vanhempi
sitoutus
045f087b0a
5 muutettua tiedostoa jossa 395 lisäystä ja 36 poistoa
  1. 7 0
      package-lock.json
  2. 1 0
      package.json
  3. 36 2
      src/api/questions.js
  4. 8 7
      src/views/AILaboratory.vue
  5. 343 27
      src/views/AIQuestions.vue

+ 7 - 0
package-lock.json

@@ -8,6 +8,7 @@
       "name": "vite-project",
       "version": "0.0.0",
       "dependencies": {
+        "@microsoft/fetch-event-source": "^2.0.1",
         "axios": "^1.10.0",
         "element-plus": "^2.10.2",
         "router": "^2.2.0",
@@ -542,6 +543,12 @@
       "integrity": "sha512-gKYheCylLIedI+CSZoDtGkFV9YEBxRRVcfCH7OfAqh4TyUyRjEE6WVE/aXDXX0p8BIe/QgLcaAoI0220KRRFgg==",
       "license": "MIT"
     },
+    "node_modules/@microsoft/fetch-event-source": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz",
+      "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==",
+      "license": "MIT"
+    },
     "node_modules/@parcel/watcher": {
       "version": "2.5.1",
       "resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.5.1.tgz",

+ 1 - 0
package.json

@@ -9,6 +9,7 @@
     "preview": "vite preview"
   },
   "dependencies": {
+    "@microsoft/fetch-event-source": "^2.0.1",
     "axios": "^1.10.0",
     "element-plus": "^2.10.2",
     "router": "^2.2.0",

+ 36 - 2
src/api/questions.js

@@ -1,10 +1,44 @@
 import axios from "@/utils/request";
 
+import { fetchEventSource } from '@microsoft/fetch-event-source'
+
 // 数字人对话框
 export function CreateDialogue (data){
   return axios({
     url: "bjdxWeb/ai/create-dialogue",
     method: 'post',
-    data 
+    data
+  })
+}
+
+// 发送 Stream 消息
+// 为什么不用 axios 呢?因为它不支持 SSE 调用
+export async function sendChatMessageStream (
+    conversationId,
+    content,
+    ctrl,
+    enableContext,
+    onMessage,
+    onError,
+    onClose
+) {
+
+
+  return fetchEventSource(`http://127.0.0.1:8080/admin-api/bjdxWeb/ai/dialogue-send-stream`, {
+    method: 'post',
+    headers: {
+      'Content-Type': 'application/json',
+      // Authorization: `Bearer b55bd67fba3e4bb49608168f078fde63`
+    },
+    openWhenHidden: true,
+    body: JSON.stringify({
+      conversationId,
+      content,
+      useContext: enableContext
+    }),
+    onmessage: onMessage,
+    onerror: onError,
+    onclose: onClose,
+    signal: ctrl.signal
   })
-}
+}

+ 8 - 7
src/views/AILaboratory.vue

@@ -64,6 +64,7 @@ const goBack = () => {
   router.go(-1)
 }
 // 引入
+import NumberPeople00 from '@/assets/images/xiaozhi.png'
 import NumberPeople01 from '@/assets/images/number-people01.png'
 import NumberPeople02 from '@/assets/images/number-people02.png'
 import NumberPeople03 from '@/assets/images/number-people03.png'
@@ -71,12 +72,12 @@ import NumberPeople04 from '@/assets/images/number-people04.png'
 import NumberPeople05 from '@/assets/images/number-people05.png'
 // 渲染数字人老师及图片
 const peopleList = ref([
-  { id: 10, name: '小智', image: NumberPeople01 },
-  { id: 21, name: '鲁迅', image: NumberPeople01 },
-  { id: 22, name: '门捷列夫', image: NumberPeople02 },
-  { id: 19, name: '牛顿', image: NumberPeople03 },
-  { id: 23, name: '特斯拉', image: NumberPeople04 },
-  { id: 18, name: '李白', image: NumberPeople05 }
+  { id: 10, name: '小智', image: NumberPeople00, message: '你好!'  },
+  { id: 21, name: '鲁迅', image: NumberPeople01 , message: '你好!' },
+  { id: 22, name: '门捷列夫', image: NumberPeople02 , message: '你好!' },
+  { id: 19, name: '牛顿', image: NumberPeople03 , message: '你好!' },
+  { id: 23, name: '特斯拉', image: NumberPeople04 , message: '你好!' },
+  { id: 18, name: '李白', image: NumberPeople05 , message: '你好!' }
 ])
 
 // 跳转页面携带名字和人物形象
@@ -84,7 +85,7 @@ const navigateToAIQuestions = (person) => {
   router.push({ 
     // 跳转问答页面
     path: '/ai-questions', 
-    query: {  id: person.id, name: person.name, image: person.image }
+    query: {  id: person.id, name: person.name, image: person.image, message: person.message }
   }); 
 };
 

+ 343 - 27
src/views/AIQuestions.vue

@@ -18,23 +18,28 @@
         <div class="chat-dialog">
           <!-- 对话消息列表 -->
           <div class="message-list">
-            <!-- AI消息 -->
-            <div class="ai-message">
-              你好小友,我是鲁迅。<br />
-              我是个写字的人,写下所见,写下所思,哪怕无人理解,哪怕只是逆风低语。
-              但只要还有一个人愿意思考,事情便还不算太坏。
+            <div v-for="(item, index) in messageList" :key="index">
+
+              <!-- AI消息 -->
+              <div class="ai-message" v-if="item.type !== 'user'">
+                {{ item.content }}
+              </div>
+
+              <!-- 用户消息 -->
+              <div class="user-message" v-if="item.type === 'user'">
+                {{ item.content }}
+              </div>
+
             </div>
-            <!-- 用户消息 -->
-            <div class="user-message">你好</div>
           </div>
           <!-- 输入框和发送按钮 -->
           <div class="input-section">
             <input
               type="text"
-              v-model="inputMessage"
+              v-model="prompt"
               placeholder="问我任何问题..."
             />
-            <button @click="sendMessage">发送</button>
+            <button @click="handleSendByButton">发送</button>
           </div>
         </div>
       </div>
@@ -43,8 +48,8 @@
 </template>
 
 <script setup>
-import { ref, onMounted } from 'vue'
-import {CreateDialogue} from '@/api/questions.js'
+import { ref, onMounted, computed} from 'vue'
+import {CreateDialogue, sendChatMessageStream} from '@/api/questions.js'
 import { useRouter, useRoute } from 'vue-router'
 import {
   Document,
@@ -63,13 +68,7 @@ const route = useRoute()
 const personId = ref(route.query.id)
 const personName = ref(route.query.name)
 const personIntroduce = ref(route.query.message)
-
-// 智能问答
-CreateDialogue({roleId: personId.value}).then(res => {
-  console.log(res);
-}).catch(error => {
-  console.error('请求出错:', error);
-});
+const personImage = ref(route.query.image)
 
 
 // 渲染实验室携带的人物形象图片
@@ -81,16 +80,333 @@ onMounted(() => {
   }
 })
 
-// 消息列表和输入内容的响应式变量
-const messages = ref([])
-const inputMessage = ref('')
-// 发送消息函数
-const sendMessage = () => {
-  if (inputMessage.value.trim()) {
-    messages.value.push(inputMessage.value.trim())
-    inputMessage.value = ''
+// // 消息列表和输入内容的响应式变量
+// const messages = ref([])
+// const inputMessage = ref('')
+// // 发送消息函数
+// const sendMessage = () => {
+//   if (inputMessage.value.trim()) {
+//     messages.value.push(inputMessage.value.trim())
+//     inputMessage.value = ''
+//   }
+// }
+
+
+
+// 聊天对话
+const activeConversationModelPath= ref(null) // 选中的对话编号
+const activeConversationId = ref(null) // 选中的对话编号
+const activeConversation = ref(null) // 选中的 Conversation
+const conversationInProgress = ref(false) // 对话是否正在进行中。目前只有【发送】消息时,会更新为 true,避免切换对话、删除对话等操作,导致 stream 中断
+
+// 消息列表
+const messageRef = ref()
+const activeMessageList = ref([]) // 选中对话的消息列表
+const activeMessageListLoading = ref(false) // activeMessageList 是否正在加载中
+const activeMessageListLoadingTimer = ref() // activeMessageListLoading Timer 定时器。如果加载速度很快,就不进入加载中
+// 消息滚动
+const textSpeed = ref(50) // Typing speed in milliseconds
+const textRoleRunning = ref(false) // Typing speed in milliseconds
+
+// 发送消息输入框
+const isComposing = ref(false) // 判断用户是否在输入
+const conversationInAbortController = ref() // 对话进行中 abort 控制器(控制 stream 对话)
+const inputTimeout = ref() // 处理输入中回车的定时器
+const prompt = ref() // prompt
+const enableContext = ref(true) // 是否开启上下文
+// 接收 Stream 消息
+const receiveMessageFullText = ref('')
+const receiveMessageDisplayedText = ref('')
+
+
+// =========== 【聊天对话】相关 ===========
+
+/** 获取对话信息 */
+const getConversation = async (id) => {
+  if (!id) {
+    return
+  }
+  const conversation = ref({})
+  if (!conversation) {
+    return
+  }
+  conversation.systemMessage = personIntroduce.value
+  activeConversation.value = conversation
+  console.log("1111111",activeConversation.value,conversation,personIntroduce.value)
+  // activeConversationId.value = personId.value
+  activeConversationModelPath.value = personImage.value
+}
+
+
+// =========== 【发送消息】相关 ===========
+
+/** 处理来自 keydown 的发送消息 */
+const handleSendByKeydown = async (event) => {
+  // 判断用户是否在输入
+  if (isComposing.value) {
+    return
+  }
+  // 进行中不允许发送
+  if (conversationInProgress.value) {
+    return
+  }
+  const content = prompt.value?.trim()
+  if (event.key === 'Enter') {
+    if (event.shiftKey) {
+      // 插入换行
+      prompt.value += '\r\n'
+      event.preventDefault() // 防止默认的换行行为
+    } else {
+      // 发送消息
+      await doSendMessage(content)
+      event.preventDefault() // 防止默认的提交行为
+    }
+  }
+}
+
+/** 处理来自【发送】按钮的发送消息 */
+const handleSendByButton = () => {
+  doSendMessage(prompt.value?.trim())
+}
+
+/** 处理 prompt 输入变化 */
+const handlePromptInput = (event) => {
+  // 非输入法 输入设置为 true
+  if (!isComposing.value) {
+    // 回车 event data 是 null
+    if (event.data == null) {
+      return
+    }
+    isComposing.value = true
+  }
+  // 清理定时器
+  if (inputTimeout.value) {
+    clearTimeout(inputTimeout.value)
   }
+  // 重置定时器
+  inputTimeout.value = setTimeout(() => {
+    isComposing.value = false
+  }, 400)
+}
+// TODO注:是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑
+const onCompositionstart = () => {
+  isComposing.value = true
+}
+const onCompositionend = () => {
+  setTimeout(() => {
+    isComposing.value = false
+  }, 200)
 }
+
+/** 真正执行【发送】消息操作 */
+const doSendMessage = async (content) => {
+  // 校验
+  if (content.length < 1) {
+    console.error('发送失败,原因:内容为空!')
+    return
+  }
+
+  if (activeConversationId.value == null) {
+    console.error('还没创建对话,不能发送!')
+    return
+  }
+  // 清空输入框
+  prompt.value = ''
+  // 执行发送
+  await doSendMessageStream({
+    conversationId: activeConversationId.value,
+    content: content
+  })
+}
+
+/** 真正执行【发送】消息操作 */
+const doSendMessageStream = async (userMessage) => {
+
+  // 创建 AbortController 实例,以便中止请求
+  conversationInAbortController.value = new AbortController()
+  // 标记对话进行中
+  conversationInProgress.value = true
+  // 设置为空
+  receiveMessageFullText.value = ''
+
+  try {
+    // 1.1 先添加两个假数据,等 stream 返回再替换
+    activeMessageList.value.push({
+      id: -1,
+      conversationId: activeConversationId.value,
+      type: 'user',
+      content: userMessage.content,
+      createTime: new Date()
+    })
+    activeMessageList.value.push({
+      id: -2,
+      conversationId: activeConversationId.value,
+      type: 'assistant',
+      content: '思考中...',
+      createTime: new Date()
+    })
+
+    // 1.3 开始滚动
+    textRoll()
+
+    // 2. 发送 event stream
+    let isFirstChunk = true // 是否是第一个 chunk 消息段
+
+    await sendChatMessageStream(
+        userMessage.conversationId,
+        userMessage.content,
+        conversationInAbortController.value,
+        enableContext.value,
+        async (res) => {
+          const { code, data, msg } = JSON.parse(res.data)
+          if (code !== 0) {
+            console.log(`对话异常! ${msg}`)
+            return
+          }
+          // 如果内容为空,就不处理。
+          // if (data.receive.content === '') {
+          //   return
+          // }
+          receiveMessageFullText.value = receiveMessageFullText.value + data.receive.content
+          // 首次返回需要添加一个 message 到页面,后面的都是更新
+          if (isFirstChunk) {
+            isFirstChunk = false
+            // 弹出两个假数据
+            activeMessageList.value.pop()
+            activeMessageList.value.pop()
+            // 更新返回的数据
+            activeMessageList.value.push(data.send)
+            activeMessageList.value.push(data.receive)
+          }
+        },
+        (error) => {
+          console.log(`对话异常! ${error}`)
+          stopStream()
+          // 需要抛出异常,禁止重试
+          throw error
+        },
+        () => {
+          stopStream()
+        }
+    )
+  } catch {}
+}
+
+/** 停止 stream 流式调用 */
+const stopStream = async () => {
+  // tip:如果 stream 进行中的 message,就需要调用 controller 结束
+  if (conversationInAbortController.value) {
+    conversationInAbortController.value.abort()
+  }
+  // 设置为 false
+  conversationInProgress.value = false
+}
+
+
+/**
+ * 消息列表
+ *
+ * 和 {@link #getMessageList()} 的差异是,把 systemMessage 考虑进去
+ */
+const messageList = computed(() => {
+  if (activeMessageList.value.length > 0) {
+    return activeMessageList.value
+  }
+  // 没有消息时,如果有 systemMessage 则展示它
+  if (activeConversation.value?.systemMessage) {
+    return [
+      {
+        id: 0,
+        type: 'system',
+        content: activeConversation.value.systemMessage
+      }
+    ]
+  }
+  return []
+})
+
+// ============== 【消息滚动】相关 =============
+
+/** 滚动到 message 底部 */
+const scrollToBottom = async (isIgnore) => {
+  // if (messageRef.value) {
+    // messageRef.value.scrollToBottom(isIgnore)
+  // }
+}
+
+/** 自提滚动效果 */
+const textRoll = async () => {
+  let index = 0
+  try {
+    // 只能执行一次
+    if (textRoleRunning.value) {
+      return
+    }
+    // 设置状态
+    textRoleRunning.value = true
+    receiveMessageDisplayedText.value = ''
+    const task = async () => {
+      // 调整速度
+      const diff =
+          (receiveMessageFullText.value.length - receiveMessageDisplayedText.value.length) / 10
+      if (diff > 5) {
+        textSpeed.value = 10
+      } else if (diff > 2) {
+        textSpeed.value = 30
+      } else if (diff > 1.5) {
+        textSpeed.value = 50
+      } else {
+        textSpeed.value = 100
+      }
+      // 对话结束,就按 30 的速度
+      if (!conversationInProgress.value) {
+        textSpeed.value = 10
+      }
+
+      if (index < receiveMessageFullText.value.length) {
+        receiveMessageDisplayedText.value += receiveMessageFullText.value[index]
+        index++
+
+        // 更新 message
+        const lastMessage = activeMessageList.value[activeMessageList.value.length - 1]
+        lastMessage.content = receiveMessageDisplayedText.value
+        // 滚动到住下面
+        await scrollToBottom()
+        // 重新设置任务
+        timer = setTimeout(task, textSpeed.value)
+      } else {
+        // 不是对话中可以结束
+        if (!conversationInProgress.value) {
+          textRoleRunning.value = false
+          clearTimeout(timer)
+        } else {
+          // 重新设置任务
+          timer = setTimeout(task, textSpeed.value)
+        }
+      }
+    }
+    let timer = setTimeout(task, textSpeed.value)
+  } catch {}
+}
+
+
+/** 初始化 **/
+onMounted(async () => {
+  if(personId.value) {
+    // 智能问答
+    CreateDialogue({roleId: personId.value}).then(res => {
+      activeConversationId.value = res.data;
+    }).catch(error => {
+      console.error('请求出错:', error);
+    });
+
+    await getConversation(personId.value)
+  }
+
+  // 获取列表数据
+  // activeMessageListLoading.value = true
+
+})
 </script>
 
 <style scoped lang="scss">
@@ -188,7 +504,7 @@ const sendMessage = () => {
   padding: rpx(5);
   font-size: rpx(8);
   border-radius: rpx(5);
-  text-align: left; // 文字左对齐 
+  text-align: left; // 文字左对齐
 }
 .input-section {
   display: flex;