fix: 修复一事一流程元数据添加失败,添加语音转文字接口

This commit is contained in:
wenjinbo 2025-09-22 12:06:33 +08:00
parent 85393210fa
commit 919ab51570
10 changed files with 403 additions and 24 deletions

View File

@ -1,6 +1,7 @@
import request from '@/utils/request' import request from '@/utils/request'
import requestStream from '@/utils/requestStream' import requestStream from '@/utils/requestStream'
import { fileUploadRequest } from '@/utils/request'
// 聊天消息相关接口定义 // 聊天消息相关接口定义
@ -84,4 +85,12 @@ export const stopMessagesStream = (chatType: Number,taskId: string, userId: stri
userId userId
} }
}) })
} }
export const voiceToText = (data: FormData) => {
return fileUploadRequest({
url: '/brichat-service/chat/voiceToText',
method: 'post',
data
})
}

View File

@ -24,6 +24,32 @@
:autosize="{ minRows: 1, maxRows: 4 }" :autosize="{ minRows: 1, maxRows: 4 }"
> >
</el-input> </el-input>
<!-- 语音录音按钮 -->
<button
class="voice-button"
@mousedown="startRecording"
@mouseup="stopRecording"
@mouseleave="stopRecording"
@touchstart="startRecording"
@touchend="stopRecording"
:disabled="isLoading"
:class="{ 'recording': isRecording }"
>
<template v-if="isRecording">
<div class="recording-animation">
<svg viewBox="0 0 24 24" class="voice-icon recording">
<path fill="currentColor" d="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/>
</svg>
</div>
</template>
<template v-else-if="isProcessingVoice">
<span class="loading-icon voice-loading"></span>
</template>
<svg v-else viewBox="0 0 24 24" class="voice-icon">
<path fill="currentColor" d="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/>
</svg>
</button>
<el-button <el-button
type="primary" type="primary"
class="send-button" class="send-button"
@ -45,8 +71,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { voiceToText } from '@/api/chat'
import { ElMessage } from 'element-plus'
const { t } = useI18n() const { t } = useI18n()
@ -69,6 +97,12 @@ const emit = defineEmits<Emits>()
const inputText = ref(props.modelValue) const inputText = ref(props.modelValue)
//
const isRecording = ref(false)
const isProcessingVoice = ref(false)
let mediaRecorder: MediaRecorder | null = null
let audioChunks: Blob[] = []
// //
watch(inputText, (newValue) => { watch(inputText, (newValue) => {
emit('update:modelValue', newValue) emit('update:modelValue', newValue)
@ -78,6 +112,119 @@ watch(inputText, (newValue) => {
watch(() => props.modelValue, (newValue) => { watch(() => props.modelValue, (newValue) => {
inputText.value = newValue inputText.value = newValue
}) })
//
const startRecording = async () => {
if (isRecording.value || isProcessingVoice.value) return
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
})
//
const mimeTypes = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/wav'
]
let selectedMimeType = 'audio/webm'
for (const mimeType of mimeTypes) {
if (MediaRecorder.isTypeSupported(mimeType)) {
selectedMimeType = mimeType
break
}
}
mediaRecorder = new MediaRecorder(stream, { mimeType: selectedMimeType })
audioChunks = []
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data)
}
}
mediaRecorder.onstop = async () => {
const audioBlob = new Blob(audioChunks, { type: selectedMimeType })
await processVoiceToText(audioBlob)
//
stream.getTracks().forEach(track => track.stop())
}
mediaRecorder.start()
isRecording.value = true
} catch (error) {
console.error('获取麦克风权限失败:', error)
ElMessage.error('无法访问麦克风,请检查权限设置')
}
}
//
const stopRecording = () => {
if (!isRecording.value || !mediaRecorder) return
isRecording.value = false
mediaRecorder.stop()
}
//
const processVoiceToText = async (audioBlob: Blob) => {
if (audioBlob.size === 0) {
ElMessage.warning('录音时间过短,请重新录制')
return
}
isProcessingVoice.value = true
try {
const formData = new FormData()
//
const fileExtension = audioBlob.type.includes('wav') ? 'wav' :
audioBlob.type.includes('mp4') ? 'mp3' : 'webm'
formData.append('file', audioBlob, `recording.${fileExtension}`)
const response = await voiceToText(formData)
if (response && response.data) {
const transcribedText = response.data
if (transcribedText.trim()) {
//
const currentText = inputText.value
const newText = currentText ? `${currentText} ${transcribedText}` : transcribedText
inputText.value = newText
emit('update:modelValue', newText)
ElMessage.success('语音转换成功')
} else {
ElMessage.warning('未识别到有效语音内容')
}
} else {
ElMessage.error('语音转换失败')
}
} catch (error) {
console.error('语音转文字失败:', error)
ElMessage.error('语音转换失败,请重试')
} finally {
isProcessingVoice.value = false
}
}
//
onUnmounted(() => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop()
}
})
</script> </script>
<style scoped> <style scoped>
@ -188,7 +335,7 @@ watch(() => props.modelValue, (newValue) => {
} }
:deep(.el-textarea__inner) { :deep(.el-textarea__inner) {
padding: 1rem 4rem 1rem 1.5rem; padding: 1rem 6rem 1rem 1.5rem;
border-radius: 16px; border-radius: 16px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
background: transparent; background: transparent;
@ -274,6 +421,127 @@ watch(() => props.modelValue, (newValue) => {
display: none; display: none;
} }
/* 语音按钮样式 */
.voice-button {
position: absolute;
right: 4rem;
top: 50%;
transform: translateY(-50%);
height: 2.5rem;
width: 2.5rem;
min-height: 2.5rem;
padding: 0;
border-radius: 50%;
background: #6b7280;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
box-shadow: 0 2px 8px rgba(107, 114, 128, 0.25);
overflow: hidden;
}
.voice-button:not(:disabled) {
background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);
}
.voice-button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.voice-button:hover:not(:disabled) {
transform: translateY(-50%) scale(1.05);
box-shadow: 0 4px 12px rgba(107, 114, 128, 0.3);
background: linear-gradient(135deg, #9ca3af 0%, #6b7280 100%);
}
/* 录音状态样式 */
.voice-button.recording {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
animation: recordingPulse 1.5s ease-in-out infinite;
}
.voice-button.recording:hover {
background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
}
/* 录音脉冲动画 */
@keyframes recordingPulse {
0%, 100% {
transform: translateY(-50%) scale(1);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
}
50% {
transform: translateY(-50%) scale(1.1);
box-shadow: 0 6px 20px rgba(239, 68, 68, 0.6);
}
}
.voice-icon {
width: 1.25rem;
height: 1.25rem;
transition: all 0.3s ease;
color: #ffffff;
}
.voice-icon.recording {
animation: microphoneWave 0.8s ease-in-out infinite alternate;
}
/* 麦克风波动动画 */
@keyframes microphoneWave {
from {
transform: scale(1);
}
to {
transform: scale(1.1);
}
}
/* 录音动画容器 */
.recording-animation {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.recording-animation::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
animation: ripple 1.5s ease-out infinite;
}
/* 波纹动画 */
@keyframes ripple {
0% {
transform: scale(0.8);
opacity: 1;
}
100% {
transform: scale(2);
opacity: 0;
}
}
/* 语音处理加载图标 */
.voice-loading {
width: 1.25rem;
height: 1.25rem;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #ffffff;
animation: spin 0.8s linear infinite;
}
.input-footer { .input-footer {
display: none; display: none;
} }
@ -301,6 +569,24 @@ watch(() => props.modelValue, (newValue) => {
border-width: 1.5px; border-width: 1.5px;
} }
/* 移动端语音按钮适配 */
.voice-button {
right: 3rem;
height: 2.25rem;
width: 2.25rem;
min-height: 2.25rem;
}
.voice-icon,
.voice-loading {
width: 1rem;
height: 1rem;
}
.voice-loading {
border-width: 1.5px;
}
/* 移动端中止按钮适配 */ /* 移动端中止按钮适配 */
.stop-button-container { .stop-button-container {
top: -2rem; top: -2rem;

View File

@ -7,10 +7,17 @@ import io.github.guoshiqiufeng.dify.chat.dto.request.ChatMessageSendRequest;
import io.github.guoshiqiufeng.dify.chat.dto.response.ChatMessageSendCompletionResponse; import io.github.guoshiqiufeng.dify.chat.dto.response.ChatMessageSendCompletionResponse;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.util.Map;
@Api(tags = "对话聊天接口") @Api(tags = "对话聊天接口")
@ -22,6 +29,17 @@ public class ChatController {
@Resource @Resource
private ChatService chatService; private ChatService chatService;
@Resource
private RestTemplate restTemplate;
@Value("${voice2text.url}")
private String voice2textUrl;
@Value("${voice2text.model}")
private String voice2textModel;
/*** /***
* 故障诊断对话接口 * 故障诊断对话接口
* @param * @param
@ -50,6 +68,65 @@ public class ChatController {
/***
* 语音转文字
* @param file 音频文件
* @return 转换后的文字
*/
@PostMapping("/voiceToText")
public String voiceToText(@RequestParam("file") MultipartFile file){
try {
log.info("开始语音转文字,文件名: {}, 文件大小: {} bytes",
file.getOriginalFilename(), file.getSize());
// 1. 参数验证
if (file == null || file.isEmpty()) {
log.error("语音文件为空");
throw new IllegalArgumentException("语音文件不能为空");
}
// 2. 构建请求头
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
// 3. 构建请求体
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("model", voice2textModel);
// 添加文件数据
ByteArrayResource fileResource = new ByteArrayResource(file.getBytes()) {
@Override
public String getFilename() {
return file.getOriginalFilename();
}
};
body.add("file", fileResource);
// 4. 发送请求
HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(body, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(voice2textUrl, requestEntity, Map.class);
// 5. 处理响应
if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
Map<String, Object> responseBody = response.getBody();
String text = (String) responseBody.get("text");
log.info("语音转文字成功,结果: {}", text);
return text;
} else {
log.error("语音转文字失败,响应状态: {}, 响应体: {}",
response.getStatusCode(), response.getBody());
throw new RuntimeException("语音转文字服务调用失败");
}
} catch (Exception e) {
log.error("语音转文字异常: {}", e.getMessage(), e);
throw new RuntimeException("语音转文字失败: " + e.getMessage());
}
}

View File

@ -94,17 +94,17 @@ public class KnowledgeBaseController {
return ResultUtils.error("索引删除失败: " + e.getMessage()); return ResultUtils.error("索引删除失败: " + e.getMessage());
} }
} }
@ApiOperation("创建单个文件的索引") // @ApiOperation("创建单个文件的索引")
@PostMapping("/createIndex") // @PostMapping("/createIndex")
public ResultUtils createIndex(@RequestParam("documentId") String documentId) throws Exception { // public ResultUtils createIndex(@RequestParam("documentId") String documentId) throws Exception {
//
try{ // try{
esTDatasetFilesImporter.importDocumentId(documentId); // esTDatasetFilesImporter.importDocumentId(documentId);
return ResultUtils.success("索引创建成功"); // return ResultUtils.success("索引创建成功");
} catch (IOException e) { // } catch (IOException e) {
return ResultUtils.error("索引创建失败: " + e.getMessage()); // return ResultUtils.error("索引创建失败: " + e.getMessage());
} // }
} // }
// @ApiOperation("创建所有文件的索引") // @ApiOperation("创建所有文件的索引")
// @PostMapping("/createAllIndex") // @PostMapping("/createAllIndex")
// public ResultUtils createAllIndex() throws Exception { // public ResultUtils createAllIndex() throws Exception {

View File

@ -59,5 +59,5 @@ public interface MetadataService {
* @param file 文件 * @param file 文件
* @return 处理后的元数据列表 * @return 处理后的元数据列表
*/ */
List<DifyMetadata> handleYsylcMetadata(String datasetId, String documentId, MultipartFile file); List<DifyMetadata> handleYsylcMetadata(String datasetId, String documentId, MultipartFile file,String metadataId);
} }

View File

@ -229,15 +229,12 @@ public class DifyDatasetApiServiceImpl implements DifyDatasetApiService {
stepStartTime = System.currentTimeMillis(); stepStartTime = System.currentTimeMillis();
Map<String, String> document = (Map<String, String>) res.getBody().get("document"); Map<String, String> document = (Map<String, String>) res.getBody().get("document");
long wordCount = Long.parseLong(document.get("word_count")); //long wordCount = Long.parseLong(document.get("word_count"));
//批次号 //批次号
String batch= (String) res.getBody().get("batch"); String batch= (String) res.getBody().get("batch");
String documentId = document.get("id"); String documentId = document.get("id");
logger.info("获取文档ID完成: {}, 耗时: {} ms", documentId, System.currentTimeMillis() - stepStartTime); logger.info("获取文档ID完成: {}, 耗时: {} ms", documentId, System.currentTimeMillis() - stepStartTime);
// 8. 记录文件信息到助手服务数据库 // 8. 记录文件信息到助手服务数据库
stepStartTime = System.currentTimeMillis(); stepStartTime = System.currentTimeMillis();
TDatasetFiles datasetFiles = new TDatasetFiles(); TDatasetFiles datasetFiles = new TDatasetFiles();
@ -432,7 +429,7 @@ public class DifyDatasetApiServiceImpl implements DifyDatasetApiService {
List<DifyMetadata> docMetadatas = res.getBody().getDocMetadatas(); List<DifyMetadata> docMetadatas = res.getBody().getDocMetadatas();
for (DifyMetadata metadata : docMetadatas) { for (DifyMetadata metadata : docMetadatas) {
if (DifyConstants.METADATA_YSYLC_JSON_URL.equals(metadata.getName())) { if (DifyConstants.METADATA_YSYLC_JSON_URL.equals(metadata.getName())) {
currentMetadatas = metadataService.handleYsylcMetadata(datasetId, documentId, file); currentMetadatas = metadataService.handleYsylcMetadata(datasetId, documentId, file,metadata.getId());
break; break;
} }
} }

View File

@ -152,7 +152,7 @@ public class MetadataServiceImpl implements MetadataService {
} }
@Override @Override
public List<DifyMetadata> handleYsylcMetadata(String datasetId, String documentId, MultipartFile file) { public List<DifyMetadata> handleYsylcMetadata(String datasetId, String documentId, MultipartFile file,String metadataId) {
// 异步执行 // 异步执行
CompletableFuture.runAsync(() -> { CompletableFuture.runAsync(() -> {
String apiKey = tApiKeyService.getApiKeyFromCache(DifyConstants.API_KEY_YSYLC_DATA_PROC); String apiKey = tApiKeyService.getApiKeyFromCache(DifyConstants.API_KEY_YSYLC_DATA_PROC);
@ -160,6 +160,7 @@ public class MetadataServiceImpl implements MetadataService {
}); });
DifyMetadata newMetadata = new DifyMetadata(); DifyMetadata newMetadata = new DifyMetadata();
newMetadata.setId(metadataId);
newMetadata.setType(DifyConstants.METADATA_TYPE_STRING); newMetadata.setType(DifyConstants.METADATA_TYPE_STRING);
newMetadata.setName(DifyConstants.METADATA_YSYLC_JSON_URL); newMetadata.setName(DifyConstants.METADATA_YSYLC_JSON_URL);
newMetadata.setValue(ysylcJsonPath + file.getOriginalFilename() + ".json"); newMetadata.setValue(ysylcJsonPath + file.getOriginalFilename() + ".json");

View File

@ -3,6 +3,7 @@ package com.bjtds.brichat.util;
import com.bjtds.brichat.entity.dataset.TDatasetFiles; import com.bjtds.brichat.entity.dataset.TDatasetFiles;
import com.bjtds.brichat.service.DatasetFilesService; import com.bjtds.brichat.service.DatasetFilesService;
import com.bjtds.brichat.service.EsTDatasetFilesService; import com.bjtds.brichat.service.EsTDatasetFilesService;
import io.swagger.models.auth.In;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -28,9 +29,9 @@ public class EsTDatasetFilesImporter {
@Autowired @Autowired
private StringRedisTemplate redisTemplate; private StringRedisTemplate redisTemplate;
public void importDocumentId(String documentId) throws IOException { public void importDocumentId(Integer fileId) throws IOException {
// 一次性获取完整的文档信息 // 一次性获取完整的文档信息
TDatasetFiles datasetFiles = datasetFilesService.getFileById(Integer.valueOf(documentId)); TDatasetFiles datasetFiles = datasetFilesService.getFileById(fileId);
if (datasetFiles == null) { if (datasetFiles == null) {
throw new IllegalArgumentException("datasetFiles 不存在"); throw new IllegalArgumentException("datasetFiles 不存在");
} }

View File

@ -85,3 +85,8 @@ elasticsearch:
#是否删除索引,重新构建索引 #是否删除索引,重新构建索引
deleteIndex: ${es-deleteIndex:false} deleteIndex: ${es-deleteIndex:false}
voice2text:
url: ${voice2text-url:http://192.168.1.211:11023/v1/audio/transcriptions}
model: ${voice2text-model:whisper-1}

View File

@ -81,3 +81,6 @@ elasticsearch:
#是否删除索引,重新构建索引 #是否删除索引,重新构建索引
deleteIndex: ${es-deleteIndex:false} deleteIndex: ${es-deleteIndex:false}
voice2text:
url: ${voice2text-url:http://192.168.8.253:11023/v1/audio/transcriptions}
model: ${voice2text-model:whisper-1}