|
|
|
|
|
<template>
|
|
|
|
|
|
<div v-if="state.isShowSearch" class="ai-float-window ai-float-fullscreen">
|
|
|
|
|
|
<div class="ai-float-main">
|
|
|
|
|
|
<div class="ai-float-left" v-if="isShowLeft">
|
|
|
|
|
|
<component :is="currentComponent" v-bind="currentProps" v-if="currentComponent"/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ai-float-right" v-loading="aiLoading">
|
|
|
|
|
|
<div class="ai-float-header" @mousedown="onDragStart">
|
|
|
|
|
|
<span>AI智能填报助手</span>
|
|
|
|
|
|
<el-icon class="ai-float-close" @click="close">
|
|
|
|
|
|
<Close/>
|
|
|
|
|
|
</el-icon>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ai-float-form-list ai-float-scroll">
|
|
|
|
|
|
<div class="ai-float-welcome">👋 欢迎使用AI智能填报助手</div>
|
|
|
|
|
|
<div class="form-list-title">请选择需要填报的表单:</div>
|
|
|
|
|
|
<!-- 加载状态 -->
|
|
|
|
|
|
<div v-if="formsLoading" class="loading-container">
|
|
|
|
|
|
<el-icon class="is-loading">
|
|
|
|
|
|
<Loading/>
|
|
|
|
|
|
</el-icon>
|
|
|
|
|
|
<span>加载表单中...</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<!-- 表单列表 -->
|
|
|
|
|
|
<div v-else class="form-list">
|
|
|
|
|
|
<el-tag
|
|
|
|
|
|
v-for="aiFormSetting in aiFormSettingList"
|
|
|
|
|
|
:key="aiFormSetting.formSettingId"
|
|
|
|
|
|
:type="aiFormSetting.formSettingId === selectedFormSettingId ? 'success' : 'info'"
|
|
|
|
|
|
class="form-tag"
|
|
|
|
|
|
@click="selectForm(aiFormSetting)"
|
|
|
|
|
|
effect="dark"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ aiFormSetting.formName }}
|
|
|
|
|
|
</el-tag>
|
|
|
|
|
|
<el-button v-if="aiFormSettingList.length > 5" type="text" class="more-btn" @click="showMore = !showMore">
|
|
|
|
|
|
更多
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<el-dialog v-model="showMore" title="选择其他表单" width="400px" @close="moreFilterText = ''">
|
|
|
|
|
|
<el-input
|
|
|
|
|
|
v-model="moreFilterText"
|
|
|
|
|
|
placeholder="输入表单名称过滤"
|
|
|
|
|
|
clearable
|
|
|
|
|
|
style="margin-bottom: 12px;"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<el-scrollbar style="max-height:300px;">
|
|
|
|
|
|
<el-tag
|
|
|
|
|
|
v-for="form in filteredForms"
|
|
|
|
|
|
:key="form.formSettingId"
|
|
|
|
|
|
:type="form.formSettingId === selectedFormSettingId ? 'success' : 'info'"
|
|
|
|
|
|
class="form-tag"
|
|
|
|
|
|
style="margin-bottom: 8px;"
|
|
|
|
|
|
@click="selectForm(form); showMore = false"
|
|
|
|
|
|
effect="dark"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ form.formName }}
|
|
|
|
|
|
</el-tag>
|
|
|
|
|
|
</el-scrollbar>
|
|
|
|
|
|
</el-dialog>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<!-- 聊天区始终渲染,内容为空时不显示消息 -->
|
|
|
|
|
|
<div class="ai-float-chat-list" ref="chatListRef">
|
|
|
|
|
|
<div v-for="(msg, idx) in chatList" :key="idx"
|
|
|
|
|
|
:class="['ai-float-chat-item', msg.from === 'ai' ? 'ai-chat-ai' : 'ai-chat-user']">
|
|
|
|
|
|
<template v-if="msg.from === 'ai'">
|
|
|
|
|
|
<div class="ai-avatar">🤖</div>
|
|
|
|
|
|
<div class="ai-float-chat-bubble ai-float-chat-bubble-ai">
|
|
|
|
|
|
<div v-if="msg.type === 'select' && msg.selectLists">
|
|
|
|
|
|
<div class="ai-select-title" style="font-weight:bold; margin-bottom:8px;">{{ msg.text }}</div>
|
|
|
|
|
|
<div class="ai-select-lists">
|
|
|
|
|
|
<div v-for="(list, lidx) in msg.selectLists" :key="lidx" class="ai-select-list-block">
|
|
|
|
|
|
<div class="ai-select-list-title">{{ list.title }}</div>
|
|
|
|
|
|
<div class="ai-select-options">
|
|
|
|
|
|
<el-button v-for="opt in list.options.slice(0,4)" :key="opt" size="small"
|
|
|
|
|
|
@click="() => handleSelectOption(opt)"
|
|
|
|
|
|
:type="selectedOption === opt ? 'primary' : 'default'">{{ opt }}
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
<el-button v-if="list.options.length > 4" size="small" type="primary" link
|
|
|
|
|
|
@click="() => openSelectDialog(list)">更多
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-else v-html="formatMsg(msg.text)"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<div class="ai-float-chat-bubble-user-wrap">
|
|
|
|
|
|
<div class="ai-float-chat-bubble" v-html="formatMsg(msg.text)"></div>
|
|
|
|
|
|
<div class="ai-avatar ai-avatar-user">👤</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<el-dialog v-model="selectDialog" title="更多选项" width="400px" @close="selectDialogFilterText = ''">
|
|
|
|
|
|
<div v-if="selectDialogList">
|
|
|
|
|
|
<div class="ai-select-title">{{ selectDialogList.title }}</div>
|
|
|
|
|
|
<el-input
|
|
|
|
|
|
v-model="selectDialogFilterText"
|
|
|
|
|
|
placeholder="输入关键字过滤"
|
|
|
|
|
|
clearable
|
|
|
|
|
|
style="margin-bottom: 12px;"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div class="ai-select-options">
|
|
|
|
|
|
<el-button v-for="opt in filteredSelectDialogOptions" :key="opt" size="small"
|
|
|
|
|
|
@click="() => handleSelectOption(opt)" :type="selectedOption === opt ? 'primary' : 'default'">
|
|
|
|
|
|
{{ opt }}
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</el-dialog>
|
|
|
|
|
|
<div class="ai-float-input-area">
|
|
|
|
|
|
<div v-if="selectedFormSettingId" class="ai-float-form-info">
|
|
|
|
|
|
<div class="form-info-title">
|
|
|
|
|
|
当前表单:{{ aiFormSettingList.find(f => f.formSettingId === selectedFormSettingId)?.formName }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-info-tip">请根据下方示例填写内容,AI将自动为您智能填报。</div>
|
|
|
|
|
|
<div class="form-info-example">示例:如"申请10个物料A用于生产线1,计划交付日期为2024-07-01"</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ai-float-input-row">
|
|
|
|
|
|
<el-input
|
|
|
|
|
|
v-model="inputValue"
|
|
|
|
|
|
type="textarea"
|
|
|
|
|
|
:rows="2"
|
|
|
|
|
|
:autosize="{ minRows: 2, maxRows: 6 }"
|
|
|
|
|
|
:maxlength="300"
|
|
|
|
|
|
:disabled="!selectedFormSettingId || aiThinking"
|
|
|
|
|
|
placeholder="请输入内容(最多300字符)"
|
|
|
|
|
|
@keydown="onInputKeydown"
|
|
|
|
|
|
class="ai-float-input"
|
|
|
|
|
|
show-word-limit
|
|
|
|
|
|
clearable
|
|
|
|
|
|
/>
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
class="ai-float-send-btn"
|
|
|
|
|
|
:disabled="!canSend || aiThinking"
|
|
|
|
|
|
icon="el-icon-s-promotion"
|
|
|
|
|
|
@click="() => handleSend()"
|
|
|
|
|
|
circle
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
@click="toggleRecording"
|
|
|
|
|
|
:class="{ 'recording': isRecording }"
|
|
|
|
|
|
v-loading="audioAsrLoading"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ isRecording ? '停止录音' : '开始录音' }}
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import {ref, reactive, computed, onMounted, onBeforeUnmount, nextTick, shallowRef, watch, getCurrentInstance} from 'vue'
|
|
|
|
|
|
import {ElMessage} from 'element-plus'
|
|
|
|
|
|
import {Close, Loading} from '@element-plus/icons-vue'
|
|
|
|
|
|
import type {ComponentInternalInstance} from 'vue'
|
|
|
|
|
|
import {getAiFormSettingList, getAiFormSettingDetailList} from '@/api/ai/base/aiFormSetting';
|
|
|
|
|
|
import {AiFormSettingVO, AiFormSettingDetailVO} from '@/api/ai/base/aiFormSetting/types';
|
|
|
|
|
|
import {aiFillForm, recognizeSpeechByUrl, uploadFile} from '@/api/ai/skill/aiAssistant';
|
|
|
|
|
|
import router from "@/router"
|
|
|
|
|
|
|
|
|
|
|
|
const {proxy} = getCurrentInstance() as ComponentInternalInstance
|
|
|
|
|
|
|
|
|
|
|
|
const aiFormSettingList = ref<AiFormSettingVO[]>([])
|
|
|
|
|
|
const aiFormSettingDetailListMap = ref<Record<number, AiFormSettingDetailVO[]>>({});
|
|
|
|
|
|
const selectedFormSettingId = ref<number | null>(null)
|
|
|
|
|
|
const formsLoading = ref(false)
|
|
|
|
|
|
const isShowLeft = ref(false);
|
|
|
|
|
|
const aiLoading = ref(false);
|
|
|
|
|
|
|
|
|
|
|
|
// 用reactive对象直接定义state
|
|
|
|
|
|
const state = reactive({
|
|
|
|
|
|
isShowSearch: false,
|
|
|
|
|
|
menuQuery: '',
|
|
|
|
|
|
menuList: []
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 搜索弹窗打开
|
|
|
|
|
|
const openSearch = () => {
|
|
|
|
|
|
state.menuQuery = '';
|
|
|
|
|
|
state.isShowSearch = true;
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
// layoutMenuAutocompleteRef.value.focus();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 动态生成的表单页面映射
|
|
|
|
|
|
const formPageMap = ref<Record<number, { loader: () => Promise<any>, props: any }>>({})
|
|
|
|
|
|
|
|
|
|
|
|
// 加载表单数据并初始化formPageMap
|
|
|
|
|
|
const loadForms = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
formsLoading.value = true
|
|
|
|
|
|
const res = await getAiFormSettingList({})
|
|
|
|
|
|
if (res.code === 200) {
|
|
|
|
|
|
aiFormSettingList.value = res.data || []
|
|
|
|
|
|
// 动态生成formPageMap
|
|
|
|
|
|
initFormPageMap()
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('加载表单数据失败:', error)
|
|
|
|
|
|
ElMessage.error('加载表单数据失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
formsLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 动态生成formPageMap
|
|
|
|
|
|
const initFormPageMap = () => {
|
|
|
|
|
|
const newFormPageMap: Record<number, { loader: () => Promise<any>, props: any }> = {}
|
|
|
|
|
|
|
|
|
|
|
|
aiFormSettingList.value.forEach(form => {
|
|
|
|
|
|
// // 根据表单的formPath字段动态生成组件路径
|
|
|
|
|
|
// let componentPath = form.formPath
|
|
|
|
|
|
// if(!form.dialogVisibleVariable || form.dialogVisibleVariable===''){
|
|
|
|
|
|
// componentPath = '../../../views/'+componentPath;
|
|
|
|
|
|
// }else{
|
|
|
|
|
|
// componentPath = "/"+componentPath;
|
|
|
|
|
|
// }
|
|
|
|
|
|
// 创建动态加载器
|
|
|
|
|
|
const loader = () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// const dd = new URL(`@/views/system/user/index.vue`, import.meta.url).href;
|
|
|
|
|
|
// return import(`${componentPathUrl}`);
|
|
|
|
|
|
if (!form.dialogVisibleVariable || form.dialogVisibleVariable === '') {
|
|
|
|
|
|
const componentPath = '../../../views/' + form.formPath;
|
|
|
|
|
|
return import(/* @vite-ignore */ componentPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
// console.error(`加载组件失败: ${componentPath}`, error)
|
|
|
|
|
|
// return import('@/views/ai/skill/aiForm/index.vue')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 根据表单类型设置不同的props
|
|
|
|
|
|
const props = getFormProps(form)
|
|
|
|
|
|
|
|
|
|
|
|
newFormPageMap[form.formSettingId] = {loader, props}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
formPageMap.value = newFormPageMap
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 根据表单信息生成props
|
|
|
|
|
|
const getFormProps = (form: AiFormSettingVO) => {
|
|
|
|
|
|
const baseProps = {
|
|
|
|
|
|
formSettingId: form.formSettingId,
|
|
|
|
|
|
formName: form.formName,
|
|
|
|
|
|
formType: form.formType,
|
|
|
|
|
|
dialogVisibleVariable: form.dialogVisibleVariable,
|
|
|
|
|
|
formPath: form.formPath,
|
|
|
|
|
|
userInput: ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 根据表单类型添加特定props
|
|
|
|
|
|
switch (form.formType) {
|
|
|
|
|
|
case 'system':
|
|
|
|
|
|
return {...baseProps, pageNum: 1, pageSize: 10}
|
|
|
|
|
|
case 'ai':
|
|
|
|
|
|
return {...baseProps, aiType: 'form', mode: 'edit'}
|
|
|
|
|
|
default:
|
|
|
|
|
|
return baseProps
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const selectForm = async (aiFormSetting: AiFormSettingVO) => {
|
|
|
|
|
|
selectedFormSettingId.value = aiFormSetting.formSettingId
|
|
|
|
|
|
aiLoading.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!aiFormSettingDetailListMap.value[aiFormSetting.formSettingId]) {
|
|
|
|
|
|
const res = await getAiFormSettingDetailList(
|
|
|
|
|
|
{formSettingId: aiFormSetting.formSettingId, settingFlag: '1'})
|
|
|
|
|
|
if (res.code === 200) {
|
|
|
|
|
|
aiFormSettingDetailListMap.value[aiFormSetting.formSettingId] = res.data || []
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
aiLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log(aiFormSettingDetailListMap.value[aiFormSetting.formSettingId])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// function selectForm(aiFormSetting: AiFormSettingVO) {
|
|
|
|
|
|
// selectedFormSettingId.value = aiFormSetting.formSettingId
|
|
|
|
|
|
// if(!aiFormSettingDetailListMap[aiFormSetting.formSettingId]){
|
|
|
|
|
|
// const res = await getAiFormSettingDetailList({formSettingId: aiFormSetting.formSettingId})
|
|
|
|
|
|
// if (res.code === 200) {
|
|
|
|
|
|
// aiFormSettingDetailListMap.value[aiFormSetting.formSettingId] = res.data || []
|
|
|
|
|
|
// }
|
|
|
|
|
|
// }
|
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const currentComponent = shallowRef(null)
|
|
|
|
|
|
const currentProps = ref({})
|
|
|
|
|
|
watch(selectedFormSettingId, async (formSettingId) => {
|
|
|
|
|
|
if (formSettingId && formPageMap.value[formSettingId]) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (formPageMap.value[formSettingId].props && formPageMap.value[formSettingId].props.dialogVisibleVariable && formPageMap.value[formSettingId].props.dialogVisibleVariable !== '') {
|
|
|
|
|
|
isShowLeft.value = false
|
|
|
|
|
|
router.push("/" + formPageMap.value[formSettingId].props.formPath)
|
|
|
|
|
|
// proxy.$store.commit('aiFormSettingId', formSettingId)
|
|
|
|
|
|
// proxy.$store.commit('aiFormSetting', formPageMap.value[formSettingId])
|
|
|
|
|
|
} else {
|
|
|
|
|
|
isShowLeft.value = true
|
|
|
|
|
|
const mod = await formPageMap.value[formSettingId].loader()
|
|
|
|
|
|
console.log(formPageMap.value[formSettingId].props)
|
|
|
|
|
|
currentComponent.value = mod.default
|
|
|
|
|
|
currentProps.value = {...formPageMap.value[formSettingId].props, userInput: ''}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('加载表单组件失败:', error)
|
|
|
|
|
|
ElMessage.error('加载表单组件失败')
|
|
|
|
|
|
currentComponent.value = null
|
|
|
|
|
|
currentProps.value = {}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
currentComponent.value = null
|
|
|
|
|
|
currentProps.value = {}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 暴露变量
|
|
|
|
|
|
defineExpose({
|
|
|
|
|
|
openSearch
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
watch(state, (newVal) => {
|
|
|
|
|
|
if (newVal.isShowSearch) {
|
|
|
|
|
|
loadForms()
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const handleSend = async (selectedText?: string) => {
|
|
|
|
|
|
if ((!canSend.value || aiThinking.value) && !selectedText) return
|
|
|
|
|
|
const userMsg = selectedText || inputValue.value
|
|
|
|
|
|
chatList.value.push({text: userMsg, from: 'user', type: 'text'})
|
|
|
|
|
|
scrollToBottom()
|
|
|
|
|
|
aiThinking.value = true
|
|
|
|
|
|
inputValue.value = 'AI思考中....'
|
|
|
|
|
|
aiLoading.value = true
|
|
|
|
|
|
console.log(aiFormSettingDetailListMap.value[selectedFormSettingId.value]);
|
|
|
|
|
|
try {
|
|
|
|
|
|
let params = {
|
|
|
|
|
|
naturalLanguageQuery: userMsg,
|
|
|
|
|
|
formSettingId: selectedFormSettingId.value,
|
|
|
|
|
|
formSettingDetailList: aiFormSettingDetailListMap.value[selectedFormSettingId.value],
|
|
|
|
|
|
modelId: 1,
|
|
|
|
|
|
platformId: 1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// currentProps.value = {completeAmount:10,productionTime:16.6};
|
|
|
|
|
|
// return;
|
|
|
|
|
|
|
|
|
|
|
|
const response = await aiFillForm(params)
|
|
|
|
|
|
currentProps.value = response;
|
|
|
|
|
|
inputValue.value = ''
|
|
|
|
|
|
aiThinking.value = false
|
|
|
|
|
|
// 固定AI回复+多个选择列表
|
|
|
|
|
|
chatList.value.push({
|
|
|
|
|
|
text: '操作成功,请选择相关信息:',
|
|
|
|
|
|
from: 'ai',
|
|
|
|
|
|
type: 'select',
|
|
|
|
|
|
selectLists: [
|
|
|
|
|
|
{title: '物料信息', options: ['物料A', '物料B', '物料C', '物料D', '物料E', '物料F']},
|
|
|
|
|
|
{title: 'BOM信息', options: ['BOM-001', 'BOM-002', 'BOM-003', 'BOM-004', 'BOM-005']}
|
|
|
|
|
|
]
|
|
|
|
|
|
})
|
|
|
|
|
|
scrollToBottom()
|
|
|
|
|
|
|
|
|
|
|
|
console.log(1)
|
|
|
|
|
|
console.log(response)
|
|
|
|
|
|
console.log(2)
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
proxy?.$modal.msgError(error);
|
|
|
|
|
|
console.log("Error:" + error)
|
|
|
|
|
|
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
aiLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 录音相关对象
|
|
|
|
|
|
const isRecording = ref(false);
|
|
|
|
|
|
let mediaRecorder = null
|
|
|
|
|
|
let microphoneStream = null
|
|
|
|
|
|
let audioChunks = []
|
|
|
|
|
|
let audioContext = null
|
|
|
|
|
|
const targetSampleRate = 16000 // 目标采样率,可根据后端要求调整
|
|
|
|
|
|
|
|
|
|
|
|
// 状态变量
|
|
|
|
|
|
const audioAsrLoading = ref(false);
|
|
|
|
|
|
const isUploading = ref(false)
|
|
|
|
|
|
const uploadProgress = ref(0)
|
|
|
|
|
|
const recognitionResult = ref('')
|
|
|
|
|
|
const recordings = ref([])
|
|
|
|
|
|
|
|
|
|
|
|
const toggleRecording = () => {
|
|
|
|
|
|
if (isRecording.value) {
|
|
|
|
|
|
stopRecording();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
startRecording();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 开始录音
|
|
|
|
|
|
async function startRecording() {
|
|
|
|
|
|
alert('您的浏览器不支持录音功能1')
|
|
|
|
|
|
proxy.$modal.msgError('您的浏览器不支持录音功能')
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 检查浏览器支持
|
|
|
|
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
|
|
|
|
proxy.$modal.msgError('您的浏览器不支持录音功能')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取用户麦克风权限
|
|
|
|
|
|
microphoneStream = await navigator.mediaDevices.getUserMedia({
|
|
|
|
|
|
audio: {
|
|
|
|
|
|
echoCancellation: true,
|
|
|
|
|
|
noiseSuppression: true,
|
|
|
|
|
|
autoGainControl: true
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化Web Audio API用于后续处理
|
|
|
|
|
|
if (!audioContext) {
|
|
|
|
|
|
audioContext = new (window.AudioContext)()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 设置MediaRecorder
|
|
|
|
|
|
const options = {
|
|
|
|
|
|
mimeType: 'audio/webm;codecs=opus' // 优先使用opus编码
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 降级处理,确保兼容性
|
|
|
|
|
|
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
|
|
|
|
|
|
options.mimeType = 'audio/webm'
|
|
|
|
|
|
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
|
|
|
|
|
|
options.mimeType = 'audio/ogg;codecs=opus'
|
|
|
|
|
|
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
|
|
|
|
|
|
proxy.$modal.msgError('您的浏览器不支持高质量录音格式')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
mediaRecorder = new MediaRecorder(microphoneStream, options)
|
|
|
|
|
|
audioChunks = []
|
|
|
|
|
|
|
|
|
|
|
|
// 收集音频数据
|
|
|
|
|
|
mediaRecorder.ondataavailable = (event) => {
|
|
|
|
|
|
if (event.data.size > 0) {
|
|
|
|
|
|
audioChunks.push(event.data)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 录音开始
|
|
|
|
|
|
mediaRecorder.start()
|
|
|
|
|
|
isRecording.value = true
|
|
|
|
|
|
// addLog('录音已开始')
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('开始录音失败:', error)
|
|
|
|
|
|
// addLog(`开始录音失败: ${error.message}`)
|
|
|
|
|
|
stopRecording()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 停止录音
|
|
|
|
|
|
function stopRecording() {
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!isRecording.value) return
|
|
|
|
|
|
// 更新录音状态
|
|
|
|
|
|
isRecording.value = false
|
|
|
|
|
|
// 停止MediaRecorder
|
|
|
|
|
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
|
|
|
|
|
mediaRecorder.stop()
|
|
|
|
|
|
// 录音停止后的处理
|
|
|
|
|
|
mediaRecorder.onstop = async () => {
|
|
|
|
|
|
audioAsrLoading.value = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 关闭麦克风流
|
|
|
|
|
|
if (microphoneStream) {
|
|
|
|
|
|
microphoneStream.getTracks().forEach(track => track.stop())
|
|
|
|
|
|
microphoneStream = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// addLog('录音已停止,正在处理音频...')
|
|
|
|
|
|
|
|
|
|
|
|
// 将录音数据转换为WAV格式
|
|
|
|
|
|
const wavBlob = await convertToWav(audioChunks)
|
|
|
|
|
|
|
|
|
|
|
|
// 保存录音到历史记录
|
|
|
|
|
|
const recordingName = `recording_${new Date().toISOString().replace(/[:.]/g, '-')}.wav`
|
|
|
|
|
|
const recordingUrl = URL.createObjectURL(wavBlob)
|
|
|
|
|
|
recordings.value.unshift({
|
|
|
|
|
|
name: recordingName,
|
|
|
|
|
|
url: recordingUrl,
|
|
|
|
|
|
file: wavBlob
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 限制历史记录数量
|
|
|
|
|
|
if (recordings.value.length > 5) {
|
|
|
|
|
|
const oldestRecording = recordings.value.pop()
|
|
|
|
|
|
URL.revokeObjectURL(oldestRecording.url)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// addLog(`音频处理完成,文件大小: ${(wavBlob.size / 1024).toFixed(2)}KB`)
|
|
|
|
|
|
|
|
|
|
|
|
// 自动上传录音文件
|
|
|
|
|
|
await uploadAudioFile(wavBlob, recordingName)
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('处理录音文件失败:', error)
|
|
|
|
|
|
proxy.$modal.msgError('处理录音文件失败,请重试', error)
|
|
|
|
|
|
// addLog(`处理录音文件失败: ${error.message}`)
|
|
|
|
|
|
}finally{
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('停止录音失败:', error)
|
|
|
|
|
|
// addLog(`停止录音失败: ${error.message}`)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 将音频数据转换为WAV格式
|
|
|
|
|
|
async function convertToWav(audioChunks) {
|
|
|
|
|
|
// 将音频块合并为单个Blob
|
|
|
|
|
|
const audioBlob = new Blob(audioChunks)
|
|
|
|
|
|
|
|
|
|
|
|
// 读取音频数据并转换为WAV
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
const fileReader = new FileReader()
|
|
|
|
|
|
fileReader.onload = async (e) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 获取ArrayBuffer
|
|
|
|
|
|
const arrayBuffer = e.target.result
|
|
|
|
|
|
|
|
|
|
|
|
// 使用Web Audio API解码音频数据
|
|
|
|
|
|
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
|
|
|
|
|
|
|
|
|
|
|
|
// 转换为目标采样率的WAV格式
|
|
|
|
|
|
const wavBuffer = await resampleAndConvertToWav(audioBuffer, targetSampleRate)
|
|
|
|
|
|
|
|
|
|
|
|
resolve(new Blob([wavBuffer], {type: 'audio/wav'}))
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
reject(error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
fileReader.onerror = reject
|
|
|
|
|
|
fileReader.readAsArrayBuffer(audioBlob)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 重采样并转换为WAV格式
|
|
|
|
|
|
async function resampleAndConvertToWav(audioBuffer, targetRate) {
|
|
|
|
|
|
// 获取原始音频数据
|
|
|
|
|
|
const channels = audioBuffer.numberOfChannels
|
|
|
|
|
|
const sampleRate = audioBuffer.sampleRate
|
|
|
|
|
|
const length = audioBuffer.length
|
|
|
|
|
|
|
|
|
|
|
|
// 计算重采样后的长度
|
|
|
|
|
|
const newLength = Math.floor(length * targetRate / sampleRate)
|
|
|
|
|
|
|
|
|
|
|
|
// 创建目标采样率的AudioContext
|
|
|
|
|
|
const offlineContext = new OfflineAudioContext(channels, newLength, targetRate)
|
|
|
|
|
|
|
|
|
|
|
|
// 创建缓冲区源节点
|
|
|
|
|
|
const source = offlineContext.createBufferSource()
|
|
|
|
|
|
source.buffer = audioBuffer
|
|
|
|
|
|
|
|
|
|
|
|
// 连接到输出
|
|
|
|
|
|
source.connect(offlineContext.destination)
|
|
|
|
|
|
|
|
|
|
|
|
// 开始播放
|
|
|
|
|
|
source.start()
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染重采样后的音频
|
|
|
|
|
|
const renderedBuffer = await offlineContext.startRendering()
|
|
|
|
|
|
|
|
|
|
|
|
// 将AudioBuffer转换为WAV格式
|
|
|
|
|
|
return encodeWAV(renderedBuffer)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 将AudioBuffer编码为WAV格式
|
|
|
|
|
|
function encodeWAV(audioBuffer) {
|
|
|
|
|
|
const numberOfChannels = audioBuffer.numberOfChannels
|
|
|
|
|
|
const sampleRate = audioBuffer.sampleRate
|
|
|
|
|
|
const bitDepth = 16
|
|
|
|
|
|
|
|
|
|
|
|
// 获取所有声道的数据
|
|
|
|
|
|
const channels = []
|
|
|
|
|
|
for (let i = 0; i < numberOfChannels; i++) {
|
|
|
|
|
|
channels.push(audioBuffer.getChannelData(i))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 计算总样本数
|
|
|
|
|
|
const sampleCount = channels[0].length
|
|
|
|
|
|
const byteRate = sampleRate * numberOfChannels * bitDepth / 8
|
|
|
|
|
|
|
|
|
|
|
|
// 创建WAV文件头部
|
|
|
|
|
|
const wavHeader = new ArrayBuffer(44)
|
|
|
|
|
|
const view = new DataView(wavHeader)
|
|
|
|
|
|
|
|
|
|
|
|
// RIFF标识符
|
|
|
|
|
|
writeString(view, 0, 'RIFF')
|
|
|
|
|
|
// 文件大小
|
|
|
|
|
|
view.setUint32(4, 36 + sampleCount * numberOfChannels * 2, true)
|
|
|
|
|
|
// WAVE标识符
|
|
|
|
|
|
writeString(view, 8, 'WAVE')
|
|
|
|
|
|
// fmt子块标识符
|
|
|
|
|
|
writeString(view, 12, 'fmt ')
|
|
|
|
|
|
// fmt子块大小
|
|
|
|
|
|
view.setUint32(16, 16, true)
|
|
|
|
|
|
// 音频格式(PCM)
|
|
|
|
|
|
view.setUint16(20, 1, true)
|
|
|
|
|
|
// 声道数
|
|
|
|
|
|
view.setUint16(22, numberOfChannels, true)
|
|
|
|
|
|
// 采样率
|
|
|
|
|
|
view.setUint32(24, sampleRate, true)
|
|
|
|
|
|
// 字节率
|
|
|
|
|
|
view.setUint32(28, byteRate, true)
|
|
|
|
|
|
// 块对齐
|
|
|
|
|
|
view.setUint16(32, numberOfChannels * 2, true)
|
|
|
|
|
|
// 位深度
|
|
|
|
|
|
view.setUint16(34, bitDepth, true)
|
|
|
|
|
|
// data子块标识符
|
|
|
|
|
|
writeString(view, 36, 'data')
|
|
|
|
|
|
// data子块大小
|
|
|
|
|
|
view.setUint32(40, sampleCount * numberOfChannels * 2, true)
|
|
|
|
|
|
|
|
|
|
|
|
// 创建PCM数据
|
|
|
|
|
|
const pcmBytes = new ArrayBuffer(wavHeader.byteLength + sampleCount * numberOfChannels * 2)
|
|
|
|
|
|
const pcmView = new DataView(pcmBytes)
|
|
|
|
|
|
|
|
|
|
|
|
// 复制WAV头部
|
|
|
|
|
|
for (let i = 0; i < wavHeader.byteLength; i++) {
|
|
|
|
|
|
pcmView.setUint8(i, new Uint8Array(wavHeader)[i])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 写入PCM数据
|
|
|
|
|
|
let offset = 44
|
|
|
|
|
|
for (let i = 0; i < sampleCount; i++) {
|
|
|
|
|
|
for (let channel = 0; channel < numberOfChannels; channel++) {
|
|
|
|
|
|
const sample = Math.max(-1, Math.min(1, channels[channel][i]))
|
|
|
|
|
|
// 将Float32转换为Int16
|
|
|
|
|
|
pcmView.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true)
|
|
|
|
|
|
offset += 2
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return pcmBytes
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 写入字符串到DataView
|
|
|
|
|
|
function writeString(view, offset, string) {
|
|
|
|
|
|
for (let i = 0; i < string.length; i++) {
|
|
|
|
|
|
view.setUint8(offset + i, string.charCodeAt(i))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 上传音频文件
|
|
|
|
|
|
async function uploadAudioFile(blob, fileName) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
isUploading.value = true
|
|
|
|
|
|
uploadProgress.value = 0
|
|
|
|
|
|
recognitionResult.value = ''
|
|
|
|
|
|
|
|
|
|
|
|
// addLog(`开始上传音频文件: ${fileName}`)
|
|
|
|
|
|
|
|
|
|
|
|
// 创建FormData对象
|
|
|
|
|
|
const formData = new FormData()
|
|
|
|
|
|
formData.append('file', blob, fileName)
|
|
|
|
|
|
|
|
|
|
|
|
const response = await uploadFile(formData);
|
|
|
|
|
|
console.log(response)
|
|
|
|
|
|
|
|
|
|
|
|
// 处理响应
|
|
|
|
|
|
if (response.code === 200) {
|
|
|
|
|
|
// addLog('音频上传成功,正在识别')
|
|
|
|
|
|
|
|
|
|
|
|
// alert(response.data.url)
|
|
|
|
|
|
const response1 = await recognizeSpeechByUrl(response.data.url);
|
|
|
|
|
|
if (response1.code === 200) {
|
|
|
|
|
|
console.log("--" + response1);
|
|
|
|
|
|
inputValue.value += response1.data.text
|
|
|
|
|
|
// recognitionResult.value = response1.data.text || '识别成功,但未返回文本结果'
|
|
|
|
|
|
// alert(recognitionResult.value)
|
|
|
|
|
|
// addLog('识别成功,结果为: ' + recognitionResult.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// addLog(`上传失败,状态码: ${response.status}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('上传音频文件失败:', error)
|
|
|
|
|
|
proxy.$message.error('上传音频文件失败', error)
|
|
|
|
|
|
// addLog(`上传音频文件失败: ${error.message || '未知错误'}`)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
isUploading.value = false
|
|
|
|
|
|
uploadProgress.value = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function handleSend1(selectedText?: string) {
|
|
|
|
|
|
if ((!canSend.value || aiThinking.value) && !selectedText) return
|
|
|
|
|
|
const userMsg = selectedText || inputValue.value
|
|
|
|
|
|
chatList.value.push({text: userMsg, from: 'user', type: 'text'})
|
|
|
|
|
|
// 动态传递userInput参数给左侧组件
|
|
|
|
|
|
if (currentComponent.value) {
|
|
|
|
|
|
currentProps.value = {...currentProps.value, userInput: userMsg}
|
|
|
|
|
|
}
|
|
|
|
|
|
scrollToBottom()
|
|
|
|
|
|
aiThinking.value = true
|
|
|
|
|
|
inputValue.value = 'AI思考中....'
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
inputValue.value = ''
|
|
|
|
|
|
aiThinking.value = false
|
|
|
|
|
|
// 固定AI回复+多个选择列表
|
|
|
|
|
|
chatList.value.push({
|
|
|
|
|
|
text: '操作成功,请选择相关信息:',
|
|
|
|
|
|
from: 'ai',
|
|
|
|
|
|
type: 'select',
|
|
|
|
|
|
selectLists: [
|
|
|
|
|
|
{title: '物料信息', options: ['物料A', '物料B', '物料C', '物料D', '物料E', '物料F']},
|
|
|
|
|
|
{title: 'BOM信息', options: ['BOM-001', 'BOM-002', 'BOM-003', 'BOM-004', 'BOM-005']}
|
|
|
|
|
|
]
|
|
|
|
|
|
})
|
|
|
|
|
|
scrollToBottom()
|
|
|
|
|
|
}, 2000)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const showMore = ref(false)
|
|
|
|
|
|
const inputValue = ref('')
|
|
|
|
|
|
const canSend = computed(() => selectedFormSettingId.value && inputValue.value.trim().length > 0)
|
|
|
|
|
|
|
|
|
|
|
|
// 新增:更多弹窗过滤输入
|
|
|
|
|
|
const moreFilterText = ref('')
|
|
|
|
|
|
const filteredForms = computed(() =>
|
|
|
|
|
|
aiFormSettingList.value.filter(form => form.formName.includes(moreFilterText.value))
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type ChatSelectList = { title: string, options: string[] }
|
|
|
|
|
|
type ChatMsg = { text: string, from: 'user' | 'ai', type?: 'text' | 'select', selectLists?: ChatSelectList[] }
|
|
|
|
|
|
const chatList = ref<ChatMsg[]>([])
|
|
|
|
|
|
const selectDialog = ref(false)
|
|
|
|
|
|
const selectDialogList = ref<ChatSelectList | null>(null)
|
|
|
|
|
|
const aiThinking = ref(false)
|
|
|
|
|
|
const chatListRef = ref<HTMLElement | null>(null)
|
|
|
|
|
|
const selectedOption = ref('')
|
|
|
|
|
|
const iframeRef = ref<HTMLIFrameElement | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
// 新增:AI更多弹窗过滤输入
|
|
|
|
|
|
const selectDialogFilterText = ref('')
|
|
|
|
|
|
const filteredSelectDialogOptions = computed(() =>
|
|
|
|
|
|
selectDialogList.value
|
|
|
|
|
|
? selectDialogList.value.options.filter(opt => opt.includes(selectDialogFilterText.value))
|
|
|
|
|
|
: []
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// 悬浮窗拖拽
|
|
|
|
|
|
const floatStyle = reactive({
|
|
|
|
|
|
left: '',
|
|
|
|
|
|
top: '0px',
|
|
|
|
|
|
width: '440px',
|
|
|
|
|
|
height: '100vh',
|
|
|
|
|
|
zIndex: 9999,
|
|
|
|
|
|
position: 'fixed' as const,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 默认显示在最右侧
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
floatStyle.left = (window.innerWidth - 440) + 'px';
|
|
|
|
|
|
floatStyle.top = '0px';
|
|
|
|
|
|
// 加载表单数据
|
|
|
|
|
|
// loadForms()
|
|
|
|
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
let drag = false
|
|
|
|
|
|
let offsetX = 0
|
|
|
|
|
|
let offsetY = 0
|
|
|
|
|
|
|
|
|
|
|
|
function onDragStart(e: MouseEvent) {
|
|
|
|
|
|
drag = true
|
|
|
|
|
|
offsetX = e.clientX - parseInt(floatStyle.left)
|
|
|
|
|
|
offsetY = e.clientY - parseInt(floatStyle.top)
|
|
|
|
|
|
document.addEventListener('mousemove', onDragging)
|
|
|
|
|
|
document.addEventListener('mouseup', onDragEnd)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onDragging(e: MouseEvent) {
|
|
|
|
|
|
if (!drag) return
|
|
|
|
|
|
let newLeft = e.clientX - offsetX
|
|
|
|
|
|
let newTop = e.clientY - offsetY
|
|
|
|
|
|
// 限制在屏幕内
|
|
|
|
|
|
newLeft = Math.max(0, Math.min(newLeft, window.innerWidth - 440))
|
|
|
|
|
|
newTop = Math.max(0, Math.min(newTop, window.innerHeight - 100))
|
|
|
|
|
|
floatStyle.left = newLeft + 'px'
|
|
|
|
|
|
floatStyle.top = newTop + 'px'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onDragEnd() {
|
|
|
|
|
|
drag = false
|
|
|
|
|
|
document.removeEventListener('mousemove', onDragging)
|
|
|
|
|
|
document.removeEventListener('mouseup', onDragEnd)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
document.removeEventListener('mousemove', onDragging)
|
|
|
|
|
|
document.removeEventListener('mouseup', onDragEnd)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 组件卸载时清理资源
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
// 停止MediaRecorder
|
|
|
|
|
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
|
|
|
|
|
mediaRecorder.stop()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 关闭音频上下文
|
|
|
|
|
|
if (audioContext) {
|
|
|
|
|
|
audioContext.close()
|
|
|
|
|
|
audioContext = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 释放URL对象
|
|
|
|
|
|
recordings.value.forEach(recording => {
|
|
|
|
|
|
URL.revokeObjectURL(recording.url)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function scrollToBottom() {
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
if (chatListRef.value) {
|
|
|
|
|
|
chatListRef.value.scrollTop = chatListRef.value.scrollHeight
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 保证所有方法直接在<script setup>中定义
|
|
|
|
|
|
function handleSelectOption(opt: string) {
|
|
|
|
|
|
selectedOption.value = opt
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openSelectDialog(list: ChatSelectList) {
|
|
|
|
|
|
selectDialogList.value = list
|
|
|
|
|
|
selectDialog.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatMsg(text: string) {
|
|
|
|
|
|
// 转换换行符为<br>,并做简单转义防XSS
|
|
|
|
|
|
return text
|
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
|
.replace(/\n/g, '<br>')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onInputKeydown(e: KeyboardEvent) {
|
|
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
handleSend();
|
|
|
|
|
|
}
|
|
|
|
|
|
// shift+enter 默认换行,无需处理
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function close() {
|
|
|
|
|
|
state.isShowSearch = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 搜索弹窗关闭
|
|
|
|
|
|
const closeSearch = () => {
|
|
|
|
|
|
state.isShowSearch = false;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="less">
|
|
|
|
|
|
.el-message {
|
|
|
|
|
|
z-index:8006 !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-float-fullscreen {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
top: 0;
|
|
|
|
|
|
width: 100vw;
|
|
|
|
|
|
height: 100vh;
|
|
|
|
|
|
z-index: 7005;
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
border-radius: 0;
|
|
|
|
|
|
box-shadow: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-float-window {
|
|
|
|
|
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.18);
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
padding: 0 0 16px 0;
|
|
|
|
|
|
min-height: 220px;
|
|
|
|
|
|
user-select: none;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
height: 100vh;
|
|
|
|
|
|
width: 60vw;
|
|
|
|
|
|
right: 0;
|
|
|
|
|
|
left: auto;
|
|
|
|
|
|
top: 0;
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
z-index: 7005;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-float-main {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
height: 100vh;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-float-left {
|
|
|
|
|
|
flex: 7 7 0;
|
|
|
|
|
|
min-width: 400px;
|
|
|
|
|
|
background: #f7f8fa;
|
|
|
|
|
|
border-right: 1px solid #e0e7ef;
|
|
|
|
|
|
overflow: auto;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-float-right {
|
|
|
|
|
|
flex: 3 3 0;
|
|
|
|
|
|
min-width: 260px;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-float-header {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
padding: 16px 24px 8px 24px;
|
|
|
|
|
|
cursor: move;
|
|
|
|
|
|
border-bottom: 1px solid #f0f0f0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-float-welcome {
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
color: #409eff;
|
|
|
|
|
|
margin-bottom: 10px;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-float-close {
|
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
color: #888;
|
|
|
|
|
|
transition: color 0.2s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-float-close:hover {
|
|
|
|
|
|
color: #f56c6c;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-float-form-list {
|
|
|
|
|
|
padding: 16px 24px 0 24px;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
max-height: 220px;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.form-list-title {
|
|
|
|
|
|
font-size: 15px;
|
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
|
color: #333;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.form-list {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.form-tag {
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
margin-bottom: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.more-btn {
|
|
|
|
|
|
margin-left: 8px;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
padding: 0 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.loading-container {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
padding: 20px;
|
|
|
|
|
|
color: #666;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-float-input-area {
|
|
|
|
|
|
padding: 6px 24px 0 24px;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
height: 140px;
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-float-form-info {
|
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
|
background: #f6f8fa;
|
|
|
|
|
|
border-radius: 1px;
|
|
|
|
|
|
padding: 10px 14px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.form-info-title {
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
color: #333;
|
|
|
|
|
|
margin-bottom: 2px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.form-info-tip {
|
|
|
|
|
|
color: #666;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
margin-bottom: 2px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.form-info-example {
|
|
|
|
|
|
color: #999;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-float-input-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: flex-end;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-float-input {
|
|
|
|
|
|
flex: 1 1 auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-float-send-btn {
|
|
|
|
|
|
margin-bottom: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-float-chat-list {
|
|
|
|
|
|
flex: 1 1 auto;
|
|
|
|
|
|
margin: 0 24px;
|
|
|
|
|
|
margin-top: 12px;
|
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
min-height: 0;
|
|
|
|
|
|
max-height: calc(100vh - 170px - 140px - 32px);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-float-chat-item {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
align-items: flex-end;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-chat-ai {
|
|
|
|
|
|
justify-content: flex-start;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-avatar {
|
|
|
|
|
|
width: 32px;
|
|
|
|
|
|
height: 32px;
|
|
|
|
|
|
background: linear-gradient(135deg, #e0e7ef 0%, #f6f8fa 100%);
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
|
margin-right: 8px;
|
|
|
|
|
|
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.08);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-float-chat-bubble {
|
|
|
|
|
|
background: linear-gradient(135deg, #409eff 0%, #6ec1ff 100%);
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
border-radius: 20px 20px 4px 24px;
|
|
|
|
|
|
padding: 12px 22px;
|
|
|
|
|
|
max-width: 80%;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
word-break: break-all;
|
|
|
|
|
|
box-shadow: 0 4px 16px rgba(64, 158, 255, 0.13);
|
|
|
|
|
|
line-height: 1.85;
|
|
|
|
|
|
min-height: 36px;
|
|
|
|
|
|
display: inline-block;
|
|
|
|
|
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-float-chat-bubble-ai {
|
|
|
|
|
|
background: linear-gradient(135deg, #f4f4f4 0%, #e9f0fb 100%);
|
|
|
|
|
|
color: #333;
|
|
|
|
|
|
border-radius: 16px 16px 16px 6px;
|
|
|
|
|
|
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.10);
|
|
|
|
|
|
border: 1px solid #e0e7ef;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-select-lists {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
align-items: stretch;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-select-list-block {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
text-align: left;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 4px;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-select-list-title {
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
color: #333;
|
|
|
|
|
|
text-align: left;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-select-options {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
text-align: left;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-select-options .el-button {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
text-align: left;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding-left: 0 !important;
|
|
|
|
|
|
justify-content: flex-start;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.el-dialog__body .ai-select-title {
|
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.el-dialog__body .ai-select-options {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
justify-content: flex-start;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-float-chat-bubble-user-wrap {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: flex-end;
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ai-avatar-user {
|
|
|
|
|
|
background: linear-gradient(135deg, #e0e7ef 0%, #d0eaff 100%);
|
|
|
|
|
|
color: #409eff;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
|