From 7fbce1ecd95b4e12ceda0a5b874ec8f3951625f7 Mon Sep 17 00:00:00 2001
From: wujingjing <gersonwu@qq.com>
Date: 星期五, 17 一月 2025 17:58:59 +0800
Subject: [PATCH] WI水务智能助理

---
 src/api/ai/chat.ts                                                                       |   24 +
 src/components/chat/chatComponents/summaryCom/components/recordSetTable/map/Map.vue      |   17 +
 vite.config.ts                                                                           |    2 
 src/model/map/OLMap.ts                                                                   |   18 +
 src/components/chat/smallChat/ChatInput.vue                                              |   84 +++++++
 src/components/chat/components/playBar/PlayBar.vue                                       |   42 ---
 src/components/chat/smallChat/types.ts                                                   |   15 +
 src/components/chat/smallChat/index.vue                                                  |  254 +++++++++++++++++++++++
 src/components/chat/smallChat/chatInput.scss                                             |  154 ++++++++++++++
 src/components/chat/chatComponents/summaryCom/components/recordSetTable/map/BasicMap.vue |    2 
 10 files changed, 563 insertions(+), 49 deletions(-)

diff --git a/src/api/ai/chat.ts b/src/api/ai/chat.ts
index 20bcaa5..df775d7 100644
--- a/src/api/ai/chat.ts
+++ b/src/api/ai/chat.ts
@@ -273,7 +273,24 @@
 		},
 		callback
 	);
-
+/**
+ * @description 娴佸紡澶фā鍨嬪璇�
+ * @param {FormData} params
+ **/
+export const agentStreamByPost = (params, callback: (chunkRes) => void, extraData: any = {}) =>
+	streamReq(
+		{
+			url: `/chat/agent_stream`,
+			method: 'post',
+			data: params,
+			params: {},
+			headers: {
+				'Content-Type': 'application/x-www-form-urlencoded',
+			},
+			...extraData,
+		},
+		callback
+	);
 /**
  * @summary AI澶фā鍨嬪璇�
  */
@@ -503,8 +520,7 @@
 		},
 	});
 
-
-	export const question_stream_reply = (params) =>
+export const question_stream_reply = (params) =>
 	request({
 		url: `/chat/question_stream_reply`,
 		method: 'post',
@@ -512,4 +528,4 @@
 		headers: {
 			'Content-Type': 'application/x-www-form-urlencoded',
 		},
-	});
\ No newline at end of file
+	});
diff --git a/src/components/chat/chatComponents/summaryCom/components/recordSetTable/map/BasicMap.vue b/src/components/chat/chatComponents/summaryCom/components/recordSetTable/map/BasicMap.vue
index 912640b..75dd1d4 100644
--- a/src/components/chat/chatComponents/summaryCom/components/recordSetTable/map/BasicMap.vue
+++ b/src/components/chat/chatComponents/summaryCom/components/recordSetTable/map/BasicMap.vue
@@ -149,6 +149,8 @@
 
 onMounted(() => {
 	initMap();
+	// window.olMap = olMap.value;
+	// window.map = olMap.value.map;
 });
 </script>
 <style scoped lang="scss"></style>
diff --git a/src/components/chat/chatComponents/summaryCom/components/recordSetTable/map/Map.vue b/src/components/chat/chatComponents/summaryCom/components/recordSetTable/map/Map.vue
index 66c202e..6ba59b7 100644
--- a/src/components/chat/chatComponents/summaryCom/components/recordSetTable/map/Map.vue
+++ b/src/components/chat/chatComponents/summaryCom/components/recordSetTable/map/Map.vue
@@ -20,7 +20,14 @@
 		<Teleport to=".layout-parent">
 			<Transition name="fullscreen">
 				<div v-if="isRenderFullscreen" v-show="isFullscreen" class="absolute inset-0 z-50 w-full h-full">
-					<BasicMap ref="fullScreenMapRef" :config="fullScreenMapConfig" class="h-full" :data="data" @markerClick="markerClick" @closeInfoWindow="closeInfoWindow" />
+					<BasicMap
+						ref="fullScreenMapRef"
+						:config="fullScreenMapConfig"
+						class="h-full"
+						:data="data"
+						@markerClick="markerClick"
+						@closeInfoWindow="closeInfoWindow"
+					/>
 					<div class="absolute right-2 top-2 cursor-pointer" @click="toggleFullScreen">
 						<el-tooltip content="閫�鍑哄叏灞忥紙Esc锛�" placement="top">
 							<div class="ywifont !text-[20px] text-black rounded-lg ywicon-tuichuquanping"></div>
