Add bot graphics

Bots can now show a concept as a visual: create_graphic (with
update_graphic, read_graphic and list_graphics, on every run, no computer
needed) stores one HTML document in bot_graphics, per user like the
conversation, and the chat renders its [graphic:ID] ref as an inline card
sized to fit, with a large view and a page of its own (no rail).

Graphic HTML is treated as hostile: it is only served at /graphics/<id>
with a CSP sandbox allowing scripts under an origin of its own and no
network, loading only the vendored Chart.js and Mermaid from
/graphics-lib/. The document carries the app's theme vars and scrollbars,
reports its height to the thread, and a frame that navigates away is
closed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-27 08:11:15 +01:00
co-authored by Claude Opus 5.5
parent 51fd4d5538
commit bea656ade8
65 changed files with 1959 additions and 21 deletions
+33
View File
@@ -0,0 +1,33 @@
import { Database } from "bun:sqlite";
import { beforeAll, describe, expect, test } from "bun:test";
import { runGraphicsSql, viewerGraphicsSql } from "./bot-graphic-sql";
import type { SqlCondition } from "./crew-access/types";
const db = new Database(":memory:");
beforeAll(() => {
db.run(`CREATE TABLE bot_graphics (id INTEGER PRIMARY KEY, user_id INTEGER, bot_id INTEGER)`);
db.run(`INSERT INTO bot_graphics VALUES (1, 7, 2), (2, 7, 3), (3, 9, 2)`);
});
function ids({ condition }: { condition: SqlCondition }) {
return db
.query(`SELECT g.id FROM bot_graphics g WHERE ${condition.sql} ORDER BY g.id`)
.all(...condition.values)
.map((row) => (row as { id: number }).id);
}
describe("viewerGraphicsSql", () => {
test("a user sees only the graphics made for them", () => {
expect(ids({ condition: viewerGraphicsSql({ user_id: 7 }) })).toEqual([1, 2]);
expect(ids({ condition: viewerGraphicsSql({ user_id: 9 }) })).toEqual([3]);
expect(ids({ condition: viewerGraphicsSql({ user_id: 1 }) })).toEqual([]);
});
});
describe("runGraphicsSql", () => {
test("a run edits only its bot's graphics for its user", () => {
expect(ids({ condition: runGraphicsSql({ user_id: 7, bot_id: 2 }) })).toEqual([1]);
expect(ids({ condition: runGraphicsSql({ user_id: 9, bot_id: 3 }) })).toEqual([]);
});
});
+25
View File
@@ -0,0 +1,25 @@
import type { SqlCondition } from "./crew-access/types";
type Params = {
/**
* The graphics table's alias in the query.
*/
alias?: string;
user_id: number | string;
};
/**
* The graphics a user sees: only those a bot made for them, like the
* conversation they were made in. Nobody else, crew managers included,
* reads another user's graphic.
*/
export function viewerGraphicsSql({ alias = "g", user_id }: Params): SqlCondition {
return { sql: `${alias}.user_id = ?`, values: [Number(user_id)] };
}
/**
* The graphics a run edits: those its bot made for its user.
*/
export function runGraphicsSql({ alias = "g", user_id, bot_id }: Params & { bot_id: number | string }): SqlCondition {
return { sql: `${alias}.user_id = ? AND ${alias}.bot_id = ?`, values: [Number(user_id), Number(bot_id)] };
}
+4
View File
@@ -20,6 +20,10 @@ describe("exportMessageText", () => {
);
});
test("words graphics by number", () => {
expect(exportMessageText({ text: "See [graphic:5]", ...names, heading_offset: 0 })).toBe("See (graphic 5)");
});
test("leaves refs that are already links alone", () => {
expect(exportMessageText({ text: "[bot:3](#x)", ...names, heading_offset: 0 })).toBe("[bot:3](#x)");
});
+3 -2
View File
@@ -15,8 +15,8 @@ type Params = {
/**
* A message's markdown as it reads outside the app: its refs in words
* (`[bot:3]` as `@Ada`, `[workspace:2]` as `#Launch`, `[computer:3]` as
* "computer 3", a folder as its path, a secret by number only, never its
* value) and its headings dropped by
* "computer 3", a folder as its path, a graphic and a secret by number only, never
* a secret's value) and its headings dropped by
* `heading_offset`. Code blocks are left exactly as they are.
*/
export default function exportMessageText({
@@ -85,6 +85,7 @@ function wordRefs({
.replace(/\[hook-proposal:(\d+)\](?!\()/g, "hook proposal $1")
.replace(/\[secret:(\d+)\](?!\()/g, "(secret $1, hidden)")
.replace(/\[secret-request:(\d+)\](?!\()/g, "(secret request $1)")
.replace(/\[graphic:(\d+)\](?!\()/g, "(graphic $1)")
.replace(/\[file:(\d+)\](?!\()/g, "file $1")
.replace(/\[folder:[a-z]+:\d+:([^\]\n]+)\](?!\()/g, "`$1`")
.replace(/\[folder:([a-z]+):(\d+)\](?!\()/g, "the $1 $2 folder");
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, test } from "bun:test";
import graphicDocument from "./graphic-document";
const vars = {
light: { "--c-paper": "#fff" },
dark: { "--c-paper": "#000" },
fonts: { "--font-sans": "sans-serif" },
};
describe("graphicDocument", () => {
test("wraps a fragment in a themed document", () => {
const doc = graphicDocument({ html: "<svg></svg>", title: "A <b>", vars });
expect(doc.startsWith("<!DOCTYPE html><html><head>")).toBe(true);
expect(doc).toContain("<title>A &lt;b&gt;</title>");
expect(doc).toContain("--c-paper:#fff;");
expect(doc).toContain(":root.dark{color-scheme:dark;--c-paper:#000;}");
expect(doc).toContain("<body><svg></svg><script>");
expect(doc).toContain("cobble:graphic-size");
});
test("styles scrollbars in the theme's colours", () => {
const doc = graphicDocument({ html: "<p>x</p>", title: "T", vars });
expect(doc).toContain("::-webkit-scrollbar-thumb{background-color:var(--c-border-strong);");
expect(doc).toContain("scrollbar-color:var(--c-border-strong) transparent;");
});
test("puts the theme ahead of a whole document's own styles", () => {
const doc = graphicDocument({
html: `<!doctype html><html lang="en"><head><style>body{color:red}</style></head><body><p>Hi</p></body></html>`,
title: "T",
dark: true,
vars,
});
expect(doc.indexOf("--c-paper")).toBeLessThan(doc.indexOf("body{color:red}"));
expect(doc).toContain(`classList.toggle("dark", true)`);
expect(doc.indexOf("cobble:graphic-size")).toBeLessThan(doc.lastIndexOf("</body>"));
expect(doc.match(/<!doctype/gi)?.length).toBe(1);
});
test("gives a document without a head one", () => {
const doc = graphicDocument({ html: `<html><body>x</body></html>`, title: "T", vars });
expect(doc).toContain(`<html><head><meta charset="utf-8">`);
});
test("drops a refresh that would navigate the frame away", () => {
const doc = graphicDocument({
html: `<head><meta http-equiv="refresh" content="0;url=https://evil.test/?d=1"></head>`,
title: "T",
vars,
});
expect(doc).not.toContain("evil.test");
});
});
+79
View File
@@ -0,0 +1,79 @@
import { GraphicSizeMessageType } from "@/src/dict/graphics-dict";
import type { ThemeVars } from "./parse-theme-vars";
type Params = {
/**
* The graphic as the bot wrote it: a whole document or a fragment.
*/
html: string;
title: string;
dark?: boolean;
vars: ThemeVars;
};
/**
* A bot graphic as it's served: the app's theme colours and fonts set
* ahead of the bot's own styles (as `--c-paper`, `--c-ink`, ... and
* `--font-sans`), `dark` on the root in the dark theme, and a script that
* tells the thread how tall it is. A fragment is wrapped into a document;
* a refresh tag (which would navigate the frame away) is dropped.
*/
export default function graphicDocument({ html, title, dark, vars }: Params) {
const head = `<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeText(title)}</title><style>${themeCss({ vars })}</style><script>document.documentElement.classList.toggle("dark", ${dark ? "true" : "false"});</script>`;
const tail = `<script>${SizeScript}</script>`;
const body = html.replace(/<meta\b[^>]*http-equiv\s*=\s*["']?refresh[^>]*>/gi, "");
const with_head = /<head\b[^>]*>/i.test(body)
? body.replace(/<head\b[^>]*>/i, (tag) => `${tag}${head}`)
: /<html\b[^>]*>/i.test(body)
? body.replace(/<html\b[^>]*>/i, (tag) => `${tag}<head>${head}</head>`)
: `<!DOCTYPE html><html><head>${head}</head><body>${body}</body></html>`;
const last_body_end = with_head.toLowerCase().lastIndexOf("</body>");
const document =
last_body_end >= 0
? `${with_head.slice(0, last_body_end)}${tail}${with_head.slice(last_body_end)}`
: `${with_head}${tail}`;
return /^\s*<!doctype/i.test(document) ? document : `<!DOCTYPE html>${document}`;
}
/**
* Posts the body's height to the page framing it whenever it changes.
*/
const SizeScript = `(function(){var last=0;function post(){var b=document.body;if(!b)return;var h=Math.ceil(b.scrollHeight);if(h==last)return;last=h;parent.postMessage({type:${JSON.stringify(GraphicSizeMessageType)},height:h},"*");}if(window.ResizeObserver&&document.body){new ResizeObserver(post).observe(document.body);}addEventListener("load",post);post();})();`;
/**
* Thin scrollbars in the theme's colours, for the graphic and anything in
* it that scrolls: a rounded thumb inset in a clear track where the
* browser takes `::-webkit-scrollbar` (Chrome, Safari), the standard
* properties elsewhere (Firefox), since Chrome drops the rounded thumb
* once those are set.
*/
const ScrollbarCss = [
`::-webkit-scrollbar{width:10px;height:10px;}`,
`::-webkit-scrollbar-track,::-webkit-scrollbar-corner{background:transparent;}`,
`::-webkit-scrollbar-thumb{background-color:var(--c-border-strong);border:3px solid transparent;border-radius:999px;background-clip:padding-box;}`,
`::-webkit-scrollbar-thumb:hover{background-color:var(--c-ink-secondary);}`,
`@supports not selector(::-webkit-scrollbar){*{scrollbar-width:thin;scrollbar-color:var(--c-border-strong) transparent;}}`,
].join("");
function themeCss({ vars }: { vars: ThemeVars }) {
const list = (record: Record<string, string>) =>
Object.entries(record)
.map(([name, value]) => `${name}:${value};`)
.join("");
return [
`:root{color-scheme:light;${list(vars.light)}${list(vars.fonts)}}`,
`:root.dark{color-scheme:dark;${list(vars.dark)}}`,
`html,body{margin:0;}`,
`body{padding:16px;background:var(--c-paper);color:var(--c-ink);font-family:var(--font-sans);font-size:14px;line-height:1.5;}`,
ScrollbarCss,
].join("");
}
function escapeText(text: string) {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test";
import graphicHeaders from "./graphic-headers";
describe("graphicHeaders", () => {
const csp = graphicHeaders({ origin: "https://app.test" })["Content-Security-Policy"]!;
const directives = csp.split("; ");
test("sandboxes scripts away from the app's origin", () => {
expect(directives).toContain("sandbox allow-scripts");
expect(csp).not.toContain("allow-same-origin");
expect(csp).not.toContain("allow-top-navigation");
expect(csp).not.toContain("allow-popups");
});
test("allows no network beyond the app's graphic libraries", () => {
expect(directives).toContain("default-src 'none'");
expect(directives).toContain("connect-src 'none'");
expect(directives).toContain("script-src 'unsafe-inline' https://app.test/graphics-lib/");
expect(directives).toContain("img-src data: blob:");
});
test("is only framed by the app and never cached", () => {
const headers = graphicHeaders({ origin: "https://app.test" });
expect(directives).toContain("frame-ancestors 'self'");
expect(headers["Cache-Control"]).toBe("private, no-store");
expect(headers["X-Content-Type-Options"]).toBe("nosniff");
});
});
+42
View File
@@ -0,0 +1,42 @@
import { GraphicsLibPrefix } from "@/src/dict/graphics-dict";
type Params = {
/**
* The app's origin as the browser reached it, e.g.
* `https://cobble.example.com`: the only place a graphic loads scripts
* from, and only its libraries.
*/
origin: string;
};
/**
* The headers a bot graphic is served with. Its HTML is a bot's and may
* carry what a prompt injection put there, so it gets:
*
* - `sandbox allow-scripts` without `allow-same-origin`: scripts run (for
* charts) but under an origin of its own, so no cookie, storage or API of
* the app's is reachable.
* - no network (`default-src 'none'`, `connect-src 'none'`): nothing it
* holds can be sent anywhere, and only the app's graphic libraries load.
* - framed only by the app (`frame-ancestors 'self'`), never cached.
*/
export default function graphicHeaders({ origin }: Params): Record<string, string> {
return {
"Content-Type": "text/html; charset=utf-8",
"Content-Security-Policy": [
"sandbox allow-scripts",
"default-src 'none'",
`script-src 'unsafe-inline' ${origin}${GraphicsLibPrefix}/`,
"style-src 'unsafe-inline'",
"img-src data: blob:",
"font-src data:",
"connect-src 'none'",
"form-action 'none'",
"base-uri 'none'",
"frame-ancestors 'self'",
].join("; "),
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"Cache-Control": "private, no-store",
};
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test";
import graphicRef, { graphicRefId } from "./graphic-ref";
import linkComputerRefs from "./link-computer-refs";
describe("graphicRef", () => {
test("names a graphic", () => {
expect(graphicRef({ id: 5 })).toBe("[graphic:5]");
});
test("reads the id back from its link", () => {
expect(graphicRefId({ href: "#graphic-5" })).toBe(5);
expect(graphicRefId({ href: "#graphic-0" })).toBeNull();
expect(graphicRefId({ href: "#graphic-x" })).toBeNull();
expect(graphicRefId({ href: "#file-5" })).toBeNull();
expect(graphicRefId({ href: null })).toBeNull();
});
test("becomes a link in a message", () => {
expect(linkComputerRefs({ text: "Here: [graphic:5]" })).toBe("Here: [graphic 5](#graphic-5)");
});
});
+28
View File
@@ -0,0 +1,28 @@
type Params = {
id: number | string;
};
/**
* How a bot names a graphic it made: `[graphic:5]`. The chat turns it
* into a link (`linkComputerRefs`) that renders as the graphic itself,
* framed in the thread, with ways to open it full size.
*/
export default function graphicRef({ id }: Params) {
return `[graphic:${id}]`;
}
/**
* The link target a graphic ref becomes, e.g. `#graphic-5`.
*/
export const GraphicRefHrefPrefix = "#graphic-";
/**
* The id in a graphic ref's link target, or null.
*/
export function graphicRefId({ href }: { href?: string | null }) {
if (!href?.startsWith(GraphicRefHrefPrefix)) return null;
const id = Number(href.slice(GraphicRefHrefPrefix.length));
return Number.isInteger(id) && id > 0 ? id : null;
}
+28
View File
@@ -0,0 +1,28 @@
import { GraphicsPrefix } from "@/src/dict/graphics-dict";
type Params = {
graphic_id: number;
dark?: boolean;
/**
* The version shown, so a changed graphic loads afresh.
*/
version?: number;
};
/**
* Where a graphic's sandboxed document is served, in a theme.
*/
export default function graphicUrl({ graphic_id, dark, version }: Params) {
const query = new URLSearchParams({ theme: dark ? "dark" : "light" });
if (version) query.set("v", String(version));
return `${GraphicsPrefix}/${graphic_id}?${query.toString()}`;
}
/**
* The app page that shows a graphic full size, beside the rail.
*/
export function graphicPageHref({ graphic_id, bot_id }: { graphic_id: number; bot_id: number }) {
return `/admin/bots/${bot_id}/graphics/${graphic_id}`;
}
+5 -1
View File
@@ -1,3 +1,5 @@
import { GraphicRefHrefPrefix } from "./graphic-ref";
/**
* The link target a computer ref (`computerRef`) becomes, e.g.
* `#computer-3`. The chat's markdown renders these links as buttons that
@@ -70,7 +72,8 @@ export const SecretRequestRefHrefPrefix = "#secret-request-";
* (`[workspace:2]`), file ref (`[file:12]`), folder ref
* (`[folder:computer:3:seo/raw]`), proposal ref (`[proposal:7]`) and hook
* proposal ref (`[hook-proposal:4]`), secret ref (`[secret:12]`) and secret
* request ref (`[secret-request:4]`) in a message into a markdown link, so it
* request ref (`[secret-request:4]`) and graphic ref (`[graphic:5]`) in a
* message into a markdown link, so it
* survives markdown rendering as a link the chat can swap for a button,
* chip or download.
*
@@ -106,6 +109,7 @@ export default function linkComputerRefs({ text }: { text: string }) {
/\[secret-request:(\d+)\](?!\()/g,
(_, id: string) => `[secret request ${id}](${SecretRequestRefHrefPrefix}${id})`,
)
.replace(/\[graphic:(\d+)\](?!\()/g, (_, id: string) => `[graphic ${id}](${GraphicRefHrefPrefix}${id})`)
.replace(/\[file:(\d+)\](?!\()/g, (_, id: string) => `[file ${id}](${FileRefHrefPrefix}${id})`)
.replace(/\[folder:([a-z]+):(\d+):([^\]\n]+)\](?!\()/g, (_, volume: string, owner: string, path: string) =>
`[${path}](${FolderRefHrefPrefix}${encodeURIComponent(`${volume}:${owner}:${path}`)})`,
+3 -2
View File
@@ -1,8 +1,9 @@
/**
* Paths the loading page never stands in for: the app's code, live data,
* media, proxied computers and the files the loading page itself needs.
* media, proxied computers, bot graphics and the files the loading page
* itself needs.
*/
const OpenPrefixes = ["/.bunext/", "/api/", "/ws", "/media", "/computers/"];
const OpenPrefixes = ["/.bunext/", "/api/", "/ws", "/media", "/computers/", "/graphics/", "/graphics-lib/"];
type Params = {
method: string;
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test";
import fs from "fs";
import path from "path";
import parseThemeVars from "./parse-theme-vars";
describe("parseThemeVars", () => {
test("reads both schemes and the fonts", () => {
const css = `:root {\n --c-paper: #fff;\n --m-fast: 1ms;\n}\n\n.dark {\n --c-paper: #000;\n}\n\n@theme inline {\n --font-sans:\n "A", sans-serif;\n --color-ink: var(--c-ink);\n}\n`;
expect(parseThemeVars({ css })).toEqual({
light: { "--c-paper": "#fff" },
dark: { "--c-paper": "#000" },
fonts: { "--font-sans": `"A", sans-serif` },
});
});
test("finds the app's own tokens", () => {
const css = fs.readFileSync(path.join(process.cwd(), "src/styles/main.css"), "utf-8");
const vars = parseThemeVars({ css });
for (const name of ["--c-paper", "--c-ink", "--c-border"]) {
expect(vars.light[name]).toBeTruthy();
expect(vars.dark[name]).toBeTruthy();
expect(vars.light[name]).not.toBe(vars.dark[name]);
}
expect(vars.fonts["--font-sans"]).toBeTruthy();
});
});
+41
View File
@@ -0,0 +1,41 @@
export type ThemeVars = {
/**
* The `:root` block's `--c-*` colours, keyed with their `--` names.
*/
light: Record<string, string>;
/**
* The `.dark` block's `--c-*` colours.
*/
dark: Record<string, string>;
/**
* `--font-sans` and `--font-mono` from the `@theme inline` block.
*/
fonts: Record<string, string>;
};
/**
* The app's neutral colours (light and dark) and font stacks from
* `src/styles/main.css`, for documents served outside the app's own
* styles (a bot graphic), so they match the theme without a copy of it.
*/
export default function parseThemeVars({ css }: { css: string }): ThemeVars {
return {
light: blockVars({ block: css.match(/(?:^|\n):root\s*\{([\s\S]*?)\n\}/)?.[1], prefix: "--c-" }),
dark: blockVars({ block: css.match(/(?:^|\n)\.dark\s*\{([\s\S]*?)\n\}/)?.[1], prefix: "--c-" }),
fonts: blockVars({ block: css.match(/@theme\s+inline\s*\{([\s\S]*?)\n\}/)?.[1], prefix: "--font-" }),
};
}
function blockVars({ block, prefix }: { block?: string; prefix: string }) {
const vars: Record<string, string> = {};
if (!block) return vars;
for (const match of block.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;{}]+);/g)) {
const [, name, value] = match;
if (name?.startsWith(prefix) && value) vars[name] = value.replace(/\s+/g, " ").trim();
}
return vars;
}