chatgpt-web/src/views/chat/index.vue
Phoenix 9293c7d456 s
2024-02-05 09:09:54 +08:00

511 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup >
import {uploadFormData, uploadImg} from "@/api/api";
import {Local} from "@/utils/storage/storage";
import dayjs from "dayjs";
import { computed, onMounted, onUnmounted, ref,watch } from 'vue'
import { useRoute } from 'vue-router'
import { NAutoComplete, NButton, NInput, useDialog, useMessage } from 'naive-ui'
import { AreaChartOutlined ,PlusOutlined } from '@ant-design/icons-vue';
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'
import { UploadOutlined } from '@ant-design/icons-vue';
import {storeToRefs} from 'pinia'
import { sessionDetailForSetup } from '@/store'
const sessionDetailData=sessionDetailForSetup()
let controller = new AbortController()
const { sessionDetail:dataSources ,currentListUuid,gptMode,isStop,isGPT4} = storeToRefs(sessionDetailData)
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 prompt = ref('')
const loading = ref(false)
const inputRef = ref(null)
// 添加PromptStore
const promptStore = usePromptStore()
// 使用storeToRefs保证store修改后联想部分能够重新渲染
const { promptList: promptTemplate } = storeToRefs(promptStore)
// 未知原因刷新页面loading 状态不会重置,手动重置
dataSources.value.forEach((item, index) => {
if (item.loading)
updateChatSome(+uuid, index, { loading: false })
})
function handleSubmit() {
dataSources.value.push({
dateTime: dayjs().format('YYYY/MM/DD HH:mm:ss'),
text: prompt.value,
inversion: true,
error: false,
fileList:fileList.value.map(x=>x.url),
loading:false
})
sendDataStream()
}
const API_URL = `${import.meta.env.VITE_APP_API_BASE_URL}/chat/completion`;
const createParams = () => {
const messages = dataSources.value.map((x) => {
return {
content:(()=>{
if (gptMode.value==='gpt-4-vision-preview'){
return [{type: "text", text: x.text},...x.fileList.map((y)=>{
return {type: "image_url", image_url:y}
})]
}else {
return x.text
}
})(),
role: x.inversion ? 'user' : 'assistant'
}
});
return {
type:isFile.value?'custom':'',
listUuid: currentListUuid.value,
messages,
frequency_penalty: 0,
max_tokens: 1000,
model: gptMode.value,
presence_penalty: 0,
stream: true,
temperature: 1,
top_p: 1
};
};
const handleResponseStream = async (reader) => {
const { done, value } = await reader.read();
if (!done) {
let decoded = new TextDecoder().decode(value);
let decodedArray = decoded.split("data: ");
decodedArray.forEach((decoded) => {
if (decoded !== "") {
if (decoded.trim() === "[DONE]") {
dataSources.value[dataSources.value.length - 1].loading=false
loading.value=false
return;
} else {
if (isStop.value){
loading.value=false
return;
}
loading.value=true
dataSources.value[dataSources.value.length - 1].loading=true
const response = JSON.parse(decoded).choices[0].delta.content
? JSON.parse(decoded).choices[0].delta.content
: "";
dataSources.value[dataSources.value.length - 1].text += response;
}
}
});
await handleResponseStream(reader);
}
};
const sendDataStream = async () => {
const params = createParams();
fileList.value=[]
prompt.value=''
isStop.value=false
dataSources.value.push({
dateTime: dayjs().format('YYYY/MM/DD HH:mm:ss'),
text: '思考中..',
inversion: false,
error: false,
loading:true
});
visible.value=false
try {
loading.value=true
const response = await fetch(API_URL, {
method: "POST",
timeout: 10000,
body: JSON.stringify(params),
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: Local.get('token'),
},
});
const contentType = response.headers.get('Content-Type');
if (!contentType || !contentType.includes('application/json')) {
const reader = response.body.getReader();
dataSources.value[dataSources.value.length-1].text=''
await handleResponseStream(reader);
}
} catch (error) {
console.error('发生错误:', error);
}
};
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, {
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) {
ms.error(t('chat.exportFailed'))
}
finally {
d.loading = false
}
},
})
}
function handleDelete(index) {
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) {
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) {
loading.value = false
isStop.value=true
}
}
// 可优化部分
// 搜索选项计算这里使用value作为索引项所以当出现重复value时渲染异常(多项同时出现选中效果)
// 理想状态下其实应该是key作为索引项,但官方的renderOption会出现问题所以就需要value反renderLabel实现
const searchOptions = computed(() => {
if (prompt.value.startsWith('/')) {
return promptTemplate.value.filter((item) => item.key.toLowerCase().includes(prompt.value.substring(1).toLowerCase())).map((obj) => {
return {
label: obj.value,
value: obj.value,
}
})
}
else {
return []
}
})
// value反渲染key
const renderOption = (option) => {
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()
})
const fileList = ref([
]);
onUnmounted(() => {
if (loading.value)
controller.abort()
})
function getBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = error => reject(error);
});
}
const previewVisible = ref(false);
const previewImage = ref('');
const previewTitle = ref('');
const handleCancel = () => {
previewVisible.value = false;
previewTitle.value = '';
};
const handlePreview = async (file) => {
if (!file.url && !file.preview) {
file.preview = (await getBase64(file.originFileObj))
}
previewImage.value = file.url || file.preview;
previewVisible.value = true;
previewTitle.value = file.name || file.url.substring(file.url.lastIndexOf('/') + 1);
};
const value = ref('gpt-3.5-turbo');
const visible=ref(false)
const removeImg=(data)=>{
fileList.value.splice(fileList.value.findIndex(x=>x.url===data.url),1)
}
watch(gptMode,()=>{
currentListUuid.value=''
dataSources.value=[]
})
const visible1=ref(false)
const fileList1=ref([])
const isFile=ref(false)
const upItemImage1= async (file)=>{
const data = {
file: file.file,
}
const res=await uploadFormData(data)
if (res.code===0){
file.onSuccess()
dataSources.value=[...dataSources.value,...res.data.paragraph.flatMap(n=>[n,'']).map((x)=>{
return {
dateTime: dayjs().format('YYYY/MM/DD HH:mm:ss'),
text: x,
inversion: Boolean(x),
error: false,
}
})]
isFile.value=true
fileList1.value=[]
visible1.value=false
sendDataStream()
}
}
const customRequest=async (file)=>{
const res=await uploadImg({file:file.file,source:'approval'})
if (res.code===0){
file.onSuccess()
fileList.value.push({
url:res.data.ori_url
})
}
}
</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>当前没有会话哦</span>
</div>
</template>
<template v-else>
<div>
<Message
v-for="(item, index) of dataSources.filter(x=>x.text||x.fileList?.length>0)"
:key="index"
:date-time="item.dateTime"
:text="item.text"
:fileList="item.fileList"
:inversion="item.inversion"
:error="item.error"
:loading="item.loading"
@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>
停止响应
</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" style="flex-wrap: initial">
<a-popover :open="visible1" trigger="click">
<template #content>
<div class="clearfix">
<a-upload
:max-count="1"
v-model:file-list="fileList1"
name="file"
:customRequest="upItemImage1"
>
<a-button>
<upload-outlined></upload-outlined>
上传文件
</a-button>
</a-upload>
</div>
</template>
<HoverButton @click="visible1=!visible1">
<span class="text-xl text-[#4f555e] dark:text-white" style="display: flex;justify-content: center;align-items: center">
<SvgIcon icon="ri:upload-2-line" />
</span>
</HoverButton>
</a-popover>
<a-popover v-if="gptMode==='gpt-4-vision-preview'" :open="visible" trigger="click">
<template #content>
<div class="clearfix">
<a-upload
:file-list="fileList"
:customRequest="customRequest"
list-type="picture-card"
@preview="handlePreview"
@remove="removeImg"
>
<div>
<plus-outlined />
<div style="margin-top: 8px">上传</div>
</div>
</a-upload>
<a-modal :open="previewVisible" :title="previewTitle" :footer="null" @cancel="handleCancel">
<img alt="example" style="width: 100%" :src="previewImage" />
</a-modal>
</div>
</template>
<HoverButton @click="visible=!visible">
<span class="text-xl text-[#4f555e] dark:text-white" style="display: flex;justify-content: center;align-items: center">
<AreaChartOutlined />
</span>
</HoverButton>
</a-popover>
<!-- <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 color="#8a2be2" 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>