diff --git a/deeptutor/agents/visualize/agents/code_generator_agent.py b/deeptutor/agents/visualize/agents/code_generator_agent.py index 9110e4089..81e5f74f1 100644 --- a/deeptutor/agents/visualize/agents/code_generator_agent.py +++ b/deeptutor/agents/visualize/agents/code_generator_agent.py @@ -8,7 +8,7 @@ from deeptutor.core.trace import build_trace_metadata, new_call_id from ..models import VisualizationAnalysis -from ..utils import extract_code_block +from ..utils import build_widget_from_spec, extract_code_block, extract_json_object, is_widget_spec class CodeGeneratorAgent(BaseAgent): @@ -73,15 +73,23 @@ async def process( else: lang_hint = "javascript" - extracted = extract_code_block(response, lang_hint) or extract_code_block(response) - - # For html, the model sometimes returns the full document with no fence. - # `extract_code_block` will then return the trimmed raw response — accept - # it as long as it looks like an HTML document. - if analysis.render_type == "html" and not extracted: + # ── HTML: try JSON widget spec first, then fall back to raw HTML ────── + if analysis.render_type == "html": + try: + spec = extract_json_object(response) + if is_widget_spec(spec): + return build_widget_from_spec(spec) + except Exception: + pass + # Fallback: accept raw HTML document if LLM returned one directly stripped = (response or "").strip() lowered = stripped.lower() if lowered.startswith("...\", + \"controls\":[{\"type\":\"slider\",\"id\":\"spd\",\"label\":\"Speed (km/h)\", + \"min\":10,\"max\":100,\"step\":1,\"value\":50}], + \"update_js\":\"var d=values.spd*10; updateMetric('m1',d+' km'); dtSvg.getElementById('bar').setAttribute('width',d/800*760);\"}" + } + + Widget spec rules: + - metrics: 3–5 entries. IDs unique. + - canvas_html: complete SVG with id="dt-svg" and all shapes having unique IDs. + - controls: sliders for numbers, toggles for booleans. + - update_js: must call updateMetric() for every metric AND update SVG/canvas. + - NO titles, headings, solution text, or explanations anywhere in the spec. user_template: | User request: {original} diff --git a/deeptutor/agents/visualize/prompts/en/code_generator_agent.yaml b/deeptutor/agents/visualize/prompts/en/code_generator_agent.yaml index 0a7d331fb..fe0ec0553 100644 --- a/deeptutor/agents/visualize/prompts/en/code_generator_agent.yaml +++ b/deeptutor/agents/visualize/prompts/en/code_generator_agent.yaml @@ -5,36 +5,76 @@ system: | Rules: - If render_type is "svg", output a complete, self-contained SVG string. The SVG must include the xmlns attribute and be well-formed XML. - Use viewBox for responsive sizing. Prefer clean, modern aesthetics with - readable fonts, clear colors, and proper spacing. + Use viewBox="0 0 900 480" unless the user explicitly needs another ratio. + Design it as a compact in-answer diagram, not a poster or full lesson page: + * Everything must fit inside the viewBox with 32px margins. + * Use one primary diagram area, not multiple large cards or sections. + * Keep labels short and tied to the written solution (same names/quantities). + * Use at most 6 prominent labels and at most 2 accent colors. + * Avoid large title banners, huge headings, explanatory paragraphs, and + dark formula blocks inside the SVG; the app renders the derivation below. + * Prefer the Google-style pattern: a small metric strip only when useful, + a simple axis/lane/geometry, direct annotations, and generous empty space. + * Text must never be clipped or placed outside the viewBox. + Prefer clean, minimal aesthetics with readable fonts, clear colors, and proper spacing. - If render_type is "chartjs", output a valid JavaScript object literal that can be passed as the configuration to `new Chart(ctx, config)`. The config must include `type`, `data`, and `options` fields. - Use modern color palettes and ensure labels are readable. + Set options.maintainAspectRatio=false and keep labels readable in a compact panel. - If render_type is "mermaid", output valid Mermaid.js diagram code. The code must start with a valid diagram type keyword (graph, flowchart, sequenceDiagram, classDiagram, stateDiagram-v2, erDiagram, gantt, mindmap, etc.). Use clear node labels and readable edge descriptions. Do NOT use spaces in node IDs — use camelCase or underscores. Avoid reserved keywords as node IDs. - - If render_type is "html", output one complete, self-contained single-file HTML - learning page. - Technical constraints: - * Must include everything from to . - * Put all CSS inside + + +
+ +
+ {metrics_html} +
+ +
+ {canvas_zone} +
+ +
+ {controls_html} +
+
+ + +""" + + __all__ = [ "build_fallback_html", + "build_widget_from_spec", "extract_code_block", "extract_json_object", "is_valid_html_document", + "is_widget_spec", ] diff --git a/web/components/visualize/VisualizationViewer.tsx b/web/components/visualize/VisualizationViewer.tsx index 7d12f418b..83dec54b2 100644 --- a/web/components/visualize/VisualizationViewer.tsx +++ b/web/components/visualize/VisualizationViewer.tsx @@ -1,9 +1,18 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import { Code2, Copy, Check, ExternalLink, Maximize2, X } from "lucide-react"; +import { + Code2, + Copy, + Check, + ExternalLink, + Maximize2, + X, + Sparkles, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { Mermaid } from "@/components/Mermaid"; +import MarkdownRenderer from "@/components/common/MarkdownRenderer"; import { prepareIframeHtml } from "@/lib/iframe-html"; import type { VisualizeResult } from "@/lib/visualize-types"; @@ -28,7 +37,6 @@ function ChartJsRenderer({ config }: { config: string }) { chartRef.current = null; } - // eslint-disable-next-line no-new-func const parsedConfig = new Function( `"use strict"; return (${config});`, )(); @@ -71,16 +79,42 @@ function ChartJsRenderer({ config }: { config: string }) { } return ( -
+
); } +function readIframeTheme(): "light" | "dark" { + if (typeof document === "undefined") return "light"; + return document.documentElement.classList.contains("dark") ? "dark" : "light"; +} + function HtmlRenderer({ html }: { html: string }) { const iframeRef = useRef(null); + const [iframeTheme, setIframeTheme] = useState<"light" | "dark">( + readIframeTheme, + ); + + useEffect(() => { + if (typeof document === "undefined") return; + + const htmlEl = document.documentElement; + const updateTheme = () => setIframeTheme(readIframeTheme()); + const observer = new MutationObserver(updateTheme); + observer.observe(htmlEl, { attributes: true, attributeFilter: ["class"] }); + window.addEventListener("storage", updateTheme); - const prepared = useMemo(() => prepareIframeHtml(html || ""), [html]); + return () => { + observer.disconnect(); + window.removeEventListener("storage", updateTheme); + }; + }, []); + + const prepared = useMemo( + () => prepareIframeHtml(html || "", iframeTheme), + [html, iframeTheme], + ); useEffect(() => { const iframe = iframeRef.current; @@ -101,7 +135,7 @@ function HtmlRenderer({ html }: { html: string }) { }; return ( -
+
); } +function normalizeSvgMarkup(svg: string): { + markup: string; + error: string | null; +} { + const trimmed = svg.trim(); + if (!trimmed.startsWith(" { + if (!value) return null; + const match = value.match(/-?\d+(?:\.\d+)?/); + if (!match) return null; + const parsed = Number(match[0]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + }; + + if (!root.getAttribute("viewBox")) { + const width = parseSize(root.getAttribute("width")) ?? 900; + const height = parseSize(root.getAttribute("height")) ?? 480; + root.setAttribute("viewBox", `0 0 ${width} ${height}`); + } + root.setAttribute("width", "100%"); + root.setAttribute("height", "100%"); + root.setAttribute("preserveAspectRatio", "xMidYMid meet"); + root.setAttribute( + "style", + `${root.getAttribute("style") || ""};max-width:100%;max-height:100%;display:block;`, + ); + + return { markup: new XMLSerializer().serializeToString(root), error: null }; + } catch { + return { markup: "", error: "Could not normalize SVG for preview" }; + } +} + function SvgRenderer({ svg }: { svg: string }) { const { t } = useTranslation(); const containerRef = useRef(null); - const [error, setError] = useState(null); - - const sanitizedSvg = useMemo(() => { - const trimmed = svg.trim(); - if (!trimmed.startsWith(" normalizeSvgMarkup(svg), [svg]); if (error) { return ( @@ -144,7 +225,7 @@ function SvgRenderer({ svg }: { svg: string }) { {t("SVG rendering error")}

-          {error}
+          {t(error)}
         
); @@ -153,8 +234,8 @@ function SvgRenderer({ svg }: { svg: string }) { return (
); } @@ -172,6 +253,67 @@ function renderVisualization(result: VisualizeResult) { return ; } +function labelForResult(result: VisualizeResult): string { + if (result.render_type === "svg") return "SVG"; + if (result.render_type === "mermaid") { + return `Mermaid · ${result.analysis.chart_type || "diagram"}`; + } + if (result.render_type === "html") { + return `Interactive · ${result.analysis.chart_type || "visual"}`; + } + return `Chart.js · ${result.analysis.chart_type || "chart"}`; +} + +/** + * Strip fenced code blocks from a response string so we only keep the + * prose explanation. The `response` field often contains both a solution + * paragraph and a ```lang … ``` block — we want only the former. + */ +function stripCodeFences(value: string): string { + // Remove all fenced code blocks (```lang\n…\n```) + const stripped = value.replace(/```[\w-]*\s*[\s\S]*?```/g, "").trim(); + return stripped; +} + +/** + * Detect and discard text that looks like leaked LLM reasoning / chain-of- + * thought rather than an actual user-facing explanation. + */ +function looksLikeLeakedReasoning(text: string): boolean { + const lower = text.slice(0, 300).toLowerCase(); + return ( + lower.includes("we need to") || + lower.includes("let me") || + lower.includes("i need to") || + lower.includes("the user wants") || + lower.includes("based on the analysis") || + lower.includes("let's design") || + lower.includes("let's build") || + lower.includes("as per the instructions") + ); +} + +function solutionForResult(result: VisualizeResult): string { + // 1. Prefer explicit solution field from the backend + const explicit = (result.solution || "").trim(); + if (explicit && !looksLikeLeakedReasoning(explicit)) return explicit; + + // 2. Try the response field, but strip code fences and leaked reasoning + const responseText = stripCodeFences(result.response || ""); + if (responseText && !looksLikeLeakedReasoning(responseText)) + return responseText; + + // 3. Fall back to analysis metadata + const labels = + result.analysis.visual_elements?.filter(Boolean).slice(0, 6) ?? []; + const parts = [ + result.analysis.description, + result.analysis.data_description, + labels.length ? `Diagram labels: ${labels.join(", ")}.` : "", + ].filter(Boolean); + return parts.join("\n\n"); +} + export default function VisualizationViewer({ result, }: { @@ -181,6 +323,8 @@ export default function VisualizationViewer({ const [showCode, setShowCode] = useState(false); const [copied, setCopied] = useState(false); const [fullscreen, setFullscreen] = useState(false); + const solution = useMemo(() => solutionForResult(result), [result]); + const resultLabel = labelForResult(result); // HTML iframe already provides its own "Open in new tab" affordance; the // sandboxed iframe also doesn't behave well inside a re-rendered modal. @@ -211,31 +355,53 @@ export default function VisualizationViewer({ }; return ( -
- {/* Visualization area */} -
- {supportsFullscreen && ( - - )} - {renderVisualization(result)} +
+
+
+
+ + + {result.analysis.description || t("Visualization")} + +
+ {supportsFullscreen && ( + + )} +
+
+ {renderVisualization(result)} +
+ {solution && ( +
+
+ {t("Step-by-Step Derivation")} +
+ +
+ )} + {/* Toolbar */} -
+
- {result.render_type === "svg" - ? "SVG" - : result.render_type === "mermaid" - ? `Mermaid · ${result.analysis.chart_type || "diagram"}` - : result.render_type === "html" - ? `HTML · ${result.analysis.chart_type || "interactive"}` - : `Chart.js · ${result.analysis.chart_type || "chart"}`} + {resultLabel}
- {/* Code panel — matches the always-dark .md-code-block style used by the - markdown renderers so a "Show code" toggle inside a chart message - looks identical to a fenced code block in the assistant response. */} + {/* Code panel */} {showCode && ( -
+
{result.code.language}
-
+          
             {result.code.content}
           
@@ -285,7 +443,7 @@ export default function VisualizationViewer({ {/* Review notes */} {result.review.changed && result.review.review_notes && ( -

+

{t("Review")}: {result.review.review_notes}

)} @@ -298,11 +456,7 @@ export default function VisualizationViewer({ >
- {result.render_type === "svg" - ? "SVG" - : result.render_type === "mermaid" - ? `Mermaid · ${result.analysis.chart_type || "diagram"}` - : `Chart.js · ${result.analysis.chart_type || "chart"}`} + {resultLabel}
e.stopPropagation()} >
diff --git a/web/lib/iframe-html.ts b/web/lib/iframe-html.ts index 503830abe..c557ccbdf 100644 --- a/web/lib/iframe-html.ts +++ b/web/lib/iframe-html.ts @@ -17,7 +17,6 @@ const KATEX_RESOURCES = [ "/script>", ].join("\n "); -// eslint-disable-next-line no-template-curly-in-string const KATEX_INIT_SCRIPT = "