fix(workbench): 模型目录与助手形象 - 修正 Live2D 首帧闪现和模型默认分组 (#773)

* fix(workbench): 模型目录与助手形象 - 修正 Live2D 首帧闪现和模型默认分组

进入画布时形象配置与 Live2D 首帧未就绪不再显示默认球,改为加载提示,失败才回退。

新导入模型一级目录默认未分类、二级渠道展示名默认跟随上游 ID;历史非空分组(含与模型 ID 同名)保留。

拉取模型弹窗默认只勾选已存在模型并移除逐项已存在字样;一级分组下拉点击即展开全部选项。

* style(web): 画布与模型导入 - 按 Prettier 规范格式化改动文件

修复 PR 中 Web checks 的格式化检查失败:Live2D 启动器 JSX、模型导入选择工具函数与 Live2D 加载测试按项目 Prettier 配置重新排版,不改变行为。

* test(web): Live2D 生命周期 - 修复 bun 1.3.9 下 text 导入与模块导入冲突

agent-live2d-lifecycle 曾用 with { type: text } 导入 live2d-avatar.tsx,bun 1.3.9 会按路径复用该缓存,导致新增的模块导入拿不到 Live2DAvatar 具名导出并报 SyntaxError。改为 readFileSync 读取源码文本,本地以 bun 1.3.9 复现并验证通过。
This commit is contained in:
Rou
2026-10-14 09:36:01 +08:00
committed by GitHub
parent db8a301474
commit 054dab28cc
22 changed files with 251 additions and 66 deletions

View File

@@ -286,6 +286,9 @@ func TestFetchAdminChannelModelsReaddsDeletedModel(t *testing.T) {
if active.ID == deleted.ID || active.Enabled || active.PriceConfigured { if active.ID == deleted.ID || active.Enabled || active.PriceConfigured {
t.Fatalf("re-added model = %#v", active) t.Fatalf("re-added model = %#v", active)
} }
if active.DisplayName != "未分类" || active.ProviderModelKey != "model-a" {
t.Fatalf("discovered model defaults = %#v", active)
}
var total int64 var total int64
if err := db.Unscoped().Model(&model.ChannelModel{}).Where("channel_id = ? AND model_key = ?", channel.ID, "model-a").Count(&total).Error; err != nil { if err := db.Unscoped().Model(&model.ChannelModel{}).Where("channel_id = ? AND model_key = ?", channel.ID, "model-a").Count(&total).Error; err != nil {
t.Fatal(err) t.Fatal(err)
@@ -339,6 +342,24 @@ func TestImportAdminChannelModelsOnlyImportsSelectedModels(t *testing.T) {
if len(imported) != 1 || imported[0].ModelKey != "model-b" { if len(imported) != 1 || imported[0].ModelKey != "model-b" {
t.Fatalf("imported models = %#v, want only model-b", imported) t.Fatalf("imported models = %#v, want only model-b", imported)
} }
if imported[0].DisplayName != "未分类" || imported[0].ProviderModelKey != "model-b" {
t.Fatalf("imported defaults = %#v", imported)
}
// 已有分组即使与模型 ID 相同,也不能被重复导入重置。
if err := db.Model(&imported[0]).Updates(map[string]any{"display_name": "model-b", "channel_label": "手动名称"}).Error; err != nil {
t.Fatal(err)
}
result, err = svc.ImportAdminChannelModels(context.Background(), admin, channel.ID, []string{"model-b"})
if err != nil || result.Added != 0 {
t.Fatalf("duplicate import = %#v, %v", result, err)
}
var unchanged model.ChannelModel
if err := db.First(&unchanged, "id = ?", imported[0].ID).Error; err != nil {
t.Fatal(err)
}
if unchanged.DisplayName != "model-b" || unchanged.ChannelLabel != "手动名称" {
t.Fatalf("duplicate import overwrote existing names: %#v", unchanged)
}
} }
func TestImportAdminChannelModelsRejectsUnknownSelection(t *testing.T) { func TestImportAdminChannelModelsRejectsUnknownSelection(t *testing.T) {

View File

@@ -92,7 +92,7 @@ func (s *Service) AdminReferences(actor *model.User) (*AdminReferenceData, error
if item.Enabled { if item.Enabled {
models = append(models, item.ModelKey) models = append(models, item.ModelKey)
} }
displayNames = append(displayNames, firstNonEmpty(strings.TrimSpace(item.DisplayName), item.ModelKey)) displayNames = append(displayNames, strings.TrimSpace(item.DisplayName))
} }
result.Channels = append(result.Channels, AdminChannelReference{ID: channel.ID, Name: channel.Name, Enabled: channel.Enabled, Models: uniqueNonEmpty(models), ModelDisplayNames: uniqueNonEmpty(displayNames)}) result.Channels = append(result.Channels, AdminChannelReference{ID: channel.ID, Name: channel.Name, Enabled: channel.Enabled, Models: uniqueNonEmpty(models), ModelDisplayNames: uniqueNonEmpty(displayNames)})
} }

View File

@@ -209,7 +209,7 @@ func TestAdminReferencesIncludeExistingDisplayNameGroups(t *testing.T) {
groups[name]++ groups[name]++
} }
} }
if len(groups) != 3 || groups["Shared"] != 1 || groups["Other"] != 1 || groups["d"] != 1 { if len(groups) != 2 || groups["Shared"] != 1 || groups["Other"] != 1 {
t.Fatalf("groups = %#v", groups) t.Fatalf("groups = %#v", groups)
} }
} }

View File

@@ -0,0 +1,52 @@
package app
import (
"strings"
"testing"
"yingce/backend/internal/model"
)
func TestChannelModelDirectoryDefaultsAndCustomNames(t *testing.T) {
svc, db := newChannelModelTestService(t)
channel := model.ModelChannel{ID: "directory-defaults", Scope: model.ChannelScopeSystem, Enabled: true, Name: "Test", BaseURL: "https://example.invalid/v1", APIKey: "synthetic", ModelsJSON: `[]`}
if err := db.Create(&channel).Error; err != nil {
t.Fatal(err)
}
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin}
enabled := true
request := ChannelModelRequest{
ModelKey: "product", ProviderModelKey: "upstream", Capability: "text", Protocol: string(model.ChannelInterfaceChatCompletion),
CapabilityConfig: DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceChatCompletion), "upstream"),
PriceTiers: []ChannelModelPriceTierRequest{{BillingMode: "fixed_request", PriceConfigured: true, Enabled: &enabled}},
Enabled: &enabled,
}
saved, err := svc.SaveAdminChannelModel(admin, channel.ID, "", request)
if err != nil {
t.Fatal(err)
}
if saved.DisplayName != "未分类" {
t.Fatalf("default group = %q", saved.DisplayName)
}
public, err := svc.sanitizeChannelModel(saved)
if err != nil || public.ChannelLabel != "upstream" {
t.Fatalf("default second-level label = %q, %v", public.ChannelLabel, err)
}
request.DisplayName, request.ChannelLabel = "product", "自定义渠道"
saved, err = svc.SaveAdminChannelModel(admin, channel.ID, saved.ID, request)
if err != nil || saved.DisplayName != "product" || saved.ChannelLabel != "自定义渠道" {
t.Fatalf("custom names = %#v, %v", saved, err)
}
// 默认名称无需写入较短的 channel_label 列,允许跟随超过 80 字的上游 ID。
request.DisplayName = "未分类"
request.ProviderModelKey = strings.Repeat("a", 100)
request.ChannelLabel = request.ProviderModelKey
saved, err = svc.SaveAdminChannelModel(admin, channel.ID, saved.ID, request)
if err != nil {
t.Fatal(err)
}
public, err = svc.sanitizeChannelModel(saved)
if err != nil || public.ChannelLabel != request.ProviderModelKey || saved.ChannelLabel != "" {
t.Fatalf("long default label = %q, %v", public.ChannelLabel, err)
}
}

