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

@@ -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
<motion.button
type="button"
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")}
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}
@@ -63,11 +65,29 @@ export function AgentLauncher({ theme, statusColor, approvalPending, reducedMoti
transition={{ duration: reducedMotion ? 0 : 0.18 }}
>
{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" />
) : (
<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}
{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} />
{approvalPending ? <span className="canvas-agent-launcher-badge">待审批</span> : null}
</motion.button>

View File

@@ -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 (
<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 }} />
{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>
);
}

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";
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<string, ModelPickerGroup>();
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) {

View File

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

View File

@@ -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<FormValues>();
const [initialValues] = useState(() => draftValues ?? initialChannelModelValues(editing, protocols));
const previousUpstream = useRef(normalizeUpstreamModelKey(initialValues.providerModelKey || initialValues.modelKey));
const [activeSection, setActiveSection] = useState<EditorSection>("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({
<Form.Item name="providerModelKey" label="上游模型 ID" tooltip="实际发送给供应商;留空时使用产品模型标识。价格档可配置独立上游 ID,命中时优先于此处。">
<Input placeholder="留空则使用产品模型标识" />
</Form.Item>
<Form.Item name="displayName" label="模型展示名(一级目录)" tooltip="跨所有系统渠道按此名称分组,例如 MiniMax H3。同名模型归入同一组,不改变调用 ID。">
<Form.Item name="displayName" label="模型展示名(一级目录)" tooltip="跨所有系统渠道按此名称分组;新模型默认未分类。同名模型归入同一组,不改变调用 ID。">
<AutoComplete
options={displayNameOptions}
filterOption={(input, option) => 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
/>
</Form.Item>
<Form.Item name="channelLabel" label="渠道展示名(二级目录)" tooltip="此模型下的渠道选项,例如秘塔(满血渠道)。留空使用所属渠道名称。" rules={[{ max: 80, message: "渠道展示名不能超过 80 字" }]}>
<Input maxLength={80} placeholder="例如:正常渠道、优惠渠道-993、特惠渠道-730" />
<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={modelUpstream || "默认使用上游模型 ID"} />
</Form.Item>
<Form.Item name="icon" label="模型 Logo">
<ModelIconPicker />

View File

@@ -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();
}

View File

@@ -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 ? (
<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,
};
});
const fetchModelOptions = visibleFetchModels.map((name) => ({
label: <span className="break-all">{name}</span>,
value: name,
}));
return (
<div className="admin-channel-model-manager">
@@ -469,7 +459,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
]}
>
<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
allowClear
autoFocus
@@ -551,10 +541,3 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
function capabilityLabel(value: ChannelModel["capability"]) {
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[]; 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))}");

View File

@@ -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("<AgentLauncher");
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('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")];
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", () => {
const draft = initialChannelModelValues(null, protocols);
draft.priceTiers[0].unitPrice = 19;

View File

@@ -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([]);
});

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