| | |
| | | <template> |
| | | <div class="relative"> |
| | | <el-tooltip v-if="!isOpen" content="你好,我是AI助理,可以解答问题、推荐解决方案等" placement="right"> |
| | | <div |
| | | class="flex flex-col items-center gap-1 cursor-pointer bg-white rounded-lg shadow-lg p-1 absolute bottom-4 right-4 opacity-80" |
| | | @click="openChat" |
| | | > |
| | | <img :src="assistantPic" class="size-10" alt="AI头像" /> |
| | | <span class="text-lg text-center w-5">AI助手</span> |
| | | </div> |
| | | </el-tooltip> |
| | | <div v-else class="bg-white rounded-lg shadow-lg flex flex-col w-[400px] h-[600px] absolute bottom-4 right-4"> |
| | | <div ref="chatContainerRef" :style="chatContainerStyle" class="opacity-90 small-chat-container" @mousedown="startDrag"> |
| | | <div class="bg-white rounded-lg shadow-lg flex flex-col w-[400px] max-h-[600px] absolute bottom-4 right-4"> |
| | | <!-- 头部 --> |
| | | <div class="h-12 flex items-center justify-between px-4 border-b"> |
| | | <div class="text-base font-medium">WI水务智能助理</div> |
| | | <div ref="chatHeaderRef" :style="handleStyle" class="small-chat-header h-12 flex items-center justify-between px-4 border-b"> |
| | | <div class="text-lg font-medium">WI水务智能助理</div> |
| | | <div class="flex items-center gap-2"> |
| | | <!-- <el-icon class="cursor-pointer text-gray-400 hover:text-gray-600"> |
| | | <Refresh /> |
| | |
| | | <el-icon class="cursor-pointer text-gray-400 hover:text-gray-600"> |
| | | <Star /> |
| | | </el-icon> --> |
| | | <el-icon class="cursor-pointer text-gray-400 hover:text-gray-600" @click="closeChat"> |
| | | <!-- <el-icon class="cursor-pointer text-gray-400 hover:text-gray-600" @click="closeChat"> |
| | | <Close /> |
| | | </el-icon> |
| | | </el-icon> --> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 内容区 --> |
| | | <div class="flex-1 overflow-y-auto p-4"> |
| | | <div class="flex-1 overflow-y-auto p-4" ref="chatContentRef" v-show="showHistory"> |
| | | <div v-if="isInit"> |
| | | <!-- 欢迎语 --> |
| | | <div class="flex flex-col items-center gap-4 mt-8"> |
| | |
| | | > |
| | | {{ item }} |
| | | </div> |
| | | <!-- <div class="cursor-pointer hover:bg-gray-50 p-3 rounded-lg border border-solid border-gray-400"> |
| | | 阿里云产品怎么购买,重点有哪些功能? |
| | | </div> |
| | | <div class="cursor-pointer hover:bg-gray-50 p-3 rounded-lg border border-solid border-gray-400">AI 助理能为我做什么?</div> --> |
| | | </div> |
| | | </div> |
| | | <div v-else class="flex flex-col gap-4"> |
| | |
| | | </div> |
| | | |
| | | <!-- 底部输入框 --> |
| | | <div class="p-4 border-t"> |
| | | <ChatInput v-model="inputText" @sendClick="sendClick" /> |
| | | <div class="p-2 border-t"> |
| | | <ChatInput v-model="inputText" @sendClick="sendClick" @toggleHistory="toggleHistory" :showHistory="showHistory" /> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup lang="ts" name="smallChat"> |
| | | import { computed, onMounted, ref } from 'vue'; |
| | | import { computed, nextTick, onMounted, ref } from 'vue'; |
| | | import ChatInput from './ChatInput.vue'; |
| | | import type { ChatMessage } from './types'; |
| | | import { AssistantContent } from './types'; |
| | | import { agentStreamByPost, question_stream_reply } from '/@/api/ai/chat'; |
| | | import { agentStreamByPost } from '/@/api/ai/chat'; |
| | | import { Logger } from '/@/model/logger/Logger'; |
| | | import type { OLMap } from '/@/model/map/OLMap'; |
| | | import assistantPic from '/static/images/role/assistant-200x192.png'; |
| | | import userPic from '/static/images/role/user-200x206.png'; |
| | | import { useDrag } from '/@/hooks/useDrag'; |
| | | |
| | | const props = defineProps<{ |
| | | olMap?: OLMap; |
| | | }>(); |
| | | const isOpen = ref(false); |
| | | const inputText = ref(''); |
| | | /** @description 对话完成前是否时初始状态 */ |
| | | let lastIsInit = true; |
| | | const closeChat = () => { |
| | | isOpen.value = false; |
| | | }; |
| | | const chatHeaderRef = ref<HTMLDivElement>(null); |
| | | |
| | | const chatContainerRef = ref<HTMLDivElement>(null); |
| | | |
| | | const { startDrag, style: chatContainerStyle, handleStyle } = useDrag({ |
| | | handle: chatHeaderRef, |
| | | }); |
| | | |
| | | const historyMessages = ref<ChatMessage[]>([]); |
| | | const isInit = computed(() => historyMessages.value.length === 0); |
| | | const initQuestionList = ref(null); |
| | | const initQuestionList = ref(['放大', '缩小']); |
| | | const chatContentRef = ref<HTMLDivElement>(null); |
| | | |
| | | const getLastAssistantMessage = () => { |
| | | const last = historyMessages.value[historyMessages.value.length - 1]; |
| | | const result = last.role === 'assistant' ? last : null; |
| | | return result as ChatMessage<AssistantContent>; |
| | | }; |
| | | let streamIsOpen = false; |
| | | const startStream = () => { |
| | | |
| | | const startStream = (question: string) => { |
| | | if (lastIsInit) { |
| | | showHistory.value = false; |
| | | } |
| | | let haveMapOperate = false; |
| | | agentStreamByPost( |
| | | { |
| | | question: question, |
| | | agent_id: 'a_019471cdb0667a83956b76ac97283f1c', |
| | | }, |
| | | (chunkRes) => { |
| | | if (!streamIsOpen) { |
| | | streamIsOpen = true; |
| | | } |
| | | Logger.info('agent stream response:\n\n' + JSON.stringify(chunkRes)); |
| | | if (chunkRes.mode === 'question') { |
| | | if (chunkRes.type === 'json') { |
| | | if (!initQuestionList.value) { |
| | | initQuestionList.value = chunkRes.value?.options ?? []; |
| | | } |
| | | activeQuestionChunk = chunkRes; |
| | | } |
| | | } |
| | | |
| | | if (chunkRes.mode === 'map') { |
| | | haveMapOperate = true; |
| | | if (chunkRes.type === 'string') { |
| | | const jsonData = JSON.parse(chunkRes.value); |
| | | handleMapCommand(jsonData); |
| | | } |
| | | } |
| | | |
| | | if (chunkRes.mode === 'finish') { |
| | | isOpen.value = false; |
| | | streamIsOpen = false; |
| | | const last = getLastAssistantMessage(); |
| | | if (last) { |
| | | last.content.value = '已退出'; |
| | | last.content.isLoading = false; |
| | | if (!haveMapOperate) { |
| | | refreshAssistantMessage({ value: `未识别到操作:${question}` }); |
| | | } |
| | | } |
| | | } |
| | | ); |
| | | ).catch((error) => { |
| | | Logger.error('agent stream error:\n\n' + error); |
| | | refreshAssistantMessage(); |
| | | }); |
| | | }; |
| | | |
| | | const refreshAssistantMessage = (content: Partial<AssistantContent> = { value: 'AI回答失败' }) => { |
| | | const last = getLastAssistantMessage(); |
| | | if (last) { |
| | | last.content.value = content.value; |
| | | last.content.isLoading = content.isLoading; |
| | | } |
| | | }; |
| | | const handleMapCommand = (command: any) => { |
| | | if (!command) return; |
| | | const last = getLastAssistantMessage(); |
| | | if (last) { |
| | | last.content.value = `已执行操作: ${command.operate}`; |
| | | switch (command.operate) { |
| | | case '放大': |
| | | props.olMap.zoomIn(); |
| | | break; |
| | | switch (command.operate) { |
| | | case '放大': |
| | | props.olMap.zoomIn(); |
| | | break; |
| | | |
| | | case '缩小': |
| | | props.olMap.zoomOut(); |
| | | break; |
| | | } |
| | | last.content.isLoading = false; |
| | | case '缩小': |
| | | props.olMap.zoomOut(); |
| | | break; |
| | | } |
| | | refreshAssistantMessage({ value: `已执行操作: ${command.operate}` }); |
| | | }; |
| | | |
| | | const scrollToBottom = () => { |
| | | const chatContainer = document.querySelector('.chat-container'); |
| | | if (chatContainer) { |
| | | chatContainer.scrollTop = chatContainer.scrollHeight; |
| | | if (chatContentRef.value) { |
| | | chatContentRef.value.scrollTop = chatContentRef.value.scrollHeight; |
| | | } |
| | | }; |
| | | const openChat = () => { |
| | | isOpen.value = true; |
| | | if (!streamIsOpen) { |
| | | startStream(); |
| | | } |
| | | }; |
| | | |
| | | const applyMessage = () => { |
| | | const time = Date.now().toString(); |
| | | const userMessageId = `user-${time}`; |
| | |
| | | }, |
| | | }; |
| | | historyMessages.value.push(assistantMessage); |
| | | |
| | | nextTick(() => { |
| | | scrollToBottom(); |
| | | }); |
| | | const userReactive = historyMessages.value.find((item) => item.id === userMessageId); |
| | | const assistantReactive = historyMessages.value.find((item) => item.id === assistantMessageId); |
| | | return [userReactive, assistantReactive]; |
| | | }; |
| | | |
| | | const resetActive = () => { |
| | | activeQuestionChunk = null; |
| | | }; |
| | | |
| | | const refreshMessageList = () => { |
| | | historyMessages.value = historyMessages.value.concat([]); |
| | | }; |
| | | const sendClick = async () => { |
| | | if (!activeQuestionChunk) return; |
| | | if (!inputText.value.trim()) return; |
| | | lastIsInit = isInit.value; |
| | | const lastMessage = historyMessages.value[historyMessages.value.length - 1]; |
| | | if (lastMessage && lastMessage.role === 'assistant' && (lastMessage.content as AssistantContent).isLoading) return; |
| | | const [userMessage, assistantMessage] = applyMessage(); |
| | | const question = inputText.value; |
| | | inputText.value = ''; |
| | | |
| | | const assistantContent = assistantMessage.content as AssistantContent; |
| | | const res = await question_stream_reply({ |
| | | select: question, |
| | | reply_id: activeQuestionChunk.value.reply_id, |
| | | }).catch(() => { |
| | | assistantContent.isLoading = false; |
| | | assistantContent.value = 'AI回答失败'; |
| | | }); |
| | | resetActive(); |
| | | |
| | | startStream(question); |
| | | }; |
| | | |
| | | const handleQuestionClick = (item: string) => { |
| | | inputText.value = item; |
| | | sendClick(); |
| | | }; |
| | | const isFinish = ref(false); |
| | | let activeQuestionChunk = null; |
| | | |
| | | //#region ====================== 历史显示控制 ====================== |
| | | const showHistory = ref(true); |
| | | const toggleHistory = () => { |
| | | showHistory.value = !showHistory.value; |
| | | nextTick(() => { |
| | | scrollToBottom(); |
| | | }); |
| | | }; |
| | | //#endregion |
| | | onMounted(() => {}); |
| | | </script> |
| | | |