fix(create): 素材参考 - 允许选择未缓存的远端素材

This commit is contained in:
MasterChiefCN
2026-10-12 09:56:11 +08:00
parent 27c8066e6d
commit ec504e031c
5 changed files with 145 additions and 36 deletions

View File

@@ -5,6 +5,14 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
# 待测试 # 待测试
## 创作参考内容的远端素材选择
- 已修复:创作页按素材类型与当前模式能力校验远端条目,不再把本地未缓存的素材误判为不适用;确认时使用弹窗返回的素材记录,仅按需缓存实际选中的远端素材。
- 已修复:确认时重新校验媒体类型与视频模型能力,缺失或不支持的条目明确报错;保持现有参考数量上限。
- [x] 新增远端素材回归测试,并与创作引用、素材库入口和画布素材选择测试一同运行,35 项通过;前端类型检查通过。
- [ ] 在本地未缓存云端素材的浏览器中选择图片、视频和音频,确认支持的条目可选并能添加;跨页选择后确认应保留选择顺序,再次打开弹窗应保留已选状态。
- [ ] 切换到图片创作及不同视频模型,确认不支持的媒体类型保持禁用,参考数量仍受模型上限约束;云端素材读取失败时保留弹窗并显示错误。
## 灵感页路由与导航 ## 灵感页路由与导航
- 已修复:Nginx 对 `/inspirations` 与 `/inspirations/` 精确返回 SPA 入口,避免同名静态目录触发带容器端口的 301;目录下的 JSON 仍按静态文件提供。 - 已修复:Nginx 对 `/inspirations` 与 `/inspirations/` 精确返回 SPA 入口,避免同名静态目录触发带容器端口的 301;目录下的 JSON 仍按静态文件提供。

View File

