feat(assets): 素材选择器 - 支持按媒体类型筛选并覆盖远程项目资产
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -14,12 +14,20 @@ import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types";
|
||||
import { flushAssetStorePersistence, useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
import { deleteAssetWithRemoteSync, loadAssetLibraryPage, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
|
||||
export type AssetPickerMediaKind = "image" | "video" | "audio" | "text";
|
||||
|
||||
export const ASSET_PICKER_MEDIA_KIND_LABELS: Record<AssetPickerMediaKind, string> = { image: "图片", video: "视频", audio: "音频", text: "文本" };
|
||||
|
||||
const DEFAULT_MEDIA_KINDS: AssetPickerMediaKind[] = ["image", "video", "audio"];
|
||||
|
||||
export type AssetLibraryPickerItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
archived?: boolean;
|
||||
kindLabel: string;
|
||||
/** 媒体类型筛选依据;缺省时按本地素材或插件素材的 kind 推断。 */
|
||||
mediaKind?: AssetPickerMediaKind;
|
||||
asset?: Asset;
|
||||
imageUrl?: string;
|
||||
imageStorageKey?: string;
|
||||
@@ -40,6 +48,8 @@ export type AssetLibraryPickerFolder = {
|
||||
type Props = {
|
||||
remoteLibrary?: boolean;
|
||||
remoteKind?: string;
|
||||
/** 左侧「媒体类型」筛选项;只有一种类型或已由 remoteKind 固定时不展示该分组。 */
|
||||
mediaKinds?: AssetPickerMediaKind[];
|
||||
open: boolean;
|
||||
items: AssetLibraryPickerItem[];
|
||||
categoryLabels: Record<string, string>;
|
||||
@@ -76,6 +86,7 @@ type Props = {
|
||||
export function AssetLibraryPickerModal({
|
||||
remoteLibrary = false,
|
||||
remoteKind,
|
||||
mediaKinds = DEFAULT_MEDIA_KINDS,
|
||||
open,
|
||||
items,
|
||||
categoryLabels,
|
||||
@@ -101,6 +112,7 @@ export function AssetLibraryPickerModal({
|
||||
}: Props) {
|
||||
const { message } = App.useApp();
|
||||
const [category, setCategory] = useState(initialCategory);
|
||||
const [mediaKind, setMediaKind] = useState<AssetPickerMediaKind | "all">("all");
|
||||
const [folderId, setFolderId] = useState(initialFolderId);
|
||||
const [source, setSource] = useState<"local" | "plugin">("local");
|
||||
const [sourceMenuOpen, setSourceMenuOpen] = useState(false);
|
||||
@@ -120,10 +132,12 @@ export function AssetLibraryPickerModal({
|
||||
const timer = window.setTimeout(() => setRemoteKeyword(keyword.trim()), 250);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [keyword]);
|
||||
useEffect(() => setRemotePage(1), [category, remoteKeyword, open]);
|
||||
useEffect(() => setRemotePage(1), [category, mediaKind, remoteKeyword, open]);
|
||||
// remoteKind 是调用方写死的能力约束;媒体类型筛选只在没有该约束时参与服务端查询。
|
||||
const remoteQueryKind = remoteKind || (mediaKind === "all" ? undefined : mediaKind);
|
||||
const remoteQuery = useQuery({
|
||||
queryKey: ["asset-picker", userId, remotePage, remotePageSize, category, remoteKeyword, remoteKind],
|
||||
queryFn: ({ signal }) => loadAssetLibraryPage({ page: remotePage, pageSize: remotePageSize, kind: remoteKind, category: category === "all" || category === "archived" || category === remoteKind ? undefined : category, status: category === "archived" ? "archived" : "active", query: remoteKeyword, signal }),
|
||||
queryKey: ["asset-picker", userId, remotePage, remotePageSize, category, remoteKeyword, remoteQueryKind],
|
||||
queryFn: ({ signal }) => loadAssetLibraryPage({ page: remotePage, pageSize: remotePageSize, kind: remoteQueryKind, category: category === "all" || category === "archived" || category === remoteQueryKind ? undefined : category, status: category === "archived" ? "archived" : "active", query: remoteKeyword, signal }),
|
||||
enabled: remoteEnabled && open && sessionHydrated,
|
||||
});
|
||||
const remoteItems = useMemo<AssetLibraryPickerItem[]>(() => (remoteQuery.data?.assets || []).filter((asset) => asset.kind !== "entity" && asset.kind !== "model").map((asset) => ({
|
||||
@@ -144,9 +158,15 @@ export function AssetLibraryPickerModal({
|
||||
const localItems = useMemo(() => allItems.filter((item) => !item.external), [allItems]);
|
||||
const pluginItems = useMemo(() => allItems.filter((item) => Boolean(item.external)), [allItems]);
|
||||
const hasPluginSource = useMemo(() => Object.keys(categoryLabels).some((value) => value.startsWith("external:")) || pluginItems.some((item) => item.category.startsWith("external:")), [categoryLabels, pluginItems]);
|
||||
const sourceItems = source === "plugin" ? pluginItems : remoteEnabled ? remoteItems : localItems;
|
||||
// 媒体类型在分类之前收窄数据源,让左侧分类计数、网格和分页始终描述同一批素材。
|
||||
const sourceItems = useMemo(() => {
|
||||
const base = source === "plugin" ? pluginItems : remoteEnabled ? remoteItems : localItems;
|
||||
if (mediaKind === "all") return base;
|
||||
return base.filter((item) => pickerItemMediaKind(item) === mediaKind);
|
||||
}, [localItems, mediaKind, pluginItems, remoteEnabled, remoteItems, source]);
|
||||
const activeSourceItems = useMemo(() => sourceItems.filter((item) => !item.archived), [sourceItems]);
|
||||
const archivedItems = useMemo(() => sourceItems.filter((item) => item.archived), [sourceItems]);
|
||||
const mediaKindOptions = useMemo(() => (remoteKind ? [] : Array.from(new Set(mediaKinds))), [mediaKinds, remoteKind]);
|
||||
const sourceFolders = source === "plugin" ? folders : [];
|
||||
const showCategories = source === "local" || !sourceFolders.length;
|
||||
const normalCategories = useMemo(() => remoteEnabled ? Object.keys(categoryLabels).filter((value) => value !== "archived" && !value.startsWith("external:")) : ["all", ...Array.from(new Set(activeSourceItems.map((item) => item.category || "other"))).filter((value) => value !== "all")], [activeSourceItems, categoryLabels, remoteEnabled]);
|
||||
@@ -180,6 +200,7 @@ export function AssetLibraryPickerModal({
|
||||
|
||||
setFolderId(initialFolderId);
|
||||
setCategory(initialCategory);
|
||||
setMediaKind("all");
|
||||
setSource("local");
|
||||
setKeyword("");
|
||||
setUploadedItems([]);
|
||||
@@ -200,6 +221,11 @@ export function AssetLibraryPickerModal({
|
||||
setSource("local");
|
||||
}, [hasPluginSource, source]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mediaKind === "all" || mediaKindOptions.includes(mediaKind)) return;
|
||||
setMediaKind("all");
|
||||
}, [mediaKind, mediaKindOptions]);
|
||||
|
||||
const selectSource = (nextSource: "local" | "plugin") => {
|
||||
if (nextSource === "plugin" && !hasPluginSource) return;
|
||||
setSource(nextSource);
|
||||
@@ -404,6 +430,16 @@ export function AssetLibraryPickerModal({
|
||||
</header>
|
||||
<div className="asset-picker-body">
|
||||
<nav className="asset-picker-categories" aria-label="素材分类">
|
||||
{mediaKindOptions.length > 1 && !isRecycleBin ? (
|
||||
<>
|
||||
<span className="asset-picker-nav-label">媒体类型</span>
|
||||
{(["all", ...mediaKindOptions] as const).map((value) => (
|
||||
<button key={value} type="button" className={cn("assets-filter-item", mediaKind === value && "is-active")} aria-pressed={mediaKind === value} onClick={() => setMediaKind(value)}>
|
||||
<span className="assets-filter-item-label">{value === "all" ? "全部类型" : ASSET_PICKER_MEDIA_KIND_LABELS[value]}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
{sourceFolders.length ? (
|
||||
<>
|
||||
<span className="asset-picker-nav-label">文件夹</span>
|
||||
@@ -527,6 +563,13 @@ export function AssetLibraryPickerModal({
|
||||
);
|
||||
}
|
||||
|
||||
// 角色卡、3D 模型等非媒体条目没有媒体类型,媒体筛选生效时不参与匹配。
|
||||
export function pickerItemMediaKind(item: AssetLibraryPickerItem): AssetPickerMediaKind | undefined {
|
||||
if (item.mediaKind) return item.mediaKind;
|
||||
const kind = item.external?.item.kind || item.asset?.kind;
|
||||
return kind === "image" || kind === "video" || kind === "audio" || kind === "text" ? kind : undefined;
|
||||
}
|
||||
|
||||
function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem; selected: boolean; onToggle: () => void }) {
|
||||
const disabled = Boolean(item.disabledReason);
|
||||
return (
|
||||
|
||||
@@ -50,6 +50,7 @@ export function AssetPickerModal({ open, multiple = true, onInsert, onClose }: P
|
||||
category: normalizeAssetCategory(asset.category),
|
||||
archived: asset.status === "archived",
|
||||
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本",
|
||||
mediaKind: asset.kind,
|
||||
asset,
|
||||
searchText: (asset.tags || []).join(" "),
|
||||
})),
|
||||
@@ -62,6 +63,7 @@ export function AssetPickerModal({ open, multiple = true, onInsert, onClose }: P
|
||||
<AssetLibraryPickerModal
|
||||
remoteLibrary
|
||||
open={open}
|
||||
mediaKinds={["image", "video", "audio", "text"]}
|
||||
items={items}
|
||||
categoryLabels={{ ...categoryLabels, ...externalAssetSources.categoryLabels }}
|
||||
folders={externalAssetSources.folders}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||||
import { AssetLibraryPickerModal, type AssetLibraryPickerItem, type AssetPickerMediaKind } from "@/components/assets/asset-library-picker-modal";
|
||||
import { useExternalAssetSources } from "@/hooks/use-external-asset-sources";
|
||||
import { externalAssetToInsertPayload, type InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
|
||||
import { compileCharacterReferencePrompt } from "@/lib/canvas/canvas-character-reference";
|
||||
@@ -55,12 +55,15 @@ export function CanvasProjectAssetModal({
|
||||
character?.character?.representations.find((representation) => representation.role === "primary") ||
|
||||
character?.character?.representations.find((representation) => representation.role === "front");
|
||||
const remoteResourceId = resourceIdFromStorageKey(project?.storageKey);
|
||||
// 项目资产可能只有服务端记录、没有本地素材,媒体类型要显式声明,筛选才能覆盖它们。
|
||||
const mediaKind = character ? undefined : pickerMediaKind(media?.kind || project?.mediaType);
|
||||
return {
|
||||
id: item.id,
|
||||
title: character?.title || project?.title || media?.title || "未命名资产",
|
||||
category: item.category,
|
||||
folderId: item.folderId,
|
||||
kindLabel: character ? "角色卡" : (media?.kind || project?.mediaType) === "video" ? "视频" : (media?.kind || project?.mediaType) === "audio" ? "音频" : (media?.kind || project?.mediaType) === "text" ? "文本" : "图片",
|
||||
kindLabel: character ? "角色卡" : mediaKind === "video" ? "视频" : mediaKind === "audio" ? "音频" : mediaKind === "text" ? "文本" : "图片",
|
||||
mediaKind,
|
||||
asset: media,
|
||||
imageUrl: coverRepresentation ? resourceFileUrl(coverRepresentation.resourceId) : project?.mediaType === "image" && remoteResourceId ? resourceFileUrl(remoteResourceId) : undefined,
|
||||
imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : undefined,
|
||||
@@ -77,6 +80,7 @@ export function CanvasProjectAssetModal({
|
||||
<AssetLibraryPickerModal
|
||||
remoteLibrary={!detail}
|
||||
open={open}
|
||||
mediaKinds={["image", "video", "audio", "text"]}
|
||||
items={pickerItems}
|
||||
categoryLabels={{ ...categoryLabels, ...externalAssetSources.categoryLabels }}
|
||||
initialCategory={initialCategory}
|
||||
@@ -117,6 +121,10 @@ export function CanvasProjectAssetModal({
|
||||
);
|
||||
}
|
||||
|
||||
function pickerMediaKind(value?: string): AssetPickerMediaKind {
|
||||
return value === "video" || value === "audio" || value === "text" ? value : "image";
|
||||
}
|
||||
|
||||
function toInsertPayload(item: ProjectPickerItem): InsertAssetPayload {
|
||||
if (item.character?.character) {
|
||||
return projectCharacterToInsertPayload(item.character);
|
||||
|
||||
Reference in New Issue
Block a user