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

This commit is contained in:
ddCat
2026-09-21 11:11:10 +08:00
parent d5ff22258e
commit b8eefdad70
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"`

View File

@@ -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 同步自增序列。

View File

@@ -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`。

View File

@@ -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 或数据库版本变更。

View File

@@ -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<string | null>(null);
const [previewedModel, setPreviewedModel] = useState("");
const [triggerWidth, setTriggerWidth] = useState<number | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(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({
<div className="canvas-model-picker-brands" aria-label="选择产品模型">
{optionGroups.map((group) => {
const groupCurrent = group.models.find((item) => item.models.includes(current));
const firstModel = groupCurrent?.models[0] || group.models[0]?.models[0] || "";
return <button key={group.key} type="button" data-model-picker-item className="canvas-model-picker-brand" onClick={() => { setActiveGroupKey(group.key); setPreviewedModel(firstModel); focusMenuOption(); }}>
return <button key={group.key} type="button" data-model-picker-item className={cn("canvas-model-picker-brand", groupCurrent && "is-active")} aria-pressed={Boolean(groupCurrent)} onClick={() => { setActiveGroupKey(group.key); focusMenuOption(); }}>
<span className="canvas-model-picker-brand-icon"><ModelLogo icon={group.icon} size={22} /></span>
<span className="canvas-model-picker-brand-copy"><strong>{group.label}</strong><small>{group.models.length} 个{group.kind === "product" ? "渠道" : "模型"}{group.scope ? ` · ${group.scope}` : ""}</small></span>
<ChevronDown className="canvas-model-picker-brand-arrow" aria-hidden="true" />
@@ -197,9 +195,7 @@ export function ModelPicker({
) : <div className="canvas-model-picker-two-pane">
<div className="canvas-model-picker-brand-rail" aria-label="产品模型">
{optionGroups.map((group) => {
const groupCurrent = group.models.find((item) => item.models.includes(current));
const firstModel = groupCurrent?.models[0] || group.models[0]?.models[0] || "";
return <button key={group.key} type="button" className={cn("canvas-model-picker-brand", activeGroupKey === group.key && "is-active")} aria-pressed={activeGroupKey === group.key} onClick={() => { setActiveGroupKey(group.key); setPreviewedModel(firstModel); }}>
return <button key={group.key} type="button" className={cn("canvas-model-picker-brand", activeGroupKey === group.key && "is-active")} aria-pressed={activeGroupKey === group.key} onClick={() => setActiveGroupKey(group.key)}>
<span className="canvas-model-picker-brand-icon"><ModelLogo icon={group.icon} size={22} /></span>
<span className="canvas-model-picker-brand-copy"><strong>{group.label}</strong><small>{group.models.length} 个{group.kind === "product" ? "渠道" : "模型"}{group.scope ? ` · ${group.scope}` : ""}</small></span>
<ChevronDown className="canvas-model-picker-brand-arrow" aria-hidden="true" />
@@ -207,10 +203,6 @@ export function ModelPicker({
})}
</div>
{optionGroups.filter((group) => group.key === activeGroupKey).map((group) => <section key={group.key} className="canvas-model-picker-group canvas-model-picker-model-pane min-w-0 overflow-hidden">
<div className="canvas-model-picker-secondary-head">
<button type="button" className="canvas-model-picker-back" onClick={() => { setActiveGroupKey(null); focusMenuOption(); }} aria-label="返回模型列表"><ChevronLeft /></button>
<span><strong>{group.label}</strong>{group.scope ? <small>{group.scope}</small> : null}</span>
</div>
<div className="grid min-w-0 gap-1">
{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());
}}
>
<ModelLabel
@@ -249,9 +236,9 @@ export function ModelPicker({
requirements={requirements}
showPrice={showOptionPrices && creditsEnabled}
disabledReason={disabledReason}
showDescription={selected || previewedModel === displayModel}
showDescription
/>
{selected ? <Check className="canvas-model-picker-option-check ml-1 shrink-0" style={{ color: theme.node.activeStroke }} /> : null}
<span className="canvas-model-picker-option-check ml-1 shrink-0" aria-hidden="true">{selected ? <Check className="size-full" style={{ color: theme.node.activeStroke }} /> : null}</span>
</button>
);
})}
@@ -352,9 +339,10 @@ export function ModelLabel({
</span>
<span className="min-w-44 flex-1 overflow-hidden">
<span className="block min-w-0 truncate text-[var(--fs-label)] font-medium leading-none">{label || pickerModelDisplayName(config, model, showConfiguredModelName)}</span>
<span className={cn("canvas-model-picker-description mt-1 block truncate text-[var(--fs-tiny)]", showDescription && "is-visible")} style={{ color: theme.node.muted }} title={capabilitySummary}>
<span className={cn("canvas-model-picker-description mt-1 block truncate text-[var(--fs-tiny)]", showDescription && "is-visible")} style={{ color: theme.node.muted }}>
{capabilitySummary}
</span>
<ModelTags tags={logicalCost?.tags} />
</span>
{showPrice ? (
<span className="ml-auto shrink-0 pl-2">
@@ -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 (
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={modelQuoteDescription(quote)}>
<Coins className="size-3" />
<span className="model-picker-price inline-flex shrink-0 items-center gap-1 text-[var(--fs-tiny)] font-bold tabular-nums" aria-label={modelQuoteDescription(quote)}>
<Coins className="model-picker-price-icon" aria-hidden="true" />
{compact ? label : `${label} 积分`}
</span>
);
@@ -519,19 +507,19 @@ function ModelPrice({ price, quote, compact = false }: { price: ModelMenuPrice |
if (price === null) return compact ? null : <span className="shrink-0 text-[var(--fs-tiny)] text-foreground/40">未配置</span>;
if (price.kind === "tiers") {
return (
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={price.title}>
<Coins className="size-3" />
<span className="model-picker-price inline-flex shrink-0 items-center gap-1 text-[var(--fs-tiny)] font-bold tabular-nums">
<Coins className="model-picker-price-icon" aria-hidden="true" />
{compact ? price.compactLabel : price.label}
</span>
);
}
if (price.kind === "estimate") {
return <span className="shrink-0 text-[var(--fs-tiny)] font-medium text-amber-600 dark:text-amber-300" title={price.title}>{price.label || "按量预估"}</span>;
return <span className="model-picker-price inline-flex shrink-0 items-center text-[var(--fs-tiny)] font-semibold"><Coins className="model-picker-price-icon" aria-hidden="true" />{price.label || "按量预估"}</span>;
}
return (
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={`每${price.unit}消耗 ${price.value.toLocaleString("zh-CN", { maximumFractionDigits: 6 })} 积分`}>
<Coins className="size-3" />
{price.value.toLocaleString("zh-CN", { maximumFractionDigits: compact ? 3 : 6 })}/{price.unit}
<span className="model-picker-price inline-flex shrink-0 items-center gap-1 text-[var(--fs-tiny)] font-bold tabular-nums">
<Coins className="model-picker-price-icon" aria-hidden="true" />
{price.value.toLocaleString("zh-CN", { maximumFractionDigits: compact ? 3 : 6 })}{compact ? "/" : " 积分/"}{price.unit}
</span>
);
}

View File

@@ -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 <span className="model-tags" aria-label="模型标签">
{tags.map((tag, index) => <span key={`${tag.text}-${index}`} className="model-tag" data-color={tag.color}>{tag.text}</span>)}
</span>;
}

10
web/src/lib/model-tags.ts Normal file
View File

@@ -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"] };

View File

@@ -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,

View File

@@ -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,

View File

@@ -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}
<Form.Item name="description" label="模型描述" extra="在创作端二级渠道选项悬浮或聚焦时显示,可说明适用场景、渠道差异和注意事项。" rules={[{ max: 500, message: "模型描述不能超过 500 字" }]}>
<ChannelModelTagsEditor />
<Form.Item name="description" label="模型描述" extra="在创作端二级渠道选项中常驻显示,可说明适用场景、渠道差异和注意事项。" rules={[{ max: 500, message: "模型描述不能超过 500 字" }]}>
<Input.TextArea rows={3} maxLength={500} showCount placeholder="填写此渠道模型的使用说明" />
</Form.Item>
</section>

View File

@@ -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 <Form.Item label="展示标签" extra="最多 5 个,每个 12 字;展示在用户的二级渠道选项中,不改变实际计费。">
<Form.List name="tags" rules={[{ validator: async (_, value: ModelTag[] = []) => {
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 }) => <div className="grid gap-2">
{fields.map(({ key, name, ...rest }) => <div key={key} className="flex flex-wrap items-start gap-2">
<Form.Item {...rest} name={[name, "text"]} className="mb-0 min-w-32 flex-1" rules={[{ validator: async (_, value: string) => {
const length = Array.from(value?.trim() || "").length;
if (length < 1 || length > 12) throw new Error("标签文字须为 1–12 字");
} }]}>
<Input aria-label={`标签 ${name + 1} 文字`} placeholder="例如:限时特价、官方1折" />
</Form.Item>
<Form.Item {...rest} name={[name, "color"]} className="mb-0 w-28" rules={[{ required: true, message: "请选择颜色" }]}>
<Select aria-label={`标签 ${name + 1} 颜色`} options={modelTagColors.map((color) => ({ value: color.value, label: <span className="model-tag" data-color={color.value}>{color.label}</span> }))} />
</Form.Item>
<Button type="text" aria-label={`删除标签 ${name + 1}`} icon={<Trash2 className="size-4" />} onClick={() => remove(name)} />
</div>)}
<Button type="dashed" icon={<Plus className="size-4" />} disabled={fields.length >= 5} onClick={() => add({ text: "", color: "purple" })}>添加标签</Button>
<Form.ErrorList errors={errors} />
{tags.some((tag) => tag?.text?.trim()) ? <div><span className="text-xs text-foreground/60">展示预览</span><ModelTags tags={tags.filter((tag) => tag?.text?.trim())} /></div> : null}
</div>}
</Form.List>
</Form.Item>;
}

View File

@@ -1,4 +1,5 @@
import { http } from "@/services/api/request";
import type { ModelTag } from "@/lib/model-tags";
export type InputConstraint = { min: number; max: number };
export type OptionConstraint = { values?: unknown[]; min?: number; max?: number; step?: number };
@@ -168,6 +169,7 @@ export type PublicChannelModel = {
modelKey: string;
displayName: string;
channelLabel?: string;
tags?: ModelTag[];
description?: string;
sortOrder?: number;
icon: string;

View File

@@ -1,4 +1,5 @@
import { http } from "@/services/api/request";
import type { ModelTag } from "@/lib/model-tags";
export type CreditAccount = {
@@ -53,6 +54,7 @@ export type ChannelModel = {
providerModelKey: string;
displayName: string;
channelLabel?: string;
tags?: ModelTag[];
description?: string;
sortOrder?: number;
icon: string;
@@ -109,6 +111,7 @@ export type ChannelModelMutation = {
providerModelKey?: string;
displayName?: string;
channelLabel?: string;
tags?: ModelTag[];
description?: string;
icon?: string;
capability: ChannelModel["capability"];

View File

@@ -1,4 +1,5 @@
import { useMemo } from "react";
import type { ModelTag } from "@/lib/model-tags";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { nanoid } from "nanoid";
@@ -363,6 +364,7 @@ export type ModelChannel = {
model: string;
displayName?: string;
channelLabel?: string;
tags?: ModelTag[];
description?: string;
icon?: string;
capability: ModelCapability;

View File

@@ -119,9 +119,6 @@
transition: background-color 150ms ease, transform 120ms ease;
}
.creation-model-picker-menu .canvas-model-picker-option:hover {
transform: translateY(-1px);
}
.creation-model-picker-menu .canvas-model-picker-option > span:first-child {
gap: 12px;
@@ -359,17 +356,14 @@ html:not(.dark) .creation-model-picker.canvas-composer-model-picker[aria-expande
width: 100%;
}
.canvas-model-picker-option:hover {
background: color-mix(in srgb, var(--workspace-accent) 9%, transparent) !important;
}
.canvas-model-picker-option[aria-selected="true"] {
background: color-mix(in srgb, var(--workspace-accent) 6%, transparent) !important;
}
.canvas-model-picker-option:focus-visible {
background: color-mix(in srgb, var(--workspace-accent) 13%, transparent) !important;
outline: none;
outline: 2px solid var(--control-selected-border);
outline-offset: -2px;
}
.canvas-model-picker-option > span:first-child {
@@ -382,8 +376,8 @@ html:not(.dark) .creation-model-picker.canvas-composer-model-picker[aria-expande
}
.canvas-model-picker-popover .canvas-model-picker-option > span:first-child > span:nth-child(2) {
flex: 0 0 auto;
min-width: max-content;
flex: 1 1 auto;
min-width: 0;
overflow: visible;
}
@@ -552,7 +546,6 @@ html:not(.dark) .creation-chat-composer .creation-model-picker.canvas-composer-m
.creation-model-picker-heading strong { color: #e6e8eb; font-weight: 500; }
.creation-model-picker-menu .canvas-model-picker-group-label { padding: 6px 7px 5px; font-size: 10px; }
.creation-model-picker-menu .canvas-model-picker-option { min-height: 43px; border-radius: 6px; padding: 5px 7px; }
.creation-model-picker-menu .canvas-model-picker-option:hover { transform: none; }
.creation-model-picker-menu .canvas-model-picker-option > span:first-child { gap: 8px; }
.creation-model-picker-menu .canvas-model-picker-option > span:first-child > span:first-child { width: 26px; height: 26px; border: 0; border-radius: 5px; background: rgba(255, 255, 255, .07) !important; }
.creation-model-picker-menu .canvas-model-picker-option > span:first-child > span:nth-child(2) > span:first-child { font-size: 11px; }
@@ -603,10 +596,19 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
text-align: left;
cursor: pointer;
}
.canvas-model-picker-brand:hover,
.canvas-model-picker-brand.is-active {
border-color: var(--workspace-border);
background: var(--workspace-surface-hover);
/* 覆盖菜单外壳的透明底色,选中状态不依赖 hover。 */
.canvas-model-picker-menu .canvas-model-picker-brand[aria-pressed="true"],
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand[aria-pressed="true"] {
border-color: var(--control-selected-border);
background: var(--control-selected-bg);
color: var(--control-selected-fg);
}
.canvas-model-picker-menu .canvas-model-picker-brand[aria-pressed="true"] .canvas-model-picker-brand-arrow {
color: var(--control-selected-fg);
}
.canvas-model-picker-brand:focus-visible {
outline: 2px solid var(--control-selected-border);
outline-offset: 2px;
}
.canvas-model-picker-brand-icon {
display: grid;
@@ -649,7 +651,7 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
width: min(360px, calc(100vw - 24px)) !important;
}
.canvas-model-picker-menu.is-model-list {
width: min(720px, calc(100vw - 24px)) !important;
width: min(800px, calc(100vw - 24px)) !important;
}
.canvas-model-picker-two-pane {
display: grid;
@@ -721,15 +723,6 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
color: color-mix(in srgb, currentColor 52%, transparent);
font-size: 10px;
}
/* 模型下拉:选中项与当前 hover/focus 项展示说明,其他项保持安静。 */
.canvas-model-picker-option {
transition: background-color 160ms ease !important;
}
.canvas-model-picker-option.is-previewed:not(:disabled) {
background: color-mix(in srgb, var(--workspace-accent) 8%, var(--workspace-surface-strong)) !important;
}
.canvas-model-picker-description {
height: 16px;
line-height: 16px;
@@ -743,6 +736,33 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
opacity: 1;
}
/* 积分独立于选中态,金黄色图标与紧凑标签保持常驻可读。 */
.model-picker-price {
--model-price-ink: #946900;
color: var(--model-price-ink);
font-weight: 650;
line-height: 1.3;
gap: 3px;
white-space: nowrap;
}
.dark .model-picker-price {
--model-price-ink: #e8c45a;
}
.model-picker-price-icon {
width: 11px;
height: 11px;
flex: 0 0 11px;
}
.canvas-model-picker-option .model-picker-price {
border: 1px solid color-mix(in srgb, var(--model-price-ink) 20%, transparent);
border-radius: var(--r-sm);
background: color-mix(in srgb, var(--model-price-ink) 9%, transparent);
padding: 2px 5px;
}
@media (prefers-reduced-motion: reduce) {
.canvas-model-picker-option,
.canvas-model-picker-description { transition: none !important; }
@@ -782,7 +802,6 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-subheading { display: flex; align-items: baseline; justify-content: space-between; padding: 3px 5px 8px; color: var(--user-ink); font-size: 12px; font-weight: 650; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-subheading small { color: var(--user-ink-soft); font-size: 10px; font-weight: 500; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand { display: flex; min-height: 58px; align-items: center; gap: 10px; border: 1px solid transparent; border-radius: 13px; padding: 8px 10px; background: transparent; color: var(--user-ink); text-align: left; transition: background-color 160ms ease, border-color 160ms ease; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand:hover { border-color: var(--user-border); background: var(--user-surface-hover); }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 11px; background: var(--user-surface-muted); color: var(--user-ink-muted); }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-copy { display: grid; min-width: 0; flex: 1; gap: 4px; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-copy strong { overflow: hidden; font-size: 13px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
@@ -823,7 +842,6 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
.creation-model-picker-surface .canvas-model-picker-subheading { display: flex; align-items: baseline; justify-content: space-between; padding: 3px 5px 8px; color: var(--foreground); font-size: 12px; font-weight: 650; }
.creation-model-picker-surface .canvas-model-picker-subheading small { color: color-mix(in srgb, var(--foreground) 52%, transparent); font-size: 10px; font-weight: 500; }
.creation-model-picker-surface .canvas-model-picker-brand { display: flex; min-height: 58px; align-items: center; gap: 10px; border: 1px solid transparent; border-radius: 13px; padding: 8px 10px; background: transparent; color: var(--foreground); text-align: left; transition: background-color 160ms ease, border-color 160ms ease; }
.creation-model-picker-surface .canvas-model-picker-brand:hover { border-color: var(--workspace-border); background: var(--workspace-surface-hover); }
.creation-model-picker-surface .canvas-model-picker-brand-icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 11px; background: var(--workspace-surface-hover); color: var(--foreground); }
.creation-model-picker-surface .canvas-model-picker-brand-copy { display: grid; min-width: 0; flex: 1; gap: 4px; }
.creation-model-picker-surface .canvas-model-picker-brand-copy strong { overflow: hidden; font-size: 13px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
@@ -840,4 +858,4 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
.creation-model-picker-surface .canvas-model-picker-option > span:first-child > span:first-child svg { color: currentColor !important; opacity: 1 !important; }
.creation-model-picker-surface .canvas-model-picker-option:nth-child(3n) > span:first-child > span:first-child { color: #d57b16; }
.creation-model-picker-surface .canvas-model-picker-option:nth-child(4n) > span:first-child > span:first-child { color: #25845d; }
.creation-model-picker-surface .canvas-model-picker-option .text-amber-600 { display: inline-flex; align-items: center; gap: 5px; border-radius: 999px; background: color-mix(in srgb, #e28a17 12%, var(--workspace-surface-strong)); color: #c66d00 !important; padding: 5px 8px; font-size: 11px; font-weight: 700; }
.creation-model-picker-surface .canvas-model-picker-option .model-picker-price { font-size: var(--fs-tiny); }

View File

@@ -0,0 +1,13 @@
.model-tags { display: flex; flex-wrap: wrap; gap: 4px; min-width: 0; margin-top: 6px; }
.model-tag { --model-tag-ink: #6850a1; display: inline-flex; align-items: center; max-width: 100%; padding: 2px 6px; border: 1px solid color-mix(in srgb, var(--model-tag-ink) 22%, transparent); border-radius: var(--r-sm); color: var(--model-tag-ink); background: color-mix(in srgb, var(--model-tag-ink) 9%, transparent); font-size: var(--fs-tiny); font-weight: 600; line-height: 1.5; overflow-wrap: anywhere; }
.model-tag[data-color="blue"] { --model-tag-ink: #3562b1; }
.model-tag[data-color="green"] { --model-tag-ink: #26745b; }
.model-tag[data-color="gold"] { --model-tag-ink: #886310; }
.model-tag[data-color="orange"] { --model-tag-ink: #a75321; }
.model-tag[data-color="pink"] { --model-tag-ink: #a43e76; }
.dark .model-tag { --model-tag-ink: #c8b6ed; }
.dark .model-tag[data-color="blue"] { --model-tag-ink: #a3c3f5; }
.dark .model-tag[data-color="green"] { --model-tag-ink: #94d6b9; }
.dark .model-tag[data-color="gold"] { --model-tag-ink: #e6cb83; }
.dark .model-tag[data-color="orange"] { --model-tag-ink: #edb08b; }
.dark .model-tag[data-color="pink"] { --model-tag-ink: #e8acce; }

View File

@@ -588,7 +588,6 @@
.creation-model-picker-surface .creation-model-picker-menu.is-model-list .canvas-model-picker-two-pane { display: grid; grid-template-columns: 250px minmax(0, 1fr); gap: 14px; min-height: 0; align-items: start; }
.creation-model-picker-surface .creation-model-picker-menu.is-model-list .canvas-model-picker-brand-rail { display: grid; align-content: start; gap: 5px; border-right: 1px solid var(--workspace-border); padding-right: 14px; }
.creation-model-picker-surface .creation-model-picker-menu.is-model-list .canvas-model-picker-brand { width: 100%; }
.creation-model-picker-surface .creation-model-picker-menu.is-model-list .canvas-model-picker-brand.is-active { border-color: var(--workspace-border); background: var(--workspace-surface-hover); }
.creation-model-picker-surface .creation-model-picker-menu.is-model-list .canvas-model-picker-model-pane { border: 0; margin: 0; padding: 0; }
@media (max-width: 720px) { .creation-floating-prompt { top: 8px; width: calc(100% - 24px); min-height: 60px; gap: 10px; padding: 8px 9px 8px 16px; border-radius: 19px; } .creation-floating-prompt input { font-size: 14px; } .creation-floating-prompt button { width: 40px; height: 40px; flex-basis: 40px; } .creation-model-picker-surface .creation-model-picker-menu:is(.is-brand-list, .is-model-list) { width: calc(100vw - 24px) !important; } .creation-model-picker-surface .creation-model-picker-menu.is-model-list .canvas-model-picker-two-pane { grid-template-columns: minmax(0, 1fr); min-height: 0; } .creation-model-picker-surface .creation-model-picker-menu.is-model-list .canvas-model-picker-brand-rail { display: none; } .app-user-workspace .creation-chat-composer.is-thread .creation-chat-dock .creation-mode-tabs { height: 30px; } .app-user-workspace .creation-chat-composer.is-thread .creation-chat-dock .creation-mode-button { height: 26px; min-height: 26px; padding: 0 6px; font-size: 10px; } }

View File

@@ -11,10 +11,12 @@ describe("channel model editor drafts", () => {
test("new drafts select an enabled protocol and never share price state", () => {
const first = initialChannelModelValues(null, protocols);
first.priceTiers[0].unitPrice = 42;
first.tags.push({ text: "限时特价", color: "purple" });
const second = initialChannelModelValues(null, protocols);
expect(second.protocol).toBe("text");
expect(second.priceTiers[0].unitPrice).toBe(0);
expect(second.modelKey).toBe("");
expect(second.tags).toEqual([]);
});
test("missing catalogs do not invent a protocol", () => {
expect(initialChannelModelValues(null, []).protocol).toBeUndefined();

View File

@@ -53,6 +53,16 @@ test("grouping uses display name even when model keys differ, without merging ch
expect(groups[0].models.map((item) => item.label)).toEqual(["秘塔(满血渠道)", "秘塔(满血渠道)"]);
});
test("catalog projection keeps promotional tags scoped to each channel model", () => {
const channels = systemChannelModelChannels([
{ id: "a", name: "A", displayName: "A", models: [{ ...model("A", 100_000), tags: [{ text: "限时特价", color: "purple" }] }] },
{ id: "b", name: "B", displayName: "B", models: [{ ...model("B", 200_000), tags: [{ text: "官方1折", color: "gold" }] }] },
]);
const config = normalizeConfigSnapshot({ config: { ...defaultConfig, channels } }).config;
expect(config.channels[0].modelCosts![0].tags).toEqual([{ text: "限时特价", color: "purple" }]);
expect(config.channels[1].modelCosts![0].tags).toEqual([{ text: "官方1折", color: "gold" }]);
});
test("selection and quote keep the chosen channel even when another channel is cheaper", () => {
const config = fixture();
const value = "b::seedance-2.0";

View File

@@ -56,6 +56,20 @@ test("candidate rows do not disguise missing prices as zero", () => {
expect(markup).not.toContain("0 积分");
});
test("model rows preserve multiple promotional labels and colors without altering prices", () => {
const config = fixture();
config.channels[0].modelCosts![0].tags = [{ text: "限时特价", color: "purple" }, { text: "官方1折", color: "gold" }];
for (const theme of [canvasThemes.light, canvasThemes.dark]) {
const markup = renderToStaticMarkup(<ModelLabel config={config} model={config.model} capability="video" theme={theme} creationVariant showConfiguredModelName={false} showPrice showDescription />);
expect(markup).toContain('data-color="purple">限时特价');
expect(markup).toContain('data-color="gold">官方1折');
expect(markup).toContain("0.1-0.2 积分/秒");
}
config.channels[0].modelCosts![0].tags = [];
const markup = renderToStaticMarkup(<ModelLabel config={config} model={config.model} capability="video" theme={canvasThemes.light} showDescription />);
expect(markup).not.toContain('aria-label="模型标签"');
});
test("the selected model and request estimate still require an exact specification match", () => {
const config = fixture();
const render = (value: ModelRequirements) => renderToStaticMarkup(<ModelPicker config={config} value={config.model} capability="video" requirements={value} onChange={() => {}} />);

View File

@@ -1,5 +1,49 @@
import { expect, test } from "bun:test";
test("模型行只保留选中高亮,价格使用独立的彩色标签", async () => {
const [component, styles, workspace] = await Promise.all([
Bun.file(new URL("../src/components/model-picker.tsx", import.meta.url)).text(),
Bun.file(new URL("../src/styles/shared/model-picker.css", import.meta.url)).text(),
Bun.file(new URL("../src/styles/workspace-product.css", import.meta.url)).text(),
]);
expect(component).not.toContain("previewedModel");
expect(component).not.toContain("onMouseEnter");
expect(styles).not.toMatch(/canvas-model-picker-(?:brand|option)(?:\[[^\]]*\])?:hover/);
expect(workspace).not.toContain(".canvas-model-picker-brand.is-active");
expect(styles).toContain('.canvas-model-picker-brand[aria-pressed="true"]');
expect(styles).toContain('.canvas-model-picker-option[aria-selected="true"]');
expect(styles).toContain(".canvas-model-picker-option:focus-visible");
const price = styles.match(/\.model-picker-price \{([^}]+)\}/)?.[1] || "";
expect(price).toContain("color: var(--model-price-ink)");
expect(price).toContain("font-weight: 650");
expect(price).not.toContain("background:");
expect(styles).toContain(".dark .model-picker-price");
const badge = styles.match(/\.canvas-model-picker-option \.model-picker-price \{([^}]+)\}/)?.[1] || "";
expect(badge).toContain("border-radius: var(--r-sm)");
expect(badge).toContain("background: color-mix");
expect(badge).toContain("padding: 2px 5px");
expect(price).toContain("--model-price-ink: #946900");
expect(component).toContain('<Coins className="model-picker-price-icon" aria-hidden="true" />');
expect(styles).toContain("width: min(800px, calc(100vw - 24px))");
});
test("每次打开菜单都展开当前选中模型所属目录,无有效选中时显示一级目录", async () => {
const component = await Bun.file(new URL("../src/components/model-picker.tsx", import.meta.url)).text();
const opening = component.match(/const setPickerOpen = \(nextOpen: boolean\) => \{([\s\S]*?)\n \};/)?.[1] || "";
expect(opening).toContain("setActiveGroupKey(optionGroups.find((group) => group.models.some((item) => item.models.includes(current)))?.key ?? null)");
expect(opening).not.toContain("setActiveGroupKey(null)");
});
test("选择模型保留菜单及行内焦点,仍可通过 Escape 和外部点击关闭", async () => {
const component = await Bun.file(new URL("../src/components/model-picker.tsx", import.meta.url)).text();
const selection = component.match(/onClick=\{\(\) => \{\s*if \(!model\) return;([\s\S]*?)\}\}/)?.[1] || "";
expect(selection).toContain("onChange(model)");
expect(selection).not.toContain("setOpen(false)");
expect(selection).not.toContain("focus()");
expect(component).toContain('event.key === "Escape"');
expect(component).toContain('window.addEventListener("pointerdown", closeOnOutsidePointer, true)');
});
test("ModelPicker 样式独立加载,并保留模型列表的视口边界", async () => {
const [application, globals, pickerStyles] = await Promise.all([
Bun.file(new URL("../src/application.tsx", import.meta.url)).text(),