feat(*): 创作配置 - 支持 Midjourney、多图保存与自定义公告
Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
48
web/test/appearance-refresh.test.ts
Normal file
48
web/test/appearance-refresh.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { refreshPublicAppearance } from "../src/services/appearance-bootstrap";
|
||||
import { DEFAULT_CANVAS_APPEARANCE } from "../src/lib/canvas/agent-appearance";
|
||||
import { commitPublicAppearance, normalizePublicAppearance, useAppearanceStore } from "../src/stores/use-appearance-store";
|
||||
|
||||
test("appearance refresh synchronizes a changed Agent name", async () => {
|
||||
const saved = normalizePublicAppearance({ canvas: { ...DEFAULT_CANVAS_APPEARANCE, agentName: "星河助手" } });
|
||||
await refreshPublicAppearance(async () => saved);
|
||||
expect(useAppearanceStore.getState().appearance.canvas?.agentName).toBe("星河助手");
|
||||
});
|
||||
|
||||
test("failed refresh preserves the last saved identity", async () => {
|
||||
commitPublicAppearance({ canvas: { ...DEFAULT_CANVAS_APPEARANCE, agentName: "影绘" } });
|
||||
await refreshPublicAppearance(async () => {
|
||||
throw new Error("offline");
|
||||
});
|
||||
expect(useAppearanceStore.getState().appearance.canvas?.agentName).toBe("影绘");
|
||||
});
|
||||
|
||||
test("focus and visibility refresh share one request", async () => {
|
||||
let resolve!: (value: ReturnType<typeof normalizePublicAppearance>) => void;
|
||||
let calls = 0;
|
||||
const pending = new Promise<ReturnType<typeof normalizePublicAppearance>>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
const fetchAppearance = async () => {
|
||||
calls++;
|
||||
return pending;
|
||||
};
|
||||
const first = refreshPublicAppearance(fetchAppearance);
|
||||
const second = refreshPublicAppearance(fetchAppearance);
|
||||
expect(calls).toBe(1);
|
||||
resolve(normalizePublicAppearance({ canvas: { ...DEFAULT_CANVAS_APPEARANCE, agentName: "星河助手" } }));
|
||||
await Promise.all([first, second]);
|
||||
expect(useAppearanceStore.getState().appearance.canvas?.agentName).toBe("星河助手");
|
||||
});
|
||||
|
||||
test("a late refresh cannot overwrite a newer admin save", async () => {
|
||||
let resolve!: (value: ReturnType<typeof normalizePublicAppearance>) => void;
|
||||
const pending = new Promise<ReturnType<typeof normalizePublicAppearance>>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
const refresh = refreshPublicAppearance(async () => pending);
|
||||
commitPublicAppearance({ canvas: { ...DEFAULT_CANVAS_APPEARANCE, agentName: "最新名称" } });
|
||||
resolve(normalizePublicAppearance({ canvas: { ...DEFAULT_CANVAS_APPEARANCE, agentName: "旧名称" } }));
|
||||
await refresh;
|
||||
expect(useAppearanceStore.getState().appearance.canvas?.agentName).toBe("最新名称");
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { applyGeneratedMediaResultMetadata, applyGenerationTaskResultToNodes, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { applyGeneratedMediaResultMetadata, applyGenerationTaskResultToNodes, generationTaskOutputsApplied, shouldRecoverCanvasImageOutputs, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { commitCanvasGenerationResult } from "@/lib/canvas/canvas-generation-result";
|
||||
import { resetGenerationTaskMetadata } from "@/lib/canvas/canvas-task-state";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
function videoNode(assetId: string, storageKey: string): CanvasNodeData {
|
||||
@@ -16,6 +18,30 @@ function videoNode(assetId: string, storageKey: string): CanvasNodeData {
|
||||
}
|
||||
|
||||
describe("applyGeneratedMediaResultMetadata", () => {
|
||||
test("旧 Midjourney 单图节点需要补齐结果,新堆叠与普通模型不重复恢复", () => {
|
||||
const old = { ...videoNode("asset", "resource:old"), type: CanvasNodeType.Image, metadata: { content: "/old.png", status: "success" as const, taskId: "task", producedModel: "CHANNEL::Midjourney v8.2 高速" } };
|
||||
const task = { id: "task", type: "canvas_image", status: "succeeded" as const, prompt: "", attempts: 1, createdAt: "", updatedAt: "", resultJson: JSON.stringify({ images: Array.from({ length: 4 }, () => ({ dataUrl: "/image.png" })) }) };
|
||||
expect(shouldRecoverCanvasImageOutputs(old)).toBe(true);
|
||||
expect(generationTaskOutputsApplied(old, task)).toBe(false);
|
||||
expect(shouldRecoverCanvasImageOutputs({ ...old, metadata: { ...old.metadata, generationOutputCount: 4 } })).toBe(false);
|
||||
expect(generationTaskOutputsApplied({ ...old, metadata: { ...old.metadata, generationOutputCount: 4 } }, task)).toBe(true);
|
||||
expect(shouldRecoverCanvasImageOutputs({ ...old, metadata: { ...old.metadata, batchRootId: "root" } })).toBe(false);
|
||||
expect(shouldRecoverCanvasImageOutputs({ ...old, metadata: { ...old.metadata, producedModel: "other-model" } })).toBe(false);
|
||||
expect(resetGenerationTaskMetadata({ generationOutputCount: 4 }).generationOutputCount).toBeUndefined();
|
||||
});
|
||||
test("多图提交保留保存期间的移动和改名,重复提交不新增或覆盖图片", () => {
|
||||
const before = { ...videoNode("", ""), type: CanvasNodeType.Image, metadata: { taskId: "task" } };
|
||||
const result = { ...before, metadata: { taskId: "task", content: "/new.png", isBatchRoot: true, batchChildIds: ["child"] } };
|
||||
const child = { ...result, id: "child", position: { x: 440, y: 0 }, metadata: { content: "/child.png", batchRootId: before.id } };
|
||||
const live = { ...before, title: "改名", position: { x: 500, y: 100 } };
|
||||
const committed = commitCanvasGenerationResult([live], before, result, "task", [child]);
|
||||
expect(committed[0]).toMatchObject({ title: "改名", position: { x: 500, y: 100 } });
|
||||
expect(committed[1].position).toEqual({ x: 940, y: 100 });
|
||||
const edited = { ...committed[1], title: "我的图片", position: { x: 3000, y: 2000 } };
|
||||
expect(commitCanvasGenerationResult([committed[0], edited], before, result, "task", [child])).toEqual([committed[0], edited]);
|
||||
expect(() => commitCanvasGenerationResult([], before, result, "task", [child])).toThrow("已删除");
|
||||
expect(() => commitCanvasGenerationResult([{ ...live, metadata: { taskId: "new-task" } }], before, result, "task", [child])).toThrow("其他生成任务");
|
||||
});
|
||||
test("明确指定的节点已删除时不能根据 taskId 误更新其他节点", async () => {
|
||||
const other = { ...videoNode("asset", "video:key"), metadata: { taskId: "task" } };
|
||||
const applied = await applyGenerationTaskResultToNodes([other], { id: "task", type: "canvas_video", status: "succeeded", prompt: "", attempts: 1, createdAt: "", updatedAt: "" }, "deleted-node");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { cancelIncompleteImageBatch, failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild, retireImageBatchChildren } from "../src/lib/canvas/canvas-image-batch-retry";
|
||||
import { removeCanvasNodes } from "../src/lib/canvas/canvas-project-domain";
|
||||
import { applyBatchPrimaryImage, removeCanvasNodes } from "../src/lib/canvas/canvas-project-domain";
|
||||
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeStatus } from "../src/types/canvas";
|
||||
|
||||
function imageNode(id: string, status: CanvasNodeStatus, metadata: Partial<NonNullable<CanvasNodeData["metadata"]>> = {}): CanvasNodeData {
|
||||
@@ -17,6 +17,12 @@ function imageNode(id: string, status: CanvasNodeStatus, metadata: Partial<NonNu
|
||||
}
|
||||
|
||||
describe("canvas image batch retry", () => {
|
||||
test("切换主图同时切换素材绑定,未绑定子图清除旧素材避免云端资源配对错误", () => {
|
||||
const root = imageNode("root", "success", { assetId: "old-asset", storageKey: "resource:old" });
|
||||
const primary = imageNode("second", "success", { assetId: "second-asset", storageKey: "resource:second", content: "/second.png" });
|
||||
expect(applyBatchPrimaryImage(root, primary).metadata).toMatchObject({ primaryImageId: "second", assetId: "second-asset", storageKey: "resource:second" });
|
||||
expect(applyBatchPrimaryImage(root, { ...primary, metadata: { ...primary.metadata, assetId: undefined } }).metadata?.assetId).toBeUndefined();
|
||||
});
|
||||
test("旧批次索引不能删除属于其他批次的节点", () => {
|
||||
const root = imageNode("root", "loading", { isBatchRoot: true, batchChildIds: ["foreign"] });
|
||||
const foreign = imageNode("foreign", "loading", { batchRootId: "other-root" });
|
||||
|
||||
@@ -12,6 +12,7 @@ import { defaultModelCapabilityConfig, pluginWorkflowCapabilityConfig } from "..
|
||||
import { ChannelModelSettings } from "../src/pages/settings/channel-video-pricing";
|
||||
import { fetchChannelModels } from "../src/services/api/image";
|
||||
import { apiClient } from "../src/services/api/request";
|
||||
import { fetchPluginProviderCatalog } from "../src/services/api/plugin-catalog";
|
||||
import { createVideoGenerationTask } from "../src/services/api/video";
|
||||
import { createModelChannel, defaultConfig, modelDisplayName, normalizeConfigSnapshot, resolveModelRequestConfig, selectableModelsByCapability, type AiConfig } from "../src/stores/use-config-store";
|
||||
|
||||
@@ -86,6 +87,34 @@ describe("public channel model catalog", () => {
|
||||
expect(profile.duration).toEqual({ selection: "enum", values: [5], default: 5 });
|
||||
});
|
||||
|
||||
test("preserves declared provider parameters in the protocol catalog", async () => {
|
||||
apiClient.request = (async () => ({
|
||||
data: {
|
||||
code: 0,
|
||||
data: {
|
||||
providers: [
|
||||
{
|
||||
id: "cangyuan-midjourney-v82",
|
||||
version: "1.0.0",
|
||||
name: "沧元 Midjourney 1K/2K · V8.2",
|
||||
vendor: "沧元算力 / 影绘",
|
||||
categories: ["image"],
|
||||
scopes: ["admin.system-channel", "user.custom-channel", "canvas"],
|
||||
enabled: true,
|
||||
parameters: [{ name: "providerOptions", type: "object", mapping: "cangyuan-midjourney-v82.speed/reference" }],
|
||||
workflows: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})) as typeof apiClient.request;
|
||||
|
||||
const result = await fetchPluginProviderCatalog("admin.system-channel", "image");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.parameters).toEqual([{ name: "providerOptions", type: "object", mapping: "cangyuan-midjourney-v82.speed/reference" }]);
|
||||
expect(result[0]?.workflows).toEqual([]);
|
||||
});
|
||||
|
||||
test("preserves six public capabilities without expanding compatibility IDs", async () => {
|
||||
apiClient.request = (async () => ({
|
||||
data: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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, validateChannelModelPrices, validateChannelModelProtocol } from "../src/pages/admin/components/channel-model-editor-form";
|
||||
import { changeChannelModelCapability, 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";
|
||||
|
||||
const definition = (value: string, capability: ModelProtocolDefinition["capability"], enabled = true): ModelProtocolDefinition => ({ value, capability, enabled, label: value, create: "POST /test", contentType: "application/json", media: "url" });
|
||||
@@ -43,6 +43,48 @@ describe("channel model editor drafts", () => {
|
||||
const draft = initialChannelModelValues(null, protocols);
|
||||
expect(changeChannelModelCapability({ ...draft, capability: "audio" }, protocols).capabilityConfig).toBeUndefined();
|
||||
});
|
||||
test("switching capability to a Midjourney protocol uses its image defaults without changing prices", () => {
|
||||
const draft = initialChannelModelValues(null, protocols);
|
||||
const next = changeChannelModelCapability({ ...draft, capability: "image", protocol: "cangyuan-midjourney-v7", modelKey: "midjourney-v7" }, [...protocols, definition("cangyuan-midjourney-v7", "image")]);
|
||||
expect(next.capabilityConfig?.image).toMatchObject({ references: { promptMaxChars: 4000, maxImages: 5, maskSupported: false }, maxOutputs: 1 });
|
||||
expect(next.priceTiers[0].unitPrice).toBe(0);
|
||||
});
|
||||
test("Kacang image protocol selection fills capabilities without changing prices", () => {
|
||||
for (const protocol of ["kacang-midjourney-special", "kacang-midjourney-v7", "kacang-midjourney"]) {
|
||||
const draft = initialChannelModelValues(null, protocols);
|
||||
draft.priceTiers[0].unitPrice = 12;
|
||||
const next = changeChannelModelCapability({ ...draft, capability: "image", protocol }, [definition(protocol, "image")]);
|
||||
expect(next.protocol).toBe(protocol);
|
||||
expect(next.capabilityConfig?.image?.maxOutputs).toBe(1);
|
||||
expect(next.capabilityConfig?.image?.references.maxImages).toBe(protocol === "kacang-midjourney" ? 5 : 1);
|
||||
expect(next.priceTiers[0].unitPrice).toBe(12);
|
||||
expect(updateChannelModelUpstreamCapabilities(next)).toBe(next);
|
||||
}
|
||||
});
|
||||
test("V8.2 upstream changes only retier presets and preserve administrator settings", () => {
|
||||
const draft = initialChannelModelValues(null, protocols);
|
||||
const capabilityConfig = defaultModelCapabilityConfig("cangyuan-midjourney-v82", "midjourney-1k");
|
||||
capabilityConfig.image!.references.promptMaxChars = 12000;
|
||||
capabilityConfig.image!.size.default = "6:11";
|
||||
const next = updateChannelModelUpstreamCapabilities({ ...draft, capability: "image", protocol: "cangyuan-midjourney-v82", modelKey: "midjourney-1k", providerModelKey: "midjourney-2k", capabilityConfig });
|
||||
expect(new Set(next.capabilityConfig!.image!.size.presets!.map((preset) => preset.tier))).toEqual(new Set(["2k"]));
|
||||
expect(next.capabilityConfig!.image!.size.values).toEqual(capabilityConfig.image!.size.values);
|
||||
expect(next.capabilityConfig!.image!.size.default).toBe("6:11");
|
||||
expect(next.capabilityConfig!.image!.references.promptMaxChars).toBe(12000);
|
||||
expect(next.priceTiers).toBe(draft.priceTiers);
|
||||
expect(capabilityConfig.image!.size.presets![0].tier).toBe("1k");
|
||||
expect(updateChannelModelUpstreamCapabilities(next)).toBe(next);
|
||||
const back = updateChannelModelUpstreamCapabilities({ ...next, providerModelKey: "midjourney-1k" });
|
||||
expect(back.capabilityConfig!.image!.size.presets![0].tier).toBe("1k");
|
||||
});
|
||||
test("existing model profiles retain saved custom capabilities on reopening", () => {
|
||||
const capabilityConfig = defaultModelCapabilityConfig("cangyuan-midjourney-v7", "midjourney-v7");
|
||||
capabilityConfig.image!.references.maxImages = 2;
|
||||
const item = { modelKey: "midjourney-v7", providerModelKey: "midjourney-v7", capability: "image" as const, protocol: "cangyuan-midjourney-v7", capabilityConfig, enabled: true };
|
||||
const reopened = initialChannelModelValues(item as Parameters<typeof initialChannelModelValues>[0], [definition("cangyuan-midjourney-v7", "image")]);
|
||||
expect(reopened.capabilityConfig?.image?.references.maxImages).toBe(2);
|
||||
expect(updateChannelModelUpstreamCapabilities(reopened)).toBe(reopened);
|
||||
});
|
||||
test("validation errors map to their mounted tab", () => {
|
||||
expect(editorSectionForField(["priceTiers", 2, "unitPrice"])).toBe("pricing");
|
||||
expect(editorSectionForField(["capabilityConfig"])).toBe("capabilities");
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race" | "canvas-batch-commit-race";
|
||||
type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race" | "canvas-batch-commit-race" | "canvas-multi-output";
|
||||
|
||||
test("一次任务四图经真实消费链路堆叠并保存,刷新和重放保留四个独立资源", async () => {
|
||||
type Node = import("../src/types/canvas").CanvasNodeData;
|
||||
const result = await runScenario<{ edited: boolean; live: Node[]; restored: Node[]; replayed: Node[] }>("canvas-multi-output");
|
||||
expect(result.edited).toBe(true);
|
||||
for (const nodes of [result.live, result.restored, result.replayed]) {
|
||||
expect(nodes).toHaveLength(9);
|
||||
const root = nodes.find((node) => node.id === "node-0")!;
|
||||
expect(root).toMatchObject({ title: "用户改名", position: { x: 700, y: 500 }, metadata: { isBatchRoot: true, imageBatchExpanded: false, generationOutputCount: 4, assetId: "asset-0" } });
|
||||
expect(root.metadata?.batchChildIds).toHaveLength(4);
|
||||
const children = nodes.filter((node) => node.metadata?.batchRootId === root.id);
|
||||
expect(children).toHaveLength(4);
|
||||
expect(children.map((node) => node.metadata?.storageKey)).toEqual([0, 1, 2, 3].map((index) => `resource:image-${index}`));
|
||||
expect(children.map((node) => node.metadata?.assetId)).toEqual([0, 1, 2, 3].map((index) => `asset-${index}`));
|
||||
expect(children.map((node) => node.metadata?.naturalWidth)).toEqual([640, 641, 642, 643]);
|
||||
expect(nodes.filter((node) => node.metadata?.status === "error")).toHaveLength(3);
|
||||
}
|
||||
});
|
||||
type ScenarioResponse<T> = { ok: true; result: T } | { ok: false; error: string };
|
||||
|
||||
test("并发生成结果经真实持久化消费链路及重新读取后保留成功、失败和用户编辑", async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getActiveUserScope, setActiveUserScope } from "../../src/lib/user-scope
|
||||
|
||||
type InstanceHook = (storeName: string, key: string, value: unknown) => Promise<void> | void;
|
||||
|
||||
type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race" | "canvas-batch-commit-race";
|
||||
type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race" | "canvas-batch-commit-race" | "canvas-multi-output";
|
||||
|
||||
function installStorageHarness() {
|
||||
const originalCreateInstance = localforage.createInstance.bind(localforage);
|
||||
@@ -393,7 +393,7 @@ async function runMediaCommitRace(mediaType: "video" | "audio") {
|
||||
}
|
||||
}
|
||||
|
||||
async function runCanvasBatchCommitRace() {
|
||||
async function runCanvasBatchCommitRace(multiOutput = false) {
|
||||
const harness = installStorageHarness();
|
||||
const previousScope = getActiveUserScope();
|
||||
const { apiClient } = await import("../../src/services/api/request");
|
||||
@@ -402,7 +402,7 @@ async function runCanvasBatchCommitRace() {
|
||||
if (config.method !== "post" || config.url !== "/resources/access") throw new Error(`Unexpected request: ${config.method} ${config.url}`);
|
||||
const requests = JSON.parse(config.data) as { resourceId: string; variant: string }[];
|
||||
const items = requests.map(({ resourceId, variant }) => {
|
||||
if (!["image-0", "image-1"].includes(resourceId)) throw new Error(`Unexpected resource: ${resourceId}`);
|
||||
if (!["image-0", "image-1", "image-2", "image-3"].includes(resourceId)) throw new Error(`Unexpected resource: ${resourceId}`);
|
||||
return {
|
||||
resourceId,
|
||||
access: {
|
||||
@@ -438,13 +438,13 @@ async function runCanvasBatchCommitRace() {
|
||||
width: 320,
|
||||
height: 240,
|
||||
metadata: { taskId: `task-${index}`, status: index < 2 ? ("loading" as const) : ("error" as const) },
|
||||
}));
|
||||
})).filter((node) => !multiOutput || node.id !== "node-1");
|
||||
const ref: { current: import("../../src/types/canvas").CanvasNodeData[] } = { current: nodes };
|
||||
const setNodes = createCanvasStateWriter(ref, () => {});
|
||||
useCanvasStore.getState().updateProject(projectId, { nodes });
|
||||
await flushCanvasStorePersistence();
|
||||
useAssetStore.setState({
|
||||
assets: [0, 1].map((index) => ({
|
||||
assets: (multiOutput ? [0, 1, 2, 3] : [0, 1]).map((index) => ({
|
||||
id: `asset-${index}`,
|
||||
kind: "image" as const,
|
||||
title: "generated",
|
||||
@@ -452,7 +452,7 @@ async function runCanvasBatchCommitRace() {
|
||||
tags: [],
|
||||
createdAt: "2026-09-21T00:00:00Z",
|
||||
updatedAt: "2026-09-21T00:00:00Z",
|
||||
data: { dataUrl: "/image.png", storageKey: `resource:image-${index}`, width: 640, height: 480, bytes: 100, mimeType: "image/png" },
|
||||
data: { dataUrl: "/image.png", storageKey: `resource:image-${index}`, width: multiOutput ? 640 + index : 640, height: 480, bytes: 100, mimeType: "image/png" },
|
||||
})),
|
||||
});
|
||||
unregister = registerCanvasGenerationLiveProject({ scope: getActiveUserScope(), projectId, adapter: { read: () => ({ nodes: ref.current, connections: [], chatSessions: [], activeChatId: null }), write: (state) => setNodes(state.nodes) } });
|
||||
@@ -463,13 +463,24 @@ async function runCanvasBatchCommitRace() {
|
||||
setNodes((current) => [...current.map((node) => (node.id === "node-0" ? { ...node, title: "用户改名", position: { x: 700, y: 500 } } : node)), { ...nodes[0], id: "new-during-save", metadata: {} }]);
|
||||
};
|
||||
await Promise.all(
|
||||
[0, 1].map((index) =>
|
||||
(multiOutput ? [0] : [0, 1]).map((index) =>
|
||||
applyCanvasGenerationTaskNodeEffect({
|
||||
projectId,
|
||||
nodeId: `node-${index}`,
|
||||
nodesRef: ref,
|
||||
setNodes,
|
||||
task: { id: `task-${index}`, projectId, type: "canvas_image", status: "succeeded", prompt: "test", attempts: 1, createdAt: "2026-09-21T00:00:00Z", updatedAt: "2026-09-21T00:00:01Z", resultJson: "{}" },
|
||||
task: {
|
||||
id: `task-${index}`,
|
||||
projectId,
|
||||
type: "canvas_image",
|
||||
status: "succeeded",
|
||||
prompt: "test",
|
||||
attempts: 1,
|
||||
createdAt: "2026-09-21T00:00:00Z",
|
||||
updatedAt: "2026-09-21T00:00:01Z",
|
||||
resultJson: multiOutput ? JSON.stringify({ images: [0, 1, 2, 3].map((i) => ({ dataUrl: "/image.png", storageKey: `resource:image-${i}` })) }) : "{}",
|
||||
...(multiOutput ? { outputs: [0, 1, 2, 3].map((i) => ({ outputIndex: i, mediaType: "image" as const, materializedAssetId: `asset-${i}` })) } : {}),
|
||||
},
|
||||
output: { outputIndex: 0, mediaType: "image", materializedAssetId: `asset-${index}` },
|
||||
effectKey: `attach:task-${index}:0`,
|
||||
}),
|
||||
@@ -478,7 +489,31 @@ async function runCanvasBatchCommitRace() {
|
||||
useCanvasStore.getState().updateProject(projectId, { nodes: ref.current });
|
||||
await flushCanvasStorePersistence();
|
||||
const stored = parseCanvasStorageDocument(await localForageStorageForScope(getActiveUserScope()).getItem(CANVAS_STORE_KEY), []);
|
||||
return { edited, live: ref.current, restored: stored.state.projects.find((project) => project.id === projectId)?.nodes };
|
||||
const restored = stored.state.projects.find((project) => project.id === projectId)?.nodes;
|
||||
const live = ref.current;
|
||||
if (multiOutput) {
|
||||
setNodes(restored!);
|
||||
await applyCanvasGenerationTaskNodeEffect({
|
||||
projectId,
|
||||
nodeId: "node-0",
|
||||
nodesRef: ref,
|
||||
setNodes,
|
||||
task: {
|
||||
id: "task-0",
|
||||
projectId,
|
||||
type: "canvas_image",
|
||||
status: "succeeded",
|
||||
prompt: "test",
|
||||
attempts: 1,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
resultJson: JSON.stringify({ images: [0, 1, 2, 3].map((i) => ({ dataUrl: "/image.png", storageKey: `resource:image-${i}` })) }),
|
||||
},
|
||||
output: { outputIndex: 0, mediaType: "image", materializedAssetId: "asset-0" },
|
||||
effectKey: "attach:task-0:0",
|
||||
});
|
||||
}
|
||||
return { edited, live, restored, replayed: ref.current };
|
||||
} finally {
|
||||
unregister?.();
|
||||
apiClient.defaults.adapter = originalAdapter;
|
||||
@@ -496,9 +531,11 @@ self.onmessage = async (event: MessageEvent<Scenario>) => {
|
||||
? await runScopeCleanupAfterSwitch()
|
||||
: event.data === "scope-cleanup-late-canvas-reference"
|
||||
? await runScopeCleanupAfterLateCanvasReference()
|
||||
: event.data === "canvas-batch-commit-race"
|
||||
? await runCanvasBatchCommitRace()
|
||||
: await runMediaCommitRace(event.data === "audio-commit-race" ? "audio" : "video");
|
||||
: event.data === "canvas-multi-output"
|
||||
? await runCanvasBatchCommitRace(true)
|
||||
: event.data === "canvas-batch-commit-race"
|
||||
? await runCanvasBatchCommitRace()
|
||||
: await runMediaCommitRace(event.data === "audio-commit-race" ? "audio" : "video");
|
||||
self.postMessage({ ok: true, result });
|
||||
} catch (error) {
|
||||
self.postMessage({ ok: false, error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) });
|
||||
|
||||
@@ -11,6 +11,24 @@ import { resolveImageRequestSize, validateImageSize } from "../src/services/api/
|
||||
import { buildGeminiImageGenerationConfig } from "../src/lib/gemini-image";
|
||||
|
||||
describe("统一图片分辨率与宽高比", () => {
|
||||
test("管理员档位开关按已有比例启用,部分比例不显示为关闭", () => {
|
||||
for (const model of ["midjourney-1k", "midjourney-2k"]) {
|
||||
const profile = defaultImageCapabilityConfig("cangyuan-midjourney-v82", model);
|
||||
const editor = renderToStaticMarkup(<ImageSizePresetsEditor profile={profile} onChange={() => {}} />);
|
||||
const active = model === "midjourney-2k" ? "2k" : "1k";
|
||||
for (const tier of IMAGE_RESOLUTIONS) {
|
||||
const button = [...editor.matchAll(/<button\b[^>]*>/g)].map((match) => match[0]).find((tag) => tag.includes(`aria-label="启用 ${tier.toUpperCase()} 规格"`));
|
||||
expect(button).toBeDefined();
|
||||
expect(button).toContain(`aria-checked="${tier === active}"`);
|
||||
}
|
||||
}
|
||||
const profile = defaultImageCapabilityConfig();
|
||||
profile.size = imageSizeConfigWithPresets(profile, [imagePresetForRatio("2k", "16:9")]);
|
||||
const editor = renderToStaticMarkup(<ImageSizePresetsEditor profile={profile} onChange={() => {}} />);
|
||||
const button = [...editor.matchAll(/<button\b[^>]*>/g)].map((match) => match[0]).find((tag) => tag.includes('aria-label="启用 2K 规格"'));
|
||||
expect(button).toContain('aria-checked="true"');
|
||||
});
|
||||
|
||||
test("默认 Gemini 质量枚举不能把未启用的 1K 显示出来", () => {
|
||||
const profile = defaultImageCapabilityConfig("gemini-image", "nano-banana-pro-4k");
|
||||
profile.size = imageSizeConfigWithPresets(profile, IMAGE_RATIOS.map((ratio) => imagePresetForRatio("4k", ratio)));
|
||||
|
||||
@@ -2,7 +2,8 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// Bun 直接执行 TypeScript 测试时需要保留扩展名;生产 tsconfig 不包含 test/。
|
||||
import { DEFAULT_VIDEO_PROMPT_MAX_CHARS, defaultModelCapabilityConfig, normalizeVideoValue } from "../src/lib/model-capabilities.ts";
|
||||
import { DEFAULT_VIDEO_PROMPT_MAX_CHARS, defaultModelCapabilityConfig, normalizeImageValue, normalizeVideoValue } from "../src/lib/model-capabilities.ts";
|
||||
import { imageTierAvailable } from "../src/lib/image-size-presets.ts";
|
||||
|
||||
test("text multimodal capability is not guessed from a model name", () => {
|
||||
for (const model of ["gpt-4o", "gemini-2.5-pro", "doubao-seed"]) {
|
||||
@@ -39,3 +40,58 @@ test("raising the video default leaves text and image limits untouched", () => {
|
||||
assert.equal(profile.text!.references.promptMaxChars, 32000);
|
||||
assert.equal(profile.image!.references.promptMaxChars, 32000);
|
||||
});
|
||||
|
||||
test("Midjourney protocol defaults expose only supported controls and one submission", () => {
|
||||
for (const [protocol, model, tier, maxImages, mask, promptMaxChars, ratios] of [
|
||||
["cangyuan-midjourney-v7", "midjourney-v7", "1k", 5, false, 4000, 10],
|
||||
["cangyuan-midjourney-v82", "midjourney-1k", "1k", 1, true, 32000, 15],
|
||||
["cangyuan-midjourney-v82", "midjourney-2k", "2k", 1, true, 32000, 15],
|
||||
["cangyuan-midjourney-v82", "", "1k", 1, true, 32000, 15],
|
||||
] as const) {
|
||||
const image = defaultModelCapabilityConfig(protocol, model).image!;
|
||||
assert.equal(image.references.maxImages, maxImages);
|
||||
assert.equal(image.references.maskSupported, mask);
|
||||
assert.equal(image.references.promptMaxChars, promptMaxChars);
|
||||
assert.equal(image.maxOutputs, 1);
|
||||
assert.equal(image.quality.supported, false);
|
||||
assert.equal(image.transparentBackground.supported, false);
|
||||
assert.equal(image.responseFormat.supported, false);
|
||||
assert.equal(image.outputFormat.supported, false);
|
||||
assert.equal(image.size.parameter, "aspect_ratio");
|
||||
assert.equal(image.size.default, "auto");
|
||||
assert.equal(image.size.allowCustom, false);
|
||||
assert.equal(image.size.presets!.length, ratios);
|
||||
assert.equal(image.size.values.length, ratios + 1);
|
||||
assert.equal(imageTierAvailable(image, tier), true);
|
||||
assert.equal(imageTierAvailable(image, tier === "1k" ? "2k" : "1k"), false);
|
||||
assert.equal(imageTierAvailable(image, "4k"), false);
|
||||
assert.deepEqual(normalizeImageValue(image, { size: "16:9", quality: "high", transparentBackground: "true", count: "4" }), { size: "16:9", quality: tier, transparentBackground: "false", count: "1" });
|
||||
}
|
||||
});
|
||||
|
||||
test("Kacang protocol selection uses the supplier ratio and reference contracts", () => {
|
||||
for (const [protocol, ratio, maxImages, presets, auto] of [
|
||||
["kacang-midjourney-special", "16:9", 1, 10, false],
|
||||
["kacang-midjourney-v7", "16:9", 1, 11, true],
|
||||
["kacang-midjourney", "9:16", 5, 11, true],
|
||||
] as const) {
|
||||
const image = defaultModelCapabilityConfig(protocol).image!;
|
||||
assert.equal(image.references.maxImages, maxImages);
|
||||
assert.equal(image.references.maskSupported, false);
|
||||
assert.equal(image.size.default, ratio);
|
||||
assert.equal(image.size.values.includes("auto"), auto);
|
||||
assert.equal(image.size.presets!.length, presets);
|
||||
for (const preset of image.size.presets!) assert.ok(image.size.values.includes(preset.ratio), `${protocol}: preset ${preset.ratio} must be an upstream-supported value`);
|
||||
if (auto) assert.ok(image.size.presets!.some((preset) => preset.ratio === "9:21"));
|
||||
assert.equal(image.maxOutputs, 1);
|
||||
assert.equal(image.size.allowCustom, false);
|
||||
assert.equal(image.quality.supported, false);
|
||||
assert.equal(image.responseFormat.supported, false);
|
||||
assert.equal(image.transparentBackground.supported, false);
|
||||
assert.equal(image.outputFormat.supported, false);
|
||||
assert.equal(imageTierAvailable(image, "1k"), true);
|
||||
assert.equal(imageTierAvailable(image, "2k"), false);
|
||||
assert.equal(imageTierAvailable(image, "4k"), false);
|
||||
assert.deepEqual(normalizeImageValue(image, { size: "bad", quality: "high", transparentBackground: "true", count: "4" }), { size: ratio, quality: "1k", transparentBackground: "false", count: "1" });
|
||||
}
|
||||
});
|
||||
|
||||
70
web/test/update-announcement.test.tsx
Normal file
70
web/test/update-announcement.test.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { UpdateAnnouncementContent } from "../src/components/layout/update-announcement-content";
|
||||
import { DEFAULT_UPDATE_ANNOUNCEMENT, moveUpdateItem, validateUpdateAnnouncement, updateAnnouncementVersion, type UpdateAnnouncement } from "../src/lib/update-announcement";
|
||||
import { normalizePublicAppearance } from "../src/stores/use-appearance-store";
|
||||
import { refreshPublicAppearance } from "../src/services/appearance-bootstrap";
|
||||
|
||||
const custom = (): UpdateAnnouncement => ({
|
||||
enabled: true,
|
||||
currentVersion: "影绘 2.0",
|
||||
releases: [
|
||||
{
|
||||
id: "r1",
|
||||
version: "影绘 2.0",
|
||||
title: "图文与视频公告",
|
||||
date: "2026-10-03",
|
||||
blocks: [
|
||||
{ id: "text", type: "text", text: "**新功能**\n\n- 内容一\n- 内容二", resourceId: "", caption: "", layout: "full" },
|
||||
{ id: "image", type: "image", text: "", resourceId: "image1", caption: "功能示意", layout: "half", mediaUrl: "/api/public/appearance/updates/image1" },
|
||||
{ id: "video", type: "video", text: "", resourceId: "video1", caption: "视频演示", layout: "half", mediaUrl: "/api/public/appearance/updates/video1" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
test("switch selects the custom display version and restores the real version", () => {
|
||||
expect(updateAnnouncementVersion(custom(), "1.6.0")).toBe("影绘 2.0");
|
||||
expect(updateAnnouncementVersion({ ...custom(), enabled: false }, "v1.6.0")).toBe("v1.6.0");
|
||||
expect(updateAnnouncementVersion(undefined, "1.6.0")).toBe("v1.6.0");
|
||||
});
|
||||
test("old appearance data defaults to the original log", () => {
|
||||
expect(normalizePublicAppearance({}).updates).toEqual(DEFAULT_UPDATE_ANNOUNCEMENT);
|
||||
});
|
||||
test("publication rejects missing versions, duplicate versions, and incomplete media", () => {
|
||||
expect(validateUpdateAnnouncement(custom())).toBeUndefined();
|
||||
expect(validateUpdateAnnouncement({ ...DEFAULT_UPDATE_ANNOUNCEMENT, enabled: true })).toBeDefined();
|
||||
const value = custom();
|
||||
value.releases[0].blocks[1].resourceId = "";
|
||||
expect(validateUpdateAnnouncement(value)).toBeDefined();
|
||||
const duplicate = custom();
|
||||
duplicate.releases.push({ ...duplicate.releases[0], id: "r2" });
|
||||
expect(validateUpdateAnnouncement(duplicate)).toBeDefined();
|
||||
});
|
||||
test("reorder preserves content identity and does not modify the source", () => {
|
||||
const blocks = custom().releases[0].blocks;
|
||||
expect(moveUpdateItem(blocks, 2, -1).map((block) => block.id)).toEqual(["text", "video", "image"]);
|
||||
expect(blocks.map((block) => block.id)).toEqual(["text", "image", "video"]);
|
||||
expect(moveUpdateItem(blocks, 0, -1)).toBe(blocks);
|
||||
});
|
||||
test("real renderer supports text, images, videos, dates and responsive half layout", () => {
|
||||
const html = renderToStaticMarkup(<UpdateAnnouncementContent value={custom()} />);
|
||||
expect(html).toContain("<strong>新功能</strong>");
|
||||
expect(html).toContain("is-half");
|
||||
expect(html).toContain('alt="功能示意"');
|
||||
expect(html).toContain("<video");
|
||||
expect(html).toContain('controls=""');
|
||||
expect(html).toMatch(/datetime="2026-10-03"/i);
|
||||
});
|
||||
test("renderer escapes raw HTML and blocks inline Markdown images", () => {
|
||||
const value = custom();
|
||||
value.releases[0].blocks[0].text = "<script>alert(1)</script>\n\n";
|
||||
const html = renderToStaticMarkup(<UpdateAnnouncementContent value={value} />);
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).not.toContain("tracker.png");
|
||||
});
|
||||
test("appearance refresh carries the saved announcement switch and version", async () => {
|
||||
const result = await refreshPublicAppearance(async () => normalizePublicAppearance({ updates: custom() }));
|
||||
expect(result?.updates?.currentVersion).toBe("影绘 2.0");
|
||||
expect(result?.updates?.enabled).toBe(true);
|
||||
});
|
||||
83
web/test/video-screen-specs.test.tsx
Normal file
83
web/test/video-screen-specs.test.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { ModelCapabilityEditor } from "../src/components/model-capability-editor";
|
||||
import { defaultModelCapabilityConfig, modelCapabilityConfigFor, normalizeModelCapabilityConfig, normalizeVideoValue } from "../src/lib/model-capabilities";
|
||||
import type { ModelProtocolWorkflow } from "../src/lib/model-protocols";
|
||||
import { resolveWorkflowVideoScreenSpec, updateWorkflowVideoScreenSpec } from "../src/lib/video-screen-specs";
|
||||
|
||||
const workflow: ModelProtocolWorkflow = {
|
||||
id: "minimax_h3_zm_u24",
|
||||
label: "H3 ZM U24",
|
||||
providerId: "autodl-comfyui",
|
||||
capability: "video",
|
||||
parameters: [{ name: "resolution", mapping: "resolution", type: "string", values: ["480p竖", "768p竖", "480p横", "768p横", "480p(1:1)", "768p(1:1)"] }],
|
||||
defaults: { resolution: "768p竖" },
|
||||
};
|
||||
const initial = () => resolveWorkflowVideoScreenSpec(defaultModelCapabilityConfig("autodl-comfyui", workflow.id).video!, workflow);
|
||||
|
||||
test("removed switch and draft cannot replace the effective six choices", () => {
|
||||
for (const customSizeEnabled of [false, true]) {
|
||||
const original = Object.assign(initial(), {
|
||||
customSizeEnabled,
|
||||
customScreenSpec: { ratios: ["2:3"], defaultRatio: "2:3", resolutions: ["invalid"], defaultResolution: "missing" },
|
||||
});
|
||||
const resolved = resolveWorkflowVideoScreenSpec(original, workflow);
|
||||
expect(resolved.resolutions).toEqual(workflow.parameters[0]!.values!);
|
||||
expect(resolved.defaultResolution).toBe("768p竖");
|
||||
expect(resolved.ratios).toEqual([]);
|
||||
expect(resolved.duration).toEqual(original.duration);
|
||||
expect(resolved.references).toEqual(original.references);
|
||||
expect(resolved.operations).toEqual(original.operations);
|
||||
}
|
||||
});
|
||||
|
||||
test("selected presets and default remain active after saving and reloading", () => {
|
||||
const profile = updateWorkflowVideoScreenSpec(initial(), workflow, { resolutions: [" 480p横 ", "768p(1:1)", "480P横", ""], defaultResolution: " 768P(1:1) " });
|
||||
const reloaded = normalizeModelCapabilityConfig(JSON.parse(JSON.stringify({ version: 1, video: profile }))).video!;
|
||||
expect(reloaded.resolutions).toEqual(["480p横", "768p(1:1)"]);
|
||||
expect(reloaded.defaultResolution).toBe("768p(1:1)");
|
||||
expect(normalizeVideoValue(reloaded, { seconds: "6", ratio: "16:9", resolution: "768p(1:1)" })).toEqual({ seconds: "6", ratio: "", resolution: "768p(1:1)" });
|
||||
expect(updateWorkflowVideoScreenSpec(profile, workflow, { resolutions: [], defaultResolution: "" }).resolutions).toEqual([]);
|
||||
});
|
||||
|
||||
test("creation and canvas use configured choices and replace stale cached selections", () => {
|
||||
const profile = updateWorkflowVideoScreenSpec(initial(), workflow, { resolutions: ["480p横", "768p(1:1)"], defaultResolution: "768p(1:1)" });
|
||||
const config = { channels: [{ id: "autodl", models: [workflow.id], modelCosts: [{ model: workflow.id, protocol: "autodl-comfyui", capabilityConfig: { version: 1, video: profile } }] }] };
|
||||
const selected = modelCapabilityConfigFor(config, `autodl::${workflow.id}`).video!;
|
||||
expect(normalizeVideoValue(selected, { seconds: "6", ratio: "16:9", resolution: "768p竖" })).toEqual({ seconds: "6", ratio: "", resolution: "768p(1:1)" });
|
||||
expect(normalizeVideoValue(selected, { resolution: "480p横" }).resolution).toBe("480p横");
|
||||
});
|
||||
|
||||
test("switching workflows refreshes the available presets without carrying foreign labels", () => {
|
||||
const previous = updateWorkflowVideoScreenSpec(initial(), workflow, { resolutions: ["480p横"], defaultResolution: "480p横" });
|
||||
const next = resolveWorkflowVideoScreenSpec(previous, { ...workflow, id: "z0901", parameters: [{ name: "resolution", type: "string", values: ["768p竖(768*1344)"] }], defaults: { resolution: "768p竖(768*1344)" } });
|
||||
expect(next.resolutions).toEqual(["768p竖(768*1344)"]);
|
||||
expect(next.defaultResolution).toBe("768p竖(768*1344)");
|
||||
});
|
||||
|
||||
test("real editor keeps enabled fields and original card copy without switch or explanation", () => {
|
||||
for (const section of ["all", "protocol"] as const) {
|
||||
const html = renderToStaticMarkup(<ModelCapabilityEditor capability="video" protocol="autodl-comfyui" model={workflow.id} workflows={[workflow]} section={section} value={{ version: 1, video: initial() }} />);
|
||||
expect(html).not.toContain("自定义比例分辨率");
|
||||
expect(html).not.toContain("画幅由完整分辨率标签");
|
||||
expect(html).not.toContain("使用当前工作流的插件预设");
|
||||
expect(html).toContain("768p(1:1)");
|
||||
expect(html).toContain('aria-label="输出分辨率"');
|
||||
expect(html).toContain('aria-label="默认分辨率"');
|
||||
expect(html).not.toContain("ant-select-disabled");
|
||||
expect(html).not.toContain("支持比例");
|
||||
expect(html).not.toContain("默认比例");
|
||||
if (section === "protocol") {
|
||||
expect(html).toContain("画面规格");
|
||||
expect(html).toContain("控制比例、分辨率及默认输出");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("other video protocols keep their existing settings and controls", () => {
|
||||
const profile = defaultModelCapabilityConfig("minimax-video").video!;
|
||||
expect(resolveWorkflowVideoScreenSpec(profile)).toBe(profile);
|
||||
const html = renderToStaticMarkup(<ModelCapabilityEditor capability="video" protocol="minimax-video" section="protocol" />);
|
||||
expect(html).not.toContain("自定义比例分辨率");
|
||||
expect(html).toContain("支持比例");
|
||||
});
|
||||
Reference in New Issue
Block a user