View File

@@ -151,9 +151,6 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
if len([]rune(description)) > 500 { if len([]rune(description)) > 500 {
return nil, BadAuthRequest("模型描述不能超过 500 字") return nil, BadAuthRequest("模型描述不能超过 500 字")
} }
if len([]rune(channelLabel)) > 80 {
return nil, BadAuthRequest("渠道展示名不能超过 80 字")
}
channel, err := s.repo.AdminSystemChannel(channelID) channel, err := s.repo.AdminSystemChannel(channelID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -162,6 +159,13 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 空值表示跟随上游 ID;不把默认名称固化,避免上游重命名后遗留旧 ID。
if channelLabel == providerModelKey {
channelLabel = ""
}
if len([]rune(channelLabel)) > 80 {
return nil, BadAuthRequest("渠道展示名不能超过 80 字")
}
// 先检查同渠道重复模型,避免无关能力校验或生成无用序列号掩盖真正的冲突。 // 先检查同渠道重复模型,避免无关能力校验或生成无用序列号掩盖真正的冲突。
conflict, conflictErr := s.repo.ChannelModelByKeyIncludingDisabled(channelID, modelKey) conflict, conflictErr := s.repo.ChannelModelByKeyIncludingDisabled(channelID, modelKey)
if conflictErr != nil && !errors.Is(conflictErr, gorm.ErrRecordNotFound) { if conflictErr != nil && !errors.Is(conflictErr, gorm.ErrRecordNotFound) {
@@ -207,12 +211,12 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
item.ModelKey = modelKey item.ModelKey = modelKey
item.ProviderModelKey = providerModelKey item.ProviderModelKey = providerModelKey
item.DisplayName = strings.TrimSpace(req.DisplayName) item.DisplayName = strings.TrimSpace(req.DisplayName)
if item.DisplayName == "" {
item.DisplayName = "未分类"
}
item.ChannelLabel = channelLabel item.ChannelLabel = channelLabel
item.Tags = tags item.Tags = tags
item.Description = description item.Description = description
if item.DisplayName == "" {
item.DisplayName = modelKey
}
item.Icon = strings.TrimSpace(req.Icon) item.Icon = strings.TrimSpace(req.Icon)
item.Capability = capability item.Capability = capability
item.Protocol = protocol item.Protocol = protocol
@@ -387,7 +391,7 @@ func (s *Service) syncInitialChannelModels(channel *model.ModelChannel, names []
if idErr != nil { if idErr != nil {
return idErr return idErr
} }
item := model.ChannelModel{ID: modelID, ChannelID: channel.ID, ModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceConfigured: false, UnitPriceMicrocredits: 0, PriceVersion: 1} item := model.ChannelModel{ID: modelID, ChannelID: channel.ID, ModelKey: name, ProviderModelKey: name, DisplayName: "未分类", BillingMode: "fixed_request", Enabled: false, PriceConfigured: false, UnitPriceMicrocredits: 0, PriceVersion: 1}
if err := s.repo.SaveChannelModel(&item); err != nil { if err := s.repo.SaveChannelModel(&item); err != nil {
return err return err
} }

View File

@@ -48,7 +48,7 @@ func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User
if idErr != nil { if idErr != nil {
return nil, idErr return nil, idErr
} }
missing = append(missing, model.ChannelModel{ID: modelID, ChannelID: channelID, ModelKey: name, ProviderModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceVersion: 1}) missing = append(missing, model.ChannelModel{ID: modelID, ChannelID: channelID, ModelKey: name, ProviderModelKey: name, DisplayName: "未分类", BillingMode: "fixed_request", Enabled: false, PriceVersion: 1})
} }
added, err := s.repo.CreateMissingChannelModels(missing) added, err := s.repo.CreateMissingChannelModels(missing)
if err != nil { if err != nil {
@@ -133,7 +133,7 @@ func (s *Service) ImportAdminChannelModels(ctx context.Context, actor *model.Use
if idErr != nil { if idErr != nil {
return nil, idErr return nil, idErr
} }
missing = append(missing, model.ChannelModel{ID: modelID, ChannelID: channelID, ModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceConfigured: false, PriceVersion: 1}) missing = append(missing, model.ChannelModel{ID: modelID, ChannelID: channelID, ModelKey: name, ProviderModelKey: name, DisplayName: "未分类", BillingMode: "fixed_request", Enabled: false, PriceConfigured: false, PriceVersion: 1})
known[key] = struct{}{} known[key] = struct{}{}
} }
added, err := s.repo.CreateMissingChannelModels(missing) added, err := s.repo.CreateMissingChannelModels(missing)

View File

@@ -245,7 +245,7 @@ func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) (PublicChannelMod
return PublicChannelModel{ return PublicChannelModel{
ID: cm.ID, ID: cm.ID,
ModelKey: cm.ModelKey, ModelKey: cm.ModelKey,
ChannelLabel: cm.ChannelLabel, ChannelLabel: firstNonEmpty(cm.ChannelLabel, cm.ProviderModelKey, cm.ModelKey),
Tags: cm.Tags, Tags: cm.Tags,
Description: cm.Description, Description: cm.Description,
DisplayName: cm.DisplayName, DisplayName: cm.DisplayName,

View File

@@ -247,7 +247,7 @@ docker compose -f docker-compose.deploy.yml up -d backend web
| 表 | 用途 | 关键边界 | | 表 | 用途 | 关键边界 |
| --- | --- | --- | | --- | --- | --- |
| `model_channels` | 保存系统渠道与用户渠道的连接配置和排序 | 系统渠道密钥不进入公开目录;废弃渠道级前台名称,历史 `public_alias` 列保留但不再用于取名或 API 返回;按 `sort_order, created_at, id` 升序展示 | | `model_channels` | 保存系统渠道与用户渠道的连接配置和排序 | 系统渠道密钥不进入公开目录;废弃渠道级前台名称,历史 `public_alias` 列保留但不再用于取名或 API 返回;按 `sort_order, created_at, id` 升序展示 |
| `channel_models` | 保存系统渠道中的调用标识、上游模型 ID、模型展示名、渠道展示名、描述、Logo、排序、能力、协议和版本状态 | 创作目录跨渠道按 `display_name` 分组一级,`channel_label` 为二级、最长 80 字、空值使用所属渠道名称;`description` 最长 500 字、非空、默认空字符串;同一渠道内调用标识唯一,跨渠道归组不依赖调用标识;模型排序不递增价格或能力版本 | | `channel_models` | 保存系统渠道中的调用标识、上游模型 ID、模型展示名、渠道展示名、描述、Logo、排序、能力、协议和版本状态 | 创作目录跨渠道按 `display_name` 分组一级,未设置归入 `未分类`;`channel_label` 为二级、最长 80 字,空值或与上游 ID 相同的值表示不固化、读取时回退上游模型 ID;`description` 最长 500 字、非空、默认空字符串;同一渠道内调用标识唯一,跨渠道归组不依赖调用标识;模型排序不递增价格或能力版本 |
| `channel_model_price_tiers` | 保存系统模型按规格匹配的上游 SKU 与价格 | 价格档与渠道模型同事务保存;未启用或未完成定价的规格不进入普通用户目录。档位 `provider_model_key` 非空时优先于渠道模型上游键发往供应商;管理端修改模型级上游 ID 时,与旧值相同的档位键自动级联跟随,其余显式 SKU 保持不变 | | `channel_model_price_tiers` | 保存系统模型按规格匹配的上游 SKU 与价格 | 价格档与渠道模型同事务保存;未启用或未完成定价的规格不进入普通用户目录。档位 `provider_model_key` 非空时优先于渠道模型上游键发往供应商;管理端修改模型级上游 ID 时,与旧值相同的档位键自动级联跟随,其余显式 SKU 保持不变 |
| `logical_models` | 保存逻辑模型的名称、Logo、能力类型和归档状态 | 不再作为创作端模型选择目录的数据源;既有任务与供应线路仍通过 revision 和 route 关联;`code` 仅在 `archived_at IS NULL` 的活动记录中唯一 | | `logical_models` | 保存逻辑模型的名称、Logo、能力类型和归档状态 | 不再作为创作端模型选择目录的数据源;既有任务与供应线路仍通过 revision 和 route 关联;`code` 仅在 `archived_at IS NULL` 的活动记录中唯一 |

View File

@@ -205,17 +205,17 @@ CANVAS_BACKEND_DATA_DIR=/明确的数据目录 ./migrate-schema repair-canvas-re
### 用户模型目录 ### 用户模型目录
`GET /model-catalog` 固定返回 `source=system`,直接查询所有启用系统渠道下的渠道模型,目录数据在 `channels[].models[]`,`models` 固定为空数组。不受逻辑模型开关影响,不反查逻辑模型或供应线路。每项的 `modelKey` 是调用标识,`displayName` 是模型展示名,`icon` 是模型图标键,`channelLabel` 是可选的渠道展示名(默认空字符串)。后台渠道模型创建与更新请求使用相同的 `channelLabel` 字段;服务端去除首尾空白并限制为 80 字,空字符串表示使用所属渠道名称。 `GET /model-catalog` 固定返回 `source=system`,直接查询所有启用系统渠道下的渠道模型,目录数据在 `channels[].models[]`,`models` 固定为空数组。不受逻辑模型开关影响,不反查逻辑模型或供应线路。每项的 `modelKey` 是调用标识,`displayName` 是一级分组展示名(未设置时归入 `未分类`),`icon` 是模型图标键,`channelLabel` 是二级渠道展示名。后台渠道模型创建与更新请求使用相同的 `channelLabel` 字段;服务端去除首尾空白后限制为 80 字,空值或与上游模型 ID 相同的值表示不固化该名称,读取时回退到上游模型 ID(默认与上游 ID 保持一致,上游改名时自动跟随)。
渠道模型创建、更新、管理列表、公开目录和会话模型价格信息均使用 `description` 表达模型描述。服务端去除首尾空白后限制为 500 字,允许空字符串清除描述;无管理员权限或超长内容拒绝保存。创作端二级选项常驻展示该字段,不依赖悬浮 Tooltip;未填写的系统模型不按名称生成宣传文案。 渠道模型创建、更新、管理列表、公开目录和会话模型价格信息均使用 `description` 表达模型描述。服务端去除首尾空白后限制为 500 字,允许空字符串清除描述;无管理员权限或超长内容拒绝保存。创作端二级选项常驻展示该字段,不依赖悬浮 Tooltip;未填写的系统模型不按名称生成宣传文案。
以上模型接口同时使用 `tags: [{ text, color }]` 传递有序展示标签,例如 `[{"text":"限时特价","color":"purple"},{"text":"官方1折","color":"gold"}]`。最多 5 个,文字去除首尾空白后为 1–12 字且不可重复;颜色仅接受 `purple`、`blue`、`green`、`gold`、`orange`、`pink`。创建/更新由管理员操作,非法标签拒绝整个保存;提交 `tags: []`(或省略字段)清空标签。用户模型选择器在对应二级渠道项中常驻展示,标签不参与路由、价格计算或权限判断。 以上模型接口同时使用 `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`。 创作端跨渠道按模型 `displayName` 分组为一级菜单(新导入模型默认 `未分类`),二级菜单使用每条模型的 `channelLabel`;未手动设置时与上游模型 ID 保持一致,例如 `MiniMax H3 → minimax-h3`。选中值继续使用 `channelId::modelKey`,报价和生成请求保留该渠道身份。展示字段不参与路由、权限及计费。系统渠道的“前台名称/前台显示别名”已移除,管理 API 不再接受或返回 `publicAlias`。
同一产品在不同渠道中使用相同的模型展示名即可归组,`modelKey` 可以不同;相同 `modelKey` 但展示名不同会分为不同组。一级顺序与图标按后台渠道和模型排序首次出现的位置确定,二级保持后台渠道与模型顺序。即使二级展示名相同,也保留独立选项及各自能力、价格,不自动改写历史配置或交换两个展示字段。 同一产品在不同渠道中使用相同的模型展示名即可归组,`modelKey` 可以不同;相同 `modelKey` 但展示名不同会分为不同组。一级顺序与图标按后台渠道和模型排序首次出现的位置确定,二级保持后台渠道与模型顺序。即使二级展示名相同,也保留独立选项及各自能力、价格,不自动改写历史配置或交换两个展示字段。
`GET /admin/references` 的 `channels[]` 同时返回 `modelDisplayNames`,包含系统渠道已创建模型的一级分组名称(含停用模型,空展示名使用产品模型标识)。编辑器“模型展示名(一级目录)”汇总去重后提供可搜索下拉,并允许输入新分组;保存后刷新引用数据。 `GET /admin/references` 的 `channels[]` 同时返回 `modelDisplayNames`,包含系统渠道已创建模型的一级分组名称(含停用模型)。已设置过展示名的分组保留原值,即使与模型 ID 相同也算作已分组;未设置的模型不进入该列表,避免把模型 ID 当成一级分组。编辑器“模型展示名(一级目录)”汇总去重后提供可搜索下拉,点击即可展开全部已有分组,同时允许输入新分组;保存后刷新引用数据。
以下接口需要登录 Cookie,返回 `data.quote`,仅校验和报价,不创建任务、账单或冻结余额,不调用供应商生成接口。 以下接口需要登录 Cookie,返回 `data.quote`,仅校验和报价,不创建任务、账单或冻结余额,不调用供应商生成接口。

View File

@@ -34,10 +34,12 @@ import { markdownPlainText } from "@/lib/markdown-plain-text";
import type { ApprovalState } from "./canvas-cloud-agent-events"; import type { ApprovalState } from "./canvas-cloud-agent-events";
// hidden 为 true 时入口保持挂载但不显示:Live2D 模型加载开销大,面板开合不能卸载它, // hidden 为 true 时入口保持挂载但不显示:Live2D 模型加载开销大,面板开合不能卸载它,
// 否则每次重新挂载都要重新下载模型并重建渲染上下文,期间只能显示默认形象。 // 否则每次重新挂载都要重新下载模型并重建渲染上下文。
export function AgentLauncher({ theme, statusColor, approvalPending, reducedMotion, hidden = false, onOpen }: { theme: CanvasTheme; statusColor: string; approvalPending: boolean; reducedMotion: boolean; hidden?: boolean; onOpen: () => void }) { export function AgentLauncher({ theme, statusColor, approvalPending, reducedMotion, hidden = false, onOpen }: { theme: CanvasTheme; statusColor: string; approvalPending: boolean; reducedMotion: boolean; hidden?: boolean; onOpen: () => void }) {
const appearance = useAppearanceStore((state) => state.appearance.canvas) || DEFAULT_CANVAS_APPEARANCE; const appearance = useAppearanceStore((state) => state.appearance.canvas) || DEFAULT_CANVAS_APPEARANCE;
const appearanceResolved = useAppearanceStore((state) => state.resolved);
const live = appearance.avatarType === "live2d" && Boolean(appearance.live2dResourceId && appearance.live2dEntry); const live = appearance.avatarType === "live2d" && Boolean(appearance.live2dResourceId && appearance.live2dEntry);
const [live2DError, setLive2DError] = useState("");
const [viewport, setViewport] = useState(() => ({ width: window.innerWidth, height: window.innerHeight })); const [viewport, setViewport] = useState(() => ({ width: window.innerWidth, height: window.innerHeight }));
useEffect(() => { useEffect(() => {
const resize = () => setViewport({ width: window.innerWidth, height: window.innerHeight }); const resize = () => setViewport({ width: window.innerWidth, height: window.innerHeight });
@@ -51,7 +53,7 @@ export function AgentLauncher({ theme, statusColor, approvalPending, reducedMoti
<motion.button <motion.button
type="button" type="button"
aria-label={`打开${appearance.agentName}`} aria-label={`打开${appearance.agentName}`}
title={`${approvalPending ? "Agent 等待你的审批" : "打开 Agent 助手"} · 拖动可调整位置,聚焦后可用方向键移动`} title={live && live2DError ? live2DError : `${approvalPending ? "Agent 等待你的审批" : "打开 Agent 助手"} · 拖动可调整位置,聚焦后可用方向键移动`}
className={cn("canvas-agent-launcher fixed z-[calc(var(--z-toast)+1)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/35", dragging && "is-dragging", live && "canvas-agent-launcher-live2d")} className={cn("canvas-agent-launcher fixed z-[calc(var(--z-toast)+1)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/35", dragging && "is-dragging", live && "canvas-agent-launcher-live2d")}
style={{ ...position, width, height, color: theme.node.text, "--canvas-agent-launcher-shadow": theme.spatial.shadow, ...(hidden ? { display: "none" } : null) } as CSSProperties} style={{ ...position, width, height, color: theme.node.text, "--canvas-agent-launcher-shadow": theme.spatial.shadow, ...(hidden ? { display: "none" } : null) } as CSSProperties}
aria-hidden={hidden || undefined} aria-hidden={hidden || undefined}
@@ -63,11 +65,29 @@ export function AgentLauncher({ theme, statusColor, approvalPending, reducedMoti
transition={{ duration: reducedMotion ? 0 : 0.18 }} transition={{ duration: reducedMotion ? 0 : 0.18 }}
> >
{live ? ( {live ? (
<Live2DAvatar url={live2DModelURL(appearance.live2dResourceId, appearance.live2dEntry)} width={width} height={height} reducedMotion={reducedMotion} fallback={<FluidOrb size={60} color="#7164f6" />} /> <Live2DAvatar
) : ( url={live2DModelURL(appearance.live2dResourceId, appearance.live2dEntry)}
width={width}
height={height}
reducedMotion={reducedMotion}
fallback={<FluidOrb size={60} color="#7164f6" />}
onReady={() => setLive2DError("")}
onError={setLive2DError}
/>
) : appearanceResolved ? (
<FluidOrb size={60} color="#7164f6" /> <FluidOrb size={60} color="#7164f6" />
) : (
<span role="status" aria-busy="true">
<LoaderCircle className={reducedMotion ? "size-6" : "size-6 motion-safe:animate-spin"} aria-hidden="true" />
<span className="sr-only">正在加载助手形象</span>
</span>
)} )}
{appearance.launcherLabel ? <span className="canvas-agent-launcher-label">{appearance.launcherLabel}</span> : null} {appearance.launcherLabel ? <span className="canvas-agent-launcher-label">{appearance.launcherLabel}</span> : null}
{live && live2DError ? (
<span className="sr-only" role="status">
{live2DError}
</span>
) : null}
<span className={cn("canvas-agent-launcher-status", approvalPending && "is-pending")} style={{ "--canvas-agent-status-color": statusColor } as CSSProperties} /> <span className={cn("canvas-agent-launcher-status", approvalPending && "is-pending")} style={{ "--canvas-agent-status-color": statusColor } as CSSProperties} />
{approvalPending ? <span className="canvas-agent-launcher-badge">待审批</span> : null} {approvalPending ? <span className="canvas-agent-launcher-badge">待审批</span> : null}
</motion.button> </motion.button>

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
import { LoaderCircle } from "lucide-react";
import { installLive2DLoader } from "@/services/live2d-loader"; import { installLive2DLoader } from "@/services/live2d-loader";
import { adaptLive2DRenderOrders } from "@/lib/canvas/live2d-core-adapter"; import { adaptLive2DRenderOrders } from "@/lib/canvas/live2d-core-adapter";
@@ -95,8 +96,9 @@ export function Live2DAvatar({
model.update(Math.min(now - elapsed, 100)); model.update(Math.min(now - elapsed, 100));
elapsed = now; elapsed = now;
app.render(); app.render();
} catch { } catch (error) {
failRendering("Live2D 渲染失败,已回退默认形象"); const detail = error instanceof Error && error.message ? `:${error.message}` : "";
failRendering(`Live2D 渲染失败${detail},已回退默认形象`);
} }
}; };
const sync = () => { const sync = () => {
@@ -188,9 +190,16 @@ export function Live2DAvatar({
}; };
}, [url, width, height, reducedMotion]); }, [url, width, height, reducedMotion]);
return ( return (
<span style={{ display: "grid", placeItems: "center", width, height, pointerEvents: "none" }}> <span aria-busy={!failed && !ready} style={{ display: "grid", placeItems: "center", width, height, pointerEvents: "none" }}>
<span ref={host} style={{ gridArea: "1 / 1", visibility: failed || !ready ? "hidden" : "visible", width, height }} /> <span ref={host} style={{ gridArea: "1 / 1", visibility: failed || !ready ? "hidden" : "visible", width, height }} />
{failed || !ready ? <span style={{ gridArea: "1 / 1" }}>{fallback}</span> : null} {failed ? (
<span style={{ gridArea: "1 / 1" }}>{fallback}</span>
) : !ready ? (
<span role="status" style={{ gridArea: "1 / 1" }}>
<LoaderCircle className={reducedMotion ? "size-6" : "size-6 motion-safe:animate-spin"} aria-hidden="true" />
<span className="sr-only">正在加载 Live2D 模型</span>
</span>
) : null}
</span> </span>
); );
} }

View File

@@ -1,4 +1,4 @@
import { configuredModelDisplayName, groupModelsByDisplayName, isDirectSystemModel, type DisplayModelGroup } from "@/lib/model-selection"; import { groupModelsByDisplayName, isDirectSystemModel, type DisplayModelGroup } from "@/lib/model-selection";
import { modelIcon, modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, type AiConfig } from "@/stores/use-config-store"; import { modelIcon, modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, type AiConfig } from "@/stores/use-config-store";
export type ModelPickerGroup = { export type ModelPickerGroup = {
@@ -15,18 +15,19 @@ export { isDirectSystemModel } from "@/lib/model-selection";
export function modelChannelLabel(config: AiConfig, value: string) { export function modelChannelLabel(config: AiConfig, value: string) {
const channel = resolveModelChannel(config, value); const channel = resolveModelChannel(config, value);
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value)); const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
if (channel.scope === "system") return cost?.channelLabel?.trim() || "系统模型"; if (channel.scope === "system") return cost?.channelLabel?.trim() || modelOptionName(value);
return cost?.channelLabel?.trim() || channel.name || "未命名渠道"; return cost?.channelLabel?.trim() || channel.name || "未命名渠道";
} }
// 一级按模型展示名跨渠道聚合;二级保留每条渠道模型的独立选择值、能力和售价。 // 一级按已设置的模型展示名跨渠道聚合,空值归入未分类;二级保留独立调用与售价。
export function groupModelsForPicker(config: AiConfig, options: string[]): ModelPickerGroup[] { export function groupModelsForPicker(config: AiConfig, options: string[]): ModelPickerGroup[] {
const groups = new Map<string, ModelPickerGroup>(); const groups = new Map<string, ModelPickerGroup>();
for (const channel of config.channels) { for (const channel of config.channels) {
const models = options.filter((value) => resolveModelChannel(config, value).id === channel.id); const models = options.filter((value) => resolveModelChannel(config, value).id === channel.id);
const directModels = models.filter((value) => isDirectSystemModel(config, value)); const directModels = models.filter((value) => isDirectSystemModel(config, value));
for (const value of directModels) { for (const value of directModels) {
const label = configuredModelDisplayName(config, value); const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
const label = cost?.displayName?.trim() || "未分类";
const key = JSON.stringify(["product", label]); const key = JSON.stringify(["product", label]);
let group = groups.get(key); let group = groups.get(key);
if (!group) { if (!group) {

View File

@@ -45,8 +45,8 @@ export function initialChannelModelValues(item: ChannelModel | null, protocols:
return { return {
modelKey: item?.modelKey || "", modelKey: item?.modelKey || "",
providerModelKey: upstreamModel, providerModelKey: upstreamModel,
displayName: item?.displayName || "", displayName: item?.displayName || "未分类",
channelLabel: item?.channelLabel || "", channelLabel: item?.channelLabel || upstreamModel,
tags: item?.tags?.map((tag) => ({ ...tag })) || [], tags: item?.tags?.map((tag) => ({ ...tag })) || [],
description: item?.description || "", description: item?.description || "",
icon: item?.icon || "", icon: item?.icon || "",

View File

@@ -39,11 +39,13 @@ export function ChannelModelEditor({
}) { }) {
const { message, modal } = App.useApp(); const { message, modal } = App.useApp();
const { references } = useAdminContext(); const { references } = useAdminContext();
const displayNameOptions = [...new Set(references.channels.flatMap((item) => item.modelDisplayNames ?? []))] const displayNameOptions = [...new Set(["未分类", ...references.channels.flatMap((item) => item.modelDisplayNames ?? [])])]
.sort((a, b) => a.localeCompare(b, "zh-CN")) .sort((a, b) => a.localeCompare(b, "zh-CN"))
.map((value) => ({ value })); .map((value) => ({ value }));
const [groupSearch, setGroupSearch] = useState("");
const [form] = Form.useForm<FormValues>(); const [form] = Form.useForm<FormValues>();
const [initialValues] = useState(() => draftValues ?? initialChannelModelValues(editing, protocols)); const [initialValues] = useState(() => draftValues ?? initialChannelModelValues(editing, protocols));
const previousUpstream = useRef(normalizeUpstreamModelKey(initialValues.providerModelKey || initialValues.modelKey));
const [activeSection, setActiveSection] = useState<EditorSection>("identity"); const [activeSection, setActiveSection] = useState<EditorSection>("identity");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
@@ -81,6 +83,11 @@ export function ChannelModelEditor({
setConfigurationChanged(true); setConfigurationChanged(true);
} else if (changed.providerModelKey !== undefined || changed.modelKey !== undefined) { } else if (changed.providerModelKey !== undefined || changed.modelKey !== undefined) {
const values = form.getFieldsValue(true); const values = form.getFieldsValue(true);
const upstream = normalizeUpstreamModelKey(values.providerModelKey || values.modelKey);
if (!values.channelLabel?.trim() || values.channelLabel.trim() === previousUpstream.current) {
form.setFieldValue("channelLabel", upstream);
}
previousUpstream.current = upstream;
const next = updateChannelModelUpstreamCapabilities(values, protocols); const next = updateChannelModelUpstreamCapabilities(values, protocols);
if (next !== values) { if (next !== values) {
form.setFieldValue("capabilityConfig", next.capabilityConfig); form.setFieldValue("capabilityConfig", next.capabilityConfig);
@@ -120,8 +127,8 @@ export function ChannelModelEditor({
const payload = { const payload = {
modelKey: values.modelKey.trim(), modelKey: values.modelKey.trim(),
providerModelKey: upstreamModel, providerModelKey: upstreamModel,
displayName: values.displayName?.trim() || values.modelKey.trim(), displayName: values.displayName?.trim() || "未分类",
channelLabel: values.channelLabel?.trim() || "", channelLabel: values.channelLabel?.trim() || upstreamModel,
tags: (values.tags || []).map((tag) => ({ text: tag.text.trim(), color: tag.color })), tags: (values.tags || []).map((tag) => ({ text: tag.text.trim(), color: tag.color })),
description: values.description?.trim() || "", description: values.description?.trim() || "",
icon: values.icon?.trim() || "", icon: values.icon?.trim() || "",
@@ -263,16 +270,19 @@ export function ChannelModelEditor({
<Form.Item name="providerModelKey" label="上游模型 ID" tooltip="实际发送给供应商;留空时使用产品模型标识。价格档可配置独立上游 ID,命中时优先于此处。"> <Form.Item name="providerModelKey" label="上游模型 ID" tooltip="实际发送给供应商;留空时使用产品模型标识。价格档可配置独立上游 ID,命中时优先于此处。">
<Input placeholder="留空则使用产品模型标识" /> <Input placeholder="留空则使用产品模型标识" />
</Form.Item> </Form.Item>
<Form.Item name="displayName" label="模型展示名(一级目录)" tooltip="跨所有系统渠道按此名称分组,例如 MiniMax H3。同名模型归入同一组,不改变调用 ID。"> <Form.Item name="displayName" label="模型展示名(一级目录)" tooltip="跨所有系统渠道按此名称分组;新模型默认未分类。同名模型归入同一组,不改变调用 ID。">
<AutoComplete <AutoComplete
options={displayNameOptions} options={displayNameOptions}
filterOption={(input, option) => Boolean(option?.value.toLowerCase().includes(input.toLowerCase()))} filterOption={(_, option) => Boolean(option?.value.toLowerCase().includes(groupSearch.trim().toLowerCase()))}
placeholder="选择已有分组或输入新名称" onSearch={setGroupSearch}
onFocus={() => setGroupSearch("")}
onOpenChange={(open) => { if (open) setGroupSearch(""); }}
placeholder="选择已有分组或输入新名称,默认为未分类"
allowClear allowClear
/> />
</Form.Item> </Form.Item>
<Form.Item name="channelLabel" label="渠道展示名(二级目录)" tooltip="此模型下的渠道选项,例如秘塔(满血渠道)。留空使用所属渠道名称。" rules={[{ max: 80, message: "渠道展示名不能超过 80 字" }]}> <Form.Item name="channelLabel" label="渠道展示名(二级目录)" tooltip="默认跟随上游模型 ID;手动名称最多 80 字。" rules={[{ validator: async (_, value: string | undefined) => { if (value?.trim() !== modelUpstream && Array.from(value?.trim() || "").length > 80) throw new Error("渠道展示名不能超过 80 字"); } }]}>
<Input maxLength={80} placeholder="例如:正常渠道、优惠渠道-993、特惠渠道-730" /> <Input maxLength={80} placeholder={modelUpstream || "默认使用上游模型 ID"} />
</Form.Item> </Form.Item>
<Form.Item name="icon" label="模型 Logo"> <Form.Item name="icon" label="模型 Logo">
<ModelIconPicker /> <ModelIconPicker />

View File

@@ -18,3 +18,16 @@ export function deselectVisibleModels(current: string[], visible: string[]): str
const hidden = new Set(visible); const hidden = new Set(visible);
return current.filter((name) => !hidden.has(name)); return current.filter((name) => !hidden.has(name));
} }
/** 初始只勾选已存在模型,新模型由管理员主动勾选。 */
export function selectExistingImportModels(models: string[], existingKeys: string[]): string[] {
const existing = new Set(existingKeys.map(normalizeFetchModelKey));
return models.filter((name) => existing.has(normalizeFetchModelKey(name)));
}
export function normalizeFetchModelKey(value: string): string {
return value
.trim()
.replace(/^models\//, "")
.toLowerCase();
}

View File

@@ -14,7 +14,7 @@ import { AdminBatchBar, AdminDataTable, AdminFilterChip, AdminStatusBadge, Admin
import { ChannelOrderDialog } from "./channel-order-dialog"; import { ChannelOrderDialog } from "./channel-order-dialog";
import { ChannelModelCostSummary } from "./channel-model-cost-summary"; import { ChannelModelCostSummary } from "./channel-model-cost-summary";
import { ChannelModelRepriceDialog } from "./channel-model-reprice-dialog"; import { ChannelModelRepriceDialog } from "./channel-model-reprice-dialog";
import { deselectVisibleModels, filterImportModels, selectVisibleModels } from "./channel-model-import-selection"; import { deselectVisibleModels, filterImportModels, normalizeFetchModelKey, selectExistingImportModels, selectVisibleModels } from "./channel-model-import-selection";
import { duplicateChannelModelValues, type ChannelModelFormValues } from "./channel-model-editor-form"; import { duplicateChannelModelValues, type ChannelModelFormValues } from "./channel-model-editor-form";
import { Select } from "@/components/ui/base/select"; import { Select } from "@/components/ui/base/select";
@@ -95,7 +95,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
return; return;
} }
setFetchPreviewModels(result.models); setFetchPreviewModels(result.models);
setSelectedFetchModels(result.models); setSelectedFetchModels(selectExistingImportModels(result.models, items.flatMap((item) => [item.modelKey, item.providerModelKey].filter(Boolean))));
setFetchPreviewOpen(true); setFetchPreviewOpen(true);
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : "拉取模型失败"); message.error(error instanceof Error ? error.message : "拉取模型失败");
@@ -265,20 +265,10 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
const visibleFetchModels = filterImportModels(fetchPreviewModels, fetchKeyword); const visibleFetchModels = filterImportModels(fetchPreviewModels, fetchKeyword);
const allVisibleFetchModelsSelected = visibleFetchModels.length > 0 && visibleFetchModels.every((name) => selectedFetchModels.includes(name)); const allVisibleFetchModelsSelected = visibleFetchModels.length > 0 && visibleFetchModels.every((name) => selectedFetchModels.includes(name));
const selectedVisibleFetchCount = visibleFetchModels.filter((name) => selectedFetchModels.includes(name)).length; const selectedVisibleFetchCount = visibleFetchModels.filter((name) => selectedFetchModels.includes(name)).length;
const fetchModelOptions = visibleFetchModels.map((name) => { const fetchModelOptions = visibleFetchModels.map((name) => ({
const alreadyExists = existingFetchModelKeys.has(normalizeFetchModelKey(name)); label: <span className="break-all">{name}</span>,
return { value: name,
label: alreadyExists ? ( }));
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 break-all">{name}</span>
<span className="shrink-0 text-xs text-foreground/45">已存在</span>
</span>
) : (
<span className="break-all">{name}</span>
),
value: name,
};
});
return ( return (
<div className="admin-channel-model-manager"> <div className="admin-channel-model-manager">
@@ -469,7 +459,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
]} ]}
> >
<div className="space-y-3"> <div className="space-y-3">
<p className="m-0 text-sm text-foreground/65">上游共返回 {fetchPreviewModels.length} 个模型。默认已全选,可批量全选或取消全选;已存在的模型不会重复导入。</p> <p className="m-0 text-sm text-foreground/65">上游共返回 {fetchPreviewModels.length} 个模型。默认勾选已存在的模型,新模型默认不勾选;可批量全选或取消全选,已存在的模型不会重复导入。</p>
<Input <Input
allowClear allowClear
autoFocus autoFocus
@@ -551,10 +541,3 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
function capabilityLabel(value: ChannelModel["capability"]) { function capabilityLabel(value: ChannelModel["capability"]) {
return { text: "文本", image: "图片", video: "视频", audio: "音频", "": "待配置" }[value]; return { text: "文本", image: "图片", video: "视频", audio: "音频", "": "待配置" }[value];
} }
function normalizeFetchModelKey(value: string) {
return value
.trim()
.replace(/^models\//, "")
.toLowerCase();
}

View File

@@ -82,9 +82,9 @@ test("channel model fetch requires explicit selection before import", async () =
expect(apiSource).toContain("http.post<{ models: string[] }>(`/admin/channels/${encodeURIComponent(channelId)}/models/fetch`)"); expect(apiSource).toContain("http.post<{ models: string[] }>(`/admin/channels/${encodeURIComponent(channelId)}/models/fetch`)");
expect(apiSource).toContain("http.post<{ models: string[]; added: number }>(`/admin/channels/${encodeURIComponent(channelId)}/models/import`, { models })"); expect(apiSource).toContain("http.post<{ models: string[]; added: number }>(`/admin/channels/${encodeURIComponent(channelId)}/models/import`, { models })");
expect(component).toContain('title="选择要导入的模型"'); expect(component).toContain('title="选择要导入的模型"');
expect(component).toContain("默认已全选"); expect(component).toContain("默认勾选已存在的模型,新模型默认不勾选");
expect(component).toContain("setFetchPreviewOpen(true)"); expect(component).toContain("setFetchPreviewOpen(true)");
expect(component).toContain("setSelectedFetchModels(result.models)"); expect(component).toContain("setSelectedFetchModels(selectExistingImportModels(result.models,");
expect(component).toContain("已选择 {selectedFetchModels.length} / {fetchPreviewModels.length} 个模型"); expect(component).toContain("已选择 {selectedFetchModels.length} / {fetchPreviewModels.length} 个模型");
expect(component).toContain("disabled={importing || !visibleFetchModels.length || allVisibleFetchModelsSelected}"); expect(component).toContain("disabled={importing || !visibleFetchModels.length || allVisibleFetchModelsSelected}");
expect(component).toContain("onClick={() => setSelectedFetchModels((current) => selectVisibleModels(current, visibleFetchModels))}"); expect(component).toContain("onClick={() => setSelectedFetchModels((current) => selectVisibleModels(current, visibleFetchModels))}");

View File

@@ -1,8 +1,12 @@
import { expect, test } from "bun:test"; import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import panelSource from "../src/components/canvas/canvas-cloud-agent-panel.tsx" with { type: "text" }; import panelSource from "../src/components/canvas/canvas-cloud-agent-panel.tsx" with { type: "text" };
import partsSource from "../src/components/canvas/canvas-cloud-agent-panel-parts.tsx" with { type: "text" }; import partsSource from "../src/components/canvas/canvas-cloud-agent-panel-parts.tsx" with { type: "text" };
// 同一路径一旦被 text 加载器注册,bun 1.3.9 的模块缓存会让其它测试的具名导入失效,这里直接读源码文本。
const avatarSource = readFileSync(new URL("../src/components/canvas/live2d-avatar.tsx", import.meta.url), "utf8");
test("Live2D launcher remains mounted while the Agent panel is open", () => { test("Live2D launcher remains mounted while the Agent panel is open", () => {
expect(panelSource).toContain("<AgentLauncher"); expect(panelSource).toContain("<AgentLauncher");
expect(panelSource).toContain("hidden={open}"); expect(panelSource).toContain("hidden={open}");
@@ -10,3 +14,17 @@ test("Live2D launcher remains mounted while the Agent panel is open", () => {
expect(partsSource).toContain("hidden = false"); expect(partsSource).toContain("hidden = false");
expect(partsSource).toContain('display: "none"'); expect(partsSource).toContain('display: "none"');
}); });
test("launcher waits for appearance configuration before displaying the default orb", () => {
expect(partsSource).toContain("const appearanceResolved = useAppearanceStore((state) => state.resolved)");
expect(partsSource).toContain(") : appearanceResolved ? (");
expect(partsSource).toContain("正在加载助手形象");
});
test("Live2D fallback is reserved for load or render failures, not initial loading", () => {
expect(avatarSource).toContain("{failed ? (");
expect(avatarSource).toContain(") : !ready ? (");
expect(avatarSource).not.toContain("{failed || !ready ? <span");
expect(avatarSource).toContain("setFailed(true)");
expect(avatarSource).toContain("callbacks.current.onError?.");
});

View File

@@ -18,6 +18,12 @@ const protocols = [definition("disabled-text", "text", false), definition("text"
const protocolsWithAudio = [...protocols, definition("audio", "audio")]; const protocolsWithAudio = [...protocols, definition("audio", "audio")];
describe("channel model editor drafts", () => { describe("channel model editor drafts", () => {
test("new drafts use 未分类 and imported drafts display the upstream ID without overwriting custom names", () => {
expect(initialChannelModelValues(null, protocols).displayName).toBe("未分类");
const imported = { modelKey: "product-id", providerModelKey: "upstream-id", displayName: "未分类", channelLabel: "", priceTiers: [] } as unknown as ChannelModel;
expect(initialChannelModelValues(imported, protocols)).toMatchObject({ displayName: "未分类", channelLabel: "upstream-id" });
expect(initialChannelModelValues({ ...imported, displayName: "product-id", channelLabel: "手动命名" }, protocols)).toMatchObject({ displayName: "product-id", channelLabel: "手动命名" });
});
test("Kemei Seedream selection initializes the image profile and preserves pricing", () => { test("Kemei Seedream selection initializes the image profile and preserves pricing", () => {
const draft = initialChannelModelValues(null, protocols); const draft = initialChannelModelValues(null, protocols);
draft.priceTiers[0].unitPrice = 19; draft.priceTiers[0].unitPrice = 19;

View File

@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"; import { expect, test } from "bun:test";
import { deselectVisibleModels, filterImportModels, selectVisibleModels } from "../src/pages/admin/components/channel-model-import-selection"; import { deselectVisibleModels, filterImportModels, selectExistingImportModels, selectVisibleModels } from "../src/pages/admin/components/channel-model-import-selection";
const MODELS = [ const MODELS = [
"MiniMax-H3", "MiniMax-H3",
@@ -50,3 +50,9 @@ test("取消选中只移除可见的,隐藏的勾选不动", () => {
test("无关键字时取消选中等于清空,与旧行为一致", () => { test("无关键字时取消选中等于清空,与旧行为一致", () => {
expect(deselectVisibleModels(MODELS, filterImportModels(MODELS, ""))).toEqual([]); expect(deselectVisibleModels(MODELS, filterImportModels(MODELS, ""))).toEqual([]);
}); });
test("已有模型默认勾选,新模型默认不勾选,沿用上游标识匹配规则", () => {
const available = ["models/gpt-a", "new-model", " GPT-B ", "gpt-c"];
expect(selectExistingImportModels(available, ["gpt-a", "models/gpt-b", "gpt-c"])).toEqual(["models/gpt-a", " GPT-B ", "gpt-c"]);
expect(selectExistingImportModels(available, [])).toEqual([]);
});

View File

@@ -0,0 +1,27 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { Live2DAvatar } from "../src/components/canvas/live2d-avatar";
function renderLoadingAvatar(reducedMotion = false) {
return renderToStaticMarkup(<Live2DAvatar url="/api/public/appearance/live2d/model-id/model.model3.json" width={165} height={220} reducedMotion={reducedMotion} fallback={<span data-default-avatar>默认球</span>} />);
}
test("Live2D first render shows loading rather than the failure fallback", () => {
const markup = renderLoadingAvatar();
expect(markup).not.toContain("data-default-avatar");
expect(markup).not.toContain("默认球");
expect(markup).toContain('role="status"');
expect(markup).toContain("正在加载 Live2D 模型");
expect(markup).toContain('aria-busy="true"');
expect(markup).toContain("visibility:hidden");
expect(markup).toContain("width:165px;height:220px");
});
test("Live2D loading respects reduced motion without showing the default avatar", () => {
expect(renderLoadingAvatar()).toContain("motion-safe:animate-spin");
const markup = renderLoadingAvatar(true);
expect(markup).not.toContain("animate-spin");
expect(markup).not.toContain("data-default-avatar");
expect(markup).toContain("正在加载 Live2D 模型");
});

View File

@@ -35,7 +35,7 @@ test("same model display name groups all channels and preserves their prices", (
expect(groups).toHaveLength(1); expect(groups).toHaveLength(1);
expect(config.channels.map((channel) => channel.modelCosts![0].description)).toEqual(["", "优惠渠道-993的使用说明", "特惠渠道-730的使用说明"]); expect(config.channels.map((channel) => channel.modelCosts![0].description)).toEqual(["", "优惠渠道-993的使用说明", "特惠渠道-730的使用说明"]);
expect(groups.map((group) => [group.label, group.kind, group.models.map((item) => [item.label, item.models])])).toEqual([ expect(groups.map((group) => [group.label, group.kind, group.models.map((item) => [item.label, item.models])])).toEqual([
["Seedance 2.0", "product", [["系统模型", ["a::seedance-2.0"]], ["优惠渠道-993", ["b::seedance-2.0"]], ["特惠渠道-730", ["c::seedance-2.0"]]]], ["Seedance 2.0", "product", [["seedance-2.0", ["a::seedance-2.0"]], ["优惠渠道-993", ["b::seedance-2.0"]], ["特惠渠道-730", ["c::seedance-2.0"]]]],
]); ]);
expect(config.channels.map((channel) => priceTierSummaryLabel(priceTiersForCurrentSelection(channel.modelCosts![0].logicalPriceTiers!, "video", config)))).toEqual(["0.3 积分/秒", "0.3 积分/秒", "0.2 积分/秒"]); expect(config.channels.map((channel) => priceTierSummaryLabel(priceTiersForCurrentSelection(channel.modelCosts![0].logicalPriceTiers!, "video", config)))).toEqual(["0.3 积分/秒", "0.3 积分/秒", "0.2 积分/秒"]);
}); });
@@ -155,3 +155,18 @@ test("managed logical models and personal channels never become system product r
expect(groups[0].models).toHaveLength(1); expect(groups[0].models).toHaveLength(1);
expect(groupModelsForPicker(config, [])).toEqual([]); expect(groupModelsForPicker(config, [])).toEqual([]);
}); });
test("new models share 未分类 while explicit groups equal to model IDs remain intact", () => {
const channels = systemChannelModelChannels([{ id: "upstream", name: "Provider", displayName: "Provider", models: [
model("upstream-a", 300000, "a", "未分类"),
model("upstream-b", 300000, "b", "未分类"),
model("upstream-c", 300000, "c", "c"),
] }]);
const config = normalizeConfigSnapshot({ config: { ...defaultConfig, channels } }).config;
const groups = groupModelsForPicker(config, selectableModelsByCapability(config, "video"));
expect(groups.map((group) => group.label)).toEqual(["未分类", "c"]);
expect(groups[0].models.map((item) => [item.label, item.models])).toEqual([
["upstream-a", ["upstream::a"]], ["upstream-b", ["upstream::b"]],
]);
expect(groups[1].models[0].models).toEqual(["upstream::c"]);
});