UnisKB/ui/src/components/ai-chat/component/chat-input-operate/index.vue

461 lines
14 KiB
Vue
Raw Normal View History

2024-11-13 02:37:16 +00:00
<template>
<div class="ai-chat__operate p-16-24">
<slot name="operateBefore" />
<div class="operate-textarea">
<el-scrollbar max-height="136">
<div
class="p-8-12"
v-loading="localLoading"
v-if="uploadDocumentList.length || uploadImageList.length"
>
<el-space wrap>
2024-11-18 11:10:39 +00:00
<template v-for="(item, index) in uploadDocumentList" :key="index">
<el-card shadow="never" style="--el-card-padding: 8px" class="file cursor">
<div
class="flex align-center"
@mouseenter.stop="mouseenter(item)"
@mouseleave.stop="mouseleave()"
>
<div
@click="deleteFile(index, 'document')"
class="delete-icon color-secondary"
v-if="showDelete === item.url"
>
<el-icon><CircleCloseFilled /></el-icon>
</div>
<img :src="getImgUrl(item && item?.name)" alt="" width="24" />
<div class="ml-4 ellipsis" :title="item && item?.name">
{{ item && item?.name }}
</div>
</div>
</el-card>
2024-11-18 11:10:39 +00:00
</template>
<template v-for="(item, index) in uploadImageList" :key="index">
<div
class="file cursor border border-r-4"
v-if="item.url"
@mouseenter.stop="mouseenter(item)"
@mouseleave.stop="mouseleave()"
>
<div
@click="deleteFile(index, 'image')"
class="delete-icon color-secondary"
v-if="showDelete === item.url"
>
<el-icon><CircleCloseFilled /></el-icon>
</div>
<el-image
:src="item.url"
alt=""
fit="cover"
style="width: 40px; height: 40px; display: block"
class="border-r-4"
/>
</div>
</template>
</el-space>
</div>
</el-scrollbar>
<div class="flex">
<el-input
ref="quickInputRef"
v-model="inputValue"
:placeholder="
startRecorderTime
? '说话中...'
: recorderLoading
? '转文字中...'
: '请输入问题Ctrl+Enter 换行Enter发送'
"
:autosize="{ minRows: 1, maxRows: isMobile ? 4 : 10 }"
type="textarea"
:maxlength="100000"
@keydown.enter="sendChatHandle($event)"
/>
2024-11-13 02:37:16 +00:00
<div class="operate flex align-center">
<span v-if="props.applicationDetails.file_upload_enable" class="flex align-center">
<!-- accept="image/jpeg, image/png, image/gif"-->
<el-upload
action="#"
:auto-upload="false"
:show-file-list="false"
:accept="getAcceptList()"
:on-change="(file: any, fileList: any) => uploadFile(file, fileList)"
2024-11-13 02:37:16 +00:00
>
<el-button text>
<el-icon><Paperclip /></el-icon>
</el-button>
</el-upload>
<el-divider direction="vertical" />
</span>
<span v-if="props.applicationDetails.stt_model_enable" class="flex align-center">
<el-button text v-if="mediaRecorderStatus" @click="startRecording">
<el-icon>
<Microphone />
</el-icon>
2024-11-13 02:37:16 +00:00
</el-button>
<div v-else class="operate flex align-center">
<el-text type="info"
>00:{{ recorderTime < 10 ? `0${recorderTime}` : recorderTime }}</el-text
>
<el-button text type="primary" @click="stopRecording" :loading="recorderLoading">
<AppIcon iconName="app-video-stop"></AppIcon>
</el-button>
</div>
<el-divider v-if="!startRecorderTime && !recorderLoading" direction="vertical" />
</span>
2024-11-13 02:37:16 +00:00
<el-button
v-if="!startRecorderTime && !recorderLoading"
text
class="sent-button"
:disabled="isDisabledChart || loading"
@click="sendChatHandle"
>
<img v-show="isDisabledChart || loading" src="@/assets/icon_send.svg" alt="" />
<SendIcon v-show="!isDisabledChart && !loading" />
</el-button>
</div>
2024-11-13 02:37:16 +00:00
</div>
</div>
<div class="text-center" v-if="applicationDetails.disclaimer" style="margin-top: 8px">
2024-11-13 02:37:16 +00:00
<el-text type="info" v-if="applicationDetails.disclaimer" style="font-size: 12px">
<auto-tooltip :content="applicationDetails.disclaimer_value">
{{ applicationDetails.disclaimer_value }}
</auto-tooltip>
</el-text>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import Recorder from 'recorder-core'
import applicationApi from '@/api/application'
import { MsgAlert } from '@/utils/message'
import { type chatType } from '@/api/type/application'
import { useRoute } from 'vue-router'
import { getImgUrl } from '@/utils/utils'
2024-11-13 02:37:16 +00:00
import 'recorder-core/src/engine/mp3'
2024-11-13 02:37:16 +00:00
import 'recorder-core/src/engine/mp3-engine'
import { MsgWarning } from '@/utils/message'
const route = useRoute()
const {
query: { mode }
} = route as any
const quickInputRef = ref()
const props = withDefaults(
defineProps<{
applicationDetails: any
type: 'log' | 'ai-chat' | 'debug-ai-chat'
loading: boolean
isMobile: boolean
appId?: string
chatId: string
sendMessage: (question: string, other_params_data?: any, chat?: chatType) => void
openChatId: () => Promise<string>
2024-11-13 02:37:16 +00:00
}>(),
{
applicationDetails: () => ({}),
available: true
}
)
const emit = defineEmits(['update:chatId', 'update:loading'])
const chartOpenId = ref<string>()
const chatId_context = computed({
get: () => {
if (chartOpenId.value) {
return chartOpenId.value
}
return props.chatId
},
set: (v) => {
chartOpenId.value = v
emit('update:chatId', v)
}
})
const localLoading = computed({
get: () => {
return props.loading
},
set: (v) => {
emit('update:loading', v)
}
})
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp']
const documentExtensions = ['pdf', 'docx', 'txt', 'xls', 'xlsx', 'md', 'html', 'csv']
const videoExtensions = ['mp4', 'avi', 'mov', 'mkv', 'flv']
const audioExtensions = ['mp3', 'wav', 'aac', 'flac']
const getAcceptList = () => {
const { image, document, audio, video } = props.applicationDetails.file_upload_setting
let accepts = ''
if (image) {
accepts += imageExtensions.map((ext) => '.' + ext).join(',')
}
if (document) {
accepts += documentExtensions.map((ext) => '.' + ext).join(',')
}
if (audio) {
accepts += audioExtensions.map((ext) => '.' + ext).join(',')
}
if (video) {
accepts += videoExtensions.map((ext) => '.' + ext).join(',')
}
return accepts
}
2024-11-13 02:37:16 +00:00
const uploadFile = async (file: any, fileList: any) => {
const { maxFiles, fileLimit } = props.applicationDetails.file_upload_setting
// 单次上传文件数量限制
const file_limit_once = uploadImageList.value.length + uploadDocumentList.value.length
if (file_limit_once >= maxFiles) {
2024-11-13 02:37:16 +00:00
MsgWarning('最多上传' + maxFiles + '个文件')
fileList.splice(0, fileList.length)
2024-11-13 02:37:16 +00:00
return
}
if (fileList.filter((f: any) => f.size > fileLimit * 1024 * 1024).length > 0) {
// MB
MsgWarning('单个文件大小不能超过' + fileLimit + 'MB')
fileList.splice(0, fileList.length)
return
}
const formData = new FormData()
for (const file of fileList) {
formData.append('file', file.raw, file.name)
//
const extension = file.name.split('.').pop().toLowerCase() // 获取文件后缀名并转为小写
if (imageExtensions.includes(extension)) {
uploadImageList.value.push(file)
} else if (documentExtensions.includes(extension)) {
uploadDocumentList.value.push(file)
} else if (videoExtensions.includes(extension)) {
// videos.push(file)
} else if (audioExtensions.includes(extension)) {
// audios.push(file)
}
2024-11-13 02:37:16 +00:00
}
if (!chatId_context.value) {
const res = await props.openChatId()
chatId_context.value = res
}
if (props.type === 'debug-ai-chat') {
formData.append('debug', 'true')
} else {
formData.append('debug', 'false')
2024-11-13 02:37:16 +00:00
}
applicationApi
.uploadFile(
props.applicationDetails.id as string,
chatId_context.value as string,
formData,
localLoading
)
.then((response) => {
fileList.splice(0, fileList.length)
uploadImageList.value.forEach((file: any) => {
2024-11-13 02:37:16 +00:00
const f = response.data.filter((f: any) => f.name === file.name)
if (f.length > 0) {
file.url = f[0].url
file.file_id = f[0].file_id
}
})
uploadDocumentList.value.forEach((file: any) => {
const f = response.data.filter((f: any) => f.name === file.name)
if (f.length > 0) {
file.url = f[0].url
file.file_id = f[0].file_id
}
})
console.log(uploadDocumentList.value, uploadImageList.value)
2024-11-13 02:37:16 +00:00
})
}
const recorderTime = ref(0)
const startRecorderTime = ref(false)
const recorderLoading = ref(false)
const inputValue = ref<string>('')
const uploadImageList = ref<Array<any>>([])
const uploadDocumentList = ref<Array<any>>([])
2024-11-13 02:37:16 +00:00
const mediaRecorderStatus = ref(true)
const showDelete = ref('')
2024-11-13 02:37:16 +00:00
// 定义响应式引用
const mediaRecorder = ref<any>(null)
const isDisabledChart = computed(
() => !(inputValue.value.trim() && (props.appId || props.applicationDetails?.name))
)
// 开始录音
const startRecording = async () => {
try {
// 取消录音控制台日志
Recorder.CLog = function () {}
mediaRecorderStatus.value = false
handleTimeChange()
mediaRecorder.value = new Recorder({
type: 'mp3',
bitRate: 128,
sampleRate: 16000
})
mediaRecorder.value.open(
() => {
mediaRecorder.value.start()
},
(err: any) => {
MsgAlert(
`提示`,
`<p>该功能需要使用麦克风,浏览器禁止不安全页面录音,解决方案如下:<br/>
1可开启 https 解决<br/>
2若无 https 配置则需要修改浏览器安全配置Chrome 设置如下<br/>
(1) 地址栏输入chrome://flags/#unsafely-treat-insecure-origin-as-secure<br/>
(2) http 站点配置在文本框中例如: http://127.0.0.1:8080。</p>
<img src="${new URL(`../../assets/tipIMG.jpg`, import.meta.url).href}" style="width: 100%;" />`,
{
confirmButtonText: '我知道了',
dangerouslyUseHTMLString: true,
customClass: 'record-tip-confirm'
}
)
}
)
} catch (error) {
MsgAlert(
`提示`,
`<p>该功能需要使用麦克风,浏览器禁止不安全页面录音,解决方案如下:<br/>
1可开启 https 解决<br/>
2若无 https 配置则需要修改浏览器安全配置Chrome 设置如下<br/>
(1) 地址栏输入chrome://flags/#unsafely-treat-insecure-origin-as-secure<br/>
(2) http 站点配置在文本框中例如: http://127.0.0.1:8080。</p>
<img src="${new URL(`../../assets/tipIMG.jpg`, import.meta.url).href}" style="width: 100%;" />`,
{
confirmButtonText: '我知道了',
dangerouslyUseHTMLString: true,
customClass: 'record-tip-confirm'
}
)
}
}
// 停止录音
const stopRecording = () => {
startRecorderTime.value = false
recorderTime.value = 0
if (mediaRecorder.value) {
mediaRecorderStatus.value = true
mediaRecorder.value.stop(
(blob: Blob, duration: number) => {
// 测试blob是否能正常播放
// const link = document.createElement('a')
// link.href = window.URL.createObjectURL(blob)
// link.download = 'abc.mp3'
// link.click()
uploadRecording(blob) // 上传录音文件
},
(err: any) => {
console.error('录音失败:', err)
}
)
}
}
// 上传录音文件
const uploadRecording = async (audioBlob: Blob) => {
try {
recorderLoading.value = true
const formData = new FormData()
formData.append('file', audioBlob, 'recording.mp3')
applicationApi
.postSpeechToText(props.applicationDetails.id as string, formData, localLoading)
.then((response) => {
recorderLoading.value = false
mediaRecorder.value.close()
inputValue.value = typeof response.data === 'string' ? response.data : ''
// chatMessage(null, res.data)
})
} catch (error) {
recorderLoading.value = false
console.error('上传失败:', error)
}
}
const handleTimeChange = () => {
startRecorderTime.value = true
setTimeout(() => {
if (recorderTime.value === 60) {
recorderTime.value = 0
stopRecording()
startRecorderTime.value = false
}
if (!startRecorderTime.value) {
return
}
recorderTime.value++
handleTimeChange()
}, 1000)
}
2024-11-13 02:37:16 +00:00
function sendChatHandle(event: any) {
if (!event.ctrlKey) {
// 如果没有按下组合键ctrl则会阻止默认事件
event.preventDefault()
if (!isDisabledChart.value && !props.loading && !event.isComposing) {
if (inputValue.value.trim()) {
props.sendMessage(inputValue.value, {
image_list: uploadImageList.value,
document_list: uploadDocumentList.value
})
2024-11-13 02:37:16 +00:00
inputValue.value = ''
uploadImageList.value = []
uploadDocumentList.value = []
2024-11-13 02:37:16 +00:00
quickInputRef.value.textareaStyle.height = '45px'
}
}
} else {
// 如果同时按下ctrl+回车键,则会换行
inputValue.value += '\n'
}
}
function deleteFile(index: number, val: string) {
if (val === 'image') {
uploadImageList.value.splice(index, 1)
} else if (val === 'document') {
uploadDocumentList.value.splice(index, 1)
}
}
function mouseenter(row: any) {
showDelete.value = row.url
}
function mouseleave() {
showDelete.value = ''
}
2024-11-13 02:37:16 +00:00
onMounted(() => {
setTimeout(() => {
if (quickInputRef.value && mode === 'embed') {
quickInputRef.value.textarea.style.height = '0'
}
}, 1800)
})
</script>
<style lang="scss" scope>
2024-11-13 03:37:36 +00:00
@import '../../index.scss';
.file {
position: relative;
overflow: inherit;
.delete-icon {
position: absolute;
right: -5px;
top: -5px;
z-index: 1;
}
}
2024-11-13 02:37:16 +00:00
</style>