fix(create): 素材参考 - 允许选择未缓存的远端素材
This commit is contained in:
@@ -5,6 +5,14 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
|
||||
# 待测试
|
||||
|
||||
## 创作参考内容的远端素材选择
|
||||
|
||||
- 已修复:创作页按素材类型与当前模式能力校验远端条目,不再把本地未缓存的素材误判为不适用;确认时使用弹窗返回的素材记录,仅按需缓存实际选中的远端素材。
|
||||
- 已修复:确认时重新校验媒体类型与视频模型能力,缺失或不支持的条目明确报错;保持现有参考数量上限。
|
||||
- [x] 新增远端素材回归测试,并与创作引用、素材库入口和画布素材选择测试一同运行,35 项通过;前端类型检查通过。
|
||||
- [ ] 在本地未缓存云端素材的浏览器中选择图片、视频和音频,确认支持的条目可选并能添加;跨页选择后确认应保留选择顺序,再次打开弹窗应保留已选状态。
|
||||
- [ ] 切换到图片创作及不同视频模型,确认不支持的媒体类型保持禁用,参考数量仍受模型上限约束;云端素材读取失败时保留弹窗并显示错误。
|
||||
|
||||
## 灵感页路由与导航
|
||||
|
||||
- 已修复:Nginx 对 `/inspirations` 与 `/inspirations/` 精确返回 SPA 入口,避免同名静态目录触发带容器端口的 301;目录下的 JSON 仍按静态文件提供。
|
||||
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const pickedRemoteRef = useRef(new Map<string, AssetLibraryPickerItem>());
|
||||
@@ -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;
|
||||
|
||||
32
web/src/pages/create/creation-library.ts
Normal file
32
web/src/pages/create/creation-library.ts
Normal file
@@ -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("所选素材已不存在,请重新选择");
|
||||
});
|
||||
}
|
||||
@@ -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<ReturnType<typeof loadCreationRuntime>>;
|
||||
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() {
|
||||
<CreationLoginDialog open={loginDialogOpen} onClose={() => setLoginDialogOpen(false)} />
|
||||
{libraryOpen ? <Suspense fallback={null}><AssetLibraryPickerModal
|
||||
remoteLibrary
|
||||
acceptRemoteItems
|
||||
remoteItemDisabledReason={(asset) => creationLibraryDisabledReason(mode, asset.kind, videoReferenceLimits)}
|
||||
open={libraryOpen}
|
||||
items={libraryItems}
|
||||
categoryLabels={{ ...creationAssetCategoryLabels, ...externalAssetSources.categoryLabels }}
|
||||
|
||||
74
web/test/creation-library.test.ts
Normal file
74
web/test/creation-library.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user