diff --git a/interface/web/package-lock.json b/interface/web/package-lock.json index 3772f87..bb6813a 100644 --- a/interface/web/package-lock.json +++ b/interface/web/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "preact": "^10.29.8", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "sucrase": "^3.35.1" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -528,7 +529,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -550,7 +550,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -560,14 +559,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -994,6 +991,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1134,6 +1137,15 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1444,7 +1456,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -2016,6 +2027,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2069,6 +2086,17 @@ "dev": true, "license": "MIT" }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -2105,6 +2133,15 @@ "node": ">=18" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2199,7 +2236,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2208,6 +2244,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/postcss": { "version": "8.5.21", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz", @@ -2402,6 +2447,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -2415,11 +2482,31 @@ "node": ">=8" } }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -2432,6 +2519,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/interface/web/package.json b/interface/web/package.json index 9845cdd..34d3b01 100644 --- a/interface/web/package.json +++ b/interface/web/package.json @@ -16,7 +16,8 @@ "dependencies": { "preact": "^10.29.8", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "sucrase": "^3.35.1" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/interface/web/src/Markdown.jsx b/interface/web/src/Markdown.jsx index 4aeefd8..296b69d 100644 --- a/interface/web/src/Markdown.jsx +++ b/interface/web/src/Markdown.jsx @@ -262,13 +262,13 @@ const _OFFSET_TOKEN = "__PREVIEW_LINE_OFFSET__"; * event-handler attributes exactly like HTML can, and transformed JSX is just * more script. Every language is contained the same way. */ -function buildSrcDoc(lang, value) { +async function buildSrcDoc(lang, value) { const entry = PREVIEW_LANGS[lang]; if (!entry) return { doc: null, error: `No preview for '${lang}'.` }; let body; try { - body = entry.toBody(value); + body = await entry.toBody(value); } catch (e) { return { doc: null, error: e && e.message ? e.message : String(e) }; } @@ -404,6 +404,8 @@ function RenderBlock({ lang, value, streaming }) { // it by markup - a fresh document then gets fresh state by remounting. function PreviewFrame({ lang, value, expanded }) { const [error, setError] = useState(""); + const [doc, setDoc] = useState(""); + const [buildError, setBuildError] = useState(""); const [height, setHeight] = useState(240); const frameRef = useRef(null); const heightRef = useRef(240); // mirrors `height` so the listener needn't re-subscribe @@ -433,9 +435,20 @@ function PreviewFrame({ lang, value, expanded }) { return () => window.removeEventListener("message", onMessage); }, []); + useEffect(() => { + let current = true; + setDoc(""); + setBuildError(""); + buildSrcDoc(lang, value).then((result) => { + if (!current) return; + setDoc(result.doc || ""); + setBuildError(result.error || ""); + }); + return () => { current = false; }; + }, [lang, value]); + // A build failure (JSX that doesn't parse) has no document to show at all, so // the message stands in for the frame rather than sitting under it. - const { doc, error: buildError } = buildSrcDoc(lang, value); const frameUrl = doc ? `data:text/html;charset=utf-8,${encodeURIComponent(doc)}` : ""; const shown = buildError || error; diff --git a/interface/web/src/preview/jsx-transform.js b/interface/web/src/preview/jsx-transform.js index 8a7570a..d945175 100644 --- a/interface/web/src/preview/jsx-transform.js +++ b/interface/web/src/preview/jsx-transform.js @@ -1,731 +1,58 @@ /* - * jsx-transform.js — JSX/TSX in, plain JS out. No dependencies. + * JSX/TSX compiler adapter. * - * Scope is deliberately the single self-contained component a model writes into - * a chat fence: elements, fragments, attributes, spreads, expression children, - * and the TypeScript annotations that decorate them. It is not a TypeScript - * compiler and does not try to be - the real one costs more bytes than this - * whole app, and nothing here needs to survive input the user didn't ask a - * local model to produce. + * JSX and TypeScript are parsed by Sucrase rather than by preview-specific + * lexer code. The dependency is dynamically imported so ordinary chat and + * HTML/SVG previews do not download the compiler chunk. Only this small adapter + * stays in the main bundle. * - * Failure is loud on purpose. Anything this can't parse throws TransformError, - * which the caller shows in place of the preview; anything it declines to touch - * is passed through, so unsupported TypeScript reaches the browser and surfaces - * as a SyntaxError in the preview's error bar (see Markdown.jsx's bootstrap). - * Both beat quietly emitting code that runs and does the wrong thing. + * Sucrase's CommonJS transform is intentional: a preview frame has no module + * loader or network access, but languages.js can provide local React/Preact + * modules through a tiny `require` shim. Unsupported imports then fail loudly + * at evaluation time with the package name that cannot be loaded. + */ + +export class TransformError extends Error { + constructor(message, options) { + super(message, options); + this.name = "TransformError"; + } +} + +/** + * Find fallback component declarations for model output that omits an export. * - * Pure string -> string, no DOM and no eval, which is what lets it run in the - * parent app instead of inside the sandbox: transforming untrusted text is not - * executing it. Tested headless by jsx-transform.test.js (node --test). + * This is deliberately not syntax transformation. Sucrase owns all parsing; + * these names only form guarded `typeof Name !== "undefined"` mount choices. + * A false match is therefore ignored at runtime. Default exports and App take + * precedence, so this compatibility fallback is used only for a bare component + * such as `function Counter() { ... }`. */ - -export class TransformError extends Error {} - -// Characters after which a value may start, so a `<` opens JSX rather than -// acting as less-than and a `/` starts a regex rather than dividing. -// -// `)` and `]` are deliberately absent: they *end* a value, so `f(x) / 2` is a -// division and `xs[0] < 3` is a comparison. Including them made -// `Math.sin(t) / 6` scan as an unterminated regex. -const EXPR_START = new Set([ - "", "(", ",", "=", ":", ";", "{", "}", "[", "&", "|", "?", "!", - "+", "-", "*", "/", "%", "^", "~", ">", "<", -]); - -// Same idea for keywords: `return
` is JSX, `a in (0)` -// (strip the ) from `a < b > (c)` (arithmetic, leave alone). -const TYPE_ARG_CHARS = /^[\w$\s,.[\]|&<>'"-]*$/; - -function isIdentifier(ch) { - return ch !== undefined && ID_CHAR.test(ch); +function componentCandidates(source) { + const names = []; + const declarations = /\b(?:function|class|const|let|var)\s+([A-Z][$\w]*)/g; + for (const match of source.matchAll(declarations)) { + if (!names.includes(match[1])) names.push(match[1]); + } + return names; } -function countNewlines(text) { - let n = 0; - for (let i = 0; i < text.length; i++) if (text[i] === "\n") n++; - return n; -} +/** Compile a self-contained JSX/TSX component into browser-ready CommonJS. */ +export async function transform(source) { + const input = String(source ?? ""); -/** The newlines `consumed` had that `produced` lost, so line numbers hold. */ -function missingNewlines(consumed, produced) { - return "\n".repeat(Math.max(0, countNewlines(consumed) - countNewlines(produced))); -} - -class Scanner { - constructor(src) { - this.src = src; - this.i = 0; - this.out = []; - this.prevSig = ""; // last significant char emitted - this.prevWord = ""; // last identifier/keyword emitted - this.defaultExport = null; - this.components = []; // capitalized declarations, in source order - this.imports = []; // {names, module} for every import statement dropped - // One frame per unclosed opener. `ch` tells a parameter list from an object - // literal - the difference between stripping a type annotation and eating a - // property's value - and `ternaries` counts `?`s still waiting for their - // `:`, so a conditional's else-branch isn't mistaken for a type either. - this.frames = [{ ch: "", ternaries: 0 }]; - } - - get frame() { - return this.frames[this.frames.length - 1]; - } - - get context() { - return this.frame.ch; - } - - get eof() { - return this.i >= this.src.length; - } - - peek(offset = 0) { - return this.src[this.i + offset]; - } - - emit(text) { - if (!text) return; - this.out.push(text); - const trimmed = text.trimEnd(); - if (trimmed) this.prevSig = trimmed[trimmed.length - 1]; - } - - fail(message) { - const line = this.src.slice(0, this.i).split("\n").length; - throw new TransformError(`${message} (line ${line})`); + try { + const { transform: compile } = await import("sucrase"); + const { code } = compile(input, { + transforms: ["typescript", "jsx", "imports"], + jsxPragma: "h", + jsxFragmentPragma: "Fragment", + production: true, + filePath: "preview.tsx", + }); + return { code, components: componentCandidates(input) }; + } catch (error) { + const detail = error && error.message ? error.message : String(error); + throw new TransformError(`Could not compile JSX/TSX: ${detail}`, { cause: error }); } } - -/** Transform a JSX/TSX source string into runnable JS. */ -export function transform(source) { - const sc = new Scanner(String(source ?? "")); - scanCode(sc, null); - return { - code: sc.out.join(""), - defaultExport: sc.defaultExport, - components: sc.components, - imports: sc.imports, - }; -} - -/** - * Scan code until `stop` is reached at depth zero (or end of input). Used for - * the whole program (stop = null) and for the interiors of `${...}` and JSX - * `{...}`, both of which may contain arbitrary code including more JSX. - */ -function scanCode(sc, stop) { - let depth = 0; - - while (!sc.eof) { - const ch = sc.peek(); - - if (stop && depth === 0 && ch === stop) return; - if (ch === "{" || ch === "(" || ch === "[") { depth++; sc.frames.push({ ch, ternaries: 0 }); } - if (ch === "}" || ch === ")" || ch === "]") { depth--; if (sc.frames.length > 1) sc.frames.pop(); } - // `?` opens a conditional whose `:` is not a type annotation. `?.` and `??` - // are their own operators, and a trailing `?` marks an optional parameter. - if (ch === "?" && sc.peek(1) !== "." && sc.peek(1) !== "?" && !/\s*:/.test(sc.src.slice(sc.i + 1, sc.i + 3))) { - sc.frame.ternaries++; - } - - // --- things copied through verbatim --- - if (ch === "/" && sc.peek(1) === "/") { copyLineComment(sc); continue; } - if (ch === "/" && sc.peek(1) === "*") { copyBlockComment(sc); continue; } - if (ch === '"' || ch === "'") { copyString(sc, ch); continue; } - if (ch === "`") { copyTemplate(sc); continue; } - if (ch === "/" && regexAllowed(sc)) { copyRegex(sc); continue; } - - // --- JSX --- - if (ch === "<" && jsxAllowed(sc)) { - const from = sc.i; - const call = parseElement(sc); - // A multi-line element becomes a single-line h() call, which would shift - // every line after it. The preview reports runtime errors by line number - // and the user reads those against the original in the Code tab, so the - // difference is padded back. - sc.emit(call + missingNewlines(sc.src.slice(from, sc.i), call)); - continue; - } - - // --- identifiers and keywords (where TS and module syntax live) --- - if (ID_START.test(ch)) { - const word = readWord(sc); - if (handleWord(sc, word)) continue; - sc.emit(word); - sc.prevWord = word; - continue; - } - - // --- TypeScript punctuation --- - if (ch === ":" && annotationAhead(sc)) { skipTypeAnnotation(sc); continue; } - if (ch === "!" && nonNullAssertion(sc)) { sc.i++; continue; } - - if (!/\s/.test(ch)) sc.prevWord = ""; - sc.emit(ch); - sc.i++; - } - - if (stop) sc.fail(`unterminated block, expected '${stop}'`); -} - -// ── verbatim copiers ──────────────────────────────────────────────────────── - -function copyLineComment(sc) { - const end = sc.src.indexOf("\n", sc.i); - const stop = end === -1 ? sc.src.length : end; - sc.out.push(sc.src.slice(sc.i, stop)); - sc.i = stop; -} - -function copyBlockComment(sc) { - const end = sc.src.indexOf("*/", sc.i + 2); - if (end === -1) sc.fail("unterminated block comment"); - sc.out.push(sc.src.slice(sc.i, end + 2)); - sc.i = end + 2; -} - -function copyString(sc, quote) { - const start = sc.i; - sc.i++; - while (!sc.eof) { - const ch = sc.peek(); - if (ch === "\\") { sc.i += 2; continue; } - if (ch === quote) { sc.i++; sc.emit(sc.src.slice(start, sc.i)); return; } - if (ch === "\n") break; - sc.i++; - } - sc.fail("unterminated string"); -} - -function copyTemplate(sc) { - sc.emit("`"); - sc.i++; - while (!sc.eof) { - const ch = sc.peek(); - if (ch === "\\") { sc.out.push(sc.src.slice(sc.i, sc.i + 2)); sc.i += 2; continue; } - if (ch === "`") { sc.emit("`"); sc.i++; return; } - // `${...}` can hold anything, JSX included - hand it back to the scanner. - if (ch === "$" && sc.peek(1) === "{") { - sc.emit("${"); - sc.i += 2; - const saved = sc.prevSig; - sc.prevSig = ""; - scanCode(sc, "}"); - sc.prevSig = saved; - sc.emit("}"); - sc.i++; - continue; - } - sc.out.push(ch); - sc.i++; - } - sc.fail("unterminated template literal"); -} - -function copyRegex(sc) { - const start = sc.i; - sc.i++; - let inClass = false; - while (!sc.eof) { - const ch = sc.peek(); - if (ch === "\\") { sc.i += 2; continue; } - if (ch === "[") inClass = true; - else if (ch === "]") inClass = false; - else if (ch === "/" && !inClass) { - sc.i++; - while (isIdentifier(sc.peek())) sc.i++; // flags - sc.emit(sc.src.slice(start, sc.i)); - return; - } else if (ch === "\n") break; - sc.i++; - } - sc.fail("unterminated regular expression"); -} - -/** A `/` starts a regex only where a value may start. */ -function regexAllowed(sc) { - if (sc.peek(1) === "=") return false; - if (EXPR_KEYWORDS.has(sc.prevWord)) return true; - if (sc.prevWord) return false; - return sc.prevSig === "" || EXPR_START.has(sc.prevSig); -} - -/** A `<` opens JSX only where a value may start, and only before a tag name. */ -function jsxAllowed(sc) { - const next = sc.peek(1); - if (next !== ">" && !ID_START.test(next ?? "")) return false; - if (EXPR_KEYWORDS.has(sc.prevWord)) return true; - if (sc.prevWord) return false; // identifier before `<` means comparison or generics - return EXPR_START.has(sc.prevSig); -} - -// ── identifiers, modules, TypeScript declarations ─────────────────────────── - -function readWord(sc) { - const start = sc.i; - while (isIdentifier(sc.peek())) sc.i++; - return sc.src.slice(start, sc.i); -} - -function skipSpace(sc) { - while (!sc.eof && /\s/.test(sc.peek())) sc.i++; -} - -/** True when the word was consumed here and needs no further emitting. */ -function handleWord(sc, word) { - const atStatement = sc.prevSig === "" || sc.prevSig === ";" || sc.prevSig === "}"; - - // Note capitalized declarations as they go past: with no default export and - // nothing named App, the caller mounts the last one declared. - if (word === "function" || word === "class" || word === "const" || word === "let" || word === "var") { - const named = sc.src.slice(sc.i).match(/^\s*\*?\s*([A-Z][\w$]*)/); - if (named && !sc.components.includes(named[1])) sc.components.push(named[1]); - } - - // Nothing can be imported into the sandbox - there is no module loader and - // no network. Hooks arrive as globals instead, so `import {useState} from - // "react"` is dropped rather than rewritten. - if (word === "import" && atStatement) { - if (sc.peek() === "(" || sc.peek() === ".") { sc.emit(word); return true; } // import()/import.meta - const from = sc.i; - skipImportStatement(sc); - recordImport(sc, sc.src.slice(from, sc.i)); - return true; - } - - if (word === "export" && atStatement) { - skipSpace(sc); - if (sc.src.startsWith("default", sc.i) && !isIdentifier(sc.peek(7))) { - sc.i += 7; - skipSpace(sc); - // `export default App;` names a component; `export default function App()` - // declares one. Either way the mount target is what follows. - const rest = sc.src.slice(sc.i); - const named = rest.match(/^(?:function\s*\*?\s*|class\s+)?([A-Za-z_$][\w$]*)/); - if (named) sc.defaultExport = named[1]; - if (/^[A-Za-z_$][\w$]*\s*;?\s*$/.test(rest)) { sc.i = sc.src.length; return true; } - } - sc.prevWord = ""; - return true; // `export ` itself is dropped either way - } - - // `interface X {...}` / `type X = ...` are types entire - drop the statement. - if ((word === "interface" || word === "type") && atStatement && typeDeclarationAhead(sc)) { - if (word === "interface") skipBalancedBraces(sc); - else skipStatement(sc); - return true; - } - - // `x as Foo` / `x satisfies Foo` - drop the cast, keep the value. - if ((word === "as" || word === "satisfies") && sc.prevSig && sc.prevSig !== "=") { - skipSpace(sc); - if (sc.peek() === "c" && sc.src.startsWith("const", sc.i)) sc.i += 5; - else skipTypeExpression(sc); - sc.prevWord = ""; - return true; - } - - // `useState(0)` - a generic argument list, not a comparison. - const save = sc.i; - skipSpace(sc); - if (sc.peek() === "<") { - const end = matchTypeArgs(sc, sc.i); - if (end !== -1) { - sc.emit(word); - sc.prevWord = word; - sc.i = end; - return true; - } - } - sc.i = save; - return false; -} - -/** - * Note the bindings an import would have introduced, so the caller can say - * where a missing name was supposed to come from. - * - * Without this, dropping `import { useInView } from 'react-infinite-scroll'` - * turns into "useInView is not defined" at the first *use* — a line nowhere - * near the import, describing a symptom rather than the cause. Parsing is - * deliberately forgiving: models write malformed imports (a missing brace was - * what prompted this), and a half-read name is still worth naming. - */ -function recordImport(sc, statement) { - const quoted = statement.match(/['"]([^'"]+)['"]\s*;?\s*$/); - const module = quoted ? quoted[1] : ""; - const clause = statement - .replace(/^\s*import\s*/, "") - .replace(/['"][^'"]*['"]\s*;?\s*$/, "") - .replace(/\bfrom\b/, ""); - const names = clause - .replace(/[{}]/g, " ") - .split(",") - .map((part) => part.trim().replace(/^\*\s*as\s+/, "").split(/\s+as\s+/).pop().trim()) - .filter((name) => /^[A-Za-z_$][\w$]*$/.test(name)); - if (module || names.length) sc.imports.push({ names, module }); -} - -function typeDeclarationAhead(sc) { - return /^\s+[A-Za-z_$][\w$]*\s*[<={]/.test(sc.src.slice(sc.i)); -} - -/** - * Skip to the end of the current statement (semicolon or line end). Nothing is - * emitted, so the scanner's idea of the last significant character has to be - * restored - otherwise a dropped `import ... from "react";` leaves a quote - * behind as `prevSig` and the next statement no longer looks like one. - */ -function skipStatement(sc) { - const sig = sc.prevSig; - const word = sc.prevWord; - while (!sc.eof) { - const ch = sc.peek(); - if (ch === ";") { sc.i++; break; } - if (ch === "\n") break; - if (ch === '"' || ch === "'") { const o = sc.out.length; copyString(sc, ch); sc.out.length = o; continue; } - sc.i++; - } - sc.prevSig = sig; - sc.prevWord = word; -} - -/** - * Drop a static import, including the common multiline named-import form. - * A bare newline before the module string is part of the import; one after it - * ends a semicolon-free import. Semicolons always win so a malformed import - * (for example a missing `}`) cannot swallow the rest of the component. - */ -function skipImportStatement(sc) { - const from = sc.i; - const sig = sc.prevSig; - const word = sc.prevWord; - let sawModule = false; - - while (!sc.eof) { - const ch = sc.peek(); - if (ch === ";") { sc.i++; break; } - if (ch === "\n" && sawModule) break; - - if (ch === '"' || ch === "'") { - const outLength = sc.out.length; - copyString(sc, ch); - sc.out.length = outLength; - sawModule = true; - continue; - } - if (ch === "/" && sc.peek(1) === "/") { - const outLength = sc.out.length; - copyLineComment(sc); - sc.out.length = outLength; - continue; - } - if (ch === "/" && sc.peek(1) === "*") { - const outLength = sc.out.length; - copyBlockComment(sc); - sc.out.length = outLength; - continue; - } - sc.i++; - } - - // Keep runtime error line numbers aligned with the source shown in Code. - sc.out.push(missingNewlines(sc.src.slice(from, sc.i), "")); - sc.prevSig = sig; - sc.prevWord = word; -} - -function skipBalancedBraces(sc) { - const from = sc.i; - const sig = sc.prevSig; - const word = sc.prevWord; - while (!sc.eof && sc.peek() !== "{") sc.i++; - let depth = 0; - while (!sc.eof) { - const ch = sc.peek(); - if (ch === "{") depth++; - else if (ch === "}") { - depth--; - sc.i++; - if (depth === 0) { - // A multi-line `interface {...}` would otherwise shift the code below it. - sc.out.push(missingNewlines(sc.src.slice(from, sc.i), "")); - sc.prevSig = sig; - sc.prevWord = word; - return; - } - continue; - } - sc.i++; - } - sc.fail("unterminated type declaration"); -} - -/** - * Find the `>` closing a generic argument list starting at `from`, or -1 when - * the contents don't look like types at all (then it was a comparison). - */ -function matchTypeArgs(sc, from) { - let depth = 0; - for (let j = from; j < sc.src.length; j++) { - const ch = sc.src[j]; - if (ch === "<") depth++; - else if (ch === ">") { - depth--; - if (depth === 0) { - const inner = sc.src.slice(from + 1, j); - if (!TYPE_ARG_CHARS.test(inner)) return -1; - // Only a call or a value position may follow a real generic list. - const after = sc.src.slice(j + 1).match(/^\s*(.)/); - return after && "(;,)]}=".includes(after[1]) ? j + 1 : -1; - } - } else if (ch === "\n" || ch === "{" || ch === ";") return -1; - } - return -1; -} - -// ── TypeScript annotations ────────────────────────────────────────────────── - -/** - * True when this `:` introduces a type annotation rather than an object-literal - * key, a ternary branch, or a label. Only the positions a small component - * actually uses are recognised; everything else is left alone deliberately. - */ -function annotationAhead(sc) { - // An unresolved `?` claims this `:` for its conditional first. - if (sc.frame.ternaries > 0) { sc.frame.ternaries--; return false; } - - const before = sc.src.slice(0, sc.i); - - // `const x: T` and a return type `): T` read the same anywhere. - if (/(?:\b(?:const|let|var)\s+[A-Za-z_$][\w$]*|\))\s*$/.test(before)) return true; - - // The rest are parameter annotations, and only count inside a parameter - // list. The same shapes inside braces are object literals, where the value - // after `:` must survive. - if (sc.context !== "(") return false; - - // `(a: T`, `, b: T`, `(...rest: T` - if (/[(,]\s*(?:\.\.\.)?[A-Za-z_$][\w$]*\??\s*$/.test(before)) return true; - // `({ a, b }: Props`, `([x, y]: T` - a destructured parameter - return /[}\]]\s*\??\s*$/.test(before); -} - -function skipTypeAnnotation(sc) { - sc.i++; // the ':' - skipTypeExpression(sc); - sc.prevWord = ""; -} - -/** Consume a type expression, stopping where the surrounding value resumes. */ -function skipTypeExpression(sc) { - let depth = 0; - while (!sc.eof) { - const ch = sc.peek(); - if ("({[<".includes(ch)) depth++; - else if (")}]>".includes(ch)) { - if (depth === 0) return; // the enclosing paren/brace, not ours - depth--; - } else if (depth === 0) { - if (ch === "," || ch === ";" || ch === "\n") return; - if (ch === "=" && sc.peek(1) !== ">") return; - if (ch === "{") return; - if (ch === "=" && sc.peek(1) === ">") return; - } - sc.i++; - } -} - -function nonNullAssertion(sc) { - const next = sc.peek(1); - if (next === "=" ) return false; // `!=` - return /[\w$)\]]/.test(sc.src[sc.i - 1] ?? ""); // `foo!.bar`, `arr[0]!` -} - -// ── JSX ───────────────────────────────────────────────────────────────────── - -/** Parse one JSX element (cursor on `<`) and return the equivalent h() call. */ -function parseElement(sc) { - sc.i++; // '<' - - if (sc.peek() === ">") { // fragment - sc.i++; - const kids = parseChildren(sc, ""); - return `h(Fragment,null${kids})`; - } - - const name = readTagName(sc); - if (!name) sc.fail("expected a JSX tag name after '<'"); - - const props = parseAttributes(sc); - const tag = /^[a-z][\w-]*$/.test(name) ? JSON.stringify(name) : name; - - if (sc.peek() === "/") { // self-closing - sc.i++; - if (sc.peek() !== ">") sc.fail(`expected '>' to close <${name} />`); - sc.i++; - return `h(${tag},${props})`; - } - - if (sc.peek() !== ">") sc.fail(`expected '>' to close <${name}>`); - sc.i++; - const kids = parseChildren(sc, name); - return `h(${tag},${props}${kids})`; -} - -function readTagName(sc) { - if (!ID_START.test(sc.peek() ?? "")) return ""; - const start = sc.i; - while (!sc.eof && /[\w$.-]/.test(sc.peek())) sc.i++; - return sc.src.slice(start, sc.i); -} - -function parseAttributes(sc) { - const parts = []; - - for (;;) { - skipSpace(sc); - const ch = sc.peek(); - if (ch === undefined) sc.fail("unterminated JSX tag"); - if (ch === ">" || ch === "/") break; - - if (ch === "{") { // {...spread} - sc.i++; - skipSpace(sc); - if (!sc.src.startsWith("...", sc.i)) sc.fail("expected '...' in a JSX attribute spread"); - sc.i += 3; - parts.push(`...${captureExpression(sc, "}")}`); - continue; - } - - const name = readAttrName(sc); - if (!name) sc.fail("expected a JSX attribute name"); - const key = /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name); - - skipSpace(sc); - if (sc.peek() !== "=") { parts.push(`${key}:true`); continue; } - sc.i++; - skipSpace(sc); - - const valueCh = sc.peek(); - if (valueCh === '"' || valueCh === "'") { - parts.push(`${key}:${JSON.stringify(readQuoted(sc, valueCh))}`); - } else if (valueCh === "{") { - sc.i++; - parts.push(`${key}:${captureExpression(sc, "}")}`); - } else if (valueCh === "<") { - parts.push(`${key}:${parseElement(sc)}`); - } else { - sc.fail(`unsupported value for JSX attribute '${name}'`); - } - } - - return parts.length ? `{${parts.join(",")}}` : "null"; -} - -function readAttrName(sc) { - const start = sc.i; - while (!sc.eof && /[\w$:.-]/.test(sc.peek())) sc.i++; - return sc.src.slice(start, sc.i); -} - -function readQuoted(sc, quote) { - sc.i++; - const start = sc.i; - while (!sc.eof && sc.peek() !== quote) sc.i++; - if (sc.eof) sc.fail("unterminated JSX attribute value"); - const text = sc.src.slice(start, sc.i); - sc.i++; - return text; -} - -/** - * Capture a braced expression as source, running it through the scanner so - * nested JSX inside it is transformed too. Leaves the cursor past `close`. - */ -function captureExpression(sc, close) { - const inner = new Scanner(sc.src); - inner.i = sc.i; - scanCode(inner, close); - if (inner.eof) sc.fail("unterminated JSX expression"); - sc.i = inner.i + 1; - const code = inner.out.join("").trim(); - return code === "" ? "undefined" : `(${code})`; -} - -function parseChildren(sc, tagName) { - const kids = []; - - for (;;) { - if (sc.eof) sc.fail(tagName ? `unclosed <${tagName}>` : "unclosed fragment"); - - // closing tag? - if (sc.peek() === "<" && sc.peek(1) === "/") { - sc.i += 2; - const closing = readTagName(sc); - skipSpace(sc); - if (sc.peek() !== ">") sc.fail(`expected '>' to close `); - sc.i++; - if (closing !== tagName) { - sc.fail(` does not match <${tagName || ""}>`); - } - break; - } - - if (sc.peek() === "<") { kids.push(parseElement(sc)); continue; } - - if (sc.peek() === "{") { - sc.i++; - skipSpace(sc); - // `{/* note */}` is a JSX comment - it renders nothing. - if (sc.peek() === "/" && sc.peek(1) === "*") { - const end = sc.src.indexOf("*/", sc.i); - if (end === -1) sc.fail("unterminated JSX comment"); - sc.i = end + 2; - skipSpace(sc); - if (sc.peek() !== "}") sc.fail("expected '}' after a JSX comment"); - sc.i++; - continue; - } - const expr = captureExpression(sc, "}"); - if (expr !== "undefined") kids.push(expr); - continue; - } - - const text = readText(sc); - if (text !== null) kids.push(JSON.stringify(text)); - } - - return kids.length ? `,${kids.join(",")}` : ""; -} - -/** - * JSX text, with JSX's whitespace rule: runs of whitespace containing a newline - * are layout, not content, so they collapse away at the edges and become a - * single space in the middle. - */ -function readText(sc) { - const start = sc.i; - while (!sc.eof && sc.peek() !== "<" && sc.peek() !== "{") sc.i++; - const raw = sc.src.slice(start, sc.i); - if (raw === "") return null; - - const lines = raw.split("\n"); - if (lines.length === 1) return raw; - - const kept = lines - .map((line, idx) => (idx === 0 ? line.replace(/\s+$/, "") : line.trim())) - .filter((line) => line !== ""); - const text = kept.join(" "); - return text === "" ? null : text; -} diff --git a/interface/web/src/preview/jsx-transform.test.js b/interface/web/src/preview/jsx-transform.test.js index 287dddf..39aa67b 100644 --- a/interface/web/src/preview/jsx-transform.test.js +++ b/interface/web/src/preview/jsx-transform.test.js @@ -1,265 +1,147 @@ -/* - * node --test src/preview/jsx-transform.test.js - * - * The transform runs on model-authored text, so these cases are the shapes a - * model actually emits, plus the ones where a naive scanner silently produces - * code that runs and does the wrong thing (generics read as comparisons, `<` in - * a string read as a tag). Silent-wrong is the failure mode worth testing; - * anything that throws is already visible to the user. - */ import { test } from "node:test"; import assert from "node:assert/strict"; import { transform, TransformError } from "./jsx-transform.js"; -// Whitespace is normalised for comparison: the transform drops the space a -// stripped annotation sat in (`const x: T = 1` -> `const x= 1`), which is -// invisible to everyone because the Code tab shows the original source, not -// this output. -const js = (src) => transform(src).code.replace(/\s+/g, " ").trim(); +async function compile(source) { + return transform(source); +} -// ── elements ──────────────────────────────────────────────────────────────── +function assertRunnable(code) { + assert.doesNotThrow(() => new Function( + "module", "exports", "require", "h", "Fragment", code, + )); +} -test("element with no attributes or children", () => { - assert.equal(js("const a =
;"), 'const a = h("div",null);'); +test("compiles elements, attributes, spreads, children, and fragments", async () => { + const { code } = await compile(` + const view = <> +
+ +
+ ; + `); + assertRunnable(code); + assert.match(code, /h\(Fragment/); + assert.match(code, /h\('section'/); + assert.doesNotMatch(code, /
{ - assert.equal(js("
"), 'h("div",null)'); - assert.equal(js(""), "h(App,null)"); - assert.equal(js(""), "h(Foo.Bar,null)"); +test("compiles nested JSX inside expression children", async () => { + const { code } = await compile( + "const view =
    {items.map((item) =>
  • {item.name}
  • )}
;", + ); + assertRunnable(code); + assert.match(code, /items\.map/); + assert.doesNotMatch(code, /
  • { - assert.equal(js(''), 'h("a",{href:"/x"})'); - assert.equal(js(""), 'h("a",{n:(1 + 2)})'); - assert.equal(js(""), 'h("input",{disabled:true})'); - assert.equal(js(''), 'h("a",{"data-id":"7"})'); +test("does not confuse comparisons with JSX", async () => { + const { code } = await compile( + "if (xs[0] < 3 && f(i) < n) { const less = a < b; }", + ); + assertRunnable(code); + assert.match(code, /xs\[0\] < 3/); + assert.match(code, /a < b/); }); -test("attribute spread", () => { - assert.equal(js("
    "), 'h("div",{...(props),id:"x"})'); +test("does not confuse division with a regular expression", async () => { + const { code } = await compile( + "const y = Math.sin((i + s) / 6) * 70; const m = xs[0] / total;", + ); + assertRunnable(code); + assert.match(code, /\(i \+ s\) \/ 6/); + assert.match(code, /xs\[0\] \/ total/); }); -test("children: text, expressions and nesting", () => { - assert.equal(js("

    hi

    "), 'h("p",null,"hi")'); - assert.equal(js("

    {name}

    "), 'h("p",null,(name))'); - assert.equal(js("

    a {b} c

    "), 'h("p",null,"a ",(b)," c")'); - assert.equal(js("
    • x
    "), 'h("ul",null,h("li",null,"x"))'); +test("preserves angle brackets and slashes in literals", async () => { + const { code } = await compile( + 'const s = "
    not jsx
    "; const t = `a c`; const r = /<[a-z]+>/g;', + ); + assertRunnable(code); + assert.match(code, /not jsx/); + assert.match(code, /\/<\[a-z\]\+>\/g/); }); -test("fragments", () => { - assert.equal(js("<>
    "), 'h(Fragment,null,h("a",null),h("b",null))'); +test("strips TypeScript annotations, declarations, generics, and assertions", async () => { + const { code } = await compile(` + interface Props { start: number } + type Pair = [number, number]; + function f({ start }: Props, pair: Pair): number { + const ref = useRef(null); + return (pair[0] as number) + ref.current!.width + start; + } + `); + assertRunnable(code); + assert.doesNotMatch(code, /interface Props|type Pair|: Props|HTMLCanvasElement|as number|current!/); }); -test("JSX nested inside an expression child", () => { - assert.equal( - js("
      {items.map((i) =>
    • {i}
    • )}
    "), - 'h("ul",null,(items.map((i) => h("li",{key:(i)},(i)))))', +test("keeps object literals, destructuring, and ternaries intact", async () => { + const { code } = await compile( + "const f = ({a, b}: Props) => ok ? {value: a} : {value: b};", + ); + assertRunnable(code); + assert.match(code, /ok \? \{value: a\} : \{value: b\}/); +}); + +test("handles TSX generic arrow functions without treating them as elements", async () => { + const { code } = await compile( + "const identity = (value: T): T => value; const view =

    {identity(3)}

    ;", + ); + assertRunnable(code); + assert.match(code, /identity = \s*\(value\) => value/); +}); + +test("converts imports and exports to CommonJS for the frame shim", async () => { + const { code } = await compile(` + import React, { useState } from "react"; + export default function App() { const [n] = useState(0); return

    {n}

    ; } + `); + assertRunnable(code); + assert.match(code, /require\(['"]react['"]\)/); + assert.match(code, /exports\.default = App/); + assert.doesNotMatch(code, /export default|

    /); +}); + +test("keeps unsupported package names in generated require calls", async () => { + const { code } = await compile( + 'import { motion } from "framer-motion"; export default () => ;', + ); + assert.match(code, /require\(['"]framer-motion['"]\)/); +}); + +test("records fallback component declarations without choosing a mount target", async () => { + const result = await compile(` + function Helper() { return null; } + const Counter = () => ; + `); + assert.deepEqual(result.components, ["Helper", "Counter"]); +}); + +test("compiles a realistic stateful component end to end", async () => { + const result = await compile(` + import { useState } from "react"; + interface Props { start: number } + export default function Counter({ start }: Props) { + const [n, setN] = useState(start); + return ; + } + `); + assertRunnable(result.code); + assert.match(result.code, /function Counter\(\{ start \}\)/); + assert.match(result.code, /useState\(start\)/); + assert.doesNotMatch(result.code, /interface|: Props|| - {n} clicks - {n > 3 && many!} -

    - ); -}`; - const out = transform(src); - assert.equal(out.defaultExport, "Counter"); - assert.match(out.code, /function Counter\(\{ start \}\)/); - assert.match(out.code, /useState\(start\)/); - assert.match(out.code, /h\("button",\{onClick:\(\(\) => setN\(n \+ 1\)\)\},"\+1"\)/); - assert.doesNotMatch(out.code, /interface|import|: Props|/); - // The real proof: it parses as JS. - assert.doesNotThrow(() => new Function(out.code)); -}); - -test("output of every element case parses as JS", () => { - for (const src of [ - "
    ", - "link", - "<>

    a

    b

    ", - "const v =
      {xs.map((x) =>
    • {x}
    • )}
    ;", - "const v = f(`${x}`)} />;", - ]) { - const { code } = transform(src); - assert.doesNotThrow(() => new Function("h", "Fragment", "xs", "p", "x", "f", code), src); - } -}); diff --git a/interface/web/src/preview/languages.js b/interface/web/src/preview/languages.js index ce0cdd0..7cc4baa 100644 --- a/interface/web/src/preview/languages.js +++ b/interface/web/src/preview/languages.js @@ -3,7 +3,7 @@ * * Each entry turns a fence's contents into the of the sandboxed frame: * - * toBody(value) -> { html, userOffset } + * await toBody(value) -> { html, userOffset } * * `userOffset` is how many lines of that body come before the user's own code. * The frame reports runtime errors by line number and those numbers are @@ -16,9 +16,8 @@ * caller catches and shows the message in place of the frame. * * The backend keeps a matching registry (PREVIEW_LANGS in synapse/tools.py) - * that says how each language is *validated* rather than rendered. Neither - * depends on the other at runtime; tests/test_tools.py asserts the key sets - * stay equal. + * for tool descriptions and language tags. Neither depends on the other at + * runtime; tests/test_tools.py asserts the key sets stay equal. */ import { transform } from "./jsx-transform.js"; import { PREACT_RUNTIME } from "./runtime.js"; @@ -29,61 +28,49 @@ const countNewlines = (text) => (text.match(/\n/g) || []).length; const markup = (value) => ({ html: value, userOffset: 0 }); /** - * Pick what to mount. An explicit default export wins, then a component named - * App, then the last capitalized declaration - models tend to define helpers - * first and the thing they were asked for last. + * Build the mount expression. An explicit default export wins, then a component + * named App, then the last capitalized declaration - models tend to define + * helpers first and the thing they were asked for last. */ -function mountTarget({ defaultExport, components }) { - if (defaultExport) return defaultExport; - if (components.includes("App")) return "App"; - if (components.length) return components[components.length - 1]; - throw new Error( - "No component found to render. Name one `App`, or `export default` it.", +function mountExpression(components) { + const names = ["App", ...components.slice().reverse()] + .filter((name, index, all) => all.indexOf(name) === index); + const lexical = names.map( + (name) => `(typeof ${name} !== "undefined" ? ${name} : null)`, ); + return [ + "module.exports.default", + "module.exports.App", + ...lexical, + "Object.values(module.exports).find((value) => typeof value === 'function')", + ].join(" || "); } -/** - * One line of stubs for every binding an import would have provided. - * - * Imports are dropped — there is no module loader in the sandbox — so a name - * that came from a package is simply missing, and the first use of it reports - * "useInView is not defined" at a line far from the import that explains it. - * Each stub throws with the module name instead, and `||` means anything the - * runtime already provides (useState and friends) keeps its real implementation. - */ -function importStubs(imports) { - const names = new Map(); - for (const { names: bound, module } of imports || []) { - for (const name of bound) if (!names.has(name)) names.set(name, module); - } - if (!names.size) return ""; - const lines = [...names].map(([name, module]) => { - const why = JSON.stringify( - `${name} came from ${module ? `"${module}"` : "an import"}, which the preview ` + - "cannot load — it has no module loader and no network. Inline what you need, " + - "or use the built-in hooks, which are already in scope.", - ); - return `window[${JSON.stringify(name)}] = window[${JSON.stringify(name)}] ` + - `|| function () { throw new Error(${why}); };`; - }); - return `\n`; -} - -function jsxBody(value) { - const result = transform(value); - const target = mountTarget(result); +async function jsxBody(value) { + const result = await transform(value); + const target = mountExpression(result.components); const head = '
    \n' + `\n` + - importStubs(result.imports) + - "", userOffset: countNewlines(head), }; diff --git a/synapse/chat.py b/synapse/chat.py index 5abe2fa..7ebb23f 100644 --- a/synapse/chat.py +++ b/synapse/chat.py @@ -139,7 +139,6 @@ async def _normalize_to_async_generator(maybe_iterable) -> AsyncGenerator[str, N pending_approvals: Dict[str, Dict[str, Any]] = {} _APPROVAL_TIMEOUT = 300 # seconds; a timeout is treated as "deny all" - def _as_tool_calls(obj) -> list: """Normalize a parsed JSON value into Ollama-style tool_calls entries.""" if isinstance(obj, list): @@ -208,27 +207,6 @@ def _coerce_tool_calls(msg: dict, allowed_names: set[str] | None = None) -> list return [] -_VISUAL_HINTS = _tools._RENDER_HINTS - - -def _render_nudge_text() -> str: - """The one retry given to a model that ignored render_preview on a visual ask. - - It arrives as a *user* turn, which means the model answers whatever it says. - Earlier wording pointed at "THIS user request" — a thing the model cannot - see — and offered "if the user's term is unclear, ask them to clarify". It - took both: two transcripts answered with "please provide the user's request - for the rendering" and nothing else. So this says only what to do next, with - no dangling reference and no escape hatch, and it names the languages the - render window actually supports rather than a hardcoded pair.""" - return ( - f"Use the render_preview tool now. Send complete {_tools._lang_prose()} " - f"markup drawn on a {_tools._STAGE_W}x{_tools._STAGE_H} stage, with the " - "values computed into an array and plotted point by point. Do not write " - "a ``` fence yourself." - ) - - def _strip_internal_turns(messages: list) -> list: """Flatten tool-loop messages for the final, tool-free streaming turn. @@ -236,15 +214,13 @@ def _strip_internal_turns(messages: list) -> list: the tools schema isn't re-sent. Their content must not go with them, though: search/memory/document results are the reason the loop ran. Preserve those results as an explicitly untrusted user-context turn immediately before the - real request, while dropping assistant tool-call envelopes and the synthetic - render nudge. Keeping the real request last also prevents the model from - answering the nudge or treating a tool result as the user's question.""" - nudge = _render_nudge_text() + real request, while dropping assistant tool-call envelopes. Keeping the real + request last prevents the model from treating a tool result as the user's + question.""" kept = [ m for m in messages if m.get("role") != "tool" and not m.get("tool_calls") - and m.get("content") != nudge ] results = [ str(m.get("content") or "") @@ -270,35 +246,6 @@ def _strip_internal_turns(messages: list) -> list: return kept -def _should_nudge_render(messages: list, tool_schemas: list | None) -> bool: - """True when render_preview is available, unused, and the user asked for a visual.""" - names = { - (s.get("function") or {}).get("name") - for s in (tool_schemas or []) - if isinstance(s, dict) - } - if "render_preview" not in names: - return False - for m in messages: - if m.get("role") == "assistant": - for c in (m.get("tool_calls") or []): - if (c.get("function") or {}).get("name") == "render_preview": - return False - if m.get("role") == "tool": - try: - body = _json.loads(m.get("content") or "") - if isinstance(body, dict) and ("fence" in body or "issues" in body): - return False - except Exception: - pass - user = "" - for m in reversed(messages): - if m.get("role") == "user": - user = (m.get("content") or "").lower() - break - return _tools.wants_render_preview(user) - - async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, num_gpu, conversation_id="", policy="allow"): """Let the model call tools before the final streamed answer. @@ -317,8 +264,6 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu # Let the UI show activity immediately — the first tool-turn is a full # non-stream generation and can sit silent for a long time otherwise. yield "__status__tools" - nudged_render = False - render_rejects = 0 allowed_names = { (schema.get("function") or {}).get("name") for schema in (tool_schemas or []) @@ -333,11 +278,6 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu break # None/error or no tool support -> fall back to plain stream calls = _coerce_tool_calls(msg, allowed_names) if not calls: - # One retry: small models often skip render_preview on visual asks. - if not nudged_render and _should_nudge_render(messages, tool_schemas): - nudged_render = True - messages.append({"role": "user", "content": _render_nudge_text()}) - continue break # Normalize content-JSON tool calls into the shape later turns expect. if not msg.get("tool_calls"): @@ -380,50 +320,19 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu continue yield f"__status__{name}" call_args = fn.get("arguments") - # Tell render_preview how many times it has already turned this - # model away. It withholds its scaffold on a first rejection - a - # complete, styled demo handed to a struggling model gets pasted - # rather than adapted, and then persists into the conversation as a - # template for later requests. - if name == "render_preview" and isinstance(call_args, dict): - call_args = {**call_args, "_attempt": render_rejects} result = await _tools.dispatch(name, call_args) messages.append({"role": "tool", "content": result}) - # Cap render_preview reject loops — each retry is another full - # non-stream generation and looks like the UI is "stuck thinking". if name == "render_preview": try: body = _json.loads(result) except Exception: body = {} - if isinstance(body, dict) and body.get("ok") is False: - render_rejects += 1 - if render_rejects >= 2: - stop_after = True - elif isinstance(body, dict) and body.get("ok") is True: + if isinstance(body, dict) and body.get("ok") is True: # Good fence in hand — let the model write the reply next. stop_after = True if stop_after: break - -def _last_ok_render_fence(messages: list) -> tuple[str | None, dict]: - """Return (fence, tool_payload) from the latest successful render_preview.""" - for m in reversed(messages or []): - if m.get("role") != "tool": - continue - try: - body = _json.loads(m.get("content") or "") - except Exception: - continue - if not isinstance(body, dict) or not body.get("ok"): - continue - fence = str(body.get("fence") or "").strip() - if fence.startswith("```"): - return fence, body - return None, {} - - # ------------------------- # Streaming implementation # ------------------------- @@ -471,24 +380,6 @@ async def stream_chat_response( except Exception: _logger.exception("tool loop failed; streaming without tools") - # If render_preview already produced a validated fence, emit it ourselves. - # Small models often "paste" a rewritten, broken copy that never runs in Preview. - forced_fence, render_meta = _last_ok_render_fence(messages) - if forced_fence: - label = render_meta.get("title") or "Interactive preview" - reply = f"{label}:\n\n{forced_fence}\n" - _logger.info( - "stream_chat_response: emitting validated render fence (chars=%d)", - len(forced_fence), - ) - _synapse_trace(f"\n── TURN [{model} | render fence] {'─' * 30}\n") - _synapse_trace(f"USR: {user_message}\n{'─' * 50}\n") - _synapse_trace(reply.replace("\n", " ")[:500] + "\n") - step = 64 - for i in range(0, len(reply), step): - yield reply[i:i + step] - return - messages = _strip_internal_turns(messages) _logger.info("stream_chat_response: starting stream (model=%s, turns=%d, timeout=%s)", model, len(messages), timeout) diff --git a/synapse/main.py b/synapse/main.py index 4c61d29..3bd9147 100644 --- a/synapse/main.py +++ b/synapse/main.py @@ -72,7 +72,7 @@ _MEMORY_PREAMBLE = ( # Static capability hint, appended to every system prompt. The live Preview UI # is frontend-only (Markdown.jsx); the model reaches it by calling the standing -# `render_preview` tool (structured markup in, validated fence out) rather than +# `render_preview` tool (structured markup in, packaged fence out) rather than # freestyling an empty ```html stub. The tool schema carries the detailed # requirements; this preamble just points at it. # See synapse/tools.py: keep this short and imperative for the same reason the diff --git a/synapse/tools.py b/synapse/tools.py index 392f0d0..e1d0851 100644 --- a/synapse/tools.py +++ b/synapse/tools.py @@ -215,473 +215,24 @@ async def _list_files(pattern: str = "", **_) -> str: return json.dumps(sorted(hits)) -# Canvas drawing APIs a real visualization must use — resizing width/height alone -# clears the buffer and draws nothing (a failure mode small models hit often). -_CANVAS_DRAW_APIS = ( - "fillrect", "strokerect", "filltext", "stroketext", "lineto", "arc(", - "beziercurveto", "quadraticcurveto", "fill(", "stroke(", "putimagedata", - "drawimage", "ellips(", -) - -# Rejection threshold for a preview stage. Tiny 40×40 tiles (a recurring -# small-model collapse, often copied from earlier demos) can't show a sequence. -# -# These numbers are a threshold, never advice: every message that mentions a -# size quotes _STAGE_W/_STAGE_H instead. Weak models copy the first dimensions -# they read, so a message saying "at least 320x200, prefer 480x280" reliably -# produces 320x200 — three separate transcripts landed on exactly the minimum, -# including one that had a 480x280 example in front of it. Name one size. -_MIN_CANVAS_W = 320 -_MIN_CANVAS_H = 200 - -# The size to ask for, and the only one any message should mention. -_STAGE_W = 480 -_STAGE_H = 280 - -# Domain-agnostic interactive shell returned on reject as a *pattern* to adapt — -# not a finished demo for any particular algorithm. The model must implement -# generate() for the user's request (or ask them to clarify first). -_INTERACTIVE_SCAFFOLD_HTML = """ - -
    - - - -
    - -""" - - -def _wants_data_visual(purpose: str = "", title: str = "", markup: str = "") -> bool: - """True when the submission claims to be a chart/plot/interactive visual.""" - blob = f"{purpose} {title} {markup}".lower() - return any(k in blob for k in ( - "plot", "chart", "graph", "visual", "sequence", "orbit", "interactive", - "demo", "canvas", "diagram", "animation", "simulate", "conjecture", - )) - - -# Component counterpart to _INTERACTIVE_SCAFFOLD_HTML, handed back when a -# jsx/tsx submission is rejected. Same contract: a pattern to adapt, not a demo -# to paste. No imports — the preview puts hooks and h/render in scope already, -# and there is no module loader in the sandbox to satisfy an import anyway. -_INTERACTIVE_SCAFFOLD_JSX = """export default function App() { - const canvasRef = useRef(null); - const [n, setN] = useState(20); - - /** Return an array of numbers for THIS demo. */ - function generate(count) { - // TODO: implement the user's algorithm / data here. Do not leave empty. - const seq = []; - for (let i = 0; i < count; i++) seq.push(i); // placeholder — replace - return seq; - } - - useEffect(() => { - const canvas = canvasRef.current; - const ctx = canvas.getContext('2d'); - const seq = generate(n); - const max = Math.max(1, ...seq); - const w = canvas.width, h = canvas.height, pad = 16; - ctx.clearRect(0, 0, w, h); - ctx.strokeStyle = '#3b82f6'; - ctx.lineWidth = 2; - ctx.beginPath(); - seq.forEach((v, i) => { - const x = pad + i * ((w - 2 * pad) / Math.max(1, seq.length - 1)); - const y = h - pad - (v / max) * (h - 2 * pad); - if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); - }); - ctx.stroke(); - }, [n]); - - return ( -
    - - -
    - ); -}""" - - -def _wants_chart(purpose: str = "", title: str = "", markup: str = "") -> bool: - """True only when the submission claims to draw *data* — a narrower test - than _wants_data_visual, which also counts "interactive" and "demo". - - That wider net is right for an HTML fence, where an interactive demo with no - canvas is usually a model writing prose and calling it a visualization. It - is wrong for a component fence: a JSX counter or form is interactive through - its own elements and state, and demanding a of it would reject the - most ordinary thing JSX is for.""" - blob = f"{purpose} {title} {markup}".lower() - return any(k in blob for k in ( - "plot", "chart", "graph", "visualiz", "diagram", "sequence", "orbit", - "histogram", "scatter", - )) - - -def _decorative_svg_not_plot(markup: str) -> bool: - """True when SVG is present but doesn't encode a multi-point data chart.""" - import re - lower = markup.lower() - if " holds definitions, not output — nothing in it is drawn unless a - # /fill references it. A long path parked in there was passing as proof - # of a real chart while the preview rendered an empty box. - if "", " ", lower, flags=re.S) - rich_poly = bool(re.search( - r"]*\bpoints\s*=\s*[\"'][^\"']{40,}", lower, - )) - rich_path = bool(re.search( - r"]*\bd\s*=\s*[\"'][^\"']{40,}", lower, - )) - builds_plot = bool( - re.search(r"createelementns\s*\(", lower) - and ("polyline" in lower or "path" in lower or "line" in lower) - and any(k in lower for k in ("foreach", "for (", "for(", "while(", "while (")) - and any(k in lower for k in ("seq", "points", "push(", "data")) - ) - canvas_plot = "getcontext" in lower and any(a in lower for a in _CANVAS_DRAW_APIS) - return not (rich_poly or rich_path or builds_plot or canvas_plot) - - -def _critique_shared(markup: str) -> list[str]: - """Checks that hold for every preview language.""" - import re - issues: list[str] = [] - lower = markup.lower() - - external_attr = re.search( - r"""(?i)\b(?:src|srcset|href|xlink:href|poster|action|formaction|data)\b\s*=\s*(?:['"]\s*)?https?://""", - markup, - ) - external_css = re.search( - r"""(?i)(?:url\s*\(\s*['"]?\s*https?://|@import\s+(?:url\s*\(\s*)?['"]?\s*https?://)""", - markup, - ) - if external_attr or external_css: - issues.append( - "Remove external http(s) URLs — the sandboxed preview blocks them. " - "Inline CSS/JS; use data: URIs for images/fonts." - ) - - # Model talking about the chat UI instead of building the visual. - if any(p in lower for p in ( - "preview/code", "render_preview", "live preview/code", - "paste the returned", "fenced block", - )): - issues.append( - "Do not describe the chat Preview UI — submit only the visualization " - "markup (canvas/SVG that plots data)." - ) - - return issues - - -def _critique_svg(markup: str, wants_plot: bool) -> list[str]: - import re - issues: list[str] = [] - lower = markup.lower() - - if " root element.") - root = re.search(r"]*>", markup, re.I) - if root: - tag = root.group(0) - wm = re.search(r'\bwidth\s*=\s*["\']?(\d+)', tag, re.I) - hm = re.search(r'\bheight\s*=\s*["\']?(\d+)', tag, re.I) - if wm and int(wm.group(1)) < _MIN_CANVAS_W: - issues.append( - f'SVG width is {wm.group(1)}px — too small to read. Use ' - f'width="{_STAGE_W}" height="{_STAGE_H}".' - ) - if hm and int(hm.group(1)) < _MIN_CANVAS_H: - issues.append( - f'SVG height is {hm.group(1)}px — too small. Use height="{_STAGE_H}".' - ) - if len(re.sub(r"\s+", "", markup)) < 60: - issues.append( - "SVG is too empty — add shapes (path/rect/circle/line/text) that " - "actually illustrate the idea." - ) - if wants_plot and _decorative_svg_not_plot(markup): - issues.append( - "This SVG is decorative (gradient/rect/single line), not a data " - "chart. Build a / from many computed points, or " - "prefer a with getContext + lineTo over an array." - ) - return issues - - -def _critique_html(markup: str, wants_plot: bool) -> list[str]: - import re - issues: list[str] = [] - lower = markup.lower() - - if re.search(r"(?:width|height)\s*:\s*40px", markup, re.I): - issues.append( - "Do not use 40x40 CSS tiles — that is not a visualization. Size the " - f"stage {_STAGE_W}x{_STAGE_H}px." - ) - - if ". Put one interactive " - " (or ) in the body and draw there." - ) - - has_canvas = " or that " - "draws the data — prose alone is rejected." - ) - if wants_plot and has_svg and not has_canvas and _decorative_svg_not_plot(markup): - issues.append( - "SVG stage present but it does not plot data (no multi-point " - "polyline/path, no JS that builds one from an array). Prefer " - f' + getContext + lineTo.' - ) - issues += _critique_canvas(markup) - - if len(re.sub(r"\s+", "", markup)) < 40: - issues.append("markup is too short to be a useful preview.") - - return issues - - -def _critique_prose(markup: str) -> list[str]: - """Reject an explanation dressed up as a visualization — paragraphs, a list, - maybe a button that reveals more text, and nothing that draws. - - Shared by html and jsx: a component returning four

    elements is the same - non-answer as a page of them, and for a while jsx was accepted precisely - because this check lived only on the html side.""" - import re - lower = markup.lower() - if "= 3 and ( - "display" in lower or "toggle" in lower or "= 4 or toggle_only: - return [ - "This is an explanation, not a visualization. Draw the data on a " - f' (or an chart) ' - "— not paragraphs, lists, or a button that only reveals more text." - ] - return [] - - -def _critique_canvas(markup: str) -> list[str]: - """Checks for markup that has a in it, wherever that markup came - from — a plain HTML body or the JSX that renders one.""" - import re - issues: list[str] = [] - lower = markup.lower() - - if "]*>", markup, re.I): - wm = re.search(r'\bwidth\s*=\s*["\']?(\d+)', tag, re.I) - hm = re.search(r'\bheight\s*=\s*["\']?(\d+)', tag, re.I) - if wm and int(wm.group(1)) < _MIN_CANVAS_W: - issues.append( - f'Canvas width="{wm.group(1)}" is too small — use width="{_STAGE_W}" ' - f'height="{_STAGE_H}", then map each data value to (x, y) pixels.' - ) - if hm and int(hm.group(1)) < _MIN_CANVAS_H: - issues.append( - f'Canvas height="{hm.group(1)}" is too small — use height="{_STAGE_H}".' - ) - if re.search(r'\bwidth\s*=\s*["\']?100%', tag, re.I): - issues.append( - "Use numeric canvas width/height attributes (e.g. width=\"480\"), " - "not percentages — the bitmap size must be explicit." - ) - - full_blit = bool(re.search( - r"(?:fillrect|clearrect)\s*\(\s*0\s*,\s*0\s*,\s*\d+\s*,\s*\d+\s*\)", - lower, - )) - plots_points = bool( - re.search(r"lineto\s*\(", lower) - or re.search(r"fillrect\s*\(\s*(?!0\s*,\s*0)", lower) - or re.search(r"filltext\s*\(", lower) - or re.search(r"arc\s*\(", lower) - or re.search(r"strokerect\s*\(\s*(?!0\s*,\s*0)", lower) - ) - if full_blit and not plots_points: - issues.append( - "You are only fillRect/clearRect(0,0,W,H) — that paints the whole " - "canvas, not the data. Collect values into an array, then for each " - "index i draw at x=i*step, y=height - value*scale (lineTo or " - "fillRect(x, y, barW, barH))." - ) - - return issues - - -def _critique_jsx(markup: str, wants_plot: bool) -> list[str]: - """A JSX/TSX fence is one self-contained component. It is transformed and - mounted in the browser (interface/web/src/preview/), so the checks here are - the things that transform cannot recover from or would mount into nothing.""" - import re - issues: list[str] = [] - lower = markup.lower() - - # Something has to be mounted: an explicit default export, a component named - # App, or some capitalized declaration to fall back to. - has_component = bool( - re.search(r"\bexport\s+default\b", markup) - or re.search(r"\bfunction\s+[A-Z]\w*", markup) - or re.search(r"\b(?:const|let|var)\s+[A-Z]\w*\s*=", markup) - or re.search(r"\bclass\s+[A-Z]\w*", markup) - ) - if not has_component: - issues.append( - "No component to mount — define one with a capitalized name " - "(e.g. `function App() { ... }`) or `export default` it." - ) - - if "<" not in markup or not re.search(r"<[A-Za-z>]", markup): - issues.append( - "No JSX found — the component must return elements " - "(e.g. `return

    ;`)." - ) - - # Imports are stripped before the code runs: there is no module loader and - # no network in the sandbox. React/Preact itself is already in scope. - for module in re.findall(r"""\bfrom\s+['"]([^'"]+)['"]""", markup): - if module.split("/")[0] not in ("react", "react-dom", "preact"): - issues.append( - f"Cannot import '{module}' — the preview has no module loader and " - "no network. Inline what you need; React/Preact hooks are already " - "in scope without importing." - ) - - if wants_plot and " or that " - "draws the data — prose alone is rejected." - ) - - issues += _critique_prose(markup) - issues += _critique_canvas(markup) - - if len(re.sub(r"\s+", "", markup)) < 40: - issues.append("markup is too short to be a useful preview.") - - return issues - - -# The one place that says which languages the render window supports. Each entry -# owns that language's validation; the tool schema's `lang` enum, the dispatch in -# _critique_render, and the capability line in the system prompt are all derived -# from these keys rather than repeating them. +# The one place that says which languages the render window supports. The tool +# schema's `lang` enum and the capability line in the system prompt are derived +# from these keys rather than repeated. # # The frontend keeps its own matching registry (PREVIEW_LANGS in -# interface/web/src/Markdown.jsx) because the two sides need different things per -# language - this side validates, that side renders - and neither should depend -# on the other at runtime. tests/test_tools.py asserts the key sets stay equal, -# so drift fails the check gate instead of silently degrading to a plain code -# block in the chat. +# interface/web/src/preview/languages.js) because the two sides need different +# things per language - this side describes them, that side renders them - and +# neither should depend on the other at runtime. tests/test_tools.py asserts the key sets +# stay equal, so drift fails the check gate instead of silently degrading to a +# plain code block in the chat. PREVIEW_LANGS: dict[str, dict] = { - "html": { - "summary": "self-contained HTML document", - "critique": _critique_html, - # HTML also covers ordinary interactive UIs (forms, calculators, DOM - # demos). Only require a drawing surface when the request specifically - # claims to be a chart/plot/data visualization. - "wants_visual": _wants_chart, - "scaffold": _INTERACTIVE_SCAFFOLD_HTML, - }, - "svg": { - "summary": "standalone SVG image", - "critique": _critique_svg, - "wants_visual": _wants_data_visual, - "scaffold": _INTERACTIVE_SCAFFOLD_HTML, - }, - "jsx": { - "summary": "single Preact/React component (JSX)", - "critique": _critique_jsx, - "wants_visual": _wants_chart, - "scaffold": _INTERACTIVE_SCAFFOLD_JSX, - }, - "tsx": { - "summary": "single Preact/React component (TypeScript JSX)", - "critique": _critique_jsx, - "wants_visual": _wants_chart, - "scaffold": _INTERACTIVE_SCAFFOLD_JSX, - }, + "html": {"summary": "self-contained HTML document"}, + "svg": {"summary": "standalone SVG image"}, + "jsx": {"summary": "single Preact/React component (JSX)"}, + "tsx": {"summary": "single Preact/React component (TypeScript JSX)"}, } -def _scaffold_for(lang: str) -> str: - """The starting pattern handed back on reject. Per-language: answering a - rejected component with a full HTML document tells the model to write the - wrong thing entirely.""" - entry = PREVIEW_LANGS.get(lang) - return (entry["scaffold"] if entry else _INTERACTIVE_SCAFFOLD_HTML).strip() - - def _lang_prose() -> str: """'html or svg' — the supported languages as a phrase for prompts/errors.""" names = list(PREVIEW_LANGS) @@ -690,74 +241,15 @@ def _lang_prose() -> str: return f"{', '.join(names[:-1])} or {names[-1]}" -def _critique_render(lang: str, markup: str, purpose: str = "", title: str = "") -> list[str]: - """Cheap static checks so render_preview rejects empty/fake visuals before - the model pastes them into the chat as a 'working' demo. - - Scope: things that RUN but are not what was asked for — a 40x40 stage, a - decorative gradient standing in for a chart, prose with no drawing in it, a - canvas that only paints itself one colour. These fail silently no matter - what, so static checks are the only thing that can catch them. - - Not in scope: code that throws. The preview reports its own runtime errors - now (the bootstrap in interface/web/src/Markdown.jsx), so guessing at them - here bought nothing and cost accuracy. Three checks were removed once that - landed, each verified against the real error channel first: - - const canvas = el.getContext('2d') ... ctx.lineTo() - -> "ReferenceError: ctx is not defined (line 4)" - function collatz() ... coll(27) - -> "ReferenceError: coll is not defined (line 5)" - document.createElementNS('line') - -> "TypeError: ... 2 arguments required, but only 1 present. (line 3)" - - The real messages are better than the regexes were: they carry a line - number, and they catch *any* undefined name rather than the two spellings - someone thought to anticipate. Resist re-adding a static check for anything - that already throws.""" - entry = PREVIEW_LANGS.get(lang) - if entry is None: - return [f"unsupported preview language {lang!r} — use {_lang_prose()}."] - # What counts as "claimed a visual" differs by language: see _wants_chart. - wants_plot = entry["wants_visual"](purpose, title, markup) - return _critique_shared(markup) + entry["critique"](markup, wants_plot) - - -def _with_scaffold(payload: dict, lang: str, attempt: int) -> dict: - """Attach the starting pattern, but only from the second rejection on. - - A complete, styled, runnable document handed to a struggling model does not - get adapted — it gets pasted, and then it persists in the conversation and - comes back as retrieved context for the next request, carrying its example - domain with it. Transcripts show exactly that: a scaffold's CSS reappearing - verbatim in an answer to an unrelated prompt, in a conversation where this - tool was never even called. So the first rejection says only what is wrong; - the pattern appears once that has not been enough.""" - if attempt < 1: - return payload - return { - **payload, - "scaffold": _scaffold_for(lang), - "scaffold_note": ( - "A pattern to adapt, not an answer to paste. Replace generate() with " - "the logic this request actually needs; keep nothing you do not use." - ), - } - - async def _render_preview( lang: str = "html", title: str = "", markup: str = "", purpose: str = "", - _attempt: int = 0, **_, ) -> str: - """Validate + package a live-preview fence. Read-only: nothing is executed - server-side; the chat UI renders the returned fence in a sandboxed iframe. - - `_attempt` is supplied by the tool loop, not by the model — it is how many - times this call has already been rejected in the current turn.""" + """Package a live-preview fence. Read-only: nothing is executed server-side; + the chat UI parses and renders the fence in a sandboxed iframe.""" lang = (lang or "html").strip().lower() markup = (markup or "").strip() title = (title or "").strip() @@ -766,28 +258,10 @@ async def _render_preview( if lang not in PREVIEW_LANGS: return json.dumps({"ok": False, "error": f"lang must be {_lang_prose()}"}) if not markup: - return json.dumps(_with_scaffold({ + return json.dumps({ "ok": False, - "error": ( - f"markup is required — send the complete {lang} for the visual you " - "want, with all CSS and JS inline and no external URLs." - ), - }, lang, _attempt)) - - issues = _critique_render(lang, markup, purpose=purpose, title=title) - if issues: - return json.dumps(_with_scaffold({ - "ok": False, - "issues": issues, - "hint": ( - "Fix these and call render_preview again, building the thing that " - f"was actually asked for. Draw on a {_STAGE_W}x{_STAGE_H} stage; " - "compute the values into an array first, then plot them point by " - "point with lineTo/fillRect(x,y,w,h); add - -""" - out = json.loads(asyncio.run(tools.dispatch("render_preview", { - "lang": "html", "title": "Demo", "markup": bad, - }))) - assert out["ok"] is False - joined = " ".join(out.get("issues", [])) - assert "draw" in joined.lower() or "getcontext" in joined.lower() - assert "scaffold" not in out # withheld on a first rejection - - -def test_render_preview_rejects_forty_by_forty_stub_with_scaffold(): - # Tiny stubs fail critique; tool returns fix hints + generic scaffold — not a - # canned Collatz/Recamán demo. - bad = """ +def test_render_preview_packages_markup_without_grading_its_quality(): + markup = """ -""" + +""" out = json.loads(asyncio.run(tools.dispatch("render_preview", { - "lang": "html", "markup": bad, "purpose": "interactive plot demo", + "lang": "html", "title": "Demo", "markup": markup, }))) - assert out["ok"] is False - assert out.get("repaired") is not True - assert "fence" not in out or not out.get("fence") - assert "scaffold" not in out # withheld on a first rejection - assert "issues" in out + assert out["ok"] is True + assert markup in out["fence"] + assert "issues" not in out + assert "scaffold" not in out def test_render_preview_accepts_canvas_that_plots(): @@ -305,8 +277,7 @@ def test_code_that_throws_is_left_to_the_previews_own_error_channel(): Static guessing at runtime failures only ever caught the spellings someone anticipated; the error channel catches every one of them and carries a line - number. What stays in the critique is the opposite case — markup that runs - perfectly and still isn't a visualization.""" + number.""" broken_at_runtime = """ """ - out = json.loads(asyncio.run(tools.dispatch("render_preview", { - "lang": "html", - "title": "Unknown sequence", - "purpose": "interactive sequence demo", - "markup": bad, - }))) - assert out["ok"] is False - assert out.get("repaired") is not True - blob = json.dumps(out).lower() - assert "recaman" not in blob and "collatz" not in blob - assert "scaffold" not in out # withheld on a first rejection - - def test_no_sequence_render_seed_helper(): assert not hasattr(tools, "sequence_render_seed") @@ -432,72 +374,16 @@ def test_render_preview_accepts_a_jsx_component(): assert out["fence"].startswith("```jsx\n") -def test_interactive_ui_needs_no_canvas_but_a_chart_does(): - """Forms and calculators are interactive through DOM elements in either - HTML or JSX; only a request claiming to be a chart needs a drawing surface.""" +def test_render_preview_does_not_grade_jsx_against_its_purpose(): component = """export default function Form() { const [name, setName] = useState(""); return ; }""" - ok = json.loads(asyncio.run(tools.dispatch("render_preview", { - "lang": "jsx", "markup": component, "purpose": "an interactive demo", - }))) - assert ok["ok"] is True, ok.get("issues") - - bad = json.loads(asyncio.run(tools.dispatch("render_preview", { + out = json.loads(asyncio.run(tools.dispatch("render_preview", { "lang": "jsx", "markup": component, "purpose": "a chart of the results", }))) - assert bad["ok"] is False - assert any("canvas" in i for i in bad["issues"]) - - html = """ - - - 4 - """ - html_ok = json.loads(asyncio.run(tools.dispatch("render_preview", { - "lang": "html", "markup": html, "purpose": "an interactive calculator demo", - }))) - assert html_ok["ok"] is True, html_ok.get("issues") - - -def test_scaffold_is_withheld_until_the_model_has_failed_twice(): - """A complete, styled, runnable document handed to a struggling model gets - pasted rather than adapted — and then persists in the conversation and comes - back as retrieved context for later requests, carrying its example domain - with it. A transcript showed this scaffold's CSS reappearing verbatim in an - answer to an unrelated prompt, in a conversation where the tool was never - called. So the first rejection says only what is wrong.""" - for args in ({"lang": "html", "markup": "
    too short
    "}, - {"lang": "jsx", "markup": ""}): - first = json.loads(asyncio.run(tools.dispatch("render_preview", args))) - assert first["ok"] is False - assert "scaffold" not in first, args - assert "issues" in first or "error" in first - - again = json.loads(asyncio.run(tools.dispatch( - "render_preview", {**args, "_attempt": 1}))) - assert again["ok"] is False - assert "scaffold" in again, args - - -def test_repeat_reject_hands_back_the_language_that_was_asked_for(): - """Answering a rejected component with a full HTML document tells the model - to write the wrong thing entirely.""" - jsx = json.loads(asyncio.run(tools.dispatch("render_preview", { - "lang": "jsx", "markup": "
    too short
    ", "_attempt": 1, - }))) - assert "export default function App" in jsx["scaffold"] - assert "" not in jsx["scaffold"] - - html = json.loads(asyncio.run(tools.dispatch("render_preview", { - "lang": "html", "markup": "
    too short
    ", "_attempt": 1, - }))) - assert "" in html["scaffold"] - - empty = json.loads(asyncio.run(tools.dispatch( - "render_preview", {"lang": "tsx", "markup": "", "_attempt": 1}))) - assert "export default function App" in empty["scaffold"] + assert out["ok"] is True + assert "issues" not in out def test_asking_for_a_preview_language_or_pointer_interaction_offers_the_tool(): @@ -524,14 +410,14 @@ def test_asking_for_a_preview_language_or_pointer_interaction_offers_the_tool(): assert tools.wants_render_preview("compare these graphs") -def test_external_preview_resources_are_rejected_in_attributes_and_css(): - for markup in ( - 'chart', - '', - '', - ): - issues = tools._critique_shared(markup) - assert any("external http(s)" in issue for issue in issues), markup +def test_external_preview_resources_are_packaged_for_the_csp_to_block(): + markup = 'chart' + out = json.loads(asyncio.run(tools.dispatch("render_preview", { + "lang": "html", "markup": markup, + }))) + assert out["ok"] is True + assert markup in out["fence"] + assert "issues" not in out def test_every_preview_language_hints_for_itself(): @@ -539,81 +425,6 @@ def test_every_preview_language_hints_for_itself(): assert lang in tools._RENDER_HINTS, lang -def test_size_guidance_never_quotes_the_minimum(): - """Weak models copy the first dimensions they read. Three transcripts - produced exactly 320x200 — the old minimum — including one that had a - 480x280 example in front of it. Only the wanted size may be spoken.""" - schema, _ = tools.REGISTRY["render_preview"] - surfaces = [json.dumps(schema)] - for lang, markup in (("html", '' + "x" * 60), - ("svg", '' + "x" * 60)): - surfaces.append(json.dumps(asyncio.run( - tools._render_preview(lang=lang, markup=markup, purpose="a chart")))) - blob = " ".join(surfaces) - assert str(tools._MIN_CANVAS_W) not in blob, "the minimum leaked into guidance" - assert str(tools._STAGE_W) in blob - - -def test_prose_only_component_is_rejected_like_a_prose_page(): - """The JSX that started the Euler misunderstanding: a component returning - three paragraphs. It was accepted because the prose check lived only on the - html side.""" - prose = """export default function App() { - return ( -
    -

    Euler's Formula

    -

    The sum of the first n natural numbers is:

    -

    {`f(x) = ${sumOfCubes(10)}`}

    -

    For example, the sum of the cubes of the first 10 is: {sumOfCubes(10)}

    -
    - ); -}""" - out = json.loads(asyncio.run(tools.dispatch("render_preview", { - "lang": "jsx", "markup": prose, "purpose": "Euler fluid field", - }))) - assert out["ok"] is False - assert any("not a visualization" in i for i in out["issues"]) - - -def test_a_path_parked_in_defs_is_not_a_plot(): - """Straight from a transcript: a long inside — never drawn — - passed as proof of a real chart while the preview rendered an empty box.""" - undrawn = ( - '' - '' - '' - ) - assert tools._decorative_svg_not_plot(undrawn) - out = json.loads(asyncio.run(tools.dispatch("render_preview", { - "lang": "svg", "markup": undrawn, "purpose": "a plot of the field", - }))) - assert out["ok"] is False - - # The same path where it actually renders is still a plot. - drawn = undrawn.replace("", "").replace("", "") - assert not tools._decorative_svg_not_plot(drawn) - - -def test_render_nudge_never_reaches_the_streaming_turn(): - """The nudge is a synthetic user turn. Left in place it becomes the last - thing the user appears to have said, and the model answers it — which is - exactly what shipped: "please provide the user's request for the rendering", - twice, in place of a bouncing particle system.""" - from synapse.chat import _strip_internal_turns, _render_nudge_text - real = {"role": "user", "content": "draw me a bouncing particle system"} - kept = _strip_internal_turns([ - real, - {"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "x"}}]}, - {"role": "tool", "content": "{}"}, - {"role": "user", "content": _render_nudge_text()}, - ]) - assert kept[-1] == real - assert len(kept) == 2 - assert kept[0]["role"] == "user" - assert "Tool results" in kept[0]["content"] - assert "{}" in kept[0]["content"] - - def test_normal_tool_results_reach_streaming_turn(): """Flatten Ollama's tool roles without discarding the retrieved data.""" from synapse.chat import _strip_internal_turns @@ -630,43 +441,19 @@ def test_normal_tool_results_reach_streaming_turn(): assert all(m.get("role") != "tool" and not m.get("tool_calls") for m in kept) -def test_render_nudge_says_only_what_to_do_next(): - """It cannot refer to something the model can't see, offer a way out, or - name a size or language that isn't the one we want — it gets answered - literally.""" - from synapse.chat import _render_nudge_text - nudge = _render_nudge_text().lower() - assert "this user request" not in nudge # dangling reference -> "please provide it" - assert "clarif" not in nudge # escape hatch -> it gets taken - assert str(tools._MIN_CANVAS_W) not in nudge - assert f"{tools._STAGE_W}x{tools._STAGE_H}" in nudge - for lang in tools.PREVIEW_LANGS: # not a hardcoded "html or svg" - assert lang in nudge, lang - - -def test_every_language_offers_a_scaffold(): - for lang in tools.PREVIEW_LANGS: - assert tools._scaffold_for(lang), f"{lang} has no scaffold" - - -def test_render_preview_rejects_jsx_with_no_component(): - out = json.loads(asyncio.run(tools.dispatch("render_preview", { - "lang": "jsx", "markup": "const x = 1;\nconsole.log(x);\n// nothing to mount", - }))) - assert out["ok"] is False - assert any("No component to mount" in i for i in out["issues"]) - - -def test_render_preview_rejects_jsx_importing_a_third_party_module(): - src = """import { motion } from "framer-motion"; -export default function App() { - return hello there friend; -}""" - out = json.loads(asyncio.run(tools.dispatch("render_preview", { - "lang": "jsx", "markup": src, - }))) - assert out["ok"] is False - assert any("framer-motion" in i for i in out["issues"]) +def test_render_preview_leaves_jsx_runtime_judgment_to_the_browser(): + sources = ( + "const x = 1;\nconsole.log(x);\n// nothing to mount", + 'import { motion } from "framer-motion"; export default () => ;', + "export default () =>
    tiny
    ;", + ) + for source in sources: + out = json.loads(asyncio.run(tools.dispatch("render_preview", { + "lang": "jsx", "markup": source, + }))) + assert out["ok"] is True + assert source in out["fence"] + assert "issues" not in out def test_render_preview_allows_react_imports_in_jsx(): @@ -681,43 +468,13 @@ export default function App() { assert out["ok"] is True, out.get("issues") -def test_render_preview_rejects_tiny_decorative_tile(): - stub = """
    """ +def test_render_preview_still_rejects_missing_markup(): out = json.loads(asyncio.run(tools.dispatch("render_preview", { - "lang": "html", "markup": stub, + "lang": "tsx", "markup": "", }))) assert out["ok"] is False - - -def test_render_preview_rejects_prose_page(): - bad = """ -

    Some Topic

    -

    This explains an idea in several paragraphs without drawing anything.

    -
    • one
    • two
    • three
    • four
    -

    To view a live Preview/Code toggle, use:

    -

    more prose

    -
    """ - out = json.loads(asyncio.run(tools.dispatch("render_preview", { - "lang": "html", - "title": "Topic", - "purpose": "interactive demo", - "markup": bad, - }))) - assert out["ok"] is False - assert "scaffold" not in out # withheld on a first rejection - assert out.get("repaired") is not True - - -def test_last_ok_render_fence_prefers_tool_result(): - from synapse.chat import _last_ok_render_fence - fence, meta = _last_ok_render_fence([ - {"role": "tool", "content": json.dumps({ - "ok": True, - "fence": "```html\n\n```", - })}, - ]) - assert fence.startswith("```html") - assert meta.get("ok") is True + assert "markup is required" in out["error"] + assert "scaffold" not in out def test_coerce_tool_calls_from_content_json(): @@ -788,8 +545,7 @@ def test_tool_loop_runs_content_json_tool_call(monkeypatch): assert json.loads(tool_msgs[0]["content"])["ok"] is True -def test_tool_loop_nudges_render_preview_on_visual_ask(): - """First turn skips tools; nudge forces a second turn that calls render_preview.""" +def test_tool_loop_does_not_inject_a_render_preview_nudge(): class _SkipThenCall: def __init__(self): self.n = 0 @@ -821,11 +577,9 @@ def test_tool_loop_nudges_render_preview_on_visual_ask(): _SkipThenCall(), "m", tools.standing_schemas(), user="Visualize the Collatz conjecture with an interactive chart", )) - assert any(s == "__status__render_preview" for s in statuses) - assert any( - m.get("role") == "user" and "render_preview tool now" in (m.get("content") or "") - for m in messages - ) + assert statuses == ["__status__tools"] + assert len(messages) == 1 + assert messages[0]["content"].startswith("Visualize") async def _drain_with_messages(manager, model, schemas, user="draw a circle"):