feat(web): 角色与音频工作台 - 完善素材选择、生成参数与同步交互
This commit is contained in:
@@ -275,10 +275,10 @@ test("request logs display user credit billing independently from upstream cost"
|
||||
expect(billingSummary).toContain("!log.billable");
|
||||
expect(billingSummary).toContain("未扣积分");
|
||||
expect(billingSummary).not.toContain("costAvailable");
|
||||
expect(detailSource).toContain('["请求阶段", requestKindText(log.requestKind)]');
|
||||
expect(detailSource).toContain('["计费属性", log.billable ? "计费调用" : "不计费"]');
|
||||
expect(detailSource).toContain('["销售价格(积分)", billingText(log)]');
|
||||
expect(detailSource).toContain('["上游成本", log.costAvailable');
|
||||
expect(detailSource).toContain('label="请求阶段" value={requestKindText(log.requestKind)}');
|
||||
expect(detailSource).toContain('label="计费" value={billingText(log)}');
|
||||
expect(detailSource).toContain('label="请求 ID" value={<CopyValue value={log.id} />}');
|
||||
expect(detailSource).toContain('label="供应商任务 ID" value={log.providerRequestId ? <CopyValue value={log.providerRequestId} /> : "未记录"}');
|
||||
expect(apiSource).toContain("billingAmountMicrocredits: number");
|
||||
expect(apiSource).toContain("billingAvailable: boolean");
|
||||
});
|
||||
|
||||
@@ -18,16 +18,25 @@ const originalWindow = globalThis.window;
|
||||
const indexed = new Map<string, unknown>();
|
||||
beforeEach(() => {
|
||||
resetRemoteUserDataSync();
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: {
|
||||
setTimeout: () => 1, clearTimeout: () => {},
|
||||
localStorage: { getItem: () => "agent-user", setItem: () => {} },
|
||||
} });
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
setTimeout: () => 1,
|
||||
clearTimeout: () => {},
|
||||
localStorage: { getItem: () => "agent-user", setItem: () => {} },
|
||||
},
|
||||
});
|
||||
indexed.clear();
|
||||
localforage.getItem = (async (key: string) => indexed.get(key) ?? null) as typeof localforage.getItem;
|
||||
localforage.setItem = (async (key: string, value: unknown) => { indexed.set(key, value); return value; }) as typeof localforage.setItem;
|
||||
localforage.setItem = (async (key: string, value: unknown) => {
|
||||
indexed.set(key, value);
|
||||
return value;
|
||||
}) as typeof localforage.setItem;
|
||||
});
|
||||
afterEach(async () => {
|
||||
unsubscribe(); restore(); resetRemoteUserDataSync();
|
||||
unsubscribe();
|
||||
restore();
|
||||
resetRemoteUserDataSync();
|
||||
await flushCanvasStorePersistence();
|
||||
localforage.getItem = originalGet;
|
||||
localforage.setItem = originalSet;
|
||||
@@ -58,7 +67,9 @@ test("successful Agent refresh projects nodes into the open editor and store", a
|
||||
|
||||
test("unsaved editor conflicts preserve store and acknowledged baseline", async () => {
|
||||
await setup();
|
||||
unsubscribe = subscribeAgentCanvasRefresh(() => { throw new Error("本地编辑冲突"); });
|
||||
unsubscribe = subscribeAgentCanvasRefresh(() => {
|
||||
throw new Error("本地编辑冲突");
|
||||
});
|
||||
await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow("本地编辑冲突");
|
||||
expect(useCanvasStore.getState().projects[0]).toEqual(initial);
|
||||
unsubscribe();
|
||||
@@ -66,13 +77,25 @@ test("unsaved editor conflicts preserve store and acknowledged baseline", async
|
||||
expect(useCanvasStore.getState().projects[0]).toMatchObject(remote);
|
||||
});
|
||||
|
||||
test("dirty persisted edits are not replaced or delivered to the editor", async () => {
|
||||
test("dirty persisted edits are rebased when Agent changes different fields", async () => {
|
||||
await setup();
|
||||
useCanvasStore.setState({ projects: [{ ...initial, title: "本地修改" }] });
|
||||
let notified = false;
|
||||
unsubscribe = subscribeAgentCanvasRefresh(() => { notified = true; });
|
||||
await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow("本地存在未同步编辑");
|
||||
expect(notified).toBe(false);
|
||||
unsubscribe = subscribeAgentCanvasRefresh(() => {
|
||||
notified = true;
|
||||
});
|
||||
await expect(refreshCanvasAfterAgent(initial.id)).resolves.toMatchObject({ title: "本地修改", revision: 2, nodes: remote.nodes });
|
||||
expect(notified).toBe(true);
|
||||
expect(useCanvasStore.getState().projects[0]).toMatchObject({ title: "本地修改", revision: 2, nodes: remote.nodes });
|
||||
expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("pending");
|
||||
});
|
||||
|
||||
test("dirty persisted edits still conflict when Agent changes the same field", async () => {
|
||||
const mock = await setup({ ...remote, title: "云端修改" });
|
||||
useCanvasStore.setState({ projects: [{ ...initial, title: "本地修改" }] });
|
||||
mock.mockResolvedValue({ project: { ...remote, title: "云端修改" } });
|
||||
await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow();
|
||||
expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("conflict");
|
||||
expect(useCanvasStore.getState().projects[0].title).toBe("本地修改");
|
||||
});
|
||||
|
||||
@@ -81,7 +104,9 @@ test("automatic focus and panning do not block Agent updates or reset the local
|
||||
const viewport = { x: -800, y: -400, k: 0.7 };
|
||||
useCanvasStore.setState({ projects: [{ ...initial, viewport, updatedAt: "2026-09-13" }] });
|
||||
let editor = initial;
|
||||
unsubscribe = subscribeAgentCanvasRefresh((project) => { editor = project; });
|
||||
unsubscribe = subscribeAgentCanvasRefresh((project) => {
|
||||
editor = project;
|
||||
});
|
||||
await refreshCanvasAfterAgent(initial.id);
|
||||
expect(editor.nodes).toEqual(remote.nodes);
|
||||
expect(editor.viewport).toEqual(viewport);
|
||||
@@ -91,7 +116,9 @@ test("automatic focus and panning do not block Agent updates or reset the local
|
||||
test("replayed completion events do not reapply unchanged canvas nodes", async () => {
|
||||
await setup();
|
||||
let deliveries = 0;
|
||||
unsubscribe = subscribeAgentCanvasRefresh(() => { deliveries += 1; });
|
||||
unsubscribe = subscribeAgentCanvasRefresh(() => {
|
||||
deliveries += 1;
|
||||
});
|
||||
await refreshCanvasAfterAgent(initial.id);
|
||||
await refreshCanvasAfterAgent(initial.id);
|
||||
expect(deliveries).toBe(1);
|
||||
@@ -102,22 +129,25 @@ test("media admission and completion refresh the editor with references and resu
|
||||
const project: CanvasProject = { ...remote, nodes: [...remote.nodes, { ...video, id: "image-1", type: CanvasNodeType.Image }, video], connections: [{ id: "edge-1", fromNodeId: "image-1", toNodeId: "video-1" }] };
|
||||
const mock = await setup(project);
|
||||
let editor = initial;
|
||||
unsubscribe = subscribeAgentCanvasRefresh((updated) => { editor = updated; });
|
||||
unsubscribe = subscribeAgentCanvasRefresh((updated) => {
|
||||
editor = updated;
|
||||
});
|
||||
await refreshCanvasAfterAgent(initial.id);
|
||||
expect(editor.connections).toEqual(project.connections);
|
||||
expect(editor.nodes.find((node) => node.id === "video-1")?.metadata?.referenceNodeIds).toEqual(["image-1"]);
|
||||
const complete: CanvasProject = { ...project, revision: 3, nodes: project.nodes.map((node) => node.id === "video-1" ? { ...node, metadata: { ...node.metadata, status: "success", storageKey: "resource:video-result" } } : node) };
|
||||
const complete: CanvasProject = { ...project, revision: 3, nodes: project.nodes.map((node) => (node.id === "video-1" ? { ...node, metadata: { ...node.metadata, status: "success", storageKey: "resource:video-result" } } : node)) };
|
||||
mock.mockResolvedValue({ project: complete });
|
||||
await refreshCanvasAfterAgent(initial.id);
|
||||
expect(editor.nodes.find((node) => node.id === "video-1")?.metadata?.storageKey).toBe("resource:video-result");
|
||||
expect(useCanvasStore.getState().projects[0].nodes).toEqual(complete.nodes);
|
||||
});
|
||||
|
||||
|
||||
test("incremental batches update the editor once without full canvas requests", async () => {
|
||||
const get = await setup();
|
||||
let deliveries = 0;
|
||||
unsubscribe = subscribeAgentCanvasRefresh(() => { deliveries++; });
|
||||
unsubscribe = subscribeAgentCanvasRefresh(() => {
|
||||
deliveries++;
|
||||
});
|
||||
const patch = { canvasId: initial.id, baseRevision: 1, revision: 2, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] };
|
||||
await applyAgentCanvasPatches(initial.id, [patch, patch]);
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
@@ -125,11 +155,21 @@ test("incremental batches update the editor once without full canvas requests",
|
||||
expect(useCanvasStore.getState().projects[0].nodes).toEqual(remote.nodes);
|
||||
});
|
||||
|
||||
test("incremental batches sort and deduplicate revisions before applying", async () => {
|
||||
await setup();
|
||||
const first = { canvasId: initial.id, baseRevision: 1, revision: 2, updatedAt: remote.updatedAt, nodes: [{ before: null, after: { ...remote.nodes[0], id: "agent-1" } }], connections: [] };
|
||||
const second = { canvasId: initial.id, baseRevision: 2, revision: 3, updatedAt: remote.updatedAt, nodes: [{ before: null, after: { ...remote.nodes[0], id: "agent-2" } }], connections: [] };
|
||||
await applyAgentCanvasPatches(initial.id, [second, first, first]);
|
||||
expect(useCanvasStore.getState().projects[0].revision).toBe(3);
|
||||
expect(useCanvasStore.getState().projects[0].nodes.map((node) => node.id)).toEqual(["agent-1", "agent-2"]);
|
||||
});
|
||||
test("already projected deltas still advance the remote baseline", async () => {
|
||||
await setup();
|
||||
useCanvasStore.setState({ projects: [{ ...remote, revision: 1 }] });
|
||||
let deliveries = 0;
|
||||
unsubscribe = subscribeAgentCanvasRefresh(() => { deliveries++; });
|
||||
unsubscribe = subscribeAgentCanvasRefresh(() => {
|
||||
deliveries++;
|
||||
});
|
||||
await applyAgentCanvasPatches(initial.id, [{ canvasId: initial.id, baseRevision: 1, revision: 2, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] }]);
|
||||
expect(deliveries).toBe(0);
|
||||
await expect(refreshCanvasAfterAgent(initial.id)).resolves.toMatchObject(remote);
|
||||
@@ -139,7 +179,9 @@ test("already projected deltas still advance the remote baseline", async () => {
|
||||
test("editor rejection keeps delta projection atomic and retryable", async () => {
|
||||
await setup();
|
||||
const patch = { canvasId: initial.id, baseRevision: 1, revision: 2, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] };
|
||||
unsubscribe = subscribeAgentCanvasRefresh(() => { throw new Error("编辑冲突"); });
|
||||
unsubscribe = subscribeAgentCanvasRefresh(() => {
|
||||
throw new Error("编辑冲突");
|
||||
});
|
||||
await expect(applyAgentCanvasPatches(initial.id, [patch])).rejects.toThrow("编辑冲突");
|
||||
expect(useCanvasStore.getState().projects[0]).toEqual(initial);
|
||||
unsubscribe();
|
||||
@@ -147,17 +189,36 @@ test("editor rejection keeps delta projection atomic and retryable", async () =>
|
||||
expect(useCanvasStore.getState().projects[0].nodes).toEqual(remote.nodes);
|
||||
});
|
||||
|
||||
|
||||
test("50 distinct media completions project once and keep the local viewport", async () => {
|
||||
const get = await setup();
|
||||
const nodes = Array.from({ length: 50 }, (_, index) => ({ id: `video-${index}`, title: `动画${index}`, type: CanvasNodeType.Video, position: { x: index * 400, y: 100 }, width: 320, height: 180, metadata: { status: "loading" as const, taskId: `task-${index}`, taskStatus: "running" } }));
|
||||
const nodes = Array.from({ length: 50 }, (_, index) => ({
|
||||
id: `video-${index}`,
|
||||
title: `动画${index}`,
|
||||
type: CanvasNodeType.Video,
|
||||
position: { x: index * 400, y: 100 },
|
||||
width: 320,
|
||||
height: 180,
|
||||
metadata: { status: "loading" as const, taskId: `task-${index}`, taskStatus: "running" },
|
||||
}));
|
||||
const pending = { ...initial, nodes };
|
||||
useCanvasStore.setState({ projects: [pending] });
|
||||
pending.remoteContentHash = await canvasContentHash(pending);
|
||||
await initializeRemoteUserDataSession("agent-user");
|
||||
let deliveries = 0;
|
||||
unsubscribe = subscribeAgentCanvasRefresh(() => { deliveries++; });
|
||||
await applyAgentCanvasPatches(initial.id, nodes.map((before, index) => ({ canvasId: initial.id, baseRevision: index + 1, revision: index + 2, updatedAt: initial.updatedAt, nodes: [{ before, after: { ...before, metadata: { ...before.metadata, status: index % 2 ? "error" as const : "success" as const, taskStatus: index % 2 ? "failed" : "succeeded" } } }], connections: [] })));
|
||||
unsubscribe = subscribeAgentCanvasRefresh(() => {
|
||||
deliveries++;
|
||||
});
|
||||
await applyAgentCanvasPatches(
|
||||
initial.id,
|
||||
nodes.map((before, index) => ({
|
||||
canvasId: initial.id,
|
||||
baseRevision: index + 1,
|
||||
revision: index + 2,
|
||||
updatedAt: initial.updatedAt,
|
||||
nodes: [{ before, after: { ...before, metadata: { ...before.metadata, status: index % 2 ? ("error" as const) : ("success" as const), taskStatus: index % 2 ? "failed" : "succeeded" } } }],
|
||||
connections: [],
|
||||
})),
|
||||
);
|
||||
const result = useCanvasStore.getState().projects[0];
|
||||
expect(result.nodes.filter((node) => node.metadata?.status === "success")).toHaveLength(25);
|
||||
expect(result.nodes.filter((node) => node.metadata?.status === "error")).toHaveLength(25);
|
||||
@@ -178,10 +239,10 @@ test("a delta gap reconciles against the cloud before declaring a conflict", asy
|
||||
});
|
||||
|
||||
test("reconciliation after a delta gap still protects genuinely conflicting edits", async () => {
|
||||
await setup();
|
||||
await setup({ ...remote, title: "云端标题" });
|
||||
useCanvasStore.setState({ projects: [{ ...initial, title: "未保存标题" }] });
|
||||
await expect(applyAgentCanvasPatches(initial.id, [{ canvasId: initial.id, baseRevision: 2, revision: 3, nodes: [], connections: [] }])).rejects.toThrow("版本不连续");
|
||||
await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow("本地存在未同步编辑");
|
||||
await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow("冲突");
|
||||
expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("conflict");
|
||||
expect((await readCanvasSyncDrafts(initial.id))[0].project.title).toBe("未保存标题");
|
||||
await expect(saveRemoteUserDataNow(initial.id)).rejects.toThrow("云端画布已有更新");
|
||||
@@ -191,7 +252,9 @@ test("an identical cloud snapshot clears a stale conflict without replaying edit
|
||||
await setup(initial);
|
||||
useSyncProgressStore.getState().setProjectProgress(initial.id, { phase: "conflict", draftCount: 9 });
|
||||
let deliveries = 0;
|
||||
unsubscribe = subscribeAgentCanvasRefresh(() => { deliveries++; });
|
||||
unsubscribe = subscribeAgentCanvasRefresh(() => {
|
||||
deliveries++;
|
||||
});
|
||||
await refreshCanvasAfterAgent(initial.id);
|
||||
expect(deliveries).toBe(0);
|
||||
expect(useSyncProgressStore.getState().syncingProjects[initial.id]).toMatchObject({ phase: "done", draftCount: 9 });
|
||||
@@ -214,7 +277,7 @@ test("an unverified dirty cache cannot become a save baseline merely because rev
|
||||
const edited = { ...initial, title: "离线标题" };
|
||||
useCanvasStore.setState({ projects: [edited] });
|
||||
await initializeRemoteUserDataSession("agent-user");
|
||||
await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow("本地存在未同步编辑");
|
||||
await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow("缺少画布合并基线");
|
||||
expect(useCanvasStore.getState().projects[0]).toBe(edited);
|
||||
expect((await readCanvasSyncDrafts(initial.id))[0].project.title).toBe(edited.title);
|
||||
});
|
||||
@@ -239,5 +302,7 @@ test("Agent updates merge a local title edit and the next save uses the new revi
|
||||
await applyAgentCanvasPatches(initial.id, [patch]);
|
||||
expect(useCanvasStore.getState().projects[0].revision).toBe(3);
|
||||
expect(useCanvasStore.getState().projects[0].title).toBe("本地标题");
|
||||
} finally { put.mockRestore(); }
|
||||
} finally {
|
||||
put.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -63,3 +63,10 @@ test("renders an agent node link as a titled inline node card", () => {
|
||||
expect(html).toContain("片头镜头");
|
||||
expect(visibleText).not.toContain("video-1790385749473-q4157");
|
||||
});
|
||||
|
||||
test("labels character cards by resource kind instead of the underlying text type", () => {
|
||||
const nodeId = "text-1790385749473-char1";
|
||||
const reference = { id: nodeId, nodeId, kind: "character" as const, label: "李莫愁", title: "李莫愁", sourceType: "text" as const };
|
||||
|
||||
expect(rewriteAgentNodeLinks(nodeId, [reference])).toContain(`[角色卡 · 李莫愁](#agent-node:${nodeId})`);
|
||||
});
|
||||
|
||||
41
web/test/audio-generation.test.ts
Normal file
41
web/test/audio-generation.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { doubaoAudioVoiceOptions, isDoubaoAudioConfig, normalizeAudioVoiceForConfig, normalizeAudioVoiceValue } from "../src/lib/audio-generation";
|
||||
|
||||
const config = (protocol: string) => ({
|
||||
provider: "custom",
|
||||
model: "models/index-tts",
|
||||
audioModel: "models/index-tts",
|
||||
apiKey: "",
|
||||
baseUrl: "",
|
||||
channels: [{ models: ["index-tts"], modelCosts: [], interfaceType: protocol, baseUrl: "", apiKey: "" }],
|
||||
} as any);
|
||||
|
||||
describe("audio voice protocol handling", () => {
|
||||
test("keeps custom voice identifiers for non-OpenAI channels", () => {
|
||||
expect(normalizeAudioVoiceValue("voice_001")).toBe("voice_001");
|
||||
});
|
||||
|
||||
test("uses provider-specific Doubao defaults instead of OpenAI alloy", () => {
|
||||
const doubao = { ...config("doubao-streaming-tts"), model: "seed-audio-1.0", audioModel: "seed-audio-1.0" } as any;
|
||||
expect(isDoubaoAudioConfig(doubao)).toBe(true);
|
||||
expect(normalizeAudioVoiceForConfig(doubao, "alloy")).toBe("");
|
||||
});
|
||||
|
||||
test("lists every official Doubao 1.0 voice in Chinese", () => {
|
||||
const values = doubaoAudioVoiceOptions.map((item) => item.value);
|
||||
expect(doubaoAudioVoiceOptions).toHaveLength(103);
|
||||
expect(new Set(values).size).toBe(values.length);
|
||||
expect(values).toContain("zh_female_shuangkuaisisi_moon_bigtts");
|
||||
expect(values).toContain("zh_female_yueyunv_mars_bigtts");
|
||||
expect(values).toContain("zh_male_qingcang_mars_bigtts");
|
||||
expect(values).not.toContain("zh_female_vv_uranus_bigtts");
|
||||
expect(values).not.toContain("zh_female_xiaohe_uranus_bigtts");
|
||||
expect(doubaoAudioVoiceOptions.every((item) => /[\u4e00-\u9fff]/.test(item.label) && /[\u4e00-\u9fff]/.test(item.scene))).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves manually entered official Doubao speaker IDs", () => {
|
||||
const doubao = { ...config("doubao-streaming-tts"), model: "seed-audio-1.0", audioModel: "seed-audio-1.0" } as any;
|
||||
expect(normalizeAudioVoiceForConfig(doubao, "zh_male_custom_official_speaker")).toBe("zh_male_custom_official_speaker");
|
||||
});
|
||||
|
||||
});
|
||||
16
web/test/canvas-character-menu.test.ts
Normal file
16
web/test/canvas-character-menu.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { resolveAddNodeMenuCommands, type AddNodeMenuContext } from "../src/lib/canvas/tool-registry";
|
||||
|
||||
describe("普通画布添加菜单", () => {
|
||||
test("双击新建在未关联项目时也包含角色卡", () => {
|
||||
const commands = resolveAddNodeMenuCommands({
|
||||
workspaceMode: "professional",
|
||||
isProjectLinked: false,
|
||||
handlers: {} as AddNodeMenuContext["handlers"],
|
||||
});
|
||||
const character = commands.find((command) => command.id === "project-character");
|
||||
expect(character?.label).toBe("角色卡");
|
||||
expect(character?.section).toBe("node");
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { canvasConnectionError } from "../src/lib/canvas/canvas-connection-polic
|
||||
import { assertCanvasImageReferenceLimit, buildGenerationConfig, canvasImageReferenceLimitError, resolveCanvasGenerationModel } from "../src/lib/canvas/canvas-project-generation";
|
||||
import { readNodeGenerationSpec, synchronizeGenerationSpec } from "../src/lib/canvas/generation-contract";
|
||||
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
|
||||
import { groupModelsByDisplayName, inferVideoOperation, modelCompatibilityError, modelGroupReferenceLimits, modelPromptLengthError, resolveCompatibleModel, resolveModelGenerationDefaults, resolveModelVideoBooleanOptions } from "../src/lib/model-selection";
|
||||
import { audioModelForConnectionInput, groupModelsByDisplayName, inferVideoOperation, modelCompatibilityError, modelGroupReferenceLimits, modelPromptLengthError, resolveCompatibleModel, resolveModelGenerationDefaults, resolveModelVideoBooleanOptions } from "../src/lib/model-selection";
|
||||
import { defaultConfig, normalizeModelOptionValue, type AiConfig, type ModelChannel } from "../src/stores/use-config-store";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
|
||||
|
||||
@@ -41,7 +41,7 @@ function policyConfig(): AiConfig {
|
||||
return { ...defaultConfig, channels: [channel], models, videoModels: models, model: models[0], videoModel: models[0] };
|
||||
}
|
||||
|
||||
function node(id: string, type: CanvasNodeType, generationMode?: "image" | "video"): CanvasNodeData {
|
||||
function node(id: string, type: CanvasNodeType, generationMode?: "image" | "video" | "audio"): CanvasNodeData {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
@@ -470,6 +470,104 @@ describe("画布连线能力", () => {
|
||||
const nodes = [character, node("target", CanvasNodeType.Audio)];
|
||||
expect(canvasConnectionError(config, nodes, [], { fromNodeId: "character", toNodeId: "target" })).toBe("");
|
||||
});
|
||||
|
||||
test("默认音频模型不支持图片时,从图片新建音频节点改选豆包音频", () => {
|
||||
const config = {
|
||||
...defaultConfig,
|
||||
audioModel: "openai::tts-1",
|
||||
audioModels: ["openai::tts-1", "doubao::seed-audio-1.0"],
|
||||
channels: [
|
||||
{ id: "openai", name: "OpenAI", baseUrl: "https://api.example.com", apiKey: "k", apiFormat: "openai" as const, interfaceType: "openai-audio", models: ["tts-1"], modelCosts: [{ model: "tts-1", capability: "audio" as const, protocol: "openai-audio", billingMode: "fixed_request" as const, unitPriceMicrocredits: 1 }] },
|
||||
{ id: "doubao", name: "豆包语音", baseUrl: "https://openspeech.bytedance.com", apiKey: "k", apiFormat: "openai" as const, models: ["seed-audio-1.0"], modelCosts: [{ model: "seed-audio-1.0", capability: "audio" as const, protocol: "doubao-streaming-tts", billingMode: "fixed_request" as const, unitPriceMicrocredits: 1 }] },
|
||||
],
|
||||
} satisfies AiConfig;
|
||||
const imageInput = { textCount: 0, imageCount: 1, videoCount: 0, audioCount: 0, characterCount: 0 };
|
||||
const model = audioModelForConnectionInput(config, imageInput);
|
||||
expect(model).toBe("doubao::seed-audio-1.0");
|
||||
const target = { ...node("target", CanvasNodeType.Audio), metadata: { model, generationMode: "audio" as const } };
|
||||
expect(canvasConnectionError(config, [node("picture", CanvasNodeType.Image), target], [], { fromNodeId: "picture", toNodeId: "target" })).toBe("");
|
||||
expect(audioModelForConnectionInput(config, { ...imageInput, imageCount: 0 })).toBe("openai::tts-1");
|
||||
});
|
||||
|
||||
test("同显示名模型组里有豆包音频时,图片可以连接到音频节点", () => {
|
||||
const config = {
|
||||
...defaultConfig,
|
||||
audioModel: "doubao::family-audio",
|
||||
channels: [{
|
||||
id: "doubao",
|
||||
name: "豆包语音",
|
||||
baseUrl: "https://openspeech.bytedance.com",
|
||||
apiKey: "test-key",
|
||||
apiFormat: "openai" as const,
|
||||
models: ["family-audio", "seed-audio-1.0"],
|
||||
modelCosts: [
|
||||
{ model: "family-audio", displayName: "豆包音频", capability: "audio" as const, protocol: "openai-audio", billingMode: "fixed_request" as const, unitPriceMicrocredits: 2 },
|
||||
{ model: "seed-audio-1.0", displayName: "豆包音频", capability: "audio" as const, protocol: "doubao-streaming-tts", billingMode: "fixed_request" as const, unitPriceMicrocredits: 1 },
|
||||
],
|
||||
}],
|
||||
} satisfies AiConfig;
|
||||
const target = { ...node("target", CanvasNodeType.Audio), metadata: { model: "doubao::family-audio", generationMode: "audio" as const } };
|
||||
expect(canvasConnectionError(config, [node("picture", CanvasNodeType.Image), target], [], { fromNodeId: "picture", toNodeId: "target" })).toBe("");
|
||||
});
|
||||
|
||||
test("豆包音频生成允许图片连接到音频节点", () => {
|
||||
const config = {
|
||||
...defaultConfig,
|
||||
model: "relay::cinema-image",
|
||||
audioModel: "",
|
||||
audioModels: ["doubao::seed-audio-1.0"],
|
||||
channels: [{
|
||||
id: "doubao",
|
||||
name: "豆包语音",
|
||||
baseUrl: "https://openspeech.bytedance.com",
|
||||
apiKey: "test-key",
|
||||
apiFormat: "openai" as const,
|
||||
models: ["seed-audio-1.0"],
|
||||
modelCosts: [{ model: "seed-audio-1.0", capability: "audio" as const, protocol: "doubao-streaming-tts", billingMode: "fixed_request" as const, unitPriceMicrocredits: 1 }],
|
||||
}],
|
||||
} satisfies AiConfig;
|
||||
const nodes = [node("picture", CanvasNodeType.Image), node("target", CanvasNodeType.Audio)];
|
||||
expect(canvasConnectionError(config, nodes, [], { fromNodeId: "picture", toNodeId: "target" })).toBe("");
|
||||
});
|
||||
|
||||
test("豆包音频生成同时拒绝图片和音频一起连接", () => {
|
||||
const config = {
|
||||
...defaultConfig,
|
||||
audioModel: "doubao::seed-audio-1.0",
|
||||
channels: [{
|
||||
id: "doubao",
|
||||
name: "豆包语音",
|
||||
baseUrl: "https://openspeech.bytedance.com",
|
||||
apiKey: "test-key",
|
||||
apiFormat: "openai" as const,
|
||||
models: ["seed-audio-1.0"],
|
||||
modelCosts: [{ model: "seed-audio-1.0", capability: "audio" as const, protocol: "doubao-streaming-tts", billingMode: "fixed_request" as const, unitPriceMicrocredits: 1 }],
|
||||
}],
|
||||
} satisfies AiConfig;
|
||||
const nodes = [node("picture", CanvasNodeType.Image), node("voice", CanvasNodeType.Audio), node("target", CanvasNodeType.Audio)];
|
||||
const imageConnection = { id: "image", fromNodeId: "picture", toNodeId: "target" };
|
||||
expect(canvasConnectionError(config, nodes, [imageConnection], { fromNodeId: "voice", toNodeId: "target" })).toContain("不能同时连接");
|
||||
});
|
||||
|
||||
test("不支持参考音频的普通音频模型仍然拒绝音频连线", () => {
|
||||
const config = {
|
||||
...defaultConfig,
|
||||
model: "openai::tts-1",
|
||||
audioModel: "openai::tts-1",
|
||||
channels: [{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
baseUrl: "https://api.example.com",
|
||||
apiKey: "test-key",
|
||||
apiFormat: "openai" as const,
|
||||
interfaceType: "openai-audio",
|
||||
models: ["tts-1"],
|
||||
modelCosts: [{ model: "tts-1", capability: "audio" as const, protocol: "openai-audio", billingMode: "fixed_request" as const, unitPriceMicrocredits: 1 }],
|
||||
}],
|
||||
} satisfies AiConfig;
|
||||
const nodes = [node("voice", CanvasNodeType.Audio), node("target", CanvasNodeType.Audio)];
|
||||
expect(canvasConnectionError(config, nodes, [], { fromNodeId: "voice", toNodeId: "target" })).toContain("不支持参考音频");
|
||||
});
|
||||
});
|
||||
|
||||
describe("图片参考图上限", () => {
|
||||
|
||||
@@ -28,8 +28,8 @@ function targetNode(): CanvasNodeData {
|
||||
};
|
||||
}
|
||||
|
||||
function connection(fromNodeId: string): CanvasConnection {
|
||||
return { id: `connection-${fromNodeId}`, fromNodeId, toNodeId: "target" };
|
||||
function connection(fromNodeId: string, toNodeId = "target"): CanvasConnection {
|
||||
return { id: `connection-${fromNodeId}`, fromNodeId, toNodeId };
|
||||
}
|
||||
|
||||
describe("canvas node generation position mentions", () => {
|
||||
@@ -171,6 +171,42 @@ describe("canvas node generation position mentions", () => {
|
||||
expect(context.prompt).toBe("让 @图片1 配合 @音频1");
|
||||
});
|
||||
|
||||
test("音频节点即使提示词只 @ 了文本,也会带上已连接的参考音频", () => {
|
||||
const target: CanvasNodeData = {
|
||||
id: "audio-target",
|
||||
type: CanvasNodeType.Audio,
|
||||
title: "audio-target",
|
||||
position: { x: 0, y: 0 },
|
||||
width: 220,
|
||||
height: 160,
|
||||
metadata: { composerContent: "参考 @文本1" },
|
||||
};
|
||||
const source = node("audio-source", CanvasNodeType.Audio, "data:audio/mpeg;base64,a");
|
||||
const note = node("note", CanvasNodeType.Text, "旁白:你好");
|
||||
const context = buildNodeGenerationContext(target.id, [source, note, target], [connection(source.id, target.id), connection(note.id, target.id)], "参考 @文本1", []);
|
||||
|
||||
expect(context.referenceAudios.map((audio) => audio.id)).toEqual(["audio-source"]);
|
||||
expect(context.prompt).toContain("旁白:你好");
|
||||
});
|
||||
|
||||
test("只有 storageKey 的已生成音频也可以作为音频节点参考输入", () => {
|
||||
const target: CanvasNodeData = {
|
||||
id: "audio-target",
|
||||
type: CanvasNodeType.Audio,
|
||||
title: "audio-target",
|
||||
position: { x: 0, y: 0 },
|
||||
width: 220,
|
||||
height: 160,
|
||||
metadata: {},
|
||||
};
|
||||
const source = node("audio-source", CanvasNodeType.Audio, "");
|
||||
source.metadata = { storageKey: "resource:audio-source", mimeType: "audio/wav", durationMs: 2400 };
|
||||
const context = buildNodeGenerationContext(target.id, [source, target], [connection(source.id, target.id)], "你好", []);
|
||||
|
||||
expect(context.referenceAudios).toHaveLength(1);
|
||||
expect(context.referenceAudios[0]).toMatchObject({ id: source.id, storageKey: "resource:audio-source", type: "audio/wav" });
|
||||
});
|
||||
|
||||
test("旧节点 token 只做读取迁移,不再进入生成提示词", () => {
|
||||
const target = targetNode();
|
||||
const image = node("image-a", CanvasNodeType.Image, "data:image/png;base64,a");
|
||||
|
||||
@@ -4,7 +4,17 @@ import { apiClient } from "../src/services/api/request";
|
||||
import { canvasContentHash } from "../src/lib/canvas/canvas-content";
|
||||
import { rebaseCanvasProjects, parseCanvasStorageDocument } from "../src/lib/canvas/canvas-storage-revision";
|
||||
import { readCanvasSyncDrafts } from "../src/services/canvas-sync-drafts";
|
||||
import { applyAgentCanvasPatches, deleteCanvasProjectsWithRemoteSync, refreshCanvasAfterAgent, initializeRemoteUserDataSession, installRemoteUserDataAutoSync, loadCanvasProjectForEditing, resetRemoteUserDataSync, saveRemoteUserDataNow, syncRemoteUserData } from "../src/services/user-data-sync";
|
||||
import {
|
||||
applyAgentCanvasPatches,
|
||||
deleteCanvasProjectsWithRemoteSync,
|
||||
refreshCanvasAfterAgent,
|
||||
initializeRemoteUserDataSession,
|
||||
installRemoteUserDataAutoSync,
|
||||
loadCanvasProjectForEditing,
|
||||
resetRemoteUserDataSync,
|
||||
saveRemoteUserDataNow,
|
||||
syncRemoteUserData,
|
||||
} from "../src/services/user-data-sync";
|
||||
import { createAgentCanvasSync } from "../src/services/agent-canvas-sync";
|
||||
import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "../src/stores/canvas/use-canvas-store";
|
||||
import { flushAssetStorePersistence, useAssetStore } from "../src/stores/use-asset-store";
|
||||
@@ -93,11 +103,20 @@ beforeEach(async () => {
|
||||
data = { id };
|
||||
}
|
||||
} else if (id === "batch" && method === "post") {
|
||||
data = { assets: [{
|
||||
id: "invalid-mime-asset", kind: "image", title: "历史素材", coverUrl: "", tags: [],
|
||||
createdAt: "2026-09-01", updatedAt: "2026-09-01",
|
||||
data: { dataUrl: "https://example.com/image.png", width: 100, height: 100, bytes: 1, mimeType: "image/*" },
|
||||
}] };
|
||||
data = {
|
||||
assets: [
|
||||
{
|
||||
id: "invalid-mime-asset",
|
||||
kind: "image",
|
||||
title: "历史素材",
|
||||
coverUrl: "",
|
||||
tags: [],
|
||||
createdAt: "2026-09-01",
|
||||
updatedAt: "2026-09-01",
|
||||
data: { dataUrl: "https://example.com/image.png", width: 100, height: 100, bytes: 1, mimeType: "image/*" },
|
||||
},
|
||||
],
|
||||
};
|
||||
} else if (id === "restore" && method === "post") {
|
||||
const current = remote.get("canvas")!;
|
||||
if (body.revision !== current.revision) status = 409;
|
||||
@@ -159,7 +178,8 @@ test("deletion skips editing and invalid MIME assets, including an uncached canv
|
||||
await deleteCanvasProjectsWithRemoteSync([" canvas ", "canvas", "other"]);
|
||||
|
||||
expect(requests.map(({ method, id }) => ({ method, id }))).toEqual([
|
||||
{ method: "delete", id: "canvas" }, { method: "delete", id: "other" },
|
||||
{ method: "delete", id: "canvas" },
|
||||
{ method: "delete", id: "other" },
|
||||
]);
|
||||
expect(remote.size).toBe(0);
|
||||
expect(useCanvasStore.getState().projects).toEqual([]);
|
||||
@@ -210,24 +230,40 @@ test("load latest, save and reload stay synced when Agent history replays an unv
|
||||
await loadCanvasProjectForEditing("canvas");
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const reconciled = new Promise<void>((done, fail) => { resolve = done; reject = fail; });
|
||||
const reconciled = new Promise<void>((done, fail) => {
|
||||
resolve = done;
|
||||
reject = fail;
|
||||
});
|
||||
const sync = createAgentCanvasSync({
|
||||
canvasId: "canvas", batchMs: 0,
|
||||
canvasId: "canvas",
|
||||
batchMs: 0,
|
||||
applyPatches: (patches) => applyAgentCanvasPatches("canvas", patches),
|
||||
refresh: async () => { await refreshCanvasAfterAgent("canvas"); resolve(); },
|
||||
refresh: async () => {
|
||||
await refreshCanvasAfterAgent("canvas");
|
||||
resolve();
|
||||
},
|
||||
onError: reject,
|
||||
});
|
||||
try {
|
||||
sync.receive({ eventId: "historical-event", runId: "completed-run", seq: 1, type: "canvas_updated", createdAt: "2026-09-17", payload: {
|
||||
canvasId: "canvas",
|
||||
canvasPatch: { canvasId: "canvas", updatedAt: "2026-09-17", nodes: [{ before: null, after: { ...canvas().nodes[0], id: "stale-node" } }], connections: [] },
|
||||
} });
|
||||
sync.receive({
|
||||
eventId: "historical-event",
|
||||
runId: "completed-run",
|
||||
seq: 1,
|
||||
type: "canvas_updated",
|
||||
createdAt: "2026-09-17",
|
||||
payload: {
|
||||
canvasId: "canvas",
|
||||
canvasPatch: { canvasId: "canvas", updatedAt: "2026-09-17", nodes: [{ before: null, after: { ...canvas().nodes[0], id: "stale-node" } }], connections: [] },
|
||||
},
|
||||
});
|
||||
await reconciled;
|
||||
expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("done");
|
||||
await saveRemoteUserDataNow("canvas");
|
||||
expect(useCanvasStore.getState().openProject("canvas")).toMatchObject({ revision: 10, title: "已保存的标题", nodes: canvas().nodes });
|
||||
expect(await readCanvasSyncDrafts("canvas")).toHaveLength(0);
|
||||
} finally { sync.dispose(); }
|
||||
} finally {
|
||||
sync.dispose();
|
||||
}
|
||||
}
|
||||
expect(requests.filter((request) => request.method === "put")).toHaveLength(1);
|
||||
});
|
||||
@@ -246,30 +282,22 @@ test("a stale save auto-reconciles when the cloud already contains the identical
|
||||
expect(await readCanvasSyncDrafts("canvas")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a conflict preserves drafts, stops retries and does not block another canvas", async () => {
|
||||
test("a stale save rebases disjoint Agent edits and keeps saving", async () => {
|
||||
remote.set("canvas", { ...addNode(canvas(), "remote-video"), revision: 2 });
|
||||
useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "local-video").nodes });
|
||||
useCanvasStore.getState().renameProject("other", "other edit");
|
||||
await expect(saveRemoteUserDataNow()).rejects.toThrow("版本冲突");
|
||||
expect(remote.get("canvas")!.nodes.map((node) => node.id)).toEqual(["old-image", "remote-video"]);
|
||||
|
||||
await expect(saveRemoteUserDataNow()).resolves.toBeUndefined();
|
||||
|
||||
expect(remote.get("canvas")!.nodes.map((node) => node.id)).toEqual(["old-image", "local-video", "remote-video"]);
|
||||
expect(remote.get("canvas")!.revision).toBe(3);
|
||||
expect(remote.get("other")!.title).toBe("other edit");
|
||||
expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("conflict");
|
||||
expect((await readCanvasSyncDrafts("canvas"))[0].project.nodes.at(-1)!.id).toBe("local-video");
|
||||
expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("done");
|
||||
|
||||
useCanvasStore.getState().renameProject("canvas", "more local edits");
|
||||
await expect(saveRemoteUserDataNow()).rejects.toThrow("云端画布已有更新");
|
||||
useCanvasStore.getState().renameProject("other", "still saves independently");
|
||||
await saveRemoteUserDataNow("other");
|
||||
expect(remote.get("other")!.title).toBe("still saves independently");
|
||||
await expect(saveRemoteUserDataNow(["canvas", "other"])).rejects.toThrow("云端画布已有更新");
|
||||
expect(requests.filter((request) => request.method === "put" && request.id === "canvas")).toHaveLength(1);
|
||||
await expect(saveRemoteUserDataNow("canvas")).rejects.toThrow("云端画布已有更新");
|
||||
await loadCanvasProjectForEditing("canvas", { latest: true });
|
||||
expect(useCanvasStore.getState().openProject("canvas")!.nodes.at(-1)!.id).toBe("remote-video");
|
||||
expect((await readCanvasSyncDrafts("canvas")).some((draft) => draft.project.title === "more local edits")).toBe(true);
|
||||
resetRemoteUserDataSync();
|
||||
await useCanvasStore.persist.rehydrate();
|
||||
await syncRemoteUserData(scope);
|
||||
expect(useSyncProgressStore.getState().syncingProjects.canvas.draftCount).toBe(2);
|
||||
await expect(saveRemoteUserDataNow("canvas")).resolves.toBeUndefined();
|
||||
expect(remote.get("canvas")!.title).toBe("more local edits");
|
||||
expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("done");
|
||||
});
|
||||
|
||||
test("transient cloud failures schedule a bounded automatic retry", async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { defaultPriceTier } from "../src/pages/admin/components/channel-model-pr
|
||||
|
||||
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")];
|
||||
const protocolsWithAudio = [...protocols, definition("audio", "audio")];
|
||||
|
||||
describe("channel model editor drafts", () => {
|
||||
test("new drafts select an enabled protocol and never share price state", () => {
|
||||
@@ -52,6 +53,15 @@ describe("channel model editor drafts", () => {
|
||||
describe("pricing write validation", () => {
|
||||
const draft = initialChannelModelValues(null, protocols);
|
||||
test("accepts an explicit free/default price", () => expect(() => validateChannelModelPrices(draft)).not.toThrow());
|
||||
test("accepts audio per-second pricing", () => {
|
||||
const audioDraft = {
|
||||
...initialChannelModelValues(null, protocolsWithAudio),
|
||||
capability: "audio" as const,
|
||||
protocol: "audio",
|
||||
priceTiers: [{ ...defaultPriceTier(), billingMode: "per_second" as const, unitPrice: 1 }],
|
||||
};
|
||||
expect(() => validateChannelModelPrices(audioDraft)).not.toThrow();
|
||||
});
|
||||
test("requires prices and exactly one or zero fallback tiers", () => {
|
||||
expect(() => validateChannelModelPrices({ ...draft, priceTiers: [] })).toThrow("至少");
|
||||
expect(() => validateChannelModelPrices({ ...draft, priceTiers: [defaultPriceTier(), defaultPriceTier()] })).toThrow("只能");
|
||||
|
||||
Reference in New Issue
Block a user