refactor: 更新 ASR 本地模型连接测试和分片上传逻辑
- 修改 `AiModelServiceImpl` 中的 `fetchLocalProfile` 方法,更新目标 URL 和解析逻辑 - 添加新的 `extractStringArray` 方法以提取字符串数组 - 更新 `AndroidChunkUploadService` 接口,移除 `totalChunks` 参数 - 修改 `LegacyMeetingAdapterServiceImpl` 和 `AndroidChunkUploadServiceImpl` 中的分片上传逻辑,支持合并音频文件 - 更新 `AndroidMeetingController` 中的 `finishOfflineMeeting` 方法,处理分片上传完成后的逻辑 - 优化 `MeetingServiceImpl` 中的 `getById` 方法,忽略租户信息 - 更新前端 `AiModels.tsx` 中的表单验证规则和隐藏部分字段dev_na
parent
2e20799b4b
commit
1b41693597
|
|
@ -44,15 +44,13 @@ public class AndroidMeetingChunkUploadController {
|
|||
public ApiResponse<Boolean> uploadChunk(HttpServletRequest request,
|
||||
@RequestParam("meeting_id") Long meetingId,
|
||||
@RequestParam("chunk_index") Integer chunkIndex,
|
||||
@RequestParam("total_chunks") Integer totalChunks,
|
||||
@RequestParam("chunk_file") MultipartFile chunkFile) throws IOException {
|
||||
AndroidRequestLogHelper.logRequest(log, "Android会议", "上传会议音频分片",
|
||||
"meetingId", meetingId,
|
||||
"chunkIndex", chunkIndex,
|
||||
"totalChunks", totalChunks,
|
||||
"chunkFile", chunkFile);
|
||||
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
||||
androidChunkUploadService.saveChunk(meetingId, chunkIndex, totalChunks, chunkFile, authContext);
|
||||
androidChunkUploadService.saveChunk(meetingId, chunkIndex, chunkFile, authContext);
|
||||
return ApiResponse.ok(true);
|
||||
}
|
||||
|
||||
|
|
@ -67,10 +65,12 @@ public class AndroidMeetingChunkUploadController {
|
|||
@PostMapping("/complete")
|
||||
@Anonymous
|
||||
public ApiResponse<LegacyUploadAudioResponse> completeUpload(HttpServletRequest request,
|
||||
@RequestParam("meeting_id") Long meetingId) throws IOException {
|
||||
@RequestParam("meeting_id") Long meetingId,
|
||||
@RequestParam("total_chunks") Integer totalChunks) throws IOException {
|
||||
AndroidRequestLogHelper.logRequest(log, "Android会议", "完成分片上传",
|
||||
"meetingId", meetingId);
|
||||
"meetingId", meetingId,
|
||||
"totalChunks", totalChunks);
|
||||
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
||||
return ApiResponse.ok(androidChunkUploadService.completeUpload(meetingId, authContext));
|
||||
return ApiResponse.ok(androidChunkUploadService.completeUpload(meetingId, totalChunks, authContext));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -206,7 +206,7 @@ public class AndroidMeetingController {
|
|||
})
|
||||
@PostMapping("/{meetingId}/finish")
|
||||
@Anonymous
|
||||
public ApiResponse<Boolean> finishOfflineMeeting(HttpServletRequest request,
|
||||
public ApiResponse<Object> finishOfflineMeeting(HttpServletRequest request,
|
||||
@PathVariable Long meetingId,
|
||||
@RequestBody(required = false) AndroidOfflineMeetingFinishRequest command) throws IOException {
|
||||
AndroidRequestLogHelper.logRequest(log, "Android会议", "结束离线会议录音阶段",
|
||||
|
|
@ -214,12 +214,20 @@ public class AndroidMeetingController {
|
|||
"request", command);
|
||||
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
||||
LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext);
|
||||
Meeting meeting = requireOperableOfflineMeeting(meetingId, authContext, loginUser);
|
||||
MeetingVO meeting = requireOperableOfflineMeeting(meetingId, authContext, loginUser);
|
||||
LegacyUploadAudioResponse uploadResult = null;
|
||||
if (isUploadFinishedStage(command)) {
|
||||
androidChunkUploadService.completeUpload(meeting.getId(), authContext);
|
||||
uploadResult = androidChunkUploadService.completeUpload(
|
||||
meeting.getId(),
|
||||
command == null ? null : command.getTotalChunks(),
|
||||
authContext
|
||||
);
|
||||
if (uploadResult == null || uploadResult.getAudioUrl() == null || uploadResult.getAudioUrl().isBlank()) {
|
||||
throw new RuntimeException("分片上传完成后未生成 audio_url");
|
||||
}
|
||||
}
|
||||
meetingCommandService.finishOfflineMeeting(meeting.getId(), command == null ? null : command.getFinishStage());
|
||||
return ApiResponse.ok(true);
|
||||
return ApiResponse.ok(uploadResult != null ? uploadResult : true);
|
||||
}
|
||||
|
||||
@Operation(summary = "分页查询Android会议")
|
||||
|
|
@ -363,8 +371,8 @@ public class AndroidMeetingController {
|
|||
return ApiResponse.ok(resultVo);
|
||||
}
|
||||
|
||||
private Meeting requireOperableOfflineMeeting(Long meetingId, AndroidAuthContext authContext, LoginUser loginUser) {
|
||||
Meeting meeting = meetingService.getById(meetingId);
|
||||
private MeetingVO requireOperableOfflineMeeting(Long meetingId, AndroidAuthContext authContext, LoginUser loginUser) {
|
||||
MeetingVO meeting = meetingQueryService.getDetailIgnoreTenant(meetingId);
|
||||
if (meeting == null) {
|
||||
throw new RuntimeException("会议不存在");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ import java.io.IOException;
|
|||
public interface AndroidChunkUploadService {
|
||||
void saveChunk(Long meetingId,
|
||||
Integer chunkIndex,
|
||||
Integer totalChunks,
|
||||
MultipartFile chunkFile,
|
||||
AndroidAuthContext authContext) throws IOException;
|
||||
|
||||
LegacyUploadAudioResponse completeUpload(Long meetingId,
|
||||
Integer totalChunks,
|
||||
AndroidAuthContext authContext) throws IOException;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,12 +15,16 @@ import org.springframework.web.multipart.MultipartFile;
|
|||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
|
|
@ -31,27 +35,29 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
|
|||
@Value("${unisbase.app.upload-path}")
|
||||
private String uploadPath;
|
||||
|
||||
@Value("${imeeting.audio.ffmpeg-path:ffmpeg}")
|
||||
private String ffmpegPath;
|
||||
|
||||
@Override
|
||||
public void saveChunk(Long meetingId,
|
||||
Integer chunkIndex,
|
||||
Integer totalChunks,
|
||||
MultipartFile chunkFile,
|
||||
AndroidAuthContext authContext) throws IOException {
|
||||
if (meetingId == null) {
|
||||
throw new RuntimeException("meeting_id不能为空");
|
||||
}
|
||||
if (chunkIndex == null || totalChunks == null || chunkIndex < 0 || totalChunks <= 0) {
|
||||
if (chunkIndex == null || chunkIndex < 0) {
|
||||
throw new RuntimeException("分片参数无效");
|
||||
}
|
||||
if (chunkFile == null || chunkFile.isEmpty()) {
|
||||
throw new RuntimeException("chunk_file不能为空");
|
||||
}
|
||||
AndroidChunkUploadSessionState state = getOrCreateState(meetingId, totalChunks, chunkFile, authContext);
|
||||
AndroidChunkUploadSessionState state = getOrCreateState(meetingId, chunkFile, authContext);
|
||||
if (!Objects.equals(state.getMeetingId(), meetingId) || !Objects.equals(state.getDeviceId(), authContext.getDeviceId())) {
|
||||
throw new RuntimeException("分片上传会话与当前设备或会议不匹配");
|
||||
}
|
||||
|
||||
String chunkFileName = normalizeChunkFileName(chunkFile.getOriginalFilename(), chunkIndex);
|
||||
String chunkFileName = buildStoredChunkFileName(chunkIndex, chunkFile.getOriginalFilename());
|
||||
String previousFileName = state.getChunkFileNames().get(chunkIndex);
|
||||
Path meetingDir = sessionDir(meetingId);
|
||||
Files.createDirectories(meetingDir);
|
||||
|
|
@ -60,12 +66,6 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
|
|||
deleteQuietly(meetingDir.resolve(previousFileName));
|
||||
state.getUploadedChunkFileNames().remove(previousFileName);
|
||||
}
|
||||
if (state.getUploadedChunkFileNames().contains(chunkFileName)) {
|
||||
state.getChunkFileNames().put(chunkIndex, chunkFileName);
|
||||
state.getReceivedChunks().add(chunkIndex);
|
||||
saveState(meetingId, state);
|
||||
return;
|
||||
}
|
||||
|
||||
Files.write(meetingDir.resolve(chunkFileName), chunkFile.getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
state.getUploadedChunkFileNames().add(chunkFileName);
|
||||
|
|
@ -76,12 +76,23 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
|
|||
|
||||
@Override
|
||||
public LegacyUploadAudioResponse completeUpload(Long meetingId,
|
||||
Integer totalChunks,
|
||||
AndroidAuthContext authContext) throws IOException {
|
||||
if (meetingId == null) {
|
||||
throw new RuntimeException("meeting_id不能为空");
|
||||
}
|
||||
if (totalChunks == null || totalChunks <= 0) {
|
||||
throw new RuntimeException("total_chunks不能为空且必须大于0");
|
||||
}
|
||||
|
||||
AndroidChunkUploadSessionState state = requireState(meetingId);
|
||||
if (!Objects.equals(state.getMeetingId(), meetingId) || !Objects.equals(state.getDeviceId(), authContext.getDeviceId())) {
|
||||
throw new RuntimeException("分片上传会话与当前设备或会议不匹配");
|
||||
}
|
||||
for (int i = 0; i < state.getTotalChunks(); i++) {
|
||||
|
||||
state.setTotalChunks(totalChunks);
|
||||
saveState(meetingId, state);
|
||||
for (int i = 0; i < totalChunks; i++) {
|
||||
if (!state.getReceivedChunks().contains(i) || !state.getChunkFileNames().containsKey(i)) {
|
||||
throw new RuntimeException("分片未上传完整");
|
||||
}
|
||||
|
|
@ -90,7 +101,7 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
|
|||
Path mergedFile = mergeChunks(state);
|
||||
try {
|
||||
MultipartFile mergedMultipart = new LocalMultipartFile(
|
||||
state.getFileName() == null ? "meeting-audio.bin" : state.getFileName(),
|
||||
buildMergedOriginalFilename(state, mergedFile),
|
||||
state.getContentType(),
|
||||
Files.readAllBytes(mergedFile)
|
||||
);
|
||||
|
|
@ -120,23 +131,15 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
|
|||
}
|
||||
|
||||
private AndroidChunkUploadSessionState getOrCreateState(Long meetingId,
|
||||
Integer totalChunks,
|
||||
MultipartFile chunkFile,
|
||||
AndroidAuthContext authContext) throws IOException {
|
||||
AndroidAuthContext authContext) {
|
||||
AndroidChunkUploadSessionState existing = getState(meetingId);
|
||||
if (existing != null) {
|
||||
String currentFileName = chunkFile == null ? null : chunkFile.getOriginalFilename();
|
||||
boolean sameTotalChunks = Objects.equals(existing.getTotalChunks(), totalChunks);
|
||||
boolean sameFileName = Objects.equals(existing.getFileName(), currentFileName);
|
||||
if (sameTotalChunks && sameFileName) {
|
||||
return existing;
|
||||
}
|
||||
cleanup(meetingId);
|
||||
}
|
||||
AndroidChunkUploadSessionState state = new AndroidChunkUploadSessionState();
|
||||
state.setMeetingId(meetingId);
|
||||
state.setDeviceId(authContext.getDeviceId());
|
||||
state.setTotalChunks(totalChunks);
|
||||
state.setFileName(chunkFile.getOriginalFilename());
|
||||
state.setContentType(chunkFile.getContentType());
|
||||
saveState(meetingId, state);
|
||||
|
|
@ -145,13 +148,30 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
|
|||
|
||||
private Path mergeChunks(AndroidChunkUploadSessionState state) throws IOException {
|
||||
Path meetingDir = sessionDir(state.getMeetingId());
|
||||
Path merged = meetingDir.resolve("merged.bin");
|
||||
Files.deleteIfExists(merged);
|
||||
Files.createFile(merged);
|
||||
Files.createDirectories(meetingDir);
|
||||
|
||||
List<Path> chunkPaths = new ArrayList<>();
|
||||
for (Map.Entry<Integer, String> entry : state.getChunkFileNames().entrySet()) {
|
||||
Files.write(merged, Files.readAllBytes(meetingDir.resolve(entry.getValue())), StandardOpenOption.APPEND);
|
||||
Path chunkPath = meetingDir.resolve(entry.getValue());
|
||||
if (!Files.exists(chunkPath)) {
|
||||
throw new RuntimeException("分片文件不存在: " + entry.getValue());
|
||||
}
|
||||
return merged;
|
||||
chunkPaths.add(chunkPath);
|
||||
}
|
||||
if (chunkPaths.isEmpty()) {
|
||||
throw new RuntimeException("没有可合并的分片文件");
|
||||
}
|
||||
if (chunkPaths.size() == 1) {
|
||||
return chunkPaths.get(0);
|
||||
}
|
||||
|
||||
String mergedExtension = resolveMergedExtension(chunkPaths.get(0));
|
||||
Path mergedOutput = meetingDir.resolve("merged" + mergedExtension);
|
||||
Path concatList = meetingDir.resolve("concat-inputs.txt");
|
||||
Files.deleteIfExists(mergedOutput);
|
||||
writeConcatListFile(concatList, chunkPaths);
|
||||
executeFfmpegConcat(concatList, mergedOutput);
|
||||
return mergedOutput;
|
||||
}
|
||||
|
||||
private AndroidChunkUploadSessionState requireState(Long meetingId) {
|
||||
|
|
@ -192,11 +212,77 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
|
|||
return Paths.get(normalizedBasePath, "android-chunks", String.valueOf(meetingId));
|
||||
}
|
||||
|
||||
private String normalizeChunkFileName(String originalFileName, Integer chunkIndex) {
|
||||
String fallback = "chunk-" + (chunkIndex == null ? "unknown" : chunkIndex);
|
||||
String fileName = originalFileName == null || originalFileName.isBlank() ? fallback : originalFileName.trim();
|
||||
fileName = Paths.get(fileName).getFileName().toString();
|
||||
return fileName.isBlank() ? fallback : fileName;
|
||||
private String buildStoredChunkFileName(Integer chunkIndex, String originalFileName) {
|
||||
String normalizedSourceName = originalFileName == null ? "" : Paths.get(originalFileName.trim()).getFileName().toString();
|
||||
int extensionIndex = normalizedSourceName.lastIndexOf('.');
|
||||
String extension = extensionIndex >= 0 ? normalizedSourceName.substring(extensionIndex) : "";
|
||||
String safeExtension = extension.isBlank() ? ".bin" : extension;
|
||||
return "chunk-" + (chunkIndex == null ? "unknown" : chunkIndex) + safeExtension;
|
||||
}
|
||||
|
||||
private String buildMergedOriginalFilename(AndroidChunkUploadSessionState state, Path mergedFile) {
|
||||
if (state.getFileName() != null && !state.getFileName().isBlank()) {
|
||||
String normalizedSourceName = Paths.get(state.getFileName().trim()).getFileName().toString();
|
||||
int extensionIndex = normalizedSourceName.lastIndexOf('.');
|
||||
if (extensionIndex >= 0) {
|
||||
return "merged" + normalizedSourceName.substring(extensionIndex);
|
||||
}
|
||||
return normalizedSourceName;
|
||||
}
|
||||
return mergedFile == null || mergedFile.getFileName() == null ? "meeting-audio.bin" : mergedFile.getFileName().toString();
|
||||
}
|
||||
|
||||
private String resolveMergedExtension(Path chunkPath) {
|
||||
if (chunkPath == null || chunkPath.getFileName() == null) {
|
||||
return ".bin";
|
||||
}
|
||||
String fileName = chunkPath.getFileName().toString();
|
||||
int extensionIndex = fileName.lastIndexOf('.');
|
||||
return extensionIndex >= 0 ? fileName.substring(extensionIndex) : ".bin";
|
||||
}
|
||||
|
||||
private void writeConcatListFile(Path concatList, List<Path> chunkPaths) throws IOException {
|
||||
List<String> lines = new ArrayList<>(chunkPaths.size());
|
||||
for (Path chunkPath : chunkPaths) {
|
||||
String normalizedPath = chunkPath.toAbsolutePath().toString().replace("'", "'\\''");
|
||||
lines.add("file '" + normalizedPath + "'");
|
||||
}
|
||||
Files.write(concatList, lines, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
}
|
||||
|
||||
private void executeFfmpegConcat(Path concatList, Path mergedOutput) throws IOException {
|
||||
List<String> command = List.of(
|
||||
ffmpegPath,
|
||||
"-v", "error",
|
||||
"-y",
|
||||
"-f", "concat",
|
||||
"-safe", "0",
|
||||
"-i", concatList.toString(),
|
||||
"-c", "copy",
|
||||
mergedOutput.toString()
|
||||
);
|
||||
ProcessBuilder processBuilder = new ProcessBuilder(command);
|
||||
processBuilder.redirectErrorStream(true);
|
||||
Process process = processBuilder.start();
|
||||
byte[] output;
|
||||
try (InputStream processStream = process.getInputStream()) {
|
||||
output = processStream.readAllBytes();
|
||||
}
|
||||
try {
|
||||
if (!process.waitFor(120, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly();
|
||||
throw new IOException("音频分片合并超时");
|
||||
}
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("音频分片合并被中断", ex);
|
||||
}
|
||||
if (process.exitValue() != 0) {
|
||||
throw new IOException("音频分片合并失败: " + new String(output, StandardCharsets.UTF_8));
|
||||
}
|
||||
if (!Files.exists(mergedOutput) || Files.size(mergedOutput) <= 0) {
|
||||
throw new IOException("音频分片合并结果为空");
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteQuietly(Path path) {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import com.imeeting.service.biz.impl.MeetingAudioUploadSupport;
|
|||
import com.imeeting.service.biz.impl.MeetingDomainSupport;
|
||||
import com.imeeting.service.biz.impl.MeetingSummaryPromptAssembler;
|
||||
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
||||
import com.imeeting.support.TaskSecurityContextRunner;
|
||||
import com.unisbase.security.LoginUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
|
@ -57,6 +58,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
|||
private final MeetingTranscriptMapper transcriptMapper;
|
||||
private final LlmModelMapper llmModelMapper;
|
||||
private final MeetingAudioUploadSupport meetingAudioUploadSupport;
|
||||
private final TaskSecurityContextRunner taskSecurityContextRunner;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
|
|
@ -137,7 +139,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
|||
throw new RuntimeException("audio_file 不能为空");
|
||||
}
|
||||
|
||||
Meeting meeting = meetingAccessService.requireMeeting(meetingId);
|
||||
Meeting meeting = meetingAccessService.requireMeetingIgnoreTenant(meetingId);
|
||||
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
|
||||
assertDeviceOwnsMeeting(meeting, authContext);
|
||||
|
||||
|
|
@ -176,6 +178,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
|||
|
||||
String stagingUrl = storeStagingAudio(audioFile);
|
||||
String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl);
|
||||
taskSecurityContextRunner.runAsTenantUser(meeting.getTenantId(), meeting.getCreatorId(), () -> {
|
||||
meeting.setAudioUrl(relocatedUrl);
|
||||
meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS);
|
||||
meeting.setAudioSaveMessage(null);
|
||||
|
|
@ -187,7 +190,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
|||
resetOrCreateChapterTask(meetingId, profile);
|
||||
resetOrCreateSummaryTask(meetingId, profile);
|
||||
dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
||||
|
||||
});
|
||||
return new LegacyUploadAudioResponse(meetingId, relocatedUrl);
|
||||
}
|
||||
|
||||
|
|
@ -244,6 +247,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
|||
);
|
||||
String stagingUrl = storeStagingAudio(audioFile);
|
||||
String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl);
|
||||
taskSecurityContextRunner.runAsTenantUser(meeting.getTenantId(), meeting.getCreatorId(), () -> {
|
||||
meeting.setAudioUrl(relocatedUrl);
|
||||
meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS);
|
||||
meeting.setAudioSaveMessage(null);
|
||||
|
|
@ -255,6 +259,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
|||
resetOrCreateChapterTask(meetingId, profile);
|
||||
resetOrCreateSummaryTask(meetingId, profile);
|
||||
dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
||||
});
|
||||
return new LegacyUploadAudioResponse(meetingId, relocatedUrl);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,5 +4,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
|
|||
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
public interface MeetingService extends IService<Meeting> {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -374,7 +374,7 @@ public class AiModelServiceImpl implements AiModelService {
|
|||
}
|
||||
|
||||
private AiLocalProfileVO fetchLocalProfile(String baseUrl, String apiKey) {
|
||||
String targetUrl = appendPath(baseUrl, "api/v1/system/profile");
|
||||
String targetUrl = appendPath(baseUrl, "stream/v1/asr/health");
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(targetUrl))
|
||||
|
|
@ -389,21 +389,18 @@ public class AiModelServiceImpl implements AiModelService {
|
|||
}
|
||||
|
||||
JsonNode root = objectMapper.readTree(response.body());
|
||||
JsonNode dataNode = root.path("data");
|
||||
String healthStatus = readText(root.path("status"));
|
||||
if (healthStatus != null && !"healthy".equalsIgnoreCase(healthStatus)) {
|
||||
throw new RuntimeException("本地模型连通性测试失败: status=" + healthStatus);
|
||||
}
|
||||
|
||||
List<String> loadedModels = extractStringArray(root.path("loaded_models"));
|
||||
AiLocalProfileVO profile = new AiLocalProfileVO();
|
||||
profile.setAsrModels(extractModelNames(dataNode.path("models").path("asr")));
|
||||
JsonNode speakerNode = dataNode.path("models").path("speaker");
|
||||
if (speakerNode.isMissingNode() || speakerNode.isNull()) {
|
||||
speakerNode = dataNode.path("model").path("speaker");
|
||||
profile.setAsrModels(loadedModels);
|
||||
profile.setSpeakerModels(Collections.emptyList());
|
||||
if (root.path("model_loaded").asBoolean(false) && !loadedModels.isEmpty()) {
|
||||
profile.setActiveAsrModel(loadedModels.get(0));
|
||||
}
|
||||
profile.setSpeakerModels(extractModelNames(speakerNode));
|
||||
profile.setActiveAsrModel(readText(dataNode.path("runtime").path("asr_model")));
|
||||
profile.setActiveSpeakerModel(readText(dataNode.path("runtime").path("speaker_model")));
|
||||
if (dataNode.path("runtime").has("sv_threshold")) {
|
||||
profile.setSvThreshold(dataNode.path("runtime").path("sv_threshold").decimalValue());
|
||||
}
|
||||
profile.setWsEndpoint(readText(dataNode.path("ws_endpoint")));
|
||||
return profile;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("本地模型连通性测试失败: " + e.getMessage(), e);
|
||||
|
|
@ -907,6 +904,20 @@ public class AiModelServiceImpl implements AiModelService {
|
|||
return result;
|
||||
}
|
||||
|
||||
private List<String> extractStringArray(JsonNode node) {
|
||||
if (node == null || !node.isArray()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<String> result = new ArrayList<>();
|
||||
for (JsonNode item : node) {
|
||||
String value = readText(item);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String readText(JsonNode node) {
|
||||
if (node == null || node.isMissingNode() || node.isNull()) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -572,6 +572,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
boolean useSpk = useSpkObj != null && useSpkObj.toString().equals("1");
|
||||
config.put("enable_speaker", useSpk);
|
||||
config.put("match_speaker_registry", useSpk);
|
||||
config.put("speaker_threshold", asrModel.getMediaConfig().get("svThreshold"));
|
||||
Object enableTextRefineObj = taskRecord.getTaskConfig().get("enableTextRefine");
|
||||
boolean enableTextRefine = enableTextRefineObj != null && Boolean.parseBoolean(enableTextRefineObj.toString());
|
||||
config.put("enable_text_cleanup", enableTextRefine);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,24 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.imeeting.dto.biz.MeetingVO;
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
import com.imeeting.mapper.biz.MeetingMapper;
|
||||
import com.imeeting.service.biz.MeetingService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
@Service
|
||||
public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> implements MeetingService {
|
||||
@Autowired
|
||||
private MeetingMapper meetingMapper;
|
||||
|
||||
@Override
|
||||
public Meeting getById(Serializable id) {
|
||||
return meetingMapper.selectByIdIgnoreTenant((Long) id);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.imeeting.service.biz.impl;
|
|||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.imeeting.dto.biz.AiModelDTO;
|
||||
import com.imeeting.dto.biz.AiLocalProfileVO;
|
||||
import com.imeeting.entity.biz.AsrModel;
|
||||
import com.imeeting.entity.biz.LlmModel;
|
||||
import com.imeeting.mapper.biz.AsrModelMapper;
|
||||
|
|
@ -188,6 +189,54 @@ class AiModelServiceImplTest {
|
|||
assertEquals(HttpClient.Version.HTTP_1_1, httpClient.version());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLocalConnectivityShouldCallNewAsrHealthEndpointAndMapLoadedModels() throws Exception {
|
||||
AtomicReference<String> requestPath = new AtomicReference<>();
|
||||
AtomicReference<String> authorization = new AtomicReference<>();
|
||||
|
||||
server = HttpServer.create(new InetSocketAddress(0), 0);
|
||||
server.createContext("/stream/v1/asr/health", exchange -> {
|
||||
captureRequest(exchange, requestPath, authorization, new AtomicReference<>());
|
||||
writeJson(exchange, 200, """
|
||||
{
|
||||
"status": "healthy",
|
||||
"version": "1.0.1",
|
||||
"message": "ASR service is running normally",
|
||||
"model_loaded": true,
|
||||
"device": "cuda:0",
|
||||
"loaded_models": [
|
||||
"qwen3-asr-0.6b"
|
||||
],
|
||||
"memory_usage": {
|
||||
"allocated": "0.18GB",
|
||||
"cached": "0.32GB",
|
||||
"max_allocated": "0.38GB"
|
||||
}
|
||||
}
|
||||
""");
|
||||
});
|
||||
server.start();
|
||||
|
||||
AiModelServiceImpl service = new AiModelServiceImpl(
|
||||
objectMapper,
|
||||
mock(AsrModelMapper.class),
|
||||
mock(LlmModelMapper.class)
|
||||
);
|
||||
|
||||
AiLocalProfileVO profile = service.testLocalConnectivity(
|
||||
"http://127.0.0.1:" + server.getAddress().getPort(),
|
||||
"test-key"
|
||||
);
|
||||
|
||||
assertEquals("/stream/v1/asr/health", requestPath.get());
|
||||
assertEquals("Bearer test-key", authorization.get());
|
||||
assertEquals(1, profile.getAsrModels().size());
|
||||
assertEquals("qwen3-asr-0.6b", profile.getAsrModels().get(0));
|
||||
assertEquals("qwen3-asr-0.6b", profile.getActiveAsrModel());
|
||||
assertEquals(0, profile.getSpeakerModels().size());
|
||||
assertNull(profile.getWsEndpoint());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveModelShouldAllowCustomLlmWithoutApiKey() {
|
||||
AsrModelMapper asrModelMapper = mock(AsrModelMapper.class);
|
||||
|
|
|
|||
|
|
@ -549,14 +549,14 @@ const AiModels: React.FC = () => {
|
|||
|
||||
<Form.Item
|
||||
label="模型名称"
|
||||
required
|
||||
required={activeType === "LLM"}
|
||||
tooltip="可从远程列表选择,也可手动输入;值将作为模型 code 传给后端"
|
||||
>
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<Form.Item
|
||||
name="modelCode"
|
||||
noStyle
|
||||
rules={[{ required: true, message: "请输入或选择模型名称" }]}
|
||||
rules={activeType === "LLM" ? [{ required: true, message: "请输入或选择模型名称" }] : []}
|
||||
>
|
||||
<AutoComplete
|
||||
allowClear
|
||||
|
|
@ -582,19 +582,16 @@ const AiModels: React.FC = () => {
|
|||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
{activeType === "ASR" && (
|
||||
<Form.Item name="wsUrl" label="WebSocket 地址">
|
||||
<Form.Item name="wsUrl" label="WebSocket 地址" hidden>
|
||||
<Input placeholder="wss://api.example.com/v1/ws" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{activeType === "ASR" && isLocalProvider && (
|
||||
<Row gutter={16}>
|
||||
<Row gutter={16} hidden>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="speakerModel"
|
||||
label="声纹模型"
|
||||
rules={[{ required: true, message: "请选择声纹模型" }]}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
|
|
@ -607,7 +604,6 @@ const AiModels: React.FC = () => {
|
|||
<Form.Item
|
||||
name="svThreshold"
|
||||
label="声纹阈值"
|
||||
rules={[{ required: true, message: "请输入声纹阈值" }]}
|
||||
>
|
||||
<InputNumber min={0} max={1} step={0.01} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
|
|
|
|||
Loading…
Reference in New Issue