461 lines
12 KiB
Vue
461 lines
12 KiB
Vue
<template>
|
||
<div class="flex flex-col w-full h-full">
|
||
<ScrollContainer id="scrollRef" ref="scrollRef">
|
||
<div class="p-26px">
|
||
<MessageGroup
|
||
v-for="(item, i) in dataSources"
|
||
:key="i"
|
||
:arr="item"
|
||
></MessageGroup>
|
||
</div>
|
||
</ScrollContainer>
|
||
<div class="p-18px">
|
||
<div class="border border-[#414548]">
|
||
<div class="border-b border-[#414548] h-74px flex items-center px-25px">
|
||
<SvgIcon
|
||
@click="handleRefresh"
|
||
name="refresh"
|
||
class="text-white text-26px cursor-pointer mr-28px"
|
||
></SvgIcon>
|
||
<SvgIcon
|
||
@click="handleStop"
|
||
name="stop"
|
||
class="text-white text-26px cursor-pointer mr-28px"
|
||
></SvgIcon>
|
||
<div class="flex-1 text-right text-22px">
|
||
剩余字数:{{ surplus }}
|
||
</div>
|
||
</div>
|
||
<van-field
|
||
ref="inputRef"
|
||
class="cu-field-msg !text-white"
|
||
type="textarea"
|
||
placeholder="请输入内容"
|
||
:rows="3"
|
||
v-model="inputValue"
|
||
:border="false"
|
||
@pressEnter="handleEnter"
|
||
/>
|
||
<div class="border-t border-[#414548] pb-10px px-14px">
|
||
<div class="flex items-end my-14px">
|
||
<div class="flex">
|
||
<div class="text-[#EC4B4B] text-18px">*</div>
|
||
<div class="opacity-40 text-21px">
|
||
可支持的格式:.doc、.docx、.pdf(小于20M)
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center">
|
||
<van-button
|
||
@click="handleClear"
|
||
type="primary"
|
||
class="!rounded-2px !h-53px !bg-[#414548] !mr-14px border-[#414548] text-white !border-none text-22px"
|
||
>
|
||
<template #icon>
|
||
<SvgIcon name="delete" class="text-25px mr-13px" />
|
||
</template>
|
||
清除</van-button
|
||
>
|
||
<UploadComp @change="handleFileChange">
|
||
<van-button
|
||
type="primary"
|
||
class="!rounded-2px !h-53px !mr-14px border-[#414548] text-white !border-none !text-22px !leading-53px"
|
||
>
|
||
<template #icon>
|
||
<SvgIcon name="cloud-upload" class="text-25px mr-13px" />
|
||
</template>
|
||
上传</van-button
|
||
>
|
||
</UploadComp>
|
||
<div class="flex-1"></div>
|
||
<van-button
|
||
@click="handleSubmit"
|
||
type="primary"
|
||
class="!rounded-2px !h-53px border-[#414548] text-white !border-none !text-22px"
|
||
>
|
||
<template #icon>
|
||
<SvgIcon name="generated" class="text-25px mr-13px" />
|
||
</template>
|
||
生成</van-button
|
||
>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<script setup>
|
||
import MessageGroup from './components/message-group.vue'
|
||
import {
|
||
ref,
|
||
computed,
|
||
onMounted,
|
||
nextTick,
|
||
onBeforeMount,
|
||
getCurrentInstance,
|
||
} from 'vue'
|
||
import ScrollContainer from '@/components/ScrollContainer/index.vue'
|
||
import http from '@/io/request'
|
||
import { v4 as uuidv4 } from 'uuid'
|
||
import { useRoute } from 'vue-router'
|
||
import { useChat } from '@/stores'
|
||
import UploadComp from './components/upload-comp.vue'
|
||
import extractTextFromPDF from '@/utils/pdfUtils.js'
|
||
import extractDocxText from '@/utils/docxUtils.js'
|
||
import { getFileInfo } from '@/utils/fileUtils.js'
|
||
import { showToast } from 'vant'
|
||
import { useAuthModal } from '@/stores/authModal'
|
||
import { useUserInfo } from '@/stores/userInfo'
|
||
const userInfo = useUserInfo()
|
||
|
||
const isLogin = computed(() => {
|
||
return !!userInfo.userData?.id ?? false
|
||
})
|
||
|
||
const surplus = computed(() => {
|
||
const { words_count=0 ,words_used = 0} = userInfo.userData?.equity ?? {}
|
||
return words_count - words_used
|
||
})
|
||
|
||
const authModal = useAuthModal()
|
||
|
||
const { proxy } = getCurrentInstance()
|
||
|
||
let controller = new AbortController()
|
||
|
||
const inputRef = ref(null)
|
||
|
||
const route = useRoute()
|
||
|
||
const { uuid } = route.params
|
||
|
||
const chatStore = useChat()
|
||
|
||
const scrollRef = ref(null)
|
||
|
||
const loading = ref(false)
|
||
|
||
const inputValue = ref('')
|
||
|
||
const fileLoading = ref(false)
|
||
|
||
const currentChart = computed(() => chatStore.getCurrentChat)
|
||
|
||
|
||
|
||
|
||
async function handleFileChange(file) {
|
||
if (!isLogin.value) {
|
||
authModal.setAuthModalType('login')
|
||
authModal.showAuthModal()
|
||
return
|
||
}
|
||
|
||
if (!userInfo.userData?.equity?.pdf) return showToast('上传文件权限未开通')
|
||
|
||
if (fileLoading.value) return
|
||
fileLoading.value = true
|
||
try {
|
||
const valid = validateFile(file)
|
||
if (!valid) return
|
||
const info = getFileInfo(file)
|
||
let result = ''
|
||
if (info.extension === 'docx' || info.extension === 'doc') {
|
||
result = await extractDocxText(file)
|
||
} else if (info.extension === 'pdf') {
|
||
result = await extractTextFromPDF(file)
|
||
}
|
||
if (!result || result.trim() == '') return showToast('文件内容为空')
|
||
onConversation('next', info, result)
|
||
} catch (error) {
|
||
showToast(error.message)
|
||
} finally {
|
||
fileLoading.value = false
|
||
}
|
||
}
|
||
|
||
function validateFile(file) {
|
||
const allowedExtensions = ['doc', 'docx', 'pdf']
|
||
const maxFileSize = 20 * 1024 * 1024 // 20MB
|
||
|
||
const fileExtension = getFileExtension(file.name)
|
||
const fileSize = file.size
|
||
|
||
if (fileSize === 0) {
|
||
throw new Error('文件内容为空')
|
||
}
|
||
|
||
if (!allowedExtensions.includes(fileExtension)) {
|
||
throw new Error('文件格式无效')
|
||
}
|
||
|
||
if (fileSize > maxFileSize) {
|
||
throw new Error('文件大小超出限制')
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
function getFileExtension(fileName) {
|
||
const parts = fileName.split('.')
|
||
return parts[parts.length - 1].toLowerCase()
|
||
}
|
||
|
||
const conversationList = computed(() => {
|
||
return chatStore.getCurrentChat.filter((item) => {
|
||
const key = Object.keys(item)[0]
|
||
const value = item[key]
|
||
return !value.inversion
|
||
})
|
||
})
|
||
|
||
const conversationUserList = computed(() => {
|
||
return chatStore.getCurrentChat.filter((item) => {
|
||
const key = Object.keys(item)[0]
|
||
const value = item[key]
|
||
return value.inversion
|
||
})
|
||
})
|
||
|
||
const dataSources = computed(() => {
|
||
const arr = chatStore.getCurrentChat
|
||
return groupDataByParentId(arr)
|
||
})
|
||
|
||
function groupDataByParentId(data) {
|
||
return data.reduce((groupedData, item) => {
|
||
const itemId = Object.keys(item)[0]
|
||
const itemData = item[itemId]
|
||
|
||
if (itemData.parent_id === '') {
|
||
groupedData[itemId] = []
|
||
} else {
|
||
const parentItemId = itemData.parent_id
|
||
groupedData[parentItemId] = groupedData[parentItemId] || []
|
||
groupedData[parentItemId].push(itemData)
|
||
}
|
||
|
||
return groupedData
|
||
}, {})
|
||
}
|
||
|
||
function handleRefresh() {
|
||
if (currentChart.value.length && !loading.value) onConversation('variant')
|
||
}
|
||
|
||
function handleStop() {
|
||
if (loading.value) {
|
||
controller.abort()
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function handleClear() {
|
||
inputValue.value = ''
|
||
}
|
||
|
||
function handleSubmit() {
|
||
onConversation()
|
||
}
|
||
|
||
async function onConversation(action = 'next', file, fileText) {
|
||
let message = inputValue.value || fileText
|
||
if (loading.value) return
|
||
if (!isLogin) {
|
||
authModal.setAuthModalType('login')
|
||
authModal.showAuthModal()
|
||
return
|
||
}
|
||
|
||
if ((!message || message.trim() === '') && action == 'next') return
|
||
|
||
const params = {
|
||
action: action,
|
||
conversation_id: chatStore.active,
|
||
message: {
|
||
text: message?.substring(0, 4000),
|
||
id: uuidv4(),
|
||
type: (file?.extension === 'doc' ? 'docx' : file?.extension) || 'text',
|
||
fileInfo: file,
|
||
},
|
||
}
|
||
if (action == 'next') {
|
||
const lastContext =
|
||
conversationList.value[conversationList.value.length - 1]
|
||
params.parent_message_id = uuidv4()
|
||
if (lastContext) {
|
||
params.parent_message_id = lastContext[Object.keys(lastContext)[0]]?.id
|
||
}
|
||
|
||
chatStore.addChatByUuid(chatStore.active, {
|
||
text: message,
|
||
inversion: true,
|
||
parent_id: params.parent_message_id || '',
|
||
id: params.message.id,
|
||
type: params.message.type,
|
||
fileInfo: file,
|
||
})
|
||
scrollToBottom()
|
||
chatStore.addChatByUuid(chatStore.active, {
|
||
text: '⋯',
|
||
id: uuidv4(),
|
||
parent_id: params.message.id,
|
||
inversion: false,
|
||
})
|
||
}
|
||
if (action == 'variant') {
|
||
const lastUserContext =
|
||
conversationUserList.value[conversationUserList.value.length - 1]
|
||
if (lastUserContext) {
|
||
const obj = lastUserContext[Object.keys(lastUserContext)[0]]
|
||
params.message.text = obj.text?.substring(0, 4000)
|
||
params.message.id = obj.id
|
||
params.parent_message_id = obj.id
|
||
}
|
||
|
||
chatStore.addChatByUuid(chatStore.active, {
|
||
text: '⋯',
|
||
id: uuidv4(),
|
||
parent_id: params.parent_message_id,
|
||
inversion: false,
|
||
})
|
||
}
|
||
|
||
loading.value = true
|
||
inputValue.value = ''
|
||
|
||
scrollToBottom()
|
||
let tempMessage_id = null
|
||
try {
|
||
const fetchChatAPIOnce = async () => {
|
||
await http.post('/api/v1/conversation', params, {
|
||
signal: controller.signal,
|
||
requestBaseUrl: 'chat',
|
||
onDownloadProgress: async ({ event }) => {
|
||
const xhr = event.target
|
||
const { responseText } = xhr
|
||
if (xhr.status == 200) {
|
||
const arr = parseEventMessages(responseText)
|
||
const { conversation_id, message_id } = arr[0]
|
||
tempMessage_id = message_id
|
||
const msg = arr.reduce((acc, item) => {
|
||
return acc + item.text
|
||
}, '')
|
||
chatStore.updateChatByUuid(
|
||
conversation_id,
|
||
chatStore.getCurrentChat.length - 1,
|
||
{
|
||
[message_id]: {
|
||
conversation_id: conversation_id,
|
||
id: message_id,
|
||
text: msg,
|
||
inversion: false,
|
||
loading: true,
|
||
parent_id: params.message.id || '',
|
||
},
|
||
}
|
||
)
|
||
scrollToBottomIfAtBottom()
|
||
}
|
||
},
|
||
})
|
||
|
||
chatStore.updateChatSome(chatStore.getCurrentChat.length - 1, {
|
||
loading: false,
|
||
})
|
||
await chatStore.getChat(chatStore.active)
|
||
}
|
||
|
||
await fetchChatAPIOnce()
|
||
} catch (error) {
|
||
if (error.message === 'canceled') {
|
||
chatStore.updateChatSome(chatStore.getCurrentChat.length - 1, {
|
||
loading: false,
|
||
})
|
||
return
|
||
}
|
||
const errorMessage = error?.errmsg ?? '好像出错了,请稍后再试。'
|
||
|
||
chatStore.updateChatSome(chatStore.getCurrentChat.length - 1, {
|
||
loading: false,
|
||
text: errorMessage,
|
||
})
|
||
} finally {
|
||
loading.value = false
|
||
userInfo.getUserInfo()
|
||
}
|
||
}
|
||
|
||
function parseEventMessages(dataString) {
|
||
const lines = dataString.trim().split('\n')
|
||
const eventMessages = []
|
||
let currentEvent = {}
|
||
|
||
for (const line of lines) {
|
||
if (line.startsWith('event:message')) {
|
||
if (Object.keys(currentEvent).length > 0) {
|
||
eventMessages.push(currentEvent)
|
||
currentEvent = {}
|
||
}
|
||
} else if (line.startsWith('data:')) {
|
||
const jsonData = line.substring('data:'.length)
|
||
currentEvent = JSON.parse(jsonData)
|
||
}
|
||
}
|
||
|
||
if (Object.keys(currentEvent).length > 0) {
|
||
eventMessages.push(currentEvent)
|
||
}
|
||
|
||
return eventMessages
|
||
}
|
||
|
||
function handleEnter(event) {
|
||
if (event.key === 'Enter' && !event.shiftKey) {
|
||
event.preventDefault()
|
||
handleSubmit()
|
||
}
|
||
}
|
||
|
||
function handleDataChange(data) {
|
||
inputValue.value = data
|
||
}
|
||
|
||
onMounted(async () => {
|
||
proxy.$mitt.on('temp-copy', handleDataChange)
|
||
if (inputRef.value) {
|
||
inputRef.value.focus()
|
||
}
|
||
await chatStore.getChat(uuid)
|
||
scrollToBottom()
|
||
})
|
||
|
||
function scrollToBottom() {
|
||
nextTick()
|
||
scrollRef.value.scrollBottom()
|
||
}
|
||
|
||
function scrollToBottomIfAtBottom() {
|
||
nextTick()
|
||
scrollRef.value.scrollToBottomIfAtBottom()
|
||
}
|
||
</script>
|
||
<style lang="scss" scoped>
|
||
.scroll-smooth {
|
||
scroll-behavior: smooth;
|
||
}
|
||
</style>
|
||
<style lang="scss">
|
||
.cu-field-msg {
|
||
&.van-cell {
|
||
background-color: transparent;
|
||
padding: 14px 18px;
|
||
font-size: 22px;
|
||
placeholder-color: #999999;
|
||
}
|
||
.van-field__control {
|
||
color: white;
|
||
}
|
||
}
|
||
</style>
|