pi-mono/apps/p2/plugins/artifacts/web.tsx
Mario Zechner 04f672a18d feat(p2): render artifacts inline at the tool call site
The artifact tool takes inline: true to also show the produced version below
its transcript card: Markdown/text rendered, HTML/SVG in the sandboxed preview
frame. Drawings fill the frame's width (an SVG with a viewBox or an absolute
size scales to it; an HTML page that is only an SVG or image too) and the frame
takes the content height the preview reports, capped at 70vh. Every viewer sees
it, guests included (owner decision). The TUI renders inline Markdown and text.
SVG previews now carry the diagnostics bridge in CDATA (its && broke the XML).
2026-11-04 21:13:26 +01:00

112 lines
3.3 KiB
TypeScript

import { compile, obj, union } from "@earendil-works/pi-schema";
import { contribute, defineFacet, literal } from "@p2/app/v1";
import {
ArtifactIcon,
ArtifactPicker,
closeLayers,
FilePanel,
InlineArtifactView,
OpenViewer,
openLayer,
SessionId,
Slots,
useObject,
useReadable,
useRoute,
type ViewerItem,
} from "@p2/app/v1/web";
import type { ReactNode } from "react";
import { DownloadInfoSchema } from "../files/contract.ts";
import { ArtifactInfoSchema, ArtifactList } from "./contract.ts";
import { ArtifactPanel, InlineArtifact } from "./preview.tsx";
const ViewerSchema = union([
obj({ kind: literal("file"), file: DownloadInfoSchema }, { open: true }),
obj({ kind: literal("artifact"), artifact: ArtifactInfoSchema }, { open: true }),
]);
const isViewer = compile(ViewerSchema).check;
/** Untrusted URL metadata is only a locator. HTTP routes authorize every content fetch. */
function viewer(overlay: string | undefined): ViewerItem | undefined {
if (!overlay?.startsWith("viewer:")) return undefined;
try {
const value: unknown = JSON.parse(overlay.slice("viewer:".length));
return isViewer(value) ? value : undefined;
} catch {
return undefined;
}
}
function Content({ children }: { children?: ReactNode }) {
const route = useRoute();
const list = useObject(
ArtifactList,
route.session ? { session: route.session, conversation: route.conversation ?? 1 } : undefined,
);
const artifacts = useReadable(list?.artifacts) ?? [];
const top = route.layers.at(-1);
const item = viewer(top?.kind === "viewer" && top.name === "artifacts" ? top.locator : undefined);
const open = (item: ViewerItem): void =>
openLayer({ kind: "viewer", name: "artifacts", locator: `viewer:${JSON.stringify(item)}` });
return (
<SessionId.Provider value={route.session}>
<OpenViewer.Provider value={open}>
<InlineArtifactView.Provider value={InlineArtifact}>{children}</InlineArtifactView.Provider>
{top?.kind === "sheet" && top.name === "artifacts" && (
<ArtifactPicker
artifacts={artifacts.toReversed()}
onOpen={(artifact) => open({ kind: "artifact", artifact })}
onClose={closeLayers}
/>
)}
{route.session &&
item &&
(item.kind === "artifact" ? (
<ArtifactPanel
key={top?.kind !== "drawer" ? top?.locator : undefined}
sessionId={route.session}
artifact={item.artifact}
onClose={closeLayers}
/>
) : (
<FilePanel
key={top?.kind !== "drawer" ? top?.locator : undefined}
sessionId={route.session}
file={item.file}
onClose={closeLayers}
/>
))}
</OpenViewer.Provider>
</SessionId.Provider>
);
}
function ArtifactButton() {
const route = useRoute();
const list = useObject(
ArtifactList,
route.session ? { session: route.session, conversation: route.conversation ?? 1 } : undefined,
);
const artifacts = useReadable(list?.artifacts) ?? [];
const count = artifacts.length;
if (!count) return null;
return (
<button
type="button"
className="artifact-button"
aria-label={`Artifacts (${count})`}
onClick={() => openLayer({ kind: "sheet", name: "artifacts" })}
>
<ArtifactIcon />
<span>{count}</span>
</button>
);
}
export default defineFacet((host) => {
contribute(host, "p2.web-artifacts", (self) => {
self.contribute(Slots, { "chat-content": Content, "artifact-actions": ArtifactButton });
});
});