Browse Source

重构配置页面,对标cc-switch三要素设计,新增动态模型列表获取

dev-mcp
wanghanlin 3 weeks ago
parent
commit
d95c921227
  1. 1
      CLAUDE.md
  2. 38
      src/main/java/com/wok/supportbot/controller/AiModelConfigController.java
  3. 170
      src/main/java/com/wok/supportbot/service/ModelListFetcher.java
  4. 727
      src/main/resources/static/components/ModelConfigManager.js
  5. 10
      src/main/resources/static/js/api.js

1
CLAUDE.md

@ -77,6 +77,7 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
- **EmbeddingModel 运行时切换**: 通过 `EmbeddingModelFactory` + `DynamicEmbeddingModel` 代理,按 DB 活跃配置动态创建/缓存 EmbeddingModel,`PgVectorStoreConfig` 和 `InMemoryVectorStoreConfig` 注入 `DynamicEmbeddingModel`,向量化模型配置变更后无需重启即可生效
- **多提供商支持**: DashScope(通义千问)+ OpenAI 兼容提供商(DeepSeek / 豆包 / Kimi / 智谱 / OpenAI),ChatModel 和 EmbeddingModel 均通过对应 API 手动构建
- **缓存刷新**: 配置增删改激活时 Controller 自动调用 `ChatModelFactory.clearCache()` + `EmbeddingModelFactory.clearCache()` + `AssistantApp.clearCache()`
- **动态模型列表获取**: `ModelListFetcher` 通过调用各提供商的 `/v1/models` 兼容端点(DashScope 用 `/compatible-mode/v1/models`),动态获取可用模型列表。前端填入 API Key + API 地址后,点击「获取模型」即可自动填充模型名称下拉列表(`<datalist>` 支持搜索选择 + 自定义输入)
### 依赖版本
- Spring AI BOM: `1.0.1`,统一管理所有 `org.springframework.ai` 依赖版本

38
src/main/java/com/wok/supportbot/controller/AiModelConfigController.java

