This commit is contained in:
ZacharyZhang-NY committed 2026-04-18 22:45:41 -04:00
1 parent 1261c2431f
commit dac59f56c2
680 files changed
+192574

No files matched your search

@@ -0,0 +1,14 @@
import { LitElement, type TemplateResult } from "lit";
export abstract class ArtifactElement extends LitElement {
public filename = "";
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this; // light DOM for shared styles
}
public abstract get content(): string;
public abstract set content(value: string);
abstract getHeaderButtons(): TemplateResult | HTMLElement;
}
@@ -0,0 +1,26 @@
import { icon } from "@mariozechner/mini-lit";
import { html, type TemplateResult } from "lit";
import { FileCode2 } from "lucide";
import type { ArtifactsPanel } from "./artifacts.js";
export function ArtifactPill(filename: string, artifactsPanel?: ArtifactsPanel): TemplateResult {
const handleClick = (e: Event) => {
if (!artifactsPanel) return;
e.preventDefault();
e.stopPropagation();
// openArtifact will show the artifact and call onOpen() to open the panel if needed
artifactsPanel.openArtifact(filename);
};
return html`
<span
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-muted/50 border border-border rounded ${
artifactsPanel ? "cursor-pointer hover:bg-muted transition-colors" : ""
}"
@click=${artifactsPanel ? handleClick : null}
>
${icon(FileCode2, "sm")}
<span class="text-foreground">${filename}</span>
</span>
`;
}
@@ -0,0 +1,93 @@
import { icon } from "@mariozechner/mini-lit";
import "@mariozechner/mini-lit/dist/CopyButton.js";
import { html, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, type Ref, ref } from "lit/directives/ref.js";
import { repeat } from "lit/directives/repeat.js";
import { ChevronDown, ChevronRight, ChevronsDown, Lock } from "lucide";
import { i18n } from "../../utils/i18n.js";
interface LogEntry {
type: "log" | "error";
text: string;
}
@customElement("artifact-console")
export class Console extends LitElement {
@property({ attribute: false }) logs: LogEntry[] = [];
@state() private expanded = false;
@state() private autoscroll = true;
private logsContainerRef: Ref<HTMLDivElement> = createRef();
protected createRenderRoot() {
return this; // light DOM
}
override updated() {
// Autoscroll to bottom when new logs arrive
if (this.autoscroll && this.expanded && this.logsContainerRef.value) {
this.logsContainerRef.value.scrollTop = this.logsContainerRef.value.scrollHeight;
}
}
private getLogsText(): string {
return this.logs.map((l) => `[${l.type}] ${l.text}`).join("\n");
}
override render(): TemplateResult {
const errorCount = this.logs.filter((l) => l.type === "error").length;
const summary =
errorCount > 0
? `${i18n("console")} (${errorCount} ${errorCount === 1 ? "error" : "errors"})`
: `${i18n("console")} (${this.logs.length})`;
return html`
<div class="border-t border-border p-2">
<div class="flex items-center gap-2 w-full">
<button
@click=${() => {
this.expanded = !this.expanded;
}}
class="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors flex-1 text-left"
>
${icon(this.expanded ? ChevronDown : ChevronRight, "sm")}
<span>${summary}</span>
</button>
${
this.expanded
? html`
<button
@click=${() => {
this.autoscroll = !this.autoscroll;
}}
class="p-1 rounded transition-colors ${this.autoscroll ? "bg-accent text-accent-foreground" : "hover:bg-muted"}"
title=${this.autoscroll ? i18n("Autoscroll enabled") : i18n("Autoscroll disabled")}
>
${icon(this.autoscroll ? ChevronsDown : Lock, "sm")}
</button>
<copy-button .text=${this.getLogsText()} title=${i18n("Copy logs")} .showText=${false} class="!bg-transparent hover:!bg-accent"></copy-button>
`
: ""
}
</div>
${
this.expanded
? html`
<div class="max-h-48 overflow-y-auto space-y-1 mt-2" ${ref(this.logsContainerRef)}>
${repeat(
this.logs,
(_log, index) => index,
(log) => html`
<div class="text-xs font-mono ${log.type === "error" ? "text-destructive" : "text-muted-foreground"}">
[${log.type}] ${log.text}
</div>
`,
)}
</div>
`
: ""
}
</div>
`;
}
}
@@ -0,0 +1,213 @@
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
import { renderAsync } from "docx-preview";
import { html, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { i18n } from "../../utils/i18n.js";
import { ArtifactElement } from "./ArtifactElement.js";
@customElement("docx-artifact")
export class DocxArtifact extends ArtifactElement {
@property({ type: String }) private _content = "";
@state() private error: string | null = null;
get content(): string {
return this._content;
}
set content(value: string) {
this._content = value;
this.error = null;
this.requestUpdate();
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
this.style.height = "100%";
}
private base64ToArrayBuffer(base64: string): ArrayBuffer {
// Remove data URL prefix if present
let base64Data = base64;
if (base64.startsWith("data:")) {
const base64Match = base64.match(/base64,(.+)/);
if (base64Match) {
base64Data = base64Match[1];
}
}
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
private decodeBase64(): Uint8Array {
let base64Data = this._content;
if (this._content.startsWith("data:")) {
const base64Match = this._content.match(/base64,(.+)/);
if (base64Match) {
base64Data = base64Match[1];
}
}
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
public getHeaderButtons() {
return html`
<div class="flex items-center gap-1">
${DownloadButton({
content: this.decodeBase64(),
filename: this.filename,
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
title: i18n("Download"),
})}
</div>
`;
}
override async updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has("_content") && this._content && !this.error) {
await this.renderDocx();
}
}
private async renderDocx() {
const container = this.querySelector("#docx-container");
if (!container || !this._content) return;
try {
const arrayBuffer = this.base64ToArrayBuffer(this._content);
// Clear container first
container.innerHTML = "";
// Create a wrapper div for the document
const wrapper = document.createElement("div");
wrapper.className = "docx-wrapper-custom";
container.appendChild(wrapper);
// Render the DOCX file into the wrapper
await renderAsync(arrayBuffer, wrapper as HTMLElement, undefined, {
className: "docx",
inWrapper: true,
ignoreWidth: true,
ignoreHeight: false,
ignoreFonts: false,
breakPages: true,
ignoreLastRenderedPageBreak: true,
experimental: false,
trimXmlDeclaration: true,
useBase64URL: false,
renderHeaders: true,
renderFooters: true,
renderFootnotes: true,
renderEndnotes: true,
});
// Apply custom styles to match theme and fix sizing
const style = document.createElement("style");
style.textContent = `
#docx-container {
padding: 0;
}
#docx-container .docx-wrapper-custom {
max-width: 100%;
overflow-x: auto;
}
#docx-container .docx-wrapper {
max-width: 100% !important;
margin: 0 !important;
background: transparent !important;
padding: 0em !important;
}
#docx-container .docx-wrapper > section.docx {
box-shadow: none !important;
border: none !important;
border-radius: 0 !important;
margin: 0 !important;
padding: 2em !important;
background: white !important;
color: black !important;
max-width: 100% !important;
width: 100% !important;
min-width: 0 !important;
overflow-x: auto !important;
}
/* Fix tables and wide content */
#docx-container table {
max-width: 100% !important;
width: auto !important;
overflow-x: auto !important;
display: block !important;
}
#docx-container img {
max-width: 100% !important;
height: auto !important;
}
/* Fix paragraphs and text */
#docx-container p,
#docx-container span,
#docx-container div {
max-width: 100% !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
}
/* Hide page breaks in web view */
#docx-container .docx-page-break {
display: none !important;
}
`;
container.appendChild(style);
} catch (error: any) {
console.error("Error rendering DOCX:", error);
this.error = error?.message || i18n("Failed to load document");
}
}
override render(): TemplateResult {
if (this.error) {
return html`
<div class="h-full flex items-center justify-center bg-background p-4">
<div class="bg-destructive/10 border border-destructive text-destructive p-4 rounded-lg max-w-2xl">
<div class="font-medium mb-1">${i18n("Error loading document")}</div>
<div class="text-sm opacity-90">${this.error}</div>
</div>
</div>
`;
}
return html`
<div class="h-full flex flex-col bg-background overflow-auto">
<div id="docx-container" class="flex-1 overflow-auto"></div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"docx-artifact": DocxArtifact;
}
}
@@ -0,0 +1,231 @@
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
import { html, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import * as XLSX from "xlsx";
import { i18n } from "../../utils/i18n.js";
import { ArtifactElement } from "./ArtifactElement.js";
@customElement("excel-artifact")
export class ExcelArtifact extends ArtifactElement {
@property({ type: String }) private _content = "";
@state() private error: string | null = null;
get content(): string {
return this._content;
}
set content(value: string) {
this._content = value;
this.error = null;
this.requestUpdate();
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
this.style.height = "100%";
}
private base64ToArrayBuffer(base64: string): ArrayBuffer {
// Remove data URL prefix if present
let base64Data = base64;
if (base64.startsWith("data:")) {
const base64Match = base64.match(/base64,(.+)/);
if (base64Match) {
base64Data = base64Match[1];
}
}
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
private decodeBase64(): Uint8Array {
let base64Data = this._content;
if (this._content.startsWith("data:")) {
const base64Match = this._content.match(/base64,(.+)/);
if (base64Match) {
base64Data = base64Match[1];
}
}
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
private getMimeType(): string {
const ext = this.filename.split(".").pop()?.toLowerCase();
if (ext === "xls") return "application/vnd.ms-excel";
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
}
public getHeaderButtons() {
return html`
<div class="flex items-center gap-1">
${DownloadButton({
content: this.decodeBase64(),
filename: this.filename,
mimeType: this.getMimeType(),
title: i18n("Download"),
})}
</div>
`;
}
override async updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has("_content") && this._content && !this.error) {
await this.renderExcel();
}
}
private async renderExcel() {
const container = this.querySelector("#excel-container");
if (!container || !this._content) return;
try {
const arrayBuffer = this.base64ToArrayBuffer(this._content);
const workbook = XLSX.read(arrayBuffer, { type: "array" });
container.innerHTML = "";
const wrapper = document.createElement("div");
wrapper.className = "overflow-auto h-full flex flex-col";
container.appendChild(wrapper);
// Create tabs for multiple sheets
if (workbook.SheetNames.length > 1) {
const tabContainer = document.createElement("div");
tabContainer.className = "flex gap-2 mb-4 border-b border-border sticky top-0 bg-background z-10";
const sheetContents: HTMLElement[] = [];
workbook.SheetNames.forEach((sheetName, index) => {
// Create tab button
const tab = document.createElement("button");
tab.textContent = sheetName;
tab.className =
index === 0
? "px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary"
: "px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-b-2 hover:border-border transition-colors";
// Create sheet content
const sheetDiv = document.createElement("div");
sheetDiv.style.display = index === 0 ? "flex" : "none";
sheetDiv.className = "flex-1 overflow-auto";
sheetDiv.appendChild(this.renderExcelSheet(workbook.Sheets[sheetName], sheetName));
sheetContents.push(sheetDiv);
// Tab click handler
tab.onclick = () => {
// Update tab styles
tabContainer.querySelectorAll("button").forEach((btn, btnIndex) => {
if (btnIndex === index) {
btn.className = "px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary";
} else {
btn.className =
"px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-b-2 hover:border-border transition-colors";
}
});
// Show/hide sheets
sheetContents.forEach((content, contentIndex) => {
content.style.display = contentIndex === index ? "flex" : "none";
});
};
tabContainer.appendChild(tab);
});
wrapper.appendChild(tabContainer);
sheetContents.forEach((content) => {
wrapper.appendChild(content);
});
} else {
// Single sheet
const sheetName = workbook.SheetNames[0];
wrapper.appendChild(this.renderExcelSheet(workbook.Sheets[sheetName], sheetName));
}
} catch (error: any) {
console.error("Error rendering Excel:", error);
this.error = error?.message || i18n("Failed to load spreadsheet");
}
}
private renderExcelSheet(worksheet: any, sheetName: string): HTMLElement {
const sheetDiv = document.createElement("div");
// Generate HTML table
const htmlTable = XLSX.utils.sheet_to_html(worksheet, { id: `sheet-${sheetName}` });
const tempDiv = document.createElement("div");
tempDiv.innerHTML = htmlTable;
// Find and style the table
const table = tempDiv.querySelector("table");
if (table) {
table.className = "w-full border-collapse text-foreground";
// Style all cells
table.querySelectorAll("td, th").forEach((cell) => {
const cellEl = cell as HTMLElement;
cellEl.className = "border border-border px-3 py-2 text-sm text-left";
});
// Style header row
const headerCells = table.querySelectorAll("thead th, tr:first-child td");
if (headerCells.length > 0) {
headerCells.forEach((th) => {
const thEl = th as HTMLElement;
thEl.className =
"border border-border px-3 py-2 text-sm font-semibold bg-muted text-foreground sticky top-0";
});
}
// Alternate row colors
table.querySelectorAll("tbody tr:nth-child(even)").forEach((row) => {
const rowEl = row as HTMLElement;
rowEl.className = "bg-muted/30";
});
sheetDiv.appendChild(table);
}
return sheetDiv;
}
override render(): TemplateResult {
if (this.error) {
return html`
<div class="h-full flex items-center justify-center bg-background p-4">
<div class="bg-destructive/10 border border-destructive text-destructive p-4 rounded-lg max-w-2xl">
<div class="font-medium mb-1">${i18n("Error loading spreadsheet")}</div>
<div class="text-sm opacity-90">${this.error}</div>
</div>
</div>
`;
}
return html`
<div class="h-full flex flex-col bg-background overflow-auto">
<div id="excel-container" class="flex-1 overflow-auto"></div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"excel-artifact": ExcelArtifact;
}
}
@@ -0,0 +1,117 @@
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
import { html, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { i18n } from "../../utils/i18n.js";
import { ArtifactElement } from "./ArtifactElement.js";
@customElement("generic-artifact")
export class GenericArtifact extends ArtifactElement {
@property({ type: String }) private _content = "";
get content(): string {
return this._content;
}
set content(value: string) {
this._content = value;
this.requestUpdate();
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
this.style.height = "100%";
}
private decodeBase64(): Uint8Array {
let base64Data = this._content;
if (this._content.startsWith("data:")) {
const base64Match = this._content.match(/base64,(.+)/);
if (base64Match) {
base64Data = base64Match[1];
}
}
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
private getMimeType(): string {
const ext = this.filename.split(".").pop()?.toLowerCase();
// Add common MIME types
const mimeTypes: Record<string, string> = {
pdf: "application/pdf",
zip: "application/zip",
tar: "application/x-tar",
gz: "application/gzip",
rar: "application/vnd.rar",
"7z": "application/x-7z-compressed",
mp3: "audio/mpeg",
mp4: "video/mp4",
avi: "video/x-msvideo",
mov: "video/quicktime",
wav: "audio/wav",
ogg: "audio/ogg",
json: "application/json",
xml: "application/xml",
bin: "application/octet-stream",
};
return mimeTypes[ext || ""] || "application/octet-stream";
}
public getHeaderButtons() {
return html`
<div class="flex items-center gap-1">
${DownloadButton({
content: this.decodeBase64(),
filename: this.filename,
mimeType: this.getMimeType(),
title: i18n("Download"),
})}
</div>
`;
}
override render(): TemplateResult {
return html`
<div class="h-full flex items-center justify-center bg-background p-8">
<div class="text-center max-w-md">
<div class="text-muted-foreground text-lg mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 mx-auto mb-4 text-muted-foreground/50"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
<div class="font-medium text-foreground mb-2">${this.filename}</div>
<p class="text-sm">
${i18n("Preview not available for this file type.")} ${i18n("Click the download button above to view it on your computer.")}
</p>
</div>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"generic-artifact": GenericArtifact;
}
}
@@ -0,0 +1,195 @@
import hljs from "highlight.js";
import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, type Ref, ref } from "lit/directives/ref.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { RefreshCw } from "lucide";
import type { SandboxIframe } from "../../components/SandboxedIframe.js";
import { type MessageConsumer, RUNTIME_MESSAGE_ROUTER } from "../../components/sandbox/RuntimeMessageRouter.js";
import type { SandboxRuntimeProvider } from "../../components/sandbox/SandboxRuntimeProvider.js";
import { i18n } from "../../utils/i18n.js";
import "../../components/SandboxedIframe.js";
import { ArtifactElement } from "./ArtifactElement.js";
import type { Console } from "./Console.js";
import "./Console.js";
import { icon } from "@mariozechner/mini-lit";
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import { CopyButton } from "@mariozechner/mini-lit/dist/CopyButton.js";
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
import { PreviewCodeToggle } from "@mariozechner/mini-lit/dist/PreviewCodeToggle.js";
@customElement("html-artifact")
export class HtmlArtifact extends ArtifactElement {
@property() override filename = "";
@property({ attribute: false }) runtimeProviders: SandboxRuntimeProvider[] = [];
@property({ attribute: false }) sandboxUrlProvider?: () => string;
private _content = "";
private logs: Array<{ type: "log" | "error"; text: string }> = [];
// Refs for DOM elements
public sandboxIframeRef: Ref<SandboxIframe> = createRef();
private consoleRef: Ref<Console> = createRef();
@state() private viewMode: "preview" | "code" = "preview";
private setViewMode(mode: "preview" | "code") {
this.viewMode = mode;
}
public getHeaderButtons() {
const toggle = new PreviewCodeToggle();
toggle.mode = this.viewMode;
toggle.addEventListener("mode-change", (e: Event) => {
this.setViewMode((e as CustomEvent).detail);
});
const copyButton = new CopyButton();
copyButton.text = this._content;
copyButton.title = i18n("Copy HTML");
copyButton.showText = false;
// Generate standalone HTML with all runtime code injected for download
const sandbox = this.sandboxIframeRef.value;
const sandboxId = `artifact-${this.filename}`;
const downloadContent =
sandbox?.prepareHtmlDocument(sandboxId, this._content, this.runtimeProviders || [], {
isHtmlArtifact: true,
isStandalone: true, // Skip runtime bridge and navigation interceptor for standalone downloads
}) || this._content;
return html`
<div class="flex items-center gap-2">
${toggle}
${Button({
variant: "ghost",
size: "sm",
onClick: () => {
this.logs = [];
this.executeContent(this._content);
},
title: i18n("Reload HTML"),
children: icon(RefreshCw, "sm"),
})}
${copyButton}
${DownloadButton({ content: downloadContent, filename: this.filename, mimeType: "text/html", title: i18n("Download HTML") })}
</div>
`;
}
override set content(value: string) {
const oldValue = this._content;
this._content = value;
if (oldValue !== value) {
// Reset logs when content changes
this.logs = [];
this.requestUpdate();
// Execute content in sandbox if it exists
if (this.sandboxIframeRef.value && value) {
this.executeContent(value);
}
}
}
public executeContent(html: string) {
const sandbox = this.sandboxIframeRef.value;
if (!sandbox) return;
// Configure sandbox URL provider if provided (for browser extensions)
if (this.sandboxUrlProvider) {
sandbox.sandboxUrlProvider = this.sandboxUrlProvider;
}
const sandboxId = `artifact-${this.filename}`;
// Create consumer for console messages
const consumer: MessageConsumer = {
handleMessage: async (message: any): Promise<void> => {
if (message.type === "console") {
// Create new array reference for Lit reactivity
this.logs = [
...this.logs,
{
type: message.method === "error" ? "error" : "log",
text: message.text,
},
];
this.requestUpdate(); // Re-render to show console
}
},
};
// Inject window.complete() call at the end of the HTML to signal when page is loaded
// HTML artifacts don't time out - they call complete() when ready
let modifiedHtml = html;
if (modifiedHtml.includes("</html>")) {
modifiedHtml = modifiedHtml.replace(
"</html>",
"<script>if (window.complete) window.complete();</script></html>",
);
} else {
// If no closing </html> tag, append the script
modifiedHtml += "<script>if (window.complete) window.complete();</script>";
}
// Load content - this handles sandbox registration, consumer registration, and iframe creation
sandbox.loadContent(sandboxId, modifiedHtml, this.runtimeProviders, [consumer]);
}
override get content(): string {
return this._content;
}
override disconnectedCallback() {
super.disconnectedCallback();
// Unregister sandbox when element is removed from DOM
const sandboxId = `artifact-${this.filename}`;
RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
}
override firstUpdated() {
// Execute initial content
if (this._content && this.sandboxIframeRef.value) {
this.executeContent(this._content);
}
}
override updated(changedProperties: Map<string | number | symbol, unknown>) {
super.updated(changedProperties);
// If we have content but haven't executed yet (e.g., during reconstruction),
// execute when the iframe ref becomes available
if (this._content && this.sandboxIframeRef.value && this.logs.length === 0) {
this.executeContent(this._content);
}
}
public getLogs(): string {
if (this.logs.length === 0) return i18n("No logs for {filename}").replace("{filename}", this.filename);
return this.logs.map((l) => `[${l.type}] ${l.text}`).join("\n");
}
override render() {
return html`
<div class="h-full flex flex-col">
<div class="flex-1 overflow-hidden relative">
<!-- Preview container - always in DOM, just hidden when not active -->
<div class="absolute inset-0 flex flex-col" style="display: ${this.viewMode === "preview" ? "flex" : "none"}">
<sandbox-iframe class="flex-1" ${ref(this.sandboxIframeRef)}></sandbox-iframe>
${
this.logs.length > 0
? html`<artifact-console .logs=${this.logs} ${ref(this.consoleRef)}></artifact-console>`
: ""
}
</div>
<!-- Code view - always in DOM, just hidden when not active -->
<div class="absolute inset-0 overflow-auto bg-background" style="display: ${this.viewMode === "code" ? "block" : "none"}">
<pre class="m-0 p-4 text-xs"><code class="hljs language-html">${unsafeHTML(
hljs.highlight(this._content, { language: "html" }).value,
)}</code></pre>
</div>
</div>
</div>
`;
}
}
@@ -0,0 +1,116 @@
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
import { html, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { i18n } from "../../utils/i18n.js";
import { ArtifactElement } from "./ArtifactElement.js";
@customElement("image-artifact")
export class ImageArtifact extends ArtifactElement {
@property({ type: String }) private _content = "";
get content(): string {
return this._content;
}
set content(value: string) {
this._content = value;
this.requestUpdate();
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
this.style.height = "100%";
}
private getMimeType(): string {
const ext = this.filename.split(".").pop()?.toLowerCase();
if (ext === "jpg" || ext === "jpeg") return "image/jpeg";
if (ext === "gif") return "image/gif";
if (ext === "webp") return "image/webp";
if (ext === "svg") return "image/svg+xml";
if (ext === "bmp") return "image/bmp";
if (ext === "ico") return "image/x-icon";
return "image/png";
}
private getImageUrl(): string {
// If content is already a data URL, use it directly
if (this._content.startsWith("data:")) {
return this._content;
}
// Otherwise assume it's base64 and construct data URL
return `data:${this.getMimeType()};base64,${this._content}`;
}
private decodeBase64(): Uint8Array {
let base64Data: string;
// If content is a data URL, extract the base64 part
if (this._content.startsWith("data:")) {
const base64Match = this._content.match(/base64,(.+)/);
if (base64Match) {
base64Data = base64Match[1];
} else {
// Not a base64 data URL, return empty
return new Uint8Array(0);
}
} else {
// Otherwise use content as-is
base64Data = this._content;
}
// Decode base64 to binary string
const binaryString = atob(base64Data);
// Convert binary string to Uint8Array
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
public getHeaderButtons() {
return html`
<div class="flex items-center gap-1">
${DownloadButton({
content: this.decodeBase64(),
filename: this.filename,
mimeType: this.getMimeType(),
title: i18n("Download"),
})}
</div>
`;
}
override render(): TemplateResult {
return html`
<div class="h-full flex flex-col bg-background overflow-auto">
<div class="flex-1 flex items-center justify-center p-4">
<img
src="${this.getImageUrl()}"
alt="${this.filename}"
class="max-w-full max-h-full object-contain"
@error=${(e: Event) => {
const target = e.target as HTMLImageElement;
target.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext x='50' y='50' text-anchor='middle' dominant-baseline='middle' fill='%23999'%3EImage Error%3C/text%3E%3C/svg%3E";
}}
/>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"image-artifact": ImageArtifact;
}
}
@@ -0,0 +1,82 @@
import hljs from "highlight.js";
import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { i18n } from "../../utils/i18n.js";
import "@mariozechner/mini-lit/dist/MarkdownBlock.js";
import { CopyButton } from "@mariozechner/mini-lit/dist/CopyButton.js";
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
import { PreviewCodeToggle } from "@mariozechner/mini-lit/dist/PreviewCodeToggle.js";
import { ArtifactElement } from "./ArtifactElement.js";
@customElement("markdown-artifact")
export class MarkdownArtifact extends ArtifactElement {
@property() override filename = "";
private _content = "";
override get content(): string {
return this._content;
}
override set content(value: string) {
this._content = value;
this.requestUpdate();
}
@state() private viewMode: "preview" | "code" = "preview";
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this; // light DOM
}
private setViewMode(mode: "preview" | "code") {
this.viewMode = mode;
}
public getHeaderButtons() {
const toggle = new PreviewCodeToggle();
toggle.mode = this.viewMode;
toggle.addEventListener("mode-change", (e: Event) => {
this.setViewMode((e as CustomEvent).detail);
});
const copyButton = new CopyButton();
copyButton.text = this._content;
copyButton.title = i18n("Copy Markdown");
copyButton.showText = false;
return html`
<div class="flex items-center gap-2">
${toggle}
${copyButton}
${DownloadButton({
content: this._content,
filename: this.filename,
mimeType: "text/markdown",
title: i18n("Download Markdown"),
})}
</div>
`;
}
override render() {
return html`
<div class="h-full flex flex-col">
<div class="flex-1 overflow-auto">
${
this.viewMode === "preview"
? html`<div class="p-4"><markdown-block .content=${this.content}></markdown-block></div>`
: html`<pre class="m-0 p-4 text-xs whitespace-pre-wrap break-words"><code class="hljs language-markdown">${unsafeHTML(
hljs.highlight(this.content, { language: "markdown", ignoreIllegals: true }).value,
)}</code></pre>`
}
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"markdown-artifact": MarkdownArtifact;
}
}
@@ -0,0 +1,201 @@
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
import { html, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import * as pdfjsLib from "pdfjs-dist";
import { i18n } from "../../utils/i18n.js";
import { ArtifactElement } from "./ArtifactElement.js";
// Configure PDF.js worker
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
@customElement("pdf-artifact")
export class PdfArtifact extends ArtifactElement {
@property({ type: String }) private _content = "";
@state() private error: string | null = null;
private currentLoadingTask: any = null;
get content(): string {
return this._content;
}
set content(value: string) {
this._content = value;
this.error = null;
this.requestUpdate();
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
this.style.height = "100%";
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.cleanup();
}
private cleanup() {
if (this.currentLoadingTask) {
this.currentLoadingTask.destroy();
this.currentLoadingTask = null;
}
}
private base64ToArrayBuffer(base64: string): ArrayBuffer {
// Remove data URL prefix if present
let base64Data = base64;
if (base64.startsWith("data:")) {
const base64Match = base64.match(/base64,(.+)/);
if (base64Match) {
base64Data = base64Match[1];
}
}
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
private decodeBase64(): Uint8Array {
let base64Data = this._content;
if (this._content.startsWith("data:")) {
const base64Match = this._content.match(/base64,(.+)/);
if (base64Match) {
base64Data = base64Match[1];
}
}
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
public getHeaderButtons() {
return html`
<div class="flex items-center gap-1">
${DownloadButton({
content: this.decodeBase64(),
filename: this.filename,
mimeType: "application/pdf",
title: i18n("Download"),
})}
</div>
`;
}
override async updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has("_content") && this._content && !this.error) {
await this.renderPdf();
}
}
private async renderPdf() {
const container = this.querySelector("#pdf-container");
if (!container || !this._content) return;
let pdf: any = null;
try {
const arrayBuffer = this.base64ToArrayBuffer(this._content);
// Cancel any existing loading task
if (this.currentLoadingTask) {
this.currentLoadingTask.destroy();
}
// Load the PDF
this.currentLoadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
pdf = await this.currentLoadingTask.promise;
this.currentLoadingTask = null;
// Clear container
container.innerHTML = "";
const wrapper = document.createElement("div");
wrapper.className = "p-4";
container.appendChild(wrapper);
// Render all pages
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
const page = await pdf.getPage(pageNum);
const pageContainer = document.createElement("div");
pageContainer.className = "mb-4 last:mb-0";
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
const viewport = page.getViewport({ scale: 1.5 });
canvas.height = viewport.height;
canvas.width = viewport.width;
canvas.className = "w-full max-w-full h-auto block mx-auto bg-white rounded shadow-sm border border-border";
if (context) {
context.fillStyle = "white";
context.fillRect(0, 0, canvas.width, canvas.height);
}
await page.render({
canvasContext: context!,
viewport: viewport,
canvas: canvas,
}).promise;
pageContainer.appendChild(canvas);
if (pageNum < pdf.numPages) {
const separator = document.createElement("div");
separator.className = "h-px bg-border my-4";
pageContainer.appendChild(separator);
}
wrapper.appendChild(pageContainer);
}
} catch (error: any) {
console.error("Error rendering PDF:", error);
this.error = error?.message || i18n("Failed to load PDF");
} finally {
if (pdf) {
pdf.destroy();
}
}
}
override render(): TemplateResult {
if (this.error) {
return html`
<div class="h-full flex items-center justify-center bg-background p-4">
<div class="bg-destructive/10 border border-destructive text-destructive p-4 rounded-lg max-w-2xl">
<div class="font-medium mb-1">${i18n("Error loading PDF")}</div>
<div class="text-sm opacity-90">${this.error}</div>
</div>
</div>
`;
}
return html`
<div class="h-full flex flex-col bg-background overflow-auto">
<div id="pdf-container" class="flex-1 overflow-auto"></div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"pdf-artifact": PdfArtifact;
}
}
@@ -0,0 +1,78 @@
import { CopyButton } from "@mariozechner/mini-lit/dist/CopyButton.js";
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
import { PreviewCodeToggle } from "@mariozechner/mini-lit/dist/PreviewCodeToggle.js";
import hljs from "highlight.js";
import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { i18n } from "../../utils/i18n.js";
import { ArtifactElement } from "./ArtifactElement.js";
@customElement("svg-artifact")
export class SvgArtifact extends ArtifactElement {
@property() override filename = "";
private _content = "";
override get content(): string {
return this._content;
}
override set content(value: string) {
this._content = value;
this.requestUpdate();
}
@state() private viewMode: "preview" | "code" = "preview";
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this; // light DOM
}
private setViewMode(mode: "preview" | "code") {
this.viewMode = mode;
}
public getHeaderButtons() {
const toggle = new PreviewCodeToggle();
toggle.mode = this.viewMode;
toggle.addEventListener("mode-change", (e: Event) => {
this.setViewMode((e as CustomEvent).detail);
});
const copyButton = new CopyButton();
copyButton.text = this._content;
copyButton.title = i18n("Copy SVG");
copyButton.showText = false;
return html`
<div class="flex items-center gap-2">
${toggle}
${copyButton}
${DownloadButton({ content: this._content, filename: this.filename, mimeType: "image/svg+xml", title: i18n("Download SVG") })}
</div>
`;
}
override render() {
return html`
<div class="h-full flex flex-col">
<div class="flex-1 overflow-auto">
${
this.viewMode === "preview"
? html`<div class="h-full flex items-center justify-center">
${unsafeHTML(this.content.replace(/<svg(\s|>)/i, (_m, p1) => `<svg class="w-full h-full"${p1}`))}
</div>`
: html`<pre class="m-0 p-4 text-xs"><code class="hljs language-xml">${unsafeHTML(
hljs.highlight(this.content, { language: "xml", ignoreIllegals: true }).value,
)}</code></pre>`
}
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"svg-artifact": SvgArtifact;
}
}
@@ -0,0 +1,148 @@
import { CopyButton } from "@mariozechner/mini-lit/dist/CopyButton.js";
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
import hljs from "highlight.js";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { i18n } from "../../utils/i18n.js";
import { ArtifactElement } from "./ArtifactElement.js";
// Known code file extensions for highlighting
const CODE_EXTENSIONS = [
"js",
"javascript",
"ts",
"typescript",
"jsx",
"tsx",
"py",
"python",
"java",
"c",
"cpp",
"cs",
"php",
"rb",
"ruby",
"go",
"rust",
"swift",
"kotlin",
"scala",
"dart",
"html",
"css",
"scss",
"sass",
"less",
"json",
"xml",
"yaml",
"yml",
"toml",
"sql",
"sh",
"bash",
"ps1",
"bat",
"r",
"matlab",
"julia",
"lua",
"perl",
"vue",
"svelte",
];
@customElement("text-artifact")
export class TextArtifact extends ArtifactElement {
@property() override filename = "";
private _content = "";
override get content(): string {
return this._content;
}
override set content(value: string) {
this._content = value;
this.requestUpdate();
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this; // light DOM
}
private isCode(): boolean {
const ext = this.filename.split(".").pop()?.toLowerCase() || "";
return CODE_EXTENSIONS.includes(ext);
}
private getLanguageFromExtension(ext: string): string {
const languageMap: Record<string, string> = {
js: "javascript",
ts: "typescript",
py: "python",
rb: "ruby",
yml: "yaml",
ps1: "powershell",
bat: "batch",
};
return languageMap[ext] || ext;
}
private getMimeType(): string {
const ext = this.filename.split(".").pop()?.toLowerCase() || "";
if (ext === "svg") return "image/svg+xml";
if (ext === "md" || ext === "markdown") return "text/markdown";
return "text/plain";
}
public getHeaderButtons() {
const copyButton = new CopyButton();
copyButton.text = this.content;
copyButton.title = i18n("Copy");
copyButton.showText = false;
return html`
<div class="flex items-center gap-1">
${copyButton}
${DownloadButton({
content: this.content,
filename: this.filename,
mimeType: this.getMimeType(),
title: i18n("Download"),
})}
</div>
`;
}
override render() {
const isCode = this.isCode();
const ext = this.filename.split(".").pop() || "";
return html`
<div class="h-full flex flex-col">
<div class="flex-1 overflow-auto">
${
isCode
? html`
<pre class="m-0 p-4 text-xs"><code class="hljs language-${this.getLanguageFromExtension(
ext.toLowerCase(),
)}">${unsafeHTML(
hljs.highlight(this.content, {
language: this.getLanguageFromExtension(ext.toLowerCase()),
ignoreIllegals: true,
}).value,
)}</code></pre>
`
: html` <pre class="m-0 p-4 text-xs font-mono">${this.content}</pre> `
}
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"text-artifact": TextArtifact;
}
}
@@ -0,0 +1,310 @@
import "@mariozechner/mini-lit/dist/CodeBlock.js";
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { createRef, ref } from "lit/directives/ref.js";
import { FileCode2 } from "lucide";
import "../../components/ConsoleBlock.js";
import { Diff } from "@mariozechner/mini-lit/dist/Diff.js";
import { html, type TemplateResult } from "lit";
import { i18n } from "../../utils/i18n.js";
import { renderCollapsibleHeader, renderHeader } from "../renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "../types.js";
import { ArtifactPill } from "./ArtifactPill.js";
import type { ArtifactsPanel, ArtifactsParams } from "./artifacts.js";
// Helper to extract text from content blocks
function getTextOutput(result: ToolResultMessage<any> | undefined): string {
if (!result) return "";
return (
result.content
?.filter((c) => c.type === "text")
.map((c: any) => c.text)
.join("\n") || ""
);
}
// Helper to determine language for syntax highlighting
function getLanguageFromFilename(filename?: string): string {
if (!filename) return "text";
const ext = filename.split(".").pop()?.toLowerCase();
const languageMap: Record<string, string> = {
js: "javascript",
jsx: "javascript",
ts: "typescript",
tsx: "typescript",
html: "html",
css: "css",
scss: "scss",
json: "json",
py: "python",
md: "markdown",
svg: "xml",
xml: "xml",
yaml: "yaml",
yml: "yaml",
sh: "bash",
bash: "bash",
sql: "sql",
java: "java",
c: "c",
cpp: "cpp",
cs: "csharp",
go: "go",
rs: "rust",
php: "php",
rb: "ruby",
swift: "swift",
kt: "kotlin",
r: "r",
};
return languageMap[ext || ""] || "text";
}
export class ArtifactsToolRenderer implements ToolRenderer<ArtifactsParams, undefined> {
constructor(public artifactsPanel?: ArtifactsPanel) {}
render(
params: ArtifactsParams | undefined,
result: ToolResultMessage<undefined> | undefined,
isStreaming?: boolean,
): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : isStreaming ? "inprogress" : "complete";
// Create refs for collapsible sections
const contentRef = createRef<HTMLDivElement>();
const chevronRef = createRef<HTMLSpanElement>();
// Helper to get command labels
const getCommandLabels = (command: string): { streaming: string; complete: string } => {
const labels: Record<string, { streaming: string; complete: string }> = {
create: { streaming: i18n("Creating artifact"), complete: i18n("Created artifact") },
update: { streaming: i18n("Updating artifact"), complete: i18n("Updated artifact") },
rewrite: { streaming: i18n("Rewriting artifact"), complete: i18n("Rewrote artifact") },
get: { streaming: i18n("Getting artifact"), complete: i18n("Got artifact") },
delete: { streaming: i18n("Deleting artifact"), complete: i18n("Deleted artifact") },
logs: { streaming: i18n("Getting logs"), complete: i18n("Got logs") },
};
return labels[command] || { streaming: i18n("Processing artifact"), complete: i18n("Processed artifact") };
};
// Helper to render header text with inline artifact pill
const renderHeaderWithPill = (labelText: string, filename?: string): TemplateResult => {
if (filename) {
return html`<span>${labelText} ${ArtifactPill(filename, this.artifactsPanel)}</span>`;
}
return html`<span>${labelText}</span>`;
};
// Error handling
if (result?.isError) {
const command = params?.command;
const filename = params?.filename;
const labels = command
? getCommandLabels(command)
: { streaming: i18n("Processing artifact"), complete: i18n("Processed artifact") };
const headerText = labels.streaming;
// For create/update/rewrite errors, show code block + console/error
if (command === "create" || command === "update" || command === "rewrite") {
const content = params?.content || "";
const { old_str, new_str } = params || {};
const isDiff = command === "update";
const diffContent =
old_str !== undefined && new_str !== undefined ? Diff({ oldText: old_str, newText: new_str }) : "";
const isHtml = filename?.endsWith(".html");
return {
content: html`
<div>
${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)}
<div ${ref(contentRef)} class="max-h-0 overflow-hidden transition-all duration-300 space-y-3">
${isDiff ? diffContent : content ? html`<code-block .code=${content} language=${getLanguageFromFilename(filename)}></code-block>` : ""}
${
isHtml
? html`<console-block .content=${getTextOutput(result) || i18n("An error occurred")} variant="error"></console-block>`
: html`<div class="text-sm text-destructive">${getTextOutput(result) || i18n("An error occurred")}</div>`
}
</div>
</div>
`,
isCustom: false,
};
}
// For other errors, just show error message
return {
content: html`
<div class="space-y-3">
${renderHeader(state, FileCode2, headerText)}
<div class="text-sm text-destructive">${getTextOutput(result) || i18n("An error occurred")}</div>
</div>
`,
isCustom: false,
};
}
// Full params + result
if (result && params) {
const { command, filename, content } = params;
const labels = command
? getCommandLabels(command)
: { streaming: i18n("Processing artifact"), complete: i18n("Processed artifact") };
const headerText = labels.complete;
// GET command: show code block with file content
if (command === "get") {
const fileContent = getTextOutput(result) || i18n("(no output)");
return {
content: html`
<div>
${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)}
<div ${ref(contentRef)} class="max-h-0 overflow-hidden transition-all duration-300">
<code-block .code=${fileContent} language=${getLanguageFromFilename(filename)}></code-block>
</div>
</div>
`,
isCustom: false,
};
}
// LOGS command: show console block
if (command === "logs") {
const logs = getTextOutput(result) || i18n("(no output)");
return {
content: html`
<div>
${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)}
<div ${ref(contentRef)} class="max-h-0 overflow-hidden transition-all duration-300">
<console-block .content=${logs}></console-block>
</div>
</div>
`,
isCustom: false,
};
}
// CREATE/UPDATE/REWRITE: always show code block, + console block for .html files
if (command === "create" || command === "rewrite") {
const codeContent = content || "";
const isHtml = filename?.endsWith(".html");
const logs = getTextOutput(result) || "";
return {
content: html`
<div>
${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)}
<div ${ref(contentRef)} class="max-h-0 overflow-hidden transition-all duration-300 space-y-3">
${codeContent ? html`<code-block .code=${codeContent} language=${getLanguageFromFilename(filename)}></code-block>` : ""}
${isHtml && logs ? html`<console-block .content=${logs}></console-block>` : ""}
</div>
</div>
`,
isCustom: false,
};
}
if (command === "update") {
const isHtml = filename?.endsWith(".html");
const logs = getTextOutput(result) || "";
return {
content: html`
<div>
${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)}
<div ${ref(contentRef)} class="max-h-0 overflow-hidden transition-all duration-300 space-y-3">
${Diff({ oldText: params.old_str || "", newText: params.new_str || "" })}
${isHtml && logs ? html`<console-block .content=${logs}></console-block>` : ""}
</div>
</div>
`,
isCustom: false,
};
}
// For DELETE, just show header
return {
content: html`
<div class="space-y-3">
${renderHeader(state, FileCode2, renderHeaderWithPill(headerText, filename))}
</div>
`,
isCustom: false,
};
}
// Params only (streaming or waiting for result)
if (params) {
const { command, filename, content, old_str, new_str } = params;
// If no command yet
if (!command) {
return { content: renderHeader(state, FileCode2, i18n("Preparing artifact...")), isCustom: false };
}
const labels = getCommandLabels(command);
const headerText = labels.streaming;
// Render based on command type
switch (command) {
case "create":
case "rewrite":
return {
content: html`
<div>
${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)}
<div ${ref(contentRef)} class="max-h-0 overflow-hidden transition-all duration-300">
${
content
? html`<code-block .code=${content} language=${getLanguageFromFilename(filename)}></code-block>`
: ""
}
</div>
</div>
`,
isCustom: false,
};
case "update":
return {
content: html`
<div>
${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)}
<div ${ref(contentRef)} class="max-h-0 overflow-hidden transition-all duration-300">
${
old_str !== undefined && new_str !== undefined
? Diff({ oldText: old_str, newText: new_str })
: ""
}
</div>
</div>
`,
isCustom: false,
};
case "get":
case "logs":
return {
content: html`
<div>
${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)}
<div ${ref(contentRef)} class="max-h-0 overflow-hidden transition-all duration-300"></div>
</div>
`,
isCustom: false,
};
default:
return {
content: html`
<div>
${renderHeader(state, FileCode2, renderHeaderWithPill(headerText, filename))}
</div>
`,
isCustom: false,
};
}
}
// No params or result yet
return { content: renderHeader(state, FileCode2, i18n("Preparing artifact...")), isCustom: false };
}
}
@@ -0,0 +1,713 @@
import { icon } from "@mariozechner/mini-lit";
import "@mariozechner/mini-lit/dist/MarkdownBlock.js";
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import type { Agent, AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
import { StringEnum, type ToolCall } from "@mariozechner/pi-ai";
import { type Static, Type } from "@sinclair/typebox";
import { html, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, type Ref, ref } from "lit/directives/ref.js";
import { X } from "lucide";
import type { ArtifactMessage } from "../../components/Messages.js";
import { ArtifactsRuntimeProvider } from "../../components/sandbox/ArtifactsRuntimeProvider.js";
import { AttachmentsRuntimeProvider } from "../../components/sandbox/AttachmentsRuntimeProvider.js";
import type { SandboxRuntimeProvider } from "../../components/sandbox/SandboxRuntimeProvider.js";
import {
ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO,
ARTIFACTS_TOOL_DESCRIPTION,
ATTACHMENTS_RUNTIME_DESCRIPTION,
} from "../../prompts/prompts.js";
import type { Attachment } from "../../utils/attachment-utils.js";
import { i18n } from "../../utils/i18n.js";
import type { ArtifactElement } from "./ArtifactElement.js";
import { DocxArtifact } from "./DocxArtifact.js";
import { ExcelArtifact } from "./ExcelArtifact.js";
import { GenericArtifact } from "./GenericArtifact.js";
import { HtmlArtifact } from "./HtmlArtifact.js";
import { ImageArtifact } from "./ImageArtifact.js";
import { MarkdownArtifact } from "./MarkdownArtifact.js";
import { PdfArtifact } from "./PdfArtifact.js";
import { SvgArtifact } from "./SvgArtifact.js";
import { TextArtifact } from "./TextArtifact.js";
// Simple artifact model
export interface Artifact {
filename: string;
content: string;
createdAt: Date;
updatedAt: Date;
}
// JSON-schema friendly parameters object (LLM-facing)
const artifactsParamsSchema = Type.Object({
command: StringEnum(["create", "update", "rewrite", "get", "delete", "logs"], {
description: "The operation to perform",
}),
filename: Type.String({ description: "Filename including extension (e.g., 'index.html', 'script.js')" }),
content: Type.Optional(Type.String({ description: "File content" })),
old_str: Type.Optional(Type.String({ description: "String to replace (for update command)" })),
new_str: Type.Optional(Type.String({ description: "Replacement string (for update command)" })),
});
export type ArtifactsParams = Static<typeof artifactsParamsSchema>;
@customElement("artifacts-panel")
export class ArtifactsPanel extends LitElement {
@state() private _artifacts = new Map<string, Artifact>();
@state() private _activeFilename: string | null = null;
// Programmatically managed artifact elements
private artifactElements = new Map<string, ArtifactElement>();
private contentRef: Ref<HTMLDivElement> = createRef();
// Agent reference (needed to get attachments for HTML artifacts)
@property({ attribute: false }) agent?: Agent;
// Sandbox URL provider for browser extensions (optional)
@property({ attribute: false }) sandboxUrlProvider?: () => string;
// Callbacks
@property({ attribute: false }) onArtifactsChange?: () => void;
@property({ attribute: false }) onClose?: () => void;
@property({ attribute: false }) onOpen?: () => void;
// Collapsed mode: hides panel content but can show a floating reopen pill
@property({ type: Boolean }) collapsed = false;
// Overlay mode: when true, panel renders full-screen overlay (mobile)
@property({ type: Boolean }) overlay = false;
// Public getter for artifacts
get artifacts() {
return this._artifacts;
}
// Get runtime providers for HTML artifacts (read-only: attachments + artifacts)
private getHtmlArtifactRuntimeProviders(): SandboxRuntimeProvider[] {
const providers: SandboxRuntimeProvider[] = [];
// Get attachments from agent messages
if (this.agent) {
const attachments: Attachment[] = [];
for (const message of this.agent.state.messages) {
if (message.role === "user-with-attachments" && message.attachments) {
attachments.push(...message.attachments);
}
}
if (attachments.length > 0) {
providers.push(new AttachmentsRuntimeProvider(attachments));
}
}
// Add read-only artifacts provider
providers.push(new ArtifactsRuntimeProvider(this, this.agent, false));
return providers;
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this; // light DOM for shared styles
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
this.style.height = "100%";
// Reattach existing artifact elements when panel is re-inserted into the DOM
requestAnimationFrame(() => {
const container = this.contentRef.value;
if (!container) return;
// Ensure we have an active filename
if (!this._activeFilename && this._artifacts.size > 0) {
this._activeFilename = Array.from(this._artifacts.keys())[0];
}
this.artifactElements.forEach((element, name) => {
if (!element.parentElement) container.appendChild(element);
element.style.display = name === this._activeFilename ? "block" : "none";
});
});
}
override disconnectedCallback() {
super.disconnectedCallback();
// Do not tear down artifact elements; keep them to restore on next mount
}
// Helper to determine file type from extension
private getFileType(
filename: string,
): "html" | "svg" | "markdown" | "image" | "pdf" | "excel" | "docx" | "text" | "generic" {
const ext = filename.split(".").pop()?.toLowerCase();
if (ext === "html") return "html";
if (ext === "svg") return "svg";
if (ext === "md" || ext === "markdown") return "markdown";
if (ext === "pdf") return "pdf";
if (ext === "xlsx" || ext === "xls") return "excel";
if (ext === "docx") return "docx";
if (
ext === "png" ||
ext === "jpg" ||
ext === "jpeg" ||
ext === "gif" ||
ext === "webp" ||
ext === "bmp" ||
ext === "ico"
)
return "image";
// Text files
if (
ext === "txt" ||
ext === "json" ||
ext === "xml" ||
ext === "yaml" ||
ext === "yml" ||
ext === "csv" ||
ext === "js" ||
ext === "ts" ||
ext === "jsx" ||
ext === "tsx" ||
ext === "py" ||
ext === "java" ||
ext === "c" ||
ext === "cpp" ||
ext === "h" ||
ext === "css" ||
ext === "scss" ||
ext === "sass" ||
ext === "less" ||
ext === "sh"
)
return "text";
// Everything else gets generic fallback
return "generic";
}
// Get or create artifact element
private getOrCreateArtifactElement(filename: string, content: string): ArtifactElement {
let element = this.artifactElements.get(filename);
if (!element) {
const type = this.getFileType(filename);
if (type === "html") {
element = new HtmlArtifact();
(element as HtmlArtifact).runtimeProviders = this.getHtmlArtifactRuntimeProviders();
if (this.sandboxUrlProvider) {
(element as HtmlArtifact).sandboxUrlProvider = this.sandboxUrlProvider;
}
} else if (type === "svg") {
element = new SvgArtifact();
} else if (type === "markdown") {
element = new MarkdownArtifact();
} else if (type === "image") {
element = new ImageArtifact();
} else if (type === "pdf") {
element = new PdfArtifact();
} else if (type === "excel") {
element = new ExcelArtifact();
} else if (type === "docx") {
element = new DocxArtifact();
} else if (type === "text") {
element = new TextArtifact();
} else {
element = new GenericArtifact();
}
element.filename = filename;
element.content = content;
element.style.display = "none";
element.style.height = "100%";
// Store element
this.artifactElements.set(filename, element);
// Add to DOM - try immediately if container exists, otherwise schedule
const newElement = element;
if (this.contentRef.value) {
this.contentRef.value.appendChild(newElement);
} else {
requestAnimationFrame(() => {
if (this.contentRef.value && !newElement.parentElement) {
this.contentRef.value.appendChild(newElement);
}
});
}
} else {
// Just update content
element.content = content;
if (element instanceof HtmlArtifact) {
element.runtimeProviders = this.getHtmlArtifactRuntimeProviders();
}
}
return element;
}
// Show/hide artifact elements
private showArtifact(filename: string) {
// Ensure the active element is in the DOM
requestAnimationFrame(() => {
this.artifactElements.forEach((element, name) => {
if (this.contentRef.value && !element.parentElement) {
this.contentRef.value.appendChild(element);
}
element.style.display = name === filename ? "block" : "none";
});
});
this._activeFilename = filename;
this.requestUpdate(); // Only for tab bar update
// Scroll the active tab into view after render
requestAnimationFrame(() => {
const activeButton = this.querySelector(`button[data-filename="${filename}"]`);
if (activeButton) {
activeButton.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
}
});
}
// Open panel and focus an artifact tab by filename
public openArtifact(filename: string) {
if (this._artifacts.has(filename)) {
this.showArtifact(filename);
// Ask host to open panel (AgentInterface demo listens to onOpen)
this.onOpen?.();
}
}
// Build the AgentTool (no details payload; return only output strings)
public get tool(): AgentTool<typeof artifactsParamsSchema, undefined> {
return {
label: "Artifacts",
name: "artifacts",
get description() {
// HTML artifacts have read-only access to attachments and artifacts
const runtimeProviderDescriptions = [
ATTACHMENTS_RUNTIME_DESCRIPTION,
ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO,
];
return ARTIFACTS_TOOL_DESCRIPTION(runtimeProviderDescriptions);
},
parameters: artifactsParamsSchema,
// Execute mutates our local store and returns a plain output
execute: async (_toolCallId: string, args: Static<typeof artifactsParamsSchema>, _signal?: AbortSignal) => {
const output = await this.executeCommand(args);
return { content: [{ type: "text", text: output }], details: undefined };
},
};
}
// Re-apply artifacts by scanning a message list (optional utility)
public async reconstructFromMessages(
messages: Array<AgentMessage | { role: "aborted" } | { role: "artifact" }>,
): Promise<void> {
const toolCalls = new Map<string, ToolCall>();
const artifactToolName = "artifacts";
// 1) Collect tool calls from assistant messages
for (const message of messages) {
if (message.role === "assistant") {
for (const block of message.content) {
if (block.type === "toolCall" && block.name === artifactToolName) {
toolCalls.set(block.id, block);
}
}
}
}
// 2) Build an ordered list of successful artifact operations
const operations: Array<ArtifactsParams> = [];
for (const m of messages) {
if ((m as any).role === "artifact") {
const artifactMsg = m as ArtifactMessage;
switch (artifactMsg.action) {
case "create":
operations.push({
command: "create",
filename: artifactMsg.filename,
content: artifactMsg.content,
});
break;
case "update":
operations.push({
command: "rewrite",
filename: artifactMsg.filename,
content: artifactMsg.content,
});
break;
case "delete":
operations.push({
command: "delete",
filename: artifactMsg.filename,
});
break;
}
}
// Handle tool result messages (from artifacts tool calls)
else if ((m as any).role === "toolResult" && (m as any).toolName === artifactToolName && !(m as any).isError) {
const toolCallId = (m as any).toolCallId as string;
const call = toolCalls.get(toolCallId);
if (!call) continue;
const params = call.arguments as ArtifactsParams;
if (params.command === "get" || params.command === "logs") continue; // no state change
operations.push(params);
}
}
// 3) Compute final state per filename by simulating operations in-memory
const finalArtifacts = new Map<string, string>();
for (const op of operations) {
const filename = op.filename;
switch (op.command) {
case "create": {
if (op.content) {
finalArtifacts.set(filename, op.content);
}
break;
}
case "rewrite": {
if (op.content) {
finalArtifacts.set(filename, op.content);
}
break;
}
case "update": {
let existing = finalArtifacts.get(filename);
if (!existing) break; // skip invalid update (shouldn't happen for successful results)
if (op.old_str !== undefined && op.new_str !== undefined) {
existing = existing.replace(op.old_str, op.new_str);
finalArtifacts.set(filename, existing);
}
break;
}
case "delete": {
finalArtifacts.delete(filename);
break;
}
case "get":
case "logs":
// Ignored above, just for completeness
break;
}
}
// 4) Reset current UI state before bulk create
this._artifacts.clear();
this.artifactElements.forEach((el) => {
el.remove();
});
this.artifactElements.clear();
this._activeFilename = null;
this._artifacts = new Map(this._artifacts);
// 5) Create artifacts in a single pass without waiting for iframe execution or tab switching
for (const [filename, content] of finalArtifacts.entries()) {
const createParams: ArtifactsParams = { command: "create", filename, content } as const;
try {
await this.createArtifact(createParams, { skipWait: true, silent: true });
} catch {
// Ignore failures during reconstruction
}
}
// 6) Show first artifact if any exist, and notify listeners once
if (!this._activeFilename && this._artifacts.size > 0) {
this.showArtifact(Array.from(this._artifacts.keys())[0]);
}
this.onArtifactsChange?.();
this.requestUpdate();
}
// Core command executor
private async executeCommand(
params: ArtifactsParams,
options: { skipWait?: boolean; silent?: boolean } = {},
): Promise<string> {
switch (params.command) {
case "create":
return await this.createArtifact(params, options);
case "update":
return await this.updateArtifact(params, options);
case "rewrite":
return await this.rewriteArtifact(params, options);
case "get":
return this.getArtifact(params);
case "delete":
return this.deleteArtifact(params);
case "logs":
return this.getLogs(params);
default:
// Should never happen with TypeBox validation
return `Error: Unknown command ${(params as any).command}`;
}
}
// Wait for HTML artifact execution and get logs
private async waitForHtmlExecution(filename: string): Promise<string> {
const element = this.artifactElements.get(filename);
if (!(element instanceof HtmlArtifact)) {
return "";
}
return new Promise((resolve) => {
// Fallback timeout - just get logs after execution should complete
setTimeout(() => {
// Get whatever logs we have
const logs = element.getLogs();
resolve(logs);
}, 1500);
});
}
// Reload all HTML artifacts (called when any artifact changes)
private reloadAllHtmlArtifacts() {
this.artifactElements.forEach((element) => {
if (element instanceof HtmlArtifact && element.sandboxIframeRef.value) {
// Update runtime providers with latest artifact state
element.runtimeProviders = this.getHtmlArtifactRuntimeProviders();
// Re-execute the HTML content
element.executeContent(element.content);
}
});
}
private async createArtifact(
params: ArtifactsParams,
options: { skipWait?: boolean; silent?: boolean } = {},
): Promise<string> {
if (!params.filename || !params.content) {
return "Error: create command requires filename and content";
}
if (this._artifacts.has(params.filename)) {
return `Error: File ${params.filename} already exists`;
}
const artifact: Artifact = {
filename: params.filename,
content: params.content,
createdAt: new Date(),
updatedAt: new Date(),
};
this._artifacts.set(params.filename, artifact);
this._artifacts = new Map(this._artifacts);
// Create or update element
this.getOrCreateArtifactElement(params.filename, params.content);
if (!options.silent) {
this.showArtifact(params.filename);
this.onArtifactsChange?.();
this.requestUpdate();
}
// Reload all HTML artifacts since they might depend on this new artifact
this.reloadAllHtmlArtifacts();
// For HTML files, wait for execution
let result = `Created file ${params.filename}`;
if (this.getFileType(params.filename) === "html" && !options.skipWait) {
const logs = await this.waitForHtmlExecution(params.filename);
result += `\n${logs}`;
}
return result;
}
private async updateArtifact(
params: ArtifactsParams,
options: { skipWait?: boolean; silent?: boolean } = {},
): Promise<string> {
const artifact = this._artifacts.get(params.filename);
if (!artifact) {
const files = Array.from(this._artifacts.keys());
if (files.length === 0) return `Error: File ${params.filename} not found. No files have been created yet.`;
return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`;
}
if (!params.old_str || params.new_str === undefined) {
return "Error: update command requires old_str and new_str";
}
if (!artifact.content.includes(params.old_str)) {
return `Error: String not found in file. Here is the full content:\n\n${artifact.content}`;
}
artifact.content = artifact.content.replace(params.old_str, params.new_str);
artifact.updatedAt = new Date();
this._artifacts.set(params.filename, artifact);
// Update element
this.getOrCreateArtifactElement(params.filename, artifact.content);
if (!options.silent) {
this.onArtifactsChange?.();
this.requestUpdate();
}
// Show the artifact
this.showArtifact(params.filename);
// Reload all HTML artifacts since they might depend on this updated artifact
this.reloadAllHtmlArtifacts();
// For HTML files, wait for execution
let result = `Updated file ${params.filename}`;
if (this.getFileType(params.filename) === "html" && !options.skipWait) {
const logs = await this.waitForHtmlExecution(params.filename);
result += `\n${logs}`;
}
return result;
}
private async rewriteArtifact(
params: ArtifactsParams,
options: { skipWait?: boolean; silent?: boolean } = {},
): Promise<string> {
const artifact = this._artifacts.get(params.filename);
if (!artifact) {
const files = Array.from(this._artifacts.keys());
if (files.length === 0) return `Error: File ${params.filename} not found. No files have been created yet.`;
return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`;
}
if (!params.content) {
return "Error: rewrite command requires content";
}
artifact.content = params.content;
artifact.updatedAt = new Date();
this._artifacts.set(params.filename, artifact);
// Update element
this.getOrCreateArtifactElement(params.filename, artifact.content);
if (!options.silent) {
this.onArtifactsChange?.();
}
// Show the artifact
this.showArtifact(params.filename);
// Reload all HTML artifacts since they might depend on this rewritten artifact
this.reloadAllHtmlArtifacts();
// For HTML files, wait for execution
let result = "";
if (this.getFileType(params.filename) === "html" && !options.skipWait) {
const logs = await this.waitForHtmlExecution(params.filename);
result += `\n${logs}`;
}
return result;
}
private getArtifact(params: ArtifactsParams): string {
const artifact = this._artifacts.get(params.filename);
if (!artifact) {
const files = Array.from(this._artifacts.keys());
if (files.length === 0) return `Error: File ${params.filename} not found. No files have been created yet.`;
return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`;
}
return artifact.content;
}
private deleteArtifact(params: ArtifactsParams): string {
const artifact = this._artifacts.get(params.filename);
if (!artifact) {
const files = Array.from(this._artifacts.keys());
if (files.length === 0) return `Error: File ${params.filename} not found. No files have been created yet.`;
return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`;
}
this._artifacts.delete(params.filename);
this._artifacts = new Map(this._artifacts);
// Remove element
const element = this.artifactElements.get(params.filename);
if (element) {
element.remove();
this.artifactElements.delete(params.filename);
}
// Show another artifact if this was active
if (this._activeFilename === params.filename) {
const remaining = Array.from(this._artifacts.keys());
if (remaining.length > 0) {
this.showArtifact(remaining[0]);
} else {
this._activeFilename = null;
this.requestUpdate();
}
}
this.onArtifactsChange?.();
this.requestUpdate();
// Reload all HTML artifacts since they might have depended on this deleted artifact
this.reloadAllHtmlArtifacts();
return `Deleted file ${params.filename}`;
}
private getLogs(params: ArtifactsParams): string {
const element = this.artifactElements.get(params.filename);
if (!element) {
const files = Array.from(this._artifacts.keys());
if (files.length === 0) return `Error: File ${params.filename} not found. No files have been created yet.`;
return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`;
}
if (!(element instanceof HtmlArtifact)) {
return `Error: File ${params.filename} is not an HTML file. Logs are only available for HTML files.`;
}
return element.getLogs();
}
override render(): TemplateResult {
const artifacts = Array.from(this._artifacts.values());
// Panel is hidden when collapsed OR when there are no artifacts
const showPanel = artifacts.length > 0 && !this.collapsed;
return html`
<div
class="${showPanel ? "" : "hidden"} ${
this.overlay ? "fixed inset-0 z-40 pointer-events-auto backdrop-blur-sm bg-background/95" : "relative"
} h-full flex flex-col bg-background text-card-foreground ${
!this.overlay ? "border-l border-border" : ""
} overflow-hidden shadow-xl"
>
<!-- Tab bar (always shown when there are artifacts) -->
<div class="flex items-center justify-between border-b border-border bg-background">
<div class="flex overflow-x-auto">
${artifacts.map((a) => {
const isActive = a.filename === this._activeFilename;
const activeClass = isActive
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground";
return html`
<button
class="px-3 py-2 whitespace-nowrap border-b-2 ${activeClass}"
data-filename="${a.filename}"
@click=${() => this.showArtifact(a.filename)}
>
<span class="font-mono text-xs">${a.filename}</span>
</button>
`;
})}
</div>
<div class="flex items-center gap-1 px-2">
${(() => {
const active = this._activeFilename ? this.artifactElements.get(this._activeFilename) : undefined;
return active ? active.getHeaderButtons() : "";
})()}
${Button({
variant: "ghost",
size: "sm",
onClick: () => this.onClose?.(),
title: i18n("Close artifacts"),
children: icon(X, "sm"),
})}
</div>
</div>
<!-- Content area where artifact elements are added programmatically -->
<div class="flex-1 overflow-hidden" ${ref(this.contentRef)}></div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"artifacts-panel": ArtifactsPanel;
}
}
@@ -0,0 +1,7 @@
export { ArtifactElement } from "./ArtifactElement.js";
export { type Artifact, ArtifactsPanel, type ArtifactsParams } from "./artifacts.js";
export { ArtifactsToolRenderer } from "./artifacts-tool-renderer.js";
export { HtmlArtifact } from "./HtmlArtifact.js";
export { MarkdownArtifact } from "./MarkdownArtifact.js";
export { SvgArtifact } from "./SvgArtifact.js";
export { TextArtifact } from "./TextArtifact.js";
@@ -0,0 +1,275 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { type Static, Type } from "@sinclair/typebox";
import { html } from "lit";
import { createRef, ref } from "lit/directives/ref.js";
import { FileText } from "lucide";
import { EXTRACT_DOCUMENT_DESCRIPTION } from "../prompts/prompts.js";
import { loadAttachment } from "../utils/attachment-utils.js";
import { isCorsError } from "../utils/proxy-utils.js";
import { registerToolRenderer, renderCollapsibleHeader, renderHeader } from "./renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "./types.js";
// ============================================================================
// TYPES
// ============================================================================
const extractDocumentSchema = Type.Object({
url: Type.String({
description: "URL of the document to extract text from (PDF, DOCX, XLSX, or PPTX)",
}),
});
export type ExtractDocumentParams = Static<typeof extractDocumentSchema>;
export interface ExtractDocumentResult {
extractedText: string;
format: string;
fileName: string;
size: number;
}
// ============================================================================
// TOOL
// ============================================================================
export function createExtractDocumentTool(): AgentTool<typeof extractDocumentSchema, ExtractDocumentResult> & {
corsProxyUrl?: string;
} {
const tool = {
label: "Extract Document",
name: "extract_document",
corsProxyUrl: undefined as string | undefined, // Can be set by consumer (e.g., from user settings)
description: EXTRACT_DOCUMENT_DESCRIPTION,
parameters: extractDocumentSchema,
execute: async (_toolCallId: string, args: ExtractDocumentParams, signal?: AbortSignal) => {
if (signal?.aborted) {
throw new Error("Extract document aborted");
}
const url = args.url.trim();
if (!url) {
throw new Error("URL is required");
}
// Validate URL format
try {
new URL(url);
} catch {
throw new Error(`Invalid URL: ${url}`);
}
// Size limit: 50MB
const MAX_SIZE = 50 * 1024 * 1024;
// Helper function to fetch and process document
const fetchAndProcess = async (fetchUrl: string) => {
const response = await fetch(fetchUrl, { signal });
if (!response.ok) {
throw new Error(
`TELL USER: Unable to download the document (${response.status} ${response.statusText}). The site likely blocks automated downloads.\n\n` +
`INSTRUCT USER: Please download the file manually and attach it to your message using the attachment button (paperclip icon) in the message input area. I can then extract the text from the attached file.`,
);
}
// Check size before downloading
const contentLength = response.headers.get("content-length");
if (contentLength) {
const size = Number.parseInt(contentLength, 10);
if (size > MAX_SIZE) {
throw new Error(
`Document is too large (${(size / 1024 / 1024).toFixed(1)}MB). Maximum supported size is 50MB.`,
);
}
}
// Download the document
const arrayBuffer = await response.arrayBuffer();
const size = arrayBuffer.byteLength;
if (size > MAX_SIZE) {
throw new Error(
`Document is too large (${(size / 1024 / 1024).toFixed(1)}MB). Maximum supported size is 50MB.`,
);
}
return arrayBuffer;
};
// Try without proxy first, fallback to proxy on CORS error
let arrayBuffer: ArrayBuffer;
try {
// Attempt direct fetch first
arrayBuffer = await fetchAndProcess(url);
} catch (directError: any) {
// If CORS error and proxy is available, retry with proxy
if (isCorsError(directError) && tool.corsProxyUrl) {
try {
const proxiedUrl = tool.corsProxyUrl + encodeURIComponent(url);
arrayBuffer = await fetchAndProcess(proxiedUrl);
} catch (proxyError: any) {
// Proxy fetch also failed - throw helpful message
throw new Error(
`TELL USER: Unable to fetch the document due to CORS restrictions.\n\n` +
`Tried with proxy but it also failed: ${proxyError.message}\n\n` +
`INSTRUCT USER: Please download the file manually and attach it to your message using the attachment button (paperclip icon) in the message input area. I can then extract the text from the attached file.`,
);
}
} else if (isCorsError(directError) && !tool.corsProxyUrl) {
// CORS error but no proxy configured
throw new Error(
`TELL USER: Unable to fetch the document due to CORS restrictions (the server blocks requests from browser extensions).\n\n` +
`To fix this, you need to configure a CORS proxy in Sitegeist settings:\n` +
`1. Open Sitegeist settings\n` +
`2. Find "CORS Proxy URL" setting\n` +
`3. Enter a proxy URL like: https://corsproxy.io/?\n` +
`4. Save and try again\n\n` +
`Alternatively, download the file manually and attach it to your message using the attachment button (paperclip icon).`,
);
} else {
// Not a CORS error - re-throw
throw directError;
}
}
// Extract filename from URL
const urlParts = url.split("/");
let fileName = urlParts[urlParts.length - 1]?.split("?")[0] || "document";
if (url.startsWith("https://arxiv.org/")) {
fileName = `${fileName}.pdf`;
}
// Use loadAttachment to process the document
const attachment = await loadAttachment(arrayBuffer, fileName);
if (!attachment.extractedText) {
throw new Error(
`Document format not supported. Supported formats:\n` +
`- PDF (.pdf)\n` +
`- Word (.docx)\n` +
`- Excel (.xlsx, .xls)\n` +
`- PowerPoint (.pptx)`,
);
}
// Determine format from attachment
let format = "unknown";
if (attachment.mimeType.includes("pdf")) {
format = "pdf";
} else if (attachment.mimeType.includes("wordprocessingml")) {
format = "docx";
} else if (attachment.mimeType.includes("spreadsheetml") || attachment.mimeType.includes("ms-excel")) {
format = "xlsx";
} else if (attachment.mimeType.includes("presentationml")) {
format = "pptx";
}
return {
content: [{ type: "text" as const, text: attachment.extractedText }],
details: {
extractedText: attachment.extractedText,
format,
fileName: attachment.fileName,
size: attachment.size,
},
};
},
};
return tool;
}
// Export a default instance
export const extractDocumentTool = createExtractDocumentTool();
// ============================================================================
// RENDERER
// ============================================================================
export const extractDocumentRenderer: ToolRenderer<ExtractDocumentParams, ExtractDocumentResult> = {
render(
params: ExtractDocumentParams | undefined,
result: ToolResultMessage<ExtractDocumentResult> | undefined,
isStreaming?: boolean,
): ToolRenderResult {
// Determine status
const state = result ? (result.isError ? "error" : "complete") : isStreaming ? "inprogress" : "complete";
// Create refs for collapsible sections
const contentRef = createRef<HTMLDivElement>();
const chevronRef = createRef<HTMLSpanElement>();
// With result: show params + result
if (result && params) {
const details = result.details;
const title = details
? result.isError
? `Failed to extract ${details.fileName || "document"}`
: `Extracted text from ${details.fileName} (${details.format.toUpperCase()}, ${(details.size / 1024).toFixed(1)}KB)`
: result.isError
? "Failed to extract document"
: "Extracted text from document";
const output =
result.content
?.filter((c) => c.type === "text")
.map((c: any) => c.text)
.join("\n") || "";
return {
content: html`
<div>
${renderCollapsibleHeader(state, FileText, title, contentRef, chevronRef, false)}
<div ${ref(contentRef)} class="max-h-0 overflow-hidden transition-all duration-300 space-y-3">
${
params.url
? html`<div class="text-sm text-gray-600 dark:text-gray-400">
<strong>URL:</strong> ${params.url}
</div>`
: ""
}
${
output && !result.isError
? html`<code-block .code=${output} language="plaintext"></code-block>`
: ""
}
${
result.isError && output
? html`<console-block .content=${output} .variant=${"error"}></console-block>`
: ""
}
</div>
</div>
`,
isCustom: false,
};
}
// Just params (streaming or waiting for result)
if (params) {
const title = "Extracting document...";
return {
content: html`
<div>
${renderCollapsibleHeader(state, FileText, title, contentRef, chevronRef, false)}
<div ${ref(contentRef)} class="max-h-0 overflow-hidden transition-all duration-300">
<div class="text-sm text-gray-600 dark:text-gray-400"><strong>URL:</strong> ${params.url}</div>
</div>
</div>
`,
isCustom: false,
};
}
// No params or result yet
return {
content: renderHeader(state, FileText, "Preparing extraction..."),
isCustom: false,
};
},
};
// Auto-register the renderer
registerToolRenderer("extract_document", extractDocumentRenderer);
@@ -0,0 +1,46 @@
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import "./javascript-repl.js"; // Auto-registers the renderer
import "./extract-document.js"; // Auto-registers the renderer
import { getToolRenderer, registerToolRenderer } from "./renderer-registry.js";
import { BashRenderer } from "./renderers/BashRenderer.js";
import { DefaultRenderer } from "./renderers/DefaultRenderer.js";
import type { ToolRenderResult } from "./types.js";
// Register all built-in tool renderers
registerToolRenderer("bash", new BashRenderer());
const defaultRenderer = new DefaultRenderer();
// Global flag to force default JSON rendering for all tools
let showJsonMode = false;
/**
* Enable or disable show JSON mode
* When enabled, all tool renderers will use the default JSON renderer
*/
export function setShowJsonMode(enabled: boolean): void {
showJsonMode = enabled;
}
/**
* Render tool - unified function that handles params, result, and streaming state
*/
export function renderTool(
toolName: string,
params: any | undefined,
result: ToolResultMessage | undefined,
isStreaming?: boolean,
): ToolRenderResult {
// If showJsonMode is enabled, always use the default renderer
if (showJsonMode) {
return defaultRenderer.render(params, result, isStreaming);
}
const renderer = getToolRenderer(toolName);
if (renderer) {
return renderer.render(params, result, isStreaming);
}
return defaultRenderer.render(params, result, isStreaming);
}
export { getToolRenderer, registerToolRenderer };
@@ -0,0 +1,293 @@
import { i18n } from "@mariozechner/mini-lit";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { type Static, Type } from "@sinclair/typebox";
import { html } from "lit";
import { createRef, ref } from "lit/directives/ref.js";
import { Code } from "lucide";
import { type SandboxFile, SandboxIframe, type SandboxResult } from "../components/SandboxedIframe.js";
import type { SandboxRuntimeProvider } from "../components/sandbox/SandboxRuntimeProvider.js";
import { JAVASCRIPT_REPL_TOOL_DESCRIPTION } from "../prompts/prompts.js";
import type { Attachment } from "../utils/attachment-utils.js";
import { registerToolRenderer, renderCollapsibleHeader, renderHeader } from "./renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "./types.js";
// Execute JavaScript code with attachments using SandboxedIframe
export async function executeJavaScript(
code: string,
runtimeProviders: SandboxRuntimeProvider[],
signal?: AbortSignal,
sandboxUrlProvider?: () => string,
): Promise<{ output: string; files?: SandboxFile[] }> {
if (!code) {
throw new Error("Code parameter is required");
}
// Check for abort before starting
if (signal?.aborted) {
throw new Error("Execution aborted");
}
// Create a SandboxedIframe instance for execution
const sandbox = new SandboxIframe();
if (sandboxUrlProvider) {
sandbox.sandboxUrlProvider = sandboxUrlProvider;
}
sandbox.style.display = "none";
document.body.appendChild(sandbox);
try {
const sandboxId = `repl-${Date.now()}-${Math.random().toString(36).substring(7)}`;
// Pass providers to execute (router handles all message routing)
// No additional consumers needed - execute() has its own internal consumer
const result: SandboxResult = await sandbox.execute(sandboxId, code, runtimeProviders, [], signal);
// Remove the sandbox iframe after execution
sandbox.remove();
// Build plain text response
let output = "";
// Add console output - result.console contains { type: string, text: string } from sandbox.js
if (result.console && result.console.length > 0) {
for (const entry of result.console) {
output += `${entry.text}\n`;
}
}
// Add error if execution failed
if (!result.success) {
if (output) output += "\n";
output += `Error: ${result.error?.message || "Unknown error"}\n${result.error?.stack || ""}`;
// Throw error so tool call is marked as failed
throw new Error(output.trim());
}
// Add return value if present
if (result.returnValue !== undefined) {
if (output) output += "\n";
output += `=> ${typeof result.returnValue === "object" ? JSON.stringify(result.returnValue, null, 2) : result.returnValue}`;
}
// Add file notifications
if (result.files && result.files.length > 0) {
output += `\n[Files returned: ${result.files.length}]\n`;
for (const file of result.files) {
output += ` - ${file.fileName} (${file.mimeType})\n`;
}
} else {
// Explicitly note when no files were returned (helpful for debugging)
if (code.includes("returnFile")) {
output += "\n[No files returned - check async operations]";
}
}
return {
output: output.trim() || "Code executed successfully (no output)",
files: result.files,
};
} catch (error: unknown) {
// Clean up on error
sandbox.remove();
throw new Error((error as Error).message || "Execution failed");
}
}
export type JavaScriptReplToolResult = {
files?:
| {
fileName: string;
contentBase64: string;
mimeType: string;
}[]
| undefined;
};
const javascriptReplSchema = Type.Object({
title: Type.String({
description:
"Brief title describing what the code snippet tries to achieve in active form, e.g. 'Calculating sum'",
}),
code: Type.String({ description: "JavaScript code to execute" }),
});
export type JavaScriptReplParams = Static<typeof javascriptReplSchema>;
interface JavaScriptReplResult {
output?: string;
files?: Array<{
fileName: string;
mimeType: string;
size: number;
contentBase64: string;
}>;
}
export function createJavaScriptReplTool(): AgentTool<typeof javascriptReplSchema, JavaScriptReplToolResult> & {
runtimeProvidersFactory?: () => SandboxRuntimeProvider[];
sandboxUrlProvider?: () => string;
} {
return {
label: "JavaScript REPL",
name: "javascript_repl",
runtimeProvidersFactory: () => [], // default to empty array
sandboxUrlProvider: undefined, // optional, for browser extensions
get description() {
const runtimeProviderDescriptions =
this.runtimeProvidersFactory?.()
.map((d) => d.getDescription())
.filter((d) => d.trim().length > 0) || [];
return JAVASCRIPT_REPL_TOOL_DESCRIPTION(runtimeProviderDescriptions);
},
parameters: javascriptReplSchema,
execute: async function (_toolCallId: string, args: Static<typeof javascriptReplSchema>, signal?: AbortSignal) {
const result = await executeJavaScript(
args.code,
this.runtimeProvidersFactory?.() ?? [],
signal,
this.sandboxUrlProvider,
);
// Convert files to JSON-serializable with base64 payloads
const files = (result.files || []).map((f) => {
const toBase64 = (input: string | Uint8Array): { base64: string; size: number } => {
if (input instanceof Uint8Array) {
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < input.length; i += chunk) {
binary += String.fromCharCode(...input.subarray(i, i + chunk));
}
return { base64: btoa(binary), size: input.length };
} else if (typeof input === "string") {
const enc = new TextEncoder();
const bytes = enc.encode(input);
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return { base64: btoa(binary), size: bytes.length };
} else {
const s = String(input);
const enc = new TextEncoder();
const bytes = enc.encode(s);
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return { base64: btoa(binary), size: bytes.length };
}
};
const { base64, size } = toBase64(f.content);
return {
fileName: f.fileName || "file",
mimeType: f.mimeType || "application/octet-stream",
size,
contentBase64: base64,
};
});
return { content: [{ type: "text", text: result.output }], details: { files } };
},
};
}
// Export a default instance for backward compatibility
export const javascriptReplTool = createJavaScriptReplTool();
export const javascriptReplRenderer: ToolRenderer<JavaScriptReplParams, JavaScriptReplResult> = {
render(
params: JavaScriptReplParams | undefined,
result: ToolResultMessage<JavaScriptReplResult> | undefined,
isStreaming?: boolean,
): ToolRenderResult {
// Determine status
const state = result ? (result.isError ? "error" : "complete") : isStreaming ? "inprogress" : "complete";
// Create refs for collapsible code section
const codeContentRef = createRef<HTMLDivElement>();
const codeChevronRef = createRef<HTMLSpanElement>();
// With result: show params + result
if (result && params) {
const output =
result.content
?.filter((c) => c.type === "text")
.map((c: any) => c.text)
.join("\n") || "";
const files = result.details?.files || [];
const attachments: Attachment[] = files.map((f, i) => {
// Decode base64 content for text files to show in overlay
let extractedText: string | undefined;
const isTextBased =
f.mimeType?.startsWith("text/") ||
f.mimeType === "application/json" ||
f.mimeType === "application/javascript" ||
f.mimeType?.includes("xml");
if (isTextBased && f.contentBase64) {
try {
extractedText = atob(f.contentBase64);
} catch (_e) {
console.warn("Failed to decode base64 content for", f.fileName);
}
}
return {
id: `repl-${Date.now()}-${i}`,
type: f.mimeType?.startsWith("image/") ? "image" : "document",
fileName: f.fileName || `file-${i}`,
mimeType: f.mimeType || "application/octet-stream",
size: f.size ?? 0,
content: f.contentBase64,
preview: f.mimeType?.startsWith("image/") ? f.contentBase64 : undefined,
extractedText,
};
});
return {
content: html`
<div>
${renderCollapsibleHeader(state, Code, params.title ? params.title : i18n("Executing JavaScript"), codeContentRef, codeChevronRef, false)}
<div ${ref(codeContentRef)} class="max-h-0 overflow-hidden transition-all duration-300 space-y-3">
<code-block .code=${params.code || ""} language="javascript"></code-block>
${output ? html`<console-block .content=${output} .variant=${result.isError ? "error" : "default"}></console-block>` : ""}
</div>
${
attachments.length
? html`<div class="flex flex-wrap gap-2 mt-3">
${attachments.map((att) => html`<attachment-tile .attachment=${att}></attachment-tile>`)}
</div>`
: ""
}
</div>
`,
isCustom: false,
};
}
// Just params (streaming or waiting for result)
if (params) {
return {
content: html`
<div>
${renderCollapsibleHeader(state, Code, params.title ? params.title : i18n("Executing JavaScript"), codeContentRef, codeChevronRef, false)}
<div ${ref(codeContentRef)} class="max-h-0 overflow-hidden transition-all duration-300">
${params.code ? html`<code-block .code=${params.code} language="javascript"></code-block>` : ""}
</div>
</div>
`,
isCustom: false,
};
}
// No params or result yet
return { content: renderHeader(state, Code, i18n("Preparing JavaScript...")), isCustom: false };
},
};
// Auto-register the renderer
registerToolRenderer(javascriptReplTool.name, javascriptReplRenderer);
@@ -0,0 +1,130 @@
import { icon } from "@mariozechner/mini-lit";
import { html, type TemplateResult } from "lit";
import type { Ref } from "lit/directives/ref.js";
import { ref } from "lit/directives/ref.js";
import { ChevronsUpDown, ChevronUp, Loader } from "lucide";
import type { ToolRenderer } from "./types.js";
// Registry of tool renderers
export const toolRenderers = new Map<string, ToolRenderer>();
/**
* Register a custom tool renderer
*/
export function registerToolRenderer(toolName: string, renderer: ToolRenderer): void {
toolRenderers.set(toolName, renderer);
}
/**
* Get a tool renderer by name
*/
export function getToolRenderer(toolName: string): ToolRenderer | undefined {
return toolRenderers.get(toolName);
}
/**
* Helper to render a header for tool renderers
* Shows icon on left when complete/error, spinner on right when in progress
*/
export function renderHeader(
state: "inprogress" | "complete" | "error",
toolIcon: any,
text: string | TemplateResult,
): TemplateResult {
const statusIcon = (iconComponent: any, color: string) =>
html`<span class="inline-block ${color}">${icon(iconComponent, "sm")}</span>`;
switch (state) {
case "inprogress":
return html`
<div class="flex items-center justify-between gap-2 text-sm text-muted-foreground">
<div class="flex items-center gap-2">
${statusIcon(toolIcon, "text-foreground")}
${text}
</div>
${statusIcon(Loader, "text-foreground animate-spin")}
</div>
`;
case "complete":
return html`
<div class="flex items-center gap-2 text-sm text-muted-foreground">
${statusIcon(toolIcon, "text-green-600 dark:text-green-500")}
${text}
</div>
`;
case "error":
return html`
<div class="flex items-center gap-2 text-sm text-muted-foreground">
${statusIcon(toolIcon, "text-destructive")}
${text}
</div>
`;
}
}
/**
* Helper to render a collapsible header for tool renderers
* Same as renderHeader but with a chevron button that toggles visibility of content
*/
export function renderCollapsibleHeader(
state: "inprogress" | "complete" | "error",
toolIcon: any,
text: string | TemplateResult,
contentRef: Ref<HTMLElement>,
chevronRef: Ref<HTMLElement>,
defaultExpanded = false,
): TemplateResult {
const statusIcon = (iconComponent: any, color: string) =>
html`<span class="inline-block ${color}">${icon(iconComponent, "sm")}</span>`;
const toggleContent = (e: Event) => {
e.preventDefault();
const content = contentRef.value;
const chevron = chevronRef.value;
if (content && chevron) {
const isCollapsed = content.classList.contains("max-h-0");
if (isCollapsed) {
content.classList.remove("max-h-0");
content.classList.add("max-h-[2000px]", "mt-3");
// Show ChevronUp, hide ChevronsUpDown
const upIcon = chevron.querySelector(".chevron-up");
const downIcon = chevron.querySelector(".chevrons-up-down");
if (upIcon && downIcon) {
upIcon.classList.remove("hidden");
downIcon.classList.add("hidden");
}
} else {
content.classList.remove("max-h-[2000px]", "mt-3");
content.classList.add("max-h-0");
// Show ChevronsUpDown, hide ChevronUp
const upIcon = chevron.querySelector(".chevron-up");
const downIcon = chevron.querySelector(".chevrons-up-down");
if (upIcon && downIcon) {
upIcon.classList.add("hidden");
downIcon.classList.remove("hidden");
}
}
}
};
const toolIconColor =
state === "complete"
? "text-green-600 dark:text-green-500"
: state === "error"
? "text-destructive"
: "text-foreground";
return html`
<button @click=${toggleContent} class="flex items-center justify-between gap-2 text-sm text-muted-foreground w-full text-left hover:text-foreground transition-colors cursor-pointer">
<div class="flex items-center gap-2">
${state === "inprogress" ? statusIcon(Loader, "text-foreground animate-spin") : ""}
${statusIcon(toolIcon, toolIconColor)}
${text}
</div>
<span class="inline-block text-muted-foreground" ${ref(chevronRef)}>
<span class="chevron-up ${defaultExpanded ? "" : "hidden"}">${icon(ChevronUp, "sm")}</span>
<span class="chevrons-up-down ${defaultExpanded ? "hidden" : ""}">${icon(ChevronsUpDown, "sm")}</span>
</span>
</button>
`;
}
@@ -0,0 +1,52 @@
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { html } from "lit";
import { SquareTerminal } from "lucide";
import { i18n } from "../../utils/i18n.js";
import { renderHeader } from "../renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "../types.js";
interface BashParams {
command: string;
}
// Bash tool has undefined details (only uses output)
export class BashRenderer implements ToolRenderer<BashParams, undefined> {
render(params: BashParams | undefined, result: ToolResultMessage<undefined> | undefined): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
// With result: show command + output
if (result && params?.command) {
const output =
result.content
?.filter((c) => c.type === "text")
.map((c: any) => c.text)
.join("\n") || "";
const combined = output ? `> ${params.command}\n\n${output}` : `> ${params.command}`;
return {
content: html`
<div class="space-y-3">
${renderHeader(state, SquareTerminal, i18n("Running command..."))}
<console-block .content=${combined} .variant=${result.isError ? "error" : "default"}></console-block>
</div>
`,
isCustom: false,
};
}
// Just params (streaming or waiting)
if (params?.command) {
return {
content: html`
<div class="space-y-3">
${renderHeader(state, SquareTerminal, i18n("Running command..."))}
<console-block .content=${`> ${params.command}`}></console-block>
</div>
`,
isCustom: false,
};
}
// No params yet
return { content: renderHeader(state, SquareTerminal, i18n("Waiting for command...")), isCustom: false };
}
}
@@ -0,0 +1,58 @@
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { html } from "lit";
import { Calculator } from "lucide";
import { i18n } from "../../utils/i18n.js";
import { renderHeader } from "../renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "../types.js";
interface CalculateParams {
expression: string;
}
// Calculate tool has undefined details (only uses output)
export class CalculateRenderer implements ToolRenderer<CalculateParams, undefined> {
render(params: CalculateParams | undefined, result: ToolResultMessage<undefined> | undefined): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
// Full params + full result
if (result && params?.expression) {
const output =
result.content
?.filter((c) => c.type === "text")
.map((c: any) => c.text)
.join("\n") || "";
// Error: show expression in header, error below
if (result.isError) {
return {
content: html`
<div class="space-y-3">
${renderHeader(state, Calculator, params.expression)}
<div class="text-sm text-destructive">${output}</div>
</div>
`,
isCustom: false,
};
}
// Success: show expression = result in header
return { content: renderHeader(state, Calculator, `${params.expression} = ${output}`), isCustom: false };
}
// Full params, no result: just show header with expression in it
if (params?.expression) {
return {
content: renderHeader(state, Calculator, `${i18n("Calculating")} ${params.expression}`),
isCustom: false,
};
}
// Partial params (empty expression), no result
if (params && !params.expression) {
return { content: renderHeader(state, Calculator, i18n("Writing expression...")), isCustom: false };
}
// No params, no result
return { content: renderHeader(state, Calculator, i18n("Waiting for expression...")), isCustom: false };
}
}
@@ -0,0 +1,103 @@
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { html } from "lit";
import { Code } from "lucide";
import { i18n } from "../../utils/i18n.js";
import { renderHeader } from "../renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "../types.js";
export class DefaultRenderer implements ToolRenderer {
render(params: any | undefined, result: ToolResultMessage | undefined, isStreaming?: boolean): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : isStreaming ? "inprogress" : "complete";
// Format params as JSON
let paramsJson = "";
if (params) {
try {
paramsJson = JSON.stringify(JSON.parse(params), null, 2);
} catch {
try {
paramsJson = JSON.stringify(params, null, 2);
} catch {
paramsJson = String(params);
}
}
}
// With result: show header + params + result
if (result) {
let outputJson =
result.content
?.filter((c) => c.type === "text")
.map((c: any) => c.text)
.join("\n") || i18n("(no output)");
let outputLanguage = "text";
// Try to parse and pretty-print if it's valid JSON
try {
const parsed = JSON.parse(outputJson);
outputJson = JSON.stringify(parsed, null, 2);
outputLanguage = "json";
} catch {
// Not valid JSON, leave as-is and use text highlighting
}
return {
content: html`
<div class="space-y-3">
${renderHeader(state, Code, "Tool Call")}
${
paramsJson
? html`<div>
<div class="text-xs font-medium mb-1 text-muted-foreground">${i18n("Input")}</div>
<code-block .code=${paramsJson} language="json"></code-block>
</div>`
: ""
}
<div>
<div class="text-xs font-medium mb-1 text-muted-foreground">${i18n("Output")}</div>
<code-block .code=${outputJson} language="${outputLanguage}"></code-block>
</div>
</div>
`,
isCustom: false,
};
}
// Just params (streaming or waiting for result)
if (params) {
if (isStreaming && (!paramsJson || paramsJson === "{}" || paramsJson === "null")) {
return {
content: html`
<div>
${renderHeader(state, Code, "Preparing tool parameters...")}
</div>
`,
isCustom: false,
};
}
return {
content: html`
<div class="space-y-3">
${renderHeader(state, Code, "Tool Call")}
<div>
<div class="text-xs font-medium mb-1 text-muted-foreground">${i18n("Input")}</div>
<code-block .code=${paramsJson} language="json"></code-block>
</div>
</div>
`,
isCustom: false,
};
}
// No params or result yet
return {
content: html`
<div>
${renderHeader(state, Code, "Preparing tool...")}
</div>
`,
isCustom: false,
};
}
}
@@ -0,0 +1,92 @@
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { html } from "lit";
import { Clock } from "lucide";
import { i18n } from "../../utils/i18n.js";
import { renderHeader } from "../renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "../types.js";
interface GetCurrentTimeParams {
timezone?: string;
}
// GetCurrentTime tool has undefined details (only uses output)
export class GetCurrentTimeRenderer implements ToolRenderer<GetCurrentTimeParams, undefined> {
render(
params: GetCurrentTimeParams | undefined,
result: ToolResultMessage<undefined> | undefined,
): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
// Full params + full result
if (result && params) {
const output =
result.content
?.filter((c) => c.type === "text")
.map((c: any) => c.text)
.join("\n") || "";
const headerText = params.timezone
? `${i18n("Getting current time in")} ${params.timezone}`
: i18n("Getting current date and time");
// Error: show header, error below
if (result.isError) {
return {
content: html`
<div class="space-y-3">
${renderHeader(state, Clock, headerText)}
<div class="text-sm text-destructive">${output}</div>
</div>
`,
isCustom: false,
};
}
// Success: show time in header
return { content: renderHeader(state, Clock, `${headerText}: ${output}`), isCustom: false };
}
// Full result, no params
if (result) {
const output =
result.content
?.filter((c) => c.type === "text")
.map((c: any) => c.text)
.join("\n") || "";
// Error: show header, error below
if (result.isError) {
return {
content: html`
<div class="space-y-3">
${renderHeader(state, Clock, i18n("Getting current date and time"))}
<div class="text-sm text-destructive">${output}</div>
</div>
`,
isCustom: false,
};
}
// Success: show time in header
return {
content: renderHeader(state, Clock, `${i18n("Getting current date and time")}: ${output}`),
isCustom: false,
};
}
// Full params, no result: show timezone info in header
if (params?.timezone) {
return {
content: renderHeader(state, Clock, `${i18n("Getting current time in")} ${params.timezone}`),
isCustom: false,
};
}
// Partial params (no timezone) or empty params, no result
if (params) {
return { content: renderHeader(state, Clock, i18n("Getting current date and time")), isCustom: false };
}
// No params, no result
return { content: renderHeader(state, Clock, i18n("Getting time...")), isCustom: false };
}
}
@@ -0,0 +1,15 @@
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import type { TemplateResult } from "lit";
export interface ToolRenderResult {
content: TemplateResult;
isCustom: boolean; // true = no card wrapper, false = wrap in card
}
export interface ToolRenderer<TParams = any, TDetails = any> {
render(
params: TParams | undefined,
result: ToolResultMessage<TDetails> | undefined,
isStreaming?: boolean,
): ToolRenderResult;
}