diff --git a/backend/internal/app/admin_channel_test.go b/backend/internal/app/admin_channel_test.go index d16525f1c..d337edbfc 100644 --- a/backend/internal/app/admin_channel_test.go +++ b/backend/internal/app/admin_channel_test.go @@ -286,6 +286,9 @@ func TestFetchAdminChannelModelsReaddsDeletedModel(t *testing.T) { if active.ID == deleted.ID || active.Enabled || active.PriceConfigured { t.Fatalf("re-added model = %#v", active) } + if active.DisplayName != "未分类" || active.ProviderModelKey != "model-a" { + t.Fatalf("discovered model defaults = %#v", active) + } 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 { t.Fatal(err) @@ -339,6 +342,24 @@ func TestImportAdminChannelModelsOnlyImportsSelectedModels(t *testing.T) { if len(imported) != 1 || imported[0].ModelKey != "model-b" { 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) { diff --git a/backend/internal/app/admin_users.go b/backend/internal/app/admin_users.go index 4b8208df3..be35f4dd6 100644 --- a/backend/internal/app/admin_users.go +++ b/backend/internal/app/admin_users.go @@ -92,7 +92,7 @@ func (s *Service) AdminReferences(actor *model.User) (*AdminReferenceData, error if item.Enabled { 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)}) } diff --git a/backend/internal/app/analytics_finance_test.go b/backend/internal/app/analytics_finance_test.go index c0b4b5250..03f66032e 100644 --- a/backend/internal/app/analytics_finance_test.go +++ b/backend/internal/app/analytics_finance_test.go @@ -209,7 +209,7 @@ func TestAdminReferencesIncludeExistingDisplayNameGroups(t *testing.T) { 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) } } diff --git a/backend/internal/app/channel_model_directory_test.go b/backend/internal/app/channel_model_directory_test.go new file mode 100644 index 000000000..210168ea6 --- /dev/null +++ b/backend/internal/app/channel_model_directory_test.go @@ -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) + } +} diff --git a/backend/internal/app/channel_models.go b/backend/internal/app/channel_models.go index 2d10b099c..915d840b9 100644 --- a/backend/internal/app/channel_models.go +++ b/backend/internal/app/channel_models.go @@ -151,9 +151,6 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id if len([]rune(description)) > 500 { return nil, BadAuthRequest("模型描述不能超过 500 字") } - if len([]rune(channelLabel)) > 80 { - return nil, BadAuthRequest("渠道展示名不能超过 80 字") - } channel, err := s.repo.AdminSystemChannel(channelID) if err != nil { return nil, err @@ -162,6 +159,13 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id if err != nil { 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) 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.ProviderModelKey = providerModelKey item.DisplayName = strings.TrimSpace(req.DisplayName) + if item.DisplayName == "" { + item.DisplayName = "未分类" + } item.ChannelLabel = channelLabel item.Tags = tags item.Description = description - if item.DisplayName == "" { - item.DisplayName = modelKey - } item.Icon = strings.TrimSpace(req.Icon) item.Capability = capability item.Protocol = protocol @@ -387,7 +391,7 @@ func (s *Service) syncInitialChannelModels(channel *model.ModelChannel, names [] if idErr != nil { 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 { return err } diff --git a/backend/internal/app/channel_models_catalog.go b/backend/internal/app/channel_models_catalog.go index e3913693a..0cb60a82b 100644 --- a/backend/internal/app/channel_models_catalog.go +++ b/backend/internal/app/channel_models_catalog.go @@ -48,7 +48,7 @@ func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User if idErr != nil { 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) if err != nil { @@ -133,7 +133,7 @@ func (s *Service) ImportAdminChannelModels(ctx context.Context, actor *model.Use if idErr != nil { 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{}{} } added, err := s.repo.CreateMissingChannelModels(missing) diff --git a/backend/internal/app/model_catalog.go b/backend/internal/app/model_catalog.go index 965c53e12..f42cc14fc 100644 --- a/backend/internal/app/model_catalog.go +++ b/backend/internal/app/model_catalog.go @@ -245,7 +245,7 @@ func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) (PublicChannelMod return PublicChannelModel{ ID: cm.ID, ModelKey: cm.ModelKey, - ChannelLabel: cm.ChannelLabel, + ChannelLabel: firstNonEmpty(cm.ChannelLabel, cm.ProviderModelKey, cm.ModelKey), Tags: cm.Tags, Description: cm.Description, DisplayName: cm.DisplayName, diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index b63c49cd9..ce6d66d21 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -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` 升序展示 | -| `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 保持不变 | | `logical_models` | 保存逻辑模型的名称、Logo、能力类型和归档状态 | 不再作为创作端模型选择目录的数据源;既有任务与供应线路仍通过 revision 和 route 关联;`code` 仅在 `archived_at IS NULL` 的活动记录中唯一 | diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 2d14dd1e0..954d19039 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -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;未填写的系统模型不按名称生成宣传文案。 以上模型接口同时使用 `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` 但展示名不同会分为不同组。一级顺序与图标按后台渠道和模型排序首次出现的位置确定,二级保持后台渠道与模型顺序。即使二级展示名相同,也保留独立选项及各自能力、价格,不自动改写历史配置或交换两个展示字段。 -`GET /admin/references` 的 `channels[]` 同时返回 `modelDisplayNames`,包含系统渠道已创建模型的一级分组名称(含停用模型,空展示名使用产品模型标识)。编辑器“模型展示名(一级目录)”汇总去重后提供可搜索下拉,并允许输入新分组;保存后刷新引用数据。 +`GET /admin/references` 的 `channels[]` 同时返回 `modelDisplayNames`,包含系统渠道已创建模型的一级分组名称(含停用模型)。已设置过展示名的分组保留原值,即使与模型 ID 相同也算作已分组;未设置的模型不进入该列表,避免把模型 ID 当成一级分组。编辑器“模型展示名(一级目录)”汇总去重后提供可搜索下拉,点击即可展开全部已有分组,同时允许输入新分组;保存后刷新引用数据。 以下接口需要登录 Cookie,返回 `data.quote`,仅校验和报价,不创建任务、账单或冻结余额,不调用供应商生成接口。 diff --git a/web/src/components/canvas/canvas-cloud-agent-panel-parts.tsx b/web/src/components/canvas/canvas-cloud-agent-panel-parts.tsx index 1a978fd25..06e8ad745 100644 --- a/web/src/components/canvas/canvas-cloud-agent-panel-parts.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-panel-parts.tsx @@ -34,10 +34,12 @@ import { markdownPlainText } from "@/lib/markdown-plain-text"; import type { ApprovalState } from "./canvas-cloud-agent-events"; // 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 }) { 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 [live2DError, setLive2DError] = useState(""); const [viewport, setViewport] = useState(() => ({ width: window.innerWidth, height: window.innerHeight })); useEffect(() => { const resize = () => setViewport({ width: window.innerWidth, height: window.innerHeight }); @@ -51,7 +53,7 @@ export function AgentLauncher({ theme, statusColor, approvalPending, reducedMoti {live ? ( - } /> - ) : ( + } + onReady={() => setLive2DError("")} + onError={setLive2DError} + /> + ) : appearanceResolved ? ( + ) : ( + + )} {appearance.launcherLabel ? {appearance.launcherLabel} : null} + {live && live2DError ? ( + + {live2DError} + + ) : null} {approvalPending ? 待审批 : null} diff --git a/web/src/components/canvas/live2d-avatar.tsx b/web/src/components/canvas/live2d-avatar.tsx index 999ce5348..919ac1b17 100644 --- a/web/src/components/canvas/live2d-avatar.tsx +++ b/web/src/components/canvas/live2d-avatar.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; +import { LoaderCircle } from "lucide-react"; import { installLive2DLoader } from "@/services/live2d-loader"; import { adaptLive2DRenderOrders } from "@/lib/canvas/live2d-core-adapter"; @@ -95,8 +96,9 @@ export function Live2DAvatar({ model.update(Math.min(now - elapsed, 100)); elapsed = now; app.render(); - } catch { - failRendering("Live2D 渲染失败,已回退默认形象"); + } catch (error) { + const detail = error instanceof Error && error.message ? `:${error.message}` : ""; + failRendering(`Live2D 渲染失败${detail},已回退默认形象`); } }; const sync = () => { @@ -188,9 +190,16 @@ export function Live2DAvatar({ }; }, [url, width, height, reducedMotion]); return ( - + - {failed || !ready ? {fallback} : null} + {failed ? ( + {fallback} + ) : !ready ? ( + + + ) : null} ); } diff --git a/web/src/lib/model-picker-groups.ts b/web/src/lib/model-picker-groups.ts index fb50e7a2a..b5aa7a2e3 100644 --- a/web/src/lib/model-picker-groups.ts +++ b/web/src/lib/model-picker-groups.ts @@ -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"; export type ModelPickerGroup = { @@ -15,18 +15,19 @@ export { isDirectSystemModel } from "@/lib/model-selection"; export function modelChannelLabel(config: AiConfig, value: string) { const channel = resolveModelChannel(config, 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 || "未命名渠道"; } -// 一级按模型展示名跨渠道聚合;二级保留每条渠道模型的独立选择值、能力和售价。 +// 一级按已设置的模型展示名跨渠道聚合,空值归入未分类;二级保留独立调用与售价。 export function groupModelsForPicker(config: AiConfig, options: string[]): ModelPickerGroup[] { const groups = new Map(); for (const channel of config.channels) { const models = options.filter((value) => resolveModelChannel(config, value).id === channel.id); const directModels = models.filter((value) => isDirectSystemModel(config, value)); 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]); let group = groups.get(key); if (!group) { diff --git a/web/src/pages/admin/components/channel-model-editor-form.ts b/web/src/pages/admin/components/channel-model-editor-form.ts index 6924643ae..8e54e118e 100644 --- a/web/src/pages/admin/components/channel-model-editor-form.ts +++ b/web/src/pages/admin/components/channel-model-editor-form.ts @@ -45,8 +45,8 @@ export function initialChannelModelValues(item: ChannelModel | null, protocols: return { modelKey: item?.modelKey || "", providerModelKey: upstreamModel, - displayName: item?.displayName || "", - channelLabel: item?.channelLabel || "", + displayName: item?.displayName || "未分类", + channelLabel: item?.channelLabel || upstreamModel, tags: item?.tags?.map((tag) => ({ ...tag })) || [], description: item?.description || "", icon: item?.icon || "", diff --git a/web/src/pages/admin/components/channel-model-editor.tsx b/web/src/pages/admin/components/channel-model-editor.tsx index 45e7e9488..0a874cd36 100644 --- a/web/src/pages/admin/components/channel-model-editor.tsx +++ b/web/src/pages/admin/components/channel-model-editor.tsx @@ -39,11 +39,13 @@ export function ChannelModelEditor({ }) { const { message, modal } = App.useApp(); 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")) .map((value) => ({ value })); + const [groupSearch, setGroupSearch] = useState(""); const [form] = Form.useForm(); const [initialValues] = useState(() => draftValues ?? initialChannelModelValues(editing, protocols)); + const previousUpstream = useRef(normalizeUpstreamModelKey(initialValues.providerModelKey || initialValues.modelKey)); const [activeSection, setActiveSection] = useState("identity"); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); @@ -81,6 +83,11 @@ export function ChannelModelEditor({ setConfigurationChanged(true); } else if (changed.providerModelKey !== undefined || changed.modelKey !== undefined) { 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); if (next !== values) { form.setFieldValue("capabilityConfig", next.capabilityConfig); @@ -120,8 +127,8 @@ export function ChannelModelEditor({ const payload = { modelKey: values.modelKey.trim(), providerModelKey: upstreamModel, - displayName: values.displayName?.trim() || values.modelKey.trim(), - channelLabel: values.channelLabel?.trim() || "", + displayName: values.displayName?.trim() || "未分类", + channelLabel: values.channelLabel?.trim() || upstreamModel, tags: (values.tags || []).map((tag) => ({ text: tag.text.trim(), color: tag.color })), description: values.description?.trim() || "", icon: values.icon?.trim() || "", @@ -263,16 +270,19 @@ export function ChannelModelEditor({ - + Boolean(option?.value.toLowerCase().includes(input.toLowerCase()))} - placeholder="选择已有分组或输入新名称" + filterOption={(_, option) => Boolean(option?.value.toLowerCase().includes(groupSearch.trim().toLowerCase()))} + onSearch={setGroupSearch} + onFocus={() => setGroupSearch("")} + onOpenChange={(open) => { if (open) setGroupSearch(""); }} + placeholder="选择已有分组或输入新名称,默认为未分类" allowClear /> - - + { if (value?.trim() !== modelUpstream && Array.from(value?.trim() || "").length > 80) throw new Error("渠道展示名不能超过 80 字"); } }]}> + diff --git a/web/src/pages/admin/components/channel-model-import-selection.ts b/web/src/pages/admin/components/channel-model-import-selection.ts index 062270a0f..71155a40c 100644 --- a/web/src/pages/admin/components/channel-model-import-selection.ts +++ b/web/src/pages/admin/components/channel-model-import-selection.ts @@ -18,3 +18,16 @@ export function deselectVisibleModels(current: string[], visible: string[]): str const hidden = new Set(visible); 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(); +} diff --git a/web/src/pages/admin/components/channel-model-manager.tsx b/web/src/pages/admin/components/channel-model-manager.tsx index fc005a61a..e645336fd 100644 --- a/web/src/pages/admin/components/channel-model-manager.tsx +++ b/web/src/pages/admin/components/channel-model-manager.tsx @@ -14,7 +14,7 @@ import { AdminBatchBar, AdminDataTable, AdminFilterChip, AdminStatusBadge, Admin import { ChannelOrderDialog } from "./channel-order-dialog"; import { ChannelModelCostSummary } from "./channel-model-cost-summary"; 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 { Select } from "@/components/ui/base/select"; @@ -95,7 +95,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan return; } setFetchPreviewModels(result.models); - setSelectedFetchModels(result.models); + setSelectedFetchModels(selectExistingImportModels(result.models, items.flatMap((item) => [item.modelKey, item.providerModelKey].filter(Boolean)))); setFetchPreviewOpen(true); } catch (error) { message.error(error instanceof Error ? error.message : "拉取模型失败"); @@ -265,20 +265,10 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan const visibleFetchModels = filterImportModels(fetchPreviewModels, fetchKeyword); const allVisibleFetchModelsSelected = visibleFetchModels.length > 0 && visibleFetchModels.every((name) => selectedFetchModels.includes(name)); const selectedVisibleFetchCount = visibleFetchModels.filter((name) => selectedFetchModels.includes(name)).length; - const fetchModelOptions = visibleFetchModels.map((name) => { - const alreadyExists = existingFetchModelKeys.has(normalizeFetchModelKey(name)); - return { - label: alreadyExists ? ( - - {name} - 已存在 - - ) : ( - {name} - ), - value: name, - }; - }); + const fetchModelOptions = visibleFetchModels.map((name) => ({ + label: {name}, + value: name, + })); return (
@@ -469,7 +459,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan ]} >
-

上游共返回 {fetchPreviewModels.length} 个模型。默认已全选,可批量全选或取消全选;已存在的模型不会重复导入。

+

上游共返回 {fetchPreviewModels.length} 个模型。默认勾选已存在的模型,新模型默认不勾选;可批量全选或取消全选,已存在的模型不会重复导入。

(`/admin/channels/${encodeURIComponent(channelId)}/models/fetch`)"); expect(apiSource).toContain("http.post<{ models: string[]; added: number }>(`/admin/channels/${encodeURIComponent(channelId)}/models/import`, { models })"); expect(component).toContain('title="选择要导入的模型"'); - expect(component).toContain("默认已全选"); + expect(component).toContain("默认勾选已存在的模型,新模型默认不勾选"); 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("disabled={importing || !visibleFetchModels.length || allVisibleFetchModelsSelected}"); expect(component).toContain("onClick={() => setSelectedFetchModels((current) => selectVisibleModels(current, visibleFetchModels))}"); diff --git a/web/test/agent-live2d-lifecycle.test.ts b/web/test/agent-live2d-lifecycle.test.ts index f223a915b..b2ec09629 100644 --- a/web/test/agent-live2d-lifecycle.test.ts +++ b/web/test/agent-live2d-lifecycle.test.ts @@ -1,8 +1,12 @@ 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 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", () => { expect(panelSource).toContain(" { expect(partsSource).toContain("hidden = false"); 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 ? { + 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", () => { const draft = initialChannelModelValues(null, protocols); draft.priceTiers[0].unitPrice = 19; diff --git a/web/test/channel-model-import-selection.test.ts b/web/test/channel-model-import-selection.test.ts index b9c6fe52b..8be6b5f69 100644 --- a/web/test/channel-model-import-selection.test.ts +++ b/web/test/channel-model-import-selection.test.ts @@ -1,6 +1,6 @@ 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 = [ "MiniMax-H3", @@ -50,3 +50,9 @@ test("取消选中只移除可见的,隐藏的勾选不动", () => { test("无关键字时取消选中等于清空,与旧行为一致", () => { 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([]); +}); diff --git a/web/test/live2d-avatar-loading.test.tsx b/web/test/live2d-avatar-loading.test.tsx new file mode 100644 index 000000000..508d4fb02 --- /dev/null +++ b/web/test/live2d-avatar-loading.test.tsx @@ -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(默认球} />); +} + +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 模型"); +}); diff --git a/web/test/model-picker-groups.test.ts b/web/test/model-picker-groups.test.ts index 468502038..f6cd3e3d8 100644 --- a/web/test/model-picker-groups.test.ts +++ b/web/test/model-picker-groups.test.ts @@ -35,7 +35,7 @@ test("same model display name groups all channels and preserves their prices", ( expect(groups).toHaveLength(1); 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([ - ["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 积分/秒"]); }); @@ -155,3 +155,18 @@ test("managed logical models and personal channels never become system product r expect(groups[0].models).toHaveLength(1); 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"]); +});