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

View File

@@ -442,6 +442,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
<AssetLibraryPickerModal
open={addOpen}
remoteLibrary
mediaKinds={["image", "video", "audio", "text"]}
items={availablePickerItems}
categoryLabels={{ ...pickerCategoryLabels, ...externalAssetSources.categoryLabels }}
folders={externalAssetSources.folders}

View File

@@ -166,6 +166,7 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
<AssetLibraryPickerModal
open={coverPickerOpen}
items={coverPickerItems}
mediaKinds={["image"]}
categoryLabels={{ all: "全部图片", image: "图片" }}
initialCategory="image"
initialSelectedIds={currentCoverItemId ? [currentCoverItemId] : []}

View File

@@ -4,7 +4,7 @@ import { resolve } from "node:path";
import { describe, expect, test } from "bun:test";
import { assetPickerItemsToInsertPayloads } from "@/components/canvas/asset-picker-modal";
import type { AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import { pickerItemMediaKind, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import type { ImageAsset, TextAsset } from "@/stores/use-asset-store";
const timestamp = "2026-08-28T00:00:00.000Z";
@@ -59,6 +59,36 @@ describe("canvas asset picker", () => {
});
});
describe("asset picker media kind", () => {
const base: AssetLibraryPickerItem = { id: "item", title: "素材", category: "other", kindLabel: "图片" };
const image: ImageAsset = {
id: "asset",
kind: "image",
title: "图",
coverUrl: "",
tags: [],
createdAt: timestamp,
updatedAt: timestamp,
data: { dataUrl: "", width: 1, height: 1, bytes: 1, mimeType: "image/png" },
};
test("prefers the declared media kind over the underlying record", () => {
expect(pickerItemMediaKind({ ...base, mediaKind: "video", asset: image })).toBe("video");
expect(pickerItemMediaKind({ ...base, asset: image })).toBe("image");
});
test("resolves external plugin items and skips records without a media kind", () => {
const external: AssetLibraryPickerItem = {
...base,
external: { sourceId: "eagle", sourceName: "Eagle", item: { id: "audio-1", title: "外部音频", kind: "audio" } },
};
expect(pickerItemMediaKind(external)).toBe("audio");
// 角色卡、3D 模型这类条目没有媒体类型,媒体筛选生效时不应被当成图片留在列表里。
expect(pickerItemMediaKind(base)).toBeUndefined();
});
});
describe("canvas context menu motion", () => {
test("keeps hover feedback stationary while retaining spotlight surfaces", () => {
const styles = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8");