weipengfei 0301413779 更新
2023-10-12 16:06:59 +08:00

717 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 lang='ts'>
import type { Ref } from 'vue'
import { computed, onMounted, onUnmounted, reactive, ref } 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 { 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 IatRecorder from '@/utils/test.js'
// import IatRecorder from '@/utils/larRcorder.js'
// import socket from "@/websocket/socket";
// let socket = new WebSocket("wss://chat.lihaink.cn/chat");
// 接收命令
const connection = new Push({
url: 'wss://chat.lihaink.cn/zhanti/push', // websocket地址
app_key: 'aaea61749929eb53a4bd75a1474c1d27',
auth: '/plugin/webman/push/auth', // 订阅鉴权(仅限于私有频道)
})
// 是否正在录音
let recordFalg = 0
// 假设用户uid为1
const uid = 1
// 浏览器监听user-1频道的消息也就是用户uid为1的用户消息
const user_channel = connection.subscribe(`user-${uid}`)
console.log(user_channel)
// 当user-1频道有message事件的消息时
user_channel.on('message', (data: any) => {
// data里是消息内容
console.log('收到命令', data)
if (recordFalg == 0) {
RecordXunfei()
recordFalg = 1
}
else {
RecordXunfei()
handleSubmit()
recordFalg = 0
}
})
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 { addChat, updateChat, updateChatSome, getChatByUuidAndIndex }
= useChat()
const { scrollRef, scrollToBottom, scrollToBottomIfAtBottom } = useScroll()
const { usingContext, toggleUsingContext } = useUsingContext()
const { uuid } = route.params as { uuid: string }
const dataSources = computed(() => chatStore.getChatByUuid(+uuid))
const conversationList = computed(() =>
dataSources.value.filter(
item => !item.inversion && !!item.conversationOptions,
),
)
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()
}
// 发送消息
async function onConversation() {
const message = prompt.value
const socket = new WebSocket('wss://chat.lihaink.cn/chat')
const promise = () => {
return new Promise((resolve, reject) => {
// 监听WebSocket连接打开事件
socket.onopen = () => {
console.log('socket已连接')
resolve(null)
}
})
}
await promise()
// 监听WebSocket关闭事件
socket.onclose = (event: any) => {
console.log('连接已关闭: ', event)
}
if (loading.value)
return
if (!message || message.trim() === '')
return
controller = new AbortController()
addChat(+uuid, {
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, {
dateTime: new Date().toLocaleString(),
text: '思考中',
loading: true,
inversion: false,
error: false,
conversationOptions: null,
requestOptions: { prompt: message, options: { ...options } },
})
scrollToBottom()
try {
let lastText = ''
let mp3: any = []
console.log('发送消息', message)
const fetchChatAPIOnce = async () => {
socket.send(JSON.stringify({
tts: 1,
data: [
{
role: 'user',
content: message,
},
],
}))
// 监听WebSocket接收消息事件
socket.onmessage = (event: any) => {
const msg = JSON.parse(event.data)
// console.log(`收到消息: `, msg.payload.choices.text[0].content);
// console.log(`当前消息: `, dataSources.value[dataSources.value.length - 1].text);
lastText += msg.payload.choices.text[0].content;
mp3.push(msg.payload.choices.mp3)
updateChat(
+uuid,
dataSources.value.length - 1,
{
dateTime: new Date().toLocaleString(),
text: lastText,
mp3: mp3,
inversion: false,
error: false,
loading: true,
conversationOptions: { conversationId: msg.header.sid, parentMessageId: msg.header.sid },
requestOptions: { prompt: message, options: { ...options } },
},
)
}
// 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,
// 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 } },
// },
// )
// if (openLongReply && 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 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) {
controller.abort()
loading.value = false
}
}
// 可优化部分
// 搜索选项计算这里使用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
})
onMounted(() => {
scrollToBottom()
if (inputRef.value && !isMobile.value)
inputRef.value?.focus()
})
onUnmounted(() => {
if (loading.value)
controller.abort()
})
const iatRecorder = reactive(new IatRecorder())
const a = true
// watch(() => iatRecorder.resultText, (n, o) => {
// console.log('监听', n)
// prompt.value = n
// })
window.winText = ''
window.addEventListener('test', (e) => {
if (recordFalg == 1)
prompt.value = window.winText
})
const click = () => {
// prompt.value = window.winText
RecordXunfei()
// if (a) {
// iatRecorder.start(prompt.value)
// a = !a
// console.log('录音开始')
// }
// else {
// iatRecorder.stop()
// a = !a
// console.log('录音结束')
// console.log('最终结果', iatRecorder)
// }
}
</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']"
>
<!-- <div @click="click">录音开始</div> -->
<div>
浏览器录音听写:<button id="btn_control" @click="click">
开始录音
</button>
</div>
<br>
<div id="result" ref="resultRef" />
<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"
:mp3="item.mp3"
:inversion="item.inversion"
:error="item.error"
:loading="item.loading"
@regenerate="onRegenerate(index)"
@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>
{{ t("common.stopResponding") }}
</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">
<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
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>