diff --git a/apps/api/internal/httpapi/model_catalog.go b/apps/api/internal/httpapi/model_catalog.go index 5bbbcb1..755362f 100644 --- a/apps/api/internal/httpapi/model_catalog.go +++ b/apps/api/internal/httpapi/model_catalog.go @@ -985,31 +985,66 @@ func modelCatalogCapabilityFilters(items []ModelCatalogItem) []ModelCatalogFilte } } options := []ModelCatalogFilterOption{{Value: "all", Label: "全部", Count: len(items)}} - for _, option := range []ModelCatalogFilterOption{ - {Value: "chat", Label: "对话"}, - {Value: "image", Label: "图像"}, - {Value: "video", Label: "视频"}, - {Value: "audio", Label: "音频"}, - {Value: "embedding", Label: "Embedding"}, - {Value: "tools", Label: "工具调用"}, - {Value: "omni", Label: "全模态"}, - } { + known := map[string]bool{} + for _, option := range modelCatalogCapabilityDefinitions() { + known[option.Value] = true if count := counts[option.Value]; count > 0 { option.Count = count options = append(options, option) } } + extraValues := make([]string, 0) + for value := range counts { + if !known[value] { + extraValues = append(extraValues, value) + } + } + sort.Slice(extraValues, func(i, j int) bool { + return capabilityLabel(extraValues[i]) < capabilityLabel(extraValues[j]) + }) + for _, value := range extraValues { + options = append(options, ModelCatalogFilterOption{Value: value, Label: capabilityLabel(value), Count: counts[value]}) + } return options } +func modelCatalogCapabilityDefinitions() []ModelCatalogFilterOption { + return []ModelCatalogFilterOption{ + {Value: "text_generate", Label: "文本生成"}, + {Value: "chat", Label: "对话"}, + {Value: "responses", Label: "Responses"}, + {Value: "image_generate", Label: "图像生成"}, + {Value: "image_edit", Label: "图像编辑"}, + {Value: "image_analysis", Label: "图像分析"}, + {Value: "video_generate", Label: "视频生成"}, + {Value: "image_to_video", Label: "图生视频"}, + {Value: "text_to_video", Label: "文生视频"}, + {Value: "video_edit", Label: "视频编辑"}, + {Value: "video_understanding", Label: "视频理解"}, + {Value: "audio_generate", Label: "音频生成"}, + {Value: "text_to_speech", Label: "语音合成"}, + {Value: "audio_understanding", Label: "音频理解"}, + {Value: "text_embedding", Label: "Embedding"}, + {Value: "omni", Label: "全模态"}, + {Value: "omni_video", Label: "全模态视频"}, + {Value: "multimodal", Label: "多模态"}, + {Value: "model_3d", Label: "3D 模型"}, + {Value: "text_to_model", Label: "文生 3D"}, + {Value: "image_to_model", Label: "图生 3D"}, + {Value: "multiview_to_model", Label: "多视角 3D"}, + {Value: "mesh_edit", Label: "3D 模型编辑"}, + {Value: "tools_call", Label: "工具调用"}, + {Value: "structured_output", Label: "结构化输出"}, + {Value: "reasoning", Label: "推理"}, + {Value: "digital_human", Label: "数字人"}, + } +} + func capabilityFilterValuesForItem(item ModelCatalogItem) []string { values := capabilityFilterValues(item.ModelType, nil) for _, tag := range item.CapabilityTags { - switch tag { - case "工具调用": - values = append(values, "tools") - case "全模态", "多模态": - values = append(values, "omni") + if value := capabilityFilterValueForTag(tag); value != "" { + values = append(values, value) } } return uniqueStrings(values) @@ -1067,33 +1102,62 @@ func capabilityTagsForGroup(modelTypes []string, capabilities map[string]any) [] func capabilityFilterValues(modelTypes []string, capabilities map[string]any) []string { values := []string{} for _, modelType := range modelTypes { - normalized := strings.ToLower(strings.TrimSpace(modelType)) - switch { - case normalized == "text_embedding" || normalized == "embedding": - values = append(values, "embedding") - case normalized == "tools_call": - values = append(values, "tools") - case normalized == "omni": - values = append(values, "omni") - case strings.Contains(normalized, "video"): - values = append(values, "video") - case strings.Contains(normalized, "audio") || strings.Contains(normalized, "speech"): - values = append(values, "audio") - case strings.Contains(normalized, "image"): - values = append(values, "image") - case normalized == "text_generate" || normalized == "chat" || normalized == "responses" || strings.Contains(normalized, "text"): - values = append(values, "chat") + if value := canonicalCapabilityFilterValue(modelType); value != "" { + values = append(values, value) } } if boolAnyValue(capabilities["tools_call"]) || boolAnyValue(capabilities["toolCall"]) || boolAnyValue(capabilities["function_call"]) { - values = append(values, "tools") + values = append(values, "tools_call") } if boolAnyValue(capabilities["multimodal"]) { - values = append(values, "omni") + values = append(values, "multimodal") } return uniqueStrings(values) } +func canonicalCapabilityFilterValue(value string) string { + normalized := strings.ReplaceAll(strings.ToLower(strings.TrimSpace(value)), "-", "_") + switch normalized { + case "embedding": + return "text_embedding" + case "model": + return "model_3d" + default: + return normalized + } +} + +func capabilityFilterValueForTag(tag string) string { + switch tag { + case "工具调用": + return "tools_call" + case "全模态": + return "omni" + case "全模态视频": + return "omni_video" + case "多模态": + return "multimodal" + case "推理": + return "reasoning" + case "结构化输出": + return "structured_output" + case "数字人": + return "digital_human" + case "3D 模型": + return "model_3d" + case "文生 3D": + return "text_to_model" + case "图生 3D": + return "image_to_model" + case "多视角 3D": + return "multiview_to_model" + case "3D 模型编辑": + return "mesh_edit" + default: + return "" + } +} + func capabilityLabel(value string) string { labels := map[string]string{ "text_generate": "文本生成", @@ -1115,9 +1179,16 @@ func capabilityLabel(value string) string { "tools_call": "工具调用", "omni": "全模态", "omni_video": "全模态视频", + "multimodal": "多模态", + "reasoning": "推理", "structured_output": "结构化输出", "digital_human": "数字人", + "model": "3D 模型", "model_3d": "3D 模型", + "text_to_model": "文生 3D", + "image_to_model": "图生 3D", + "multiview_to_model": "多视角 3D", + "mesh_edit": "3D 模型编辑", } if label, ok := labels[value]; ok { return label diff --git a/apps/api/internal/httpapi/model_catalog_test.go b/apps/api/internal/httpapi/model_catalog_test.go index 4a76a97..a28759f 100644 --- a/apps/api/internal/httpapi/model_catalog_test.go +++ b/apps/api/internal/httpapi/model_catalog_test.go @@ -106,8 +106,8 @@ func TestBuildModelCatalogAggregatesSources(t *testing.T) { if !hasFilterCount(response.Filters.Providers, "volces", 1) || !hasFilterCount(response.Filters.Providers, "gemini", 1) { t.Fatalf("expected provider filters to count merged model for each provider: %+v", response.Filters.Providers) } - if !hasFilterCount(response.Filters.Capabilities, "image", 1) { - t.Fatalf("expected image capability filter: %+v", response.Filters.Capabilities) + if !hasFilterCount(response.Filters.Capabilities, "image_generate", 1) { + t.Fatalf("expected image generation capability filter: %+v", response.Filters.Capabilities) } if got := item.Pricing.Lines[0]; got != "图像:1K 10 / 2K 20" { t.Fatalf("unexpected pricing line %q", got) @@ -168,6 +168,58 @@ func TestBuildModelCatalogUsesBaseModelProviderForProviderFilters(t *testing.T) } } +func TestBuildModelCatalogIncludes3DModelCapabilityFilter(t *testing.T) { + models := []store.PlatformModel{ + { + ID: "tripo-image-to-model", + PlatformID: "platform-tripo", + ModelName: "tripo-3d", + ModelAlias: "Tripo 3D", + ModelType: store.StringList{"image_to_model"}, + DisplayName: "Tripo 3D", + Enabled: true, + }, + } + platforms := []store.Platform{ + {ID: "platform-tripo", Provider: "tripo3d", Name: "Tripo3D", Status: "enabled"}, + } + + response := buildModelCatalog(models, platforms, nil, nil, nil, nil, nil) + if !hasFilterCount(response.Filters.Capabilities, "image_to_model", 1) { + t.Fatalf("expected 3D model capability filter, got %+v", response.Filters.Capabilities) + } + if hasFilterCount(response.Filters.Capabilities, "image_generate", 1) { + t.Fatalf("did not expect image_to_model to be classified as image: %+v", response.Filters.Capabilities) + } + if len(response.Items) != 1 || len(response.Items[0].CapabilityTags) != 1 || response.Items[0].CapabilityTags[0] != "图生 3D" { + t.Fatalf("expected image-to-model capability tag, got %+v", response.Items) + } +} + +func TestBuildModelCatalogKeepsDistinctCapabilityFilters(t *testing.T) { + models := []store.PlatformModel{ + {ID: "image-generate", PlatformID: "platform-a", ModelName: "image-generate", ModelAlias: "Image Generate", ModelType: store.StringList{"image_generate"}, DisplayName: "Image Generate", Enabled: true}, + {ID: "image-analysis", PlatformID: "platform-a", ModelName: "image-analysis", ModelAlias: "Image Analysis", ModelType: store.StringList{"image_analysis"}, DisplayName: "Image Analysis", Enabled: true}, + {ID: "video-generate", PlatformID: "platform-a", ModelName: "video-generate", ModelAlias: "Video Generate", ModelType: store.StringList{"video_generate"}, DisplayName: "Video Generate", Enabled: true}, + {ID: "video-understanding", PlatformID: "platform-a", ModelName: "video-understanding", ModelAlias: "Video Understanding", ModelType: store.StringList{"video_understanding"}, DisplayName: "Video Understanding", Enabled: true}, + {ID: "omni", PlatformID: "platform-a", ModelName: "omni", ModelAlias: "Omni", ModelType: store.StringList{"omni"}, DisplayName: "Omni", Enabled: true}, + {ID: "omni-video", PlatformID: "platform-a", ModelName: "omni-video", ModelAlias: "Omni Video", ModelType: store.StringList{"omni_video"}, DisplayName: "Omni Video", Enabled: true}, + } + platforms := []store.Platform{ + {ID: "platform-a", Provider: "test", Name: "测试平台", Status: "enabled"}, + } + + response := buildModelCatalog(models, platforms, nil, nil, nil, nil, nil) + for _, value := range []string{"image_generate", "image_analysis", "video_generate", "video_understanding", "omni", "omni_video"} { + if !hasFilterCount(response.Filters.Capabilities, value, 1) { + t.Fatalf("expected distinct capability filter %s, got %+v", value, response.Filters.Capabilities) + } + } + if hasFilterCount(response.Filters.Capabilities, "image", 2) || hasFilterCount(response.Filters.Capabilities, "video", 2) { + t.Fatalf("did not expect broad image/video filters: %+v", response.Filters.Capabilities) + } +} + func TestBuildModelCatalogOnlyUsesEnabledPlatformModels(t *testing.T) { models := []store.PlatformModel{ { diff --git a/apps/web/src/pages/ModelsPage.tsx b/apps/web/src/pages/ModelsPage.tsx index 91d214b..cb9cac7 100644 --- a/apps/web/src/pages/ModelsPage.tsx +++ b/apps/web/src/pages/ModelsPage.tsx @@ -60,9 +60,15 @@ export function ModelsPage(props: { data: ConsoleData }) {
-
+
- setQuery(event.target.value)} placeholder="模型名称、能力或厂商" /> + setQuery(event.target.value)} + placeholder="模型名称、能力或厂商" + />
@@ -235,21 +241,37 @@ function ModelIcon(props: { iconPath?: string; label: string }) { function modelMatchesCapability(model: ModelCatalogItem, capability: string) { if (model.modelType.some((type) => modelTypeMatchesCapability(type, capability))) return true; - if (capability === 'tools') return model.capabilityTags.includes('工具调用'); - if (capability === 'omni') return model.capabilityTags.includes('全模态'); + if (model.capabilityTags.some((tag) => capabilityValueForTag(tag) === capability)) return true; return false; } function modelTypeMatchesCapability(value: string, capability: string) { - const type = value.trim().toLowerCase(); - if (capability === 'chat') return type === 'text_generate' || type === 'chat' || type === 'responses' || type.includes('text'); - if (capability === 'image') return type.includes('image') && !type.includes('video'); - if (capability === 'video') return type.includes('video'); - if (capability === 'audio') return type.includes('audio') || type.includes('speech'); - if (capability === 'embedding') return type === 'text_embedding' || type === 'embedding'; - if (capability === 'tools') return type === 'tools_call'; - if (capability === 'omni') return type === 'omni' || type === 'omni_video'; - return type === capability; + return canonicalCapabilityValue(value) === capability; +} + +function canonicalCapabilityValue(value: string) { + const type = value.trim().toLowerCase().replaceAll('-', '_'); + if (type === 'embedding') return 'text_embedding'; + if (type === 'model') return 'model_3d'; + return type; +} + +function capabilityValueForTag(tag: string) { + const values: Record = { + 工具调用: 'tools_call', + 全模态: 'omni', + 全模态视频: 'omni_video', + 多模态: 'multimodal', + 推理: 'reasoning', + 结构化输出: 'structured_output', + 数字人: 'digital_human', + '3D 模型': 'model_3d', + '文生 3D': 'text_to_model', + '图生 3D': 'image_to_model', + '多视角 3D': 'multiview_to_model', + '3D 模型编辑': 'mesh_edit', + }; + return values[tag] ?? ''; } function providerInitials(label: string) { diff --git a/apps/web/src/styles/pages.css b/apps/web/src/styles/pages.css index 108d899..79e9356 100644 --- a/apps/web/src/styles/pages.css +++ b/apps/web/src/styles/pages.css @@ -26,8 +26,8 @@ .modelsPage { display: grid; - grid-template-columns: 246px minmax(0, 1fr); - gap: 22px; + grid-template-columns: 282px minmax(0, 1fr); + gap: 18px; align-items: start; } @@ -35,10 +35,38 @@ position: sticky; top: 88px; display: grid; + max-height: calc(100vh - 104px); gap: 22px; padding-left: 14px; padding-right: 18px; + padding-bottom: 8px; border-right: 1px solid var(--border); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: transparent transparent; +} + +.modelFilters:hover { + scrollbar-color: rgba(113, 113, 122, 0.36) transparent; +} + +.modelFilters::-webkit-scrollbar { + width: 4px; +} + +.modelFilters::-webkit-scrollbar-track { + background: transparent; +} + +.modelFilters::-webkit-scrollbar-thumb { + border-radius: 999px; + background: transparent; +} + +.modelFilters:hover::-webkit-scrollbar-thumb { + background: rgba(113, 113, 122, 0.32); } .filterGroup { @@ -124,33 +152,53 @@ align-items: center; justify-content: flex-end; gap: 16px; + min-height: 36px; } .searchField { display: grid; - width: min(420px, 100%); - grid-template-columns: 34px minmax(0, 1fr); + width: min(360px, 100%); + min-height: 36px; + grid-template-columns: 36px minmax(0, 1fr); align-items: center; border: 1px solid var(--border); border-radius: 999px; - background: #fff; + background: var(--surface); color: var(--muted-foreground); + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04); + transition: border-color 0.16s ease, box-shadow 0.16s ease, color 0.16s ease; } .searchField.modelHeaderSearch { - width: min(520px, 42vw); + width: min(360px, 34vw); +} + +.searchField:hover, +.searchField:focus-within { + border-color: var(--ring); + color: var(--text-normal); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 24%, transparent); } .searchField svg { - margin-left: 12px; + margin-left: 13px; + transition: color 0.16s ease; } .searchField .shInput { + min-height: 34px; + padding-left: 0; + padding-right: 12px; border: 0; border-radius: 999px; + background: transparent; box-shadow: none; } +.searchField .shInput::placeholder { + color: var(--text-faint); +} + .providerLogo, .modelIcon { display: grid; @@ -177,7 +225,7 @@ .modelCards { display: grid; grid-template-columns: repeat(3, minmax(260px, 1fr)); - gap: 10px; + gap: 8px; } .modelCard .shCardContent { @@ -2123,9 +2171,12 @@ } .modelFilters { + max-height: none; padding-left: 0; padding-right: 0; border-right: 0; + overflow: visible; + scrollbar-width: auto; } .modelCards,