| | |
| | | <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 /> |
| | |
| | | <!-- 内容区 --> |
| | | <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 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'"> |
| | | <!-- <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"> |
| | | <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> |
| | |
| | | import { AssistantContent } from './types'; |
| | | import { agentStreamByPost } from '/@/api/ai/chat'; |
| | | import { Logger } from '/@/model/logger/Logger'; |
| | | import type { OLMap } from '/@/model/map/OLMap'; |
| | | 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 chatContainerRef = ref<HTMLDivElement>(null); |
| | | |
| | | const { startDrag, style: chatContainerStyle, handleStyle } = useDrag({ |
| | | const { |
| | | startDrag, |
| | | style: chatContainerStyle, |
| | | handleStyle, |
| | | } = useDrag({ |
| | | handle: chatHeaderRef, |
| | | }); |
| | | |
| | | 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: '聚焦设备' }, |
| | | ]); |
| | | const chatContentRef = ref<HTMLDivElement>(null); |
| | | |
| | | const getLastAssistantMessage = () => { |
| | |
| | | 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( |
| | | { |
| | |
| | | } |
| | | if (chunkRes.mode === 'finish') { |
| | | if (!haveMapOperate) { |
| | | refreshAssistantMessage({ value: `未识别到操作:${question}` }); |
| | | refreshAssistantMessage({ reason: `未识别到操作:"${question}"` }); |
| | | } |
| | | } |
| | | } |
| | | ).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; |
| | | switch (command.operate) { |
| | |
| | | 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: `已执行操作: ${command.operate}` }); |
| | | refreshAssistantMessage({ value: `成功`, isError: false }); |
| | | }; |
| | | |
| | | const scrollToBottom = () => { |
| | |
| | | const [userMessage, assistantMessage] = applyMessage(); |
| | | const question = inputText.value; |
| | | inputText.value = ''; |
| | | |
| | | |
| | | startStream(question); |
| | | }; |
| | | |
| | |
| | | :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> |