index.vue 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. <script lang="ts" setup>
  2. import { computed, onMounted, onUnmounted, onUpdated, Ref, ref } from 'vue';
  3. import { SvgIcon } from '@/components/common';
  4. import { v4 as uuidv4 } from 'uuid';
  5. import { chat } from '@/api/chat';
  6. import { Message as AiMessage } from '@/typings/chat';
  7. import Message from './message/message.vue';
  8. import Sider from './sider/index.vue';
  9. import { useDialog } from 'naive-ui';
  10. import { useBasicLayout } from '@/hooks/useBasicLayout';
  11. import { useScroll } from './store/useScroll';
  12. import { useChatStore } from './store/useChatStore';
  13. import Header from './Header.vue';
  14. import { addMessage } from '@/api/conversation';
  15. import { t } from '@/locales';
  16. const dialog = useDialog();
  17. const { isMobile } = useBasicLayout();
  18. const { scrollRef, contentRef, scrollToBottom, scrollToBottomIfAtBottom } = useScroll();
  19. const chatStore = useChatStore();
  20. let controller = new AbortController();
  21. const loading = ref<boolean>(false);
  22. const prompt = ref<string>('');
  23. const chatId = ref<string>('');
  24. const aiChatId = ref<string>('');
  25. const inputRef = ref();
  26. onMounted(async () => {
  27. if (inputRef.value && !isMobile.value) {
  28. inputRef.value?.focus();
  29. }
  30. await chatStore.loadData();
  31. if (chatStore.conversations.length == 0) {
  32. await chatStore.addConversation({ title: 'New Chat' });
  33. await chatStore.loadData();
  34. }
  35. });
  36. onUnmounted(() => {
  37. if (loading.value) {
  38. controller.abort();
  39. }
  40. });
  41. onUpdated(() => {
  42. scrollToBottomIfAtBottom();
  43. });
  44. const dataSources = computed(() => {
  45. // 获取当前聊天窗口的数据
  46. scrollToBottom();
  47. return chatStore.messages;
  48. });
  49. async function handleSubmit() {
  50. let message = prompt.value;
  51. if (loading.value) {
  52. return;
  53. }
  54. if (!message || message.trim() === '') {
  55. return;
  56. }
  57. controller = new AbortController();
  58. // user
  59. chatId.value = uuidv4();
  60. const data = await chatStore.addMessage(message, 'user', chatId.value);
  61. loading.value = true;
  62. prompt.value = '';
  63. // ai
  64. await scrollToBottom();
  65. const { conversationId } = await addMessage(data);
  66. aiChatId.value = uuidv4();
  67. await scrollToBottom();
  68. await chatStore.addMessage('', 'assistant', aiChatId.value);
  69. await scrollToBottomIfAtBottom();
  70. await onChat(message, conversationId);
  71. }
  72. async function onChat(message: string, conversationId?: string) {
  73. try {
  74. let promptText = undefined;
  75. if (chatStore.selectPromptId !== null && chatStore.selectPromptId !== '') {
  76. const arr = chatStore.prompts.filter((i) => i.id === chatStore.selectPromptId);
  77. if (arr.length) {
  78. promptText = arr[0].prompt;
  79. }
  80. }
  81. await chat(
  82. {
  83. chatId: chatId.value,
  84. message,
  85. role: 'user',
  86. model: chatStore.model,
  87. conversationId: conversationId,
  88. promptId: chatStore.selectPromptId,
  89. promptText,
  90. },
  91. async ({ event }) => {
  92. const list = event.target.responseText.split('\n\n');
  93. let text = '';
  94. let isRun = true;
  95. list.forEach((i: any) => {
  96. if (i.startsWith('data:Error')) {
  97. isRun = false;
  98. text += i.substring(5, i.length);
  99. chatStore.updateMessage(aiChatId.value, text, true);
  100. return;
  101. }
  102. if (!i.startsWith('data:{')) {
  103. return;
  104. }
  105. const { done, message } = JSON.parse(i.substring(5, i.length));
  106. if (done) {
  107. if (chatStore.curConversation?.id == undefined) {
  108. chatStore.curConversation = { id: String(conversationId) };
  109. chatStore.selectConversation({ id: conversationId });
  110. }
  111. return;
  112. }
  113. text += message;
  114. });
  115. if (!isRun) {
  116. await scrollToBottomIfAtBottom();
  117. return;
  118. }
  119. await chatStore.updateMessage(aiChatId.value, text, false);
  120. await scrollToBottomIfAtBottom();
  121. }
  122. )
  123. .catch((e: any) => {
  124. loading.value = false;
  125. if (e.message !== undefined) {
  126. chatStore.updateMessage(aiChatId.value, e.message, true);
  127. return;
  128. }
  129. if (e.startsWith('data:Error')) {
  130. chatStore.updateMessage(aiChatId.value, e.substring(5, e.length), true);
  131. return;
  132. }
  133. })
  134. .finally(() => {
  135. scrollToBottomIfAtBottom();
  136. });
  137. } finally {
  138. loading.value = false;
  139. }
  140. }
  141. // 删除
  142. function handleDelete(item: AiMessage) {
  143. if (loading.value) {
  144. return;
  145. }
  146. dialog.warning({
  147. title: t('chat.deleteMessage'),
  148. content: t('chat.deleteMessageConfirm'),
  149. positiveText: t('common.yes'),
  150. negativeText: t('common.no'),
  151. onPositiveClick: () => {
  152. chatStore.delMessage(item);
  153. },
  154. });
  155. chatId.value = '';
  156. }
  157. function handleEnter(event: KeyboardEvent) {
  158. if (!isMobile.value) {
  159. if (event.key === 'Enter' && !event.shiftKey) {
  160. event.preventDefault();
  161. handleSubmit();
  162. }
  163. } else {
  164. if (event.key === 'Enter' && event.ctrlKey) {
  165. event.preventDefault();
  166. handleSubmit();
  167. }
  168. }
  169. }
  170. function handleStop() {
  171. if (loading.value) {
  172. controller.abort();
  173. loading.value = false;
  174. }
  175. }
  176. const menuOptions = ref([
  177. {
  178. label: 'Upload File',
  179. value: 'Upload File',
  180. },
  181. ]);
  182. const footerClass = computed(() => {
  183. let classes = ['p-4 pt-0'];
  184. if (isMobile.value) {
  185. classes = ['sticky', 'left-0', 'bottom-0', 'right-0', 'p-2', 'pr-3', 'overflow-hidden'];
  186. }
  187. return classes;
  188. });
  189. const chatIsLoading = computed(() => {
  190. return chatStore.chatIsLoading;
  191. });
  192. const collapsed = computed(() => chatStore.siderCollapsed);
  193. const getContainerClass = computed(() => {
  194. return ['h-full', { 'pl-[260px]': !isMobile.value && !collapsed.value }];
  195. });
  196. </script>
  197. <template>
  198. <div class="transition-all overflow-hidden h-full">
  199. <n-layout :class="getContainerClass" class="z-40 transition" has-sider>
  200. <!-- Sider -->
  201. <Sider />
  202. <!-- Main -->
  203. <n-layout-content class="h-full">
  204. <div class="flex flex-col w-full h-full">
  205. <Header />
  206. <!-- chat -->
  207. <main class="flex-1 overflow-hidden">
  208. <div id="image-wrapper" ref="contentRef" class="h-full overflow-hidden overflow-y-auto">
  209. <div v-if="chatIsLoading" class="w-full h-full flex items-center justify-center">
  210. <n-spin :show="chatIsLoading" size="large" />
  211. </div>
  212. <div
  213. v-else
  214. ref="scrollRef"
  215. class="max-w-screen-2xl m-auto"
  216. :class="[isMobile ? 'p-2' : 'p-5 py-8 !px-12']"
  217. >
  218. <Message
  219. v-for="(item, index) of dataSources"
  220. :key="index"
  221. :date-time="item.createTime"
  222. :error="item.isError"
  223. :inversion="item.role !== 'assistant'"
  224. :loading="loading"
  225. :text="item.message"
  226. @delete="handleDelete(item)"
  227. />
  228. <div class="sticky bottom-0 left-0 flex justify-center">
  229. <NButton v-if="loading" type="warning" @click="handleStop">
  230. <template #icon>
  231. <SvgIcon icon="ri:stop-circle-line" />
  232. </template>
  233. Stop Responding
  234. </NButton>
  235. </div>
  236. </div>
  237. </div>
  238. </main>
  239. <footer :class="footerClass">
  240. <div
  241. class="w-full max-w-screen-2xl m-auto relative"
  242. :class="isMobile ? 'pb-2' : ' px-20 pb-10 '"
  243. >
  244. <div class="flex items-center justify-between">
  245. <n-input
  246. ref="inputRef"
  247. v-model:value="prompt"
  248. type="textarea"
  249. @keypress="handleEnter"
  250. :autosize="{ minRows: 1, maxRows: isMobile ? 1 : 4 }"
  251. class="!rounded-full px-2 py-1"
  252. :placeholder="t('chat.placeholder')"
  253. size="large"
  254. >
  255. <template #prefix>
  256. <n-popselect placement="top" :options="menuOptions" trigger="click">
  257. <n-button text class="!mr-2" size="large">
  258. <template #icon>
  259. <SvgIcon icon="ion:attach" />
  260. </template>
  261. </n-button>
  262. </n-popselect>
  263. </template>
  264. <template #suffix>
  265. <n-button text :loading="loading" @click="handleSubmit">
  266. <template #icon>
  267. <SvgIcon icon="mdi:sparkles-outline" />
  268. </template>
  269. </n-button>
  270. </template>
  271. </n-input>
  272. </div>
  273. </div>
  274. </footer>
  275. </div>
  276. </n-layout-content>
  277. </n-layout>
  278. </div>
  279. </template>
  280. <style lang="less" scoped></style>