From 4b0703cd5bc485806049ca4d9b5411e106814c7c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 1 Oct 2025 23:32:14 +0200 Subject: [PATCH] Artifacts tool, V1, htmlartifact broken due to CSP --- packages/browser-extension/src/ChatPanel.ts | 149 ++- packages/browser-extension/src/sidepanel.ts | 89 +- .../src/tools/artifacts/ArtifactElement.ts | 15 + .../src/tools/artifacts/HtmlArtifact.ts | 390 ++++++++ .../src/tools/artifacts/MarkdownArtifact.ts | 81 ++ .../src/tools/artifacts/SvgArtifact.ts | 77 ++ .../src/tools/artifacts/TextArtifact.ts | 148 +++ .../src/tools/artifacts/artifacts.ts | 888 ++++++++++++++++++ .../src/tools/artifacts/index.ts | 6 + packages/browser-extension/src/utils/i18n.ts | 57 ++ 10 files changed, 1861 insertions(+), 39 deletions(-) create mode 100644 packages/browser-extension/src/tools/artifacts/ArtifactElement.ts create mode 100644 packages/browser-extension/src/tools/artifacts/HtmlArtifact.ts create mode 100644 packages/browser-extension/src/tools/artifacts/MarkdownArtifact.ts create mode 100644 packages/browser-extension/src/tools/artifacts/SvgArtifact.ts create mode 100644 packages/browser-extension/src/tools/artifacts/TextArtifact.ts create mode 100644 packages/browser-extension/src/tools/artifacts/artifacts.ts create mode 100644 packages/browser-extension/src/tools/artifacts/index.ts diff --git a/packages/browser-extension/src/ChatPanel.ts b/packages/browser-extension/src/ChatPanel.ts index 14de27b8..4692f40b 100644 --- a/packages/browser-extension/src/ChatPanel.ts +++ b/packages/browser-extension/src/ChatPanel.ts @@ -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 { `; } + 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` - +
+ +
+ +
+ + + ${ + !isMobile + ? html`
+ ${this.artifactsPanel} +
` + : "" + } + + + ${isMobile ? html`
${this.artifactsPanel}
` : ""} +
`; } } diff --git a/packages/browser-extension/src/sidepanel.ts b/packages/browser-extension/src/sidepanel.ts index 57575df4..24439ac5 100644 --- a/packages/browser-extension/src/sidepanel.ts +++ b/packages/browser-extension/src/sidepanel.ts @@ -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` -
- pi-ai - - ${Button({ - variant: "ghost", - size: "icon", - children: html`${icon(Settings, "sm")}`, - onClick: async () => { - ApiKeysDialog.open(); - }, - })} +
+ pi-ai +
+ + ${Button({ + variant: "ghost", + size: "icon", + children: html`${icon(Settings, "sm")}`, + onClick: async () => { + ApiKeysDialog.open(); + }, + })} +
`; } } +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`
- +
`; diff --git a/packages/browser-extension/src/tools/artifacts/ArtifactElement.ts b/packages/browser-extension/src/tools/artifacts/ArtifactElement.ts new file mode 100644 index 00000000..f9f0b839 --- /dev/null +++ b/packages/browser-extension/src/tools/artifacts/ArtifactElement.ts @@ -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; +} diff --git a/packages/browser-extension/src/tools/artifacts/HtmlArtifact.ts b/packages/browser-extension/src/tools/artifacts/HtmlArtifact.ts new file mode 100644 index 00000000..55be8bb8 --- /dev/null +++ b/packages/browser-extension/src/tools/artifacts/HtmlArtifact.ts @@ -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 = createRef(); + private consoleLogsRef: Ref = createRef(); + private consoleButtonRef: Ref = 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` +
+ ${toggle} + ${copyButton} + ${DownloadButton({ content: this._content, filename: this.filename, mimeType: "text/html", title: i18n("Download HTML") })} +
+ `; + } + + 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")} ${errorCount} errors` + : `${i18n("console")} (${this.logs.length})`; + button.innerHTML = `${text}${this.consoleOpen ? "▼" : "▶"}`; + } + + 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 to send completion message after page loads + const completionScript = ` + + `; + + // Add console setup to head and completion script to end of body + let enhancedContent = this._content; + + // Ensure iframe content has proper dimensions + const dimensionFix = ` + + `; + + // Add dimension fix and console setup to head (or beginning if no head) + if (enhancedContent.match(/]*>/i)) { + enhancedContent = enhancedContent.replace( + /]*>/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}`); + } 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` +
+
+ +
+
+ ${ + this.logs.length > 0 + ? html` +
+ + ${this.consoleOpen ? html`
` : ""} +
+ ` + : "" + } +
+ + +
+
${unsafeHTML(
+							hljs.highlight(this._content, { language: "html" }).value,
+						)}
+
+
+
+ `; + } +} diff --git a/packages/browser-extension/src/tools/artifacts/MarkdownArtifact.ts b/packages/browser-extension/src/tools/artifacts/MarkdownArtifact.ts new file mode 100644 index 00000000..07b6181b --- /dev/null +++ b/packages/browser-extension/src/tools/artifacts/MarkdownArtifact.ts @@ -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` +
+ ${toggle} + ${copyButton} + ${DownloadButton({ + content: this._content, + filename: this.filename, + mimeType: "text/markdown", + title: i18n("Download Markdown"), + })} +
+ `; + } + + override render() { + return html` +
+
+ ${ + this.viewMode === "preview" + ? html`
` + : html`
${unsafeHTML(
+									hljs.highlight(this.content, { language: "markdown", ignoreIllegals: true }).value,
+								)}
` + } +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "markdown-artifact": MarkdownArtifact; + } +} diff --git a/packages/browser-extension/src/tools/artifacts/SvgArtifact.ts b/packages/browser-extension/src/tools/artifacts/SvgArtifact.ts new file mode 100644 index 00000000..51c24236 --- /dev/null +++ b/packages/browser-extension/src/tools/artifacts/SvgArtifact.ts @@ -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` +
+ ${toggle} + ${copyButton} + ${DownloadButton({ content: this._content, filename: this.filename, mimeType: "image/svg+xml", title: i18n("Download SVG") })} +
+ `; + } + + override render() { + return html` +
+
+ ${ + this.viewMode === "preview" + ? html`
+ ${unsafeHTML(this.content.replace(/)/i, (_m, p1) => `` + : html`
${unsafeHTML(
+									hljs.highlight(this.content, { language: "xml", ignoreIllegals: true }).value,
+								)}
` + } +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "svg-artifact": SvgArtifact; + } +} diff --git a/packages/browser-extension/src/tools/artifacts/TextArtifact.ts b/packages/browser-extension/src/tools/artifacts/TextArtifact.ts new file mode 100644 index 00000000..b7899316 --- /dev/null +++ b/packages/browser-extension/src/tools/artifacts/TextArtifact.ts @@ -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 = { + 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` +
+ ${copyButton} + ${DownloadButton({ + content: this.content, + filename: this.filename, + mimeType: this.getMimeType(), + title: i18n("Download"), + })} +
+ `; + } + + override render() { + const isCode = this.isCode(); + const ext = this.filename.split(".").pop() || ""; + return html` +
+
+ ${ + isCode + ? html` +
${unsafeHTML(
+									hljs.highlight(this.content, {
+										language: this.getLanguageFromExtension(ext.toLowerCase()),
+										ignoreIllegals: true,
+									}).value,
+								)}
+ ` + : html`
${this.content}
` + } +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "text-artifact": TextArtifact; + } +} diff --git a/packages/browser-extension/src/tools/artifacts/artifacts.ts b/packages/browser-extension/src/tools/artifacts/artifacts.ts new file mode 100644 index 00000000..f6a5be41 --- /dev/null +++ b/packages/browser-extension/src/tools/artifacts/artifacts.ts @@ -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; + +// Minimal helper to render plain text outputs consistently +function plainOutput(text: string): TemplateResult { + return html`
${text}
`; +} + +@customElement("artifacts-panel") +export class ArtifactsPanel extends LitElement implements ToolRenderer { + @state() private _artifacts = new Map(); + @state() private _activeFilename: string | null = null; + + // Programmatically managed artifact elements + private artifactElements = new Map(); + private contentRef: Ref = 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 = { + 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 { + 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: + + +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: +- 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, _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`
${i18n("Processing artifact...")}
`; + } + + 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` +
this.openArtifact(params.filename)} + > +
+ ${i18n("Create")} + ${filename} +
+ ${ + params.content + ? html`` + : "" + } +
+ `; + case "update": + return html` +
this.openArtifact(params.filename)} + > +
+ ${i18n("Update")} + ${filename} +
+ ${ + params.old_str !== undefined && params.new_str !== undefined + ? Diff({ oldText: params.old_str, newText: params.new_str, className: "mt-2" }) + : "" + } +
+ `; + case "rewrite": + return html` +
this.openArtifact(params.filename)} + > +
+ ${i18n("Rewrite")} + ${filename} +
+ ${ + params.content + ? html`` + : "" + } +
+ `; + case "get": + return html` +
this.openArtifact(params.filename)} + > + ${i18n("Get")} + ${filename} +
+ `; + case "delete": + return html` +
this.openArtifact(params.filename)} + > + ${i18n("Delete")} + ${filename} +
+ `; + case "logs": + return html` +
this.openArtifact(params.filename)} + > + ${i18n("Get logs")} + ${filename} +
+ `; + default: + // Fallback for any command not yet handled during streaming + return html` +
this.openArtifact(params.filename)} + > + ${commandLabel} + ${filename} +
+ `; + } + } + + renderResult(params: ArtifactsParams, result: ToolResultMessage): TemplateResult { + // Make result clickable to focus the referenced file when applicable + const content = result.output || i18n("(no output)"); + return html` +
this.openArtifact(params.filename)}> + ${plainOutput(content)} +
+ `; + } + + // Re-apply artifacts by scanning a message list (optional utility) + public async reconstructFromMessages(messages: Array): Promise { + const toolCalls = new Map(); + 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 = []; + 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(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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` + + ${ + this.collapsed && artifacts.length > 0 + ? html` + + ` + : "" + } + + +
+ +
+
+ ${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` + + `; + })} +
+
+ ${(() => { + 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"), + })} +
+
+ + +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "artifacts-panel": ArtifactsPanel; + } +} diff --git a/packages/browser-extension/src/tools/artifacts/index.ts b/packages/browser-extension/src/tools/artifacts/index.ts new file mode 100644 index 00000000..5b3d3010 --- /dev/null +++ b/packages/browser-extension/src/tools/artifacts/index.ts @@ -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"; diff --git a/packages/browser-extension/src/utils/i18n.ts b/packages/browser-extension/src/utils/i18n.ts index 390d20ac..566b13e1 100644 --- a/packages/browser-extension/src/utils/i18n.ts +++ b/packages/browser-extension/src/utils/i18n.ts @@ -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}", }, };