dvc890's picture
[feat]add project files
41af422
<script setup lang='ts'>
import type { Ref } from 'vue'
import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import type { MessageReactive } from 'naive-ui'
import { NAutoComplete, NButton, NInput, NSelect, NSpace, NSpin, useDialog, useMessage } from 'naive-ui'
import html2canvas from 'html2canvas'
import { Message } from './components'
import { useScroll } from './hooks/useScroll'
import { useChat } from './hooks/useChat'
import HeaderComponent from './components/Header/index.vue'
import { HoverButton, SvgIcon } from '@/components/common'
import { useBasicLayout } from '@/hooks/useBasicLayout'
import { useAuthStore, useChatStore, usePromptStore, useUserStore } from '@/store'
import { fetchChatAPIProcess, fetchChatResponseoHistory, fetchChatStopResponding, fetchUpdateUserChatModel } from '@/api'
import { t } from '@/locales'
import { debounce } from '@/utils/functions/debounce'
import IconPrompt from '@/icons/Prompt.vue'
import { UserConfig } from '@/components/common/Setting/model'
import type { CHATMODEL } from '@/components/common/Setting/model'
const Prompt = defineAsyncComponent(() => import('@/components/common/Setting/Prompt.vue'))
let controller = new AbortController()
let lastChatInfo: any = {}
const openLongReply = import.meta.env.VITE_GLOB_OPEN_LONG_REPLY === 'true'
const route = useRoute()
const dialog = useDialog()
const ms = useMessage()
const authStore = useAuthStore()
const userStore = useUserStore()
const chatStore = useChatStore()
const { isMobile } = useBasicLayout()
const { addChat, updateChat, updateChatSome, getChatByUuidAndIndex } = useChat()
const { scrollRef, scrollToBottom, scrollToBottomIfAtBottom, scrollTo } = useScroll()
const { uuid } = route.params as { uuid: string }
const currentChatHistory = computed(() => chatStore.getChatHistoryByCurrentActive)
const usingContext = computed(() => currentChatHistory?.value?.usingContext ?? true)
const dataSources = computed(() => chatStore.getChatByUuid(+uuid))
const conversationList = computed(() => dataSources.value.filter(item => (!item.inversion && !!item.conversationOptions)))
const prompt = ref<string>('')
const firstLoading = ref<boolean>(false)
const loading = ref<boolean>(false)
const inputRef = ref<Ref | null>(null)
const showPrompt = ref(false)
let loadingms: MessageReactive
let allmsg: MessageReactive
let prevScrollTop: number
// 添加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()
const chatUuid = Date.now()
addChat(
+uuid,
{
uuid: chatUuid,
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,
{
uuid: chatUuid,
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>({
roomId: +uuid,
uuid: chatUuid,
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)
lastChatInfo = data
const usage = (data.detail && data.detail.usage)
? {
completion_tokens: data.detail.usage.completion_tokens || null,
prompt_tokens: data.detail.usage.prompt_tokens || null,
total_tokens: data.detail.usage.total_tokens || null,
estimated: data.detail.usage.estimated || null,
}
: undefined
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 } },
usage,
},
)
if (openLongReply && data.detail && data.detail.choices.length > 0 && 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 responseCount = dataSources.value[index].responseCount || 1
responseCount++
let message = requestOptions?.prompt ?? ''
let options: Chat.ConversationRequest = {}
if (requestOptions.options)
options = { ...requestOptions.options }
loading.value = true
const chatUuid = dataSources.value[index].uuid
updateChat(
+uuid,
index,
{
dateTime: new Date().toLocaleString(),
text: '',
inversion: false,
responseCount,
error: false,
loading: true,
conversationOptions: null,
requestOptions: { prompt: message, options: { ...options } },
},
)
try {
let lastText = ''
const fetchChatAPIOnce = async () => {
await fetchChatAPIProcess<Chat.ConversationResponse>({
roomId: +uuid,
uuid: chatUuid || Date.now(),
regenerate: true,
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)
lastChatInfo = data
const usage = (data.detail && data.detail.usage)
? {
completion_tokens: data.detail.usage.completion_tokens || null,
prompt_tokens: data.detail.usage.prompt_tokens || null,
total_tokens: data.detail.usage.total_tokens || null,
estimated: data.detail.usage.estimated || null,
}
: undefined
updateChat(
+uuid,
index,
{
dateTime: new Date().toLocaleString(),
text: lastText + (data.text ?? ''),
inversion: false,
responseCount,
error: false,
loading: true,
conversationOptions: { conversationId: data.conversationId, parentMessageId: data.id },
requestOptions: { prompt: message, options: { ...options } },
usage,
},
)
if (openLongReply && data.detail && data.detail.choices.length > 0 && 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,
responseCount,
error: true,
loading: false,
conversationOptions: null,
requestOptions: { prompt: message, options: { ...options } },
},
)
}
finally {
loading.value = false
}
}
async function onResponseHistory(index: number, historyIndex: number) {
const chat = (await fetchChatResponseoHistory(+uuid, dataSources.value[index].uuid || Date.now(), historyIndex)).data
updateChat(
+uuid,
index,
{
dateTime: chat.dateTime,
text: chat.text,
inversion: false,
responseCount: chat.responseCount,
error: true,
loading: false,
conversationOptions: chat.conversationOptions,
requestOptions: { prompt: chat.requestOptions.prompt, options: { ...chat.requestOptions.options } },
usage: chat.usage,
},
)
}
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()
}
}
}
async function handleStop() {
if (loading.value) {
controller.abort()
loading.value = false
await fetchChatStopResponding(lastChatInfo.text, lastChatInfo.id, lastChatInfo.conversationId)
}
}
async function loadMoreMessage(event: any) {
const chatIndex = chatStore.chat.findIndex(d => d.uuid === +uuid)
if (chatIndex <= -1 || chatStore.chat[chatIndex].data.length <= 0)
return
const scrollPosition = event.target.scrollHeight - event.target.scrollTop
const lastId = chatStore.chat[chatIndex].data[0].uuid
await chatStore.syncChat({ uuid: +uuid } as Chat.History, lastId, () => {
loadingms && loadingms.destroy()
nextTick(() => scrollTo(event.target.scrollHeight - scrollPosition))
}, () => {
loadingms = ms.loading(
'加载中...', {
duration: 0,
},
)
}, () => {
allmsg && allmsg.destroy()
allmsg = ms.warning('没有更多了', {
duration: 1000,
})
})
}
const handleLoadMoreMessage = debounce(loadMoreMessage, 300)
const handleSyncChat
= debounce(() => {
// 直接刷 极小概率不请求
chatStore.syncChat({ uuid: Number(uuid) } as Chat.History, undefined, () => {
firstLoading.value = false
const scrollRef = document.querySelector('#scrollRef')
if (scrollRef)
nextTick(() => scrollRef.scrollTop = scrollRef.scrollHeight)
if (inputRef.value && !isMobile.value)
inputRef.value?.focus()
})
}, 200)
async function handleScroll(event: any) {
const scrollTop = event.target.scrollTop
if (scrollTop < 50 && (scrollTop < prevScrollTop || prevScrollTop === undefined))
handleLoadMoreMessage(event)
prevScrollTop = scrollTop
}
async function handleToggleUsingContext() {
if (!currentChatHistory.value)
return
currentChatHistory.value.usingContext = !currentChatHistory.value.usingContext
chatStore.setUsingContext(currentChatHistory.value.usingContext, +uuid)
if (currentChatHistory.value.usingContext)
ms.success(t('chat.turnOnContext'))
else
ms.warning(t('chat.turnOffContext'))
}
// 可优化部分
// 搜索选项计算,这里使用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
})
async function handleSyncChatModel(chatModel: CHATMODEL) {
if (userStore.userInfo.config == null)
userStore.userInfo.config = new UserConfig()
userStore.userInfo.config.chatModel = chatModel
userStore.recordState()
await fetchUpdateUserChatModel(chatModel)
}
onMounted(() => {
firstLoading.value = true
handleSyncChat()
if (authStore.token) {
const chatModels = authStore.session?.chatModels
if (chatModels != null && chatModels.filter(d => d.value === userStore.userInfo.config.chatModel).length <= 0)
ms.error('你选择的模型已不存在,请重新选择 | The selected model not exists, please choose again.', { duration: 7000 })
}
})
watch(() => chatStore.active, (newVal, oldVal) => {
handleSyncChat()
})
onUnmounted(() => {
if (loading.value)
controller.abort()
})
</script>
<template>
<div class="flex flex-col w-full h-full">
<HeaderComponent
v-if="isMobile"
:using-context="usingContext"
:show-prompt="showPrompt"
@export="handleExport" @toggle-using-context="handleToggleUsingContext"
@toggle-show-prompt="showPrompt = true"
/>
<main class="flex-1 overflow-hidden">
<div id="scrollRef" ref="scrollRef" class="h-full overflow-hidden overflow-y-auto" @scroll="handleScroll">
<div
id="image-wrapper"
class="w-full max-w-screen-xl m-auto dark:bg-[#101014]"
:class="[isMobile ? 'p-2' : 'p-4']"
>
<NSpin :show="firstLoading">
<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>Aha~</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"
:response-count="item.responseCount"
:usage="item && item.usage || undefined"
:error="item.error"
:loading="item.loading"
@regenerate="onRegenerate(index)"
@delete="handleDelete(index)"
@response-history="(ev) => onResponseHistory(index, ev)"
/>
<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>
Stop Responding
</NButton>
</div>
</div>
</template>
</NSpin>
</div>
</div>
</main>
<footer :class="footerClass">
<div class="w-full max-w-screen-xl m-auto">
<NSpace vertical>
<div class="flex items-center space-x-2">
<HoverButton @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 v-if="!isMobile" @click="showPrompt = true">
<span class="text-xl text-[#4f555e] dark:text-white">
<IconPrompt class="w-[20px] m-auto" />
</span>
</HoverButton>
<HoverButton v-if="!isMobile" @click="handleToggleUsingContext">
<span class="text-xl" :class="{ 'text-[#4b9e5f]': usingContext, 'text-[#a8071a]': !usingContext }">
<SvgIcon icon="ri:chat-history-line" />
</span>
</HoverButton>
<NSelect
style="width: 250px"
:value="userStore.userInfo.config.chatModel"
:options="authStore.session?.chatModels"
:disabled="!!authStore.session?.auth && !authStore.token"
@update-value="(val) => handleSyncChatModel(val)"
/>
</div>
<div class="flex items-center justify-between space-x-2">
<NAutoComplete v-model:value="prompt" :options="searchOptions" :render-label="renderOption">
<template #default="{ handleInput, handleBlur, handleFocus }">
<NInput
ref="inputRef"
v-model:value="prompt"
:disabled="!!authStore.session?.auth && !authStore.token"
type="textarea"
:placeholder="placeholder"
:autosize="{ minRows: isMobile ? 1 : 4, 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>
</NSpace>
</div>
</footer>
<Prompt v-if="showPrompt" v-model:roomId="uuid" v-model:visible="showPrompt" />
</div>
</template>