chore: import upstream snapshot with attribution
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,556 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Code2, Copy, Check, ExternalLink, Maximize2, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Mermaid } from "@/components/Mermaid";
|
||||
import { prepareIframeHtml } from "@/lib/iframe-html";
|
||||
import { isManimResult, type VisualizeResult } from "@/lib/visualize-types";
|
||||
import "./svg-theme.css";
|
||||
|
||||
const MathAnimatorViewer = dynamic(
|
||||
() => import("@/components/math-animator/MathAnimatorViewer"),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
function stripCodeFence(source: string): string {
|
||||
const trimmed = source.trim();
|
||||
const fenced = trimmed.match(
|
||||
/^```(?:json|javascript|js)?\s*([\s\S]*?)\s*```$/i,
|
||||
);
|
||||
return fenced ? fenced[1].trim() : trimmed;
|
||||
}
|
||||
|
||||
function parseChartConfig(source: string): unknown {
|
||||
const raw = stripCodeFence(source);
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
const jsonish = raw
|
||||
.replace(/([{,]\s*)([A-Za-z_$][\w$]*)\s*:/g, '$1"$2":')
|
||||
.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, (_match, value: string) =>
|
||||
JSON.stringify(value.replace(/\\'/g, "'")),
|
||||
)
|
||||
.replace(/,\s*([}\]])/g, "$1");
|
||||
return JSON.parse(jsonish);
|
||||
}
|
||||
}
|
||||
|
||||
function ChartJsRenderer({ config }: { config: string }) {
|
||||
const { t } = useTranslation();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const chartRef = useRef<unknown>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function render() {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
try {
|
||||
const ChartModule = await import("chart.js/auto");
|
||||
const Chart = ChartModule.default;
|
||||
|
||||
if (chartRef.current) {
|
||||
(chartRef.current as InstanceType<typeof Chart>).destroy();
|
||||
chartRef.current = null;
|
||||
}
|
||||
|
||||
const parsedConfig = parseChartConfig(config) as ConstructorParameters<
|
||||
typeof Chart
|
||||
>[1];
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
chartRef.current = new Chart(canvasRef.current, parsedConfig);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("Failed to render chart"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void render();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (chartRef.current) {
|
||||
(chartRef.current as { destroy: () => void }).destroy();
|
||||
chartRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [config, t]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900/60 dark:bg-red-950/30">
|
||||
<p className="text-sm font-medium text-red-600 dark:text-red-400">
|
||||
{t("Chart rendering error")}
|
||||
</p>
|
||||
<pre className="mt-2 whitespace-pre-wrap text-xs text-red-500">
|
||||
{error}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dt-chart-wrap relative w-full">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HtmlRenderer({ html }: { html: string }) {
|
||||
const { t } = useTranslation();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [height, setHeight] = useState(560);
|
||||
|
||||
const prepared = useMemo(() => prepareIframeHtml(html || ""), [html]);
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
iframe.srcdoc = prepared;
|
||||
}, [prepared]);
|
||||
|
||||
// Listen for the iframe bridge: a sendPrompt() call (mirror into the composer
|
||||
// via the shared window event) or a height report (grow to fit, no clipping).
|
||||
useEffect(() => {
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe || e.source !== iframe.contentWindow) return;
|
||||
const data = e.data as { type?: string; text?: string; height?: number };
|
||||
if (!data || typeof data !== "object") return;
|
||||
if (data.type === "dt:visualize-prompt" && data.text) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("dt:visualize-prompt", { detail: data.text }),
|
||||
);
|
||||
} else if (
|
||||
data.type === "dt:visualize-height" &&
|
||||
typeof data.height === "number"
|
||||
) {
|
||||
setHeight(Math.min(2400, Math.max(240, Math.ceil(data.height) + 8)));
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, []);
|
||||
|
||||
const handleOpenInNewTab = () => {
|
||||
try {
|
||||
const contentUrl = URL.createObjectURL(
|
||||
new Blob([prepared], { type: "text/html" }),
|
||||
);
|
||||
const wrapper = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Visualization</title><style>html,body,iframe{height:100%;width:100%;margin:0;border:0;}</style></head><body><iframe sandbox="allow-scripts" src="${contentUrl}"></iframe></body></html>`;
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([wrapper], { type: "text/html" }),
|
||||
);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
URL.revokeObjectURL(contentUrl);
|
||||
}, 60_000);
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenInNewTab}
|
||||
className="absolute right-2 top-2 z-10 inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--background)]/90 px-2 py-1 text-[10px] font-medium text-[var(--muted-foreground)] backdrop-blur transition-colors hover:text-[var(--foreground)]"
|
||||
title={t("Open in new tab")}
|
||||
>
|
||||
<ExternalLink size={10} strokeWidth={1.8} />
|
||||
{t("Open")}
|
||||
</button>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title={t("HTML visualization")}
|
||||
sandbox="allow-scripts"
|
||||
className="w-full rounded-lg border border-[var(--border)] bg-[var(--card)]"
|
||||
style={{ minHeight: 320, height }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Per-page sequence used to scope SVG ids (see the scoping block below).
|
||||
let svgScopeSeq = 0;
|
||||
|
||||
// Sanitize an SVG string for safe inline rendering: parse as XML, strip
|
||||
// script/foreign-object/event-handler vectors, then reserialize. SVGs come from
|
||||
// our own LLM and already pass a backend well-formedness check, but we still
|
||||
// defend against prompt-injected <script>/on* handlers. Kept dependency-free
|
||||
// (same sanitize→string contract as DOMPurify, so it can be swapped later).
|
||||
function sanitizeSvg(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (typeof DOMParser === "undefined") return "";
|
||||
const doc = new DOMParser().parseFromString(trimmed, "image/svg+xml");
|
||||
const root = doc.documentElement;
|
||||
if (!root || root.nodeName.toLowerCase() !== "svg") return "";
|
||||
if (root.getElementsByTagName("parsererror").length > 0) return "";
|
||||
|
||||
const STRIP = [
|
||||
"script",
|
||||
"foreignObject",
|
||||
"iframe",
|
||||
"object",
|
||||
"embed",
|
||||
"audio",
|
||||
"video",
|
||||
"handler",
|
||||
];
|
||||
root.querySelectorAll(STRIP.join(",")).forEach((n) => n.remove());
|
||||
|
||||
const walk = (el: Element) => {
|
||||
const tag = el.nodeName.toLowerCase();
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
const name = attr.name.toLowerCase();
|
||||
const val = attr.value.replace(/\s+/g, "").toLowerCase();
|
||||
if (name.startsWith("on")) {
|
||||
el.removeAttribute(attr.name);
|
||||
} else if (
|
||||
(name === "href" || name === "xlink:href") &&
|
||||
(val.startsWith("javascript:") ||
|
||||
(val.startsWith("data:") && !val.startsWith("data:image/")))
|
||||
) {
|
||||
el.removeAttribute(attr.name);
|
||||
} else if (name === "style" && val.includes("javascript:")) {
|
||||
el.removeAttribute(attr.name);
|
||||
} else if (
|
||||
(tag === "set" || tag === "animate") &&
|
||||
name === "attributename" &&
|
||||
val.startsWith("on")
|
||||
) {
|
||||
// <set attributeName="onclick" .../> can inject a handler — drop it.
|
||||
el.remove();
|
||||
return;
|
||||
}
|
||||
}
|
||||
Array.from(el.children).forEach((child) => walk(child));
|
||||
};
|
||||
walk(root);
|
||||
|
||||
// Scope ids so multiple inlined SVGs on one page don't collide: marker /
|
||||
// clipPath / gradient defs are referenced via url(#id) or href="#id". A bare
|
||||
// <img> kept each SVG in its own document; inline DOM shares one namespace,
|
||||
// so without this the 2nd+ figure's arrows/gradients break.
|
||||
const ids = new Set<string>();
|
||||
root.querySelectorAll("[id]").forEach((el) => {
|
||||
const id = el.getAttribute("id");
|
||||
if (id) ids.add(id);
|
||||
});
|
||||
if (ids.size) {
|
||||
const prefix = `dtsvg${svgScopeSeq++}-`;
|
||||
const rescope = (el: Element) => {
|
||||
const ownId = el.getAttribute("id");
|
||||
if (ownId && ids.has(ownId)) el.setAttribute("id", prefix + ownId);
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
const lname = attr.name.toLowerCase();
|
||||
let v = attr.value.replace(
|
||||
/url\(\s*(['"]?)#([^)'"\s]+)\1\s*\)/g,
|
||||
(m, q, id) => (ids.has(id) ? `url(${q}#${prefix}${id}${q})` : m),
|
||||
);
|
||||
if (
|
||||
(lname === "href" || lname.endsWith(":href")) &&
|
||||
v.charAt(0) === "#" &&
|
||||
ids.has(v.slice(1))
|
||||
) {
|
||||
v = `#${prefix}${v.slice(1)}`;
|
||||
} else if (
|
||||
lname === "aria-labelledby" ||
|
||||
lname === "aria-describedby"
|
||||
) {
|
||||
v = v
|
||||
.split(/\s+/)
|
||||
.map((token) => (ids.has(token) ? prefix + token : token))
|
||||
.join(" ");
|
||||
}
|
||||
if (v !== attr.value) el.setAttribute(attr.name, v);
|
||||
}
|
||||
Array.from(el.children).forEach((child) => rescope(child));
|
||||
};
|
||||
rescope(root);
|
||||
}
|
||||
|
||||
return root.outerHTML;
|
||||
}
|
||||
|
||||
function SvgFigure({ svg }: { svg: string }) {
|
||||
const { t } = useTranslation();
|
||||
const trimmed = svg.trim();
|
||||
const looksSvg = trimmed.startsWith("<svg") || trimmed.startsWith("<?xml");
|
||||
|
||||
// Client-only component (mounted via dynamic ssr:false), so DOMParser is
|
||||
// always available and there's no SSR/hydration concern — sanitize in useMemo.
|
||||
const safe = useMemo(
|
||||
() => (looksSvg ? sanitizeSvg(trimmed) : ""),
|
||||
[looksSvg, trimmed],
|
||||
);
|
||||
|
||||
if (!looksSvg || !safe) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900/60 dark:bg-red-950/30">
|
||||
<p className="text-sm font-medium text-red-600 dark:text-red-400">
|
||||
{t("SVG rendering error")}
|
||||
</p>
|
||||
<pre className="mt-2 whitespace-pre-wrap text-xs text-red-500">
|
||||
{looksSvg
|
||||
? t("SVG could not be safely rendered")
|
||||
: t("Invalid SVG: does not start with <svg")}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline (not <img>) so host CSS and the SVG's own <style> apply. Clicking a
|
||||
// node carrying data-prompt drops a follow-up question into the composer (via
|
||||
// a window event the chat page listens for) — prefilled, not auto-sent.
|
||||
const onSvgClick = (e: MouseEvent<HTMLDivElement>) => {
|
||||
const node = (e.target as Element).closest?.("[data-prompt]");
|
||||
const prompt = node?.getAttribute("data-prompt")?.trim();
|
||||
if (prompt) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("dt:visualize-prompt", { detail: prompt }),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="dt-svg-root flex justify-center overflow-x-auto"
|
||||
onClick={onSvgClick}
|
||||
dangerouslySetInnerHTML={{ __html: safe }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// A model occasionally emits several <svg> blocks in one response, and the
|
||||
// backend extractor concatenates everything from the first <svg to the last
|
||||
// </svg> — so one code.content can hold multiple svgs with colliding ids
|
||||
// (marker/gradient/clipPath). Split them and render each as its own figure,
|
||||
// independently sanitized and id-scoped, instead of one malformed multi-root
|
||||
// document where only the last svg's defs win.
|
||||
function splitSvgBlocks(raw: string): string[] {
|
||||
const blocks = raw.match(/<svg[\s\S]*?<\/svg>/gi);
|
||||
return blocks && blocks.length ? blocks : [raw.trim()];
|
||||
}
|
||||
|
||||
function SvgRenderer({ svg }: { svg: string }) {
|
||||
const blocks = useMemo(() => splitSvgBlocks(svg.trim()), [svg]);
|
||||
if (blocks.length <= 1) {
|
||||
return <SvgFigure svg={blocks[0] ?? svg} />;
|
||||
}
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{blocks.map((block, i) => (
|
||||
<SvgFigure key={i} svg={block} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TextResult = Extract<
|
||||
VisualizeResult,
|
||||
{ render_type: "svg" | "chartjs" | "mermaid" | "html" }
|
||||
>;
|
||||
|
||||
function renderTextVisualization(result: TextResult) {
|
||||
if (result.render_type === "svg") {
|
||||
return <SvgRenderer svg={result.code.content} />;
|
||||
}
|
||||
if (result.render_type === "mermaid") {
|
||||
return <Mermaid chart={result.code.content} />;
|
||||
}
|
||||
if (result.render_type === "html") {
|
||||
return <HtmlRenderer html={result.code.content} />;
|
||||
}
|
||||
return <ChartJsRenderer config={result.code.content} />;
|
||||
}
|
||||
|
||||
export default function VisualizationViewer({
|
||||
result,
|
||||
}: {
|
||||
result: VisualizeResult;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// All hooks must run unconditionally before any early return — React
|
||||
// requires a stable hook order across renders. The text-path body below
|
||||
// is the only consumer of these states; the manim path returns earlier
|
||||
// and ignores them.
|
||||
const [showCode, setShowCode] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullscreen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setFullscreen(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [fullscreen]);
|
||||
|
||||
if (isManimResult(result)) {
|
||||
return <MathAnimatorViewer result={result.manim} />;
|
||||
}
|
||||
|
||||
// TypeScript narrows ``result`` to the text-only variant from here on.
|
||||
// HTML iframe already provides its own "Open in new tab" affordance; the
|
||||
// sandboxed iframe also doesn't behave well inside a re-rendered modal.
|
||||
const supportsFullscreen = result.render_type !== "html";
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(result.code.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
/* clipboard API may be unavailable */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Visualization area */}
|
||||
<div
|
||||
className={`relative ${
|
||||
result.render_type === "html"
|
||||
? "overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--background)]"
|
||||
: "overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--background)] p-4"
|
||||
}`}
|
||||
>
|
||||
{supportsFullscreen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFullscreen(true)}
|
||||
title={t("Fullscreen")}
|
||||
className="absolute right-2 top-2 z-10 inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--background)]/90 px-2 py-1 text-[10px] font-medium text-[var(--muted-foreground)] backdrop-blur transition-colors hover:text-[var(--foreground)]"
|
||||
>
|
||||
<Maximize2 size={10} strokeWidth={1.8} />
|
||||
{t("Fullscreen")}
|
||||
</button>
|
||||
)}
|
||||
{renderTextVisualization(result)}
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCode((prev) => !prev)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--background)] px-2.5 py-1.5 text-[11px] font-medium text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)]"
|
||||
>
|
||||
<Code2 size={12} strokeWidth={1.8} />
|
||||
{showCode ? t("Hide code") : t("Show code")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--background)] px-2.5 py-1.5 text-[11px] font-medium text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)]"
|
||||
>
|
||||
{copied ? (
|
||||
<Check size={12} strokeWidth={1.8} />
|
||||
) : (
|
||||
<Copy size={12} strokeWidth={1.8} />
|
||||
)}
|
||||
{copied ? t("Copied") : t("Copy code")}
|
||||
</button>
|
||||
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wider text-[var(--muted-foreground)]/50">
|
||||
{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"}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
{showCode && (
|
||||
<div className="md-code-block overflow-hidden rounded-xl border border-[var(--border)] bg-[#1f2937]">
|
||||
<div className="border-b border-white/10 px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-[#9ca3af]">
|
||||
{result.code.language}
|
||||
</div>
|
||||
<pre className="max-h-80 overflow-auto p-4 text-[13px] leading-relaxed text-[#e5e7eb]">
|
||||
<code>{result.code.content}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review notes */}
|
||||
{result.review.changed && result.review.review_notes && (
|
||||
<p className="text-[11px] text-[var(--muted-foreground)]">
|
||||
{t("Review")}: {result.review.review_notes}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Fullscreen overlay — rendered via portal: the message bubble sits
|
||||
inside transformed/overflow ancestors (streaming animations, chat
|
||||
scroll root), which break position:fixed and put the composer above
|
||||
the overlay. document.body has neither problem. */}
|
||||
{fullscreen &&
|
||||
supportsFullscreen &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[120] flex flex-col bg-black/85 p-4 backdrop-blur-sm"
|
||||
onClick={() => setFullscreen(false)}
|
||||
>
|
||||
<div className="mb-2 flex shrink-0 items-center justify-between text-white">
|
||||
<div className="text-xs uppercase tracking-wider opacity-80">
|
||||
{result.render_type === "svg"
|
||||
? "SVG"
|
||||
: result.render_type === "mermaid"
|
||||
? `Mermaid · ${result.analysis.chart_type || "diagram"}`
|
||||
: `Chart.js · ${result.analysis.chart_type || "chart"}`}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFullscreen(false);
|
||||
}}
|
||||
title={t("Close")}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-white/10 px-2.5 py-1.5 text-[11px] font-medium text-white transition-colors hover:bg-white/20"
|
||||
>
|
||||
<X size={12} strokeWidth={1.8} />
|
||||
{t("Close")}
|
||||
</button>
|
||||
</div>
|
||||
{/* m-auto (not items-center/justify-center) so oversized content
|
||||
stays scrollable from its start edge instead of clipping. */}
|
||||
<div
|
||||
className="flex flex-1 overflow-auto rounded-xl bg-[var(--card)] p-6 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="dt-viz-fullscreen m-auto w-full max-w-[1600px]">
|
||||
{renderTextVisualization(result)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
summarizeVisualizeConfig,
|
||||
type VisualizeFormConfig,
|
||||
} from "@/lib/visualize-types";
|
||||
import {
|
||||
CollapsibleConfigSection,
|
||||
Field,
|
||||
INPUT_CLS,
|
||||
} from "@/components/chat/home/composer-field";
|
||||
|
||||
interface VisualizeConfigPanelProps {
|
||||
value: VisualizeFormConfig;
|
||||
onChange: (next: VisualizeFormConfig) => void;
|
||||
/**
|
||||
* When provided, the panel is wrapped in a `CollapsibleConfigSection`.
|
||||
* Omit both to render bare for the chat Activity panel.
|
||||
*/
|
||||
collapsed?: boolean;
|
||||
onToggleCollapsed?: () => void;
|
||||
}
|
||||
|
||||
export default memo(function VisualizeConfigPanel({
|
||||
value,
|
||||
onChange,
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
}: VisualizeConfigPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const update = <K extends keyof VisualizeFormConfig>(
|
||||
key: K,
|
||||
val: VisualizeFormConfig[K],
|
||||
) => onChange({ ...value, [key]: val });
|
||||
|
||||
// Manim modes need extra knobs (render quality, style hint) — match what
|
||||
// the legacy Animator panel exposed so users don't lose granularity when
|
||||
// they pick "Animation" / "Storyboard" here.
|
||||
const isManim =
|
||||
value.render_mode === "manim_video" || value.render_mode === "manim_image";
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<Field label={t("Render Mode")} width="w-[140px]">
|
||||
<select
|
||||
value={value.render_mode}
|
||||
onChange={(e) =>
|
||||
update(
|
||||
"render_mode",
|
||||
e.target.value as VisualizeFormConfig["render_mode"],
|
||||
)
|
||||
}
|
||||
className={`${INPUT_CLS} w-full`}
|
||||
>
|
||||
<option value="auto">{t("Auto")}</option>
|
||||
<option value="chartjs">{t("Chart.js")}</option>
|
||||
<option value="svg">{t("SVG")}</option>
|
||||
<option value="mermaid">{t("Mermaid")}</option>
|
||||
<option value="html">{t("HTML")}</option>
|
||||
<option value="manim_video">{t("Animation")}</option>
|
||||
<option value="manim_image">{t("Storyboard")}</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{isManim ? (
|
||||
<>
|
||||
<Field label={t("Quality")} width="w-[100px]">
|
||||
<select
|
||||
value={value.quality}
|
||||
onChange={(e) =>
|
||||
update(
|
||||
"quality",
|
||||
e.target.value as VisualizeFormConfig["quality"],
|
||||
)
|
||||
}
|
||||
className={`${INPUT_CLS} w-full`}
|
||||
>
|
||||
<option value="low">{t("Low")}</option>
|
||||
<option value="medium">{t("Medium")}</option>
|
||||
<option value="high">{t("High")}</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label={t("Style Hint")} width="min-w-[160px] flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={value.style_hint}
|
||||
onChange={(e) => update("style_hint", e.target.value)}
|
||||
placeholder={t("Style, pacing, color...")}
|
||||
className={`${INPUT_CLS} w-full`}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (collapsed === undefined) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-x-3 gap-y-2 px-3.5 py-2.5">
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CollapsibleConfigSection
|
||||
collapsed={collapsed}
|
||||
summary={summarizeVisualizeConfig(value, t)}
|
||||
onToggleCollapsed={onToggleCollapsed ?? (() => undefined)}
|
||||
bodyClassName="flex flex-wrap items-end gap-x-3 gap-y-2 px-3.5 pb-2.5"
|
||||
>
|
||||
{body}
|
||||
</CollapsibleConfigSection>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
/* Theme for inlined visualization SVGs (rendered under .dt-svg-root).
|
||||
The codegen prompt (rules_svg) emits these pre-built classes instead of
|
||||
hard-coded fills, so figures follow the app's light/dark theme and font:
|
||||
text: .t (14px), .th (14px/500), .ts (12px muted)
|
||||
neutral: .box (container), .arr (arrow), .leader (dashed leader)
|
||||
interactive: .node (hover), [data-prompt] (clickable)
|
||||
color ramps: .c-{gray,blue,teal,coral,pink,purple,green,amber,red}
|
||||
Ramp values are Claude's 9-ramp palette. Light = 50 fill / 600 stroke /
|
||||
800 text; dark = 800 fill / 200 stroke / 100 text — switched via .dark. */
|
||||
|
||||
.dt-svg-root svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Chart.js wrapper: keep inline charts compact. The canvas itself must
|
||||
never be CSS-clamped — Chart.js owns the canvas size, and a stylesheet
|
||||
max-height would squish the bitmap non-uniformly. */
|
||||
.dt-chart-wrap {
|
||||
max-height: 480px;
|
||||
}
|
||||
|
||||
/* ---- Fullscreen overlay (.dt-viz-fullscreen wraps the figure) ----
|
||||
Fit every render type to the viewport in BOTH dimensions. */
|
||||
|
||||
/* SVG (ours + mermaid's): clamp height; the default preserveAspectRatio
|
||||
(xMidYMid meet) letterboxes the content inside the clamped box, so
|
||||
nothing distorts. Width-only scaling would overflow the screen. */
|
||||
.dt-viz-fullscreen svg {
|
||||
max-height: calc(100vh - 140px);
|
||||
}
|
||||
|
||||
/* Chart.js: cap the WIDTH so the chart's own aspect-ratio scaling (default
|
||||
2:1) lands within the viewport height, instead of clamping the canvas. */
|
||||
.dt-viz-fullscreen .dt-chart-wrap {
|
||||
max-height: none;
|
||||
max-width: min(100%, calc((100vh - 140px) * 2));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* Use the app sans font (Geist), overriding any font-family on the SVG itself
|
||||
and the serif (Lora) inherited from a surrounding .prose container. */
|
||||
.dt-svg-root,
|
||||
.dt-svg-root text {
|
||||
font-family: var(
|
||||
--font-sans,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
sans-serif
|
||||
);
|
||||
}
|
||||
|
||||
/* Vertical centering is guaranteed by the host so the model never has to
|
||||
emit (and possibly misspell) dominant-baseline itself: every text is
|
||||
centered on its y, and the codegen prompt instructs y = box/row center. */
|
||||
.dt-svg-root text {
|
||||
dominant-baseline: central;
|
||||
}
|
||||
|
||||
/* Text roles */
|
||||
.dt-svg-root text.t,
|
||||
.dt-svg-root text.th {
|
||||
font-size: 14px;
|
||||
fill: var(--foreground);
|
||||
}
|
||||
.dt-svg-root text.th {
|
||||
font-weight: 500;
|
||||
}
|
||||
.dt-svg-root text.ts {
|
||||
font-size: 12px;
|
||||
fill: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Neutral container + connectors */
|
||||
.dt-svg-root .box {
|
||||
fill: var(--secondary);
|
||||
stroke: var(--border);
|
||||
stroke-width: 0.5;
|
||||
}
|
||||
.dt-svg-root .arr {
|
||||
stroke: var(--muted-foreground);
|
||||
stroke-width: 1.5;
|
||||
fill: none;
|
||||
}
|
||||
.dt-svg-root .leader {
|
||||
stroke: var(--muted-foreground);
|
||||
stroke-width: 0.5;
|
||||
stroke-dasharray: 3 3;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
/* Interactive affordances */
|
||||
.dt-svg-root .node,
|
||||
.dt-svg-root [data-prompt] {
|
||||
cursor: pointer;
|
||||
}
|
||||
.dt-svg-root .node {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.dt-svg-root .node:hover,
|
||||
.dt-svg-root [data-prompt]:hover {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
/* Color ramps — each publishes --fill / --stroke / --text (light defaults). */
|
||||
.dt-svg-root .c-gray {
|
||||
--fill: #f1efe8;
|
||||
--stroke: #5f5e5a;
|
||||
--text: #2c2c2a;
|
||||
}
|
||||
.dt-svg-root .c-blue {
|
||||
--fill: #e6f1fb;
|
||||
--stroke: #185fa5;
|
||||
--text: #0c447c;
|
||||
}
|
||||
.dt-svg-root .c-teal {
|
||||
--fill: #e1f5ee;
|
||||
--stroke: #0f6e56;
|
||||
--text: #085041;
|
||||
}
|
||||
.dt-svg-root .c-coral {
|
||||
--fill: #faece7;
|
||||
--stroke: #993c1d;
|
||||
--text: #712b13;
|
||||
}
|
||||
.dt-svg-root .c-pink {
|
||||
--fill: #fbeaf0;
|
||||
--stroke: #993556;
|
||||
--text: #72243e;
|
||||
}
|
||||
.dt-svg-root .c-purple {
|
||||
--fill: #eeedfe;
|
||||
--stroke: #534ab7;
|
||||
--text: #3c3489;
|
||||
}
|
||||
.dt-svg-root .c-green {
|
||||
--fill: #eaf3de;
|
||||
--stroke: #3b6d11;
|
||||
--text: #27500a;
|
||||
}
|
||||
.dt-svg-root .c-amber {
|
||||
--fill: #faeeda;
|
||||
--stroke: #854f0b;
|
||||
--text: #633806;
|
||||
}
|
||||
.dt-svg-root .c-red {
|
||||
--fill: #fcebeb;
|
||||
--stroke: #a32d2d;
|
||||
--text: #791f1f;
|
||||
}
|
||||
|
||||
/* Dark mode: flip to 800 fill / 200 stroke / 100 text. */
|
||||
.dark .dt-svg-root .c-gray {
|
||||
--fill: #444441;
|
||||
--stroke: #b4b2a9;
|
||||
--text: #d3d1c7;
|
||||
}
|
||||
.dark .dt-svg-root .c-blue {
|
||||
--fill: #0c447c;
|
||||
--stroke: #85b7eb;
|
||||
--text: #b5d4f4;
|
||||
}
|
||||
.dark .dt-svg-root .c-teal {
|
||||
--fill: #085041;
|
||||
--stroke: #5dcaa5;
|
||||
--text: #9fe1cb;
|
||||
}
|
||||
.dark .dt-svg-root .c-coral {
|
||||
--fill: #712b13;
|
||||
--stroke: #f0997b;
|
||||
--text: #f5c4b3;
|
||||
}
|
||||
.dark .dt-svg-root .c-pink {
|
||||
--fill: #72243e;
|
||||
--stroke: #ed93b1;
|
||||
--text: #f4c0d1;
|
||||
}
|
||||
.dark .dt-svg-root .c-purple {
|
||||
--fill: #3c3489;
|
||||
--stroke: #afa9ec;
|
||||
--text: #cecbf6;
|
||||
}
|
||||
.dark .dt-svg-root .c-green {
|
||||
--fill: #27500a;
|
||||
--stroke: #97c459;
|
||||
--text: #c0dd97;
|
||||
}
|
||||
.dark .dt-svg-root .c-amber {
|
||||
--fill: #633806;
|
||||
--stroke: #ef9f27;
|
||||
--text: #fac775;
|
||||
}
|
||||
.dark .dt-svg-root .c-red {
|
||||
--fill: #791f1f;
|
||||
--stroke: #f09595;
|
||||
--text: #f7c1c1;
|
||||
}
|
||||
|
||||
/* Apply ramp vars: shapes take fill+stroke, descendant text takes ramp text. */
|
||||
.dt-svg-root
|
||||
:is(
|
||||
.c-gray,
|
||||
.c-blue,
|
||||
.c-teal,
|
||||
.c-coral,
|
||||
.c-pink,
|
||||
.c-purple,
|
||||
.c-green,
|
||||
.c-amber,
|
||||
.c-red
|
||||
)
|
||||
> :is(rect, circle, ellipse, polygon),
|
||||
.dt-svg-root
|
||||
:is(rect, circle, ellipse, polygon):is(
|
||||
.c-gray,
|
||||
.c-blue,
|
||||
.c-teal,
|
||||
.c-coral,
|
||||
.c-pink,
|
||||
.c-purple,
|
||||
.c-green,
|
||||
.c-amber,
|
||||
.c-red
|
||||
) {
|
||||
fill: var(--fill);
|
||||
stroke: var(--stroke);
|
||||
}
|
||||
.dt-svg-root
|
||||
:is(
|
||||
.c-gray,
|
||||
.c-blue,
|
||||
.c-teal,
|
||||
.c-coral,
|
||||
.c-pink,
|
||||
.c-purple,
|
||||
.c-green,
|
||||
.c-amber,
|
||||
.c-red
|
||||
)
|
||||
text {
|
||||
fill: var(--text);
|
||||
}
|
||||
Reference in New Issue
Block a user