Files
AgentHub/client/src/lib/artifactView.ts
T
nntrivi2001 8546748759 chore: maintenance — dependency majors, rename step 4, member Claude config, OpenAPI coverage
Dependencies
- Security: body-parser and multer DoS advisories fixed (npm audit: 0).
- Server: Express 5 (query parser kept "extended", req.body defaults to {},
  SPA catch-all /{*splat}, send-file helper restores Express 4's dot-folder
  rule for sendFile/download, alerts ack route without inline regex),
  better-sqlite3 13, js-yaml 5 (server/lib/yaml.js keeps js-yaml 4's default
  schema), uuid dropped for crypto.randomUUID, node-pty beta.18, multer 2.5.
- Client: React 19, xterm 6 (WebGL renderer replaces the removed canvas one,
  DOM fallback on context loss; Alt+arrow word moves re-mapped), lucide-react 1,
  TypeScript 7, i18next/react-i18next/codemirror minors.

Workspace rename step 4
- `agenthub lanes` and `--lane` exit 1 with "renamed: use …"; client api.ts,
  the CLI and the MCP server call /api/workspaces; /api/lanes stays mounted.

Server mode (S3): members manage their own Claude Code config
- server/lib/cc-target.js resolves whose config a /cc-config request touches
  and gates the router by allowlist; /profiles* and /plugins/update-all stay
  admin-only. Member writes go through write-as, CLI calls through sudo as the
  member (never -y), reads through openSafe; cc_config_changed reaches only
  its owner. Not verified live on an S3 install yet.

Other
- Chat: an HTML file is an artifact only when written whole and not a project
  page (index.html, or under src/, public/, templates/, …).