@ -40,6 +40,9 @@ public class AiModelConfigController {
@Autowired
private ModelHealthService modelHealthService;
@Autowired
private com.wok.supportbot.service.ModelListFetcher modelListFetcher;
// ==================== 分页列表 ====================
/**
@ -500,6 +503,41 @@ public class AiModelConfigController {
}
}
// ==================== 获取模型列表动态发现 ====================
/**
* 通过提供商 API 获取可用模型列表
* 前端填入 API Key + API 地址后可调用此接口自动发现可用模型减少手动输入
*
* @param body 请求体{ baseUrl?, apiKey, provider? }
* @return 模型列表 [{ id, owned_by }]
*/
@PostMapping("/model-config/fetch-models")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> fetchModels(@RequestBody Map<String, String> body) {
try {
String apiKey = body.get("apiKey");
if (apiKey == null || apiKey.trim().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"success", false, "message", "请先填写 API Key"));
}
String baseUrl = body.getOrDefault("baseUrl", "");
String provider = body.getOrDefault("provider", "");
var models = modelListFetcher.fetchModels(baseUrl, apiKey, provider);
return ResponseEntity.ok(Map.of(
"success", true,
"data", models
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "获取模型列表失败:" + e.getMessage()
));
}
}
// ==================== 缓存刷新 ====================
/**

170
src/main/java/com/wok/supportbot/service/ModelListFetcher.java

@ -0,0 +1,170 @@
package com.wok.supportbot.service;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* 模型列表获取服务
* 通过调用各 AI 提供商的 /v1/models 兼容端点动态获取可用模型列表
* 对标 cc-switch 理念填入 API Key + 地址即可自动发现模型
*/
@Service
@Slf4j
public class ModelListFetcher {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/**
* 各提供商默认的 API 基础地址 ChatModelFactory.DEFAULT_BASE_URLS 对应
*/
private static final Map<String, String> DEFAULT_BASE_URLS = Map.of(
"dashscope", "https://dashscope.aliyuncs.com/compatible-mode",
"deepseek", "https://api.deepseek.com",
"moonshot", "https://api.moonshot.cn/v1",
"volcengine", "https://ark.cn-beijing.volces.com/api/v3",
"zhipu", "https://open.bigmodel.cn/api/paas/v4",
"openai", "https://api.openai.com"
);
/**
* 获取模型列表
*
* @param baseUrl API 基础地址可为空使用提供商默认值
* @param apiKey API Key
* @param provider 提供商标识可为空自动推断
* @return 模型列表 [{ id, owned_by }]
*/
public List<ModelInfo> fetchModels(String baseUrl, String apiKey, String provider) {
// 解析实际 baseUrl
String actualBaseUrl = resolveBaseUrl(baseUrl, provider);
// 拼接 /v1/models 端点
String modelsUrl = resolveModelsUrl(actualBaseUrl, provider);
log.info("获取模型列表: provider={}, url={}", provider, modelsUrl);
try {
RestClient restClient = RestClient.builder()
.defaultHeader("Authorization", "Bearer " + apiKey)
.defaultHeader("Content-Type", "application/json")
.build();
String responseJson = restClient.get()
.uri(modelsUrl)
.retrieve()
.body(String.class);
// 解析 OpenAI 标准格式 { data: [{ id, object, owned_by }] }
ModelsResponse response = OBJECT_MAPPER.readValue(responseJson, ModelsResponse.class);
if (response.getData() == null) {
return Collections.emptyList();
}
// id 排序方便前端展示
response.getData().sort((a, b) -> {
if (a.getId() == null) return 1;
if (b.getId() == null) return -1;
return a.getId().compareToIgnoreCase(b.getId());
});
log.info("获取模型列表成功: provider={}, count={}", provider, response.getData().size());
return response.getData();
} catch (Exception e) {
log.warn("获取模型列表失败: provider={}, url={}, error={}", provider, modelsUrl, e.getMessage());
throw new RuntimeException("获取模型列表失败: " + e.getMessage(), e);
}
}
/**
* 解析 API 基础地址
* 优先级用户传入的 baseUrl > 提供商默认值
*/
private String resolveBaseUrl(String baseUrl, String provider) {
if (baseUrl != null && !baseUrl.isBlank()) {
return baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
}
if (provider != null && DEFAULT_BASE_URLS.containsKey(provider.toLowerCase())) {
return DEFAULT_BASE_URLS.get(provider.toLowerCase());
}
throw new RuntimeException("请填写 API 地址或选择提供商");
}
/**
* 拼接 models 端点 URL
* 各厂商路径差异
* - dashscope: /compatible-mode/v1/models
* - openai/deepseek: baseUrl + /v1/models
* - moonshot/volcengine/zhipu: baseUrl 已含版本段/v1/api/v3/api/paas/v4直接 + /models
*/
private String resolveModelsUrl(String baseUrl, String provider) {
String p = provider != null ? provider.toLowerCase() : "";
switch (p) {
case "dashscope":
// DashScope 兼容模式端点
if (baseUrl.contains("/compatible-mode")) {
return baseUrl + "/v1/models";
}
return baseUrl + "/compatible-mode/v1/models";
case "moonshot":
// moonshot baseUrl 已含 /v1
return appendPath(baseUrl, "/models");
case "volcengine":
// volcengine baseUrl 已含 /api/v3
return appendPath(baseUrl, "/models");
case "zhipu":
// zhipu baseUrl 已含 /api/paas/v4
return appendPath(baseUrl, "/models");
default:
// openai/deepseek/other: 标准 /v1/models
return appendPath(baseUrl, "/v1/models");
}
}
/**
* 拼接路径避免重复斜杠
*/
private String appendPath(String baseUrl, String path) {
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
}
return baseUrl + path;
}
// ==================== 响应数据结构 ====================
/**
* OpenAI /v1/models 响应格式
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ModelsResponse {
private List<ModelInfo> data;
}
/**
* 单个模型信息
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ModelInfo {
private String id;
private String object;
@JsonProperty("owned_by")
private String ownedBy;
}
}

727
src/main/resources/static/components/ModelConfigManager.js
File diff suppressed because it is too large
View File

10
src/main/resources/static/js/api.js

@ -640,6 +640,16 @@ export function importModelConfigs(configs, onConflict = 'skip') {
return postJSON('/model-config/import', { configs, onConflict })
}
/**
* 动态获取提供商的模型列表
* @param {string} baseUrl - API 基础地址
* @param {string} apiKey - API Key
* @param {string} provider - 提供商标识可选
*/
export function fetchModels(baseUrl, apiKey, provider = '') {
return postJSON('/model-config/fetch-models', { baseUrl, apiKey, provider })
}
/**
* Truncate a conversation from the given user turn onward.
*/

Loading…
Cancel
Save