wujingjing
2025-02-07 4c20089472b20319746649decbce3a11f16cb6a0
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,48 +28,107 @@
         <!-- 内容区 -->
         <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>
@@ -84,10 +148,11 @@
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;
@@ -103,13 +168,22 @@
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 = () => {
@@ -118,10 +192,35 @@
   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(
      {
@@ -140,23 +239,36 @@
         }
         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) {
@@ -167,8 +279,28 @@
      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 = () => {
@@ -215,7 +347,7 @@
   const [userMessage, assistantMessage] = applyMessage();
   const question = inputText.value;
   inputText.value = '';
   startStream(question);
};
@@ -240,4 +372,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>