): ChartRow {
+ return Object.fromEntries(Object.entries(row).map(([key, value]) => {
+ if (typeof value === "number") return [key, value];
+ const text = String(value ?? "").trim();
+ const numeric = text !== "" && Number.isFinite(Number(text));
+ return [key, numeric ? Number(text) : text];
+ }));
+}
+
+const SERIES_COLORS = ["#f59e0b", "#22c55e", "#06b6d4", "#a855f7", "#ef4444"];
+
+/**
+ * 图表节点:把上游文本(JSON 数组或 CSV)渲染成柱状图/折线图。
+ * metadata.chartKind 为 "line" 时画折线,缺省柱状。
+ */
+export function ChartNodeContent({ node, theme }: ChartNodeContentProps) {
+ const upstream = useUpstreamNodes(node.id);
+ const inherited = upstream.find((item) => getNodeResourceKind(item) === "text");
+ const source = node.metadata?.content || inherited?.metadata?.content || inherited?.metadata?.prompt || "";
+ const parsed = parseChartSource(source);
+ const asLine = node.metadata?.chartKind === "line";
+
+ if (!parsed) {
+ return (
+
+
+ {source.trim() ? "无法解析为图表数据(需要 JSON 数组或 CSV)" : "连接输出 JSON 数组或 CSV 的文本节点"}
+
+ );
+ }
+
+ return (
+ event.stopPropagation()}
+ onMouseDown={(event) => event.stopPropagation()}
+ >
+
+ {asLine ? (
+
+
+
+
+
+ {parsed.series.length > 1 ? : null}
+ {parsed.series.map((key, index) => )}
+
+ ) : (
+
+
+
+
+
+ {parsed.series.length > 1 ? : null}
+ {parsed.series.map((key, index) => )}
+
+ )}
+
+
+ );
+}
diff --git a/web/src/components/canvas/nodes/color-grade-node.tsx b/web/src/components/canvas/nodes/color-grade-node.tsx
new file mode 100644
index 000000000..f1031f186
--- /dev/null
+++ b/web/src/components/canvas/nodes/color-grade-node.tsx
@@ -0,0 +1,93 @@
+import { Palette, RotateCcw } from "lucide-react";
+
+import { useUpstreamNodes } from "@/components/canvas/canvas-node-graph-context";
+import { useCanvasNodeActions } from "@/components/canvas/canvas-node-action-context";
+import { colorGradeCssFilter, DEFAULT_COLOR_GRADE, isNeutralColorGrade, type CanvasColorGrade } from "@/lib/canvas/canvas-color-grade";
+import { getNodeResourceKind } from "@/lib/canvas/node-registry";
+import type { CanvasTheme } from "@/lib/canvas-theme";
+import type { CanvasNodeData } from "@/types/canvas";
+
+type ColorGradeNodeContentProps = {
+ node: CanvasNodeData;
+ theme: CanvasTheme;
+};
+
+const SLIDERS: Array<{ key: keyof CanvasColorGrade; label: string; min: number; max: number }> = [
+ { key: "brightness", label: "亮度", min: 0, max: 200 },
+ { key: "contrast", label: "对比", min: 0, max: 200 },
+ { key: "saturate", label: "饱和", min: 0, max: 200 },
+ { key: "hueRotate", label: "色相", min: -180, max: 180 },
+];
+
+/**
+ * 调色节点:吃上游图片,本地预览调色结果,并作为图片素材喂给下游。
+ *
+ * 预览只用 CSS filter,不上传任何东西;真正落地成资源要等到生成时——
+ * 见 canvas-color-grade.ts 的 resolveCanvasColorGradeReference。拖滑杆不产生上传,
+ * 也不消耗文件容量。
+ */
+export function ColorGradeNodeContent({ node, theme }: ColorGradeNodeContentProps) {
+ const { updateMetadata } = useCanvasNodeActions();
+ const upstream = useUpstreamNodes(node.id);
+ // 判据要与 canvas-node-generation 的 readReferenceImage 完全一致,
+ // 否则会出现「预览有图、生成却没用上」这种不报错的偏差。
+ const inherited = upstream.find((item) => getNodeResourceKind(item) === "image" && item.metadata?.content);
+ const url = inherited?.metadata?.content || "";
+ const grade = node.metadata?.colorGrade || DEFAULT_COLOR_GRADE;
+
+ if (!url) {
+ return (
+
+ );
+ }
+
+ const editable = Boolean(updateMetadata);
+ const update = (key: keyof CanvasColorGrade, value: number) => updateMetadata?.(node.id, { colorGrade: { ...grade, [key]: value } });
+
+ return (
+
+
+

+ {isNeutralColorGrade(grade) ? (
+
未调色
+ ) : null}
+
+
+ {editable ? (
+
event.stopPropagation()}
+ onMouseDown={(event) => event.stopPropagation()}
+ >
+ {SLIDERS.map((slider) => (
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
diff --git a/web/src/components/canvas/nodes/compare-node.tsx b/web/src/components/canvas/nodes/compare-node.tsx
new file mode 100644
index 000000000..5ab4eb0fa
--- /dev/null
+++ b/web/src/components/canvas/nodes/compare-node.tsx
@@ -0,0 +1,72 @@
+import { useRef, useState } from "react";
+import { Columns2 } from "lucide-react";
+
+import { useUpstreamNodes } from "@/components/canvas/canvas-node-graph-context";
+import { getNodeResourceKind } from "@/lib/canvas/node-registry";
+import type { CanvasTheme } from "@/lib/canvas-theme";
+import type { CanvasNodeData } from "@/types/canvas";
+
+type CompareNodeContentProps = {
+ node: CanvasNodeData;
+ theme: CanvasTheme;
+};
+
+/**
+ * A/B 对比节点:吃两个图片上游,中间一根滑杆左右拖动对比。
+ *
+ * 分割位置只存组件内 state,不落 metadata——它是「看的时候临时挪一下」,
+ * 不是画布内容;CanvasNodeContentProps 也只给了 onContentChange(content),
+ * 为它另开一条写元数据的通道不值得。
+ */
+export function CompareNodeContent({ node, theme }: CompareNodeContentProps) {
+ const upstream = useUpstreamNodes(node.id);
+ const images = upstream.filter((item) => getNodeResourceKind(item) === "image");
+ const [split, setSplit] = useState(50);
+ const hostRef = useRef(null);
+ const draggingRef = useRef(false);
+
+ const before = images[0]?.metadata?.content || "";
+ const after = images[1]?.metadata?.content || "";
+
+ if (!before || !after) {
+ return (
+
+
+ {images.length === 1 ? "再连一张图片即可对比" : "连接两张图片进行对比"}
+
+ );
+ }
+
+ const moveTo = (clientX: number) => {
+ const rect = hostRef.current?.getBoundingClientRect();
+ if (!rect?.width) return;
+ setSplit(Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100)));
+ };
+
+ return (
+ event.stopPropagation()}
+ onPointerDown={(event) => {
+ draggingRef.current = true;
+ event.currentTarget.setPointerCapture(event.pointerId);
+ moveTo(event.clientX);
+ }}
+ onPointerMove={(event) => { if (draggingRef.current) moveTo(event.clientX); }}
+ onPointerUp={(event) => {
+ draggingRef.current = false;
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
+ }}
+ >
+

+ {/* 上层按分割位置裁切,露出下层的 B */}
+

+
+
A
+
B
+
+ );
+}
diff --git a/web/src/components/canvas/nodes/html-node.tsx b/web/src/components/canvas/nodes/html-node.tsx
new file mode 100644
index 000000000..f0dc72b4b
--- /dev/null
+++ b/web/src/components/canvas/nodes/html-node.tsx
@@ -0,0 +1,47 @@
+import { Code } from "lucide-react";
+
+import { useUpstreamNodes } from "@/components/canvas/canvas-node-graph-context";
+import { getNodeResourceKind } from "@/lib/canvas/node-registry";
+import type { CanvasTheme } from "@/lib/canvas-theme";
+import type { CanvasNodeData } from "@/types/canvas";
+
+import { SandboxedFrame } from "./sandboxed-frame";
+
+type HtmlNodeContentProps = {
+ node: CanvasNodeData;
+ theme: CanvasTheme;
+};
+
+/**
+ * HTML 展示节点:在沙箱 iframe 里预览 HTML。
+ *
+ * 源码取自身 metadata.content,为空时回落上游文本(与 SVG / Markdown 一致)。
+ * 自身有源码时,上游文本作为数据填进 `{{input}}`,所以「模板 + 数据」两条线可以分开接。
+ *
+ * 脚本可执行(allow-scripts),但 SandboxedFrame 结构上不允许同时给 allow-same-origin
+ * —— 否则页面能读到本源登录态。
+ */
+export function HtmlNodeContent({ node, theme }: HtmlNodeContentProps) {
+ const upstream = useUpstreamNodes(node.id);
+ const inherited = upstream.find((item) => getNodeResourceKind(item) === "text");
+ const upstreamText = inherited?.metadata?.content || inherited?.metadata?.prompt || "";
+ const own = node.metadata?.content || "";
+ // 与 SVG / Markdown 一致:自身为空时把上游文本当源码,这样「AI 写页面 → 连线 → 看效果」
+ // 不需要先把源码搬进本节点。
+ const source = own || upstreamText;
+ // 源码本身就来自上游时,{{input}} 没有第二份数据可填——替换成空串,
+ // 否则会把同一段内容再塞回自己里。
+ const input = own ? upstreamText : "";
+
+ if (!source.trim()) {
+ return (
+
+
+ 连接输出 HTML 的文本节点
+ 源码里的 {"{{input}}"} 会替换为上游文本
+
+ );
+ }
+
+ return ;
+}
diff --git a/web/src/components/canvas/nodes/markdown-node.tsx b/web/src/components/canvas/nodes/markdown-node.tsx
new file mode 100644
index 000000000..2a8b00875
--- /dev/null
+++ b/web/src/components/canvas/nodes/markdown-node.tsx
@@ -0,0 +1,48 @@
+import { FileText } from "lucide-react";
+
+import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
+import { useUpstreamNodes } from "@/components/canvas/canvas-node-graph-context";
+import { getNodeResourceKind } from "@/lib/canvas/node-registry";
+import type { CanvasTheme } from "@/lib/canvas-theme";
+import type { CanvasNodeData } from "@/types/canvas";
+
+type MarkdownNodeContentProps = {
+ node: CanvasNodeData;
+ theme: CanvasTheme;
+};
+
+/**
+ * Markdown 展示节点。
+ *
+ * 内容取自身 metadata.content;为空时回落到上游文本素材——这样「AI 写文档 → 连一根线 →
+ * 排版好的文档」不需要先把内容搬进本节点。上游取用统一经 CanvasNodeGraphContext,
+ * 判定文本素材复用注册表的 resourceKind,不在这里另立一套判断。
+ */
+export function MarkdownNodeContent({ node, theme }: MarkdownNodeContentProps) {
+ const upstream = useUpstreamNodes(node.id);
+ const own = node.metadata?.content || "";
+ const inherited = upstream.find((item) => getNodeResourceKind(item) === "text");
+ const source = own || inherited?.metadata?.content || inherited?.metadata?.prompt || "";
+
+ if (!source.trim()) {
+ return (
+
+
+ 连接文本节点,或双击编辑 Markdown
+
+ );
+ }
+
+ return (
+ // 滚动区要吞掉 wheel 并标 data-canvas-no-zoom,否则滚动会被画布缩放拦走。
+ event.stopPropagation()}
+ onMouseDown={(event) => event.stopPropagation()}
+ >
+
{source}
+
+ );
+}
diff --git a/web/src/components/canvas/nodes/panorama-node.tsx b/web/src/components/canvas/nodes/panorama-node.tsx
new file mode 100644
index 000000000..94055d497
--- /dev/null
+++ b/web/src/components/canvas/nodes/panorama-node.tsx
@@ -0,0 +1,205 @@
+import { useEffect, useRef, useState } from "react";
+import { Globe, X } from "lucide-react";
+
+import { useUpstreamNodes } from "@/components/canvas/canvas-node-graph-context";
+import { getNodeResourceKind } from "@/lib/canvas/node-registry";
+import type { CanvasTheme } from "@/lib/canvas-theme";
+import type { CanvasNodeData } from "@/types/canvas";
+
+type PanoramaNodeContentProps = {
+ node: CanvasNodeData;
+ theme: CanvasTheme;
+ reduceMediaEffects?: boolean;
+};
+
+/**
+ * 360° 全景查看器节点:取上游图片,拖拽环视。
+ *
+ * 三个刻意的取舍:
+ * 1. **点击才激活 WebGL**。浏览器对同时存在的 WebGL 上下文有硬上限(十几个),
+ * 画布上可以有任意多个全景节点,自动激活必然在某个数量后整片黑。默认只显示平面预览,
+ * 与既有的 DeferredMediaLoad「点击加载」是同一套交互习惯。
+ * 2. **用原生 three 而不是 @react-three/fiber**。这里需要精确控制 dispose——
+ * 上下文/纹理/几何体漏一个就逼近上限,节点还会被频繁增删。
+ * 3. **性能模式下不提供激活入口**。错题本里多条崩溃出在画布高频渲染,
+ * reduceMediaEffects 时只给静态预览。
+ */
+export function PanoramaNodeContent({ node, theme, reduceMediaEffects }: PanoramaNodeContentProps) {
+ const upstream = useUpstreamNodes(node.id);
+ const inherited = upstream.find((item) => getNodeResourceKind(item) === "image");
+ const url = node.metadata?.content || inherited?.metadata?.content || "";
+ const [active, setActive] = useState(false);
+ const [failed, setFailed] = useState(false);
+
+ // 上游换图后退出环视,避免旧上下文继续持有已被替换的纹理。
+ useEffect(() => {
+ setActive(false);
+ setFailed(false);
+ }, [url]);
+
+ if (!url) {
+ return (
+
+
+ 连接一张 360° 等距柱状全景图
+
+ );
+ }
+
+ if (!active) {
+ return (
+
+

+
+
+ {failed ? "全景加载失败(图片可能不允许跨域读取)" : reduceMediaEffects ? "性能模式下仅显示预览" : "360° 全景"}
+
+ {reduceMediaEffects ? null : (
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+
{ setFailed(true); setActive(false); }} />
+
+
+ );
+}
+
+function PanoramaViewport({ url, onError }: { url: string; onError: () => void }) {
+ const hostRef = useRef(null);
+ // onError 是内联箭头函数,每次渲染都是新引用;直接放进 effect 依赖会让
+ // 父级任何重渲染(悬停/选中/画布状态)都销毁重建 WebGL 上下文 ——
+ // 表现就是环视画面闪动、视角回到初始位置。用 ref 稳定它。
+ const onErrorRef = useRef(onError);
+ onErrorRef.current = onError;
+
+ useEffect(() => {
+ const host = hostRef.current;
+ if (!host) return;
+ let disposed = false;
+ let frame = 0;
+ // three 体积大,按需加载,不进主包。
+ const teardown: Array<() => void> = [];
+
+ void (async () => {
+ const THREE = await import("three");
+ if (disposed) return;
+
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ const scene = new THREE.Scene();
+ const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1100);
+
+ const loader = new THREE.TextureLoader();
+ loader.setCrossOrigin("anonymous");
+ const texture = await new Promise | null>((resolve) => {
+ loader.load(url, resolve, undefined, () => resolve(null));
+ });
+ if (disposed) { renderer.dispose(); return; }
+ if (!texture) { renderer.dispose(); onErrorRef.current(); return; }
+
+ texture.colorSpace = THREE.SRGBColorSpace;
+ const geometry = new THREE.SphereGeometry(500, 60, 40);
+ const material = new THREE.MeshBasicMaterial({ map: texture, side: THREE.BackSide });
+ scene.add(new THREE.Mesh(geometry, material));
+ host.appendChild(renderer.domElement);
+ renderer.domElement.style.cssText = "width:100%;height:100%;display:block;cursor:grab";
+
+ let lon = 0;
+ let lat = 0;
+ let dragging = false;
+ let lastX = 0;
+ let lastY = 0;
+
+ const onPointerDown = (event: PointerEvent) => {
+ dragging = true;
+ lastX = event.clientX;
+ lastY = event.clientY;
+ renderer.domElement.setPointerCapture(event.pointerId);
+ event.stopPropagation();
+ };
+ const onPointerMove = (event: PointerEvent) => {
+ if (!dragging) return;
+ lon -= (event.clientX - lastX) * 0.18;
+ lat += (event.clientY - lastY) * 0.18;
+ lat = Math.max(-85, Math.min(85, lat));
+ lastX = event.clientX;
+ lastY = event.clientY;
+ };
+ const onPointerUp = (event: PointerEvent) => {
+ dragging = false;
+ if (renderer.domElement.hasPointerCapture(event.pointerId)) renderer.domElement.releasePointerCapture(event.pointerId);
+ };
+ // 滚轮留给画布缩放,全景不吃 wheel —— 节点里再嵌一层缩放会让手感彻底乱。
+ renderer.domElement.addEventListener("pointerdown", onPointerDown);
+ renderer.domElement.addEventListener("pointermove", onPointerMove);
+ renderer.domElement.addEventListener("pointerup", onPointerUp);
+ renderer.domElement.addEventListener("pointercancel", onPointerUp);
+
+ const resize = () => {
+ const { clientWidth, clientHeight } = host;
+ if (!clientWidth || !clientHeight) return;
+ renderer.setSize(clientWidth, clientHeight, false);
+ camera.aspect = clientWidth / clientHeight;
+ camera.updateProjectionMatrix();
+ };
+ const observer = new ResizeObserver(resize);
+ observer.observe(host);
+ resize();
+
+ const tick = () => {
+ const phi = THREE.MathUtils.degToRad(90 - lat);
+ const theta = THREE.MathUtils.degToRad(lon);
+ camera.lookAt(500 * Math.sin(phi) * Math.cos(theta), 500 * Math.cos(phi), 500 * Math.sin(phi) * Math.sin(theta));
+ renderer.render(scene, camera);
+ frame = requestAnimationFrame(tick);
+ };
+ tick();
+
+ teardown.push(() => {
+ cancelAnimationFrame(frame);
+ observer.disconnect();
+ renderer.domElement.removeEventListener("pointerdown", onPointerDown);
+ renderer.domElement.removeEventListener("pointermove", onPointerMove);
+ renderer.domElement.removeEventListener("pointerup", onPointerUp);
+ renderer.domElement.removeEventListener("pointercancel", onPointerUp);
+ renderer.domElement.remove();
+ geometry.dispose();
+ material.dispose();
+ texture.dispose();
+ // forceContextLoss 才真正释放上下文;只 dispose 在部分浏览器上仍占着配额。
+ renderer.forceContextLoss();
+ renderer.dispose();
+ });
+ })();
+
+ return () => {
+ disposed = true;
+ teardown.forEach((fn) => fn());
+ };
+ }, [url]);
+
+ return event.stopPropagation()} />;
+}
diff --git a/web/src/components/canvas/nodes/sandboxed-frame.tsx b/web/src/components/canvas/nodes/sandboxed-frame.tsx
new file mode 100644
index 000000000..361ac604a
--- /dev/null
+++ b/web/src/components/canvas/nodes/sandboxed-frame.tsx
@@ -0,0 +1,41 @@
+import type { CanvasTheme } from "@/lib/canvas-theme";
+
+type SandboxedFrameProps = {
+ /** 完整的 HTML 文档字符串,作为 iframe 的 srcDoc */
+ srcDoc: string;
+ theme: CanvasTheme;
+ /**
+ * 是否允许文档内脚本执行。
+ *
+ * ⚠️ 只给 allow-scripts,**绝不同时给 allow-same-origin** —— 两者并存等于取消沙箱:
+ * 文档能拿到本源 DOM 与存储(含登录态)。这里刻意不暴露 sandbox 的完整取值,
+ * 就是为了让这条约束没有被绕过的入口。
+ */
+ allowScripts?: boolean;
+};
+
+/**
+ * 沙箱 iframe —— SVG 与 HTML 节点共用。
+ *
+ * 依赖里没有 DOMPurify,手写消毒不可靠(script / on* / foreignObject 等绕法很多),
+ * 所以两种节点都不走 innerHTML,统一塞进沙箱 iframe 渲染。
+ */
+export function SandboxedFrame({ srcDoc, theme, allowScripts = false }: SandboxedFrameProps) {
+ return (
+