- OpenAPI covers every /api route; openapi-coverage.test.js guards it.
- read-pool test keeps its event loop alive (its worker is unref'd).
- Docs: plan statuses, checklists, CLI.md, LANES.md, SERVER-MODE.md, API.md.
2026-10-26 14:22:41 +07:00

255 lines
11 KiB
TypeScript

/**
* @file artifactView.ts
* @description What makes a file an artifact, Claude Desktop's way, and how
* one is shown: its kind from its name (a page, an image, a diagram, a React
* component, a document, a table, code), the sandboxed document an HTML,
* SVG, Mermaid or React artifact runs in (an iframe `srcdoc` with no
* same-origin access, its libraries from a pinned CDN build, as Claude's own
* artifacts load theirs), a CSV's rows, and the versions a conversation's
* Write/Edit calls left of one file — rebuilt from their own inputs, so no
* server keeps copies.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { TranscriptMessage } from "./types";
export type ArtifactKind =
"html" | "svg" | "markdown" | "mermaid" | "react" | "csv" | "image" | "code";
const EXT = (p: string) =>
(/\.([A-Za-z0-9]+)$/.exec(p.split(/[\\/]/).pop() || "")?.[1] || "").toLowerCase();
/** The kind a file shows as. */
export function artifactKind(path: string): ArtifactKind {
const e = EXT(path);
if (e === "html" || e === "htm") return "html";
if (e === "svg") return "svg";
if (e === "md" || e === "markdown" || e === "mdx") return "markdown";
if (e === "mmd" || e === "mermaid") return "mermaid";
if (e === "jsx" || e === "tsx") return "react";
if (e === "csv" || e === "tsv") return "csv";
if (["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico"].includes(e)) return "image";
return "code";
}
/** Kinds with a rendered Preview (the rest show their code only). */
export const RENDERED: ReadonlySet<ArtifactKind> = new Set([
"html",
"svg",
"markdown",
"mermaid",
"react",
"csv",
"image",
]);
/** Folders an app keeps its own pages in: an HTML file under one of them is
* the project's code, never a page the model made to show. */
const PROJECT_DIRS = new Set([
"src",
"public",
"templates",
"views",
"pages",
"app",
"static",
"dist",
"build",
"components",
"layouts",
]);
/** An HTML file the project serves itself: an `index.html`, or one under a
* folder in {@link PROJECT_DIRS}. */
function isProjectPage(path: string): boolean {
const parts = path.split(/[\\/]/);
const name = (parts.pop() || "").toLowerCase();
if (name === "index.html" || name === "index.htm") return true;
return parts.some((d) => PROJECT_DIRS.has(d.toLowerCase()));
}
/** An edit worth a card in the conversation: a document, a diagram, a table —
* and a page or React component the model wrote whole (a `.tsx` or `.html` it
* merely edited, and an HTML page of the project itself, are project code,
* not an artifact). */
export function isArtifact(path: string, created: boolean): boolean {
const k = artifactKind(path);
if (k === "react") return created;
if (k === "html") return created && !isProjectPage(path);
return k === "svg" || k === "markdown" || k === "mermaid" || k === "csv";
}
/** A code fence language for the Code tab. */
export function artifactLang(path: string): string {
const e = EXT(path);
const map: Record<string, string> = {
htm: "html",
mmd: "text",
mermaid: "text",
md: "markdown",
mdx: "markdown",
jsx: "jsx",
tsx: "tsx",
js: "javascript",
mjs: "javascript",
cjs: "javascript",
ts: "typescript",
py: "python",
sh: "bash",
yml: "yaml",
};
return map[e] || e;
}
// Pinned builds (all on npm for well over two weeks), as Claude's own
// artifacts load React, Tailwind and Mermaid from a CDN.
const CDN = {
react: "https://cdn.jsdelivr.net/npm/react@18.3.1/umd/react.production.min.js",
reactDom: "https://cdn.jsdelivr.net/npm/react-dom@18.3.1/umd/react-dom.production.min.js",
babel: "https://cdn.jsdelivr.net/npm/@babel/standalone@7.26.4/babel.min.js",
tailwind: "https://cdn.tailwindcss.com/3.4.16",
mermaid: "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs",
};
/** Text safe inside a `<script>` element. */
const scriptSafe = (s: string) => s.replace(/<\/(script)/gi, "<\\/$1");
const htmlEscape = (s: string) =>
s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
/** Shown inside the frame when the artifact itself fails. */
const ERROR_BOX = `<pre id="__err" style="display:none;margin:16px;padding:12px;border-radius:8px;background:#fee2e2;color:#991b1b;font:12px/1.5 ui-monospace,monospace;white-space:pre-wrap"></pre>
<script>window.addEventListener("error",function(e){var b=document.getElementById("__err");if(b){b.style.display="block";b.textContent=String(e.message||e.error||e);}});</script>`;
/** The document a rendered artifact runs in (null: it is not a frame kind). */
export function artifactDocument(kind: ArtifactKind, source: string, dark: boolean): string | null {
switch (kind) {
case "html":
return /<html[\s>]|<!doctype/i.test(source)
? source
: `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body>${source}</body></html>`;
case "svg":
return `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%}body{display:flex;align-items:center;justify-content:center;background:${dark ? "#1f1f1f" : "#fff"}}svg{max-width:100%;max-height:100vh;height:auto}</style></head><body>${source}</body></html>`;
case "mermaid":
return `<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;padding:16px;background:${dark ? "#1f1f1f" : "#fff"};display:flex;justify-content:center}</style></head><body>${ERROR_BOX}<pre class="mermaid">${htmlEscape(source)}</pre>
<script type="module">import mermaid from "${CDN.mermaid}";mermaid.initialize({startOnLoad:false,theme:${dark ? '"dark"' : '"default"'},securityLevel:"strict"});mermaid.run().catch(function(e){var b=document.getElementById("__err");b.style.display="block";b.textContent=String(e&&e.message||e);});</script></body></html>`;
case "react":
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<script src="${CDN.react}"></script><script src="${CDN.reactDom}"></script><script src="${CDN.babel}"></script><script src="${CDN.tailwind}"></script></head>
<body><div id="root"></div>${ERROR_BOX}
<script>
(function(){
var src = ${JSON.stringify(scriptSafe(source))};
var mods = { react: window.React, "react-dom": window.ReactDOM, "react-dom/client": window.ReactDOM };
function req(name){ if (mods[name]) return mods[name]; throw new Error("This preview does not provide \\"" + name + "\\" — only react and react-dom are available."); }
try {
var out = Babel.transform(src, { filename: "Artifact.tsx", presets: [["typescript", { isTSX: true, allExtensions: true }], "react"], plugins: ["transform-modules-commonjs"] }).code;
var module = { exports: {} };
new Function("require", "module", "exports", "React", out)(req, module, module.exports, window.React);
var C = module.exports.default || module.exports.App || Object.values(module.exports).find(function(v){ return typeof v === "function"; });
if (!C) throw new Error("No component exported: export a default React component.");
ReactDOM.createRoot(document.getElementById("root")).render(React.createElement(C));
} catch (e) { var b = document.getElementById("__err"); b.style.display = "block"; b.textContent = String(e && e.message || e); }
})();
</script></body></html>`;
default:
return null;
}
}
/** The sandbox a frame kind gets: scripts but never the dashboard's origin
* (no `allow-same-origin`), and none at all for an SVG. */
export const frameSandbox = (kind: ArtifactKind) =>
kind === "svg" ? "" : "allow-scripts allow-forms allow-modals allow-popups allow-downloads";
/** A CSV/TSV's rows (quoted fields, doubled quotes, newlines inside quotes). */
export function parseCsv(text: string, sep = ","): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let quoted = false;
for (let i = 0; i < text.length; i++) {
const c = text[i]!;
if (quoted) {
if (c === '"' && text[i + 1] === '"') {
field += '"';
i++;
} else if (c === '"') quoted = false;
else field += c;
} else if (c === '"' && field === "") quoted = true;
else if (c === sep) {
row.push(field);
field = "";
} else if (c === "\n" || c === "\r") {
if (c === "\r" && text[i + 1] === "\n") i++;
row.push(field);
rows.push(row);
row = [];
field = "";
} else field += c;
}
if (field !== "" || row.length) {
row.push(field);
rows.push(row);
}
return rows;
}
/** One version of a file, as a Write/Edit call left it. */
export interface ArtifactVersion {
/** The call that made it. */
toolUseId: string | null;
timestamp: string | null;
/** null: the call is known, its result text is not (an edit to a file
* whose earlier text the conversation never showed). */
content: string | null;
}
const samePath = (a: unknown, b: string) =>
typeof a === "string" && a.replace(/\\/g, "/") === b.replace(/\\/g, "/");
/** Applies one Edit (old → new, once or everywhere) to known text. */
function applyEdit(text: string, oldS: unknown, newS: unknown, all: unknown): string | null {
if (typeof oldS !== "string" || typeof newS !== "string") return null;
if (!text.includes(oldS)) return null;
return all ? text.split(oldS).join(newS) : text.replace(oldS, () => newS);
}
/**
* The versions of `path` in a conversation, oldest first: each successful
* Write gives its whole text, and each Edit/MultiEdit after a known text gives
* that text with its edits applied. A call whose input the transcript cut, or
* an edit to text never shown, is a version of unknown content (the current
* file on disk stands in for the newest one).
*/
export function artifactVersions(messages: TranscriptMessage[], path: string): ArtifactVersion[] {
const failed = new Set<string>();
for (const m of messages)
for (const c of m.content) if (c.type === "tool_result" && c.is_error && c.id) failed.add(c.id);
const out: ArtifactVersion[] = [];
let text: string | null = null;
for (const m of messages) {
if (m.type !== "assistant") continue;
for (const c of m.content) {
if (c.type !== "tool_use" || (c.id && failed.has(c.id))) continue;
const input = (c.input || {}) as Record<string, unknown>;
if (!samePath(input.file_path, path)) continue;
if (c.name === "Write") {
text = typeof input.content === "string" ? input.content : null;
} else if (c.name === "Edit") {
text =
text === null
? null
: applyEdit(text, input.old_string, input.new_string, input.replace_all);
} else if (c.name === "MultiEdit") {
const edits = Array.isArray(input.edits) ? (input.edits as Record<string, unknown>[]) : [];
for (const e of edits) {
if (text === null) break;
text = applyEdit(text, e.old_string, e.new_string, e.replace_all);
}
} else continue;
out.push({ toolUseId: c.id ?? null, timestamp: m.timestamp, content: text });
}
}
return out;
}