wujingjing
2025-04-09 28706df7da34b8854cdce96ad89c035eaded6ea9
src/components/chat/Chat.vue
@@ -1,11 +1,5 @@
<template>
   <ChatContainer
      :loading="chatListLoading"
      :more-is-loading="moreIsLoading"
      :is-share-page="isSharePage"
      :chat-width="chatWidth"
      ref="containerRef"
   >
   <ChatContainer :loading="chatListLoading" :more-is-loading="moreIsLoading" :is-share-page="isSharePage" ref="containerRef">
      <!-- 消息列表 -->
      <template #message-list>
         <MessageList
@@ -15,6 +9,7 @@
            @shareClick="shareClick"
            @setCommonQuestionClick="setCommonPhraseClick"
            @sendChatMessage="sendChatMessage"
            @stopGenClick="stopGenClick"
            @askMoreClick="askMoreClick"
         />
         <el-empty v-else-if="isSharePage && !chatListLoading" :image-size="200">
@@ -26,17 +21,20 @@
      <!-- 输入区域 -->
      <template #input-area>
         <PlayBar
            ref="playBarRef"
            v-model:voicePageIsShow="voicePageIsShow"
            :isTalking="isTalking"
            :isHome="false"
            :msgList="computedMessageList"
            v-model="messageContent.values"
            @sendClick="sendClick"
            @stopGenClick="stopGenClick"
            :style="{ width: chatWidth }"
         />
         <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>
      </template>
      <!-- 抽屉 -->
