yangyin
2024-11-01 7fae98913908f6fe828f0b52480c1f4efe5a4b86
src/components/chat/Chat.vue
@@ -1,225 +1,518 @@
<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 messageList.filter((v) => v.role !== 'system')"
               :key="index"
            >
               <img class="rounded-full size-12 mr-4" :src="roleImageMap[item.role]" alt="" srcset="" />
   <div class="flex h-full">
      <div class="flex flex-col h-full flex-auto">
         <div ref="chatListDom" class="relative h-full flex flex-col items-center overflow-y-auto">
            <span
               class="more-loading absolute text-blue-400 left-[50%] translate-x-[-50%] cursor-pointer w-10"
               v-loading="moreIsLoading"
            ></span>
            <div class="h-full relative" v-loading="chatListLoading" :style="{ width: chatWidth }">
               <div
                  class="group flex px-4 py-6 hover:bg-slate-100 rounded-lg relative"
                  :class="{ 'flex-row-reverse': item.role === RoleEnum.user }"
                  v-for="(item, index) of computedMessageList"
                  :key="`${item.historyId}_${item.role}`"
               >
                  <div class="absolute top-0 left-[72px] text-[#8d8e99]">{{ item?.createTime }}</div>
                  <img
                     class="rounded-full size-12 flex-0"
                     :class="{ 'mr-4': item.role === RoleEnum.assistant, 'ml-4': item.role === RoleEnum.user }"
                     :src="roleImageMap[item.role]"
                     alt=""
                     srcset=""
                  />
                  <div class="flex-auto flex" :class="{ 'justify-end': item.role === RoleEnum.user }">
                     <div class="inline-flex flex-col" :class="{ 'w-full': item.role === RoleEnum.assistant }">
                        <div class="w-full" v-if="item.content?.values">
                           <div
                              class="text-sm rounded-[6px] p-4 leading-relaxed"
                              :style="{ backgroundColor: item.role === RoleEnum.user ? 'rgb(197 224 255)' : 'white' }"
                           >
                              <div v-if="item.content.errCode === ErrorCode.Message" class="flex-column w-full">
                                 <p class="text-red-500">
                                    {{ item.content.errMsg }}
                                 </p>
                                 <div class="mt-5 flex items-center" v-if="showFixQuestion(item)">
                                    <div class="text-gray-600 flex-0">
                                       {{ item.content.origin.err_json.fix_question.title + ':' }}
                                    </div>
                                    <div class="ml-1 space-x-2 inline-flex flex-wrap">
                                       <div
                                          v-for="fixItem in item.content.origin.err_json.fix_question?.values"
                                          :key="fixItem"
                                          class="bg-gray-200 p-3 hover:bg-[#c5e0ff] hover:text-[#1c86ff] cursor-pointer rounded-lg"
                                          @click="fixQuestionClick(fixItem, item.content.origin)"
                                       >
                                          {{ fixItem.title }}
                                       </div>
                                    </div>
                                 </div>
                              </div>
                              <template v-else>
                                 <component :is="answerTypeMapCom[item.content.type]" :data="item.content.values" :originData="item" />
               <div class="flex">
                  <div class="relative" v-if="item.content">
                     <div
                        :class="{ 'bg-[#d8d8ff]': item.role === RoleEnum.assistant, 'bg-white': item.role === RoleEnum.user }"
                        class="prose text-sm rounded-[6px] p-4 leading-relaxed max-w-[100ch]"
                        v-html="md.render(item.content)"
                     ></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
                                    v-if="item.role === RoleEnum.assistant && item.content.origin?.ext_call_list"
                                    class="flex font-bold items-center mt-6"
                                 >
                                    <div class="flex-0 mb-auto -mr-4">关联功能:</div>
                                    <div class="space-x-5 flex flex-wrap">
                                       <div
                                          v-for="callItem in item.content.origin?.ext_call_list"
                                          :key="callItem.call_ext_id"
                                          @click="relativeQueryClick(callItem)"
                                          class="cursor-pointer hover:underline first-of-type:ml-5"
                                       >
                                          {{ callItem.question }}
                                       </div>
                                    </div>
                                 </div>
                              </template>
                           </div>
                           <!-- 操作 -->
                           <div v-if="item.role === RoleEnum.assistant" class="absolute flex items-center right-0 mr-4 mt-2 space-x-2">
                              <div
                                 class="flex items-center justify-center size-[15px]"
                                 v-if="item.content?.type === AnswerType.Text || item.content?.type === AnswerType.Knowledge"
                              >
                                 <i
                                    class="p-2 ywifont ywicon-copy cursor-pointer hover:text-[#0284ff] hover:!text-[18px]"
                                    @click="copyClick(item)"
                                 />
                              </div>
                              <template v-if="item.content.errCode !== ErrorCode.Message">
                                 <div class="flex items-center justify-center size-[15px]">
                                    <i
                                       :class="{ 'text-[#0284ff]': item.state === AnswerState.Like }"
                                       class="p-2 ywifont ywicon-dianzan cursor-pointer hover:text-[#0284ff] font-medium hover:!text-[18px]"
                                       @click="likeClick(item)"
                                    />
                                 </div>
                                 <div class="flex items-center justify-center size-[15px]">
                                    <i
                                       :class="{ 'text-[#0284ff]': item.state === AnswerState.Unlike }"
                                       class="p-2 ywifont ywicon-buzan cursor-pointer hover:text-[#0284ff] !text-[13px] hover:!text-[15px]"
                                       @click="unLikeClick(item)"
                                    />
                                 </div>
                              </template>
                              <div class="flex items-center justify-center size-[15px] relative">
                                 <i
                                    class="p-2 ywifont ywicon-wentifankui cursor-pointer hover:text-[#0284ff] !text-[13px] hover:!text-[15px]"
                                    @click="
                                       ($event) =>
                                          feedbackClick(
                                             $event,
                                             item,
                                             computedMessageList
                                                .filter((v) => v.role === RoleEnum.assistant)
                                                .findIndex((v) => v.historyId === item.historyId)
                                          )
                                    "
                                 />
                                 <FeedbackPanel
                                    v-show="feedbackIsShow && currentFeedbackMapItem === item"
                                    ref="feedbackPanelRef"
                                    v-model:isShow="feedbackIsShow"
                                    v-model:content="feedbackContent"
                                    :chatItem="currentFeedbackMapItem"
                                    :position="feedbackPosition"
                                 />
                              </div>
                           </div>
                        </div>
                        <Loding v-if="isTalking && index === computedMessageList.length - 1" class="w-fit" :process="process" />
                     </div>
                  </div>
                  <Loding v-else />
               </div>
               <div v-if="showAskMore" class="ml-4 mt-5 text-sm pb-10">
                  <div class="text-gray-600 mb-5">你可以继续问我:</div>
                  <div class="space-y-2 inline-flex flex-col">
                     <div
                        v-for="item in computedMessageList.at(-1).content.askMoreList"
                        :key="item.history_id"
                        class="bg-white p-3 hover:bg-[#c5e0ff] hover:text-[#1c86ff] cursor-pointer rounded-lg"
                        @click="askMoreClick(item)"
                     >
                        {{ item.question }}
                     </div>
                  </div>
               </div>
            </div>
         </div>
         <div class="sticky bottom-0 w-full p-6 bg-[rgb(247,248,250)] flex justify-center">
            <PlayBar
               v-model:voicePageIsShow="voicePageIsShow"
               :isTalking="isTalking"
               :isHome="false"
               v-model="messageContent.values"
               @sendClick="sendClick"
               :style="{ width: chatWidth }"
            ></PlayBar>
         </div>
      </div>
      <div class="sticky bottom-0 w-full p-6 pb-8 bg-gray-100 flex justify-center">
         <PlayBar :isTalking="isTalking" v-model="messageContent" @sendClick="sendOrSave" />
      </div>
      <CustomDrawer v-model:isShow="drawerIsShow" @updateChatInput="updateChatInput" />
   </div>
