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,

View File

@@ -0,0 +1,39 @@
package database
import (
"testing"
"infinite-canvas/backend/internal/model"
)
func TestChannelModelTagsMigration(t *testing.T) {
db, err := Open(Config{Driver: "sqlite", DSN: ":memory:"})
if err != nil {
t.Fatal(err)
}
if err := db.Exec(`CREATE TABLE channel_models (id TEXT PRIMARY KEY, model_key TEXT, unit_price_microcredits INTEGER)`).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`INSERT INTO channel_models VALUES ('a', 'test', 300000)`).Error; err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err := migrateChannelModelTags(db); err != nil {
t.Fatal(err)
}
}
var row struct {
ModelKey string
Tags string
UnitPriceMicrocredits int64
}
if err := db.Table("channel_models").Where("id = ?", "a").First(&row).Error; err != nil {
t.Fatal(err)
}
if row.Tags != "[]" || row.ModelKey != "test" || row.UnitPriceMicrocredits != 300000 {
t.Fatalf("migration changed data: %#v", row)
}
if !db.Migrator().HasColumn(&model.ChannelModel{}, "Tags") {
t.Fatal("tags column missing")
}
}

View File

@@ -11,7 +11,7 @@ import (
"gorm.io/gorm"
)
const CurrentSchemaVersion int64 = 31
const CurrentSchemaVersion int64 = 32
const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830"
const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830"
@@ -105,6 +105,14 @@ var schemaMigrations = []migration{
{version: 31, name: "tool_favorites", checksum: "sha256:tool-favorites-v31", apply: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.ToolFavorite{})
}},
{version: 32, name: "channel_model_tags", checksum: "sha256:channel-model-tags-v32", apply: migrateChannelModelTags},
}
func migrateChannelModelTags(tx *gorm.DB) error {
if tx.Migrator().HasColumn(&model.ChannelModel{}, "Tags") {
return nil
}
return tx.Migrator().AddColumn(&model.ChannelModel{}, "Tags")
}
func migrateChannelCreditCost(tx *gorm.DB) error {

View File

@@ -11,6 +11,16 @@ import (
"gorm.io/gorm"
)
func TestCurrentSchemaVersionMatchesMigrationPlan(t *testing.T) {
if len(schemaMigrations) == 0 {
t.Fatal("migration plan is empty")
}
latest := schemaMigrations[len(schemaMigrations)-1].version
if CurrentSchemaVersion != latest {
t.Fatalf("supported schema version %d does not match latest migration %d", CurrentSchemaVersion, latest)
}
}
func TestMigrateSchemaRecordsAndValidatesVersion(t *testing.T) {
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-version?mode=memory&cache=shared"})
if err != nil {

View File

@@ -141,6 +141,21 @@ paths:
type: string
maxLength: 80
default: ""
tags:
type: array
maxItems: 5
description: 后台模型创建及更新使用同名字段;文字去除首尾空白且不能重复,空数组清除标签,仅用于展示。
items:
type: object
required: [text, color]
properties:
text:
type: string
minLength: 1
maxLength: 12
color:
type: string
enum: [purple, blue, green, gold, orange, pink]
description:
type: string
maxLength: 500

View File

@@ -29,6 +29,11 @@ type ModelChannel struct {
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
type ChannelModelTag struct {
Text string `json:"text"`
Color string `json:"color"`
}
type ChannelModel struct {
ID string `json:"id" gorm:"primaryKey;size:36"`
ChannelID string `json:"channelId" gorm:"size:36;index;uniqueIndex:idx_channel_model_key_active,priority:1,where:deleted_at IS NULL"`
@@ -36,6 +41,7 @@ type ChannelModel struct {
ProviderModelKey string `json:"providerModelKey" gorm:"size:120"`
DisplayName string `json:"displayName" gorm:"size:160"`
ChannelLabel string `json:"channelLabel" gorm:"size:80;not null;default:''"`
Tags []ChannelModelTag `json:"tags" gorm:"serializer:json;type:text;not null;default:'[]'"`
Description string `json:"description" gorm:"size:500;not null;default:''"`
SortOrder int `json:"sortOrder" gorm:"not null;default:0"`
Icon string `json:"icon" gorm:"size:80"`