diff --git a/backend/internal/app/admin.go b/backend/internal/app/admin.go index 88e37b5e2..b69f063f7 100644 --- a/backend/internal/app/admin.go +++ b/backend/internal/app/admin.go @@ -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 { diff --git a/backend/internal/app/channel_model_label_test.go b/backend/internal/app/channel_model_label_test.go index 63fa7ee84..4dd8e2c2e 100644 --- a/backend/internal/app/channel_model_label_test.go +++ b/backend/internal/app/channel_model_label_test.go @@ -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") } diff --git a/backend/internal/app/channel_model_tags.go b/backend/internal/app/channel_model_tags.go new file mode 100644 index 000000000..4eb0726a7 --- /dev/null +++ b/backend/internal/app/channel_model_tags.go @@ -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 +} diff --git a/backend/internal/app/channel_model_tags_test.go b/backend/internal/app/channel_model_tags_test.go new file mode 100644 index 000000000..a0ea4ce5b --- /dev/null +++ b/backend/internal/app/channel_model_tags_test.go @@ -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) + } + } +} diff --git a/backend/internal/app/channel_models.go b/backend/internal/app/channel_models.go index b8f2793dd..797607540 100644 --- a/backend/internal/app/channel_models.go +++ b/backend/internal/app/channel_models.go @@ -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 diff --git a/backend/internal/app/model_catalog.go b/backend/internal/app/model_catalog.go index d9a9b1018..acf55e0ba 100644 --- a/backend/internal/app/model_catalog.go +++ b/backend/internal/app/model_catalog.go @@ -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, diff --git a/backend/internal/database/channel_model_tags_test.go b/backend/internal/database/channel_model_tags_test.go new file mode 100644 index 000000000..aa77802a0 --- /dev/null +++ b/backend/internal/database/channel_model_tags_test.go @@ -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") + } +} diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index 122eb1e3c..0c8945c4a 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -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 { diff --git a/backend/internal/database/migrations_test.go b/backend/internal/database/migrations_test.go index 26f68d097..359c47242 100644 --- a/backend/internal/database/migrations_test.go +++ b/backend/internal/database/migrations_test.go @@ -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 { diff --git a/backend/internal/handler/openapi.yaml b/backend/internal/handler/openapi.yaml index f5ea6b942..6368f3a0d 100644 --- a/backend/internal/handler/openapi.yaml +++ b/backend/internal/handler/openapi.yaml @@ -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 diff --git a/backend/internal/model/models_channel.go b/backend/internal/model/models_channel.go index 89dac46a0..f877b5d9c 100644 --- a/backend/internal/model/models_channel.go +++ b/backend/internal/model/models_channel.go @@ -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"` diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index bdc50d6ae..712688a87 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -259,6 +259,8 @@ schema v27 为 `channel_model_price_tiers` 和 `billing_orders` 添加 `cost_con Schema 30 新增 `tools`,31 新增 `tool_favorites`;主分支已有 28(Agent 执行日志)、29(资源租约)编号和 checksum 不变。 +Schema 32 为 `channel_models` 新增 `tags`(TEXT、NOT NULL、默认 `'[]'`),通过 JSON serializer 存储有序的 `{ text, color }` 数组。每个模型最多 5 个标签,文字去除首尾空白后为 1–12 字且不重复,颜色为 `purple/blue/green/gold/orange/pink`。标签仅用于展示,不影响价格、能力和路由。后端启动时执行增量迁移,已有模型默认无标签;回退应用可保留此新增列,若人工删除列会丢失标签配置,须先备份。 + - `tools`:自增 `id`、类型 `type`、名称 `label/label_en`、描述/标签、`prompt`、预览 `cover/media_url/extra_info_json`、比例、`owner_id`、`source`、`enabled`、`visibility`、排序权重和时间。 - `tool_favorites`:`id`、`user_id`、`tool_id`、收藏时间;用户和工具联合唯一。删除自己的自定义工具时事务清理收藏,不删除预览文件。 - 启动幂等装载 87 个预设(风格 45、运镜 33、九宫格 9),不下载第三方媒体。显式 ID 与用户工具冲突时启动报错,不覆盖用户数据;PostgreSQL 同步自增序列。 diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 7a6bbf3fd..f00f57b97 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -45,7 +45,9 @@ description: 信封、错误码与 OpenAPI `GET /model-catalog` 固定返回 `source=system`,直接查询所有启用系统渠道下的渠道模型,目录数据在 `channels[].models[]`,`models` 固定为空数组。不受逻辑模型开关影响,不反查逻辑模型或供应线路。每项的 `modelKey` 是调用标识,`displayName` 是模型展示名,`icon` 是模型图标键,`channelLabel` 是可选的渠道展示名(默认空字符串)。后台渠道模型创建与更新请求使用相同的 `channelLabel` 字段;服务端去除首尾空白并限制为 80 字,空字符串表示使用所属渠道名称。 -渠道模型创建、更新、管理列表、公开目录和会话模型价格信息均使用 `description` 表达模型描述。服务端去除首尾空白后限制为 500 字,允许空字符串清除描述;无管理员权限或超长内容拒绝保存。创作端二级选项在悬浮、聚焦或选中时展示该字段,长内容可悬浮查看完整文本;未填写的系统模型不按名称生成宣传文案。描述区域保留固定行高,悬浮不改变菜单项尺寸。 +渠道模型创建、更新、管理列表、公开目录和会话模型价格信息均使用 `description` 表达模型描述。服务端去除首尾空白后限制为 500 字,允许空字符串清除描述;无管理员权限或超长内容拒绝保存。创作端二级选项常驻展示该字段,不依赖悬浮 Tooltip;未填写的系统模型不按名称生成宣传文案。 + +以上模型接口同时使用 `tags: [{ text, color }]` 传递有序展示标签,例如 `[{"text":"限时特价","color":"purple"},{"text":"官方1折","color":"gold"}]`。最多 5 个,文字去除首尾空白后为 1–12 字且不可重复;颜色仅接受 `purple`、`blue`、`green`、`gold`、`orange`、`pink`。创建/更新由管理员操作,非法标签拒绝整个保存;提交 `tags: []`(或省略字段)清空标签。用户模型选择器在对应二级渠道项中常驻展示,标签不参与路由、价格计算或权限判断。 创作端跨渠道按模型 `displayName` 分组为一级菜单,二级菜单使用每条模型的 `channelLabel`,例如 `MiniMax H3 → 秘塔(满血渠道)`。选中值继续使用 `channelId::modelKey`,报价和生成请求保留该渠道身份。展示字段不参与路由、权限及计费。系统渠道的“前台名称/前台显示别名”已移除,管理 API 不再接受或返回 `publicAlias`。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 5516fddc3..873e823fa 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,27 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 系统渠道模型展示标签 + +- [ ] 在系统渠道的模型编辑中添加多个标签(如“限时特价”“官方1折”),选择不同颜色,保存并重新打开后文字、颜色及顺序保持一致;删除全部标签后前台不再显示。 +- [ ] 最多 5 个标签,每个去除首尾空白后为 1–12 字;空白、重复、超长标签或无效颜色被拒绝,失败不覆盖原数据。 +- [ ] 用户模型选择器的二级渠道行常驻显示对应标签;同名模型的不同渠道标签互不串用,长内容可换行,明暗主题可读,积分、报价和请求路由不受影响。 +- [ ] 已有数据库升级至 schema 32 后,旧模型默认无标签,原模型标识、价格和能力配置不变。 +- [ ] 后端启动迁移完成后正常通过版本检查,不再出现“数据库结构版本 32 高于程序支持的 31”;程序支持版本已同步为 32,并添加迁移计划版本一致性回归测试。 + +## 模型一级菜单选中状态 + +- [ ] 明暗主题下打开模型菜单,当前模型所属一级项保持高亮;进入二级菜单并切换左侧一级项,确认仅当前展开项及已选渠道高亮。悬浮其他行只显示手形鼠标,不变色、不位移;键盘导航仍保留焦点轮廓。 +- [ ] 积分使用紧凑金黄色、浅底细边框的小圆角标签及 11px 金币图标,适配明暗主题;所有行价格右对齐,选中勾选不挤动价格,金额精度和计费单位保持不变。 +- [ ] 每次打开模型菜单默认展开当前选中项所属二级目录;无有效选中项时显示一级目录。桌面双栏总宽度为 800px,一级栏宽度不变,增加的空间留给二级目录;窄屏不超出视口。 +- [ ] 选择渠道后菜单保持打开,选中高亮及勾选立即更新,可连续切换;点击外部、再次点击触发器或按 Escape 仍可关闭,悬浮其他行不高亮。 + +## 社区技能文件包初始化 + +- 修复从 Markdown 正文推断技能元数据时,截断后额外追加 `...` 导致简介超过 500 字、后端启动中止的问题。名称、简介和版本的省略标记均计入长度上限,原始 `SKILL.md` 正文保持完整;不放宽文件包长度校验、不跳过失败技能。 +- 补充完整种子启动链回归:原有 33 条 JSON 技能、新增 72 条社区技能、3 条 Go 内置技能,共 108 条,依次同步入库并生成文件包;重复初始化不产生重复版本、不丢失加入/收藏状态;技能广场可搜索 `freestyle-template-match`。 +- [ ] 更新后端并重新启动,确认不再出现 `迁移技能 16000000000003 文件包失败`;在「技能库 → 技能广场」清空分类筛选后搜索 `freestyle-template-match`,查看详情并加入「我的技能」。本地临时数据库回归不代表实际部署已更新或浏览器验收通过。 + ## PR #554–#571 批量整合 - 合入除 #557 外的 17 个 PR;注册协议正文未确认,暂不引入空协议勾选、相关 API 或数据库版本变更。 diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index 7796e6e39..9243dd4eb 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -1,5 +1,5 @@ import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from "react"; -import { Check, ChevronDown, ChevronLeft, Coins } from "lucide-react"; +import { Check, ChevronDown, Coins } from "lucide-react"; import { Popover } from "antd"; import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme"; @@ -12,6 +12,7 @@ import { modelDisplayName, modelIcon, modelOptionName, resolveModelChannel, sele import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import { useUserStore } from "@/stores/use-user-store"; import { ModelLogo } from "@/components/model-logo"; +import { ModelTags } from "@/components/model-tags"; import { quoteModel, type LogicalModelQuote } from "@/services/api/logical-models"; type ModelPickerProps = { @@ -54,7 +55,6 @@ export function ModelPicker({ const theme = (canvasThemes[rawTheme as keyof typeof canvasThemes] ?? canvasThemes.dark) as CanvasTheme; const [open, setOpen] = useState(false); const [activeGroupKey, setActiveGroupKey] = useState(null); - const [previewedModel, setPreviewedModel] = useState(""); const [triggerWidth, setTriggerWidth] = useState(null); const menuRef = useRef(null); const triggerRef = useRef(null); @@ -121,8 +121,7 @@ export function ModelPicker({ if (nextOpen && !options.length) onMissingConfig?.(); if (nextOpen) window.dispatchEvent(new CustomEvent("model-picker-open", { detail: pickerId })); if (nextOpen) { - setPreviewedModel(current || options[0] || ""); - setActiveGroupKey(null); + setActiveGroupKey(optionGroups.find((group) => group.models.some((item) => item.models.includes(current)))?.key ?? null); } setOpen(nextOpen); }; @@ -186,8 +185,7 @@ export function ModelPicker({
{optionGroups.map((group) => { const groupCurrent = group.models.find((item) => item.models.includes(current)); - const firstModel = groupCurrent?.models[0] || group.models[0]?.models[0] || ""; - return
{optionGroups.filter((group) => group.key === activeGroupKey).map((group) =>
-
- - {group.label}{group.scope ? {group.scope} : null} -
{group.models.map((modelGroup) => { const selected = modelGroup.models.includes(current); @@ -226,16 +218,11 @@ export function ModelPicker({ aria-selected={selected} aria-disabled={Boolean(disabledReason)} disabled={Boolean(disabledReason)} - title={disabledReason || pickerModelOptionLabel(config, displayModel, showConfiguredModelName)} - className={cn("canvas-model-picker-option disabled:cursor-not-allowed disabled:opacity-45", previewedModel === displayModel && "is-previewed")} + className="canvas-model-picker-option disabled:cursor-not-allowed disabled:opacity-45" style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text }} - onMouseEnter={() => setPreviewedModel(displayModel)} - onFocus={() => setPreviewedModel(displayModel)} onClick={() => { if (!model) return; onChange(model); - setOpen(false); - window.requestAnimationFrame(() => triggerRef.current?.focus()); }} > - {selected ? : null} + ); })} @@ -352,9 +339,10 @@ export function ModelLabel({ {label || pickerModelDisplayName(config, model, showConfiguredModelName)} - + {capabilitySummary} + {showPrice ? ( @@ -509,8 +497,8 @@ function ModelPrice({ price, quote, compact = false }: { price: ModelMenuPrice | const amount = (quote.amountMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 }); const label = quote.estimated ? `预估:${amount}` : `${amount}`; return ( - - + + ); @@ -519,19 +507,19 @@ function ModelPrice({ price, quote, compact = false }: { price: ModelMenuPrice | if (price === null) return compact ? null : 未配置; if (price.kind === "tiers") { return ( - - + + ); } if (price.kind === "estimate") { - return {price.label || "按量预估"}; + return ; } return ( - - - {price.value.toLocaleString("zh-CN", { maximumFractionDigits: compact ? 3 : 6 })}/{price.unit} + + ); } diff --git a/web/src/components/model-tags.tsx b/web/src/components/model-tags.tsx new file mode 100644 index 000000000..7a6e8cff2 --- /dev/null +++ b/web/src/components/model-tags.tsx @@ -0,0 +1,9 @@ +import type { ModelTag } from "@/lib/model-tags"; +import "@/styles/shared/model-tags.css"; + +export function ModelTags({ tags }: { tags?: ModelTag[] }) { + if (!tags?.length) return null; + return + {tags.map((tag, index) => {tag.text})} + ; +} diff --git a/web/src/lib/model-tags.ts b/web/src/lib/model-tags.ts new file mode 100644 index 000000000..e0bd634a4 --- /dev/null +++ b/web/src/lib/model-tags.ts @@ -0,0 +1,10 @@ +export const modelTagColors = [ + { value: "purple", label: "紫色" }, + { value: "blue", label: "蓝色" }, + { value: "green", label: "绿色" }, + { value: "gold", label: "金色" }, + { value: "orange", label: "橙色" }, + { value: "pink", label: "粉色" }, +] as const; + +export type ModelTag = { text: string; color: typeof modelTagColors[number]["value"] }; diff --git a/web/src/lib/user-session.ts b/web/src/lib/user-session.ts index 2d656a42b..928706d64 100644 --- a/web/src/lib/user-session.ts +++ b/web/src/lib/user-session.ts @@ -139,6 +139,7 @@ export function systemChannelModelChannels(channels: PublicChannelCatalog[]): Mo model: model.modelKey, displayName: model.displayName, channelLabel: model.channelLabel, + tags: model.tags || [], description: model.description || "", icon: model.icon || "", capability: model.capability as ModelCapability, diff --git a/web/src/pages/admin/components/channel-model-editor-form.ts b/web/src/pages/admin/components/channel-model-editor-form.ts index bb34507ec..b8873e990 100644 --- a/web/src/pages/admin/components/channel-model-editor-form.ts +++ b/web/src/pages/admin/components/channel-model-editor-form.ts @@ -2,6 +2,7 @@ import type { ModelCapabilityChoice } from "@/components/model-protocol-picker"; import { defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities"; import { modelProtocolSupportsTokenBilling, type ModelProtocolDefinition } from "@/lib/model-protocols"; import type { ChannelModel } from "@/services/api/wallet"; +import type { ModelTag } from "@/lib/model-tags"; import { defaultPriceTier, legacyPriceTierToForm, priceTierToForm, type PriceTierFormValues } from "./channel-model-price-tier-form"; export type ChannelModelFormValues = { @@ -9,6 +10,7 @@ export type ChannelModelFormValues = { providerModelKey?: string; displayName?: string; channelLabel?: string; + tags: ModelTag[]; description?: string; icon?: string; capability: ModelCapabilityChoice; @@ -33,6 +35,7 @@ export function initialChannelModelValues(item: ChannelModel | null, protocols: providerModelKey: upstreamModel, displayName: item?.displayName || "", channelLabel: item?.channelLabel || "", + tags: item?.tags?.map((tag) => ({ ...tag })) || [], description: item?.description || "", icon: item?.icon || "", capability, diff --git a/web/src/pages/admin/components/channel-model-editor.tsx b/web/src/pages/admin/components/channel-model-editor.tsx index 5ab5d384a..a24e1df78 100644 --- a/web/src/pages/admin/components/channel-model-editor.tsx +++ b/web/src/pages/admin/components/channel-model-editor.tsx @@ -13,6 +13,7 @@ import { createAdminChannelModel, testAdminChannelModel, updateAdminChannelModel import type { ModelChannel } from "@/stores/use-config-store"; import { defaultPriceTier, normalizeUpstreamModelKey, priceTierPayloadFromForm } from "./channel-model-price-tier-form"; import { PriceTierFields } from "./channel-model-price-tier-fields"; +import { ChannelModelTagsEditor } from "./channel-model-tags-editor"; import { changeChannelModelCapability, editorSectionForField, initialChannelModelValues, validateChannelModelPrices, validateChannelModelProtocol, type ChannelModelFormValues as FormValues, type EditorSection } from "./channel-model-editor-form"; export function ChannelModelEditor({ @@ -112,6 +113,7 @@ export function ChannelModelEditor({ providerModelKey: upstreamModel, displayName: values.displayName?.trim() || values.modelKey.trim(), channelLabel: values.channelLabel?.trim() || "", + tags: (values.tags || []).map((tag) => ({ text: tag.text.trim(), color: tag.color })), description: values.description?.trim() || "", icon: values.icon?.trim() || "", capability: values.capability, @@ -275,7 +277,8 @@ export function ChannelModelEditor({ description="命中的请求会优先使用价格档的上游 ID;修改上方上游模型 ID 时,只有与旧值相同的档位会自动跟随更新,其余保持不变。" /> ) : null} - + +
diff --git a/web/src/pages/admin/components/channel-model-tags-editor.tsx b/web/src/pages/admin/components/channel-model-tags-editor.tsx new file mode 100644 index 000000000..c5d0bacff --- /dev/null +++ b/web/src/pages/admin/components/channel-model-tags-editor.tsx @@ -0,0 +1,34 @@ +import { Button, Form, Input, Select } from "antd"; +import { Plus, Trash2 } from "lucide-react"; +import { ModelTags } from "@/components/model-tags"; +import { modelTagColors, type ModelTag } from "@/lib/model-tags"; + +export function ChannelModelTagsEditor() { + const form = Form.useFormInstance(); + const tags: ModelTag[] = Form.useWatch("tags", form) || []; + return + { + if (value.length > 5) throw new Error("最多添加 5 个标签"); + const texts = value.map((tag) => tag?.text?.trim()).filter(Boolean); + if (new Set(texts).size !== texts.length) throw new Error("标签文字不能重复"); + } }]}> + {(fields, { add, remove }, { errors }) =>
+ {fields.map(({ key, name, ...rest }) =>
+ { + const length = Array.from(value?.trim() || "").length; + if (length < 1 || length > 12) throw new Error("标签文字须为 1–12 字"); + } }]}> + + + +