diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index 93cb2ac26..81b00a32a 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -255,6 +255,20 @@ export default function CreatePage() { }; const maxReferences = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : mode === "image" ? imageProfile.references.maxImages : 6; + const uploadCreationAsset = async (file: File) => { + if (file.type.startsWith("video/")) { + const uploaded = await uploadMediaFile(file, "create-upload"); + return { + asset: creationVideoAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }), + attachment: creationAttachmentFromVideo(file, uploaded), + }; + } + const uploaded = await uploadImage(file); + return { + asset: creationImageAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }), + attachment: creationAttachmentFromImage(file, uploaded), + }; + }; const addAttachments = (files: FileList | File[]) => { if ((mode === "image" || mode === "video") && maxReferences === 0) { toast.warning(mode === "image" ? "当前图片模型不支持参考图" : "当前模型不支持图生视频"); @@ -265,14 +279,9 @@ export default function CreatePage() { .slice(0, Math.max(0, maxReferences - attachments.length)); if (!next.length) return; void Promise.allSettled(next.map(async (file) => { - if (file.type.startsWith("video/")) { - const uploaded = await uploadMediaFile(file, "create-upload"); - addAsset(creationVideoAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } })); - return creationAttachmentFromVideo(file, uploaded); - } - const uploaded = await uploadImage(file); - addAsset(creationImageAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } })); - return creationAttachmentFromImage(file, uploaded); + const { asset, attachment } = await uploadCreationAsset(file); + addAsset(asset); + return attachment; })).then((settled) => { const items = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []); const failed = settled.filter((entry) => entry.status === "rejected"); @@ -281,6 +290,19 @@ export default function CreatePage() { }); }; + const uploadLibraryAssets = async (files: FileList | File[]) => { + const next = Array.from(files).filter((file) => file.type.startsWith("image/") || (mode === "video" && file.type.startsWith("video/"))); + if (!next.length) return []; + const settled = await Promise.allSettled(next.map(async (file) => { + const { asset } = await uploadCreationAsset(file); + return addAsset(asset); + })); + const assetIds = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []); + const failed = settled.filter((entry) => entry.status === "rejected"); + if (failed.length) toast.error(`${failed.length} 个素材上传失败,请重试`); + return assetIds; + }; + const handleFileChange = (event: ChangeEvent) => { if (event.target.files) addAttachments(event.target.files); event.target.value = ""; @@ -558,16 +580,18 @@ export default function CreatePage() { setHistoryOpen(false)} onSelect={selectConversation} /> - item.id.startsWith("asset:")).map((item) => item.id.slice(6)))} onClose={() => setLibraryOpen(false)} onConfirm={handleLibrarySelect} onUpload={() => fileInputRef.current?.click()} /> + item.id.startsWith("asset:")).map((item) => item.id.slice(6)))} onClose={() => setLibraryOpen(false)} onConfirm={handleLibrarySelect} onUpload={uploadLibraryAssets} /> ; } const creationAssetCategoryLabels: Record = { all: "全部素材", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" }; -function CreationAssetLibraryModal({ open, assets, mode, selectedIds, onClose, onConfirm, onUpload }: { open: boolean; assets: Asset[]; mode: CreationMode; selectedIds: Set; onClose: () => void; onConfirm: (assets: Asset[]) => void; onUpload: () => void }) { +function CreationAssetLibraryModal({ open, assets, mode, selectedIds, onClose, onConfirm, onUpload }: { open: boolean; assets: Asset[]; mode: CreationMode; selectedIds: Set; onClose: () => void; onConfirm: (assets: Asset[]) => void; onUpload: (files: FileList | File[]) => Promise }) { const [category, setCategory] = useState("all"); const [keyword, setKeyword] = useState(""); const [selected, setSelected] = useState>(new Set()); + const [uploading, setUploading] = useState(false); + const uploadInputRef = useRef(null); const mediaAssets = useMemo(() => assets.filter((asset): asset is Extract => asset.kind === "image" || asset.kind === "video"), [assets]); const categories = useMemo(() => ["all", ...Array.from(new Set(mediaAssets.map((asset) => asset.category || "other")))], [mediaAssets]); const visibleAssets = useMemo(() => { @@ -590,6 +614,17 @@ function CreationAssetLibraryModal({ open, assets, mode, selectedIds, onClose, o return next; }); }; + const handleUpload = async (event: ChangeEvent) => { + if (!event.target.files || uploading) return; + setUploading(true); + try { + const assetIds = await onUpload(event.target.files); + if (assetIds.length) setSelected((current) => new Set([...current, ...assetIds])); + } finally { + event.target.value = ""; + setUploading(false); + } + }; const selectedAssets = mediaAssets.filter((asset) => selected.has(asset.id) && (mode === "video" || asset.kind === "image")); const count = category === "all" ? mediaAssets.length : mediaAssets.filter((asset) => (asset.category || "other") === category).length; @@ -600,7 +635,7 @@ function CreationAssetLibraryModal({ open, assets, mode, selectedIds, onClose, o
{visibleAssets.length ? visibleAssets.map((asset) => toggle(asset)} />) :
这个分类还没有素材换个分类,或从底部上传一份新素材。
}
-
+
; } @@ -755,6 +790,7 @@ function CreationComposer(props: ComposerProps) { onTranscribed={(text) => props.setPrompt(props.prompt.trim() ? `${props.prompt} ${text}` : text)} /> + {props.mode === "video" || (props.mode === "image" && imageSettingsSupported) ? : null} {props.mode === "video" ? : null} diff --git a/web/test/create-library-button.test.ts b/web/test/create-library-button.test.ts new file mode 100644 index 000000000..90164b37e --- /dev/null +++ b/web/test/create-library-button.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +describe("creation library button", () => { + test("places a library control beside the generation mode picker", () => { + const source = readFileSync(resolve(import.meta.dir, "../src/pages/create/index.tsx"), "utf8"); + const modePickerIndex = source.indexOf(""); + const libraryButtonIndex = source.indexOf('className="creation-chat-control" onClick={props.onOpenLibrary} disabled={props.busy || !referencesSupported} aria-label="打开素材库选择参考内容"'); + + expect(modePickerIndex).toBeGreaterThanOrEqual(0); + expect(libraryButtonIndex).toBeGreaterThan(modePickerIndex); + expect(source.slice(libraryButtonIndex, libraryButtonIndex + 180)).toContain(""); + expect(source.slice(libraryButtonIndex, libraryButtonIndex + 180)).toContain("素材库"); + }); + + test("uploads from the library without adding a reference before confirmation", () => { + const source = readFileSync(resolve(import.meta.dir, "../src/pages/create/index.tsx"), "utf8"); + const uploadStart = source.indexOf("const uploadLibraryAssets = async"); + const uploadEnd = source.indexOf("const handleFileChange", uploadStart); + + expect(uploadStart).toBeGreaterThanOrEqual(0); + expect(uploadEnd).toBeGreaterThan(uploadStart); + expect(source.slice(uploadStart, uploadEnd)).not.toContain("setAttachments"); + expect(source).toContain("onUpload={uploadLibraryAssets}"); + expect(source).not.toContain("onUpload={() => fileInputRef.current?.click()}"); + }); +});