@@ -36,6 +43,7 @@
 							:tableHeight="240"
 						/>
 					</div>
+					<SmallChat class="absolute bottom-0 right-0" :olMap="fullScreenOlMap" />
 				</div>
 			</Transition>
 		</Teleport>
@@ -44,17 +52,19 @@
 
 <script setup lang="ts">
 import 'ol/ol.css';
-import { nextTick, onDeactivated, onMounted, onUnmounted, ref } from 'vue';
+import { computed, nextTick, onDeactivated, onMounted, onUnmounted, ref } from 'vue';
 import EquipCurve from '../components/EquipCurve.vue';
 import BasicMap from './BasicMap.vue';
-import { useCompRef } from '/@/utils/types';
+import SmallChat from '/@/components/chat/smallChat/index.vue';
 import { GaoDeSourceType } from '/@/model/map/OLMap';
+import { useCompRef } from '/@/utils/types';
 const isRenderFullscreen = ref(false);
 
 const isFullscreen = ref(false);
 const props = defineProps(['data']);
 const normalMapRef = useCompRef(BasicMap);
 const fullScreenMapRef = useCompRef(BasicMap);
+const fullScreenOlMap = computed(() => fullScreenMapRef.value?.olMap);
 const emit = defineEmits(['equipClick', 'closeInfoWindow']);
 
 const markerClick = (row) => {
@@ -71,7 +81,6 @@
 		emit('closeInfoWindow');
 	}
 };
-
 
 const fullScreenMapConfig = ref({
 	sourceType: GaoDeSourceType.Vector,
diff --git a/src/components/chat/components/playBar/PlayBar.vue b/src/components/chat/components/playBar/PlayBar.vue
index fbcd44a..aba898c 100644
--- a/src/components/chat/components/playBar/PlayBar.vue
+++ b/src/components/chat/components/playBar/PlayBar.vue
@@ -31,10 +31,11 @@
 				placeholder="鍦ㄨ繖閲岃緭鍏ユ偍鐨勯棶棰樺紑濮嬪拰AI瀵硅瘽"
 				clearable
 			>
+				
 			</el-input>
 			<InputTip ref="inputTipRef" :inputValue="inputValue" @updateInputValue="updateInputValue" :isHome="isHome" />
 		</div>
-		<div class="h100 flex items-center">
+		<div class="h100 flex items-end">
 			<div class="upload_img space-y">
 				<div class="imgbox cursor-pointer flex items-center">
 					<el-button
@@ -111,46 +112,7 @@
 	type: String,
 });
 const inputRef = ref<InputInstance>(null);
-// useSyncMsg({
-// 		msgList: props.msgList,
-// 		updateLoadIndex: () => {},
-// 	});
-const test = () => {
-	// useSyncMsg({
-	// 	msgList: props.msgList,
-	// 	updateLoadIndex: () => {},
-	// });
-	return;
-	const recentIds = [
-		{ id: 'a1b2c3d4', time: '2024-03-27 15:42:33' },
-		{ id: 'e5f6g7h8', time: '2024-02-15 09:23:45' },
-		{ id: 'i9j0k1l2', time: '2024-05-08 14:37:21' },
-		{ id: 'm3n4o5p6', time: '2024-01-30 11:55:16' },
-		{ id: 'q7r8s9t0', time: '2024-07-12 16:48:59' },
-		{ id: 'u1v2w3x4', time: '2024-04-03 10:15:27' },
-		{ id: 'y5z6a7b8', time: '2024-06-21 13:29:44' },
-		{ id: 'c9d0e1f2', time: '2024-08-09 17:52:38' },
-		{ id: 'g3h4i5j6', time: '2024-09-14 12:33:51' },
-		{ id: 'k7l8m9n0', time: '2024-10-25 08:19:07' },
-	];
-	// const userHistoryIds = reverse(msgList.value.filter((item) => item.role === RoleEnum.user).map((item) => item.historyId));
-	const currentHistoryIds = [
-		{ id: 'a1b2c3d4', time: '2024-03-27 15:42:33' },
-		{ id: 'e5f6g7h8', time: '2024-02-15 09:23:45' },
-		// {id: 'i9j0k1l2', time: '2024-05-08 14:37:21'},
-		// {id: 'm3n4o5p6', time: '2024-01-30 11:55:16'},
-		{ id: 'q7r8s9t0', time: '2024-07-12 16:48:59' },
-		// {id: 'u1v2w3x4', time: '2024-04-03 10:15:27'},
-		{ id: 'y5z6a7b8', time: '2024-06-21 13:29:44' },
-		{ id: 'c9d0e1f2', time: '2024-08-09 17:52:38' },
-		{ id: 'g3h4i5j6', time: '2024-09-14 12:33:51' },
-		// {id: 'k7l8m9n0', time: '2024-10-25 08:19:07'},
-	];
 
-	const unSyncedHistoryIds = differenceBy(recentIds, currentHistoryIds, 'id');
-	const unSyncedUserMsgs = [];
-	// const unSyncedMsgs =await loadReplyData(unSyncedUserMsgs);
-};
 const updateInputValue = (val) => {
 	inputValue.value = val;
 };
