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
@@ -0,0 +1,46 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import { runGraphicsSql } from "@/src/utils/bot-graphic-sql";
import checkGraphicInput from "./check-graphic-input";
type Params = {
graphic_id: number | string;
/**
* The run's bot and user: only the graphics that bot made for that
* user are changed.
*/
bot_id: number | string;
user_id: number | string;
title?: string;
html?: string;
};
/**
* Replaces a graphic's HTML (and title, when given) and bumps its
* version. Every message naming it shows the new one. Returns its new
* version.
*/
export default async function updateBotGraphic({ graphic_id, bot_id, user_id, title, html }: Params) {
const checked = checkGraphicInput({ title, html });
if (!checked.title && !checked.html) throw new AppError(`Nothing to change: give new html or a title.`);
const reach = runGraphicsSql({ alias: "bot_graphics", user_id, bot_id });
const row = (
await BunSQLite.sql<{ version: number }>({
sql: `UPDATE bot_graphics SET
title = COALESCE(?, title),
html = COALESCE(?, html),
version = COALESCE(version, 1) + 1,
updated_at = ?
WHERE id = ? AND ${reach.sql}
RETURNING version`,
values: [checked.title ?? null, checked.html ?? null, Date.now(), Number(graphic_id), ...reach.values],
})
).payload?.[0];
if (!row) throw new AppError(`Graphic ${graphic_id} isn't one you made for this user.`);
return Number(row.version);
}