feat(canvas): 模型选择 - 支持彩色展示标签并优化目录与积分交互

This commit is contained in:
ddCat
2026-09-21 11:11:10 +08:00
parent ea6f02b42f
commit 4f9e79f67f
31 changed files with 410 additions and 63 deletions

View File

@@ -124,6 +124,7 @@ type PublicChannelModelPrice struct {
Model string `json:"model"`
DisplayName string `json:"displayName"`
ChannelLabel string `json:"channelLabel"`
Tags []model.ChannelModelTag `json:"tags"`
Description string `json:"description"`
Icon string `json:"icon"`
Capability string `json:"capability"`
@@ -947,7 +948,7 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
capabilityConfig = normalized
}
}
modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, DisplayName: item.DisplayName, ChannelLabel: item.ChannelLabel, Description: item.Description, Icon: item.Icon, Capability: item.Capability, Protocol: item.Protocol, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits, CapabilityConfig: capabilityConfig})
modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, DisplayName: item.DisplayName, ChannelLabel: item.ChannelLabel, Tags: item.Tags, Description: item.Description, Icon: item.Icon, Capability: item.Capability, Protocol: item.Protocol, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits, CapabilityConfig: capabilityConfig})
}
}
if len(models) == 0 {

View File

@@ -18,6 +18,7 @@ func TestChannelModelLabelSaveAndCatalogPreserveChannelIdentity(t *testing.T) {
ModelKey: "gpt-test", ProviderModelKey: "upstream-text", DisplayName: "GPT Test", Icon: "OpenAI",
ChannelLabel: " 优惠渠道-993 ", Capability: "text", Protocol: string(model.ChannelInterfaceChatCompletion),
Description: " 适合分镜脚本,请先确认输入要求。 ",
Tags: []model.ChannelModelTag{{Text: " 限时特价 ", Color: "purple"}, {Text: "官方1折", Color: "gold"}},
CapabilityConfig: DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceChatCompletion), "gpt-test"),
PriceTiers: []ChannelModelPriceTierRequest{{BillingMode: "fixed_request", UnitPriceMicrocredits: 300000, PriceConfigured: true, Enabled: &enabled}},
Enabled: &enabled,
@@ -40,6 +41,9 @@ func TestChannelModelLabelSaveAndCatalogPreserveChannelIdentity(t *testing.T) {
t.Fatalf("stored channel model: %#v", stored)
}
legacy := publicChannel(channel, false, []model.ChannelModel{*saved})
if len(stored.Tags) != 2 || stored.Tags[0].Text != "限时特价" || stored.Tags[1].Color != "gold" || len(legacy.ModelCosts[0].Tags) != 2 {
t.Fatalf("tags not persisted or projected: %#v", stored.Tags)
}
if stored.Description != "适合分镜脚本,请先确认输入要求。" || legacy.ModelCosts[0].Description != stored.Description {
t.Fatal("model description was not persisted or projected")
}
@@ -55,6 +59,9 @@ func TestChannelModelLabelSaveAndCatalogPreserveChannelIdentity(t *testing.T) {
t.Fatalf("catalog: %#v, %v", catalog, err)
}
for _, channel := range catalog.Channels {
if len(channel.Models) != 1 || len(channel.Models[0].Tags) != 2 || channel.Models[0].Tags[0].Text != "限时特价" {
t.Fatalf("catalog lost tags: %#v", channel)
}
if len(channel.Models) == 1 && channel.Models[0].Description != "适合分镜脚本,请先确认输入要求。" {
t.Fatal("catalog lost model description")
}
@@ -62,6 +69,15 @@ func TestChannelModelLabelSaveAndCatalogPreserveChannelIdentity(t *testing.T) {
t.Fatalf("catalog lost label or pricing: %#v", channel)
}
}
invalidTags := request
invalidTags.Tags = []model.ChannelModelTag{{Text: "限时特价", Color: "invalid"}}
if _, err := svc.SaveAdminChannelModel(admin, "channel-a", firstID, invalidTags); err == nil {
t.Fatal("accepted invalid tag color")
}
var unchanged model.ChannelModel
if err := db.First(&unchanged, "id = ?", firstID).Error; err != nil || len(unchanged.Tags) != 2 || unchanged.Tags[0].Color != "purple" {
t.Fatalf("invalid write changed persisted tags: %#v, %v", unchanged.Tags, err)
}
request.ChannelLabel = strings.Repeat("字", 81)
if _, err := svc.SaveAdminChannelModel(admin, "channel-a", firstID, request); err == nil {
t.Fatal("accepted oversized channel label")
@@ -76,10 +92,15 @@ func TestChannelModelLabelSaveAndCatalogPreserveChannelIdentity(t *testing.T) {
t.Fatalf("rejected valid 500-character description: %v", err)
}
request.Description = " "
request.Tags = []model.ChannelModelTag{}
updated, err := svc.SaveAdminChannelModel(admin, "channel-a", firstID, request)
if err != nil || updated.ChannelLabel != "" || updated.Description != "" || updated.ID != firstID {
t.Fatalf("label clearing changed identity: %#v, %v", updated, err)
}
var cleared model.ChannelModel
if err := db.First(&cleared, "id = ?", firstID).Error; err != nil || len(cleared.Tags) != 0 {
t.Fatalf("tags not cleared: %#v %v", cleared.Tags, err)
}
if _, err := svc.SaveAdminChannelModel(&model.User{ID: "user", Role: model.UserRoleUser}, "channel-a", firstID, request); err == nil {
t.Fatal("non-admin changed channel label")
}

View File

@@ -0,0 +1,32 @@
package app
import (
"strings"
"infinite-canvas/backend/internal/model"
)
func normalizeChannelModelTags(input []model.ChannelModelTag) ([]model.ChannelModelTag, error) {
if len(input) > 5 {
return nil, BadAuthRequest("每个模型最多配置 5 个标签")
}
tags := make([]model.ChannelModelTag, 0, len(input))
seen := make(map[string]bool)
for _, tag := range input {
tag.Text = strings.TrimSpace(tag.Text)
if len([]rune(tag.Text)) == 0 || len([]rune(tag.Text)) > 12 {
return nil, BadAuthRequest("标签文字须为 1–12 字")
}
if seen[tag.Text] {
return nil, BadAuthRequest("标签文字不能重复")
}
switch tag.Color {
case "purple", "blue", "green", "gold", "orange", "pink":
default:
return nil, BadAuthRequest("请选择有效的标签颜色")
}
seen[tag.Text] = true
tags = append(tags, tag)
}
return tags, nil
}

View File

@@ -0,0 +1,27 @@
package app
import (
"strings"
"testing"
"infinite-canvas/backend/internal/model"
)
func TestChannelModelTagsValidation(t *testing.T) {
for _, input := range [][]model.ChannelModelTag{
{{Text: " ", Color: "purple"}},
{{Text: strings.Repeat("字", 13), Color: "purple"}},
{{Text: "特价", Color: "unknown"}},
{{Text: "特价", Color: "purple"}, {Text: " 特价 ", Color: "gold"}},
make([]model.ChannelModelTag, 6),
} {
if _, err := normalizeChannelModelTags(input); err == nil {
t.Fatalf("accepted invalid tags: %#v", input)
}
}
for _, color := range []string{"purple", "blue", "green", "gold", "orange", "pink"} {
if _, err := normalizeChannelModelTags([]model.ChannelModelTag{{Text: strings.Repeat("字", 12), Color: color}}); err != nil {
t.Fatal(err)
}
}
}

View File

@@ -22,6 +22,7 @@ type ChannelModelRequest struct {
ProviderModelKey string `json:"providerModelKey"`
DisplayName string `json:"displayName"`
ChannelLabel string `json:"channelLabel"`
Tags []model.ChannelModelTag `json:"tags"`
Description string `json:"description"`
Icon string `json:"icon"`
Capability string `json:"capability"`
@@ -300,6 +301,10 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
return nil, err
}
channelLabel := strings.TrimSpace(req.ChannelLabel)
tags, err := normalizeChannelModelTags(req.Tags)
if err != nil {
return nil, err
}
description := strings.TrimSpace(req.Description)
if len([]rune(description)) > 500 {
return nil, BadAuthRequest("模型描述不能超过 500 字")
@@ -359,6 +364,7 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
item.ProviderModelKey = providerModelKey
item.DisplayName = strings.TrimSpace(req.DisplayName)
item.ChannelLabel = channelLabel
item.Tags = tags
item.Description = description
if item.DisplayName == "" {
item.DisplayName = modelKey

View File

@@ -38,6 +38,7 @@ type PublicChannelModel struct {
ID string `json:"id"`
ModelKey string `json:"modelKey"`
ChannelLabel string `json:"channelLabel"`
Tags []model.ChannelModelTag `json:"tags"`
Description string `json:"description"`
DisplayName string `json:"displayName"`
SortOrder int `json:"sortOrder"`
@@ -185,6 +186,7 @@ func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) (PublicChannelMod
ID: cm.ID,
ModelKey: cm.ModelKey,
ChannelLabel: cm.ChannelLabel,
Tags: cm.Tags,
Description: cm.Description,
DisplayName: cm.DisplayName,
SortOrder: cm.SortOrder,