diff --git a/app/biome.json b/app/biome.json index 5b253fa..b861323 100644 --- a/app/biome.json +++ b/app/biome.json @@ -1,48 +1,48 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json", - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": false - }, - "formatter": { - "enabled": true, - "indentStyle": "tab", - "indentWidth": 2, - "lineWidth": 100 - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "correctness": { - "useExhaustiveDependencies": "warn", - "noUnusedImports": "warn", - "noUnusedVariables": "warn" - }, - "style": { - "noNonNullAssertion": "off" - }, - "suspicious": { - "noExplicitAny": "warn", - "noArrayIndexKey": "warn" - }, - "a11y": { - "useButtonType": "warn" - }, - "performance": { - "noImgElement": "warn" - }, - "security": { - "noDangerouslySetInnerHtml": "warn" - } - } - }, - "javascript": { - "formatter": { - "quoteStyle": "double", - "semicolons": "always", - "trailingCommas": "es5" - } - } + "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": false + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "useExhaustiveDependencies": "warn", + "noUnusedImports": "warn", + "noUnusedVariables": "warn" + }, + "style": { + "noNonNullAssertion": "off" + }, + "suspicious": { + "noExplicitAny": "warn", + "noArrayIndexKey": "warn" + }, + "a11y": { + "useButtonType": "warn" + }, + "performance": { + "noImgElement": "warn" + }, + "security": { + "noDangerouslySetInnerHtml": "warn" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always", + "trailingCommas": "es5" + } + } } diff --git a/app/next.config.ts b/app/next.config.ts index 35a9619..d0d6345 100644 --- a/app/next.config.ts +++ b/app/next.config.ts @@ -1,18 +1,18 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - reactCompiler: true, - // Proxy tier2 streaming tool requests directly to the Python runner, - // bypassing the Next.js API route runtime which kills long-lived streams. - async rewrites() { - const runnerUrl = process.env.TOOL_RUNNER_URL || "http://localhost:9080"; - return [ - { - source: "/api/tools-stream/:toolId", - destination: `${runnerUrl}/api/tools/:toolId`, - }, - ]; - }, + reactCompiler: true, + // Proxy tier2 streaming tool requests directly to the Python runner, + // bypassing the Next.js API route runtime which kills long-lived streams. + async rewrites() { + const runnerUrl = process.env.TOOL_RUNNER_URL || "http://localhost:9080"; + return [ + { + source: "/api/tools-stream/:toolId", + destination: `${runnerUrl}/api/tools/:toolId`, + }, + ]; + }, }; export default nextConfig; diff --git a/app/package-lock.json b/app/package-lock.json index 84038a9..5e07519 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -19,7 +19,8 @@ "react-dom": "19.2.4", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "undici": "^8.1.0" }, "devDependencies": { "@biomejs/biome": "2.4.6", @@ -9251,6 +9252,15 @@ "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "license": "MIT" }, + "node_modules/undici": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.1.0.tgz", + "integrity": "sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/app/package.json b/app/package.json index 6b8d9ab..b492e40 100644 --- a/app/package.json +++ b/app/package.json @@ -1,36 +1,36 @@ { - "name": "oxtools", - "version": "1.0.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "biome check", - "format": "biome format --write" - }, - "dependencies": { - "@ansospace/ui": "^0.0.3", - "@icons-pack/react-simple-icons": "^13.13.0", - "idb-keyval": "^6.2.2", - "lucide-react": "^1.7.0", - "mermaid": "^11.14.0", - "next": "16.2.2", - "openai": "^6.33.0", - "react": "19.2.4", - "react-dom": "19.2.4", - "react-markdown": "^10.1.0", - "rehype-highlight": "^7.0.2", - "remark-gfm": "^4.0.1" - }, - "devDependencies": { - "@biomejs/biome": "2.4.6", - "@tailwindcss/postcss": "^4", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "babel-plugin-react-compiler": "1.0.0", - "tailwindcss": "^4", - "typescript": "^5" - } + "name": "oxtools", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "biome check", + "format": "biome format --write" + }, + "dependencies": { + "@ansospace/ui": "^0.0.3", + "@icons-pack/react-simple-icons": "^13.13.0", + "idb-keyval": "^6.2.2", + "lucide-react": "^1.7.0", + "mermaid": "^11.14.0", + "next": "16.2.2", + "openai": "^6.33.0", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-markdown": "^10.1.0", + "rehype-highlight": "^7.0.2", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@biomejs/biome": "2.4.6", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "babel-plugin-react-compiler": "1.0.0", + "tailwindcss": "^4", + "typescript": "^5" + } } diff --git a/app/postcss.config.mjs b/app/postcss.config.mjs index 79bcf13..6ba14df 100644 --- a/app/postcss.config.mjs +++ b/app/postcss.config.mjs @@ -1,8 +1,8 @@ /** @type {import('postcss-load-config').Config} */ const config = { - plugins: { - "@tailwindcss/postcss": {}, - }, + plugins: { + "@tailwindcss/postcss": {}, + }, }; export default config; diff --git a/app/src/app/api/tools/[toolId]/route.ts b/app/src/app/api/tools/[toolId]/route.ts index 7539717..247430d 100644 --- a/app/src/app/api/tools/[toolId]/route.ts +++ b/app/src/app/api/tools/[toolId]/route.ts @@ -1,24 +1,12 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; - +import { type NextRequest, NextResponse } from "next/server"; import { createToolRoute } from "@/lib/create-tool-route"; import { getToolById } from "@/lib/tools/registry"; - -// Allow long-running tool executions (up to 5 min locally, 300s on Vercel) +// Issue 5: maxDuration is set to 300s (5 minutes). +// We align the AbortController timeout to match this limit exactly, +// so that requests gracefully abort rather than hanging when Vercel kills the function. export const maxDuration = 300; -// Force dynamic rendering — prevents Next.js from evaluating this route -// at build time (which would fail without OXLO_API_KEY in CI) export const dynamic = "force-dynamic"; -/** - * Dynamic API route for ALL tools. - * - * TIER 1: Dispatches to createToolRoute (calls Oxlo LLM API directly). - * TIER 2: Proxies to the unified Python tool runner on port 9080. - * - * The unified runner handles ALL Python tools on ONE port. - * Route: POST http://localhost:9080/api/tools/{toolId} - */ export async function POST( request: NextRequest, { params }: { params: Promise<{ toolId: string }> } @@ -33,12 +21,10 @@ export async function POST( ); } - // --- Tier 2: Proxy to unified Python tool runner --- if (tool.tier === "tier2") { return proxyToToolRunner(request, toolId); } - // --- Tier 1: Handle via LLM prompt (default) --- const handler = createToolRoute({ requiredFields: tool.requiredFields, buildSystemPrompt: tool.buildSystemPrompt, @@ -46,18 +32,9 @@ export async function POST( defaultModel: tool.defaultModel, errorMessage: `Failed to execute ${tool.name}`, }); - return handler(request); } -/** - * Proxy a request to the unified Python tool runner. - * - * ALL Tier 2 tools run on ONE service (port 9080) via: - * POST http://runner:9080/api/tools/{toolId} - * - * No more port-per-tool. One port, one container, unlimited tools. - */ async function proxyToToolRunner(request: NextRequest, toolId: string) { const runnerUrl = process.env.TOOL_RUNNER_URL || "http://localhost:9080"; const targetUrl = `${runnerUrl}/api/tools/${toolId}`; @@ -66,12 +43,12 @@ async function proxyToToolRunner(request: NextRequest, toolId: string) { const body = await request.text(); const contentType = request.headers.get("content-type") || "application/json"; - // 10 minute timeout — security scans with LLM retries can take 5-10 min + // 5 minute timeout — security scans with LLM retries can take 5 min const response = await fetch(targetUrl, { method: "POST", headers: { "Content-Type": contentType }, body, - signal: AbortSignal.timeout(600_000), + signal: AbortSignal.timeout(300_000), }); if (!response.ok) { diff --git a/app/src/app/tools/[toolId]/page.tsx b/app/src/app/tools/[toolId]/page.tsx index 154a99e..9b66469 100644 --- a/app/src/app/tools/[toolId]/page.tsx +++ b/app/src/app/tools/[toolId]/page.tsx @@ -1,9 +1,9 @@ "use client"; - import { Button, Label, Textarea } from "@ansospace/ui"; import { ArrowUpRight, Crown, Lock, Play, X } from "lucide-react"; +import NextImage from "next/image"; import { notFound, useParams } from "next/navigation"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { CodeEditor } from "@/components/code-editor"; import { ResultViewer } from "@/components/result-viewer"; import { ToolLayout } from "@/components/tool-layout"; @@ -14,27 +14,62 @@ import { getToolById } from "@/lib/tools/registry"; import { useAuth } from "@/providers/auth-provider"; import type { InputFieldConfig } from "@/types"; +// Helper function to compress images using canvas +async function compressImage(base64: string, quality: number = 0.8): Promise { + return new Promise((resolve) => { + const img = document.createElement("img"); + img.onload = () => { + const canvas = document.createElement("canvas"); + let width = img.width; + let height = img.height; + + // Scale down if image is too large (max 2000px on longest side) + const maxDim = 2000; + if (width > maxDim || height > maxDim) { + const ratio = Math.min(maxDim / width, maxDim / height); + width = Math.floor(width * ratio); + height = Math.floor(height * ratio); + } + + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.drawImage(img, 0, 0, width, height); + const compressed = canvas.toDataURL("image/jpeg", quality); + resolve(compressed); + } else { + resolve(base64); + } + }; + img.onerror = () => resolve(base64); + img.src = base64; + }); +} + /** * Dynamic tool page - renders any tool from the registry. * * Contributors only need to create a ToolDefinition file in * src/lib/tools/.ts - this page handles the rest. */ + export default function DynamicToolPage() { const params = useParams<{ toolId: string }>(); const tool = getToolById(params.toolId); - if (!tool || tool.status !== "active") { notFound(); } - return ; } function ToolPageContent({ toolId }: { toolId: string }) { const tool = getToolById(toolId)!; + // Model is hardcoded per tool - no user selection const model = tool.defaultModel || "llama-3.3-70b"; + const [fields, setFields] = useState>(() => { const initial: Record = {}; for (const input of tool.inputs) { @@ -50,6 +85,7 @@ function ToolPageContent({ toolId }: { toolId: string }) { const { result, isLoading, error, execute, setResult } = useToolExecution({ apiEndpoint: `${apiBase}/${tool.id}`, toolId: tool.id, + ...(tool.timeoutMs && { timeoutMs: tool.timeoutMs }), }); const setField = useCallback((key: string, value: string) => { @@ -69,13 +105,32 @@ function ToolPageContent({ toolId }: { toolId: string }) { ); const { canExecute, getToolUsage, trackExecution, redirectToUpgrade } = useAuth(); - const toolUsage = getToolUsage(tool.id); - const [showUpgradeDialog, setShowUpgradeDialog] = useState(false); - // Defer localStorage-dependent rendering to prevent hydration mismatch. - // Server always renders the "Run" button; limit state only applies after mount. + + // ── HYDRATION FIX ────────────────────────────────────────────────────────── + // getToolUsage reads from localStorage/cookies which don't exist on the server. + // We defer the real value until after mount so SSR and client agree on the + // initial render (both see the zero/default state), then the effect below + // runs on the client and updates to the real value. const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); + // Always call the hook (Rules of Hooks) — but only use its value post-mount. + const rawToolUsage = getToolUsage(tool.id); + const toolUsage = mounted + ? rawToolUsage + : { + // Issue 8: use optional chaining + safe defaults to prevent SSR TypeError + // when getToolUsage returns undefined or an incomplete object before hydration. + used: 0, + limit: rawToolUsage?.limit ?? 0, + remaining: rawToolUsage?.limit ?? 0, + limitReached: false, + plan: rawToolUsage?.plan ?? "free", + }; + // ── END HYDRATION FIX ────────────────────────────────────────────────────── + + const [showUpgradeDialog, setShowUpgradeDialog] = useState(false); + // Show popup when limit is newly reached useEffect(() => { if (mounted && toolUsage.limitReached) { @@ -101,6 +156,11 @@ function ToolPageContent({ toolId }: { toolId: string }) { // Check if all required fields are filled const isReady = tool.requiredFields.every((field) => fields[field]?.trim()); + const uploadedImageSrc = useMemo(() => { + const imageKey = tool.inputs.find((i) => i.type === "image")?.key; + return imageKey ? fields[imageKey] : undefined; + }, [tool.inputs, fields]); + return ( <> Upgrade your plan for more daily executions.{" "} + +
+ ({hoveredPixel.x}, {hoveredPixel.y}) +
+ {highlightedPaletteColor && ( +
+ Match: {highlightedPaletteColor} +
+ )} + + )} + + + {data.imageWidth && data.imageHeight && ( +

+ Resolution: {data.imageWidth} × {data.imageHeight}px +

+ )} + {!canvasReady && ( +

Loading image preview…

+ )} + + + )} + {data.extractedColors && data.extractedColors.length > 0 && ( + + +
+

Top Colors from Image

+ +
+
+ {data.extractedColors.map((color) => { + const isHighlighted = highlightedPaletteColor === color; + return ( + + ); + })} +
+
+
+ )} + + ); +} diff --git a/app/src/components/result-viewer.tsx b/app/src/components/result-viewer.tsx index 8231460..c07b72e 100644 --- a/app/src/components/result-viewer.tsx +++ b/app/src/components/result-viewer.tsx @@ -5,7 +5,6 @@ import { AlertCircle, Check, Copy, - Download, ExternalLink, Key, Lock, @@ -17,6 +16,7 @@ import mermaid from "mermaid"; import { useCallback, useEffect, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { ColorPaletteViewer } from "./color-palette-viewer"; mermaid.initialize({ startOnLoad: false, @@ -46,6 +46,12 @@ function MermaidViewer({ chart }: { chart: string }) { }; }, [chart]); + useEffect(() => { + if (containerRef.current) { + containerRef.current.innerHTML = svg; + } + }, [svg]); + if (error) { return (
@@ -60,7 +66,6 @@ function MermaidViewer({ chart }: { chart: string }) {
); } @@ -72,6 +77,7 @@ interface ResultViewerProps { isLoading?: boolean; error?: ToolError | string | null; streaming?: boolean; + uploadedImageSrc?: string; onOpenSettings?: () => void; } @@ -212,28 +218,33 @@ export function ResultViewer({ isLoading, error, streaming, + uploadedImageSrc, onOpenSettings, }: ResultViewerProps) { const [copiedAll, setCopiedAll] = useState(false); + const [copiedExtracted, setCopiedExtracted] = useState(false); const [activeTab, setActiveTab] = useState<"preview" | "code">("preview"); + type StructuredResult = { + code?: string; + palette?: Record; + roles?: Record; + image?: string; + extractedColors?: string[]; + [key: string]: unknown; + }; + const handleCopyAll = useCallback(async () => { await navigator.clipboard.writeText(result); setCopiedAll(true); setTimeout(() => setCopiedAll(false), 2000); }, [result]); - const handleDownload = useCallback(() => { - const blob = new Blob([result], { type: "text/markdown;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `devkernel-ai-result-${Date.now()}.md`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - }, [result]); + const handleCopyExtractedAll = useCallback(async (colors: string[]) => { + await navigator.clipboard.writeText(colors.join("\n")); + setCopiedExtracted(true); + setTimeout(() => setCopiedExtracted(false), 2000); + }, []); if (error) { // Normalize string errors to ToolError shape @@ -272,46 +283,160 @@ export function ResultViewer({ ); } - let parsedJson: { code?: string; [key: string]: any } | null = null; - let displayMarkdown = result; + let parsedJson: StructuredResult | null = null; + let displayMarkdown = ""; let pipelineLogs = ""; let isReportStarted = false; + let extractedColorsPreview: string[] = []; + + let outputContent = result; + + const extractStructuredBlock = (text: string, startMarker: string, endMarker?: string) => { + const startIndex = text.indexOf(startMarker); + if (startIndex === -1) return null; + + const startContentIndex = startIndex + startMarker.length; + if (endMarker) { + const endIndex = text.indexOf(endMarker, startContentIndex); + return text.slice(startContentIndex, endIndex === -1 ? undefined : endIndex).trim(); + } + + return text.slice(startContentIndex).trim(); + }; + + const extractBalancedJson = (text: string) => { + const startIndex = text.indexOf("{"); + if (startIndex === -1) return null; + + let depth = 0; + let inString = false; + let escaped = false; + + for (let index = startIndex; index < text.length; index += 1) { + const character = text[index]; + + if (escaped) { + escaped = false; + continue; + } + + if (character === "\\") { + escaped = true; + continue; + } + + if (character === '"') { + inString = !inString; + continue; + } + + if (inString) continue; + + if (character === "{") depth += 1; + if (character === "}") { + depth -= 1; + if (depth === 0) { + return text.slice(startIndex, index + 1); + } + } + } + + return null; + }; if (result) { - if (result.includes("---REPORT_START---")) { + // Check for output markers (both old and new format) + if (result.includes("---OUTPUT_START---")) { + const structuredBlock = extractStructuredBlock( + result, + "---OUTPUT_START---", + "---OUTPUT_END---" + ); + if (structuredBlock) { + outputContent = structuredBlock; + } else { + const parts = result.split("---OUTPUT_START---"); + pipelineLogs = parts[0].trim(); + outputContent = parts[1] || ""; + } + isReportStarted = true; + } else if (result.includes("---REPORT_START---")) { const parts = result.split("---REPORT_START---"); pipelineLogs = parts[0].trim(); - displayMarkdown = parts[1] || ""; + outputContent = parts[1] || ""; isReportStarted = true; } else if (streaming && isLoading) { - // If we haven't hit the report marker yet, everything is logs. + // If we haven't hit the output marker yet, everything is logs. pipelineLogs = result.trim(); - displayMarkdown = ""; + outputContent = ""; } } - if (result) { + // Extract early KMeans colors from logs (arrives before OUTPUT_START) + const extractedSource = pipelineLogs || result || ""; + if (extractedSource.includes("---EXTRACTED_COLORS_START---")) { + try { + // Prefer regex so we survive streaming chunk boundaries / extra whitespace. + const match = extractedSource.match( + /---EXTRACTED_COLORS_START---\s*([\s\S]*?)\s*---EXTRACTED_COLORS_END---/m + ); + if (match?.[1]) { + const parsed = JSON.parse(match[1].trim()); + if (Array.isArray(parsed?.extractedColors)) extractedColorsPreview = parsed.extractedColors; + } + } catch { + // ignore - preview is optional + } + } + + // Hide the extracted-colors JSON block from the logs viewer (UI already shows swatches) + const pipelineLogsDisplay = pipelineLogs + ? pipelineLogs.replace( + /---EXTRACTED_COLORS_START---[\s\S]*?---EXTRACTED_COLORS_END---\s*/gm, + "" + ) + : pipelineLogs; + + if (outputContent) { try { - const trimmed = result.trim(); - if (trimmed.startsWith("{") && trimmed.endsWith("}")) { - const parsed = JSON.parse(trimmed); - if (typeof parsed.code === "string") { + const trimmed = outputContent.trim(); + const jsonCandidate = + trimmed.startsWith("{") && trimmed.endsWith("}") ? trimmed : extractBalancedJson(trimmed); + if (jsonCandidate) { + const parsed = JSON.parse(jsonCandidate) as StructuredResult; + // Check for color palette JSON + if ( + parsed.palette && + typeof parsed.palette === "object" && + parsed.roles && + typeof parsed.roles === "object" + ) { + parsedJson = parsed; + displayMarkdown = ""; // Don't show markdown for palettes + } else if (typeof parsed.code === "string") { parsedJson = parsed; displayMarkdown = `\`\`\`html\n${parsed.code}\n\`\`\``; } } } catch (_e) { - // Ignore parsing errors + // Ignore parsing errors - fall back to markdown rendering + displayMarkdown = outputContent; } // Fallback: Check if the result embeds a standalone HTML block in markdown if (!parsedJson) { const htmlBlockRegex = /```(?:html)?\s*([\s\S]*?") || result.trim().startsWith("") || + outputContent.trim().startsWith(" + {/* Early extracted-colors preview (shows before final palette) */} + {extractedColorsPreview.length > 0 && ( + + +
+
+
+

Extracted Colors

+ + {extractedColorsPreview.length} + +
+

+ {isLoading ? "Showing fast extraction while refining palette…" : "Ready"} +

+
+ +
+ +
+
+ +
+
+ {extractedColorsPreview.map((color) => ( + + ))} +
+
+
+
+ )} + {/* Pipeline Logs Viewer */} - {pipelineLogs && ( + {pipelineLogsDisplay && (
@@ -343,7 +529,7 @@ export function ResultViewer({
- {pipelineLogs.split("\n").map((line, i) => { + {pipelineLogsDisplay.split("\n").map((line, _i) => { if (!line.trim() || line === ".") return null; let textColor = "text-zinc-400"; if (line.startsWith("[")) { @@ -357,7 +543,7 @@ export function ResultViewer({ textColor = "text-red-400"; } return ( -
+
{line}
); @@ -383,8 +569,67 @@ export function ResultViewer({ )} - {/* Final Report Viewer */} - {(isReportStarted || displayMarkdown) && ( + {/* If result contains an image or extracted colors but no full palette, + render an interactive preview so the uploaded image can be inspected locally. */} + {(() => { + let previewData: StructuredResult | null = null; + try { + const candidate = ( + outputContent ? JSON.parse(outputContent.trim()) : null + ) as StructuredResult | null; + if (candidate && (candidate.image || Array.isArray(candidate.extractedColors))) { + previewData = { + ...candidate, + image: candidate.image || uploadedImageSrc, + }; + } + } catch { + // ignore parse errors + } + + if (previewData) { + return ( + + + + + + ); + } + + // Fallback to the regular palette viewer when full palette + roles exist + if (parsedJson?.palette && parsedJson?.roles) { + return ( + +
+ +
+ + + +
+ ); + } + return null; + })()} + + {/* Final Report Viewer - Shows when palette/roles DON'T exist */} + {(isReportStarted || displayMarkdown) && !(parsedJson?.palette && parsedJson?.roles) && (
-
{hasHtmlCode && (
+
+ )} + + {/* State 2: Loading */} + {loading && ( +
+
+

{status || 'Working...'}

+
+ )} + + {/* State 3: Results */} + {resultHtml && !loading && ( +
+
+ + +
+ +
+ {activeTab === 'preview' ? ( +
+
+