</template>
<script setup lang="ts">
import cryptoJS from 'crypto-js';
import { ElMessage } from 'element-plus';
import { nextTick, onMounted, ref, watch } from 'vue';
import useClipboard from 'vue-clipboard3';
import _ from 'lodash';
import moment from 'moment';
import { v4 as uuidv4 } from 'uuid';
import { computed, onMounted, ref } from 'vue';
import FeedbackPanel from './components/FeedbackPanel.vue';
import Loding from './components/Loding.vue';
import { md } from './libs/markdown';
import { RoleEnum, roleImageMap, type ChatMessage } from './types';
import { useAssistantContentOpt } from './hooks/useAssistantContentOpt';
import { useQueryProcess } from './hooks/useQueryProcess';
import { useScrollLoad } from './hooks/useScrollLoad';
import { useScrollToBottom } from './hooks/useScrollToBottom';
import type { ChatContent } from './model/types';
import { AnswerState, AnswerType, RoleEnum, answerTypeMapCom, roleImageMap, type ChatMessage } from './model/types';
import { QuestionAi, extCallQuery } from '/@/api/ai/chat';
import PlayBar from '/@/components/chat/components/playBar/PlayBar.vue';
import { activeChatRoom } from '/@/stores/chatRoom';
import CustomDrawer from '/@/components/drawer/CustomDrawer.vue';
import router from '/@/router';
import {
   activeChatRoom,
   activeGroupType,
   activeLLMId,
   activeRoomId,
   activeSampleId,
   activeSectionAId,
   getRoomConfig,
   roomConfig,
} from '/@/stores/chatRoom';
import { ErrorCode } from '/@/utils/request';
let apiKey = '';
let isConfig = ref(false);
const chatWidth = '75%';
const voicePageIsShow = ref(false);
let isTalking = ref(false);
let messageContent = ref('');
const chatListDom = ref<HTMLDivElement>();
const decoder = new TextDecoder('utf-8');
const roleAlias = { user: 'ME', assistant: 'ChatGPT', system: 'System' };
const messageList = ref<ChatMessage[]>([
   //    {
   //       role: RoleEnum.assistant,
   //       content: `你好,我是AI语言模型,我可以提供一些常用服务和信息,例如:
   //   1. 翻译:我可以把中文翻译成英文,英文翻译成中文,还有其他一些语言翻译,比如法语、日语、西班牙语等。
   //   2. 咨询服务:如果你有任何问题需要咨询,例如健康、法律、投资等方面,我可以尽可能为你提供帮助。
   //   3. 闲聊:如果你感到寂寞或无聊,我们可以聊一些有趣的话题,以减轻你的压力。
   //   请告诉我你需要哪方面的帮助,我会根据你的需求给你提供相应的信息和建议。`,
   //    },
   //    {
   //       role: RoleEnum.user,
   //       content: `你好`,
   //    },
]);
onMounted(() => {
   if (getAPIKey()) {
      switchConfigStatus();
   }
   if (!activeChatRoom.value) {
      router.replace({
         name: 'Home',
      });
      return;
   }
   messageContent.value = activeChatRoom.value.title;
   sendOrSave();
let messageContent = ref<ChatContent>({
   type: AnswerType.Text,
   values: '',
});
const currentRoute = router.currentRoute;
const currentRouteId = currentRoute.value.query.id as string;
activeRoomId.value = currentRouteId;
const chatListDom = ref<HTMLDivElement>();
const messageList = ref<ChatMessage[]>([]);
const computedMessageList = computed(() => {
   return messageList.value.filter((v) => !!v);
});
const parseContent = (res) => {
   if (!res) return null;
   let content: ChatContent = {
      type: AnswerType.Text,
      values: '解析失败!',
   };
const sendChatMessage = async (content: string = messageContent.value) => {
   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;
      case AnswerType.Summary:
         content = {
            type: AnswerType.Summary,
            values: res.summary,
         };
         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;
   }
   content.askMoreList = _.orderBy(res.context_history, [(item) => Number(item.radio)], ['desc']);
   content.errCode = res?.err_code;
   content.errMsg = res?.json_msg;
   content.origin = res;
   return content;
};
const { clearQueryProcess, process, processId, queryProcess } = useQueryProcess();
const DEFAULT_SECTION_A_ID = 'knowledge_base';
let questionRes = null;
let finalCalcSectionAId = null;
const questionAi = async (text) => {
   processId.value = uuidv4();
   let judgeParams = null;
   if (!preQuestion.value) {
      // const aiContent = computedMessageList.value.filter((item) => item.role === RoleEnum.assistant);
      // const lastQuestion = aiContent[aiContent.length - 2]?.content?.origin?.question;
      // judgeParams = lastQuestion
      //    ? {
      //          prev_question: lastQuestion,
      //      }
      //    : {};
      // 正常回答暂时不采用
      judgeParams = {};
   } else {
      judgeParams = {
         prev_question: preQuestion.value,
      };
   }
   let currentSectionAId = '';
   if (activeSectionAId.value) {
      currentSectionAId = activeSectionAId.value;
      activeSectionAId.value = '';
   } else {
      const lastSectionAItem = _.findLast(
         computedMessageList.value as any,
         (item) => item.role === RoleEnum.assistant && !!item.sectionAId
      );
      currentSectionAId = lastSectionAItem?.sectionAId ?? DEFAULT_SECTION_A_ID;
   }
   finalCalcSectionAId = currentSectionAId;
   const params = {
      process_id: processId.value,
      question: text,
      // FIXME: 暂时这样
      // section_a_id: currentSectionAId,
      history_group_id: currentRouteId,
      raw_mode: roomConfig.value?.[currentRouteId]?.isAnswerByLLM ?? false,
      ...judgeParams,
   } as any;
   if (activeGroupType.value) {
      params.group_type = activeGroupType.value;
   }
   if (currentSampleId) {
      params.sample_id = currentSampleId;
      currentSampleId = '';
   }
   // if (currentLLMId) {
   //    params.llm_id = currentLLMId;
   // }
   clearQueryProcess();
   queryProcess();
   const res = await QuestionAi(params).finally(() => {
      clearQueryProcess();
   });
   questionRes = res;
   const content = parseContent(res);
   return content;
};
const clearMessageContent = () =>
   (messageContent.value = {
      type: AnswerType.Text,
      values: '',
   });
let currentSampleId = '';
let currentLLMId = null;
const sendChatMessage = async (content: ChatContent = messageContent.value, cb?: any, isCallExtParams?: any) => {
   if (!content?.values || isTalking.value || chatListLoading.value) 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 {
      isTalking.value = true;
      // if (messageList.value.length === 0) {
      //    messageList.value.pop();
      // }
      messageList.value.push({ role: RoleEnum.user, content });
      const userItem: ChatMessage = { role: RoleEnum.user, content } as any;
      const assistantItem: ChatMessage = { role: RoleEnum.assistant, content: null, state: AnswerState.Null } as any;
      // 发送当前
      messageList.value.push(userItem);
      // 清空输入框
      clearMessageContent();
      messageList.value.push({ role: RoleEnum.assistant, content: '' });
      // const { body, status } = await chat(messageList.value, getAPIKey());
      // if (body) {
      //   const reader = body.getReader();
      //   await readStream(reader, status);
      // }
      const a = new Promise<string>((resolve) => {
         setTimeout(() => {
            resolve('你好,我是AI语言模型 ');
         }, 500);
      });
      // 出现回复,置空出现等待动画
      messageList.value.push(assistantItem);
      // 滚动至当前发送消息
      scrollToBottom();
      const msg = await a;
      appendLastMessageContent(msg);
      if (isCallExtParams) {
         const extRes = await extCallQuery(isCallExtParams);
         questionRes = extRes;
         resMsgContent = parseContent(extRes);
      } else {
         resMsgContent = await questionAi(content.values);
      }
      nextUserMsgEndIndex.value++;
      if (isNewChat) {
         const firstResCb = getRoomConfig(currentRouteId, 'firstResCb');
         firstResCb?.(resMsgContent);
      } else {
         cb?.(resMsgContent);
      }
      userItem.historyId = questionRes.history_id;
      userItem.content.values = questionRes?.question ?? userItem.content.values;
      assistantItem.historyId = questionRes.history_id;
      assistantItem.sectionAId = finalCalcSectionAId;
      appendLastMessageContent(resMsgContent);
      setTimeout(() => {
         // 收到回复,继续滚
         scrollToBottom();
      }, 300);
   } catch (error: any) {
      appendLastMessageContent(error);
      // appendLastMessageContent({
      //    type: AnswerType.Text,
      //    values: '发生错误!',
      // });
   } finally {
      isTalking.value = false;
   }
};
const readStream = async (reader: ReadableStreamDefaultReader<Uint8Array>, status: number) => {
   let partialLine = '';
   while (true) {
      // eslint-disable-next-line no-await-in-loop
      const { value, done } = await reader.read();
      if (done) break;
      const decodedText = decoder.decode(value, { stream: true });
      if (status !== 200) {
         const json = JSON.parse(decodedText); // start with "data: "
         const content = json.error.message ?? decodedText;
         appendLastMessageContent(content);
         return;
      }
      const chunk = partialLine + decodedText;
      const newLines = chunk.split(/\r?\n/);
      partialLine = newLines.pop() ?? '';
      for (const line of newLines) {
         if (line.length === 0) continue; // ignore empty message
         if (line.startsWith(':')) continue; // ignore sse comment message
         if (line === 'data: [DONE]') return; //
         const json = JSON.parse(line.substring(6)); // start with "data: "
         const content = status === 200 ? json.choices[0].delta.content ?? '' : json.error.message;
         appendLastMessageContent(content);
      }
const sendClick = (cb) => {
   sendChatMessage(messageContent.value, cb);
};
const appendLastMessageContent = (content: ChatContent) => {
   const currentTime = moment().format('MM月DD日 HH:mm:ss');
   if (messageList.value.at(-1)) {
      messageList.value.at(-1).content = content;
      messageList.value.at(-1).createTime = currentTime;
   }
};
const { loadRangeData, onChatListScroll, moreIsLoading, nextUserMsgEndIndex } = useScrollLoad({
   container: chatListDom,
   historyGroupId: currentRouteId,
   messageList,
   parseAnswerContent: parseContent,
});
const appendLastMessageContent = (content: string) => (messageList.value[messageList.value.length - 1].content += content);
const chatListLoading = ref(false);
const sendOrSave = () => {
   if (!messageContent.value.length) return;
   if (activeChatRoom.value.isInitial) {
      activeChatRoom.value.title = messageContent.value;
      activeChatRoom.value.isInitial = false;
   }
   if (isConfig.value) {
      if (saveAPIKey(messageContent.value.trim())) {
         switchConfigStatus();
      }
      clearMessageContent();
   } else {
const { scrollToBottom } = useScrollToBottom({
   chatListDom: chatListDom,
});
onMounted(async () => {
   messageList.value = [];
   // 加载初始数据
   chatListLoading.value = true;
   await loadRangeData().finally(() => {
      chatListLoading.value = false;
   });
   if (messageList.value.length === 0) {
      messageContent.value = {
         type: AnswerType.Text,
         values: activeChatRoom.value.title,
      };
      sendChatMessage();
   }
};
const clickConfig = () => {
   if (!isConfig.value) {
      messageContent.value = getAPIKey();
   } else {
      clearMessageContent();
      setTimeout(() => {
         // 初始状态滚一下
         scrollToBottom();
         setTimeout(() => {
            chatListDom.value.addEventListener('scroll', onChatListScroll);
         }, 300);
      }, 300);
   }
   switchConfigStatus();
});
//#region ====================== 关联查询 ======================
const relativeQueryClick = async (val) => {
   sendChatMessage(
      {
         type: AnswerType.Text,
         values: val.question,
      },
      undefined,
      {
         history_group_id: currentRouteId,
         question: val.question,
         call_ext_id: val.call_ext_id,
         call_ext_args: val.agrs ? JSON.stringify(val.agrs) : null,
      }
   );
};
//#endregion
const getSecretKey = () => 'lianginx';
const {
   copyClick,
   likeClick,
   unLikeClick,
   feedbackPosition,
   feedbackIsShow,
   feedbackContent,
   feedbackPanelRef,
   currentFeedbackMapItem,
   feedbackClick,
   askMoreClick,
   fixQuestionClick,
   preQuestion,
   showFixQuestion,
   showAskMore,
} = useAssistantContentOpt({
   sendChatMessage,
   displayMessageList: computedMessageList,
});
const saveAPIKey = (apiKey: string) => {
   if (apiKey.slice(0, 3) !== 'sk-' || apiKey.length !== 51) {
      alert('API Key 错误,请检查后重新输入!');
      return false;
   }
   const aesAPIKey = cryptoJS.AES.encrypt(apiKey, getSecretKey()).toString();
   localStorage.setItem('apiKey', aesAPIKey);
   return true;
};
//#region ====================== 侧边栏drawer ======================
const drawerIsShow = ref(false);
const getAPIKey = () => {
   if (apiKey) return apiKey;
   const aesAPIKey = localStorage.getItem('apiKey') ?? '';
   apiKey = cryptoJS.AES.decrypt(aesAPIKey, getSecretKey()).toString(cryptoJS.enc.Utf8);
   return apiKey;
};
const switchConfigStatus = () => (isConfig.value = !isConfig.value);
const clearMessageContent = () => (messageContent.value = '');
const scrollToBottom = () => {
   if (!chatListDom.value) return;
   chatListDom.value.lastElementChild.scrollIntoView();
   // scrollTo(0, chatListDom.value.scrollHeight);
};
watch(messageList.value, () => nextTick(() => scrollToBottom()));
//#region ====================== 聊天内容操作 ======================
const { toClipboard } = useClipboard();
const copyClick = (content) => {
   ElMessage.success('复制成功');
   toClipboard(content);
const updateChatInput = (content) => {
   messageContent.value.values = content;
};
//#endregion
</script>
<style scoped>
<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);
      }
   }
}
</style>