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:
@@ -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) {
|
||||||
|
|||||||
@@ -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)})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
52
backend/internal/app/channel_model_directory_test.go
Normal file
52
backend/internal/app/channel_model_directory_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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` 的活动记录中唯一 |
|
||||||
|
|
||||||
|
|||||||
@@ -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`,仅校验和报价,不创建任务、账单或冻结余额,不调用供应商生成接口。
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 || "",
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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))}");
|
||||||
|
|||||||
@@ -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?.");
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
|||||||
27
web/test/live2d-avatar-loading.test.tsx
Normal file
27
web/test/live2d-avatar-loading.test.tsx
Normal 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 模型");
|
||||||
|
});
|
||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user