From 763c7d6a2fd02b844bd1c8384da3bb42a61c5aa4 Mon Sep 17 00:00:00 2001 From: fj-sunny Date: Mon, 14 Sep 2026 15:56:10 +0800 Subject: [PATCH] fix(ai): route scene generation through configured providers --- .../component/scene/CustomSceneGenerator.java | 22 ++-- .../ai/qwen/QwenLlmProvider.java | 39 ++++-- .../provider/AiProviderRegistry.java | 86 +++++-------- .../service/scene/CustomSceneService.java | 2 +- .../provider/AiProviderRegistryTest.java | 58 ++++++--- .../scene/CustomSceneGeneratorTest.java | 115 +++++++----------- .../service/scene/CustomSceneServiceTest.java | 11 +- .../system/SystemManagementPage.test.tsx | 11 ++ .../features/system/SystemManagementPage.tsx | 9 +- frontend/admin/src/styles/global.css | 15 ++- 10 files changed, 183 insertions(+), 185 deletions(-) diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/component/scene/CustomSceneGenerator.java b/backend/unispeaking-server/src/main/java/com/unispeaking/component/scene/CustomSceneGenerator.java index 7d323102..0bcead5e 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/component/scene/CustomSceneGenerator.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/component/scene/CustomSceneGenerator.java @@ -92,12 +92,9 @@ public CustomSceneDefinition generate( String normalizedInput = requiredInput(sceneInput); String prompt = buildPrompt(normalizedInput, currentPreference, profile); BusinessException lastFailure = null; - List models = List.of( - AiProviderRegistry.QWEN_LLM_FLASH, - AiProviderRegistry.QWEN_LLM_PLUS); - for (int index = 0; index < models.size(); index++) { + int maximumAttempts = 2; + for (int index = 0; index < maximumAttempts; index++) { int attempt = index + 1; - String modelId = models.get(index); String attemptPrompt = attempt == 1 ? prompt : prompt + "\n\nA prior generation attempt did not satisfy the JSON contract. " @@ -105,7 +102,7 @@ public CustomSceneDefinition generate( try { long llmStartedAt = System.nanoTime(); String content = providerRegistry.executeLlmTask( - modelId, + null, attemptPrompt, null, LlmResponseFormat.JSON_OBJECT); @@ -118,9 +115,8 @@ public CustomSceneDefinition generate( catch (BusinessException exception) { if ("CUSTOM_SCENE_LLM_RESPONSE_INVALID".equals(exception.code())) { LOGGER.warn( - "custom scene LLM response rejected sceneId={} model={} attempt={} llmMs={} parseMs={} responseChars={}", + "custom scene LLM response rejected sceneId={} route=default attempt={} llmMs={} parseMs={} responseChars={}", sceneId, - modelId, attempt, llmMillis, elapsedMillis(parseStartedAt), @@ -129,9 +125,8 @@ public CustomSceneDefinition generate( throw exception; } LOGGER.info( - "custom scene LLM completed sceneId={} model={} attempt={} llmMs={} parseMs={}", + "custom scene LLM completed sceneId={} route=default attempt={} llmMs={} parseMs={}", sceneId, - modelId, attempt, llmMillis, elapsedMillis(parseStartedAt)); @@ -139,12 +134,11 @@ public CustomSceneDefinition generate( } catch (BusinessException exception) { lastFailure = exception; - if (index + 1 < models.size()) { + if (index + 1 < maximumAttempts) { LOGGER.warn( - "custom scene LLM falling back sceneId={} failedModel={} nextModel={} code={}", + "custom scene LLM retrying configured route sceneId={} attempt={} code={}", sceneId, - modelId, - models.get(index + 1), + attempt, exception.code()); continue; } diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/ai/qwen/QwenLlmProvider.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/ai/qwen/QwenLlmProvider.java index 01f29f82..89d589db 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/ai/qwen/QwenLlmProvider.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/ai/qwen/QwenLlmProvider.java @@ -39,6 +39,8 @@ public class QwenLlmProvider extends LlmProvider { private final ObjectMapper objectMapper; private final String apiKey; private final URI endpoint; + private String workspaceId = ""; + private String region = ""; private final String model; private final Duration readTimeout; private final int maxResponseBytes; @@ -63,6 +65,8 @@ public QwenLlmProvider( model, positiveDuration(readTimeoutSeconds, "Qwen LLM read timeout"), maxResponseBytes); + this.workspaceId = trim(workspaceId); + this.region = trim(region); } public QwenLlmProvider( @@ -131,7 +135,8 @@ private AiProviderResponse callForContent( if (prompt.isBlank()) { throw nonRetryableFailure("INVALID_LLM_PROMPT", "LLM task prompt is required"); } - requireHttpsEndpoint(); + URI requestEndpoint = resolveEndpoint(); + requireHttpsEndpoint(requestEndpoint); try { Map body = new LinkedHashMap<>(); @@ -142,7 +147,7 @@ private AiProviderResponse callForContent( body.put("response_format", Map.of("type", "json_object")); } HttpRequest httpRequest = HttpRequest.newBuilder() - .uri(endpoint) + .uri(requestEndpoint) .timeout(readTimeout) .header("Authorization", "Bearer " + credential) .header("Content-Type", "application/json") @@ -225,19 +230,27 @@ private Object parseContent(String content) { } } - private void requireHttpsEndpoint() { - String host = endpoint == null || endpoint.getHost() == null + private URI resolveEndpoint() { + String effectiveWorkspaceId = ProviderCredentialOverride.currentOr("workspaceId", workspaceId); + if (!effectiveWorkspaceId.isBlank()) { + return buildEndpoint(effectiveWorkspaceId, region); + } + return endpoint; + } + + private void requireHttpsEndpoint(URI requestEndpoint) { + String host = requestEndpoint == null || requestEndpoint.getHost() == null ? "" - : endpoint.getHost().toLowerCase(java.util.Locale.ROOT); - if (endpoint == null - || !endpoint.isAbsolute() - || !"https".equalsIgnoreCase(endpoint.getScheme()) + : requestEndpoint.getHost().toLowerCase(java.util.Locale.ROOT); + if (requestEndpoint == null + || !requestEndpoint.isAbsolute() + || !"https".equalsIgnoreCase(requestEndpoint.getScheme()) || !host.endsWith(".maas.aliyuncs.com") - || endpoint.getUserInfo() != null - || endpoint.getPort() != -1 - || !"/compatible-mode/v1/chat/completions".equals(endpoint.getPath()) - || endpoint.getRawQuery() != null - || endpoint.getRawFragment() != null) { + || requestEndpoint.getUserInfo() != null + || requestEndpoint.getPort() != -1 + || !"/compatible-mode/v1/chat/completions".equals(requestEndpoint.getPath()) + || requestEndpoint.getRawQuery() != null + || requestEndpoint.getRawFragment() != null) { throw retryableFailure( "QWEN_LLM_ENDPOINT_INVALID", "Qwen LLM endpoint must be the trusted Aliyun compatible-mode URL"); diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/provider/AiProviderRegistry.java b/backend/unispeaking-server/src/main/java/com/unispeaking/provider/AiProviderRegistry.java index be8e2dcc..73c11e3a 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/provider/AiProviderRegistry.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/provider/AiProviderRegistry.java @@ -115,9 +115,7 @@ public AiProviderRegistry( @Value("${AI_PROVIDER_ROUTE_TTS:}") String ttsRoute, @Value("${AI_PROVIDER_ROUTE_TRANSCRIPTION:}") - String transcriptionRoute, - @Value("${AI_QINIU_MODELS_ENABLED:false}") - boolean qiniuModelsEnabled) { + String transcriptionRoute) { this( realtimeProviders, llmProviders, @@ -129,8 +127,7 @@ AiCapability.REALTIME, parseRoute(realtimeRoute), AiCapability.LLM, parseRoute(llmRoute), AiCapability.SCORING, parseRoute(scoringRoute), AiCapability.TTS, parseRoute(ttsRoute), - AiCapability.TRANSCRIPTION, parseRoute(transcriptionRoute)), - qiniuModelsEnabled); + AiCapability.TRANSCRIPTION, parseRoute(transcriptionRoute))); } public AiProviderRegistry( @@ -155,30 +152,6 @@ public AiProviderRegistry( List ttsProviders, List transcriptionProviders, Map> configuredRoutes) { - this( - realtimeProviders, - llmProviders, - scoringProviders, - ttsProviders, - transcriptionProviders, - configuredRoutes, - true); - } - - AiProviderRegistry( - List realtimeProviders, - List llmProviders, - List scoringProviders, - List ttsProviders, - List transcriptionProviders, - Map> configuredRoutes, - boolean qiniuModelsEnabled) { - if (!qiniuModelsEnabled) { - realtimeProviders = withoutProvider(realtimeProviders, "qiniu"); - llmProviders = withoutProvider(llmProviders, "qiniu-maas"); - configuredRoutes = withoutQiniuModels(configuredRoutes); - LOGGER.info("Qiniu AI models are disabled; RTI and MaaS adapters will not be registered"); - } this.realtimeProviders = registerProviders(realtimeProviders, AiCapability.REALTIME); this.llmProviders = registerProviders(llmProviders, AiCapability.LLM); this.scoringProviders = registerProviders(scoringProviders, AiCapability.SCORING); @@ -191,29 +164,6 @@ public AiProviderRegistry( this.models = List.copyOf(modelDefinitions.values()); } - private static List withoutProvider( - List providers, - String providerId) { - return providers.stream() - .filter(provider -> !provider.providerId().equalsIgnoreCase(providerId)) - .toList(); - } - - private static Map> withoutQiniuModels( - Map> configuredRoutes) { - if (configuredRoutes == null || configuredRoutes.isEmpty()) return Map.of(); - Map> filtered = new EnumMap<>(AiCapability.class); - configuredRoutes.forEach((capability, route) -> filtered.put( - capability, - route.stream() - .map(AbstractAiProvider::normalizeModelId) - .filter(modelId -> !modelId.equals(QINIU_REALTIME_PLUS)) - .filter(modelId -> !modelId.equals(QINIU_MAAS_QWEN_PLUS)) - .filter(modelId -> !modelId.equals(QINIU_MAAS_DEEPSEEK_FLASH)) - .toList())); - return Map.copyOf(filtered); - } - public List models() { AiRuntimeConfiguration runtime = runtimeConfiguration(); if (!runtime.databaseBacked()) return models; @@ -426,9 +376,16 @@ public byte[] generateSpeechAudioBytes( String token, String voice) { if (modelId == null || modelId.isBlank()) { - throw new BusinessException( - "AI_TTS_MODEL_REQUIRED", - "A voice-specific TTS request requires an explicit model"); + return unboxAudio(invokeRouteWithResult( + context, + AiCapability.TTS, + id -> { + AiProviderResponse measured = getTtsProvider(id) + .generateSpeechAudioMeasured( + text, credential(id, token), voice); + return new AiProviderResponse<>(boxAudio(measured.response()), + measured.providerRequestId(), measured.usage()); + }).response()); } return invokeExplicitMeasured(context, AiCapability.TTS, modelId, id -> getTtsProvider(id).generateSpeechAudioMeasured( @@ -444,6 +401,9 @@ public String executeLlmTask( String prompt, String token, LlmResponseFormat responseFormat) { + if (modelId == null || modelId.isBlank()) { + return executeLlmTaskRouted(prompt, token, responseFormat).response(); + } return invokeExplicitMeasured( automaticContext("llm"), AiCapability.LLM, @@ -855,7 +815,14 @@ private AiModelConfiguration modelConfigurationForLedger(String modelId, AiCapab private AiInvocationContext automaticContext(String businessScene) { AiInvocationContext scoped = AiInvocationContexts.current(); - if (scoped != null) return scoped; + if (scoped != null) { + return new AiInvocationContext( + UUID.randomUUID(), + scoped.userId(), + scoped.sessionId(), + scoped.businessScene(), + scoped.routeKey()); + } String userId = null; try { if (authService != null) userId = authService.currentUserIdOrNull(); @@ -877,11 +844,16 @@ private static long elapsedMillis(long startedNanos) { } private boolean shouldFailOver(BusinessException exception) { + String code = exception.code() == null ? "" : exception.code(); + // Authentication and account-policy failures cannot be retried against the + // same Qiniu model, but they must not prevent the configured route fallback. + if ("QINIU_MAAS_LLM_REQUEST_FAILED".equals(code)) { + return true; + } Boolean classifiedRetryable = AbstractAiProvider.retryable(exception); if (classifiedRetryable != null) { return classifiedRetryable; } - String code = exception.code() == null ? "" : exception.code(); return !code.startsWith("INVALID_") && !code.startsWith("UNSUPPORTED_") && !code.endsWith("_INTERRUPTED") diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/service/scene/CustomSceneService.java b/backend/unispeaking-server/src/main/java/com/unispeaking/service/scene/CustomSceneService.java index 7578ff29..e08589bc 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/service/scene/CustomSceneService.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/service/scene/CustomSceneService.java @@ -105,7 +105,7 @@ public byte[] synthesizeSpeech(String sceneId, String text, String model) { } UserProfile profile = profileService.getProfile(definition.userId()); byte[] audio = providerRegistry.generateSpeechAudioBytes( - AiProviderRegistry.QWEN_TTS, + model, text.strip(), null, profile == null ? null : profile.voiceId()); diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/provider/AiProviderRegistryTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/provider/AiProviderRegistryTest.java index 7c919940..7ea89deb 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/provider/AiProviderRegistryTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/provider/AiProviderRegistryTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -35,7 +36,7 @@ class AiProviderRegistryTest { @Test - void disablesAllQiniuAiModelsAtRegistrationBoundary() { + void registersQiniuModelsWithoutAnEnvironmentKillSwitch() { AiProviderRegistry registry = new AiProviderRegistry( List.of(new StubQiniuRealtimeProvider(), new StubRealtimeProvider()), llmProviders(), @@ -49,18 +50,20 @@ void disablesAllQiniuAiModelsAtRegistrationBoundary() { AiCapability.LLM, List.of( AiProviderRegistry.QINIU_MAAS_QWEN_PLUS, AiProviderRegistry.QWEN_LLM_PLUS, - AiProviderRegistry.DEEPSEEK_CHAT)), - false); + AiProviderRegistry.DEEPSEEK_CHAT))); assertEquals( - List.of(AiProviderRegistry.QWEN_REALTIME_FLASH), + List.of( + AiProviderRegistry.QINIU_REALTIME_PLUS, + AiProviderRegistry.QWEN_REALTIME_FLASH), registry.route(AiCapability.REALTIME)); assertEquals( List.of( + AiProviderRegistry.QINIU_MAAS_QWEN_PLUS, AiProviderRegistry.QWEN_LLM_PLUS, AiProviderRegistry.DEEPSEEK_CHAT), registry.route(AiCapability.LLM)); - assertFalse(registry.deployedModels().stream() + assertTrue(registry.deployedModels().stream() .anyMatch(model -> model.providerId().startsWith("qiniu"))); } @@ -516,10 +519,8 @@ void rejectsExplicitRealtimeModelThatDoesNotBelongToTheRequestedProvider() { ProviderType.DEEPSEEK, AiProviderRegistry.QWEN_REALTIME_FLASH, (modelId, provider) -> "unused")).code()); - assertEquals("AI_TTS_MODEL_REQUIRED", assertThrows( - BusinessException.class, - () -> registry.generateSpeechAudioBytes( - (String) null, "hello", null, "Katerina")).code()); + assertArrayEquals(new byte[] {1, 2}, registry.generateSpeechAudioBytes( + (String) null, "hello", null, "Katerina")); } @Test @@ -691,15 +692,14 @@ void routesExplicitAndAutomaticRequestsAcrossAllCapabilityAdapters() { } @Test - void rejectsNullAudioBytesAndRequiresModelForExplicitVoiceRequests() { + void rejectsNullAudioBytesAndRoutesVoiceRequestsWithoutAnExplicitModel() { AiProviderRegistry registry = registry(new StubRealtimeProvider()); assertEquals("INVALID_AUDIO", assertThrows( BusinessException.class, () -> registry.evaluatePronunciation("hello", new Byte[] {null}, null)).code()); - assertEquals("AI_TTS_MODEL_REQUIRED", assertThrows( - BusinessException.class, - () -> registry.generateSpeechAudioBytes(" ", "hello", null, "voice-a")).code()); + assertArrayEquals(new byte[] {1, 2}, + registry.generateSpeechAudioBytes(" ", "hello", null, "voice-a")); assertEquals("AI_MODEL_NOT_FOUND", assertThrows( BusinessException.class, () -> registry.executeLlmTask("missing", "prompt", null)).code()); @@ -721,7 +721,32 @@ void preservesRoutedResultMetadataAndScopedInvocationContext() { assertEquals("qiniu-maas", result.providerId()); assertEquals(AiCapability.LLM, result.capability()); assertEquals("qiniu-maas", result.response()); - assertEquals(context, attempts.getFirst().context()); + assertEquals(context.userId(), attempts.getFirst().context().userId()); + assertEquals(context.sessionId(), attempts.getFirst().context().sessionId()); + assertEquals(context.businessScene(), attempts.getFirst().context().businessScene()); + assertEquals(context.routeKey(), attempts.getFirst().context().routeKey()); + assertNotEquals(context.logicalRequestId(), attempts.getFirst().context().logicalRequestId()); + } + + @Test + void assignsDistinctLogicalRequestIdsToSequentialCallsInOneScopedTask() { + AiProviderRegistry registry = registry(new StubRealtimeProvider()); + AiInvocationContext context = new AiInvocationContext( + UUID.randomUUID(), "user-1", "session-1", "custom_scene_generation", "default"); + List attempts = new ArrayList<>(); + registry.configureDynamicRuntime(null, attempts::add, null, credentials()); + + AiInvocationContexts.call(context, () -> { + registry.executeLlmTask("first", null); + registry.executeLlmTask("second", null); + return null; + }); + + assertEquals(2, attempts.size()); + assertNotEquals( + attempts.get(0).context().logicalRequestId(), + attempts.get(1).context().logicalRequestId()); + assertTrue(attempts.stream().allMatch(attempt -> attempt.attemptNo() == 1)); } @Test @@ -809,8 +834,7 @@ void parsesConfiguredEnvironmentRoutesAndNormalizesTheirModelIds() { " qwen3.5-plus , DEEPSEEK-V4-FLASH ", " iflytek-suntone ", " qwen3-tts-flash ", - " stub-asr ", - true); + " stub-asr "); assertEquals(List.of(AiProviderRegistry.QWEN_REALTIME_FLASH), registry.route(AiCapability.REALTIME)); @@ -1175,7 +1199,7 @@ private FailingQiniuMaasLlmProvider(String model) { @Override public String executeLlmTask(String prompt, String token) { - throw new BusinessException("QINIU_MAAS_LLM_IO_ERROR", "unavailable"); + throw nonRetryableFailure("QINIU_MAAS_LLM_REQUEST_FAILED", "forbidden"); } } diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/service/scene/CustomSceneGeneratorTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/service/scene/CustomSceneGeneratorTest.java index 40460f81..b98dca80 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/service/scene/CustomSceneGeneratorTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/service/scene/CustomSceneGeneratorTest.java @@ -38,7 +38,7 @@ class CustomSceneGeneratorTest { void generatesCompactLearningContentAndMachineReadableSuccessFactor() { AiProviderRegistry registry = mock(AiProviderRegistry.class); when(registry.executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_FLASH), + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT))) @@ -86,13 +86,8 @@ void generatesCompactLearningContentAndMachineReadableSuccessFactor() { ArgumentCaptor prompt = ArgumentCaptor.forClass(String.class); verify(registry).executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_FLASH), - prompt.capture(), isNull(), - eq(LlmResponseFormat.JSON_OBJECT)); - verify(registry, never()).executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_PLUS), - anyString(), + prompt.capture(), isNull(), eq(LlmResponseFormat.JSON_OBJECT)); assertTrue(prompt.getValue().contains("酒店办理入住")); @@ -121,17 +116,11 @@ void retriesWhenFirstResponseHasTooFewWords() { AiProviderRegistry registry = mock(AiProviderRegistry.class); String rejectedResponse = validResponse(3); when(registry.executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_FLASH), - anyString(), isNull(), - eq(LlmResponseFormat.JSON_OBJECT))) - .thenReturn(rejectedResponse); - when(registry.executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_PLUS), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT))) - .thenReturn(validResponse(4)); + .thenReturn(rejectedResponse, validResponse(4)); var service = new CustomSceneGenerator(registry, objectMapper); Logger logger = (Logger) LoggerFactory.getLogger(CustomSceneGenerator.class); ListAppender appender = new ListAppender<>(); @@ -152,13 +141,8 @@ void retriesWhenFirstResponseHasTooFewWords() { } assertEquals(4, scene.wordList().size()); - verify(registry).executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_FLASH), - anyString(), + verify(registry, times(2)).executeLlmTask( isNull(), - eq(LlmResponseFormat.JSON_OBJECT)); - verify(registry).executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_PLUS), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT)); @@ -166,7 +150,7 @@ void retriesWhenFirstResponseHasTooFewWords() { .map(ILoggingEvent::getFormattedMessage) .collect(java.util.stream.Collectors.joining("\n")); assertTrue(logs.contains( - "response rejected sceneId=custom_retry model=qwen3.5-flash attempt=1")); + "response rejected sceneId=custom_retry route=default attempt=1")); assertTrue(logs.contains("llmMs=")); assertTrue(logs.contains("parseMs=")); assertTrue(logs.contains("responseChars=" + rejectedResponse.length())); @@ -174,15 +158,12 @@ void retriesWhenFirstResponseHasTooFewWords() { } @Test - void fallsBackToPlusWhenFlashProviderFails() { + void retriesConfiguredRouteWhenProviderRouteFails() { AiProviderRegistry registry = mock(AiProviderRegistry.class); when(registry.executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_FLASH), anyString(), isNull(), - eq(LlmResponseFormat.JSON_OBJECT))) - .thenThrow(new BusinessException("QWEN_LLM_IO_ERROR", "unavailable")); - when(registry.executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_PLUS), anyString(), isNull(), + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT))) + .thenThrow(new BusinessException("AI_ROUTE_UNAVAILABLE", "unavailable")) .thenReturn(validResponse(4)); var scene = new CustomSceneGenerator(registry, objectMapper).generate( @@ -193,22 +174,20 @@ void fallsBackToPlusWhenFlashProviderFails() { new UserProfile("user-1", "B", "Katerina", "zh-CN", "")); assertEquals("住宿", scene.label()); - verify(registry).executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_PLUS), anyString(), isNull(), + verify(registry, times(2)).executeLlmTask( + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT)); } @Test - void propagatesPlusFailureAfterFlashFailure() { + void propagatesSecondConfiguredRouteFailure() { AiProviderRegistry registry = mock(AiProviderRegistry.class); when(registry.executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_FLASH), anyString(), isNull(), - eq(LlmResponseFormat.JSON_OBJECT))) - .thenThrow(new BusinessException("QWEN_LLM_IO_ERROR", "flash unavailable")); - when(registry.executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_PLUS), anyString(), isNull(), + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT))) - .thenThrow(new BusinessException("QWEN_LLM_REQUEST_FAILED", "plus unavailable")); + .thenThrow( + new BusinessException("AI_ROUTE_UNAVAILABLE", "first unavailable"), + new BusinessException("AI_ROUTE_UNAVAILABLE", "second unavailable")); BusinessException failure = assertThrows( BusinessException.class, @@ -219,20 +198,16 @@ void propagatesPlusFailureAfterFlashFailure() { null, new UserProfile("user-1", "B", "Katerina", "zh-CN", ""))); - assertEquals("QWEN_LLM_REQUEST_FAILED", failure.code()); + assertEquals("AI_ROUTE_UNAVAILABLE", failure.code()); } @Test void retriesWhenModelReturnsLabelOutsideAllowList() { AiProviderRegistry registry = mock(AiProviderRegistry.class); when(registry.executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_FLASH), anyString(), isNull(), - eq(LlmResponseFormat.JSON_OBJECT))) - .thenReturn(validResponse(4).replace("住宿", "旅游")); - when(registry.executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_PLUS), anyString(), isNull(), + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT))) - .thenReturn(validResponse(4)); + .thenReturn(validResponse(4).replace("住宿", "旅游"), validResponse(4)); var service = new CustomSceneGenerator(registry, objectMapper); var scene = service.generate( @@ -243,8 +218,8 @@ void retriesWhenModelReturnsLabelOutsideAllowList() { new UserProfile("user-1", "B", "Katerina", "zh-CN", "")); assertEquals("住宿", scene.label()); - verify(registry).executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_PLUS), anyString(), isNull(), + verify(registry, times(2)).executeLlmTask( + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT)); } @@ -252,17 +227,13 @@ void retriesWhenModelReturnsLabelOutsideAllowList() { void retriesWhenPhraseListContainsCompleteSentences() { AiProviderRegistry registry = mock(AiProviderRegistry.class); when(registry.executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_FLASH), anyString(), isNull(), + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT))) .thenReturn(validResponse(4, List.of( "There is a hole in it", "I would like to return this", "Can I get my money back?", - "It was bought yesterday"))); - when(registry.executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_PLUS), anyString(), isNull(), - eq(LlmResponseFormat.JSON_OBJECT))) - .thenReturn(validResponse(4)); + "It was bought yesterday")), validResponse(4)); var service = new CustomSceneGenerator(registry, objectMapper); var scene = service.generate( @@ -273,8 +244,8 @@ void retriesWhenPhraseListContainsCompleteSentences() { new UserProfile("user-1", "B", "Katerina", "zh-CN", "")); assertEquals("check in", scene.phraseList().getFirst().englishText()); - verify(registry).executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_PLUS), anyString(), isNull(), + verify(registry, times(2)).executeLlmTask( + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT)); } @@ -282,7 +253,7 @@ void retriesWhenPhraseListContainsCompleteSentences() { void acceptsReusableLexicalChunksAsPhrases() { AiProviderRegistry registry = mock(AiProviderRegistry.class); when(registry.executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_FLASH), anyString(), isNull(), + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT))) .thenReturn(validResponse(4, List.of( "money back", @@ -300,7 +271,7 @@ void acceptsReusableLexicalChunksAsPhrases() { assertEquals("return this item", scene.phraseList().get(1).englishText()); verify(registry).executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_FLASH), + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT)); @@ -310,17 +281,13 @@ void acceptsReusableLexicalChunksAsPhrases() { void retriesWhenPhraseStartsWithNominalSubjectClause() { AiProviderRegistry registry = mock(AiProviderRegistry.class); when(registry.executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_FLASH), anyString(), isNull(), + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT))) .thenReturn(validResponse(4, List.of( "The item is defective", "return this item", "proof of purchase", - "ask for a refund"))); - when(registry.executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_PLUS), anyString(), isNull(), - eq(LlmResponseFormat.JSON_OBJECT))) - .thenReturn(validResponse(4)); + "ask for a refund")), validResponse(4)); var service = new CustomSceneGenerator(registry, objectMapper); var scene = service.generate( @@ -331,8 +298,8 @@ void retriesWhenPhraseStartsWithNominalSubjectClause() { new UserProfile("user-1", "B", "Katerina", "zh-CN", "")); assertEquals("check in", scene.phraseList().getFirst().englishText()); - verify(registry).executeLlmTask( - eq(AiProviderRegistry.QWEN_LLM_PLUS), anyString(), isNull(), + verify(registry, times(2)).executeLlmTask( + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT)); } @@ -340,7 +307,7 @@ void retriesWhenPhraseStartsWithNominalSubjectClause() { void acceptsJsonFenceAndNormalizesOptionalInstruction() { AiProviderRegistry registry = mock(AiProviderRegistry.class); when(registry.executeLlmTask( - anyString(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT))) + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT))) .thenReturn("```json\n" + validResponse(5) + "\n```"); var service = new CustomSceneGenerator(registry, objectMapper); @@ -350,7 +317,7 @@ void acceptsJsonFenceAndNormalizesOptionalInstruction() { assertEquals("保持礼貌,每次回复不超过三句话。", scene.customInstruction()); verify(registry).executeLlmTask( - anyString(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT)); + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT)); } @Test @@ -365,7 +332,7 @@ void rejectsNullBlankAndOverlongSceneInputBeforeCallingLlm() { assertEquals("INVALID_SCENE_INPUT", exception.code()); } verify(registry, never()).executeLlmTask( - anyString(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT)); + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT)); } @Test @@ -381,7 +348,7 @@ void retriesMalformedJsonDuplicateKeysTrailingTokensAndMarkdown() { final int caseIndex = index; AiProviderRegistry registry = mock(AiProviderRegistry.class); when(registry.executeLlmTask( - anyString(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT))) + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT))) .thenReturn(invalid[index], invalid[index]); var service = new CustomSceneGenerator(registry, objectMapper); @@ -390,7 +357,7 @@ void retriesMalformedJsonDuplicateKeysTrailingTokensAndMarkdown() { new UserProfile("user-1", "B", "Katerina", "zh-CN", ""))); assertEquals("CUSTOM_SCENE_LLM_RESPONSE_INVALID", exception.code()); verify(registry, times(2)).executeLlmTask( - anyString(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT)); + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT)); } } @@ -443,18 +410,18 @@ void rejectsPhraseWordCountPunctuationAndClauseShapes() { void propagatesProviderFailuresAndReturnsLastInvalidResponseAfterTwoAttempts() { AiProviderRegistry registry = mock(AiProviderRegistry.class); when(registry.executeLlmTask( - anyString(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT))) + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT))) .thenThrow(new IllegalStateException("provider unavailable")); var service = new CustomSceneGenerator(registry, objectMapper); assertThrows(IllegalStateException.class, () -> service.generate("custom_provider", "user-1", "酒店办理入住", null, new UserProfile("user-1", "B", "Katerina", "zh-CN", ""))); verify(registry).executeLlmTask( - anyString(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT)); + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT)); AiProviderRegistry invalidRegistry = mock(AiProviderRegistry.class); when(invalidRegistry.executeLlmTask( - anyString(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT))) + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT))) .thenReturn("{}", "{}"); var invalidService = new CustomSceneGenerator(invalidRegistry, objectMapper); BusinessException exception = assertThrows(BusinessException.class, @@ -462,7 +429,7 @@ void propagatesProviderFailuresAndReturnsLastInvalidResponseAfterTwoAttempts() { new UserProfile("user-1", "B", "Katerina", "zh-CN", ""))); assertEquals("CUSTOM_SCENE_LLM_RESPONSE_INVALID", exception.code()); verify(invalidRegistry, times(2)).executeLlmTask( - anyString(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT)); + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT)); } private void assertAllRejected(String[] responses) { @@ -470,7 +437,7 @@ private void assertAllRejected(String[] responses) { final int caseIndex = index; AiProviderRegistry registry = mock(AiProviderRegistry.class); when(registry.executeLlmTask( - anyString(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT))) + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT))) .thenReturn(responses[index], responses[index]); var service = new CustomSceneGenerator(registry, objectMapper); BusinessException exception = assertThrows(BusinessException.class, @@ -478,7 +445,7 @@ private void assertAllRejected(String[] responses) { new UserProfile("user-1", "B", "Katerina", "zh-CN", ""))); assertEquals("CUSTOM_SCENE_LLM_RESPONSE_INVALID", exception.code()); verify(registry, times(2)).executeLlmTask( - anyString(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT)); + isNull(), anyString(), isNull(), eq(LlmResponseFormat.JSON_OBJECT)); } } diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/service/scene/CustomSceneServiceTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/service/scene/CustomSceneServiceTest.java index b5a69002..9edd1d60 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/service/scene/CustomSceneServiceTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/service/scene/CustomSceneServiceTest.java @@ -62,15 +62,16 @@ void speechUsesProfileVoiceAndRejectsEmptyProviderAudio() { CustomSceneDefinition definition = definition("owned", USER_ID, "{}"); when(repository.findCustomDefinitionById("owned")).thenReturn(Optional.of(definition)); when(profileService.getProfile(USER_ID)).thenReturn(profile("Tina")); - when(providers.generateSpeechAudioBytes(AiProviderRegistry.QWEN_TTS, "hello", null, "Tina")) + when(providers.generateSpeechAudioBytes((String) null, "hello", null, "Tina")) .thenReturn(new byte[] {1, 2}); - assertArrayEquals(new byte[] {1, 2}, service.synthesizeSpeech("owned", " hello ", "ignored")); - verify(providers).generateSpeechAudioBytes(AiProviderRegistry.QWEN_TTS, "hello", null, "Tina"); + assertArrayEquals(new byte[] {1, 2}, service.synthesizeSpeech("owned", " hello ", null)); + verify(providers).generateSpeechAudioBytes((String) null, "hello", null, "Tina"); - when(providers.generateSpeechAudioBytes(AiProviderRegistry.QWEN_TTS, "empty", null, "Tina")) + when(providers.generateSpeechAudioBytes("cosyvoice-v3-flash", "empty", null, "Tina")) .thenReturn(new byte[0]); - assertCode("TTS_AUDIO_EMPTY", () -> service.synthesizeSpeech("owned", "empty", null)); + assertCode("TTS_AUDIO_EMPTY", () -> service.synthesizeSpeech( + "owned", "empty", "cosyvoice-v3-flash")); } @Test diff --git a/frontend/admin/src/features/system/SystemManagementPage.test.tsx b/frontend/admin/src/features/system/SystemManagementPage.test.tsx index 4e0d4b58..c236eb6e 100644 --- a/frontend/admin/src/features/system/SystemManagementPage.test.tsx +++ b/frontend/admin/src/features/system/SystemManagementPage.test.tsx @@ -90,6 +90,17 @@ describe('SystemManagementPage', () => { expect(replaceRoute).toHaveBeenCalledWith('LLM', ['deepseek-v4-flash', 'qwen3.5-plus']) }) + it('keeps an explicit add fallback button visible when no candidates remain', async () => { + vi.mocked(getAiConfiguration).mockResolvedValue(configuration) + + renderPage() + + const add = await screen.findByRole('button', { name: '文本模型添加备用模型' }) + expect(add).toBeVisible() + expect(add).toBeDisabled() + expect(screen.getByText('没有其他已启用模型')).toBeInTheDocument() + }) + it('updates the complete Qwen credential form through the real admin flow', async () => { vi.mocked(getAiConfiguration).mockResolvedValue(configuration) vi.mocked(getCredentialStatus) diff --git a/frontend/admin/src/features/system/SystemManagementPage.tsx b/frontend/admin/src/features/system/SystemManagementPage.tsx index 29f7c936..d2747e48 100644 --- a/frontend/admin/src/features/system/SystemManagementPage.tsx +++ b/frontend/admin/src/features/system/SystemManagementPage.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { ArrowDown, ArrowUp, Bot, BrainCircuit, KeyRound, Mic2, Radio, RefreshCw, - Save, Sparkles, Volume2, X, + Plus, Save, Sparkles, Volume2, X, type LucideIcon, } from 'lucide-react' import { useEffect, useState } from 'react' @@ -207,6 +207,7 @@ function ModelDialog({ model, onClose, onChanged }: { model: ModelView; onClose: function RouteEditor({ models, routes, onChanged }: { models: ModelView[]; routes: Array<{ capability: AiCapability; modelIds: string[] }>; onChanged: () => Promise }) { const [drafts, setDrafts] = useState>({}) + const [adding, setAdding] = useState(null) useEffect(() => setDrafts(Object.fromEntries(routes.map((route) => [route.capability, route.modelIds]))), [routes]) const mutation = useMutation({ mutationFn: ({ capability, modelIds }: { capability: AiCapability; modelIds: string[] }) => replaceRoute(capability, modelIds), onSuccess: onChanged }) const move = (capability: AiCapability, index: number, offset: number) => setDrafts((current) => { const list = [...(current[capability] || [])]; const target = index + offset; if (target < 0 || target >= list.length) return current; [list[index], list[target]] = [list[target], list[index]]; return { ...current, [capability]: list } }) @@ -227,7 +228,11 @@ function RouteEditor({ models, routes, onChanged }: { models: ModelView[]; route })} - {candidates.length > 0 && } +
+ + {adding === route.capability && candidates.length > 0 && } + {candidates.length === 0 && 没有其他已启用模型} +
})} diff --git a/frontend/admin/src/styles/global.css b/frontend/admin/src/styles/global.css index 873dc267..b2dbf34a 100644 --- a/frontend/admin/src/styles/global.css +++ b/frontend/admin/src/styles/global.css @@ -1973,14 +1973,25 @@ input:disabled { } .route-add { - display: grid; - grid-template-columns: auto minmax(0, 1fr); + display: flex; + flex-wrap: wrap; align-items: center; gap: 12px; color: var(--muted); font-size: 0.68rem; } +.route-add__button { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 34px; +} + +.route-add small { + align-self: center; +} + .usage-filters { display: flex; flex-wrap: wrap;