Spaces:
Sleeping
Sleeping
| <script setup lang='ts'> | |
| import type { Ref } from 'vue' | |
| import { computed, onMounted, onUnmounted, ref } from 'vue' | |
| import { useRoute } from 'vue-router' | |
| import { storeToRefs } from 'pinia' | |
| import { NAutoComplete, NButton, NInput, useDialog, useMessage } from 'naive-ui' | |
| import html2canvas from 'html2canvas' | |
| import { Message } from './components' | |
| import { useScroll } from './hooks/useScroll' | |
| import { useChat } from './hooks/useChat' | |
| import { useUsingContext } from './hooks/useUsingContext' | |
| import HeaderComponent from './components/Header/index.vue' | |
| import { HoverButton, SvgIcon } from '@/components/common' | |
| import { useBasicLayout } from '@/hooks/useBasicLayout' | |
| import { useChatStore, usePromptStore } from '@/store' | |
| import { fetchChatAPIProcess } from '@/api' | |
| import { t } from '@/locales' | |
| let controller = new AbortController() | |
| const openLongReply = import.meta.env.VITE_GLOB_OPEN_LONG_REPLY === 'true' | |
| const route = useRoute() | |
| const dialog = useDialog() | |
| const ms = useMessage() | |
| const chatStore = useChatStore() | |
| const { isMobile } = useBasicLayout() | |
| const { addChat, updateChat, updateChatSome, getChatByUuidAndIndex } = useChat() | |
| const { scrollRef, scrollToBottom, scrollToBottomIfAtBottom } = useScroll() | |
| const { usingContext, toggleUsingContext } = useUsingContext() | |
| const { uuid } = route.params as { uuid: string } | |
| const dataSources = computed(() => chatStore.getChatByUuid(+uuid)) | |
| const conversationList = computed(() => dataSources.value.filter(item => (!item.inversion && !!item.conversationOptions))) | |
| const prompt = ref<string>('') | |
| const loading = ref<boolean>(false) | |
| const inputRef = ref<Ref | null>(null) | |
| // 添加PromptStore | |
| const promptStore = usePromptStore() | |
| // 使用storeToRefs,保证store修改后,联想部分能够重新渲染 | |
| const { promptList: promptTemplate } = storeToRefs<any>(promptStore) | |
| // 未知原因刷新页面,loading 状态不会重置,手动重置 | |
| dataSources.value.forEach((item, index) => { | |
| if (item.loading) | |
| updateChatSome(+uuid, index, { loading: false }) | |
| }) | |
| function handleSubmit() { | |
| onConversation() | |
| } | |
| async function onConversation() { | |
| let message = prompt.value | |
| if (loading.value) | |
| return | |
| if (!message || message.trim() === '') | |
| return | |
| controller = new AbortController() | |
| addChat( | |
| +uuid, | |
| { | |
| dateTime: new Date().toLocaleString(), | |
| text: message, | |
| inversion: true, | |
| error: false, | |
| conversationOptions: null, | |
| requestOptions: { prompt: message, options: null }, | |
| }, | |
| ) | |
| scrollToBottom() | |
| loading.value = true | |
| prompt.value = '' | |
| let options: Chat.ConversationRequest = {} | |
| const lastContext = conversationList.value[conversationList.value.length - 1]?.conversationOptions | |
| if (lastContext && usingContext.value) | |
| options = { ...lastContext } | |
| addChat( | |
| +uuid, | |
| { | |
| dateTime: new Date().toLocaleString(), | |
| text: '⚫', | |
| loading: true, | |
| inversion: false, | |
| error: false, | |
| conversationOptions: null, | |
| requestOptions: { prompt: message, options: { ...options } }, | |
| }, | |
| ) | |
| scrollToBottom() | |
| try { | |
| let lastText = '' | |
| const fetchChatAPIOnce = async () => { | |
| await fetchChatAPIProcess<Chat.ConversationResponse>({ | |
| prompt: message, | |
| options, | |
| signal: controller.signal, | |
| onDownloadProgress: ({ event }) => { | |
| const xhr = event.target | |
| const { responseText } = xhr | |
| // Always process the final line | |
| const lastIndex = responseText.lastIndexOf('\n', responseText.length - 2) | |
| let chunk = responseText | |
| if (lastIndex !== -1) | |
| chunk = responseText.substring(lastIndex) | |
| try { | |
| const data = JSON.parse(chunk) | |
| updateChat( | |
| +uuid, | |
| dataSources.value.length - 1, | |
| { | |
| dateTime: new Date().toLocaleString(), | |
| text: lastText + (data.text ?? ''), | |
| inversion: false, | |
| error: false, | |
| loading: true, | |
| conversationOptions: { conversationId: data.conversationId, parentMessageId: data.id }, | |
| requestOptions: { prompt: message, options: { ...options } }, | |
| }, | |
| ) | |
| if (openLongReply && data.detail.choices[0].finish_reason === 'length') { | |
| options.parentMessageId = data.id | |
| lastText = data.text | |
| message = '' | |
| return fetchChatAPIOnce() | |
| } | |
| scrollToBottomIfAtBottom() | |
| } | |
| catch (error) { | |
| // | |
| } | |
| }, | |
| }) | |
| updateChatSome(+uuid, dataSources.value.length - 1, { loading: false }) | |
| } | |
| await fetchChatAPIOnce() | |
| } | |
| catch (error: any) { | |
| const errorMessage = error?.message ?? t('common.wrong') | |
| if (error.message === 'canceled') { | |
| updateChatSome( | |
| +uuid, | |
| dataSources.value.length - 1, | |
| { | |
| loading: false, | |
| }, | |
| ) | |
| scrollToBottomIfAtBottom() | |
| return | |
| } | |
| const currentChat = getChatByUuidAndIndex(+uuid, dataSources.value.length - 1) | |
| if (currentChat?.text && currentChat.text !== '') { | |
| updateChatSome( | |
| +uuid, | |
| dataSources.value.length - 1, | |
| { | |
| text: `${currentChat.text}\n[${errorMessage}]`, | |
| error: false, | |
| loading: false, | |
| }, | |
| ) | |
| return | |
| } | |
| updateChat( | |
| +uuid, | |
| dataSources.value.length - 1, | |
| { | |
| dateTime: new Date().toLocaleString(), | |
| text: errorMessage, | |
| inversion: false, | |
| error: true, | |
| loading: false, | |
| conversationOptions: null, | |
| requestOptions: { prompt: message, options: { ...options } }, | |
| }, | |
| ) | |
| scrollToBottomIfAtBottom() | |
| } | |
| finally { | |
| loading.value = false | |
| } | |
| } | |
| async function onRegenerate(index: number) { | |
| if (loading.value) | |
| return | |
| controller = new AbortController() | |
| const { requestOptions } = dataSources.value[index] | |
| let message = requestOptions?.prompt ?? '' | |
| let options: Chat.ConversationRequest = {} | |
| if (requestOptions.options) | |
| options = { ...requestOptions.options } | |
| loading.value = true | |
| updateChat( | |
| +uuid, | |
| index, | |
| { | |
| dateTime: new Date().toLocaleString(), | |
| text: '', | |
| inversion: false, | |
| error: false, | |
| loading: true, | |
| conversationOptions: null, | |
| requestOptions: { prompt: message, options: { ...options } }, | |
| }, | |
| ) | |
| try { | |
| let lastText = '' | |
| const fetchChatAPIOnce = async () => { | |
| await fetchChatAPIProcess<Chat.ConversationResponse>({ | |
| prompt: message, | |
| options, | |
| signal: controller.signal, | |
| onDownloadProgress: ({ event }) => { | |
| const xhr = event.target | |
| const { responseText } = xhr | |
| // Always process the final line | |
| const lastIndex = responseText.lastIndexOf('\n', responseText.length - 2) | |
| let chunk = responseText | |
| if (lastIndex !== -1) | |
| chunk = responseText.substring(lastIndex) | |
| try { | |
| const data = JSON.parse(chunk) | |
| updateChat( | |
| +uuid, | |
| index, | |
| { | |
| dateTime: new Date().toLocaleString(), | |
| text: lastText + (data.text ?? ''), | |
| inversion: false, | |
| error: false, | |
| loading: true, | |
| conversationOptions: { conversationId: data.conversationId, parentMessageId: data.id }, | |
| requestOptions: { prompt: message, options: { ...options } }, | |
| }, | |
| ) | |
| if (openLongReply && data.detail.choices[0].finish_reason === 'length') { | |
| options.parentMessageId = data.id | |
| lastText = data.text | |
| message = '' | |
| return fetchChatAPIOnce() | |
| } | |
| } | |
| catch (error) { | |
| // | |
| } | |
| }, | |
| }) | |
| updateChatSome(+uuid, index, { loading: false }) | |
| } | |
| await fetchChatAPIOnce() | |
| } | |
| catch (error: any) { | |
| if (error.message === 'canceled') { | |
| updateChatSome( | |
| +uuid, | |
| index, | |
| { | |
| loading: false, | |
| }, | |
| ) | |
| return | |
| } | |
| const errorMessage = error?.message ?? t('common.wrong') | |
| updateChat( | |
| +uuid, | |
| index, | |
| { | |
| dateTime: new Date().toLocaleString(), | |
| text: errorMessage, | |
| inversion: false, | |
| error: true, | |
| loading: false, | |
| conversationOptions: null, | |
| requestOptions: { prompt: message, options: { ...options } }, | |
| }, | |
| ) | |
| } | |
| finally { | |
| loading.value = false | |
| } | |
| } | |
| function handleExport() { | |
| if (loading.value) | |
| return | |
| const d = dialog.warning({ | |
| title: t('chat.exportImage'), | |
| content: t('chat.exportImageConfirm'), | |
| positiveText: t('common.yes'), | |
| negativeText: t('common.no'), | |
| onPositiveClick: async () => { | |
| try { | |
| d.loading = true | |
| const ele = document.getElementById('image-wrapper') | |
| const canvas = await html2canvas(ele as HTMLDivElement, { | |
| useCORS: true, | |
| }) | |
| const imgUrl = canvas.toDataURL('image/png') | |
| const tempLink = document.createElement('a') | |
| tempLink.style.display = 'none' | |
| tempLink.href = imgUrl | |
| tempLink.setAttribute('download', 'chat-shot.png') | |
| if (typeof tempLink.download === 'undefined') | |
| tempLink.setAttribute('target', '_blank') | |
| document.body.appendChild(tempLink) | |
| tempLink.click() | |
| document.body.removeChild(tempLink) | |
| window.URL.revokeObjectURL(imgUrl) | |
| d.loading = false | |
| ms.success(t('chat.exportSuccess')) | |
| Promise.resolve() | |
| } | |
| catch (error: any) { | |
| ms.error(t('chat.exportFailed')) | |
| } | |
| finally { | |
| d.loading = false | |
| } | |
| }, | |
| }) | |
| } | |
| function handleDelete(index: number) { | |
| if (loading.value) | |
| return | |
| dialog.warning({ | |
| title: t('chat.deleteMessage'), | |
| content: t('chat.deleteMessageConfirm'), | |
| positiveText: t('common.yes'), | |
| negativeText: t('common.no'), | |
| onPositiveClick: () => { | |
| chatStore.deleteChatByUuid(+uuid, index) | |
| }, | |
| }) | |
| } | |
| function handleClear() { | |
| if (loading.value) | |
| return | |
| dialog.warning({ | |
| title: t('chat.clearChat'), | |
| content: t('chat.clearChatConfirm'), | |
| positiveText: t('common.yes'), | |
| negativeText: t('common.no'), | |
| onPositiveClick: () => { | |
| chatStore.clearChatByUuid(+uuid) | |
| }, | |
| }) | |
| } | |
| function handleEnter(event: KeyboardEvent) { | |
| if (!isMobile.value) { | |
| if (event.key === 'Enter' && !event.shiftKey) { | |
| event.preventDefault() | |
| handleSubmit() | |
| } | |
| } | |
| else { | |
| if (event.key === 'Enter' && event.ctrlKey) { | |
| event.preventDefault() | |
| handleSubmit() | |
| } | |
| } | |
| } | |
| function handleStop() { | |
| if (loading.value) { | |
| controller.abort() | |
| loading.value = false | |
| } | |
| } | |
| // 可优化部分 | |
| // 搜索选项计算,这里使用value作为索引项,所以当出现重复value时渲染异常(多项同时出现选中效果) | |
| // 理想状态下其实应该是key作为索引项,但官方的renderOption会出现问题,所以就需要value反renderLabel实现 | |
| const searchOptions = computed(() => { | |
| if (prompt.value.startsWith('/')) { | |
| return promptTemplate.value.filter((item: { key: string }) => item.key.toLowerCase().includes(prompt.value.substring(1).toLowerCase())).map((obj: { value: any }) => { | |
| return { | |
| label: obj.value, | |
| value: obj.value, | |
| } | |
| }) | |
| } | |
| else { | |
| return [] | |
| } | |
| }) | |
| // value反渲染key | |
| const renderOption = (option: { label: string }) => { | |
| for (const i of promptTemplate.value) { | |
| if (i.value === option.label) | |
| return [i.key] | |
| } | |
| return [] | |
| } | |
| const placeholder = computed(() => { | |
| if (isMobile.value) | |
| return t('chat.placeholderMobile') | |
| return t('chat.placeholder') | |
| }) | |
| const buttonDisabled = computed(() => { | |
| return loading.value || !prompt.value || prompt.value.trim() === '' | |
| }) | |
| const footerClass = computed(() => { | |
| let classes = ['p-4'] | |
| if (isMobile.value) | |
| classes = ['sticky', 'left-0', 'bottom-0', 'right-0', 'p-2', 'pr-3', 'overflow-hidden'] | |
| return classes | |
| }) | |
| onMounted(() => { | |
| scrollToBottom() | |
| if (inputRef.value && !isMobile.value) | |
| inputRef.value?.focus() | |
| }) | |
| onUnmounted(() => { | |
| if (loading.value) | |
| controller.abort() | |
| }) | |
| </script> | |
| <template> | |
| <div class="flex flex-col w-full h-full"> | |
| <HeaderComponent | |
| v-if="isMobile" | |
| :using-context="usingContext" | |
| @export="handleExport" | |
| @handle-clear="handleClear" | |
| /> | |
| <main class="flex-1 overflow-hidden"> | |
| <div id="scrollRef" ref="scrollRef" class="h-full overflow-hidden overflow-y-auto"> | |
| <div | |
| id="image-wrapper" | |
| class="w-full max-w-screen-xl m-auto dark:bg-[#101014]" | |
| :class="[isMobile ? 'p-2' : 'p-4']" | |
| > | |
| <template v-if="!dataSources.length"> | |
| <div class="flex items-center justify-center mt-4 text-center text-neutral-300"> | |
| <SvgIcon icon="ri:bubble-chart-fill" class="mr-2 text-3xl" /> | |
| <span>{{ t('chat.newChatTitle') }}</span> | |
| </div> | |
| </template> | |
| <template v-else> | |
| <div> | |
| <Message | |
| v-for="(item, index) of dataSources" | |
| :key="index" | |
| :date-time="item.dateTime" | |
| :text="item.text" | |
| :inversion="item.inversion" | |
| :error="item.error" | |
| :loading="item.loading" | |
| @regenerate="onRegenerate(index)" | |
| @delete="handleDelete(index)" | |
| /> | |
| <div class="sticky bottom-0 left-0 flex justify-center"> | |
| <NButton v-if="loading" type="warning" @click="handleStop"> | |
| <template #icon> | |
| <SvgIcon icon="ri:stop-circle-line" /> | |
| </template> | |
| {{ t('common.stopResponding') }} | |
| </NButton> | |
| </div> | |
| </div> | |
| </template> | |
| </div> | |
| </div> | |
| </main> | |
| <footer :class="footerClass"> | |
| <div class="w-full max-w-screen-xl m-auto"> | |
| <div class="flex items-center justify-between space-x-2"> | |
| <HoverButton v-if="!isMobile" @click="handleClear"> | |
| <span class="text-xl text-[#4f555e] dark:text-white"> | |
| <SvgIcon icon="ri:delete-bin-line" /> | |
| </span> | |
| </HoverButton> | |
| <HoverButton v-if="!isMobile" @click="handleExport"> | |
| <span class="text-xl text-[#4f555e] dark:text-white"> | |
| <SvgIcon icon="ri:download-2-line" /> | |
| </span> | |
| </HoverButton> | |
| <HoverButton @click="toggleUsingContext"> | |
| <span class="text-xl" :class="{ 'text-[#4b9e5f]': usingContext, 'text-[#a8071a]': !usingContext }"> | |
| <SvgIcon icon="ri:chat-history-line" /> | |
| </span> | |
| </HoverButton> | |
| <NAutoComplete v-model:value="prompt" :options="searchOptions" :render-label="renderOption"> | |
| <template #default="{ handleInput, handleBlur, handleFocus }"> | |
| <NInput | |
| ref="inputRef" | |
| v-model:value="prompt" | |
| type="textarea" | |
| :placeholder="placeholder" | |
| :autosize="{ minRows: 1, maxRows: isMobile ? 4 : 8 }" | |
| @input="handleInput" | |
| @focus="handleFocus" | |
| @blur="handleBlur" | |
| @keypress="handleEnter" | |
| /> | |
| </template> | |
| </NAutoComplete> | |
| <NButton type="primary" :disabled="buttonDisabled" @click="handleSubmit"> | |
| <template #icon> | |
| <span class="dark:text-black"> | |
| <SvgIcon icon="ri:send-plane-fill" /> | |
| </span> | |
| </template> | |
| </NButton> | |
| </div> | |
| </div> | |
| </footer> | |
| </div> | |
| </template> | |