diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 22ddb9783..7f014ae6c 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,14 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 创作参考内容的远端素材选择 + +- 已修复:创作页按素材类型与当前模式能力校验远端条目,不再把本地未缓存的素材误判为不适用;确认时使用弹窗返回的素材记录,仅按需缓存实际选中的远端素材。 +- 已修复:确认时重新校验媒体类型与视频模型能力,缺失或不支持的条目明确报错;保持现有参考数量上限。 +- [x] 新增远端素材回归测试,并与创作引用、素材库入口和画布素材选择测试一同运行,35 项通过;前端类型检查通过。 +- [ ] 在本地未缓存云端素材的浏览器中选择图片、视频和音频,确认支持的条目可选并能添加;跨页选择后确认应保留选择顺序,再次打开弹窗应保留已选状态。 +- [ ] 切换到图片创作及不同视频模型,确认不支持的媒体类型保持禁用,参考数量仍受模型上限约束;云端素材读取失败时保留弹窗并显示错误。 + ## 灵感页路由与导航 - 已修复:Nginx 对 `/inspirations` 与 `/inspirations/` 精确返回 SPA 入口,避免同名静态目录触发带容器端口的 301;目录下的 JSON 仍按静态文件提供。 diff --git a/web/src/components/assets/asset-library-picker-modal.tsx b/web/src/components/assets/asset-library-picker-modal.tsx index 5982f5511..a244aa11f 100644 --- a/web/src/components/assets/asset-library-picker-modal.tsx +++ b/web/src/components/assets/asset-library-picker-modal.tsx @@ -54,6 +54,8 @@ type Props = { allowCharacters?: boolean; /** 调用方能处理 onConfirm 的 pickedItems 时设为 true:远端分页里不在本地缓存的素材也可选。 */ acceptRemoteItems?: boolean; + /** 远端条目不依赖本地缓存,仍需按调用方当前能力校验。 */ + remoteItemDisabledReason?: (asset: Asset) => string | undefined; /** 左侧「媒体类型」筛选项;只有一种类型或已由 remoteKind 固定时不展示该分组。 */ mediaKinds?: AssetPickerMediaKind[]; open: boolean; @@ -95,6 +97,7 @@ export function AssetLibraryPickerModal({ remoteKind, allowCharacters = false, acceptRemoteItems = false, + remoteItemDisabledReason, mediaKinds = DEFAULT_MEDIA_KINDS, open, items, @@ -166,20 +169,8 @@ export function AssetLibraryPickerModal({ () => (remoteQuery.data?.assets || []) .filter((asset) => asset.kind !== "model" && (asset.kind !== "entity" || allowCharacters)) - .map((asset) => { - const known = items.find((item) => item.id === asset.id); - return { - id: asset.id, - title: asset.title, - category: asset.category || "other", - archived: asset.status === "archived", - kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : asset.kind === "entity" ? "角色" : "文本", - searchText: (asset.tags ?? []).join(" "), - ...(known || (acceptRemoteItems ? {} : { disabledReason: "此素材不适用于当前操作" })), - asset, - }; - }), - [acceptRemoteItems, allowCharacters, remoteQuery.data, items], + .map((asset) => remoteAssetToPickerItem(asset, items.find((item) => item.id === asset.id), acceptRemoteItems, remoteItemDisabledReason?.(asset))), + [acceptRemoteItems, allowCharacters, remoteItemDisabledReason, remoteQuery.data, items], ); const uploadInputRef = useRef(null); const pickedRemoteRef = useRef(new Map()); @@ -648,6 +639,20 @@ export function AssetLibraryPickerModal({ ); } +export function remoteAssetToPickerItem(asset: Asset, known: AssetLibraryPickerItem | undefined, acceptRemoteItems: boolean, disabledReason?: string): AssetLibraryPickerItem { + return { + id: asset.id, + title: asset.title, + category: asset.category || "other", + archived: asset.status === "archived", + kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : asset.kind === "entity" ? "角色" : "文本", + searchText: (asset.tags ?? []).join(" "), + ...known, + disabledReason: known?.disabledReason || (!known && !acceptRemoteItems ? "此素材不适用于当前操作" : disabledReason), + asset, + }; +} + // 角色卡、3D 模型等非媒体条目没有媒体类型,媒体筛选生效时不参与匹配。 export function pickerItemMediaKind(item: AssetLibraryPickerItem): AssetPickerMediaKind | undefined { if (item.mediaKind) return item.mediaKind; diff --git a/web/src/pages/create/creation-library.ts b/web/src/pages/create/creation-library.ts new file mode 100644 index 000000000..478944631 --- /dev/null +++ b/web/src/pages/create/creation-library.ts @@ -0,0 +1,32 @@ +import type { AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal"; +import type { CreationMode } from "@/lib/creation-mode"; +import type { CreationReferenceLimits } from "./creation-references"; +import { creationAttachmentFromAsset, creationAttachmentFromAudioAsset, creationAttachmentFromExternalAsset, creationAttachmentFromVideoAsset, type CreationAttachment } from "./creation-assets"; + +export function creationLibraryDisabledReason(mode: CreationMode, kind: string | undefined, videoLimits?: CreationReferenceLimits) { + if (kind !== "image" && kind !== "video" && kind !== "audio") return "此类型素材暂不支持作为创作参考"; + if (mode === "image") return kind === "image" ? undefined : "图片创作仅支持参考图"; + if (mode !== "video") return undefined; + + const maximum = kind === "image" ? videoLimits?.maxImages : kind === "video" ? videoLimits?.maxVideos : videoLimits?.maxAudios; + if ((maximum || 0) > 0) return undefined; + const label = kind === "video" ? "视频" : kind === "audio" ? "音频" : "图片"; + return `当前视频模型不支持参考${label}`; +} + +export function creationLibrarySelectionToAttachments(ids: string[], items: AssetLibraryPickerItem[], mode: CreationMode, videoLimits?: CreationReferenceLimits): CreationAttachment[] { + const itemsById = new Map(items.map((item) => [item.id, item])); + return ids.map((id) => { + const item = itemsById.get(id); + if (!item) throw new Error("所选素材已不存在,请重新选择"); + const kind = item.external?.item.kind || item.asset?.kind; + const disabledReason = item.disabledReason || creationLibraryDisabledReason(mode, kind, videoLimits); + if (disabledReason) throw new Error(`“${item.title}”:${disabledReason}`); + if (item.external) return creationAttachmentFromExternalAsset(item.external); + const asset = item.asset; + if (asset?.kind === "image") return creationAttachmentFromAsset(asset); + if (asset?.kind === "video") return creationAttachmentFromVideoAsset(asset); + if (asset?.kind === "audio") return creationAttachmentFromAudioAsset(asset); + throw new Error("所选素材已不存在,请重新选择"); + }); +} diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index 3de406922..a7eff4fd4 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -20,6 +20,7 @@ import { loadCreationConversations, pendingCreationTaskIds, removeCreationConver import { resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { useCreationPreferencesStore } from "@/stores/use-creation-preferences-store"; import { useAssetStore, type Asset } from "@/stores/use-asset-store"; +import { loadAssetsForUse } from "@/services/user-data-sync"; import { useAppearanceStore } from "@/stores/use-appearance-store"; import { cn } from "@/lib/utils"; import { useUserStore } from "@/stores/use-user-store"; @@ -27,8 +28,9 @@ import type { PromptOptimizerProvider } from "@/lib/plugins/plugin-types"; import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer"; import { createPluginHostContext } from "@/services/plugin-host"; import { usePluginStore } from "@/stores/use-plugin-store"; -import { buildCreationMentionReferences, expandCreationPrompt, reconcileCreationAttachmentLimit, reconcileCreationAttachmentLimits, removeCreationReferenceTokens, replaceCreationAttachmentReference, selectedCreationReferences, type CreationReference, type CreationReferenceLimits } from "./creation-references"; -import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromExternalAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, removeCreationAttachment, splitCreationAttachments, type CreationAttachment } from "./creation-assets"; +import { buildCreationMentionReferences, expandCreationPrompt, reconcileCreationAttachmentLimit, reconcileCreationAttachmentLimits, removeCreationReferenceTokens, replaceCreationAttachmentReference, selectedCreationReferences, type CreationReference } from "./creation-references"; +import { creationAttachmentFromAudio, creationAttachmentFromDocument, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, removeCreationAttachment, splitCreationAttachments, type CreationAttachment } from "./creation-assets"; +import { creationLibraryDisabledReason, creationLibrarySelectionToAttachments } from "./creation-library"; import { defaultCreationMode, modeLabels, type CreationConversation, type CreationMessage, type CreationMode, type CreationRetryContext, type CreationSettings, type CreationShotRailEntry, type CreationStatus } from "./creation-types"; import { attachCreationTaskContexts, completedCreationGenerationTask, conversationTimestamp, creationShotRail, creationVideoShotOrdinal, isImageAttachment, isVideoAttachment, materializeCreationTaskResults, newConversation, newMessage, reconcileCreationTaskMessages } from "./creation-conversations"; import { CreationComposer, CreationEmptySuggest, CreationHistoryDrawer, CreationMessageView, CreationModeTabs, CreationWorkspaceToolbar, creationAssetCategoryLabels } from "./creation-workspace"; @@ -46,17 +48,6 @@ type CreationRuntime = Awaited>; const TEXT_STREAMING_PREF_KEY = "creation.composer.text-streaming"; const TEXT_THINKING_PREF_KEY = "creation.composer.text-thinking"; -function creationLibraryDisabledReason(mode: CreationMode, kind: string | undefined, videoLimits?: CreationReferenceLimits) { - if (!kind) return undefined; - if (mode === "image") return kind === "image" ? undefined : "图片创作仅支持参考图"; - if (mode !== "video") return undefined; - - const maximum = kind === "image" ? videoLimits?.maxImages : kind === "video" ? videoLimits?.maxVideos : kind === "audio" ? videoLimits?.maxAudios : 0; - if ((maximum || 0) > 0) return undefined; - const label = kind === "video" ? "视频" : kind === "audio" ? "音频" : kind === "image" ? "图片" : "此类素材"; - return `当前视频模型不支持参考${label}`; -} - function readComposerPref(key: string, fallback: boolean): boolean { try { const stored = window.localStorage.getItem(key); @@ -493,16 +484,13 @@ export default function CreatePage() { return assetIds; }; - const handleLibrarySelect = (selectedIds: string[]) => { - const next = selectedIds.flatMap((id): CreationAttachment[] => { - const asset = assets.find((item) => item.id === id); - if (asset?.kind === "image") return [creationAttachmentFromAsset(asset)]; - if (asset?.kind === "video" && mode !== "image") return [creationAttachmentFromVideoAsset(asset)]; - if (asset?.kind === "audio" && mode !== "image") return [creationAttachmentFromAudioAsset(asset)]; - const external = libraryItems.find((item) => item.id === id)?.external; - return external ? [creationAttachmentFromExternalAsset(external)] : []; - }); + const handleLibrarySelect = async (selectedIds: string[], pickedItems = libraryItems) => { + const next = creationLibrarySelectionToAttachments(selectedIds, pickedItems, mode, videoReferenceLimits); if (!next.length) return; + const selected = new Set(selectedIds); + const cachedIds = new Set(useAssetStore.getState().assets.map((asset) => asset.id)); + const uncachedAssetIds = pickedItems.filter((item) => item.asset && selected.has(item.id) && !cachedIds.has(item.id)).map((item) => item.id); + if (uncachedAssetIds.length) await loadAssetsForUse(uncachedAssetIds); setAttachments((current) => { const candidates = [...current.filter((item) => !next.some((candidate) => candidate.id === item.id)), ...next]; const reconciled = mode === "video" && videoReferenceLimits @@ -1123,6 +1111,8 @@ export default function CreatePage() { setLoginDialogOpen(false)} /> {libraryOpen ? creationLibraryDisabledReason(mode, asset.kind, videoReferenceLimits)} open={libraryOpen} items={libraryItems} categoryLabels={{ ...creationAssetCategoryLabels, ...externalAssetSources.categoryLabels }} diff --git a/web/test/creation-library.test.ts b/web/test/creation-library.test.ts new file mode 100644 index 000000000..cd53ca835 --- /dev/null +++ b/web/test/creation-library.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; +import { remoteAssetToPickerItem, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal"; +import { creationLibraryDisabledReason, creationLibrarySelectionToAttachments } from "@/pages/create/creation-library"; +import type { Asset } from "@/stores/use-asset-store"; + +const base = { title: "远端素材", coverUrl: "", tags: [], createdAt: "2026-10-01T00:00:00Z", updatedAt: "2026-10-01T00:00:00Z" }; +const image: Asset = { ...base, id: "remote-image", kind: "image", data: { dataUrl: "https://example.com/image.png", width: 1280, height: 720, bytes: 1024, mimeType: "image/png" } }; +const video: Asset = { ...base, id: "remote-video", kind: "video", data: { url: "https://example.com/video.mp4", storageKey: "resource:video", width: 1280, height: 720, durationMs: 1000, bytes: 2048, mimeType: "video/mp4" } }; +const audio: Asset = { ...base, id: "remote-audio", kind: "audio", data: { url: "https://example.com/audio.mp3", storageKey: "resource:audio", durationMs: 1000, bytes: 512, mimeType: "audio/mpeg" } }; + +function remoteItem(asset: Asset, mode: "text" | "image" | "video" = "text") { + return remoteAssetToPickerItem(asset, undefined, true, creationLibraryDisabledReason(mode, asset.kind)); +} + +describe("creation remote library", () => { + test("uncached remote media can be selected and converted without a local asset record", () => { + const items = [remoteItem(image), remoteItem(video), remoteItem(audio)]; + expect(items.every((item) => !item.disabledReason)).toBe(true); + const attachments = creationLibrarySelectionToAttachments([audio.id, image.id, video.id], items, "text"); + expect(attachments.map((item) => item.id)).toEqual([`asset:${audio.id}`, `asset:${image.id}`, `asset:${video.id}`]); + expect(attachments.map((item) => item.type)).toEqual(["audio/mpeg", "image/png", "video/mp4"]); + expect(attachments[0].url).toBe(audio.data.url); + expect(attachments[1].url).toBe(image.data.dataUrl); + expect(attachments[2].storageKey).toBe(video.data.storageKey); + }); + + test("unselected items from other pages are not added to the references", () => { + const attachments = creationLibrarySelectionToAttachments([video.id, image.id], [remoteItem(image), remoteItem(audio), remoteItem(video)], "text"); + expect(attachments.map((item) => item.id)).toEqual([`asset:${video.id}`, `asset:${image.id}`]); + }); + + test("other callers still reject uncached assets unless they accept remote records", () => { + expect(remoteAssetToPickerItem(image, undefined, false).disabledReason).toBe("此素材不适用于当前操作"); + const known = { ...remoteItem(image), disabledReason: "素材不可用" }; + expect(remoteAssetToPickerItem(image, known, true).disabledReason).toBe("素材不可用"); + }); + + test("image creation applies the same media restrictions before selection and on confirmation", () => { + expect(remoteItem(image, "image").disabledReason).toBeUndefined(); + expect(remoteItem(video, "image").disabledReason).toBe("图片创作仅支持参考图"); + expect(remoteItem(audio, "image").disabledReason).toBe("图片创作仅支持参考图"); + expect(() => creationLibrarySelectionToAttachments([image.id, video.id], [remoteItem(image), remoteItem(video)], "image")).toThrow("图片创作仅支持参考图"); + }); + + test("video creation revalidates changed model capabilities on confirmation", () => { + const limits = { maxImages: 1, maxVideos: 1, maxAudios: 0 }; + expect(creationLibraryDisabledReason("video", "image", limits)).toBeUndefined(); + expect(creationLibraryDisabledReason("video", "video", limits)).toBeUndefined(); + expect(remoteAssetToPickerItem(audio, undefined, true, creationLibraryDisabledReason("video", "audio", limits)).disabledReason).toBe("当前视频模型不支持参考音频"); + expect(() => creationLibrarySelectionToAttachments([audio.id], [remoteItem(audio)], "video", limits)).toThrow("当前视频模型不支持参考音频"); + expect(creationLibrarySelectionToAttachments([video.id], [remoteItem(video)], "video", limits)[0].id).toBe(`asset:${video.id}`); + }); + + test("missing, disabled and unsupported records fail explicitly", () => { + expect(() => creationLibrarySelectionToAttachments([image.id, "missing"], [remoteItem(image)], "text")).toThrow("所选素材已不存在"); + expect(() => creationLibrarySelectionToAttachments([image.id], [{ ...remoteItem(image), disabledReason: "素材不可用" }], "text")).toThrow("素材不可用"); + const text: Asset = { ...base, id: "remote-text", kind: "text", data: { content: "参考文本" } }; + expect(remoteItem(text).disabledReason).toBe("此类型素材暂不支持作为创作参考"); + expect(() => creationLibrarySelectionToAttachments([text.id], [remoteAssetToPickerItem(text, undefined, true)], "text")).toThrow("此类型素材暂不支持作为创作参考"); + }); + + test("plugin assets are still converted alongside remote library assets", () => { + const external: AssetLibraryPickerItem = { + id: "external:eagle:image", + title: "Eagle 图片", + category: "external:eagle", + kindLabel: "图片", + external: { sourceId: "eagle", sourceName: "Eagle", item: { id: "image", title: "Eagle 图片", kind: "image", fileUrl: "https://example.com/external.png" } }, + }; + const attachments = creationLibrarySelectionToAttachments([external.id, image.id], [remoteItem(image), external], "text"); + expect(attachments.map((item) => item.id)).toEqual([external.id, `asset:${image.id}`]); + expect(attachments[0].url).toBe("https://example.com/external.png"); + }); +});