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

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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"
popper-class="topIndex"
style="width: 150px"
: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
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;
}
</style>
<style>
.topIndex {
z-index: 99999999999999999999999 !important;
}
</style>