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,57 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { BotGraphicView } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
graphic_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Graphics read lately, by id, so a thread naming one twice asks once. An
|
||||
* entry is read again after `FreshMs`, since a bot may have changed it.
|
||||
*/
|
||||
const loaded = new Map<number, { at: number; view: Promise<BotGraphicView | null> }>();
|
||||
const FreshMs = 10_000;
|
||||
|
||||
function loadGraphic({ graphic_id }: Params) {
|
||||
const cached = loaded.get(graphic_id);
|
||||
|
||||
if (cached && Date.now() - cached.at < FreshMs) return cached.view;
|
||||
|
||||
const view = fetchApi<{}, APIResponseObject<BotGraphicView>>(
|
||||
`/api/admin/graphics/get-graphic?graphic_id=${graphic_id}`,
|
||||
{ method: "GET" },
|
||||
)
|
||||
.then((res) => (res?.success && res.singleRes ? res.singleRes : null))
|
||||
.catch(() => null);
|
||||
|
||||
loaded.set(graphic_id, { at: Date.now(), view });
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
/**
|
||||
* A graphic a bot named (`[graphic:ID]`), for its card: its title, bot and
|
||||
* version. Only the user it was made for reads it; anyone else gets null.
|
||||
*/
|
||||
export default function useBotGraphic({ graphic_id }: Params) {
|
||||
const [view, setView] = useState<BotGraphicView | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
|
||||
loadGraphic({ graphic_id }).then((result) => {
|
||||
if (!live) return;
|
||||
setView(result);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [graphic_id]);
|
||||
|
||||
return { view, loading };
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { ChartColumn, Maximize2, SquareArrowOutUpRight } from "lucide-react";
|
||||
import GraphicFrame from "@/src/components/general/graphic-frame";
|
||||
import GraphicViewerModal from "@/src/components/general/graphic-viewer-modal";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { graphicPageHref } from "@/src/utils/graphic-url";
|
||||
import useBotGraphic from "../(hooks)/use-bot-graphic";
|
||||
|
||||
type Props = {
|
||||
graphic_id: number;
|
||||
};
|
||||
|
||||
const ActionClassName =
|
||||
"shrink-0 p-1! text-ink-secondary dark:text-ink-secondary hover:text-ink rounded-default-xs";
|
||||
|
||||
/**
|
||||
* A graphic a bot made (`graphicRef`), shown where the bot named it: its
|
||||
* title and the graphic itself, sized to fit (up to
|
||||
* `AppData.BotGraphicInlineMaxHeight`), with ways to open it large or on
|
||||
* its own page. Built from spans, since the ref sits in a paragraph.
|
||||
* Anyone it wasn't made for sees its number only.
|
||||
*/
|
||||
export default function GraphicRefCard({ graphic_id }: Props) {
|
||||
const { view, loading } = useBotGraphic({ graphic_id });
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!view) {
|
||||
return (
|
||||
<Span className="font-bold">
|
||||
{loading
|
||||
? `graphic ${graphic_id}`
|
||||
: `graphic ${graphic_id} (not available)`}
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Span className="flex w-full flex-col my-1 overflow-hidden rounded-default border border-solid border-hairline">
|
||||
<Span className="flex w-full flex-nowrap items-center gap-2 pl-3 pr-1.5 py-1.5 border-0 border-b border-solid border-hairline bg-surface">
|
||||
<ChartColumn
|
||||
size={14}
|
||||
aria-hidden
|
||||
className="shrink-0 text-ink-secondary"
|
||||
/>
|
||||
<Span className="min-w-0 grow truncate text-[13px] leading-5 font-bold text-ink">
|
||||
{view.title}
|
||||
</Span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
title="Open large"
|
||||
aria-label="Open large"
|
||||
onClick={() => setOpen(true)}
|
||||
className={ActionClassName}
|
||||
>
|
||||
<Maximize2 size={14} aria-hidden />
|
||||
</Button>
|
||||
<Link
|
||||
href={graphicPageHref({
|
||||
graphic_id: view.id,
|
||||
bot_id: view.bot_id,
|
||||
})}
|
||||
title="Open on its own page"
|
||||
aria-label="Open on its own page"
|
||||
className={ActionClassName}
|
||||
target="_blank"
|
||||
>
|
||||
<SquareArrowOutUpRight size={14} aria-hidden />
|
||||
</Link>
|
||||
</Span>
|
||||
<GraphicFrame
|
||||
graphic_id={view.id}
|
||||
title={view.title}
|
||||
version={view.version}
|
||||
max_height={AppData.BotGraphicInlineMaxHeight}
|
||||
/>
|
||||
{open ? (
|
||||
<GraphicViewerModal
|
||||
graphic={view}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
WorkspaceRefHrefPrefix,
|
||||
} from "@/src/utils/link-computer-refs";
|
||||
import { parseFolderRefHref } from "@/src/utils/folder-ref";
|
||||
import { graphicRefId } from "@/src/utils/graphic-ref";
|
||||
import BotRefChip from "./bot-ref-chip";
|
||||
import ComputerRefChip from "./computer-ref-chip";
|
||||
import FileRefChip from "./file-ref-chip";
|
||||
import FolderRefChip from "./folder-ref-chip";
|
||||
import GraphicRefCard from "./graphic-ref-card";
|
||||
import HookProposalRefChip from "./hook-proposal-ref-chip";
|
||||
import ProposalRefChip from "./proposal-ref-chip";
|
||||
import SecretRefChip from "./secret-ref-chip";
|
||||
@@ -32,7 +34,7 @@ import WorkspaceRefChip from "./workspace-ref-chip";
|
||||
* folder it named a chip that opens it in the files browser, an MCP server,
|
||||
* tool, skill or hook it proposed a chip that opens the confirm card, a
|
||||
* secret it showed a masked chip, a secret it asked for a chip that opens
|
||||
* the card it's typed into;
|
||||
* the card it's typed into, a graphic it made the graphic itself;
|
||||
* anything else opens in a new tab.
|
||||
*/
|
||||
export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
||||
@@ -99,6 +101,10 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
||||
if (request_id) return <SecretRequestRefChip request_id={request_id} />;
|
||||
}
|
||||
|
||||
const graphic_id = graphicRefId({ href });
|
||||
|
||||
if (graphic_id) return <GraphicRefCard graphic_id={graphic_id} />;
|
||||
|
||||
const folder_ref = parseFolderRefHref({ href });
|
||||
|
||||
if (folder_ref) return <FolderRefChip spec={folder_ref.spec} path={folder_ref.path} />;
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabBotGraphicView from "@/src/functions/backend/db/bot-graphics/grab-bot-graphic-view";
|
||||
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||
|
||||
type Params = Parameters<BunextPageServerFn<PagePropsType>>[0];
|
||||
|
||||
/**
|
||||
* A graphic's page: the graphic, for the user it was made for. Anyone
|
||||
* else, or a missing graphic, lands on the bot's conversation (which
|
||||
* leads on to the bots when that isn't theirs either).
|
||||
*/
|
||||
export default async function grabGraphicPageProps(params: Params) {
|
||||
const { user } = await userAuth({ req: params.req });
|
||||
|
||||
if (!user?.id) {
|
||||
return { redirect: { destination: "/auth/login" } };
|
||||
}
|
||||
|
||||
const bot_id = Number(params.query?.bot_id);
|
||||
const graphic_id = Number(params.query?.graphic_id);
|
||||
const graphic =
|
||||
Number.isInteger(graphic_id) && graphic_id > 0
|
||||
? await grabBotGraphicView({ graphic_id, user_id: user.id }).catch(() => null)
|
||||
: null;
|
||||
|
||||
if (!graphic) {
|
||||
return { redirect: { destination: bot_id ? `/admin/bots/${bot_id}` : AdminHomePath } };
|
||||
}
|
||||
|
||||
return { props: { graphic } };
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { useContext } from "react";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { MessagesSquare } from "lucide-react";
|
||||
import GraphicFrame from "@/src/components/general/graphic-frame";
|
||||
import SettingsPaneHeader from "@/src/pages/admin/settings/(partials)/settings-pane-header";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import conversationHref from "@/src/utils/conversation-href";
|
||||
import Center from "@moduletrace/twui/layout/Center";
|
||||
|
||||
/**
|
||||
* A graphic filling the window (the page has no rail), under a header
|
||||
* naming it and its bot, with a link back to the conversation it was made
|
||||
* in.
|
||||
*/
|
||||
export default function GraphicPageSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const graphic = pageProps?.graphic;
|
||||
|
||||
if (!graphic) return null;
|
||||
|
||||
return (
|
||||
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
|
||||
<SettingsPaneHeader
|
||||
title={graphic.title}
|
||||
description={`By ${graphic.bot_name}`}
|
||||
action={
|
||||
<Link
|
||||
href={conversationHref({
|
||||
bot_id: graphic.bot_id,
|
||||
workspace_id: graphic.workspace_id,
|
||||
session_id: graphic.session_id,
|
||||
})}
|
||||
title="Back to the conversation"
|
||||
aria-label="Back to the conversation"
|
||||
className="p-2 text-ink-secondary hover:text-ink rounded-default hover:bg-sunken"
|
||||
>
|
||||
<MessagesSquare size={16} aria-hidden />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Center>
|
||||
<Stack className="grow min-h-0 gap-0! items-stretch max-w-[1200px] w-full">
|
||||
<GraphicFrame
|
||||
graphic_id={graphic.id}
|
||||
title={graphic.title}
|
||||
version={graphic.version}
|
||||
className="grow h-full"
|
||||
/>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import grabGraphicPageProps from "./(functions)/grab-graphic-page-props";
|
||||
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
return await grabGraphicPageProps(params);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import GraphicPageSection from "./(sections)/graphic-page-section";
|
||||
|
||||
export default function AdminBotGraphicPage() {
|
||||
return <GraphicPageSection />;
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMetaFn = async (params) => {
|
||||
const graphic = (params.serverRes?.props as PagePropsType | undefined)?.graphic;
|
||||
|
||||
return {
|
||||
title: `${graphic?.title || "Graphic"} | ${SiteData["SiteName"]}`,
|
||||
description: graphic ? `A graphic by ${graphic.bot_name}.` : "A graphic a bot made.",
|
||||
};
|
||||
};
|
||||
@@ -85,6 +85,18 @@ The computer keeps the session, like everything else in its browser (extensions,
|
||||
3. Click **Hand back to** the bot. It gets a message that you're done and carries on.
|
||||
|
||||
A task that hit the wall stops and reports instead of waiting, so after you hand back the bot picks the work up again from the chat.`,
|
||||
},
|
||||
{
|
||||
id: "graphics",
|
||||
title: "A bot's graphic is blank or looks wrong",
|
||||
summary: "A chart or diagram in a message doesn't draw, or is missing data or images.",
|
||||
body: `Bots can answer with a graphic: a chart, diagram or visual report drawn in HTML and shown right in the message. Use the buttons on its title bar to open it large or on its own page.
|
||||
|
||||
Graphics are sandboxed: they can't reach the app or the internet, so nothing in one can read your conversation or send it anywhere. That also means a graphic can only use what's written into it, plus the chart and diagram libraries Cobble ships (Chart.js and Mermaid). If one is blank or missing pieces, it most likely tried to load something from the web (a CDN script, an online image or font).
|
||||
|
||||
Ask the bot to fix it, e.g. "the chart is blank, redraw it without outside files". It changes the same graphic, and every message showing it shows the new version. A graphic that tries to open another page is closed in place.
|
||||
|
||||
Only you see the graphics a bot made for you.`,
|
||||
},
|
||||
{
|
||||
id: "computer-not-ready",
|
||||
|
||||
Reference in New Issue
Block a user