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