feat(canvas): 画布插件与视觉 - 合并 PR #289 并保留任务计费边界

This commit is contained in:
ddCat
2026-08-22 10:01:41 +08:00
45 changed files with 5649 additions and 240 deletions

View File

@@ -1,13 +1,15 @@
import { useMemo } from "react";
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import { useExternalAssetSources } from "@/hooks/use-external-asset-sources";
import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types";
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
type InsertableAsset = Extract<Asset, { kind: "text" | "image" | "video" | "audio" }>;
export type InsertAssetPayload =
| { kind: "text"; content: string; title: string; assetId?: string }
| { kind: "image"; dataUrl: string; title: string; storageKey?: string; assetId?: string }
| { kind: "image"; dataUrl: string; title: string; url?: string; storageKey?: string; width?: number; height?: number; bytes?: number; mimeType?: string; assetId?: string }
| { kind: "video"; url: string; title: string; storageKey?: string; width?: number; height?: number; durationMs?: number; bytes?: number; mimeType?: string; assetId?: string }
| { kind: "audio"; url: string; title: string; storageKey?: string; durationMs?: number; bytes?: number; mimeType?: string; assetId?: string }
| { kind: "character"; title: string; assetId: string; versionId: string; prompt: string; aliases: string[]; definition: Record<string, unknown>; coverUrl?: string; visualStatus: string; voiceStatus: string; voiceName?: string; voiceProfile?: { name: string; provider: string; language: string; timbre: string }; voiceInstructions?: string };
@@ -22,17 +24,27 @@ const categoryLabels: Record<string, string> = { all: "全部素材", character:
export function AssetPickerModal({ open, onInsert, onClose }: Props) {
const assets = useAssetStore((state) => state.assets);
const externalAssetSources = useExternalAssetSources(open);
const insertableAssets = useMemo(() => assets.filter((asset): asset is InsertableAsset => asset.kind === "text" || asset.kind === "image" || asset.kind === "video" || asset.kind === "audio"), [assets]);
const items = useMemo<AssetLibraryPickerItem[]>(() => insertableAssets.map((asset) => ({
id: asset.id,
title: asset.title,
category: asset.category || "other",
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本",
asset,
searchText: asset.tags.join(" "),
})), [insertableAssets]);
const items = useMemo<AssetLibraryPickerItem[]>(() => [
...insertableAssets.map((asset) => ({
id: asset.id,
title: asset.title,
category: asset.category || "other",
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本",
asset,
searchText: asset.tags.join(" "),
})),
...externalAssetSources.items,
], [externalAssetSources.items, insertableAssets]);
const insert = (id: string) => {
const pickerItem = items.find((item) => item.id === id);
if (pickerItem?.external) {
onInsert(externalAssetToInsertPayload(pickerItem.external));
onClose();
return;
}
const asset = insertableAssets.find((item) => item.id === id);
if (!asset) throw new Error("所选素材已不存在,请重新选择");
if (asset.kind === "text") onInsert({ kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id });
@@ -46,7 +58,9 @@ export function AssetPickerModal({ open, onInsert, onClose }: Props) {
<AssetLibraryPickerModal
open={open}
items={items}
categoryLabels={categoryLabels}
categoryLabels={{ ...categoryLabels, ...externalAssetSources.categoryLabels }}
folders={externalAssetSources.folders}
footerNote={externalAssetSources.error || undefined}
multiple={false}
confirmLabel={() => "插入所选素材"}
emptyDescription="先在素材库中添加图片、视频、音频或文本。"
@@ -55,3 +69,14 @@ export function AssetPickerModal({ open, onInsert, onClose }: Props) {
/>
);
}
export function externalAssetToInsertPayload(reference: ExternalAssetPickerReference): InsertAssetPayload {
const item = reference.item;
const url = item.fileUrl || "";
if (!url) throw new Error(`“${item.title}”暂时无法读取,请先在 Eagle 中确认文件可用`);
const assetId = `external:${reference.sourceId}:${item.id}`;
if (item.kind === "image") return { kind: "image", dataUrl: url, url, title: item.title || "素材图片", width: item.width, height: item.height, bytes: item.bytes, mimeType: item.mimeType, assetId };
if (item.kind === "video") return { kind: "video", url, title: item.title, width: item.width, height: item.height, bytes: item.bytes, mimeType: item.mimeType, assetId };
if (item.kind === "audio") return { kind: "audio", url, title: item.title, bytes: item.bytes, mimeType: item.mimeType, assetId };
throw new Error(`“${item.title}”不是可插入画布的媒体文件`);
}

View File

@@ -21,6 +21,14 @@ const TRAY_DEFAULT_HEIGHT = 520;
const TRAY_MIN_HEIGHT = 400;
const TRAY_BOTTOM_SAFE_SPACE = 82;
type ResizeSession = {
pointerId: number;
startY: number;
startHeight: number;
nextHeight: number;
frameId: number | null;
};
function getMaxTrayHeight() {
if (typeof window === "undefined") return 520;
return Math.max(360, window.innerHeight - TRAY_BOTTOM_SAFE_SPACE);
@@ -49,7 +57,10 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true,
const [tab, setTab] = useState<TrayTab>(() => showLibrary ? "library" : "canvas");
const [keyword, setKeyword] = useState("");
const [trayHeight, setTrayHeight] = useState(() => clampTrayHeight(TRAY_DEFAULT_HEIGHT));
const resizeRef = useRef<{ startY: number; startHeight: number } | null>(null);
const panelRef = useRef<HTMLElement>(null);
const resizeRef = useRef<ResizeSession | null>(null);
const resizeCleanupRef = useRef<(() => void) | null>(null);
const resizeHandleRef = useRef<{ element: HTMLButtonElement; pointerId: number } | null>(null);
const query = keyword.trim().toLowerCase();
const filteredAssets = useMemo(() => assetImages.filter((asset) => !query || [asset.title, ...(asset.tags || [])].join(" ").toLowerCase().includes(query)), [assetImages, query]);
const filteredNodes = useMemo(() => canvasImages.filter((node) => !query || canvasImageTitle(node).toLowerCase().includes(query)), [canvasImages, query]);
@@ -63,29 +74,70 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true,
event.dataTransfer.setData("text/plain", asset.title);
};
const startResize = useCallback(
(event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
resizeRef.current = { startY: event.clientY, startHeight: safeTrayHeight };
const paintResize = useCallback(() => {
const session = resizeRef.current;
if (!session || session.frameId !== null) return;
session.frameId = window.requestAnimationFrame(() => {
session.frameId = null;
if (resizeRef.current !== session || !panelRef.current) return;
panelRef.current.style.height = String(session.nextHeight) + "px";
});
}, []);
const updateHeight = (moveEvent: PointerEvent) => {
if (!resizeRef.current) return;
setTrayHeight(clampTrayHeight(resizeRef.current.startHeight + resizeRef.current.startY - moveEvent.clientY));
};
const stopResize = () => {
resizeRef.current = null;
window.removeEventListener("pointermove", updateHeight);
window.removeEventListener("pointerup", stopResize);
window.removeEventListener("pointercancel", stopResize);
};
const stopResize = useCallback(() => {
const session = resizeRef.current;
if (!session) return;
resizeCleanupRef.current?.();
resizeCleanupRef.current = null;
if (session.frameId !== null) {
window.cancelAnimationFrame(session.frameId);
session.frameId = null;
}
const finalHeight = session.nextHeight;
if (panelRef.current) {
panelRef.current.style.height = String(finalHeight) + "px";
panelRef.current.classList.remove("is-resizing");
}
const handle = resizeHandleRef.current;
if (handle?.element.hasPointerCapture(handle.pointerId)) handle.element.releasePointerCapture(handle.pointerId);
resizeHandleRef.current = null;
resizeRef.current = null;
setTrayHeight(finalHeight);
}, []);
window.addEventListener("pointermove", updateHeight);
window.addEventListener("pointerup", stopResize, { once: true });
window.addEventListener("pointercancel", stopResize, { once: true });
},
[safeTrayHeight],
);
const startResize = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
stopResize();
const session: ResizeSession = { pointerId: event.pointerId, startY: event.clientY, startHeight: safeTrayHeight, nextHeight: safeTrayHeight, frameId: null };
const updateHeight = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== session.pointerId) return;
session.nextHeight = clampTrayHeight(session.startHeight + session.startY - moveEvent.clientY);
paintResize();
};
const finish = (endEvent: PointerEvent) => {
if (endEvent.pointerId === session.pointerId) stopResize();
};
resizeRef.current = session;
resizeHandleRef.current = { element: event.currentTarget, pointerId: event.pointerId };
panelRef.current?.classList.add("is-resizing");
event.currentTarget.setPointerCapture(event.pointerId);
window.addEventListener("pointermove", updateHeight);
window.addEventListener("pointerup", finish);
window.addEventListener("pointercancel", finish);
resizeCleanupRef.current = () => {
window.removeEventListener("pointermove", updateHeight);
window.removeEventListener("pointerup", finish);
window.removeEventListener("pointercancel", finish);
};
}, [paintResize, safeTrayHeight, stopResize]);
useEffect(() => () => {
resizeCleanupRef.current?.();
const session = resizeRef.current;
if (session && session.frameId !== null) window.cancelAnimationFrame(session.frameId);
resizeRef.current = null;
}, []);
useEffect(() => {
const syncHeight = () => setTrayHeight((height) => clampTrayHeight(height));
@@ -134,11 +186,12 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true,
animate={{ opacity: 1, y: 0, scale: 1, rotateX: 0 }}
exit={{ opacity: 0, y: 14, scale: 0.95, rotateX: 3 }}
transition={aceternityMotion.spring.panel}
className="aceternity-floating-panel absolute bottom-[var(--canvas-dock-popover-offset)] left-0 flex w-[min(88vw,312px)] origin-bottom-left flex-col overflow-hidden rounded-[var(--r-2xl)] border p-2.5 backdrop-blur-2xl"
ref={panelRef}
className="canvas-asset-tray-panel aceternity-floating-panel absolute bottom-[var(--canvas-dock-popover-offset)] left-0 flex w-[min(88vw,312px)] origin-bottom-left flex-col overflow-hidden rounded-[var(--r-2xl)] border p-2.5 backdrop-blur-2xl"
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, height: safeTrayHeight, minHeight: Math.min(TRAY_MIN_HEIGHT, getMaxTrayHeight()), maxHeight: "calc(100vh - 6rem)", boxShadow: `0 32px 100px ${theme.spatial.shadow}` }}
>
<div className="absolute inset-x-10 top-0 h-px" style={{ background: `linear-gradient(90deg, transparent, ${theme.spatial.glowStrong}, transparent)` }} />
<button type="button" className="absolute left-1/2 top-1 z-10 flex h-5 w-28 -translate-x-1/2 cursor-ns-resize items-center justify-center rounded-full opacity-35 transition-opacity hover:opacity-75" onPointerDown={startResize} aria-label="从顶部调整素材托盘高度" title="拖动调整高度">
<button type="button" className="canvas-asset-tray-resize-handle absolute left-1/2 top-1 z-10 flex h-5 w-28 -translate-x-1/2 items-center justify-center rounded-full opacity-35 transition-opacity hover:opacity-75" onPointerDown={startResize} aria-label="从顶部调整素材托盘高度" title="拖动调整高度">
<span className="h-1 w-12 rounded-full bg-current" />
</button>

