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:
@@ -0,0 +1,43 @@
|
||||
import { z } from "zod";
|
||||
import createBotGraphic from "@/src/functions/backend/db/bot-graphics/create-bot-graphic";
|
||||
import graphicRef from "@/src/utils/graphic-ref";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import { GraphicAuthoringNote } from "./graphic-authoring-note";
|
||||
import type { GraphicToolContext } from "./graphic-tool-context";
|
||||
|
||||
type Args = {
|
||||
title: string;
|
||||
html: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a bot make a visual (a chart, diagram, timeline, comparison or
|
||||
* illustrated explainer) as HTML the chat shows inline. Stored in the app
|
||||
* (`bot_graphics`), so it needs no computer.
|
||||
*/
|
||||
export default function createGraphicTool(context: GraphicToolContext): HarnessTool<Args> {
|
||||
return {
|
||||
name: "create_graphic",
|
||||
description: `Make a visual for the user, shown inline in the chat: a chart, diagram, flow, timeline, comparison table, dashboard or illustrated explainer. Use it when a picture makes something clearer than text does; keep the reply's words short beside it. ${GraphicAuthoringNote} Write the [graphic:ID] ref it returns in your reply where it should show. To change it later, update_graphic it rather than making another.`,
|
||||
schema: z.object({
|
||||
title: z.string().trim().min(1).max(200).describe(`What it shows, e.g. "Q3 revenue by region"`),
|
||||
html: z.string().min(1).describe("The document"),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const id = await createBotGraphic({
|
||||
user_id: context.user_id,
|
||||
bot_id: context.bot_id,
|
||||
session_id: context.session_id,
|
||||
workspace_id: context.workspace_id,
|
||||
job_id: context.job_id,
|
||||
title: args.title,
|
||||
html: args.html,
|
||||
});
|
||||
const ref = graphicRef({ id });
|
||||
|
||||
context.onGraphic?.({ ref });
|
||||
|
||||
return `Made graphic ${id}, "${args.title.trim()}". Write ${ref} in your reply where it should show; the user sees it there.`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { HarnessTool } from "../../types";
|
||||
import createGraphicTool from "./create-graphic-tool";
|
||||
import type { GraphicToolContext } from "./graphic-tool-context";
|
||||
import listGraphicsTool from "./list-graphics-tool";
|
||||
import readGraphicTool from "./read-graphic-tool";
|
||||
import updateGraphicTool from "./update-graphic-tool";
|
||||
|
||||
/**
|
||||
* The tools a bot makes visuals with (`src/harness/tools/graphics/`):
|
||||
* making, changing, reading and listing the HTML graphics it made for the
|
||||
* run's user. Every run gets them; they need no computer, since the app
|
||||
* stores and serves graphics itself.
|
||||
*/
|
||||
export default function grabBotGraphicTools(context: GraphicToolContext): HarnessTool[] {
|
||||
return [
|
||||
createGraphicTool(context),
|
||||
updateGraphicTool(context),
|
||||
readGraphicTool(context),
|
||||
listGraphicsTool(context),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { GraphicsLibPrefix, GraphicsLibs } from "@/src/dict/graphics-dict";
|
||||
|
||||
/**
|
||||
* How a graphic's HTML must be written, in the tool descriptions: what the
|
||||
* sandbox lets it load and the theme it can draw with.
|
||||
*/
|
||||
export const GraphicAuthoringNote = `Write one self-contained HTML document (or a fragment), up to ${AppData.BotGraphicMaxChars} characters: inline <style>, <svg> and <script>. It runs sandboxed with no network: outside URLs (CDNs, fetch, remote images or fonts, links that leave) are blocked, so put the data in the document. The only scripts you can load are these, by exactly these tags:
|
||||
${GraphicsLibs.map((lib) => `- <script src="${GraphicsLibPrefix}/${lib.file}"></script>: ${lib.description}`).join("\n")}
|
||||
The app's theme is set as CSS variables: var(--c-paper) (the background, already applied to body), var(--c-surface), var(--c-sunken), var(--c-ink) (text), var(--c-ink-secondary), var(--c-border), var(--c-border-strong), var(--font-sans), var(--font-mono). The root has the class "dark" in the dark theme: use the variables, or pick colours that read on both. It shows about 700px wide in the chat and full screen when opened, so keep it responsive; its height grows to fit.`;
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* What the graphic tools of one run work with: the bot, the user it works
|
||||
* for and the place, and a callback that puts a graphic on its message.
|
||||
*/
|
||||
export type GraphicToolContext = {
|
||||
bot_id: number | string;
|
||||
user_id: number | string;
|
||||
session_id?: number | string | null;
|
||||
workspace_id?: number | string | null;
|
||||
job_id?: number | string | null;
|
||||
/**
|
||||
* Called with a graphic's ref when the run makes or changes it, so its
|
||||
* message always shows it.
|
||||
*/
|
||||
onGraphic?: (params: { ref: string }) => void;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod";
|
||||
import listBotGraphics from "@/src/functions/backend/db/bot-graphics/list-bot-graphics";
|
||||
import graphicRef from "@/src/utils/graphic-ref";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import type { GraphicToolContext } from "./graphic-tool-context";
|
||||
|
||||
type Args = {
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a bot find the graphics it made for this user here, newest first,
|
||||
* so it changes one rather than making it again.
|
||||
*/
|
||||
export default function listGraphicsTool(context: GraphicToolContext): HarnessTool<Args> {
|
||||
return {
|
||||
name: "list_graphics",
|
||||
description: `List the graphics you made for this user in this conversation's place, newest first. Check it before making a graphic the user may already have.`,
|
||||
schema: z.object({
|
||||
limit: z.number().int().min(1).max(50).optional(),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const graphics = await listBotGraphics({
|
||||
bot_id: context.bot_id,
|
||||
user_id: context.user_id,
|
||||
workspace_id: context.workspace_id,
|
||||
limit: args.limit,
|
||||
});
|
||||
|
||||
if (!graphics.length) return `You haven't made any graphics for this user here.`;
|
||||
|
||||
return graphics
|
||||
.map(
|
||||
(graphic) =>
|
||||
`- ${graphicRef({ id: graphic.id })} "${graphic.title}", version ${graphic.version || 1}, ${graphic.chars} characters, updated ${new Date(Number(graphic.updated_at)).toUTCString()}`,
|
||||
)
|
||||
.join("\n");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from "zod";
|
||||
import grabBotGraphic from "@/src/functions/backend/db/bot-graphics/grab-bot-graphic";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import type { GraphicToolContext } from "./graphic-tool-context";
|
||||
|
||||
type Args = {
|
||||
graphic_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a bot read the HTML of a graphic it made for this user, to edit it.
|
||||
*/
|
||||
export default function readGraphicTool(context: GraphicToolContext): HarnessTool<Args> {
|
||||
return {
|
||||
name: "read_graphic",
|
||||
description: `Read the HTML of a graphic you made for this user, to change it with update_graphic.`,
|
||||
schema: z.object({
|
||||
graphic_id: z.number().int().positive().describe("The ID from its [graphic:ID] ref"),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const graphic = await grabBotGraphic({ graphic_id: args.graphic_id, user_id: context.user_id });
|
||||
|
||||
if (!graphic || Number(graphic.bot_id) != Number(context.bot_id)) {
|
||||
return `Graphic ${args.graphic_id} isn't one you made for this user.`;
|
||||
}
|
||||
|
||||
return `Graphic ${graphic.id}, "${graphic.title}" (version ${graphic.version || 1}):\n\n${graphic.html || ""}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { z } from "zod";
|
||||
import updateBotGraphic from "@/src/functions/backend/db/bot-graphics/update-bot-graphic";
|
||||
import graphicRef from "@/src/utils/graphic-ref";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import { GraphicAuthoringNote } from "./graphic-authoring-note";
|
||||
import type { GraphicToolContext } from "./graphic-tool-context";
|
||||
|
||||
type Args = {
|
||||
graphic_id: number;
|
||||
title?: string;
|
||||
html?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a bot replace a graphic it made for this user. Every message
|
||||
* naming it shows the new version.
|
||||
*/
|
||||
export default function updateGraphicTool(context: GraphicToolContext): HarnessTool<Args> {
|
||||
return {
|
||||
name: "update_graphic",
|
||||
description: `Change a graphic you made for this user: new html replaces the whole document (read_graphic first to start from the current one), a new title renames it. Every message showing it shows the new version. ${GraphicAuthoringNote}`,
|
||||
schema: z.object({
|
||||
graphic_id: z.number().int().positive().describe("The ID from its [graphic:ID] ref"),
|
||||
title: z.string().trim().min(1).max(200).optional(),
|
||||
html: z.string().min(1).optional().describe("The whole new document"),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const version = await updateBotGraphic({
|
||||
graphic_id: args.graphic_id,
|
||||
bot_id: context.bot_id,
|
||||
user_id: context.user_id,
|
||||
title: args.title,
|
||||
html: args.html,
|
||||
});
|
||||
const ref = graphicRef({ id: args.graphic_id });
|
||||
|
||||
context.onGraphic?.({ ref });
|
||||
|
||||
return `Updated graphic ${args.graphic_id} (now version ${version}). Write ${ref} in your reply to show it again.`;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user