Files
ForgeLM/tauri-app/src/components/chat/ArtifactViewer.tsx
T
Evandro Fonseca Junior 77c57c3d45 Add runtime state validation before ForgeAgent HTTP call
- Check process_running and ready state before making inference request
- Emit descriptive error messages for each failure scenario
- Handle local vs remote connection modes appropriately
- Validation score: 100 (was 0)

Result: {"status":"keep","validation_score":100,"response_time_ms":500}
2026-04-02 14:37:01 -03:00

305 lines
14 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import hljs from "highlight.js/lib/core";
import bash from "highlight.js/lib/languages/bash";
import c from "highlight.js/lib/languages/c";
import cpp from "highlight.js/lib/languages/cpp";
import css from "highlight.js/lib/languages/css";
import go from "highlight.js/lib/languages/go";
import javascript from "highlight.js/lib/languages/javascript";
import json from "highlight.js/lib/languages/json";
import python from "highlight.js/lib/languages/python";
import rust from "highlight.js/lib/languages/rust";
import sql from "highlight.js/lib/languages/sql";
import typescript from "highlight.js/lib/languages/typescript";
import xml from "highlight.js/lib/languages/xml";
import { ensureHljs, renderMarkdown } from "../../lib/markdown";
interface ArtifactViewerProps {
artifact: {
filename: string;
content: string;
language?: string;
};
onDownload?: () => void;
}
let codeHlReady = false;
function ensureCodeHljs() {
if (codeHlReady) return;
hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("typescript", typescript);
hljs.registerLanguage("python", python);
hljs.registerLanguage("rust", rust);
hljs.registerLanguage("bash", bash);
hljs.registerLanguage("json", json);
hljs.registerLanguage("sql", sql);
hljs.registerLanguage("css", css);
hljs.registerLanguage("xml", xml);
hljs.registerLanguage("go", go);
hljs.registerLanguage("cpp", cpp);
hljs.registerLanguage("c", c);
codeHlReady = true;
}
function getExtension(filename: string) {
const trimmed = filename.trim().toLowerCase();
const parts = trimmed.split(".");
if (parts.length < 2) return "";
return parts[parts.length - 1] ?? "";
}
function getLanguage(filename: string, language?: string) {
if (language) return language.toLowerCase();
const ext = getExtension(filename);
const map: Record<string, string> = {
md: "markdown",
markdown: "markdown",
ts: "typescript",
tsx: "typescript",
js: "javascript",
jsx: "javascript",
py: "python",
rs: "rust",
go: "go",
c: "c",
h: "c",
cpp: "cpp",
cc: "cpp",
cxx: "cpp",
css: "css",
html: "xml",
xml: "xml",
json: "json",
sql: "sql",
sh: "bash",
bash: "bash",
zsh: "bash",
};
return map[ext] ?? ext ?? "plaintext";
}
function fileTypeTone(ext: string) {
switch (ext) {
case "md":
case "markdown":
return "from-sky-400/20 to-cyan-400/10 text-sky-300 border-sky-500/20";
case "json":
return "from-emerald-400/20 to-teal-400/10 text-emerald-300 border-emerald-500/20";
case "ts":
case "tsx":
case "js":
case "jsx":
return "from-indigo-400/20 to-violet-400/10 text-indigo-300 border-indigo-500/20";
case "py":
return "from-amber-400/20 to-orange-400/10 text-amber-300 border-amber-500/20";
default:
return "from-zinc-400/20 to-zinc-500/10 text-zinc-300 border-zinc-700";
}
}
function FileGlyph({ filename }: { filename: string }) {
const ext = getExtension(filename);
const tone = fileTypeTone(ext);
if (ext === "md" || ext === "markdown") {
return (
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl border bg-gradient-to-br ${tone}`}>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M7 3.75h7l4.25 4.25V20.25H7z" strokeLinejoin="round" />
<path d="M14 3.75v4.5h4.25" strokeLinejoin="round" />
<path d="M10 11.25h4" strokeLinecap="round" />
<path d="M10 14.25h7" strokeLinecap="round" />
<path d="M10 17.25h5" strokeLinecap="round" />
</svg>
</div>
);
}
if (["ts", "tsx", "js", "jsx", "c", "cpp", "go", "rs", "py"].includes(ext)) {
return (
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl border bg-gradient-to-br ${tone}`}>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M9 8 5 12l4 4" strokeLinecap="round" strokeLinejoin="round" />
<path d="m15 8 4 4-4 4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
);
}
if (ext === "json") {
return (
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl border bg-gradient-to-br ${tone}`}>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M9 8c-1.3 0-2 .7-2 2v1c0 .7-.3 1.3-.9 1.7-.6.4-1.1 1-.1 1.4.8.3 1 .8 1 1.5v1c0 1.3.7 2 2 2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M15 8c1.3 0 2 .7 2 2v1c0 .7.3 1.3.9 1.7.6.4 1.1 1 .1 1.4-.8.3-1 .8-1 1.5v1c0 1.3-.7 2-2 2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
);
}
return (
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl border bg-gradient-to-br ${tone}`}>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M7 3.75h7l4.25 4.25V20.25H7z" strokeLinejoin="round" />
<path d="M14 3.75v4.5h4.25" strokeLinejoin="round" />
</svg>
</div>
);
}
function downloadFile(filename: string, content: string, language?: string) {
const safeFilename = filename || "artifact.txt";
const lower = safeFilename.toLowerCase();
const mime =
language === "markdown" || lower.endsWith(".md")
? "text/markdown;charset=utf-8"
: "text/plain;charset=utf-8";
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = safeFilename;
link.click();
URL.revokeObjectURL(url);
}
export default function ArtifactViewer({ artifact, onDownload }: ArtifactViewerProps) {
const [expanded, setExpanded] = useState(true);
const [copied, setCopied] = useState(false);
const ext = getExtension(artifact.filename);
const isMarkdown = ext === "md" || ext === "markdown" || artifact.language?.toLowerCase() === "markdown";
const language = getLanguage(artifact.filename, artifact.language);
const previewId = useMemo(() => `artifact-preview-${artifact.filename.replace(/[^a-zA-Z0-9_-]+/g, "-")}`, [artifact.filename]);
useEffect(() => {
void ensureHljs();
ensureCodeHljs();
}, []);
const previewHtml = useMemo(() => {
if (isMarkdown) return renderMarkdown(artifact.content);
let highlighted = artifact.content;
try {
highlighted = language !== "plaintext" && hljs.getLanguage(language)
? hljs.highlight(artifact.content, { language }).value
: hljs.highlightAuto(artifact.content).value;
} catch {
highlighted = artifact.content.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
return `<pre class="hljs rounded-2xl border border-zinc-800 bg-zinc-950 p-4 text-sm leading-7 text-zinc-200 overflow-auto"><code class="language-${language}">${highlighted}</code></pre>`;
}, [artifact.content, isMarkdown, language]);
const handleCopy = async () => {
await navigator.clipboard.writeText(artifact.content);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
};
const handleDownload = () => {
if (onDownload) {
onDownload();
return;
}
downloadFile(artifact.filename, artifact.content, artifact.language ?? language);
};
return (
<section className="overflow-hidden rounded-3xl border border-zinc-800/80 bg-zinc-950 text-zinc-100 shadow-[0_24px_80px_-36px_rgba(0,0,0,0.9)]">
<header className="border-b border-zinc-800/80 bg-gradient-to-b from-zinc-900/80 to-zinc-950 px-4 py-4 sm:px-5">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<FileGlyph filename={artifact.filename} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-sm font-semibold text-zinc-50" title={artifact.filename}>
{artifact.filename}
</h2>
<span className="rounded-full border border-zinc-800 bg-zinc-900 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.22em] text-zinc-400">
{language}
</span>
</div>
<p className="mt-1 text-xs leading-5 text-zinc-500">
Preview, copy, or download the generated artifact.
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/80 px-3 py-2 text-sm font-medium text-zinc-100 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M9 9h10v10H9z" strokeLinejoin="round" />
<path d="M5 15V5h10" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{copied ? "Copied" : "Copy"}
</button>
<button
type="button"
onClick={handleDownload}
className="inline-flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/80 px-3 py-2 text-sm font-medium text-zinc-100 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M12 4v10" strokeLinecap="round" />
<path d="m8 10 4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
<path d="M5 20h14" strokeLinecap="round" />
</svg>
Download
</button>
<button
type="button"
aria-expanded={expanded}
aria-controls={previewId}
onClick={() => setExpanded((value) => !value)}
className="inline-flex items-center rounded-xl border border-zinc-700 bg-zinc-900/80 p-2 text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
>
<svg
viewBox="0 0 24 24"
className={`h-4 w-4 transition-transform duration-200 ${expanded ? "rotate-180" : "rotate-0"}`}
fill="none"
stroke="currentColor"
strokeWidth="1.8"
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
</div>
</header>
{expanded ? (
<div
id={previewId}
className="max-h-[70vh] overflow-auto p-4 sm:p-5"
>
{isMarkdown ? (
<div
className="max-w-none text-sm leading-7 text-zinc-300 [&_h1]:mb-4 [&_h1]:text-3xl [&_h1]:font-semibold [&_h1]:tracking-tight [&_h1]:text-zinc-50 [&_h2]:mb-3 [&_h2]:mt-7 [&_h2]:text-2xl [&_h2]:font-semibold [&_h2]:text-zinc-50 [&_h3]:mb-2 [&_h3]:mt-5 [&_h3]:text-xl [&_h3]:font-semibold [&_h3]:text-zinc-100 [&_h4]:mt-5 [&_h4]:text-lg [&_h4]:font-semibold [&_h4]:text-zinc-100 [&_p]:my-3 [&_p]:text-zinc-300 [&_a]:text-sky-400 [&_a]:underline [&_a:hover]:text-sky-300 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:space-y-2 [&_ul]:pl-6 [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:space-y-2 [&_ol]:pl-6 [&_li]:text-zinc-300 [&_blockquote]:my-4 [&_blockquote]:border-l-2 [&_blockquote]:border-zinc-700 [&_blockquote]:pl-4 [&_blockquote]:text-zinc-400 [&_blockquote]:italic [&_hr]:my-6 [&_hr]:border-zinc-800 [&_table]:my-4 [&_table]:w-full [&_table]:border-collapse [&_table]:overflow-hidden [&_table]:rounded-lg [&_table]:border [&_table]:border-zinc-800 [&_th]:border [&_th]:border-zinc-800 [&_th]:bg-zinc-900 [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:font-semibold [&_th]:text-zinc-100 [&_td]:border [&_td]:border-zinc-800 [&_td]:px-3 [&_td]:py-2 [&_td]:text-zinc-300 [&_code:not(.hljs)]:rounded [&_code:not(.hljs)]:border [&_code:not(.hljs)]:border-zinc-800 [&_code:not(.hljs)]:bg-zinc-900 [&_code:not(.hljs)]:px-1.5 [&_code:not(.hljs)]:py-0.5 [&_code:not(.hljs)]:font-mono [&_code:not(.hljs)]:text-[0.85em] [&_.code-block]:my-4 [&_.code-block]:overflow-hidden [&_.code-block]:rounded-2xl [&_.code-block]:border [&_.code-block]:border-zinc-800 [&_.code-block]:bg-zinc-950 [&_.code-header]:flex [&_.code-header]:items-center [&_.code-header]:justify-between [&_.code-header]:border-b [&_.code-header]:border-zinc-800 [&_.code-header]:bg-zinc-900/80 [&_.code-header]:px-3 [&_.code-header]:py-2 [&_.code-lang]:font-mono [&_.code-lang]:text-[11px] [&_.code-lang]:uppercase [&_.code-lang]:tracking-[0.22em] [&_.code-lang]:text-zinc-500 [&_.copy-code-btn]:rounded-lg [&_.copy-code-btn]:border [&_.copy-code-btn]:border-zinc-700 [&_.copy-code-btn]:bg-zinc-900 [&_.copy-code-btn]:px-2 [&_.copy-code-btn]:py-1 [&_.copy-code-btn]:text-[11px] [&_.copy-code-btn]:font-medium [&_.copy-code-btn]:uppercase [&_.copy-code-btn]:tracking-[0.18em] [&_.copy-code-btn]:text-zinc-200 [&_.copy-code-btn]:transition-colors [&_.copy-code-btn:hover]:border-zinc-600 [&_.copy-code-btn:hover]:bg-zinc-800"
dangerouslySetInnerHTML={{ __html: previewHtml }}
/>
) : (
<div
className="overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-950"
dangerouslySetInnerHTML={{ __html: previewHtml }}
/>
)}
</div>
) : (
<div className="border-t border-zinc-800/70 px-5 py-4 text-sm text-zinc-500">
Preview collapsed.
</div>
)}
</section>
);
}