发布 v1.10 #5
@@ -1,21 +1,33 @@
|
||||
package tech.easyflow.admin.controller.ai;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.ai.dto.RemoteModelImportRequest;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
import tech.easyflow.ai.entity.ModelProvider;
|
||||
import tech.easyflow.ai.service.ModelProviderService;
|
||||
import tech.easyflow.ai.service.ModelService;
|
||||
import tech.easyflow.ai.service.discovery.RemoteModelDiscoveryService;
|
||||
import tech.easyflow.ai.service.discovery.RemoteModelImportResult;
|
||||
import tech.easyflow.ai.service.discovery.RemoteModelImportService;
|
||||
import tech.easyflow.ai.service.discovery.RemoteModelListResult;
|
||||
import tech.easyflow.common.annotation.UsePermission;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* 控制层。
|
||||
@@ -28,12 +40,33 @@ import java.io.Serializable;
|
||||
@UsePermission(moduleName = "/api/v1/model")
|
||||
public class ModelProviderController extends BaseCurdController<ModelProviderService, ModelProvider> {
|
||||
private final ModelService modelService;
|
||||
private final RemoteModelDiscoveryService remoteModelDiscoveryService;
|
||||
private final RemoteModelImportService remoteModelImportService;
|
||||
|
||||
public ModelProviderController(ModelProviderService service, ModelService modelService) {
|
||||
/**
|
||||
* 创建模型服务商控制器。
|
||||
*
|
||||
* @param service 模型服务商服务
|
||||
* @param modelService 模型服务
|
||||
* @param remoteModelDiscoveryService 远端模型发现服务
|
||||
* @param remoteModelImportService 远端模型一键添加服务
|
||||
*/
|
||||
public ModelProviderController(ModelProviderService service,
|
||||
ModelService modelService,
|
||||
RemoteModelDiscoveryService remoteModelDiscoveryService,
|
||||
RemoteModelImportService remoteModelImportService) {
|
||||
super(service);
|
||||
this.modelService = modelService;
|
||||
this.remoteModelDiscoveryService = remoteModelDiscoveryService;
|
||||
this.remoteModelImportService = remoteModelImportService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除没有子模型的服务商。
|
||||
*
|
||||
* @param id 服务商 ID
|
||||
* @return 删除结果
|
||||
*/
|
||||
@Override
|
||||
@PostMapping("remove")
|
||||
@Transactional
|
||||
@@ -45,4 +78,35 @@ public class ModelProviderController extends BaseCurdController<ModelProviderSer
|
||||
}
|
||||
return Result.ok(service.removeById(id));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动获取当前服务商的远端模型列表。
|
||||
*
|
||||
* @param providerId 服务商 ID
|
||||
* @return 已补全能力和本地添加状态的远端模型列表
|
||||
*/
|
||||
@GetMapping("{providerId}/remoteModels")
|
||||
@SaCheckPermission("/api/v1/model/save")
|
||||
public Result<RemoteModelListResult> remoteModels(@PathVariable BigInteger providerId) {
|
||||
return Result.ok(remoteModelDiscoveryService.discover(providerId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等添加单个远端模型。
|
||||
*
|
||||
* @param providerId 服务商 ID
|
||||
* @param request 一键添加请求
|
||||
* @return 创建或已存在结果
|
||||
*/
|
||||
@PostMapping("{providerId}/remoteModels/import")
|
||||
@SaCheckPermission("/api/v1/model/save")
|
||||
public Result<RemoteModelImportResult> importRemoteModel(
|
||||
@PathVariable BigInteger providerId,
|
||||
@RequestBody RemoteModelImportRequest request) {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
Model model = new Model();
|
||||
commonFiled(model, account.getId(), account.getTenantId(), account.getDeptId());
|
||||
String modelId = request == null ? null : request.getModelId();
|
||||
return Result.ok(remoteModelImportService.importModel(providerId, modelId, model));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package tech.easyflow.ai.dto;
|
||||
|
||||
/**
|
||||
* 远端模型一键添加请求。
|
||||
*/
|
||||
public class RemoteModelImportRequest {
|
||||
|
||||
/** 待添加的远端原始模型 ID。 */
|
||||
private String modelId;
|
||||
|
||||
/**
|
||||
* 获取待添加模型 ID。
|
||||
*
|
||||
* @return 远端原始模型 ID
|
||||
*/
|
||||
public String getModelId() {
|
||||
return modelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置待添加模型 ID。
|
||||
*
|
||||
* @param modelId 远端原始模型 ID
|
||||
*/
|
||||
public void setModelId(String modelId) {
|
||||
this.modelId = modelId;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package tech.easyflow.ai.mapper;
|
||||
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import tech.easyflow.ai.entity.ModelProvider;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* 映射层。
|
||||
*
|
||||
@@ -11,4 +15,12 @@ import tech.easyflow.ai.entity.ModelProvider;
|
||||
*/
|
||||
public interface ModelProviderMapper extends BaseMapper<ModelProvider> {
|
||||
|
||||
/**
|
||||
* 锁定服务商记录,用于串行化同一服务商下的幂等模型导入。
|
||||
*
|
||||
* @param id 服务商 ID
|
||||
* @return 已锁定的服务商 ID,不存在时返回 null
|
||||
*/
|
||||
@Select("SELECT id FROM tb_model_provider WHERE id = #{id} FOR UPDATE")
|
||||
BigInteger lockById(@Param("id") BigInteger id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
package tech.easyflow.ai.service.capability;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 从静态 {@code llm.json} 加载模型能力,并建立常量时间查询索引。
|
||||
*/
|
||||
@Component
|
||||
public class ModelCapabilityCatalog {
|
||||
|
||||
/** classpath 模型能力库资源。 */
|
||||
private static final String CATALOG_RESOURCE = "llm.json";
|
||||
|
||||
/** 按规范化完整模型 ID 建立的目录索引。 */
|
||||
private final Map<String, ModelCatalogMetadata> metadataById;
|
||||
/** 仅在模型短 ID 唯一时建立的目录别名索引。 */
|
||||
private final Map<String, ModelCatalogMetadata> metadataByAlias;
|
||||
|
||||
/**
|
||||
* 加载并索引静态模型能力库。
|
||||
*
|
||||
* @param objectMapper JSON 解析器
|
||||
* @throws IllegalStateException 静态资源缺失或格式非法时抛出
|
||||
*/
|
||||
public ModelCapabilityCatalog(ObjectMapper objectMapper) {
|
||||
Map<String, ModelCatalogMetadata> fullIdIndex = new HashMap<>();
|
||||
Map<String, ModelCatalogMetadata> aliasCandidates = new HashMap<>();
|
||||
Set<String> ambiguousAliases = new HashSet<>();
|
||||
loadCatalog(objectMapper, fullIdIndex, aliasCandidates, ambiguousAliases);
|
||||
ambiguousAliases.forEach(aliasCandidates::remove);
|
||||
this.metadataById = Map.copyOf(fullIdIndex);
|
||||
this.metadataByAlias = Map.copyOf(aliasCandidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询模型能力。
|
||||
*
|
||||
* @param providerType EasyFlow 供应商类型
|
||||
* @param modelId 用户配置的模型 ID
|
||||
* @return 命中的模型能力
|
||||
*/
|
||||
public Optional<ModelCapabilityResolution> find(String providerType, String modelId) {
|
||||
return findMetadata(providerType, modelId).map(ModelCatalogMetadata::getCapability);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询模型目录元数据。
|
||||
*
|
||||
* @param providerType EasyFlow 供应商类型
|
||||
* @param modelId 用户配置的模型 ID
|
||||
* @return 命中的模型目录元数据
|
||||
*/
|
||||
public Optional<ModelCatalogMetadata> findMetadata(String providerType, String modelId) {
|
||||
String normalizedId = normalize(modelId);
|
||||
if (normalizedId.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
ModelCatalogMetadata direct = metadataById.get(normalizedId);
|
||||
if (direct != null) {
|
||||
return Optional.of(direct);
|
||||
}
|
||||
|
||||
String providerPrefix = providerPrefix(providerType);
|
||||
if (!providerPrefix.isEmpty() && !normalizedId.contains("/")) {
|
||||
direct = metadataById.get(providerPrefix + "/" + normalizedId);
|
||||
if (direct != null) {
|
||||
return Optional.of(direct);
|
||||
}
|
||||
}
|
||||
|
||||
String alias = shortId(normalizedId);
|
||||
return Optional.ofNullable(metadataByAlias.get(alias));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取静态 JSON 并建立完整 ID 与唯一短 ID 索引。
|
||||
*
|
||||
* @param objectMapper JSON 解析器
|
||||
* @param fullIdIndex 完整 ID 索引
|
||||
* @param aliasCandidates 短 ID 候选索引
|
||||
* @param ambiguousAliases 存在冲突的短 ID
|
||||
*/
|
||||
private void loadCatalog(ObjectMapper objectMapper,
|
||||
Map<String, ModelCatalogMetadata> fullIdIndex,
|
||||
Map<String, ModelCatalogMetadata> aliasCandidates,
|
||||
Set<String> ambiguousAliases) {
|
||||
ClassPathResource resource = new ClassPathResource(CATALOG_RESOURCE);
|
||||
try (InputStream inputStream = resource.getInputStream()) {
|
||||
JsonNode root = objectMapper.readTree(inputStream);
|
||||
if (root == null || !root.isObject()) {
|
||||
throw new IllegalStateException("模型能力库根节点必须是 JSON 对象");
|
||||
}
|
||||
Iterator<Map.Entry<String, JsonNode>> fields = root.fields();
|
||||
while (fields.hasNext()) {
|
||||
Map.Entry<String, JsonNode> field = fields.next();
|
||||
String normalizedId = normalize(field.getKey());
|
||||
if (normalizedId.isEmpty() || !field.getValue().isObject()) {
|
||||
continue;
|
||||
}
|
||||
ModelCatalogMetadata metadata = toMetadata(normalizedId, field.getValue());
|
||||
fullIdIndex.put(normalizedId, metadata);
|
||||
registerAlias(shortId(normalizedId), metadata, aliasCandidates, ambiguousAliases);
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("无法加载模型能力库 " + CATALOG_RESOURCE, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将目录条目转换为展示元数据与能力信息。
|
||||
*
|
||||
* @param normalizedId 规范化模型 ID
|
||||
* @param node 模型目录条目
|
||||
* @return 模型目录元数据
|
||||
*/
|
||||
private ModelCatalogMetadata toMetadata(String normalizedId, JsonNode node) {
|
||||
return new ModelCatalogMetadata(
|
||||
normalizedId,
|
||||
textValue(node, "name"),
|
||||
textValue(node, "family"),
|
||||
modalities(node, "input"),
|
||||
modalities(node, "output"),
|
||||
toCapability(normalizedId, node));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将模型目录条目转换为 EasyFlow 能力结果。
|
||||
*
|
||||
* @param normalizedId 规范化模型 ID
|
||||
* @param node 模型目录条目
|
||||
* @return EasyFlow 能力结果
|
||||
*/
|
||||
private ModelCapabilityResolution toCapability(String normalizedId, JsonNode node) {
|
||||
String modelType = resolveModelType(normalizedId);
|
||||
if (!Model.MODEL_TYPES[0].equals(modelType)) {
|
||||
return new ModelCapabilityResolution(
|
||||
modelType,
|
||||
Boolean.FALSE,
|
||||
Boolean.FALSE,
|
||||
Boolean.FALSE,
|
||||
ModelCapabilitySource.CATALOG);
|
||||
}
|
||||
return new ModelCapabilityResolution(
|
||||
modelType,
|
||||
hasInputModality(node, "image"),
|
||||
booleanValue(node, "reasoning"),
|
||||
booleanValue(node, "tool_call"),
|
||||
ModelCapabilitySource.CATALOG);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模型目录 ID 识别互斥模型类型。
|
||||
*
|
||||
* @param normalizedId 规范化模型 ID
|
||||
* @return EasyFlow 模型类型
|
||||
*/
|
||||
private String resolveModelType(String normalizedId) {
|
||||
if (ModelCapabilityNameRules.isRerankModel(normalizedId)) {
|
||||
return Model.MODEL_TYPES[2];
|
||||
}
|
||||
if (ModelCapabilityNameRules.isEmbeddingModel(normalizedId)) {
|
||||
return Model.MODEL_TYPES[1];
|
||||
}
|
||||
return Model.MODEL_TYPES[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取布尔字段,缺失时按 false 处理。
|
||||
*
|
||||
* @param node 模型条目
|
||||
* @param fieldName 字段名
|
||||
* @return 布尔字段值
|
||||
*/
|
||||
private boolean booleanValue(JsonNode node, String fieldName) {
|
||||
JsonNode value = node.get(fieldName);
|
||||
return value != null && value.asBoolean(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断模型输入模态是否包含指定类型。
|
||||
*
|
||||
* @param node 模型条目
|
||||
* @param modality 输入模态
|
||||
* @return 包含指定模态返回 true
|
||||
*/
|
||||
private boolean hasInputModality(JsonNode node, String modality) {
|
||||
JsonNode inputs = node.path("modalities").path("input");
|
||||
if (!inputs.isArray()) {
|
||||
return false;
|
||||
}
|
||||
for (JsonNode input : inputs) {
|
||||
if (modality.equalsIgnoreCase(input.asText())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取非空文本字段。
|
||||
*
|
||||
* @param node 模型目录条目
|
||||
* @param fieldName 字段名
|
||||
* @return 去除首尾空白的文本,缺失时返回 null
|
||||
*/
|
||||
private String textValue(JsonNode node, String fieldName) {
|
||||
JsonNode value = node.get(fieldName);
|
||||
if (value == null || !value.isTextual() || value.asText().isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return value.asText().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取并规范化模型模态集合。
|
||||
*
|
||||
* @param node 模型目录条目
|
||||
* @param direction input 或 output
|
||||
* @return 小写模态集合
|
||||
*/
|
||||
private Set<String> modalities(JsonNode node, String direction) {
|
||||
JsonNode values = node.path("modalities").path(direction);
|
||||
if (!values.isArray()) {
|
||||
return Set.of();
|
||||
}
|
||||
Set<String> modalities = new HashSet<>();
|
||||
values.forEach(value -> {
|
||||
if (value.isTextual() && !value.asText().isBlank()) {
|
||||
modalities.add(value.asText().trim().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
});
|
||||
return Set.copyOf(modalities);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册无冲突的模型短 ID。
|
||||
*
|
||||
* @param alias 模型短 ID
|
||||
* @param capability 模型能力
|
||||
* @param aliasCandidates 短 ID 候选索引
|
||||
* @param ambiguousAliases 冲突短 ID 集合
|
||||
*/
|
||||
private void registerAlias(String alias,
|
||||
ModelCatalogMetadata metadata,
|
||||
Map<String, ModelCatalogMetadata> aliasCandidates,
|
||||
Set<String> ambiguousAliases) {
|
||||
if (alias.isEmpty() || ambiguousAliases.contains(alias)) {
|
||||
return;
|
||||
}
|
||||
ModelCatalogMetadata previous = aliasCandidates.putIfAbsent(alias, metadata);
|
||||
if (previous != null) {
|
||||
aliasCandidates.remove(alias);
|
||||
ambiguousAliases.add(alias);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 EasyFlow 供应商类型映射为 models.dev 前缀。
|
||||
*
|
||||
* @param providerType EasyFlow 供应商类型
|
||||
* @return models.dev 供应商前缀,未知时返回空字符串
|
||||
*/
|
||||
private String providerPrefix(String providerType) {
|
||||
return switch (normalize(providerType)) {
|
||||
case "dashscope", "bailian", "aliyun" -> "alibaba";
|
||||
case "gemini" -> "google";
|
||||
case "kimi" -> "moonshotai";
|
||||
case "zhipu" -> "zhipuai";
|
||||
case "minimax" -> "minimax";
|
||||
case "azure-openai", "azure_openai" -> "openai";
|
||||
case "openai", "anthropic", "deepseek", "google", "xai", "mistral", "cohere" ->
|
||||
normalize(providerType);
|
||||
default -> "";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化模型或供应商标识。
|
||||
*
|
||||
* @param value 原始值
|
||||
* @return 小写且去除首尾空白的标识
|
||||
*/
|
||||
private String normalize(String value) {
|
||||
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取最后一个路径段作为模型短 ID。
|
||||
*
|
||||
* @param normalizedId 规范化模型 ID
|
||||
* @return 模型短 ID
|
||||
*/
|
||||
private String shortId(String normalizedId) {
|
||||
int separator = normalizedId.lastIndexOf('/');
|
||||
return separator < 0 ? normalizedId : normalizedId.substring(separator + 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package tech.easyflow.ai.service.capability;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 未命中模型目录时使用的保守命名规则。
|
||||
*/
|
||||
final class ModelCapabilityNameRules {
|
||||
|
||||
private static final Pattern RERANK_PATTERN = Pattern.compile(
|
||||
"(^|[/_.:-])rerank(?:er)?($|[/_.:-])");
|
||||
private static final Pattern EMBEDDING_PATTERN = Pattern.compile(
|
||||
"(^|[/_.:-])(embedding|embed)($|[/_.:-])|(^|/)bge-m3($|[/_.:-])"
|
||||
+ "|(^|/)(e5|gte)(-|$)");
|
||||
private static final Pattern VISION_PATTERN = Pattern.compile(
|
||||
"(^|[/_.:-])(vl|vision|visual|omni)($|[/_.:-])");
|
||||
private static final Pattern REASONING_PATTERN = Pattern.compile(
|
||||
"reasoning|reasoner|deepseek-r1|(^|[/_.:-])r1($|[/_.:-])"
|
||||
+ "|(^|[/_.:-])qwq($|[/_.:-])|(^|[/_.:-])o[134]($|[/_.:-])");
|
||||
|
||||
/** 禁止实例化规则工具类。 */
|
||||
private ModelCapabilityNameRules() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断模型 ID 是否明确指向重排模型。
|
||||
*
|
||||
* @param modelId 模型 ID
|
||||
* @return 明确为重排模型返回 true
|
||||
*/
|
||||
static boolean isRerankModel(String modelId) {
|
||||
return RERANK_PATTERN.matcher(normalize(modelId)).find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断模型 ID 是否明确指向嵌入模型。
|
||||
*
|
||||
* @param modelId 模型 ID
|
||||
* @return 明确为嵌入模型返回 true
|
||||
*/
|
||||
static boolean isEmbeddingModel(String modelId) {
|
||||
return EMBEDDING_PATTERN.matcher(normalize(modelId)).find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断模型 ID 是否明确指向视觉模型。
|
||||
*
|
||||
* @param modelId 模型 ID
|
||||
* @return 明确支持视觉输入返回 true
|
||||
*/
|
||||
static boolean supportsVision(String modelId) {
|
||||
return VISION_PATTERN.matcher(normalize(modelId)).find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断模型 ID 是否明确指向推理模型。
|
||||
*
|
||||
* @param modelId 模型 ID
|
||||
* @return 明确支持推理返回 true
|
||||
*/
|
||||
static boolean supportsReasoning(String modelId) {
|
||||
return REASONING_PATTERN.matcher(normalize(modelId)).find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化待匹配模型 ID。
|
||||
*
|
||||
* @param modelId 原始模型 ID
|
||||
* @return 规范化模型 ID
|
||||
*/
|
||||
private static String normalize(String modelId) {
|
||||
return modelId == null ? "" : modelId.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package tech.easyflow.ai.service.capability;
|
||||
|
||||
/**
|
||||
* 模型类型与对话能力识别结果。
|
||||
*/
|
||||
public final class ModelCapabilityResolution {
|
||||
|
||||
/** 模型类型。 */
|
||||
private final String modelType;
|
||||
/** 是否支持视觉输入,空值表示未知。 */
|
||||
private final Boolean supportImage;
|
||||
/** 是否支持推理,空值表示未知。 */
|
||||
private final Boolean supportThinking;
|
||||
/** 是否支持工具调用,空值表示未知。 */
|
||||
private final Boolean supportTool;
|
||||
/** 能力识别来源。 */
|
||||
private final ModelCapabilitySource source;
|
||||
|
||||
/**
|
||||
* 创建模型能力识别结果。
|
||||
*
|
||||
* @param modelType 模型类型
|
||||
* @param supportImage 是否支持视觉输入
|
||||
* @param supportThinking 是否支持推理
|
||||
* @param supportTool 是否支持工具调用
|
||||
* @param source 能力识别来源
|
||||
*/
|
||||
public ModelCapabilityResolution(String modelType,
|
||||
Boolean supportImage,
|
||||
Boolean supportThinking,
|
||||
Boolean supportTool,
|
||||
ModelCapabilitySource source) {
|
||||
this.modelType = modelType;
|
||||
this.supportImage = supportImage;
|
||||
this.supportThinking = supportThinking;
|
||||
this.supportTool = supportTool;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型类型。
|
||||
*
|
||||
* @return 模型类型
|
||||
*/
|
||||
public String getModelType() {
|
||||
return modelType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视觉输入能力。
|
||||
*
|
||||
* @return 是否支持视觉输入,空值表示未知
|
||||
*/
|
||||
public Boolean getSupportImage() {
|
||||
return supportImage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取推理能力。
|
||||
*
|
||||
* @return 是否支持推理,空值表示未知
|
||||
*/
|
||||
public Boolean getSupportThinking() {
|
||||
return supportThinking;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具调用能力。
|
||||
*
|
||||
* @return 是否支持工具调用,空值表示未知
|
||||
*/
|
||||
public Boolean getSupportTool() {
|
||||
return supportTool;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取能力识别来源。
|
||||
*
|
||||
* @return 能力识别来源
|
||||
*/
|
||||
public ModelCapabilitySource getSource() {
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否获得了模型库或命名规则证据。
|
||||
*
|
||||
* @return 已识别返回 true
|
||||
*/
|
||||
public boolean isDetected() {
|
||||
return source != ModelCapabilitySource.DEFAULT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package tech.easyflow.ai.service.capability;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
|
||||
/**
|
||||
* 统一解析静态目录和保守命名规则中的模型能力。
|
||||
*/
|
||||
@Component
|
||||
public class ModelCapabilityResolver {
|
||||
|
||||
/** 静态模型能力目录。 */
|
||||
private final ModelCapabilityCatalog catalog;
|
||||
|
||||
/**
|
||||
* 创建模型能力解析器。
|
||||
*
|
||||
* @param catalog 静态模型能力目录
|
||||
*/
|
||||
public ModelCapabilityResolver(ModelCapabilityCatalog catalog) {
|
||||
this.catalog = catalog;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模型类型和对话能力。
|
||||
*
|
||||
* @param providerType 供应商类型
|
||||
* @param modelId 模型 ID
|
||||
* @return 模型能力识别结果
|
||||
*/
|
||||
public ModelCapabilityResolution resolve(String providerType, String modelId) {
|
||||
return catalog.find(providerType, modelId).orElseGet(() -> resolveByName(modelId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 对未命中目录的模型执行保守命名推断。
|
||||
*
|
||||
* @param modelId 模型 ID
|
||||
* @return 模型能力识别结果
|
||||
*/
|
||||
private ModelCapabilityResolution resolveByName(String modelId) {
|
||||
if (ModelCapabilityNameRules.isRerankModel(modelId)) {
|
||||
return new ModelCapabilityResolution(
|
||||
Model.MODEL_TYPES[2], false, false, false, ModelCapabilitySource.RULE);
|
||||
}
|
||||
if (ModelCapabilityNameRules.isEmbeddingModel(modelId)) {
|
||||
return new ModelCapabilityResolution(
|
||||
Model.MODEL_TYPES[1], false, false, false, ModelCapabilitySource.RULE);
|
||||
}
|
||||
|
||||
boolean vision = ModelCapabilityNameRules.supportsVision(modelId);
|
||||
boolean reasoning = ModelCapabilityNameRules.supportsReasoning(modelId);
|
||||
if (vision || reasoning) {
|
||||
return new ModelCapabilityResolution(
|
||||
Model.MODEL_TYPES[0],
|
||||
vision ? Boolean.TRUE : null,
|
||||
reasoning ? Boolean.TRUE : null,
|
||||
null,
|
||||
ModelCapabilitySource.RULE);
|
||||
}
|
||||
return new ModelCapabilityResolution(
|
||||
Model.MODEL_TYPES[0], null, null, null, ModelCapabilitySource.DEFAULT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package tech.easyflow.ai.service.capability;
|
||||
|
||||
/**
|
||||
* 模型能力识别来源。
|
||||
*/
|
||||
public enum ModelCapabilitySource {
|
||||
/** 静态模型能力库精确命中。 */
|
||||
CATALOG,
|
||||
/** 根据稳定模型命名规则推断。 */
|
||||
RULE,
|
||||
/** 未识别模型使用的保守默认值。 */
|
||||
DEFAULT
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package tech.easyflow.ai.service.capability;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 静态模型目录中的展示元数据与能力信息。
|
||||
*/
|
||||
public final class ModelCatalogMetadata {
|
||||
|
||||
/** 规范化模型目录 ID。 */
|
||||
private final String modelId;
|
||||
/** 模型展示名称。 */
|
||||
private final String displayName;
|
||||
/** 模型家族。 */
|
||||
private final String family;
|
||||
/** 输入模态。 */
|
||||
private final Set<String> inputModalities;
|
||||
/** 输出模态。 */
|
||||
private final Set<String> outputModalities;
|
||||
/** EasyFlow 模型能力。 */
|
||||
private final ModelCapabilityResolution capability;
|
||||
|
||||
/**
|
||||
* 创建模型目录元数据。
|
||||
*
|
||||
* @param modelId 规范化模型目录 ID
|
||||
* @param displayName 模型展示名称
|
||||
* @param family 模型家族
|
||||
* @param inputModalities 输入模态
|
||||
* @param outputModalities 输出模态
|
||||
* @param capability EasyFlow 模型能力
|
||||
*/
|
||||
public ModelCatalogMetadata(String modelId,
|
||||
String displayName,
|
||||
String family,
|
||||
Set<String> inputModalities,
|
||||
Set<String> outputModalities,
|
||||
ModelCapabilityResolution capability) {
|
||||
this.modelId = modelId;
|
||||
this.displayName = displayName;
|
||||
this.family = family;
|
||||
this.inputModalities = Set.copyOf(inputModalities);
|
||||
this.outputModalities = Set.copyOf(outputModalities);
|
||||
this.capability = capability;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型展示名称。
|
||||
*
|
||||
* @return 模型展示名称
|
||||
*/
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型家族。
|
||||
*
|
||||
* @return 模型家族
|
||||
*/
|
||||
public String getFamily() {
|
||||
return family;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取输入模态。
|
||||
*
|
||||
* @return 不可变输入模态集合
|
||||
*/
|
||||
public Set<String> getInputModalities() {
|
||||
return inputModalities;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取输出模态。
|
||||
*
|
||||
* @return 不可变输出模态集合
|
||||
*/
|
||||
public Set<String> getOutputModalities() {
|
||||
return outputModalities;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 EasyFlow 模型能力。
|
||||
*
|
||||
* @return 模型能力
|
||||
*/
|
||||
public ModelCapabilityResolution getCapability() {
|
||||
return capability;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断目录条目是否为当前系统尚未接入的生成模型。
|
||||
*
|
||||
* @return 已知属于媒体生成模型时返回 true
|
||||
*/
|
||||
public boolean isUnsupportedGenerationModel() {
|
||||
if (outputModalities.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
boolean mediaOutput = outputModalities.contains("image")
|
||||
|| outputModalities.contains("video")
|
||||
|| outputModalities.contains("audio");
|
||||
if (mediaOutput && !outputModalities.contains("text")) {
|
||||
return true;
|
||||
}
|
||||
String normalizedFamily = family == null ? "" : family.toLowerCase();
|
||||
return mediaOutput && (containsGenerationKeyword(modelId)
|
||||
|| containsGenerationKeyword(normalizedFamily));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断模型标识是否明确属于媒体生成家族。
|
||||
*
|
||||
* @param value 规范化模型 ID 或家族
|
||||
* @return 命中明确生成模型关键词返回 true
|
||||
*/
|
||||
private boolean containsGenerationKeyword(String value) {
|
||||
return value.contains("gpt-image")
|
||||
|| value.contains("dall-e")
|
||||
|| value.contains("stable-diffusion")
|
||||
|| value.contains("text-to-image")
|
||||
|| value.contains("text-to-video")
|
||||
|| value.contains("image-generation")
|
||||
|| value.contains("video-generation");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package tech.easyflow.ai.service.discovery;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.ai.entity.ModelProvider;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 阿里百炼可部署基础模型目录适配器。
|
||||
*/
|
||||
@Component
|
||||
public class AliyunRemoteModelAdapter implements RemoteModelProviderAdapter {
|
||||
|
||||
/** 单页最大模型数量。 */
|
||||
private static final int PAGE_SIZE = 100;
|
||||
/** 最大安全分页数。 */
|
||||
private static final int MAX_PAGES = 10;
|
||||
/** 当前未接入的百炼媒体生成及内部算法模型。 */
|
||||
private static final Set<String> UNSUPPORTED_MODEL_IDS = Set.of(
|
||||
"animate-anyone",
|
||||
"animate-anyone-detect",
|
||||
"emo",
|
||||
"emo-detect",
|
||||
"mock-algo-v1",
|
||||
"wanx-v1-0521");
|
||||
|
||||
/**
|
||||
* 分页获取阿里百炼基础模型 ID。
|
||||
*
|
||||
* @param provider 已保存的服务商配置
|
||||
* @param httpClient 受控 HTTP 客户端
|
||||
* @return 原始模型 ID 列表
|
||||
* @throws BusinessException 响应结构不兼容时抛出
|
||||
*/
|
||||
@Override
|
||||
public List<String> fetchModelIds(ModelProvider provider, RemoteModelHttpClient httpClient) {
|
||||
List<String> modelIds = new ArrayList<>();
|
||||
for (int page = 1; page <= MAX_PAGES; page++) {
|
||||
JsonNode root = httpClient.getJson(provider, "/api/v1/deployments/models",
|
||||
queryParameters(page));
|
||||
JsonNode models = modelsNode(root);
|
||||
if (models == null || !models.isArray()) {
|
||||
throw new BusinessException(502, 50233, "阿里百炼模型列表响应格式不兼容");
|
||||
}
|
||||
models.forEach(item -> {
|
||||
JsonNode value = item.get("model_name");
|
||||
if (value != null && value.isTextual()) {
|
||||
String modelId = value.asText();
|
||||
if (!modelId.isBlank() && !UNSUPPORTED_MODEL_IDS.contains(modelId)) {
|
||||
modelIds.add(modelId);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!hasNextPage(root, models.size(), page)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return modelIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建单页受控查询参数。
|
||||
*
|
||||
* @param page 页码
|
||||
* @return 查询参数
|
||||
*/
|
||||
private Map<String, String> queryParameters(int page) {
|
||||
Map<String, String> parameters = new LinkedHashMap<>();
|
||||
parameters.put("model_source", "base");
|
||||
parameters.put("page_no", String.valueOf(page));
|
||||
parameters.put("page_size", String.valueOf(PAGE_SIZE));
|
||||
// v1.0 才会返回当前完整的可部署基础模型目录;省略时可能退回旧版模型集合。
|
||||
parameters.put("version", "v1.0");
|
||||
return parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容官方 output 节点及历史根节点、data 节点中的 models 数组。
|
||||
*
|
||||
* @param root JSON 根节点
|
||||
* @return models 节点
|
||||
*/
|
||||
private JsonNode modelsNode(JsonNode root) {
|
||||
return responsePayload(root).get("models");
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据明确分页字段或当前页数量判断是否继续。
|
||||
*
|
||||
* @param root JSON 根节点
|
||||
* @param currentSize 当前页数量
|
||||
* @param page 当前页码
|
||||
* @return 需要继续分页返回 true
|
||||
*/
|
||||
private boolean hasNextPage(JsonNode root, int currentSize, int page) {
|
||||
JsonNode payload = responsePayload(root);
|
||||
boolean hasMore = payload.path("has_more").asBoolean(false);
|
||||
long total = payload.path("total").asLong(payload.path("total_count").asLong(-1));
|
||||
int responsePage = payload.path("page_no").asInt(page);
|
||||
int responsePageSize = payload.path("page_size").asInt(PAGE_SIZE);
|
||||
if (hasMore) {
|
||||
return true;
|
||||
}
|
||||
if (total >= 0) {
|
||||
return (long) responsePage * responsePageSize < total;
|
||||
}
|
||||
return currentSize == PAGE_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取承载模型列表与分页字段的响应节点。
|
||||
*
|
||||
* @param root JSON 根节点
|
||||
* @return 官方 output、嵌套 output、data 或根节点
|
||||
*/
|
||||
private JsonNode responsePayload(JsonNode root) {
|
||||
JsonNode output = root.path("output");
|
||||
if (output.isObject()) {
|
||||
return output;
|
||||
}
|
||||
JsonNode data = root.path("data");
|
||||
JsonNode nestedOutput = data.path("output");
|
||||
if (nestedOutput.isObject()) {
|
||||
return nestedOutput;
|
||||
}
|
||||
return data.isObject() ? data : root;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package tech.easyflow.ai.service.discovery;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.ai.entity.ModelProvider;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Ollama 原生 {@code /api/tags} 模型目录适配器。
|
||||
*/
|
||||
@Component
|
||||
public class OllamaRemoteModelAdapter implements RemoteModelProviderAdapter {
|
||||
|
||||
/**
|
||||
* 获取 Ollama 本地模型 ID。
|
||||
*
|
||||
* @param provider 已保存的服务商配置
|
||||
* @param httpClient 受控 HTTP 客户端
|
||||
* @return 原始模型 ID 列表
|
||||
* @throws BusinessException 响应结构不兼容时抛出
|
||||
*/
|
||||
@Override
|
||||
public List<String> fetchModelIds(ModelProvider provider, RemoteModelHttpClient httpClient) {
|
||||
JsonNode root = httpClient.getJson(provider, "/api/tags", Map.of());
|
||||
JsonNode models = root.get("models");
|
||||
if (models == null || !models.isArray()) {
|
||||
throw new BusinessException(502, 50232, "Ollama 模型列表响应格式不兼容");
|
||||
}
|
||||
List<String> modelIds = new ArrayList<>();
|
||||
models.forEach(item -> {
|
||||
JsonNode value = item.get("name");
|
||||
if (value == null || !value.isTextual() || value.asText().isBlank()) {
|
||||
value = item.get("model");
|
||||
}
|
||||
if (value != null && value.isTextual() && !value.asText().isBlank()) {
|
||||
modelIds.add(value.asText());
|
||||
}
|
||||
});
|
||||
return modelIds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package tech.easyflow.ai.service.discovery;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.ai.entity.ModelProvider;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* OpenAI-compatible {@code data[].id} 模型目录适配器。
|
||||
*/
|
||||
@Component
|
||||
public class OpenAiCompatibleRemoteModelAdapter implements RemoteModelProviderAdapter {
|
||||
|
||||
/**
|
||||
* 获取 OpenAI-compatible 模型 ID。
|
||||
*
|
||||
* @param provider 已保存的服务商配置
|
||||
* @param httpClient 受控 HTTP 客户端
|
||||
* @return 原始模型 ID 列表
|
||||
* @throws BusinessException 对话路径或响应结构不兼容时抛出
|
||||
*/
|
||||
@Override
|
||||
public List<String> fetchModelIds(ModelProvider provider, RemoteModelHttpClient httpClient) {
|
||||
String modelsPath = deriveModelsPath(provider.getChatPath());
|
||||
Map<String, String> query = "siliconflow".equals(normalize(provider.getProviderType()))
|
||||
? Map.of("type", "text") : Map.of();
|
||||
JsonNode root = httpClient.getJson(provider, modelsPath, query);
|
||||
JsonNode data = root.get("data");
|
||||
if (data == null || !data.isArray()) {
|
||||
throw new BusinessException(502, 50231, "模型列表响应格式不兼容");
|
||||
}
|
||||
List<String> modelIds = new ArrayList<>();
|
||||
data.forEach(item -> addText(modelIds, item.get("id")));
|
||||
return modelIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对话路径推导同版本的 models 路径。
|
||||
*
|
||||
* @param chatPath 已保存的对话路径
|
||||
* @return models 路径
|
||||
* @throws BusinessException 路径不符合兼容协议时抛出
|
||||
*/
|
||||
public String deriveModelsPath(String chatPath) {
|
||||
if (chatPath == null || chatPath.isBlank()) {
|
||||
return "/v1/models";
|
||||
}
|
||||
String normalized = chatPath.trim();
|
||||
String suffix = "/chat/completions";
|
||||
if (!normalized.toLowerCase(Locale.ROOT).endsWith(suffix)) {
|
||||
throw new BusinessException(422, 42231, "当前服务暂不支持获取模型列表");
|
||||
}
|
||||
String prefix = normalized.substring(0, normalized.length() - suffix.length());
|
||||
return (prefix.isBlank() ? "" : prefix) + "/models";
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加非空文本模型 ID。
|
||||
*
|
||||
* @param target 结果列表
|
||||
* @param value JSON 文本节点
|
||||
*/
|
||||
private void addText(List<String> target, JsonNode value) {
|
||||
if (value != null && value.isTextual() && !value.asText().isBlank()) {
|
||||
target.add(value.asText());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化供应商类型。
|
||||
*
|
||||
* @param value 原始供应商类型
|
||||
* @return 小写供应商类型
|
||||
*/
|
||||
private String normalize(String value) {
|
||||
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package tech.easyflow.ai.service.discovery;
|
||||
|
||||
import tech.easyflow.ai.service.capability.ModelCapabilitySource;
|
||||
|
||||
/**
|
||||
* 远端模型在管理端使用的统一描述。
|
||||
*/
|
||||
public final class RemoteModelDescriptor {
|
||||
|
||||
/** 远端原始模型 ID。 */
|
||||
private final String modelId;
|
||||
/** 模型展示名称。 */
|
||||
private final String displayName;
|
||||
/** 模型家族。 */
|
||||
private final String family;
|
||||
/** EasyFlow 模型类型。 */
|
||||
private final String modelType;
|
||||
/** 是否支持视觉输入。 */
|
||||
private final Boolean supportImage;
|
||||
/** 是否支持推理。 */
|
||||
private final Boolean supportThinking;
|
||||
/** 是否支持工具调用。 */
|
||||
private final Boolean supportTool;
|
||||
/** 能力识别来源。 */
|
||||
private final ModelCapabilitySource capabilitySource;
|
||||
/** 当前租户是否已经添加。 */
|
||||
private final boolean added;
|
||||
/** 当前模型是否允许一键添加。 */
|
||||
private final boolean addable;
|
||||
/** 无法添加时的简短原因。 */
|
||||
private final String unavailableReason;
|
||||
|
||||
/**
|
||||
* 创建远端模型描述。
|
||||
*
|
||||
* @param modelId 远端原始模型 ID
|
||||
* @param displayName 模型展示名称
|
||||
* @param family 模型家族
|
||||
* @param modelType EasyFlow 模型类型
|
||||
* @param supportImage 是否支持视觉输入
|
||||
* @param supportThinking 是否支持推理
|
||||
* @param supportTool 是否支持工具调用
|
||||
* @param capabilitySource 能力识别来源
|
||||
* @param added 是否已经添加
|
||||
* @param addable 是否允许一键添加
|
||||
* @param unavailableReason 无法添加原因
|
||||
*/
|
||||
public RemoteModelDescriptor(String modelId,
|
||||
String displayName,
|
||||
String family,
|
||||
String modelType,
|
||||
Boolean supportImage,
|
||||
Boolean supportThinking,
|
||||
Boolean supportTool,
|
||||
ModelCapabilitySource capabilitySource,
|
||||
boolean added,
|
||||
boolean addable,
|
||||
String unavailableReason) {
|
||||
this.modelId = modelId;
|
||||
this.displayName = displayName;
|
||||
this.family = family;
|
||||
this.modelType = modelType;
|
||||
this.supportImage = supportImage;
|
||||
this.supportThinking = supportThinking;
|
||||
this.supportTool = supportTool;
|
||||
this.capabilitySource = capabilitySource;
|
||||
this.added = added;
|
||||
this.addable = addable;
|
||||
this.unavailableReason = unavailableReason;
|
||||
}
|
||||
|
||||
/** @return 远端原始模型 ID */
|
||||
public String getModelId() {
|
||||
return modelId;
|
||||
}
|
||||
|
||||
/** @return 模型展示名称 */
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
/** @return 模型家族 */
|
||||
public String getFamily() {
|
||||
return family;
|
||||
}
|
||||
|
||||
/** @return EasyFlow 模型类型 */
|
||||
public String getModelType() {
|
||||
return modelType;
|
||||
}
|
||||
|
||||
/** @return 是否支持视觉输入,空值表示未知 */
|
||||
public Boolean getSupportImage() {
|
||||
return supportImage;
|
||||
}
|
||||
|
||||
/** @return 是否支持推理,空值表示未知 */
|
||||
public Boolean getSupportThinking() {
|
||||
return supportThinking;
|
||||
}
|
||||
|
||||
/** @return 是否支持工具调用,空值表示未知 */
|
||||
public Boolean getSupportTool() {
|
||||
return supportTool;
|
||||
}
|
||||
|
||||
/** @return 能力识别来源 */
|
||||
public ModelCapabilitySource getCapabilitySource() {
|
||||
return capabilitySource;
|
||||
}
|
||||
|
||||
/** @return 已经添加返回 true */
|
||||
public boolean isAdded() {
|
||||
return added;
|
||||
}
|
||||
|
||||
/** @return 允许一键添加返回 true */
|
||||
public boolean isAddable() {
|
||||
return addable;
|
||||
}
|
||||
|
||||
/** @return 无法添加原因,可添加时为 null */
|
||||
public String getUnavailableReason() {
|
||||
return unavailableReason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package tech.easyflow.ai.service.discovery;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
import tech.easyflow.ai.entity.ModelProvider;
|
||||
import tech.easyflow.ai.mapper.ModelMapper;
|
||||
import tech.easyflow.ai.service.ModelProviderService;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 远端模型发现应用服务。
|
||||
*/
|
||||
@Service
|
||||
public class RemoteModelDiscoveryService {
|
||||
|
||||
/** 单次发现最多返回的可用模型数量。 */
|
||||
private static final int MAX_MODEL_COUNT = 1000;
|
||||
|
||||
/** 服务商服务。 */
|
||||
private final ModelProviderService modelProviderService;
|
||||
/** 本地模型映射器。 */
|
||||
private final ModelMapper modelMapper;
|
||||
/** 静态适配表。 */
|
||||
private final RemoteModelProviderAdapterRegistry adapterRegistry;
|
||||
/** 受控 HTTP 客户端。 */
|
||||
private final RemoteModelHttpClient httpClient;
|
||||
/** 模型元数据解析器。 */
|
||||
private final RemoteModelMetadataResolver metadataResolver;
|
||||
|
||||
/**
|
||||
* 创建远端模型发现服务。
|
||||
*
|
||||
* @param modelProviderService 服务商服务
|
||||
* @param modelMapper 本地模型映射器
|
||||
* @param adapterRegistry 静态适配表
|
||||
* @param httpClient 受控 HTTP 客户端
|
||||
* @param metadataResolver 模型元数据解析器
|
||||
*/
|
||||
public RemoteModelDiscoveryService(ModelProviderService modelProviderService,
|
||||
ModelMapper modelMapper,
|
||||
RemoteModelProviderAdapterRegistry adapterRegistry,
|
||||
RemoteModelHttpClient httpClient,
|
||||
RemoteModelMetadataResolver metadataResolver) {
|
||||
this.modelProviderService = modelProviderService;
|
||||
this.modelMapper = modelMapper;
|
||||
this.adapterRegistry = adapterRegistry;
|
||||
this.httpClient = httpClient;
|
||||
this.metadataResolver = metadataResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动获取指定服务商的远端模型列表。
|
||||
*
|
||||
* @param providerId 服务商 ID
|
||||
* @return 已补全能力和本地添加状态的模型列表
|
||||
* @throws BusinessException 服务商不存在或远端发现失败时抛出
|
||||
*/
|
||||
public RemoteModelListResult discover(BigInteger providerId) {
|
||||
if (providerId == null) {
|
||||
throw new BusinessException(400, 40031, "服务商 ID 不能为空");
|
||||
}
|
||||
ModelProvider provider = modelProviderService.getById(providerId);
|
||||
if (provider == null) {
|
||||
throw new BusinessException(404, 40431, "模型服务商不存在");
|
||||
}
|
||||
|
||||
RemoteModelProviderAdapter adapter = adapterRegistry.get(provider.getProviderType());
|
||||
List<String> fetchedIds = adapter.fetchModelIds(provider, httpClient);
|
||||
LinkedHashSet<String> uniqueIds = normalizeModelIds(fetchedIds);
|
||||
Set<String> addedModelIds = loadAddedModelIds(providerId);
|
||||
|
||||
List<String> supportedIds = uniqueIds.stream()
|
||||
.filter(modelId -> !metadataResolver.isUnsupportedGenerationModel(
|
||||
provider.getProviderType(), modelId))
|
||||
.collect(Collectors.toList());
|
||||
boolean truncated = supportedIds.size() > MAX_MODEL_COUNT;
|
||||
List<RemoteModelDescriptor> descriptors = supportedIds.stream()
|
||||
.limit(MAX_MODEL_COUNT)
|
||||
.map(modelId -> metadataResolver.describe(
|
||||
provider.getProviderType(), modelId, addedModelIds.contains(modelId)))
|
||||
.sorted(Comparator.comparing(RemoteModelDescriptor::getFamily,
|
||||
String.CASE_INSENSITIVE_ORDER)
|
||||
.thenComparing(RemoteModelDescriptor::getDisplayName,
|
||||
String.CASE_INSENSITIVE_ORDER)
|
||||
.thenComparing(RemoteModelDescriptor::getModelId))
|
||||
.collect(Collectors.toList());
|
||||
return new RemoteModelListResult(providerId, descriptors, truncated);
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化、去重并过滤非法远端模型 ID。
|
||||
*
|
||||
* @param fetchedIds 适配器返回的原始列表
|
||||
* @return 保持远端顺序的唯一模型 ID
|
||||
*/
|
||||
private LinkedHashSet<String> normalizeModelIds(List<String> fetchedIds) {
|
||||
LinkedHashSet<String> uniqueIds = new LinkedHashSet<>();
|
||||
if (fetchedIds == null) {
|
||||
return uniqueIds;
|
||||
}
|
||||
for (String modelId : fetchedIds) {
|
||||
if (modelId == null) {
|
||||
continue;
|
||||
}
|
||||
String normalized = modelId.trim();
|
||||
if (!normalized.isEmpty() && normalized.chars().noneMatch(Character::isISOControl)) {
|
||||
uniqueIds.add(normalized);
|
||||
}
|
||||
}
|
||||
return uniqueIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载当前租户在指定服务商下已添加的原始模型 ID。
|
||||
*
|
||||
* @param providerId 服务商 ID
|
||||
* @return 已添加模型 ID 集合
|
||||
*/
|
||||
private Set<String> loadAddedModelIds(BigInteger providerId) {
|
||||
QueryWrapper query = QueryWrapper.create().eq(Model::getProviderId, providerId);
|
||||
List<Model> models = modelMapper.selectListByQuery(query);
|
||||
return models.stream()
|
||||
.map(Model::getModelName)
|
||||
.filter(value -> value != null && !value.isBlank())
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package tech.easyflow.ai.service.discovery;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.ai.entity.ModelProvider;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.Inet6Address;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.UnknownHostException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Comparator;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
|
||||
/**
|
||||
* 使用服务端已保存配置执行受控的远端模型目录请求。
|
||||
*/
|
||||
@Component
|
||||
public class RemoteModelHttpClient {
|
||||
|
||||
/** 日志记录器。 */
|
||||
private static final Logger log = LoggerFactory.getLogger(RemoteModelHttpClient.class);
|
||||
/** 最大响应体大小。 */
|
||||
private static final int MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
||||
/** 单次远端请求超时。 */
|
||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(15);
|
||||
/** 允许访问本机或内网地址的服务商类型。 */
|
||||
private static final Set<String> PRIVATE_ENDPOINT_PROVIDER_TYPES = Set.of(
|
||||
"ollama", "self-hosted", "self_hosted", "selfhost");
|
||||
/** 始终禁止访问的元数据主机。 */
|
||||
private static final Set<String> BLOCKED_HOSTS = Set.of(
|
||||
"metadata.google.internal", "metadata.google.internal.", "100.100.100.200");
|
||||
|
||||
/** HTTP 客户端。 */
|
||||
private final HttpClient httpClient;
|
||||
/** JSON 解析器。 */
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 创建受控远端模型 HTTP 客户端。
|
||||
*
|
||||
* @param objectMapper JSON 解析器
|
||||
*/
|
||||
public RemoteModelHttpClient(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(8))
|
||||
.followRedirects(HttpClient.Redirect.NEVER)
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求并解析模型目录 JSON。
|
||||
*
|
||||
* @param provider 已保存的模型服务商
|
||||
* @param requestPath 静态适配器确定的请求路径
|
||||
* @param queryParameters 受控查询参数
|
||||
* @return JSON 根节点
|
||||
* @throws BusinessException URL、网络、状态码、响应大小或 JSON 格式不合法时抛出
|
||||
*/
|
||||
public JsonNode getJson(ModelProvider provider,
|
||||
String requestPath,
|
||||
Map<String, String> queryParameters) {
|
||||
URI uri = buildUri(provider, requestPath, queryParameters);
|
||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(uri)
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.header("Accept", "application/json")
|
||||
.header("Accept-Encoding", "gzip")
|
||||
.header("User-Agent", "EasyFlow-RemoteModelDiscovery/1.0")
|
||||
.GET();
|
||||
String apiKey = provider.getApiKey();
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
String trimmedKey = apiKey.trim();
|
||||
if (trimmedKey.indexOf('\r') >= 0 || trimmedKey.indexOf('\n') >= 0) {
|
||||
throw new BusinessException(422, 42211, "API 密钥格式不正确");
|
||||
}
|
||||
requestBuilder.header("Authorization", "Bearer " + trimmedKey);
|
||||
}
|
||||
|
||||
long startedAt = System.nanoTime();
|
||||
try {
|
||||
HttpResponse<InputStream> response = httpClient.send(
|
||||
requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream());
|
||||
log.info("远端模型目录请求完成 providerId={}, providerType={}, host={}, status={}, elapsedMs={}",
|
||||
provider.getId(), provider.getProviderType(), uri.getHost(), response.statusCode(),
|
||||
Duration.ofNanos(System.nanoTime() - startedAt).toMillis());
|
||||
try (InputStream rawBody = response.body()) {
|
||||
validateStatus(response.statusCode());
|
||||
try (InputStream body = decodeResponseBody(response, rawBody)) {
|
||||
byte[] payload = body.readNBytes(MAX_RESPONSE_BYTES + 1);
|
||||
if (payload.length > MAX_RESPONSE_BYTES) {
|
||||
throw new BusinessException(502, 50213, "远端模型列表响应过大");
|
||||
}
|
||||
JsonNode root;
|
||||
try {
|
||||
root = objectMapper.readTree(payload);
|
||||
} catch (com.fasterxml.jackson.core.JsonProcessingException exception) {
|
||||
throw new BusinessException(502, 50214,
|
||||
"远端模型列表响应不是有效 JSON", exception);
|
||||
}
|
||||
if (root == null) {
|
||||
throw new BusinessException(502, 50215, "远端模型列表响应为空");
|
||||
}
|
||||
return root;
|
||||
}
|
||||
}
|
||||
} catch (BusinessException exception) {
|
||||
throw exception;
|
||||
} catch (java.net.http.HttpTimeoutException exception) {
|
||||
log.error("远端模型目录请求超时 providerId={}, providerType={}, host={}",
|
||||
provider.getId(), provider.getProviderType(), uri.getHost(), exception);
|
||||
throw new BusinessException(504, 50411, "获取模型列表超时,请检查 API 地址后重试", exception);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.error("远端模型目录请求被中断 providerId={}, providerType={}, host={}",
|
||||
provider.getId(), provider.getProviderType(), uri.getHost(), exception);
|
||||
throw new BusinessException(503, 50311, "获取模型列表被中断,请稍后重试", exception);
|
||||
} catch (IOException | IllegalArgumentException exception) {
|
||||
log.error("远端模型目录请求失败 providerId={}, providerType={}, host={}",
|
||||
provider.getId(), provider.getProviderType(), uri.getHost(), exception);
|
||||
throw new BusinessException(502, 50211, "无法获取模型列表,请检查 API 地址和密钥", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据响应编码解压响应体,降低大型模型目录的网络传输开销。
|
||||
*
|
||||
* @param response HTTP 响应
|
||||
* @param rawBody 原始响应流
|
||||
* @return 可直接读取的响应流
|
||||
* @throws IOException gzip 响应无法解压时抛出
|
||||
*/
|
||||
private InputStream decodeResponseBody(HttpResponse<InputStream> response,
|
||||
InputStream rawBody) throws IOException {
|
||||
String contentEncoding = response.headers()
|
||||
.firstValue("Content-Encoding")
|
||||
.orElse("")
|
||||
.trim();
|
||||
if ("gzip".equalsIgnoreCase(contentEncoding)) {
|
||||
return new GZIPInputStream(rawBody);
|
||||
}
|
||||
return rawBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并服务商 Endpoint、静态路径和受控查询参数。
|
||||
*
|
||||
* @param provider 已保存的模型服务商
|
||||
* @param requestPath 静态请求路径
|
||||
* @param queryParameters 受控查询参数
|
||||
* @return 已完成安全校验的请求 URI
|
||||
* @throws BusinessException URL 或目标地址不安全时抛出
|
||||
*/
|
||||
public URI buildUri(ModelProvider provider,
|
||||
String requestPath,
|
||||
Map<String, String> queryParameters) {
|
||||
if (provider == null || provider.getEndpoint() == null || provider.getEndpoint().isBlank()) {
|
||||
throw new BusinessException(422, 42212, "请先配置并保存 API 地址");
|
||||
}
|
||||
if (requestPath == null || requestPath.isBlank()
|
||||
|| requestPath.contains("?") || requestPath.contains("#")) {
|
||||
throw new BusinessException(422, 42213, "当前服务暂不支持获取模型列表");
|
||||
}
|
||||
|
||||
try {
|
||||
URI endpoint = URI.create(provider.getEndpoint().trim());
|
||||
validateEndpoint(provider, endpoint);
|
||||
String combinedPath = combinePaths(endpoint.getRawPath(), requestPath);
|
||||
String query = buildQuery(queryParameters);
|
||||
return new URI(endpoint.getScheme(), null, endpoint.getHost(), endpoint.getPort(),
|
||||
combinedPath, query, null);
|
||||
} catch (BusinessException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new BusinessException(422, 42214, "API 地址格式不正确", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 Endpoint 协议、主机与解析后的地址范围。
|
||||
*
|
||||
* @param provider 模型服务商
|
||||
* @param endpoint Endpoint URI
|
||||
*/
|
||||
private void validateEndpoint(ModelProvider provider, URI endpoint) {
|
||||
String scheme = endpoint.getScheme() == null
|
||||
? "" : endpoint.getScheme().toLowerCase(Locale.ROOT);
|
||||
if (!("http".equals(scheme) || "https".equals(scheme))
|
||||
|| endpoint.getHost() == null
|
||||
|| endpoint.getUserInfo() != null
|
||||
|| endpoint.getQuery() != null
|
||||
|| endpoint.getFragment() != null) {
|
||||
throw new BusinessException(422, 42214, "API 地址格式不正确");
|
||||
}
|
||||
String host = endpoint.getHost().toLowerCase(Locale.ROOT);
|
||||
if (BLOCKED_HOSTS.contains(host)) {
|
||||
throw new BusinessException(422, 42215, "API 地址指向受限网络目标");
|
||||
}
|
||||
|
||||
boolean privateEndpointAllowed = PRIVATE_ENDPOINT_PROVIDER_TYPES.contains(
|
||||
normalize(provider.getProviderType()));
|
||||
try {
|
||||
for (InetAddress address : InetAddress.getAllByName(host)) {
|
||||
if (isAlwaysBlocked(address)
|
||||
|| (!privateEndpointAllowed && isPrivateOrLoopback(address))) {
|
||||
throw new BusinessException(422, 42215, "API 地址指向受限网络目标");
|
||||
}
|
||||
}
|
||||
} catch (UnknownHostException exception) {
|
||||
throw new BusinessException(502, 50212, "API 地址无法解析", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断地址是否在任何服务商下都禁止访问。
|
||||
*
|
||||
* @param address 已解析地址
|
||||
* @return 禁止访问返回 true
|
||||
*/
|
||||
private boolean isAlwaysBlocked(InetAddress address) {
|
||||
return address.isAnyLocalAddress()
|
||||
|| address.isLinkLocalAddress()
|
||||
|| address.isMulticastAddress()
|
||||
|| "100.100.100.200".equals(address.getHostAddress());
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断地址是否为内网或本机地址。
|
||||
*
|
||||
* @param address 已解析地址
|
||||
* @return 内网或本机地址返回 true
|
||||
*/
|
||||
private boolean isPrivateOrLoopback(InetAddress address) {
|
||||
return address.isLoopbackAddress()
|
||||
|| address.isSiteLocalAddress()
|
||||
|| isUniqueLocalIpv6(address);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断地址是否位于 IPv6 唯一本地地址段 fc00::/7。
|
||||
*
|
||||
* @param address 已解析地址
|
||||
* @return 位于 fc00::/7 返回 true
|
||||
*/
|
||||
private boolean isUniqueLocalIpv6(InetAddress address) {
|
||||
if (!(address instanceof Inet6Address)) {
|
||||
return false;
|
||||
}
|
||||
return (address.getAddress()[0] & 0xFE) == 0xFC;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并 Endpoint 路径与模型目录路径。
|
||||
*
|
||||
* @param basePath Endpoint 自带路径
|
||||
* @param requestPath 目录请求路径
|
||||
* @return 规范化请求路径
|
||||
*/
|
||||
private String combinePaths(String basePath, String requestPath) {
|
||||
String normalizedBase = normalizePath(basePath);
|
||||
String normalizedRequest = normalizePath(requestPath);
|
||||
if ("/".equals(normalizedBase)) {
|
||||
return normalizedRequest;
|
||||
}
|
||||
if (normalizedRequest.equals(normalizedBase)
|
||||
|| normalizedRequest.startsWith(normalizedBase + "/")) {
|
||||
return normalizedRequest;
|
||||
}
|
||||
return normalizePath(normalizedBase + "/" + normalizedRequest.substring(1));
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化 URL 路径中的首尾与重复斜杠。
|
||||
*
|
||||
* @param path 原始路径
|
||||
* @return 以单斜杠开头的路径
|
||||
*/
|
||||
private String normalizePath(String path) {
|
||||
if (path == null || path.isBlank() || "/".equals(path.trim())) {
|
||||
return "/";
|
||||
}
|
||||
String normalized = path.trim();
|
||||
if (!normalized.startsWith("/")) {
|
||||
normalized = "/" + normalized;
|
||||
}
|
||||
normalized = normalized.replaceAll("/{2,}", "/");
|
||||
return normalized.length() > 1 && normalized.endsWith("/")
|
||||
? normalized.substring(0, normalized.length() - 1) : normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建顺序稳定的查询字符串。
|
||||
*
|
||||
* @param parameters 查询参数
|
||||
* @return 查询字符串,无参数时返回 null
|
||||
*/
|
||||
private String buildQuery(Map<String, String> parameters) {
|
||||
if (parameters == null || parameters.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return parameters.entrySet().stream()
|
||||
.filter(entry -> entry.getKey() != null && entry.getValue() != null)
|
||||
.sorted(Comparator.comparing(Map.Entry::getKey))
|
||||
.map(entry -> encode(entry.getKey()) + "=" + encode(entry.getValue()))
|
||||
.collect(Collectors.joining("&"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 对单个查询参数执行 UTF-8 编码。
|
||||
*
|
||||
* @param value 参数值
|
||||
* @return 编码结果
|
||||
*/
|
||||
private String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
}
|
||||
|
||||
/**
|
||||
* 将远端 HTTP 状态映射为可恢复的业务错误。
|
||||
*
|
||||
* @param statusCode 远端 HTTP 状态码
|
||||
* @throws BusinessException 非 2xx 状态时抛出
|
||||
*/
|
||||
private void validateStatus(int statusCode) {
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
return;
|
||||
}
|
||||
switch (statusCode) {
|
||||
case 401 -> throw new BusinessException(422, 42221, "API 密钥无效,请检查服务商配置");
|
||||
case 403 -> throw new BusinessException(422, 42222, "当前 API 密钥无权获取模型列表");
|
||||
case 404, 405 -> throw new BusinessException(422, 42223, "当前服务暂不支持获取模型列表");
|
||||
case 429 -> throw new BusinessException(429, 42911, "请求过于频繁,请稍后重试");
|
||||
default -> {
|
||||
if (statusCode >= 500) {
|
||||
throw new BusinessException(502, 50221,
|
||||
"模型服务暂时不可用(HTTP " + statusCode + ")");
|
||||
}
|
||||
throw new BusinessException(422, 42224,
|
||||
"获取模型列表失败(HTTP " + statusCode + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化供应商类型。
|
||||
*
|
||||
* @param value 原始供应商类型
|
||||
* @return 小写无首尾空白的供应商类型
|
||||
*/
|
||||
private String normalize(String value) {
|
||||
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package tech.easyflow.ai.service.discovery;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* 远端模型一键添加结果。
|
||||
*/
|
||||
public final class RemoteModelImportResult {
|
||||
|
||||
/** 远端原始模型 ID。 */
|
||||
private final String modelId;
|
||||
/** 本地模型 ID。 */
|
||||
private final BigInteger localModelId;
|
||||
/** 最终模型类型。 */
|
||||
private final String modelType;
|
||||
/** 添加结果状态。 */
|
||||
private final RemoteModelImportStatus status;
|
||||
|
||||
/**
|
||||
* 创建一键添加结果。
|
||||
*
|
||||
* @param modelId 远端原始模型 ID
|
||||
* @param localModelId 本地模型 ID
|
||||
* @param modelType 最终模型类型
|
||||
* @param status 添加结果状态
|
||||
*/
|
||||
public RemoteModelImportResult(String modelId,
|
||||
BigInteger localModelId,
|
||||
String modelType,
|
||||
RemoteModelImportStatus status) {
|
||||
this.modelId = modelId;
|
||||
this.localModelId = localModelId;
|
||||
this.modelType = modelType;
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
/** @return 远端原始模型 ID */
|
||||
public String getModelId() {
|
||||
return modelId;
|
||||
}
|
||||
|
||||
/** @return 本地模型 ID */
|
||||
public BigInteger getLocalModelId() {
|
||||
return localModelId;
|
||||
}
|
||||
|
||||
/** @return 最终模型类型 */
|
||||
public String getModelType() {
|
||||
return modelType;
|
||||
}
|
||||
|
||||
/** @return 添加结果状态 */
|
||||
public RemoteModelImportStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package tech.easyflow.ai.service.discovery;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
import tech.easyflow.ai.entity.ModelProvider;
|
||||
import tech.easyflow.ai.mapper.ModelMapper;
|
||||
import tech.easyflow.ai.mapper.ModelProviderMapper;
|
||||
import tech.easyflow.ai.service.ModelProviderService;
|
||||
import tech.easyflow.ai.service.ModelService;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* 远端模型一键添加应用服务。
|
||||
*/
|
||||
@Service
|
||||
public class RemoteModelImportService {
|
||||
|
||||
/** 数据库允许的最大模型 ID 长度。 */
|
||||
private static final int MAX_MODEL_ID_LENGTH = 255;
|
||||
|
||||
/** 服务商服务。 */
|
||||
private final ModelProviderService modelProviderService;
|
||||
/** 服务商映射器。 */
|
||||
private final ModelProviderMapper modelProviderMapper;
|
||||
/** 模型服务。 */
|
||||
private final ModelService modelService;
|
||||
/** 模型映射器。 */
|
||||
private final ModelMapper modelMapper;
|
||||
/** 模型元数据解析器。 */
|
||||
private final RemoteModelMetadataResolver metadataResolver;
|
||||
|
||||
/**
|
||||
* 创建远端模型一键添加服务。
|
||||
*
|
||||
* @param modelProviderService 服务商服务
|
||||
* @param modelProviderMapper 服务商映射器
|
||||
* @param modelService 模型服务
|
||||
* @param modelMapper 模型映射器
|
||||
* @param metadataResolver 模型元数据解析器
|
||||
*/
|
||||
public RemoteModelImportService(ModelProviderService modelProviderService,
|
||||
ModelProviderMapper modelProviderMapper,
|
||||
ModelService modelService,
|
||||
ModelMapper modelMapper,
|
||||
RemoteModelMetadataResolver metadataResolver) {
|
||||
this.modelProviderService = modelProviderService;
|
||||
this.modelProviderMapper = modelProviderMapper;
|
||||
this.modelService = modelService;
|
||||
this.modelMapper = modelMapper;
|
||||
this.metadataResolver = metadataResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等添加单个远端模型。
|
||||
*
|
||||
* @param providerId 服务商 ID
|
||||
* @param rawModelId 远端原始模型 ID
|
||||
* @param auditModel 已由控制层填充租户和部门字段的模型种子
|
||||
* @return 创建或已存在结果
|
||||
* @throws BusinessException 参数、服务商或保存结果不合法时抛出
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public RemoteModelImportResult importModel(BigInteger providerId,
|
||||
String rawModelId,
|
||||
Model auditModel) {
|
||||
if (providerId == null) {
|
||||
throw new BusinessException(400, 40031, "服务商 ID 不能为空");
|
||||
}
|
||||
String modelId = validateModelId(rawModelId);
|
||||
ModelProvider provider = modelProviderService.getById(providerId);
|
||||
if (provider == null) {
|
||||
throw new BusinessException(404, 40431, "模型服务商不存在");
|
||||
}
|
||||
|
||||
// 对同一服务商的一键添加串行化,配合唯一键避免并发重复插入。
|
||||
if (modelProviderMapper.lockById(providerId) == null) {
|
||||
throw new BusinessException(404, 40431, "模型服务商不存在");
|
||||
}
|
||||
Model existing = findExisting(providerId, modelId);
|
||||
if (existing != null) {
|
||||
return toResult(existing, RemoteModelImportStatus.ALREADY_EXISTS);
|
||||
}
|
||||
|
||||
Model model = auditModel == null ? new Model() : auditModel;
|
||||
model.setProviderId(providerId);
|
||||
metadataResolver.configureNewModel(model, provider.getProviderType(), modelId);
|
||||
modelService.validateForSaveOrUpdate(model, true);
|
||||
if (!modelService.save(model)) {
|
||||
throw new BusinessException(500, 50031, "添加模型失败,请稍后重试");
|
||||
}
|
||||
return toResult(model, RemoteModelImportStatus.CREATED);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并规范化模型 ID。
|
||||
*
|
||||
* @param rawModelId 原始模型 ID
|
||||
* @return 去除首尾空白的模型 ID
|
||||
*/
|
||||
private String validateModelId(String rawModelId) {
|
||||
if (rawModelId == null || rawModelId.trim().isEmpty()) {
|
||||
throw new BusinessException(400, 40032, "模型 ID 不能为空");
|
||||
}
|
||||
String modelId = rawModelId.trim();
|
||||
if (modelId.codePointCount(0, modelId.length()) > MAX_MODEL_ID_LENGTH) {
|
||||
throw new BusinessException(422, 42232, "模型 ID 不能超过 255 个字符");
|
||||
}
|
||||
if (modelId.chars().anyMatch(Character::isISOControl)) {
|
||||
throw new BusinessException(422, 42233, "模型 ID 包含非法控制字符");
|
||||
}
|
||||
return modelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前租户下已存在的相同模型。
|
||||
*
|
||||
* @param providerId 服务商 ID
|
||||
* @param modelId 原始模型 ID
|
||||
* @return 已存在模型,不存在时返回 null
|
||||
*/
|
||||
private Model findExisting(BigInteger providerId, String modelId) {
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.eq(Model::getProviderId, providerId)
|
||||
.eq(Model::getModelName, modelId);
|
||||
return modelMapper.selectOneByQuery(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建添加接口结果。
|
||||
*
|
||||
* @param model 本地模型
|
||||
* @param status 添加状态
|
||||
* @return 添加接口结果
|
||||
*/
|
||||
private RemoteModelImportResult toResult(Model model, RemoteModelImportStatus status) {
|
||||
return new RemoteModelImportResult(
|
||||
model.getModelName(), model.getId(), model.getModelType(), status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package tech.easyflow.ai.service.discovery;
|
||||
|
||||
/**
|
||||
* 远端模型一键添加结果状态。
|
||||
*/
|
||||
public enum RemoteModelImportStatus {
|
||||
/** 已创建新的本地模型。 */
|
||||
CREATED,
|
||||
/** 相同模型已经存在。 */
|
||||
ALREADY_EXISTS
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package tech.easyflow.ai.service.discovery;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 单个服务商的远端模型发现结果。
|
||||
*/
|
||||
public final class RemoteModelListResult {
|
||||
|
||||
/** 服务商 ID。 */
|
||||
private final BigInteger providerId;
|
||||
/** 统一模型列表。 */
|
||||
private final List<RemoteModelDescriptor> models;
|
||||
/** 远端结果是否超过服务端安全上限。 */
|
||||
private final boolean truncated;
|
||||
|
||||
/**
|
||||
* 创建远端模型发现结果。
|
||||
*
|
||||
* @param providerId 服务商 ID
|
||||
* @param models 统一模型列表
|
||||
* @param truncated 是否因数量上限而截断
|
||||
*/
|
||||
public RemoteModelListResult(BigInteger providerId,
|
||||
List<RemoteModelDescriptor> models,
|
||||
boolean truncated) {
|
||||
this.providerId = providerId;
|
||||
this.models = List.copyOf(models);
|
||||
this.truncated = truncated;
|
||||
}
|
||||
|
||||
/** @return 服务商 ID */
|
||||
public BigInteger getProviderId() {
|
||||
return providerId;
|
||||
}
|
||||
|
||||
/** @return 不可变远端模型列表 */
|
||||
public List<RemoteModelDescriptor> getModels() {
|
||||
return models;
|
||||
}
|
||||
|
||||
/** @return 结果被截断返回 true */
|
||||
public boolean isTruncated() {
|
||||
return truncated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package tech.easyflow.ai.service.discovery;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
import tech.easyflow.ai.service.capability.ModelCapabilityCatalog;
|
||||
import tech.easyflow.ai.service.capability.ModelCapabilityResolution;
|
||||
import tech.easyflow.ai.service.capability.ModelCapabilityResolver;
|
||||
import tech.easyflow.ai.service.capability.ModelCatalogMetadata;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 使用静态模型目录和统一能力规则补全远端模型信息。
|
||||
*/
|
||||
@Component
|
||||
public class RemoteModelMetadataResolver {
|
||||
|
||||
/** 数据库允许的最大模型 ID 长度。 */
|
||||
private static final int MAX_MODEL_ID_LENGTH = 255;
|
||||
/** 数据库允许的最大模型标题长度。 */
|
||||
private static final int MAX_TITLE_LENGTH = 128;
|
||||
/** 未命中目录时使用的默认家族。 */
|
||||
private static final String DEFAULT_FAMILY = "其他模型";
|
||||
|
||||
/** 静态模型目录。 */
|
||||
private final ModelCapabilityCatalog catalog;
|
||||
/** 统一模型能力解析器。 */
|
||||
private final ModelCapabilityResolver capabilityResolver;
|
||||
|
||||
/**
|
||||
* 创建远端模型元数据解析器。
|
||||
*
|
||||
* @param catalog 静态模型目录
|
||||
* @param capabilityResolver 统一模型能力解析器
|
||||
*/
|
||||
public RemoteModelMetadataResolver(ModelCapabilityCatalog catalog,
|
||||
ModelCapabilityResolver capabilityResolver) {
|
||||
this.catalog = catalog;
|
||||
this.capabilityResolver = capabilityResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建管理端使用的远端模型描述。
|
||||
*
|
||||
* @param providerType 供应商类型
|
||||
* @param modelId 远端原始模型 ID
|
||||
* @param added 是否已经添加
|
||||
* @return 统一模型描述
|
||||
*/
|
||||
public RemoteModelDescriptor describe(String providerType, String modelId, boolean added) {
|
||||
Optional<ModelCatalogMetadata> metadata = catalog.findMetadata(providerType, modelId);
|
||||
ModelCapabilityResolution capability = capabilityResolver.resolve(providerType, modelId);
|
||||
boolean addable = modelId.codePointCount(0, modelId.length()) <= MAX_MODEL_ID_LENGTH;
|
||||
return new RemoteModelDescriptor(
|
||||
modelId,
|
||||
metadata.map(ModelCatalogMetadata::getDisplayName)
|
||||
.filter(value -> !value.isBlank()).orElse(modelId),
|
||||
metadata.map(ModelCatalogMetadata::getFamily)
|
||||
.filter(value -> !value.isBlank()).orElse(DEFAULT_FAMILY),
|
||||
capability.getModelType(),
|
||||
capability.getSupportImage(),
|
||||
capability.getSupportThinking(),
|
||||
capability.getSupportTool(),
|
||||
capability.getSource(),
|
||||
added,
|
||||
addable,
|
||||
addable ? null : "模型 ID 超过 255 个字符");
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用目录元数据和能力识别结果配置待新增模型。
|
||||
*
|
||||
* @param target 待新增模型
|
||||
* @param providerType 供应商类型
|
||||
* @param modelId 远端原始模型 ID
|
||||
*/
|
||||
public void configureNewModel(Model target, String providerType, String modelId) {
|
||||
Optional<ModelCatalogMetadata> metadata = catalog.findMetadata(providerType, modelId);
|
||||
ModelCapabilityResolution capability = capabilityResolver.resolve(providerType, modelId);
|
||||
String displayName = metadata.map(ModelCatalogMetadata::getDisplayName)
|
||||
.filter(value -> !value.isBlank()).orElse(modelId);
|
||||
String family = metadata.map(ModelCatalogMetadata::getFamily)
|
||||
.filter(value -> !value.isBlank()).orElse(DEFAULT_FAMILY);
|
||||
|
||||
target.setModelName(modelId);
|
||||
target.setTitle(limitCodePoints(displayName, MAX_TITLE_LENGTH));
|
||||
target.setGroupName(family);
|
||||
target.setModelType(capability.getModelType());
|
||||
target.setSupportImage(capability.getSupportImage());
|
||||
target.setSupportThinking(capability.getSupportThinking());
|
||||
target.setSupportTool(capability.getSupportTool());
|
||||
target.setSupportToolMessage(capability.getSupportTool());
|
||||
target.setSupportImageB64Only(Boolean.FALSE);
|
||||
target.setSupportVideo(Boolean.FALSE);
|
||||
target.setSupportAudio(Boolean.FALSE);
|
||||
target.setSupportFree(Boolean.FALSE);
|
||||
target.setPublishEnabled(Boolean.FALSE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断目录中的模型是否为尚未接入的生成模型。
|
||||
*
|
||||
* @param providerType 供应商类型
|
||||
* @param modelId 远端原始模型 ID
|
||||
* @return 已知仅生成图片、视频或音频时返回 true
|
||||
*/
|
||||
public boolean isUnsupportedGenerationModel(String providerType, String modelId) {
|
||||
return catalog.findMetadata(providerType, modelId)
|
||||
.map(ModelCatalogMetadata::isUnsupportedGenerationModel)
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 Unicode 码点安全截断文本。
|
||||
*
|
||||
* @param value 原始文本
|
||||
* @param maxCodePoints 最大码点数
|
||||
* @return 截断后的文本
|
||||
*/
|
||||
private String limitCodePoints(String value, int maxCodePoints) {
|
||||
if (value.codePointCount(0, value.length()) <= maxCodePoints) {
|
||||
return value;
|
||||
}
|
||||
int endIndex = value.offsetByCodePoints(0, maxCodePoints);
|
||||
return value.substring(0, endIndex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package tech.easyflow.ai.service.discovery;
|
||||
|
||||
import tech.easyflow.ai.entity.ModelProvider;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 单类远端服务商模型目录协议适配器。
|
||||
*/
|
||||
public interface RemoteModelProviderAdapter {
|
||||
|
||||
/**
|
||||
* 获取远端原始模型 ID。
|
||||
*
|
||||
* @param provider 已保存的服务商配置
|
||||
* @param httpClient 受控 HTTP 客户端
|
||||
* @return 远端原始模型 ID 列表
|
||||
*/
|
||||
List<String> fetchModelIds(ModelProvider provider, RemoteModelHttpClient httpClient);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package tech.easyflow.ai.service.discovery;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 远端模型目录的简洁静态服务商适配表。
|
||||
*/
|
||||
@Component
|
||||
public class RemoteModelProviderAdapterRegistry {
|
||||
|
||||
/** 通用 OpenAI-compatible 适配器。 */
|
||||
private final RemoteModelProviderAdapter defaultAdapter;
|
||||
/** 按规范化供应商类型维护的静态适配表。 */
|
||||
private final Map<String, RemoteModelProviderAdapter> adapters;
|
||||
|
||||
/**
|
||||
* 创建静态服务商适配表。
|
||||
*
|
||||
* @param openAiCompatibleAdapter OpenAI-compatible 适配器
|
||||
* @param ollamaAdapter Ollama 适配器
|
||||
* @param aliyunAdapter 阿里百炼适配器
|
||||
*/
|
||||
public RemoteModelProviderAdapterRegistry(
|
||||
OpenAiCompatibleRemoteModelAdapter openAiCompatibleAdapter,
|
||||
OllamaRemoteModelAdapter ollamaAdapter,
|
||||
AliyunRemoteModelAdapter aliyunAdapter) {
|
||||
this.defaultAdapter = openAiCompatibleAdapter;
|
||||
this.adapters = Map.ofEntries(
|
||||
Map.entry("openai", openAiCompatibleAdapter),
|
||||
Map.entry("deepseek", openAiCompatibleAdapter),
|
||||
Map.entry("zhipu", openAiCompatibleAdapter),
|
||||
Map.entry("minimax", openAiCompatibleAdapter),
|
||||
Map.entry("kimi", openAiCompatibleAdapter),
|
||||
Map.entry("siliconflow", openAiCompatibleAdapter),
|
||||
Map.entry("self-hosted", openAiCompatibleAdapter),
|
||||
Map.entry("self_hosted", openAiCompatibleAdapter),
|
||||
Map.entry("selfhost", openAiCompatibleAdapter),
|
||||
Map.entry("ollama", ollamaAdapter),
|
||||
Map.entry("aliyun", aliyunAdapter),
|
||||
Map.entry("dashscope", aliyunAdapter),
|
||||
Map.entry("bailian", aliyunAdapter));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据供应商类型获取适配器。
|
||||
*
|
||||
* @param providerType 供应商类型
|
||||
* @return 专用适配器,未知类型使用通用兼容适配器
|
||||
*/
|
||||
public RemoteModelProviderAdapter get(String providerType) {
|
||||
return adapters.getOrDefault(normalize(providerType), defaultAdapter);
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化供应商类型。
|
||||
*
|
||||
* @param value 原始供应商类型
|
||||
* @return 小写供应商类型
|
||||
*/
|
||||
private String normalize(String value) {
|
||||
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 models.dev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,15 @@
|
||||
# llm.json 数据来源
|
||||
|
||||
- 上游地址:<https://models.dev/models.json>
|
||||
- 上游仓库:<https://github.com/anomalyco/models.dev>
|
||||
- 下载日期:2026-07-21
|
||||
- 上游条目数:259
|
||||
- 上游文件 SHA-256:`22f0e8bd69d5addebc2e762419082c828d121f4e25a9c247db7017ef545aa6ff`
|
||||
- 本地补充:7 个 BAAI 模型条目,元数据来自 BAAI 官方 Hugging Face 页面
|
||||
|
||||
本地补充条目包括 `bge-m3`、`bge-reranker-v2-m3`、`bge-reranker-v2-gemma`、
|
||||
`bge-reranker-v2-minicpm-layerwise`、`bge-reranker-v2.5-gemma2-lightweight`、
|
||||
`bge-reranker-large` 和 `bge-reranker-base`。
|
||||
|
||||
更新上游快照时,需要保留上述本地补充条目。
|
||||
各模型权重许可证以 `llm.json` 条目和对应模型卡为准。
|
||||
11810
easyflow-modules/easyflow-module-ai/src/main/resources/llm.json
Normal file
11810
easyflow-modules/easyflow-module-ai/src/main/resources/llm.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
package tech.easyflow.ai.service.capability;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
|
||||
/**
|
||||
* 静态模型能力库与命名规则解析测试。
|
||||
*/
|
||||
public class ModelCapabilityResolverTest {
|
||||
|
||||
/** 待测试能力解析器。 */
|
||||
private ModelCapabilityResolver resolver;
|
||||
|
||||
/**
|
||||
* 加载真实静态模型目录。
|
||||
*/
|
||||
@Before
|
||||
public void setUp() {
|
||||
resolver = new ModelCapabilityResolver(new ModelCapabilityCatalog(new ObjectMapper()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 DashScope 模型短 ID 能命中 Alibaba 目录条目。
|
||||
*/
|
||||
@Test
|
||||
public void shouldResolveCatalogCapabilitiesByProviderAlias() {
|
||||
ModelCapabilityResolution result = resolver.resolve("dashscope", "qwen3.7-plus");
|
||||
|
||||
Assert.assertEquals(ModelCapabilitySource.CATALOG, result.getSource());
|
||||
Assert.assertEquals(Model.MODEL_TYPES[0], result.getModelType());
|
||||
Assert.assertEquals(Boolean.TRUE, result.getSupportImage());
|
||||
Assert.assertEquals(Boolean.TRUE, result.getSupportThinking());
|
||||
Assert.assertEquals(Boolean.TRUE, result.getSupportTool());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证本地补充的 BAAI 嵌入与重排模型类型互斥。
|
||||
*/
|
||||
@Test
|
||||
public void shouldResolveBaaiEmbeddingAndRerankModels() {
|
||||
ModelCapabilityResolution embedding = resolver.resolve(null, "BAAI/bge-m3");
|
||||
ModelCapabilityResolution rerank = resolver.resolve(null, "bge-reranker-v2-m3");
|
||||
|
||||
Assert.assertEquals(Model.MODEL_TYPES[1], embedding.getModelType());
|
||||
Assert.assertEquals(Model.MODEL_TYPES[2], rerank.getModelType());
|
||||
Assert.assertEquals(Boolean.FALSE, embedding.getSupportTool());
|
||||
Assert.assertEquals(Boolean.FALSE, rerank.getSupportImage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证自定义部署名称仍能通过严格关键词识别视觉模型。
|
||||
*/
|
||||
@Test
|
||||
public void shouldInferVisionForCustomDeploymentName() {
|
||||
ModelCapabilityResolution result = resolver.resolve(
|
||||
"gpustack", "team-a/qwen2.5-vl-7b-instruct-awq");
|
||||
|
||||
Assert.assertEquals(ModelCapabilitySource.RULE, result.getSource());
|
||||
Assert.assertEquals(Model.MODEL_TYPES[0], result.getModelType());
|
||||
Assert.assertEquals(Boolean.TRUE, result.getSupportImage());
|
||||
Assert.assertNull(result.getSupportTool());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证无法识别的自定义模型保留未知能力,不误判为不支持工具。
|
||||
*/
|
||||
@Test
|
||||
public void shouldKeepCapabilitiesUnknownForCustomModel() {
|
||||
ModelCapabilityResolution result = resolver.resolve("custom", "team-production-model");
|
||||
|
||||
Assert.assertEquals(ModelCapabilitySource.DEFAULT, result.getSource());
|
||||
Assert.assertEquals(Model.MODEL_TYPES[0], result.getModelType());
|
||||
Assert.assertNull(result.getSupportImage());
|
||||
Assert.assertNull(result.getSupportThinking());
|
||||
Assert.assertNull(result.getSupportTool());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package tech.easyflow.ai.service.discovery;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.entity.ModelProvider;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
/**
|
||||
* 远端模型目录 URL 合并与网络目标限制测试。
|
||||
*/
|
||||
public class RemoteModelHttpClientTest {
|
||||
|
||||
/**
|
||||
* 验证 Endpoint 已含版本路径时不会重复拼接。
|
||||
*/
|
||||
@Test
|
||||
public void shouldJoinEndpointAndPathWithoutDuplicatingPrefix() {
|
||||
ModelProvider provider = provider("self-hosted", "http://127.0.0.1:8000/v1");
|
||||
|
||||
URI uri = new RemoteModelHttpClient(new ObjectMapper()).buildUri(
|
||||
provider, "/v1/models", Map.of("type", "text"));
|
||||
|
||||
Assert.assertEquals("http://127.0.0.1:8000/v1/models?type=text", uri.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证云服务商类型不能访问本机地址。
|
||||
*/
|
||||
@Test(expected = BusinessException.class)
|
||||
public void shouldRejectPrivateTargetForCloudProvider() {
|
||||
ModelProvider provider = provider("openai", "http://127.0.0.1:8000");
|
||||
|
||||
new RemoteModelHttpClient(new ObjectMapper()).buildUri(provider, "/v1/models", Map.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证自部署类型也不能访问链路本地元数据地址。
|
||||
*/
|
||||
@Test(expected = BusinessException.class)
|
||||
public void shouldRejectMetadataTargetForSelfHostedProvider() {
|
||||
ModelProvider provider = provider("self-hosted", "http://169.254.169.254");
|
||||
|
||||
new RemoteModelHttpClient(new ObjectMapper()).buildUri(provider, "/v1/models", Map.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证客户端会请求并正确解压 gzip 模型目录响应。
|
||||
*
|
||||
* @throws Exception 本地测试服务启动或请求失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldRequestAndDecodeGzipResponse() throws Exception {
|
||||
AtomicReference<String> acceptEncoding = new AtomicReference<>();
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/v1/models", exchange -> {
|
||||
acceptEncoding.set(exchange.getRequestHeaders().getFirst("Accept-Encoding"));
|
||||
byte[] payload = gzip("{\"data\":[{\"id\":\"test-model\"}]}");
|
||||
exchange.getResponseHeaders().add("Content-Type", "application/json");
|
||||
exchange.getResponseHeaders().add("Content-Encoding", "gzip");
|
||||
exchange.sendResponseHeaders(200, payload.length);
|
||||
try (var responseBody = exchange.getResponseBody()) {
|
||||
responseBody.write(payload);
|
||||
}
|
||||
});
|
||||
server.start();
|
||||
|
||||
try {
|
||||
ModelProvider provider = provider("self-hosted",
|
||||
"http://127.0.0.1:" + server.getAddress().getPort());
|
||||
|
||||
var result = new RemoteModelHttpClient(new ObjectMapper())
|
||||
.getJson(provider, "/v1/models", Map.of());
|
||||
|
||||
Assert.assertEquals("test-model", result.path("data").path(0).path("id").asText());
|
||||
Assert.assertEquals("gzip", acceptEncoding.get());
|
||||
} finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 压缩测试响应内容。
|
||||
*
|
||||
* @param content 原始响应内容
|
||||
* @return gzip 压缩后的字节
|
||||
* @throws IOException 压缩失败时抛出
|
||||
*/
|
||||
private byte[] gzip(String content) throws IOException {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
try (GZIPOutputStream gzip = new GZIPOutputStream(output)) {
|
||||
gzip.write(content.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
return output.toByteArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试服务商。
|
||||
*
|
||||
* @param providerType 服务商类型
|
||||
* @param endpoint API 地址
|
||||
* @return 测试服务商
|
||||
*/
|
||||
private ModelProvider provider(String providerType, String endpoint) {
|
||||
ModelProvider provider = new ModelProvider();
|
||||
provider.setProviderType(providerType);
|
||||
provider.setEndpoint(endpoint);
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package tech.easyflow.ai.service.discovery;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
import tech.easyflow.ai.entity.ModelProvider;
|
||||
import tech.easyflow.ai.mapper.ModelMapper;
|
||||
import tech.easyflow.ai.mapper.ModelProviderMapper;
|
||||
import tech.easyflow.ai.service.ModelProviderService;
|
||||
import tech.easyflow.ai.service.ModelService;
|
||||
import tech.easyflow.ai.service.capability.ModelCapabilityCatalog;
|
||||
import tech.easyflow.ai.service.capability.ModelCapabilityResolver;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.math.BigInteger;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 远端模型幂等导入服务测试。
|
||||
*/
|
||||
public class RemoteModelImportServiceTest {
|
||||
|
||||
/** 测试服务商 ID。 */
|
||||
private static final BigInteger PROVIDER_ID = BigInteger.valueOf(100);
|
||||
|
||||
/** 测试服务商。 */
|
||||
private ModelProvider provider;
|
||||
/** 预置的已存在模型。 */
|
||||
private Model existingModel;
|
||||
/** 保存调用次数。 */
|
||||
private final AtomicInteger saveCount = new AtomicInteger();
|
||||
/** 待测试服务。 */
|
||||
private RemoteModelImportService importService;
|
||||
|
||||
/**
|
||||
* 初始化测试夹具。
|
||||
*/
|
||||
@Before
|
||||
public void setUp() {
|
||||
provider = new ModelProvider();
|
||||
provider.setId(PROVIDER_ID);
|
||||
provider.setProviderType("openai");
|
||||
existingModel = null;
|
||||
saveCount.set(0);
|
||||
|
||||
ModelProviderService providerService = proxy(ModelProviderService.class,
|
||||
(method, arguments) -> "getById".equals(method) ? provider : defaultValue(method));
|
||||
ModelProviderMapper providerMapper = proxy(ModelProviderMapper.class,
|
||||
(method, arguments) -> "lockById".equals(method) ? PROVIDER_ID : defaultValue(method));
|
||||
ModelMapper modelMapper = proxy(ModelMapper.class,
|
||||
(method, arguments) -> "selectOneByQuery".equals(method)
|
||||
? existingModel : defaultValue(method));
|
||||
ModelService modelService = proxy(ModelService.class, (method, arguments) -> {
|
||||
if ("save".equals(method)) {
|
||||
Model target = (Model) arguments[0];
|
||||
target.setId(BigInteger.valueOf(201));
|
||||
saveCount.incrementAndGet();
|
||||
return true;
|
||||
}
|
||||
return defaultValue(method);
|
||||
});
|
||||
ModelCapabilityCatalog catalog = new ModelCapabilityCatalog(new ObjectMapper());
|
||||
RemoteModelMetadataResolver metadataResolver = new RemoteModelMetadataResolver(
|
||||
catalog, new ModelCapabilityResolver(catalog));
|
||||
importService = new RemoteModelImportService(
|
||||
providerService, providerMapper, modelService, modelMapper, metadataResolver);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证命中已有模型时返回幂等结果且不重复保存。
|
||||
*/
|
||||
@Test
|
||||
public void shouldReturnAlreadyExistsWithoutSaving() {
|
||||
Model existing = new Model();
|
||||
existing.setId(BigInteger.valueOf(200));
|
||||
existing.setModelName("gpt-5");
|
||||
existing.setModelType(Model.MODEL_TYPES[0]);
|
||||
existingModel = existing;
|
||||
|
||||
RemoteModelImportResult result = importService.importModel(
|
||||
PROVIDER_ID, "gpt-5", new Model());
|
||||
|
||||
Assert.assertEquals(RemoteModelImportStatus.ALREADY_EXISTS, result.getStatus());
|
||||
Assert.assertEquals(existing.getId(), result.getLocalModelId());
|
||||
Assert.assertEquals(0, saveCount.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证新模型完成默认值补全和保存。
|
||||
*/
|
||||
@Test
|
||||
public void shouldCreateModelWhenNotExists() {
|
||||
RemoteModelImportResult result = importService.importModel(
|
||||
PROVIDER_ID, " gpt-5 ", new Model());
|
||||
|
||||
Assert.assertEquals(RemoteModelImportStatus.CREATED, result.getStatus());
|
||||
Assert.assertEquals(BigInteger.valueOf(201), result.getLocalModelId());
|
||||
Assert.assertEquals(1, saveCount.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建按方法名返回结果的 JDK 动态接口替身。
|
||||
*
|
||||
* @param type 接口类型
|
||||
* @param handler 方法处理器
|
||||
* @param <T> 接口类型
|
||||
* @return 接口替身
|
||||
*/
|
||||
private <T> T proxy(Class<T> type, TestInvocationHandler handler) {
|
||||
Object value = Proxy.newProxyInstance(
|
||||
type.getClassLoader(),
|
||||
new Class<?>[]{type},
|
||||
(proxy, method, arguments) -> handler.invoke(method.getName(), arguments));
|
||||
return type.cast(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回方法返回类型的基础默认值。
|
||||
*
|
||||
* @param methodName 方法名
|
||||
* @return 默认值
|
||||
*/
|
||||
private Object defaultValue(String methodName) {
|
||||
if ("count".equals(methodName)) {
|
||||
return 0L;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试接口方法处理器。
|
||||
*/
|
||||
@FunctionalInterface
|
||||
private interface TestInvocationHandler {
|
||||
|
||||
/**
|
||||
* 处理接口方法调用。
|
||||
*
|
||||
* @param method 方法名
|
||||
* @param arguments 方法参数
|
||||
* @return 方法结果
|
||||
*/
|
||||
Object invoke(String method, Object[] arguments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package tech.easyflow.ai.service.discovery;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
import tech.easyflow.ai.service.capability.ModelCapabilityCatalog;
|
||||
import tech.easyflow.ai.service.capability.ModelCapabilityResolver;
|
||||
|
||||
/**
|
||||
* 远端模型目录元数据和默认值补全测试。
|
||||
*/
|
||||
public class RemoteModelMetadataResolverTest {
|
||||
|
||||
/** 待测试元数据解析器。 */
|
||||
private RemoteModelMetadataResolver resolver;
|
||||
|
||||
/**
|
||||
* 加载真实静态模型目录。
|
||||
*/
|
||||
@Before
|
||||
public void setUp() {
|
||||
ModelCapabilityCatalog catalog = new ModelCapabilityCatalog(new ObjectMapper());
|
||||
resolver = new RemoteModelMetadataResolver(catalog, new ModelCapabilityResolver(catalog));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 BAAI 模型使用目录名称、家族和向量类型。
|
||||
*/
|
||||
@Test
|
||||
public void shouldEnrichBaaiEmbeddingModel() {
|
||||
RemoteModelDescriptor result = resolver.describe(null, "BAAI/bge-m3", false);
|
||||
|
||||
Assert.assertEquals("BGE-M3", result.getDisplayName());
|
||||
Assert.assertEquals("bge", result.getFamily());
|
||||
Assert.assertEquals(Model.MODEL_TYPES[1], result.getModelType());
|
||||
Assert.assertTrue(result.isAddable());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证未知模型使用保守对话默认值。
|
||||
*/
|
||||
@Test
|
||||
public void shouldConfigureUnknownModelConservatively() {
|
||||
Model model = new Model();
|
||||
|
||||
resolver.configureNewModel(model, "self-hosted", "team/custom-model");
|
||||
|
||||
Assert.assertEquals("team/custom-model", model.getTitle());
|
||||
Assert.assertEquals("其他模型", model.getGroupName());
|
||||
Assert.assertEquals(Model.MODEL_TYPES[0], model.getModelType());
|
||||
Assert.assertNull(model.getSupportTool());
|
||||
Assert.assertEquals(Boolean.FALSE, model.getSupportVideo());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证已知图片生成模型不会进入当前可添加范围。
|
||||
*/
|
||||
@Test
|
||||
public void shouldIdentifyUnsupportedImageGenerationModel() {
|
||||
Assert.assertTrue(resolver.isUnsupportedGenerationModel("openai", "gpt-image-1"));
|
||||
Assert.assertTrue(resolver.isUnsupportedGenerationModel("openai", "gpt-image-1.5"));
|
||||
Assert.assertFalse(resolver.isUnsupportedGenerationModel("openai", "gpt-5"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package tech.easyflow.ai.service.discovery;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.entity.ModelProvider;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 三类远端模型目录适配器测试。
|
||||
*/
|
||||
public class RemoteModelProviderAdapterTest {
|
||||
|
||||
/** JSON 解析器。 */
|
||||
private ObjectMapper objectMapper;
|
||||
/** 受控 HTTP 客户端替身。 */
|
||||
private StubRemoteModelHttpClient httpClient;
|
||||
/** 服务商配置。 */
|
||||
private ModelProvider provider;
|
||||
|
||||
/**
|
||||
* 初始化测试夹具。
|
||||
*/
|
||||
@Before
|
||||
public void setUp() {
|
||||
objectMapper = new ObjectMapper();
|
||||
httpClient = new StubRemoteModelHttpClient(objectMapper);
|
||||
provider = new ModelProvider();
|
||||
provider.setChatPath("/v1/chat/completions");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 OpenAI-compatible 路径推导和 data.id 解析。
|
||||
*
|
||||
* @throws Exception JSON 夹具解析失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldParseOpenAiCompatibleModels() throws Exception {
|
||||
provider.setProviderType("openai");
|
||||
httpClient.addResponse("{\"data\":[{\"id\":\"gpt-5\"}]}");
|
||||
|
||||
List<String> result = new OpenAiCompatibleRemoteModelAdapter()
|
||||
.fetchModelIds(provider, httpClient);
|
||||
|
||||
Assert.assertEquals(List.of("gpt-5"), result);
|
||||
Assert.assertEquals("/v1/models", httpClient.lastPath);
|
||||
Assert.assertEquals(Map.of(), httpClient.lastQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Ollama 优先读取 name 并兼容 model 字段。
|
||||
*
|
||||
* @throws Exception JSON 夹具解析失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldParseOllamaModels() throws Exception {
|
||||
httpClient.addResponse(
|
||||
"{\"models\":[{\"name\":\"qwen3:8b\"},{\"model\":\"bge-m3:latest\"}]}");
|
||||
|
||||
List<String> result = new OllamaRemoteModelAdapter().fetchModelIds(provider, httpClient);
|
||||
|
||||
Assert.assertEquals(List.of("qwen3:8b", "bge-m3:latest"), result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证阿里百炼模型名称和分页字段解析。
|
||||
*
|
||||
* @throws Exception JSON 夹具解析失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldParseAliyunModels() throws Exception {
|
||||
httpClient.addResponse(
|
||||
"{\"request_id\":\"request-1\",\"output\":{"
|
||||
+ "\"page_no\":1,\"page_size\":100,\"total\":1,"
|
||||
+ "\"models\":[{\"model_name\":\"qwen-plus\"}]}}");
|
||||
|
||||
List<String> result = new AliyunRemoteModelAdapter().fetchModelIds(provider, httpClient);
|
||||
|
||||
Assert.assertEquals(List.of("qwen-plus"), result);
|
||||
Assert.assertEquals("/api/v1/deployments/models", httpClient.lastPath);
|
||||
Assert.assertEquals(Map.of(
|
||||
"model_source", "base",
|
||||
"page_no", "1",
|
||||
"page_size", "100",
|
||||
"version", "v1.0"), httpClient.lastQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证阿里百炼历史根级响应仍可解析。
|
||||
*
|
||||
* @throws Exception JSON 夹具解析失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldKeepAliyunLegacyResponseCompatibility() throws Exception {
|
||||
httpClient.addResponse(
|
||||
"{\"models\":[{\"model_name\":\"qwen-turbo\"}],\"total_count\":1}");
|
||||
|
||||
List<String> result = new AliyunRemoteModelAdapter().fetchModelIds(provider, httpClient);
|
||||
|
||||
Assert.assertEquals(List.of("qwen-turbo"), result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证百炼媒体生成和内部算法模型不会被误标为对话模型。
|
||||
*
|
||||
* @throws Exception JSON 夹具解析失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldExcludeUnsupportedAliyunModels() throws Exception {
|
||||
httpClient.addResponse(
|
||||
"{\"output\":{\"page_no\":1,\"page_size\":100,\"total\":7,"
|
||||
+ "\"models\":["
|
||||
+ "{\"model_name\":\"animate-anyone\"},"
|
||||
+ "{\"model_name\":\"animate-anyone-detect\"},"
|
||||
+ "{\"model_name\":\"emo\"},"
|
||||
+ "{\"model_name\":\"emo-detect\"},"
|
||||
+ "{\"model_name\":\"mock-algo-v1\"},"
|
||||
+ "{\"model_name\":\"wanx-v1-0521\"},"
|
||||
+ "{\"model_name\":\"qwen-plus\"}]}}"
|
||||
);
|
||||
|
||||
List<String> result = new AliyunRemoteModelAdapter().fetchModelIds(provider, httpClient);
|
||||
|
||||
Assert.assertEquals(List.of("qwen-plus"), result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 以队列响应替代真实网络请求的轻量测试客户端。
|
||||
*/
|
||||
private static final class StubRemoteModelHttpClient extends RemoteModelHttpClient {
|
||||
|
||||
/** JSON 解析器。 */
|
||||
private final ObjectMapper objectMapper;
|
||||
/** 待返回响应队列。 */
|
||||
private final Deque<String> responses = new ArrayDeque<>();
|
||||
/** 最近请求路径。 */
|
||||
private String lastPath;
|
||||
/** 最近查询参数。 */
|
||||
private Map<String, String> lastQuery;
|
||||
|
||||
/**
|
||||
* 创建测试客户端。
|
||||
*
|
||||
* @param objectMapper JSON 解析器
|
||||
*/
|
||||
private StubRemoteModelHttpClient(ObjectMapper objectMapper) {
|
||||
super(objectMapper);
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加下一次请求返回的 JSON。
|
||||
*
|
||||
* @param response JSON 文本
|
||||
*/
|
||||
private void addResponse(String response) {
|
||||
responses.addLast(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回预置 JSON 并记录请求参数。
|
||||
*
|
||||
* @param provider 服务商配置
|
||||
* @param requestPath 请求路径
|
||||
* @param queryParameters 查询参数
|
||||
* @return 预置 JSON 根节点
|
||||
*/
|
||||
@Override
|
||||
public com.fasterxml.jackson.databind.JsonNode getJson(
|
||||
ModelProvider provider,
|
||||
String requestPath,
|
||||
Map<String, String> queryParameters) {
|
||||
lastPath = requestPath;
|
||||
lastQuery = Map.copyOf(queryParameters);
|
||||
try {
|
||||
return objectMapper.readTree(responses.removeFirst());
|
||||
} catch (Exception exception) {
|
||||
throw new AssertionError("测试 JSON 解析失败", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 扩展模型 ID 长度,并以原始大小写稳定区分同一租户、服务商下的模型。
|
||||
-- 若存量存在完全相同的重复记录,唯一索引创建会明确失败,需人工确认后清理。
|
||||
ALTER TABLE `tb_model`
|
||||
MODIFY COLUMN `model_name` varchar(255)
|
||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '大模型名称';
|
||||
|
||||
ALTER TABLE `tb_model`
|
||||
ADD UNIQUE INDEX `uni_model_provider_name`
|
||||
(`tenant_id`, `provider_id`, `model_name`) USING BTREE;
|
||||
@@ -37,6 +37,47 @@ export async function verifyModelConfig(id: string) {
|
||||
return api.get('/api/v1/model/verifyLlmConfig', { params: { id } });
|
||||
}
|
||||
|
||||
export type ModelCapabilitySource = 'CATALOG' | 'DEFAULT' | 'RULE';
|
||||
|
||||
export interface RemoteModelDescriptor {
|
||||
addable: boolean;
|
||||
added: boolean;
|
||||
capabilitySource: ModelCapabilitySource;
|
||||
displayName: string;
|
||||
family: string;
|
||||
modelId: string;
|
||||
modelType: 'chatModel' | 'embeddingModel' | 'rerankModel';
|
||||
supportImage?: boolean | null;
|
||||
supportThinking?: boolean | null;
|
||||
supportTool?: boolean | null;
|
||||
unavailableReason?: null | string;
|
||||
}
|
||||
|
||||
export interface RemoteModelListData {
|
||||
models: RemoteModelDescriptor[];
|
||||
providerId: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export type RemoteModelImportStatus = 'ALREADY_EXISTS' | 'CREATED';
|
||||
|
||||
export interface RemoteModelImportData {
|
||||
localModelId: string;
|
||||
modelId: string;
|
||||
modelType: RemoteModelDescriptor['modelType'];
|
||||
status: RemoteModelImportStatus;
|
||||
}
|
||||
|
||||
export async function getRemoteModels(providerId: string) {
|
||||
return api.get(`/api/v1/modelProvider/${providerId}/remoteModels`);
|
||||
}
|
||||
|
||||
export async function importRemoteModel(providerId: string, modelId: string) {
|
||||
return api.post(`/api/v1/modelProvider/${providerId}/remoteModels/import`, {
|
||||
modelId,
|
||||
});
|
||||
}
|
||||
|
||||
export type ModelVerificationStageStatus =
|
||||
| 'FAILED'
|
||||
| 'PARTIAL'
|
||||
|
||||
@@ -3,7 +3,14 @@ import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import { Delete, Edit, Plus, Select, Setting } from '@element-plus/icons-vue';
|
||||
import {
|
||||
Delete,
|
||||
Edit,
|
||||
Plus,
|
||||
Refresh,
|
||||
Select,
|
||||
Setting,
|
||||
} from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElEmpty,
|
||||
@@ -13,6 +20,7 @@ import {
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
ElTag,
|
||||
ElTooltip,
|
||||
} from 'element-plus';
|
||||
|
||||
import { getLlmProviderList } from '#/api/ai/llm.js';
|
||||
@@ -31,6 +39,7 @@ import {
|
||||
} from '#/views/ai/model/modelUtils/providerDraft';
|
||||
import ModelVerifyConfig from '#/views/ai/model/ModelVerifyConfig.vue';
|
||||
import ModelViewItemOperation from '#/views/ai/model/ModelViewItemOperation.vue';
|
||||
import RemoteModelDialog from '#/views/ai/model/RemoteModelDialog.vue';
|
||||
import UnifiedGatewayWorkspace from '#/views/ai/model/UnifiedGatewayWorkspace.vue';
|
||||
|
||||
type ModelWorkspaceView = 'active' | 'gateway' | 'provider';
|
||||
@@ -61,6 +70,7 @@ const llmVerifyConfigRef = ref();
|
||||
const addLlmRef = ref();
|
||||
const activeWorkspaceRef = ref();
|
||||
const unifiedGatewayWorkspaceRef = ref();
|
||||
const remoteModelDialogRef = ref();
|
||||
|
||||
const selectedProvider = computed(() =>
|
||||
providers.value.find((item) => item.id === selectedProviderId.value),
|
||||
@@ -119,6 +129,19 @@ const isProviderDirty = computed(() =>
|
||||
isProviderDraftDirty(selectedProvider.value, providerDraft.value),
|
||||
);
|
||||
|
||||
const remoteModelsDisabledReason = computed(() => {
|
||||
if (!selectedProvider.value) {
|
||||
return '请先选择模型服务商';
|
||||
}
|
||||
if (isProviderDirty.value) {
|
||||
return '请先保存服务商配置';
|
||||
}
|
||||
if (!selectedProvider.value.endpoint?.trim()) {
|
||||
return '请先配置并保存 API 地址';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const currentProviderMetrics = computed(() =>
|
||||
getProviderConfigMetrics(
|
||||
{
|
||||
@@ -405,6 +428,24 @@ const handleAddLlm = (modelType = activeModelType.value) => {
|
||||
addLlmRef.value.openAddDialog(targetModelType);
|
||||
};
|
||||
|
||||
const openRemoteModelDialog = () => {
|
||||
if (remoteModelsDisabledReason.value) {
|
||||
return;
|
||||
}
|
||||
remoteModelDialogRef.value?.openDialog?.(
|
||||
selectedProviderId.value,
|
||||
selectedProvider.value?.providerName || '',
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoteManualAdd = () => {
|
||||
handleAddLlm(actionModelType.value);
|
||||
};
|
||||
|
||||
const handleRemoteModelImported = async () => {
|
||||
await loadProviderDetail(selectedProviderId.value, { keepDraft: true });
|
||||
};
|
||||
|
||||
const handleDeleteLlm = (id: string) => {
|
||||
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
||||
confirmButtonText: $t('message.ok'),
|
||||
@@ -722,6 +763,21 @@ onMounted(() => {
|
||||
<p>按模型能力分组管理已配置模型。</p>
|
||||
</div>
|
||||
<div class="provider-card__head-actions">
|
||||
<ElTooltip
|
||||
:disabled="!remoteModelsDisabledReason"
|
||||
:content="remoteModelsDisabledReason"
|
||||
placement="top"
|
||||
>
|
||||
<span>
|
||||
<ElButton
|
||||
:icon="Refresh"
|
||||
:disabled="Boolean(remoteModelsDisabledReason)"
|
||||
@click="openRemoteModelDialog"
|
||||
>
|
||||
获取模型列表
|
||||
</ElButton>
|
||||
</span>
|
||||
</ElTooltip>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
@@ -825,6 +881,11 @@ onMounted(() => {
|
||||
@reload="handleModelDataReload"
|
||||
/>
|
||||
<ModelVerifyConfig ref="llmVerifyConfigRef" />
|
||||
<RemoteModelDialog
|
||||
ref="remoteModelDialogRef"
|
||||
@imported="handleRemoteModelImported"
|
||||
@manual-add="handleRemoteManualAdd"
|
||||
/>
|
||||
</ListPageShell>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import RemoteModelDialog from './RemoteModelDialog.vue';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
getRemoteModels: vi.fn(),
|
||||
importRemoteModel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('#/api/ai/llm', () => apiMocks);
|
||||
vi.mock('@easyflow/common-ui', async () => {
|
||||
const { defineComponent, h } = await import('vue');
|
||||
return {
|
||||
EasyFlowFormModal: defineComponent({
|
||||
props: {
|
||||
open: Boolean,
|
||||
title: { default: '', type: String },
|
||||
},
|
||||
emits: ['update:open'],
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
props.open
|
||||
? h('section', [h('h2', props.title), slots.default?.()])
|
||||
: null;
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const mountDialog = () =>
|
||||
mount(RemoteModelDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
Transition: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('remote model dialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMocks.getRemoteModels.mockResolvedValue({
|
||||
data: {
|
||||
models: [
|
||||
{
|
||||
addable: true,
|
||||
added: false,
|
||||
capabilitySource: 'CATALOG',
|
||||
displayName: 'BGE-M3',
|
||||
family: 'bge',
|
||||
modelId: 'BAAI/bge-m3',
|
||||
modelType: 'embeddingModel',
|
||||
supportImage: false,
|
||||
supportThinking: false,
|
||||
supportTool: false,
|
||||
},
|
||||
],
|
||||
providerId: '100',
|
||||
truncated: false,
|
||||
},
|
||||
errorCode: 0,
|
||||
});
|
||||
apiMocks.importRemoteModel.mockResolvedValue({
|
||||
data: { localModelId: '200', status: 'CREATED' },
|
||||
errorCode: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('loads remote models and keeps the dialog open after one-click import', async () => {
|
||||
const wrapper = mountDialog();
|
||||
|
||||
wrapper.vm.openDialog('100', '测试服务商');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.getRemoteModels).toHaveBeenCalledWith('100');
|
||||
expect(wrapper.text()).toContain('BAAI/bge-m3');
|
||||
expect(wrapper.text()).not.toContain('BGE-M3');
|
||||
|
||||
await wrapper.get('button[aria-label="添加 BAAI/bge-m3"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.importRemoteModel).toHaveBeenCalledWith(
|
||||
'100',
|
||||
'BAAI/bge-m3',
|
||||
);
|
||||
expect(wrapper.text()).toContain('已添加');
|
||||
expect(wrapper.emitted('imported')).toHaveLength(1);
|
||||
expect(wrapper.get('h2').text()).toBe('测试服务商 模型');
|
||||
});
|
||||
|
||||
it('shows a retryable error and preserves manual add', async () => {
|
||||
apiMocks.getRemoteModels.mockRejectedValueOnce(
|
||||
new Error('当前服务暂不支持获取模型列表'),
|
||||
);
|
||||
const wrapper = mountDialog();
|
||||
|
||||
wrapper.vm.openDialog('100', '测试服务商');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('获取失败');
|
||||
expect(wrapper.text()).toContain('当前服务暂不支持获取模型列表');
|
||||
const manualAdd = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().trim() === '手动新增');
|
||||
await manualAdd?.trigger('click');
|
||||
|
||||
expect(wrapper.emitted('manualAdd')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('uses one scroll container and keeps semantic capability colors', async () => {
|
||||
apiMocks.getRemoteModels.mockResolvedValueOnce({
|
||||
data: {
|
||||
models: [
|
||||
{
|
||||
addable: true,
|
||||
added: false,
|
||||
capabilitySource: 'CATALOG',
|
||||
displayName: 'DeepSeek V4 Pro',
|
||||
family: 'deepseek-thinking',
|
||||
modelId: 'deepseek-v4-pro',
|
||||
modelType: 'chatModel',
|
||||
supportImage: false,
|
||||
supportThinking: true,
|
||||
supportTool: true,
|
||||
},
|
||||
],
|
||||
providerId: '100',
|
||||
truncated: false,
|
||||
},
|
||||
errorCode: 0,
|
||||
});
|
||||
const wrapper = mountDialog();
|
||||
|
||||
wrapper.vm.openDialog('100', '测试服务商');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('.el-vl__wrapper').exists()).toBe(false);
|
||||
expect(wrapper.find('.el-tag--info').text()).toBe('推理');
|
||||
expect(wrapper.find('.el-tag--warning').text()).toBe('工具');
|
||||
});
|
||||
|
||||
it('renders a flat list sorted by model id', async () => {
|
||||
apiMocks.getRemoteModels.mockResolvedValueOnce({
|
||||
data: {
|
||||
models: [
|
||||
{
|
||||
addable: true,
|
||||
added: false,
|
||||
capabilitySource: 'CATALOG',
|
||||
displayName: 'Zeta Model',
|
||||
family: 'zeta-family',
|
||||
modelId: 'zeta/model',
|
||||
modelType: 'chatModel',
|
||||
supportImage: false,
|
||||
supportThinking: false,
|
||||
supportTool: false,
|
||||
},
|
||||
{
|
||||
addable: true,
|
||||
added: false,
|
||||
capabilitySource: 'CATALOG',
|
||||
displayName: 'Alpha Model',
|
||||
family: 'alpha-family',
|
||||
modelId: 'alpha/model',
|
||||
modelType: 'chatModel',
|
||||
supportImage: false,
|
||||
supportThinking: false,
|
||||
supportTool: false,
|
||||
},
|
||||
],
|
||||
providerId: '100',
|
||||
truncated: false,
|
||||
},
|
||||
errorCode: 0,
|
||||
});
|
||||
const wrapper = mountDialog();
|
||||
|
||||
wrapper.vm.openDialog('100', '测试服务商');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('.remote-model-dialog__group').exists()).toBe(false);
|
||||
expect(
|
||||
wrapper
|
||||
.findAll('.remote-model-row__identity strong')
|
||||
.map((item) => item.text()),
|
||||
).toEqual(['alpha/model', 'zeta/model']);
|
||||
expect(wrapper.text()).not.toContain('alpha-family');
|
||||
expect(wrapper.text()).not.toContain('zeta-family');
|
||||
});
|
||||
});
|
||||
494
easyflow-ui-admin/app/src/views/ai/model/RemoteModelDialog.vue
Normal file
494
easyflow-ui-admin/app/src/views/ai/model/RemoteModelDialog.vue
Normal file
@@ -0,0 +1,494 @@
|
||||
<script setup lang="ts">
|
||||
import type { RemoteModelDescriptor, RemoteModelListData } from '#/api/ai/llm';
|
||||
|
||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||
|
||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||
|
||||
import { Check, Plus, Refresh, Search } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElEmpty,
|
||||
ElIcon,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElSegmented,
|
||||
ElSkeleton,
|
||||
ElTag,
|
||||
} from 'element-plus';
|
||||
|
||||
import { getRemoteModels, importRemoteModel } from '#/api/ai/llm';
|
||||
|
||||
type ModelTypeFilter =
|
||||
| 'allModel'
|
||||
| 'chatModel'
|
||||
| 'embeddingModel'
|
||||
| 'rerankModel';
|
||||
|
||||
const emit = defineEmits<{
|
||||
imported: [];
|
||||
manualAdd: [];
|
||||
}>();
|
||||
|
||||
const visible = ref(false);
|
||||
const providerId = ref('');
|
||||
const providerName = ref('');
|
||||
const models = ref<RemoteModelDescriptor[]>([]);
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const truncated = ref(false);
|
||||
const searchText = ref('');
|
||||
const debouncedSearch = ref('');
|
||||
const activeType = ref<ModelTypeFilter>('allModel');
|
||||
const importingIds = ref(new Set<string>());
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let requestSequence = 0;
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '全部', value: 'allModel' },
|
||||
{ label: '对话', value: 'chatModel' },
|
||||
{ label: '向量', value: 'embeddingModel' },
|
||||
{ label: '重排', value: 'rerankModel' },
|
||||
];
|
||||
|
||||
const typeLabelMap: Record<RemoteModelDescriptor['modelType'], string> = {
|
||||
chatModel: '对话',
|
||||
embeddingModel: '向量',
|
||||
rerankModel: '重排',
|
||||
};
|
||||
|
||||
const getTypeLabel = (modelType: RemoteModelDescriptor['modelType']) =>
|
||||
typeLabelMap[modelType] || '对话';
|
||||
|
||||
const filteredModels = computed(() => {
|
||||
const keyword = debouncedSearch.value.trim().toLowerCase();
|
||||
return models.value
|
||||
.filter((model) => {
|
||||
const typeMatched =
|
||||
activeType.value === 'allModel' || model.modelType === activeType.value;
|
||||
if (!typeMatched || !keyword) {
|
||||
return typeMatched;
|
||||
}
|
||||
return model.modelId.toLowerCase().includes(keyword);
|
||||
})
|
||||
.sort((left, right) =>
|
||||
left.modelId.localeCompare(right.modelId, undefined, {
|
||||
sensitivity: 'base',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const hasFilters = computed(
|
||||
() => Boolean(searchText.value.trim()) || activeType.value !== 'allModel',
|
||||
);
|
||||
|
||||
const emptyDescription = computed(() =>
|
||||
hasFilters.value ? '没有匹配的模型' : '远端没有返回可添加的模型',
|
||||
);
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
providerName.value ? `${providerName.value} 模型` : '获取模型列表',
|
||||
);
|
||||
|
||||
const handleSearchInput = () => {
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer);
|
||||
}
|
||||
searchTimer = setTimeout(() => {
|
||||
debouncedSearch.value = searchText.value;
|
||||
}, 160);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
searchText.value = '';
|
||||
debouncedSearch.value = '';
|
||||
activeType.value = 'allModel';
|
||||
};
|
||||
|
||||
const requestModels = async () => {
|
||||
if (!providerId.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
const sequence = ++requestSequence;
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const response = await getRemoteModels(providerId.value);
|
||||
if (sequence !== requestSequence) {
|
||||
return;
|
||||
}
|
||||
if (response?.errorCode !== 0) {
|
||||
throw new Error(response?.message || '获取模型列表失败');
|
||||
}
|
||||
const data = (response.data || {}) as RemoteModelListData;
|
||||
models.value = Array.isArray(data.models) ? data.models : [];
|
||||
truncated.value = Boolean(data.truncated);
|
||||
} catch (error: any) {
|
||||
if (sequence === requestSequence) {
|
||||
models.value = [];
|
||||
truncated.value = false;
|
||||
errorMessage.value =
|
||||
error?.response?.data?.message || error?.message || '获取模型列表失败';
|
||||
}
|
||||
} finally {
|
||||
if (sequence === requestSequence) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async (model: RemoteModelDescriptor) => {
|
||||
if (model.added || !model.addable || importingIds.value.has(model.modelId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
importingIds.value = new Set(importingIds.value).add(model.modelId);
|
||||
try {
|
||||
const response = await importRemoteModel(providerId.value, model.modelId);
|
||||
if (response?.errorCode !== 0) {
|
||||
throw new Error(response?.message || '添加模型失败');
|
||||
}
|
||||
models.value = models.value.map((item) =>
|
||||
item.modelId === model.modelId ? { ...item, added: true } : item,
|
||||
);
|
||||
ElMessage.success(
|
||||
response.data?.status === 'ALREADY_EXISTS' ? '模型已添加' : '添加成功',
|
||||
);
|
||||
emit('imported');
|
||||
} catch (error: any) {
|
||||
ElMessage.error(
|
||||
error?.response?.data?.message || error?.message || '添加模型失败',
|
||||
);
|
||||
} finally {
|
||||
const nextIds = new Set(importingIds.value);
|
||||
nextIds.delete(model.modelId);
|
||||
importingIds.value = nextIds;
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualAdd = () => {
|
||||
visible.value = false;
|
||||
emit('manualAdd');
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
visible.value = open;
|
||||
if (!open) {
|
||||
requestSequence += 1;
|
||||
}
|
||||
};
|
||||
|
||||
const openDialog = (nextProviderId: string, nextProviderName = '') => {
|
||||
providerId.value = nextProviderId;
|
||||
providerName.value = nextProviderName;
|
||||
models.value = [];
|
||||
importingIds.value = new Set();
|
||||
errorMessage.value = '';
|
||||
truncated.value = false;
|
||||
clearFilters();
|
||||
visible.value = true;
|
||||
void requestModels();
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer);
|
||||
}
|
||||
requestSequence += 1;
|
||||
});
|
||||
|
||||
defineExpose({ openDialog });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EasyFlowFormModal
|
||||
:open="visible"
|
||||
:title="dialogTitle"
|
||||
width="800px"
|
||||
:show-footer="false"
|
||||
@update:open="handleOpenChange"
|
||||
>
|
||||
<div class="remote-model-dialog">
|
||||
<div class="remote-model-dialog__toolbar">
|
||||
<ElInput
|
||||
v-model="searchText"
|
||||
clearable
|
||||
:disabled="loading || Boolean(errorMessage)"
|
||||
placeholder="搜索模型 ID"
|
||||
aria-label="搜索远端模型"
|
||||
@input="handleSearchInput"
|
||||
@clear="handleSearchInput"
|
||||
>
|
||||
<template #prefix>
|
||||
<ElIcon><Search /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
<ElButton
|
||||
:icon="Refresh"
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
aria-label="刷新远端模型列表"
|
||||
@click="requestModels"
|
||||
>
|
||||
刷新
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div class="remote-model-dialog__filters">
|
||||
<ElSegmented
|
||||
v-model="activeType"
|
||||
:options="typeOptions"
|
||||
aria-label="按模型类型筛选"
|
||||
/>
|
||||
<span
|
||||
v-if="!loading && !errorMessage"
|
||||
class="remote-model-dialog__count"
|
||||
>
|
||||
{{ filteredModels.length }} 个模型
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="remote-model-dialog__loading"
|
||||
aria-live="polite"
|
||||
>
|
||||
<ElSkeleton v-for="item in 5" :key="item" animated :rows="1" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="errorMessage" class="remote-model-dialog__state">
|
||||
<strong>获取失败</strong>
|
||||
<p>{{ errorMessage }}</p>
|
||||
<div class="remote-model-dialog__state-actions">
|
||||
<ElButton type="primary" :icon="Refresh" @click="requestModels">
|
||||
重新获取
|
||||
</ElButton>
|
||||
<ElButton @click="handleManualAdd">手动新增</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="filteredModels.length === 0"
|
||||
class="remote-model-dialog__state"
|
||||
>
|
||||
<ElEmpty :description="emptyDescription">
|
||||
<ElButton v-if="hasFilters" @click="clearFilters">清空筛选</ElButton>
|
||||
<ElButton v-else :icon="Refresh" @click="requestModels">
|
||||
重新获取
|
||||
</ElButton>
|
||||
</ElEmpty>
|
||||
</div>
|
||||
|
||||
<div v-else class="remote-model-dialog__list">
|
||||
<div
|
||||
v-for="model in filteredModels"
|
||||
:key="model.modelId"
|
||||
class="remote-model-row"
|
||||
>
|
||||
<div class="remote-model-row__identity">
|
||||
<strong :title="model.modelId">
|
||||
{{ model.modelId }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="remote-model-row__abilities">
|
||||
<ElTag size="small" effect="plain">
|
||||
{{ getTypeLabel(model.modelType) }}
|
||||
</ElTag>
|
||||
<ElTag
|
||||
v-if="model.supportImage"
|
||||
size="small"
|
||||
effect="plain"
|
||||
type="success"
|
||||
>
|
||||
视觉
|
||||
</ElTag>
|
||||
<ElTag
|
||||
v-if="model.supportThinking"
|
||||
size="small"
|
||||
effect="plain"
|
||||
type="info"
|
||||
>
|
||||
推理
|
||||
</ElTag>
|
||||
<ElTag
|
||||
v-if="model.supportTool"
|
||||
size="small"
|
||||
effect="plain"
|
||||
type="warning"
|
||||
>
|
||||
工具
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="remote-model-row__action">
|
||||
<span v-if="model.added" class="remote-model-row__added">
|
||||
<ElIcon><Check /></ElIcon>
|
||||
已添加
|
||||
</span>
|
||||
<ElButton
|
||||
v-else
|
||||
circle
|
||||
text
|
||||
:icon="Plus"
|
||||
:loading="importingIds.has(model.modelId)"
|
||||
:disabled="!model.addable || importingIds.has(model.modelId)"
|
||||
:title="model.unavailableReason || '添加模型'"
|
||||
:aria-label="`添加 ${model.modelId}`"
|
||||
@click="handleImport(model)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="truncated" class="remote-model-dialog__notice">
|
||||
模型数量较多,当前显示前 1000 项。
|
||||
</p>
|
||||
</div>
|
||||
</EasyFlowFormModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.remote-model-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.remote-model-dialog__toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.remote-model-dialog__filters {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.remote-model-dialog__count,
|
||||
.remote-model-dialog__notice {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.remote-model-dialog__loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.remote-model-dialog__state {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 280px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.remote-model-dialog__state strong {
|
||||
color: hsl(var(--text-strong));
|
||||
}
|
||||
|
||||
.remote-model-dialog__state p {
|
||||
max-width: 560px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.remote-model-dialog__state-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.remote-model-dialog__list {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.remote-model-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(160px, auto) 64px;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
min-height: 64px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid hsl(var(--divider-faint) / 42%);
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
|
||||
.remote-model-row:hover {
|
||||
background: hsl(var(--surface-contrast-soft) / 28%);
|
||||
}
|
||||
|
||||
.remote-model-row__identity {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.remote-model-row__identity strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--text-strong));
|
||||
}
|
||||
|
||||
.remote-model-row__abilities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.remote-model-row__action {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.remote-model-row__added {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--success));
|
||||
}
|
||||
|
||||
.remote-model-dialog__notice {
|
||||
margin: -8px 0 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.remote-model-dialog {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.remote-model-dialog__toolbar {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.remote-model-dialog__filters {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.remote-model-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.remote-model-row__abilities {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user