fix(model-catalog): refine summary capability filters
This commit is contained in:
parent
355d8cad74
commit
3d23918542
@ -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
|
||||
|
||||
@ -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{
|
||||
{
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user