You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1793 lines
49 KiB
Vue

<template>
<div v-if="state.isShowSearch" :class="['ai-float-window', currentSizeClass]"
:style="currentSize === 'custom' ? {
width: windowSize.width,
height: windowSize.height,
left: typeof windowPosition.x === 'string' ? 'auto' : windowPosition.x + 'px',
right: typeof windowPosition.x === 'string' ? '0' : 'auto',
top: windowPosition.y + 'px'
} : {}">
<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="startDrag">
<span>AI智能填报助手</span>
<!-- 将两个图标按钮放在一个容器内 -->
<div class="ai-float-header-actions">
<el-icon v-if="currentSize==='minimize'" class="ai-float-minimize" @click.stop="toggleSize">
<Plus />
</el-icon>
<el-icon v-if="currentSize!=='minimize'" class="ai-float-minimize" @click.stop="toggleSize">
<Minus />
</el-icon>
<el-icon class="ai-float-close" @click="close">
<Close/>
</el-icon>
</div>
</div>
<div class="ai-float-form-list ai-float-scroll">
<div class="form-list-top">
<div class="ai-float-welcome">👋 欢迎使用AI智能填报助手</div>
<div class="ai-model-selector">
<el-select
v-model="selectedModelId"
placeholder="选择AI模型"
size="small"
2 months ago
popper-class="topIndex"
style="width: 150px"
2 months ago
:teleported="true"
append-to="body"
:disabled="aiThinking"
@change="onModelChange"
>
<el-option
v-for="model in aiModelList"
:key="model.modelId"
:label="model.modelName"
:value="model.modelId"
/>
</el-select>
</div>
</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,lidx)"
:type="selectedOption === opt ? 'primary' : 'default'">{{ opt.fieldValue }}
</el-button>
<el-button v-if="list.options.length > 4" size="small" type="primary" link
@click="() => openSelectDialog(list,lidx)">更多
</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.fieldValue }}
</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">{{ aiFormSettingList.find(f => f.formSettingId === selectedFormSettingId)?.remark ?? '如申请10个物料A用于生产线1计划交付日期为2024-07-01'}}</div>
</div>
<div class="ai-float-input-row">
<el-button
@click="toggleRecording"
:class="['recording-btn', { 'recording': isRecording }]"
v-loading="audioAsrLoading"
:disabled="aiThinking"
circle
type="primary"
>
<template #icon>
<el-icon v-if="isRecording"><VideoPause /></el-icon>
<el-icon v-else><Microphone /></el-icon>
</template>
</el-button>
<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="Top"
@click="() => handleSend()"
circle
type="success"
/>
</div>
</div>
</div>
</div>
<!-- 新增调整大小的句柄 -->
<div class="ai-float-resize-handle" @mousedown="startResize"></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"
import {useSharedDataStore} from '@/api/monitorData'
import {getAiModelJoinList} from "@/api/ai/skill/aiChat";
import {AIModelVO} from "@/api/ai/skill/aiChat/types";
const sharedStore = useSharedDataStore()
const {proxy} = getCurrentInstance() as ComponentInternalInstance
3 months ago
const aiFormSettingList = ref<AiFormSettingVO[]>([])
const aiFormSettingDetailListMap = ref<Record<number, AiFormSettingDetailVO[]>>({});
const selectedFormSettingId = ref<number | null>(null)
const selectedForm = ref();
const formsLoading = ref(false)
const isShowLeft = ref(false);
const aiLoading = ref(false);
const currentSize = ref("menu");
const currentSizeClass = computed(() =>
`size-${currentSize.value}`
);
// 用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) => {
if( selectedFormSettingId.value === aiFormSetting.formSettingId) return;
selectedFormSettingId.value = aiFormSetting.formSettingId
selectedForm.value = aiFormSetting;
aiLoading.value = true
try {
resetAiAssistant();
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])
}
const resetAiAssistant = () =>{
chatList.value = [];
}
// 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
currentSize.value = "menu";
router.push({path: "/" + formPageMap.value[formSettingId].props.formPath, query: {from: "ai"}})
// proxy.$store.commit('aiFormSettingId', formSettingId)
// proxy.$store.commit('aiFormSetting', formPageMap.value[formSettingId])
} else {
isShowLeft.value = true
currentSize.value = "form";
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
});
const getAiModelList = async () => {
// loading.value = true;
const res = await getAiModelJoinList({modelTypeId: 1});
aiModelList.value = res.data;
await setDefaultAiModel();
// console.log(res)
// platformList.value = res.data;
// loading.value = false;
}
const setDefaultAiModel = async () => {
aiModelList.value.forEach((item: AIModelVO) => {
if (item.defaultFlag === "1") {
// alert(JSON.stringify(item))
selectedModelId.value = item.modelId;
selectedPlatformId.value = item.platformId;
// provider.value = item.platformCode;
// console.log(item.platformIcon)
// platformIcon.value = item.platformIcon;
}
})
}
function onModelChange() {
const model = aiModelList.value.find(m => m.modelId === selectedModelId.value)
if (model) {
selectedPlatformId.value = model.platformId
// platformIcon.value = model.platformIcon;
}
}
watch(state, (newVal) => {
if (newVal.isShowSearch) {
loadForms()
getAiModelList();
}
});
const selectLists = ref([]);
// 存储fieldType为1的元素及其位置信息,显示使用
const type1Elements = ref([]);
// 存储fieldType为2的元素及其位置信息赋值使用
const type2Elements = ref([]);
// 存储选中后对应的fieldType为2的元素
const selectedType2Elements = ref([]);
// 处理数据找出所有fieldType为1的元素
const processData = (data) => {
type1Elements.value = [];
type2Elements.value = [];
let title = "";
data.forEach((subArray, subArrayIndex) => {
if (Array.isArray(subArray)) {
subArray.forEach(item => {
if (item.fieldType === '3' || item.fieldType === 3) {//请选择关联表的表名title信息
if (title == '') title = item.fieldValue;
}
if (item.fieldType === '1' || item.fieldType === 1) {//赋值
type1Elements.value.push({
...item,
subArrayIndex
});
}
if (item.fieldType === '2' || item.fieldType === 2) {//显示的名称
type2Elements.value.push({
...item,
subArrayIndex
});
}
});
}
});
selectLists.value.push({title: title, options: type2Elements.value, formOptions: type1Elements.value});
};
const sendForm = ref({});
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: selectedModelId.value,
platformId: selectedPlatformId.value
}
// chatList.value.push({
// text: '操作成功,请选择相关信息:',
// from: 'ai',
// type: 'select',
// selectLists: selectLists.value
// })
//
// sharedStore.updateDynamicValue({
// message: {deptId:100},
// timestamp: new Date().toLocaleString()
// })
// return;
const response = await aiFillForm(params)
sendForm.value = response;
inputValue.value = ''
aiThinking.value = false
traverseJSON();
if (selectedForm.value && selectedForm.value.dialogVisibleVariable && selectedForm.value.dialogVisibleVariable !== '') {
//打开菜单时赋值使用
sharedStore.updateDynamicValue({
message: sendForm.value,
timestamp: new Date().toLocaleString()
})
} else {
//直接打开表单赋值使用
currentProps.value = sendForm.value;
}
// 固定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']}
// ]
// })
if (selectLists.value.length > 0) {
chatList.value.push({
text: '操作成功,请选择相关信息:',
from: 'ai',
type: 'select',
selectLists: selectLists.value
})
} else {
chatList.value.push({
text: '操作成功',
from: 'ai',
})
}
scrollToBottom()
console.log(1)
console.log(response)
} catch (error) {
proxy?.$modal.msgError(error);
console.log("Error:" + error)
chatList.value.push({
text: '请重试:'+error,
from: 'ai',
})
} finally {
aiThinking.value = false
inputValue.value = ''
aiLoading.value = false
}
}
// 递归遍历JSON对象的函数
function traverseJSON() {
// 判断是否为数组
if (Array.isArray(sendForm.value)) {
console.log('这是一个数组,包含 ' + sendForm.value.length + ' 个元素:');
// 遍历数组中的每个元素
// obj.forEach((item, index) => {
// console.log(' '.repeat(indent) + '索引 ' + index + ':');
// });
} else {
// 遍历对象的所有key值
for (const key in sendForm.value) {
// 确保key是对象自身的属性非继承
if (sendForm.value.hasOwnProperty(key)) {
const value = sendForm.value[key];
// 判断value是否为数组
if (Array.isArray(value)) {
// selectLists: [
// {title: '物料信息', options: ['物料A', '物料B', '物料C', '物料D', '物料E', '物料F']},
// {title: 'BOM信息', options: ['BOM-001', 'BOM-002', 'BOM-003', 'BOM-004', 'BOM-005']}
// ]
sendForm.value[key] = '';
if (value.length == 1) {
const firstValue = value[0];
firstValue.forEach(item => {
if(item.fieldType ==='1' || item.fieldType===1){
sendForm.value[key] = item.fieldValue
}
});
// let selectList = {title: value[0].relateTableDesc, options: value}
// selectLists.value.push(selectList);
}else if (value.length > 1) {
processData(value)
}
// 遍历数组中的每个元素
// value.forEach((item, index) => {
// console.log(' '.repeat(indent + 1) + '数组元素 ' + index + ':');
// });
}
// 如果value是对象但不是数组递归遍历
// else if (value !== null && typeof value === 'object') {
// console.log(' '.repeat(indent) + '值是一个对象:');
// traverseJSON(value, indent + 1);
// }
// 基本类型值直接输出
// else {
// console.log(' '.repeat(indent) + '值:', value);
// }
}
}
}
}
// 录音相关对象
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() {
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 {
audioAsrLoading.value = false;
}
}
}
} 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('音频上传成功,正在识别')
const response1 = await recognizeSpeechByUrl(response.data.url);
if (response1.code === 200) {
console.log("--" + response1);
inputValue.value += response1.data.text
// recognitionResult.value = response1.data.text || '识别成功,但未返回文本结果'
// 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)
// AI模型相关数据
const aiModelList = ref([
{ modelId: 1, modelName: 'GPT-3.5' },
{ modelId: 2, modelName: 'GPT-4' },
{ modelId: 3, modelName: 'Claude-3' },
{ modelId: 4, modelName: 'Gemini-Pro' }
])
const selectedModelId = ref() // AI模型ID
const selectedPlatformId = ref() // AI平台ID
// 新增:更多弹窗过滤输入
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 selectDialogListIndex = ref();
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是否为对象如果是则访问其字符串属性
// 这里假设对象有label或value属性根据实际情况修改
const fieldValue = typeof opt === 'string' ? opt :
opt.fieldValue
return fieldValue.toLowerCase().includes(selectDialogFilterText.value.toLowerCase());
})
: []
)
// 默认显示在最右侧
onMounted(() => {
// 加载表单数据
// loadForms()
//
// const testData = [[{"fieldKey":"relateTableDesc","fieldValue":"部门1","fieldType":"3"},
// {"fieldKey":"deptName","fieldValue":"测试部门1","fieldType":"2"},
// {"fieldKey":"deptId","fieldValue":105,"fieldType":"1"}],
// [{"fieldKey":"relateTableDesc","fieldValue":null,"fieldType":"3"},
// {"fieldKey":"deptName","fieldValue":"测试部门2","fieldType":"2"},
// {"fieldKey":"deptId","fieldValue":"1952572898270183426","fieldType":"1"}]
// ]
//
//
// processData(testData);
//
// const testData1 = [
// [{"fieldKey":"relateTableDesc","fieldValue":"部门2","fieldType":"3"},
// {"fieldKey":"deptName","fieldValue":"测试部门11","fieldType":"2"},
// {"fieldKey":"deptId","fieldValue":1051,"fieldType":"1"}],
// [{"fieldKey":"relateTableDesc","fieldValue":null,"fieldType":"3"},
// {"fieldKey":"deptName","fieldValue":"测试部门22","fieldType":"2"},
// {"fieldKey":"deptId","fieldValue":"19525728982701834261","fieldType":"1"}]
// ]
// processData(testData1);
})
onBeforeUnmount(() => {
})
// 组件卸载时清理资源
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, index) {
if (index === undefined || index === null) {
index = selectDialogListIndex.value
}
let selectList = selectLists.value[index];
selectedOption.value = opt
let selectFormOptions = selectList.formOptions;
const subArray = selectFormOptions[opt.subArrayIndex];
const newValueJson = {[subArray.fieldKey]: subArray.fieldValue}
sharedStore.updateDynamicValue({
message: newValueJson,
timestamp: new Date().toLocaleString()
})
// currentProps.value= newValueJson;
}
function openSelectDialog(list: ChatSelectList,listIndex:number) {
selectDialogList.value = list
selectDialog.value = true
selectDialogListIndex.value = listIndex;
}
function formatMsg(text: string) {
// 转换换行符为<br>并做简单转义防XSS
if (text) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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 isDragging = ref(false);
const isResizing = ref(false);
const windowPosition = ref({ x: 'auto', y: 0 });
const isSmallSize = ref(false);
const windowSize = ref({ width: '24%', height: '100vh' });
const startPos = ref({ x: 0, y: 0 });
const startSize = ref({ width: 0, height: 0 });
const isCustomPosition = ref(false); // 新增:标记是否为自定义位置
// 新增:拖拽功能实现
function startDrag(e: MouseEvent) {
if (isResizing.value) return;
isDragging.value = true;
// 获取窗口当前实际位置
const windowElement = document.querySelector('.ai-float-window') as HTMLElement;
if (windowElement) {
const rect = windowElement.getBoundingClientRect();
startPos.value = {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
}
// 标记为自定义位置
isCustomPosition.value = true;
document.addEventListener('mousemove', onDrag);
document.addEventListener('mouseup', stopDrag);
}
function onDrag(e: MouseEvent) {
if (!isDragging.value) return;
const newX = e.clientX - startPos.value.x;
const newY = e.clientY - startPos.value.y;
// 确保窗口不会超出视口边界(留出一些边距)
const maxX = window.innerWidth - 200; // 最小宽度200px
const maxY = window.innerHeight - 100; // 最小高度100px
windowPosition.value = {
x: Math.max(0, Math.min(newX, maxX)),
y: Math.max(0, Math.min(newY, maxY))
};
// 当窗口被拖动时,不再固定在右侧
if (currentSize.value === 'menu' || currentSize.value === 'form' || currentSize.value === 'minimize') {
currentSize.value = 'custom';
}
}
function stopDrag() {
isDragging.value = false;
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', stopDrag);
}
// ... existing code ...
// 新增:调整大小功能实现
function startResize(e: MouseEvent) {
isResizing.value = true;
startPos.value = { x: e.clientX, y: e.clientY };
startSize.value = {
width: window.innerWidth * parseFloat(windowSize.value.width) / 100,
height: window.innerHeight * parseFloat(windowSize.value.height) / 100
};
document.addEventListener('mousemove', onResize);
document.addEventListener('mouseup', stopResize);
e.preventDefault(); // 防止拖动时选中文本
}
function onResize(e: MouseEvent) {
if (!isResizing.value) return;
const deltaX = e.clientX - startPos.value.x;
const deltaY = e.clientY - startPos.value.y;
// 计算新的宽度和高度,确保最小尺寸
let newWidth = startSize.value.width + deltaX;
let newHeight = startSize.value.height + deltaY;
newWidth = Math.max(200, newWidth); // 最小宽度200px
newHeight = Math.max(300, newHeight); // 最小高度300px
// 转换为视口百分比
windowSize.value = {
width: `${(newWidth / window.innerWidth) * 100}%`,
height: `${(newHeight / window.innerHeight) * 100}%`
};
// 当窗口大小被调整时,不再使用预设尺寸
if (currentSize.value !== 'custom') {
currentSize.value = 'custom';
}
}
function stopResize() {
isResizing.value = false;
document.removeEventListener('mousemove', onResize);
document.removeEventListener('mouseup', stopResize);
}
// 新增:切换预设尺寸时更新窗口大小
watch(currentSize, (newSize) => {
if (newSize === 'menu') {
windowSize.value = { width: '24%', height: '100vh' };
windowPosition.value = { x: 'auto', y: 0 };
} else if (newSize === 'form') {
windowSize.value = { width: '80%', height: '100vh' };
windowPosition.value = { x: 'auto', y: 0 };
} else if(newSize === 'minimize'){
windowSize.value = { width: '24%', height: '360px' };
windowPosition.value = { x: 'auto', y: 0 };
}
// const windowSize = computed(() => {
// if (isSmallSize.value) {
// return {
// width: '100px',
// height: '100px',
// top: 'auto',
// bottom: '20px',
// left: 'auto',
// right: '20px',
// borderRadius: '8px',
// overflow: 'hidden'
// }
// }else{
// return {
// width: '24%', height: '100vh'
// }
// }
// return {}
// })
});
// 新增:调整窗口大小
function toggleSize() {
if(currentSize.value === 'minimize'){
if (!selectedForm.value) {//没有选择form时原始大小
currentSize.value = 'menu';
}else{
if(selectedForm.value.dialogVisibleVariable && selectedForm.value.dialogVisibleVariable !== ''){
currentSize.value = 'menu'
} else{
currentSize.value = 'form'
}
}
}else{
currentSize.value = 'minimize'
}
}
</script>
<style scoped lang="less">
.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: 99vh;
max-height:99vh;
right: 0;
left: auto;
top: 0;
position: fixed;
z-index: 7005;
}
/* 确保Element Plus下拉列表显示在浮窗之上 */
::deep(.el-select-dropdown) {
z-index: 9000 !important;
}
::deep(.el-popper) {
z-index: 9000 !important;
}
::deep(.ai-select-popper) {
z-index: 9500 !important;
}
::deep(.el-overlay) {
z-index: 8000 !important;
}
::deep(.el-overlay .el-dialog) {
z-index: 8001 !important;
}
// 修复动态样式
&.size-custom {
right: v-bind('isCustomPosition ? "auto" : "0"');
left: v-bind('isCustomPosition ? windowPosition.x + "px" : "auto"');
top: v-bind('isCustomPosition ? windowPosition.y + "px" : "0"');
width: v-bind('windowSize.width');
height: v-bind('windowSize.height');
}
&.size-minimized {
right: auto !important;
left: v-bind('windowPosition.x + "px"') !important;
top: v-bind('windowPosition.y + "px"') !important;
width: v-bind('windowSize.width + "px"') !important;
height: v-bind('windowSize.height + "px"') !important;
border-radius: 20px;
padding: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
// 新增:最小化状态下隐藏内容
.size-minimized .ai-float-main,
.size-minimized .ai-float-form-list,
.size-minimized .ai-float-header > div:first-child {
display: none;
}
// 新增:最小化按钮样式
.size-minimized .ai-float-header {
padding: 0;
width: 100%;
height: 100%;
border-bottom: none;
cursor: default;
display: flex;
align-items: center;
justify-content: center;
}
/* 确保头部右侧按钮组正确显示 */
.ai-float-header-actions {
display: flex;
align-items: center;
}
// 优化最小化按钮样式
.ai-float-minimize {
font-size: 16px;
cursor: pointer;
color: #888;
transition: color 0.2s;
margin-right: 12px;
line-height: 1;
}
.size-form {
width: 80vw;
}
.size-menu {
width: 24%; //80vw的30%。跟下面al-float-right的flex定义匹配宽度
}
.size-minimize {
width: 24%;
height:360px;
}
.ai-float-minimize:hover {
color: #409eff;
}
// 最小化状态下隐藏关闭按钮
.size-minimized .ai-float-close {
display: none;
}
// 优化调整大小的句柄,使用通用图标样式
.ai-float-resize-handle {
position: absolute;
right: 4px;
bottom: 4px;
width: 16px;
height: 16px;
border-radius: 2px;
cursor: se-resize;
opacity: 0.6;
transition: opacity 0.2s;
z-index: 10;
// 使用CSS创建通用的调整大小图标
&::after {
content: '';
position: absolute;
right: -2px;
bottom: -2px;
width: 0;
height: 0;
border-style: solid;
border-width: 0 8px 8px 0;
border-color: transparent #fff transparent transparent;
}
}
.ai-float-resize-handle:hover {
opacity: 1;
}
.ai-float-main {
display: flex;
height: 100vh;
}
.ai-float-left {
flex: 7 7 0;
background: #f7f8fa;
border-right: 1px solid #e0e7ef;
overflow: auto;
height: 100%;
}
.ai-float-right {
flex: 3 3 0;
min-width: 260px;
width: 500px;
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-header-actions {
display: flex;
align-items: center;
}
.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-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.form-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.form-list-title {
font-size: 15px;
color: #333;
margin: 0;
margin-bottom:10px;
}
.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-model-selector {
flex-shrink: 0;
z-index:8000;
}
.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;
}
.recording-btn {
margin-bottom: 4px;
}
.recording-btn.recording {
color: #f56c6c;
}
2 months ago
</style>
2 months ago
<style>
2 months ago
.topIndex {
z-index: 99999999999999999999999 !important;
}
</style>