chatgpt-web/src/views/chat/index.vue
Phoenix 3f99879fce s
2024-03-04 13:27:32 +08:00

673 lines
19 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 {AreaChartOutlined, PlusOutlined} from '@ant-design/icons-vue';
import html2canvas from 'html2canvas'
import {Message} from './components'
import {useScroll} from './hooks/useScroll'
import { FlashOutline } from '@vicons/ionicons5'
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'
import StopSvg from '@/assets/RecordStop12Regular.svg'
const sessionDetailData = sessionDetailForSetup()
let controller = new AbortController()
const {
sessionDetail: dataSources,
currentListUuid,
gptMode,
isStop,
isGPT4,
loading
} = 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 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() {
if (loading.value){
handleStop()
}else {
loading.value = true
dataSources.value.push({
dateTime: dayjs().format('YYYY/MM/DD HH:mm:ss'),
text: prompt.value?.trim(),
inversion: true,
error: false,
fileList: fileList.value.map(x => x.url),
loading: false
})
sendDataStream()
scrollToBottom('smooth')
}
}
//一键滚动到底部,一键滚动到顶部逻辑未完善
/* const isTopBottom=()=>{
if ( scrollRef.value.scrollTop ===0){
}
} */
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
}, ...(Array.isArray(x.fileList) && x.fileList.length > 0 ? x.fileList.map(y => ({
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: ");
for (const decoded of decodedArray) {
if (decoded !== "") {
if (decoded.trim() === "[DONE]") {
dataSources.value[dataSources.value.length - 1].loading = false
loading.value = false
} else {
if (isStop.value) {
dataSources.value[dataSources.value.length - 1].loading = false
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 {
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 prompt.value.trim() === '' &&!loading.value
})
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
})
const isShowBottom=ref(false)
onMounted(() => {
scrollRef.value.addEventListener('scroll', function() {
if (scrollRef.value.scrollTop + scrollRef.value.clientHeight +100>= scrollRef.value.scrollHeight) {
isShowBottom.value=false
}else {
isShowBottom.value=true
}
});
if (inputRef.value && !isMobile.value) {
inputRef.value?.focus()
}
})
const currentColor = ref('#ff0000'); // 初始颜色红色
const colors = ['#ff0000', '#ffffff']; // 颜色数组(红色和白色)
let intervalId = null;
// 计时器函数
const changeColor = () => {
currentColor.value = currentColor.value === colors[0] ? colors[1] : colors[0];
};
watch(loading,()=>{
if (loading.value){
intervalId = setInterval(changeColor, 1000);
}else {
clearInterval(intervalId);
}
})
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) => {
previewImage.value = (await getBase64(file.file));
previewVisible.value = true;
};
const value = ref('gpt-3.5-turbo');
const visible = ref(false)
const removeImg = (data) => {
fileList.value.splice(fileList.value.findIndex(x => x.id === data.file.id), 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.file,
}
const res = await uploadFormData(data)
if (res.code === 0) {
file.onFinish()
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()
}
}
watch(dataSources,()=>{
loading.value=false
scrollToBottom('auto')
})
const customRequest = async (file) => {
console.log(file,'file')
const res = await uploadImg({
file: file.file.file,
source: 'approval'
})
if (res.code === 0) {
file.onFinish()
fileList.value.push({
id:file.file.id,
url: res.data.ori_url,
status: 'finished',
})
}
}
</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">
<transition name="fade">
<div class="shortcut-arrow" v-if="isShowBottom">
<div class="top">
<n-button @click="scrollToBottom('smooth')" type="primary" dashed circle>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="48"
d="M244 400L100 256l144-144"
></path>
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="48"
d="M120 256h292"
></path>
</svg>
</template>
</n-button>
</div>
</div>
</transition>
<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"
: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(item)">
<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-center space-x-2" style="flex-wrap: initial">
<NPopover trigger="click" :show="visible1">
<template #trigger>
<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>
</template>
<div class="clearfix">
<NUpload
:max-count="1"
:default-file-list="fileList1"
name="file"
:customRequest="upItemImage1"
>
<NButton >上传文件</NButton>
</NUpload>
</div>
</NPopover>
<NPopover v-if="gptMode==='gpt-4-vision-preview'" :show="visible" trigger="click">
<template #trigger>
<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>
</template>
<NUpload
:file-list-style="{display:'flex'}"
:custom-request="customRequest"
:default-file-list="fileList"
list-type="image-card"
:on-preview="handlePreview"
:on-remove="removeImg"
/>
</NPopover>
<!-- <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 }">
</template>
</NAutoComplete>-->
<!-- <NInput
ref="inputRef"
v-model:value="prompt"
type="textarea"
size="large"
:placeholder="placeholder"
:autosize="{ minRows: 1, maxRows: isMobile ? 4 : 8 }"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@keypress="handleEnter"
/> -->
<NInput
style="width:75%"
ref="inputRef"
v-model:value="prompt"
type="textarea"
size="large"
:placeholder="placeholder"
:autosize="{ minRows: 1, maxRows: isMobile ? 4 : 8 }"
@keypress="handleEnter"
>
</NInput>
<NButton color="#8a2be2" type="primary" size="large" :disabled="buttonDisabled" @click="handleSubmit">
<template #icon>
<span class="dark:text-black" v-if="!loading">
<SvgIcon icon="ri:send-plane-fill"/>
</span>
<span class="dark:text-black" v-if="loading">
<svg style="width:100%;height:100%;" xmlns="http://www.w3.org/2000/svg" :style="{color:currentColor}" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 12 12"><g fill="none"><path d="M5 4a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H5zm6 2A5 5 0 1 1 1 6a5 5 0 0 1 10 0zm-1 0a4 4 0 1 0-8 0a4 4 0 0 0 8 0z" fill="currentColor"></path></g></svg>
</span>
</template>
</NButton>
</div>
</div>
</footer>
<NModal
v-model:show="previewVisible"
preset="card"
style="width: 600px"
>
<img :src="previewImage" style="width: 100%">
</NModal>
</div>
</template>
<style scoped>
.fade-enter-active, .fade-leave-active {
transition: opacity 0.4s ease-in-out;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
.shortcut-arrow {
width: min-content;
height: min-content;
position: absolute;
z-index: 10;
left: 50%;
bottom: 100px;
}
.shortcut-arrow .top {
transform: rotate(270deg) translateX(-50%);
width: min-content;
height: min-content;
}
</style>