From 457cc6cf166d3b6c22be4f78c1db8802a7fbb4c7 Mon Sep 17 00:00:00 2001
From: wujingjing <gersonwu@qq.com>
Date: 星期一, 07 四月 2025 17:53:19 +0800
Subject: [PATCH] DigitalHuman

---
 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..d7dbd39 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