8546748759
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.
255 lines
11 KiB
TypeScript
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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;
|
|
}
|