- 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}
305 lines
14 KiB
TypeScript
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|