mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 08:54:23 +08:00
Merge branch 'dev'
# Conflicts: # .gitignore # CHANGELOG.md # web/bun.lock
This commit is contained in:
@@ -6,10 +6,10 @@ import { imageAspectOptions, imageQualityOptions } from "@/components/image-sett
|
||||
import { videoResolutionOptions, videoSecondOptions, videoSizeOptions } from "@/components/video-settings-panel";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { modelOptionLabel, modelOptionName, normalizeModelOptionValue, useConfigStore } from "@/stores/use-config-store";
|
||||
import { modelOptionLabel, modelOptionName, normalizeModelOptionValue, selectableModelsByCapability, useConfigStore } from "@/stores/use-config-store";
|
||||
import { useWorkbenchAgentStore } from "@/stores/use-workbench-agent-store";
|
||||
|
||||
// 在网页端执行 Agent 的「站点级」工具(画布列表、工作台生成、提示词搜索、素材增删查等)。
|
||||
// 在网页端执行 Agent 的「站点级」工具(画布列表、工作台生成、提示词搜索、资产增删查等)。
|
||||
// 这些工具的数据都在浏览器本地(localforage / zustand),因此由本模块直接读写对应 store 后返回结果。
|
||||
|
||||
export const SITE_TOOL_NAMES = [
|
||||
@@ -36,8 +36,8 @@ export const SITE_TOOL_LABELS: Record<SiteToolName, string> = {
|
||||
workbench_video_get_config: "视频配置",
|
||||
workbench_video_generate: "视频创作台生成",
|
||||
prompts_search: "搜索提示词",
|
||||
assets_list: "素材列表",
|
||||
assets_add: "添加素材",
|
||||
assets_list: "资产列表",
|
||||
assets_add: "添加资产",
|
||||
};
|
||||
|
||||
type SiteToolInput = Record<string, unknown>;
|
||||
@@ -87,7 +87,7 @@ function getImageConfig() {
|
||||
const model = config.imageModel || config.model;
|
||||
return {
|
||||
current: { model, modelName: modelOptionName(model), quality: config.quality || "auto", size: config.size || "1:1", count: config.count || "1" },
|
||||
models: config.imageModels.map((value) => ({ value, label: modelOptionLabel(config, value) })),
|
||||
models: selectableModelsByCapability(config, "image").map((value) => ({ value, label: modelOptionLabel(config, value) })),
|
||||
qualityOptions: imageQualityOptions,
|
||||
sizeOptions: imageAspectOptions,
|
||||
countRange: { min: 1, max: 15 },
|
||||
@@ -135,7 +135,7 @@ function getVideoConfig() {
|
||||
generateAudio: config.videoGenerateAudio !== "false",
|
||||
watermark: config.videoWatermark === "true",
|
||||
},
|
||||
models: config.videoModels.map((value) => ({ value, label: modelOptionLabel(config, value) })),
|
||||
models: selectableModelsByCapability(config, "video").map((value) => ({ value, label: modelOptionLabel(config, value) })),
|
||||
sizeOptions: videoSizeOptions,
|
||||
secondsOptions: videoSecondOptions,
|
||||
resolutionOptions: videoResolutionOptions,
|
||||
@@ -194,7 +194,7 @@ async function searchPrompts(input: SiteToolInput) {
|
||||
|
||||
function listAssets(input: SiteToolInput) {
|
||||
const { assets, hydrated } = useAssetStore.getState();
|
||||
if (!hydrated) throw new Error("素材还在加载中,请稍后重试");
|
||||
if (!hydrated) throw new Error("资产还在加载中,请稍后重试");
|
||||
const kind = input.kind === "text" || input.kind === "image" || input.kind === "video" ? input.kind : "all";
|
||||
const keyword = String(input.keyword || "").trim().toLowerCase();
|
||||
const filtered = assets.filter((asset) => {
|
||||
@@ -221,7 +221,7 @@ function listAssets(input: SiteToolInput) {
|
||||
async function addAsset(input: SiteToolInput) {
|
||||
const kind = input.kind;
|
||||
const title = String(input.title || "").trim();
|
||||
if (!title) throw new Error("请提供素材标题 title");
|
||||
if (!title) throw new Error("请提供资产标题 title");
|
||||
const tags = Array.isArray(input.tags) ? input.tags.filter((tag): tag is string => typeof tag === "string") : [];
|
||||
const source = typeof input.source === "string" ? input.source : "Agent";
|
||||
const note = typeof input.note === "string" ? input.note : undefined;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { getNodeSpec } from "@/constant/canvas";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type ViewportTransform } from "@/types/canvas";
|
||||
import { getNodeSpec, isRegisteredNodeType } from "@/lib/canvas/node-registry";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ViewportTransform } from "@/types/canvas";
|
||||
|
||||
export type CanvasAgentOp =
|
||||
| { type: "add_node"; id?: string; nodeType?: CanvasNodeType; title?: string; position?: { x: number; y: number }; x?: number; y?: number; width?: number; height?: number; metadata?: CanvasNodeMetadata }
|
||||
| { type: "add_node"; id?: string; nodeType?: CanvasNodeTypeId; title?: string; position?: { x: number; y: number }; x?: number; y?: number; width?: number; height?: number; metadata?: CanvasNodeMetadata }
|
||||
| { type: "update_node"; id: string; patch?: Partial<CanvasNodeData>; metadata?: CanvasNodeMetadata }
|
||||
| { type: "delete_node"; id?: string; ids?: string[]; nodeType?: CanvasNodeType }
|
||||
| { type: "delete_node"; id?: string; ids?: string[]; nodeType?: CanvasNodeTypeId }
|
||||
| { type: "delete_connections"; id?: string; ids?: string[]; all?: boolean }
|
||||
| { type: "connect_nodes"; id?: string; fromNodeId: string; toNodeId: string }
|
||||
| { type: "set_viewport"; viewport: ViewportTransform }
|
||||
@@ -42,7 +42,7 @@ export function applyCanvasAgentOps(snapshot: CanvasAgentSnapshot, ops?: CanvasA
|
||||
(Array.isArray(ops) ? ops : []).forEach((op, index) => {
|
||||
if (!op?.type) return;
|
||||
if (op.type === "add_node") {
|
||||
const nodeType = Object.values(CanvasNodeType).includes(op.nodeType as CanvasNodeType) ? op.nodeType! : CanvasNodeType.Text;
|
||||
const nodeType = op.nodeType && isRegisteredNodeType(op.nodeType) ? op.nodeType : CanvasNodeType.Text;
|
||||
const spec = getNodeSpec(nodeType);
|
||||
const node: CanvasNodeData = {
|
||||
id: op.id || `${nodeType}-${Date.now()}-${index}`,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import localforage from "localforage";
|
||||
|
||||
import type { PluginStorage } from "@/types/canvas-plugin";
|
||||
|
||||
// 画布内轻量事件总线,供节点/插件互相通信
|
||||
type Handler = (payload: unknown) => void;
|
||||
const handlers = new Map<string, Set<Handler>>();
|
||||
|
||||
export function emitCanvasEvent(event: string, payload?: unknown) {
|
||||
handlers.get(event)?.forEach((handler) => {
|
||||
try {
|
||||
handler(payload);
|
||||
} catch (error) {
|
||||
console.error(`[canvas-event] handler for "${event}" failed`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function onCanvasEvent(event: string, handler: Handler) {
|
||||
let set = handlers.get(event);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
handlers.set(event, set);
|
||||
}
|
||||
set.add(handler);
|
||||
return () => set!.delete(handler);
|
||||
}
|
||||
|
||||
// 插件私有存储,按 pluginId 命名空间隔离
|
||||
const stores = new Map<string, LocalForage>();
|
||||
|
||||
export function createPluginStorage(pluginId: string): PluginStorage {
|
||||
let store = stores.get(pluginId);
|
||||
if (!store) {
|
||||
store = localforage.createInstance({ name: "infinite-canvas-plugins", storeName: pluginId });
|
||||
stores.set(pluginId, store);
|
||||
}
|
||||
return {
|
||||
get: (key) => store!.getItem(key),
|
||||
set: async (key, value) => {
|
||||
await store!.setItem(key, value);
|
||||
},
|
||||
remove: async (key) => {
|
||||
await store!.removeItem(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
|
||||
import { seedanceReferenceLabel } from "@/lib/seedance-video";
|
||||
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
export type CanvasResourceKind = "image" | "video" | "audio" | "text";
|
||||
@@ -71,7 +72,7 @@ function labelResourceNodes(nodes: CanvasNodeData[], active: boolean) {
|
||||
label,
|
||||
title: node.title || label,
|
||||
previewUrl: node.metadata?.content,
|
||||
text: node.type === CanvasNodeType.Text ? node.metadata?.content || node.metadata?.prompt : undefined,
|
||||
text: resourceText(node),
|
||||
active,
|
||||
},
|
||||
];
|
||||
@@ -89,10 +90,17 @@ function isResourceNode(node: CanvasNodeData) {
|
||||
return Boolean(resourceKind(node));
|
||||
}
|
||||
|
||||
function resourceText(node: CanvasNodeData): string | undefined {
|
||||
if (node.type === CanvasNodeType.Text) return node.metadata?.content || node.metadata?.prompt;
|
||||
const resource = getNodeDefinition(node.type)?.resource?.(node);
|
||||
return resource?.kind === "text" ? resource.text : undefined;
|
||||
}
|
||||
|
||||
function resourceKind(node: CanvasNodeData): CanvasResourceKind | null {
|
||||
if (node.type === CanvasNodeType.Image && node.metadata?.content) return "image";
|
||||
if (node.type === CanvasNodeType.Video && node.metadata?.content) return "video";
|
||||
if (node.type === CanvasNodeType.Audio && node.metadata?.content) return "audio";
|
||||
if (node.type === CanvasNodeType.Text && (node.metadata?.content || node.metadata?.prompt)) return "text";
|
||||
return null;
|
||||
// 插件节点通过 definition.resource 声明可作为输入
|
||||
return getNodeDefinition(node.type)?.resource?.(node)?.kind || null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
import type { CanvasNodeDefinition } from "@/types/canvas-plugin";
|
||||
import { CanvasNodeType } from "@/types/canvas";
|
||||
|
||||
const definitions = new Map<string, CanvasNodeDefinition>();
|
||||
const ownerByType = new Map<string, string>(); // type -> pluginId(内置为 "builtin")
|
||||
|
||||
// 注册表版本号,注册/卸载时自增,驱动创建菜单等 UI 重渲染
|
||||
export const useNodeRegistryVersion = create<{ version: number }>(() => ({ version: 0 }));
|
||||
function bump() {
|
||||
useNodeRegistryVersion.setState((state) => ({ version: state.version + 1 }));
|
||||
}
|
||||
|
||||
export function registerNodeDefinitions(defs: CanvasNodeDefinition[], pluginId = "builtin") {
|
||||
defs.forEach((def) => {
|
||||
definitions.set(def.type, def);
|
||||
ownerByType.set(def.type, pluginId);
|
||||
});
|
||||
bump();
|
||||
}
|
||||
|
||||
export function unregisterPluginNodes(pluginId: string) {
|
||||
for (const [type, owner] of ownerByType) {
|
||||
if (owner !== pluginId) continue;
|
||||
definitions.delete(type);
|
||||
ownerByType.delete(type);
|
||||
}
|
||||
bump();
|
||||
}
|
||||
|
||||
export function getNodeDefinition(type: string) {
|
||||
return definitions.get(type);
|
||||
}
|
||||
|
||||
export function getNodePluginId(type: string) {
|
||||
return ownerByType.get(type) || "builtin";
|
||||
}
|
||||
|
||||
export function listNodeDefinitions() {
|
||||
return Array.from(definitions.values());
|
||||
}
|
||||
|
||||
export function isRegisteredNodeType(type: string) {
|
||||
return definitions.has(type);
|
||||
}
|
||||
|
||||
const FALLBACK_SPEC = { width: 340, height: 240, title: "节点", metadata: {} as CanvasNodeDefinition["defaultMetadata"] };
|
||||
|
||||
// 提供默认尺寸/标题/初始 metadata,createCanvasNode 与 agent-ops 复用
|
||||
export function getNodeSpec(type: string) {
|
||||
const def = definitions.get(type);
|
||||
if (!def) return FALLBACK_SPEC;
|
||||
return { width: def.defaultSize.width, height: def.defaultSize.height, title: def.title, metadata: def.defaultMetadata };
|
||||
}
|
||||
|
||||
export function isBuiltinNodeType(type: string) {
|
||||
return (Object.values(CanvasNodeType) as string[]).includes(type);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { registerNodeDefinitions, unregisterPluginNodes } from "@/lib/canvas/node-registry";
|
||||
import { getPluginRuntime } from "@/lib/canvas/plugin-runtime";
|
||||
import { usePluginStore, type InstalledPlugin } from "@/stores/canvas/use-plugin-store";
|
||||
import type { CanvasPlugin } from "@/types/canvas-plugin";
|
||||
|
||||
const cleanups = new Map<string, () => void>();
|
||||
|
||||
// 远程插件默认导出可以是 CanvasPlugin,或接收 runtime 返回 CanvasPlugin 的工厂
|
||||
// (工厂形式用 runtime.React,无需 bundle 自带 React)
|
||||
async function evaluatePluginSource(source: string): Promise<CanvasPlugin> {
|
||||
const blob = new Blob([source], { type: "text/javascript" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const mod = (await import(/* @vite-ignore */ url)) as { default?: unknown; plugin?: unknown };
|
||||
const exported = mod.default ?? mod.plugin;
|
||||
const plugin = typeof exported === "function" ? (exported as (runtime: unknown) => unknown)(getPluginRuntime()) : exported;
|
||||
assertPlugin(plugin);
|
||||
return plugin;
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPlugin(plugin: unknown): asserts plugin is CanvasPlugin {
|
||||
const value = plugin as Partial<CanvasPlugin> | null;
|
||||
if (!value || typeof value !== "object") throw new Error("插件未导出有效对象");
|
||||
if (!value.id || !Array.isArray(value.nodes) || !value.nodes.length) throw new Error("插件缺少 id 或 nodes");
|
||||
}
|
||||
|
||||
export function activatePlugin(plugin: CanvasPlugin) {
|
||||
registerNodeDefinitions(plugin.nodes, plugin.id);
|
||||
const runtime = getPluginRuntime();
|
||||
const disposers: Array<() => void> = [];
|
||||
// 插件声明的样式:启用时注入,禁用/卸载时清理
|
||||
if (plugin.css) disposers.push(runtime.injectCSS(plugin.css, plugin.id));
|
||||
const cleanup = plugin.setup?.(runtime);
|
||||
if (typeof cleanup === "function") disposers.push(cleanup);
|
||||
if (disposers.length) cleanups.set(plugin.id, () => disposers.forEach((dispose) => dispose()));
|
||||
}
|
||||
|
||||
export function deactivatePlugin(pluginId: string) {
|
||||
cleanups.get(pluginId)?.();
|
||||
cleanups.delete(pluginId);
|
||||
unregisterPluginNodes(pluginId);
|
||||
}
|
||||
|
||||
async function fetchPluginSource(url: string) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`下载失败 (HTTP ${response.status})`);
|
||||
return response.text();
|
||||
}
|
||||
|
||||
// 加缓存穿透参数,配合 watch 构建拿到最新产物
|
||||
function withCacheBust(url: string) {
|
||||
return `${url}${url.includes("?") ? "&" : "?"}t=${Date.now()}`;
|
||||
}
|
||||
|
||||
// 从 URL 安装(或覆盖更新)一个插件,成功后立即启用
|
||||
export async function installPluginFromUrl(url: string, opts?: { official?: boolean }) {
|
||||
const source = await fetchPluginSource(url);
|
||||
const plugin = await evaluatePluginSource(source);
|
||||
deactivatePlugin(plugin.id); // 覆盖旧版本
|
||||
usePluginStore.getState().upsert({ id: plugin.id, name: plugin.name || plugin.id, version: plugin.version || "0.0.0", description: plugin.description, url, source, enabled: true, official: opts?.official });
|
||||
activatePlugin(plugin);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
export async function updatePlugin(record: InstalledPlugin) {
|
||||
return installPluginFromUrl(record.url, { official: record.official });
|
||||
}
|
||||
|
||||
export async function setPluginEnabled(record: InstalledPlugin, enabled: boolean) {
|
||||
usePluginStore.getState().setEnabled(record.id, enabled);
|
||||
if (!enabled) {
|
||||
deactivatePlugin(record.id);
|
||||
return;
|
||||
}
|
||||
// 本地插件启用时按 url 重新拉取,拿到最新构建(缓存 source 可能已过期)
|
||||
const source = record.local ? await fetchPluginSource(withCacheBust(record.url)) : record.source;
|
||||
const plugin = await evaluatePluginSource(source);
|
||||
activatePlugin(plugin);
|
||||
}
|
||||
|
||||
export function uninstallPlugin(id: string) {
|
||||
deactivatePlugin(id);
|
||||
usePluginStore.getState().remove(id);
|
||||
}
|
||||
|
||||
let loaded = false;
|
||||
|
||||
// 应用启动时加载已安装且启用的插件
|
||||
export async function ensurePluginsLoaded() {
|
||||
if (loaded) return;
|
||||
loaded = true;
|
||||
await usePluginStore.persist.rehydrate();
|
||||
await loadLocalPlugins(); // 先发现本地插件(默认关闭),再统一按 enabled 激活
|
||||
const records = usePluginStore.getState().plugins.filter((record) => record.enabled);
|
||||
await Promise.all(
|
||||
records.map(async (record) => {
|
||||
try {
|
||||
// 本地插件用最新产物,其余用缓存的源码
|
||||
const source = record.local ? await fetchPluginSource(withCacheBust(record.url)) : record.source;
|
||||
activatePlugin(await evaluatePluginSource(source));
|
||||
} catch (error) {
|
||||
console.error(`[plugin] 加载失败: ${record.id}`, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
await loadDevPlugins();
|
||||
}
|
||||
|
||||
// 自动发现 web/public/plugins 下的本地插件:加入列表但默认关闭,
|
||||
// 本地开发放好插件文件即可在管理器里看到并一键启用,无需手动填 URL。
|
||||
// 已在列表中的(用户装过/发现过)不覆盖,尊重其现有开关。
|
||||
async function loadLocalPlugins() {
|
||||
let urls: unknown;
|
||||
try {
|
||||
const response = await fetch("/plugins/index.json");
|
||||
if (!response.ok) return;
|
||||
urls = await response.json();
|
||||
} catch {
|
||||
return; // 无本地清单(如生产环境未构建插件)则跳过
|
||||
}
|
||||
if (!Array.isArray(urls) || !urls.length) return;
|
||||
const store = usePluginStore.getState();
|
||||
await Promise.all(
|
||||
urls.map(async (url: string) => {
|
||||
try {
|
||||
const source = await fetchPluginSource(withCacheBust(url));
|
||||
const plugin = await evaluatePluginSource(source);
|
||||
if (store.plugins.some((item) => item.id === plugin.id)) return;
|
||||
store.upsert({ id: plugin.id, name: plugin.name || plugin.id, version: plugin.version || "0.0.0", description: plugin.description, url, source, enabled: false, local: true });
|
||||
} catch (error) {
|
||||
console.error(`[plugin] 本地插件发现失败: ${url}`, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 本地开发:VITE_DEV_PLUGINS 里的 URL 每次启动都重新拉取(不缓存、不落库),
|
||||
// 配合 watch 构建即可「改代码→刷新页面」看到最新插件,无需反复安装。
|
||||
async function loadDevPlugins() {
|
||||
const raw = import.meta.env.VITE_DEV_PLUGINS;
|
||||
if (!raw) return;
|
||||
const urls = raw.split(",").map((item) => item.trim()).filter(Boolean);
|
||||
await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
try {
|
||||
const source = await fetchPluginSource(withCacheBust(url));
|
||||
const plugin = await evaluatePluginSource(source);
|
||||
deactivatePlugin(plugin.id);
|
||||
activatePlugin(plugin);
|
||||
console.info(`[plugin] dev 插件已加载: ${plugin.id} (${url})`);
|
||||
} catch (error) {
|
||||
console.error(`[plugin] dev 插件加载失败: ${url}`, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createPluginStorage, emitCanvasEvent, onCanvasEvent } from "@/lib/canvas/canvas-event-bus";
|
||||
import { getNodePluginId } from "@/lib/canvas/node-registry";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
import type { CanvasNodeContext, CanvasPluginHost } from "@/types/canvas-plugin";
|
||||
|
||||
// 把宿主能力 + 节点 + 主题/缩放,组装成注入给插件节点的上下文
|
||||
export function buildNodeContext(host: CanvasPluginHost, node: CanvasNodeData, theme: CanvasTheme, scale: number): CanvasNodeContext {
|
||||
const storage = createPluginStorage(getNodePluginId(node.type));
|
||||
return {
|
||||
node,
|
||||
theme,
|
||||
scale,
|
||||
updateMetadata: (patch) => host.updateMetadata(node.id, patch),
|
||||
updateNode: (patch) => host.updateNode(node.id, patch),
|
||||
getNode: (id) => host.getNode(id),
|
||||
getNodes: () => host.getNodes(),
|
||||
getConnections: () => host.getConnections(),
|
||||
getUpstream: () => host.getUpstream(node.id),
|
||||
getDownstream: () => host.getDownstream(node.id),
|
||||
applyOps: (ops) => host.applyOps(ops),
|
||||
emit: (event, payload) => emitCanvasEvent(event, payload),
|
||||
on: (event, handler) => onCanvasEvent(event, handler),
|
||||
storage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { PLUGIN_REGISTRY_URL } from "@/constant/env";
|
||||
|
||||
// 官方插件清单里的一条(entry 已解析成绝对 URL)
|
||||
export type OfficialPluginEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type RawEntry = { id?: string; name?: string; version?: string; description?: string; icon?: string; entry?: string; url?: string };
|
||||
type RawManifest = { plugins?: RawEntry[] };
|
||||
|
||||
// 拉取官方插件清单;entry(相对文件名)按清单地址解析成绝对 URL,再走既有 URL 安装流程
|
||||
export async function fetchOfficialPlugins(registryUrl: string = PLUGIN_REGISTRY_URL): Promise<OfficialPluginEntry[]> {
|
||||
const response = await fetch(registryUrl, { headers: { accept: "application/json" } });
|
||||
if (!response.ok) throw new Error(`获取官方插件列表失败 (HTTP ${response.status})`);
|
||||
const data = (await response.json()) as RawManifest;
|
||||
const list = Array.isArray(data?.plugins) ? data.plugins : [];
|
||||
return list
|
||||
.filter((item): item is RawEntry & { id: string } => Boolean(item && item.id && (item.entry || item.url)))
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name || item.id,
|
||||
version: item.version || "0.0.0",
|
||||
description: item.description,
|
||||
icon: item.icon,
|
||||
url: item.url ? item.url : new URL(item.entry as string, registryUrl).toString(),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
|
||||
import { emitCanvasEvent, onCanvasEvent } from "@/lib/canvas/canvas-event-bus";
|
||||
import type { CanvasPluginApp } from "@/types/canvas-plugin";
|
||||
|
||||
// 插件运行时:远程插件通过它拿到宿主的 React 实例,避免多份 React 实例
|
||||
export type PluginRuntime = CanvasPluginApp & {
|
||||
React: typeof React;
|
||||
jsx: typeof React.createElement;
|
||||
Fragment: typeof React.Fragment;
|
||||
injectCSS: (css: string, key?: string) => () => void;
|
||||
};
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
// 注入插件样式:同 key 覆盖旧样式,返回移除函数
|
||||
function injectCSS(css: string, key?: string) {
|
||||
const id = key ? `canvas-plugin-style-${key}` : undefined;
|
||||
if (id) document.getElementById(id)?.remove();
|
||||
const style = document.createElement("style");
|
||||
if (id) style.id = id;
|
||||
style.dataset.canvasPluginStyle = "true";
|
||||
style.textContent = css;
|
||||
document.head.appendChild(style);
|
||||
return () => style.remove();
|
||||
}
|
||||
|
||||
export function getPluginRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
runtime = {
|
||||
React,
|
||||
jsx: React.createElement,
|
||||
Fragment: React.Fragment,
|
||||
injectCSS,
|
||||
version: typeof __APP_VERSION__ === "string" ? __APP_VERSION__ : "dev",
|
||||
emit: emitCanvasEvent,
|
||||
on: onCanvasEvent,
|
||||
};
|
||||
(window as unknown as { InfiniteCanvasRuntime?: PluginRuntime }).InfiniteCanvasRuntime = runtime;
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
@@ -141,7 +141,7 @@ export function buildSeedancePromptText(prompt: string, images: ReferenceImage[]
|
||||
];
|
||||
const text = prompt.trim();
|
||||
if (!labels.length) return text;
|
||||
return `参考素材编号:${labels.join("、")}。请按这些编号理解提示词中的图片、视频和音频引用。\n\n${text}`;
|
||||
return `参考资产编号:${labels.join("、")}。请按这些编号理解提示词中的图片、视频和音频引用。\n\n${text}`;
|
||||
}
|
||||
|
||||
export function seedanceVideoReferenceError(videos: ReferenceVideo[]) {
|
||||
@@ -166,4 +166,4 @@ export function seedanceVideoReferenceError(videos: ReferenceVideo[]) {
|
||||
return "";
|
||||
}
|
||||
|
||||
export const seedanceVideoReferenceHint = "参考视频需为 mp4/mov,H.264/H.265,FPS 24-60;含真人人脸素材请使用火山授权 asset:// 素材。";
|
||||
export const seedanceVideoReferenceHint = "参考视频需为 mp4/mov,H.264/H.265,FPS 24-60;含真人人脸资产请使用火山授权 asset:// 资产。";
|
||||
|
||||
Reference in New Issue
Block a user