<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-[540px] absolute bottom-4 right-4">
|
<!-- 头部 -->
|
<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 />
|
</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" ref="chatContentRef" v-show="showHistory">
|
<div v-if="isInit">
|
<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
|
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.question)"
|
>
|
{{ 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-1.5">
|
<!-- 对话内容 -->
|
<div class="flex flex-col gap-4" v-for="(item, index) in historyMessages" :key="item.id">
|
<!-- 用户提问 -->
|
<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 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'">
|
<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正在思考中...
|
</div>
|
<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 class="p-2 border-t">
|
<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 { systemGlobalConfig } from '/@/stores/global';
|
import { formatDate } from '/@/utils/formatTime';
|
import userPic from '/static/images/role/user-200x206.png';
|
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,
|
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: '聚焦设备' },
|
{ 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;
|
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(
|
{
|
question: question,
|
agent_id: 'a_019471cdb0667a83956b76ac97283f1c',
|
},
|
(chunkRes) => {
|
Logger.info('agent stream response:\n\n' + JSON.stringify(chunkRes));
|
|
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({ reason: `未识别到操作:"${question}"` });
|
}
|
}
|
},
|
{
|
cancelToken: currentSource.token,
|
}
|
).catch((error) => {
|
Logger.error('agent stream error:\n\n' + error);
|
refreshAssistantMessage({ reason: '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) {
|
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.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: `成功`, isError: false });
|
};
|
|
const scrollToBottom = () => {
|
if (chatContentRef.value) {
|
chatContentRef.value.scrollTop = chatContentRef.value.scrollHeight;
|
}
|
};
|
|
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);
|
|
nextTick(() => {
|
scrollToBottom();
|
});
|
const userReactive = historyMessages.value.find((item) => item.id === userMessageId);
|
const assistantReactive = historyMessages.value.find((item) => item.id === assistantMessageId);
|
return [userReactive, assistantReactive];
|
};
|
|
const sendClick = async () => {
|
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 = '';
|
|
startStream(question);
|
};
|
|
const handleQuestionClick = (item: string) => {
|
inputText.value = item;
|
sendClick();
|
};
|
|
//#region ====================== 历史显示控制 ======================
|
const showHistory = ref(true);
|
const toggleHistory = () => {
|
showHistory.value = !showHistory.value;
|
nextTick(() => {
|
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>
|
|
<style scoped lang="scss">
|
: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>
|