refactor: update comments for clarity and consistency across multiple files

This commit is contained in:
HouYunFei
2026-08-05 14:17:03 +08:00
parent 0f6809251a
commit 5a7116f924
43 changed files with 218 additions and 223 deletions
+5 -5
View File
@@ -1,12 +1,12 @@
#!/bin/sh
set -e
# 由 nginx 官方镜像的入口在启动前自动执行(/docker-entrypoint.d/*.sh),随后 nginx 正常拉起。
# 从环境变量生成运行期配置 config.js;每家统计一个独立变量,未设置的留空,
# 前端据此判定该家「关闭」,不加载对应脚本、不发外部请求。可同时启用多家。
# Executed automatically by the official nginx image entrypoint through /docker-entrypoint.d/*.sh before nginx starts.
# Generate runtime config.js from environment variables. Each analytics provider has an independent variable;
# unset providers remain disabled, load no scripts, and send no external requests. Multiple providers may be enabled together.
# GA4 / 百度 ID 只含字母、数字和连字符;过滤掉其它字符,
# 避免值里的引号等破坏 config.js 的 JS 字符串(纵深防御)。
# GA4 and Baidu IDs contain only letters, numbers, and hyphens. Remove other characters
# so quotes and similar values cannot break the JavaScript strings in config.js as a defense-in-depth measure.
sanitize_id() {
printf '%s' "$1" | tr -cd 'A-Za-z0-9-'
}
+2 -2
View File
@@ -1,3 +1,3 @@
// 运行期配置。容器启动时由 docker-entrypoint.sh 从环境变量重新生成此文件;
// 本地开发与未经 entrypoint 处理时使用这份默认空配置(统计默认关闭)。
// Runtime configuration regenerated from environment variables by docker-entrypoint.sh at container startup.
// Local development and deployments without the entrypoint use these empty defaults, leaving analytics disabled.
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {};
@@ -125,10 +125,10 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
const { message, modal } = App.useApp();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
// 逐字段 selector + useShallow:只有这些字段变化时才重渲染。
// 注意:canvasContext 不在此订阅内 —— 它在拖拽/resize 时会被 project 每帧写入,
// 但面板只在 ref 同步与防抖 postState 中用到它、渲染层从不读它。若把它放进订阅,
// 面板会随画布每帧重渲染(性能问题,也是 #185 崩溃的放大器)。改为下方 subscribe 命令式监听。
// Field-level selectors with useShallow rerender only when these fields change.
// canvasContext is intentionally excluded because project updates it every frame during dragging and resizing.
// The panel uses it only for ref synchronization and debounced postState calls, never during rendering.
// Subscribing here would rerender the panel every frame and amplify the #185 crash, so it is observed imperatively below.
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, tokenUsage, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, permissionMode, models, model, reasoningEffort, activity, conversation, connectError, pendingTool, pendingApprovals } = useAgentStore(
useShallow((state) => ({
width: state.width,
@@ -315,7 +315,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
if (sequence === loadThreadsSequenceRef.current && !threadOperationRef.current) setAgentState({ loadingThreads: false });
}
}, [applyConversationState, applyWorkspaceChange, endpoint, loadThreadSnapshot, setAgentState, token]);
// canvasContext 命令式订阅:保持 ref 最新,并在快照变化时防抖上报,全程不触发面板重渲染。
// Imperatively subscribe to canvasContext to keep the ref current and debounce snapshot reports without rerendering the panel.
useEffect(() => {
let timer: ReturnType<typeof setTimeout> | null = null;
const unsubscribe = useAgentStore.subscribe((state) => {
@@ -1466,7 +1466,7 @@ function saveAgentClientId(clientId: string) {
try {
sessionStorage.setItem("canvas-agent-client-id", clientId);
} catch {
// 内存身份仍可保证当前页面会话内的请求归属一致。
// The in-memory identity still keeps request ownership consistent within the current page session.
}
}
@@ -81,7 +81,7 @@ export function NodeCreateMenu({ position, onCreate, onClose }: { position: Posi
useNodeRegistryVersion();
const menuRef = useRef<HTMLDivElement>(null);
const definitions = listNodeDefinitions().filter((def) => def.showInCreateMenu !== false);
// 点击菜单外的空白处自动关闭
// Close automatically when clicking outside the menu.
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) onClose();
@@ -27,7 +27,7 @@ type CanvasNodePromptPanelProps = {
onStop: (nodeId: string) => void;
mentionReferences?: CanvasResourceReference[];
onImageSettingsOpenChange?: (open: boolean) => void;
modeOverride?: CanvasNodeGenerationMode; // 插件节点用 useBuiltinPanel.mode 指定生成类型
modeOverride?: CanvasNodeGenerationMode; // Plugin nodes set their generation type through useBuiltinPanel.mode.
};
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onStop, mentionReferences = [], onImageSettingsOpenChange, modeOverride }: CanvasNodePromptPanelProps) {
@@ -42,7 +42,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
const isEditingExistingContent = hasTextContent || hasImageContent;
const [prompt, setPrompt] = useState(node.metadata?.prompt || "");
// 仅在切换到其它节点时恢复对应提示词;同一节点生成完成后继续保留当前输入。
// Restore prompts only when switching nodes; preserve the current input after generation on the same node.
useEffect(() => {
setPrompt(node.metadata?.prompt || "");
// eslint-disable-next-line react-hooks/exhaustive-deps
+4 -4
View File
@@ -133,13 +133,13 @@ export const CanvasNode = React.memo(function CanvasNode({
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content);
const isGroup = data.type === CanvasNodeType.Group;
const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1;
// 支持「交互/移动」开关的节点:移动态(默认)内容不吃指针,拖动整块;交互态内容可操作。
// forceInteractive(如编辑态)强制可交互;空态(无内容)始终可交互,避免上传/生成按钮点不动。
// Nodes with the interaction/move toggle ignore content pointer events in move mode and allow interaction in interactive mode.
// forceInteractive states such as editing stay interactive, as do empty nodes so their upload and generation actions remain usable.
const supportsInteractionToggle = Boolean(definition?.interactionToggle);
const forceInteractive = supportsInteractionToggle ? Boolean(definition?.forceInteractive?.(data)) : false;
const contentInteractive = !supportsInteractionToggle || forceInteractive || !data.metadata?.content ? true : Boolean(data.metadata?.interactive);
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
// 透明背景节点(如 SVG):卡片背景/边框透明,直接融入画布;选中/关联态仍显示描边以便定位
// Transparent nodes such as SVGs blend into the canvas while retaining outlines for selected or related states.
const transparentBg = Boolean(definition?.transparentBackground);
const isActive = isConnectionTarget || isSelected || isFocusRelated;
const imageBorderColor = isActive ? selectionBlue : isRelated && !isBatchChild ? theme.node.muted : "transparent";
@@ -449,7 +449,7 @@ function NodeContent(props: NodeContentRendererProps) {
const Renderer = nodeContentRenderers[props.node.type as CanvasNodeType];
if (Renderer) return <Renderer {...props} />;
// 插件节点:有注册渲染器则渲染,否则展示缺少插件占位
// Render plugin nodes with their registered renderer, or show the missing-plugin placeholder.
const definition = getNodeDefinition(props.node.type);
if (definition?.Content && props.pluginContext) {
const PluginContent = definition.Content;
@@ -38,7 +38,7 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
}
}, []);
// 打开面板时拉取官方清单(仅在尚未加载过时,避免重复请求)
// Fetch the official registry when opening the panel, but only if it has not been loaded yet.
useEffect(() => {
if (open && official.length === 0 && !loadingOfficial && !officialError) void loadOfficial();
}, [open, official.length, loadingOfficial, officialError, loadOfficial]);
@@ -82,8 +82,8 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
}
};
// 已安装插件的操作区:启用开关 +(非本地)更新/卸载
// upgradable=true 时(远程有更高版本),更新按钮高亮为主色以提示升级
// Installed plugin actions: enable toggle plus update/uninstall for non-local plugins.
// Highlight the update action when a newer remote version is available.
const installedControls = (record: InstalledPlugin, upgradable = false) => (
<>
<Switch size="small" checked={record.enabled} loading={busyId === record.id} onChange={(checked) => runOnPlugin(record, () => setPluginEnabled(record, checked), t(checked ? "canvas.plugins.enabled" : "canvas.plugins.disabled"))} />
@@ -105,8 +105,8 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
</>
);
// 图标外挂一个绿点(右上角),用于「有可升级版本」的提示。
// boxShadow 画一圈与卡片同色的描边环,让绿点从图标上「浮起」。
// Add a green dot at the icon's top-right corner when an update is available.
// A card-colored box shadow separates the dot visually from the icon.
const withUpgradeDot = (icon: ReactNode) => (
<span className="relative inline-flex">
{icon}
@@ -126,7 +126,7 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
</div>
);
// 通用插件行:图标 + 标题(名称 + 版本)+ 描述 + 右侧操作
// Shared plugin row: icon, title with name and version, description, and actions.
const row = (key: string, icon: ReactNode, name: string, version: string, subtitle: string | undefined, right: ReactNode) => (
<div key={key} className="flex items-center gap-3 rounded-xl border px-3 py-2.5" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}>
<span className="grid size-9 shrink-0 place-items-center rounded-lg text-base" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
@@ -169,14 +169,14 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
<div className="thin-scrollbar max-h-[46vh] space-y-2 overflow-auto">
{official.map((entry) => {
const record = recordById.get(entry.id);
// 已安装且远程版本更高 → 显示绿点并高亮升级按钮
// Show the update dot and highlight the action when the remote version is newer.
const upgradable = Boolean(record && hasUpgrade(record.version, entry.version));
const icon = entry.icon || <Puzzle className="size-4" />;
return row(
entry.id,
upgradable ? withUpgradeDot(icon) : icon,
entry.name,
// 有升级时标题版本展示为「本地 → 远程」,让用户看清升级到哪个版本
// Show local and remote versions in the title so the update target is explicit.
upgradable && record ? `${record.version}${entry.version}` : entry.version,
entry.description,
record ? (
@@ -29,14 +29,14 @@ type Token =
| { type: "text"; value: string }
| { type: "reference"; label: string };
// 提示词面板专用的 contentEditable 输入框:@ 引用图片时直接内嵌真实缩略图 chip,而不是「图片1」文字。
// 序列化时 chip → 引用 label 文本(如「图片1」),保证发给生成的 value 语义与旧 textarea 版一致。
// Prompt-panel contentEditable input: @ references embed thumbnail chips instead of plain label text.
// Serialization converts chips back to reference labels so the generated value matches the former textarea semantics.
export function CanvasPromptChipInput({ value, references, onChange, onSubmit, className, style, placeholder }: Props) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const editorRef = useRef<HTMLDivElement>(null);
const composingRef = useRef(false);
// 记录我们最近一次向父级 emit 的 value。聚焦时若 value 与它一致,说明是本组件输入的回声,
// 跳过重建以免打断光标 / IME;若不一致(如发送后父级把 prompt 清空、或从提示词库插入),即使聚焦也要重建。
// Track the last value emitted to the parent. An identical focused value is this component's own echo,
// so skip rebuilding to preserve the caret and IME. Rebuild external changes even while focused.
const lastEmittedRef = useRef(value);
const [mention, setMention] = useState<MentionState | null>(null);
const [activeIndex, setActiveIndex] = useState(0);
@@ -44,7 +44,7 @@ export function CanvasPromptChipInput({ value, references, onChange, onSubmit, c
const activeReferences = useMemo(() => references.filter((item) => item.active), [references]);
const referenceByLabel = useMemo(() => new Map(activeReferences.map((item) => [item.label, item])), [activeReferences]);
// 长 label 优先匹配,避免「图片1」把「图片10」切坏。
// Match longer labels first so a shorter label cannot split a longer one.
const activeLabels = useMemo(() => Array.from(new Set(activeReferences.map((item) => item.label))).sort((a, b) => b.length - a.length), [activeReferences]);
const tokens = useMemo(() => parseTokens(value, activeLabels), [value, activeLabels]);
@@ -55,7 +55,7 @@ export function CanvasPromptChipInput({ value, references, onChange, onSubmit, c
return activeReferences.filter((item) => `${item.label} ${item.title} ${item.kind} ${item.text || ""}`.toLowerCase().includes(query));
}, [mention, activeReferences]);
// DOM ← value:未聚焦时按 value 重建;聚焦时仅当 value 是外部改动(非本组件回声)才重建。
// Rebuild the DOM from value when unfocused, or when a focused value is an external change rather than an emitted echo.
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
@@ -328,7 +328,7 @@ function removeActiveMention() {
range.deleteContents();
}
// chip contentEditable="false" 的原子块,光标紧邻它按 Backspace/Delete 时整块删除。
// Chips are atomic contentEditable="false" blocks and are removed as a unit with adjacent Backspace/Delete presses.
function deleteAdjacentReference(key: string) {
const selection = window.getSelection();
if (!selection?.rangeCount || !selection.isCollapsed) return false;
@@ -380,7 +380,7 @@ function caretRect(): DOMRect | null {
range.collapse(true);
const rect = range.getBoundingClientRect();
if (rect.width || rect.height || rect.left || rect.top) return rect;
// 空行/空编辑器时 range 无尺寸,退回到编辑器盒子。
// Empty lines and editors produce a zero-sized range, so fall back to the editor bounds.
const editor = closestEditor(range.startContainer);
return editor ? editor.getBoundingClientRect() : null;
}
@@ -399,7 +399,7 @@ function placeCaretAtEnd(element: HTMLElement) {
selection?.addRange(range);
}
// 按 active label(已按长度降序)把 value 文本切成「文本片段 + 命中的引用 label」。
// Split value into text fragments and matching active labels, which are already sorted by descending length.
function parseTokens(value: string, labels: string[]): Token[] {
if (!labels.length) return value ? [{ type: "text", value }] : [];
const escaped = labels.map(escapeRegExp).join("|");
@@ -90,7 +90,7 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
color: showOverlay ? "transparent" : style?.color,
caretColor: style?.color || theme.node.text,
cursor: "text",
// showOverlay 时高亮 div 覆盖在 textarea 上,若不把 textarea 提到上层,原生插入光标(caret)会被盖住看不见
// The highlight layer covers the textarea when showOverlay is active, so keep the textarea above it to preserve the native caret.
...(showOverlay ? { position: "relative", zIndex: 1, background: "transparent", backgroundColor: "transparent" } : {}),
} as CSSProperties;
const menu = mention && candidates.length && textareaRef.current ? <MentionMenu textarea={textareaRef.current} caretIndex={mention.start} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null;
@@ -220,8 +220,8 @@ function MentionMenu({ textarea, caretIndex, references, activeIndex, theme, onS
const menuWidth = 256;
const maxMenuHeight = 224;
const gap = 6;
// 菜单锚定到 @ 所在的光标像素位置(而非 textarea 底边),避免输入框较高时菜单离 @ 太远。
// 画布可能被缩放,rect 是缩放后坐标,而镜像测量得到的是布局坐标,需按 scale 换算。
// Anchor the menu to the pixel position of the @ caret instead of the textarea edge.
// The canvas may be scaled: rect uses scaled coordinates while mirror measurements use layout coordinates, so apply the scale.
const scale = textarea.offsetWidth ? rect.width / textarea.offsetWidth : 1;
const computed = window.getComputedStyle(textarea);
const lineHeight = (parseFloat(computed.lineHeight) || parseFloat(computed.fontSize) * 1.4 || 20) * scale;
@@ -295,7 +295,7 @@ function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
// 通过镜像 div 复刻 textarea 的排版,测出第 index 个字符处光标相对 textarea 的像素坐标(布局尺度,未含缩放)。
// Mirror the textarea layout in a div to measure the caret at index in unscaled layout coordinates.
const MIRROR_STYLE_PROPS = ["boxSizing", "width", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth", "fontStyle", "fontVariant", "fontWeight", "fontStretch", "fontSize", "lineHeight", "fontFamily", "textAlign", "textIndent", "letterSpacing", "wordSpacing", "tabSize", "textTransform"] as const;
function getCaretPoint(textarea: HTMLTextAreaElement, index: number) {
@@ -323,8 +323,8 @@ function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// textarea 无法做真正的原子 token,这里在删除时把整个引用 label 当作一个整体一次删掉,
// 避免逐字删除把「图片1」删成「图片」。labels 需按长度降序传入以优先匹配更长的 label。
// A plain textarea cannot represent atomic tokens, so delete a complete reference label at once.
// Labels must be ordered by descending length so longer labels match first.
function deleteAdjacentLabel(value: string, caret: number, direction: "backward" | "forward", labels: string[]): { value: string; caret: number } | null {
if (direction === "backward") {
const before = value.slice(0, caret);
@@ -332,12 +332,12 @@ function deleteAdjacentLabel(value: string, caret: number, direction: "backward"
if (!label) continue;
const match = new RegExp(`(^|\\s)(${escapeRegExp(label)})\\s*$`).exec(before);
if (match) {
const start = match.index + match[1].length; // label 起始位置(保留前面的分隔空格)
const start = match.index + match[1].length; // Label start; preserve the preceding separator space.
return { value: value.slice(0, start) + value.slice(caret), caret: start };
}
}
} else {
// 向后删除:光标前须是行首或空白,避免切进其它文字中间
// Forward deletion requires the caret to follow a line boundary or whitespace to avoid cutting into other text.
if (caret > 0 && !/\s/.test(value[caret - 1])) return null;
const after = value.slice(caret);
for (const label of labels) {
@@ -129,7 +129,7 @@ function TabButton({ label, active, theme, onClick }: { label: string; active: b
}
// ---------------------------------------------------------------------------
// 画布 Tab —— 列出节点,点击居中放大并选中
// Canvas tab: list nodes and center, zoom, and select the clicked node.
// ---------------------------------------------------------------------------
const NODE_FILTER_VALUES = ["all", CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Text, CanvasNodeType.Audio, CanvasNodeType.Config, CanvasNodeType.Group];
@@ -270,7 +270,7 @@ function CheckMark({ checked, theme }: { checked: boolean; theme: CanvasTheme })
}
// ---------------------------------------------------------------------------
// 资产 Tab —— 按类型折叠分组 + 标签筛选,点击插入画布
// Assets tab: collapsible type groups, tag filtering, and click-to-insert.
// ---------------------------------------------------------------------------
const ASSET_GROUPS: { kind: AssetKind; icon: typeof Square }[] = [
@@ -438,7 +438,7 @@ function AssetCover({ asset }: { asset: Asset }) {
}
// ---------------------------------------------------------------------------
// 提示词库 Tab —— 按来源折叠分组,展开时按需加载,点击复制 / 插入文本节点
// Prompt library tab: collapsible source groups, lazy loading, and copy or text-node insertion actions.
// ---------------------------------------------------------------------------
const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) {
@@ -506,7 +506,7 @@ function PromptSourceGroup({
onView: (prompt: Prompt) => void;
}) {
const { t } = useTranslation();
// 展开过一次即缓存,避免收起后重复请求;搜索命中时也需要拿到数据来计数。
// Cache a source after its first expansion to avoid repeated requests; search results also need the data for counts.
const showResults = open || !!keyword.trim();
const query = useQuery({ queryKey: ["side-panel-prompts", sourceId], queryFn: () => fetchSourcePrompts(sourceId), enabled: showResults, staleTime: 1000 * 60 * 60 });
+2 -2
View File
@@ -64,7 +64,7 @@ export function CanvasToolbar({
const [panelX, setPanelX] = useState(0);
const [extensionsOpen, setExtensionsOpen] = useState(false);
const [extPanelX, setExtPanelX] = useState(0);
// 扩展(插件)节点,随注册表变化实时更新
// Keep extension plugin nodes synchronized with registry changes.
useNodeRegistryVersion();
const extensionDefs = listNodeDefinitions().filter((def) => def.showInCreateMenu !== false && getNodePluginId(def.type) !== "builtin");
const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" };
@@ -72,7 +72,7 @@ export function CanvasToolbar({
const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
const tip = hovered ? toolLabel(hovered, t) : "";
// 点击工具栏(含弹出面板)以外的地方,关闭弹出的扩展节点/画布外观面板
// Close extension-node and canvas-appearance popovers when clicking outside the toolbar and its panels.
useEffect(() => {
if (!extensionsOpen && !appearanceOpen) return;
const handlePointerDown = (event: PointerEvent) => {
@@ -167,7 +167,7 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
const container = containerRef.current;
if (!container) return;
// 阻止画布滚动导致页面滚动;但浮层(创建菜单/弹窗等)内允许原生滚动
// Prevent canvas scrolling from moving the page while preserving native scrolling inside overlays and dialogs.
const preventWheelScroll = (event: WheelEvent) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("[data-canvas-no-zoom],.ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown")) return;
@@ -7,8 +7,8 @@ import { registerNodeDefinitions } from "@/lib/canvas/node-registry";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import type { CanvasNodeDefinition, CanvasNodeResource } from "@/types/canvas-plugin";
// 内置节点的可扩展元数据(尺寸/初始 metadata 复用 NODE_SPECS)。
// 渲染仍由 canvas-node 内部渲染器负责,故不提供 Content。
// Extensible metadata for built-in nodes, reusing NODE_SPECS for size and initial metadata.
// Rendering remains in canvas-node's internal renderer, so no Content component is provided.
function builtinResource(node: CanvasNodeData): CanvasNodeResource | null {
if (node.type === CanvasNodeType.Image && node.metadata?.content) return { kind: "image", url: node.metadata.content };
if (node.type === CanvasNodeType.Video && node.metadata?.content) return { kind: "video", url: node.metadata.content };
@@ -3,7 +3,7 @@ import { useLocation } from "react-router-dom";
import { trackPageview } from "@/lib/analytics";
// 监听 SPA 路由变化并上报 pageview。无统计配置时 trackPageview 为空操作。
// Observe SPA route changes and report page views; trackPageview is a no-op when analytics is not configured.
export function AnalyticsTracker() {
const location = useLocation();
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { fetchChannelModels } from "@/services/api/image";
import type { ModelChannel } from "@/stores/use-config-store";
// 选择渠道模型弹窗:拉取上游模型列表或手动增加,勾选后才会进入渠道模型列表。
// Channel model selector: fetch upstream models or add them manually, then include checked models in the channel list.
export function ModelSelectModal({ open, channel, selectedNames, onConfirm, onClose }: { open: boolean; channel: ModelChannel | null; selectedNames: string[]; onConfirm: (names: string[]) => void; onClose: () => void }) {
const { message } = App.useApp();
const { t } = useTranslation();
+1 -1
View File
@@ -46,7 +46,7 @@ export const NODE_SPECS = {
},
} satisfies Record<CanvasNodeType, CanvasNodeSpec>;
// 内置类型返回内置 spec;插件类型从注册表解析
// Return built-in specs directly and resolve plugin types from the registry.
export function getNodeSpec(type: string) {
if ((Object.values(CanvasNodeType) as string[]).includes(type)) return NODE_SPECS[type as CanvasNodeType];
const spec = getRegistryNodeSpec(type);
+1 -1
View File
@@ -2,5 +2,5 @@ export const APP_VERSION = __APP_VERSION__ || "dev";
export const DOCS_URL = import.meta.env.VITE_DOC_URL || "https://docs.canvas.best";
// 官方插件清单地址:CI 发布到 plugins-dist 分支,经 jsDelivr 远程拉取;可用环境变量覆盖成自建来源
// Official plugin registry URL: CI publishes to plugins-dist for jsDelivr delivery; an environment variable may override it for self-hosting.
export const PLUGIN_REGISTRY_URL = import.meta.env.VITE_PLUGIN_REGISTRY_URL || "https://cdn.jsdelivr.net/gh/basketikun/infinite-canvas@plugins-dist/official-plugins.json";
+7 -8
View File
@@ -1,13 +1,13 @@
// 运行期配置读取层。
// 优先级:window.__RUNTIME_CONFIG__(容器启动时由 entrypoint 注入)> 构建期 VITE_ 变量 > 默认值。
// 这样既支持「同一镜像 docker run -e 配置」,也兼容自行 build 时的构建期注入。
// Runtime configuration access layer.
// Priority: window.__RUNTIME_CONFIG__ (injected by the container entrypoint) > build-time VITE_ variables > defaults.
// This supports both configuring the same image with docker run -e and injecting values during custom builds.
//
// 统计按「每家一个独立变量」配置:填了谁就启用谁,可同时启用多家,默认全空即关闭。
// 仅支持 GA4 与百度:两者都只接受 ID,脚本地址由代码固定拼接,不接受任意脚本/内联 JS。
// Each analytics provider has its own variable; configured providers are enabled independently and all are disabled by default.
// Only GA4 and Baidu are supported. Both accept IDs only, and script URLs are assembled in code without arbitrary scripts or inline JavaScript.
type RuntimeConfig = {
ANALYTICS_GA4_ID?: string; // GA4 衡量 IDG-XXXX
ANALYTICS_BAIDU_ID?: string; // 百度统计站点 ID
ANALYTICS_GA4_ID?: string; // GA4 measurement ID (G-XXXX)
ANALYTICS_BAIDU_ID?: string; // Baidu Analytics site ID
};
declare global {
@@ -27,4 +27,3 @@ function read(key: keyof RuntimeConfig, buildTime: string | undefined, fallback
export const ANALYTICS_GA4_ID = read("ANALYTICS_GA4_ID", import.meta.env.VITE_ANALYTICS_GA4_ID);
export const ANALYTICS_BAIDU_ID = read("ANALYTICS_BAIDU_ID", import.meta.env.VITE_ANALYTICS_BAIDU_ID);
+1 -1
View File
@@ -28,7 +28,7 @@ export function usePromptSourceScheduler() {
queryClient.invalidateQueries({ queryKey: ["prompt-source-statuses"] }),
]);
} catch {
// 单个来源的错误已写入来源状态,下一个检查周期会继续尝试。
// Per-source errors are stored in source state and retried during the next check cycle.
} finally {
running = false;
}
+2 -2
View File
@@ -11,8 +11,8 @@ import { useAssetStore } from "@/stores/use-asset-store";
import { modelOptionLabel, modelOptionName, normalizeModelOptionValue, selectableModelsByCapability, useConfigStore } from "@/stores/use-config-store";
import { useWorkbenchAgentStore } from "@/stores/use-workbench-agent-store";
// 在网页端执行 Agent 的「站点级」工具(画布列表、工作台生成、提示词搜索、资产增删查等)。
// 这些工具的数据都在浏览器本地(localforage / zustand),因此由本模块直接读写对应 store 后返回结果。
// Execute site-level Agent tools in the browser, including canvas lists, workbench generation, prompt search, and asset operations.
// Their data lives locally in the browser through localforage and Zustand, so this module accesses the relevant stores directly.
export const SITE_TOOL_NAMES = [
"canvas_list_projects",
+12 -13
View File
@@ -1,8 +1,8 @@
// 统计分析加载器:默认关闭,可同时启用多家。
// 仅支持 GA4 与百度:两者都只接受 ID,脚本地址由代码固定拼接——
// 不接受任意脚本 URL / 内联 JS,避免「配置项被用来在访客浏览器执行任意代码」。
// 全空时不注入任何脚本、不发任何外部请求。这是开源项目的硬要求:
// fork/自托管者默认零统计,官方站点仅通过环境变量注入自己的 ID(ID 不入库)。
// Analytics loader: disabled by default, with support for enabling multiple providers.
// Only GA4 and Baidu are supported. Both accept IDs only, and their script URLs are assembled here.
// Arbitrary script URLs and inline JavaScript are intentionally rejected to prevent configuration from executing code in visitors' browsers.
// When no IDs are configured, no scripts are injected and no external requests are sent.
// Forks and self-hosted deployments therefore have no analytics by default; the official site provides its IDs through environment variables.
import { ANALYTICS_BAIDU_ID, ANALYTICS_GA4_ID } from "@/constant/runtime-config";
@@ -17,7 +17,7 @@ declare global {
}
let initialized = false;
// 记录实际启用了哪些统计,供路由上报时按需分发。
// Track enabled providers so route events are dispatched only where needed.
const active = { ga4: false, baidu: false };
function appendScript(src: string, attrs: Record<string, string> = {}) {
@@ -37,7 +37,7 @@ function initGa4(id: string) {
window.gtag = gtag;
appendScript(`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`);
gtag("js", new Date());
// SPA 路由上报交给 trackPageview,这里关闭默认的自动 page_view,避免重复。
// Disable GA4's automatic page view because SPA route reporting is handled by trackPageview.
gtag("config", id, { send_page_view: false });
active.ga4 = true;
}
@@ -52,24 +52,24 @@ export function initAnalytics() {
if (initialized || typeof window === "undefined") return;
initialized = true;
// 各家相互独立,逐个判断并启用;任一家出错都不影响其它家与主应用。
// Initialize providers independently so one failure does not affect the others or the application.
if (ANALYTICS_GA4_ID) {
try {
initGa4(ANALYTICS_GA4_ID);
} catch {
/* 忽略 */
/* Ignore analytics initialization errors. */
}
}
if (ANALYTICS_BAIDU_ID) {
try {
initBaidu(ANALYTICS_BAIDU_ID);
} catch {
/* 忽略 */
/* Ignore analytics initialization errors. */
}
}
}
// SPA 路由切换时上报页面浏览,分发给所有已启用的统计。
// Report SPA route changes to every enabled analytics provider.
export function trackPageview(path: string) {
try {
if (active.ga4 && window.gtag) {
@@ -79,7 +79,6 @@ export function trackPageview(path: string) {
window._hmt.push(["_trackPageview", path]);
}
} catch {
/* 忽略 */
/* Ignore analytics reporting errors. */
}
}
+2 -2
View File
@@ -2,7 +2,7 @@ import localforage from "localforage";
import type { PluginStorage } from "@/types/canvas-plugin";
// 画布内轻量事件总线,供节点/插件互相通信
// Lightweight canvas event bus for communication between nodes and plugins.
type Handler = (payload: unknown) => void;
const handlers = new Map<string, Set<Handler>>();
@@ -26,7 +26,7 @@ export function onCanvasEvent(event: string, handler: Handler) {
return () => set!.delete(handler);
}
// 插件私有存储,按 pluginId 命名空间隔离
// Private plugin storage isolated by pluginId namespace.
const stores = new Map<string, LocalForage>();
export function createPluginStorage(pluginId: string): PluginStorage {
@@ -95,6 +95,6 @@ function resourceKind(node: CanvasNodeData): CanvasResourceKind | null {
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";
// 插件节点通过 definition.resource 声明可作为输入
// Plugin nodes declare their input eligibility through definition.resource.
return getNodeDefinition(node.type)?.resource?.(node)?.kind || null;
}
+3 -3
View File
@@ -6,9 +6,9 @@ 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")
const ownerByType = new Map<string, string>(); // type -> pluginId; built-in nodes use "builtin".
// 注册表版本号,注册/卸载时自增,驱动创建菜单等 UI 重渲染
// Increment the registry version on registration or removal to update dependent UI such as creation menus.
export const useNodeRegistryVersion = create<{ version: number }>(() => ({ version: 0 }));
function bump() {
useNodeRegistryVersion.setState((state) => ({ version: state.version + 1 }));
@@ -49,7 +49,7 @@ export function isRegisteredNodeType(type: string) {
const FALLBACK_SPEC = { width: 340, height: 240, title: i18n.t("canvas.node.node"), metadata: {} as CanvasNodeDefinition["defaultMetadata"] };
// 提供默认尺寸/标题/初始 metadata,createCanvasNode agent-ops 复用
// Provide default size, title, and metadata shared by createCanvasNode and agent operations.
export function getNodeSpec(type: string) {
const def = definitions.get(type);
if (!def) return FALLBACK_SPEC;
+22 -25
View File
@@ -6,8 +6,8 @@ import i18n from "@/i18n";
const cleanups = new Map<string, () => void>();
// 远程插件默认导出可以是 CanvasPlugin,或接收 runtime 返回 CanvasPlugin 的工厂
// (工厂形式用 runtime.React,无需 bundle 自带 React)
// A remote plugin may export CanvasPlugin directly or a factory that receives runtime and returns CanvasPlugin.
// The factory uses runtime.React so the bundle does not need its own React copy.
async function evaluatePluginSource(source: string): Promise<CanvasPlugin> {
const blob = new Blob([source], { type: "text/javascript" });
const url = URL.createObjectURL(blob);
@@ -32,7 +32,7 @@ export function activatePlugin(plugin: CanvasPlugin) {
registerNodeDefinitions(plugin.nodes, plugin.id);
const runtime = getPluginRuntime();
const disposers: Array<() => void> = [];
// 插件声明的样式:启用时注入,禁用/卸载时清理
// Inject declared styles when enabled and remove them when disabled or uninstalled.
if (plugin.css) disposers.push(runtime.injectCSS(plugin.css, plugin.id));
const cleanup = plugin.setup?.(runtime);
if (typeof cleanup === "function") disposers.push(cleanup);
@@ -51,25 +51,24 @@ async function fetchPluginSource(url: string) {
return response.text();
}
// 加缓存穿透参数,配合 watch 构建拿到最新产物
// Add a cache-busting parameter so watch builds load the latest output.
function withCacheBust(url: string) {
return `${url}${url.includes("?") ? "&" : "?"}t=${Date.now()}`;
}
// 从 URL 安装(或覆盖更新)一个插件,成功后立即启用。
// bustCache=true 时下载绕过 HTTP/CDN 缓存(升级场景必需,避免拿到旧产物),
// 但落库的 url 始终保持干净(不带 ?t=),便于后续再次更新。
// Install or replace a plugin from a URL and enable it immediately.
// bustCache bypasses HTTP/CDN caches during upgrades while persisting a clean URL without the timestamp query.
export async function installPluginFromUrl(url: string, opts?: { official?: boolean; bustCache?: boolean }) {
const source = await fetchPluginSource(opts?.bustCache ? withCacheBust(url) : url);
const plugin = await evaluatePluginSource(source);
deactivatePlugin(plugin.id); // 覆盖旧版本
deactivatePlugin(plugin.id); // Replace the previous version.
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) {
// 升级必须拿到最新产物,强制绕过缓存
// Upgrades must fetch the latest output and therefore always bypass caches.
return installPluginFromUrl(record.url, { official: record.official, bustCache: true });
}
@@ -79,7 +78,7 @@ export async function setPluginEnabled(record: InstalledPlugin, enabled: boolean
deactivatePlugin(record.id);
return;
}
// 本地插件启用时按 url 重新拉取,拿到最新构建(缓存 source 可能已过期)
// Reload local plugins from their URL when enabled because the cached source may be stale.
const source = record.local ? await fetchPluginSource(withCacheBust(record.url)) : record.source;
const plugin = await evaluatePluginSource(source);
activatePlugin(plugin);
@@ -92,31 +91,29 @@ export function uninstallPlugin(id: string) {
let loaded = false;
// 应用启动时加载已安装且启用的插件
// Load installed and enabled plugins at application startup.
export async function ensurePluginsLoaded() {
if (loaded) return;
loaded = true;
await usePluginStore.persist.rehydrate();
await loadLocalPlugins(); // 先发现本地插件(默认关闭),再统一按 enabled 激活
await loadLocalPlugins(); // Discover disabled local plugins first, then activate all enabled records.
const records = usePluginStore.getState().plugins.filter((record) => record.enabled);
await Promise.all(
records.map(async (record) => {
try {
// 本地插件用最新产物,其余用缓存的源码
// Local plugins use the latest output; other plugins use their cached source.
const source = record.local ? await fetchPluginSource(withCacheBust(record.url)) : record.source;
activatePlugin(await evaluatePluginSource(source));
} catch (error) {
console.error(`[plugin] 加载失败: ${record.id}`, error);
console.error(`[plugin] Failed to load: ${record.id}`, error);
}
}),
);
await loadDevPlugins();
}
// 自动发现 web/public/plugins 下的本地插件:加入列表但默认关闭,
// 本地开发放好插件文件即可在管理器里看到并一键启用,无需手动填 URL。
// 已在列表中的:刷新元数据(version/name/description/source)到最新产物,
// 但保留用户的 enabled 开关 —— 否则改了插件版本后,持久化 store 里的旧 version 永不更新。
// Discover local plugins from web/public/plugins, add them disabled, and expose them in the manager without a URL.
// Refresh metadata and source for existing records while preserving the enabled flag so persisted versions stay current.
async function loadLocalPlugins() {
let urls: unknown;
try {
@@ -124,7 +121,7 @@ async function loadLocalPlugins() {
if (!response.ok) return;
urls = await response.json();
} catch {
return; // 无本地清单(如生产环境未构建插件)则跳过
return; // Skip when no local manifest exists, such as production builds without plugins.
}
if (!Array.isArray(urls) || !urls.length) return;
const store = usePluginStore.getState();
@@ -141,18 +138,18 @@ async function loadLocalPlugins() {
description: plugin.description,
url,
source,
enabled: existing?.enabled ?? false, // 保留用户开关,新发现默认关闭
enabled: existing?.enabled ?? false, // Preserve the user setting; new discoveries default to disabled.
local: true,
});
} catch (error) {
console.error(`[plugin] 本地插件发现失败: ${url}`, error);
console.error(`[plugin] Failed to discover local plugin: ${url}`, error);
}
}),
);
}
// 本地开发:VITE_DEV_PLUGINS 里的 URL 每次启动都重新拉取(不缓存、不落库),
// 配合 watch 构建即可「改代码→刷新页面」看到最新插件,无需反复安装。
// During local development, refetch VITE_DEV_PLUGINS URLs without caching or persistence on every startup.
// Together with watch builds, refreshing the page loads code changes without reinstalling the plugin.
async function loadDevPlugins() {
const raw = import.meta.env.VITE_DEV_PLUGINS;
if (!raw) return;
@@ -164,9 +161,9 @@ async function loadDevPlugins() {
const plugin = await evaluatePluginSource(source);
deactivatePlugin(plugin.id);
activatePlugin(plugin);
console.info(`[plugin] dev 插件已加载: ${plugin.id} (${url})`);
console.info(`[plugin] Dev plugin loaded: ${plugin.id} (${url})`);
} catch (error) {
console.error(`[plugin] dev 插件加载失败: ${url}`, error);
console.error(`[plugin] Failed to load dev plugin: ${url}`, error);
}
}),
);
+1 -1
View File
@@ -4,7 +4,7 @@ import type { CanvasTheme } from "@/lib/canvas-theme";
import type { CanvasNodeData } from "@/types/canvas";
import type { CanvasNodeContext, CanvasPluginHost } from "@/types/canvas-plugin";
// 把宿主能力 + 节点 + 主题/缩放,组装成注入给插件节点的上下文
// Assemble host capabilities, node data, theme, and scale into the context injected into plugin nodes.
export function buildNodeContext(host: CanvasPluginHost, node: CanvasNodeData, theme: CanvasTheme, scale: number, isSelected = false): CanvasNodeContext {
const storage = createPluginStorage(getNodePluginId(node.type));
return {
+5 -5
View File
@@ -1,6 +1,6 @@
import { PLUGIN_REGISTRY_URL } from "@/constant/env";
// 官方插件清单里的一条(entry 已解析成绝对 URL)
// An official registry item whose entry has been resolved to an absolute URL.
export type OfficialPluginEntry = {
id: string;
name: string;
@@ -13,7 +13,7 @@ export type OfficialPluginEntry = {
type RawEntry = { id?: string; name?: string; version?: string; description?: string; icon?: string; entry?: string; url?: string };
type RawManifest = { plugins?: RawEntry[] };
// 拉取官方插件清单;entry(相对文件名)按清单地址解析成绝对 URL,再走既有 URL 安装流程
// Fetch the official registry and resolve relative entries against its URL for the existing URL installation flow.
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(i18n.t("canvas.pluginErrors.registryFailed", { status: response.status }));
@@ -31,8 +31,8 @@ export async function fetchOfficialPlugins(registryUrl: string = PLUGIN_REGISTRY
}));
}
// 语义化版本比较:返回 >0 表示 a 更高,<0 表示 b 更高,0 表示相等。
// 只按 major.minor.patch 数值比较,忽略非数字段(预发布标签等)。
// Compare semantic versions: positive means a is newer, negative means b is newer, and zero means equal.
// Compare numeric major.minor.patch components only and ignore non-numeric parts such as prerelease labels.
function compareSemver(a: string, b: string): number {
const parse = (v: string) => v.split(".").map((part) => parseInt(part, 10) || 0);
const [pa, pb] = [parse(a), parse(b)];
@@ -43,7 +43,7 @@ function compareSemver(a: string, b: string): number {
return 0;
}
// 远程版本是否比本地已安装版本更高(即有可升级的更新)
// Return whether the remote version is newer than the installed version.
export function hasUpgrade(installedVersion: string, remoteVersion: string): boolean {
return compareSemver(remoteVersion, installedVersion) > 0;
}
+2 -2
View File
@@ -3,7 +3,7 @@ import React from "react";
import { emitCanvasEvent, onCanvasEvent } from "@/lib/canvas/canvas-event-bus";
import type { CanvasPluginApp } from "@/types/canvas-plugin";
// 插件运行时:远程插件通过它拿到宿主的 React 实例,避免多份 React 实例
// Remote plugins obtain the host React instance through this runtime to avoid multiple React copies.
export type PluginRuntime = CanvasPluginApp & {
React: typeof React;
jsx: typeof React.createElement;
@@ -13,7 +13,7 @@ export type PluginRuntime = CanvasPluginApp & {
let runtime: PluginRuntime | null = null;
// 注入插件样式:同 key 覆盖旧样式,返回移除函数
// Inject plugin styles, replacing the previous style with the same key, and return a removal function.
function injectCSS(css: string, key?: string) {
const id = key ? `canvas-plugin-style-${key}` : undefined;
if (id) document.getElementById(id)?.remove();
@@ -29,8 +29,8 @@ type AgentBridgeParams = {
};
/**
* 画布与本地 Agent 的桥接:把当前画布快照与 apply/undo 能力发布到 agent store
* 供本地 Codex 面板读取。除 applyAgentOps(配置节点插件宿主会用到)外均为内部实现。
* Bridge between the canvas and local Agent: publish the current snapshot and apply/undo capabilities
* to the Agent store for the local Codex panel. All members except applyAgentOps are internal.
*/
export function useAgentBridge(params: AgentBridgeParams) {
const { projectId, title, nodes, connections, selectedNodeIds, viewport, nodesRef, connectionsRef, selectedNodeIdsRef, viewportRef, generateNodeRef, setNodes, setConnections, setSelectedNodeIds, setSelectedConnectionId, setViewport, setContextMenu } =
@@ -30,18 +30,18 @@ type PluginHostParams = {
};
/**
* 插件节点宿主能力:把宿主侧的 AI 生成、画布读写、面板开关等封装成插件可调用的 host/ai 对象,
* 并在挂载时加载已安装的远程插件。返回给画布用于渲染插件面板与工具条。
* Plugin node host capabilities: expose host-side AI generation, canvas access, and panel controls
* through plugin-callable host/ai objects. Loads installed remote plugins on mount and returns renderers for plugin panels and toolbars.
*/
export function usePluginHost(params: PluginHostParams) {
const { t } = useTranslation();
const { effectiveConfig, isAiConfigReady, openConfigDialog, theme, nodesRef, connectionsRef, viewportRef, setNodes, setDialogNodeId, applyAgentOps } = params;
// 提供给插件节点的宿主能力(节点无关,方法接收 nodeId)
// Host capabilities available to plugin nodes; methods receive nodeId and are not bound to a specific node.
const pluginAi = useMemo<CanvasPluginAi>(() => {
// 把插件传入的参考图(dataURL 或 URL)整理成宿主生成 API 需要的 ReferenceImage[]
// Convert plugin reference images (data URLs or URLs) into the ReferenceImage[] expected by the host generation API.
const toReferences = (refs?: string[]): ReferenceImage[] => (refs || []).filter(Boolean).map((src, index) => ({ id: `plugin-ref-${index}`, name: `ref-${index}.png`, type: "image/png", dataUrl: src }));
// AI 配置未就绪:弹出配置弹窗并抛错,交由插件 catch 处理
// Open the configuration dialog and throw when AI is not configured, allowing the plugin to handle the error.
const ensureReady = (config: AiConfig) => {
if (!isAiConfigReady(config, config.model)) {
openConfigDialog(true);
@@ -74,7 +74,7 @@ export function usePluginHost(params: PluginHostParams) {
const text = await requestImageQuestion(config, messages, (delta) => options?.onDelta?.(delta), { signal: options?.signal });
return { text };
},
// 列出某能力下用户已配置的模型;label 取编码值中的模型名(去掉 channel 前缀)
// List configured models for a capability; labels use the model name without the channel prefix.
listModels: (capability) => selectableModelsByCapability(effectiveConfig, capability as ModelCapability | undefined).map((value) => ({ value, label: decodeChannelModel(value)?.model || value })),
defaultModel: (capability) => buildGenerationConfig(effectiveConfig, undefined, capability).model,
};
@@ -115,13 +115,13 @@ export function usePluginHost(params: PluginHostParams) {
[pluginHost, theme],
);
// 组装节点悬浮工具条按钮:插件自定义 toolbar +(声明 interactionToggle 时)宿主自动注入的「交互 ⇄ 移动」开关
// Build the node toolbar from plugin items and a host-provided interaction/move toggle when enabled.
const buildNodeToolbarItems = useCallback(
(node: CanvasNodeData): CanvasNodeToolbarItem[] => {
const definition = getNodeDefinition(node.type);
const ctx = buildNodeContext(pluginHost, node, theme, viewportRef.current.k);
const custom = definition?.toolbar?.(ctx) || [];
// 仅在节点有内容(展示态)且非强制交互态(如编辑态)时提供「交互/移动」开关
// Show the interaction/move toggle only for nodes with content that are not forced into an interactive state.
if (!definition?.interactionToggle || !node.metadata?.content || definition.forceInteractive?.(node)) return custom;
const interactive = Boolean(node.metadata?.interactive);
const toggle: CanvasNodeToolbarItem = {
@@ -137,7 +137,7 @@ export function usePluginHost(params: PluginHostParams) {
[pluginHost, t, theme],
);
// 启动时加载已安装的远程插件
// Load installed remote plugins on startup.
useEffect(() => {
void ensurePluginsLoaded();
}, []);
+27 -27
View File
@@ -88,7 +88,7 @@ import {
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio } from "@/types/media";
// 内置节点注册到统一注册表(模块加载时执行一次)
// Register built-in nodes in the shared registry once when the module loads.
registerBuiltinNodes();
type CanvasClipboard = {
@@ -117,7 +117,7 @@ type CanvasGenerationRequest = {
const VIDEO_NODE_MAX_WIDTH = 420;
const VIDEO_NODE_MAX_HEIGHT = 420;
// 稳定的空引用数组:避免每次渲染 `... || []` 产生新数组引用而击穿 CanvasNode React.memo
// Stable empty reference array prevents `... || []` from invalidating CanvasNode's React.memo on every render.
const EMPTY_REFERENCES: CanvasResourceReference[] = [];
const CONNECTION_HANDLE_HIT_RADIUS = 40;
const CONNECTION_NODE_HIT_PADDING = 32;
@@ -140,7 +140,7 @@ export default function CanvasPage() {
function InfiniteCanvasPage() {
const { message, modal } = App.useApp();
const { t } = useTranslation();
// 订阅节点注册表版本,插件动态注册/卸载后驱动画布重渲染
// Subscribe to the registry version so plugin registration changes rerender the canvas.
const nodeRegistryVersion = useNodeRegistryVersion((state) => state.version);
const params = useParams<{ id: string }>();
const navigate = useNavigate();
@@ -572,8 +572,8 @@ function InfiniteCanvasPage() {
}, [collapsingBatchIds, nodes, size.height, size.width, viewport.k, viewport.x, viewport.y]);
const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
// 工具条跟随「单选节点」:点击/新建/框选/键盘选中任一节点都会显示,不再仅靠精确点中触发。
// 多选时不显示;拖拽中由下方 isNodeDragging 守卫隐藏。
// The toolbar follows a single selected node selected by click, creation, marquee, or keyboard.
// It stays hidden for multi-selection and while isNodeDragging is true.
const singleSelectedNodeId = selectedNodeIds.size === 1 ? Array.from(selectedNodeIds)[0] : null;
const toolbarNode = (toolbarNodeId ? nodeById.get(toolbarNodeId) || null : null) || (singleSelectedNodeId ? nodeById.get(singleSelectedNodeId) || null : null);
const infoNode = infoNodeId ? nodeById.get(infoNodeId) || null : null;
@@ -693,9 +693,9 @@ function InfiniteCanvasPage() {
setSelectedNodeIds(new Set([newNode.id]));
setSelectedConnectionId(null);
const definition = getNodeDefinition(type);
// 纯展示型插件节点(hidePanel)不弹面板;插件自定义 Panel 需显式 autoOpenPanel 才在新建时打开;
// 声明了 useBuiltinPanel 的插件节点复用内置生成面板,新建即打开(与图片节点一致);
// 内置的图片/视频/配置类节点保持原有「新建即打开生图面板」行为。
// Display-only plugin nodes with hidePanel do not open a panel; custom Panels require autoOpenPanel on creation.
// Plugin nodes declaring useBuiltinPanel open the built-in generation panel on creation, like image nodes.
// Built-in image, video, and config nodes retain their existing open-on-create behavior.
const wantsPanel = definition?.hidePanel
? false
: definition?.Panel
@@ -1034,8 +1034,8 @@ function InfiniteCanvasPage() {
[cancelPendingConnectionCreate, screenToCanvas],
);
// 仅处理「选中」的纯逻辑,供 body 冒泡拖拽入口与外层 capture 入口共用。
// 返回本次点击后的单选目标 id(多选/取消时为 null),用于同步工具条。
// Selection-only logic shared by the bubbling drag entry point and outer capture handler.
// Returns the single target ID after the click, or null for multi-selection or deselection, to sync the toolbar.
const selectNodeByEvent = useCallback((event: Pick<ReactMouseEvent, "shiftKey" | "metaKey" | "ctrlKey">, nodeId: string) => {
const nextSelected = new Set(selectedNodeIdsRef.current);
if (event.shiftKey || event.metaKey || event.ctrlKey) {
@@ -1051,9 +1051,9 @@ function InfiniteCanvasPage() {
return { nextSelected, soloId };
}, []);
// capture 阶段选中:点击节点内部任意元素(含吞掉 mousedown 的 textarea/iframe)都能选中并弹出工具条。
// 只做选中,不启动拖拽 —— 拖拽仍由 body onMouseDown(冒泡)负责,故编辑器内选词不会拖动节点。
// capture 必先于同一次事件的 body 冒泡触发,故把算好的选中集暂存,供紧随其后的拖拽入口复用,避免二次选中(shift 反选被抵消)。
// Capture-phase selection lets any inner element, including textarea or iframe, select the node and show its toolbar.
// It only selects; body onMouseDown still starts dragging, so text selection inside editors does not drag the node.
// Cache the capture result for the following bubbling drag handler to avoid applying shift-selection twice.
const pendingSelectionRef = useRef<Set<string> | null>(null);
const handleNodeSelectCapture = useCallback(
(event: ReactMouseEvent, nodeId: string) => {
@@ -1069,7 +1069,7 @@ function InfiniteCanvasPage() {
const handleNodeMouseDown = useCallback((event: ReactMouseEvent, nodeId: string) => {
event.stopPropagation();
// 选中已由 capture 阶段完成;这里只负责建立拖拽。若因故没走 capture,则兜底再选一次。
// Capture already selected the node; this only starts dragging, with a fallback selection if capture did not run.
const currentNodes = nodesRef.current;
const nextSelected = pendingSelectionRef.current ?? selectNodeByEvent(event, nodeId).nextSelected;
pendingSelectionRef.current = null;
@@ -1140,7 +1140,7 @@ function InfiniteCanvasPage() {
if (clickedNode?.type === CanvasNodeType.Text) {
setDialogNodeId((current) => (current === clickedNodeId ? current : null));
} else if (clickedDefinition?.hidePanel) {
// 纯展示型插件节点:单击只选中,不弹下方面板
// Clicking a display-only plugin node selects it without opening a lower panel.
setDialogNodeId((current) => (current === clickedNodeId ? current : null));
} else if (clickedNode?.type !== CanvasNodeType.Group) {
setDialogNodeId(clickedNodeId);
@@ -1864,13 +1864,13 @@ function InfiniteCanvasPage() {
(containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2,
(containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2,
);
const STAGGER = 40; // 多文件时的偏移间距
const STAGGER = 40; // Offset between multiple imported files.
// 如果有替换目标节点,第一个文件替换它,其余在附近新建
// When replacing a target node, use the first file as the replacement and create the rest nearby.
if (target?.nodeId) {
const [first, ...rest] = files;
// 第一个文件:替换目标节点
// Replace the target node with the first file.
if (isAudioFile(first)) {
const audio = await uploadMediaFile(first, "audio");
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
@@ -1949,7 +1949,7 @@ function InfiniteCanvasPage() {
setSelectedConnectionId(null);
}
// 剩余文件:在目标节点附近新建
// Create the remaining files near the target node.
for (let i = 0; i < rest.length; i++) {
const offsetPos = { x: basePosition.x + (i + 1) * STAGGER, y: basePosition.y + (i + 1) * STAGGER };
const f = rest[i];
@@ -1962,7 +1962,7 @@ function InfiniteCanvasPage() {
}
}
} else {
// 无替换目标:所有文件在画布中心附近新建
// Without a replacement target, create all files near the canvas center.
for (let i = 0; i < files.length; i++) {
const offsetPos = { x: basePosition.x + i * STAGGER, y: basePosition.y + i * STAGGER };
const f = files[i];
@@ -2033,8 +2033,8 @@ function InfiniteCanvasPage() {
return;
}
// 插件节点声明了 useBuiltinPanel.writeBackToSelf:复用内置面板生成,但结果写回节点自身。
// 目前支持 image 模式(全景等展示型节点),前缀由 useBuiltinPanel.promptPrefix 指定。
// useBuiltinPanel.writeBackToSelf reuses built-in generation while writing the result back to the plugin node.
// Image mode currently supports display-only nodes such as panoramas, with a useBuiltinPanel.promptPrefix.
const builtinPanel = sourceNode ? getNodeDefinition(sourceNode.type)?.useBuiltinPanel : undefined;
if (sourceNode && builtinPanel?.writeBackToSelf && builtinPanel.mode === "image") {
const scene = prompt.trim();
@@ -2044,7 +2044,7 @@ function InfiniteCanvasPage() {
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt: scene, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)));
try {
const fullPrompt = (builtinPanel.promptPrefix || "") + scene;
// 上游图片节点作为参考图(图生图);无上游则纯文生图
// Upstream image nodes become references; without them this is text-to-image.
const upstreamNodes = connectionsRef.current
.filter((conn) => conn.toNodeId === nodeId)
.map((conn) => nodesRef.current.find((node) => node.id === conn.fromNodeId))
@@ -2690,10 +2690,10 @@ function InfiniteCanvasPage() {
[insertAssistantImage, insertAssistantText, screenToCanvas, size.height, size.width],
);
// --- 传给 CanvasNode 的回调/渲染函数统一 memo 化 ---
// CanvasNode React.memo,但只要这些 prop 每次渲染都是新引用,memo 就失效,
// 导致点击/悬停/移动视角时全部节点跟着重渲染(markdown 尤其明显)。全部 useCallback 后,
// 未变化的节点不再重渲染。依赖里的 map/handler 均已 memo 化,纯交互时保持稳定。
// Memoize every callback and render function passed to CanvasNode.
// CanvasNode uses React.memo, but new prop references would invalidate it on every render and rerender every node
// during click, hover, or viewport changes, which is especially expensive for Markdown. These useCallback values
// and their memoized map/handler dependencies remain stable during interaction, so unchanged nodes do not rerender.
const handleNodeHoverStart = useCallback((nodeId: string) => {
if (nodeDraggingRef.current) return;
setHoveredNodeId(nodeId);
+2 -2
View File
@@ -213,7 +213,7 @@ export default function ImagePage() {
}
};
// 响应 Agent 面板下发的生图命令:填入提示词,并按需自动触发生成。
// Handle image-generation commands from the Agent panel by setting the prompt and optionally starting generation.
useEffect(() => {
if (!imageCommand || imageCommand.nonce === processedCommandRef.current) return;
processedCommandRef.current = imageCommand.nonce;
@@ -366,7 +366,7 @@ export default function ImagePage() {
);
message.success(t("workbench.retrySuccess"));
} catch {
// runGenerationSlot 已经把结果状态更新为 failed
// runGenerationSlot has already marked the result as failed.
}
};
+1 -1
View File
@@ -225,7 +225,7 @@ export default function VideoPage() {
}
};
// 响应 Agent 面板下发的视频命令:填入提示词,并按需自动触发生成。
// Handle video-generation commands from the Agent panel by setting the prompt and optionally starting generation.
useEffect(() => {
if (!videoCommand || videoCommand.nonce === processedCommandRef.current) return;
processedCommandRef.current = videoCommand.nonce;
+9 -9
View File
@@ -247,7 +247,7 @@ function parseImagePayload(payload: ImageApiResponse) {
if (typeof payload.code === "number" && payload.code !== 0) {
throw new Error(payload.msg || apiText("requestFailed"));
}
// 支持 data / images / results 三种返回字段(兼容不同 API
// Support data, images, and results response fields used by different APIs.
const imageList = payload.data
|| (payload as Record<string, unknown>).images as Array<Record<string, unknown>> | undefined
|| (payload as Record<string, unknown>).results as Array<Record<string, unknown>> | undefined
@@ -259,7 +259,7 @@ function parseImagePayload(payload: ImageApiResponse) {
.map((dataUrl) => ({ id: nanoid(), dataUrl }));
if (images.length === 0) {
// 尝试检查是否有返回了但格式不被识别的数据
// Check whether the response contains data in an unrecognized format.
const rawKeys = Object.keys(payload).filter((k) => k !== "code" && k !== "msg" && k !== "error");
throw new Error(rawKeys.length > 0
? apiText("unknownImageResponse", { fields: rawKeys.join(", ") })
@@ -272,22 +272,22 @@ function parseImagePayload(payload: ImageApiResponse) {
function readApiErrorMessage(value: unknown): string {
if (!value) return "";
if (typeof value === "string") {
// 可能是 JSON 字符串(如 error.message 被序列化)或纯文本错误
// The value may be serialized JSON, such as error.message, or a plain-text error.
try {
const parsed = JSON.parse(value);
const inner = readApiErrorMessage(parsed) || value;
// 如果 JSON 解析后得到 "{}" 这种空对象,返回原始字符串
// Treat an empty parsed object such as "{}" as having no useful message.
if (inner === value && typeof parsed === "object" && Object.keys(parsed).length === 0) return "";
return inner;
} catch {
// 检查是否是 HTML 错误页面
// Detect HTML error pages.
if (/<[a-z][\s\S]*>/i.test(value)) return apiText("htmlError", { preview: `${value.slice(0, 80)}...` });
return value;
}
}
if (typeof value !== "object") return "";
const payload = value as { msg?: unknown; message?: unknown; error?: unknown; detail?: unknown };
// error 可能是字符串或含 message 的对象
// error may be a string or an object containing a message.
const errorMsg =
typeof payload.error === "string"
? payload.error
@@ -305,13 +305,13 @@ function readAxiosError(error: unknown, fallback: string) {
if (axios.isCancel(error)) return apiText("requestCanceled");
if (axios.isAxiosError(error)) {
const responseData = error.response?.data;
// 优先从响应体提取业务错误
// Prefer the API error from the response body.
const apiMsg = readApiErrorMessage(responseData);
if (apiMsg) return apiMsg;
// 响应体无法提取时用 HTTP 状态推断
// Infer the error from the HTTP status when the response body has no usable message.
const statusMsg = readStatusError(error.response?.status, fallback);
if (statusMsg) return statusMsg;
// 最后用 axios 自身的错误文本
// Fall back to Axios's own error message.
return error.message || fallback;
}
if (error instanceof DOMException && error.name === "AbortError") return apiText("requestCanceled");
+3 -3
View File
@@ -106,9 +106,9 @@ function createPoll(signal?: AbortSignal) {
/**
* Run a user-authored model call script as an async function body with flat locals (see PLUGIN_VARIABLES):
* prompt / images / messages / params
* model / baseUrl / apiKey / systemPrompt / reasoningEffort
* http / request / poll / sleep / signal / onDelta
* prompt / images / messages / params request input
* model / baseUrl / apiKey / systemPrompt / reasoningEffort current channel and text settings
* http / request / poll / sleep / signal / onDelta request helpers
* The script must `return` the result; each caller normalizes it to its capability's shape.
*/
export async function runModelPlugin<T = unknown>(args: RunPluginArgs): Promise<T> {
+1 -1
View File
@@ -352,7 +352,7 @@ function readApiErrorMessage(value: unknown): string {
}
if (typeof value !== "object") return "";
const payload = value as { msg?: unknown; message?: unknown; error?: unknown; detail?: unknown };
// error 可能是字符串或含 message 的对象
// error may be a string or an object containing a message.
const errorMsg =
typeof payload.error === "string"
? payload.error
+4 -4
View File
@@ -8,11 +8,11 @@ export type InstalledPlugin = {
name: string;
version: string;
description?: string;
url: string; // 安装来源,可用于更新
source: string; // 缓存的插件源码,离线可用、版本固定
url: string; // Installation source used for updates.
source: string; // Cached plugin source for offline use and pinned versions.
enabled: boolean;
local?: boolean; // 自动发现于 web/public/plugins 的本地插件(默认关闭,启用时按 url 重新拉取)
official?: boolean; // 从官方注册表安装(用于在管理器里归类)
local?: boolean; // Local plugin discovered in web/public/plugins; disabled by default and refetched from its URL when enabled.
official?: boolean; // Installed from the official registry and grouped accordingly in the manager.
installedAt: string;
};
+1 -1
View File
@@ -149,7 +149,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
}
localStorage.setItem("canvas-agent-url", endpoint);
localStorage.setItem("canvas-agent-token", token);
// 只设 enabled=true,由 LocalAgentPanel 的 useEffect 统一负责开 SSE
// Only set enabled here; LocalAgentPanel's effect owns SSE initialization.
set({ url: endpoint, token, enabled: true, silentConnect: silent, activity: i18n.t("agent.status.connecting"), connectError: "" });
},
disconnectAgent: (patch = {}) => {
+3 -3
View File
@@ -1,8 +1,8 @@
import { create } from "zustand";
// Agent 面板通过该 store 向生图/视频工作台派发命令:设置提示词、并可选自动点击生成。
// 参数(模型/质量/尺寸/张数等)由 Agent 面板直接写入 use-config-store,工作台页面从 config 读取;
// prompt run 通过这里下发,页面用 nonce 判断是否为新命令,消费后调用 clear 清空。
// The Agent panel dispatches commands through this store to set workbench prompts and optionally start generation.
// The panel writes model, quality, size, count, and other options to use-config-store, which workbench pages read directly.
// Prompt and run are sent here; pages identify new commands by nonce and call clear after consuming them.
export type WorkbenchCommand = {
nonce: number;
+32 -32
View File
@@ -5,10 +5,10 @@ import type { CanvasTheme } from "@/lib/canvas-theme";
import type { CanvasConnection, CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
import type { CanvasResourceKind } from "@/lib/canvas/canvas-resource-references";
// 插件节点作为上游输入被消费时输出的资源
// Resource emitted when a plugin node is consumed as an upstream input.
export type CanvasNodeResource = { kind: CanvasResourceKind; text?: string; url?: string };
// --- AI 生成能力(生图/生视频/生文本),由宿主注入,复用宿主模型/密钥配置 ---
// AI generation capabilities injected by the host, reusing its model and credential configuration.
export type GenerateOptions = { signal?: AbortSignal; references?: string[]; model?: string };
export type GenerateImageOptions = GenerateOptions & { count?: number; size?: string };
export type GenerateImageResult = { images: string[] };
@@ -27,7 +27,7 @@ export type CanvasPluginAi = {
defaultModel: (capability: PluginModelCapability) => string;
};
// 节点自带的工具栏按钮(追加到 hover 工具栏尾部)
// Node-specific buttons appended to the hover toolbar.
export type CanvasNodeToolbarItem = {
id: string;
title: string;
@@ -38,32 +38,32 @@ export type CanvasNodeToolbarItem = {
danger?: boolean;
};
// 每个节点渲染时注入的上下文,是插件与画布交互的核心接口
// Context injected while rendering each node; the primary interface between plugins and the canvas.
export type CanvasNodeContext = {
node: CanvasNodeData;
theme: CanvasTheme;
scale: number;
isSelected: boolean; // 该节点当前是否被选中(用于按需启用 iframe 交互等)
// 自身数据
isSelected: boolean; // Whether this node is selected, used to enable iframe interaction on demand.
// Node data.
updateMetadata: (patch: CanvasNodeMetadata) => void;
updateNode: (patch: Partial<Pick<CanvasNodeData, "title" | "width" | "height">>) => void;
// 图访问
// Graph access.
getNode: (id: string) => CanvasNodeData | null;
getNodes: () => CanvasNodeData[];
getConnections: () => CanvasConnection[];
getUpstream: () => CanvasNodeData[];
getDownstream: () => CanvasNodeData[];
// 画布操作,复用 Agent 指令集(增删节点/连线/选择/视口/触发生成)
// Canvas operations using the Agent instruction set for nodes, connections, selection, viewport, and generation.
applyOps: (ops: CanvasAgentOp[]) => void;
// 节点间/插件间通信
// Inter-node and inter-plugin communication.
emit: (event: string, payload?: unknown) => void;
on: (event: string, handler: (payload: unknown) => void) => () => void;
// AI 生成能力(生图/生视频/生文本),复用宿主模型配置
// AI image, video, and text generation using the host model configuration.
ai: CanvasPluginAi;
// 打开/关闭本节点下方的自定义 Panel(需在节点定义里提供 Panel)
// Opens or closes the custom panel below this node; the definition must provide a Panel.
openPanel: () => void;
closePanel: () => void;
// 插件私有持久化,命名空间隔离
// Plugin-private persistence isolated by namespace.
storage: PluginStorage;
};
@@ -73,7 +73,7 @@ export type PluginStorage = {
remove: (key: string) => Promise<void>;
};
// 画布宿主提供的、与具体节点无关的能力集合(由画布页面构建、注入渲染链路)
// Node-independent host capabilities constructed by the canvas page and injected into the render chain.
export type CanvasPluginHost = {
getNode: (id: string) => CanvasNodeData | null;
getNodes: () => CanvasNodeData[];
@@ -83,65 +83,65 @@ export type CanvasPluginHost = {
updateNode: (nodeId: string, patch: Partial<Pick<CanvasNodeData, "title" | "width" | "height">>) => void;
updateMetadata: (nodeId: string, patch: CanvasNodeMetadata) => void;
applyOps: (ops: CanvasAgentOp[]) => void;
// AI 生成能力,复用画布页面当前的模型/密钥配置
// AI generation using the current canvas model and credential configuration.
ai: CanvasPluginAi;
// 打开/关闭指定节点下方的自定义 Panel
// Opens or closes the custom panel below a specified node.
openPanel: (nodeId: string) => void;
closePanel: () => void;
};
// 复用宿主内置生成面板的配置(见 SDK CanvasBuiltinPanelConfig)
// Configuration for reusing the host's built-in generation panel; see SDK CanvasBuiltinPanelConfig.
export type CanvasBuiltinPanelConfig = {
mode: "image" | "video" | "text" | "audio";
promptPrefix?: string;
writeBackToSelf?: boolean;
};
// 节点类型定义:内置节点与插件节点统一走这套结构
// Shared node definition used by both built-in and plugin nodes.
export type CanvasNodeDefinition = {
type: string; // 内置如 "image";插件建议 "<pluginId>:<name>"
type: string; // Built-ins use values such as "image"; plugins should use "<pluginId>:<name>".
title: string;
icon: ReactNode;
description?: string;
defaultSize: { width: number; height: number };
defaultMetadata?: CanvasNodeMetadata;
minimapColor?: string;
showInCreateMenu?: boolean; // 默认 true
hasSourceHandle?: boolean; // 右侧输出连接点,默认 true
hidePanel?: boolean; // 为 true 时:点击/新建不弹出下方面板(含内置生图面板),纯展示型节点用
transparentBackground?: boolean; // 为 true 时:节点卡片背景与边框透明,内容直接融入画布(如 SVG/矢量图)
autoOpenPanel?: boolean; // 为 true 时:单击节点自动打开自定义 Panel(默认仅内置节点单击自动打开)
useBuiltinPanel?: CanvasBuiltinPanelConfig; // 复用宿主内置生成面板(与自定义 Panel 二选一)
// 为 true 时:宿主自动提供「交互 ⇄ 移动」工具条开关,并按 metadata.interactive 控制内容层指针事件
showInCreateMenu?: boolean; // Defaults to true.
hasSourceHandle?: boolean; // Right-side output handle; defaults to true.
hidePanel?: boolean; // Prevents click/create from opening a lower panel; intended for display-only nodes.
transparentBackground?: boolean; // Makes the node card transparent so SVG or vector content blends into the canvas.
autoOpenPanel?: boolean; // Opens a custom Panel on click; automatic opening otherwise applies only to built-ins.
useBuiltinPanel?: CanvasBuiltinPanelConfig; // Reuses the built-in generation panel instead of a custom Panel.
// Lets the host provide an Interaction/Move toolbar toggle and control pointer events through metadata.interactive.
interactionToggle?: boolean;
// 配合 interactionToggle:返回 true 表示内容强制可交互(如编辑态),忽略 interactive 并隐藏开关
// With interactionToggle, true forces interactive content, ignores metadata.interactive, and hides the toggle.
forceInteractive?: (node: CanvasNodeData) => boolean;
keepAspectRatio?: (node: CanvasNodeData) => boolean;
resource?: (node: CanvasNodeData) => CanvasNodeResource | null;
// 渲染:内置节点由 canvas-node 内部渲染器负责,可不提供 Content
// Built-ins use canvas-node's internal renderer and may omit Content.
Content?: ComponentType<{ ctx: CanvasNodeContext }>;
Panel?: ComponentType<{ ctx: CanvasNodeContext; onClose: () => void }>;
toolbar?: (ctx: CanvasNodeContext) => CanvasNodeToolbarItem[];
onDoubleClick?: (ctx: CanvasNodeContext) => boolean; // 返回 true 表示已处理
onDoubleClick?: (ctx: CanvasNodeContext) => boolean; // Return true when handled.
};
// 插件启动时可访问的应用能力
// Application capabilities available while a plugin starts.
export type CanvasPluginApp = {
version: string;
emit: (event: string, payload?: unknown) => void;
on: (event: string, handler: (payload: unknown) => void) => () => void;
// 注入插件样式,返回移除函数;传 key 时同 key 覆盖旧样式
// Injects plugin styles and returns a cleanup function; the same key replaces previous styles.
injectCSS: (css: string, key?: string) => () => void;
};
// 插件包默认导出
// Default plugin package export.
export type CanvasPlugin = {
id: string;
name: string;
version: string;
description?: string;
minAppVersion?: string;
css?: string; // 插件样式,启用时自动注入、卸载/禁用时自动清理
css?: string; // Injected when enabled and removed when uninstalled or disabled.
nodes: CanvasNodeDefinition[];
setup?: (app: CanvasPluginApp) => void | (() => void);
};
+2 -2
View File
@@ -18,7 +18,7 @@ export enum CanvasNodeType {
Group = "group",
}
// 节点类型放开为字符串,内置类型用 CanvasNodeType,插件类型为 "<pluginId>:<name>"
// Node types are open strings: built-ins use CanvasNodeType and plugins use "<pluginId>:<name>".
export type CanvasNodeTypeId = CanvasNodeType | (string & {});
export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
@@ -63,7 +63,7 @@ export type CanvasNodeMetadata = {
bytes?: number;
durationMs?: number;
groupId?: string;
interactive?: boolean; // 插件节点「交互 ⇄ 移动」开关状态(见 CanvasNodeDefinition.interactionToggle)
interactive?: boolean; // Plugin node interaction/move state; see CanvasNodeDefinition.interactionToggle.
};
export type CanvasNodeData = {
+4 -4
View File
@@ -4,11 +4,11 @@ declare const __APP_VERSION__: string;
declare const __APP_RELEASES__: import("@/lib/release").ReleaseInfo[];
interface ImportMetaEnv {
// 逗号分隔的本地开发插件 URL,每次启动重新拉取(不缓存、不落库)
// Comma-separated local development plugin URLs, refetched on every startup without caching or persistence.
readonly VITE_DEV_PLUGINS?: string;
// 统计分析(可选,构建期注入):每家一个独立变量,填了谁就启用谁,可同时启用多家
// GA4 衡量 IDG-XXXX
// Optional build-time analytics configuration, with one independent variable per provider.
// GA4 measurement ID (G-XXXX)
readonly VITE_ANALYTICS_GA4_ID?: string;
// 百度统计站点 ID
// Baidu Analytics site ID
readonly VITE_ANALYTICS_BAIDU_ID?: string;
}
+2 -2
View File
@@ -10,8 +10,8 @@ const webDir = dirname(fileURLToPath(import.meta.url));
const localVersion = readFileSync(resolve(webDir, "../VERSION"), "utf8").trim() || "dev";
const localChangelog = readFileSync(resolve(webDir, "../CHANGELOG.md"), "utf8");
// 暴露 /plugins/index.json:列出 public/plugins 下的本地插件文件,
// 供前端自动发现并加入插件列表(默认关闭)。dev 下实时读目录,构建时产出静态清单。
// Expose /plugins/index.json with local plugin files from public/plugins.
// The frontend can discover and list them when enabled; development reads the directory live, while builds emit a static registry.
function localPluginsManifest(): Plugin {
const pluginsDir = resolve(webDir, "public/plugins");
const listLocalPlugins = () => {