feat(*): 生图与视频工作台 - 新增独立生成流程并整合平台配置

This commit is contained in:
ddcat666
2026-10-13 08:54:53 +08:00
parent 4c1d2b4012
commit 1f3f2c6020
69 changed files with 7108 additions and 87 deletions

View File

@@ -1,8 +1,9 @@
import { describe, expect, test } from "bun:test";
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
import type { ModelProtocolDefinition } from "../src/lib/model-protocols";
import { changeChannelModelCapability, editorSectionForField, initialChannelModelValues, updateChannelModelUpstreamCapabilities, validateChannelModelPrices, validateChannelModelProtocol } from "../src/pages/admin/components/channel-model-editor-form";
import { changeChannelModelCapability, duplicateChannelModelValues, editorSectionForField, initialChannelModelValues, updateChannelModelUpstreamCapabilities, validateChannelModelPrices, validateChannelModelProtocol } from "../src/pages/admin/components/channel-model-editor-form";
import { defaultPriceTier } from "../src/pages/admin/components/channel-model-price-tier-form";
import type { ChannelModel, ChannelModelPriceTier } from "../src/services/api/wallet";
const definition = (value: string, capability: ModelProtocolDefinition["capability"], enabled = true): ModelProtocolDefinition => ({ value, capability, enabled, label: value, create: "POST /test", contentType: "application/json", media: "url" });
const protocols = [definition("disabled-text", "text", false), definition("text", "text"), definition("image", "image"), definition("video", "video")];
@@ -26,6 +27,67 @@ describe("channel model editor drafts", () => {
expect(second.modelKey).toBe("");
expect(second.tags).toEqual([]);
});
test("duplicate drafts clear model IDs and inherited tier IDs while preserving custom routing and configuration", () => {
const tier = (id: string, providerModelKey: string, unitPriceMicrocredits: number): ChannelModelPriceTier => ({
id,
channelModelId: "source-id",
selector: {},
selectorKey: "{}",
resolution: "*",
videoSeconds: 0,
providerModelKey,
billingMode: "fixed_request",
unitPriceMicrocredits,
inputTokenPriceMicrocredits: 0,
outputTokenPriceMicrocredits: 0,
cachedTokenPriceMicrocredits: 0,
priceConfigured: true,
enabled: true,
priceVersion: 1,
createdAt: "2026-08-29T00:00:00Z",
updatedAt: "2026-08-29T00:00:00Z",
});
const item: ChannelModel = {
id: "source-id",
channelId: "channel-id",
modelKey: "product-video",
providerModelKey: "models/vendor-video",
displayName: "视频模型组",
channelLabel: "原渠道",
tags: [{ text: "稳定", color: "green" }],
description: "视频生成配置",
icon: "",
capability: "video",
protocol: "video",
billingMode: "fixed_request",
unitPriceMicrocredits: 1_000_000,
inputTokenPriceMicrocredits: 0,
outputTokenPriceMicrocredits: 0,
cachedTokenPriceMicrocredits: 0,
priceConfigured: true,
enabled: false,
priceVersion: 1,
priceTiers: [tier("default-tier", "vendor-video", 5_000_000), tier("custom-tier", "vendor-video-hq", 8_000_000)],
createdAt: "2026-08-29T00:00:00Z",
updatedAt: "2026-08-29T00:00:00Z",
};
const draft = duplicateChannelModelValues(item, protocols);
expect(draft).toMatchObject({
modelKey: "",
providerModelKey: "",
displayName: "视频模型组",
channelLabel: "原渠道",
tags: [{ text: "稳定", color: "green" }],
description: "视频生成配置",
capability: "video",
protocol: "video",
enabled: false,
priceTiers: [{ providerModelKey: "", unitPrice: 5 }, { providerModelKey: "vendor-video-hq", unitPrice: 8 }],
});
expect(draft.tags).not.toBe(item.tags);
});
test("missing catalogs do not invent a protocol", () => {
expect(initialChannelModelValues(null, []).protocol).toBeUndefined();
expect(() => validateChannelModelProtocol("text", "text", [])).toThrow("已启用");

View File

@@ -2,6 +2,7 @@ import { expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { ModelLabel, ModelPicker } from "../src/components/model-picker";
import { canvasThemes } from "../src/lib/canvas-theme";
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
import { requestCreditCost } from "../src/lib/model-pricing";
import type { ModelRequirements } from "../src/lib/model-selection";
import { createModelChannel, defaultConfig, normalizeConfigSnapshot } from "../src/stores/use-config-store";
@@ -70,6 +71,35 @@ test("model rows preserve multiple promotional labels and colors without alterin
expect(markup).not.toContain('aria-label="模型标签"');
});
test("video capability tag is derived from limits and precedes editable model tags", () => {
const config = fixture();
const cost = config.channels[0].modelCosts![0];
cost.capabilityConfig = defaultModelCapabilityConfig();
Object.assign(cost.capabilityConfig.video!.references, { maxImages: 30, maxVideos: 10, maxAudios: 10 });
cost.tags = [{ text: "限时特价", color: "purple" }, { text: "按次收费", color: "blue" }];
const markup = renderToStaticMarkup(<ModelLabel config={config} model={config.model} capability="video" theme={canvasThemes.light} creationVariant showConfiguredModelName={false} showPrice showDescription />);
expect(markup).toContain('data-color="green">图×30 · 视×10 · 音×10');
expect(markup.indexOf("图×30 · 视×10 · 音×10")).toBeLessThan(markup.indexOf("限时特价"));
expect(markup.indexOf("限时特价")).toBeLessThan(markup.indexOf("按次收费"));
});
test("video capability tag omits zero limits while preserving editable model tags", () => {
const config = fixture();
const cost = config.channels[0].modelCosts![0];
cost.capabilityConfig = defaultModelCapabilityConfig();
Object.assign(cost.capabilityConfig.video!.references, { maxImages: 0, maxVideos: 0, maxAudios: 0 });
cost.tags = [{ text: "按次收费", color: "blue" }];
const markup = renderToStaticMarkup(<ModelLabel config={config} model={config.model} capability="video" theme={canvasThemes.light} creationVariant showConfiguredModelName={false} showPrice showDescription />);
expect(markup).not.toContain("图×");
expect(markup).not.toContain("视×");
expect(markup).not.toContain("音×");
expect(markup).toContain('data-color="blue">按次收费');
});
test("the selected model and request estimate still require an exact specification match", () => {
const config = fixture();
const render = (value: ModelRequirements) => renderToStaticMarkup(<ModelPicker config={config} value={config.model} capability="video" requirements={value} onChange={() => {}} />);

View File

@@ -0,0 +1,21 @@
import { expect, test } from "bun:test";
import { videoReferenceCapabilityTag } from "../src/lib/model-tags";
test("formats video reference limits in image, video, audio order", () => {
const examples = [
[{ maxImages: 30, maxVideos: 10, maxAudios: 10 }, "图×30 · 视×10 · 音×10"],
[{ maxImages: 9, maxVideos: 3, maxAudios: 3 }, "图×9 · 视×3 · 音×3"],
[{ maxImages: 30, maxVideos: 0, maxAudios: 0 }, "图×30"],
[{ maxImages: 30, maxVideos: 0, maxAudios: 10 }, "图×30 · 音×10"],
] as const;
for (const [references, text] of examples) {
expect(videoReferenceCapabilityTag(references)).toEqual({ text, color: "green" });
}
});
test("omits zero, negative, and non-finite reference limits", () => {
expect(videoReferenceCapabilityTag({ maxImages: 0, maxVideos: 0, maxAudios: 0 })).toBeUndefined();
expect(videoReferenceCapabilityTag({ maxImages: -1, maxVideos: Number.NaN, maxAudios: Number.POSITIVE_INFINITY })).toBeUndefined();
expect(videoReferenceCapabilityTag()).toBeUndefined();
});

View File

@@ -0,0 +1,8 @@
import { expect, test } from "bun:test";
import { defaultFeatureAvailability } from "./use-user-store";
test("new workbench entries stay closed until the backend explicitly opens them", () => {
expect(defaultFeatureAvailability.imageWorkbenchEnabled).toBe(false);
expect(defaultFeatureAvailability.videoWorkbenchEnabled).toBe(false);
});

View File

@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test";
import { filterWorkbenchTools, workbenchTaskSnapshot } from "./image-workbench-page";
import type { GenerationTask } from "@/services/api/task-center";
import type { WorkbenchTool } from "@/services/api/workbench";
const tools: WorkbenchTool[] = [
{ id: "face", kind: "image", categoryId: "people", name: "脸部三视图", subtitle: "生成正脸、侧脸", description: "角色参考", sortOrder: 0, enabled: true, supportedModes: ["image"], presets: [] },
{ id: "product", kind: "image", categoryId: "product", name: "商品白底图", subtitle: "生成商品图", description: "电商产品", sortOrder: 1, enabled: true, supportedModes: ["image"], presets: [] },
];
describe("workbenchTaskSnapshot", () => {
test("restores video settings and reference identity from safe task details", () => {
const task = {
inputJson: JSON.stringify({
metadata: {
source: "workbench",
workbenchKind: "video",
workbenchToolId: "tool-video",
workbenchPresetId: "preset-video",
workbench: {
kind: "video",
parameters: { seconds: 8, ratio: "16:9", resolution: "1080p", generateAudio: true, watermark: false },
references: [{ resourceId: "resource-1", kind: "image", role: "first_frame", slotKey: "subject" }],
},
},
}),
} as GenerationTask;
expect(workbenchTaskSnapshot(task)).toEqual({
kind: "video",
toolId: "tool-video",
presetId: "preset-video",
parameters: { seconds: 8, ratio: "16:9", resolution: "1080p", generateAudio: true, watermark: false },
references: [{ resourceId: "resource-1", kind: "image", role: "first_frame", slotKey: "subject" }],
});
});
test("does not treat a task-list summary as loaded restore data", () => {
expect(workbenchTaskSnapshot({ id: "summary" } as GenerationTask)).toBeNull();
});
});
describe("filterWorkbenchTools", () => {
test("combines category and case-insensitive text search", () => {
expect(filterWorkbenchTools(tools, "people", "侧脸").map((tool) => tool.id)).toEqual(["face"]);
expect(filterWorkbenchTools(tools, "people", "商品")).toEqual([]);
});
test("searches tool description and returns all tools for blank query", () => {
expect(filterWorkbenchTools(tools, "all", "电商").map((tool) => tool.id)).toEqual(["product"]);
expect(filterWorkbenchTools(tools, "all", " ")).toHaveLength(2);
});
});