diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/ModelController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/ModelController.java index d2dfa1b5..a08a5471 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/ModelController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/ModelController.java @@ -9,7 +9,6 @@ import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.*; import tech.easyflow.ai.dto.ModelInvokeConfigDtos; import tech.easyflow.ai.entity.Model; -import tech.easyflow.ai.entity.ModelProvider; import tech.easyflow.ai.entity.table.ModelTableDef; import tech.easyflow.ai.mapper.ModelMapper; import tech.easyflow.ai.service.ModelService; @@ -27,7 +26,6 @@ import java.math.BigInteger; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.stream.Collectors; /** @@ -53,16 +51,7 @@ public class ModelController extends BaseCurdController { @GetMapping("list") @SaCheckPermission("/api/v1/model/query") public Result> list(Model entity, Boolean asTree, String sortKey, String sortType) { - QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity)); - queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy())); - List list = Tree.tryToTree(modelMapper.selectListWithRelationsByQuery(queryWrapper), asTree); - list.forEach(item -> { - String providerName = Optional.ofNullable(item.getModelProvider()) - .map(ModelProvider::getProviderName) - .orElse("-"); - item.setTitle(providerName + "/" + item.getTitle()); - }); - return Result.ok(list); + return Result.ok(service.listSelectableModels(entity, asTree, sortKey, sortType)); } @GetMapping("getList") diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/ModelControllerTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/ModelControllerTest.java new file mode 100644 index 00000000..16fac409 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/ModelControllerTest.java @@ -0,0 +1,41 @@ +package tech.easyflow.admin.controller.ai; + +import org.testng.Assert; +import org.testng.annotations.Test; +import tech.easyflow.ai.entity.Model; +import tech.easyflow.ai.service.ModelService; +import tech.easyflow.common.domain.Result; + +import java.util.List; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 模型列表控制器测试。 + */ +public class ModelControllerTest { + + /** + * 验证模型列表使用包含目录能力元数据的服务查询。 + */ + @Test + public void listShouldReturnCapabilityDecoratedModels() { + ModelService service = mock(ModelService.class); + Model query = new Model(); + Model decoratedModel = new Model(); + decoratedModel.setContextWindowTokens(1_000_000L); + decoratedModel.setMaxOutputTokens(64_000L); + when(service.listSelectableModels(query, false, "id", "desc")) + .thenReturn(List.of(decoratedModel)); + ModelController controller = new ModelController(service); + + Result> result = controller.list(query, false, "id", "desc"); + + Assert.assertSame(decoratedModel, result.getData().get(0)); + Assert.assertEquals(Long.valueOf(1_000_000L), + result.getData().get(0).getContextWindowTokens()); + verify(service).listSelectableModels(query, false, "id", "desc"); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/Model.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/Model.java index 62d8307b..2f4ddef3 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/Model.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/Model.java @@ -20,6 +20,7 @@ import com.easyagents.rerank.DefaultRerankModel; import com.easyagents.rerank.DefaultRerankModelConfig; import com.easyagents.rerank.gitee.GiteeRerankModel; import com.easyagents.rerank.gitee.GiteeRerankModelConfig; +import com.mybatisflex.annotation.Column; import com.mybatisflex.annotation.RelationManyToOne; import com.mybatisflex.annotation.Table; import tech.easyflow.ai.entity.base.ModelBase; @@ -39,6 +40,18 @@ public class Model extends ModelBase { @RelationManyToOne(selfField = "providerId", targetField = "id") private ModelProvider modelProvider; + /** + * 模型最大上下文窗口 Token 数。 + */ + @Column(ignore = true) + private Long contextWindowTokens; + + /** + * 模型最大输出 Token 数。 + */ + @Column(ignore = true) + private Long maxOutputTokens; + /** * 模型类型 */ @@ -53,6 +66,42 @@ public class Model extends ModelBase { this.modelProvider = modelProvider; } + /** + * 获取模型最大上下文窗口。 + * + * @return 最大上下文窗口 Token 数,目录未提供时返回 null + */ + public Long getContextWindowTokens() { + return contextWindowTokens; + } + + /** + * 设置模型最大上下文窗口。 + * + * @param contextWindowTokens 最大上下文窗口 Token 数 + */ + public void setContextWindowTokens(Long contextWindowTokens) { + this.contextWindowTokens = contextWindowTokens; + } + + /** + * 获取模型最大输出 Token 数。 + * + * @return 最大输出 Token 数,目录未提供时返回 null + */ + public Long getMaxOutputTokens() { + return maxOutputTokens; + } + + /** + * 设置模型最大输出 Token 数。 + * + * @param maxOutputTokens 最大输出 Token 数 + */ + public void setMaxOutputTokens(Long maxOutputTokens) { + this.maxOutputTokens = maxOutputTokens; + } + public ChatModel toChatModel() { String providerType = modelProvider.getProviderType(); if (StringUtil.noText(providerType)) { diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/capability/ModelCapabilityCatalog.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/capability/ModelCapabilityCatalog.java index b7098aed..77e2a5e3 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/capability/ModelCapabilityCatalog.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/capability/ModelCapabilityCatalog.java @@ -27,7 +27,7 @@ public class ModelCapabilityCatalog { /** 按规范化完整模型 ID 建立的目录索引。 */ private final Map metadataById; - /** 仅在模型短 ID 唯一时建立的目录别名索引。 */ + /** 仅在规范化模型短 ID 唯一时建立的目录别名索引。 */ private final Map metadataByAlias; /** @@ -83,7 +83,7 @@ public class ModelCapabilityCatalog { } } - String alias = shortId(normalizedId); + String alias = canonicalAlias(normalizedId); return Optional.ofNullable(metadataByAlias.get(alias)); } @@ -114,7 +114,7 @@ public class ModelCapabilityCatalog { } ModelCatalogMetadata metadata = toMetadata(normalizedId, field.getValue()); fullIdIndex.put(normalizedId, metadata); - registerAlias(shortId(normalizedId), metadata, aliasCandidates, ambiguousAliases); + registerAlias(canonicalAlias(normalizedId), metadata, aliasCandidates, ambiguousAliases); } } catch (IOException exception) { throw new IllegalStateException("无法加载模型能力库 " + CATALOG_RESOURCE, exception); @@ -147,20 +147,26 @@ public class ModelCapabilityCatalog { */ private ModelCapabilityResolution toCapability(String normalizedId, JsonNode node) { String modelType = resolveModelType(normalizedId); + Long contextWindowTokens = positiveLongValue(node.path("limit"), "context"); + Long maxOutputTokens = positiveLongValue(node.path("limit"), "output"); if (!Model.MODEL_TYPES[0].equals(modelType)) { return new ModelCapabilityResolution( modelType, Boolean.FALSE, Boolean.FALSE, Boolean.FALSE, - ModelCapabilitySource.CATALOG); + ModelCapabilitySource.CATALOG, + contextWindowTokens, + maxOutputTokens); } return new ModelCapabilityResolution( modelType, hasInputModality(node, "image"), booleanValue(node, "reasoning"), booleanValue(node, "tool_call"), - ModelCapabilitySource.CATALOG); + ModelCapabilitySource.CATALOG, + contextWindowTokens, + maxOutputTokens); } /** @@ -191,6 +197,22 @@ public class ModelCapabilityCatalog { return value != null && value.asBoolean(false); } + /** + * 读取正整数长整型字段。 + * + * @param node 字段所属节点 + * @param fieldName 字段名 + * @return 正整数值,字段缺失或非法时返回 null + */ + private Long positiveLongValue(JsonNode node, String fieldName) { + JsonNode value = node.get(fieldName); + if (value == null || !value.isIntegralNumber() || !value.canConvertToLong()) { + return null; + } + long number = value.longValue(); + return number > 0L ? number : null; + } + /** * 判断模型输入模态是否包含指定类型。 * @@ -309,4 +331,24 @@ public class ModelCapabilityCatalog { int separator = normalizedId.lastIndexOf('/'); return separator < 0 ? normalizedId : normalizedId.substring(separator + 1); } + + /** + * 生成忽略厂商前缀、大小写和常见连接符的模型短 ID 索引键。 + * + * @param normalizedId 已完成大小写与首尾空白规范化的模型 ID + * @return 保留版本点号、移除横线、下划线和空白字符的索引键 + */ + private String canonicalAlias(String normalizedId) { + String modelId = shortId(normalizedId); + StringBuilder alias = new StringBuilder(modelId.length()); + for (int index = 0; index < modelId.length(); index++) { + char character = modelId.charAt(index); + if (character == '-' || character == '_' + || Character.isWhitespace(character) || Character.isSpaceChar(character)) { + continue; + } + alias.append(character); + } + return alias.toString(); + } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/capability/ModelCapabilityResolution.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/capability/ModelCapabilityResolution.java index 46803a27..3d4a7c3d 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/capability/ModelCapabilityResolution.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/capability/ModelCapabilityResolution.java @@ -15,6 +15,10 @@ public final class ModelCapabilityResolution { private final Boolean supportTool; /** 能力识别来源。 */ private final ModelCapabilitySource source; + /** 模型最大上下文窗口 Token 数,空值表示目录未提供。 */ + private final Long contextWindowTokens; + /** 模型最大输出 Token 数,空值表示目录未提供。 */ + private final Long maxOutputTokens; /** * 创建模型能力识别结果。 @@ -30,11 +34,34 @@ public final class ModelCapabilityResolution { Boolean supportThinking, Boolean supportTool, ModelCapabilitySource source) { + this(modelType, supportImage, supportThinking, supportTool, source, null, null); + } + + /** + * 创建包含上下文窗口限制的模型能力识别结果。 + * + * @param modelType 模型类型 + * @param supportImage 是否支持视觉输入 + * @param supportThinking 是否支持推理 + * @param supportTool 是否支持工具调用 + * @param source 能力识别来源 + * @param contextWindowTokens 模型最大上下文窗口 Token 数 + * @param maxOutputTokens 模型最大输出 Token 数 + */ + public ModelCapabilityResolution(String modelType, + Boolean supportImage, + Boolean supportThinking, + Boolean supportTool, + ModelCapabilitySource source, + Long contextWindowTokens, + Long maxOutputTokens) { this.modelType = modelType; this.supportImage = supportImage; this.supportThinking = supportThinking; this.supportTool = supportTool; this.source = source; + this.contextWindowTokens = contextWindowTokens; + this.maxOutputTokens = maxOutputTokens; } /** @@ -82,6 +109,24 @@ public final class ModelCapabilityResolution { return source; } + /** + * 获取模型最大上下文窗口。 + * + * @return 最大上下文窗口 Token 数,目录未提供时返回 null + */ + public Long getContextWindowTokens() { + return contextWindowTokens; + } + + /** + * 获取模型最大输出 Token 数。 + * + * @return 最大输出 Token 数,目录未提供时返回 null + */ + public Long getMaxOutputTokens() { + return maxOutputTokens; + } + /** * 判断是否获得了模型库或命名规则证据。 * diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ModelServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ModelServiceImpl.java index afcf7abb..487d7027 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ModelServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ModelServiceImpl.java @@ -420,10 +420,16 @@ public class ModelServiceImpl extends ServiceImpl implements if (model == null) { return; } - String providerName = Optional.ofNullable(model.getModelProvider()) + ModelProvider provider = model.getModelProvider(); + String providerName = Optional.ofNullable(provider) .map(ModelProvider::getProviderName) .orElse("-"); model.setTitle(providerName + "/" + model.getTitle()); + ModelCapabilityResolution resolution = modelCapabilityResolver.resolve( + resolveProviderType(model.getProviderId(), provider), + model.getModelName()); + model.setContextWindowTokens(resolution.getContextWindowTokens()); + model.setMaxOutputTokens(resolution.getMaxOutputTokens()); } private String buildOrderBy(String sortKey, String sortType) { diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/capability/ModelCapabilityResolverTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/capability/ModelCapabilityResolverTest.java index 969de590..4c1912f0 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/capability/ModelCapabilityResolverTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/capability/ModelCapabilityResolverTest.java @@ -34,6 +34,28 @@ public class ModelCapabilityResolverTest { Assert.assertEquals(Boolean.TRUE, result.getSupportImage()); Assert.assertEquals(Boolean.TRUE, result.getSupportThinking()); Assert.assertEquals(Boolean.TRUE, result.getSupportTool()); + Assert.assertEquals(Long.valueOf(1_000_000L), result.getContextWindowTokens()); + Assert.assertEquals(Long.valueOf(64_000L), result.getMaxOutputTokens()); + } + + /** + * 验证目录匹配忽略厂商前缀、大小写和常见连接符。 + */ + @Test + public void shouldResolveCatalogCapabilitiesByCanonicalShortId() { + String[] modelIds = { + "阿里百炼/QWEN3.7_PLUS", + "custom/qwen3.7 plus", + "qwen3.7plus" + }; + + for (String modelId : modelIds) { + ModelCapabilityResolution result = resolver.resolve(null, modelId); + + Assert.assertEquals(ModelCapabilitySource.CATALOG, result.getSource()); + Assert.assertEquals(Long.valueOf(1_000_000L), result.getContextWindowTokens()); + Assert.assertEquals(Long.valueOf(64_000L), result.getMaxOutputTokens()); + } } /** @@ -76,5 +98,7 @@ public class ModelCapabilityResolverTest { Assert.assertNull(result.getSupportImage()); Assert.assertNull(result.getSupportThinking()); Assert.assertNull(result.getSupportTool()); + Assert.assertNull(result.getContextWindowTokens()); + Assert.assertNull(result.getMaxOutputTokens()); } } diff --git a/easyflow-ui-admin/app/src/api/ai/llm.ts b/easyflow-ui-admin/app/src/api/ai/llm.ts index 7cc6766a..166432e7 100644 --- a/easyflow-ui-admin/app/src/api/ai/llm.ts +++ b/easyflow-ui-admin/app/src/api/ai/llm.ts @@ -43,6 +43,8 @@ export interface ModelCapabilityResolution { detected: boolean; modelType: 'chatModel' | 'embeddingModel' | 'rerankModel'; source: ModelCapabilitySource; + contextWindowTokens?: null | number; + maxOutputTokens?: null | number; supportImage?: boolean | null; supportThinking?: boolean | null; supportTool?: boolean | null; @@ -141,6 +143,8 @@ export interface llmType { providerId?: string; title: string; modelName?: string; + contextWindowTokens?: null | number; + maxOutputTokens?: null | number; modelProvider: { icon: string; providerName: string; diff --git a/easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue b/easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue index 217400ad..2cf1fccc 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue +++ b/easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue @@ -38,6 +38,7 @@ import AgentStudioCanvas from './components/agent-studio/AgentStudioCanvas.vue'; import AgentCommandBar from './components/AgentCommandBar.vue'; import AgentInspectorPanel from './components/AgentInspectorPanel.vue'; import { useAgentDesignerState } from './composables/useAgentDesignerState'; +import { resolveAgentCompressionTokenThreshold } from './compression-threshold'; import { createMcpToolLoader } from './mcpToolLoader'; const route = useRoute(); @@ -385,8 +386,25 @@ function runValidation() { return true; } +function syncAgentCompressionThreshold() { + const selectedModel = models.value.find( + (model) => model.value === String(state.agent.modelId ?? ''), + ); + const memoryConfig = (state.agent.memoryConfigJson ||= {}); + const compressionParameter = (memoryConfig.compressionParameter ||= {}); + compressionParameter.minCompressionTokenThreshold = + resolveAgentCompressionTokenThreshold({ + contextWindowTokens: selectedModel?.raw?.contextWindowTokens, + generationConfig: state.agent.generationConfigJson, + maxOutputTokens: selectedModel?.raw?.maxOutputTokens, + systemPrompt: state.agent.promptConfigJson?.systemPrompt, + toolBindings: state.toolBindings, + }); +} + async function handleSave(showMessage = true) { if (!runValidation()) return false; + syncAgentCompressionThreshold(); saveLoading.value = true; try { const agentPayload = buildPayloadAgent(); @@ -497,6 +515,7 @@ async function handleOffline() { function handleTryout() { if (!runValidation()) return; + syncAgentCompressionThreshold(); openTryout(); } diff --git a/easyflow-ui-admin/app/src/views/ai/agents/components/AgentBaseForm.vue b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentBaseForm.vue index f7e46b7e..1174933d 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/components/AgentBaseForm.vue +++ b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentBaseForm.vue @@ -1,6 +1,6 @@