@@ -54,6 +54,8 @@ type Props = {
allowCharacters?: boolean; allowCharacters?: boolean;
/** 调用方能处理 onConfirm 的 pickedItems 时设为 true:远端分页里不在本地缓存的素材也可选。 */ /** 调用方能处理 onConfirm 的 pickedItems 时设为 true:远端分页里不在本地缓存的素材也可选。 */
acceptRemoteItems?: boolean; acceptRemoteItems?: boolean;
/** 远端条目不依赖本地缓存,仍需按调用方当前能力校验。 */
remoteItemDisabledReason?: (asset: Asset) => string | undefined;
/** 左侧「媒体类型」筛选项;只有一种类型或已由 remoteKind 固定时不展示该分组。 */ /** 左侧「媒体类型」筛选项;只有一种类型或已由 remoteKind 固定时不展示该分组。 */
mediaKinds?: AssetPickerMediaKind[]; mediaKinds?: AssetPickerMediaKind[];
open: boolean; open: boolean;
@@ -95,6 +97,7 @@ export function AssetLibraryPickerModal({
remoteKind, remoteKind,
allowCharacters = false, allowCharacters = false,
acceptRemoteItems = false, acceptRemoteItems = false,
remoteItemDisabledReason,
mediaKinds = DEFAULT_MEDIA_KINDS, mediaKinds = DEFAULT_MEDIA_KINDS,
open, open,
items, items,
@@ -166,20 +169,8 @@ export function AssetLibraryPickerModal({
() => () =>
(remoteQuery.data?.assets || []) (remoteQuery.data?.assets || [])
.filter((asset) => asset.kind !== "model" && (asset.kind !== "entity" || allowCharacters)) .filter((asset) => asset.kind !== "model" && (asset.kind !== "entity" || allowCharacters))
.map((asset) => { .map((asset) => remoteAssetToPickerItem(asset, items.find((item) => item.id === asset.id), acceptRemoteItems, remoteItemDisabledReason?.(asset))),
const known = items.find((item) => item.id === asset.id); [acceptRemoteItems, allowCharacters, remoteItemDisabledReason, remoteQuery.data, items],
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],
); );
const uploadInputRef = useRef<HTMLInputElement>(null); const uploadInputRef = useRef<HTMLInputElement>(null);
const pickedRemoteRef = useRef(new Map<string, AssetLibraryPickerItem>()); 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 模型等非媒体条目没有媒体类型,媒体筛选生效时不参与匹配。 // 角色卡、3D 模型等非媒体条目没有媒体类型,媒体筛选生效时不参与匹配。
export function pickerItemMediaKind(item: AssetLibraryPickerItem): AssetPickerMediaKind | undefined { export function pickerItemMediaKind(item: AssetLibraryPickerItem): AssetPickerMediaKind | undefined {
if (item.mediaKind) return item.mediaKind; if (item.mediaKind) return item.mediaKind;

View 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("所选素材已不存在,请重新选择");
});
}

View File

@@ -20,6 +20,7 @@ import { loadCreationConversations, pendingCreationTaskIds, removeCreationConver
import { resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { useCreationPreferencesStore } from "@/stores/use-creation-preferences-store"; import { useCreationPreferencesStore } from "@/stores/use-creation-preferences-store";
import { useAssetStore, type Asset } from "@/stores/use-asset-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 { useAppearanceStore } from "@/stores/use-appearance-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useUserStore } from "@/stores/use-user-store"; 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 { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer";
import { createPluginHostContext } from "@/services/plugin-host"; import { createPluginHostContext } from "@/services/plugin-host";
import { usePluginStore } from "@/stores/use-plugin-store"; import { usePluginStore } from "@/stores/use-plugin-store";
import { buildCreationMentionReferences, expandCreationPrompt, reconcileCreationAttachmentLimit, reconcileCreationAttachmentLimits, removeCreationReferenceTokens, replaceCreationAttachmentReference, selectedCreationReferences, type CreationReference, type CreationReferenceLimits } from "./creation-references"; import { buildCreationMentionReferences, expandCreationPrompt, reconcileCreationAttachmentLimit, reconcileCreationAttachmentLimits, removeCreationReferenceTokens, replaceCreationAttachmentReference, selectedCreationReferences, type CreationReference } 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 { 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 { 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 { 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"; 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_STREAMING_PREF_KEY = "creation.composer.text-streaming";
const TEXT_THINKING_PREF_KEY = "creation.composer.text-thinking"; 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 { function readComposerPref(key: string, fallback: boolean): boolean {
try { try {
const stored = window.localStorage.getItem(key); const stored = window.localStorage.getItem(key);
@@ -493,16 +484,13 @@ export default function CreatePage() {
return assetIds; return assetIds;
}; };
const handleLibrarySelect = (selectedIds: string[]) => { const handleLibrarySelect = async (selectedIds: string[], pickedItems = libraryItems) => {
const next = selectedIds.flatMap((id): CreationAttachment[] => { const next = creationLibrarySelectionToAttachments(selectedIds, pickedItems, mode, videoReferenceLimits);
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)] : [];
});
if (!next.length) return; 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) => { setAttachments((current) => {
const candidates = [...current.filter((item) => !next.some((candidate) => candidate.id === item.id)), ...next]; const candidates = [...current.filter((item) => !next.some((candidate) => candidate.id === item.id)), ...next];
const reconciled = mode === "video" && videoReferenceLimits const reconciled = mode === "video" && videoReferenceLimits
@@ -1123,6 +1111,8 @@ export default function CreatePage() {
<CreationLoginDialog open={loginDialogOpen} onClose={() => setLoginDialogOpen(false)} /> <CreationLoginDialog open={loginDialogOpen} onClose={() => setLoginDialogOpen(false)} />
{libraryOpen ? <Suspense fallback={null}><AssetLibraryPickerModal {libraryOpen ? <Suspense fallback={null}><AssetLibraryPickerModal
remoteLibrary remoteLibrary
acceptRemoteItems
remoteItemDisabledReason={(asset) => creationLibraryDisabledReason(mode, asset.kind, videoReferenceLimits)}
open={libraryOpen} open={libraryOpen}
items={libraryItems} items={libraryItems}
categoryLabels={{ ...creationAssetCategoryLabels, ...externalAssetSources.categoryLabels }} categoryLabels={{ ...creationAssetCategoryLabels, ...externalAssetSources.categoryLabels }}

View 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");
});
});