| | |
| | | <template> |
| | | <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-[370px] max-h-[600px] absolute bottom-4 right-4"> |
| | | <div class="bg-white rounded-lg shadow-lg flex flex-col w-[370px] max-h-[540px] absolute bottom-4 right-4"> |
| | | <!-- 头部 --> |
| | | <div |
| | | ref="chatHeaderRef" |
| | |
| | | class="small-chat-header h-12 flex items-center justify-between px-4" |
| | | style="border-bottom: 1px solid #e0e0e0" |
| | | > |
| | | <div class="text-lg font-bold">WI水务智能助手</div> |
| | | <div class="text-lg font-bold py-2">WI水务智能助手</div> |
| | | <div class="flex items-center gap-2"> |
| | | <!-- <el-icon class="cursor-pointer text-gray-400 hover:text-gray-600"> |
| | | <Refresh /> |
| | |
| | | </div> --> |
| | | </div> |
| | | </div> |
| | | <div v-else class="flex flex-col gap-4"> |
| | | <div v-else class="flex flex-col gap-1.5"> |
| | | <!-- 对话内容 --> |
| | | <div class="flex flex-col gap-4" v-for="item in historyMessages" :key="item.id"> |
| | | <div class="flex flex-col gap-4" v-for="(item, index) in historyMessages" :key="item.id"> |
| | | <!-- 用户提问 --> |
| | | <div class="flex gap-3" v-if="item.role === 'user'"> |
| | | <div class="flex gap-3 items-center" v-if="item.role === 'user'"> |
| | | <img :src="userPic" class="w-10 h-10" alt="用户头像" /> |
| | | <div class="flex-1 bg-blue-100"> |
| | | <div class="p-4 rounded-lg">{{ item.content.value }}</div> |
| | | <div class="flex-1 bg-blue-100 rounded-lg"> |
| | | <div class="p-3 flex items-center"> |
| | | <span> |
| | | {{ item.content.value }} |
| | | </span> |
| | | |
| | | <!-- #region ====================== 回复反馈 ======================--> |
| | | <el-icon v-if="(historyMessages[index+1].content as AssistantContent).isLoading" class="ml-2 animate-spin" |
| | | ><Loading |
| | | /></el-icon> |
| | | <template v-else> |
| | | <span |
| | | v-if="(historyMessages[index+1].content as AssistantContent).isError" |
| | | class="flex items-center text-nowrap ml-4 text-danger before:content-['('] after:content-[')']" |
| | | > |
| | | {{ (historyMessages[index + 1].content as AssistantContent).value }} |
| | | <el-tooltip |
| | | v-if="(historyMessages[index + 1].content as AssistantContent).reason" |
| | | :content="(historyMessages[index + 1].content as AssistantContent).reason" |
| | | placement="top" |
| | | > |
| | | <el-icon class="flex-center cursor-pointer ml-1"> |
| | | <question-filled /> |
| | | </el-icon> |
| | | </el-tooltip> |
| | | </span> |
| | | <span v-else class="ml-4 text-success text-nowrap before:content-['('] after:content-[')']"> |
| | | {{ (historyMessages[index + 1].content as AssistantContent).value }} |
| | | </span> |
| | | </template> |
| | | |
| | | <!-- #endregion --> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- AI回答 --> |
| | | <div class="flex gap-3" v-else-if="item.role === 'assistant'"> |
| | | <!-- <div class="flex gap-3" v-else-if="item.role === 'assistant'"> |
| | | <img :src="assistantPic" class="w-10 h-10" alt="AI头像" /> |
| | | <div class="flex-1 bg-gray-100"> |
| | | <div v-if="(item.content as AssistantContent)?.isLoading" class="p-4 rounded-lg flex items-center"> |
| | |
| | | {{ item.content.value }} |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> --> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 底部输入框 --> |
| | | <div class="p-2 border-t"> |
| | | <ChatInput v-model="inputText" @sendClick="sendClick" @toggleHistory="toggleHistory" :showHistory="showHistory" /> |
| | | <ChatInput |
| | | :isTalking="lastIsLoading" |
| | | v-model="inputText" |
| | | @sendClick="sendClick" |
| | | @toggleHistory="toggleHistory" |
| | | @stopGenClick="stopGenClick" |
| | | :showHistory="showHistory" |
| | | /> |
| | | </div> |
| | | </div> |
| | | <Teleport to="body"> |
| | | <WorkOrderDlg v-model="optDlgIsShow" :item="optDlgMapRow" @insert="submitDlg" @cancelSubmit="cancelSubmit"></WorkOrderDlg> |
| | | </Teleport> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup lang="ts" name="smallChat"> |
| | | import type { CancelTokenSource } from 'axios'; |
| | | import axios from 'axios'; |
| | | import { cloneDeep, defaults } from 'lodash-es'; |
| | | import { fromLonLat } from 'ol/proj'; |
| | | import { computed, nextTick, onMounted, ref } from 'vue'; |
| | | import ChatInput from './ChatInput.vue'; |
| | | import type { ChatMessage } from './types'; |
| | | import { AssistantContent } from './types'; |
| | | import WorkOrderDlg from './WorkOrderDlg.vue'; |
| | | import { agentStreamByPost } from '/@/api/ai/chat'; |
| | | import { getSearchMapElement } from '/@/api/map'; |
| | | import { useDrag } from '/@/hooks/useDrag'; |
| | | import { Logger } from '/@/model/logger/Logger'; |
| | | import { GaoDeSourceType, gaoDeSourceTypeMap, type OLMap } from '/@/model/map/OLMap'; |
| | | import assistantPic from '/static/images/role/assistant-200x192.png'; |
| | | import { systemGlobalConfig } from '/@/stores/global'; |
| | | import { formatDate } from '/@/utils/formatTime'; |
| | | import userPic from '/static/images/role/user-200x206.png'; |
| | | import { useDrag } from '/@/hooks/useDrag'; |
| | | |
| | | const props = defineProps<{ |
| | | olMap?: OLMap; |
| | | }>(); |
| | |
| | | handleStyle, |
| | | } = useDrag({ |
| | | handle: chatHeaderRef, |
| | | startPos: { |
| | | x: 200, |
| | | }, |
| | | }); |
| | | |
| | | const cancelSubmit = (reason) => { |
| | | refreshAssistantMessage({ reason: reason }); |
| | | }; |
| | | const submitDlg = () => { |
| | | refreshAssistantMessage({ value: `成功`, isError: false }); |
| | | }; |
| | | const historyMessages = ref<ChatMessage[]>([]); |
| | | const isInit = computed(() => historyMessages.value.length === 0); |
| | | const initQuestionList = ref([ |
| | |
| | | { title: '地图缩放', question: '放大' }, |
| | | { title: '设备显隐', question: '隐藏设备' }, |
| | | { title: '设备聚焦', question: '聚焦设备' }, |
| | | { title: '创建工单', question: '松福大道DN800松岗联通监测设备没有数据,创建一个设备维修工单,请及时派人维修。' }, |
| | | ]); |
| | | const chatContentRef = ref<HTMLDivElement>(null); |
| | | |
| | | const getLastAssistantMessage = () => { |
| | | const last = historyMessages.value[historyMessages.value.length - 1]; |
| | | const result = last.role === 'assistant' ? last : null; |
| | | const result = last?.role === 'assistant' ? last : null; |
| | | return result as ChatMessage<AssistantContent>; |
| | | }; |
| | | |
| | | const lastIsLoading = computed(() => { |
| | | const last = getLastAssistantMessage(); |
| | | const loading = last?.content?.isLoading ?? false; |
| | | return loading; |
| | | }); |
| | | |
| | | //#region ====================== 添加工单 ====================== |
| | | const optDlgIsShow = ref(false); |
| | | const optDlgMapRow = ref(null); |
| | | |
| | | const openOptDlg = (row?: any) => { |
| | | optDlgMapRow.value = row; |
| | | optDlgIsShow.value = true; |
| | | }; |
| | | |
| | | const submit = () => {}; |
| | | //#endregion |
| | | |
| | | const mockCommand = (question: string) => { |
| | | if (question === `切换${gaoDeSourceTypeMap[GaoDeSourceType.Vector]}`) { |
| | |
| | | } |
| | | }; |
| | | |
| | | const handleCreateWorkOrder = (formData: any) => { |
| | | openOptDlg(formData ?? {}); |
| | | }; |
| | | |
| | | const handleSwitchLayer = (formData: { layerId: string; visible: boolean }) => { |
| | | props.olMap.setLayerVisible(formData.layerId, formData.visible); |
| | | refreshAssistantMessage({ value: `成功`, isError: false }); |
| | | }; |
| | | |
| | | const changeTheme = (formData: { themeId: string }) => { |
| | | props.olMap.setThemeById(formData.themeId); |
| | | refreshAssistantMessage({ value: `成功`, isError: false }); |
| | | }; |
| | | |
| | | const handleQueryObject = async (formData: { objectName: string }) => { |
| | | const res = await getSearchMapElement({ |
| | | search_text: formData.objectName, |
| | | max_count: 10, |
| | | time: formatDate(new Date()), |
| | | }); |
| | | const result = res?.values ?? []; |
| | | props.olMap.clearObjectSearch(); |
| | | const features = []; |
| | | for (const item of result) { |
| | | if (!item.WKT) continue; |
| | | const feature = props.olMap.readWKT(item.WKT); |
| | | features.push(feature); |
| | | } |
| | | props.olMap.zoomToFeatures(features); |
| | | props.olMap.highlightSearch(features); |
| | | refreshAssistantMessage({ value: `成功`, isError: false }); |
| | | }; |
| | | |
| | | const handleSearchAddress = async (formData: { address: string }) => { |
| | | const result = await props.olMap.gaodeAddressSearch(formData.address, systemGlobalConfig.value['ui.project_city']); |
| | | const pois = result?.pois ?? []; |
| | | const searchResultList = |
| | | pois |
| | | .map((item) => { |
| | | return { |
| | | name: item.name, |
| | | // address: item.address, |
| | | cityname: item.cityname, |
| | | adname: item.adname, |
| | | model: item, |
| | | isSearchObj: false, |
| | | location: item.location, |
| | | }; |
| | | }) |
| | | ?.slice(0, 5) ?? []; |
| | | |
| | | const features = []; |
| | | for (const item of searchResultList) { |
| | | if (!item.location) continue; |
| | | const [lon, lat] = item.location.split(',').map(Number); |
| | | const point = fromLonLat([lon, lat]); |
| | | const WKT = `SRID=3857;POINT(${point[0]} ${point[1]})`; |
| | | const feature = props.olMap.readWKT(WKT); |
| | | features.push(feature); |
| | | } |
| | | props.olMap.highlightSearch(features); |
| | | props.olMap.zoomToFeatures(features); |
| | | refreshAssistantMessage({ value: `成功`, isError: false }); |
| | | }; |
| | | |
| | | const handleSetBackgroundLayer = (formData: { LayerId: string }) => { |
| | | if (!formData.LayerId) return; |
| | | props.olMap.setSourceType(formData.LayerId as GaoDeSourceType); |
| | | refreshAssistantMessage({ value: `成功`, isError: false }); |
| | | }; |
| | | let lastAxiosSource: CancelTokenSource = null; |
| | | |
| | | const startStream = (question: string) => { |
| | | if (lastIsInit) { |
| | | showHistory.value = false; |
| | | } |
| | | |
| | | const currentSource = axios.CancelToken.source(); |
| | | lastAxiosSource = currentSource; |
| | | // if (question === '松福大道DN800松岗联通监测设备没有数据,创建一个设备维修工单,请及时派人维修。') { |
| | | // setTimeout(() => { |
| | | // openOptDlg(); |
| | | // }, 400); |
| | | // return; |
| | | // } |
| | | |
| | | // mockCommand(question); |
| | | // return; |
| | |
| | | (chunkRes) => { |
| | | Logger.info('agent stream response:\n\n' + JSON.stringify(chunkRes)); |
| | | |
| | | if (chunkRes.mode === 'map') { |
| | | haveMapOperate = true; |
| | | if (chunkRes.type === 'string') { |
| | | const jsonData = JSON.parse(chunkRes.value); |
| | | handleMapCommand(jsonData); |
| | | if ( |
| | | chunkRes.type === 'string' && |
| | | ['create_work_order', 'switch_layers', 'switch_topic', 'query_address', 'query_object', 'map'].includes(chunkRes.mode) |
| | | ) { |
| | | const jsonData = JSON.parse(chunkRes.value); |
| | | |
| | | switch (chunkRes.mode) { |
| | | case 'create_work_order': |
| | | haveMapOperate = true; |
| | | handleCreateWorkOrder(jsonData); |
| | | break; |
| | | |
| | | case 'switch_layers': |
| | | haveMapOperate = true; |
| | | handleSwitchLayer(jsonData); |
| | | break; |
| | | |
| | | case 'switch_topic': |
| | | haveMapOperate = true; |
| | | changeTheme(jsonData); |
| | | break; |
| | | |
| | | case 'switch_background_layers': |
| | | haveMapOperate = true; |
| | | handleSetBackgroundLayer(jsonData); |
| | | break; |
| | | |
| | | case 'query_address': |
| | | haveMapOperate = true; |
| | | handleSearchAddress(jsonData); |
| | | break; |
| | | |
| | | case 'query_object': |
| | | haveMapOperate = true; |
| | | handleQueryObject(jsonData); |
| | | break; |
| | | case 'map': |
| | | haveMapOperate = true; |
| | | handleMapCommand(jsonData); |
| | | break; |
| | | } |
| | | } |
| | | |
| | | if (chunkRes.mode === 'finish') { |
| | | if (!haveMapOperate) { |
| | | refreshAssistantMessage({ value: `未识别到操作:${question}`, isError: true }); |
| | | refreshAssistantMessage({ reason: `未识别到操作:"${question}"` }); |
| | | } |
| | | } |
| | | }, |
| | | { |
| | | cancelToken: currentSource.token, |
| | | } |
| | | ).catch((error) => { |
| | | Logger.error('agent stream error:\n\n' + error); |
| | | refreshAssistantMessage(); |
| | | refreshAssistantMessage({ reason: 'AI回答失败' }); |
| | | }); |
| | | }; |
| | | |
| | | const refreshAssistantMessage = (content: Partial<AssistantContent> = { value: 'AI回答失败', isError: true }) => { |
| | | const refreshAssistantMessage = ( |
| | | content: Partial<AssistantContent> = { value: '失败', isError: true, reason: '', isLoading: false } |
| | | ) => { |
| | | const cloneContent = cloneDeep(content); |
| | | const last = getLastAssistantMessage(); |
| | | content = defaults(cloneContent, { |
| | | value: '失败', |
| | | isError: true, |
| | | reason: '', |
| | | isLoading: false, |
| | | }); |
| | | if (last) { |
| | | last.content.value = content.value; |
| | | last.content.isLoading = content.isLoading; |
| | | last.content.isError = content.isError ?? false; |
| | | for (const key in content) { |
| | | if (Object.prototype.hasOwnProperty.call(content, key)) { |
| | | last.content[key] = content[key]; |
| | | } |
| | | } |
| | | } |
| | | }; |
| | | |
| | | const handleMapCommand = (command: any) => { |
| | | if (!command) return; |
| | | // openOptDlg(); |
| | | switch (command.operate) { |
| | | case '放大': |
| | | props.olMap.zoomIn(); |
| | |
| | | break; |
| | | |
| | | case '显示设备': |
| | | props.olMap.toggleMarkerOverlayVisible(true); |
| | | const equipOverlay = props.olMap.getEquipOverlay(); |
| | | if (equipOverlay) { |
| | | equipOverlay.isVisible = true; |
| | | // 强制触发更新 |
| | | props.olMap.layerInfo.value = props.olMap.layerInfo.value.concat([]); |
| | | // props.olMap.toggleMarkerOverlayVisible(true); |
| | | } |
| | | break; |
| | | case '隐藏设备': |
| | | props.olMap.toggleMarkerOverlayVisible(false); |
| | | const equipOverlay1 = props.olMap.getEquipOverlay(); |
| | | if (equipOverlay1) { |
| | | equipOverlay1.isVisible = false; |
| | | // 强制触发更新 |
| | | props.olMap.layerInfo.value = props.olMap.layerInfo.value.concat([]); |
| | | // props.olMap.toggleMarkerOverlayVisible(false); |
| | | } |
| | | break; |
| | | case '聚焦设备': |
| | | props.olMap.adjustViewToMarkers(); |
| | | break; |
| | | } |
| | | refreshAssistantMessage({ value: `${command.operate}成功` }); |
| | | refreshAssistantMessage({ value: `成功`, isError: false }); |
| | | }; |
| | | |
| | | const scrollToBottom = () => { |
| | |
| | | }); |
| | | }; |
| | | //#endregion |
| | | |
| | | //#region ====================== 流停止 ====================== |
| | | const stopGenClick = () => { |
| | | lastAxiosSource?.cancel(); |
| | | const last = getLastAssistantMessage(); |
| | | if (!last) return; |
| | | last.content.isLoading = false; |
| | | last.content.isError = true; |
| | | last.content.reason = '用户停止操作'; |
| | | }; |
| | | //#endregion |
| | | onMounted(() => {}); |
| | | </script> |
| | | |