fix(model-catalog): refine summary capability filters

This commit is contained in:
wangbo 2026-05-24 23:38:31 +08:00
parent 355d8cad74
commit 3d23918542
4 changed files with 251 additions and 55 deletions

View File

@ -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

View File

@ -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{
{

View File

@ -60,9 +60,15 @@ export function ModelsPage(props: { data: ConsoleData }) {
<main className="modelsContent">
<div className="modelsToolbar">
<div className="searchField modelHeaderSearch">
<div className="searchField modelHeaderSearch" role="search">
<Search size={16} />
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="模型名称、能力或厂商" />
<Input
aria-label="搜索模型"
size="sm"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="模型名称、能力或厂商"
/>
</div>
</div>
@ -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<string, string> = {
: '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) {

View File

@ -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,