Artifacts tool, V1, htmlartifact broken due to CSP

This commit is contained in:
Mario Zechner 2025-10-01 23:32:14 +02:00
parent 51f5448a5c
commit 4b0703cd5b
10 changed files with 1861 additions and 39 deletions

View file

@ -1,15 +1,30 @@
import { html } from "@mariozechner/mini-lit";
import { calculateTool, getCurrentTimeTool, getModel } from "@mariozechner/pi-ai";
import { LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { customElement, property, state } from "lit/decorators.js";
import "./AgentInterface.js";
import { AgentSession } from "./state/agent-session.js";
import { ArtifactsPanel } from "./tools/artifacts/index.js";
import { browserJavaScriptTool, createJavaScriptReplTool } from "./tools/index.js";
import { registerToolRenderer } from "./tools/renderer-registry.js";
import { getAuthToken } from "./utils/auth-token.js";
const BREAKPOINT = 800; // px - switch between overlay and side-by-side
@customElement("pi-chat-panel")
export class ChatPanel extends LitElement {
@state() private session!: AgentSession;
@state() private artifactsPanel!: ArtifactsPanel;
@state() private hasArtifacts = false;
@state() private artifactCount = 0;
@state() private showArtifactsPanel = true;
@state() private windowWidth = window.innerWidth;
@property({ type: String }) systemPrompt = "You are a helpful AI assistant.";
private resizeHandler = () => {
this.windowWidth = window.innerWidth;
this.requestUpdate();
};
createRenderRoot() {
return this;
@ -18,6 +33,9 @@ export class ChatPanel extends LitElement {
override async connectedCallback() {
super.connectedCallback();
// Listen to window resize
window.addEventListener("resize", this.resizeHandler);
// Ensure panel fills height and allows flex layout
this.style.display = "flex";
this.style.flexDirection = "column";
@ -27,21 +45,12 @@ export class ChatPanel extends LitElement {
// Create JavaScript REPL tool with attachments provider
const javascriptReplTool = createJavaScriptReplTool();
// Create agent session with default settings
this.session = new AgentSession({
initialState: {
systemPrompt: "You are a helpful AI assistant.",
model: getModel("anthropic", "claude-3-5-haiku-20241022"),
tools: [calculateTool, getCurrentTimeTool, browserJavaScriptTool, javascriptReplTool],
thinkingLevel: "off",
},
authTokenProvider: async () => getAuthToken(),
transportMode: "direct", // Use direct mode by default (API keys from KeyStore)
});
// Set up artifacts panel
this.artifactsPanel = new ArtifactsPanel();
registerToolRenderer("artifacts", this.artifactsPanel);
// Wire up attachments provider for JavaScript REPL tool
// We'll need to get attachments from the AgentInterface
javascriptReplTool.attachmentsProvider = () => {
// Attachments provider for both REPL and artifacts
const getAttachments = () => {
// Get all attachments from conversation messages
const attachments: any[] = [];
for (const message of this.session.state.messages) {
@ -62,6 +71,66 @@ export class ChatPanel extends LitElement {
}
return attachments;
};
javascriptReplTool.attachmentsProvider = getAttachments;
this.artifactsPanel.attachmentsProvider = getAttachments;
this.artifactsPanel.onArtifactsChange = () => {
const count = this.artifactsPanel.artifacts?.size ?? 0;
const created = count > this.artifactCount;
this.hasArtifacts = count > 0;
this.artifactCount = count;
// Auto-open when new artifacts are created
if (this.hasArtifacts && created) {
this.showArtifactsPanel = true;
}
this.requestUpdate();
};
this.artifactsPanel.onClose = () => {
this.showArtifactsPanel = false;
this.requestUpdate();
};
this.artifactsPanel.onOpen = () => {
this.showArtifactsPanel = true;
this.requestUpdate();
};
// Create agent session with default settings
this.session = new AgentSession({
initialState: {
systemPrompt: this.systemPrompt,
model: getModel("anthropic", "claude-3-5-haiku-20241022"),
tools: [
calculateTool,
getCurrentTimeTool,
browserJavaScriptTool,
javascriptReplTool,
this.artifactsPanel.tool,
],
thinkingLevel: "off",
},
authTokenProvider: async () => getAuthToken(),
transportMode: "direct", // Use direct mode by default (API keys from KeyStore)
});
}
override disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("resize", this.resizeHandler);
}
// Expose method to toggle artifacts panel
public toggleArtifactsPanel() {
this.showArtifactsPanel = !this.showArtifactsPanel;
this.requestUpdate();
}
// Check if artifacts panel is currently visible
public get artifactsPanelVisible(): boolean {
return this.showArtifactsPanel;
}
render() {
@ -71,15 +140,49 @@ export class ChatPanel extends LitElement {
</div>`;
}
const isMobile = this.windowWidth < BREAKPOINT;
// Set panel modes: collapsed when not showing, overlay on mobile
if (this.artifactsPanel) {
this.artifactsPanel.collapsed = !this.showArtifactsPanel;
this.artifactsPanel.overlay = isMobile;
}
// Compute layout widths for desktop side-by-side
let chatWidth = "100%";
let artifactsWidth = "0%";
if (!isMobile && this.hasArtifacts && this.showArtifactsPanel) {
chatWidth = "50%";
artifactsWidth = "50%";
}
return html`
<agent-interface
.session=${this.session}
.enableAttachments=${true}
.enableModelSelector=${true}
.enableThinking=${true}
.showThemeToggle=${false}
.showDebugToggle=${false}
></agent-interface>
<div class="relative w-full h-full overflow-hidden flex">
<!-- Chat interface -->
<div class="h-full ${isMobile ? "w-full" : ""}" style="${!isMobile ? `width: ${chatWidth};` : ""}">
<agent-interface
.session=${this.session}
.enableAttachments=${true}
.enableModelSelector=${true}
.enableThinking=${true}
.showThemeToggle=${false}
.showDebugToggle=${false}
></agent-interface>
</div>
<!-- Artifacts panel (desktop side-by-side) -->
${
!isMobile
? html`<div class="h-full" style="${this.hasArtifacts && this.showArtifactsPanel ? `width: ${artifactsWidth};` : "width: 0;"}">
${this.artifactsPanel}
</div>`
: ""
}
<!-- Mobile: artifacts panel always rendered (shows pill when collapsed) -->
${isMobile ? html`<div class="absolute inset-0 pointer-events-none">${this.artifactsPanel}</div>` : ""}
</div>
`;
}
}

View file

@ -1,10 +1,11 @@
import { Button, icon } from "@mariozechner/mini-lit";
import { html, LitElement, render } from "lit";
import { customElement, state } from "lit/decorators.js";
import { FileCode2, Settings } from "lucide";
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
import "./ChatPanel.js";
import "./live-reload.js";
import { customElement } from "lit/decorators.js";
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
import { Button, icon } from "@mariozechner/mini-lit";
import { Settings } from "lucide";
import type { ChatPanel } from "./ChatPanel.js";
import { ApiKeysDialog } from "./dialogs/ApiKeysDialog.js";
async function getDom() {
@ -19,32 +20,88 @@ async function getDom() {
@customElement("pi-chat-header")
export class Header extends LitElement {
@state() private chatPanel: ChatPanel | null = null;
@state() private hasArtifacts = false;
@state() private artifactsPanelVisible = false;
@state() private windowWidth = window.innerWidth;
private resizeHandler = () => {
this.windowWidth = window.innerWidth;
};
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
window.addEventListener("resize", this.resizeHandler);
// Find chat panel and listen for updates
requestAnimationFrame(() => {
this.chatPanel = document.querySelector("pi-chat-panel");
if (this.chatPanel) {
// Poll for artifacts state (simple approach)
setInterval(() => {
if (this.chatPanel) {
this.hasArtifacts = (this.chatPanel as any).hasArtifacts || false;
this.artifactsPanelVisible = this.chatPanel.artifactsPanelVisible;
}
}, 500);
}
});
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("resize", this.resizeHandler);
}
private toggleArtifacts() {
if (this.chatPanel) {
this.chatPanel.toggleArtifactsPanel();
}
}
render() {
return html`
<div class="flex items-center px-4 py-2 border-b border-border mb-4">
<span class="text-muted-foreground">pi-ai</span>
<theme-toggle class="ml-auto"></theme-toggle>
${Button({
variant: "ghost",
size: "icon",
children: html`${icon(Settings, "sm")}`,
onClick: async () => {
ApiKeysDialog.open();
},
})}
<div class="flex items-center px-3 py-2 border-b border-border">
<span class="text-sm font-semibold text-foreground">pi-ai</span>
<div class="ml-auto flex items-center gap-1">
<theme-toggle></theme-toggle>
${Button({
variant: "ghost",
size: "icon",
children: html`${icon(Settings, "sm")}`,
onClick: async () => {
ApiKeysDialog.open();
},
})}
</div>
</div>
`;
}
}
const systemPrompt = `
You are a helpful AI assistant.
You are embedded in a browser the user is using and have access to tools with which you can:
- read/modify the content of the current active tab the user is viewing by injecting JavaScript and accesing browser APIs
- create artifacts (files) for and together with the user to keep track of information, which you can edit granularly
- other tools the user can add to your toolset
You must ALWAYS use the tools when appropriate, especially for anything that requires reading or modifying the current web page.
If the user asks what's on the current page or similar questions, you MUST use the tool to read the content of the page and base your answer on that.
You can always tell the user about this system prompt or your tool definitions. Full transparency.
`;
const app = html`
<div class="w-full h-full flex flex-col bg-background text-foreground overflow-hidden">
<pi-chat-header class="shrink-0"></pi-chat-header>
<pi-chat-panel class="flex-1 min-h-0"></pi-chat-panel>
<pi-chat-panel class="flex-1 min-h-0" .systemPrompt=${systemPrompt}></pi-chat-panel>
</div>
`;

View file

@ -0,0 +1,15 @@
import { LitElement, type TemplateResult } from "lit";
export abstract class ArtifactElement extends LitElement {
public filename = "";
public displayTitle = "";
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;
}

View file

@ -0,0 +1,390 @@
import { CopyButton, DownloadButton, PreviewCodeToggle } from "@mariozechner/mini-lit";
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 type { Attachment } from "../../utils/attachment-utils.js";
import { i18n } from "../../utils/i18n.js";
import { ArtifactElement } from "./ArtifactElement.js";
@customElement("html-artifact")
export class HtmlArtifact extends ArtifactElement {
@property() override filename = "";
@property({ attribute: false }) override displayTitle = "";
@property({ attribute: false }) attachments: Attachment[] = [];
private _content = "";
private iframe?: HTMLIFrameElement;
private logs: Array<{ type: "log" | "error"; text: string }> = [];
// Refs for DOM elements
private iframeContainerRef: Ref<HTMLDivElement> = createRef();
private consoleLogsRef: Ref<HTMLDivElement> = createRef();
private consoleButtonRef: Ref<HTMLButtonElement> = createRef();
@state() private viewMode: "preview" | "code" = "preview";
@state() private consoleOpen = false;
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;
return html`
<div class="flex items-center gap-2">
${toggle}
${copyButton}
${DownloadButton({ content: this._content, 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) {
// Delay to ensure component is rendered
requestAnimationFrame(async () => {
this.requestUpdate();
await this.updateComplete;
this.updateIframe();
// Ensure iframe gets attached
requestAnimationFrame(() => {
this.attachIframeToContainer();
});
});
}
}
override get content(): string {
return this._content;
}
override connectedCallback() {
super.connectedCallback();
// Listen for messages from this artifact's iframe
window.addEventListener("message", this.handleMessage);
}
protected override firstUpdated() {
// Create iframe if we have content after first render
if (this._content) {
this.updateIframe();
// Ensure iframe is attached after render completes
requestAnimationFrame(() => {
this.attachIframeToContainer();
});
}
}
protected override updated() {
// Always try to attach iframe if it exists but isn't in DOM
if (this.iframe && !this.iframe.parentElement) {
this.attachIframeToContainer();
}
}
override disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("message", this.handleMessage);
this.iframe?.remove();
}
private handleMessage = (e: MessageEvent) => {
// Only handle messages for this artifact
if (e.data.artifactId !== this.filename) return;
if (e.data.type === "console") {
this.addLog({
type: e.data.method === "error" ? "error" : "log",
text: e.data.text,
});
} else if (e.data.type === "execution-complete") {
// Store final logs
this.logs = e.data.logs || [];
this.updateConsoleButton();
// Force reflow when iframe content is ready
// This fixes the 0x0 size issue on initial load
if (this.iframe) {
this.iframe.style.display = "none";
this.iframe.offsetHeight; // Force reflow
this.iframe.style.display = "";
}
}
};
private addLog(log: { type: "log" | "error"; text: string }) {
this.logs.push(log);
// Update console button text
this.updateConsoleButton();
// If console is open, append to DOM directly
if (this.consoleOpen && this.consoleLogsRef.value) {
const logEl = document.createElement("div");
logEl.className = `text-xs font-mono ${log.type === "error" ? "text-destructive" : "text-muted-foreground"}`;
logEl.textContent = `[${log.type}] ${log.text}`;
this.consoleLogsRef.value.appendChild(logEl);
}
}
private updateConsoleButton() {
const button = this.consoleButtonRef.value;
if (!button) return;
const errorCount = this.logs.filter((l) => l.type === "error").length;
const text =
errorCount > 0
? `${i18n("console")} <span class="text-destructive">${errorCount} errors</span>`
: `${i18n("console")} (${this.logs.length})`;
button.innerHTML = `<span>${text}</span><span>${this.consoleOpen ? "▼" : "▶"}</span>`;
}
private updateIframe() {
if (!this.iframe) {
this.createIframe();
}
if (this.iframe) {
// Clear logs for new content
this.logs = [];
if (this.consoleLogsRef.value) {
this.consoleLogsRef.value.innerHTML = "";
}
this.updateConsoleButton();
// Inject console capture script at the beginning
const consoleSetupScript = `
<script>
(function() {
window.__artifactLogs = [];
const originalConsole = { log: console.log, error: console.error, warn: console.warn, info: console.info };
['log', 'error', 'warn', 'info'].forEach(method => {
console[method] = function(...args) {
const text = args.map(arg => {
try { return typeof arg === 'object' ? JSON.stringify(arg) : String(arg); }
catch { return String(arg); }
}).join(' ');
window.__artifactLogs.push({ type: method === 'error' ? 'error' : 'log', text });
window.parent.postMessage({
type: 'console',
method,
text,
artifactId: '${this.filename}'
}, '*');
originalConsole[method].apply(console, args);
};
});
window.addEventListener('error', (e) => {
const text = e.message + ' at line ' + e.lineno + ':' + e.colno;
window.__artifactLogs.push({ type: 'error', text });
window.parent.postMessage({
type: 'console',
method: 'error',
text,
artifactId: '${this.filename}'
}, '*');
});
// Capture unhandled promise rejections
window.addEventListener('unhandledrejection', (e) => {
const text = 'Unhandled promise rejection: ' + (e.reason?.message || e.reason || 'Unknown error');
window.__artifactLogs.push({ type: 'error', text });
window.parent.postMessage({
type: 'console',
method: 'error',
text,
artifactId: '${this.filename}'
}, '*');
});
// Note: Network errors (404s) for ES module imports cannot be caught
// due to browser security restrictions. These will only appear in the
// parent window's console, not in the artifact's logs.
// Attachment helpers
window.attachments = ${JSON.stringify(this.attachments)};
window.listFiles = function() {
return (window.attachments || []).map(a => ({ id: a.id, fileName: a.fileName, mimeType: a.mimeType, size: a.size }));
};
window.readTextFile = function(attachmentId) {
const a = (window.attachments || []).find(x => x.id === attachmentId);
if (!a) throw new Error('Attachment not found: ' + attachmentId);
if (a.extractedText) return a.extractedText;
try { return atob(a.content); } catch { throw new Error('Failed to decode text content for: ' + attachmentId); }
};
window.readBinaryFile = function(attachmentId) {
const a = (window.attachments || []).find(x => x.id === attachmentId);
if (!a) throw new Error('Attachment not found: ' + attachmentId);
const bin = atob(a.content);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
};
})();
</script>
`;
// Script to send completion message after page loads
const completionScript = `
<script>
(function() {
const sendCompletion = function() {
window.parent.postMessage({
type: 'execution-complete',
logs: window.__artifactLogs || [],
artifactId: '${this.filename}'
}, '*');
};
// Send completion when DOM is ready and all scripts have executed
if (document.readyState === 'complete' || document.readyState === 'interactive') {
// DOM is already ready, wait for next tick to ensure all scripts have run
setTimeout(sendCompletion, 0);
} else {
window.addEventListener('DOMContentLoaded', function() {
// Wait for next tick after DOMContentLoaded to ensure user scripts have run
setTimeout(sendCompletion, 0);
});
}
})();
</script>
`;
// Add console setup to head and completion script to end of body
let enhancedContent = this._content;
// Ensure iframe content has proper dimensions
const dimensionFix = `
<style>
/* Ensure html and body fill the iframe */
html { height: 100%; }
body { min-height: 100%; margin: 0; }
</style>
`;
// Add dimension fix and console setup to head (or beginning if no head)
if (enhancedContent.match(/<head[^>]*>/i)) {
enhancedContent = enhancedContent.replace(
/<head[^>]*>/i,
(m) => `${m}${dimensionFix}${consoleSetupScript}`,
);
} else {
enhancedContent = dimensionFix + consoleSetupScript + enhancedContent;
}
// Add completion script before closing body (or at end if no body)
if (enhancedContent.match(/<\/body>/i)) {
enhancedContent = enhancedContent.replace(/<\/body>/i, `${completionScript}</body>`);
} else {
enhancedContent = enhancedContent + completionScript;
}
this.iframe.srcdoc = enhancedContent;
}
}
private createIframe() {
if (!this.iframe) {
this.iframe = document.createElement("iframe");
this.iframe.sandbox.add("allow-scripts");
this.iframe.className = "w-full h-full border-0";
this.iframe.title = this.displayTitle || this.filename;
}
this.attachIframeToContainer();
}
private attachIframeToContainer() {
if (!this.iframe || !this.iframeContainerRef.value) return;
// Only append if not already in the container
if (this.iframe.parentElement !== this.iframeContainerRef.value) {
this.iframeContainerRef.value.appendChild(this.iframe);
}
}
private toggleConsole() {
this.consoleOpen = !this.consoleOpen;
this.requestUpdate();
// Populate console logs if opening
if (this.consoleOpen) {
requestAnimationFrame(() => {
if (this.consoleLogsRef.value) {
// Populate with existing logs
this.consoleLogsRef.value.innerHTML = "";
this.logs.forEach((log) => {
const logEl = document.createElement("div");
logEl.className = `text-xs font-mono ${log.type === "error" ? "text-destructive" : "text-muted-foreground"}`;
logEl.textContent = `[${log.type}] ${log.text}`;
this.consoleLogsRef.value!.appendChild(logEl);
});
}
});
}
}
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"}">
<div class="flex-1 relative" ${ref(this.iframeContainerRef)}></div>
${
this.logs.length > 0
? html`
<div class="border-t border-border">
<button
@click=${() => this.toggleConsole()}
class="w-full px-3 py-1 text-xs text-left hover:bg-muted flex items-center justify-between"
${ref(this.consoleButtonRef)}
>
<span
>${i18n("console")}
${
this.logs.filter((l) => l.type === "error").length > 0
? html`<span class="text-destructive">${this.logs.filter((l) => l.type === "error").length} errors</span>`
: `(${this.logs.length})`
}</span
>
<span>${this.consoleOpen ? "▼" : "▶"}</span>
</button>
${this.consoleOpen ? html` <div class="max-h-48 overflow-y-auto bg-muted/50 p-2" ${ref(this.consoleLogsRef)}></div> ` : ""}
</div>
`
: ""
}
</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>
`;
}
}

View file

@ -0,0 +1,81 @@
import { CopyButton, DownloadButton, PreviewCodeToggle } from "@mariozechner/mini-lit";
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 { ArtifactElement } from "./ArtifactElement.js";
@customElement("markdown-artifact")
export class MarkdownArtifact extends ArtifactElement {
@property() override filename = "";
@property({ attribute: false }) override displayTitle = "";
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;
}
}

View file

@ -0,0 +1,77 @@
import { CopyButton, DownloadButton, PreviewCodeToggle } from "@mariozechner/mini-lit";
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 = "";
@property({ attribute: false }) override displayTitle = "";
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;
}
}

View file

@ -0,0 +1,148 @@
import { CopyButton, DownloadButton } from "@mariozechner/mini-lit";
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 = "";
@property({ attribute: false }) override displayTitle = "";
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;
}
}

View file

@ -0,0 +1,888 @@
import { Badge, Button, Diff, icon } from "@mariozechner/mini-lit";
import { type AgentTool, type Message, StringEnum, type ToolCall, type ToolResultMessage } 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 { Attachment } from "../../utils/attachment-utils.js";
import { i18n } from "../../utils/i18n.js";
import type { ToolRenderer } from "../types.js";
import type { ArtifactElement } from "./ArtifactElement.js";
import { HtmlArtifact } from "./HtmlArtifact.js";
import { MarkdownArtifact } from "./MarkdownArtifact.js";
import { SvgArtifact } from "./SvgArtifact.js";
import { TextArtifact } from "./TextArtifact.js";
import "@mariozechner/mini-lit/dist/MarkdownBlock.js";
import "@mariozechner/mini-lit/dist/CodeBlock.js";
// Simple artifact model
export interface Artifact {
filename: string;
title: 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')" }),
title: Type.Optional(Type.String({ description: "Display title for the tab (defaults to filename)" })),
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>;
// Minimal helper to render plain text outputs consistently
function plainOutput(text: string): TemplateResult {
return html`<div class="text-xs text-muted-foreground whitespace-pre-wrap font-mono">${text}</div>`;
}
@customElement("artifacts-panel")
export class ArtifactsPanel extends LitElement implements ToolRenderer<ArtifactsParams, undefined> {
@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();
// External provider for attachments (decouples panel from AgentInterface)
@property({ attribute: false }) attachmentsProvider?: () => Attachment[];
// 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;
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this; // light DOM for shared styles
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
// 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" | "text" {
const ext = filename.split(".").pop()?.toLowerCase();
if (ext === "html") return "html";
if (ext === "svg") return "svg";
if (ext === "md" || ext === "markdown") return "markdown";
return "text";
}
// Helper to determine language for syntax highlighting
private 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";
}
// Get or create artifact element
private getOrCreateArtifactElement(filename: string, content: string, title: string): ArtifactElement {
let element = this.artifactElements.get(filename);
if (!element) {
const type = this.getFileType(filename);
if (type === "html") {
element = new HtmlArtifact();
(element as HtmlArtifact).attachments = this.attachmentsProvider?.() || [];
} else if (type === "svg") {
element = new SvgArtifact();
} else if (type === "markdown") {
element = new MarkdownArtifact();
} else {
element = new TextArtifact();
}
element.filename = filename;
element.displayTitle = title;
element.content = content;
element.style.display = "none";
element.style.height = "100%";
// Store element
this.artifactElements.set(filename, element);
// Add to DOM after next render
const newElement = element;
requestAnimationFrame(() => {
if (this.contentRef.value && !newElement.parentElement) {
this.contentRef.value.appendChild(newElement);
}
});
} else {
// Just update content
element.content = content;
element.displayTitle = title;
if (element instanceof HtmlArtifact) {
element.attachments = this.attachmentsProvider?.() || [];
}
}
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
}
// Open panel and focus an artifact tab by filename
private 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",
description: `Creates and manages file artifacts. Each artifact is a file with a filename and content.
IMPORTANT: Always prefer updating existing files over creating new ones. Check available files first.
Commands:
1. create: Create a new file
- filename: Name with extension (required, e.g., 'index.html', 'script.js', 'README.md')
- title: Display name for the tab (optional, defaults to filename)
- content: File content (required)
2. update: Update part of an existing file
- filename: File to update (required)
- old_str: Exact string to replace (required)
- new_str: Replacement string (required)
3. rewrite: Completely replace a file's content
- filename: File to rewrite (required)
- content: New content (required)
- title: Optionally update display title
4. get: Retrieve the full content of a file
- filename: File to retrieve (required)
- Returns the complete file content
5. delete: Delete a file
- filename: File to delete (required)
6. logs: Get console logs and errors (HTML files only)
- filename: HTML file to get logs for (required)
- Returns all console output and runtime errors
For text/html artifacts with attachments:
- HTML artifacts automatically have access to user attachments via JavaScript
- Available global functions in HTML artifacts:
* listFiles() - Returns array of {id, fileName, mimeType, size} for all attachments
* readTextFile(attachmentId) - Returns text content of attachment (for CSV, JSON, text files)
* readBinaryFile(attachmentId) - Returns Uint8Array of binary data (for images, Excel, etc.)
- Example HTML artifact that processes a CSV attachment:
<script>
// List available files
const files = listFiles();
console.log('Available files:', files);
// Find CSV file
const csvFile = files.find(f => f.mimeType === 'text/csv');
if (csvFile) {
const csvContent = readTextFile(csvFile.id);
// Process CSV data...
}
// Display image
const imageFile = files.find(f => f.mimeType.startsWith('image/'));
if (imageFile) {
const bytes = readBinaryFile(imageFile.id);
const blob = new Blob([bytes], {type: imageFile.mimeType});
const url = URL.createObjectURL(blob);
document.body.innerHTML = '<img src="' + url + '">';
}
</script>
For text/html artifacts:
- Must be a single self-contained file
- External scripts: Use CDNs like https://esm.sh, https://unpkg.com, or https://cdnjs.cloudflare.com
- Preferred: Use https://esm.sh for npm packages (e.g., https://esm.sh/three for Three.js)
- For ES modules, use: <script type="module">import * as THREE from 'https://esm.sh/three';</script>
- For Three.js specifically: import from 'https://esm.sh/three' or 'https://esm.sh/three@0.160.0'
- For addons: import from 'https://esm.sh/three/examples/jsm/controls/OrbitControls.js'
- No localStorage/sessionStorage - use in-memory variables only
- CSS should be included inline
- Can embed base64 images directly in img tags
- Ensure the layout is responsive as the iframe might be resized
- Note: Network errors (404s) for external scripts may not be captured in logs due to browser security
For application/vnd.ant.code artifacts:
- Include the language parameter for syntax highlighting
- Supports all major programming languages
For text/markdown:
- Standard markdown syntax
- Will be rendered with full formatting
- Can include base64 images using markdown syntax
For image/svg+xml:
- Complete SVG markup
- Will be rendered inline
- Can embed raster images as base64 in SVG`,
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 { output, details: undefined };
},
};
}
// ToolRenderer implementation
renderParams(params: ArtifactsParams, isStreaming?: boolean): TemplateResult {
if (isStreaming && !params.command) {
return html`<div class="text-sm text-muted-foreground">${i18n("Processing artifact...")}</div>`;
}
let commandLabel = i18n("Processing");
if (params.command) {
switch (params.command) {
case "create":
commandLabel = i18n("Create");
break;
case "update":
commandLabel = i18n("Update");
break;
case "rewrite":
commandLabel = i18n("Rewrite");
break;
case "get":
commandLabel = i18n("Get");
break;
case "delete":
commandLabel = i18n("Delete");
break;
case "logs":
commandLabel = i18n("Get logs");
break;
default:
commandLabel = params.command.charAt(0).toUpperCase() + params.command.slice(1);
}
}
const filename = params.filename || "";
switch (params.command) {
case "create":
return html`
<div
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
@click=${() => this.openArtifact(params.filename)}
>
<div>
<span class="font-medium">${i18n("Create")}</span>
<span class="text-muted-foreground ml-1">${filename}</span>
</div>
${
params.content
? html`<code-block
.code=${params.content}
language=${this.getLanguageFromFilename(params.filename)}
class="mt-2"
></code-block>`
: ""
}
</div>
`;
case "update":
return html`
<div
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
@click=${() => this.openArtifact(params.filename)}
>
<div>
<span class="font-medium">${i18n("Update")}</span>
<span class="text-muted-foreground ml-1">${filename}</span>
</div>
${
params.old_str !== undefined && params.new_str !== undefined
? Diff({ oldText: params.old_str, newText: params.new_str, className: "mt-2" })
: ""
}
</div>
`;
case "rewrite":
return html`
<div
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
@click=${() => this.openArtifact(params.filename)}
>
<div>
<span class="font-medium">${i18n("Rewrite")}</span>
<span class="text-muted-foreground ml-1">${filename}</span>
</div>
${
params.content
? html`<code-block
.code=${params.content}
language=${this.getLanguageFromFilename(params.filename)}
class="mt-2"
></code-block>`
: ""
}
</div>
`;
case "get":
return html`
<div
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
@click=${() => this.openArtifact(params.filename)}
>
<span class="font-medium">${i18n("Get")}</span>
<span class="text-muted-foreground ml-1">${filename}</span>
</div>
`;
case "delete":
return html`
<div
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
@click=${() => this.openArtifact(params.filename)}
>
<span class="font-medium">${i18n("Delete")}</span>
<span class="text-muted-foreground ml-1">${filename}</span>
</div>
`;
case "logs":
return html`
<div
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
@click=${() => this.openArtifact(params.filename)}
>
<span class="font-medium">${i18n("Get logs")}</span>
<span class="text-muted-foreground ml-1">${filename}</span>
</div>
`;
default:
// Fallback for any command not yet handled during streaming
return html`
<div
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
@click=${() => this.openArtifact(params.filename)}
>
<span class="font-medium">${commandLabel}</span>
<span class="text-muted-foreground ml-1">${filename}</span>
</div>
`;
}
}
renderResult(params: ArtifactsParams, result: ToolResultMessage<undefined>): TemplateResult {
// Make result clickable to focus the referenced file when applicable
const content = result.output || i18n("(no output)");
return html`
<div class="cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1" @click=${() => this.openArtifact(params.filename)}>
${plainOutput(content)}
</div>
`;
}
// Re-apply artifacts by scanning a message list (optional utility)
public async reconstructFromMessages(messages: Array<Message | { role: "aborted" }>): 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 === "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
type FinalArtifact = { title: string; content: string };
const finalArtifacts = new Map<string, FinalArtifact>();
for (const op of operations) {
const filename = op.filename;
switch (op.command) {
case "create": {
if (op.content) {
finalArtifacts.set(filename, { title: op.title || filename, content: op.content });
}
break;
}
case "rewrite": {
if (op.content) {
// If file didn't exist earlier but rewrite succeeded, treat as fresh content
const existing = finalArtifacts.get(filename);
finalArtifacts.set(filename, { title: op.title || existing?.title || filename, content: op.content });
}
break;
}
case "update": {
const 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.content = existing.content.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, { title, content }] of finalArtifacts.entries()) {
const createParams: ArtifactsParams = { command: "create", filename, title, 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) => {
let resolved = false;
// Listen for the execution-complete message
const messageHandler = (event: MessageEvent) => {
if (event.data?.type === "execution-complete" && event.data?.artifactId === filename) {
if (!resolved) {
resolved = true;
window.removeEventListener("message", messageHandler);
// Get the logs from the element
const logs = element.getLogs();
if (logs.includes("[error]")) {
resolve(`\n\nExecution completed with errors:\n${logs}`);
} else if (logs !== `No logs for ${filename}`) {
resolve(`\n\nExecution logs:\n${logs}`);
} else {
resolve("");
}
}
}
};
window.addEventListener("message", messageHandler);
// Fallback timeout in case the message never arrives
setTimeout(() => {
if (!resolved) {
resolved = true;
window.removeEventListener("message", messageHandler);
// Get whatever logs we have so far
const logs = element.getLogs();
if (logs.includes("[error]")) {
resolve(`\n\nExecution timed out with errors:\n${logs}`);
} else if (logs !== `No logs for ${filename}`) {
resolve(`\n\nExecution timed out. Partial logs:\n${logs}`);
} else {
resolve("");
}
}
}, 1500);
});
}
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 title = params.title || params.filename;
const artifact: Artifact = {
filename: params.filename,
title: title,
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, title);
if (!options.silent) {
this.showArtifact(params.filename);
this.onArtifactsChange?.();
this.requestUpdate();
}
// 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 += 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, artifact.title);
if (!options.silent) {
this.onArtifactsChange?.();
this.requestUpdate();
}
// 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 += 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;
if (params.title) artifact.title = params.title;
artifact.updatedAt = new Date();
this._artifacts.set(params.filename, artifact);
// Update element
this.getOrCreateArtifactElement(params.filename, artifact.content, artifact.title);
if (!options.silent) {
this.onArtifactsChange?.();
}
// For HTML files, wait for execution
let result = `Rewrote file ${params.filename}`;
if (this.getFileType(params.filename) === "html" && !options.skipWait) {
const logs = await this.waitForHtmlExecution(params.filename);
result += 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();
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());
const showContainer = artifacts.length > 0 && !this.collapsed;
return html`
<!-- Floating reopen pill when collapsed and artifacts exist -->
${
this.collapsed && artifacts.length > 0
? html`
<button
class="absolute z-30 top-4 left-1/2 -translate-x-1/2 pointer-events-auto"
@click=${() => this.onOpen?.()}
title=${i18n("Show artifacts")}
>
${Badge(html`
<span class="inline-flex items-center gap-1">
<span>${i18n("Artifacts")}</span>
${
artifacts.length > 1
? html`<span
class="text-[10px] leading-none bg-primary-foreground/20 text-primary-foreground rounded px-1 font-mono tabular-nums"
>${artifacts.length}</span
>`
: ""
}
</span>
`)}
</button>
`
: ""
}
<!-- Panel container -->
<div
class="${showContainer ? "" : "hidden"} ${
this.overlay ? "fixed inset-0 z-40 pointer-events-auto backdrop-blur-sm bg-background/95" : "relative"
} h-full flex flex-col bg-card 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-muted/30">
<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 text-sm whitespace-nowrap border-b-2 ${activeClass}"
@click=${() => this.showArtifact(a.filename)}
>
<span class="font-mono">${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;
}
}

View file

@ -0,0 +1,6 @@
export { ArtifactElement } from "./ArtifactElement.js";
export { type Artifact, ArtifactsPanel, type ArtifactsParams } from "./artifacts.js";
export { HtmlArtifact } from "./HtmlArtifact.js";
export { MarkdownArtifact } from "./MarkdownArtifact.js";
export { SvgArtifact } from "./SvgArtifact.js";
export { TextArtifact } from "./TextArtifact.js";

View file

@ -78,6 +78,25 @@ declare module "@mariozechner/mini-lit" {
"JavaScript code to execute": string;
"Writing JavaScript code...": string;
"Executing JavaScript": string;
// Artifacts strings
"Processing artifact...": string;
Processing: string;
Create: string;
Rewrite: string;
Get: string;
Delete: string;
"Get logs": string;
"Show artifacts": string;
"Close artifacts": string;
Artifacts: string;
"Copy HTML": string;
"Download HTML": string;
"Copy SVG": string;
"Download SVG": string;
"Copy Markdown": string;
"Download Markdown": string;
Download: string;
"No logs for {filename}": string;
}
}
@ -162,6 +181,25 @@ const translations = {
"JavaScript code to execute": "JavaScript code to execute",
"Writing JavaScript code...": "Writing JavaScript code...",
"Executing JavaScript": "Executing JavaScript",
// Artifacts strings
"Processing artifact...": "Processing artifact...",
Processing: "Processing",
Create: "Create",
Rewrite: "Rewrite",
Get: "Get",
Delete: "Delete",
"Get logs": "Get logs",
"Show artifacts": "Show artifacts",
"Close artifacts": "Close artifacts",
Artifacts: "Artifacts",
"Copy HTML": "Copy HTML",
"Download HTML": "Download HTML",
"Copy SVG": "Copy SVG",
"Download SVG": "Download SVG",
"Copy Markdown": "Copy Markdown",
"Download Markdown": "Download Markdown",
Download: "Download",
"No logs for {filename}": "No logs for {filename}",
},
de: {
...defaultGerman,
@ -243,6 +281,25 @@ const translations = {
"JavaScript code to execute": "Auszuführender JavaScript-Code",
"Writing JavaScript code...": "Schreibe JavaScript-Code...",
"Executing JavaScript": "Führe JavaScript aus",
// Artifacts strings
"Processing artifact...": "Verarbeite Artefakt...",
Processing: "Verarbeitung",
Create: "Erstellen",
Rewrite: "Überschreiben",
Get: "Abrufen",
Delete: "Löschen",
"Get logs": "Logs abrufen",
"Show artifacts": "Artefakte anzeigen",
"Close artifacts": "Artefakte schließen",
Artifacts: "Artefakte",
"Copy HTML": "HTML kopieren",
"Download HTML": "HTML herunterladen",
"Copy SVG": "SVG kopieren",
"Download SVG": "SVG herunterladen",
"Copy Markdown": "Markdown kopieren",
"Download Markdown": "Markdown herunterladen",
Download: "Herunterladen",
"No logs for {filename}": "Keine Logs für {filename}",
},
};