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