feat(*): 画布创作体验 - 完善 Agent 引导、生成防重、搜索快捷键、素材多选与分辨率兼容

This commit is contained in:
ddCat committed 2026-08-28 16:24:58 +08:00
1 parent 913cf4b304
commit ab89c05362
52 files changed
+2344 -500

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, test } from "bun:test";
import { assetPickerItemsToInsertPayloads } from "@/components/canvas/asset-picker-modal";
import type { AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import type { ImageAsset, TextAsset } from "@/stores/use-asset-store";
const timestamp = "2026-08-28T00:00:00.000Z";
describe("canvas asset picker", () => {
test("maps multiple local and external selections in the requested order", () => {
const image: ImageAsset = {
id: "image-1",
kind: "image",
title: "场景图",
coverUrl: "",
tags: [],
createdAt: timestamp,
updatedAt: timestamp,
data: { dataUrl: "data:image/png;base64,AAAA", storageKey: "resource:image-1", width: 1280, height: 720, bytes: 1024, mimeType: "image/png" },
};
const text: TextAsset = {
id: "text-1",
kind: "text",
title: "旁白",
coverUrl: "",
tags: [],
createdAt: timestamp,
updatedAt: timestamp,
data: { content: "夜色渐深。" },
};
const items: AssetLibraryPickerItem[] = [
{ id: image.id, title: image.title, category: "environment", kindLabel: "图片", asset: image },
{ id: text.id, title: text.title, category: "other", kindLabel: "文本", asset: text },
{
id: "external-video",
title: "外部视频",
category: "external:eagle",
kindLabel: "视频",
external: {
sourceId: "eagle",
sourceName: "Eagle",
item: { id: "video-1", title: "外部视频", kind: "video", fileUrl: "http://127.0.0.1/video.mp4", width: 1920, height: 1080, bytes: 2048, mimeType: "video/mp4" },
},
},
];
expect(assetPickerItemsToInsertPayloads(["external-video", image.id, text.id], items)).toEqual([
{ kind: "video", url: "http://127.0.0.1/video.mp4", title: "外部视频", width: 1920, height: 1080, bytes: 2048, mimeType: "video/mp4", assetId: "external:eagle:video-1" },
{ kind: "image", dataUrl: "data:image/png;base64,AAAA", storageKey: "resource:image-1", title: "场景图", width: 1280, height: 720, bytes: 1024, mimeType: "image/png", assetId: "image-1" },
{ kind: "text", content: "夜色渐深。", title: "旁白", assetId: "text-1" },
]);
});
test("rejects stale selections instead of silently dropping them", () => {
expect(() => assetPickerItemsToInsertPayloads(["missing"], [])).toThrow("所选素材已不存在");
});
});
describe("canvas context menu motion", () => {
test("keeps hover feedback stationary while retaining spotlight surfaces", () => {
const styles = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8");
const menu = readFileSync(resolve(import.meta.dir, "../src/components/canvas/canvas-context-menu.tsx"), "utf8");
const menuRule = styles.match(/\.canvas-menu-item\s*\{([\s\S]*?)\}/)?.[1] || "";
expect(menuRule).not.toContain("transform");
expect(styles).not.toContain(".canvas-menu-item:not(:disabled):hover {");
expect(styles).not.toContain(".canvas-menu-item:not(:disabled):active");
expect(menu).not.toContain("group-hover:translate-x");
expect(menu).not.toContain("scale: 0.97");
expect(menu).toContain("<SpotlightSurface");
});
});
@@ -0,0 +1,76 @@
import { describe, expect, test } from "bun:test";
import { canvasGenerationRequestFingerprint, runCanvasGenerationSubmissionOnce } from "@/lib/canvas/canvas-generation-submission";
function fingerprint(overrides: Partial<Parameters<typeof canvasGenerationRequestFingerprint>[0]> = {}) {
return canvasGenerationRequestFingerprint({
nodeId: "node-1",
mode: "video",
prompt: "夜晚的城市",
model: "video-model",
options: { size: "16:9", videoSeconds: 10, vquality: "768p" },
context: {
referenceImages: [{ id: "image-1", name: "reference.png", type: "image/png", dataUrl: "", storageKey: "resource:image-1" }],
referenceVideos: [],
referenceAudios: [],
characterReferences: [],
resolvedCharacterVersions: [],
resolvedCharacterVoices: [],
},
...overrides,
});
}
describe("canvas generation submission", () => {
test("同一节点的并发提交只执行一次", async () => {
const locks = new Map<string, Promise<unknown>>();
let executions = 0;
let duplicates = 0;
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
const run = () =>
runCanvasGenerationSubmissionOnce(
locks,
"node-1",
async () => {
executions += 1;
await pending;
return "done";
},
() => {
duplicates += 1;
},
);
const submissions = [run(), run(), run()];
await Promise.resolve();
expect(executions).toBe(1);
expect(duplicates).toBe(2);
release();
expect(await Promise.all(submissions)).toEqual(["done", "done", "done"]);
await run();
expect(executions).toBe(2);
});
test("相同输入生成稳定指纹,关键内容变化会改变指纹", () => {
expect(fingerprint({ options: { vquality: "768p", videoSeconds: 10, size: "16:9" } })).toBe(fingerprint());
expect(fingerprint({ prompt: "白天的城市" })).not.toBe(fingerprint());
expect(fingerprint({ model: "another-model" })).not.toBe(fingerprint());
expect(fingerprint({ options: { size: "9:16", videoSeconds: 10, vquality: "768p" } })).not.toBe(fingerprint());
expect(
fingerprint({
context: {
referenceImages: [{ id: "image-2", name: "other.png", type: "image/png", dataUrl: "", storageKey: "resource:image-2" }],
referenceVideos: [],
referenceAudios: [],
characterReferences: [],
resolvedCharacterVersions: [],
resolvedCharacterVoices: [],
},
}),
).not.toBe(fingerprint());
});
});
+17
View File
@@ -0,0 +1,17 @@
import { expect, test } from "bun:test";
import { hasCanvasTextSelection } from "../src/pages/canvas/use-canvas-keyboard";
test("Canvas copy shortcut yields to a real browser text selection", () => {
expect(hasCanvasTextSelection(null)).toBe(false);
expect(hasCanvasTextSelection({ isCollapsed: true, rangeCount: 1, toString: () => "Agent 文本" })).toBe(false);
expect(hasCanvasTextSelection({ isCollapsed: false, rangeCount: 0, toString: () => "Agent 文本" })).toBe(false);
expect(hasCanvasTextSelection({ isCollapsed: false, rangeCount: 1, toString: () => "" })).toBe(false);
expect(hasCanvasTextSelection({ isCollapsed: false, rangeCount: 1, toString: () => "Agent 文本" })).toBe(true);
});
test("Canvas keyboard keeps node copy as the fallback when no text is selected", async () => {
const source = await Bun.file(new URL("../src/pages/canvas/use-canvas-keyboard.ts", import.meta.url)).text();
expect(source).toContain("if (hasCanvasTextSelection(window.getSelection())) return;");
expect(source).toContain("event.preventDefault();\n copySelectedNodes();");
});
+21
View File
@@ -230,6 +230,27 @@ test("Canvas launch mode auto-connects after rejected legacy deep-link secrets a
expect(shouldAutoConnect(new URLSearchParams("agentUrl=http://127.0.0.1:17371"))).toBe(false);
});
test("Canvas local Agent setup generates exact-origin commands for Unix and Windows", async () => {
const module = await import("../src/lib/canvas/local-agent-setup");
const unix = module.buildLocalAgentSetupCommands("https://ddcat.pronhubcn.com", "unix");
const windows = module.buildLocalAgentSetupCommands("https://ddcat.pronhubcn.com", "windows");
expect(unix.install).toContain("git clone https://github.com/ddcat-ai/open-ai-canvas.git");
expect(unix.start).toContain("FRAMEFIELD_TRUSTED_WEB_ORIGINS='https://ddcat.pronhubcn.com'");
expect(unix.start).toContain("node dist/index.js");
expect(windows.start).toContain("$env:FRAMEFIELD_TRUSTED_WEB_ORIGINS='https://ddcat.pronhubcn.com'");
expect(windows.start).toContain("node .\\dist\\index.js");
expect(module.detectLocalAgentSetupPlatform("Windows NT 10.0")).toBe("windows");
expect(module.detectLocalAgentSetupPlatform("Macintosh Intel Mac OS X")).toBe("unix");
});
test("Canvas local Agent tutorial replaces the conversation only while disconnected", async () => {
const source = await Bun.file(new URL("../src/components/canvas/canvas-local-agent-panel.tsx", import.meta.url)).text();
expect(source).toContain('!connected ? (\n <AgentConnectView');
expect(source).toContain("连接成功后,这里会自动恢复原对话");
expect(source).toContain("当前支持范围");
});
test("Canvas reconnect wait resolves quietly when component cleanup aborts", async () => {
const module = await import("../src/lib/canvas/local-runtime-connection");
const waitForReconnect = (
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, test } from "bun:test";
import { buildCanvasMediaDownloadFileName, canvasMediaFileExtension } from "@/lib/canvas/canvas-media-download";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
function mediaNode(overrides: Partial<CanvasNodeData> = {}): CanvasNodeData {
return {
id: "image-1",
type: CanvasNodeType.Image,
title: "女明星角色三视图",
position: { x: 0, y: 0 },
width: 1024,
height: 1024,
metadata: { content: "data:image/png;base64,aW1hZ2U=", mimeType: "image/png", status: "success" },
...overrides,
};
}
describe("canvas media download", () => {
test("按画布名、节点名和本地日期生成文件名", () => {
expect(buildCanvasMediaDownloadFileName("写给阿妈的情书", mediaNode(), new Date(2026, 7, 28, 12))).toBe("写给阿妈的情书_女明星角色三视图_20260828.png");
});
test("清理跨平台非法字符且保留中文可读名称", () => {
const node = mediaNode({ title: "女明星/角色:三视图?" });
expect(buildCanvasMediaDownloadFileName("写给阿妈的情书|终稿", node, new Date(2026, 7, 28, 12))).toBe("写给阿妈的情书_终稿_女明星_角色_三视图_20260828.png");
});
test("截断长名称后不会留下 Windows 不接受的结尾句点", () => {
const node = mediaNode({ title: `${"a".repeat(79)}.extra` });
expect(buildCanvasMediaDownloadFileName("画布", node, new Date(2026, 7, 28, 12))).toBe(`画布_${"a".repeat(79)}_20260828.png`);
});
test("优先按媒体 MIME 类型确定视频扩展名", () => {
expect(canvasMediaFileExtension(mediaNode({ type: CanvasNodeType.Video, metadata: { content: "https://example.com/video", mimeType: "video/webm" } }))).toBe("webm");
expect(canvasMediaFileExtension(mediaNode({ type: CanvasNodeType.Video, metadata: { content: "https://example.com/video", mimeType: "video/quicktime" } }))).toBe("mov");
});
test("MIME 类型不明确时从远程资源 URL 识别扩展名", () => {
expect(canvasMediaFileExtension(mediaNode({ metadata: { content: "https://example.com/result.jpeg?token=hidden", mimeType: "image/*" } }))).toBe("jpg");
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test";
import { nextCopiedNodeTitle } from "@/lib/canvas/canvas-node-copy";
describe("canvas node copy title", () => {
test("首次复制使用 copy1,后续按已有最大序号递增", () => {
expect(nextCopiedNodeTitle("女明星角色三视图", ["女明星角色三视图"])).toBe("女明星角色三视图_copy1");
expect(nextCopiedNodeTitle("女明星角色三视图", ["女明星角色三视图", "女明星角色三视图_copy1"])).toBe("女明星角色三视图_copy2");
expect(nextCopiedNodeTitle("女明星角色三视图", ["女明星角色三视图_copy1", "女明星角色三视图_copy3"])).toBe("女明星角色三视图_copy4");
});
test("复制已有副本时仍使用原始名称计算下一序号", () => {
expect(nextCopiedNodeTitle("女明星角色三视图_copy1", ["女明星角色三视图", "女明星角色三视图_copy1"])).toBe("女明星角色三视图_copy2");
});
test("兼容旧版空格 Copy 后缀", () => {
expect(nextCopiedNodeTitle("女明星角色三视图 Copy", ["女明星角色三视图", "女明星角色三视图 Copy"])).toBe("女明星角色三视图_copy1");
});
test("同一批粘贴可以通过预留标题连续分配序号", () => {
const titles = new Set(["节点", "节点_copy1"]);
const first = nextCopiedNodeTitle("节点", titles);
titles.add(first);
const second = nextCopiedNodeTitle("节点", titles);
expect([first, second]).toEqual(["节点_copy2", "节点_copy3"]);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, test } from "bun:test";
import { canvasNodeMaterialSummary, canvasNodeSearchContext, canvasNodeSearchTimes, searchCanvasNodes } from "@/lib/canvas/canvas-node-search";
import { normalizeCanvasNodeTimestamps, stampCanvasNodeChanges } from "@/lib/canvas/canvas-node-timestamps";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
function node(id: string, patch: Partial<CanvasNodeData> = {}): CanvasNodeData {
return {
id,
type: CanvasNodeType.Image,
title: id,
position: { x: 0, y: 0 },
width: 320,
height: 180,
metadata: {},
...patch,
};
}
describe("canvas node timestamps", () => {
test("legacy nodes receive the project timestamp baseline", () => {
const normalized = normalizeCanvasNodeTimestamps([node("legacy")], {
createdAt: "2026-08-01T01:00:00.000Z",
updatedAt: "2026-08-20T02:00:00.000Z",
});
expect(normalized[0]?.createdAt).toBe("2026-08-01T01:00:00.000Z");
expect(normalized[0]?.updatedAt).toBe("2026-08-20T02:00:00.000Z");
});
test("new nodes and meaningful edits receive timestamps without touching unchanged nodes", () => {
const created = stampCanvasNodeChanges([], [node("new")], "2026-08-28T01:00:00.000Z");
expect(created[0]).toMatchObject({ createdAt: "2026-08-28T01:00:00.000Z", updatedAt: "2026-08-28T01:00:00.000Z" });
const unchanged = created[0]!;
const edited = { ...unchanged, title: "新标题" };
const updated = stampCanvasNodeChanges(created, [edited], "2026-08-28T02:00:00.000Z");
expect(updated[0]?.createdAt).toBe("2026-08-28T01:00:00.000Z");
expect(updated[0]?.updatedAt).toBe("2026-08-28T02:00:00.000Z");
});
test("media hydration does not pretend to be a user edit", () => {
const previous = node("image", {
createdAt: "2026-08-28T01:00:00.000Z",
updatedAt: "2026-08-28T01:00:00.000Z",
metadata: { storageKey: "resource:1", content: "resource:1" },
});
const hydrated = { ...previous, metadata: { ...previous.metadata, content: "blob:preview", naturalWidth: 1920, naturalHeight: 1080 } };
const updated = stampCanvasNodeChanges([previous], [hydrated], "2026-08-28T02:00:00.000Z");
expect(updated[0]?.updatedAt).toBe("2026-08-28T01:00:00.000Z");
});
});
describe("canvas node search", () => {
test("defaults to recently edited order and searches model or tags", () => {
const older = node("older", { updatedAt: "2026-08-20T01:00:00.000Z", metadata: { model: "wan-video", assetTags: ["夜景"] } });
const newer = node("newer", { updatedAt: "2026-08-28T01:00:00.000Z", metadata: { model: "seedance" } });
expect(searchCanvasNodes([older, newer], "").map((item) => item.id)).toEqual(["newer", "older"]);
expect(searchCanvasNodes([older, newer], "夜景").map((item) => item.id)).toEqual(["older"]);
expect(searchCanvasNodes([older, newer], "seedance").map((item) => item.id)).toEqual(["newer"]);
});
test("summaries expose useful media metadata without leaking data URLs into context", () => {
const image = node("image", {
createdAt: "2026-08-28T01:00:00.000Z",
updatedAt: "2026-08-28T02:00:00.000Z",
metadata: { content: "data:image/png;base64,AAAA", naturalWidth: 1920, naturalHeight: 1080, bytes: 2 * 1024 * 1024, model: "image-model" },
});
expect(canvasNodeMaterialSummary(image)).toContain("1920×1080");
expect(canvasNodeSearchContext(image)).toBe("图片节点");
expect(canvasNodeSearchTimes(image)).toMatchObject({ createdAt: image.createdAt, updatedAt: image.updatedAt });
});
});
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test";
import { CANVAS_SHORTCUTS, filterCanvasShortcuts } from "../src/lib/canvas/canvas-shortcuts";
describe("canvas shortcuts", () => {
test("uses Ctrl/Cmd + F for search and Shift + Ctrl/Cmd + F for focus mode", () => {
expect(CANVAS_SHORTCUTS.find((shortcut) => shortcut.id === "search")?.keys).toEqual([["Ctrl / Cmd", "F"]]);
expect(CANVAS_SHORTCUTS.find((shortcut) => shortcut.id === "focus")?.keys).toEqual([["Shift", "Ctrl / Cmd", "F"]]);
});
test("searches titles, descriptions, keys and keywords", () => {
expect(filterCanvasShortcuts("粘贴").map((shortcut) => shortcut.id)).toContain("paste");
expect(filterCanvasShortcuts("缩放").map((shortcut) => shortcut.id)).toEqual(expect.arrayContaining(["zoom-wheel", "zoom-controls", "zoom-presets"]));
expect(filterCanvasShortcuts("Alt L").map((shortcut) => shortcut.id)).toContain("batch-connect");
});
test("filters by category without losing the full catalog", () => {
expect(filterCanvasShortcuts("", "common").every((shortcut) => shortcut.category === "common")).toBe(true);
expect(filterCanvasShortcuts("", "all")).toHaveLength(CANVAS_SHORTCUTS.length);
});
});
+30
View File
@@ -5,6 +5,8 @@ import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { CanvasVideoSettingsPopover } from "../src/components/canvas/canvas-video-settings-popover";
import { VideoSettingsPanel } from "../src/components/video-settings-panel";
import { canvasThemes } from "../src/lib/canvas-theme";
import { mergeFetchedChannelModelCosts, type ChannelModelCatalogItem } from "../src/lib/channel-model-catalog";
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
import { ChannelModelSettings } from "../src/pages/settings/channel-video-pricing";
@@ -328,4 +330,32 @@ describe("public channel model catalog", () => {
expect(body.seconds).toBe("8");
expect(body.size).toBe("1280x720");
});
test("preserves provider resolution enums with direction suffixes across Canvas display and requests", async () => {
let body: Record<string, string> = {};
axios.post = (async (_url: string, requestBody: unknown) => {
body = formEntries(requestBody);
return { data: { id: "synthetic-directional" } };
}) as typeof axios.post;
const catalog: ChannelModelCatalogItem = {
...omniCatalog,
id: "directional-video",
displayName: "Directional Video",
defaultParameters: { aspectRatio: "16:9", durationSeconds: "8", resolution: "768p竖" },
options: {
...omniCatalog.options,
durationSeconds: [{ value: "8" }],
resolution: [{ value: "480p竖" }, { value: "768p竖" }, { value: "480p横" }, { value: "768p横" }],
},
};
const config = configForCatalog([catalog], { videoSeconds: "8", size: "16:9", vquality: "768P竖" });
const html = renderToStaticMarkup(React.createElement(CanvasVideoSettingsPopover, { config, onConfigChange: () => undefined }));
const panelHtml = renderToStaticMarkup(React.createElement(VideoSettingsPanel, { config, onConfigChange: () => undefined, theme: canvasThemes.dark }));
await createVideoGenerationTask(config, "synthetic prompt");
expect(html).toContain("768P竖 · 16:9 · 8s");
expect(panelHtml).toMatch(/aria-pressed="true"[^>]*>768P竖<\/button>/);
expect(body.resolution_name).toBe("768p竖");
});
});
+6 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { modelQuoteRequest, priceTiersForCurrentSelection, requestCreditCost } from "../src/lib/model-pricing";
import { modelQuoteRequest, normalizeTierResolution, priceTiersForCurrentSelection, requestCreditCost } from "../src/lib/model-pricing";
import type { ModelRequirements } from "../src/lib/model-selection";
import { createModelChannel, defaultConfig, normalizeConfigSnapshot, resolveModelChannel, type AiConfig } from "../src/stores/use-config-store";
@@ -58,6 +58,11 @@ const textVideoRequirements: ModelRequirements = {
};
describe("model request pricing", () => {
test("preserves provider-specific resolution enums when matching price tiers", () => {
expect(normalizeTierResolution("768P竖")).toBe("768p竖");
expect(normalizeTierResolution("HD_Portrait")).toBe("hd_portrait");
});
test("matches the current Agnes resolution and duration tier and totals per-second credits", () => {
const config = systemConfig({
tiers: [
+5 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test";
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
import { normalizeVideoResolution, VIDEO_RESOLUTION_CAPABILITY_OPTIONS, VIDEO_RESOLUTION_OPTIONS } from "../src/lib/video-generation-options";
import { formatVideoResolutionLabel, normalizeVideoResolution, videoResolutionComparisonKey, VIDEO_RESOLUTION_CAPABILITY_OPTIONS, VIDEO_RESOLUTION_OPTIONS } from "../src/lib/video-generation-options";
describe("video generation resolution options", () => {
test("统一档位包含 1440P 与 4K,并识别常见别名", () => {
@@ -14,6 +14,10 @@ describe("video generation resolution options", () => {
test("保留模型声明的非标准档位而不是静默降级", () => {
expect(normalizeVideoResolution("768p")).toBe("768");
expect(normalizeVideoResolution("768p竖")).toBe("768p竖");
expect(normalizeVideoResolution("HD_Portrait")).toBe("HD_Portrait");
expect(videoResolutionComparisonKey("768P竖")).toBe("768p竖");
expect(formatVideoResolutionLabel("768p竖")).toBe("768P竖");
});
test("按协议限制实际可选档位", () => {