gerson
2025-02-20 a762b59c2c4b459f72ede19716d476bb3513f622
src/components/chat/Chat.vue
@@ -1,219 +1,358 @@
<template>
   <div class="flex flex-col h-full">
      <div class="h-full flex flex-col items-center overflow-y-auto">
         <div ref="chatListDom" class="h-full w-[100ch]">
            <div class="group flex px-4 py-4 hover:bg-slate-100 rounded-lg" v-for="(item, index) of computedMessageList" :key="index">
               <img class="rounded-full size-12 mr-4" :src="roleImageMap[item.role]" alt="" srcset="" />
   <ChatContainer :loading="chatListLoading" :more-is-loading="moreIsLoading" :is-share-page="isSharePage" ref="containerRef">
      <!-- 消息列表 -->
      <template #message-list>
         <MessageList
            v-if="computedMessageList?.length > 0"
            :msgList="computedMessageList"
            :isTalking="isTalking"
            @shareClick="shareClick"
            @setCommonQuestionClick="setCommonPhraseClick"
            @sendChatMessage="sendChatMessage"
            @stopGenClick="stopGenClick"
            @askMoreClick="askMoreClick"
         />
         <el-empty v-else-if="isSharePage && !chatListLoading" :image-size="200">
            <template #description>
               <span class="text-[15px]">分享的对话不存在或已失效</span>
            </template>
         </el-empty>
      </template>
               <div class="flex">
                  <div class="relative" v-if="item.content?.values">
                     <div class="text-sm rounded-[6px] p-4 leading-relaxed max-w-[100ch] bg-white">
                        <component class="max-w-[100ch]" :is="answerTypeMapCom[item.content.type]" :data="item.content.values" />
                     </div>
                     <!-- <div v-if="item.role === RoleEnum.assistant" class="absolute flex items-center right-0 space-x-2 mr-2 mt-2">
                        <SvgIcon class="cursor-pointer" name="ele-CopyDocument" @click="copyClick(item.content)" />
                        <SvgIcon class="cursor-pointer" name="ywicon icon-dianzan" />
                        <SvgIcon class="cursor-pointer" :size="12" name="ywicon icon-buzan" />
                     </div> -->
                  </div>
                  <Loding v-else />
               </div>
            </div>
      <!-- 输入区域 -->
      <template #input-area>
         <div class="w-full">
            <PlayBar
               ref="playBarRef"
               v-model:voicePageIsShow="voicePageIsShow"
               :isTalking="isTalking"
               :isHome="false"
               :msgList="computedMessageList"
               v-model="messageContent.values"
               @sendClick="sendClick"
               @stopGenClick="stopGenClick"
               :style="{ width: chatWidth }"
               class="mx-auto"
            />
         </div>
      </div>
      </template>
      <div class="sticky bottom-0 w-full p-6 pb-8 bg-gray-100 flex justify-center">
         <PlayBar :isTalking="isTalking" v-model="messageContent.values" @sendClick="sendChatMessage" />
      </div>
   </div>
      <!-- 抽屉 -->
      <template #drawer>
         <CustomDrawer v-model:isShow="drawerIsShow" @updateChatInput="updateChatInput" />
         <ShareLinkDlg ref="shareLinkDlgRef" />
      </template>
   </ChatContainer>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import useClipboard from 'vue-clipboard3';
