feat(assets): 素材选择器 - 支持按媒体类型筛选并覆盖远程项目资产

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCat
2026-09-09 10:34:10 +08:00
parent 7336945623
commit 9acac6cbbe
6 changed files with 92 additions and 7 deletions

View File

@@ -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 (

View File

@@ -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}

View File

@@ -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);