feat(canvas): 画布插件与视觉 - 合并 PR #289 并保留任务计费边界
This commit is contained in:
@@ -4,6 +4,8 @@ import "antd/dist/reset.css";
|
||||
import "./styles/globals.css";
|
||||
import { RouterProvider } from "react-router";
|
||||
|
||||
import "@/lib/plugins/builtin";
|
||||
|
||||
import { AppProviders } from "@/components/layout/app-providers";
|
||||
import { router } from "@/router";
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}”不是可插入画布的媒体文件`);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)` }}>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 }] : []),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CircleDollarSign, Clapperboard, Images, LibraryBig, ListTodo, PanelsTopLeft, Settings, WandSparkles } from "lucide-react";
|
||||
import { Blocks, CircleDollarSign, Clapperboard, Images, LibraryBig, ListTodo, PanelsTopLeft, Settings, WandSparkles } from "lucide-react";
|
||||
|
||||
export const navigationTools = [
|
||||
{
|
||||
@@ -37,6 +37,12 @@ export const navigationTools = [
|
||||
icon: LibraryBig,
|
||||
section: "工作台管理",
|
||||
},
|
||||
{
|
||||
slug: "plugins",
|
||||
label: "插件中心",
|
||||
icon: Blocks,
|
||||
section: "工作台管理",
|
||||
},
|
||||
{
|
||||
slug: "wallet",
|
||||
label: "积分中心",
|
||||
|
||||
151
web/src/hooks/use-external-asset-sources.ts
Normal file
151
web/src/hooks/use-external-asset-sources.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { AssetLibraryPickerFolder, AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||||
import "@/lib/plugins/builtin";
|
||||
import { createHostedAssetSources, type HostedAssetSource } from "@/services/external-asset-sources";
|
||||
import type { ExternalAssetFolder, ExternalAssetItem, ExternalAssetPickerReference } from "@/lib/plugins/plugin-types";
|
||||
import { usePluginStore } from "@/stores/use-plugin-store";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
|
||||
type LoadedAssetSource = {
|
||||
source: HostedAssetSource;
|
||||
folders: ExternalAssetFolder[];
|
||||
items: ExternalAssetItem[];
|
||||
};
|
||||
|
||||
export type ExternalAssetSourceState = {
|
||||
items: AssetLibraryPickerItem[];
|
||||
folders: AssetLibraryPickerFolder[];
|
||||
categoryLabels: Record<string, string>;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
importExternalAsset: (reference: ExternalAssetPickerReference, signal?: AbortSignal) => Promise<Asset>;
|
||||
uploadExternalFiles: (files: FileList | File[], folderId?: string, signal?: AbortSignal) => Promise<AssetLibraryPickerItem[]>;
|
||||
};
|
||||
|
||||
export function useExternalAssetSources(open: boolean): ExternalAssetSourceState {
|
||||
const hydrated = usePluginStore((state) => state.hydrated);
|
||||
const installations = usePluginStore((state) => state.installations);
|
||||
const sources = useMemo(() => createHostedAssetSources(hydrated ? installations : []), [hydrated, installations]);
|
||||
const [loadedSources, setLoadedSources] = useState<LoadedAssetSource[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
if (!open || !sources.length) {
|
||||
setLoadedSources([]);
|
||||
setLoading(false);
|
||||
setError("");
|
||||
return () => controller.abort();
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
void Promise.allSettled(sources.map(async (source) => {
|
||||
try {
|
||||
const [folders, items] = await Promise.all([
|
||||
source.provider.listFolders?.(controller.signal) || Promise.resolve([]),
|
||||
source.provider.list?.({ limit: 200, offset: 0, signal: controller.signal }) || Promise.resolve([]),
|
||||
]);
|
||||
return { source, folders: folders || [], items: items || [] };
|
||||
} catch (reason) {
|
||||
const detail = reason instanceof Error ? reason.message : "读取失败";
|
||||
throw new Error(source.name + ":" + detail);
|
||||
}
|
||||
})).then((results) => {
|
||||
if (controller.signal.aborted) return;
|
||||
const loaded: LoadedAssetSource[] = [];
|
||||
const errors: string[] = [];
|
||||
results.forEach((result) => {
|
||||
if (result.status === "fulfilled") loaded.push(result.value);
|
||||
else errors.push(result.reason instanceof Error ? result.reason.message : "外部素材来源读取失败");
|
||||
});
|
||||
setLoadedSources(loaded);
|
||||
setError(errors.join(";"));
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [open, sources]);
|
||||
|
||||
const items = useMemo(() => loadedSources.flatMap(({ source, items: sourceItems }) => sourceItems.filter(isPickerMedia).map((item) => toPickerItem(source, item))), [loadedSources]);
|
||||
const folders = useMemo(() => loadedSources.flatMap(({ source, folders: sourceFolders }) => sourceFolders.map((folder) => ({
|
||||
id: externalFolderId(source.id, folder.id),
|
||||
parentId: folder.parentId ? externalFolderId(source.id, folder.parentId) : undefined,
|
||||
name: folder.name,
|
||||
}))), [loadedSources]);
|
||||
const categoryLabels = useMemo(
|
||||
() => Object.fromEntries(sources.map((source) => [externalCategory(source.id), source.name])),
|
||||
[sources],
|
||||
);
|
||||
|
||||
const importExternalAsset = useCallback(async (reference: ExternalAssetPickerReference, signal?: AbortSignal) => {
|
||||
const source = sources.find((item) => item.id === reference.sourceId);
|
||||
if (!source?.provider.importAsset) throw new Error(`${reference.sourceName}暂不支持导入项目资产`);
|
||||
return source.provider.importAsset(reference.item, signal);
|
||||
}, [sources]);
|
||||
|
||||
const uploadExternalFiles = useCallback(async (files: FileList | File[], folderId?: string, signal?: AbortSignal) => {
|
||||
const source = resolveUploadSource(sources, folderId);
|
||||
if (!source?.provider.uploadFile) throw new Error("当前插件不支持写入文件");
|
||||
const providerFolderId = folderId ? externalFolderValue(source.id, folderId) : undefined;
|
||||
const uploadedItems: ExternalAssetItem[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
uploadedItems.push(await source.provider.uploadFile(file, providerFolderId, signal));
|
||||
}
|
||||
const pickerItems = uploadedItems.map((item) => toPickerItem(source, item));
|
||||
setLoadedSources((current) => {
|
||||
const existing = current.find((item) => item.source.id === source.id);
|
||||
if (!existing) return [...current, { source, folders: [], items: uploadedItems }];
|
||||
return current.map((item) => item.source.id === source.id ? { ...item, items: [...item.items, ...uploadedItems] } : item);
|
||||
});
|
||||
return pickerItems;
|
||||
}, [sources]);
|
||||
|
||||
return { items, folders, categoryLabels, loading, error, importExternalAsset, uploadExternalFiles };
|
||||
}
|
||||
|
||||
function isPickerMedia(item: ExternalAssetItem) {
|
||||
return item.kind === "image" || item.kind === "video" || item.kind === "audio";
|
||||
}
|
||||
|
||||
function externalCategory(sourceId: string) {
|
||||
return "external:" + sourceId;
|
||||
}
|
||||
|
||||
export function externalAssetPickerId(reference: ExternalAssetPickerReference) {
|
||||
return "external:" + reference.sourceId + ":" + reference.item.id;
|
||||
}
|
||||
|
||||
function externalFolderId(sourceId: string, folderId: string) {
|
||||
return "external-folder:" + sourceId + ":" + folderId;
|
||||
}
|
||||
|
||||
function externalFolderValue(sourceId: string, folderId: string) {
|
||||
const prefix = externalFolderId(sourceId, "");
|
||||
return folderId.startsWith(prefix) ? folderId.slice(prefix.length) : folderId;
|
||||
}
|
||||
|
||||
function resolveUploadSource(sources: HostedAssetSource[], folderId?: string) {
|
||||
if (folderId) return sources.find((source) => folderId.startsWith(externalFolderId(source.id, "")));
|
||||
if (sources.length === 1) return sources[0];
|
||||
throw new Error("请先选择一个插件文件夹,再写入素材");
|
||||
}
|
||||
|
||||
function toPickerItem(source: HostedAssetSource, item: ExternalAssetItem): AssetLibraryPickerItem {
|
||||
const reference: ExternalAssetPickerReference = { sourceId: source.id, sourceName: source.name, item };
|
||||
const folderPath = item.folderPath?.join(" / ");
|
||||
return {
|
||||
id: externalAssetPickerId(reference),
|
||||
title: item.title,
|
||||
category: externalCategory(source.id),
|
||||
kindLabel: item.kind === "video" ? "视频" : item.kind === "audio" ? "音频" : "图片",
|
||||
imageUrl: item.thumbnailUrl,
|
||||
description: folderPath || item.description || undefined,
|
||||
searchText: [item.title, ...(item.tags || []), item.description || "", folderPath || ""].join(" "),
|
||||
folderId: item.folderId ? externalFolderId(source.id, item.folderId) : undefined,
|
||||
external: reference,
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,9 @@ export const canvasThemes = {
|
||||
fill: "#ffffff",
|
||||
panel: "#ffffff",
|
||||
stroke: "#e2e4e8",
|
||||
edge: "rgba(15,23,42,.16)",
|
||||
shadow: "0 6px 18px rgba(15,23,42,.08)",
|
||||
hoverShadow: "0 10px 24px rgba(15,23,42,.12)",
|
||||
activeStroke: "#111827",
|
||||
placeholder: "#9ca3af",
|
||||
text: "#111827",
|
||||
@@ -77,6 +80,9 @@ export const canvasThemes = {
|
||||
fill: "#181818",
|
||||
panel: "#141414",
|
||||
stroke: "rgba(255,255,255,.12)",
|
||||
edge: "rgba(255,255,255,.18)",
|
||||
shadow: "0 8px 24px rgba(0,0,0,.34)",
|
||||
hoverShadow: "0 12px 30px rgba(0,0,0,.46)",
|
||||
activeStroke: "#f1f1f1",
|
||||
placeholder: "#737373",
|
||||
text: "#ededed",
|
||||
|
||||
280
web/src/lib/plugins/builtin/eagle.ts
Normal file
280
web/src/lib/plugins/builtin/eagle.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { addEagleItem, createEagleFolder, downloadEagleItem, eagleItemFileUrl, eagleItemThumbnailUrl, getEagleLibrary, listEagleItems, type EagleFolder, type EagleItem } from "@/services/api/eagle";
|
||||
import { getMediaBlob, uploadMediaFile } from "@/services/file-storage";
|
||||
import { imageToDataUrl, uploadImage } from "@/services/image-storage";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
import { registerPlugin } from "../plugin-registry";
|
||||
import type { AssetSourceProvider, ExternalAssetItem, PluginHostContext, RegisteredPlugin } from "../plugin-types";
|
||||
|
||||
export const EAGLE_PLUGIN_ID = "eagle-asset-connector";
|
||||
export const EAGLE_DEFAULT_BASE_URL = "http://127.0.0.1:41595";
|
||||
|
||||
export const eagleAssetPlugin: RegisteredPlugin = {
|
||||
manifest: {
|
||||
id: EAGLE_PLUGIN_ID,
|
||||
name: "Eagle 素材库",
|
||||
version: "0.3.0",
|
||||
apiVersion: "1",
|
||||
category: "asset-source",
|
||||
description: "把 Eagle 作为影策的外部素材来源,直接浏览原始文件夹并读写 Eagle 文件。",
|
||||
author: "影策社区",
|
||||
surfaces: ["asset-source"],
|
||||
permissions: ["asset.read", "asset.search", "asset.upload", "external.open"],
|
||||
trusted: true,
|
||||
},
|
||||
createAssetSource: ({ config }: PluginHostContext) => {
|
||||
const configuredBaseUrl = config.baseUrl;
|
||||
return createEagleAssetSource(typeof configuredBaseUrl === "string" && configuredBaseUrl.trim() ? configuredBaseUrl.trim() : undefined);
|
||||
},
|
||||
};
|
||||
|
||||
export function createEagleAssetSource(baseUrl = EAGLE_DEFAULT_BASE_URL): AssetSourceProvider {
|
||||
let folderCache: EagleFolder[] | null = null;
|
||||
|
||||
const getFolders = async (signal?: AbortSignal) => {
|
||||
if (signal?.aborted) throw new DOMException("请求已取消", "AbortError");
|
||||
if (!folderCache) folderCache = (await getEagleLibrary(baseUrl)).library.folders;
|
||||
return folderCache;
|
||||
};
|
||||
|
||||
return {
|
||||
listFolders: async (signal) => (await getFolders(signal)).map((folder) => ({ id: folder.id, name: folder.name, parentId: folder.parentId })),
|
||||
list: async (query) => {
|
||||
const [result, folders] = await Promise.all([listEagleItems({ baseUrl, folderId: query.folderId, keyword: query.keyword, limit: query.limit, offset: query.offset }), getFolders(query.signal)]);
|
||||
const pathMap = buildFolderPathMap(folders);
|
||||
return result.items.map((item) => toExternalItem(item, baseUrl, pathMap));
|
||||
},
|
||||
importAsset: (item, signal) => importEagleAsset(item, baseUrl, signal),
|
||||
uploadAsset: (asset, signal) => uploadEagleAsset(asset, baseUrl, undefined, signal),
|
||||
uploadAssetToFolder: (asset, folderId, signal) => uploadEagleAsset(asset, baseUrl, folderId, signal),
|
||||
uploadFile: (file, folderId, signal) => uploadEagleFile(file, baseUrl, folderId, signal),
|
||||
createFolder: async (name, parentId) => {
|
||||
await createEagleFolder(baseUrl, { name, parentId });
|
||||
folderCache = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function importEagleAsset(item: ExternalAssetItem, baseUrl: string, signal?: AbortSignal): Promise<Asset> {
|
||||
const kind = item.kind;
|
||||
if (kind === "text" || kind === "entity") throw new Error("当前 Eagle 导入暂不支持文本或实体素材");
|
||||
const blob = await downloadEagleItem(item.id, baseUrl, signal);
|
||||
const now = new Date().toISOString();
|
||||
const metadata = {
|
||||
source: "eagle",
|
||||
eagle: {
|
||||
itemId: item.id,
|
||||
baseUrl,
|
||||
folderId: item.folderId,
|
||||
folderIds: item.folderIds || [],
|
||||
folderPath: item.folderPath || [],
|
||||
extension: item.metadata?.extension,
|
||||
},
|
||||
};
|
||||
if (kind === "image") {
|
||||
const uploaded = await uploadImage(blob);
|
||||
return {
|
||||
id: nanoid(), kind, title: item.title, coverUrl: uploaded.url, tags: item.tags || [], source: "Eagle 素材库", createdAt: now, updatedAt: now, metadata,
|
||||
data: { dataUrl: uploaded.url, storageKey: uploaded.storageKey, width: item.width || uploaded.width, height: item.height || uploaded.height, bytes: uploaded.bytes, mimeType: uploaded.mimeType },
|
||||
};
|
||||
}
|
||||
const uploaded = await uploadMediaFile(blob, `eagle-${kind}`);
|
||||
if (kind === "video") {
|
||||
return {
|
||||
id: nanoid(), kind, title: item.title, coverUrl: "", tags: item.tags || [], source: "Eagle 素材库", createdAt: now, updatedAt: now, metadata,
|
||||
data: { url: uploaded.url, storageKey: uploaded.storageKey, width: item.width || uploaded.width || 1280, height: item.height || uploaded.height || 720, bytes: uploaded.bytes, mimeType: uploaded.mimeType },
|
||||
};
|
||||
}
|
||||
if (kind === "audio") {
|
||||
return {
|
||||
id: nanoid(), kind, title: item.title, coverUrl: "", tags: item.tags || [], source: "Eagle 素材库", createdAt: now, updatedAt: now, metadata,
|
||||
data: { url: uploaded.url, storageKey: uploaded.storageKey, durationMs: uploaded.durationMs, bytes: uploaded.bytes, mimeType: uploaded.mimeType },
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: nanoid(), kind: "model", title: item.title, coverUrl: "", tags: item.tags || [], source: "Eagle 素材库", createdAt: now, updatedAt: now, metadata,
|
||||
data: { url: uploaded.url, storageKey: uploaded.storageKey, bytes: uploaded.bytes, mimeType: uploaded.mimeType, fileName: item.title },
|
||||
};
|
||||
}
|
||||
|
||||
type EagleWritableAsset = Extract<Asset, { kind: "image" | "video" | "audio" }>;
|
||||
|
||||
async function uploadEagleAsset(asset: Asset, baseUrl: string, folderId?: string, signal?: AbortSignal): Promise<ExternalAssetItem> {
|
||||
if (!isEagleWritableAsset(asset)) throw new Error("当前仅支持将图片、视频或音频写回 Eagle");
|
||||
const dataUrl = await assetToDataUrl(asset, signal);
|
||||
const result = await addEagleItem(baseUrl, {
|
||||
url: dataUrl,
|
||||
name: eagleAssetName(asset),
|
||||
folderId,
|
||||
tags: asset.tags,
|
||||
annotation: asset.note,
|
||||
website: typeof asset.metadata?.eagle === "object" && asset.metadata.eagle && "url" in asset.metadata.eagle ? String(asset.metadata.eagle.url || "") : undefined,
|
||||
});
|
||||
return { id: result.item.id || "eagle:" + asset.id, title: eagleAssetName(asset), kind: asset.kind, tags: asset.tags, mimeType: asset.data.mimeType };
|
||||
}
|
||||
|
||||
function isEagleWritableAsset(asset: Asset): asset is EagleWritableAsset {
|
||||
return asset.kind === "image" || asset.kind === "video" || asset.kind === "audio";
|
||||
}
|
||||
|
||||
async function assetToDataUrl(asset: EagleWritableAsset, signal?: AbortSignal) {
|
||||
if (asset.kind === "image") {
|
||||
return imageToDataUrl({ dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, url: asset.data.dataUrl, name: asset.title, mimeType: asset.data.mimeType });
|
||||
}
|
||||
const stored = asset.data.storageKey ? await getMediaBlob(asset.data.storageKey) : null;
|
||||
if (stored) return fileToDataUrl(withMimeType(stored, asset.data.mimeType), signal);
|
||||
const url = asset.data.url;
|
||||
if (!url) throw new Error("生成结果没有可写入 Eagle 的媒体地址");
|
||||
if (url.startsWith("data:")) return url;
|
||||
const response = await fetch(url, { credentials: "include", signal });
|
||||
if (!response.ok) throw new Error("读取生成媒体失败,无法写入 Eagle");
|
||||
return fileToDataUrl(withMimeType(await response.blob(), asset.data.mimeType), signal);
|
||||
}
|
||||
|
||||
function eagleAssetName(asset: EagleWritableAsset) {
|
||||
const title = asset.title.trim() || (asset.kind === "image" ? "生成图片" : asset.kind === "video" ? "生成视频" : "生成音频");
|
||||
if (/\.[a-z0-9]{2,8}$/i.test(title)) return title;
|
||||
const extension = asset.kind === "image" ? extensionFromMime(asset.data.mimeType, "png") : asset.kind === "video" ? extensionFromMime(asset.data.mimeType, "mp4") : extensionFromMime(asset.data.mimeType, "mp3");
|
||||
return title + "." + extension;
|
||||
}
|
||||
|
||||
function extensionFromMime(mimeType: string, fallback: string) {
|
||||
const value = mimeType.toLowerCase();
|
||||
if (value.includes("png")) return "png";
|
||||
if (value.includes("jpeg") || value.includes("jpg")) return "jpg";
|
||||
if (value.includes("webm")) return "webm";
|
||||
if (value.includes("quicktime")) return "mov";
|
||||
if (value.includes("wav")) return "wav";
|
||||
if (value.includes("ogg")) return "ogg";
|
||||
if (value.includes("mp4")) return "mp4";
|
||||
if (value.includes("mpeg") || value.includes("mp3")) return "mp3";
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function withMimeType(blob: Blob, mimeType: string) {
|
||||
return mimeType && blob.type !== mimeType ? blob.slice(0, blob.size, mimeType) : blob;
|
||||
}
|
||||
async function uploadEagleFile(file: File, baseUrl: string, folderId?: string, signal?: AbortSignal): Promise<ExternalAssetItem> {
|
||||
if (file.size > 96 * 1024 * 1024) throw new Error("单个文件不能超过 96 MB");
|
||||
if (!isEagleMediaType(file.type)) throw new Error("Eagle 写入目前支持图片、视频和音频文件");
|
||||
const dataUrl = await fileToDataUrl(file, signal);
|
||||
const result = await addEagleItem(baseUrl, {
|
||||
url: dataUrl,
|
||||
name: file.name,
|
||||
folderId,
|
||||
});
|
||||
const itemId = result.item.id || "eagle:" + nanoid();
|
||||
const extension = file.name.includes(".") ? file.name.split(".").pop() || "" : "";
|
||||
return {
|
||||
id: itemId,
|
||||
title: file.name,
|
||||
kind: kindFromExtension(extension),
|
||||
thumbnailUrl: result.item.id ? eagleItemThumbnailUrl(result.item.id, baseUrl) : undefined,
|
||||
fileUrl: result.item.id ? eagleItemFileUrl(result.item.id, baseUrl) : undefined,
|
||||
mimeType: file.type || mimeTypeFromExtension(extension),
|
||||
bytes: file.size,
|
||||
folderId,
|
||||
folderIds: folderId ? [folderId] : [],
|
||||
metadata: { extension },
|
||||
};
|
||||
}
|
||||
|
||||
function isEagleMediaType(mimeType: string) {
|
||||
const value = mimeType.toLowerCase();
|
||||
return value.startsWith("image/") || value.startsWith("video/") || value.startsWith("audio/");
|
||||
}
|
||||
function fileToDataUrl(file: Blob, signal?: AbortSignal): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
const cleanup = () => signal?.removeEventListener("abort", abort);
|
||||
const abort = () => {
|
||||
reader.abort();
|
||||
cleanup();
|
||||
reject(new DOMException("请求已取消", "AbortError"));
|
||||
};
|
||||
reader.onload = () => {
|
||||
cleanup();
|
||||
if (typeof reader.result !== "string") {
|
||||
reject(new Error("无法读取待写入的文件"));
|
||||
return;
|
||||
}
|
||||
resolve(reader.result);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
cleanup();
|
||||
reject(new Error("无法读取待写入的文件"));
|
||||
};
|
||||
if (signal?.aborted) {
|
||||
abort();
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function toExternalItem(item: EagleItem, baseUrl: string, pathMap: Map<string, string[]>) {
|
||||
const kind = kindFromExtension(item.extension);
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
kind,
|
||||
thumbnailUrl: eagleItemThumbnailUrl(item.id, baseUrl),
|
||||
fileUrl: eagleItemFileUrl(item.id, baseUrl),
|
||||
mimeType: mimeTypeFromExtension(item.extension),
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
bytes: item.size,
|
||||
tags: item.tags,
|
||||
folderId: item.folderIds[0],
|
||||
folderIds: item.folderIds,
|
||||
folderPath: item.folderIds[0] ? pathMap.get(item.folderIds[0]) : [],
|
||||
description: item.annotation,
|
||||
metadata: { extension: item.extension, modificationTime: item.modificationTime, url: item.url, deleted: item.deleted },
|
||||
} satisfies ExternalAssetItem;
|
||||
}
|
||||
|
||||
function buildFolderPathMap(folders: EagleFolder[]) {
|
||||
const byId = new Map(folders.map((folder) => [folder.id, folder]));
|
||||
const result = new Map<string, string[]>();
|
||||
for (const folder of folders) {
|
||||
const path: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let current: EagleFolder | undefined = folder;
|
||||
while (current && !seen.has(current.id)) {
|
||||
seen.add(current.id);
|
||||
path.unshift(current.name);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
result.set(folder.id, path);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function kindFromExtension(extension: string): Asset["kind"] {
|
||||
const value = extension.toLowerCase();
|
||||
if (["png", "jpg", "jpeg", "gif", "webp", "bmp", "avif", "svg"].includes(value)) return "image";
|
||||
if (["mp4", "mov", "webm", "mkv", "avi"].includes(value)) return "video";
|
||||
if (["mp3", "wav", "m4a", "aac", "flac", "ogg"].includes(value)) return "audio";
|
||||
if (["glb", "gltf", "obj", "fbx", "usdz", "blend"].includes(value)) return "model";
|
||||
return "model";
|
||||
}
|
||||
|
||||
function mimeTypeFromExtension(extension: string) {
|
||||
const value = extension.toLowerCase();
|
||||
if (["jpg", "jpeg"].includes(value)) return "image/jpeg";
|
||||
if (value === "png") return "image/png";
|
||||
if (value === "webp") return "image/webp";
|
||||
if (value === "gif") return "image/gif";
|
||||
if (value === "svg") return "image/svg+xml";
|
||||
if (value === "mp4") return "video/mp4";
|
||||
if (value === "webm") return "video/webm";
|
||||
if (value === "mp3") return "audio/mpeg";
|
||||
if (value === "wav") return "audio/wav";
|
||||
if (value === "glb") return "model/gltf-binary";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
|
||||
registerPlugin(eagleAssetPlugin);
|
||||
1
web/src/lib/plugins/builtin/index.ts
Normal file
1
web/src/lib/plugins/builtin/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "./eagle";
|
||||
35
web/src/lib/plugins/plugin-registry.ts
Normal file
35
web/src/lib/plugins/plugin-registry.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { PluginManifest, RegisteredPlugin } from "./plugin-types";
|
||||
|
||||
const registeredPlugins = new Map<string, RegisteredPlugin>();
|
||||
|
||||
function assertManifest(manifest: PluginManifest) {
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(manifest.id)) throw new Error("插件 ID 必须使用 kebab-case");
|
||||
if (!manifest.name.trim() || !manifest.version.trim() || !manifest.apiVersion.trim()) throw new Error("插件清单缺少名称、版本或 API 版本");
|
||||
if (!manifest.surfaces.length) throw new Error("插件至少需要声明一种界面形态");
|
||||
if (new Set(manifest.permissions).size !== manifest.permissions.length) throw new Error("插件权限不能重复");
|
||||
}
|
||||
|
||||
export function registerPlugin(plugin: RegisteredPlugin) {
|
||||
assertManifest(plugin.manifest);
|
||||
const existing = registeredPlugins.get(plugin.manifest.id);
|
||||
if (existing && existing.manifest.version !== plugin.manifest.version) {
|
||||
throw new Error(`插件 ${plugin.manifest.id} 已注册其他版本`);
|
||||
}
|
||||
registeredPlugins.set(plugin.manifest.id, plugin);
|
||||
}
|
||||
|
||||
export function unregisterPlugin(pluginId: string) {
|
||||
registeredPlugins.delete(pluginId);
|
||||
}
|
||||
|
||||
export function getRegisteredPlugin(pluginId: string) {
|
||||
return registeredPlugins.get(pluginId);
|
||||
}
|
||||
|
||||
export function listRegisteredPlugins() {
|
||||
return [...registeredPlugins.values()];
|
||||
}
|
||||
|
||||
export function listRegisteredManifests(): PluginManifest[] {
|
||||
return listRegisteredPlugins().map(({ manifest }) => manifest);
|
||||
}
|
||||
29
web/src/lib/plugins/plugin-storage.ts
Normal file
29
web/src/lib/plugins/plugin-storage.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { localForageStorageForScope } from "@/lib/localforage-storage";
|
||||
import type { PluginStorage } from "./plugin-types";
|
||||
|
||||
const PLUGIN_STORAGE_PREFIX = "infinite-canvas:plugin-storage:";
|
||||
|
||||
export function pluginStorageFor(pluginId: string): PluginStorage {
|
||||
const storage = localForageStorageForScope();
|
||||
const keyFor = (key: string) => PLUGIN_STORAGE_PREFIX + pluginId + ":" + key;
|
||||
return {
|
||||
get: async <T>(key: string) => {
|
||||
const value = await storage.getItem(keyFor(key));
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return value as T;
|
||||
}
|
||||
},
|
||||
set: async <T>(key: string, value: T) => {
|
||||
const serialized = JSON.stringify(value);
|
||||
if (serialized === undefined) {
|
||||
await storage.removeItem(keyFor(key));
|
||||
return;
|
||||
}
|
||||
await storage.setItem(keyFor(key), serialized);
|
||||
},
|
||||
remove: async (key) => { await storage.removeItem(keyFor(key)); },
|
||||
};
|
||||
}
|
||||
109
web/src/lib/plugins/plugin-types.ts
Normal file
109
web/src/lib/plugins/plugin-types.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
|
||||
export const PLUGIN_API_VERSION = "1" as const;
|
||||
|
||||
export type PluginCategory = "asset-source" | "canvas-node" | "workflow" | "ai-capability" | "import-export" | "agent";
|
||||
export type PluginSurface = "node" | "fullscreen" | "hybrid" | "asset-source";
|
||||
export type PluginPermission =
|
||||
| "canvas.read"
|
||||
| "canvas.write"
|
||||
| "asset.read"
|
||||
| "asset.search"
|
||||
| "asset.import"
|
||||
| "asset.upload"
|
||||
| "generation.run"
|
||||
| "external.open";
|
||||
|
||||
export type PluginManifest = {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
apiVersion: string;
|
||||
category: PluginCategory;
|
||||
description: string;
|
||||
author?: string;
|
||||
entry?: string;
|
||||
surfaces: PluginSurface[];
|
||||
permissions: PluginPermission[];
|
||||
trusted?: boolean;
|
||||
};
|
||||
|
||||
export type PluginStorage = {
|
||||
get<T>(key: string): Promise<T | null>;
|
||||
set<T>(key: string, value: T): Promise<void>;
|
||||
remove(key: string): Promise<void>;
|
||||
};
|
||||
|
||||
export type PluginHostContext = {
|
||||
manifest: PluginManifest;
|
||||
permissions: ReadonlySet<PluginPermission>;
|
||||
storage: PluginStorage;
|
||||
config: Readonly<PluginInstallation["config"]>;
|
||||
};
|
||||
|
||||
export type AssetSourceQuery = {
|
||||
keyword?: string;
|
||||
folderId?: string;
|
||||
tags?: string[];
|
||||
kind?: Asset["kind"];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type ExternalAssetFolder = {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId?: string;
|
||||
};
|
||||
|
||||
export type ExternalAssetItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
kind: Asset["kind"];
|
||||
thumbnailUrl?: string;
|
||||
fileUrl?: string;
|
||||
mimeType?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
bytes?: number;
|
||||
tags?: string[];
|
||||
folderId?: string;
|
||||
folderIds?: string[];
|
||||
folderPath?: string[];
|
||||
description?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ExternalAssetPickerReference = {
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
item: ExternalAssetItem;
|
||||
};
|
||||
|
||||
export type AssetSourceProvider = {
|
||||
listFolders?: (signal?: AbortSignal) => Promise<ExternalAssetFolder[]>;
|
||||
list?: (query: AssetSourceQuery) => Promise<ExternalAssetItem[]>;
|
||||
importAsset?: (item: ExternalAssetItem, signal?: AbortSignal) => Promise<Asset>;
|
||||
uploadAsset?: (asset: Asset, signal?: AbortSignal) => Promise<ExternalAssetItem>;
|
||||
uploadAssetToFolder?: (asset: Asset, folderId?: string, signal?: AbortSignal) => Promise<ExternalAssetItem>;
|
||||
uploadFile?: (file: File, folderId?: string, signal?: AbortSignal) => Promise<ExternalAssetItem>;
|
||||
createFolder?: (name: string, parentId?: string) => Promise<void>;
|
||||
openAsset?: (item: ExternalAssetItem) => Promise<void>;
|
||||
};
|
||||
|
||||
export type RegisteredPlugin = {
|
||||
manifest: PluginManifest;
|
||||
activate?: (context: PluginHostContext) => Promise<void> | void;
|
||||
deactivate?: (context: PluginHostContext) => Promise<void> | void;
|
||||
createAssetSource?: (context: PluginHostContext) => AssetSourceProvider;
|
||||
};
|
||||
|
||||
export type PluginInstallation = {
|
||||
manifest: PluginManifest;
|
||||
enabled: boolean;
|
||||
config: Record<string, string | number | boolean>;
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
lastError?: string;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AudioLines, Box, CheckCheck, Clapperboard, Copy, Download, FileText, FileUp, Image as ImageIcon, Link2, MoreHorizontal, PencilLine, Play, Plus, Search, Trash2, Upload, type LucideIcon } from "lucide-react";
|
||||
import { AudioLines, Box, CheckCheck, Clapperboard, Copy, Download, FileText, FileUp, FolderOpen, Image as ImageIcon, Link2, MoreHorizontal, PencilLine, Play, Plus, Search, Trash2, Upload, type LucideIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { App, Button, Drawer, Dropdown, Form, Input, Modal, Select, Space, Tag, Typography } from "antd";
|
||||
@@ -21,6 +21,7 @@ import { exportAssets, readAssetPackage } from "./asset-transfer";
|
||||
import { AssetStorageUsage, assetStorageUsageQueryKey } from "./asset-storage-usage";
|
||||
import { deleteAssetWithRemoteSync } from "@/services/user-data-sync";
|
||||
|
||||
|
||||
type LibraryAsset = Exclude<Asset, { kind: "entity" }>;
|
||||
|
||||
type AssetFormValues = {
|
||||
@@ -76,6 +77,7 @@ export default function AssetsPage() {
|
||||
const modelInputRef = useRef<HTMLInputElement>(null);
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const addAsset = useAssetStore((state) => state.addAsset);
|
||||
|
||||
const updateAsset = useAssetStore((state) => state.updateAsset);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [kindFilter, setKindFilter] = useState<AssetKind | "all">("all");
|
||||
@@ -88,6 +90,7 @@ export default function AssetsPage() {
|
||||
const [deletingAsset, setDeletingAsset] = useState<LibraryAsset | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false);
|
||||
|
||||
const [formKind, setFormKind] = useState<AssetKind>("text");
|
||||
const [imageDraft, setImageDraft] = useState<ImageDraft>(null);
|
||||
const coverUrl = Form.useWatch("coverUrl", form) || "";
|
||||
@@ -273,6 +276,7 @@ export default function AssetsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkspacePage grid className="library-page assets-library-page canvas-library-page">
|
||||
@@ -285,6 +289,7 @@ export default function AssetsPage() {
|
||||
<div className="assets-header-actions">
|
||||
<div className="assets-header-action-buttons">
|
||||
<Button className="library-primary-action" type="primary" icon={<Plus className="size-3.5" />} onClick={openCreate}>新增素材</Button>
|
||||
<Button icon={<FolderOpen className="size-3.5" />} onClick={() => navigate("/plugins/eagle")}>Eagle 素材库</Button>
|
||||
<Button title="导出全部素材" aria-label="导出全部素材" icon={<Download className="size-4" />} onClick={() => void exportAllAssets()} />
|
||||
<Dropdown trigger={["click"]} menu={{ items: [{ key: "package", icon: <FileUp className="size-4" />, label: "导入素材包", onClick: () => assetInputRef.current?.click() }, { key: "model", icon: <Upload className="size-4" />, label: "上传 3D 模型", onClick: () => modelInputRef.current?.click() }] }}>
|
||||
<Button title="导入素材" aria-label="导入素材" icon={<FileUp className="size-4" />} />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button, Dropdown, Modal, Tooltip } from "antd";
|
||||
|
||||
import { useWalletBalance } from "@/hooks/use-wallet-balance";
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
|
||||
import type { CanvasContextSummary } from "@/lib/canvas/canvas-context-summary";
|
||||
import type { CanvasShortDramaProgress } from "@/lib/canvas/canvas-short-drama";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
@@ -15,8 +16,6 @@ import type { CanvasMediaPerformanceMode, CanvasWorkspaceMode } from "@/types/ca
|
||||
|
||||
type CanvasTopBarProps = {
|
||||
title: string;
|
||||
workspaceMode: CanvasWorkspaceMode;
|
||||
onWorkspaceModeChange: (mode: CanvasWorkspaceMode) => void;
|
||||
titleDraft: string;
|
||||
isTitleEditing: boolean;
|
||||
onTitleDraftChange: (value: string) => void;
|
||||
@@ -47,8 +46,6 @@ type CanvasTopBarProps = {
|
||||
|
||||
export function CanvasTopBar({
|
||||
title,
|
||||
workspaceMode,
|
||||
onWorkspaceModeChange,
|
||||
titleDraft,
|
||||
isTitleEditing,
|
||||
onTitleDraftChange,
|
||||
@@ -77,22 +74,14 @@ export function CanvasTopBar({
|
||||
shortDramaGuide,
|
||||
}: CanvasTopBarProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const dockStyle = canvasDockStyle(theme, theme.node.text);
|
||||
const user = useUserStore((state) => state.user);
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const { availableMicrocredits, refreshing } = useWalletBalance(user?.id, creditsEnabled);
|
||||
const titleRef = useRef<HTMLDivElement>(null);
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const [workspaceModeOpen, setWorkspaceModeOpen] = useState(false);
|
||||
|
||||
const handleWorkspaceModeOpenChange = (next: boolean) => {
|
||||
// 模式开关下拉向下展开,会覆盖展开中的短剧流程条,打开时先收起流程条避免重叠。
|
||||
if (next && shortDramaGuide && !shortDramaGuide.collapsed) shortDramaGuide.onToggle();
|
||||
setWorkspaceModeOpen(next);
|
||||
};
|
||||
|
||||
const handleShortDramaGuideToggle = () => {
|
||||
// 展开流程条会与模式开关下拉在顶部中心重叠,展开前先关闭模式开关。
|
||||
if (shortDramaGuide?.collapsed) setWorkspaceModeOpen(false);
|
||||
shortDramaGuide?.onToggle();
|
||||
};
|
||||
|
||||
@@ -112,7 +101,7 @@ export function CanvasTopBar({
|
||||
return (
|
||||
<>
|
||||
<div className="canvas-topbar pointer-events-none absolute left-0 right-0 top-0 z-[var(--z-toolbar)] flex h-[var(--canvas-topbar-h)] items-center justify-between px-4 sm:px-5">
|
||||
<div className="canvas-topbar-cluster canvas-topbar-project-cluster pointer-events-auto flex min-w-0 items-center gap-2">
|
||||
<div className="canvas-topbar-cluster canvas-topbar-project-cluster pointer-events-auto flex min-w-0 items-center gap-2" style={dockStyle}>
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
@@ -141,7 +130,7 @@ export function CanvasTopBar({
|
||||
],
|
||||
}}
|
||||
>
|
||||
<button type="button" className="grid size-9 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: theme.node.text }} aria-label="打开画布菜单">
|
||||
<button type="button" className="canvas-topbar-action grid size-9 place-items-center rounded-full" style={{ color: theme.node.text }} aria-label="打开画布菜单">
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
@@ -168,7 +157,7 @@ export function CanvasTopBar({
|
||||
{title}
|
||||
</button>
|
||||
<Tooltip title="重命名画布">
|
||||
<button type="button" className="grid size-7 shrink-0 place-items-center rounded-md opacity-60 transition hover:bg-black/5 hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 dark:hover:bg-white/10" style={{ color: theme.node.text }} onClick={onStartTitleEditing} aria-label="重命名画布">
|
||||
<button type="button" className="canvas-topbar-action grid size-7 shrink-0 place-items-center rounded-md opacity-60 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2" style={{ color: theme.node.text }} onClick={onStartTitleEditing} aria-label="重命名画布">
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
@@ -191,10 +180,8 @@ export function CanvasTopBar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CanvasWorkspaceModeSwitch mode={workspaceMode} onChange={onWorkspaceModeChange} open={workspaceModeOpen} onOpenChange={handleWorkspaceModeOpenChange} />
|
||||
|
||||
<div className="canvas-topbar-cluster pointer-events-auto flex items-center gap-1.5">
|
||||
<Button type="text" className="!hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Search className="size-4" />} onClick={onOpenSearch} aria-label="搜索画布节点" title="搜索画布节点" />
|
||||
<div className="canvas-topbar-cluster pointer-events-auto flex items-center gap-1.5" style={dockStyle}>
|
||||
<Button type="text" className="canvas-topbar-action !hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Search className="size-4" />} onClick={onOpenSearch} aria-label="搜索画布节点" title="搜索画布节点" />
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
placement="bottomRight"
|
||||
@@ -205,7 +192,7 @@ export function CanvasTopBar({
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button type="text" className="canvas-topbar-import-button !h-10 !rounded-xl !px-2.5 !font-medium" style={{ color: theme.node.text }} icon={<CloudDownload className="size-4" />} aria-label="导入第三方画布" title="导入第三方画布">
|
||||
<Button type="text" className="canvas-topbar-action canvas-topbar-import-button !h-10 !rounded-xl !px-2.5 !font-medium" style={{ color: theme.node.text }} icon={<CloudDownload className="size-4" />} aria-label="导入第三方画布" title="导入第三方画布">
|
||||
<span className="hidden lg:inline">导入第三方画布</span>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
@@ -222,13 +209,13 @@ export function CanvasTopBar({
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button type="text" className="!hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Gauge className="size-4" />} aria-label="媒体性能模式" title="媒体性能模式" />
|
||||
<Button type="text" className="canvas-topbar-action !hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Gauge className="size-4" />} aria-label="媒体性能模式" title="媒体性能模式" />
|
||||
</Dropdown>
|
||||
{compactAgentStatus ? <CompactAgentStatus status={compactAgentStatus} onClick={onToggleAgent} /> : null}
|
||||
{user && creditsEnabled ? (
|
||||
<Link
|
||||
to="/wallet"
|
||||
className="inline-flex h-9 min-w-[5.5rem] items-center justify-center gap-1.5 rounded-lg px-2.5 text-xs font-medium tabular-nums transition hover:bg-black/5 dark:hover:bg-white/10"
|
||||
className="canvas-topbar-action inline-flex h-9 min-w-[5.5rem] items-center justify-center gap-1.5 rounded-lg px-2.5 text-xs font-medium tabular-nums"
|
||||
style={{ color: theme.node.text }}
|
||||
title="查看积分明细"
|
||||
>
|
||||
@@ -239,7 +226,7 @@ export function CanvasTopBar({
|
||||
<Tooltip title="进入专注模式(⇧⌘F)">
|
||||
<Button
|
||||
type="text"
|
||||
className="!h-10 !w-10 !min-w-10 !rounded-xl !p-0"
|
||||
className="canvas-topbar-action !h-10 !w-10 !min-w-10 !rounded-xl !p-0"
|
||||
style={{ color: theme.node.text }}
|
||||
icon={<Focus className="size-4" />}
|
||||
onClick={onEnterFocusMode}
|
||||
@@ -250,24 +237,26 @@ export function CanvasTopBar({
|
||||
<Tooltip title={shortDramaGuide.collapsed ? "展开短剧流程" : "收起短剧流程"}>
|
||||
<Button
|
||||
type="text"
|
||||
className="!h-10 !rounded-xl !px-2.5 !font-medium"
|
||||
className="canvas-topbar-action !h-10 !rounded-xl !px-2.5 !font-medium"
|
||||
style={{ color: theme.node.text, background: shortDramaGuide.collapsed ? undefined : theme.toolbar.activeBg }}
|
||||
icon={<Clapperboard className="size-4" />}
|
||||
onClick={handleShortDramaGuideToggle}
|
||||
aria-label="短剧流程"
|
||||
aria-pressed={!shortDramaGuide.collapsed}
|
||||
>
|
||||
<span className="tabular-nums">{shortDramaGuide.progress.completedCount}/5</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Button type="text" className="!h-10 !w-10 !min-w-10 !rounded-xl !p-0" style={{ color: theme.node.text }} icon={<Share2 className="size-4" />} onClick={onShare} aria-label="分享画布" title="分享画布" />
|
||||
<Button type="text" className="canvas-topbar-action !h-10 !w-10 !min-w-10 !rounded-xl !p-0" style={{ color: theme.node.text }} icon={<Share2 className="size-4" />} onClick={onShare} aria-label="分享画布" title="分享画布" />
|
||||
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
|
||||
<Button
|
||||
type="text"
|
||||
className="!h-10 !rounded-xl !px-3 !font-medium"
|
||||
className="canvas-topbar-action !h-10 !rounded-xl !px-3 !font-medium"
|
||||
style={{ background: agentOpen ? theme.toolbar.activeBg : "transparent", color: theme.node.text }}
|
||||
icon={<Bot className="size-4" />}
|
||||
onClick={onToggleAgent}
|
||||
aria-pressed={agentOpen}
|
||||
>
|
||||
Agent
|
||||
</Button>
|
||||
@@ -301,19 +290,20 @@ export function CanvasTopBar({
|
||||
);
|
||||
}
|
||||
|
||||
function CanvasWorkspaceModeSwitch({ mode, onChange, open, onOpenChange }: { mode: CanvasWorkspaceMode; onChange: (mode: CanvasWorkspaceMode) => void; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
export function CanvasWorkspaceModeSwitch({ mode, onChange }: { mode: CanvasWorkspaceMode; onChange: (mode: CanvasWorkspaceMode) => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const reducedMotion = useReducedMotion();
|
||||
const simple = mode === "simple";
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const closeOnOutsidePress = (event: PointerEvent) => {
|
||||
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) onOpenChange(false);
|
||||
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setOpen(false);
|
||||
};
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onOpenChange(false);
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", closeOnOutsidePress);
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
@@ -321,26 +311,26 @@ function CanvasWorkspaceModeSwitch({ mode, onChange, open, onOpenChange }: { mod
|
||||
document.removeEventListener("pointerdown", closeOnOutsidePress);
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [open, onOpenChange]);
|
||||
}, [open]);
|
||||
|
||||
const selectMode = (nextMode: CanvasWorkspaceMode) => {
|
||||
if (nextMode !== mode) onChange(nextMode);
|
||||
onOpenChange(false);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="aceternity-mode-switch pointer-events-auto absolute left-1/2 top-2 z-[var(--dock-z-popover)] -translate-x-1/2">
|
||||
<div ref={rootRef} className="aceternity-mode-switch pointer-events-auto relative z-[var(--dock-z-popover)]">
|
||||
<motion.button
|
||||
type="button"
|
||||
whileHover={reducedMotion ? undefined : { y: -1 }}
|
||||
whileTap={reducedMotion ? undefined : { scale: 0.97 }}
|
||||
transition={aceternityMotion.spring.dock}
|
||||
className="flex h-9 min-w-28 items-center gap-2 rounded-full px-2.5 text-left outline-none backdrop-blur-2xl focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
style={{ background: open ? theme.spatial.surface : theme.spatial.elevated, color: theme.node.text, boxShadow: "var(--workspace-overlay-shadow)", outlineColor: theme.accent.primary }}
|
||||
className="canvas-mode-switch-trigger flex h-10 min-w-28 items-center gap-2 rounded-xl border px-2.5 text-left outline-none backdrop-blur-2xl focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
style={{ ...canvasDockStyle(theme, theme.node.text), background: open ? "var(--dock-command-active)" : "var(--dock-surface)", outlineColor: theme.accent.primary }}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={`当前为${simple ? "简洁" : "专业"}模式,点击切换`}
|
||||
onClick={() => onOpenChange(!open)}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<span className="grid size-6 shrink-0 place-items-center rounded-full" style={{ background: theme.toolbar.itemHover, color: theme.accent.primary }}>
|
||||
{simple ? <Sparkles className="size-3" /> : <Settings2 className="size-3" />}
|
||||
@@ -351,18 +341,18 @@ function CanvasWorkspaceModeSwitch({ mode, onChange, open, onOpenChange }: { mod
|
||||
</motion.span>
|
||||
</motion.button>
|
||||
|
||||
<div className="absolute left-1/2 top-11 w-72 -translate-x-1/2" style={{ maxWidth: "calc(100vw - var(--space-8))" }}>
|
||||
<div className="absolute bottom-11 right-0 w-72" style={{ maxWidth: "calc(100vw - var(--space-8))" }}>
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
role="listbox"
|
||||
aria-label="选择画布工作模式"
|
||||
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -8, scale: 0.92 }}
|
||||
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.92 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.95 }}
|
||||
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.95 }}
|
||||
transition={aceternityMotion.spring.panel}
|
||||
className="aceternity-floating-panel w-full overflow-hidden rounded-[var(--r-lg)] p-1.5 backdrop-blur-2xl"
|
||||
style={{ background: theme.spatial.elevated, color: theme.node.text, boxShadow: "var(--workspace-overlay-shadow)" }}
|
||||
className="canvas-mode-switch-menu aceternity-floating-panel w-full overflow-hidden rounded-[var(--r-lg)] border p-1.5 backdrop-blur-2xl"
|
||||
style={{ ...canvasDockStyle(theme, theme.node.text), background: "var(--dock-surface)" }}
|
||||
>
|
||||
<ModeOption active={simple} motionEnabled={!reducedMotion} icon={<Sparkles className="size-4" />} title="简洁模式" description="保留核心创作路径,降低参数密度" theme={theme} onClick={() => selectMode("simple")} />
|
||||
<ModeOption active={!simple} motionEnabled={!reducedMotion} icon={<Settings2 className="size-4" />} title="专业模式" description="显示完整节点、导演台与生成控制" theme={theme} onClick={() => selectMode("professional")} />
|
||||
@@ -384,7 +374,7 @@ function ModeOption({ active, motionEnabled, icon, title, description, theme, on
|
||||
aria-selected={active}
|
||||
whileTap={motionEnabled ? { scale: 0.98 } : undefined}
|
||||
transition={aceternityMotion.spring.dock}
|
||||
className="group flex min-h-14 w-full items-center gap-3 rounded-[var(--r-md)] px-3 py-2 text-left outline-none transition-colors hover:bg-black/5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-0 dark:hover:bg-white/8"
|
||||
className="canvas-mode-switch-option group flex min-h-14 w-full items-center gap-3 rounded-[var(--r-md)] px-3 py-2 text-left outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-0"
|
||||
style={{ background: active ? theme.accent.primarySoft : "transparent", color: theme.node.text, outlineColor: theme.accent.primary }}
|
||||
onClick={onClick}
|
||||
>
|
||||
@@ -414,7 +404,7 @@ function CompactAgentStatus({ status, onClick }: { status: { connected: boolean;
|
||||
const label = status.connected ? "已连接到本地 Codex" : status.enabled ? status.activity || "连接中" : "正在连接本地 Codex";
|
||||
const dotColor = status.connected ? "#22c55e" : status.enabled ? "#f59e0b" : theme.node.muted;
|
||||
return (
|
||||
<button type="button" className="flex h-10 items-center gap-2 rounded-xl px-3 text-sm font-medium transition hover:opacity-85" style={{ background: theme.toolbar.panel, color: theme.node.text, boxShadow: "0 10px 30px rgba(28,25,23,.10)" }} onClick={onClick} title="打开本地 Codex 面板">
|
||||
<button type="button" className="canvas-topbar-action flex h-10 items-center gap-2 rounded-xl px-3 text-sm font-medium" style={{ background: "transparent", color: theme.node.text }} onClick={onClick} title="打开本地 Codex 面板">
|
||||
<span className="size-2 rounded-full" style={{ background: dotColor }} />
|
||||
<span className="max-w-[180px] truncate">{label}</span>
|
||||
</button>
|
||||
|
||||
@@ -73,7 +73,7 @@ import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection";
|
||||
import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard-progress";
|
||||
import { CanvasAgentChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
|
||||
import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation";
|
||||
import { CanvasTopBar } from "./canvas-project-top-bar";
|
||||
import { CanvasTopBar, CanvasWorkspaceModeSwitch } from "./canvas-project-top-bar";
|
||||
import { LibTVImportDialog } from "./components/libtv-import-dialog";
|
||||
import { TapNowImportDialog } from "./components/tapnow-import-dialog";
|
||||
import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar";
|
||||
@@ -1752,8 +1752,6 @@ function InfiniteCanvasPage() {
|
||||
{!focusMode ? (
|
||||
<CanvasTopBar
|
||||
title={currentProject?.title || "未命名画布"}
|
||||
workspaceMode={workspaceMode}
|
||||
onWorkspaceModeChange={setWorkspaceMode}
|
||||
titleDraft={titleDraft}
|
||||
isTitleEditing={titleEditing}
|
||||
onTitleDraftChange={setTitleDraft}
|
||||
@@ -1791,6 +1789,18 @@ function InfiniteCanvasPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!focusMode ? (
|
||||
<div
|
||||
data-canvas-no-zoom
|
||||
className="pointer-events-none absolute bottom-[calc(var(--canvas-inset-y)+var(--space-16))] right-[var(--canvas-inset-x)] z-[var(--z-toolbar)] lg:bottom-[var(--canvas-inset-y)]"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
>
|
||||
<CanvasWorkspaceModeSwitch mode={workspaceMode} onChange={setWorkspaceMode} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CanvasNodeSearchModal
|
||||
open={nodeSearchOpen}
|
||||
nodes={nodes}
|
||||
@@ -2144,7 +2154,7 @@ function InfiniteCanvasPage() {
|
||||
{!focusMode ? (
|
||||
<div
|
||||
data-canvas-no-zoom
|
||||
className="absolute bottom-[calc(var(--canvas-inset-y)+var(--space-16))] left-4 z-[var(--z-panel)] flex items-end gap-2 lg:bottom-[var(--canvas-inset-y)]"
|
||||
className="absolute bottom-[calc(var(--canvas-inset-y)+var(--space-16))] left-[var(--canvas-inset-x)] z-[var(--z-panel)] flex items-end gap-2 lg:bottom-[var(--canvas-inset-y)]"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
|
||||
@@ -706,11 +706,18 @@ export function useCanvasUpload({
|
||||
const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
return { id, type: CanvasNodeType.Video, title: payload.title, position: { x: center.x - size.width / 2, y: center.y - size.height / 2 }, width: size.width, height: size.height, metadata: { content: payload.url, storageKey: payload.storageKey, status: NODE_STATUS_SUCCESS, naturalWidth: payload.width, naturalHeight: payload.height, durationMs: payload.durationMs, bytes: payload.bytes, mimeType: payload.mimeType || "video/mp4", assetId: payload.assetId } } satisfies CanvasNodeData;
|
||||
}
|
||||
const storedImage = payload.storageKey ? { url: payload.dataUrl, storageKey: payload.storageKey, width: 1, height: 1, bytes: 0, mimeType: "image/png" } : await uploadImage(payload.dataUrl);
|
||||
const meta = storedImage.width === 1 && storedImage.height === 1 ? await readImageMeta(storedImage.url) : storedImage;
|
||||
const storedImage = payload.url
|
||||
? { url: payload.url, storageKey: undefined, width: payload.width || 1, height: payload.height || 1, bytes: payload.bytes || 0, mimeType: payload.mimeType || "image/png" }
|
||||
: payload.storageKey
|
||||
? { url: payload.dataUrl, storageKey: payload.storageKey, width: 1, height: 1, bytes: 0, mimeType: "image/png" }
|
||||
: await uploadImage(payload.dataUrl);
|
||||
const meta = !payload.storageKey && (!payload.width || !payload.height) ? await readImageMeta(storedImage.url) : storedImage;
|
||||
const size = fitNodeSize(meta.width, meta.height);
|
||||
const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
return { id, type: CanvasNodeType.Image, title: payload.title.slice(0, 32) || "Generated Image", position: { x: center.x - size.width / 2, y: center.y - size.height / 2 }, width: size.width, height: size.height, metadata: { ...imageMetadata({ ...storedImage, width: meta.width, height: meta.height }), prompt: payload.title, assetId: payload.assetId } } satisfies CanvasNodeData;
|
||||
const metadata = storedImage.storageKey
|
||||
? imageMetadata({ ...storedImage, storageKey: storedImage.storageKey, width: meta.width, height: meta.height })
|
||||
: { content: storedImage.url, status: NODE_STATUS_SUCCESS, naturalWidth: meta.width, naturalHeight: meta.height, bytes: storedImage.bytes, mimeType: storedImage.mimeType };
|
||||
return { id, type: CanvasNodeType.Image, title: payload.title.slice(0, 32) || "Generated Image", position: { x: center.x - size.width / 2, y: center.y - size.height / 2 }, width: size.width, height: size.height, metadata: { ...metadata, prompt: payload.title, assetId: payload.assetId } } satisfies CanvasNodeData;
|
||||
}, []);
|
||||
|
||||
const handleAssetInsert = useCallback(async (payload: InsertAssetPayload, options: { openDialog?: boolean } = {}): Promise<CanvasNodeData | null> => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { UploadedFile } from "@/services/file-storage";
|
||||
import type { UploadedImage } from "@/services/image-storage";
|
||||
import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types";
|
||||
import type { Asset, AudioAsset, ImageAsset, NewAsset } from "@/stores/use-asset-store";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||
@@ -192,6 +193,49 @@ export function creationAttachmentFromAudioAsset(asset: AudioAsset): CreationAtt
|
||||
};
|
||||
}
|
||||
|
||||
export function creationAttachmentFromExternalAsset(reference: ExternalAssetPickerReference): CreationAttachment {
|
||||
const item = reference.item;
|
||||
const url = item.fileUrl || "";
|
||||
if (!url) throw new Error("“" + item.title + "”暂时无法读取,请先在 Eagle 中确认文件可用");
|
||||
const id = "external:" + reference.sourceId + ":" + item.id;
|
||||
const type = item.mimeType || (item.kind === "image" ? "image/png" : item.kind === "video" ? "video/mp4" : "audio/mpeg");
|
||||
if (item.kind === "image") {
|
||||
return {
|
||||
id,
|
||||
name: item.title || "素材图片",
|
||||
type,
|
||||
dataUrl: url,
|
||||
url,
|
||||
bytes: item.bytes,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
previewUrl: item.thumbnailUrl || url,
|
||||
};
|
||||
}
|
||||
if (item.kind === "video") {
|
||||
return {
|
||||
id,
|
||||
name: item.title || "素材视频",
|
||||
type,
|
||||
url,
|
||||
bytes: item.bytes,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
previewUrl: item.thumbnailUrl || url,
|
||||
};
|
||||
}
|
||||
if (item.kind === "audio") {
|
||||
return {
|
||||
id,
|
||||
name: item.title || "素材音频",
|
||||
type,
|
||||
url,
|
||||
bytes: item.bytes,
|
||||
previewUrl: item.thumbnailUrl || url,
|
||||
};
|
||||
}
|
||||
throw new Error("“" + item.title + "”不是可用于创作参考的媒体文件");
|
||||
}
|
||||
export function creationImageAsset({ title, uploaded, metadata }: { title: string; uploaded: UploadedImage; metadata?: Record<string, unknown> }): NewAsset {
|
||||
return {
|
||||
kind: "image",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { createGenerationBatchRetryContexts, createGenerationRetryContext, runGe
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
import { generationErrorCode, generationErrorMessage } from "@/lib/generation-error";
|
||||
import { useCopyText } from "@/hooks/use-copy-text";
|
||||
import { useExternalAssetSources } from "@/hooks/use-external-asset-sources";
|
||||
import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSize, imageResolutionChoices, imageResolutionOption, imageSizeForResolution, supportsImageResolutionPresets, type ImageResolutionChoice } from "@/lib/image-resolution-tiers";
|
||||
import { VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
|
||||
import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
|
||||
@@ -37,7 +38,7 @@ import { modelDisplayName, modelOptionName, resolveModelChannel, selectableModel
|
||||
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { buildCreationMentionReferences, creationReferenceMetadata, displayCreationPrompt, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, selectedCreationReferences, type CreationReference } from "./creation-references";
|
||||
import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, splitCreationAttachments, type CreationAttachment } from "./creation-assets";
|
||||
import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromExternalAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, splitCreationAttachments, type CreationAttachment } from "./creation-assets";
|
||||
|
||||
type CreationMode = "text" | "image" | "video";
|
||||
type CreationViewMode = "chat" | "storyboard";
|
||||
@@ -151,6 +152,7 @@ export default function CreatePage() {
|
||||
const [composingNextShot, setComposingNextShot] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [libraryOpen, setLibraryOpen] = useState(false);
|
||||
const externalAssetSources = useExternalAssetSources(libraryOpen);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const composerFocusRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -189,6 +191,13 @@ export default function CreatePage() {
|
||||
const imageProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).image!, [config, selectedModel]);
|
||||
const videoProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).video!, [config, selectedModel]);
|
||||
const maxReferences = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : mode === "image" ? imageProfile.references.maxImages : 6;
|
||||
const referenceImageSize = useMemo(() => {
|
||||
const imageAttachments = attachments.filter(isImageAttachment);
|
||||
if (imageAttachments.length !== 1) return undefined;
|
||||
const { width, height } = imageAttachments[0];
|
||||
if (typeof width !== "number" || typeof height !== "number" || width <= 0 || height <= 0) return undefined;
|
||||
return { width, height };
|
||||
}, [attachments]);
|
||||
const mentionReferences = useMemo(() => buildCreationMentionReferences(addedSkills, attachments, draftReferences), [addedSkills, attachments, draftReferences]);
|
||||
const isEmpty = !activeConversation?.messages.length;
|
||||
const pendingTaskKey = useMemo(() => pendingCreationTaskKey(conversations), [conversations]);
|
||||
@@ -344,17 +353,27 @@ export default function CreatePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const libraryItems = useMemo<AssetLibraryPickerItem[]>(() => assets
|
||||
.filter((asset): asset is Extract<Asset, { kind: "image" | "video" | "audio" }> => asset.kind === "image" || asset.kind === "video" || asset.kind === "audio")
|
||||
.map((asset) => ({
|
||||
id: asset.id,
|
||||
title: asset.title,
|
||||
category: asset.category || "other",
|
||||
kindLabel: asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "图片",
|
||||
asset,
|
||||
searchText: asset.tags.join(" "),
|
||||
disabledReason: mode === "image" && asset.kind !== "image" ? "图片创作仅支持参考图" : undefined,
|
||||
})), [assets, mode]);
|
||||
const externalLibraryItems = useMemo<AssetLibraryPickerItem[]>(
|
||||
() => externalAssetSources.items.map((item) => ({
|
||||
...item,
|
||||
disabledReason: mode === "image" && item.external?.item.kind !== "image" ? "图片创作仅支持参考图" : undefined,
|
||||
})),
|
||||
[externalAssetSources.items, mode],
|
||||
);
|
||||
const libraryItems = useMemo<AssetLibraryPickerItem[]>(() => [
|
||||
...assets
|
||||
.filter((asset): asset is Extract<Asset, { kind: "image" | "video" | "audio" }> => asset.kind === "image" || asset.kind === "video" || asset.kind === "audio")
|
||||
.map((asset) => ({
|
||||
id: asset.id,
|
||||
title: asset.title,
|
||||
category: asset.category || "other",
|
||||
kindLabel: asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "图片",
|
||||
asset,
|
||||
searchText: asset.tags.join(" "),
|
||||
disabledReason: mode === "image" && asset.kind !== "image" ? "图片创作仅支持参考图" : undefined,
|
||||
})),
|
||||
...externalLibraryItems,
|
||||
], [assets, externalLibraryItems, mode]);
|
||||
const uploadCreationAsset = async (file: File) => {
|
||||
if (file.type.startsWith("video/")) {
|
||||
const uploaded = await uploadMediaFile(file, "create-upload");
|
||||
@@ -420,12 +439,14 @@ export default function CreatePage() {
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
const handleLibrarySelect = (selected: Asset[]) => {
|
||||
const next = selected.flatMap((asset): CreationAttachment[] => {
|
||||
if (asset.kind === "image") return [creationAttachmentFromAsset(asset)];
|
||||
if (asset.kind === "video" && mode !== "image") return [creationAttachmentFromVideoAsset(asset)];
|
||||
if (asset.kind === "audio" && mode !== "image") return [creationAttachmentFromAudioAsset(asset)];
|
||||
return [];
|
||||
const handleLibrarySelect = (selectedIds: string[]) => {
|
||||
const next = selectedIds.flatMap((id): CreationAttachment[] => {
|
||||
const asset = assets.find((item) => item.id === id);
|
||||
if (asset?.kind === "image") return [creationAttachmentFromAsset(asset)];
|
||||
if (asset?.kind === "video" && mode !== "image") return [creationAttachmentFromVideoAsset(asset)];
|
||||
if (asset?.kind === "audio" && mode !== "image") return [creationAttachmentFromAudioAsset(asset)];
|
||||
const external = libraryItems.find((item) => item.id === id)?.external;
|
||||
return external ? [creationAttachmentFromExternalAsset(external)] : [];
|
||||
});
|
||||
if (!next.length) return;
|
||||
setAttachments((current) => [...current.filter((item) => !next.some((candidate) => candidate.id === item.id)), ...next].slice(0, maxReferences));
|
||||
@@ -787,6 +808,7 @@ export default function CreatePage() {
|
||||
setPrompt,
|
||||
busy,
|
||||
attachments,
|
||||
referenceImageSize,
|
||||
maxReferences,
|
||||
references: mentionReferences,
|
||||
onRemoveAttachment: removeAttachment,
|
||||
@@ -885,11 +907,12 @@ export default function CreatePage() {
|
||||
<AssetLibraryPickerModal
|
||||
open={libraryOpen}
|
||||
items={libraryItems}
|
||||
categoryLabels={creationAssetCategoryLabels}
|
||||
initialSelectedIds={attachments.filter((item) => item.id.startsWith("asset:")).map((item) => item.id.slice(6))}
|
||||
upload={{ accept: creationUploadAccept(mode), description: mode === "text" ? "支持图片、视频、音频和常用文档;媒体会保存到素材库" : `支持图片${mode === "video" ? "、视频和音频" : ""},上传后保存到素材库`, onUpload: uploadLibraryAssets }}
|
||||
categoryLabels={{ ...creationAssetCategoryLabels, ...externalAssetSources.categoryLabels }}
|
||||
folders={externalAssetSources.folders}
|
||||
initialSelectedIds={attachments.flatMap((item) => item.id.startsWith("asset:") ? [item.id.slice(6)] : item.id.startsWith("external:") ? [item.id] : [])}
|
||||
upload={{ accept: creationUploadAccept(mode), description: mode === "text" ? "支持图片、视频、音频和常用文档;媒体会保存到素材库" : `支持图片${mode === "video" ? "、视频和音频" : ""},上传后保存到素材库`, onUpload: uploadLibraryAssets, external: { accept: "image/*", description: "写入当前 Eagle 文件夹;Eagle 当前支持图片文件", onUpload: (files, folderId) => externalAssetSources.uploadExternalFiles(files, folderId) } }}
|
||||
onClose={() => setLibraryOpen(false)}
|
||||
onConfirm={(ids) => handleLibrarySelect(assets.filter((asset) => ids.includes(asset.id)))}
|
||||
onConfirm={handleLibrarySelect}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
@@ -1051,6 +1074,7 @@ type ComposerProps = {
|
||||
setPrompt: (value: string) => void;
|
||||
busy: boolean;
|
||||
attachments: CreationAttachment[];
|
||||
referenceImageSize?: { width: number; height: number };
|
||||
maxReferences: number;
|
||||
references: CreationReference[];
|
||||
onRemoveAttachment: (id: string) => void;
|
||||
@@ -1183,6 +1207,11 @@ function GenerationSettingsMenu(props: ComposerProps) {
|
||||
? Array.from(new Set(imageResolutionOptions.filter((item) => !activeImageResolution || item.tier === activeImageResolution.tier).map((item) => item.ratio)))
|
||||
: mergedProfile.size.values.length ? mergedProfile.size.values : ratioOptions.map((item) => item.value);
|
||||
const ratios = props.mode === "video" ? props.videoProfile.ratios : imageRatios;
|
||||
const referenceImageSize = props.mode === "image" && mergedProfile.size.allowCustom ? props.referenceImageSize : undefined;
|
||||
const referenceImageSizeValue = referenceImageSize ? String(referenceImageSize.width) + "x" + String(referenceImageSize.height) : "";
|
||||
const referenceImageSizeLabel = referenceImageSize ? String(referenceImageSize.width) + " × " + String(referenceImageSize.height) : "";
|
||||
const referenceImageSizeRatio = referenceImageSize ? String(referenceImageSize.width) + ":" + String(referenceImageSize.height) : "";
|
||||
const referenceImageSizeSelected = Boolean(referenceImageSizeValue && props.ratio === referenceImageSizeValue);
|
||||
const resolutions = props.mode === "video" ? props.videoProfile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: videoResolutionLabel(value) })) : resolutionOptions;
|
||||
const selectImageRatio = (nextRatio: string) => {
|
||||
if (!usesImageResolutionPicker || activeImageResolutionChoice === "auto") {
|
||||
@@ -1199,15 +1228,20 @@ function GenerationSettingsMenu(props: ComposerProps) {
|
||||
const nextSize = imageSizeForResolution(imageResolutionOptions, choice, activeImageRatio) || imageResolutionOptions.find((item) => item.tier === choice)?.size;
|
||||
if (nextSize) props.setRatio(nextSize);
|
||||
};
|
||||
const selectReferenceImageSize = () => {
|
||||
if (!referenceImageSizeValue) return;
|
||||
props.setRatio(referenceImageSizeValue);
|
||||
setCustomRatioOpen(false);
|
||||
};
|
||||
const videoResolutionSupported = props.mode === "video" && resolutions.length > 0;
|
||||
const imageSummary = [
|
||||
...(mergedProfile.size.parameter !== "none" ? [usesImageResolutionPicker ? formatImageResolutionSize(props.ratio, imageResolutionOptions) : props.ratio] : []),
|
||||
...(mergedProfile.size.parameter !== "none" ? [referenceImageSizeSelected ? referenceImageSizeLabel : usesImageResolutionPicker ? formatImageResolutionSize(props.ratio, imageResolutionOptions) : props.ratio] : []),
|
||||
...(props.imageProfile.quality.supported ? [qualityLabel] : []),
|
||||
...(props.imageProfile.maxOutputs > 1 ? [props.count] : []),
|
||||
].join(" · ");
|
||||
const summary = props.mode === "video" ? [props.ratio, ...(videoResolutionSupported ? [videoResolutionLabel(props.videoQuality)] : [])].join(" · ") : imageSummary;
|
||||
const panel = <div className="creation-parameter-menu">
|
||||
{props.mode === "video" || mergedProfile.size.parameter !== "none" ? <SettingSection title="画幅" value={props.mode === "image" && usesImageResolutionPicker ? activeImageRatio : props.ratio}><div className="creation-parameter-content"><div className="creation-choice-grid is-ratio">{ratios.map((value) => { const selected = props.mode === "image" && usesImageResolutionPicker ? value === activeImageRatio : value === props.ratio; return <button key={value} type="button" aria-pressed={selected} className={selected ? "is-selected" : ""} onClick={() => { if (props.mode === "image") selectImageRatio(value); else props.setRatio(value); setCustomRatioOpen(false); }}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(value)} /></span><span>{value}</span></button>; })}</div>{props.mode !== "video" && mergedProfile.size.allowCustom && (customRatioOpen ? <label className="creation-custom-value"><span>宽 x 高</span><input value={props.ratio} onFocus={(event) => event.currentTarget.select()} onChange={(event) => props.setRatio(event.target.value)} placeholder="1920x1080 或 2:1" aria-label="自定义图片尺寸或比例" /></label> : <button type="button" className="creation-custom-trigger" onClick={() => setCustomRatioOpen(true)}><Plus />输入自定义尺寸</button>)}</div></SettingSection> : null}
|
||||
{props.mode === "video" || mergedProfile.size.parameter !== "none" ? <SettingSection title="画幅" value={referenceImageSizeSelected ? referenceImageSizeLabel : props.mode === "image" && usesImageResolutionPicker ? activeImageRatio : props.ratio}><div className="creation-parameter-content"><div className="creation-choice-grid is-ratio">{referenceImageSizeValue ? <button type="button" aria-pressed={referenceImageSizeSelected} aria-label={"使用参考图尺寸 " + referenceImageSizeLabel} title={"使用参考图尺寸 " + referenceImageSizeLabel} className={"creation-reference-size-choice" + (referenceImageSizeSelected ? " is-selected" : "")} onClick={selectReferenceImageSize}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(referenceImageSizeRatio)} /></span><span>参考图</span></button> : null}{ratios.map((value) => { const selected = props.mode === "image" && usesImageResolutionPicker ? value === activeImageRatio : value === props.ratio; return <button key={value} type="button" aria-pressed={selected} className={selected ? "is-selected" : ""} onClick={() => { if (props.mode === "image") selectImageRatio(value); else props.setRatio(value); setCustomRatioOpen(false); }}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(value)} /></span><span>{value}</span></button>; })}</div>{props.mode !== "video" && mergedProfile.size.allowCustom && (customRatioOpen ? <label className="creation-custom-value"><span>宽 x 高</span><input value={props.ratio} onFocus={(event) => event.currentTarget.select()} onChange={(event) => props.setRatio(event.target.value)} placeholder="1920x1080 或 2:1" aria-label="自定义图片尺寸或比例" /></label> : <button type="button" className="creation-custom-trigger" onClick={() => setCustomRatioOpen(true)}><Plus />输入自定义尺寸</button>)}</div></SettingSection> : null}
|
||||
{props.mode === "video" ? (videoResolutionSupported ? <SettingSection title="清晰度" value={videoResolutionLabel(props.videoQuality)}><div className="creation-choice-grid is-resolution">{resolutions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.videoQuality} className={option.value === props.videoQuality ? "is-selected" : ""} onClick={() => props.setVideoQuality(option.value)}>{option.label}</button>)}</div></SettingSection> : null) : <>
|
||||
{imageResolutionChoiceOptions.length ? <SettingSection title="分辨率" value={activeImageResolutionChoice === "auto" ? "自动" : activeImageResolutionChoice.toUpperCase()}><div className="creation-choice-grid is-resolution">{imageResolutionChoiceOptions.map((choice) => <button key={choice} type="button" aria-pressed={choice === activeImageResolutionChoice} className={choice === activeImageResolutionChoice ? "is-selected" : ""} onClick={() => selectImageResolution(choice)}>{choice === "auto" ? "自动" : choice.toUpperCase()}</button>)}</div></SettingSection> : null}
|
||||
{props.imageProfile.quality.supported ? <SettingSection title={activeQualityOptions.some((item) => item.value === "1k" || item.value === "2k") ? "分辨率" : "图片质量"} value={qualityLabel}><div className="creation-choice-grid is-quality">{activeQualityOptions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.quality} className={option.value === props.quality ? "is-selected" : ""} onClick={() => props.setQuality(option.value)}><span>{option.label}</span><small>{option.description}</small></button>)}</div></SettingSection> : null}
|
||||
@@ -1509,7 +1543,7 @@ function isVideoAttachment(attachment: CreationAttachment): attachment is Creati
|
||||
return creationAttachmentKind(attachment) === "video";
|
||||
}
|
||||
|
||||
function isImageAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { dataUrl: string } {
|
||||
function isImageAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { dataUrl: string; width?: number; height?: number } {
|
||||
return creationAttachmentKind(attachment) === "image";
|
||||
}
|
||||
|
||||
@@ -1607,7 +1641,8 @@ function formatConversationTime(value: string) {
|
||||
|
||||
function ratioPreviewStyle(value: string) {
|
||||
const [width, height] = value.replace("x", ":").split(":").map(Number);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return { width: 14, height: 14 };
|
||||
const scale = Math.min(28 / width, 20 / height);
|
||||
return { width: Math.max(8, width * scale), height: Math.max(8, height * scale) };
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return { width: 10, height: 10 };
|
||||
// 画幅容器的可用空间是 14×10;同时计算宽高,避免 CSS 的 max-width/max-height 把宽银幕比例压扁。
|
||||
const scale = Math.min(14 / width, 10 / height);
|
||||
return { width: Math.max(4, Math.round(width * scale)), height: Math.max(4, Math.round(height * scale)) };
|
||||
}
|
||||
|
||||
206
web/src/pages/plugins/eagle.css
Normal file
206
web/src/pages/plugins/eagle.css
Normal file
@@ -0,0 +1,206 @@
|
||||
.eagle-library-page .eagle-folder-sidebar {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.eagle-library-page .eagle-folder-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
min-height: 32px;
|
||||
padding-inline: 4px;
|
||||
}
|
||||
|
||||
.eagle-library-page .eagle-folder-sidebar-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-4);
|
||||
padding-inline: 4px;
|
||||
color: color-mix(in srgb, var(--foreground) 38%, transparent);
|
||||
font-size: var(--fs-tiny);
|
||||
font-weight: 650;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.eagle-library-page .eagle-folder-tree {
|
||||
max-height: clamp(12rem, 42vh, 28rem);
|
||||
overflow: auto;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.eagle-library-page .eagle-folder-tree .ant-tree-treenode {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding-block: 1px;
|
||||
}
|
||||
|
||||
.eagle-library-page .eagle-folder-tree .ant-tree-indent-unit {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.eagle-library-page .eagle-folder-tree .ant-tree-switcher {
|
||||
flex: 0 0 16px;
|
||||
width: 16px;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
.eagle-library-page .eagle-folder-tree .ant-tree-switcher-noop {
|
||||
flex-basis: 0;
|
||||
width: 0;
|
||||
}
|
||||
.eagle-library-page .eagle-folder-tree .ant-tree-node-content-wrapper {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
border-radius: var(--r-md);
|
||||
padding: 6px 4px;
|
||||
transition: background-color var(--motion-state) ease-out, color var(--motion-state) ease-out;
|
||||
}
|
||||
|
||||
.eagle-library-page .eagle-folder-tree-title {
|
||||
display: block;
|
||||
width: 8rem;
|
||||
max-width: 8rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.eagle-library-page .eagle-folder-tree .ant-tree-node-content-wrapper:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
}
|
||||
|
||||
.eagle-library-page .eagle-folder-tree .ant-tree-node-content-wrapper.ant-tree-node-selected {
|
||||
background: var(--control-selected-bg);
|
||||
color: var(--control-selected-fg);
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
.eagle-library-page .eagle-folder-tree .ant-tree-node-content-wrapper:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.eagle-library-page .eagle-folder-collapse {
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: var(--r-md);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
transition: background-color var(--motion-state) ease-out, color var(--motion-state) ease-out;
|
||||
}
|
||||
|
||||
.eagle-library-page .eagle-folder-collapse:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 7%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.eagle-library-page .eagle-folder-collapse:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.eagle-library-page .eagle-folder-empty {
|
||||
padding: var(--space-3) 4px;
|
||||
color: color-mix(in srgb, var(--foreground) 42%, transparent);
|
||||
font-size: var(--fs-label);
|
||||
}
|
||||
.eagle-library-page .eagle-folder-create {
|
||||
width: 100%;
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.eagle-library-page .eagle-cover-download {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
place-items: center;
|
||||
border-radius: var(--r-md);
|
||||
background: rgba(10, 10, 10, .62);
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
transition: background-color var(--motion-state) ease-out, opacity var(--motion-state) ease-out;
|
||||
}
|
||||
|
||||
.eagle-library-page .asset-library-card:hover .eagle-cover-download,
|
||||
.eagle-library-page .asset-library-card:focus-within .eagle-cover-download,
|
||||
.eagle-library-page .eagle-cover-download:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.eagle-library-page .eagle-cover-download:hover {
|
||||
background: rgba(10, 10, 10, .82);
|
||||
}
|
||||
|
||||
.eagle-library-page .eagle-cover-download:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.eagle-library-page .eagle-empty-state {
|
||||
display: grid;
|
||||
min-height: 16rem;
|
||||
place-items: center;
|
||||
border-radius: var(--r-lg);
|
||||
background: color-mix(in srgb, var(--foreground) 2%, transparent);
|
||||
}
|
||||
|
||||
.eagle-library-page .eagle-drawer-preview {
|
||||
display: grid;
|
||||
min-height: 220px;
|
||||
max-height: 52vh;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: var(--r-lg);
|
||||
background: color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
}
|
||||
|
||||
.eagle-library-page .eagle-drawer-preview img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 52vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.eagle-library-page .eagle-drawer-download {
|
||||
width: 100%;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
transition: background-color var(--motion-state) ease-out;
|
||||
}
|
||||
|
||||
.eagle-library-page .eagle-drawer-download:hover {
|
||||
background: color-mix(in srgb, var(--primary) 88%, var(--foreground));
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.eagle-library-page .eagle-folder-tree {
|
||||
max-height: 14rem;
|
||||
}
|
||||
|
||||
.eagle-library-page .eagle-cover-download {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.eagle-library-page .eagle-folder-tree .ant-tree-node-content-wrapper,
|
||||
.eagle-library-page .eagle-cover-download,
|
||||
.eagle-library-page .eagle-drawer-download {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
}
|
||||
351
web/src/pages/plugins/eagle.tsx
Normal file
351
web/src/pages/plugins/eagle.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
import { ArrowLeft, ChevronDown, ChevronUp, Download, FileAudio, FileBox, FileImage, FileVideo, FolderOpen, FolderPlus, RefreshCw, Search, Settings2, Upload } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import { App, Button, Drawer, Empty, Input, Spin, Tag, Tree, Typography } from "antd";
|
||||
import type { DataNode } from "antd/es/tree";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import "@/lib/plugins/builtin";
|
||||
import { createEagleAssetSource, EAGLE_DEFAULT_BASE_URL, eagleAssetPlugin } from "@/lib/plugins/builtin/eagle";
|
||||
import type { ExternalAssetFolder, ExternalAssetItem } from "@/lib/plugins/plugin-types";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
import { usePluginStore } from "@/stores/use-plugin-store";
|
||||
import { CollectionGrid, ListToolbar, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page";
|
||||
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
|
||||
import "./eagle.css";
|
||||
|
||||
export default function EagleLibraryPage() {
|
||||
const navigate = useNavigate();
|
||||
const { message } = App.useApp();
|
||||
const installations = usePluginStore((state) => state.installations);
|
||||
const hydrated = usePluginStore((state) => state.hydrated);
|
||||
const ensurePlugin = usePluginStore((state) => state.ensurePlugin);
|
||||
const installation = installations.find((item) => item.manifest.id === eagleAssetPlugin.manifest.id);
|
||||
const enabled = Boolean(installation?.enabled);
|
||||
const savedBaseUrl = installation?.config.baseUrl;
|
||||
const baseUrl = typeof savedBaseUrl === "string" && savedBaseUrl.trim() ? savedBaseUrl.trim() : EAGLE_DEFAULT_BASE_URL;
|
||||
const provider = useMemo(() => createEagleAssetSource(baseUrl), [baseUrl]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [folders, setFolders] = useState<ExternalAssetFolder[]>([]);
|
||||
const [items, setItems] = useState<ExternalAssetItem[]>([]);
|
||||
const [selectedFolder, setSelectedFolder] = useState("");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [folderName, setFolderName] = useState("");
|
||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [progress, setProgress] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(40);
|
||||
const [previewItem, setPreviewItem] = useState<ExternalAssetItem | null>(null);
|
||||
const [foldersExpanded, setFoldersExpanded] = useState(true);
|
||||
|
||||
const treeData = useMemo<DataNode[]>(() => renderFolderNodes(folders), [folders]);
|
||||
const folderPath = useMemo(() => externalFolderPath(folders, selectedFolder), [folders, selectedFolder]);
|
||||
const currentFolder = folders.find((folder) => folder.id === selectedFolder);
|
||||
const visibleItems = useMemo(() => items.slice((page - 1) * pageSize, page * pageSize), [items, page, pageSize]);
|
||||
const totalBytes = useMemo(() => items.reduce((total, item) => total + (item.bytes || 0), 0), [items]);
|
||||
|
||||
useEffect(() => {
|
||||
ensurePlugin(eagleAssetPlugin.manifest);
|
||||
}, [ensurePlugin]);
|
||||
|
||||
const load = async (folderId = selectedFolder, search = keyword) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextFolders, nextItems] = await Promise.all([
|
||||
provider.listFolders?.(),
|
||||
provider.list?.({ folderId: folderId || undefined, keyword: search.trim() || undefined, limit: 100, offset: 0 }),
|
||||
]);
|
||||
setFolders(nextFolders || []);
|
||||
setItems(nextItems || []);
|
||||
setPage(1);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "连接 Eagle 失败,请确认 Eagle 已启动");
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
setSelectedFolder("");
|
||||
setKeyword("");
|
||||
void load("", "");
|
||||
// provider 随本机 API 地址变化;页面进入或配置变化时重新读取 Eagle。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, provider]);
|
||||
|
||||
const handleFolderSelect = (nextFolder: string) => {
|
||||
const folderId = nextFolder === "root" ? "" : nextFolder;
|
||||
setSelectedFolder(folderId);
|
||||
setKeyword("");
|
||||
setPreviewItem(null);
|
||||
setPage(1);
|
||||
void load(folderId, "");
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
const name = folderName.trim();
|
||||
if (!name || !provider.createFolder || creatingFolder) return;
|
||||
setCreatingFolder(true);
|
||||
setError("");
|
||||
try {
|
||||
await provider.createFolder(name, selectedFolder || undefined);
|
||||
setFolderName("");
|
||||
message.success("已在" + (currentFolder?.name || "Eagle 素材库") + "中新建文件夹");
|
||||
await load();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "新建 Eagle 文件夹失败");
|
||||
} finally {
|
||||
setCreatingFolder(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
event.target.value = "";
|
||||
if (!files.length || !provider.uploadFile || working) return;
|
||||
setWorking(true);
|
||||
setError("");
|
||||
let uploaded = 0;
|
||||
try {
|
||||
for (const [index, file] of files.entries()) {
|
||||
setProgress("正在写入 " + (index + 1) + "/" + files.length + ":" + file.name);
|
||||
await provider.uploadFile(file, selectedFolder || undefined);
|
||||
uploaded += 1;
|
||||
}
|
||||
message.success("已写入 Eagle " + uploaded + " 个文件");
|
||||
await load();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "写入 Eagle 失败");
|
||||
if (uploaded) message.warning("已写入 " + uploaded + " 个文件,剩余文件未完成");
|
||||
} finally {
|
||||
setProgress("");
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (hydrated && !enabled) {
|
||||
return (
|
||||
<WorkspacePage grid className="library-page eagle-library-page">
|
||||
<PageHeader
|
||||
title="Eagle 素材库"
|
||||
description="把 Eagle 作为影策的外部素材来源,直接浏览和管理 Eagle 原始文件。"
|
||||
actions={<Button icon={<ArrowLeft className="size-3.5" />} onClick={() => navigate("/assets")}>返回影策素材库</Button>}
|
||||
/>
|
||||
<section className="mt-4 library-card-surface flex min-h-72 flex-col items-center justify-center rounded-[var(--r-xl)] px-6 py-10 text-center">
|
||||
<span className="grid size-14 place-items-center rounded-[var(--r-lg)] bg-[var(--workspace-accent-soft)] text-[var(--workspace-accent)]"><FolderOpen className="size-7" aria-hidden="true" /></span>
|
||||
<h2 className="mt-4 text-base font-semibold">先启用 Eagle 素材来源</h2>
|
||||
<p className="mt-2 max-w-md text-sm leading-6 text-foreground/55">启用后,这里会直接显示 Eagle 原本的文件夹和文件,不会把素材复制成影策本地素材。</p>
|
||||
<Button type="primary" className="mt-5" icon={<Settings2 className="size-4" />} onClick={() => navigate("/plugins")}>去插件中心启用</Button>
|
||||
</section>
|
||||
</WorkspacePage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkspacePage grid className="library-page assets-library-page canvas-library-page eagle-library-page">
|
||||
<div className="studio-band">
|
||||
<PageHeader
|
||||
title="Eagle 素材库"
|
||||
description="Eagle 是影策的外部素材来源;这里复用影策素材库的浏览方式,直接读取和写入 Eagle 原始文件。"
|
||||
meta={<span className="app-projects-header-meta assets-header-meta">{error ? "Eagle · 连接异常" : "Eagle · 已连接"}</span>}
|
||||
actions={(
|
||||
<div className="assets-header-actions">
|
||||
<div className="assets-header-action-buttons">
|
||||
<Button className="library-primary-action" type="primary" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()} disabled={working}>写入素材</Button>
|
||||
<Button icon={<ArrowLeft className="size-3.5" />} onClick={() => navigate("/assets")}>影策素材库</Button>
|
||||
<Button icon={<Settings2 className="size-3.5" />} onClick={() => navigate("/plugins")}>插件设置</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<ListToolbar
|
||||
className="library-toolbar"
|
||||
active={Boolean(keyword)}
|
||||
onReset={() => { setKeyword(""); setPage(1); void load(selectedFolder, ""); }}
|
||||
trailing={<Button icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={() => void load()}>刷新</Button>}
|
||||
>
|
||||
<Input allowClear className="w-full sm:w-80" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索 Eagle 素材标题、标签或文件夹" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} onPressEnter={() => void load()} />
|
||||
</ListToolbar>
|
||||
</div>
|
||||
|
||||
{error ? <div className="mt-3 flex items-start justify-between gap-3 rounded-[var(--r-lg)] bg-danger/[.08] px-4 py-3 text-sm text-danger" role="alert"><span>{error}</span><Button size="small" icon={<RefreshCw className="size-3.5" />} onClick={() => void load()}>重试</Button></div> : null}
|
||||
|
||||
<div className="canvas-library-frame assets-library-frame eagle-library-frame mt-4">
|
||||
<div className="grid min-h-0 gap-4 lg:grid-cols-[176px_minmax(0,1fr)]">
|
||||
<aside className="eagle-folder-sidebar thin-scrollbar flex gap-2 overflow-x-auto py-3 lg:sticky lg:top-0 lg:block lg:max-h-[calc(100vh-190px)] lg:overflow-y-auto lg:pr-3">
|
||||
<div className="eagle-folder-sidebar-header">
|
||||
<span className="text-[var(--fs-label)] font-semibold">Eagle 文件夹</span>
|
||||
<Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} aria-label="刷新 Eagle 文件夹" loading={loading} onClick={() => void load()} />
|
||||
</div>
|
||||
<button type="button" className={"assets-filter-item " + (selectedFolder === "" ? "is-active" : "")} aria-pressed={selectedFolder === ""} onClick={() => handleFolderSelect("root")}>
|
||||
<span className="assets-filter-item-label">全部素材</span>
|
||||
<span className="assets-filter-count">{items.length}</span>
|
||||
</button>
|
||||
<div className="eagle-folder-sidebar-label">
|
||||
<span>文件夹</span>
|
||||
<button type="button" className="eagle-folder-collapse" aria-expanded={foldersExpanded} aria-controls="eagle-folder-tree" onClick={() => setFoldersExpanded((expanded) => !expanded)}>
|
||||
{foldersExpanded ? <ChevronUp className="size-3.5" aria-hidden="true" /> : <ChevronDown className="size-3.5" aria-hidden="true" />}
|
||||
<span className="sr-only">{foldersExpanded ? "收起文件夹" : "展开文件夹"}</span>
|
||||
</button>
|
||||
</div>
|
||||
{foldersExpanded ? (treeData.length ? <div id="eagle-folder-tree"><Tree className="eagle-folder-tree" blockNode selectable selectedKeys={selectedFolder ? [selectedFolder] : []} treeData={treeData} onSelect={(keys) => handleFolderSelect(String(keys[0] || "root"))} /></div> : <div className="eagle-folder-empty">Eagle 中还没有文件夹</div>) : null}
|
||||
<Button className="eagle-folder-create" icon={<FolderPlus className="size-3.5" />} onClick={() => setFolderName((value) => value ? "" : "新文件夹")}>新建文件夹</Button>
|
||||
</aside>
|
||||
|
||||
<section className="min-w-0">
|
||||
<nav aria-label="Eagle 文件夹路径" className="mb-3 flex min-w-0 items-center gap-1 text-xs text-foreground/48">
|
||||
<button type="button" className="truncate rounded px-1.5 py-1 hover:bg-surface-hover" onClick={() => handleFolderSelect("root")}>Eagle 素材库</button>
|
||||
{folderPath.map((folder) => <span key={folder.id} className="contents"><span aria-hidden="true">/</span><button type="button" className="truncate rounded px-1.5 py-1 font-medium text-foreground hover:bg-surface-hover" onClick={() => handleFolderSelect(folder.id)}>{folder.name}</button></span>)}
|
||||
</nav>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-base font-semibold">{currentFolder?.name || "全部素材"}</h2>
|
||||
<span className="app-projects-header-meta">{items.length} 个素材</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-foreground/48">{currentFolder ? "当前文件夹由 Eagle 管理,影策只负责展示和调用。" : "当前展示 Eagle 素材库中的全部文件。"}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button icon={<FolderPlus className="size-3.5" />} onClick={() => setFolderName((value) => value ? "" : "新文件夹")}>新建文件夹</Button>
|
||||
<Button icon={<Download className="size-3.5" />} onClick={() => { const firstFile = visibleItems.find((item) => item.fileUrl); if (firstFile?.fileUrl) window.open(firstFile.fileUrl, "_blank", "noopener,noreferrer"); }}>下载当前文件</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{folderName ? <div className="mb-4 flex flex-wrap items-center gap-2 rounded-[var(--r-lg)] bg-surface-secondary p-3">
|
||||
<Input autoFocus value={folderName} onChange={(event) => setFolderName(event.target.value)} onPressEnter={() => void handleCreateFolder()} placeholder="输入文件夹名称" className="min-w-48 flex-1" aria-label="新文件夹名称" />
|
||||
<Button type="primary" loading={creatingFolder} onClick={() => void handleCreateFolder()}>创建</Button>
|
||||
<Button onClick={() => setFolderName("")}>取消</Button>
|
||||
</div> : null}
|
||||
|
||||
{loading ? <div className="library-loading-grid grid min-h-64 place-items-center"><Spin tip="读取 Eagle 文件…"/></div> : items.length ? (
|
||||
<>
|
||||
<CollectionGrid className="library-grid assets-library-grid eagle-assets-grid">
|
||||
{visibleItems.map((item) => <EagleItemCard key={item.id} item={item} selected={previewItem?.id === item.id} onOpen={() => setPreviewItem(item)} />)}
|
||||
</CollectionGrid>
|
||||
<PaginationBar current={page} pageSize={pageSize} total={items.length} pageSizeOptions={[20, 40, 80]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
|
||||
</>
|
||||
) : <div className="eagle-empty-state"><Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前 Eagle 文件夹没有文件" /></div>}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input ref={fileInputRef} type="file" accept="image/*,video/*,audio/*" multiple className="hidden" onChange={(event) => void handleUpload(event)} />
|
||||
</WorkspacePage>
|
||||
<EagleAssetDrawer item={previewItem} onClose={() => setPreviewItem(null)} totalBytes={totalBytes} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EagleItemCard({ item, selected, onOpen }: { item: ExternalAssetItem; selected: boolean; onOpen: () => void }) {
|
||||
return <AssetLibraryCard selected={selected}>
|
||||
<AssetLibraryCardMedia className={item.kind === "image" || item.kind === "video" ? "assets-cover" : "assets-cover is-light"}>
|
||||
<button type="button" className="assets-cover-link" onClick={onOpen} aria-label={"查看 Eagle 素材:" + item.title}>
|
||||
{item.thumbnailUrl ? <img src={item.thumbnailUrl} alt={item.title} loading="lazy" decoding="async" className="assets-cover-media" /> : <div className="assets-cover-fallback"><AssetKindIcon kind={item.kind} size="size-7" /></div>}
|
||||
<span className="assets-cover-vignette" aria-hidden="true" />
|
||||
</button>
|
||||
<span className="assets-cover-badges">
|
||||
<span className="assets-cover-badge is-kind"><AssetKindIcon kind={item.kind} size="size-3" />{assetKindLabel(item.kind)}</span>
|
||||
<span className="assets-cover-badge is-category">Eagle</span>
|
||||
</span>
|
||||
{item.fileUrl ? <a href={item.fileUrl} download={item.title} target="_blank" rel="noreferrer" className="eagle-cover-download" aria-label={"下载原文件:" + item.title}><Download className="size-3.5" aria-hidden="true" /></a> : null}
|
||||
</AssetLibraryCardMedia>
|
||||
<button type="button" className="block w-full px-2.5 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)]" onClick={onOpen}>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<h2 className="truncate text-[var(--fs-body)] font-semibold text-foreground" title={item.title}>{item.title}</h2>
|
||||
<span className="shrink-0 text-[var(--fs-tiny)] tabular-nums text-foreground/38">{formatBytes(item.bytes || 0)}</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[var(--fs-label)] text-foreground/52" title={item.folderPath?.join(" / ") || "Eagle 根目录"}>{item.folderPath?.join(" / ") || "Eagle 根目录"}</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/38">
|
||||
<span className="truncate">{formatDimensions(item)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="truncate">Eagle 原文件</span>
|
||||
</div>
|
||||
</button>
|
||||
</AssetLibraryCard>;
|
||||
}
|
||||
|
||||
function EagleAssetDrawer({ item, onClose, totalBytes }: { item: ExternalAssetItem | null; onClose: () => void; totalBytes: number }) {
|
||||
return <Drawer className="library-drawer" title="素材档案" open={Boolean(item)} size="large" onClose={onClose}>
|
||||
{item ? <div className="space-y-4">
|
||||
<div className="asset-archive-header">
|
||||
<span className="asset-archive-header-icon"><AssetKindIcon kind={item.kind} size="size-5" /></span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="asset-archive-title">{item.title}</h2>
|
||||
<p className="asset-archive-subtitle">Eagle 原文件 · {item.folderPath?.join(" / ") || "根目录"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="eagle-drawer-preview">
|
||||
{item.thumbnailUrl ? <img src={item.thumbnailUrl} alt={item.title} loading="lazy" decoding="async" /> : <AssetKindIcon kind={item.kind} size="size-9" />}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<EagleFact label="类型" value={assetKindLabel(item.kind)} />
|
||||
<EagleFact label="尺寸" value={formatDimensions(item)} />
|
||||
<EagleFact label="文件大小" value={formatBytes(item.bytes || 0)} />
|
||||
<EagleFact label="所在文件夹" value={item.folderPath?.join(" / ") || "Eagle 根目录"} />
|
||||
<EagleFact label="素材库总大小" value={formatBytes(totalBytes)} />
|
||||
</div>
|
||||
{item.tags?.length ? <div><Typography.Text strong className="text-xs">标签</Typography.Text><div className="mt-2 flex flex-wrap gap-1.5">{item.tags.map((tag) => <Tag key={tag} className="m-0">{tag}</Tag>)}</div></div> : null}
|
||||
{item.description ? <div><Typography.Text strong className="text-xs">备注</Typography.Text><p className="mt-2 text-sm leading-6 text-foreground/65">{item.description}</p></div> : null}
|
||||
{item.fileUrl ? <a href={item.fileUrl} download={item.title} target="_blank" rel="noreferrer" className="eagle-drawer-download inline-flex min-h-10 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium"><Download className="size-4" aria-hidden="true" />下载 Eagle 原文件</a> : null}
|
||||
</div> : null}
|
||||
</Drawer>;
|
||||
}
|
||||
|
||||
function EagleFact({ label, value }: { label: string; value: string }) {
|
||||
return <div className="flex items-center justify-between gap-3 rounded-md bg-surface-secondary px-3 py-2 text-sm"><span className="text-foreground/48">{label}</span><span className="max-w-[65%] truncate text-right font-medium" title={value}>{value}</span></div>;
|
||||
}
|
||||
|
||||
function AssetKindIcon({ kind, size = "size-5" }: { kind: Asset["kind"]; size?: string }) {
|
||||
if (kind === "image") return <FileImage className={size + " text-foreground/48"} aria-hidden="true" />;
|
||||
if (kind === "video") return <FileVideo className={size + " text-foreground/48"} aria-hidden="true" />;
|
||||
if (kind === "audio") return <FileAudio className={size + " text-foreground/48"} aria-hidden="true" />;
|
||||
return <FileBox className={size + " text-foreground/48"} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
function assetKindLabel(kind: Asset["kind"]) {
|
||||
if (kind === "image") return "图片";
|
||||
if (kind === "video") return "视频";
|
||||
if (kind === "audio") return "音频";
|
||||
if (kind === "model") return "模型";
|
||||
return "文件";
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (!bytes) return "—";
|
||||
if (bytes < 1024) return String(bytes) + " B";
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
||||
}
|
||||
|
||||
function formatDimensions(item: ExternalAssetItem) {
|
||||
if (item.width && item.height) return item.width + " × " + item.height;
|
||||
return item.mimeType || assetKindLabel(item.kind);
|
||||
}
|
||||
|
||||
function renderFolderNodes(folders: ExternalAssetFolder[], parentId = ""): DataNode[] {
|
||||
return folders.filter((folder) => (folder.parentId || "") === parentId).map((folder) => ({
|
||||
key: folder.id,
|
||||
title: <span className="eagle-folder-tree-title" title={folder.name}>{folder.name}</span>,
|
||||
children: renderFolderNodes(folders, folder.id),
|
||||
}));
|
||||
}
|
||||
|
||||
function externalFolderPath(folders: ExternalAssetFolder[], folderId: string) {
|
||||
const byId = new Map(folders.map((folder) => [folder.id, folder]));
|
||||
const result: ExternalAssetFolder[] = [];
|
||||
const seen = new Set<string>();
|
||||
let current = byId.get(folderId);
|
||||
while (current && !seen.has(current.id)) {
|
||||
seen.add(current.id);
|
||||
result.unshift(current);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
256
web/src/pages/plugins/index.tsx
Normal file
256
web/src/pages/plugins/index.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { App, Button, Input, Select, Switch, Tag, Typography } from "antd";
|
||||
import { CheckCircle2, ChevronDown, ExternalLink, FolderOpen, PlugZap, Settings2, ShieldCheck } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import { listRegisteredPlugins } from "@/lib/plugins/plugin-registry";
|
||||
import "@/lib/plugins/builtin";
|
||||
import { EAGLE_PLUGIN_ID } from "@/lib/plugins/builtin/eagle";
|
||||
import { getEagleLibrary, type EagleFolder } from "@/services/api/eagle";
|
||||
import { usePluginStore } from "@/stores/use-plugin-store";
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
"asset-source": "素材来源",
|
||||
"canvas-node": "画布节点",
|
||||
workflow: "工作流",
|
||||
"ai-capability": "AI 能力",
|
||||
"import-export": "导入导出",
|
||||
agent: "智能体",
|
||||
};
|
||||
|
||||
const surfaceLabels: Record<string, string> = {
|
||||
node: "画布节点",
|
||||
fullscreen: "全屏工作台",
|
||||
hybrid: "混合接入",
|
||||
"asset-source": "素材库",
|
||||
};
|
||||
|
||||
const permissionLabels: Record<string, string> = {
|
||||
"canvas.read": "读取画布",
|
||||
"canvas.write": "修改画布",
|
||||
"asset.read": "读取素材",
|
||||
"asset.search": "搜索素材",
|
||||
"asset.import": "导入素材",
|
||||
"asset.upload": "上传素材",
|
||||
"generation.run": "调用生成",
|
||||
"external.open": "打开外部详情",
|
||||
};
|
||||
|
||||
export default function PluginsPage() {
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const installations = usePluginStore((state) => state.installations);
|
||||
const ensurePlugin = usePluginStore((state) => state.ensurePlugin);
|
||||
const setEnabled = usePluginStore((state) => state.setEnabled);
|
||||
const updateConfig = usePluginStore((state) => state.updateConfig);
|
||||
const registeredPlugins = useMemo(() => listRegisteredPlugins(), []);
|
||||
const [expandedPluginId, setExpandedPluginId] = useState<string | null>(null);
|
||||
const [eagleBaseUrl, setEagleBaseUrl] = useState("http://localhost:41595");
|
||||
const [eagleAutoUploadGenerated, setEagleAutoUploadGenerated] = useState(true);
|
||||
const [eagleGeneratedFolderId, setEagleGeneratedFolderId] = useState("");
|
||||
const [eagleFolders, setEagleFolders] = useState<EagleFolder[]>([]);
|
||||
const [eagleFoldersLoading, setEagleFoldersLoading] = useState(false);
|
||||
const [eagleFoldersError, setEagleFoldersError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
for (const plugin of registeredPlugins) ensurePlugin(plugin.manifest);
|
||||
}, [ensurePlugin, registeredPlugins]);
|
||||
|
||||
const eagle = installations.find((item) => item.manifest.id === EAGLE_PLUGIN_ID);
|
||||
|
||||
useEffect(() => {
|
||||
const configured = eagle?.config.baseUrl;
|
||||
if (typeof configured === "string" && configured.trim()) setEagleBaseUrl(configured);
|
||||
const autoUpload = eagle?.config.autoUploadGenerated;
|
||||
setEagleAutoUploadGenerated(autoUpload !== false && autoUpload !== "false");
|
||||
const folderId = eagle?.config.generatedFolderId;
|
||||
setEagleGeneratedFolderId(typeof folderId === "string" ? folderId : "");
|
||||
}, [eagle?.config.baseUrl, eagle?.config.autoUploadGenerated, eagle?.config.generatedFolderId]);
|
||||
|
||||
const loadEagleFolders = async (url = eagleBaseUrl) => {
|
||||
setEagleFoldersLoading(true);
|
||||
setEagleFoldersError("");
|
||||
try {
|
||||
const result = await getEagleLibrary(url.trim().replace(/\/$/, ""));
|
||||
setEagleFolders(result.library.folders || []);
|
||||
} catch (reason) {
|
||||
setEagleFoldersError(reason instanceof Error ? reason.message : "读取 Eagle 文件夹失败");
|
||||
setEagleFolders([]);
|
||||
} finally {
|
||||
setEagleFoldersLoading(false);
|
||||
}
|
||||
};
|
||||
const saveEagleConfig = () => {
|
||||
const baseUrl = eagleBaseUrl.trim().replace(/\/$/, "");
|
||||
if (!/^https?:\/\//i.test(baseUrl)) {
|
||||
message.error("Eagle 地址必须以 http:// 或 https:// 开头");
|
||||
return;
|
||||
}
|
||||
updateConfig(EAGLE_PLUGIN_ID, { baseUrl, autoUploadGenerated: eagleAutoUploadGenerated, generatedFolderId: eagleGeneratedFolderId });
|
||||
message.success("Eagle 插件配置已保存");
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="app-workspace-page flex h-full min-h-0 flex-col text-foreground">
|
||||
<header className="shrink-0 border-b border-[var(--workspace-border)] px-5 py-4 md:px-7">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 flex items-center gap-2 text-[var(--fs-label)] text-foreground/45">
|
||||
<PlugZap className="size-3.5" aria-hidden="true" />
|
||||
<span>工作台管理</span>
|
||||
<span>/</span>
|
||||
<span>插件中心</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold tracking-tight md:text-2xl">插件中心</h1>
|
||||
<p className="mt-1 text-sm text-foreground/55">管理可连接到影策的素材来源、画布能力和创作工作流。</p>
|
||||
</div>
|
||||
<Tag icon={<ShieldCheck className="size-3.5" />} color="gold">可信插件模式</Tag>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="app-workspace-scroll min-h-0 flex-1 overflow-y-auto px-5 py-5 md:px-7 md:py-6">
|
||||
<div className="mx-auto w-full max-w-5xl">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">已接入插件</h2>
|
||||
<p className="mt-1 text-xs text-foreground/48">插件状态和配置按当前账号隔离保存。</p>
|
||||
</div>
|
||||
<Tag className="m-0">{registeredPlugins.length} 个插件</Tag>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{registeredPlugins.map((plugin) => {
|
||||
const installation = installations.find((item) => item.manifest.id === plugin.manifest.id);
|
||||
const enabled = Boolean(installation?.enabled);
|
||||
const expanded = expandedPluginId === plugin.manifest.id;
|
||||
const settingsId = `plugin-settings-${plugin.manifest.id}`;
|
||||
return (
|
||||
<section key={plugin.manifest.id} className="library-card-surface rounded-[var(--r-xl)] p-4 transition-colors duration-200 md:p-5">
|
||||
<div className="flex min-w-0 flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<span className="grid size-11 shrink-0 place-items-center rounded-[var(--r-lg)] bg-[var(--workspace-accent-soft)] text-[var(--workspace-accent)]">
|
||||
<PlugZap className="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="min-w-0 truncate text-base font-semibold">{plugin.manifest.name}</h3>
|
||||
<span className="whitespace-nowrap text-[var(--fs-tiny)] text-foreground/42">v{plugin.manifest.version}</span>
|
||||
<span className="whitespace-nowrap rounded-full bg-foreground/[.06] px-2 py-1 text-[var(--fs-tiny)] text-foreground/58">{categoryLabels[plugin.manifest.category] ?? plugin.manifest.category}</span>
|
||||
</div>
|
||||
<p className="mt-1 max-w-3xl text-sm leading-6 text-foreground/55">{plugin.manifest.description}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{plugin.manifest.surfaces.map((surface) => <span key={surface} className="rounded-full bg-foreground/[.06] px-2.5 py-1 text-[var(--fs-tiny)] text-foreground/62">{surfaceLabels[surface] ?? surface}</span>)}
|
||||
<span className="rounded-full bg-[var(--library-icon-surface)] px-2.5 py-1 text-[var(--fs-tiny)] text-foreground/52">{plugin.manifest.permissions.length} 项能力</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-3 lg:justify-end">
|
||||
<span role="status" className={`settings-channel-status ${enabled ? "is-ready" : ""}`}>
|
||||
<i aria-hidden="true" />
|
||||
{enabled ? "已启用" : "未启用"}
|
||||
</span>
|
||||
<Switch checked={enabled} aria-label={`${plugin.manifest.name}${enabled ? "停用" : "启用"}`} onChange={(checked) => setEnabled(plugin.manifest.id, checked)} />
|
||||
<Button
|
||||
className="min-h-9"
|
||||
icon={<Settings2 className="size-4" />}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={settingsId}
|
||||
onClick={() => setExpandedPluginId((current) => current === plugin.manifest.id ? null : plugin.manifest.id)}
|
||||
>
|
||||
{expanded ? "收起设置" : "设置"}
|
||||
<ChevronDown className={`ml-1 size-3.5 transition-transform duration-200${expanded ? " rotate-180" : ""}`} aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded ? (
|
||||
<div id={settingsId} className="mt-4 border-t border-[var(--workspace-border)] pt-4">
|
||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">{plugin.manifest.name} 设置</h4>
|
||||
<p className="mt-1 text-xs leading-5 text-foreground/48">只展示这个插件实际支持的配置项。</p>
|
||||
</div>
|
||||
<span className="whitespace-nowrap rounded-full bg-foreground/[.06] px-2.5 py-1 text-[var(--fs-tiny)] text-foreground/52">{plugin.manifest.trusted ? "可信插件" : "第三方插件"}</span>
|
||||
</div>
|
||||
|
||||
{plugin.manifest.id === EAGLE_PLUGIN_ID ? (
|
||||
<>
|
||||
<div className="grid min-w-0 gap-4 lg:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<label htmlFor="eagle-base-url" className="block text-xs font-medium text-foreground/58">Eagle 本地 API 地址</label>
|
||||
<Input id="eagle-base-url" aria-label="Eagle 本地 API 地址" value={eagleBaseUrl} onChange={(event) => setEagleBaseUrl(event.target.value)} placeholder="http://localhost:41595" className="mt-2 w-full" />
|
||||
<p className="mt-2 text-xs leading-5 text-foreground/45">Eagle 必须在本机运行;影策通过插件直接读取和写入 Eagle 原始文件。</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label htmlFor="eagle-auto-upload-generated" className="block text-xs font-medium text-foreground/58">自动归档生成结果</label>
|
||||
<Switch id="eagle-auto-upload-generated" checked={eagleAutoUploadGenerated} onChange={setEagleAutoUploadGenerated} aria-label="自动归档生成结果到 Eagle" />
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-foreground/45">图片、视频和音频生成成功后,自动写入 Eagle;影策本地素材仍会保留。</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label htmlFor="eagle-generated-folder" className="block text-xs font-medium text-foreground/58">生成结果写入文件夹</label>
|
||||
<Button type="link" size="small" loading={eagleFoldersLoading} onClick={() => void loadEagleFolders()}>读取文件夹</Button>
|
||||
</div>
|
||||
<Select
|
||||
id="eagle-generated-folder"
|
||||
aria-label="生成结果写入文件夹"
|
||||
className="mt-2 w-full"
|
||||
showSearch
|
||||
allowClear
|
||||
value={eagleGeneratedFolderId || undefined}
|
||||
placeholder="Eagle 根目录"
|
||||
optionFilterProp="label"
|
||||
options={[{ value: "__root__", label: "Eagle 根目录" }, ...eagleFolderOptions(eagleFolders)]}
|
||||
onChange={(value) => setEagleGeneratedFolderId(value === "__root__" || !value ? "" : value)}
|
||||
/>
|
||||
<p className="mt-2 text-xs leading-5 text-foreground/45">{eagleFoldersError || "默认写入 Eagle 根目录;选择文件夹后按 Eagle 原始目录归档。"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex shrink-0 flex-wrap gap-2">
|
||||
<Button type="primary" className="min-h-9" icon={<CheckCircle2 className="size-4" />} onClick={saveEagleConfig}>保存配置</Button>
|
||||
<Button className="min-h-9" icon={<FolderOpen className="size-4" />} disabled={!enabled} onClick={() => navigate("/plugins/eagle")}>打开 Eagle 素材库</Button>
|
||||
<Button className="min-h-9" icon={<ExternalLink className="size-4" />} href="https://api.eagle.cool/" target="_blank">查看 API</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-[var(--r-lg)] bg-foreground/[.035] p-4 text-sm text-foreground/55">该插件暂无可编辑设置项。当前接入位置和权限会根据插件清单自动生效。</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-x-6 gap-y-3 text-xs text-foreground/52">
|
||||
<div><span className="mr-2 text-foreground/38">接入位置</span>{plugin.manifest.surfaces.map((surface) => surfaceLabels[surface] ?? surface).join("、")}</div>
|
||||
<div><span className="mr-2 text-foreground/38">插件能力</span>{plugin.manifest.permissions.map((permission) => permissionLabels[permission] ?? permission).join("、")}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{installation?.lastError ? <Typography.Text type="danger" className="mt-3 block text-xs" role="alert">{installation.lastError}</Typography.Text> : null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function eagleFolderOptions(folders: EagleFolder[]) {
|
||||
const byId = new Map(folders.map((folder) => [folder.id, folder]));
|
||||
const pathFor = (folder: EagleFolder) => {
|
||||
const path: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let current: EagleFolder | undefined = folder;
|
||||
while (current && !seen.has(current.id)) {
|
||||
seen.add(current.id);
|
||||
path.unshift(current.name);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
return path.join(" / ");
|
||||
};
|
||||
return folders
|
||||
.map((folder) => ({ value: folder.id, label: pathFor(folder) }))
|
||||
.sort((left, right) => left.label.localeCompare(right.label, "zh-CN"));
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { AssetMediaPreview } from "@/components/asset-media-preview";
|
||||
import { CachedResourceImage } from "@/components/cached-resource-image";
|
||||
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
|
||||
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||||
import { useExternalAssetSources } from "@/hooks/use-external-asset-sources";
|
||||
import { CanvasFolderPreview } from "@/components/canvas/canvas-folder-preview";
|
||||
import { CANVAS_FOLDER_THEME_OPTIONS, resolveCanvasFolderTheme } from "@/lib/canvas/canvas-folder-theme";
|
||||
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
|
||||
@@ -59,6 +60,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
const { message, modal } = App.useApp();
|
||||
const queryClient = useQueryClient();
|
||||
const personalAssets = useAssetStore((state) => state.assets);
|
||||
const addAsset = useAssetStore((state) => state.addAsset);
|
||||
const updatePersonalAsset = useAssetStore((state) => state.updateAsset);
|
||||
const effectiveConfig = useEffectiveConfig();
|
||||
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
|
||||
@@ -69,6 +71,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [editorAsset, setEditorAsset] = useState<ProjectAsset | "new" | null>(null);
|
||||
const [imageAsset, setImageAsset] = useState<ProjectAsset | null>(null);
|
||||
const externalAssetSources = useExternalAssetSources(addOpen || Boolean(imageAsset));
|
||||
const [voiceAsset, setVoiceAsset] = useState<ProjectAsset | null>(null);
|
||||
const [previewAsset, setPreviewAsset] = useState<ProjectAsset | null>(null);
|
||||
const [voiceProfileId, setVoiceProfileId] = useState("");
|
||||
@@ -78,23 +81,29 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
const projectAssetIds = new Set(detail.assets.map((asset) => asset.id));
|
||||
const availableAssets = personalAssets.filter((asset) => !projectAssetIds.has(asset.id));
|
||||
const imageAssets = personalAssets.filter((asset): asset is ImageAsset => asset.kind === "image");
|
||||
const availablePickerItems = useMemo<AssetLibraryPickerItem[]>(() => availableAssets.map((asset) => ({
|
||||
id: asset.id,
|
||||
title: asset.title,
|
||||
category: asset.kind === "entity" ? "character" : asset.category || "other",
|
||||
kindLabel: mediaLabel(asset.kind),
|
||||
asset,
|
||||
description: asset.note,
|
||||
searchText: asset.tags.join(" "),
|
||||
})), [availableAssets]);
|
||||
const imagePickerItems = useMemo<AssetLibraryPickerItem[]>(() => imageAssets.map((asset) => ({
|
||||
id: asset.id,
|
||||
title: asset.title,
|
||||
category: asset.category || "other",
|
||||
kindLabel: "图片",
|
||||
asset,
|
||||
searchText: asset.tags.join(" "),
|
||||
})), [imageAssets]);
|
||||
const availablePickerItems = useMemo<AssetLibraryPickerItem[]>(() => [
|
||||
...availableAssets.map((asset) => ({
|
||||
id: asset.id,
|
||||
title: asset.title,
|
||||
category: asset.kind === "entity" ? "character" : asset.category || "other",
|
||||
kindLabel: mediaLabel(asset.kind),
|
||||
asset,
|
||||
description: asset.note,
|
||||
searchText: asset.tags.join(" "),
|
||||
})),
|
||||
...externalAssetSources.items,
|
||||
], [availableAssets, externalAssetSources.items]);
|
||||
const imagePickerItems = useMemo<AssetLibraryPickerItem[]>(() => [
|
||||
...imageAssets.map((asset) => ({
|
||||
id: asset.id,
|
||||
title: asset.title,
|
||||
category: asset.category || "other",
|
||||
kindLabel: "图片",
|
||||
asset,
|
||||
searchText: asset.tags.join(" "),
|
||||
})),
|
||||
...externalAssetSources.items.filter((item) => item.external?.item.kind === "image"),
|
||||
], [externalAssetSources.items, imageAssets]);
|
||||
const pendingCandidates = detail.assetCandidates.filter((candidate) => candidate.category === "character" && candidate.status === "pending_confirmation");
|
||||
const characterAssets = detail.assets.filter((asset) => asset.category === "character" && asset.character);
|
||||
const mediaAssetCount = detail.assets.filter((asset) => asset.category !== "character").length;
|
||||
@@ -202,7 +211,8 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
if (!imageAsset) throw new Error("未选择角色");
|
||||
await saveRemoteUserDataNow();
|
||||
const latest = useAssetStore.getState().assets;
|
||||
const selected = latest.find((asset) => asset.id === selectedAssetId);
|
||||
const pickerItem = imagePickerItems.find((item) => item.id === selectedAssetId);
|
||||
const selected = latest.find((asset) => asset.id === selectedAssetId) || (pickerItem?.external ? await externalAssetSources.importExternalAsset(pickerItem.external) : undefined);
|
||||
if (selected?.kind !== "image") throw new Error("请选择一张包含正面、侧面和背面的三视图设定图");
|
||||
const resourceId = resourceIdFromStorageKey((selected as ImageAsset).data.storageKey);
|
||||
if (!resourceId) throw new Error("所选图片尚未同步到后端资源库");
|
||||
@@ -324,15 +334,24 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
<AssetLibraryPickerModal
|
||||
open={addOpen}
|
||||
items={availablePickerItems}
|
||||
categoryLabels={pickerCategoryLabels}
|
||||
categoryLabels={{ ...pickerCategoryLabels, ...externalAssetSources.categoryLabels }}
|
||||
folders={externalAssetSources.folders}
|
||||
footerNote={externalAssetSources.error || undefined}
|
||||
multiple={false}
|
||||
title="个人素材"
|
||||
title="素材库"
|
||||
confirmLabel={() => "加入项目"}
|
||||
emptyTitle="没有可引用的个人素材"
|
||||
emptyDescription="个人素材已全部加入项目,或素材库仍为空。"
|
||||
emptyTitle="没有可引用的素材"
|
||||
emptyDescription="本地素材已全部加入项目,或切换到插件来源选择外部素材。"
|
||||
onClose={() => setAddOpen(false)}
|
||||
onConfirm={async (ids) => {
|
||||
const selected = personalAssets.find((asset) => asset.id === ids[0]);
|
||||
const selectedItem = availablePickerItems.find((item) => item.id === ids[0]);
|
||||
if (selectedItem?.external) {
|
||||
const imported = await externalAssetSources.importExternalAsset(selectedItem.external);
|
||||
const assetId = addAsset(imported);
|
||||
await addMutation.mutateAsync({ assetId, category: imported.kind === "entity" ? "character" : imported.category || "other", nextFolderId: folderId === ALL_FOLDERS ? undefined : folderId });
|
||||
return;
|
||||
}
|
||||
const selected = selectedItem?.asset || personalAssets.find((asset) => asset.id === ids[0]);
|
||||
if (!selected) throw new Error("所选素材已不存在,请重新选择");
|
||||
await addMutation.mutateAsync({ assetId: selected.id, category: selected.kind === "entity" ? "character" : selected.category || "other", nextFolderId: folderId === ALL_FOLDERS ? undefined : folderId });
|
||||
}}
|
||||
@@ -345,12 +364,14 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
<AssetLibraryPickerModal
|
||||
open={Boolean(imageAsset)}
|
||||
items={imagePickerItems}
|
||||
categoryLabels={pickerCategoryLabels}
|
||||
categoryLabels={{ ...pickerCategoryLabels, ...externalAssetSources.categoryLabels }}
|
||||
folders={externalAssetSources.folders}
|
||||
footerNote={externalAssetSources.error || undefined}
|
||||
multiple={false}
|
||||
eyebrow="绑定图片"
|
||||
title={imageAsset?.title || "角色三视图"}
|
||||
confirmLabel={() => "绑定并生成新版本"}
|
||||
emptyTitle="个人图片素材为空"
|
||||
emptyTitle="没有可绑定的图片"
|
||||
emptyDescription="需要一张包含正面、侧面、背面的角色设定图。"
|
||||
onClose={() => setImageAsset(null)}
|
||||
onConfirm={async (ids) => {
|
||||
|
||||
@@ -37,6 +37,8 @@ const CreatePage = lazy(loadCreatePage);
|
||||
const HomePage = lazy(() => import("@/pages/home"));
|
||||
const NotFound = lazy(() => import("@/pages/not-found"));
|
||||
const SkillsPage = lazy(() => import("@/pages/skills"));
|
||||
const PluginsPage = lazy(() => import("@/pages/plugins"));
|
||||
const EagleLibraryPage = lazy(() => import("@/pages/plugins/eagle"));
|
||||
const TasksPage = lazy(() => import("@/pages/tasks"));
|
||||
const WalletPage = lazy(loadWalletPage);
|
||||
const ProjectsPage = lazy(loadProjectsPage);
|
||||
@@ -87,6 +89,8 @@ export const router = createBrowserRouter([
|
||||
},
|
||||
{ path: "/assets", element: <RequireAuth>{deferred(<AssetsPage />)}</RequireAuth> },
|
||||
{ path: "/skills", element: <RequireAuth>{deferred(<SkillsPage />)}</RequireAuth> },
|
||||
{ path: "/plugins", element: <RequireAuth>{deferred(<PluginsPage />)}</RequireAuth> },
|
||||
{ path: "/plugins/eagle", element: <RequireAuth>{deferred(<EagleLibraryPage />)}</RequireAuth> },
|
||||
{
|
||||
path: "/wallet",
|
||||
element: (
|
||||
|
||||
85
web/src/services/api/eagle.ts
Normal file
85
web/src/services/api/eagle.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { apiBaseURL, apiClient, request } from "@/services/api/request";
|
||||
|
||||
export type EagleFolder = {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId?: string;
|
||||
};
|
||||
|
||||
export type EagleLibrary = {
|
||||
applicationVersion: string;
|
||||
libraryName: string;
|
||||
folders: EagleFolder[];
|
||||
};
|
||||
|
||||
export type EagleItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
extension: string;
|
||||
tags: string[];
|
||||
folderIds: string[];
|
||||
url: string;
|
||||
annotation: string;
|
||||
modificationTime: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
deleted: boolean;
|
||||
};
|
||||
|
||||
export type EagleAddItemInput = {
|
||||
url: string;
|
||||
name: string;
|
||||
folderId?: string;
|
||||
tags?: string[];
|
||||
annotation?: string;
|
||||
website?: string;
|
||||
modificationTime?: number;
|
||||
};
|
||||
|
||||
export async function getEagleLibrary(baseUrl: string) {
|
||||
return request<{ library: EagleLibrary }>(apiClient.get("/plugins/eagle/library", { params: { baseUrl } }));
|
||||
}
|
||||
|
||||
export async function listEagleItems(input: { baseUrl: string; folderId?: string; keyword?: string; limit?: number; offset?: number }) {
|
||||
return request<{ items: EagleItem[] }>(apiClient.get("/plugins/eagle/items", {
|
||||
params: {
|
||||
baseUrl: input.baseUrl,
|
||||
folderId: input.folderId || undefined,
|
||||
keyword: input.keyword || undefined,
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export function eagleItemThumbnailUrl(itemId: string, baseUrl: string) {
|
||||
return `${String(apiBaseURL).replace(/\/+$/, "")}/plugins/eagle/items/${encodeURIComponent(itemId)}/thumbnail?baseUrl=${encodeURIComponent(baseUrl)}`;
|
||||
}
|
||||
|
||||
export function eagleItemFileUrl(itemId: string, baseUrl: string) {
|
||||
return String(apiBaseURL).replace(/\/+$/, "") + "/plugins/eagle/items/" + encodeURIComponent(itemId) + "/file?baseUrl=" + encodeURIComponent(baseUrl);
|
||||
}
|
||||
|
||||
export async function downloadEagleItem(itemId: string, baseUrl: string, signal?: AbortSignal) {
|
||||
const response = await fetch(`${String(apiBaseURL).replace(/\/+$/, "")}/plugins/eagle/items/${encodeURIComponent(itemId)}/file?baseUrl=${encodeURIComponent(baseUrl)}`, { credentials: "include", signal });
|
||||
if (!response.ok) {
|
||||
let message = "读取 Eagle 素材失败";
|
||||
try {
|
||||
const body = await response.json() as { msg?: string };
|
||||
message = body.msg || message;
|
||||
} catch {
|
||||
// 保留通用错误,避免把 HTML 或服务器内部信息展示给用户。
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export async function addEagleItem(baseUrl: string, input: EagleAddItemInput) {
|
||||
return request<{ item: { id?: string } }>(apiClient.post(`/plugins/eagle/items?baseUrl=${encodeURIComponent(baseUrl)}`, input));
|
||||
}
|
||||
|
||||
export async function createEagleFolder(baseUrl: string, input: { name: string; parentId?: string }) {
|
||||
return request<{ created: boolean }>(apiClient.post(`/plugins/eagle/folders?baseUrl=${encodeURIComponent(baseUrl)}`, input));
|
||||
}
|
||||
124
web/src/services/external-asset-sources.ts
Normal file
124
web/src/services/external-asset-sources.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import "@/lib/plugins/builtin";
|
||||
|
||||
import { getRegisteredPlugin } from "@/lib/plugins/plugin-registry";
|
||||
import { pluginStorageFor } from "@/lib/plugins/plugin-storage";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
import { usePluginStore } from "@/stores/use-plugin-store";
|
||||
import type { AssetSourceProvider, ExternalAssetItem, PluginInstallation } from "@/lib/plugins/plugin-types";
|
||||
|
||||
export type HostedAssetSource = {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: AssetSourceProvider;
|
||||
};
|
||||
|
||||
export type ExternalAssetSyncRecord = {
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
status: "synced" | "failed";
|
||||
externalId?: string;
|
||||
folderId?: string;
|
||||
error?: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ExternalAssetSyncResult = {
|
||||
records: ExternalAssetSyncRecord[];
|
||||
uploaded: ExternalAssetItem[];
|
||||
failures: Array<{ sourceId: string; sourceName: string; error: string }>;
|
||||
};
|
||||
|
||||
export function createHostedAssetSources(installations: PluginInstallation[]): HostedAssetSource[] {
|
||||
return installations.flatMap((installation) => {
|
||||
if (!installation.enabled) return [];
|
||||
const plugin = getRegisteredPlugin(installation.manifest.id);
|
||||
if (!plugin?.createAssetSource) return [];
|
||||
try {
|
||||
return [
|
||||
{
|
||||
id: plugin.manifest.id,
|
||||
name: plugin.manifest.name,
|
||||
provider: plugin.createAssetSource({
|
||||
manifest: plugin.manifest,
|
||||
permissions: new Set(plugin.manifest.permissions),
|
||||
storage: pluginStorageFor(plugin.manifest.id),
|
||||
config: installation.config,
|
||||
}),
|
||||
},
|
||||
];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadGeneratedAssetToConfiguredSources(asset: Asset, signal?: AbortSignal): Promise<ExternalAssetSyncResult> {
|
||||
const installations = usePluginStore.getState().installations;
|
||||
const autoUploadInstallations = installations.filter((installation) => installation.enabled && isAutoUploadEnabled(installation.config.autoUploadGenerated));
|
||||
if (!autoUploadInstallations.length) return { records: readSyncRecords(asset), uploaded: [], failures: [] };
|
||||
|
||||
const sources = createHostedAssetSources(installations);
|
||||
const records = readSyncRecords(asset);
|
||||
const uploaded: ExternalAssetItem[] = [];
|
||||
const failures: ExternalAssetSyncResult["failures"] = [];
|
||||
for (const installation of autoUploadInstallations) {
|
||||
if (signal?.aborted) throw new DOMException("请求已取消", "AbortError");
|
||||
const source = sources.find((candidate) => candidate.id === installation.manifest.id);
|
||||
const folderId = stringConfig(installation.config.generatedFolderId);
|
||||
const existing = records.find((record) => record.sourceId === installation.manifest.id);
|
||||
if (existing?.status === "synced" && existing.folderId === folderId) continue;
|
||||
|
||||
if (!source) {
|
||||
const error = "插件未能加载素材来源";
|
||||
replaceSyncRecord(records, { sourceId: installation.manifest.id, sourceName: installation.manifest.name, status: "failed", folderId, error, updatedAt: new Date().toISOString() });
|
||||
failures.push({ sourceId: installation.manifest.id, sourceName: installation.manifest.name, error });
|
||||
continue;
|
||||
}
|
||||
if (!source.provider.uploadAsset && !source.provider.uploadAssetToFolder) {
|
||||
const error = "当前插件不支持自动写入生成结果";
|
||||
replaceSyncRecord(records, { sourceId: source.id, sourceName: source.name, status: "failed", folderId, error, updatedAt: new Date().toISOString() });
|
||||
failures.push({ sourceId: source.id, sourceName: source.name, error });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const item = source.provider.uploadAssetToFolder ? await source.provider.uploadAssetToFolder(asset, folderId, signal) : await source.provider.uploadAsset!(asset, signal);
|
||||
uploaded.push(item);
|
||||
replaceSyncRecord(records, { sourceId: source.id, sourceName: source.name, status: "synced", externalId: item.id, folderId, updatedAt: new Date().toISOString() });
|
||||
} catch (reason) {
|
||||
if (reason instanceof DOMException && reason.name === "AbortError") throw reason;
|
||||
const error = reason instanceof Error ? reason.message : "插件写入失败";
|
||||
replaceSyncRecord(records, { sourceId: source.id, sourceName: source.name, status: "failed", folderId, error, updatedAt: new Date().toISOString() });
|
||||
failures.push({ sourceId: source.id, sourceName: source.name, error });
|
||||
}
|
||||
}
|
||||
return { records, uploaded, failures };
|
||||
}
|
||||
|
||||
export function externalAssetSyncFailures(asset: Asset) {
|
||||
return readSyncRecords(asset).filter((record) => record.status === "failed");
|
||||
}
|
||||
|
||||
function isAutoUploadEnabled(value: unknown) {
|
||||
return value !== false && value !== "false";
|
||||
}
|
||||
|
||||
function stringConfig(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readSyncRecords(asset: Asset): ExternalAssetSyncRecord[] {
|
||||
const raw = asset.metadata?.externalSync;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter((item): item is ExternalAssetSyncRecord => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
const candidate = item as Partial<ExternalAssetSyncRecord>;
|
||||
return typeof candidate.sourceId === "string" && typeof candidate.sourceName === "string" && (candidate.status === "synced" || candidate.status === "failed") && typeof candidate.updatedAt === "string";
|
||||
});
|
||||
}
|
||||
|
||||
function replaceSyncRecord(records: ExternalAssetSyncRecord[], next: ExternalAssetSyncRecord) {
|
||||
const index = records.findIndex((record) => record.sourceId === next.sourceId);
|
||||
if (index === -1) records.push(next);
|
||||
else records[index] = next;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { GenerationTask, GenerationTaskOutput } from "@/services/api/task-c
|
||||
import { getMediaBlob, resolveMediaUrl, setMediaBlob } from "@/services/file-storage";
|
||||
import { createGenerationTaskMaterializer, createIdempotentMaterializeOutput, type MaterializeGenerationTaskOutput } from "@/services/generation-task-materializer";
|
||||
import { withGenerationArtifactCommitLock } from "@/services/generation-asset-repository";
|
||||
import { uploadGeneratedAssetToConfiguredSources } from "@/services/external-asset-sources";
|
||||
import { getImageBlob, resolveImageUrl, setImageBlob } from "@/services/image-storage";
|
||||
import { generationArtifactStorageKey, loadOrStoreGenerationArtifact } from "@/services/generation-artifact-sink";
|
||||
import { createLocalDreaminaTaskEffectStore } from "@/services/local-dreamina-generation";
|
||||
@@ -351,18 +352,30 @@ function generationTaskEffectStore() {
|
||||
const materializeGenerationOutput: MaterializeGenerationTaskOutput = createIdempotentMaterializeOutput({
|
||||
async insertOrReturnAsset(input) {
|
||||
const scope = getActiveUserScope();
|
||||
return withGenerationArtifactCommitLock(
|
||||
const assetId = await withGenerationArtifactCommitLock(
|
||||
scope,
|
||||
async () => {
|
||||
throwIfAborted(input.signal);
|
||||
const asset = await generationOutputAsset(input, scope);
|
||||
throwIfAborted(input.signal);
|
||||
const assetId = await useAssetStore.getState().addGenerationAsset(input.effectKey, asset, input.signal);
|
||||
const createdAssetId = await useAssetStore.getState().addGenerationAsset(input.effectKey, asset, input.signal);
|
||||
throwIfAborted(input.signal);
|
||||
return assetId;
|
||||
return createdAssetId;
|
||||
},
|
||||
{ requireCrossRealmLock: true },
|
||||
);
|
||||
const storedAsset = useAssetStore.getState().assets.find((candidate) => candidate.id === assetId);
|
||||
if (storedAsset) {
|
||||
const syncResult = await uploadGeneratedAssetToConfiguredSources(storedAsset, input.signal);
|
||||
throwIfAborted(input.signal);
|
||||
if (syncResult.records.length) {
|
||||
const currentAsset = useAssetStore.getState().assets.find((candidate) => candidate.id === assetId) || storedAsset;
|
||||
useAssetStore.getState().updateAsset(assetId, {
|
||||
metadata: { ...currentAsset.metadata, externalSync: syncResult.records },
|
||||
});
|
||||
}
|
||||
}
|
||||
return assetId;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
61
web/src/stores/use-plugin-store.ts
Normal file
61
web/src/stores/use-plugin-store.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
import { localForageStorageForScope } from "@/lib/localforage-storage";
|
||||
import type { PluginInstallation, PluginManifest } from "@/lib/plugins/plugin-types";
|
||||
|
||||
export const PLUGIN_STORE_KEY = "infinite-canvas:plugin-store";
|
||||
|
||||
type PluginStore = {
|
||||
hydrated: boolean;
|
||||
installations: PluginInstallation[];
|
||||
ensurePlugin: (manifest: PluginManifest) => void;
|
||||
setEnabled: (pluginId: string, enabled: boolean) => void;
|
||||
updateConfig: (pluginId: string, config: Record<string, string | number | boolean>) => void;
|
||||
setError: (pluginId: string, error?: string) => void;
|
||||
removePlugin: (pluginId: string) => void;
|
||||
};
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export const usePluginStore = create<PluginStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
hydrated: false,
|
||||
installations: [],
|
||||
ensurePlugin: (manifest) =>
|
||||
set((state) => {
|
||||
const current = state.installations.find((item) => item.manifest.id === manifest.id);
|
||||
if (current && current.manifest.version === manifest.version) return { hydrated: true };
|
||||
const timestamp = now();
|
||||
const next: PluginInstallation = current
|
||||
? { ...current, manifest, updatedAt: timestamp }
|
||||
: { manifest, enabled: false, config: {}, installedAt: timestamp, updatedAt: timestamp };
|
||||
return { hydrated: true, installations: [...state.installations.filter((item) => item.manifest.id !== manifest.id), next] };
|
||||
}),
|
||||
setEnabled: (pluginId, enabled) =>
|
||||
set((state) => ({
|
||||
installations: state.installations.map((item) => item.manifest.id === pluginId ? { ...item, enabled, updatedAt: now(), lastError: undefined } : item),
|
||||
})),
|
||||
updateConfig: (pluginId, config) =>
|
||||
set((state) => ({
|
||||
installations: state.installations.map((item) => item.manifest.id === pluginId ? { ...item, config: { ...item.config, ...config }, updatedAt: now() } : item),
|
||||
})),
|
||||
setError: (pluginId, error) =>
|
||||
set((state) => ({
|
||||
installations: state.installations.map((item) => item.manifest.id === pluginId ? { ...item, lastError: error, updatedAt: now() } : item),
|
||||
})),
|
||||
removePlugin: (pluginId) => set((state) => ({ installations: state.installations.filter((item) => item.manifest.id !== pluginId) })),
|
||||
}),
|
||||
{
|
||||
name: PLUGIN_STORE_KEY,
|
||||
storage: createJSONStorage(() => localForageStorageForScope()),
|
||||
partialize: (state) => ({ installations: state.installations }),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (state) state.hydrated = true;
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -1394,12 +1394,46 @@
|
||||
|
||||
.canvas-topbar-cluster {
|
||||
min-height: var(--space-10);
|
||||
border: 0;
|
||||
border: 1px solid var(--dock-border);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--canvas-topbar-surface);
|
||||
background: var(--dock-surface);
|
||||
padding: var(--space-half) var(--space-2);
|
||||
box-shadow: var(--workspace-overlay-shadow);
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
box-shadow: var(--elevation-overlay);
|
||||
backdrop-filter: blur(var(--dock-blur)) saturate(var(--glass-saturate));
|
||||
}
|
||||
|
||||
/* 顶部操作区复用底部 FloatingDock 的命令状态,保持两组浮层在不同主题下有同一套反馈。 */
|
||||
.canvas-topbar-action {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
transition:
|
||||
background-color var(--motion-instant) ease-out,
|
||||
color var(--motion-instant) ease-out,
|
||||
opacity var(--motion-instant) ease-out;
|
||||
}
|
||||
|
||||
.canvas-topbar-action:hover:not(:disabled) {
|
||||
background: var(--dock-command-hover) !important;
|
||||
}
|
||||
|
||||
.canvas-mode-switch-trigger {
|
||||
border-color: var(--dock-border);
|
||||
box-shadow: var(--elevation-overlay);
|
||||
transition: background-color var(--motion-instant) ease-out, color var(--motion-instant) ease-out;
|
||||
}
|
||||
|
||||
.canvas-mode-switch-trigger:hover:not(:disabled) {
|
||||
background: var(--dock-command-hover) !important;
|
||||
}
|
||||
|
||||
.canvas-mode-switch-menu {
|
||||
border-color: var(--dock-border);
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
.canvas-mode-switch-option:hover:not([aria-selected="true"]) {
|
||||
background: var(--dock-command-hover) !important;
|
||||
}
|
||||
|
||||
.canvas-topbar-project-cluster {
|
||||
@@ -5436,6 +5470,21 @@
|
||||
box-shadow: var(--elevation-overlay);
|
||||
}
|
||||
|
||||
.canvas-asset-tray-panel {
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.canvas-asset-tray-panel.is-resizing {
|
||||
cursor: ns-resize;
|
||||
user-select: none;
|
||||
will-change: height;
|
||||
}
|
||||
|
||||
.canvas-asset-tray-resize-handle {
|
||||
touch-action: none;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.app-workspace-canvas {
|
||||
background: var(--workspace-content);
|
||||
}
|
||||
@@ -6247,6 +6296,7 @@
|
||||
.canvas-folder-shell {
|
||||
transition:
|
||||
box-shadow var(--motion-state) var(--ease-aceternity),
|
||||
border-color var(--motion-state) var(--ease-aceternity),
|
||||
background-color var(--motion-state) var(--ease-aceternity),
|
||||
transform var(--motion-state) var(--ease-aceternity);
|
||||
}
|
||||
@@ -6742,6 +6792,11 @@
|
||||
padding-inline: var(--space-2);
|
||||
}
|
||||
|
||||
.canvas-node-composer-icon-button:hover,
|
||||
.canvas-node-composer-parameters-toggle:hover {
|
||||
background: var(--canvas-composer-control-hover) !important;
|
||||
}
|
||||
|
||||
.canvas-node-composer-editor {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -9518,41 +9573,56 @@ html:not(.dark) .creation-history-drawer-root {
|
||||
.asset-library-picker-modal .ant-modal-close { top: 12px; right: 12px; }
|
||||
.asset-picker-shell { display: flex; height: min(640px, calc(100vh - var(--space-12))); min-height: 460px; flex-direction: column; color: var(--foreground); overscroll-behavior: contain; }
|
||||
.asset-picker-toolbar { display: grid; min-height: 64px; flex-shrink: 0; grid-template-columns: minmax(132px, 1fr) minmax(240px, 360px) minmax(132px, 1fr); align-items: center; gap: 16px; padding: 10px 52px 10px 18px; }
|
||||
.asset-picker-heading { display: flex; min-width: 0; align-items: baseline; gap: 10px; }
|
||||
.asset-picker-heading span { color: var(--foreground); font-size: var(--fs-label); font-weight: 650; }
|
||||
.asset-picker-heading strong { color: color-mix(in srgb, var(--foreground) 40%, transparent); font-size: var(--fs-label); font-weight: 560; }
|
||||
.asset-picker-heading { display: flex; min-width: 0; align-items: center; gap: 10px; overflow: hidden; }
|
||||
.asset-picker-heading-copy { display: flex; min-width: 0; flex-shrink: 0; align-items: center; gap: 10px; }
|
||||
.asset-picker-heading-copy > span { color: var(--foreground); font-size: var(--fs-label); font-weight: 650; line-height: 1.2; }
|
||||
.asset-picker-title-trigger { display: inline-flex; min-height: 32px; align-items: center; gap: 6px; border: 0; border-radius: var(--r-sm); background: transparent; padding: 0 6px; color: var(--foreground); cursor: pointer; transition: background-color var(--motion-state) ease, color var(--motion-state) ease; }
|
||||
.asset-picker-title-trigger strong { color: color-mix(in srgb, var(--foreground) 40%, transparent); font-size: var(--fs-label); font-weight: 560; line-height: 1.2; }
|
||||
.asset-picker-title-trigger:hover { background: color-mix(in srgb, var(--foreground) 7%, transparent); }
|
||||
.asset-picker-title-trigger:hover strong { color: var(--foreground); }
|
||||
.asset-picker-title-trigger:focus-visible { outline: 2px solid var(--control-focus-ring); outline-offset: -2px; }
|
||||
.asset-picker-title-trigger svg { width: 14px; height: 14px; color: color-mix(in srgb, var(--foreground) 48%, transparent); }
|
||||
.asset-picker-source-dropdown .ant-dropdown-menu { width: 148px !important; min-width: 0 !important; padding: 3px !important; }
|
||||
.asset-picker-source-dropdown .ant-dropdown-menu-item { min-height: 30px; padding: 4px 8px !important; border-radius: var(--r-sm) !important; font-size: var(--fs-micro); line-height: 20px; }
|
||||
.asset-picker-source-dropdown .ant-dropdown-menu-item-icon { width: 15px; height: 15px; margin-inline-end: 6px !important; }
|
||||
.asset-picker-source-menu-label { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.asset-picker-source-menu-label em { color: color-mix(in srgb, currentColor 58%, transparent); font-size: var(--fs-micro); font-style: normal; }
|
||||
.asset-picker-search { display: flex; height: 38px; min-width: 0; align-items: center; gap: 9px; border-radius: var(--r-sm); background: color-mix(in srgb, var(--foreground) 6%, transparent); padding: 0 12px; transition: background-color var(--motion-state) ease, box-shadow var(--motion-state) ease; }
|
||||
.asset-picker-search:focus-within { background: color-mix(in srgb, var(--foreground) 8%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 54%, transparent); }
|
||||
.asset-picker-search svg { width: 16px; height: 16px; flex-shrink: 0; color: color-mix(in srgb, var(--foreground) 42%, transparent); }
|
||||
.asset-picker-search input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--foreground); font-size: var(--fs-label); }
|
||||
.asset-picker-count { justify-self: end; color: color-mix(in srgb, var(--foreground) 42%, transparent); font-size: var(--fs-micro); }
|
||||
.asset-picker-body { display: grid; min-height: 0; flex: 1; grid-template-columns: 156px minmax(0, 1fr); gap: 8px; padding: 0 8px; }
|
||||
.asset-picker-categories { overflow-y: auto; padding: 10px 8px; }
|
||||
.asset-picker-body { display: grid; min-height: 0; flex: 1; grid-template-columns: 176px minmax(0, 1fr); gap: var(--space-4); padding: 0 18px; }
|
||||
.asset-picker-categories { overflow-y: auto; padding: 10px 0; }
|
||||
.asset-picker-nav-label { display: block; padding: 10px 10px 4px; color: color-mix(in srgb, var(--foreground) 38%, transparent); font-size: var(--fs-micro); font-weight: 650; letter-spacing: .04em; }
|
||||
.asset-picker-categories button { display: flex; width: 100%; height: 42px; align-items: center; justify-content: space-between; gap: 8px; border: 0; border-radius: var(--r-sm); background: transparent; padding: 0 10px; color: color-mix(in srgb, var(--foreground) 54%, transparent); font-size: var(--fs-label); text-align: left; transition: background-color var(--motion-state) ease, color var(--motion-state) ease; }
|
||||
.asset-picker-categories button:hover { background: var(--library-surface-hover); color: var(--foreground); }
|
||||
.asset-picker-categories button.is-active { background: var(--library-surface-hover); color: var(--foreground); font-weight: 650; }
|
||||
.asset-picker-categories em { min-width: 24px; border-radius: var(--r-xs); background: color-mix(in srgb, var(--foreground) 6%, transparent); padding: 2px 5px; color: color-mix(in srgb, var(--foreground) 44%, transparent); font-size: var(--fs-micro); font-style: normal; text-align: center; }
|
||||
.asset-picker-grid-wrap { min-width: 0; overflow-y: auto; padding: 8px 8px 16px; }
|
||||
.asset-picker-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(152px, 1fr)); gap: 12px; align-content: start; }
|
||||
.asset-picker-card { min-width: 0; overflow: hidden; border: 0; border-radius: var(--r-md); background: var(--library-surface); color: var(--foreground); padding: 0; text-align: left; transition: background-color var(--motion-state) ease, box-shadow var(--motion-state) ease, transform var(--motion-state) var(--motion-ease-out); }
|
||||
.asset-picker-card:hover:not(:disabled) { background: var(--library-surface-hover); transform: translateY(var(--library-hover-offset)); }
|
||||
.asset-picker-card:active:not(:disabled) { transform: translateY(var(--library-active-offset)) scale(.995); }
|
||||
.asset-picker-card:focus-visible { outline: 2px solid var(--control-focus-ring); outline-offset: 2px; }
|
||||
.asset-picker-categories .assets-filter-item { width: 100%; height: 32px; }
|
||||
.asset-picker-categories .assets-filter-item:hover { background: color-mix(in srgb, var(--foreground) 6%, transparent); color: var(--foreground); }
|
||||
.asset-picker-categories .assets-filter-item.is-active { background: var(--control-selected-bg); color: var(--control-selected-fg); font-weight: 560; }
|
||||
.asset-picker-categories .assets-filter-item.is-active:hover { background: var(--control-selected-hover-bg); color: var(--control-selected-fg); }
|
||||
.asset-picker-grid-wrap { min-width: 0; overflow-y: auto; padding: 8px 0 16px; }
|
||||
.asset-picker-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-5) var(--space-4); align-content: start; }
|
||||
.asset-picker-card { min-width: 0; overflow: hidden; }
|
||||
.asset-picker-card-action { display: block; width: 100%; border: 0; background: transparent; padding: 0; color: inherit; cursor: pointer; text-align: left; }
|
||||
.asset-picker-card-action:disabled { cursor: not-allowed; }
|
||||
.asset-library-picker-modal .asset-picker-card > .asset-picker-card-action { padding-inline: 0; }
|
||||
.asset-picker-card:hover { background: var(--library-surface-hover); }
|
||||
.asset-picker-card:active { transform: translateY(var(--library-active-offset)) scale(.995); }
|
||||
.asset-picker-card-action:focus-visible { outline: 2px solid var(--control-focus-ring); outline-offset: -2px; }
|
||||
.asset-picker-card.is-selected { box-shadow: inset 0 0 0 1px var(--library-selected-stroke); }
|
||||
.asset-picker-card.is-disabled { cursor: not-allowed; opacity: .46; }
|
||||
.asset-picker-card.is-disabled { opacity: .46; }
|
||||
.asset-picker-card-media { position: relative; aspect-ratio: 4 / 3; overflow: hidden; background: var(--library-cover-surface); }
|
||||
.cached-resource-image-shell { display: grid; width: 100%; height: 100%; place-items: center; }
|
||||
.asset-picker-card-media > img, .asset-picker-card-media > video, .asset-picker-card-media > .cached-resource-image-shell > img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.asset-picker-card-media > img.is-contain, .asset-picker-card-media > .cached-resource-image-shell > img.is-contain { object-fit: contain; padding: 4px; }
|
||||
.asset-picker-card-check { position: absolute; top: 9px; right: 9px; display: grid; width: 22px; height: 22px; place-items: center; border: 1px solid rgba(255, 255, 255, .72); border-radius: var(--r-xs); background: rgba(0, 0, 0, .28); color: transparent; backdrop-filter: blur(5px); }
|
||||
.asset-picker-card-check { position: absolute; right: 8px; bottom: 8px; z-index: 3; display: grid; width: 22px; height: 22px; place-items: center; border: 1.5px solid rgba(255, 255, 255, .72); border-radius: 7px; background: rgba(10, 10, 10, .42); color: transparent; opacity: 0; backdrop-filter: blur(6px); transition: opacity 180ms ease-out, background-color 180ms ease-out, border-color 180ms ease-out; }
|
||||
.asset-picker-card-check svg { width: 13px; height: 13px; }
|
||||
.asset-picker-card:hover .asset-picker-card-check, .asset-picker-card:focus-within .asset-picker-card-check, .asset-picker-card.is-selected .asset-picker-card-check { opacity: 1; }
|
||||
.asset-picker-card.is-selected .asset-picker-card-check { border-color: var(--control-check-bg); background: var(--control-check-bg); color: var(--control-check-fg); }
|
||||
.asset-picker-card-kind { position: absolute; bottom: 8px; left: 8px; border-radius: var(--r-xs); background: rgba(0, 0, 0, .58); padding: 3px 6px; color: #fff; font-size: var(--fs-micro); }
|
||||
.asset-picker-card-lock { position: absolute; right: 8px; bottom: 8px; max-width: 112px; color: rgba(255, 255, 255, .82); font-size: var(--fs-micro); text-align: right; }
|
||||
.asset-picker-card-copy { display: flex; min-height: 42px; min-width: 0; flex-direction: column; justify-content: center; gap: 2px; padding: 8px 10px 10px; }
|
||||
|
||||
.asset-picker-card-lock { position: absolute; right: 38px; bottom: 8px; max-width: 112px; color: rgba(255, 255, 255, .82); font-size: var(--fs-micro); text-align: right; }
|
||||
.asset-picker-card-copy { display: flex; min-height: 64px; min-width: 0; flex-direction: column; justify-content: center; gap: 4px; padding: 10px 10px 12px; }
|
||||
.asset-picker-card-copy strong, .asset-picker-card-copy span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.asset-picker-card-copy strong { font-size: var(--fs-label); font-weight: 560; }
|
||||
.asset-picker-card-copy strong { font-size: var(--fs-body); font-weight: 620; }
|
||||
.asset-picker-card-copy span { color: color-mix(in srgb, var(--foreground) 42%, transparent); font-size: var(--fs-micro); }
|
||||
.asset-picker-card-fallback, .asset-picker-empty { display: grid; place-items: center; color: color-mix(in srgb, var(--foreground) 36%, transparent); }
|
||||
.asset-picker-card-fallback { height: 100%; }
|
||||
@@ -9583,6 +9653,9 @@ html:not(.dark) .creation-history-drawer-root {
|
||||
.creation-empty-art-frame.is-back, .creation-empty-art-frame.is-front { height: 102px; }
|
||||
.asset-picker-shell { min-height: 0; height: min(640px, calc(100vh - var(--space-8))); }
|
||||
.asset-picker-toolbar { grid-template-columns: minmax(0, 1fr) auto; gap: 8px 12px; padding: 10px 42px 10px 12px; }
|
||||
.asset-picker-heading { gap: 6px; }
|
||||
.asset-picker-heading-copy { gap: 6px; }
|
||||
.asset-picker-title-trigger { padding-inline: 4px; }
|
||||
.asset-picker-search { grid-column: 1 / -1; grid-row: 2; max-width: none; }
|
||||
.asset-picker-count { grid-column: 2; grid-row: 1; }
|
||||
.asset-picker-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
|
||||
|
||||
Reference in New Issue
Block a user