View File

@@ -65,24 +65,41 @@ export const ConnectionPath = React.memo(function ConnectionPath({
/>
{showVisual ? <path
d={pathD}
stroke={emphasized ? theme.accent.primary : theme.node.muted}
strokeWidth={emphasized ? 1.4 : 1}
stroke={theme.node.muted}
strokeWidth={emphasized ? 5 : 3.5}
vectorEffect="non-scaling-stroke"
strokeOpacity={emphasized ? 0.42 : 0.16}
strokeOpacity={emphasized ? 0.18 : 0.16}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
style={{ pointerEvents: "none" }}
/> : null}
{showVisual ? <path
d={pathD}
stroke={emphasized ? theme.accent.primary : theme.node.muted}
strokeWidth={emphasized ? 2.2 : 1.5}
vectorEffect="non-scaling-stroke"
strokeOpacity={emphasized ? 0.9 : 0.72}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
style={{ pointerEvents: "none" }}
/> : null}
{showVisual ? <>
<circle cx={startX} cy={startY} r={emphasized ? 3.5 : 2.5} fill={emphasized ? theme.accent.primary : theme.node.muted} fillOpacity={emphasized ? 0.9 : 0.72} vectorEffect="non-scaling-stroke" style={{ pointerEvents: "none" }} />
<circle cx={endX} cy={endY} r={emphasized ? 3.5 : 2.5} fill={emphasized ? theme.accent.primary : theme.node.muted} fillOpacity={emphasized ? 0.9 : 0.72} vectorEffect="non-scaling-stroke" style={{ pointerEvents: "none" }} />
</> : null}
{showVisual && emphasized ? <path
className="canvas-connection-flow"
d={pathD}
stroke={`url(#${gradientId})`}
strokeWidth="1.8"
strokeWidth="2.2"
vectorEffect="non-scaling-stroke"
strokeOpacity="0.72"
strokeOpacity="0.84"
strokeDasharray="18 26"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
style={{ pointerEvents: "none" }}
/> : null}
</g>

View File

@@ -99,10 +99,13 @@ function DrawingContent({ node, theme, drawingProjectId }: CanvasNodeContentProp
{previewUrl ? (
<img src={previewUrl} alt="绘图预览" className="absolute inset-0 h-full w-full object-cover" draggable={false} />
) : (
<div className="absolute inset-0 grid place-items-center" style={{ backgroundImage: `radial-gradient(circle, ${theme.node.stroke} 1px, transparent 1px)`, backgroundSize: "18px 18px" }}>
<span className="grid size-12 place-items-center rounded-[var(--r-md)]" style={{ background: theme.toolbar.panel, color: theme.node.muted }}>
<Pencil className="size-5" />
</span>
<div className="absolute inset-0 grid place-items-center" style={{ background: theme.node.panel, backgroundImage: `radial-gradient(circle, ${theme.node.stroke} 1px, transparent 1px)`, backgroundSize: "18px 18px" }}>
<div className="flex flex-col items-center gap-2 rounded-[var(--r-lg)] px-4 py-3" style={{ border: `1px solid ${theme.node.edge}`, background: theme.node.fill, color: theme.node.muted }}>
<span className="grid size-10 place-items-center rounded-[var(--r-md)]" style={{ background: theme.toolbar.panel, border: `1px solid ${theme.node.edge}`, color: theme.node.text }}>
<Pencil className="size-5" />
</span>
<span className="text-[var(--fs-tiny)] font-medium">打开绘图</span>
</div>
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-3 px-4 pb-3 pt-12" style={{ background: `linear-gradient(to top, ${theme.node.fill}, ${theme.node.fill}e6 55%, transparent)` }}>

View File

@@ -104,6 +104,20 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
const activeReferenceCount = activeReferences.length;
const videoFrameOptions = mentionReferences.filter((item) => item.active && item.kind === "image").map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl }));
const monochromeAccent = theme.node.activeStroke;
const composerTokens = {
"--canvas-composer-surface": theme.node.panel,
"--canvas-composer-control-surface": theme.toolbar.itemHover,
"--canvas-composer-control-hover": theme.toolbar.activeBg,
"--canvas-composer-shadow": theme.node.shadow,
"--cn-text": theme.node.text,
} as CSSProperties;
const composerSurfaceStyle = {
...composerTokens,
border: `1px solid ${theme.node.edge}`,
background: theme.node.panel,
color: theme.node.text,
boxShadow: theme.node.shadow,
} as CSSProperties;
const controlSurface = "var(--canvas-composer-control-surface)";
const promptBounds = promptEditorBounds(false, activeReferenceCount > 0);
const expandedPromptBounds = promptEditorBounds(true, activeReferenceCount > 0);
@@ -187,7 +201,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
<Tooltip title="放大编辑">
<button
type="button"
className="grid size-6 shrink-0 place-items-center rounded-md transition hover:bg-white/[.06] hover:brightness-125 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 motion-reduce:hover:translate-y-0"
className="canvas-node-composer-icon-button grid size-6 shrink-0 place-items-center rounded-md transition-[background-color,filter] hover:brightness-125 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 motion-reduce:hover:translate-y-0"
style={{ background: controlSurface, color: theme.node.text, outlineColor: monochromeAccent }}
onClick={() => setExpandedPromptOpen(true)}
aria-label="放大编辑提示词"
@@ -333,6 +347,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
return (
<div
className="canvas-node-composer"
style={composerSurfaceStyle}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
@@ -346,7 +361,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
<div className="canvas-node-composer-parameters overflow-hidden">
<button
type="button"
className="flex w-full items-center gap-1.5 px-2 py-1 text-[var(--fs-micro)] font-medium transition-colors hover:bg-white/[.04]"
className="canvas-node-composer-parameters-toggle flex w-full items-center gap-1.5 rounded-[var(--r-md)] px-2 py-1 text-[var(--fs-micro)] font-medium transition-colors"
style={{ color: theme.node.muted }}
onClick={() => setParamsExpanded(!paramsExpanded)}
aria-expanded={paramsExpanded}
@@ -379,11 +394,11 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
setExpandedPromptOpen(false);
}}
styles={{
container: { border: 0, borderRadius: "var(--canvas-composer-radius)", padding: 0, overflow: "hidden", background: "var(--canvas-composer-surface)", boxShadow: "var(--canvas-composer-shadow)" },
container: { border: `1px solid ${theme.node.edge}`, borderRadius: "var(--canvas-composer-radius)", padding: 0, overflow: "hidden", background: theme.node.panel, boxShadow: theme.node.shadow },
body: { minHeight: 0, padding: 0 },
}}
>
<div className="flex min-h-0 flex-col gap-2.5 p-3" style={{ color: theme.node.text }}>
<div className="flex min-h-0 flex-col gap-2.5 p-3" style={{ ...composerTokens, color: theme.node.text }}>
<div className="shrink-0 pr-8">{renderComposerHeader(true)}</div>
{renderPromptEditor(true)}
{mode === "video" && !simpleMode ? (

View File

@@ -291,7 +291,9 @@ export const CanvasNode = React.memo(function CanvasNode({
data-node-state={nodeState}
data-state={data.metadata?.status || (isActive ? "active" : isRelated ? "related" : "idle")}
style={{
background: hasImageContent || hasVideoContent ? "transparent" : "var(--card-surface)",
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
border: `1px solid ${theme.node.edge}`,
boxShadow: isSelected ? `0 0 0 2px ${theme.node.activeStroke}, ${theme.node.hoverShadow}` : hovered ? theme.node.hoverShadow : theme.node.shadow,
}}
onMouseDown={(event) => onMouseDown(event, data.id)}
onDoubleClick={(event) => {
@@ -329,7 +331,7 @@ export const CanvasNode = React.memo(function CanvasNode({
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot || data.type === CanvasNodeType.Script ? "overflow-visible" : "overflow-hidden"}`}
style={
{
background: hasImageContent || hasVideoContent ? "transparent" : "var(--card-surface)",
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
"--batch-from-x": `${batchMotion?.x || 0}px`,
"--batch-from-y": `${batchMotion?.y || 0}px`,
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
@@ -632,7 +634,17 @@ function NodeExternalHeader({ node, scale, active, editable, editing, draft, the
return (
<div
className="canvas-node-external-header absolute bottom-full left-0 z-[var(--node-z-overlay)] flex h-6 items-center gap-1 overflow-hidden"
style={{ maxWidth: maxHeaderWidth, color: active ? theme.node.text : theme.node.label, transform: `scale(var(--canvas-live-inverse-scale, ${inverseScale}))`, transformOrigin: "left bottom" }}
style={{
maxWidth: maxHeaderWidth,
border: `1px solid ${theme.node.edge}`,
borderRadius: "var(--r-sm)",
background: theme.node.panel,
paddingInline: "var(--space-1-half)",
color: active ? theme.node.text : theme.node.label,
boxShadow: active ? theme.node.shadow : undefined,
transform: `scale(var(--canvas-live-inverse-scale, ${inverseScale}))`,
transformOrigin: "left bottom",
}}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>

View File

@@ -1,7 +1,8 @@
import { useMemo } from "react";
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import type { InsertAssetPayload } from "@/components/canvas/asset-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";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import type { ProjectAsset, ProjectDetail } from "@/services/api/projects";
@@ -13,6 +14,7 @@ type ProjectPickerItem = { id: string; category: string; folderId?: string; proj
export function CanvasProjectAssetModal({ open, detail, initialCategory = "all", initialFolderId = "all", onClose, onInsert, onInsertFolder }: { open: boolean; detail?: ProjectDetail; initialCategory?: string; initialFolderId?: string; onClose: () => void; onInsert: (payloads: InsertAssetPayload[]) => Promise<void> | void; onInsertFolder?: (folderId: string) => Promise<void> | void }) {
const mediaAssets = useAssetStore((state) => state.assets);
const externalAssetSources = useExternalAssetSources(open);
const items = useMemo<ProjectPickerItem[]>(() => {
const mediaById = new Map(mediaAssets.map((asset) => [asset.id, asset]));
const projectItems = (detail?.assets || []).flatMap((asset): ProjectPickerItem[] => {
@@ -26,7 +28,7 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
.filter((asset) => asset.kind !== "model" && asset.kind !== "entity")
.map((media): ProjectPickerItem => ({ id: media.id, category: media.category || "other", media }));
}, [detail?.assets, mediaAssets]);
const pickerItems = useMemo<AssetLibraryPickerItem[]>(() => items.map((item) => {
const localPickerItems = useMemo<AssetLibraryPickerItem[]>(() => items.map((item) => {
const character = item.character;
const project = item.project;
const media = item.media;
@@ -50,24 +52,30 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
searchText: [media?.tags.join(" ") || "", project?.previewText || ""].join(" "),
};
}), [items]);
const pickerItems = useMemo<AssetLibraryPickerItem[]>(() => [...localPickerItems, ...externalAssetSources.items], [externalAssetSources.items, localPickerItems]);
return (
<AssetLibraryPickerModal
open={open}
items={pickerItems}
categoryLabels={categoryLabels}
categoryLabels={{ ...categoryLabels, ...externalAssetSources.categoryLabels }}
initialCategory={initialCategory}
initialFolderId={initialFolderId}
folders={detail?.assetFolders || []}
folders={externalAssetSources.folders}
folderActionSource="local"
title="项目资产"
confirmLabel={(count) => `引入已选资产${count ? `(${count})` : ""}`}
emptyTitle="此分类没有可引用资产"
emptyDescription={detail ? "先在项目角色与资产中完成角色确认或素材关联。" : "当前为自由画布,可先在素材库添加内容。"}
footerNote="角色引用会在生成时解析当前角色版本"
footerNote={externalAssetSources.error || "角色引用会在生成时解析当前角色版本"}
onFolderAction={onInsertFolder ? async (folderId) => { await onInsertFolder(folderId); onClose(); } : undefined}
onClose={onClose}
onConfirm={async (ids) => {
const payloads = await Promise.all(items.filter((item) => ids.includes(item.id)).map(async (item) => {
const payloads = await Promise.all(ids.map(async (id) => {
const external = externalAssetSources.items.find((item) => item.id === id)?.external;
if (external) return externalAssetToInsertPayload(external);
const item = items.find((candidate) => candidate.id === id);
if (!item) throw new Error("所选资产已不存在,请重新选择");
if (item.media || item.character || !item.project) return toInsertPayload(item);
const { asset } = await getRemoteAsset(item.project.id);
return toInsertPayload({ ...item, media: asset });