index.vue 10.0 KB

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