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,10 +1,13 @@
import { Button, Modal } from "antd";
import { Check, FileText, FolderOpen, Image as ImageIcon, LoaderCircle, Music2, Search, Upload, UserRound, Video } from "lucide-react";
import { Button, Dropdown, Modal } from "antd";
import type { MenuProps } from "antd";
import { Check, ChevronDown, FileText, FolderOpen, HardDrive, Image as ImageIcon, LoaderCircle, Music2, Puzzle, Search, Upload, UserRound, Video } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { AssetLibraryCard } from "@/components/assets/asset-library-card";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { cn } from "@/lib/utils";
import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types";
import type { Asset } from "@/stores/use-asset-store";
export type AssetLibraryPickerItem = {
@@ -20,6 +23,7 @@ export type AssetLibraryPickerItem = {
searchText?: string;
disabledReason?: string;
folderId?: string;
external?: ExternalAssetPickerReference;
};
export type AssetLibraryPickerFolder = {
@@ -44,10 +48,16 @@ type Props = {
emptyDescription?: string;
footerNote?: string;
folderActionLabel?: string;
folderActionSource?: "local" | "all";
upload?: {
accept: string;
description: string;
onUpload: (files: FileList) => Promise<string[]>;
external?: {
accept: string;
description: string;
onUpload: (files: FileList, folderId?: string) => Promise<AssetLibraryPickerItem[]>;
};
};
onClose: () => void;
onConfirm: (ids: string[]) => Promise<void> | void;
@@ -70,6 +80,7 @@ export function AssetLibraryPickerModal({
emptyDescription = "换个分类后再试。",
footerNote,
folderActionLabel = "将文件夹放到画布",
folderActionSource = "all",
upload,
onClose,
onConfirm,
@@ -77,8 +88,11 @@ export function AssetLibraryPickerModal({
}: Props) {
const [category, setCategory] = useState(initialCategory);
const [folderId, setFolderId] = useState(initialFolderId);
const [source, setSource] = useState<"local" | "plugin">("local");
const [sourceMenuOpen, setSourceMenuOpen] = useState(false);
const [keyword, setKeyword] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [uploadedItems, setUploadedItems] = useState<AssetLibraryPickerItem[]>([]);
const [working, setWorking] = useState(false);
const [uploadingCount, setUploadingCount] = useState(0);
const [error, setError] = useState("");
@@ -86,26 +100,42 @@ export function AssetLibraryPickerModal({
const initialSelectedIdsRef = useRef(initialSelectedIds);
const itemsRef = useRef(items);
initialSelectedIdsRef.current = initialSelectedIds;
itemsRef.current = items;
const categories = useMemo(() => ["all", ...Array.from(new Set(items.map((item) => item.category || "other")))], [items]);
const allItems = useMemo(() => {
const known = new Set(items.map((item) => item.id));
return [...items, ...uploadedItems.filter((item) => !known.has(item.id))];
}, [items, uploadedItems]);
itemsRef.current = allItems;
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 : localItems;
const sourceFolders = source === "plugin" ? folders : [];
const showCategories = source === "local" || !sourceFolders.length;
const categories = useMemo(() => ["all", ...Array.from(new Set(sourceItems.map((item) => item.category || "other")))], [sourceItems]);
const visibleItems = useMemo(() => {
const query = keyword.trim().toLowerCase();
return items.filter((item) => {
return sourceItems.filter((item) => {
if (category !== "all" && item.category !== category) return false;
if (folderId !== "all" && (item.folderId || "") !== folderId) return false;
return !query || [item.title, item.searchText || "", item.description || ""].join(" ").toLowerCase().includes(query);
});
}, [category, folderId, items, keyword]);
}, [category, folderId, keyword, sourceItems]);
const selectedIds = useMemo(
() => items.filter((item) => !item.disabledReason && selected.has(item.id)).map((item) => item.id),
[items, selected],
() => allItems.filter((item) => !item.disabledReason && selected.has(item.id)).map((item) => item.id),
[allItems, selected],
);
useEffect(() => {
if (!open) return;
setCategory(initialCategory);
setFolderId(initialFolderId);
setCategory(initialCategory);
setSource("local");
setKeyword("");
setUploadedItems([]);
const selectableIds = new Set(itemsRef.current.filter((item) => !item.disabledReason).map((item) => item.id));
setSelected(new Set(Array.from(initialSelectedIdsRef.current || []).filter((id) => selectableIds.has(id))));
setWorking(false);
@@ -118,6 +148,19 @@ export function AssetLibraryPickerModal({
setCategory("all");
}, [categories, category]);
useEffect(() => {
if (hasPluginSource || source === "local") return;
setSource("local");
}, [hasPluginSource, source]);
const selectSource = (nextSource: "local" | "plugin") => {
if (nextSource === "plugin" && !hasPluginSource) return;
setSource(nextSource);
setCategory("all");
setFolderId("all");
setError("");
};
const toggle = (item: AssetLibraryPickerItem) => {
if (item.disabledReason || working) return;
setError("");
@@ -144,13 +187,20 @@ export function AssetLibraryPickerModal({
};
const handleUpload = async (files: FileList | null) => {
if (!upload || !files?.length || working) return;
if (!files?.length || working || (source === "local" && !upload) || (source === "plugin" && !upload?.external)) return;
setWorking(true);
setError("");
setUploadingCount(files.length);
try {
const ids = await upload.onUpload(files);
if (ids.length) setSelected((current) => new Set(multiple ? [...current, ...ids] : ids.slice(-1)));
if (source === "plugin") {
const uploaded = await upload!.external!.onUpload(files, folderId === "all" ? undefined : folderId);
setUploadedItems((current) => [...current, ...uploaded]);
const ids = uploaded.map((item) => item.id);
if (ids.length) setSelected((current) => new Set(multiple ? [...current, ...ids] : ids.slice(-1)));
} else {
const ids = await upload!.onUpload(files);
if (ids.length) setSelected((current) => new Set(multiple ? [...current, ...ids] : ids.slice(-1)));
}
} catch (reason) {
setError(reason instanceof Error ? reason.message : "素材上传失败,请重试");
} finally {
@@ -173,7 +223,13 @@ export function AssetLibraryPickerModal({
}
};
const countFor = (value: string) => value === "all" ? items.length : items.filter((item) => item.category === value).length;
const countFor = (value: string) => value === "all" ? sourceItems.length : sourceItems.filter((item) => item.category === value).length;
const sourceLabel = source === "plugin" ? "插件来源" : "本地素材";
const sourceMenuItems: MenuProps["items"] = [
{ key: "local", icon: <HardDrive aria-hidden="true" />, label: <span className="asset-picker-source-menu-label"><span>本地素材</span><em>{localItems.length}</em></span> },
...(hasPluginSource ? [{ key: "plugin", icon: <Puzzle aria-hidden="true" />, label: <span className="asset-picker-source-menu-label"><span>插件来源</span><em>{pluginItems.length}</em></span> }] : []),
];
const activeUpload = source === "plugin" ? upload?.external : upload;
const uploading = uploadingCount > 0;
return (
@@ -193,42 +249,63 @@ export function AssetLibraryPickerModal({
>
<div className="asset-picker-shell">
<header className="asset-picker-toolbar">
<div className="asset-picker-heading"><span>{eyebrow}</span><strong>{title}</strong></div>
<div className="asset-picker-heading">
<div className="asset-picker-heading-copy">
<span>{eyebrow}</span>
<Dropdown
trigger={["click"]}
placement="bottomLeft"
rootClassName="asset-picker-source-dropdown"
onOpenChange={setSourceMenuOpen}
menu={{
selectedKeys: [source],
items: sourceMenuItems,
onClick: ({ key }) => {
if (key === "local" || key === "plugin") selectSource(key);
},
}}
>
<button type="button" className="asset-picker-title-trigger" aria-haspopup="menu" aria-expanded={sourceMenuOpen} aria-label={"素材库来源:" + sourceLabel}>
<strong>{title}</strong><ChevronDown aria-hidden="true" />
</button>
</Dropdown>
</div>
</div>
<label className="asset-picker-search"><Search aria-hidden /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索素材名称或标签" aria-label="搜索素材" /></label>
<span className="asset-picker-count">已选 {selectedIds.length} · {visibleItems.length} 个素材</span>
</header>
<div className="asset-picker-body">
<nav className="asset-picker-categories" aria-label="素材分类">
{folders.length ? <><span className="asset-picker-nav-label">文件夹</span><button type="button" className={folderId === "all" ? "is-active" : ""} aria-pressed={folderId === "all"} onClick={() => setFolderId("all")}><span>全部文件夹</span><em>{items.length}</em></button>{renderPickerFolders(folders, items, folderId, setFolderId)}<span className="asset-picker-nav-label">分类</span></> : null}
{categories.map((value) => (
<button key={value} type="button" className={category === value ? "is-active" : ""} aria-pressed={category === value} onClick={() => setCategory(value)}>
<span>{categoryLabels[value] || "其他"}</span><em>{countFor(value)}</em>
{sourceFolders.length ? <><span className="asset-picker-nav-label">文件夹</span><button type="button" className={cn("assets-filter-item", folderId === "all" && "is-active")} aria-pressed={folderId === "all"} onClick={() => setFolderId("all")}><span className="assets-filter-item-label">全部文件夹</span><span className="assets-filter-count">{sourceItems.length}</span></button>{renderPickerFolders(sourceFolders, sourceItems, folderId, setFolderId)}</> : null}
{showCategories ? <><span className="asset-picker-nav-label">分类</span>{categories.map((value) => (
<button key={value} type="button" className={cn("assets-filter-item", category === value && "is-active")} aria-pressed={category === value} onClick={() => setCategory(value)}>
<span className="assets-filter-item-label">{categoryLabels[value] || "其他"}</span><span className="assets-filter-count">{countFor(value)}</span>
</button>
))}
))}</> : null}
</nav>
<div className="asset-picker-grid-wrap">
<div className="asset-picker-grid">
{visibleItems.length ? visibleItems.map((item) => (
<PickerCard key={item.id} item={item} selected={selected.has(item.id)} onToggle={() => toggle(item)} />
)) : (
<div className="asset-picker-empty"><FolderOpen /><strong>{emptyTitle}</strong><span>{upload ? "换个分类,或从底部上传一份新素材。" : emptyDescription}</span></div>
<div className="asset-picker-empty"><FolderOpen /><strong>{emptyTitle}</strong><span>{activeUpload ? "换个分类,或从底部上传一份新素材。" : emptyDescription}</span></div>
)}
</div>
</div>
</div>
<footer className={cn("asset-picker-footer", !upload && "is-compact")}>
{upload ? (
<footer className={cn("asset-picker-footer", !activeUpload && "is-compact")}>
{activeUpload ? (
<>
<input ref={uploadInputRef} type="file" hidden accept={upload.accept} multiple={multiple} onChange={(event) => void handleUpload(event.target.files)} />
<input ref={uploadInputRef} type="file" hidden accept={activeUpload.accept} multiple={multiple} onChange={(event) => void handleUpload(event.target.files)} />
<button type="button" className="asset-picker-upload" onClick={() => uploadInputRef.current?.click()} disabled={working} aria-busy={uploading}>
{uploading ? <LoaderCircle className="animate-spin" /> : <Upload />}
<span><strong>{uploading ? `正在上传 ${uploadingCount} 个素材` : "上传新素材"}</strong><small>{uploading ? "保存完成后会自动选中" : upload.description}</small></span>
<span><strong>{uploading ? `正在上传 ${uploadingCount} 个素材` : "上传新素材"}</strong><small>{uploading ? "保存完成后会自动选中" : activeUpload.description}</small></span>
</button>
</>
) : footerNote ? <span className="asset-picker-footer-note">{footerNote}</span> : <span />}
{error ? <span className="asset-picker-footer-error" role="alert">{error}</span> : null}
<div className="asset-picker-actions">
{onFolderAction && folderId !== "all" ? <Button type="text" icon={<FolderOpen />} disabled={working} onClick={() => void runFolderAction()}>{folderActionLabel}</Button> : null}
{onFolderAction && folderId !== "all" && (folderActionSource !== "local" || source === "local") ? <Button type="text" icon={<FolderOpen />} disabled={working} onClick={() => void runFolderAction()}>{folderActionLabel}</Button> : null}
<Button type="text" onClick={onClose} disabled={working}>取消</Button>
<Button type="primary" icon={<Check />} disabled={working || !selectedIds.length} loading={working && !uploading} onClick={() => void confirm()}>
{confirmLabel(selectedIds.length)}
@@ -243,15 +320,19 @@ export function AssetLibraryPickerModal({
function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem; selected: boolean; onToggle: () => void }) {
const disabled = Boolean(item.disabledReason);
return (
<button type="button" className={cn("asset-picker-card", selected && "is-selected", disabled && "is-disabled")} onClick={onToggle} disabled={disabled} aria-pressed={selected} title={item.disabledReason || item.title}>
<div className="asset-picker-card-media">
{item.imageUrl || item.imageStorageKey ? <CachedResourceImage storageKey={item.imageStorageKey} src={item.imageUrl} alt={item.title} loading="lazy" decoding="async" className={item.imageFit === "contain" ? "is-contain" : undefined} fallback={<div className="asset-picker-card-fallback">{kindIcon(item.kindLabel)}</div>} /> : <AssetMediaPreview asset={item.asset} alt={item.title} fallback={<div className="asset-picker-card-fallback">{kindIcon(item.kindLabel)}</div>} />}
<span className="asset-picker-card-check"><Check /></span>
<span className="asset-picker-card-kind">{item.kindLabel}</span>
{item.disabledReason ? <span className="asset-picker-card-lock">{item.disabledReason}</span> : null}
</div>
<div className="asset-picker-card-copy"><strong>{item.title || "未命名素材"}</strong>{item.description ? <span>{item.description}</span> : null}</div>
</button>
<AssetLibraryCard selected={selected} className={cn("asset-picker-card", disabled && "is-disabled")}>
<button type="button" className="asset-picker-card-action" onClick={onToggle} disabled={disabled} aria-pressed={selected} title={item.disabledReason || item.title}>
<div className="assets-cover asset-picker-card-media">
{item.imageUrl || item.imageStorageKey ? <CachedResourceImage storageKey={item.imageStorageKey} src={item.imageUrl} alt={item.title} loading="lazy" decoding="async" className={item.imageFit === "contain" ? "is-contain" : undefined} fallback={<div className="assets-cover-fallback">{kindIcon(item.kindLabel)}</div>} /> : <AssetMediaPreview asset={item.asset} alt={item.title} fallback={<div className="assets-cover-fallback">{kindIcon(item.kindLabel)}</div>} />}
<span className="assets-cover-vignette" aria-hidden="true" />
<span className="assets-cover-badges" aria-hidden="true">
<span className="assets-cover-badge is-kind">{item.kindLabel}</span></span>
<span className="asset-picker-card-check" aria-hidden="true"><Check /></span>
{item.disabledReason ? <span className="asset-picker-card-lock">{item.disabledReason}</span> : null}
</div>
<div className="asset-picker-card-copy"><strong>{item.title || "未命名素材"}</strong>{item.description ? <span>{item.description}</span> : null}</div>
</button>
</AssetLibraryCard>
);
}
@@ -261,8 +342,8 @@ function renderPickerFolders(folders: AssetLibraryPickerFolder[], items: AssetLi
const nextVisited = new Set(visited).add(folder.id);
return (
<span key={folder.id} className="contents">
<button type="button" className={selectedId === folder.id ? "is-active" : ""} aria-pressed={selectedId === folder.id} onClick={() => onSelect(folder.id)} style={{ paddingLeft: `calc(var(--space-3) + ${depth} * var(--space-3))` }}>
<span>{folder.name}</span><em>{items.filter((item) => item.folderId === folder.id).length}</em>
<button type="button" className={cn("assets-filter-item", selectedId === folder.id && "is-active")} aria-pressed={selectedId === folder.id} onClick={() => onSelect(folder.id)} style={{ paddingLeft: `calc(var(--space-3) + ${depth} * var(--space-3))` }}>
<span className="assets-filter-item-label" title={folder.name}>{folder.name}</span><span className="assets-filter-count">{items.filter((item) => item.folderId === folder.id).length}</span>
</button>
{renderPickerFolders(folders, items, selectedId, onSelect, folder.id, depth + 1, nextVisited)}
</span>

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

View File

@@ -56,6 +56,7 @@ function buildNav(features: FeatureAvailability, balance: string, isAdmin: boole
heading: "工作台管理",
items: [
toolItem("skills", "/skills"),
toolItem("plugins", "/plugins"),
...(features.creditsEnabled ? [{ ...toolItem("wallet", "/wallet"), badge: balance }] : []),
],
},