@@ -50,23 +48,25 @@
<script setup lang="ts">
import type { CancelTokenSource } from 'axios';
import axios from 'axios';
import { orderBy } from 'lodash-es';
import { ElMessage } from 'element-plus';
import moment from 'moment';
import { computed, onActivated, onMounted, ref } from 'vue';
import { v4 as uuidv4 } from 'uuid';
import { computed, nextTick, onActivated, onMounted, ref } from 'vue';
import { loadAmisSource } from '../amis/load';
import { convertProcessItem, convertProcessToStep, formatShowTimeYear, useScrollLoad } from './hooks/useScrollLoad';
import ChatContainer from './components/ChatContainer.vue';
import ShareLinkDlg from './components/shareLink/index.vue';
import type { SendMsg } from './hooks/types';
import { useLoadData } from './hooks/useLoadData';
import { useScrollLoad } from './hooks/useScrollLoad';
import { useSyncMsg } from './hooks/useSyncMsg';
import MessageList from './messageList/index.vue';
import type { ChatContent } from './model/types';
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 { ElMessage } from 'element-plus';
import ChatContainer from './components/ChatContainer.vue';
import ShareLinkDlg from './components/shareLink/index.vue';
import router from '/@/router';
import MessageList from './messageList/index.vue';
import {
   activeChatRoom,
   activeGroupType,
@@ -76,15 +76,31 @@
   isSharePage,
   roomConfig,
} from '/@/stores/chatRoom';
import { ParentRegister } from '/@/stores/global';
import emitter from '/@/utils/mitt';
import { deepClone } from '/@/utils/other';
import { useCompRef } from '/@/utils/types';
import { toMyFixed } from '/@/utils/util';
import { toFormData, toMyFixed } from '/@/utils/util';
const containerRef = useCompRef(ChatContainer);
const chatListDom = computed(() => containerRef.value?.chatListDom);
const chatWidth = '75%';
const scrollToBottom = () => {
   containerRef.value?.scrollToBottom();
};
const {
   loadReplyData,
   parseContent,
   parseExtraContent,
   convertProcessItem,
   convertProcessToStep,
   convertAttach,
   formatShowTimeYear,
   getStepGroupList,
} = useLoadData();
const voicePageIsShow = ref(false);
let isTalking = ref(false);
const chatWidth = computed(() => containerRef.value?.chatWidth);
let messageContent = ref<ChatContent>({
   type: AnswerType.Text,
   values: '',
@@ -97,116 +113,24 @@
   return messageList.value.filter((v) => !!v);
});
const parseExtraContent = (res) => {
   if (!res) return {};
   const askMoreList = orderBy(res.context_history, [(item) => Number(item.radio)], ['desc']);
   const errCode = res?.err_code;
   const errMsg = res?.json_msg;
   const origin = res;
   return {
      askMoreList,
      errCode,
      errMsg,
      origin,
   };
};
const parseContent = (res, reportIsShow = false, extraContent?) => {
   if (!res) return null;
   let content: ChatContent = {
      type: AnswerType.Text,
      values: '解析失败!',
   };
   if (res.type) {
      res.answer_type = res.type;
   }
   const curExtraContent = parseExtraContent(res);
   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.Script:
         content = {
            type: AnswerType.Script,
            values: res,
         };
         break;
      case AnswerType.Knowledge:
         content = {
            type: AnswerType.Knowledge,
            values: res.knowledge,
         };
         break;
      case AnswerType.Report:
         content = {
            type: AnswerType.Report,
            values: (res?.reports ?? []).map((item) => ({
               content: parseContent(item, reportIsShow, { origin: item, conclusion: item.conclusion ?? [] }),
            })),
         };
         break;
      case AnswerType.Summary:
         content = {
            type: AnswerType.Summary,
            values: res.summary?.map((item) => {
               item.reportIsShow = reportIsShow;
               return item;
            }),
         };
         break;
      case AnswerType.Url:
         content = {
            type: AnswerType.Url,
            values: res.url,
         };
         break;
      case AnswerType.Map:
         content = {
            type: AnswerType.Map,
            values: res.values,
         };
         break;
      default:
         content = {
            type: AnswerType.Text,
            values: '解析失败!',
         };
         break;
   }
   if (!extraContent) {
      content = {
         ...content,
         ...curExtraContent,
      };
   } else {
      content = {
         ...content,
         ...extraContent,
      };
   }
   return content;
};
let questionRes = null;
let position = null;
const preQuestion = ref(null);
let lastAxiosSource: CancelTokenSource = null;
// 通过修改 isTalking 来触发更新
const triggerRefresh = () => {
   isTalking.value = !isTalking.value;
   nextTick(() => {
      isTalking.value = !isTalking.value;
   });
};
const enableCallback = ref(false);
let streamOutputIsStart = false;
let position: Position = null;
const questionAi = async (text) => {
   let judgeParams = null;
   if (!preQuestion.value) {
@@ -223,12 +147,30 @@
      raw_mode: roomConfig.value?.[currentRouteId]?.isAnswerByLLM ?? false,
      ...judgeParams,
   } as any;
   if (position) {
      const longitude = position.coords.longitude;
      const latitude = position.coords.latitude;
      params.cur_pos = [longitude, latitude].join(',');
   const tableList = attachList.value.filter((item) => item.type === 'table').map((item) => item.model);
   if (tableList?.length > 0) {
      params.tables = JSON.stringify(tableList);
   }
   const metricList = attachList.value.filter((item) => item.type === 'metric').map((item) => item.model);
   if (metricList?.length > 0) {
      params.metrics = JSON.stringify(metricList);
   }
   // 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;
@@ -238,7 +180,12 @@
      params.sample_id = currentSampleId;
      currentSampleId = '';
   }
   const formDataParams = toFormData(params);
   const fileList = attachList.value.filter((item) => item.type === 'file').map((item) => item.model);
   for (const item of fileList) {
      formDataParams.append('files', item.file);
   }
   // clearAttach();
   let lastTimestamp = new Date().getTime();
   questionRes = {};
   let lastIsResult = false;
@@ -255,35 +202,58 @@
      };
      const checkReportEmpty = () => {
         const isEmpty = !questionRes?.reports || questionRes?.reports?.length === 0;
         return isEmpty;
      };
      questionStreamByPost(
         params,
         formDataParams,
         (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 === 'main_frame') {
               const jsonObj = JSON.parse(chunkRes.value);
               if (!enableCallback.value) {
                  return;
               }
               ParentRegister.notify?.({
                  type: 'main_frame',
                  value: jsonObj,
               });
               return;
            }
            if (chunkRes.mode === 'create_work_order') {
               const lastMsg = computedMessageList.value.at(-1);
               lastMsg.modeContent = chunkRes;
               triggerRefresh();
               return;
            }
            if (chunkRes.mode === 'summary') {
@@ -335,19 +305,35 @@
            if (chunkRes.mode === 'conclusion') {
               const lastReport = computedMessageList.value.at(-1)?.content?.values?.at(-1);
               if (lastReport) {
                  lastReport.conclusion = chunkRes.value;
                  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 lastGroup = computedMessageList.value.at(-1).stepGroup.at(-1);
               const stepList = lastGroup?.value ?? [];
               const lastStepItem = stepList.at(-1);
               if (!lastStepItem.subStep) {
                  lastStepItem.subStep = [];
               }
               const lastStepItem = getLastStepItem();
               checkStepItem(lastStepItem);
               lastStepItem.subStep.push({
                  type: chunkRes.value.type,
                  data: chunkRes.value,
@@ -355,40 +341,59 @@
               scrollToBottom();
               return;
            }
            // 暂时不考虑多个 report情况
            // if (lastIsResult && chunkRes.mode !== 'finish') {
            //    // 开始增加新的 stepGroup
            //    computedMessageList.value.at(-1).stepGroup.push({
            //       value: [],
            //       isShow: true,
            //    });
            //    lastIsResult = false;
            // }
            const lastGroup = computedMessageList.value.at(-1).stepGroup.at(-1);
            // 暂时不考虑多个 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 = getLastGroup();
            const stepList = lastGroup?.value ?? [];
            const currentTimeStamp = new Date().getTime();
            const ms = toMyFixed(currentTimeStamp - lastTimestamp, 2) + ' ms';
            if (chunkRes.mode === 'finish') {
               const ms = toMyFixed(currentTimeStamp - lastTimestamp, 2) + ' ms';
               stepList.at(-1).ms = ms;
               isTalking.value = false;
               streamOutputIsStart = false;
               return;
            }
            if (stepList?.length >= 1) {
            if (stepList?.length >= 1 && !streamOutputIsStart) {
               const ms = toMyFixed(currentTimeStamp - lastTimestamp, 2) + ' ms';
               stepList.at(-1).ms = ms;
            }
            if (!streamOutputIsStart) {
               lastTimestamp = currentTimeStamp;
            }
            if (!streamOutputIsStart) {
               const stepItem = convertProcessItem(chunkRes);
               stepList.push(stepItem);
            } else {
               const stepGroup = computedMessageList.value.at(-1).stepGroup;
               if (stepGroup.length > 1) {
                  const lastStepList = stepGroup.at(-2).value;
                  lastStepList.at(-1).ms = ms;
               const lastItem = stepList.at(-1);
               if (lastItem) {
                  lastItem.title += chunkRes.value ?? '';
               }
            }
            lastTimestamp = currentTimeStamp;
            const stepItem = convertProcessItem(chunkRes);
            stepList.push(stepItem);
            if (chunkRes.mode === 'begin_stream') {
               streamOutputIsStart = true;
               lastTimestamp = currentTimeStamp;
            }
            if (chunkRes.mode === 'end_stream') {
               streamOutputIsStart = false;
            }
            // 强制触发更新
            scrollToBottom();
@@ -413,23 +418,40 @@
   const content = parseContent(questionRes, true);
   return content;
};
const clearMessageContent = () =>
   (messageContent.value = {
const playBarRef = useCompRef(PlayBar);
const attachList = computed(() => playBarRef.value?.attachList ?? []);
const clearMessageContent = () => {
   messageContent.value = {
      type: AnswerType.Text,
      values: '',
   });
   };
};
let currentSampleId = '';
let currentLLMId = null;
const stopGenClick = () => {
const resetTalking = () => {
   lastAxiosSource?.cancel();
   isTalking.value = false;
   chatListLoading.value = false;
   streamOutputIsStart = false;
};
const stopGenClick = () => {
   resetTalking();
   if (isFrontQuestion) {
      ParentRegister.notify?.({
         type: 'msg_stop',
      });
   }
   computedMessageList.value.at(-1).isStopMsg = true;
};
const finishFrontQuestion = () => {
   resetTalking();
   ParentRegister.updateChildCallObj('sendMsg', null);
};
const checkCanSend = (content: ChatContent = messageContent.value) => {
@@ -445,11 +467,12 @@
const addChatItem = (content: ChatContent) => {
   isTalking.value = true;
   const userItem: ChatMessage = { role: RoleEnum.user, content, isChecked: false } as any;
   const userItem: ChatMessage = { role: RoleEnum.user, content, isChecked: false, attachList: deepClone(attachList.value) } as any;
   const assistantItem: ChatMessage = {
      role: RoleEnum.assistant,
      content: {
         type: AnswerType.Report,
         values: [],
      },
      state: AnswerState.Null,
      stepGroup: [
@@ -458,6 +481,7 @@
            isShow: true,
         },
      ],
      isStopMsg: false,
      isChecked: false,
   } as any;
@@ -469,10 +493,64 @@
   return [userItem, assistantItem];
};
/**
 * 清除附件
 */
const clearAttach = () => {
   playBarRef.value?.clearAttach();
};
const updateUserInfo = (userItem: ChatMessage, other: { historyId: string; question: string }) => {
   userItem.historyId = other.historyId;
   const current = moment().format('YYYY-MM-DD HH:mm:ss');
   userItem.createTime = current;
   userItem.content.values = other.question ?? userItem.content.values;
};
const updateAssistantInfo = (
   assistantItem: ChatMessage,
   resMsgContent: ChatContent,
   other: { historyId: string; question: string }
) => {
   const current = moment().format('YYYY-MM-DD HH:mm:ss');
   assistantItem.historyId = other.historyId;
   const currentTime = formatShowTimeYear(current);
   assistantItem.createTime = currentTime;
   assistantItem.content = resMsgContent;
};
const updateInfo = (
   userItem: ChatMessage,
   assistantItem: ChatMessage,
   resMsgContent: ChatContent,
   other: {
      historyId: string;
      question: string;
   }
) => {
   updateUserInfo(userItem, other);
   updateAssistantInfo(assistantItem, resMsgContent, other);
};
const handleAfterQuestion = (
   userItem: ChatMessage,
   assistantItem: ChatMessage,
   resMsgContent: ChatContent,
   other: { historyId: string; question: string }
) => {
   updateLoadIndex();
   updateInfo(userItem, assistantItem, resMsgContent, other);
   setTimeout(() => {
      // 收到回复,继续滚
      scrollToBottom();
   }, 300);
};
const sendChatMessage = async (content: ChatContent = messageContent.value) => {
   if (!checkCanSend(content)) {
      return;
   }
   isFrontQuestion = false;
   const isNewChat = messageList.value.length === 0;
   if (isNewChat) {
      if (activeSampleId.value) {
@@ -487,23 +565,97 @@
   try {
      const [userItem, assistantItem] = addChatItem(content);
      resMsgContent = await questionAi(content.values);
      updateLoadIndex();
      userItem.historyId = questionRes?.history_id;
      userItem.content.values = questionRes?.question ?? userItem.content.values;
      assistantItem.historyId = questionRes?.history_id;
      const currentTime = formatShowTimeYear(moment().format('YYYY-MM-DD HH:mm:ss'));
      assistantItem.createTime = currentTime;
      assistantItem.content = resMsgContent;
      setTimeout(() => {
         // 收到回复,继续滚
         scrollToBottom();
      }, 300);
      handleAfterQuestion(userItem, assistantItem, resMsgContent, {
         historyId: questionRes?.history_id,
         question: questionRes?.question,
      });
   } catch (error: any) {}
};
let isFrontQuestion = false;
const sendFrontChatMessage = async (content: ChatContent = messageContent.value): Promise<any> => {
   isFrontQuestion = true;
   const [userItem, assistantItem] = addChatItem(content);
   const promise = new Promise((resolve, reject) => {
      const receiveMsg = (msg: SendMsg) => {
         console.log('receiveMsg', msg);
         if (!assistantItem.content?.values) {
            assistantItem.content.values = [];
         }
         switch (msg.type) {
            case 'text':
               // 开始增加新的 stepGroup,后续的 stepGroup 并没有实际作用,只是为了做迭代用,迭代出组件,屎山代码实在太难改了!!!
               assistantItem.stepGroup.push({
                  value: [],
                  isShow: true,
               });
               assistantItem.content.values.push({
                  content: {
                     type: 'knowledge',
                     values: [
                        {
                           answer: msg.value,
                        },
                     ],
                  },
               });
               break;
            case 'select':
            case 'confirm':
            case 'input':
               // 开始增加新的 stepGroup,后续的 stepGroup 并没有实际作用,只是为了做迭代用,迭代出组件,屎山代码实在太难改了!!!
               assistantItem.stepGroup.push({
                  value: [],
                  isShow: true,
               });
               assistantItem.content.values.push({
                  content: {
                     type: 'content_cb',
                     values: msg,
                  },
               });
               break;
            case 'info':
               if (msg.value === 'finish') {
                  finishFrontQuestion();
                  return resolve({ userItem, assistantItem, resMsgContent: assistantItem.content });
               }
               break;
            default:
               break;
         }
         scrollToBottom();
         triggerRefresh();
      };
      ParentRegister.updateChildCallObj('sendMsg', receiveMsg);
   });
   return promise;
};
const questionSelf = (content: string) => {
   const myContent = { type: AnswerType.Text, values: content };
   if (!checkCanSend(myContent)) {
      return false;
   }
   sendFrontChatMessage(myContent).then(({ userItem, assistantItem, resMsgContent }) => {
      handleAfterQuestion(userItem, assistantItem, resMsgContent, {
         historyId: uuidv4(),
         question: content,
      });
   });
   return true;
};
const backQuestion = (content: { question: string; data: any }) => {
   sendChatMessage({ type: AnswerType.Text, values: content.question });
};
ParentRegister.updateChildCallObj('frontQuestion', questionSelf);
ParentRegister.updateChildCallObj('backQuestion', backQuestion);
const sendClick = () => {
   sendChatMessage(messageContent.value);
@@ -513,9 +665,21 @@
   container: chatListDom,
   historyGroupId: currentRouteId,
   messageList,
   parseAnswerContent: parseContent,
   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(() => {
@@ -528,9 +692,6 @@
      values: activeChatRoom.value?.title,
   };
   sendChatMessage();
};
const scrollToBottom = () => {
   containerRef.value?.scrollToBottom();
};
const initHistoryChat = () => {
@@ -562,6 +723,7 @@
         type: AnswerType.Text,
         values: msgValue.question,
      },
      attachList: convertAttach(msgValue),
      isChecked: false,
   };
@@ -569,10 +731,8 @@
      historyId: msgValue.history_id,
      role: RoleEnum.assistant,
      content: parseContent(msgValue),
      stepGroup: (msgValue?.reports ?? []).map((item) => ({
         value: convertProcessToStep(item?.exec_process),
         isShow: false,
      })),
      stepGroup: getStepGroupList(msgValue?.reports ?? []),
      isStopMsg: false,
      conclusion: msgValue.conclusion ?? [],
@@ -609,8 +769,6 @@
   loadAmisSource();
});
const askMoreClick = (item) => {
   if (!item.question) return;
   sendChatMessage({ type: AnswerType.Text, values: item.question });
@@ -623,7 +781,6 @@
   messageContent.value.values = content;
};
//#endregion
const playBarRef = useCompRef(PlayBar);
//用户问题设置为常用语
const setCommonPhraseClick = (item) => {
   playBarRef.value.addPhrase(item);
@@ -640,37 +797,5 @@
</script>
<style scoped lang="scss">
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;
}
.more-loading {
   :deep(.el-loading-spinner) {
      --loading-size: 35px;
      margin-top: 0;
      .circular {
         width: var(--loading-size);
         height: var(--loading-size);
      }
   }
}
:deep(.el-step__icon.is-text) {
   --radius-size: 24px;
   width: var(--radius-size);
   height: var((--radius-size));
}
:deep(.el-step__icon-inner) {
   font-size: 16px !important;
}
:deep(.el-step__description) {
   min-height: 20px;
}
:deep(.el-step:last-of-type .el-step__description) {
   // display: none;
}
@import './index.scss';
</style>