<script setup lang='ts'>
|
import type { Ref } from "vue"
|
import { computed, onMounted, onUnmounted, ref, watch} 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 { SvgIcon } from "@/components/common"
|
import { useBasicLayout } from "@/hooks/useBasicLayout"
|
import {
|
gptConfigStore,
|
gptsUlistStore,
|
homeStore,
|
useChatStore,
|
usePromptStore
|
} from "@/store"
|
import {
|
fetchChatAPIProcess,
|
gptsType,
|
mlog,
|
} from "@/api"
|
import { t } from "@/locales"
|
import drawListVue from "../mj/drawList.vue"
|
import aiGPT from "../mj/aiGpt.vue"
|
import AiSiderInput from "../mj/aiSiderInput.vue"
|
import aiGptInput from "../mj/aiGptInput.vue"
|
|
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 { updateChat, updateChatSome} = useChat()
|
const { scrollRef, scrollToBottom, scrollToBottomIfAtBottom } = useScroll()
|
|
const { uuid } = route.params as { uuid: string }
|
|
const dataSources = computed(() => chatStore.getChatByUuid(+uuid))
|
|
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() //把这个放到aiGpt
|
let message = prompt.value
|
if (!message || message.trim() === "") return
|
if (loading.value) return
|
loading.value = true
|
homeStore.setMyData({
|
act: "gpt.submit",
|
actData: { prompt: prompt.value, uuid },
|
})
|
prompt.value = ""
|
}
|
|
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) {
|
homeStore.setMyData({ act: "abort" })
|
controller.abort()
|
loading.value = false
|
}
|
}
|
|
// 可优化部分
|
// 搜索选项计算,这里使用value作为索引项,所以当出现重复value时渲染异常(多项同时出现选中效果)
|
// 理想状态下其实应该是key作为索引项,但官方的renderOption会出现问题,所以就需要value反renderLabel实现
|
const searchOptions = computed(() => {
|
if (prompt.value.startsWith("/")) {
|
const abc = 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,
|
}
|
})
|
mlog("搜索选项", abc)
|
return abc
|
} else if (prompt.value == "@") {
|
const abc = gptsUlistStore.myData.slice(0, 10).map((v: gptsType) => {
|
return {
|
label: v.info,
|
gpts: v,
|
value: v.gid,
|
}
|
})
|
return abc
|
} else {
|
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()
|
homeStore.setMyData({ isLoader: false })
|
})
|
|
const local = computed(() => homeStore.myData.local)
|
watch(
|
() => homeStore.myData.act,
|
(n) => {
|
if (n == "draw") scrollToBottom()
|
if (n == "scrollToBottom") scrollToBottom()
|
if (n == "scrollToBottomIfAtBottom") scrollToBottomIfAtBottom()
|
if (n == "gpt.submit" || n == "gpt.resubmit") {
|
loading.value = true
|
}
|
if (n == "stopLoading") {
|
loading.value = false
|
}
|
}
|
)
|
const st = ref({ inputme: true })
|
|
watch(
|
() => loading.value,
|
(n) => homeStore.setMyData({ isLoader: n })
|
)
|
|
const ychat = computed(() => {
|
let text = prompt.value
|
if (loading.value) text = ""
|
else {
|
scrollToBottomIfAtBottom()
|
}
|
return { text, dateTime: t("chat.preview") } as Chat.Chat
|
})
|
|
</script>
|
|
<template>
|
|
|
<div class="flex flex-col w-full h-full chat-content" :class="[isMobile ? '' : 'chat-content-noMobile']">
|
|
<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-[1100px] m-auto dark:bg-[#101014]"
|
:class="[isMobile ? 'p-2' : 'p-4']">
|
<template v-if="!dataSources.length">
|
<div v-if="homeStore.myData.session.notify" v-html="homeStore.myData.session.notify"
|
class="text-neutral-300 mt-4">
|
|
</div>
|
|
<div class="gpts-box" v-else>
|
<br>
|
|
<br>
|
<div v-if="local !== 'draw'">
|
<div class="help">
|
<div class="ai-icon">
|
<IconSvg icon="chatGPT" :width="isMobile ? '32px' : '64px'" :height="isMobile ? '32px' : '64px'">
|
</IconSvg>
|
</div>
|
|
<div class="text"
|
:style="{ padding: isMobile ? '22px 10px' : '22px 27px 5px', 'line-height': isMobile ? '20px' : '28px' }">
|
<p class="title">
|
{{ t('chat.helpTitle') }}
|
</p>
|
<p style="font-weight: bold" v-for="(item, index) in t('chat.helpcontent').split(';')" :key="index">{{ item }}</p>
|
</div>
|
</div>
|
</div>
|
</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)" :chat="item" :index="index" />
|
<Message v-if="ychat.text && !homeStore.myData.session.isCloseMdPreview" :key="dataSources.length"
|
:inversion="true" :date-time="$t('mj.typing')" :chat="ychat" :text="ychat.text"
|
:index="dataSources.length" />
|
<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" class="footer-content" v-if="local !== 'draw'">
|
<!-- max-w-screen-xl -->
|
<div class="w-full max-w-[1100px] m-auto">
|
<aiGptInput @handle-clear="handleClear" @export="handleExport"
|
v-if="['gpt-4o-mini', 'gpt-3.5-turbo-16k'].indexOf(gptConfigStore.myData.model) > -1 || st.inputme"
|
v-model:modelValue="prompt" :disabled="buttonDisabled" :searchOptions="searchOptions" />
|
<div class="flex items-center justify-between space-x-2" v-else>
|
<NAutoComplete v-model:value="prompt" :options="searchOptions">
|
<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>
|
|
<drawListVue />
|
<aiGPT @finished="loading = false" />
|
<AiSiderInput v-if="isMobile" :button-disabled="false" />
|
|
</template>
|
|
<style>
|
.new-chat-header {
|
width: 100%;
|
padding: 0 24px;
|
height: 70px;
|
line-height: 70px;
|
max-width: 300px;
|
overflow: hidden;
|
white-space: nowrap;
|
text-overflow: ellipsis;
|
font-size: 16px;
|
font-weight: 500;
|
}
|
|
</style>
|