refactor: 更新 ASR 本地模型连接测试和分片上传逻辑

- 修改 `AiModelServiceImpl` 中的 `fetchLocalProfile` 方法,更新目标 URL 和解析逻辑
- 添加新的 `extractStringArray` 方法以提取字符串数组
- 更新 `AndroidChunkUploadService` 接口,移除 `totalChunks` 参数
- 修改 `LegacyMeetingAdapterServiceImpl` 和 `AndroidChunkUploadServiceImpl` 中的分片上传逻辑,支持合并音频文件
- 更新 `AndroidMeetingController` 中的 `finishOfflineMeeting` 方法,处理分片上传完成后的逻辑
- 优化 `MeetingServiceImpl` 中的 `getById` 方法,忽略租户信息
- 更新前端 `AiModels.tsx` 中的表单验证规则和隐藏部分字段
dev_na
chenhao 2026-06-05 18:31:44 +08:00
parent 2e20799b4b
commit 1b41693597
11 changed files with 253 additions and 82 deletions

View File

@ -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));
}
}

View File

@ -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("会议不存在");
}

View File

@ -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;
}

View File

@ -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) {

View File

@ -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);
}

View File

@ -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> {
}

View File

@ -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;

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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>