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