feat(canvas): 模型选择 - 支持彩色展示标签并优化目录与积分交互
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
32
backend/internal/app/channel_model_tags.go
Normal file
32
backend/internal/app/channel_model_tags.go
Normal 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
|
||||
}
|
||||
27
backend/internal/app/channel_model_tags_test.go
Normal file
27
backend/internal/app/channel_model_tags_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
39
backend/internal/database/channel_model_tags_test.go
Normal file
39
backend/internal/database/channel_model_tags_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user