增加复制粘贴交互

This commit is contained in:
andy committed 2026-09-30 13:22:53 +08:00
1 parent 4195525dc7
commit deb9cc5bcf
14 files changed
+1144 -31

No files matched your search

+8 -1
View File
@@ -7,11 +7,18 @@ describe("assets HTTP v1 compatibility contract", () => {
expect(contract.version).toBe(1);
expect(contract.limits).toEqual({ jsonBytes: 1_048_576, uploadBytes: 20_971_520, remoteBytes: 20_971_520 });
expect(contract.routes.map((route: { path: string }) => route.path)).toEqual([
"/api/assets", "/api/assets/upload", "/api/assets/{id}", "/api/assets/{id}/download",
"/api/assets", "/api/assets/upload", "/api/assets/import-image", "/api/assets/{id}", "/api/assets/{id}/download",
"/api/v1/assets", "/api/v1/assets/{id}", "/api/v1/assets/{id}/download",
"/uploads/{path...}", "/generated-results/{path...}"
]);
expect(contract.errors.internal.body).toEqual({ error: "Internal server error." });
expect(contract.errors.importImageUnsupported).toEqual({ status: 415, body: { error: "不支持的图片格式" } });
expect(contract.importImage).toEqual({
method: "POST", path: "/api/assets/import-image", auth: "platform-session", contentType: "application/json",
request: { url: "string" }, success: { status: 201, body: { asset: "Asset" } }, source: "upload",
formats: ["png", "jpeg", "webp", "gif", "bmp"], maxURLBytes: 8192, maxImageBytes: 20_971_520,
maxImagePixels: 25_000_000
});
expect(contract.methods).toEqual({ headExecutesGet: true, optionsStatus: 204, unsupportedStatus: 405, unsupportedBody: "empty" });
expect(contract.binary).toEqual({
downloadCacheControl: "private, no-store",
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import { clipboardImageFiles, clipboardImageURL, insertClipboardImageReferences } from "@/lib/client/clipboard-images";
function clipboard(items: Array<{ kind: string; type: string; getAsFile: () => File | null }>, files: File[] = []) {
return { items, files } as unknown as Pick<DataTransfer, "items" | "files">;
}
function item(file: File | null, type = file?.type || "image/png") {
return { kind: "file", type, getAsFile: () => file };
}
describe("clipboardImageFiles", () => {
it("accepts image items and avoids their duplicate files representation", () => {
const png = new File(["png bytes"], "image", { type: "image/png" });
const jpeg = new File(["jpeg bytes"], "photo.jpeg", { type: "image/jpeg" });
const result = clipboardImageFiles(clipboard([item(png), item(jpeg)], [png, jpeg]));
expect(result).toHaveLength(2);
expect(result.map((file) => file.name)).toEqual(["pasted-image.png", "photo.jpeg"]);
expect(result[1]).toBe(jpeg);
expect(result[0].type).toBe("image/png");
});
it("falls back to files when clipboard items do not expose an image", () => {
const webp = new File(["webp bytes"], "screenshot", { type: "image/webp" });
const text = new File(["https://example.test/image.png"], "link.txt", { type: "text/plain" });
const result = clipboardImageFiles(clipboard([item(null), { kind: "string", type: "text/plain", getAsFile: () => null }], [text, webp]));
expect(result.map((file) => file.name)).toEqual(["screenshot.webp"]);
});
it("ignores text, HTML, URLs, null files and non-image files", () => {
const text = new File(["image URL"], "photo.png", { type: "text/plain" });
expect(clipboardImageFiles(clipboard([
{ kind: "string", type: "text/html", getAsFile: () => null },
{ kind: "string", type: "text/uri-list", getAsFile: () => null },
item(null),
item(text)
], [text]))).toEqual([]);
expect(clipboardImageFiles(null)).toEqual([]);
});
it("normalizes generic screenshot names but keeps usable names and image bytes", async () => {
const original = new File([Uint8Array.from([1, 2, 3, 4])], "capture.image", { type: "image/gif" });
const good = new File(["jpeg"], "holiday.jpg", { type: "image/jpeg" });
const [renamed, preserved] = clipboardImageFiles(clipboard([item(original), item(good)]));
expect(renamed.name).toBe("capture.gif");
expect(new Uint8Array(await renamed.arrayBuffer())).toEqual(Uint8Array.from([1, 2, 3, 4]));
expect(preserved).toBe(good);
});
it("fills a missing File MIME from the image clipboard item even when its filename is usable", async () => {
const original = new File([Uint8Array.from([7, 8, 9])], "screenshot.png");
const [file] = clipboardImageFiles(clipboard([item(original, "image/png")]));
expect(file.name).toBe("screenshot.png");
expect(file.type).toBe("image/png");
expect(new Uint8Array(await file.arrayBuffer())).toEqual(Uint8Array.from([7, 8, 9]));
});
});
describe("insertClipboardImageReferences", () => {
it("replaces a selected range when the prompt has not changed", () => {
expect(insertClipboardImageReferences("请修改这里的衣服", ["@图片1"], {
text: "请修改这里的衣服", start: 3, end: 5
})).toBe("请修改 @图片1 的衣服");
expect(insertClipboardImageReferences("请修改 衣服", ["图片2"], {
text: "请修改 衣服", start: 4, end: 5
})).toBe("请修改 @图片2 衣服");
});
it("appends to the latest prompt when the user edited it during upload", () => {
expect(insertClipboardImageReferences("原提示词又加了内容", ["@图片3"], {
text: "原提示词", start: 0, end: 4
})).toBe("原提示词又加了内容 @图片3");
});
it("deduplicates exact references without confusing image 1 and image 10", () => {
expect(insertClipboardImageReferences("看 @图片10", ["@图片1", "@图片1", "@图片10"]))
.toBe("看 @图片10 @图片1");
expect(insertClipboardImageReferences("看 @图片1", ["@图片1"]))
.toBe("看 @图片1");
});
it("does not erase selection text if all requested references already exist", () => {
expect(insertClipboardImageReferences("替换这里 @图片1", ["@图片1"], {
text: "替换这里 @图片1", start: 2, end: 4
})).toBe("替换这里 @图片1");
});
});
describe("clipboardImageURL", () => {
const asClipboard = (plain: string, html = "") => ({
getData: (type: string) => type === "text/plain" ? plain : html
}) as Pick<DataTransfer, "getData">;
it("preserves a single signed URL exactly after trimming, regardless of suffix", () => {
const signed = "https://images.example.test/asset.image?Expires=123&Signature=A%2FB%3D&key=abc";
expect(clipboardImageURL(asClipboard(` ${signed} `))).toBe(signed);
expect(clipboardImageURL(asClipboard("http://images.example.test/opaque?id=1")))
.toBe("http://images.example.test/opaque?id=1");
});
it("does not extract URLs from prose or accept more than one URL", () => {
expect(clipboardImageURL(asClipboard("请看 https://images.example.test/a.png"))).toBeNull();
expect(clipboardImageURL(asClipboard("https://images.example.test/a.png\nhttps://images.example.test/b.png"))).toBeNull();
expect(clipboardImageURL(asClipboard("https://images.example.test/a.png more"))).toBeNull();
});
it("ignores HTML and rejects non-HTTP schemes and userinfo", () => {
expect(clipboardImageURL(asClipboard("", '<img src="https://images.example.test/a.png">'))).toBeNull();
for (const value of [
"data:image/png;base64,AAAA", "file:///tmp/a.png", "javascript:alert(1)",
"https://user:pass@images.example.test/a.png", "https://@images.example.test/a.png",
"images.example.test/a.png"
]) expect(clipboardImageURL(asClipboard(value))).toBeNull();
expect(clipboardImageURL(null)).toBeNull();
});
it("rejects oversized URLs and embedded whitespace", () => {
expect(clipboardImageURL(asClipboard(`https://images.example.test/${"a".repeat(8192)}`))).toBeNull();
expect(clipboardImageURL(asClipboard("https://images.example.test/a\tb"))).toBeNull();
});
});
+61
View File
@@ -0,0 +1,61 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
const createStudioUrl = new URL("../components/create-studio.tsx", import.meta.url);
async function pasteFlowSource() {
const source = await readFile(createStudioUrl, "utf8");
const start = source.indexOf("function handleImagePaste(");
const end = source.indexOf("async function uploadTemplatePreview(", start);
expect(start).toBeGreaterThanOrEqual(0);
expect(end).toBeGreaterThan(start);
return { source, flow: source.slice(start, end) };
}
describe("create studio image paste wiring", () => {
it("handles paste in both the prompt and workspace, leaving text paste to the browser", async () => {
const { source, flow } = await pasteFlowSource();
expect(source).toContain("onPaste={handleImagePaste}");
expect(source).toContain("onPasteImage={handleImagePaste}");
expect(flow.indexOf("if (!files.length && !imageURL) return;")).toBeLessThan(flow.indexOf("event.preventDefault()"));
expect(flow).toContain("clipboardImageFiles(event.clipboardData)");
});
it("requires confirmation before downloading an entire URL and keeps canceled links as native text paste", async () => {
const { flow } = await pasteFlowSource();
expect(flow).toContain("files.length ? null : clipboardImageURL(event.clipboardData)");
const confirmation = flow.indexOf("if (imageURL && !window.confirm(");
expect(confirmation).toBeGreaterThanOrEqual(0);
expect(confirmation).toBeLessThan(flow.indexOf("event.preventDefault()"));
expect(flow).toContain('fetch("/api/assets/import-image"');
expect(flow).toContain('headers: { "Content-Type": "application/json" }');
expect(flow).toContain("receiveMaterialAssets(() => importImageURL(imageURL), options, 1)");
expect(flow).toContain("receiveMaterialAssets(() => uploadAssetFiles(selectedFiles), options, allFiles.length)");
expect(flow).toContain("new URL(imageURL).hostname");
expect(flow).toContain('asset.kind !== "image"');
expect(flow).toContain('options.importedURL && options.seedreamSourceMode === "single"');
});
it("blocks concurrent uploads and discards a stale result after a mode or target switch", async () => {
const { flow } = await pasteFlowSource();
expect(flow).toContain("if (uploadingRef.current || busy)");
expect(flow).toContain("uploadingRef.current = true");
expect(flow).toContain("uploadingRef.current = false");
expect(flow).toContain("options.paste.contextKey !== pasteContextRef.current");
expect(flow.indexOf("options.paste.contextKey !== pasteContextRef.current"))
.toBeLessThan(flow.indexOf("setMaterials(next)"));
});
it("routes special modes through existing source limits and adds references only for basic image or video", async () => {
const { flow } = await pasteFlowSource();
expect(flow).toContain('imageCreationMode === "interactive" ? "append" : "single"');
expect(flow).toContain("isSeedreamLayerInputFile(files[0])");
expect(flow).toContain("if (selectedSeedreamSource && !window.confirm(");
expect(flow).toContain("options.maxFiles = 1");
expect(flow).toContain("options.replaceAll = true");
expect(flow).toContain("options.maxFiles = remaining");
expect(flow).toContain("insertReferences: !specializedImageMode");
expect(flow).toContain('const labels = addedMaterials.map((material) => material.label || "");');
expect(flow).toContain("insertClipboardImageReferences(items[pasted.mode], labels, selection)");
});
});