import Loding from './components/Loding.vue';
import { RecordSet } from './model/Record';
import type { CancelTokenSource } from 'axios';
import axios from 'axios';
import { orderBy } from 'lodash-es';
import moment from 'moment';
import { computed, nextTick, onActivated, onMounted, ref } from 'vue';
import { loadAmisSource } from '../amis/load';
import { useScrollLoad } from './hooks/useScrollLoad';
import type { ChatContent } from './model/types';
import { AnswerType, RoleEnum, answerTypeMapCom, roleImageMap, type ChatMessage } from './model/types';
import { GetHistoryAnswer, QueryHistoryDetail, QuestionAi } from '/@/api/ai/chat';
import { AnswerState, AnswerType, RoleEnum, type ChatMessage } from './model/types';
import { getShareChatJsonByPost, questionStreamByPost } from '/@/api/ai/chat';
import PlayBar from '/@/components/chat/components/playBar/PlayBar.vue';
import CustomDrawer from '/@/components/drawer/CustomDrawer.vue';
import { Logger } from '/@/model/logger/Logger';
import { triggerRef } from 'vue';
import { ElLoadingService, ElMessage } from 'element-plus';
import ChatContainer from './components/ChatContainer.vue';
import ShareLinkDlg from './components/shareLink/index.vue';
import router from '/@/router';
import { activeChatRoom, activeRoomId } from '/@/stores/chatRoom';
import { ElMessage } from 'element-plus';
import { content } from 'html2canvas/dist/types/css/property-descriptors/content';
import MessageList from './messageList/index.vue';
import {
   activeChatRoom,
   activeGroupType,
   activeLLMId,
   activeRoomId,
   activeSampleId,
   isSharePage,
   roomConfig,
} from '/@/stores/chatRoom';
import emitter from '/@/utils/mitt';
import { useCompRef } from '/@/utils/types';
import { toMyFixed } from '/@/utils/util';
import { useLoadData } from './hooks/useLoadData';
import { useSyncMsg } from './hooks/useSyncMsg';
import { getCurrentPosition } from '/@/utils/brower';
const containerRef = useCompRef(ChatContainer);
const chatListDom = computed(() => containerRef.value?.chatListDom);
const scrollToBottom = () => {
   containerRef.value?.scrollToBottom();
};
const { loadReplyData, parseContent, parseExtraContent, convertProcessItem, convertProcessToStep, formatShowTimeYear } = useLoadData();
const voicePageIsShow = ref(false);
let isTalking = ref(false);
const chatWidth = computed(() => containerRef.value?.chatWidth);
let messageContent = ref<ChatContent>({
   type: AnswerType.Text,
   values: '',
});
const chatListDom = ref<HTMLDivElement>();
const currentRoute = router.currentRoute;
const currentRouteId = currentRoute.value.query.id as string;
activeRoomId.value = currentRouteId;
const messageList = ref<ChatMessage[]>([]);
const computedMessageList = computed(() => {
   return messageList.value.filter((v) => v.role !== RoleEnum.system);
   return messageList.value.filter((v) => !!v);
});
// onMounted(() => {
//    if (!activeChatRoom.value) {
//       router.replace({
//          name: 'Home',
//       });
//       return;
//    }
//    messageContent.value = {
//       type: AnswerType.Text,
//       values: activeChatRoom.value.title,
//    };
//    sendChatMessage();
// });
const getAnswerById = async (historyId: string) => {
   return await GetHistoryAnswer({
      history_id: historyId,
let questionRes = null;
const preQuestion = ref(null);
let lastAxiosSource: CancelTokenSource = null;
// 通过修改 isTalking 来触发更新
const triggerRefresh = () => {
   isTalking.value = !isTalking.value;
   nextTick(() => {
      isTalking.value = !isTalking.value;
   });
};
let currentSectionId = '';
watch(
   () => activeRoomId.value,
   async (val) => {
      if (!val) {
         router.replace({
            name: 'Home',
         });
         return;
      }
      const res = await QueryHistoryDetail({
         history_group_id: activeRoomId.value,
      });
      messageList.value = (res.details ?? []).map((item) => {
         return {
            role: RoleEnum.user,
            content: {
               type: AnswerType.Text,
               values: item.question,
            },
         } as ChatMessage;
      });
      currentSectionId = res?.details?.[0]?.section_a_id;
      const resList = await Promise.all((res.details ?? []).map((item) => getAnswerById(item.history_id)));
      let i = 0;
      resList.map((item, index) => {
         const insertIndex = index + 1 + i;
         messageList.value.splice(insertIndex, 0, {
            role: RoleEnum.assistant,
            content: parseContent(item.answer),
         });
         i++;
      });
      // messageContent.value = {
      //    type: AnswerType.Text,
      //    values: activeChatRoom.value.title,
      // };
      // sendChatMessage();
      console.log("🚀 ~ messageList.value:", messageList.value)
   },
   {
      immediate: true,
   }
);
const parseContent = (res) => {
   let content: ChatContent = {
      type: AnswerType.Text,
      values: '发生错误!',
   };
   switch (res.answer_type) {
      case AnswerType.RecordSet:
         content = {
            type: AnswerType.RecordSet,
            values: res.values,
         };
         break;
      case AnswerType.Text:
         content = {
            type: AnswerType.Text,
            values: res.values ?? res.answer,
         };
         break;
      case AnswerType.Knowledge:
         content = {
            type: AnswerType.Knowledge,
            values: res.knowledge,
         };
         break;
      default:
         content = {
            type: AnswerType.Text,
            values: '其他',
         };
         break;
   }
   return content;
};
let position: Position = null;
const questionAi = async (text) => {
   const res = await QuestionAi({
   let judgeParams = null;
   if (!preQuestion.value) {
      judgeParams = {};
   } else {
      judgeParams = {
         prev_question: preQuestion.value,
      };
   }
   const params = {
      question: text,
      section_a_id: currentSectionId,
      history_group_id: activeRoomId.value,
      history_group_id: currentRouteId,
      raw_mode: roomConfig.value?.[currentRouteId]?.isAnswerByLLM ?? false,
      ...judgeParams,
   } as any;
   // if (!position) {
   //    const loadingInstance = ElLoadingService({
   //       text: '获取位置中...',
   //       target: '.layout-parent',
   //       fullscreen:false,
   //    });
   //    position = await getCurrentPosition().finally(() => {
   //       loadingInstance.close();
   //    });
   // }
   // if (position) {
   //    const { latitude, longitude } = position;
   //    params.cur_pos = [longitude, latitude].join(',');
   // }
   if (activeGroupType.value) {
      params.group_type = activeGroupType.value;
   }
   if (currentSampleId) {
      params.sample_id = currentSampleId;
      currentSampleId = '';
   }
   let lastTimestamp = new Date().getTime();
   questionRes = {};
   let lastIsResult = false;
   const resultP = new Promise((resolve, reject) => {
      const currentSource = axios.CancelToken.source();
      lastAxiosSource = currentSource;
      const getResReport = () => {
         const resReport = {
            answer_type: AnswerType.Report,
            reports: [],
         };
         return resReport;
      };
      const checkReportEmpty = () => {
         const isEmpty = !questionRes?.reports || questionRes?.reports?.length === 0;
         return isEmpty;
      };
      questionStreamByPost(
         params,
         (chunkRes) => {
            Logger.info('chunk response:\n\n' + JSON.stringify(chunkRes));
            if (chunkRes.mode === 'result') {
               lastIsResult = true;
               const res = chunkRes.value;
               if (checkReportEmpty()) {
                  const resReport = getResReport();
                  resReport.reports.push(res);
                  questionRes = resReport;
                  // resReport.reports = resReport.reports.concat([]);
                  resolve(resReport);
               } else {
                  const lastMsg = computedMessageList.value.at(-1);
                  // lastMsg.content.values = lastMsg.content.values.concat([]);
                  // 已经解析过一次 reports
                  if (!lastMsg.content.values) {
                     lastMsg.content.values = [];
                  }
                  lastMsg.content.values.push({
                     content: parseContent(res, true, {
                        origin: res,
                     }),
                  });
               }
               triggerRefresh();
               return;
               // chunkRes.value = '准备数据分析';
            }
            if (chunkRes.mode === 'summary') {
               const lastMsg = computedMessageList.value.at(-1);
               const extraContent = parseExtraContent(chunkRes.value);
               const isReportEmpty = checkReportEmpty();
               // 没有经过 result 报告还没初始化
               if (isReportEmpty) {
                  const resReport = getResReport();
                  questionRes = resReport;
               }
               // 此对话已经加入到对话列表
               if (lastMsg.content?.values && extraContent) {
                  for (const key in extraContent) {
                     if (Object.prototype.hasOwnProperty.call(extraContent, key)) {
                        const value = extraContent[key];
                        if (!lastMsg.content[key] || (Array.isArray(lastMsg.content[key]) && lastMsg.content[key].length === 0)) {
                           lastMsg.content[key] = value;
                        }
                     }
                  }
                  lastMsg.historyId = chunkRes.value.history_id;
                  const userMsg = computedMessageList.value.at(-2);
                  userMsg.historyId = chunkRes.value.history_id;
                  userMsg.content.values = chunkRes.value.question;
               }
               if (Object.keys(questionRes).length === 0) {
                  questionRes = chunkRes.value;
               }
               // 此对话还未加入到对话列表
               if (!lastMsg.content?.values && questionRes) {
                  questionRes = {
                     ...questionRes,
                     ...chunkRes.value,
                  };
               }
               if (isReportEmpty) {
                  resolve(questionRes);
               }
               // computedMessageList.value[computedMessageList.value.length - 1] = finalMsg;
               scrollToBottom();
               // chunkRes.value = '你可以继续问我';
               return;
            }
            if (chunkRes.mode === 'conclusion') {
               const lastReport = computedMessageList.value.at(-1)?.content?.values?.at(-1);
               if (lastReport) {
                  lastReport.conclusion = chunkRes.value;
                  chunkRes.value = '分析结束';
               }
            }
            const getLastGroup = () => {
               const lastGroup = computedMessageList.value.at(-1).stepGroup[0];
               return lastGroup;
            };
            const getLastStepList = () => {
               const stepList = getLastGroup()?.value ?? [];
               return stepList;
            };
            const getLastStepItem = () => {
               const stepList = getLastStepList();
               const lastStepItem = stepList.at(-1);
               return lastStepItem;
            };
            const checkStepItem = (stepItem) => {
               if (!stepItem.subStep) {
                  stepItem.subStep = [];
               }
            };
            if (chunkRes.mode === 'question') {
               const lastStepItem = getLastStepItem();
               checkStepItem(lastStepItem);
               lastStepItem.subStep.push({
                  type: chunkRes.value.type,
                  data: chunkRes.value,
               });
               scrollToBottom();
               return;
            }
            // 暂时不考虑多个 report 情况
            if (lastIsResult && chunkRes.mode !== 'finish') {
               // const lastTow = computedMessageList.value.at(-1);
               // lastTow.stepGroup.at(-1).value.at(-1).finishLoading = true;
               // lastTow.content.values = lastTow.content.values.concat([]);
               // 开始增加新的 stepGroup,后续的 stepGroup 并没有实际作用,只是为了做迭代用,迭代出组件,屎山代码实在太难改了!!!
               computedMessageList.value.at(-1).stepGroup.push({
                  value: [],
                  isShow: true,
               });
               lastIsResult = false;
            }
            const lastGroup = computedMessageList.value.at(-1).stepGroup[0];
            const stepList = lastGroup?.value ?? [];
            const currentTimeStamp = new Date().getTime();
            const ms = toMyFixed(currentTimeStamp - lastTimestamp, 2) + ' ms';
            if (chunkRes.mode === 'finish') {
               stepList.at(-1).ms = ms;
               isTalking.value = false;
               return;
            }
            if (stepList?.length >= 1) {
               stepList.at(-1).ms = ms;
            } else {
               const stepGroup = computedMessageList.value.at(-1).stepGroup;
               if (stepGroup.length > 1) {
                  const lastStepList = stepGroup.at(-2).value;
                  lastStepList.at(-1).ms = ms;
               }
            }
            lastTimestamp = currentTimeStamp;
            const stepItem = convertProcessItem(chunkRes);
            stepList.push(stepItem);
            // 强制触发更新
            scrollToBottom();
         },
         {
            cancelToken: currentSource.token,
         }
      )
         .catch((err) => {
            throw err;
         })
         .finally(() => {
            isTalking.value = false;
            // 收起所有 stepGroup
            computedMessageList.value.at(-1).stepGroup.forEach((item) => {
               item.isShow = false;
            });
         });
   });
   // const res = {
   //    json_ok: true,
   //    question: '昨日五一广场压力',
   //    answer_type: 'recordset',
   //    values: {
   //       names: ['yesterday', 'max_pressure'],
   //       values: [
   //          ['2024-06-28 00:00:00', 24.378],
   //          ['2024-06-29 00:00:00', 24.276],
   //       ],
   //       type: 'records',
   //       title: '昨日五一广场(D_GW_04)的最大压力值',
   //    },
   // };
   const content =  parseContent(res);
   console.log("🚀 ~ content:", content)
   await resultP;
   const content = parseContent(questionRes, true);
   return content;
};
const sendChatMessage = async (content: ChatContent = messageContent.value) => {
   if (!messageContent.value?.values) return;
   if (activeChatRoom.value.isInitial) {
      activeChatRoom.value.title = messageContent.value.values;
      activeChatRoom.value.isInitial = false;
   }
   try {
      isTalking.value = true;
      // 发送当前
      messageList.value.push({ role: RoleEnum.user, content });
      // 清空输入框
      clearMessageContent();
      // 出现回复,置空出现等待动画
      messageList.value.push({ role: RoleEnum.assistant, content: null });
      let resMsgContent: ChatContent = null;
      resMsgContent = await questionAi(content.values);
      appendLastMessageContent(resMsgContent);
   } catch (error: any) {
      appendLastMessageContent({
         type: AnswerType.Text,
         values: '发生错误!',
      });
   } finally {
      isTalking.value = false;
   }
};
const appendLastMessageContent = (content: ChatContent) => {
   messageList.value.at(-1).content = content;
};
const clearMessageContent = () =>
@@ -222,36 +361,232 @@
      values: '',
   });
const scrollToBottom = () => {
   if (!chatListDom.value) return;
   chatListDom.value.lastElementChild?.scrollIntoView();
let currentSampleId = '';
let currentLLMId = null;
const stopGenClick = () => {
   lastAxiosSource?.cancel();
   isTalking.value = false;
   chatListLoading.value = false;
   computedMessageList.value.at(-1).isStopMsg = true;
};
watch(
   messageList,
   () => {
      nextTick(() => scrollToBottom());
   },
   {
      deep: true,
const checkCanSend = (content: ChatContent = messageContent.value) => {
   if (!content?.values) {
      return false;
   }
);
   if (isTalking.value || chatListLoading.value) {
      ElMessage.warning('ai 正在回复中,请稍后尝试提问');
      return false;
   }
   return true;
};
//#region ====================== 聊天内容操作 ======================
const addChatItem = (content: ChatContent) => {
   isTalking.value = true;
   const userItem: ChatMessage = { role: RoleEnum.user, content, isChecked: false } as any;
   const assistantItem: ChatMessage = {
      role: RoleEnum.assistant,
      content: {
         type: AnswerType.Report,
      },
      state: AnswerState.Null,
      stepGroup: [
         {
            value: [],
            isShow: true,
         },
      ],
      isStopMsg: false,
      isChecked: false,
   } as any;
   messageList.value.push(userItem);
   clearMessageContent();
const { toClipboard } = useClipboard();
   messageList.value.push(assistantItem);
   scrollToBottom();
   return [userItem, assistantItem];
};
const copyClick = (content) => {
   ElMessage.success('复制成功');
   toClipboard(content);
const sendChatMessage = async (content: ChatContent = messageContent.value) => {
   if (!checkCanSend(content)) {
      return;
   }
   const isNewChat = messageList.value.length === 0;
   if (isNewChat) {
      if (activeSampleId.value) {
         currentSampleId = activeSampleId.value;
      }
      if (activeLLMId.value) {
         currentLLMId = activeLLMId.value;
      }
   }
   let resMsgContent: ChatContent = null;
   try {
      const [userItem, assistantItem] = addChatItem(content);
      resMsgContent = await questionAi(content.values);
      updateLoadIndex();
      userItem.historyId = questionRes?.history_id;
      const current = moment().format('YYYY-MM-DD HH:mm:ss');
      userItem.createTime = current;
      userItem.content.values = questionRes?.question ?? userItem.content.values;
      assistantItem.historyId = questionRes?.history_id;
      const currentTime = formatShowTimeYear(current);
      assistantItem.createTime = currentTime;
      assistantItem.content = resMsgContent;
      setTimeout(() => {
         // 收到回复,继续滚
         scrollToBottom();
      }, 300);
   } catch (error: any) {}
};
const sendClick = () => {
   sendChatMessage(messageContent.value);
};
const { loadRangeData, onChatListScroll, moreIsLoading, updateLoadIndex } = useScrollLoad({
   container: chatListDom,
   historyGroupId: currentRouteId,
   messageList,
   loadReplyData,
});
useSyncMsg({
   msgList: messageList,
   updateLoadIndex,
   historyGroupId: currentRouteId,
   checkCanSync: (data) => {
      return !isTalking.value && !moreIsLoading.value;
   },
   showTip: (data) => {
      playBarRef.value.showSyncTip(data);
   },
   loadReplyData,
   scrollToBottom,
});
const chatListLoading = ref(true);
onActivated(() => {
   emitter.emit('updateHeaderTitle', activeChatRoom.value?.title ?? '');
});
const initNewChat = () => {
   messageContent.value = {
      type: AnswerType.Text,
      values: activeChatRoom.value?.title,
   };
   sendChatMessage();
};
const initHistoryChat = () => {
   // 初始状态滚一下
   scrollToBottom();
   setTimeout(() => {
      chatListDom.value.addEventListener('scroll', onChatListScroll);
   }, 300);
};
/**
 * 加载分享数据
 */
const loadShareData = async () => {
   const res = await getShareChatJsonByPost({
      share_id: router.currentRoute.value.query.id as string,
   });
   const msgValue = res?.values;
   if (!msgValue) {
      messageList.value = [];
      return;
   }
   const userMsg: ChatMessage = {
      historyId: msgValue.history_id,
      role: RoleEnum.user,
      content: {
         type: AnswerType.Text,
         values: msgValue.question,
      },
      isChecked: false,
   };
   const assistantMsg: ChatMessage = {
      historyId: msgValue.history_id,
      role: RoleEnum.assistant,
      content: parseContent(msgValue),
      stepGroup: (msgValue?.reports ?? []).map((item) => ({
         value: convertProcessToStep(item?.exec_process),
         isShow: false,
      })),
      isStopMsg: false,
      conclusion: msgValue.conclusion ?? [],
      isChecked: false,
   };
   messageList.value = [userMsg, assistantMsg];
};
onMounted(async () => {
   messageList.value = [];
   chatListLoading.value = true;
   if (isSharePage.value) {
      await loadShareData().finally(() => {
         chatListLoading.value = false;
      });
   } else {
      await loadRangeData().finally(() => {
         chatListLoading.value = false;
      });
   }
   setTimeout(() => {
      emitter.emit('updateHeaderTitle', activeChatRoom.value?.title ?? '');
   }, 300);
   if (messageList.value.length === 0) {
      initNewChat();
   } else {
      if (!isSharePage.value) {
         setTimeout(() => {
            initHistoryChat();
         }, 300);
      }
   }
   loadAmisSource();
});
const askMoreClick = (item) => {
   if (!item.question) return;
   sendChatMessage({ type: AnswerType.Text, values: item.question });
};
//#region ====================== 侧边栏drawer ======================
const drawerIsShow = ref(false);
const updateChatInput = (content) => {
   messageContent.value.values = content;
};
//#endregion
const playBarRef = useCompRef(PlayBar);
//用户问题设置为常用语
const setCommonPhraseClick = (item) => {
   playBarRef.value.addPhrase(item);
};
//#region ====================== 分享 ======================
const shareLinkDlgRef = useCompRef(ShareLinkDlg);
const shareClick = async (item: ChatMessage) => {
   shareLinkDlgRef.value.openShare(item);
};
//#endregion
</script>
<style scoped>
pre {
   font-family: -apple-system, 'Noto Sans', 'Helvetica Neue', Helvetica, 'Nimbus Sans L', Arial, 'Liberation Sans', 'PingFang SC',
      'Hiragino Sans GB', 'Noto Sans CJK SC', 'Source Han Sans SC', 'Source Han Sans CN', 'Microsoft YaHei', 'Wenquanyi Micro Hei',
      'WenQuanYi Zen Hei', 'ST Heiti', SimHei, 'WenQuanYi Zen Hei Sharp', sans-serif;
}
<style scoped lang="scss">
@import './index.scss';
</style>