houzhongjian
2024-08-08 820397e43a0b64d35c6d31d2a55475061438593b
提交 | 用户 | 时间
820397 1 <template>
H 2   <el-container class="ai-layout">
3     <!-- 左侧:对话列表 -->
4     <ConversationList
5       :active-id="activeConversationId"
6       ref="conversationListRef"
7       @on-conversation-create="handleConversationCreateSuccess"
8       @on-conversation-click="handleConversationClick"
9       @on-conversation-clear="handleConversationClear"
10       @on-conversation-delete="handlerConversationDelete"
11     />
12     <!-- 右侧:对话详情 -->
13     <el-container class="detail-container">
14       <el-header class="header">
15         <div class="title">
16           {{ activeConversation?.title ? activeConversation?.title : '对话' }}
17           <span v-if="activeMessageList.length">({{ activeMessageList.length }})</span>
18         </div>
19         <div class="btns" v-if="activeConversation">
20           <el-button type="primary" bg plain size="small" @click="openChatConversationUpdateForm">
21             <span v-html="activeConversation?.modelName"></span>
22             <Icon icon="ep:setting" class="ml-10px" />
23           </el-button>
24           <el-button size="small" class="btn" @click="handlerMessageClear">
25             <Icon icon="heroicons-outline:archive-box-x-mark" color="#787878" />
26           </el-button>
27           <el-button size="small" class="btn">
28             <Icon icon="ep:download" color="#787878" />
29           </el-button>
30           <el-button size="small" class="btn" @click="handleGoTopMessage">
31             <Icon icon="ep:top" color="#787878" />
32           </el-button>
33         </div>
34       </el-header>
35
36       <!-- main:消息列表 -->
37       <el-main class="main-container">
38         <div>
39           <div class="message-container">
40             <!-- 情况一:消息加载中 -->
41             <MessageLoading v-if="activeMessageListLoading" />
42             <!-- 情况二:无聊天对话时 -->
43             <MessageNewConversation
44               v-if="!activeConversation"
45               @on-new-conversation="handleConversationCreate"
46             />
47             <!-- 情况三:消息列表为空 -->
48             <MessageListEmpty
49               v-if="!activeMessageListLoading && messageList.length === 0 && activeConversation"
50               @on-prompt="doSendMessage"
51             />
52             <!-- 情况四:消息列表不为空 -->
53             <MessageList
54               v-if="!activeMessageListLoading && messageList.length > 0"
55               ref="messageRef"
56               :conversation="activeConversation"
57               :list="messageList"
58               @on-delete-success="handleMessageDelete"
59               @on-edit="handleMessageEdit"
60               @on-refresh="handleMessageRefresh"
61             />
62           </div>
63         </div>
64       </el-main>
65
66       <!-- 底部 -->
67       <el-footer class="footer-container">
68         <form class="prompt-from">
69           <textarea
70             class="prompt-input"
71             v-model="prompt"
72             @keydown="handleSendByKeydown"
73             @input="handlePromptInput"
74             @compositionstart="onCompositionstart"
75             @compositionend="onCompositionend"
76             placeholder="问我任何问题...(Shift+Enter 换行,按下 Enter 发送)"
77           ></textarea>
78           <div class="prompt-btns">
79             <div>
80               <el-switch v-model="enableContext" />
81               <span class="ml-5px text-14px text-#8f8f8f">上下文</span>
82             </div>
83             <el-button
84               type="primary"
85               size="default"
86               @click="handleSendByButton"
87               :loading="conversationInProgress"
88               v-if="conversationInProgress == false"
89             >
90               {{ conversationInProgress ? '进行中' : '发送' }}
91             </el-button>
92             <el-button
93               type="danger"
94               size="default"
95               @click="stopStream()"
96               v-if="conversationInProgress == true"
97             >
98               停止
99             </el-button>
100           </div>
101         </form>
102       </el-footer>
103     </el-container>
104
105     <!-- 更新对话 Form -->
106     <ConversationUpdateForm
107       ref="conversationUpdateFormRef"
108       @success="handleConversationUpdateSuccess"
109     />
110   </el-container>
111 </template>
112
113 <script setup lang="ts">
114 import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message'
115 import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
116 import ConversationList from './components/conversation/ConversationList.vue'
117 import ConversationUpdateForm from './components/conversation/ConversationUpdateForm.vue'
118 import MessageList from './components/message/MessageList.vue'
119 import MessageListEmpty from './components/message/MessageListEmpty.vue'
120 import MessageLoading from './components/message/MessageLoading.vue'
121 import MessageNewConversation from './components/message/MessageNewConversation.vue'
122
123 /** AI 聊天对话 列表 */
124 defineOptions({ name: 'AiChat' })
125
126 const route = useRoute() // 路由
127 const message = useMessage() // 消息弹窗
128
129 // 聊天对话
130 const conversationListRef = ref()
131 const activeConversationId = ref<number | null>(null) // 选中的对话编号
132 const activeConversation = ref<ChatConversationVO | null>(null) // 选中的 Conversation
133 const conversationInProgress = ref(false) // 对话是否正在进行中。目前只有【发送】消息时,会更新为 true,避免切换对话、删除对话等操作
134
135 // 消息列表
136 const messageRef = ref()
137 const activeMessageList = ref<ChatMessageVO[]>([]) // 选中对话的消息列表
138 const activeMessageListLoading = ref<boolean>(false) // activeMessageList 是否正在加载中
139 const activeMessageListLoadingTimer = ref<any>() // activeMessageListLoading Timer 定时器。如果加载速度很快,就不进入加载中
140 // 消息滚动
141 const textSpeed = ref<number>(50) // Typing speed in milliseconds
142 const textRoleRunning = ref<boolean>(false) // Typing speed in milliseconds
143
144 // 发送消息输入框
145 const isComposing = ref(false) // 判断用户是否在输入
146 const conversationInAbortController = ref<any>() // 对话进行中 abort 控制器(控制 stream 对话)
147 const inputTimeout = ref<any>() // 处理输入中回车的定时器
148 const prompt = ref<string>() // prompt
149 const enableContext = ref<boolean>(true) // 是否开启上下文
150 // 接收 Stream 消息
151 const receiveMessageFullText = ref('')
152 const receiveMessageDisplayedText = ref('')
153
154 // =========== 【聊天对话】相关 ===========
155
156 /** 获取对话信息 */
157 const getConversation = async (id: number | null) => {
158   if (!id) {
159     return
160   }
161   const conversation: ChatConversationVO = await ChatConversationApi.getChatConversationMy(id)
162   if (!conversation) {
163     return
164   }
165   activeConversation.value = conversation
166   activeConversationId.value = conversation.id
167 }
168
169 /**
170  * 点击某个对话
171  *
172  * @param conversation 选中的对话
173  * @return 是否切换成功
174  */
175 const handleConversationClick = async (conversation: ChatConversationVO) => {
176   // 对话进行中,不允许切换
177   if (conversationInProgress.value) {
178     message.alert('对话中,不允许切换!')
179     return false
180   }
181
182   // 更新选中的对话 id
183   activeConversationId.value = conversation.id
184   activeConversation.value = conversation
185   // 刷新 message 列表
186   await getMessageList()
187   // 滚动底部
188   scrollToBottom(true)
189   // 清空输入框
190   prompt.value = ''
191   return true
192 }
193
194 /** 删除某个对话*/
195 const handlerConversationDelete = async (delConversation: ChatConversationVO) => {
196   // 删除的对话如果是当前选中的,那么就重置
197   if (activeConversationId.value === delConversation.id) {
198     await handleConversationClear()
199   }
200 }
201 /** 清空选中的对话 */
202 const handleConversationClear = async () => {
203   // 对话进行中,不允许切换
204   if (conversationInProgress.value) {
205     message.alert('对话中,不允许切换!')
206     return false
207   }
208   activeConversationId.value = null
209   activeConversation.value = null
210   activeMessageList.value = []
211 }
212
213 /** 修改聊天对话 */
214 const conversationUpdateFormRef = ref()
215 const openChatConversationUpdateForm = async () => {
216   conversationUpdateFormRef.value.open(activeConversationId.value)
217 }
218 const handleConversationUpdateSuccess = async () => {
219   // 对话更新成功,刷新最新信息
220   await getConversation(activeConversationId.value)
221 }
222
223 /** 处理聊天对话的创建成功 */
224 const handleConversationCreate = async () => {
225   // 创建对话
226   await conversationListRef.value.createConversation()
227 }
228 /** 处理聊天对话的创建成功 */
229 const handleConversationCreateSuccess = async () => {
230   // 创建新的对话,清空输入框
231   prompt.value = ''
232 }
233
234 // =========== 【消息列表】相关 ===========
235
236 /** 获取消息 message 列表 */
237 const getMessageList = async () => {
238   try {
239     if (activeConversationId.value === null) {
240       return
241     }
242     // Timer 定时器,如果加载速度很快,就不进入加载中
243     activeMessageListLoadingTimer.value = setTimeout(() => {
244       activeMessageListLoading.value = true
245     }, 60)
246
247     // 获取消息列表
248     activeMessageList.value = await ChatMessageApi.getChatMessageListByConversationId(
249       activeConversationId.value
250     )
251
252     // 滚动到最下面
253     await nextTick()
254     await scrollToBottom()
255   } finally {
256     // time 定时器,如果加载速度很快,就不进入加载中
257     if (activeMessageListLoadingTimer.value) {
258       clearTimeout(activeMessageListLoadingTimer.value)
259     }
260     // 加载结束
261     activeMessageListLoading.value = false
262   }
263 }
264
265 /**
266  * 消息列表
267  *
268  * 和 {@link #getMessageList()} 的差异是,把 systemMessage 考虑进去
269  */
270 const messageList = computed(() => {
271   if (activeMessageList.value.length > 0) {
272     return activeMessageList.value
273   }
274   // 没有消息时,如果有 systemMessage 则展示它
275   if (activeConversation.value?.systemMessage) {
276     return [
277       {
278         id: 0,
279         type: 'system',
280         content: activeConversation.value.systemMessage
281       }
282     ]
283   }
284   return []
285 })
286
287 /** 处理删除 message 消息 */
288 const handleMessageDelete = () => {
289   if (conversationInProgress.value) {
290     message.alert('回答中,不能删除!')
291     return
292   }
293   // 刷新 message 列表
294   getMessageList()
295 }
296
297 /** 处理 message 清空 */
298 const handlerMessageClear = async () => {
299   if (!activeConversationId.value) {
300     return
301   }
302   try {
303     // 确认提示
304     await message.delConfirm('确认清空对话消息?')
305     // 清空对话
306     await ChatMessageApi.deleteByConversationId(activeConversationId.value)
307     // 刷新 message 列表
308     activeMessageList.value = []
309   } catch {}
310 }
311
312 /** 回到 message 列表的顶部 */
313 const handleGoTopMessage = () => {
314   messageRef.value.handlerGoTop()
315 }
316
317 // =========== 【发送消息】相关 ===========
318
319 /** 处理来自 keydown 的发送消息 */
320 const handleSendByKeydown = async (event) => {
321   // 判断用户是否在输入
322   if (isComposing.value) {
323     return
324   }
325   // 进行中不允许发送
326   if (conversationInProgress.value) {
327     return
328   }
329   const content = prompt.value?.trim() as string
330   if (event.key === 'Enter') {
331     if (event.shiftKey) {
332       // 插入换行
333       prompt.value += '\r\n'
334       event.preventDefault() // 防止默认的换行行为
335     } else {
336       // 发送消息
337       await doSendMessage(content)
338       event.preventDefault() // 防止默认的提交行为
339     }
340   }
341 }
342
343 /** 处理来自【发送】按钮的发送消息 */
344 const handleSendByButton = () => {
345   doSendMessage(prompt.value?.trim() as string)
346 }
347
348 /** 处理 prompt 输入变化 */
349 const handlePromptInput = (event) => {
350   // 非输入法 输入设置为 true
351   if (!isComposing.value) {
352     // 回车 event data 是 null
353     if (event.data == null) {
354       return
355     }
356     isComposing.value = true
357   }
358   // 清理定时器
359   if (inputTimeout.value) {
360     clearTimeout(inputTimeout.value)
361   }
362   // 重置定时器
363   inputTimeout.value = setTimeout(() => {
364     isComposing.value = false
365   }, 400)
366 }
367 // TODO @芋艿:是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑
368 const onCompositionstart = () => {
369   isComposing.value = true
370 }
371 const onCompositionend = () => {
372   // console.log('输入结束...')
373   setTimeout(() => {
374     isComposing.value = false
375   }, 200)
376 }
377
378 /** 真正执行【发送】消息操作 */
379 const doSendMessage = async (content: string) => {
380   // 校验
381   if (content.length < 1) {
382     message.error('发送失败,原因:内容为空!')
383     return
384   }
385   if (activeConversationId.value == null) {
386     message.error('还没创建对话,不能发送!')
387     return
388   }
389   // 清空输入框
390   prompt.value = ''
391   // 执行发送
392   await doSendMessageStream({
393     conversationId: activeConversationId.value,
394     content: content
395   } as ChatMessageVO)
396 }
397
398 /** 真正执行【发送】消息操作 */
399 const doSendMessageStream = async (userMessage: ChatMessageVO) => {
400   // 创建 AbortController 实例,以便中止请求
401   conversationInAbortController.value = new AbortController()
402   // 标记对话进行中
403   conversationInProgress.value = true
404   // 设置为空
405   receiveMessageFullText.value = ''
406
407   try {
408     // 1.1 先添加两个假数据,等 stream 返回再替换
409     activeMessageList.value.push({
410       id: -1,
411       conversationId: activeConversationId.value,
412       type: 'user',
413       content: userMessage.content,
414       createTime: new Date()
415     } as ChatMessageVO)
416     activeMessageList.value.push({
417       id: -2,
418       conversationId: activeConversationId.value,
419       type: 'assistant',
420       content: '思考中...',
421       createTime: new Date()
422     } as ChatMessageVO)
423     // 1.2 滚动到最下面
424     await nextTick()
425     await scrollToBottom() // 底部
426     // 1.3 开始滚动
427     textRoll()
428
429     // 2. 发送 event stream
430     let isFirstChunk = true // 是否是第一个 chunk 消息段
431     await ChatMessageApi.sendChatMessageStream(
432       userMessage.conversationId,
433       userMessage.content,
434       conversationInAbortController.value,
435       enableContext.value,
436       async (res) => {
437         const { code, data, msg } = JSON.parse(res.data)
438         if (code !== 0) {
439           message.alert(`对话异常! ${msg}`)
440           return
441         }
442
443         // 如果内容为空,就不处理。
444         if (data.receive.content === '') {
445           return
446         }
447         // 首次返回需要添加一个 message 到页面,后面的都是更新
448         if (isFirstChunk) {
449           isFirstChunk = false
450           // 弹出两个假数据
451           activeMessageList.value.pop()
452           activeMessageList.value.pop()
453           // 更新返回的数据
454           activeMessageList.value.push(data.send)
455           activeMessageList.value.push(data.receive)
456         }
457         // debugger
458         receiveMessageFullText.value = receiveMessageFullText.value + data.receive.content
459         // 滚动到最下面
460         await scrollToBottom()
461       },
462       (error) => {
463         message.alert(`对话异常! ${error}`)
464         stopStream()
465       },
466       () => {
467         stopStream()
468       }
469     )
470   } catch {}
471 }
472
473 /** 停止 stream 流式调用 */
474 const stopStream = async () => {
475   // tip:如果 stream 进行中的 message,就需要调用 controller 结束
476   if (conversationInAbortController.value) {
477     conversationInAbortController.value.abort()
478   }
479   // 设置为 false
480   conversationInProgress.value = false
481 }
482
483 /** 编辑 message:设置为 prompt,可以再次编辑 */
484 const handleMessageEdit = (message: ChatMessageVO) => {
485   prompt.value = message.content
486 }
487
488 /** 刷新 message:基于指定消息,再次发起对话 */
489 const handleMessageRefresh = (message: ChatMessageVO) => {
490   doSendMessage(message.content)
491 }
492
493 // ============== 【消息滚动】相关 =============
494
495 /** 滚动到 message 底部 */
496 const scrollToBottom = async (isIgnore?: boolean) => {
497   await nextTick()
498   if (messageRef.value) {
499     messageRef.value.scrollToBottom(isIgnore)
500   }
501 }
502
503 /** 自提滚动效果 */
504 const textRoll = async () => {
505   let index = 0
506   try {
507     // 只能执行一次
508     if (textRoleRunning.value) {
509       return
510     }
511     // 设置状态
512     textRoleRunning.value = true
513     receiveMessageDisplayedText.value = ''
514     const task = async () => {
515       // 调整速度
516       const diff =
517         (receiveMessageFullText.value.length - receiveMessageDisplayedText.value.length) / 10
518       if (diff > 5) {
519         textSpeed.value = 10
520       } else if (diff > 2) {
521         textSpeed.value = 30
522       } else if (diff > 1.5) {
523         textSpeed.value = 50
524       } else {
525         textSpeed.value = 100
526       }
527       // 对话结束,就按 30 的速度
528       if (!conversationInProgress.value) {
529         textSpeed.value = 10
530       }
531
532       if (index < receiveMessageFullText.value.length) {
533         receiveMessageDisplayedText.value += receiveMessageFullText.value[index]
534         index++
535
536         // 更新 message
537         const lastMessage = activeMessageList.value[activeMessageList.value.length - 1]
538         lastMessage.content = receiveMessageDisplayedText.value
539         // 滚动到住下面
540         await scrollToBottom()
541         // 重新设置任务
542         timer = setTimeout(task, textSpeed.value)
543       } else {
544         // 不是对话中可以结束
545         if (!conversationInProgress.value) {
546           textRoleRunning.value = false
547           clearTimeout(timer)
548         } else {
549           // 重新设置任务
550           timer = setTimeout(task, textSpeed.value)
551         }
552       }
553     }
554     let timer = setTimeout(task, textSpeed.value)
555   } catch {}
556 }
557
558 /** 初始化 **/
559 onMounted(async () => {
560   // 如果有 conversationId 参数,则默认选中
561   if (route.query.conversationId) {
562     const id = route.query.conversationId as unknown as number
563     activeConversationId.value = id
564     await getConversation(id)
565   }
566
567   // 获取列表数据
568   activeMessageListLoading.value = true
569   await getMessageList()
570 })
571 </script>
572
573 <style lang="scss" scoped>
574 .ai-layout {
575   position: absolute;
576   flex: 1;
577   top: 0;
578   left: 0;
579   height: 100%;
580   width: 100%;
581 }
582
583 .conversation-container {
584   position: relative;
585   display: flex;
586   flex-direction: column;
587   justify-content: space-between;
588   padding: 10px 10px 0;
589
590   .btn-new-conversation {
591     padding: 18px 0;
592   }
593
594   .search-input {
595     margin-top: 20px;
596   }
597
598   .conversation-list {
599     margin-top: 20px;
600
601     .conversation {
602       display: flex;
603       flex-direction: row;
604       justify-content: space-between;
605       flex: 1;
606       padding: 0 5px;
607       margin-top: 10px;
608       cursor: pointer;
609       border-radius: 5px;
610       align-items: center;
611       line-height: 30px;
612
613       &.active {
614         background-color: #e6e6e6;
615
616         .button {
617           display: inline-block;
618         }
619       }
620
621       .title-wrapper {
622         display: flex;
623         flex-direction: row;
624         align-items: center;
625       }
626
627       .title {
628         padding: 5px 10px;
629         max-width: 220px;
630         font-size: 14px;
631         overflow: hidden;
632         white-space: nowrap;
633         text-overflow: ellipsis;
634       }
635
636       .avatar {
637         width: 28px;
638         height: 28px;
639         display: flex;
640         flex-direction: row;
641         justify-items: center;
642       }
643
644       // 对话编辑、删除
645       .button-wrapper {
646         right: 2px;
647         display: flex;
648         flex-direction: row;
649         justify-items: center;
650         color: #606266;
651
652         .el-icon {
653           margin-right: 5px;
654         }
655       }
656     }
657   }
658
659   // 角色仓库、清空未设置对话
660   .tool-box {
661     line-height: 35px;
662     display: flex;
663     justify-content: space-between;
664     align-items: center;
665     color: var(--el-text-color);
666
667     > div {
668       display: flex;
669       align-items: center;
670       color: #606266;
671       padding: 0;
672       margin: 0;
673       cursor: pointer;
674
675       > span {
676         margin-left: 5px;
677       }
678     }
679   }
680 }
681
682 // 头部
683 .detail-container {
684   background: #ffffff;
685
686   .header {
687     display: flex;
688     flex-direction: row;
689     align-items: center;
690     justify-content: space-between;
691     background: #fbfbfb;
692     box-shadow: 0 0 0 0 #dcdfe6;
693
694     .title {
695       font-size: 18px;
696       font-weight: bold;
697     }
698
699     .btns {
700       display: flex;
701       width: 300px;
702       flex-direction: row;
703       justify-content: flex-end;
704       //justify-content: space-between;
705
706       .btn {
707         padding: 10px;
708       }
709     }
710   }
711 }
712
713 // main 容器
714 .main-container {
715   margin: 0;
716   padding: 0;
717   position: relative;
718   height: 100%;
719   width: 100%;
720
721   .message-container {
722     position: absolute;
723     top: 0;
724     bottom: 0;
725     left: 0;
726     right: 0;
727     overflow-y: hidden;
728     padding: 0;
729     margin: 0;
730   }
731 }
732
733 // 底部
734 .footer-container {
735   display: flex;
736   flex-direction: column;
737   height: auto;
738   margin: 0;
739   padding: 0;
740
741   .prompt-from {
742     display: flex;
743     flex-direction: column;
744     height: auto;
745     border: 1px solid #e3e3e3;
746     border-radius: 10px;
747     margin: 10px 20px 20px 20px;
748     padding: 9px 10px;
749   }
750
751   .prompt-input {
752     height: 80px;
753     //box-shadow: none;
754     border: none;
755     box-sizing: border-box;
756     resize: none;
757     padding: 0 2px;
758     overflow: auto;
759   }
760
761   .prompt-input:focus {
762     outline: none;
763   }
764
765   .prompt-btns {
766     display: flex;
767     justify-content: space-between;
768     padding-bottom: 0;
769     padding-top: 5px;
770   }
771 }
772 </style>