Merge branch 'dev'

# Conflicts:
#	.gitignore
#	CHANGELOG.md
#	web/bun.lock
This commit is contained in:
HouYunFei
2026-07-15 15:32:32 +08:00
112 changed files with 5611 additions and 384 deletions
+8 -8
View File
@@ -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;
+5 -5
View File
@@ -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}`,
+47
View File
@@ -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;
}
+59
View File
@@ -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);
}
+159
View File
@@ -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);
}
}),
);
}
+26
View File
@@ -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,
};
}
+32
View File
@@ -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(),
}));
}
+42
View File
@@ -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;
}
+2 -2
View File
@@ -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/movH.264/H.265FPS 24-60;含真人人脸素材请使用火山授权 asset:// 素材。";
export const seedanceVideoReferenceHint = "参考视频需为 mp4/movH.264/H.265FPS 24-60;含真人人脸资产请使用火山授权 asset:// 资产。";