diff --git a/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java b/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java index c934e16..1f5889f 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java @@ -1,8 +1,7 @@ package com.imeeting.controller.biz; - -import com.imeeting.dto.biz.AiModelDTO; import com.imeeting.dto.biz.AiLocalProfileVO; +import com.imeeting.dto.biz.AiModelDTO; import com.imeeting.dto.biz.AiModelVO; import com.imeeting.service.biz.AiModelService; import com.unisbase.common.ApiResponse; @@ -114,6 +113,20 @@ public class AiModelController { return ApiResponse.ok(aiModelService.testLocalConnectivity(dto.getBaseUrl(), dto.getApiKey())); } + @Operation(summary = "测试LLM模型连通性") + @PostMapping("/llm-connectivity-test") + @PreAuthorize("isAuthenticated()") + public ApiResponse testLlmConnectivity(@RequestBody AiModelDTO dto) { + if ("custom".equalsIgnoreCase(dto.getProvider()) && (dto.getBaseUrl() == null || dto.getBaseUrl().isBlank())) { + return ApiResponse.error("Base URL不能为空"); + } + if (dto.getModelCode() == null || dto.getModelCode().isBlank()) { + return ApiResponse.error("模型名称不能为空"); + } + aiModelService.testLlmConnectivity(dto); + return ApiResponse.ok(Boolean.TRUE); + } + @Operation(summary = "获取默认AI模型") @GetMapping("/default") @PreAuthorize("isAuthenticated()") diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyScreenSaverCatalogResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyScreenSaverCatalogResponse.java new file mode 100644 index 0000000..53cc61e --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyScreenSaverCatalogResponse.java @@ -0,0 +1,24 @@ +package com.imeeting.dto.android.legacy; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +@Data +public class LegacyScreenSaverCatalogResponse { + + @JsonProperty("refresh_interval_sec") + private Integer refreshIntervalSec; + + @JsonProperty("play_mode") + private String playMode; + + @JsonProperty("source_scope") + private String sourceScope; + + @JsonProperty("display_duration_sec") + private Integer displayDurationSec; + + private List items; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/AiModelDTO.java b/backend/src/main/java/com/imeeting/dto/biz/AiModelDTO.java index 8ca4c9b..69a6e8f 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/AiModelDTO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/AiModelDTO.java @@ -13,6 +13,7 @@ public class AiModelDTO { private String baseUrl; private String apiPath; private String apiKey; + private String testMessage; private String modelCode; private String wsUrl; private BigDecimal temperature; diff --git a/backend/src/main/java/com/imeeting/dto/biz/ScreenSaverUserSettingsDTO.java b/backend/src/main/java/com/imeeting/dto/biz/ScreenSaverUserSettingsDTO.java new file mode 100644 index 0000000..f11d282 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/ScreenSaverUserSettingsDTO.java @@ -0,0 +1,12 @@ +package com.imeeting.dto.biz; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "屏保用户播放设置请求") +public class ScreenSaverUserSettingsDTO { + + @Schema(description = "统一屏保展示时长(秒)", example = "15") + private Integer displayDurationSec; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/ScreenSaverUserSettingsVO.java b/backend/src/main/java/com/imeeting/dto/biz/ScreenSaverUserSettingsVO.java new file mode 100644 index 0000000..35c9111 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/ScreenSaverUserSettingsVO.java @@ -0,0 +1,15 @@ +package com.imeeting.dto.biz; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "屏保用户播放设置响应") +public class ScreenSaverUserSettingsVO { + + @Schema(description = "用户 ID") + private Long userId; + + @Schema(description = "统一屏保展示时长(秒)") + private Integer displayDurationSec; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/ScreenSaverUserSettings.java b/backend/src/main/java/com/imeeting/entity/biz/ScreenSaverUserSettings.java new file mode 100644 index 0000000..b7f30cd --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/ScreenSaverUserSettings.java @@ -0,0 +1,44 @@ +package com.imeeting.entity.biz; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Schema(description = "屏保用户播放设置实体") +@TableName("biz_screen_saver_user_settings") +public class ScreenSaverUserSettings { + + @TableId(value = "id", type = IdType.AUTO) + @Schema(description = "设置 ID") + private Long id; + + @TableField(fill = FieldFill.INSERT) + @Schema(description = "租户 ID") + private Long tenantId; + + @Schema(description = "用户 ID") + private Long userId; + + @Schema(description = "统一屏保展示时长(秒)") + private Integer displayDurationSec; + + @TableLogic(value = "0", delval = "1") + @Schema(description = "逻辑删除标记") + private Integer isDeleted; + + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createdAt; + + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "更新时间") + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/imeeting/mapper/biz/ScreenSaverUserSettingsMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/ScreenSaverUserSettingsMapper.java new file mode 100644 index 0000000..054b32a --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/ScreenSaverUserSettingsMapper.java @@ -0,0 +1,9 @@ +package com.imeeting.mapper.biz; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.imeeting.entity.biz.ScreenSaverUserSettings; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ScreenSaverUserSettingsMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/imeeting/service/biz/AiModelService.java b/backend/src/main/java/com/imeeting/service/biz/AiModelService.java index 0bfc171..1948858 100644 --- a/backend/src/main/java/com/imeeting/service/biz/AiModelService.java +++ b/backend/src/main/java/com/imeeting/service/biz/AiModelService.java @@ -14,6 +14,7 @@ public interface AiModelService { PageResult> pageModels(Integer current, Integer size, String name, String type, Long tenantId); List fetchRemoteModels(String provider, String baseUrl, String apiKey); AiLocalProfileVO testLocalConnectivity(String baseUrl, String apiKey); + void testLlmConnectivity(AiModelDTO dto); AiModelVO getDefaultModel(String type, Long tenantId); AiModelVO getModelById(Long id, String type); boolean removeModelById(Long id, String type); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java index 287c932..64a30cd 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java @@ -31,6 +31,7 @@ import java.time.Duration; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -43,6 +44,20 @@ public class AiModelServiceImpl implements AiModelService { private static final String TYPE_ASR = "ASR"; private static final String TYPE_LLM = "LLM"; + private static final String DEFAULT_LLM_API_PATH = "/v1/chat/completions"; + private static final String DEFAULT_ANTHROPIC_API_PATH = "/messages"; + private static final String CONNECTIVITY_TEST_SYSTEM_PROMPT = """ + You are an LLM connectivity test assistant. + You must return exactly one JSON object and nothing else. + Do not return markdown, code fences, explanations, or any extra text. + The JSON schema is fixed: + {"status":"success","message":"LLM connectivity test passed"} + Rules: + 1. status must be success + 2. message must be LLM connectivity test passed + 3. no extra fields are allowed + """; + private static final String DEFAULT_LLM_TEST_MESSAGE = "Please reply with the fixed JSON payload."; private final ObjectMapper objectMapper; private final AsrModelMapper asrModelMapper; @@ -50,6 +65,7 @@ public class AiModelServiceImpl implements AiModelService { private final HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(300)) + .version(HttpClient.Version.HTTP_1_1) .build(); @Override @@ -161,14 +177,7 @@ public class AiModelServiceImpl implements AiModelService { .timeout(Duration.ofSeconds(10)) .GET(); - if ("anthropic".equals(providerKey)) { - if (apiKey != null && !apiKey.isBlank()) { - requestBuilder.header("x-api-key", apiKey); - } - requestBuilder.header("anthropic-version", "2023-06-01"); - } else if (!"gemini".equals(providerKey) && apiKey != null && !apiKey.isBlank()) { - requestBuilder.header("Authorization", "Bearer " + apiKey); - } + applyProviderAuthHeaders(requestBuilder, providerKey, apiKey); HttpResponse response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); if (response.statusCode() != 200) { @@ -227,6 +236,138 @@ public class AiModelServiceImpl implements AiModelService { return fetchLocalProfile(baseUrl, apiKey); } + @Override + public void testLlmConnectivity(AiModelDTO dto) { + String providerKey = normalizeProvider(dto.getProvider()); + if ("anthropic".equals(providerKey)) { + testAnthropicConnectivity(dto, providerKey); + return; + } + if ("gemini".equals(providerKey) || "google".equals(providerKey)) { + testGeminiConnectivity(dto, providerKey); + return; + } + testOpenAiCompatibleConnectivity(dto, providerKey); + } + + private void testOpenAiCompatibleConnectivity(AiModelDTO dto, String providerKey) { + String resolvedBaseUrl = resolveBaseUrl(providerKey, dto.getBaseUrl()); + String targetUrl = appendPath(resolvedBaseUrl, + dto.getApiPath() == null || dto.getApiPath().isBlank() ? DEFAULT_LLM_API_PATH : dto.getApiPath()); + + Map body = new LinkedHashMap<>(); + body.put("model", dto.getModelCode().trim()); + body.put("stream", false); + body.put("max_tokens", 32); + if (dto.getTemperature() != null) { + body.put("temperature", dto.getTemperature()); + } + if (dto.getTopP() != null) { + body.put("top_p", dto.getTopP()); + } + body.put("response_format", Map.of("type", "json_object")); + body.put("messages", buildConnectivityTestMessages(dto.getTestMessage())); + + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(targetUrl)) + .timeout(Duration.ofSeconds(20)) + .header("Content-Type", "application/json; charset=UTF-8") + .header("Accept", "application/json"); + applyProviderAuthHeaders(requestBuilder, providerKey, dto.getApiKey()); + + executeConnectivityRequest(targetUrl, dto.getModelCode(), requestBuilder, body); + } + + private void testAnthropicConnectivity(AiModelDTO dto, String providerKey) { + String resolvedBaseUrl = resolveBaseUrl(providerKey, dto.getBaseUrl()); + String targetUrl = appendPath(resolvedBaseUrl, + dto.getApiPath() == null || dto.getApiPath().isBlank() ? DEFAULT_ANTHROPIC_API_PATH : dto.getApiPath()); + + Map body = new LinkedHashMap<>(); + body.put("model", dto.getModelCode().trim()); + body.put("system", CONNECTIVITY_TEST_SYSTEM_PROMPT); + body.put("max_tokens", 32); + body.put("messages", List.of( + Map.of("role", "user", "content", resolveTestMessage(dto.getTestMessage())) + )); + + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(targetUrl)) + .timeout(Duration.ofSeconds(20)) + .header("Content-Type", "application/json; charset=UTF-8") + .header("Accept", "application/json"); + applyProviderAuthHeaders(requestBuilder, providerKey, dto.getApiKey()); + + executeConnectivityRequest(targetUrl, dto.getModelCode(), requestBuilder, body); + } + + private void testGeminiConnectivity(AiModelDTO dto, String providerKey) { + String resolvedBaseUrl = resolveBaseUrl(providerKey, dto.getBaseUrl()); + String apiPath = dto.getApiPath(); + if (apiPath == null || apiPath.isBlank()) { + apiPath = "/models/" + dto.getModelCode().trim() + ":generateContent"; + } + String targetUrl = appendPath(resolvedBaseUrl, apiPath); + if (dto.getApiKey() != null && !dto.getApiKey().isBlank()) { + targetUrl = appendQueryParam(targetUrl, "key", dto.getApiKey().trim()); + } + + Map body = new LinkedHashMap<>(); + body.put("systemInstruction", Map.of( + "parts", List.of(Map.of("text", CONNECTIVITY_TEST_SYSTEM_PROMPT)) + )); + body.put("contents", List.of( + Map.of( + "role", "user", + "parts", List.of(Map.of("text", resolveTestMessage(dto.getTestMessage()))) + ) + )); + if (dto.getTemperature() != null || dto.getTopP() != null) { + Map generationConfig = new LinkedHashMap<>(); + if (dto.getTemperature() != null) { + generationConfig.put("temperature", dto.getTemperature()); + } + if (dto.getTopP() != null) { + generationConfig.put("topP", dto.getTopP()); + } + body.put("generationConfig", generationConfig); + } + + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(targetUrl)) + .timeout(Duration.ofSeconds(20)) + .header("Content-Type", "application/json; charset=UTF-8") + .header("Accept", "application/json"); + + executeConnectivityRequest(targetUrl, dto.getModelCode(), requestBuilder, body); + } + + private void executeConnectivityRequest(String targetUrl, String modelCode, HttpRequest.Builder requestBuilder, Object body) { + try { + String requestBody = objectMapper.writeValueAsString(body); + log.info("Testing LLM connectivity, url={}, model={}", targetUrl, modelCode); + HttpRequest request = requestBuilder + .POST(HttpRequest.BodyPublishers.ofString(requestBody, StandardCharsets.UTF_8)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new RuntimeException("HTTP " + response.statusCode() + ", body=" + response.body()); + } + + JsonNode root = objectMapper.readTree(response.body()); + String responseContent = readConnectivityResponseContent(root); + String resultMessage = extractConnectivityResultMessage(responseContent); + if (resultMessage == null || resultMessage.isBlank()) { + throw new RuntimeException("Unexpected empty connectivity response, body=" + response.body()); + } + } catch (Exception e) { + String detail = describeException(e); + log.error("LLM connectivity test failed, url={}, detail={}", targetUrl, detail, e); + throw new RuntimeException("LLM连通性测试失败 url=" + targetUrl + ", detail=" + detail, e); + } + } + private AiLocalProfileVO fetchLocalProfile(String baseUrl, String apiKey) { String targetUrl = appendPath(baseUrl, "api/v1/system/profile"); try { @@ -297,10 +438,10 @@ public class AiModelServiceImpl implements AiModelService { HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() < 200 || response.statusCode() >= 300) { - throw new RuntimeException("本地模型配置保存失败: HTTP " + response.statusCode()); + throw new RuntimeException("闂佸搫鐗滈崜娆忥耿閺夋嚦鐔煎灳瀹曞洠鍋撻悜鑺ョ厐鐎广儱娲ㄩ弸鍌毲庨崶銊х畵闁宦板妽瀵板嫭娼忛銉? HTTP " + response.statusCode()); } } catch (Exception e) { - throw new RuntimeException("本地模型配置保存失败: " + e.getMessage(), e); + throw new RuntimeException("闂佸搫鐗滈崜娆忥耿閺夋嚦鐔煎灳瀹曞洠鍋撻悜鑺ョ厐鐎广儱娲ㄩ弸鍌毲庨崶銊х畵闁宦板妽瀵板嫭娼忛銉? " + e.getMessage(), e); } } @@ -335,7 +476,227 @@ public class AiModelServiceImpl implements AiModelService { } private String appendPath(String baseUrl, String path) { - return baseUrl.endsWith("/") ? baseUrl + path : baseUrl + "/" + path; + if (baseUrl == null || baseUrl.isBlank()) { + throw new RuntimeException("baseUrl is required"); + } + String trimmedBaseUrl = baseUrl.trim(); + if (path == null || path.isBlank()) { + return trimmedBaseUrl; + } + + String trimmedPath = path.trim(); + if (trimmedPath.startsWith("http://") || trimmedPath.startsWith("https://")) { + return trimmedPath; + } + + String normalizedBaseUrl = trimmedBaseUrl.endsWith("/") + ? trimmedBaseUrl.substring(0, trimmedBaseUrl.length() - 1) + : trimmedBaseUrl; + if (!trimmedPath.startsWith("/")) { + return normalizedBaseUrl + "/" + trimmedPath; + } + + URI baseUri = URI.create(normalizedBaseUrl + "/"); + String basePath = baseUri.getPath(); + if (basePath != null && !basePath.isBlank() && !"/".equals(basePath)) { + String normalizedBasePath = basePath.endsWith("/") ? basePath.substring(0, basePath.length() - 1) : basePath; + if (trimmedPath.startsWith(normalizedBasePath + "/")) { + return baseUri.resolve(trimmedPath).toString(); + } + } + return normalizedBaseUrl + trimmedPath; + } + + private String appendQueryParam(String url, String name, String value) { + if (value == null || value.isBlank()) { + return url; + } + String delimiter = url.contains("?") ? "&" : "?"; + return url + delimiter + name + "=" + URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private void applyProviderAuthHeaders(HttpRequest.Builder requestBuilder, String providerKey, String apiKey) { + if ("anthropic".equals(providerKey)) { + if (apiKey != null && !apiKey.isBlank()) { + requestBuilder.header("x-api-key", apiKey.trim()); + } + requestBuilder.header("anthropic-version", "2023-06-01"); + return; + } + if ("gemini".equals(providerKey) || "google".equals(providerKey)) { + return; + } + if (apiKey != null && !apiKey.isBlank()) { + requestBuilder.header("Authorization", buildAuthorization(apiKey)); + } + } + + private String buildAuthorization(String apiKey) { + String trimmedApiKey = apiKey == null ? "" : apiKey.trim(); + if (trimmedApiKey.isEmpty()) { + return trimmedApiKey; + } + return trimmedApiKey.startsWith("Bearer ") ? trimmedApiKey : "Bearer " + trimmedApiKey; + } + + private String resolveTestMessage(String testMessage) { + if (testMessage == null || testMessage.isBlank()) { + return DEFAULT_LLM_TEST_MESSAGE; + } + return testMessage.trim(); + } + + private List> buildConnectivityTestMessages(String testMessage) { + return List.of( + Map.of("role", "system", "content", CONNECTIVITY_TEST_SYSTEM_PROMPT), + Map.of("role", "user", "content", resolveTestMessage(testMessage)) + ); + } + + private String readConnectivityResponseContent(JsonNode root) { + String anthropicText = root.path("content").path(0).path("text").asText(null); + if (anthropicText != null && !anthropicText.isBlank()) { + return anthropicText; + } + + String chatCompletionContent = root.path("choices").path(0).path("message").path("content").asText(null); + if (chatCompletionContent != null && !chatCompletionContent.isBlank()) { + return chatCompletionContent; + } + + String textCompletionContent = root.path("choices").path(0).path("text").asText(null); + if (textCompletionContent != null && !textCompletionContent.isBlank()) { + return textCompletionContent; + } + + String responsesApiContent = root.path("output_text").asText(null); + if (responsesApiContent != null && !responsesApiContent.isBlank()) { + return responsesApiContent; + } + + JsonNode candidatesNode = root.path("candidates"); + if (candidatesNode.isArray()) { + for (JsonNode candidate : candidatesNode) { + JsonNode partsNode = candidate.path("content").path("parts"); + if (!partsNode.isArray()) { + continue; + } + for (JsonNode part : partsNode) { + String text = part.path("text").asText(null); + if (text != null && !text.isBlank()) { + return text; + } + } + } + } + + JsonNode outputNode = root.path("output"); + if (outputNode.isArray()) { + for (JsonNode item : outputNode) { + JsonNode contentArray = item.path("content"); + if (!contentArray.isArray()) { + continue; + } + for (JsonNode contentItem : contentArray) { + String text = contentItem.path("text").asText(null); + if (text != null && !text.isBlank()) { + return text; + } + } + } + } + + return null; + } + + private String extractConnectivityResultMessage(String responseContent) { + if (responseContent == null || responseContent.isBlank()) { + return null; + } + String trimmed = responseContent.trim(); + JsonNode parsed = tryParseConnectivityPayload(trimmed); + if (parsed != null) { + String status = parsed.path("status").asText(""); + String message = parsed.path("message").asText(""); + if ("success".equalsIgnoreCase(status) && !message.isBlank()) { + return message; + } + } + return trimmed; + } + + private JsonNode tryParseConnectivityPayload(String text) { + try { + return objectMapper.readTree(text); + } catch (Exception ignored) { + } + String jsonObject = extractFirstJsonObject(text); + if (jsonObject == null) { + return null; + } + try { + return objectMapper.readTree(jsonObject); + } catch (Exception ignored) { + return null; + } + } + + private String extractFirstJsonObject(String text) { + if (text == null || text.isBlank()) { + return null; + } + int start = text.indexOf('{'); + if (start < 0) { + return null; + } + int depth = 0; + boolean inString = false; + boolean escaped = false; + for (int i = start; i < text.length(); i++) { + char ch = text.charAt(i); + if (escaped) { + escaped = false; + continue; + } + if (ch == '\\') { + escaped = true; + continue; + } + if (ch == '"') { + inString = !inString; + continue; + } + if (inString) { + continue; + } + if (ch == '{') { + depth++; + } else if (ch == '}') { + depth--; + if (depth == 0) { + return text.substring(start, i + 1); + } + } + } + return null; + } + + private String describeException(Throwable throwable) { + if (throwable == null) { + return "unknown"; + } + String message = throwable.getMessage(); + if (message != null && !message.isBlank()) { + return throwable.getClass().getSimpleName() + ": " + message; + } + Throwable cause = throwable.getCause(); + if (cause != null && cause != throwable) { + String causeMessage = describeException(cause); + if (causeMessage != null && !causeMessage.isBlank()) { + return throwable.getClass().getSimpleName() + " <- " + causeMessage; + } + } + return throwable.getClass().getName(); } private String sanitizeModelName(String rawName) { @@ -357,19 +718,19 @@ public class AiModelServiceImpl implements AiModelService { private void validateModel(AiModelDTO dto) { if (dto == null) { - throw new RuntimeException("模型配置不能为空"); + throw new RuntimeException("妯″瀷閰嶇疆涓嶈兘涓虹┖"); } if ("custom".equals(normalizeProvider(dto.getProvider()))) { if (dto.getApiKey() == null || dto.getApiKey().isBlank()) { - throw new RuntimeException("本地模型必须填写API Key"); + throw new RuntimeException("鏈湴妯″瀷蹇呴』濉啓API Key"); } if (TYPE_ASR.equals(normalizeType(dto.getModelType()))) { Map mediaConfig = dto.getMediaConfig() == null ? Collections.emptyMap() : dto.getMediaConfig(); if (readConfigString(mediaConfig.get("speakerModel")) == null) { - throw new RuntimeException("本地ASR模型必须选择声纹模型"); + throw new RuntimeException("Local ASR model requires a speaker model"); } if (mediaConfig.get("svThreshold") == null) { - throw new RuntimeException("本地ASR模型必须填写声纹阈值"); + throw new RuntimeException("Local ASR model requires svThreshold"); } } } diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/AiModelServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/AiModelServiceImplTest.java new file mode 100644 index 0000000..2dd75d3 --- /dev/null +++ b/backend/src/test/java/com/imeeting/service/biz/impl/AiModelServiceImplTest.java @@ -0,0 +1,162 @@ +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.mapper.biz.AsrModelMapper; +import com.imeeting.mapper.biz.LlmModelMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.lang.reflect.Field; +import java.net.InetSocketAddress; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; + +class AiModelServiceImplTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private HttpServer server; + + @AfterEach + void tearDown() { + if (server != null) { + server.stop(0); + } + } + + @Test + void testLlmConnectivityShouldCallBaseUrlAndApiPathWithAuthorizationAndMessage() throws Exception { + AtomicReference requestPath = new AtomicReference<>(); + AtomicReference authorization = new AtomicReference<>(); + AtomicReference body = new AtomicReference<>(); + + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/gateway/v1/chat/completions", exchange -> { + captureRequest(exchange, requestPath, authorization, body); + writeJson(exchange, 200, "{\"choices\":[{\"message\":{\"content\":\"{\\\"status\\\":\\\"success\\\",\\\"message\\\":\\\"LLM connectivity test passed\\\"}\"}}]}"); + }); + server.start(); + + AiModelServiceImpl service = new AiModelServiceImpl( + objectMapper, + mock(AsrModelMapper.class), + mock(LlmModelMapper.class) + ); + + AiModelDTO dto = new AiModelDTO(); + dto.setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort() + "/gateway"); + dto.setApiPath("/v1/chat/completions"); + dto.setApiKey("test-key"); + dto.setModelCode("gpt-test"); + dto.setTestMessage("璇峰洖澶嶏細杩炴帴姝e父"); + + service.testLlmConnectivity(dto); + + assertEquals("/gateway/v1/chat/completions", requestPath.get()); + assertEquals("Bearer test-key", authorization.get()); + + JsonNode requestJson = objectMapper.readTree(body.get()); + assertEquals("gpt-test", requestJson.path("model").asText()); + assertEquals( + "{\"status\":\"success\",\"message\":\"LLM connectivity test passed\"}", + requestJson.path("messages").path(0).path("content").asText().lines() + .filter(line -> line.trim().startsWith("{")) + .findFirst() + .orElse("") + .trim() + ); + assertEquals("璇峰洖澶嶏細杩炴帴姝e父", requestJson.path("messages").path(1).path("content").asText()); + } + + @Test + void testLlmConnectivityShouldAvoidDuplicatingSharedPathPrefix() throws Exception { + AtomicReference requestPath = new AtomicReference<>(); + + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/v1/chat/completions", exchange -> { + captureRequest(exchange, requestPath, new AtomicReference<>(), new AtomicReference<>()); + writeJson(exchange, 200, "{\"choices\":[{\"message\":{\"content\":\"{\\\"status\\\":\\\"success\\\",\\\"message\\\":\\\"LLM connectivity test passed\\\"}\"}}]}"); + }); + server.start(); + + AiModelServiceImpl service = new AiModelServiceImpl( + objectMapper, + mock(AsrModelMapper.class), + mock(LlmModelMapper.class) + ); + + AiModelDTO dto = new AiModelDTO(); + dto.setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort() + "/v1"); + dto.setApiPath("/v1/chat/completions"); + dto.setModelCode("gpt-test"); + + service.testLlmConnectivity(dto); + + assertEquals("/v1/chat/completions", requestPath.get()); + } + + @Test + void testLlmConnectivityShouldAcceptPlainTextResponseWhenModelIgnoresJsonFormat() throws Exception { + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/v1/chat/completions", exchange -> { + captureRequest(exchange, new AtomicReference<>(), new AtomicReference<>(), new AtomicReference<>()); + writeJson(exchange, 200, "{\"choices\":[{\"message\":{\"content\":\"thought\\n* Input: \\\"请回复:LLM 连通性测试成功。\\\"\"}}]}"); + }); + server.start(); + + AiModelServiceImpl service = new AiModelServiceImpl( + objectMapper, + mock(AsrModelMapper.class), + mock(LlmModelMapper.class) + ); + + AiModelDTO dto = new AiModelDTO(); + dto.setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort()); + dto.setApiPath("/v1/chat/completions"); + dto.setModelCode("gpt-test"); + + service.testLlmConnectivity(dto); + } + + @Test + void testLlmConnectivityShouldUseHttp11ClientForCompatibility() throws Exception { + AiModelServiceImpl service = new AiModelServiceImpl( + objectMapper, + mock(AsrModelMapper.class), + mock(LlmModelMapper.class) + ); + + Field httpClientField = AiModelServiceImpl.class.getDeclaredField("httpClient"); + httpClientField.setAccessible(true); + HttpClient httpClient = (HttpClient) httpClientField.get(service); + + assertEquals(HttpClient.Version.HTTP_1_1, httpClient.version()); + } + + private void captureRequest(HttpExchange exchange, + AtomicReference requestPath, + AtomicReference authorization, + AtomicReference body) throws IOException { + requestPath.set(exchange.getRequestURI().getPath()); + authorization.set(exchange.getRequestHeaders().getFirst("Authorization")); + body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + } + + private void writeJson(HttpExchange exchange, int status, String responseBody) throws IOException { + byte[] bytes = responseBody.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } +} diff --git a/frontend/src/api/business/aimodel.ts b/frontend/src/api/business/aimodel.ts index aa7a50a..765bc60 100644 --- a/frontend/src/api/business/aimodel.ts +++ b/frontend/src/api/business/aimodel.ts @@ -37,6 +37,7 @@ export interface AiModelDTO { baseUrl?: string; apiPath?: string; apiKey?: string; + testMessage?: string; modelCode?: string; wsUrl?: string; temperature?: number; @@ -100,6 +101,22 @@ export const testLocalModelConnectivity = (data: { baseUrl: string; apiKey: stri ); }; +export const testLlmModelConnectivity = (data: { + provider?: string; + baseUrl: string; + apiPath?: string; + apiKey?: string; + modelCode: string; + temperature?: number; + topP?: number; + testMessage: string; +}) => { + return http.post<{ code: string; data: boolean; msg: string }>( + "/api/biz/aimodel/llm-connectivity-test", + data + ); +}; + export const getAiModelDefault = (type: 'ASR' | 'LLM') => { return http.get<{ code: string; data: AiModelVO; msg: string }>( "/api/biz/aimodel/default", diff --git a/frontend/src/pages/business/AiModels.tsx b/frontend/src/pages/business/AiModels.tsx index 5100030..b314225 100644 --- a/frontend/src/pages/business/AiModels.tsx +++ b/frontend/src/pages/business/AiModels.tsx @@ -19,6 +19,7 @@ import { getAiModelPage, getRemoteModelList, saveAiModel, + testLlmModelConnectivity, testLocalModelConnectivity, updateAiModel, } from "../../api/business/aimodel"; @@ -40,6 +41,8 @@ const PROVIDER_BASE_URL_MAP: Record = { groq: "https://api.groq.com/openai/v1", }; +const DEFAULT_LLM_TEST_MESSAGE = "请回复:LLM 连通性测试成功。"; + const AiModels: React.FC = () => { const { message } = App.useApp(); const [form] = Form.useForm(); @@ -277,6 +280,28 @@ const AiModels: React.FC = () => { }; const handleTestConnectivity = async () => { + if (activeType === "LLM") { + const values = await form.validateFields(["provider", "baseUrl", "apiPath", "modelCode"]); + const extraValues = form.getFieldsValue(["apiKey", "temperature", "topP"]); + setConnectivityLoading(true); + try { + await testLlmModelConnectivity({ + provider: values.provider, + baseUrl: values.baseUrl, + apiPath: values.apiPath, + apiKey: extraValues.apiKey, + modelCode: values.modelCode, + temperature: extraValues.temperature, + topP: extraValues.topP, + testMessage: DEFAULT_LLM_TEST_MESSAGE, + }); + message.success("LLM 连通性测试成功"); + } finally { + setConnectivityLoading(false); + } + return; + } + const values = await form.validateFields(["provider", "baseUrl", "apiKey"]); if (String(values.provider || "").toLowerCase() !== "custom") { message.warning("仅本地模型支持连通性测试"); @@ -486,7 +511,7 @@ const AiModels: React.FC = () => { - {isLocalProvider && ( + {(activeType === "LLM" || isLocalProvider) && (