yangyin
2024-10-31 05f34e1af59e139e6e914e772ba7dcd5b4bf680d
src/components/chat/Chat.vue
@@ -1,207 +1,518 @@
<template>
   <div class="flex flex-col h-screen">
      <div class="flex flex-nowrap fixed w-full items-baseline top-0 px-6 py-4 bg-gray-100">
         <div class="text-2xl font-bold">ChatGPT</div>
         <div class="ml-4 text-sm text-gray-500">基于 OpenAI 的 ChatGPT 自然语言模型人工智能对话</div>
         <div class="ml-auto px-3 py-2 text-sm cursor-pointer hover:bg-white rounded-md" @click="clickConfig()">设置</div>
      </div>
   <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-1 mx-2 mt-20 mb-2" ref="chatListDom">
         <div
            class="group flex flex-col px-4 py-3 hover:bg-slate-100 rounded-lg"
            v-for="(item, index) of messageList.filter((v) => v.role !== 'system')"
            :key="index"
         >
            <div class="flex justify-between items-center mb-2">
               <div class="font-bold">{{ roleAlias[item.role] }}:</div>
               <Copy class="invisible group-hover:visible" :content="item.content" />
                                 <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>
               </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="prose text-sm text-slate-600 leading-relaxed" v-if="item.content" v-html="md.render(item.content)"></div>
               <Loding v-else />
            </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">
         <div class="-mt-2 mb-2 text-sm text-gray-500" v-if="isConfig">请输入 API Key:</div>
         <div class="flex">
            <input
               class="input"
               :type="isConfig ? 'password' : 'text'"
               :placeholder="isConfig ? 'sk-xxxxxxxxxx' : '请输入'"
               v-model="messageContent"
               @keydown.enter="isTalking || sendOrSave()"
            />
            <button class="btn" :disabled="isTalking" @click="sendOrSave()">
               {{ isConfig ? '保存' : '发送' }}
            </button>
         </div>
      </div>
      <CustomDrawer v-model:isShow="drawerIsShow" @updateChatInput="updateChatInput" />
   </div>
</template>
<script setup lang="ts">
import type { ChatMessage } from './types';
import { ref, watch, nextTick, onMounted } from 'vue';
import cryptoJS from 'crypto-js';
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 Copy from './components/Copy.vue';
import { md } from './libs/markdown';
let apiKey = '';
let isConfig = ref(false);
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 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';
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: 'system',
      content: '你是 ChatGPT,OpenAI 训练的大型语言模型,尽可能简洁地回答。',
   },
   {
      role: 'assistant',
      content: `你好,我是AI语言模型,我可以提供一些常用服务和信息,例如:
  1. 翻译:我可以把中文翻译成英文,英文翻译成中文,还有其他一些语言翻译,比如法语、日语、西班牙语等。
  2. 咨询服务:如果你有任何问题需要咨询,例如健康、法律、投资等方面,我可以尽可能为你提供帮助。
  3. 闲聊:如果你感到寂寞或无聊,我们可以聊一些有趣的话题,以减轻你的压力。
  请告诉我你需要哪方面的帮助,我会根据你的需求给你提供相应的信息和建议。`,
   },
]);
onMounted(() => {
   if (getAPIKey()) {
      switchConfigStatus();
   }
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 === 2) {
         messageList.value.pop();
      }
      messageList.value.push({ role: '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: '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('nihao ');
         }, 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 (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 updateChatInput = (content) => {
   messageContent.value.values = content;
};
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;
   scrollTo(0, chatListDom.value.scrollHeight);
};
watch(messageList.value, () => nextTick(() => scrollToBottom()));
//#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>