diff --git a/src/components/chat/smallChat/ChatInput.vue b/src/components/chat/smallChat/ChatInput.vue
new file mode 100644
index 0000000..7e78a9f
--- /dev/null
+++ b/src/components/chat/smallChat/ChatInput.vue
@@ -0,0 +1,84 @@
+<template>
+	<div class="playInput !w-full hl_input rounded-[22px] input-border input-shadow">
+		<div class="set-input">
+			<!-- @input="inputText" -->
+			<el-input
+				ref="inputRef"
+				class="question-input relative align-bottom set-inputAnswer"
+				type="textarea"
+				resize="none"
+				maxlength="1024"
+				:autosize="{ minRows: 1, maxRows: 3 }"
+				v-elInputFocus
+				show-word-limit
+				v-model="inputText"
+				placeholder="鍦ㄨ繖閲岃緭鍏ユ偍鐨勯棶棰樺紑濮嬪拰AI瀵硅瘽"
+				clearable
+				@keyup.enter="emits('sendClick')"
+			>
+			</el-input>
+		</div>
+		<div class="h100 flex items-end">
+			<div class="upload_img space-y">
+				<div class="imgbox cursor-pointer flex items-center">
+					<el-button
+						title="娓呴櫎"
+						class="cursor-pointer !text-gray-500"
+						link
+						style="margin-left: unset; margin-right: 0px"
+						@click="clearTextarea"
+						icon="ele-Close"
+						v-if="inputText"
+					>
+					</el-button>
+					<el-tooltip placement="top" content="鍋滄鐢熸垚" v-if="isTalking">
+						<div class="cursor-pointer !ml-0 size-[38px] bg-[#1d86ff] relative !z-10 rounded-full flex-center" link>
+							<div
+								class="size-[38px] bg-black text-white stop-breathe box-content rounded-full flex-center"
+								@click="emits('stopGenClick')"
+							>
+								<span class="ywifont ywicon-jieshu"></span>
+							</div>
+						</div>
+					</el-tooltip>
+					<el-tooltip v-else placement="top" content="鍙戦��">
+						<div class="size-[38px] rounded-full bg-black flex-center" @click="emits('sendClick')">
+							<img src="/static/images/wave/QueryImg.png" />
+						</div>
+					</el-tooltip>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts" name="ChatInput">
+import { onMounted } from 'vue';
+import { Logger } from '/@/model/logger/Logger';
+import { agentStreamByPost } from '/@/api/ai/chat';
+
+const inputText = defineModel('modelValue', {
+	type: String,
+	default: '',
+});
+const props = defineProps({
+	isTalking: {
+		type: Boolean,
+		default: false,
+	},
+});
+const emits = defineEmits(['sendClick', 'stopGenClick']);
+
+const clearTextarea = () => {
+	inputText.value = '';
+};
+
+
+</script>
+<style scoped lang="scss">
+@use './chatInput.scss';
+.playInput {
+	--y-padding: 7px;
+	--x-padding: 14px;
+}
+</style>
diff --git a/src/components/chat/smallChat/chatInput.scss b/src/components/chat/smallChat/chatInput.scss
new file mode 100644
index 0000000..71911cc
--- /dev/null
+++ b/src/components/chat/smallChat/chatInput.scss
@@ -0,0 +1,154 @@
+.set-waterTitle {
+	line-height: 24px;
+	font-weight: 500;
+	font-size: 18px;
+	color: #3b4066;
+	vertical-align: middle;
+}
+strong {
+	font-size: 26px;
+	font-weight: 700;
+	margin-right: 12px;
+}
+.layout-logo-medium-img {
+	width: 28px;
+	margin-right: 7px;
+}
+.pc-roleList {
+	margin: 40px 0 26px;
+	position: relative;
+}
+.modelItem {
+	height: 34px;
+	padding: 0 16px;
+	border-radius: 17px;
+	border: 1px solid #00000020;
+	background-color: #f2f4f8;
+	transition: background-color 0.1s, border-color 0.1s, color 0.1s;
+	color: #333;
+	.set-icon {
+		width: 20px;
+		height: 20px;
+		position: relative;
+	}
+	.set-icon-more {
+		width: 16px;
+		height: 16px;
+		position: relative;
+	}
+	span {
+		margin-left: 8px;
+		font-weight: 500;
+		font-size: 15px;
+	}
+}
+.modelItemActive {
+	background-color: #1c86ff;
+	border-color: #1c86ff;
+	color: #fff;
+}
+
+.input-shadow {
+	-webkit-box-shadow: 0 0 0 1px transparent, 0 3px 16px 0 #dee0f3;
+	box-shadow: 0 0 0 1px transparent, 0 3px 16px 0 #dee0f3;
+}
+
+.input-border {
+	border: 1px solid #00000030;
+
+	-webkit-transition: border-color 0.1s ease-in-out, -webkit-box-shadow 0.1s ease-in-out;
+	transition: border-color 0.1s ease-in-out, -webkit-box-shadow 0.1s ease-in-out;
+	-o-transition: border-color 0.1s ease-in-out, box-shadow 0.1s ease-in-out;
+	transition: border-color 0.1s ease-in-out, box-shadow 0.1s ease-in-out;
+	transition: border-color 0.1s ease-in-out, box-shadow 0.1s ease-in-out, -webkit-box-shadow 0.1s ease-in-out;
+}
+.playInput {
+
+	--y-padding: 9px;
+	--x-padding: 7px;
+	align-items: flex-start;
+	width: 760px;
+	position: relative;
+
+	padding: var(--y-padding) var(--x-padding) var(--y-padding) var(--x-padding);
+
+	display: flex;
+	-webkit-box-align: end;
+	-ms-flex-align: end;
+	align-items: flex-end;
+	-webkit-box-sizing: border-box;
+	box-sizing: border-box;
+
+	background-color: #fff;
+	.assembly {
+		position: relative;
+		align-self: flex-end;
+		margin-right: 12px;
+		.label {
+			height: 38px;
+		}
+	}
+	.set-input {
+		position: relative;
+		vertical-align: bottom;
+		font-size: 14px;
+		display: inline-block;
+		width: 100%;
+		.set-inputAnswer {
+			padding: 3px 0;
+			line-height: 20px;
+			border: none;
+			background-color: transparent;
+			color: #333;
+			font-size: 15px;
+			:deep(.el-textarea__inner) {
+				// 鍘婚櫎绾�
+				box-shadow: none;
+				padding:5px 2px
+			}
+		}
+		:deep(.el-input__wrapper) {
+			box-shadow: unset;
+		}
+	}
+	.upload_img {
+		.imgbox {
+			height: 38px;
+			.set-img-icon {
+				width: 38px;
+				height: 38px;
+				border-radius: 5px;
+				transition: background-color 0.1s ease-in-out;
+			}
+			.send {
+				width: 36px;
+				height: 36px;
+				border-radius: 50%;
+				background-color: #2c1e1d;
+				img {
+					margin: 4px 0 0 -2px;
+				}
+			}
+		}
+	}
+}
+
+.stop-breathe {
+	@keyframes breathe {
+		0%,
+		100% {
+			transform: scale(0.6);
+		}
+		50% {
+			transform: scale(1);
+		}
+	}
+
+	animation: breathe 1.8s infinite ease-in-out;
+}
+
+.question-input {
+	:deep(.el-input__count) {
+		@apply text-gray-400;
+	}
+}
diff --git a/src/components/chat/smallChat/index.vue b/src/components/chat/smallChat/index.vue
new file mode 100644
index 0000000..e40d41a
--- /dev/null
+++ b/src/components/chat/smallChat/index.vue
@@ -0,0 +1,254 @@
+<template>
+	<div class="relative">
+		<el-tooltip v-if="!isOpen" content="浣犲ソ锛屾垜鏄疉I鍔╃悊锛屽彲浠ヨВ绛旈棶棰樸�佹帹鑽愯В鍐虫柟妗堢瓑" 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 class="h-12 flex items-center justify-between px-4 border-b">
+				<div class="text-base 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>
+				<el-icon class="cursor-pointer text-gray-400 hover:text-gray-600">
+					<FullScreen />
+				</el-icon>
+				<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">
+						<Close />
+					</el-icon>
+				</div>
+			</div>
+
+			<!-- 鍐呭鍖� -->
+			<div class="flex-1 overflow-y-auto p-4">
+				<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>
+					<!-- 蹇嵎闂 -->
+					<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)"
+						>
+							{{ 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 class="flex flex-col gap-4" v-for="item in historyMessages" :key="item.id">
+						<!-- 鐢ㄦ埛鎻愰棶 -->
+						<div class="flex gap-3" 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>
+						</div>
+
+						<!-- AI鍥炵瓟 -->
+						<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">
+									{{ item.content.value }}
+								</div>
+							</div>
+						</div>
+					</div>
+				</div>
+			</div>
+
+			<!-- 搴曢儴杈撳叆妗� -->
+			<div class="p-4 border-t">
+				<ChatInput v-model="inputText" @sendClick="sendClick" />
+			</div>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts" name="smallChat">
+import { computed, 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 { 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';
+
+const props = defineProps<{
+	olMap?: OLMap;
+}>();
+const isOpen = ref(false);
+const inputText = ref('');
+const closeChat = () => {
+	isOpen.value = false;
+};
+const historyMessages = ref<ChatMessage[]>([]);
+const isInit = computed(() => historyMessages.value.length === 0);
+const initQuestionList = ref(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 = () => {
+	agentStreamByPost(
+		{
+			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') {
+				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;
+				}
+			}
+		}
+	);
+};
+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;
+
+			case '缂╁皬':
+				props.olMap.zoomOut();
+				break;
+		}
+		last.content.isLoading = false;
+	}
+};
+
+const scrollToBottom = () => {
+	const chatContainer = document.querySelector('.chat-container');
+	if (chatContainer) {
+		chatContainer.scrollTop = chatContainer.scrollHeight;
+	}
+};
+const openChat = () => {
+	isOpen.value = true;
+	if (!streamIsOpen) {
+		startStream();
+	}
+};
+const applyMessage = () => {
+	const time = Date.now().toString();
+	const userMessageId = `user-${time}`;
+	const userMessage: ChatMessage = {
+		id: userMessageId,
+		role: 'user',
+		content: {
+			value: inputText.value,
+		},
+	};
+	historyMessages.value.push(userMessage);
+	const assistantMessageId = `assistant-${time}`;
+	const assistantMessage: ChatMessage = {
+		id: assistantMessageId,
+		role: 'assistant',
+		content: {
+			value: '',
+			isLoading: true,
+		},
+	};
+	historyMessages.value.push(assistantMessage);
+	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;
+	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();
+};
+
+const handleQuestionClick = (item: string) => {
+	inputText.value = item;
+	sendClick();
+};
+const isFinish = ref(false);
+let activeQuestionChunk = null;
+onMounted(() => {});
+</script>
+
+<style scoped lang="scss">
+:deep(.el-input__wrapper) {
+	padding-right: 0;
+}
+</style>
diff --git a/src/components/chat/smallChat/types.ts b/src/components/chat/smallChat/types.ts
new file mode 100644
index 0000000..0e929cb
--- /dev/null
+++ b/src/components/chat/smallChat/types.ts
@@ -0,0 +1,15 @@
+export type AssistantContent = {
+	value:string;
+	isLoading: boolean;
+};
+
+export type UserContent = {
+	value: string;
+};
+
+export interface ChatMessage<T = UserContent | AssistantContent> {
+	id: string;
+	role: 'user' | 'assistant';
+	content: T;
+}
+
diff --git a/src/model/map/OLMap.ts b/src/model/map/OLMap.ts
index 4d51821..55c97df 100644
--- a/src/model/map/OLMap.ts
+++ b/src/model/map/OLMap.ts
@@ -267,6 +267,24 @@
 		olZoom.style.display = 'none';
 	}
 
+	/**
+	 * 鏀惧ぇ鍦板浘
+	 */
+	zoomIn() {
+		const view = this.map.getView();
+		const zoom = view.getZoom();
+		view.setZoom(zoom + 1);
+	}
+
+	/**
+	 * 缂╁皬鍦板浘
+	 */
+	zoomOut() {
+		const view = this.map.getView();
+		const zoom = view.getZoom();
+		view.setZoom(zoom - 1);
+	}
+
 	getWMTS = () => {
 		const projection = getProjection('EPSG:3857');
 		const projectionExtent = projection.getExtent();
diff --git a/vite.config.ts b/vite.config.ts
index 27dae42..1c82ca0 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -58,7 +58,7 @@
 			host: '0.0.0.0',
 			port: env.VITE_PORT as unknown as number,
 			open: JSON.parse(env.VITE_OPEN),
-			hmr: true,
+			hmr: false,
 			proxy: {
 				'/events': {
 					target: 'http://localhost:3000',

--
Gitblit v1.9.3