From 92d2ea48d343fc00d81905167d033c40200ea716 Mon Sep 17 00:00:00 2001 From: wujingjing <gersonwu@qq.com> Date: 星期二, 04 三月 2025 15:56:19 +0800 Subject: [PATCH] 快速引用;去重 --- src/components/chat/smallChat/index.vue | 428 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 files changed, 386 insertions(+), 42 deletions(-) diff --git a/src/components/chat/smallChat/index.vue b/src/components/chat/smallChat/index.vue index 10bb079..d19dd9c 100644 --- a/src/components/chat/smallChat/index.vue +++ b/src/components/chat/smallChat/index.vue @@ -1,9 +1,14 @@ <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-[400px] 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" :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 + ref="chatHeaderRef" + :style="handleStyle" + 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 py-2">WI姘村姟鏅鸿兘鍔╂墜</div> <div class="flex items-center gap-2"> <!-- <el-icon class="cursor-pointer text-gray-400 hover:text-gray-600"> <Refresh /> @@ -23,72 +28,146 @@ <!-- 鍐呭鍖� --> <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"> - <img :src="assistantPic" class="w-16 h-16" alt="AI澶村儚" /> - <div class="text-lg">浣犲ソ, 鎴戞槸</div> - <div class="text-lg text-blue-500">WI姘村姟鏅鸿兘鍔╃悊</div> + <div class="mx-1"> + <div class="robot-tip mt-1 p-0.5"> + <span class="text-[14px] flex flex-col gap-1"> + <span style="font-style: italic; font-size: 15px; font-weight: 550">娆㈣繋鏉ュ埌AI涓栫晫锛屾帰绱㈡棤闄愬彲鑳斤紒 </span> + <span>鎴戞槸WI姘村姟鏅鸿兘鍔╂墜锛屼綘鍙互杈撳叆浠ヤ笅闂锛岃繘琛屽湴鍥炬搷浣溿��</span> + </span> + </div> </div> + <!-- 娆㈣繋璇� --> + <!-- <div class="flex flex-col items-center gap-1.5 mt-8"> + <div class="text-lg">浣犲ソ, 鎴戞槸</div> + <div class="text-lg text-blue-500">WI姘村姟鏅鸿兘鍔╂墜</div> + <span class="text-lg">浣犲彲浠ヨ緭鍏ヤ互涓嬮棶棰橈紝杩涜鍦板浘鎿嶄綔</span> + </div> --> <!-- 蹇嵎闂 --> - <div class="mt-8 flex flex-col gap-3 mx-10"> + <!-- <div class="mt-8 flex flex-col gap-3 mx-10"> <div v-for="(item, index) in initQuestionList" :key="index" class="cursor-pointer hover:bg-gray-50 p-3 rounded-lg border border-solid border-gray-400" - @click="handleQuestionClick(item)" + @click="handleQuestionClick(item.question)" > - {{ item }} + {{ item.title }} </div> + </div> --> + <div class="mr-1 ml-10 !mt-6 text-[13px] flex flex-col"> + <div class="space-y-2"> + <div + class="bg-gray-200 hover:bg-gray-300 cursor-pointer rounded-lg p-3" + v-for="(item, index) in initQuestionList" + :key="index" + style="width: fit-content" + @click="handleQuestionClick(item.question)" + > + <div class="over-ellisis-1" style="width: fit-content"> + {{ item.title }} + </div> + </div> + </div> + <!-- <div class="flex-items-center ml-auto mr-2 mt-1 text-gray-600 active:text-gray-500" @click="changeABatch"> + <span class="ywifont ywicon-shuaxin !text-[12px] mr-1"></span> + <span>鎹竴鎵�</span> + </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"> <el-icon class="animate-spin mr-2"><Loading /></el-icon> AI姝e湪鎬濊�冧腑... </div> - <div v-else class="p-4 rounded-lg"> + <div v-else class="p-4 rounded-lg" :class="{ 'text-danger': (item.content as AssistantContent)?.isError }"> {{ 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 { 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 { 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 { systemGlobalConfig } from '/@/stores/global'; +import { formatDate } from '/@/utils/formatTime'; +import userPic from '/static/images/role/user-200x206.png'; const props = defineProps<{ olMap?: OLMap; }>(); @@ -103,25 +182,166 @@ const chatContainerRef = ref<HTMLDivElement>(null); -const { startDrag, style: chatContainerStyle, handleStyle } = useDrag({ +const { + startDrag, + style: chatContainerStyle, + 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(['鏀惧ぇ', '缂╁皬']); +const initQuestionList = ref([ + { title: '鍥惧眰鍒囨崲', question: '鍒囨崲鍗槦鍦板浘' }, + { 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]}`) { + handleMapCommand({ operate: '鍒囨崲鏍囧噯鍦板浘' }); + return; + } else if (question === `鍒囨崲${gaoDeSourceTypeMap[GaoDeSourceType.Satellite]}`) { + handleMapCommand({ operate: '鍒囨崲鍗槦鍦板浘' }); + return; + } else if (question === `鍒囨崲${gaoDeSourceTypeMap[GaoDeSourceType.SatelliteRoad]}`) { + handleMapCommand({ operate: '鍒囨崲璺綉鍦板浘' }); + return; + } else if (question === '鏄剧ず璁惧') { + handleMapCommand({ operate: '鏄剧ず璁惧' }); + return; + } else if (question === '闅愯棌璁惧') { + handleMapCommand({ operate: '闅愯棌璁惧' }); + return; + } else if (question === '鑱氱劍璁惧') { + handleMapCommand({ operate: '鑱氱劍璁惧' }); + return; + } +}; + +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; let haveMapOperate = false; agentStreamByPost( { @@ -131,34 +351,87 @@ (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}` }); + 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鍥炵瓟澶辫触' }) => { +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; + 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(); @@ -167,8 +440,40 @@ case '缂╁皬': props.olMap.zoomOut(); break; + case `鍒囨崲${gaoDeSourceTypeMap[GaoDeSourceType.Vector]}`: + props.olMap.setSourceType(GaoDeSourceType.Vector); + break; + case `鍒囨崲${gaoDeSourceTypeMap[GaoDeSourceType.Satellite]}`: + props.olMap.setSourceType(GaoDeSourceType.Satellite); + break; + + case `鍒囨崲${gaoDeSourceTypeMap[GaoDeSourceType.SatelliteRoad]}`: + props.olMap.setSourceType(GaoDeSourceType.SatelliteRoad); + break; + + case '鏄剧ず璁惧': + 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 '闅愯棌璁惧': + 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 = () => { @@ -215,7 +520,7 @@ const [userMessage, assistantMessage] = applyMessage(); const question = inputText.value; inputText.value = ''; - + startStream(question); }; @@ -233,6 +538,17 @@ }); }; //#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> @@ -240,4 +556,32 @@ :deep(.el-input__wrapper) { padding-right: 0; } + +.robot-tip { + position: relative; + width: fit-content; + height: fit-content; + background: linear-gradient(90deg, #ccdcf5 0%, #ccdcf5 25%, #ebf3fe 55%, #ccdcf5 100%); + border: 4px solid transparent; + border-radius: 10px; +} +.robot-tip::after { + content: ''; + position: absolute; + top: 100%; + left: 20%; + transform: translateX(-50%); + border: 20px solid transparent; + border-top: 20px solid transparent; +} +.robot-tip::before { + content: ''; + position: absolute; + top: 100%; + left: 20%; + transform: translateX(-50%); + border: 20px solid transparent; + border-top: 20px solid #ccdcf5; + z-index: 1; +} </style> -- Gitblit v1.9.3