AIQuestions.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695
  1. <template>
  2. <!-- 数字人智能问答 -->
  3. <div class="home-container">
  4. <!-- 展开收起侧边栏 -->
  5. <div class="icon-expand">
  6. <el-icon
  7. @click="toggleDrawer"
  8. :style="{ color: drawerVisible ? 'white' : '#B6B0D8' }"
  9. >
  10. <component :is="drawerVisible ? Fold : Expand" />
  11. </el-icon>
  12. </div>
  13. <!-- 左侧折叠面板 -->
  14. <transition name="drawer-slide">
  15. <div class="left-group" v-if="drawerVisible">
  16. <el-row class="tac">
  17. <el-col :span="12">
  18. <el-menu
  19. default-active="2"
  20. class="el-menu-vertical-demo"
  21. @open="handleOpen"
  22. @close="handleClose"
  23. >
  24. <el-menu-item
  25. v-for="(item, index) in groupList"
  26. :key="index"
  27. @click="navigateToAI(item)"
  28. >
  29. <el-icon><component :is="item.icon" /></el-icon>
  30. {{ item.title }}
  31. </el-menu-item>
  32. </el-menu>
  33. </el-col>
  34. </el-row>
  35. </div>
  36. </transition>
  37. <!-- 原左侧折叠面板和右侧AI问答 -->
  38. <div class="content-wrapper">
  39. <div class="left-group2">
  40. <div class="title-box">
  41. <div class="box-icon" @click="goBack">
  42. <el-icon class="left-icon"><ArrowLeftBold /></el-icon>
  43. {{ personName }}
  44. </div>
  45. </div>
  46. <div class="selected-image">
  47. <img :src="selectedImage" alt="" />
  48. </div>
  49. </div>
  50. <!-- 右侧AI问答 -->
  51. <div class="number-people">
  52. <div class="content-box">
  53. <!-- AI对话框 -->
  54. <div class="chat-dialog">
  55. <!-- 对话消息列表 -->
  56. <div class="message-list">
  57. <div v-for="(item, index) in messageList" :key="index">
  58. <!-- AI消息 -->
  59. <div class="ai-message" v-if="item.type !== 'user'">
  60. <MarkdownView class="left-text" :content="item.content" />
  61. <!-- {{item.content}} -->
  62. </div>
  63. <!-- 用户消息 -->
  64. <div class="user-message" v-if="item.type === 'user'">
  65. {{ item.content }}
  66. </div>
  67. </div>
  68. </div>
  69. <!-- 输入框和发送按钮 -->
  70. <div class="input-section">
  71. <input
  72. type="text"
  73. v-model="prompt"
  74. placeholder="问我任何问题..."
  75. @keyup.enter="handleSendByKeydown"
  76. />
  77. <button @click="handleSendByButton">发送</button>
  78. </div>
  79. </div>
  80. </div>
  81. </div>
  82. </div>
  83. </div>
  84. </template>
  85. <script setup>
  86. import { ref, onMounted, computed } from 'vue'
  87. import { CreateDialogue, sendChatMessageStream } from '@/api/questions.js'
  88. import { useRouter, useRoute } from 'vue-router'
  89. import MarkdownView from '@/components/MarkdownView/index.vue'
  90. import {
  91. Document,
  92. Menu as IconMenu,
  93. Location,
  94. Setting,
  95. ArrowLeftBold,
  96. MagicStick,
  97. ChatLineRound,
  98. Fold,
  99. Expand,
  100. Picture,
  101. Tickets,
  102. Avatar
  103. } from '@element-plus/icons-vue'
  104. // 添加抽屉显示状态
  105. const drawerVisible = ref(true)
  106. // 添加切换抽屉显示状态的函数
  107. const toggleDrawer = () => {
  108. drawerVisible.value = !drawerVisible.value
  109. }
  110. // 渲染侧边栏
  111. const groupList = ref([
  112. { icon: ChatLineRound, title: '智能问答' },
  113. { icon: MagicStick, title: '智能绘画' },
  114. { icon: Avatar, title: '数字人老师' }
  115. ])
  116. // 跳转智能问答
  117. const navigateToAI = group => {
  118. if (group.title === '智能问答') {
  119. let person = {
  120. id: 10,
  121. name: '小智',
  122. image: '@/assets/images/xiaozhi.png',
  123. message:
  124. '您好,我是您的AI智能助手小智,我会尽力回答您的问题或提供有用的建议!!!!'
  125. }
  126. router.push({
  127. // 跳转问答页面
  128. path: '/ai-questions',
  129. query: {
  130. id: person.id,
  131. name: person.name,
  132. image: person.image,
  133. message: person.message
  134. }
  135. })
  136. }
  137. if (group.title === '智能绘画') {
  138. router.push('/ai-painting')
  139. }
  140. if (group.title === '数字人老师') {
  141. router.push('/ai-laboratory') // 添加跳转到AI实验室的逻辑
  142. }
  143. }
  144. // 处理菜单展开和关闭
  145. const handleOpen = () => {}
  146. const handleClose = () => {}
  147. // 返回上一页
  148. const goBack = () => {
  149. router.push('/ai-laboratory')
  150. }
  151. const router = useRouter()
  152. const route = useRoute()
  153. const personId = ref(route.query.id)
  154. const personName = ref(route.query.name)
  155. const personIntroduce = ref(route.query.message)
  156. const personImage = ref(route.query.image)
  157. // 渲染实验室携带的人物形象图片
  158. const selectedImage = ref('')
  159. onMounted(() => {
  160. const image = route.query.image
  161. if (image) {
  162. selectedImage.value = image
  163. }
  164. })
  165. // 聊天对话
  166. const activeConversationModelPath = ref(null) // 选中的对话编号
  167. const activeConversationId = ref(null) // 选中的对话编号
  168. const activeConversation = ref(null) // 选中的 Conversation
  169. const conversationInProgress = ref(false) // 对话是否正在进行中。目前只有【发送】消息时,会更新为 true,避免切换对话、删除对话等操作,导致 stream 中断
  170. // 消息列表
  171. const messageRef = ref()
  172. const activeMessageList = ref([]) // 选中对话的消息列表
  173. const activeMessageListLoading = ref(false) // activeMessageList 是否正在加载中
  174. const activeMessageListLoadingTimer = ref() // activeMessageListLoading Timer 定时器。如果加载速度很快,就不进入加载中
  175. // 消息滚动
  176. const textSpeed = ref(50) // Typing speed in milliseconds
  177. const textRoleRunning = ref(false) // Typing speed in milliseconds
  178. // 发送消息输入框
  179. const isComposing = ref(false) // 判断用户是否在输入
  180. const conversationInAbortController = ref() // 对话进行中 abort 控制器(控制 stream 对话)
  181. const inputTimeout = ref() // 处理输入中回车的定时器
  182. const prompt = ref() // prompt
  183. const enableContext = ref(true) // 是否开启上下文
  184. // 接收 Stream 消息
  185. const receiveMessageFullText = ref('')
  186. const receiveMessageDisplayedText = ref('')
  187. // =========== 【聊天对话】相关 ===========
  188. /** 获取对话信息 */
  189. const getConversation = async id => {
  190. if (!id) {
  191. return
  192. }
  193. const conversation = ref({})
  194. if (!conversation) {
  195. return
  196. }
  197. conversation.systemMessage = personIntroduce.value
  198. activeConversation.value = conversation
  199. // activeConversationId.value = personId.value
  200. activeConversationModelPath.value = personImage.value
  201. }
  202. // =========== 【发送消息】相关 ===========
  203. /** 处理来自 keydown 的发送消息 */
  204. const handleSendByKeydown = async event => {
  205. // 判断用户是否在输入
  206. if (isComposing.value) {
  207. return
  208. }
  209. // 进行中不允许发送
  210. if (conversationInProgress.value) {
  211. return
  212. }
  213. const content = prompt.value?.trim()
  214. if (event.key === 'Enter') {
  215. if (event.shiftKey) {
  216. // 插入换行
  217. prompt.value += '\r\n'
  218. event.preventDefault() // 防止默认的换行行为
  219. } else {
  220. // 发送消息
  221. await doSendMessage(content)
  222. event.preventDefault() // 防止默认的提交行为
  223. }
  224. }
  225. }
  226. /** 处理来自【发送】按钮的发送消息 */
  227. const handleSendByButton = () => {
  228. doSendMessage(prompt.value?.trim())
  229. }
  230. /** 处理 prompt 输入变化 */
  231. const handlePromptInput = event => {
  232. // 非输入法 输入设置为 true
  233. if (!isComposing.value) {
  234. // 回车 event data 是 null
  235. if (event.data == null) {
  236. return
  237. }
  238. isComposing.value = true
  239. }
  240. // 清理定时器
  241. if (inputTimeout.value) {
  242. clearTimeout(inputTimeout.value)
  243. }
  244. // 重置定时器
  245. inputTimeout.value = setTimeout(() => {
  246. isComposing.value = false
  247. }, 400)
  248. }
  249. // TODO注:是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑
  250. const onCompositionstart = () => {
  251. isComposing.value = true
  252. }
  253. const onCompositionend = () => {
  254. setTimeout(() => {
  255. isComposing.value = false
  256. }, 200)
  257. }
  258. /** 真正执行【发送】消息操作 */
  259. const doSendMessage = async content => {
  260. // 校验
  261. if (content.length < 1) {
  262. console.error('发送失败,原因:内容为空!')
  263. return
  264. }
  265. if (activeConversationId.value == null) {
  266. console.error('还没创建对话,不能发送!')
  267. return
  268. }
  269. // 清空输入框
  270. prompt.value = ''
  271. // 执行发送
  272. await doSendMessageStream({
  273. conversationId: activeConversationId.value,
  274. content: content
  275. })
  276. }
  277. /** 真正执行【发送】消息操作 */
  278. const doSendMessageStream = async userMessage => {
  279. // 创建 AbortController 实例,以便中止请求
  280. conversationInAbortController.value = new AbortController()
  281. // 标记对话进行中
  282. conversationInProgress.value = true
  283. // 设置为空
  284. receiveMessageFullText.value = ''
  285. try {
  286. // 1.1 先添加两个假数据,等 stream 返回再替换
  287. activeMessageList.value.push({
  288. id: -1,
  289. conversationId: activeConversationId.value,
  290. type: 'user',
  291. content: userMessage.content,
  292. createTime: new Date()
  293. })
  294. activeMessageList.value.push({
  295. id: -2,
  296. conversationId: activeConversationId.value,
  297. type: 'assistant',
  298. content: '思考中...',
  299. createTime: new Date()
  300. })
  301. // 1.3 开始滚动
  302. textRoll()
  303. // 2. 发送 event stream
  304. let isFirstChunk = true // 是否是第一个 chunk 消息段
  305. await sendChatMessageStream(
  306. userMessage.conversationId,
  307. userMessage.content,
  308. conversationInAbortController.value,
  309. enableContext.value,
  310. async res => {
  311. const { code, data, msg } = JSON.parse(res.data)
  312. if (code !== 0) {
  313. console.log(`对话异常! ${msg}`)
  314. return
  315. }
  316. // 如果内容为空,就不处理。
  317. // if (data.receive.content === '') {
  318. // return
  319. // }
  320. receiveMessageFullText.value =
  321. receiveMessageFullText.value + data.receive.content
  322. // 首次返回需要添加一个 message 到页面,后面的都是更新
  323. if (isFirstChunk) {
  324. isFirstChunk = false
  325. // 弹出两个假数据
  326. activeMessageList.value.pop()
  327. activeMessageList.value.pop()
  328. // 更新返回的数据
  329. activeMessageList.value.push(data.send)
  330. activeMessageList.value.push(data.receive)
  331. }
  332. },
  333. error => {
  334. console.log(`对话异常! ${error}`)
  335. stopStream()
  336. // 需要抛出异常,禁止重试
  337. throw error
  338. },
  339. () => {
  340. stopStream()
  341. }
  342. )
  343. } catch {}
  344. }
  345. /** 停止 stream 流式调用 */
  346. const stopStream = async () => {
  347. // tip:如果 stream 进行中的 message,就需要调用 controller 结束
  348. if (conversationInAbortController.value) {
  349. conversationInAbortController.value.abort()
  350. }
  351. // 设置为 false
  352. conversationInProgress.value = false
  353. }
  354. /**
  355. * 消息列表
  356. *
  357. * 和 {@link #getMessageList()} 的差异是,把 systemMessage 考虑进去
  358. */
  359. const messageList = computed(() => {
  360. if (activeMessageList.value.length > 0) {
  361. return activeMessageList.value
  362. }
  363. // 没有消息时,如果有 systemMessage 则展示它
  364. if (activeConversation.value?.systemMessage) {
  365. let systemMessage = {
  366. id: 0,
  367. type: 'system',
  368. content: activeConversation.value.systemMessage
  369. }
  370. activeMessageList.value.push(systemMessage)
  371. return [systemMessage]
  372. }
  373. return []
  374. })
  375. // ============== 【消息滚动】相关 =============
  376. /** 滚动到 message 底部 */
  377. const scrollToBottom = async isIgnore => {
  378. // if (messageRef.value) {
  379. // messageRef.value.scrollToBottom(isIgnore)
  380. // }
  381. }
  382. /** 自提滚动效果 */
  383. const textRoll = async () => {
  384. let index = 0
  385. try {
  386. // 只能执行一次
  387. if (textRoleRunning.value) {
  388. return
  389. }
  390. // 设置状态
  391. textRoleRunning.value = true
  392. receiveMessageDisplayedText.value = ''
  393. const task = async () => {
  394. // 调整速度
  395. const diff =
  396. (receiveMessageFullText.value.length -
  397. receiveMessageDisplayedText.value.length) /
  398. 10
  399. if (diff > 5) {
  400. textSpeed.value = 10
  401. } else if (diff > 2) {
  402. textSpeed.value = 30
  403. } else if (diff > 1.5) {
  404. textSpeed.value = 50
  405. } else {
  406. textSpeed.value = 100
  407. }
  408. // 对话结束,就按 30 的速度
  409. if (!conversationInProgress.value) {
  410. textSpeed.value = 10
  411. }
  412. if (index < receiveMessageFullText.value.length) {
  413. receiveMessageDisplayedText.value += receiveMessageFullText.value[index]
  414. index++
  415. // 更新 message
  416. const lastMessage =
  417. activeMessageList.value[activeMessageList.value.length - 1]
  418. lastMessage.content = receiveMessageDisplayedText.value
  419. // 滚动到住下面
  420. await scrollToBottom()
  421. // 重新设置任务
  422. timer = setTimeout(task, textSpeed.value)
  423. } else {
  424. // 不是对话中可以结束
  425. if (!conversationInProgress.value) {
  426. textRoleRunning.value = false
  427. clearTimeout(timer)
  428. } else {
  429. // 重新设置任务
  430. timer = setTimeout(task, textSpeed.value)
  431. }
  432. }
  433. }
  434. let timer = setTimeout(task, textSpeed.value)
  435. } catch {}
  436. }
  437. /** 初始化 **/
  438. onMounted(async () => {
  439. if (personId.value) {
  440. // 智能问答
  441. CreateDialogue({ roleId: personId.value })
  442. .then(res => {
  443. console.log('创建会话:', res)
  444. activeConversationId.value = res.data
  445. })
  446. .catch(error => {
  447. console.error('请求出错:', error)
  448. })
  449. await getConversation(personId.value)
  450. }
  451. // 获取列表数据
  452. // activeMessageListLoading.value = true
  453. })
  454. </script>
  455. <style scoped lang="scss">
  456. @use 'sass:math';
  457. // 定义rpx转换函数
  458. @function rpx($px) {
  459. @return math.div($px, 750) * 100vw;
  460. }
  461. /* 添加过渡样式 */
  462. .drawer-slide-enter-active,
  463. .drawer-slide-leave-active {
  464. transition: all 0.3s ease;
  465. }
  466. .drawer-slide-enter-from,
  467. .drawer-slide-leave-to {
  468. transform: translateX(-100%);
  469. opacity: 0;
  470. }
  471. .home-container {
  472. position: fixed;
  473. top: 0;
  474. left: 0;
  475. right: 0;
  476. bottom: 0;
  477. display: flex;
  478. flex-direction: row;
  479. gap: rpx(0);
  480. background: linear-gradient(to bottom, #e2ddfc, #f1effd);
  481. }
  482. .icon-expand {
  483. width: rpx(30);
  484. height: rpx(30);
  485. z-index: 9999;
  486. position: absolute;
  487. cursor: pointer;
  488. }
  489. .icon-expand .el-icon {
  490. font-size: rpx(15);
  491. position: absolute;
  492. color: white;
  493. left: rpx(9);
  494. margin-top: rpx(17);
  495. }
  496. .icon-expand .el-icon:active {
  497. color: black;
  498. }
  499. .content-wrapper {
  500. display: flex;
  501. flex: 1;
  502. }
  503. .left-group {
  504. width: rpx(135);
  505. height: 100%;
  506. background: linear-gradient(to bottom, #001169, #b4a8e1);
  507. }
  508. .mb-2 {
  509. color: black;
  510. margin-top: rpx(1);
  511. }
  512. .tac ::v-deep(.el-menu) {
  513. background-color: transparent;
  514. border: none;
  515. width: 100%;
  516. margin-top: rpx(55);
  517. margin-left: rpx(10);
  518. }
  519. .el-menu-item {
  520. width: rpx(115);
  521. height: rpx(25);
  522. margin-bottom: rpx(5);
  523. border-radius: rpx(6);
  524. color: white;
  525. font-size: rpx(8);
  526. }
  527. .el-menu-item .el-icon svg {
  528. font-size: rpx(15);
  529. color: white;
  530. }
  531. .el-menu ::v-deep(.el-menu-item:hover),
  532. .el-menu ::v-deep(.el-menu-item:focus),
  533. .el-menu ::v-deep(.el-menu-item:active) {
  534. background: linear-gradient(
  535. to bottom,
  536. #ffefb0,
  537. #ffcc00
  538. ); /* 设置悬停、聚焦、点击状态下的背景色 */
  539. box-shadow: 0 8px 8px rgb(0, 0, 0, 0.3);
  540. color: black;
  541. font-size: rpx(8);
  542. }
  543. // 侧边栏
  544. .left-group2 {
  545. width: rpx(150);
  546. height: 100%;
  547. display: flex;
  548. background-color: #ece9fd;
  549. }
  550. .left-group2 img {
  551. width: rpx(120);
  552. // height: auto;
  553. }
  554. .selected-image {
  555. flex: 1;
  556. margin: auto;
  557. margin-left: rpx(-60);
  558. }
  559. .title-box {
  560. height: rpx(50);
  561. }
  562. .box-icon {
  563. width: 100%;
  564. height: 100%;
  565. flex: 1;
  566. display: flex; // 添加 flex 布局
  567. align-items: center; // 垂直居中
  568. color: black; // 设置图标颜色为白色
  569. padding-left: rpx(15);
  570. font-size: rpx(10); // 设置图标大小,可按需调整
  571. cursor: pointer; // 添加鼠标指针样式
  572. }
  573. .box-icon .left-icon {
  574. margin-left: rpx(10);
  575. margin-right: rpx(5); // 设置图标和文字之间的间距 ;
  576. }
  577. .number-people {
  578. flex: 1;
  579. height: 100%;
  580. display: flex;
  581. background-color: #ece9fd;
  582. }
  583. .content-box {
  584. flex: 1;
  585. margin-top: rpx(10);
  586. margin-bottom: rpx(10);
  587. margin-right: rpx(10);
  588. border-radius: rpx(15);
  589. background: rgba($color: #ffffff, $alpha: 0.5);
  590. }
  591. // 对话框
  592. .chat-dialog {
  593. display: flex;
  594. flex-direction: column;
  595. height: 100%;
  596. }
  597. .message-list {
  598. flex: 1;
  599. overflow-y: auto;
  600. padding: rpx(15);
  601. }
  602. .message-list .user-message {
  603. background-color: #ffffff;
  604. margin-left: auto; // 消息靠右显示
  605. margin-right: 0; // 重置右边距
  606. max-width: rpx(400);
  607. font-size: rpx(8);
  608. width: fit-content; // 宽度随文字内容变化
  609. border-radius: rpx(5);
  610. padding: rpx(5);
  611. text-align: left; // 文字左对齐
  612. }
  613. .message-list .ai-message {
  614. background-color: #ffdd55;
  615. margin-left: 0; // 消息靠左显示
  616. margin-right: auto; // 重置右边距
  617. margin-bottom: rpx(10);
  618. width: fit-content;
  619. max-width: rpx(400);
  620. padding: rpx(5);
  621. font-size: rpx(8);
  622. border-radius: rpx(5);
  623. text-align: left; // 文字左对齐
  624. }
  625. .input-section {
  626. display: flex;
  627. padding: rpx(10);
  628. gap: rpx(10);
  629. }
  630. .input-section input {
  631. flex: 1;
  632. padding: rpx(5);
  633. font-size: rpx(7);
  634. border: 1px solid #ccc;
  635. border-radius: rpx(5);
  636. }
  637. .input-section button {
  638. padding: rpx(5) rpx(15);
  639. background: linear-gradient(
  640. to bottom,
  641. #fee78a,
  642. #ffce1b
  643. ); /* 设置悬停、聚焦、点击状态下的背景色 */
  644. color: black;
  645. border: none;
  646. font-size: rpx(7);
  647. border-radius: rpx(5);
  648. cursor: pointer;
  649. box-shadow: 0 4px 8px rgba(202, 52, 52, 0.3);
  650. }
  651. </style>