<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 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 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 v-model="inputText" @sendClick="sendClick" @toggleHistory="toggleHistory" :showHistory="showHistory" />
|
</div>
|
</div>
|
</div>
|
</template>
|
|
<script setup lang="ts" name="smallChat">
|
import { computed, nextTick, onMounted, ref } from 'vue';
|
import ChatInput from './ChatInput.vue';
|
import type { ChatMessage } from './types';
|
import { AssistantContent } from './types';
|
import { agentStreamByPost } from '/@/api/ai/chat';
|
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 userPic from '/static/images/role/user-200x206.png';
|
import { useDrag } from '/@/hooks/useDrag';
|
import { cloneDeep, defaults } from 'lodash-es';
|
|
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 historyMessages = ref<ChatMessage[]>([]);
|
const isInit = computed(() => historyMessages.value.length === 0);
|
const initQuestionList = ref([
|
{ title: '图层切换', question: '切换卫星地图' },
|
{ title: '地图缩放', question: '放大' },
|
{ title: '设备显隐', question: '隐藏设备' },
|
{ title: '设备聚焦', question: '聚焦设备' },
|
]);
|
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 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 startStream = (question: string) => {
|
if (lastIsInit) {
|
showHistory.value = false;
|
}
|
|
// 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.mode === 'map') {
|
haveMapOperate = true;
|
if (chunkRes.type === 'string') {
|
const jsonData = JSON.parse(chunkRes.value);
|
handleMapCommand(jsonData);
|
}
|
}
|
if (chunkRes.mode === 'finish') {
|
if (!haveMapOperate) {
|
refreshAssistantMessage({ reason: `未识别到操作:"${question}"` });
|
}
|
}
|
}
|
).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;
|
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 '显示设备':
|
props.olMap.toggleMarkerOverlayVisible(true);
|
break;
|
case '隐藏设备':
|
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
|
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>
|