This commit is contained in:
ZacharyZhang-NY committed 2026-04-16 14:48:51 -04:00
1 parent 9779c4d1ca
commit 1b578c111a
719 files changed
+191593 -98

No files matched your search

@@ -0,0 +1,403 @@
import { streamSimple, type ToolResultMessage, type Usage } from "@mariozechner/pi-ai";
import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import { ModelSelector } from "../dialogs/ModelSelector.js";
import type { MessageEditor } from "./MessageEditor.js";
import "./MessageEditor.js";
import "./MessageList.js";
import "./Messages.js"; // Import for side effects to register the custom elements
import { getAppStorage } from "../storage/app-storage.js";
import "./StreamingMessageContainer.js";
import type { Agent, AgentEvent } from "@mariozechner/pi-agent-core";
import type { Attachment } from "../utils/attachment-utils.js";
import { formatUsage } from "../utils/format.js";
import { i18n } from "../utils/i18n.js";
import { createStreamFn } from "../utils/proxy-utils.js";
import type { UserMessageWithAttachments } from "./Messages.js";
import type { StreamingMessageContainer } from "./StreamingMessageContainer.js";
@customElement("agent-interface")
export class AgentInterface extends LitElement {
// Optional external session: when provided, this component becomes a view over the session
@property({ attribute: false }) session?: Agent;
@property({ type: Boolean }) enableAttachments = true;
@property({ type: Boolean }) enableModelSelector = true;
@property({ type: Boolean }) enableThinkingSelector = true;
@property({ type: Boolean }) showThemeToggle = false;
// Optional custom API key prompt handler - if not provided, uses default dialog
@property({ attribute: false }) onApiKeyRequired?: (provider: string) => Promise<boolean>;
// Optional callback called before sending a message
@property({ attribute: false }) onBeforeSend?: () => void | Promise<void>;
// Optional callback called before executing a tool call - return false to prevent execution
@property({ attribute: false }) onBeforeToolCall?: (toolName: string, args: any) => boolean | Promise<boolean>;
// Optional callback called when cost display is clicked
@property({ attribute: false }) onCostClick?: () => void;
// Optional callback to override model selector behavior
@property({ attribute: false }) onModelSelect?: () => void;
// References
@query("message-editor") private _messageEditor!: MessageEditor;
@query("streaming-message-container") private _streamingContainer!: StreamingMessageContainer;
private _autoScroll = true;
private _lastScrollTop = 0;
private _lastClientHeight = 0;
private _scrollContainer?: HTMLElement;
private _resizeObserver?: ResizeObserver;
private _unsubscribeSession?: () => void;
public setInput(text: string, attachments?: Attachment[]) {
const update = () => {
if (!this._messageEditor) requestAnimationFrame(update);
else {
this._messageEditor.value = text;
this._messageEditor.attachments = attachments || [];
}
};
update();
}
public setAutoScroll(enabled: boolean) {
this._autoScroll = enabled;
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override willUpdate(changedProperties: Map<string, any>) {
super.willUpdate(changedProperties);
// Re-subscribe when session property changes
if (changedProperties.has("session")) {
this.setupSessionSubscription();
}
}
override async connectedCallback() {
super.connectedCallback();
this.style.display = "flex";
this.style.flexDirection = "column";
this.style.height = "100%";
this.style.minHeight = "0";
// Wait for first render to get scroll container
await this.updateComplete;
this._scrollContainer = this.querySelector(".overflow-y-auto") as HTMLElement;
if (this._scrollContainer) {
// Set up ResizeObserver to detect content changes
this._resizeObserver = new ResizeObserver(() => {
if (this._autoScroll && this._scrollContainer) {
this._scrollContainer.scrollTop = this._scrollContainer.scrollHeight;
}
});
// Observe the content container inside the scroll container
const contentContainer = this._scrollContainer.querySelector(".max-w-3xl");
if (contentContainer) {
this._resizeObserver.observe(contentContainer);
}
// Set up scroll listener with better detection
this._scrollContainer.addEventListener("scroll", this._handleScroll);
}
// Subscribe to external session if provided
this.setupSessionSubscription();
}
override disconnectedCallback() {
super.disconnectedCallback();
// Clean up observers and listeners
if (this._resizeObserver) {
this._resizeObserver.disconnect();
this._resizeObserver = undefined;
}
if (this._scrollContainer) {
this._scrollContainer.removeEventListener("scroll", this._handleScroll);
}
if (this._unsubscribeSession) {
this._unsubscribeSession();
this._unsubscribeSession = undefined;
}
}
private setupSessionSubscription() {
if (this._unsubscribeSession) {
this._unsubscribeSession();
this._unsubscribeSession = undefined;
}
if (!this.session) return;
// Set default streamFn with proxy support if not already set
if (this.session.streamFn === streamSimple) {
this.session.streamFn = createStreamFn(async () => {
const enabled = await getAppStorage().settings.get<boolean>("proxy.enabled");
return enabled ? (await getAppStorage().settings.get<string>("proxy.url")) || undefined : undefined;
});
}
// Set default getApiKey if not already set
if (!this.session.getApiKey) {
this.session.getApiKey = async (provider: string) => {
const key = await getAppStorage().providerKeys.get(provider);
return key ?? undefined;
};
}
this._unsubscribeSession = this.session.subscribe(async (ev: AgentEvent) => {
switch (ev.type) {
case "message_start":
case "turn_start":
case "turn_end":
case "agent_start":
this.requestUpdate();
break;
case "message_end":
// Clear streaming container when a message completes
// to prevent duplicate rendering (stable list now has this message)
if (this._streamingContainer) {
this._streamingContainer.setMessage(null, true);
}
this.requestUpdate();
break;
case "agent_end":
// Clear streaming container when agent finishes
if (this._streamingContainer) {
this._streamingContainer.isStreaming = false;
this._streamingContainer.setMessage(null, true);
}
this.requestUpdate();
break;
case "message_update":
if (this._streamingContainer) {
const isStreaming = this.session?.state.isStreaming || false;
this._streamingContainer.isStreaming = isStreaming;
this._streamingContainer.setMessage(ev.message, !isStreaming);
}
this.requestUpdate();
break;
}
});
}
private _handleScroll = (_ev: any) => {
if (!this._scrollContainer) return;
const currentScrollTop = this._scrollContainer.scrollTop;
const scrollHeight = this._scrollContainer.scrollHeight;
const clientHeight = this._scrollContainer.clientHeight;
const distanceFromBottom = scrollHeight - currentScrollTop - clientHeight;
// Ignore relayout due to message editor getting pushed up by stats
if (clientHeight < this._lastClientHeight) {
this._lastClientHeight = clientHeight;
return;
}
// Only disable auto-scroll if user scrolled UP or is far from bottom
if (currentScrollTop !== 0 && currentScrollTop < this._lastScrollTop && distanceFromBottom > 50) {
this._autoScroll = false;
} else if (distanceFromBottom < 10) {
// Re-enable if very close to bottom
this._autoScroll = true;
}
this._lastScrollTop = currentScrollTop;
this._lastClientHeight = clientHeight;
};
public async sendMessage(input: string, attachments?: Attachment[]) {
if ((!input.trim() && attachments?.length === 0) || this.session?.state.isStreaming) return;
const session = this.session;
if (!session) throw new Error("No session set on AgentInterface");
if (!session.state.model) throw new Error("No model set on AgentInterface");
// Check if API key exists for the provider (only needed in direct mode)
const provider = session.state.model.provider;
const apiKey = await getAppStorage().providerKeys.get(provider);
// If no API key, prompt for it
if (!apiKey) {
if (!this.onApiKeyRequired) {
console.error("No API key configured and no onApiKeyRequired handler set");
return;
}
const success = await this.onApiKeyRequired(provider);
// If still no API key, abort the send
if (!success) {
return;
}
}
// Call onBeforeSend hook before sending
if (this.onBeforeSend) {
await this.onBeforeSend();
}
// Only clear editor after we know we can send
this._messageEditor.value = "";
this._messageEditor.attachments = [];
this._autoScroll = true; // Enable auto-scroll when sending a message
// Compose message with attachments if any
if (attachments && attachments.length > 0) {
const message: UserMessageWithAttachments = {
role: "user-with-attachments",
content: input,
attachments,
timestamp: Date.now(),
};
await this.session?.prompt(message);
} else {
await this.session?.prompt(input);
}
}
private renderMessages() {
if (!this.session)
return html`<div class="p-4 text-center text-muted-foreground">${i18n("No session available")}</div>`;
const state = this.session.state;
// Build a map of tool results to allow inline rendering in assistant messages
const toolResultsById = new Map<string, ToolResultMessage<any>>();
for (const message of state.messages) {
if (message.role === "toolResult") {
toolResultsById.set(message.toolCallId, message);
}
}
return html`
<div class="flex flex-col gap-3">
<!-- Stable messages list - won't re-render during streaming -->
<message-list
.messages=${this.session.state.messages}
.tools=${state.tools}
.pendingToolCalls=${this.session ? this.session.state.pendingToolCalls : new Set<string>()}
.isStreaming=${state.isStreaming}
.onCostClick=${this.onCostClick}
></message-list>
<!-- Streaming message container - manages its own updates -->
<streaming-message-container
class="${state.isStreaming ? "" : "hidden"}"
.tools=${state.tools}
.isStreaming=${state.isStreaming}
.pendingToolCalls=${state.pendingToolCalls}
.toolResultsById=${toolResultsById}
.onCostClick=${this.onCostClick}
></streaming-message-container>
</div>
`;
}
private renderStats() {
if (!this.session) return html`<div class="text-xs h-5"></div>`;
const state = this.session.state;
const totals = state.messages
.filter((m) => m.role === "assistant")
.reduce(
(acc, msg: any) => {
const usage = msg.usage;
if (usage) {
acc.input += usage.input;
acc.output += usage.output;
acc.cacheRead += usage.cacheRead;
acc.cacheWrite += usage.cacheWrite;
acc.cost.total += usage.cost.total;
}
return acc;
},
{
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
} satisfies Usage,
);
const hasTotals = totals.input || totals.output || totals.cacheRead || totals.cacheWrite;
const totalsText = hasTotals ? formatUsage(totals) : "";
return html`
<div class="text-xs text-muted-foreground flex justify-between items-center h-5">
<div class="flex items-center gap-1">
${this.showThemeToggle ? html`<theme-toggle></theme-toggle>` : html``}
</div>
<div class="flex ml-auto items-center gap-3">
${
totalsText
? this.onCostClick
? html`<span class="cursor-pointer hover:text-foreground transition-colors" @click=${this.onCostClick}>${totalsText}</span>`
: html`<span>${totalsText}</span>`
: ""
}
</div>
</div>
`;
}
override render() {
if (!this.session)
return html`<div class="p-4 text-center text-muted-foreground">${i18n("No session set")}</div>`;
const session = this.session;
const state = this.session.state;
return html`
<div class="flex flex-col h-full bg-background text-foreground">
<!-- Messages Area -->
<div class="flex-1 overflow-y-auto">
<div class="max-w-3xl mx-auto p-4 pb-0">${this.renderMessages()}</div>
</div>
<!-- Input Area -->
<div class="shrink-0">
<div class="max-w-3xl mx-auto px-2">
<message-editor
.isStreaming=${state.isStreaming}
.currentModel=${state.model}
.thinkingLevel=${state.thinkingLevel}
.showAttachmentButton=${this.enableAttachments}
.showModelSelector=${this.enableModelSelector}
.showThinkingSelector=${this.enableThinkingSelector}
.onSend=${(input: string, attachments: Attachment[]) => {
this.sendMessage(input, attachments);
}}
.onAbort=${() => session.abort()}
.onModelSelect=${() => {
if (this.onModelSelect) {
this.onModelSelect();
} else {
ModelSelector.open(state.model, (model) => {
session.state.model = model;
});
}
}}
.onThinkingChange=${
this.enableThinkingSelector
? (level: "off" | "minimal" | "low" | "medium" | "high") => {
session.state.thinkingLevel = level;
}
: undefined
}
></message-editor>
${this.renderStats()}
</div>
</div>
</div>
`;
}
}
// Register custom element with guard
if (!customElements.get("agent-interface")) {
customElements.define("agent-interface", AgentInterface);
}
@@ -0,0 +1,107 @@
import { icon } from "@mariozechner/mini-lit/dist/icons.js";
import { LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { html } from "lit/html.js";
import { FileSpreadsheet, FileText, X } from "lucide";
import { AttachmentOverlay } from "../dialogs/AttachmentOverlay.js";
import type { Attachment } from "../utils/attachment-utils.js";
import { i18n } from "../utils/i18n.js";
@customElement("attachment-tile")
export class AttachmentTile extends LitElement {
@property({ type: Object }) attachment!: Attachment;
@property({ type: Boolean }) showDelete = false;
@property() onDelete?: () => void;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
this.classList.add("max-h-16");
}
private handleClick = () => {
AttachmentOverlay.open(this.attachment);
};
override render() {
const hasPreview = !!this.attachment.preview;
const isImage = this.attachment.type === "image";
const isPdf = this.attachment.mimeType === "application/pdf";
const isExcel =
this.attachment.mimeType?.includes("spreadsheetml") ||
this.attachment.fileName.toLowerCase().endsWith(".xlsx") ||
this.attachment.fileName.toLowerCase().endsWith(".xls");
// Choose the appropriate icon
const getDocumentIcon = () => {
if (isExcel) return icon(FileSpreadsheet, "md");
return icon(FileText, "md");
};
return html`
<div class="relative group inline-block">
${
hasPreview
? html`
<div class="relative">
<img
src="data:${isImage ? this.attachment.mimeType : "image/png"};base64,${this.attachment.preview}"
class="w-16 h-16 object-cover rounded-lg border border-input cursor-pointer hover:opacity-80 transition-opacity"
alt="${this.attachment.fileName}"
title="${this.attachment.fileName}"
@click=${this.handleClick}
/>
${
isPdf
? html`
<!-- PDF badge overlay -->
<div class="absolute bottom-0 left-0 right-0 bg-background/90 px-1 py-0.5 rounded-b-lg">
<div class="text-[10px] text-muted-foreground text-center font-medium">${i18n("PDF")}</div>
</div>
`
: ""
}
</div>
`
: html`
<!-- Fallback: document icon + filename -->
<div
class="w-16 h-16 rounded-lg border border-input cursor-pointer hover:opacity-80 transition-opacity bg-muted text-muted-foreground flex flex-col items-center justify-center p-2"
@click=${this.handleClick}
title="${this.attachment.fileName}"
>
${getDocumentIcon()}
<div class="text-[10px] text-center truncate w-full">
${
this.attachment.fileName.length > 10
? `${this.attachment.fileName.substring(0, 8)}...`
: this.attachment.fileName
}
</div>
</div>
`
}
${
this.showDelete
? html`
<button
@click=${(e: Event) => {
e.stopPropagation();
this.onDelete?.();
}}
class="absolute -top-1 -right-1 w-5 h-5 bg-background hover:bg-muted text-muted-foreground hover:text-foreground rounded-full flex items-center justify-center opacity-100 hover:opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 transition-opacity border border-input shadow-sm"
title="${i18n("Remove")}"
>
${icon(X, "xs")}
</button>
`
: ""
}
</div>
`;
}
}
@@ -0,0 +1,72 @@
import { icon } from "@mariozechner/mini-lit";
import { LitElement } from "lit";
import { property, state } from "lit/decorators.js";
import { html } from "lit/html.js";
import { Check, Copy } from "lucide";
import { i18n } from "../utils/i18n.js";
export class ConsoleBlock extends LitElement {
@property() content: string = "";
@property() variant: "default" | "error" = "default";
@state() private copied = false;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
private async copy() {
try {
await navigator.clipboard.writeText(this.content || "");
this.copied = true;
setTimeout(() => {
this.copied = false;
}, 1500);
} catch (e) {
console.error("Copy failed", e);
}
}
override updated() {
// Auto-scroll to bottom on content changes
const container = this.querySelector(".console-scroll") as HTMLElement | null;
if (container) {
container.scrollTop = container.scrollHeight;
}
}
override render() {
const isError = this.variant === "error";
const textClass = isError ? "text-destructive" : "text-foreground";
return html`
<div class="border border-border rounded-lg overflow-hidden">
<div class="flex items-center justify-between px-3 py-1.5 bg-muted border-b border-border">
<span class="text-xs text-muted-foreground font-mono">${i18n("console")}</span>
<button
@click=${() => this.copy()}
class="flex items-center gap-1 px-2 py-0.5 text-xs rounded hover:bg-accent text-muted-foreground hover:text-accent-foreground transition-colors"
title="${i18n("Copy output")}"
>
${this.copied ? icon(Check, "sm") : icon(Copy, "sm")}
${this.copied ? html`<span>${i18n("Copied!")}</span>` : ""}
</button>
</div>
<div class="console-scroll overflow-auto max-h-64">
<pre class="!bg-background !border-0 !rounded-none m-0 p-3 text-xs ${textClass} font-mono whitespace-pre-wrap">
${this.content || ""}</pre
>
</div>
</div>
`;
}
}
// Register custom element
if (!customElements.get("console-block")) {
customElements.define("console-block", ConsoleBlock);
}
@@ -0,0 +1,100 @@
import { i18n } from "@mariozechner/mini-lit";
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import { html, LitElement, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { CustomProvider } from "../storage/stores/custom-providers-store.js";
@customElement("custom-provider-card")
export class CustomProviderCard extends LitElement {
@property({ type: Object }) provider!: CustomProvider;
@property({ type: Boolean }) isAutoDiscovery = false;
@property({ type: Object }) status?: { modelCount: number; status: "connected" | "disconnected" | "checking" };
@property() onRefresh?: (provider: CustomProvider) => void;
@property() onEdit?: (provider: CustomProvider) => void;
@property() onDelete?: (provider: CustomProvider) => void;
protected createRenderRoot() {
return this;
}
private renderStatus(): TemplateResult {
if (!this.isAutoDiscovery) {
return html`
<div class="text-xs text-muted-foreground mt-1">
${i18n("Models")}: ${this.provider.models?.length || 0}
</div>
`;
}
if (!this.status) return html``;
const statusIcon =
this.status.status === "connected"
? html`<span class="text-green-500">●</span>`
: this.status.status === "checking"
? html`<span class="text-yellow-500">●</span>`
: html`<span class="text-red-500">●</span>`;
const statusText =
this.status.status === "connected"
? `${this.status.modelCount} ${i18n("models")}`
: this.status.status === "checking"
? i18n("Checking...")
: i18n("Disconnected");
return html`
<div class="text-xs text-muted-foreground mt-1 flex items-center gap-1">
${statusIcon} ${statusText}
</div>
`;
}
render(): TemplateResult {
return html`
<div class="border border-border rounded-lg p-4 space-y-2">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="font-medium text-sm text-foreground">${this.provider.name}</div>
<div class="text-xs text-muted-foreground mt-1">
<span class="capitalize">${this.provider.type}</span>
${this.provider.baseUrl ? html` • ${this.provider.baseUrl}` : ""}
</div>
${this.renderStatus()}
</div>
<div class="flex gap-2">
${
this.isAutoDiscovery && this.onRefresh
? Button({
onClick: () => this.onRefresh?.(this.provider),
variant: "ghost",
size: "sm",
children: i18n("Refresh"),
})
: ""
}
${
this.onEdit
? Button({
onClick: () => this.onEdit?.(this.provider),
variant: "ghost",
size: "sm",
children: i18n("Edit"),
})
: ""
}
${
this.onDelete
? Button({
onClick: () => this.onDelete?.(this.provider),
variant: "ghost",
size: "sm",
children: i18n("Delete"),
})
: ""
}
</div>
</div>
</div>
`;
}
}
@@ -0,0 +1,46 @@
import { icon } from "@mariozechner/mini-lit";
import { html, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ChevronDown, ChevronRight } from "lucide";
/**
* Reusable expandable section component for tool renderers.
* Captures children in connectedCallback and re-renders them in the details area.
*/
@customElement("expandable-section")
export class ExpandableSection extends LitElement {
@property() summary!: string;
@property({ type: Boolean }) defaultExpanded = false;
@state() private expanded = false;
private capturedChildren: Node[] = [];
protected createRenderRoot() {
return this; // light DOM
}
override connectedCallback() {
super.connectedCallback();
// Capture children before first render
this.capturedChildren = Array.from(this.childNodes);
// Clear children (we'll re-insert them in render)
this.innerHTML = "";
this.expanded = this.defaultExpanded;
}
override render(): TemplateResult {
return html`
<div>
<button
@click=${() => {
this.expanded = !this.expanded;
}}
class="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full text-left"
>
${icon(this.expanded ? ChevronDown : ChevronRight, "sm")}
<span>${this.summary}</span>
</button>
${this.expanded ? html`<div class="mt-2">${this.capturedChildren}</div>` : ""}
</div>
`;
}
}
@@ -0,0 +1,113 @@
import { type BaseComponentProps, fc } from "@mariozechner/mini-lit/dist/mini.js";
import { html } from "lit";
import { type Ref, ref } from "lit/directives/ref.js";
import { i18n } from "../utils/i18n.js";
export type InputType = "text" | "email" | "password" | "number" | "url" | "tel" | "search";
export type InputSize = "sm" | "md" | "lg";
export interface InputProps extends BaseComponentProps {
type?: InputType;
size?: InputSize;
value?: string;
placeholder?: string;
label?: string;
error?: string;
disabled?: boolean;
required?: boolean;
name?: string;
autocomplete?: string;
min?: number;
max?: number;
step?: number;
inputRef?: Ref<HTMLInputElement>;
onInput?: (e: Event) => void;
onChange?: (e: Event) => void;
onKeyDown?: (e: KeyboardEvent) => void;
onKeyUp?: (e: KeyboardEvent) => void;
}
export const Input = fc<InputProps>(
({
type = "text",
size = "md",
value = "",
placeholder = "",
label = "",
error = "",
disabled = false,
required = false,
name = "",
autocomplete = "",
min,
max,
step,
inputRef,
onInput,
onChange,
onKeyDown,
onKeyUp,
className = "",
}) => {
const sizeClasses = {
sm: "h-8 px-3 py-1 text-sm",
md: "h-9 px-3 py-1 text-sm md:text-sm",
lg: "h-10 px-4 py-1 text-base",
};
const baseClasses =
"flex w-full min-w-0 rounded-md border bg-transparent text-foreground shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium";
const interactionClasses =
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground";
const focusClasses = "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]";
const darkClasses = "dark:bg-input/30";
const stateClasses = error
? "border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40"
: "border-input";
const disabledClasses = "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50";
const handleInput = (e: Event) => {
onInput?.(e);
};
const handleChange = (e: Event) => {
onChange?.(e);
};
return html`
<div class="flex flex-col gap-1.5 ${className}">
${
label
? html`
<label class="text-sm font-medium text-foreground">
${label} ${required ? html`<span class="text-destructive">${i18n("*")}</span>` : ""}
</label>
`
: ""
}
<input
type="${type}"
class="${baseClasses} ${
sizeClasses[size]
} ${interactionClasses} ${focusClasses} ${darkClasses} ${stateClasses} ${disabledClasses}"
.value=${value}
placeholder="${placeholder}"
?disabled=${disabled}
?required=${required}
?aria-invalid=${!!error}
name="${name}"
autocomplete="${autocomplete}"
min="${min ?? ""}"
max="${max ?? ""}"
step="${step ?? ""}"
@input=${handleInput}
@change=${handleChange}
@keydown=${onKeyDown}
@keyup=${onKeyUp}
${inputRef ? ref(inputRef) : ""}
/>
${error ? html`<span class="text-sm text-destructive">${error}</span>` : ""}
</div>
`;
},
);
@@ -0,0 +1,405 @@
import { icon } from "@mariozechner/mini-lit";
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import { Select, type SelectOption } from "@mariozechner/mini-lit/dist/Select.js";
import type { Model } from "@mariozechner/pi-ai";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import { Brain, Loader2, Paperclip, Send, Sparkles, Square } from "lucide";
import { type Attachment, loadAttachment } from "../utils/attachment-utils.js";
import { i18n } from "../utils/i18n.js";
import "./AttachmentTile.js";
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
@customElement("message-editor")
export class MessageEditor extends LitElement {
private _value = "";
private textareaRef = createRef<HTMLTextAreaElement>();
@property()
get value() {
return this._value;
}
set value(val: string) {
const oldValue = this._value;
this._value = val;
this.requestUpdate("value", oldValue);
}
@property() isStreaming = false;
@property() currentModel?: Model<any>;
@property() thinkingLevel: ThinkingLevel = "off";
@property() showAttachmentButton = true;
@property() showModelSelector = true;
@property() showThinkingSelector = true;
@property() onInput?: (value: string) => void;
@property() onSend?: (input: string, attachments: Attachment[]) => void;
@property() onAbort?: () => void;
@property() onModelSelect?: () => void;
@property() onThinkingChange?: (level: "off" | "minimal" | "low" | "medium" | "high") => void;
@property() onFilesChange?: (files: Attachment[]) => void;
@property() attachments: Attachment[] = [];
@property() maxFiles = 10;
@property() maxFileSize = 20 * 1024 * 1024; // 20MB
@property() acceptedTypes =
"image/*,application/pdf,.docx,.pptx,.xlsx,.xls,.txt,.md,.json,.xml,.html,.css,.js,.ts,.jsx,.tsx,.yml,.yaml";
@state() processingFiles = false;
@state() isDragging = false;
private fileInputRef = createRef<HTMLInputElement>();
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
private handleTextareaInput = (e: Event) => {
const textarea = e.target as HTMLTextAreaElement;
this.value = textarea.value;
this.onInput?.(this.value);
};
private handleKeyDown = (e: KeyboardEvent) => {
// Ignore key events during IME composition (e.g. CJK input)
if (e.isComposing || e.key === "Process") return;
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!this.isStreaming && !this.processingFiles && (this.value.trim() || this.attachments.length > 0)) {
this.handleSend();
}
} else if (e.key === "Escape" && this.isStreaming) {
e.preventDefault();
this.onAbort?.();
}
};
private handlePaste = async (e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
const imageFiles: File[] = [];
// Check for image items in clipboard
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (file) {
imageFiles.push(file);
}
}
}
// If we found images, process them
if (imageFiles.length > 0) {
e.preventDefault(); // Prevent default paste behavior
if (imageFiles.length + this.attachments.length > this.maxFiles) {
alert(`Maximum ${this.maxFiles} files allowed`);
return;
}
this.processingFiles = true;
const newAttachments: Attachment[] = [];
for (const file of imageFiles) {
try {
if (file.size > this.maxFileSize) {
alert(`Image exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`);
continue;
}
const attachment = await loadAttachment(file);
newAttachments.push(attachment);
} catch (error) {
console.error("Error processing pasted image:", error);
alert(`Failed to process pasted image: ${String(error)}`);
}
}
this.attachments = [...this.attachments, ...newAttachments];
this.onFilesChange?.(this.attachments);
this.processingFiles = false;
}
};
private handleSend = () => {
this.onSend?.(this.value, this.attachments);
};
private handleAttachmentClick = () => {
this.fileInputRef.value?.click();
};
private async handleFilesSelected(e: Event) {
const input = e.target as HTMLInputElement;
const files = Array.from(input.files || []);
if (files.length === 0) return;
if (files.length + this.attachments.length > this.maxFiles) {
alert(`Maximum ${this.maxFiles} files allowed`);
input.value = "";
return;
}
this.processingFiles = true;
const newAttachments: Attachment[] = [];
for (const file of files) {
try {
if (file.size > this.maxFileSize) {
alert(`${file.name} exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`);
continue;
}
const attachment = await loadAttachment(file);
newAttachments.push(attachment);
} catch (error) {
console.error(`Error processing ${file.name}:`, error);
alert(`Failed to process ${file.name}: ${String(error)}`);
}
}
this.attachments = [...this.attachments, ...newAttachments];
this.onFilesChange?.(this.attachments);
this.processingFiles = false;
input.value = ""; // Reset input
}
private removeFile(fileId: string) {
this.attachments = this.attachments.filter((f) => f.id !== fileId);
this.onFilesChange?.(this.attachments);
}
private handleDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!this.isDragging) {
this.isDragging = true;
}
};
private handleDragLeave = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only set isDragging to false if we're leaving the entire component
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) {
this.isDragging = false;
}
};
private handleDrop = async (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
this.isDragging = false;
const files = Array.from(e.dataTransfer?.files || []);
if (files.length === 0) return;
if (files.length + this.attachments.length > this.maxFiles) {
alert(`Maximum ${this.maxFiles} files allowed`);
return;
}
this.processingFiles = true;
const newAttachments: Attachment[] = [];
for (const file of files) {
try {
if (file.size > this.maxFileSize) {
alert(`${file.name} exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`);
continue;
}
const attachment = await loadAttachment(file);
newAttachments.push(attachment);
} catch (error) {
console.error(`Error processing ${file.name}:`, error);
alert(`Failed to process ${file.name}: ${String(error)}`);
}
}
this.attachments = [...this.attachments, ...newAttachments];
this.onFilesChange?.(this.attachments);
this.processingFiles = false;
};
override firstUpdated() {
const textarea = this.textareaRef.value;
if (textarea) {
textarea.focus();
}
}
override render() {
// Check if current model supports thinking/reasoning
const model = this.currentModel;
const supportsThinking = model?.reasoning === true; // Models with reasoning:true support thinking
return html`
<div
class="bg-card rounded-xl border shadow-sm relative ${this.isDragging ? "border-primary border-2 bg-primary/5" : "border-border"}"
@dragover=${this.handleDragOver}
@dragleave=${this.handleDragLeave}
@drop=${this.handleDrop}
>
<!-- Drag overlay -->
${
this.isDragging
? html`
<div class="absolute inset-0 bg-primary/10 rounded-xl pointer-events-none z-10 flex items-center justify-center">
<div class="text-primary font-medium">${i18n("Drop files here")}</div>
</div>
`
: ""
}
<!-- Attachments -->
${
this.attachments.length > 0
? html`
<div class="px-4 pt-3 pb-2 flex flex-wrap gap-2">
${this.attachments.map(
(attachment) => html`
<attachment-tile
.attachment=${attachment}
.showDelete=${true}
.onDelete=${() => this.removeFile(attachment.id)}
></attachment-tile>
`,
)}
</div>
`
: ""
}
<textarea
class="w-full bg-transparent p-4 text-foreground placeholder-muted-foreground outline-none resize-none overflow-y-auto"
placeholder=${i18n("Type a message...")}
rows="1"
style="max-height: 200px; field-sizing: content; min-height: 1lh; height: auto;"
.value=${this.value}
@input=${this.handleTextareaInput}
@keydown=${this.handleKeyDown}
@paste=${this.handlePaste}
${ref(this.textareaRef)}
></textarea>
<!-- Hidden file input -->
<input
type="file"
${ref(this.fileInputRef)}
@change=${this.handleFilesSelected}
accept=${this.acceptedTypes}
multiple
style="display: none;"
/>
<!-- Button Row -->
<div class="px-2 pb-2 flex items-center justify-between">
<!-- Left side - attachment and thinking selector -->
<div class="flex gap-2 items-center">
${
this.showAttachmentButton
? this.processingFiles
? html`
<div class="h-8 w-8 flex items-center justify-center">
${icon(Loader2, "sm", "animate-spin text-muted-foreground")}
</div>
`
: html`
${Button({
variant: "ghost",
size: "icon",
className: "h-8 w-8",
onClick: this.handleAttachmentClick,
children: icon(Paperclip, "sm"),
})}
`
: ""
}
${
supportsThinking && this.showThinkingSelector
? html`
${Select({
value: this.thinkingLevel,
placeholder: i18n("Off"),
options: [
{ value: "off", label: i18n("Off"), icon: icon(Brain, "sm") },
{ value: "minimal", label: i18n("Minimal"), icon: icon(Brain, "sm") },
{ value: "low", label: i18n("Low"), icon: icon(Brain, "sm") },
{ value: "medium", label: i18n("Medium"), icon: icon(Brain, "sm") },
{ value: "high", label: i18n("High"), icon: icon(Brain, "sm") },
] as SelectOption[],
onChange: (value: string) => {
const level = value as "off" | "minimal" | "low" | "medium" | "high";
this.thinkingLevel = level;
this.onThinkingChange?.(level);
},
width: "80px",
size: "sm",
variant: "ghost",
fitContent: true,
})}
`
: ""
}
</div>
<!-- Model selector and send on the right -->
<div class="flex gap-2 items-center">
${
this.showModelSelector && this.currentModel
? html`
${Button({
variant: "ghost",
size: "sm",
onClick: () => {
// Focus textarea before opening model selector so focus returns there
this.textareaRef.value?.focus();
// Wait for next frame to ensure focus takes effect before dialog captures it
requestAnimationFrame(() => {
this.onModelSelect?.();
});
},
children: html`
${icon(Sparkles, "sm")}
<span class="ml-1">${this.currentModel.id}</span>
`,
className: "h-8 text-xs truncate",
})}
`
: ""
}
${
this.isStreaming
? html`
${Button({
variant: "ghost",
size: "icon",
onClick: this.onAbort,
children: icon(Square, "sm"),
className: "h-8 w-8",
})}
`
: html`
${Button({
variant: "ghost",
size: "icon",
onClick: this.handleSend,
disabled: (!this.value.trim() && this.attachments.length === 0) || this.processingFiles,
children: html`<div style="transform: rotate(-45deg)">${icon(Send, "sm")}</div>`,
className: "h-8 w-8",
})}
`
}
</div>
</div>
</div>
`;
}
}
@@ -0,0 +1,98 @@
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
import type {
AssistantMessage as AssistantMessageType,
ToolResultMessage as ToolResultMessageType,
} from "@mariozechner/pi-ai";
import { html, LitElement, type TemplateResult } from "lit";
import { property } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
import { renderMessage } from "./message-renderer-registry.js";
export class MessageList extends LitElement {
@property({ type: Array }) messages: AgentMessage[] = [];
@property({ type: Array }) tools: AgentTool[] = [];
@property({ type: Object }) pendingToolCalls?: ReadonlySet<string>;
@property({ type: Boolean }) isStreaming: boolean = false;
@property({ attribute: false }) onCostClick?: () => void;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
private buildRenderItems() {
// Map tool results by call id for quick lookup
const resultByCallId = new Map<string, ToolResultMessageType>();
for (const message of this.messages) {
if (message.role === "toolResult") {
resultByCallId.set(message.toolCallId, message);
}
}
const items: Array<{ key: string; template: TemplateResult }> = [];
let index = 0;
for (const msg of this.messages) {
// Skip artifact messages - they're for session persistence only, not UI display
if (msg.role === "artifact") {
continue;
}
// Try custom renderer first
const customTemplate = renderMessage(msg);
if (customTemplate) {
items.push({ key: `msg:${index}`, template: customTemplate });
index++;
continue;
}
// Fall back to built-in renderers
if (msg.role === "user" || msg.role === "user-with-attachments") {
items.push({
key: `msg:${index}`,
template: html`<user-message .message=${msg}></user-message>`,
});
index++;
} else if (msg.role === "assistant") {
const amsg = msg as AssistantMessageType;
items.push({
key: `msg:${index}`,
template: html`<assistant-message
.message=${amsg}
.tools=${this.tools}
.isStreaming=${false}
.pendingToolCalls=${this.pendingToolCalls}
.toolResultsById=${resultByCallId}
.hideToolCalls=${false}
.hidePendingToolCalls=${this.isStreaming}
.onCostClick=${this.onCostClick}
></assistant-message>`,
});
index++;
} else {
// Skip standalone toolResult messages; they are rendered via paired tool-message above
// Skip unknown roles
}
}
return items;
}
override render() {
const items = this.buildRenderItems();
return html`<div class="flex flex-col gap-3">
${repeat(
items,
(it) => it.key,
(it) => it.template,
)}
</div>`;
}
}
// Register custom element
if (!customElements.get("message-list")) {
customElements.define("message-list", MessageList);
}
@@ -0,0 +1,383 @@
import type {
AssistantMessage as AssistantMessageType,
ImageContent,
TextContent,
ToolCall,
ToolResultMessage as ToolResultMessageType,
UserMessage as UserMessageType,
} from "@mariozechner/pi-ai";
import { html, LitElement, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { renderTool } from "../tools/index.js";
import type { Attachment } from "../utils/attachment-utils.js";
import { formatUsage } from "../utils/format.js";
import { i18n } from "../utils/i18n.js";
import "./ThinkingBlock.js";
import type { AgentTool } from "@mariozechner/pi-agent-core";
export type UserMessageWithAttachments = {
role: "user-with-attachments";
content: string | (TextContent | ImageContent)[];
timestamp: number;
attachments?: Attachment[];
};
// Artifact message type for session persistence
export interface ArtifactMessage {
role: "artifact";
action: "create" | "update" | "delete";
filename: string;
content?: string;
title?: string;
timestamp: string;
}
declare module "@mariozechner/pi-agent-core" {
interface CustomAgentMessages {
"user-with-attachments": UserMessageWithAttachments;
artifact: ArtifactMessage;
}
}
@customElement("user-message")
export class UserMessage extends LitElement {
@property({ type: Object }) message!: UserMessageWithAttachments | UserMessageType;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
override render() {
const content =
typeof this.message.content === "string"
? this.message.content
: this.message.content.find((c) => c.type === "text")?.text || "";
return html`
<div class="flex justify-start mx-4">
<div class="user-message-container py-2 px-4 rounded-xl">
<markdown-block .content=${content}></markdown-block>
${
this.message.role === "user-with-attachments" &&
this.message.attachments &&
this.message.attachments.length > 0
? html`
<div class="mt-3 flex flex-wrap gap-2">
${this.message.attachments.map(
(attachment) => html` <attachment-tile .attachment=${attachment}></attachment-tile> `,
)}
</div>
`
: ""
}
</div>
</div>
`;
}
}
@customElement("assistant-message")
export class AssistantMessage extends LitElement {
@property({ type: Object }) message!: AssistantMessageType;
@property({ type: Array }) tools?: AgentTool<any>[];
@property({ type: Object }) pendingToolCalls?: ReadonlySet<string>;
@property({ type: Boolean }) hideToolCalls = false;
@property({ type: Object }) toolResultsById?: Map<string, ToolResultMessageType>;
@property({ type: Boolean }) isStreaming: boolean = false;
@property({ type: Boolean }) hidePendingToolCalls = false;
@property({ attribute: false }) onCostClick?: () => void;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
override render() {
// Render content in the order it appears
const orderedParts: TemplateResult[] = [];
for (const chunk of this.message.content) {
if (chunk.type === "text" && chunk.text.trim() !== "") {
orderedParts.push(html`<markdown-block .content=${chunk.text}></markdown-block>`);
} else if (chunk.type === "thinking" && chunk.thinking.trim() !== "") {
orderedParts.push(
html`<thinking-block .content=${chunk.thinking} .isStreaming=${this.isStreaming}></thinking-block>`,
);
} else if (chunk.type === "toolCall") {
if (!this.hideToolCalls) {
const tool = this.tools?.find((t) => t.name === chunk.name);
const pending = this.pendingToolCalls?.has(chunk.id) ?? false;
const result = this.toolResultsById?.get(chunk.id);
// Skip rendering pending tool calls when hidePendingToolCalls is true
// (used to prevent duplication when StreamingMessageContainer is showing them)
if (this.hidePendingToolCalls && pending && !result) {
continue;
}
// A tool call is aborted if the message was aborted and there's no result for this tool call
const aborted = this.message.stopReason === "aborted" && !result;
orderedParts.push(
html`<tool-message
.tool=${tool}
.toolCall=${chunk}
.result=${result}
.pending=${pending}
.aborted=${aborted}
.isStreaming=${this.isStreaming}
></tool-message>`,
);
}
}
}
return html`
<div>
${orderedParts.length ? html` <div class="px-4 flex flex-col gap-3">${orderedParts}</div> ` : ""}
${
this.message.usage && !this.isStreaming
? this.onCostClick
? html` <div class="px-4 mt-2 text-xs text-muted-foreground cursor-pointer hover:text-foreground transition-colors" @click=${this.onCostClick}>${formatUsage(this.message.usage)}</div> `
: html` <div class="px-4 mt-2 text-xs text-muted-foreground">${formatUsage(this.message.usage)}</div> `
: ""
}
${
this.message.stopReason === "error" && this.message.errorMessage
? html`
<div class="mx-4 mt-3 p-3 bg-destructive/10 text-destructive rounded-lg text-sm overflow-hidden">
<strong>${i18n("Error:")}</strong> ${this.message.errorMessage}
</div>
`
: ""
}
${
this.message.stopReason === "aborted"
? html`<span class="text-sm text-destructive italic">${i18n("Request aborted")}</span>`
: ""
}
</div>
`;
}
}
@customElement("tool-message-debug")
export class ToolMessageDebugView extends LitElement {
@property({ type: Object }) callArgs: any;
@property({ type: Object }) result?: ToolResultMessageType;
@property({ type: Boolean }) hasResult: boolean = false;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this; // light DOM for shared styles
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
private pretty(value: unknown): { content: string; isJson: boolean } {
try {
if (typeof value === "string") {
const maybeJson = JSON.parse(value);
return { content: JSON.stringify(maybeJson, null, 2), isJson: true };
}
return { content: JSON.stringify(value, null, 2), isJson: true };
} catch {
return { content: typeof value === "string" ? value : String(value), isJson: false };
}
}
override render() {
const textOutput =
this.result?.content
?.filter((c) => c.type === "text")
.map((c: any) => c.text)
.join("\n") || "";
const output = this.pretty(textOutput);
const details = this.pretty(this.result?.details);
return html`
<div class="mt-3 flex flex-col gap-2">
<div>
<div class="text-xs font-medium mb-1 text-muted-foreground">${i18n("Call")}</div>
<code-block .code=${this.pretty(this.callArgs).content} language="json"></code-block>
</div>
<div>
<div class="text-xs font-medium mb-1 text-muted-foreground">${i18n("Result")}</div>
${
this.hasResult
? html`<code-block .code=${output.content} language="${output.isJson ? "json" : "text"}"></code-block>
<code-block .code=${details.content} language="${details.isJson ? "json" : "text"}"></code-block>`
: html`<div class="text-xs text-muted-foreground">${i18n("(no result)")}</div>`
}
</div>
</div>
`;
}
}
@customElement("tool-message")
export class ToolMessage extends LitElement {
@property({ type: Object }) toolCall!: ToolCall;
@property({ type: Object }) tool?: AgentTool<any>;
@property({ type: Object }) result?: ToolResultMessageType;
@property({ type: Boolean }) pending: boolean = false;
@property({ type: Boolean }) aborted: boolean = false;
@property({ type: Boolean }) isStreaming: boolean = false;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
override render() {
const toolName = this.tool?.name || this.toolCall.name;
// Render tool content (renderer handles errors and styling)
const result: ToolResultMessageType<any> | undefined = this.aborted
? {
role: "toolResult",
isError: true,
content: [],
toolCallId: this.toolCall.id,
toolName: this.toolCall.name,
timestamp: Date.now(),
}
: this.result;
const renderResult = renderTool(
toolName,
this.toolCall.arguments,
result,
!this.aborted && (this.isStreaming || this.pending),
);
// Handle custom rendering (no card wrapper)
if (renderResult.isCustom) {
return renderResult.content;
}
// Default: wrap in card
return html`
<div class="p-2.5 border border-border rounded-md bg-card text-card-foreground shadow-xs">
${renderResult.content}
</div>
`;
}
}
@customElement("aborted-message")
export class AbortedMessage extends LitElement {
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
protected override render(): unknown {
return html`<span class="text-sm text-destructive italic">${i18n("Request aborted")}</span>`;
}
}
// ============================================================================
// Default Message Transformer
// ============================================================================
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { Message } from "@mariozechner/pi-ai";
/**
* Convert attachments to content blocks for LLM.
* - Images become ImageContent blocks
* - Documents with extractedText become TextContent blocks with filename header
*/
export function convertAttachments(attachments: Attachment[]): (TextContent | ImageContent)[] {
const content: (TextContent | ImageContent)[] = [];
for (const attachment of attachments) {
if (attachment.type === "image") {
content.push({
type: "image",
data: attachment.content,
mimeType: attachment.mimeType,
} as ImageContent);
} else if (attachment.type === "document" && attachment.extractedText) {
content.push({
type: "text",
text: `\n\n[Document: ${attachment.fileName}]\n${attachment.extractedText}`,
} as TextContent);
}
}
return content;
}
/**
* Check if a message is a UserMessageWithAttachments.
*/
export function isUserMessageWithAttachments(msg: AgentMessage): msg is UserMessageWithAttachments {
return (msg as UserMessageWithAttachments).role === "user-with-attachments";
}
/**
* Check if a message is an ArtifactMessage.
*/
export function isArtifactMessage(msg: AgentMessage): msg is ArtifactMessage {
return (msg as ArtifactMessage).role === "artifact";
}
/**
* Default convertToLlm for web-ui apps.
*
* Handles:
* - UserMessageWithAttachments: converts to user message with content blocks
* - ArtifactMessage: filtered out (UI-only, for session reconstruction)
* - Standard LLM messages (user, assistant, toolResult): passed through
*/
export function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
return messages
.filter((m) => {
// Filter out artifact messages - they're for session reconstruction only
if (isArtifactMessage(m)) {
return false;
}
return true;
})
.map((m): Message | null => {
// Convert user-with-attachments to user message with content blocks
if (isUserMessageWithAttachments(m)) {
const textContent: (TextContent | ImageContent)[] =
typeof m.content === "string" ? [{ type: "text", text: m.content }] : [...m.content];
if (m.attachments) {
textContent.push(...convertAttachments(m.attachments));
}
return {
role: "user",
content: textContent,
timestamp: m.timestamp,
} as Message;
}
// Pass through standard LLM roles
if (m.role === "user" || m.role === "assistant" || m.role === "toolResult") {
return m as Message;
}
// Filter out unknown message types
return null;
})
.filter((m): m is Message => m !== null);
}
@@ -0,0 +1,153 @@
import { i18n } from "@mariozechner/mini-lit";
import { Badge } from "@mariozechner/mini-lit/dist/Badge.js";
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import { type Context, complete, getModel } from "@mariozechner/pi-ai";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { getAppStorage } from "../storage/app-storage.js";
import { applyProxyIfNeeded } from "../utils/proxy-utils.js";
import { Input } from "./Input.js";
// Test models for each provider
const TEST_MODELS: Record<string, string> = {
anthropic: "claude-haiku-4-5",
openai: "gpt-4o-mini",
google: "gemini-2.5-flash",
groq: "openai/gpt-oss-20b",
openrouter: "z-ai/glm-4.6",
"vercel-ai-gateway": "anthropic/claude-opus-4.5",
cerebras: "gpt-oss-120b",
xai: "grok-4-fast-non-reasoning",
zai: "glm-4.5-air",
};
@customElement("provider-key-input")
export class ProviderKeyInput extends LitElement {
@property() provider = "";
@state() private keyInput = "";
@state() private testing = false;
@state() private failed = false;
@state() private hasKey = false;
@state() private inputChanged = false;
protected createRenderRoot() {
return this;
}
override async connectedCallback() {
super.connectedCallback();
await this.checkKeyStatus();
}
private async checkKeyStatus() {
try {
const key = await getAppStorage().providerKeys.get(this.provider);
this.hasKey = !!key;
} catch (error) {
console.error("Failed to check key status:", error);
}
}
private async testApiKey(provider: string, apiKey: string): Promise<boolean> {
try {
const modelId = TEST_MODELS[provider];
// Returning true here for Ollama and friends. Can' know which model to use for testing
if (!modelId) return true;
let model = getModel(provider as any, modelId);
if (!model) return false;
// Get proxy URL from settings (if available)
const proxyEnabled = await getAppStorage().settings.get<boolean>("proxy.enabled");
const proxyUrl = await getAppStorage().settings.get<string>("proxy.url");
// Apply proxy only if this provider/key combination requires it
model = applyProxyIfNeeded(model, apiKey, proxyEnabled ? proxyUrl || undefined : undefined);
const context: Context = {
messages: [{ role: "user", content: "Reply with: ok", timestamp: Date.now() }],
};
const result = await complete(model, context, {
apiKey,
maxTokens: 200,
} as any);
return result.stopReason === "stop";
} catch (error) {
console.error(`API key test failed for ${provider}:`, error);
return false;
}
}
private async saveKey() {
if (!this.keyInput) return;
this.testing = true;
this.failed = false;
const success = await this.testApiKey(this.provider, this.keyInput);
this.testing = false;
if (success) {
try {
await getAppStorage().providerKeys.set(this.provider, this.keyInput);
this.hasKey = true;
this.inputChanged = false;
this.requestUpdate();
} catch (error) {
console.error("Failed to save API key:", error);
this.failed = true;
setTimeout(() => {
this.failed = false;
this.requestUpdate();
}, 5000);
}
} else {
this.failed = true;
setTimeout(() => {
this.failed = false;
this.requestUpdate();
}, 5000);
}
}
render() {
return html`
<div class="space-y-3">
<div class="flex items-center gap-2">
<span class="text-sm font-medium capitalize text-foreground">${this.provider}</span>
${
this.testing
? Badge({ children: i18n("Testing..."), variant: "secondary" })
: this.hasKey
? html`<span class="text-green-600 dark:text-green-400">✓</span>`
: ""
}
${this.failed ? Badge({ children: i18n("✗ Invalid"), variant: "destructive" }) : ""}
</div>
<div class="flex items-center gap-2">
${Input({
type: "password",
placeholder: this.hasKey ? "••••••••••••" : i18n("Enter API key"),
value: this.keyInput,
onInput: (e: Event) => {
this.keyInput = (e.target as HTMLInputElement).value;
this.inputChanged = true;
this.requestUpdate();
},
className: "flex-1",
})}
${Button({
onClick: () => this.saveKey(),
variant: "default",
size: "sm",
disabled: !this.keyInput || this.testing || (this.hasKey && !this.inputChanged),
children: i18n("Save"),
})}
</div>
</div>
`;
}
}
@@ -0,0 +1,626 @@
import { LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ConsoleRuntimeProvider } from "./sandbox/ConsoleRuntimeProvider.js";
import { RuntimeMessageBridge } from "./sandbox/RuntimeMessageBridge.js";
import { type MessageConsumer, RUNTIME_MESSAGE_ROUTER } from "./sandbox/RuntimeMessageRouter.js";
import type { SandboxRuntimeProvider } from "./sandbox/SandboxRuntimeProvider.js";
export interface SandboxFile {
fileName: string;
content: string | Uint8Array;
mimeType: string;
}
export interface SandboxResult {
success: boolean;
console: Array<{ type: string; text: string }>;
files?: SandboxFile[];
error?: { message: string; stack: string };
returnValue?: any;
}
/**
* Function that returns the URL to the sandbox HTML file.
* Used in browser extensions to load sandbox.html via chrome.runtime.getURL().
*/
export type SandboxUrlProvider = () => string;
/**
* Configuration for prepareHtmlDocument
*/
export interface PrepareHtmlOptions {
/** True if this is an HTML artifact (inject into existing HTML), false if REPL (wrap in HTML) */
isHtmlArtifact: boolean;
/** True if this is a standalone download (no runtime bridge, no navigation interceptor) */
isStandalone?: boolean;
}
/**
* Escape HTML special sequences in code to prevent premature tag closure
* @param code Code that will be injected into <script> tags
* @returns Escaped code safe for injection
*/
function escapeScriptContent(code: string): string {
return code.replace(/<\/script/gi, "<\\/script");
}
@customElement("sandbox-iframe")
export class SandboxIframe extends LitElement {
private iframe?: HTMLIFrameElement;
/**
* Optional: Provide a function that returns the sandbox HTML URL.
* If provided, the iframe will use this URL instead of srcdoc.
* This is required for browser extensions with strict CSP.
*/
@property({ attribute: false }) sandboxUrlProvider?: SandboxUrlProvider;
createRenderRoot() {
return this;
}
override connectedCallback() {
super.connectedCallback();
}
override disconnectedCallback() {
super.disconnectedCallback();
// Note: We don't unregister the sandbox here for loadContent() mode
// because the caller (HtmlArtifact) owns the sandbox lifecycle.
// For execute() mode, the sandbox is unregistered in the cleanup function.
this.iframe?.remove();
}
/**
* Load HTML content into sandbox and keep it displayed (for HTML artifacts)
* @param sandboxId Unique ID
* @param htmlContent Full HTML content
* @param providers Runtime providers to inject
* @param consumers Message consumers to register (optional)
*/
public loadContent(
sandboxId: string,
htmlContent: string,
providers: SandboxRuntimeProvider[] = [],
consumers: MessageConsumer[] = [],
): void {
// Unregister previous sandbox if exists
try {
RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
} catch {
// Sandbox might not exist, that's ok
}
providers = [new ConsoleRuntimeProvider(), ...providers];
RUNTIME_MESSAGE_ROUTER.registerSandbox(sandboxId, providers, consumers);
// loadContent is always used for HTML artifacts (not standalone)
const completeHtml = this.prepareHtmlDocument(sandboxId, htmlContent, providers, {
isHtmlArtifact: true,
isStandalone: false,
});
// Validate HTML before loading
const validationError = this.validateHtml(completeHtml);
if (validationError) {
console.error("HTML validation failed:", validationError);
// Show error in iframe instead of crashing
this.iframe?.remove();
this.iframe = document.createElement("iframe");
this.iframe.style.cssText = "width: 100%; height: 100%; border: none;";
this.iframe.srcdoc = `
<html>
<body style="font-family: monospace; padding: 20px; background: #fff; color: #000;">
<h3 style="color: #c00;">HTML Validation Error</h3>
<pre style="background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; white-space: pre-wrap;">${validationError}</pre>
</body>
</html>
`;
this.appendChild(this.iframe);
return;
}
// Remove previous iframe if exists
this.iframe?.remove();
if (this.sandboxUrlProvider) {
// Browser extension mode: use sandbox.html with postMessage
this.loadViaSandboxUrl(sandboxId, completeHtml);
} else {
// Web mode: use srcdoc
this.loadViaSrcdoc(sandboxId, completeHtml);
}
}
private loadViaSandboxUrl(sandboxId: string, completeHtml: string): void {
// Create iframe pointing to sandbox URL
this.iframe = document.createElement("iframe");
this.iframe.sandbox.add("allow-scripts");
this.iframe.sandbox.add("allow-modals");
this.iframe.style.width = "100%";
this.iframe.style.height = "100%";
this.iframe.style.border = "none";
this.iframe.src = this.sandboxUrlProvider!();
// Update router with iframe reference BEFORE appending to DOM
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
// Listen for open-external-url messages from iframe
const externalUrlHandler = (e: MessageEvent) => {
if (e.data.type === "open-external-url" && e.source === this.iframe?.contentWindow) {
// Use chrome.tabs API to open in new tab
const chromeAPI = (globalThis as any).chrome;
if (chromeAPI?.tabs) {
chromeAPI.tabs.create({ url: e.data.url });
} else {
// Fallback for non-extension context
window.open(e.data.url, "_blank");
}
}
};
window.addEventListener("message", externalUrlHandler);
// Listen for sandbox-ready and sandbox-error messages directly
const readyHandler = (e: MessageEvent) => {
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
window.removeEventListener("message", readyHandler);
window.removeEventListener("message", errorHandler);
// Send content to sandbox
this.iframe?.contentWindow?.postMessage(
{
type: "sandbox-load",
sandboxId,
code: completeHtml,
},
"*",
);
}
};
const errorHandler = (e: MessageEvent) => {
if (e.data.type === "sandbox-error" && e.source === this.iframe?.contentWindow) {
window.removeEventListener("message", readyHandler);
window.removeEventListener("message", errorHandler);
// The sandbox.js already sent us the error via postMessage.
// We need to convert it to an execution-error message that the execute() consumer will handle.
// Simulate receiving an execution-error from the sandbox
window.postMessage(
{
sandboxId: sandboxId,
type: "execution-error",
error: { message: e.data.error, stack: e.data.stack },
},
"*",
);
}
};
window.addEventListener("message", readyHandler);
window.addEventListener("message", errorHandler);
this.appendChild(this.iframe);
}
private loadViaSrcdoc(sandboxId: string, completeHtml: string): void {
// Create iframe with srcdoc
this.iframe = document.createElement("iframe");
this.iframe.sandbox.add("allow-scripts");
this.iframe.sandbox.add("allow-modals");
this.iframe.style.width = "100%";
this.iframe.style.height = "100%";
this.iframe.style.border = "none";
this.iframe.srcdoc = completeHtml;
// Update router with iframe reference BEFORE appending to DOM
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
// Listen for open-external-url messages from iframe
const externalUrlHandler = (e: MessageEvent) => {
if (e.data.type === "open-external-url" && e.source === this.iframe?.contentWindow) {
// Fallback for non-extension context
window.open(e.data.url, "_blank");
}
};
window.addEventListener("message", externalUrlHandler);
this.appendChild(this.iframe);
}
/**
* Execute code in sandbox
* @param sandboxId Unique ID for this execution
* @param code User code (plain JS for REPL, or full HTML for artifacts)
* @param providers Runtime providers to inject
* @param consumers Additional message consumers (optional, execute has its own internal consumer)
* @param signal Abort signal
* @returns Promise resolving to execution result
*/
public async execute(
sandboxId: string,
code: string,
providers: SandboxRuntimeProvider[] = [],
consumers: MessageConsumer[] = [],
signal?: AbortSignal,
isHtmlArtifact: boolean = false,
): Promise<SandboxResult> {
if (signal?.aborted) {
throw new Error("Execution aborted");
}
const consoleProvider = new ConsoleRuntimeProvider();
providers = [consoleProvider, ...providers];
RUNTIME_MESSAGE_ROUTER.registerSandbox(sandboxId, providers, consumers);
// Notify providers that execution is starting
for (const provider of providers) {
provider.onExecutionStart?.(sandboxId, signal);
}
const files: SandboxFile[] = [];
let completed = false;
return new Promise((resolve, reject) => {
// 4. Create execution consumer for lifecycle messages
const executionConsumer: MessageConsumer = {
async handleMessage(message: any): Promise<void> {
if (message.type === "file-returned") {
files.push({
fileName: message.fileName,
content: message.content,
mimeType: message.mimeType,
});
} else if (message.type === "execution-complete") {
completed = true;
cleanup();
resolve({
success: true,
console: consoleProvider.getLogs(),
files,
returnValue: message.returnValue,
});
} else if (message.type === "execution-error") {
completed = true;
cleanup();
resolve({ success: false, console: consoleProvider.getLogs(), error: message.error, files });
}
},
};
RUNTIME_MESSAGE_ROUTER.addConsumer(sandboxId, executionConsumer);
const cleanup = () => {
// Notify providers that execution has ended
for (const provider of providers) {
provider.onExecutionEnd?.(sandboxId);
}
RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
signal?.removeEventListener("abort", abortHandler);
clearTimeout(timeoutId);
this.iframe?.remove();
this.iframe = undefined;
};
// Abort handler
const abortHandler = () => {
if (!completed) {
completed = true;
cleanup();
reject(new Error("Execution aborted"));
}
};
if (signal) {
signal.addEventListener("abort", abortHandler);
}
// Timeout handler (30 seconds)
const timeoutId = setTimeout(() => {
if (!completed) {
completed = true;
cleanup();
resolve({
success: false,
console: consoleProvider.getLogs(),
error: { message: "Execution timeout (120s)", stack: "" },
files,
});
}
}, 120000);
// 4. Prepare HTML and create iframe
const completeHtml = this.prepareHtmlDocument(sandboxId, code, providers, {
isHtmlArtifact,
isStandalone: false,
});
// 5. Validate HTML before sending to sandbox
const validationError = this.validateHtml(completeHtml);
if (validationError) {
reject(new Error(`HTML validation failed: ${validationError}`));
return;
}
if (this.sandboxUrlProvider) {
// Browser extension mode: wait for sandbox-ready
this.iframe = document.createElement("iframe");
this.iframe.sandbox.add("allow-scripts", "allow-modals");
this.iframe.style.cssText = "width: 100%; height: 100%; border: none;";
this.iframe.src = this.sandboxUrlProvider();
// Update router with iframe reference BEFORE appending to DOM
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
// Listen for sandbox-ready and sandbox-error messages
const readyHandler = (e: MessageEvent) => {
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
window.removeEventListener("message", readyHandler);
window.removeEventListener("message", errorHandler);
// Send content to sandbox
this.iframe?.contentWindow?.postMessage(
{
type: "sandbox-load",
sandboxId,
code: completeHtml,
},
"*",
);
}
};
const errorHandler = (e: MessageEvent) => {
if (e.data.type === "sandbox-error" && e.source === this.iframe?.contentWindow) {
window.removeEventListener("message", readyHandler);
window.removeEventListener("message", errorHandler);
// Convert sandbox-error to execution-error for the execution consumer
window.postMessage(
{
sandboxId: sandboxId,
type: "execution-error",
error: { message: e.data.error, stack: e.data.stack },
},
"*",
);
}
};
window.addEventListener("message", readyHandler);
window.addEventListener("message", errorHandler);
this.appendChild(this.iframe);
} else {
// Web mode: use srcdoc
this.iframe = document.createElement("iframe");
this.iframe.sandbox.add("allow-scripts", "allow-modals");
this.iframe.style.cssText = "width: 100%; height: 100%; border: none; display: none;";
this.iframe.srcdoc = completeHtml;
// Update router with iframe reference BEFORE appending to DOM
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
this.appendChild(this.iframe);
}
});
}
/**
* Validate HTML using DOMParser - returns error message if invalid, null if valid
* Note: JavaScript syntax validation is done in sandbox.js to avoid CSP restrictions
*/
private validateHtml(html: string): string | null {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// Check for parser errors
const parserError = doc.querySelector("parsererror");
if (parserError) {
return parserError.textContent || "Unknown parse error";
}
return null;
} catch (error: any) {
return error.message || "Unknown validation error";
}
}
/**
* Prepare complete HTML document with runtime + user code
* PUBLIC so HtmlArtifact can use it for download button
*/
public prepareHtmlDocument(
sandboxId: string,
userCode: string,
providers: SandboxRuntimeProvider[] = [],
options?: PrepareHtmlOptions,
): string {
// Default options
const opts: PrepareHtmlOptions = {
isHtmlArtifact: false,
isStandalone: false,
...options,
};
// Runtime script that will be injected
const runtime = this.getRuntimeScript(sandboxId, providers, opts.isStandalone || false);
// Only check for HTML tags if explicitly marked as HTML artifact
// For javascript_repl, userCode is JavaScript that may contain HTML in string literals
if (opts.isHtmlArtifact) {
// HTML Artifact - inject runtime into existing HTML
const headMatch = userCode.match(/<head[^>]*>/i);
if (headMatch) {
const index = headMatch.index! + headMatch[0].length;
return userCode.slice(0, index) + runtime + userCode.slice(index);
}
const htmlMatch = userCode.match(/<html[^>]*>/i);
if (htmlMatch) {
const index = htmlMatch.index! + htmlMatch[0].length;
return userCode.slice(0, index) + runtime + userCode.slice(index);
}
// Fallback: prepend runtime
return runtime + userCode;
} else {
// REPL - wrap code in HTML with runtime and call complete() when done
// Escape </script> in user code to prevent premature tag closure
const escapedUserCode = escapeScriptContent(userCode);
return `<!DOCTYPE html>
<html>
<head>
${runtime}
</head>
<body>
<script type="module">
(async () => {
try {
// Wrap user code in async function to capture return value
const userCodeFunc = async () => {
${escapedUserCode}
};
const returnValue = await userCodeFunc();
// Call completion callbacks before complete()
if (window.__completionCallbacks && window.__completionCallbacks.length > 0) {
try {
await Promise.all(window.__completionCallbacks.map(cb => cb(true)));
} catch (e) {
console.error('Completion callback error:', e);
}
}
await window.complete(null, returnValue);
} catch (error) {
// Call completion callbacks before complete() (error path)
if (window.__completionCallbacks && window.__completionCallbacks.length > 0) {
try {
await Promise.all(window.__completionCallbacks.map(cb => cb(false)));
} catch (e) {
console.error('Completion callback error:', e);
}
}
await window.complete({
message: error?.message || String(error),
stack: error?.stack || new Error().stack
});
}
})();
</script>
</body>
</html>`;
}
}
/**
* Generate runtime script from providers
* @param sandboxId Unique sandbox ID
* @param providers Runtime providers
* @param isStandalone If true, skip runtime bridge and navigation interceptor (for standalone downloads)
*/
private getRuntimeScript(
sandboxId: string,
providers: SandboxRuntimeProvider[] = [],
isStandalone: boolean = false,
): string {
// Collect all data from providers
const allData: Record<string, any> = {};
for (const provider of providers) {
Object.assign(allData, provider.getData());
}
// Generate bridge code (skip if standalone)
const bridgeCode = isStandalone
? ""
: RuntimeMessageBridge.generateBridgeCode({
context: "sandbox-iframe",
sandboxId,
});
// Collect all runtime functions - pass sandboxId as string literal
const runtimeFunctions: string[] = [];
for (const provider of providers) {
runtimeFunctions.push(`(${provider.getRuntime().toString()})(${JSON.stringify(sandboxId)});`);
}
// Build script with HTML escaping
// Escape </script> to prevent premature tag closure in HTML parser
const dataInjection = Object.entries(allData)
.map(([key, value]) => {
const jsonStr = JSON.stringify(value).replace(/<\/script/gi, "<\\/script");
return `window.${key} = ${jsonStr};`;
})
.join("\n");
// TODO the font-size is needed, as chrome seems to inject a stylesheet into iframes
// found in an extension context like sidepanel, setting body { font-size: 75% }. It's
// definitely not our code doing that.
// See https://stackoverflow.com/questions/71480433/chrome-is-injecting-some-stylesheet-in-popup-ui-which-reduces-the-font-size-to-7
// Navigation interceptor (only if NOT standalone)
const navigationInterceptor = isStandalone
? ""
: `
// Navigation interceptor: prevent all navigation and open externally
(function() {
// Intercept link clicks
document.addEventListener('click', function(e) {
const link = e.target.closest('a');
if (link && link.href) {
// Check if it's an external link (not javascript: or #hash)
if (link.href.startsWith('http://') || link.href.startsWith('https://')) {
e.preventDefault();
e.stopPropagation();
window.parent.postMessage({ type: 'open-external-url', url: link.href }, '*');
}
}
}, true);
// Intercept form submissions
document.addEventListener('submit', function(e) {
const form = e.target;
if (form && form.action) {
e.preventDefault();
e.stopPropagation();
window.parent.postMessage({ type: 'open-external-url', url: form.action }, '*');
}
}, true);
// Prevent window.location changes (only if not already redefined)
try {
const originalLocation = window.location;
Object.defineProperty(window, 'location', {
get: function() { return originalLocation; },
set: function(url) {
window.parent.postMessage({ type: 'open-external-url', url: url.toString() }, '*');
}
});
} catch (e) {
// Already defined, skip
}
})();
`;
return `<style>
html, body {
font-size: initial;
}
</style>
<script>
window.sandboxId = ${JSON.stringify(sandboxId)};
${dataInjection}
${bridgeCode}
${runtimeFunctions.join("\n")}
${navigationInterceptor}
</script>`;
}
}
@@ -0,0 +1,103 @@
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { html, LitElement } from "lit";
import { property, state } from "lit/decorators.js";
export class StreamingMessageContainer extends LitElement {
@property({ type: Array }) tools: AgentTool[] = [];
@property({ type: Boolean }) isStreaming = false;
@property({ type: Object }) pendingToolCalls?: ReadonlySet<string>;
@property({ type: Object }) toolResultsById?: Map<string, ToolResultMessage>;
@property({ attribute: false }) onCostClick?: () => void;
@state() private _message: AgentMessage | null = null;
private _pendingMessage: AgentMessage | null = null;
private _updateScheduled = false;
private _immediateUpdate = false;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
// Public method to update the message with batching for performance
public setMessage(message: AgentMessage | null, immediate = false) {
// Store the latest message
this._pendingMessage = message;
// If this is an immediate update (like clearing), apply it right away
if (immediate || message === null) {
this._immediateUpdate = true;
this._message = message;
this.requestUpdate();
// Cancel any pending updates since we're clearing
this._pendingMessage = null;
this._updateScheduled = false;
return;
}
// Otherwise batch updates for performance during streaming
if (!this._updateScheduled) {
this._updateScheduled = true;
requestAnimationFrame(async () => {
// Only apply the update if we haven't been cleared
if (!this._immediateUpdate && this._pendingMessage !== null) {
// Deep clone the message to ensure Lit detects changes in nested properties
// (like toolCall.arguments being mutated during streaming)
this._message = JSON.parse(JSON.stringify(this._pendingMessage));
this.requestUpdate();
}
// Reset for next batch
this._pendingMessage = null;
this._updateScheduled = false;
this._immediateUpdate = false;
});
}
}
override render() {
// Show loading indicator if loading but no message yet
if (!this._message) {
if (this.isStreaming)
return html`<div class="flex flex-col gap-3 mb-3">
<span class="mx-4 inline-block w-2 h-4 bg-muted-foreground animate-pulse"></span>
</div>`;
return html``; // Empty until a message is set
}
const msg = this._message;
if (msg.role === "toolResult") {
// Skip standalone tool result in streaming; the stable list will render paired tool-message
return html``;
} else if (msg.role === "user" || msg.role === "user-with-attachments") {
// Skip standalone tool result in streaming; the stable list will render it immediiately
return html``;
} else if (msg.role === "assistant") {
// Assistant message - render inline tool messages during streaming
return html`
<div class="flex flex-col gap-3 mb-3">
<assistant-message
.message=${msg}
.tools=${this.tools}
.isStreaming=${this.isStreaming}
.pendingToolCalls=${this.pendingToolCalls}
.toolResultsById=${this.toolResultsById}
.hideToolCalls=${false}
.onCostClick=${this.onCostClick}
></assistant-message>
${this.isStreaming ? html`<span class="mx-4 inline-block w-2 h-4 bg-muted-foreground animate-pulse"></span>` : ""}
</div>
`;
}
}
}
// Register custom element
if (!customElements.get("streaming-message-container")) {
customElements.define("streaming-message-container", StreamingMessageContainer);
}
@@ -0,0 +1,43 @@
import { icon } from "@mariozechner/mini-lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ChevronRight } from "lucide";
@customElement("thinking-block")
export class ThinkingBlock extends LitElement {
@property() content!: string;
@property({ type: Boolean }) isStreaming = false;
@state() private isExpanded = false;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
private toggleExpanded() {
this.isExpanded = !this.isExpanded;
}
override render() {
const shimmerClasses = this.isStreaming
? "animate-shimmer bg-gradient-to-r from-muted-foreground via-foreground to-muted-foreground bg-[length:200%_100%] bg-clip-text text-transparent"
: "";
return html`
<div class="thinking-block">
<div
class="thinking-header cursor-pointer select-none flex items-center gap-2 py-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
@click=${this.toggleExpanded}
>
<span class="transition-transform inline-block ${this.isExpanded ? "rotate-90" : ""}">${icon(ChevronRight, "sm")}</span>
<span class="${shimmerClasses}">Thinking...</span>
</div>
${this.isExpanded ? html`<markdown-block .content=${this.content} .isThinking=${true}></markdown-block>` : ""}
</div>
`;
}
}
@@ -0,0 +1,28 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { TemplateResult } from "lit";
// Extract role type from AppMessage union
export type MessageRole = AgentMessage["role"];
// Generic message renderer typed to specific message type
export interface MessageRenderer<TMessage extends AgentMessage = AgentMessage> {
render(message: TMessage): TemplateResult;
}
// Registry of custom message renderers by role
const messageRenderers = new Map<MessageRole, MessageRenderer<any>>();
export function registerMessageRenderer<TRole extends MessageRole>(
role: TRole,
renderer: MessageRenderer<Extract<AgentMessage, { role: TRole }>>,
): void {
messageRenderers.set(role, renderer);
}
export function getMessageRenderer(role: MessageRole): MessageRenderer | undefined {
return messageRenderers.get(role);
}
export function renderMessage(message: AgentMessage): TemplateResult | undefined {
return messageRenderers.get(message.role)?.render(message);
}
@@ -0,0 +1,220 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import {
ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO,
ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW,
} from "../../prompts/prompts.js";
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
// Define minimal interface for ArtifactsPanel to avoid circular dependencies
interface ArtifactsPanelLike {
artifacts: Map<string, { content: string }>;
tool: {
execute(toolCallId: string, args: { command: string; filename: string; content?: string }): Promise<any>;
};
}
interface AgentLike {
state: { messages: AgentMessage[] };
}
/**
* Artifacts Runtime Provider
*
* Provides programmatic access to session artifacts from sandboxed code.
* Allows code to create, read, update, and delete artifacts dynamically.
* Supports both online (extension) and offline (downloaded HTML) modes.
*/
export class ArtifactsRuntimeProvider implements SandboxRuntimeProvider {
constructor(
private artifactsPanel: ArtifactsPanelLike,
private agent?: AgentLike,
private readWrite: boolean = true,
) {}
getData(): Record<string, any> {
// Inject artifact snapshot for offline mode
const snapshot: Record<string, string> = {};
this.artifactsPanel.artifacts.forEach((artifact, filename) => {
snapshot[filename] = artifact.content;
});
return { artifacts: snapshot };
}
getRuntime(): (sandboxId: string) => void {
// This function will be stringified, so no external references!
return (_sandboxId: string) => {
// Auto-parse/stringify for .json files
const isJsonFile = (filename: string) => filename.endsWith(".json");
(window as any).listArtifacts = async (): Promise<string[]> => {
// Online: ask extension
if ((window as any).sendRuntimeMessage) {
const response = await (window as any).sendRuntimeMessage({
type: "artifact-operation",
action: "list",
});
if (!response.success) throw new Error(response.error);
return response.result;
}
// Offline: return snapshot keys
else {
return Object.keys((window as any).artifacts || {});
}
};
(window as any).getArtifact = async (filename: string): Promise<any> => {
let content: string;
// Online: ask extension
if ((window as any).sendRuntimeMessage) {
const response = await (window as any).sendRuntimeMessage({
type: "artifact-operation",
action: "get",
filename,
});
if (!response.success) throw new Error(response.error);
content = response.result;
}
// Offline: read snapshot
else {
if (!(window as any).artifacts?.[filename]) {
throw new Error(`Artifact not found (offline mode): ${filename}`);
}
content = (window as any).artifacts[filename];
}
// Auto-parse .json files
if (isJsonFile(filename)) {
try {
return JSON.parse(content);
} catch (e) {
throw new Error(`Failed to parse JSON from ${filename}: ${e}`);
}
}
return content;
};
(window as any).createOrUpdateArtifact = async (
filename: string,
content: any,
mimeType?: string,
): Promise<void> => {
if (!(window as any).sendRuntimeMessage) {
throw new Error("Cannot create/update artifacts in offline mode (read-only)");
}
let finalContent = content;
// Auto-stringify .json files
if (isJsonFile(filename) && typeof content !== "string") {
finalContent = JSON.stringify(content, null, 2);
} else if (typeof content !== "string") {
finalContent = JSON.stringify(content, null, 2);
}
const response = await (window as any).sendRuntimeMessage({
type: "artifact-operation",
action: "createOrUpdate",
filename,
content: finalContent,
mimeType,
});
if (!response.success) throw new Error(response.error);
};
(window as any).deleteArtifact = async (filename: string): Promise<void> => {
if (!(window as any).sendRuntimeMessage) {
throw new Error("Cannot delete artifacts in offline mode (read-only)");
}
const response = await (window as any).sendRuntimeMessage({
type: "artifact-operation",
action: "delete",
filename,
});
if (!response.success) throw new Error(response.error);
};
};
}
async handleMessage(message: any, respond: (response: any) => void): Promise<void> {
if (message.type !== "artifact-operation") {
return;
}
const { action, filename, content } = message;
try {
switch (action) {
case "list": {
const filenames = Array.from(this.artifactsPanel.artifacts.keys());
respond({ success: true, result: filenames });
break;
}
case "get": {
const artifact = this.artifactsPanel.artifacts.get(filename);
if (!artifact) {
respond({ success: false, error: `Artifact not found: ${filename}` });
} else {
respond({ success: true, result: artifact.content });
}
break;
}
case "createOrUpdate": {
try {
const exists = this.artifactsPanel.artifacts.has(filename);
const command = exists ? "rewrite" : "create";
const action = exists ? "update" : "create";
await this.artifactsPanel.tool.execute("", {
command,
filename,
content,
});
this.agent?.state.messages.push({
role: "artifact",
action,
filename,
content,
...(action === "create" && { title: filename }),
timestamp: new Date().toISOString(),
});
respond({ success: true });
} catch (err: any) {
respond({ success: false, error: err.message });
}
break;
}
case "delete": {
try {
await this.artifactsPanel.tool.execute("", {
command: "delete",
filename,
});
this.agent?.state.messages.push({
role: "artifact",
action: "delete",
filename,
timestamp: new Date().toISOString(),
});
respond({ success: true });
} catch (err: any) {
respond({ success: false, error: err.message });
}
break;
}
default:
respond({ success: false, error: `Unknown artifact action: ${action}` });
}
} catch (error: any) {
respond({ success: false, error: error.message });
}
}
getDescription(): string {
return this.readWrite ? ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW : ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO;
}
}
@@ -0,0 +1,66 @@
import { ATTACHMENTS_RUNTIME_DESCRIPTION } from "../../prompts/prompts.js";
import type { Attachment } from "../../utils/attachment-utils.js";
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
/**
* Attachments Runtime Provider
*
* OPTIONAL provider that provides file access APIs to sandboxed code.
* Only needed when attachments are present.
* Attachments are read-only snapshot data - no messaging needed.
*/
export class AttachmentsRuntimeProvider implements SandboxRuntimeProvider {
constructor(private attachments: Attachment[]) {}
getData(): Record<string, any> {
const attachmentsData = this.attachments.map((a) => ({
id: a.id,
fileName: a.fileName,
mimeType: a.mimeType,
size: a.size,
content: a.content,
extractedText: a.extractedText,
}));
return { attachments: attachmentsData };
}
getRuntime(): (sandboxId: string) => void {
// This function will be stringified, so no external references!
// These functions read directly from window.attachments
// Works both online AND offline (no messaging needed!)
return (_sandboxId: string) => {
(window as any).listAttachments = () =>
((window as any).attachments || []).map((a: any) => ({
id: a.id,
fileName: a.fileName,
mimeType: a.mimeType,
size: a.size,
}));
(window as any).readTextAttachment = (attachmentId: string) => {
const a = ((window as any).attachments || []).find((x: any) => 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 as any).readBinaryAttachment = (attachmentId: string) => {
const a = ((window as any).attachments || []).find((x: any) => 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;
};
};
}
getDescription(): string {
return ATTACHMENTS_RUNTIME_DESCRIPTION;
}
}
@@ -0,0 +1,186 @@
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
export interface ConsoleLog {
type: "log" | "warn" | "error" | "info";
text: string;
args?: unknown[];
}
/**
* Console Runtime Provider
*
* REQUIRED provider that should always be included first.
* Provides console capture, error handling, and execution lifecycle management.
* Collects console output for retrieval by caller.
*/
export class ConsoleRuntimeProvider implements SandboxRuntimeProvider {
private logs: ConsoleLog[] = [];
private completionError: { message: string; stack: string } | null = null;
private completed = false;
getData(): Record<string, any> {
// No data needed
return {};
}
getDescription(): string {
return "";
}
getRuntime(): (sandboxId: string) => void {
return (_sandboxId: string) => {
// Store truly original console methods on first wrap only
// This prevents accumulation of wrapper functions across multiple executions
if (!(window as any).__originalConsole) {
(window as any).__originalConsole = {
log: console.log.bind(console),
error: console.error.bind(console),
warn: console.warn.bind(console),
info: console.info.bind(console),
};
}
// Always use the truly original console, not the current (possibly wrapped) one
const originalConsole = (window as any).__originalConsole;
// Track pending send promises to wait for them in onCompleted
const pendingSends: Promise<any>[] = [];
["log", "error", "warn", "info"].forEach((method) => {
(console as any)[method] = (...args: any[]) => {
const text = args
.map((arg) => {
try {
return typeof arg === "object" ? JSON.stringify(arg) : String(arg);
} catch {
return String(arg);
}
})
.join(" ");
// Always log locally too (using truly original console)
(originalConsole as any)[method].apply(console, args);
// Send immediately and track the promise (only in extension context)
if ((window as any).sendRuntimeMessage) {
const sendPromise = (window as any)
.sendRuntimeMessage({
type: "console",
method,
text,
args,
})
.catch(() => {});
pendingSends.push(sendPromise);
}
};
});
// Register completion callback to wait for all pending sends
if ((window as any).onCompleted) {
(window as any).onCompleted(async (_success: boolean) => {
// Wait for all pending console sends to complete
if (pendingSends.length > 0) {
await Promise.all(pendingSends);
}
});
}
// Track errors for HTML artifacts
let lastError: { message: string; stack: string } | null = null;
// Error handlers - track errors but don't log them
// (they'll be shown via execution-error message)
window.addEventListener("error", (e) => {
const text = `${e.error?.stack || e.message || String(e)} at line ${e.lineno || "?"}:${e.colno || "?"}`;
lastError = {
message: e.error?.message || e.message || String(e),
stack: e.error?.stack || text,
};
});
window.addEventListener("unhandledrejection", (e) => {
const text = `Unhandled promise rejection: ${e.reason?.message || e.reason || "Unknown error"}`;
lastError = {
message: e.reason?.message || String(e.reason) || "Unhandled promise rejection",
stack: e.reason?.stack || text,
};
});
// Expose complete() method for user code to call
let completionSent = false;
(window as any).complete = async (error?: { message: string; stack: string }, returnValue?: any) => {
if (completionSent) return;
completionSent = true;
const finalError = error || lastError;
if ((window as any).sendRuntimeMessage) {
if (finalError) {
await (window as any).sendRuntimeMessage({
type: "execution-error",
error: finalError,
});
} else {
await (window as any).sendRuntimeMessage({
type: "execution-complete",
returnValue,
});
}
}
};
};
}
async handleMessage(message: any, respond: (response: any) => void): Promise<void> {
if (message.type === "console") {
// Collect console output
this.logs.push({
type:
message.method === "error"
? "error"
: message.method === "warn"
? "warn"
: message.method === "info"
? "info"
: "log",
text: message.text,
args: message.args,
});
// Acknowledge receipt
respond({ success: true });
}
}
/**
* Get collected console logs
*/
getLogs(): ConsoleLog[] {
return this.logs;
}
/**
* Get completion status
*/
isCompleted(): boolean {
return this.completed;
}
/**
* Get completion error if any
*/
getCompletionError(): { message: string; stack: string } | null {
return this.completionError;
}
/**
* Reset state for reuse
*/
reset(): void {
this.logs = [];
this.completionError = null;
this.completed = false;
}
}
@@ -0,0 +1,110 @@
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
export interface DownloadableFile {
fileName: string;
content: string | Uint8Array;
mimeType: string;
}
/**
* File Download Runtime Provider
*
* Provides returnDownloadableFile() for creating user downloads.
* Files returned this way are NOT accessible to the LLM later (one-time download).
* Works both online (sends to extension) and offline (triggers browser download directly).
* Collects files for retrieval by caller.
*/
export class FileDownloadRuntimeProvider implements SandboxRuntimeProvider {
private files: DownloadableFile[] = [];
getData(): Record<string, any> {
// No data needed
return {};
}
getRuntime(): (sandboxId: string) => void {
return (_sandboxId: string) => {
(window as any).returnDownloadableFile = async (fileName: string, content: any, mimeType?: string) => {
let finalContent: any, finalMimeType: string;
if (content instanceof Blob) {
const arrayBuffer = await content.arrayBuffer();
finalContent = new Uint8Array(arrayBuffer);
finalMimeType = mimeType || content.type || "application/octet-stream";
if (!mimeType && !content.type) {
throw new Error(
"returnDownloadableFile: MIME type is required for Blob content. Please provide a mimeType parameter (e.g., 'image/png').",
);
}
} else if (content instanceof Uint8Array) {
finalContent = content;
if (!mimeType) {
throw new Error(
"returnDownloadableFile: MIME type is required for Uint8Array content. Please provide a mimeType parameter (e.g., 'image/png').",
);
}
finalMimeType = mimeType;
} else if (typeof content === "string") {
finalContent = content;
finalMimeType = mimeType || "text/plain";
} else {
finalContent = JSON.stringify(content, null, 2);
finalMimeType = mimeType || "application/json";
}
// Send to extension if in extension context (online mode)
if ((window as any).sendRuntimeMessage) {
const response = await (window as any).sendRuntimeMessage({
type: "file-returned",
fileName,
content: finalContent,
mimeType: finalMimeType,
});
if (response.error) throw new Error(response.error);
} else {
// Offline mode: trigger browser download directly
const blob = new Blob([finalContent instanceof Uint8Array ? finalContent : finalContent], {
type: finalMimeType,
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
};
};
}
async handleMessage(message: any, respond: (response: any) => void): Promise<void> {
if (message.type === "file-returned") {
// Collect file for caller
this.files.push({
fileName: message.fileName,
content: message.content,
mimeType: message.mimeType,
});
respond({ success: true });
}
}
/**
* Get collected files
*/
getFiles(): DownloadableFile[] {
return this.files;
}
/**
* Reset state for reuse
*/
reset(): void {
this.files = [];
}
getDescription(): string {
return "returnDownloadableFile(filename, content, mimeType?) - Create downloadable file for user (one-time download, not accessible later)";
}
}
@@ -0,0 +1,82 @@
/**
* Generates sendRuntimeMessage() function for injection into execution contexts.
* Provides unified messaging API that works in both sandbox iframe and user script contexts.
*/
export type MessageType = "request-response" | "fire-and-forget";
export interface RuntimeMessageBridgeOptions {
context: "sandbox-iframe" | "user-script";
sandboxId: string;
}
// biome-ignore lint/complexity/noStaticOnlyClass: fine
export class RuntimeMessageBridge {
/**
* Generate sendRuntimeMessage() function as injectable string.
* Returns the function source code to be injected into target context.
*/
static generateBridgeCode(options: RuntimeMessageBridgeOptions): string {
if (options.context === "sandbox-iframe") {
return RuntimeMessageBridge.generateSandboxBridge(options.sandboxId);
} else {
return RuntimeMessageBridge.generateUserScriptBridge(options.sandboxId);
}
}
private static generateSandboxBridge(sandboxId: string): string {
// Returns stringified function that uses window.parent.postMessage
return `
window.__completionCallbacks = [];
window.sendRuntimeMessage = async (message) => {
const messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9);
return new Promise((resolve, reject) => {
const handler = (e) => {
if (e.data.type === 'runtime-response' && e.data.messageId === messageId) {
window.removeEventListener('message', handler);
if (e.data.success) {
resolve(e.data);
} else {
reject(new Error(e.data.error || 'Operation failed'));
}
}
};
window.addEventListener('message', handler);
window.parent.postMessage({
...message,
sandboxId: ${JSON.stringify(sandboxId)},
messageId: messageId
}, '*');
// Timeout after 30s
setTimeout(() => {
window.removeEventListener('message', handler);
reject(new Error('Runtime message timeout'));
}, 30000);
});
};
window.onCompleted = (callback) => {
window.__completionCallbacks.push(callback);
};
`.trim();
}
private static generateUserScriptBridge(sandboxId: string): string {
// Returns stringified function that uses chrome.runtime.sendMessage
return `
window.__completionCallbacks = [];
window.sendRuntimeMessage = async (message) => {
return await chrome.runtime.sendMessage({
...message,
sandboxId: ${JSON.stringify(sandboxId)}
});
};
window.onCompleted = (callback) => {
window.__completionCallbacks.push(callback);
};
`.trim();
}
}
@@ -0,0 +1,216 @@
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
// Type declaration for chrome extension API (when available)
declare const chrome: any;
/**
* Message consumer interface - components that want to receive messages from sandboxes
*/
export interface MessageConsumer {
/**
* Handle a message from a sandbox.
* All consumers receive all messages - decide internally what to handle.
*/
handleMessage(message: any): Promise<void>;
}
/**
* Sandbox context - tracks active sandboxes and their consumers
*/
interface SandboxContext {
sandboxId: string;
iframe: HTMLIFrameElement | null; // null until setSandboxIframe() or null for user scripts
providers: SandboxRuntimeProvider[];
consumers: Set<MessageConsumer>;
}
/**
* Centralized message router for all runtime communication.
*
* This singleton replaces all individual window.addEventListener("message") calls
* with a single global listener that routes messages to the appropriate handlers.
* Also handles user script messages from chrome.runtime.onUserScriptMessage.
*
* Benefits:
* - Single global listener instead of multiple independent listeners
* - Automatic cleanup when sandboxes are destroyed
* - Support for bidirectional communication (providers) and broadcasting (consumers)
* - Works with both sandbox iframes and user scripts
* - Clear lifecycle management
*/
export class RuntimeMessageRouter {
private sandboxes = new Map<string, SandboxContext>();
private messageListener: ((e: MessageEvent) => void) | null = null;
private userScriptMessageListener:
| ((message: any, sender: any, sendResponse: (response: any) => void) => boolean)
| null = null;
/**
* Register a new sandbox with its runtime providers.
* Call this BEFORE creating the iframe (for sandbox contexts) or executing user script.
*/
registerSandbox(sandboxId: string, providers: SandboxRuntimeProvider[], consumers: MessageConsumer[]): void {
this.sandboxes.set(sandboxId, {
sandboxId,
iframe: null, // Will be set via setSandboxIframe() for sandbox contexts
providers,
consumers: new Set(consumers),
});
// Setup global listener if not already done
this.setupListener();
}
/**
* Update the iframe reference for a sandbox.
* Call this AFTER creating the iframe.
* This is needed so providers can send responses back to the sandbox.
*/
setSandboxIframe(sandboxId: string, iframe: HTMLIFrameElement): void {
const context = this.sandboxes.get(sandboxId);
if (context) {
context.iframe = iframe;
}
}
/**
* Unregister a sandbox and remove all its consumers.
* Call this when the sandbox is destroyed.
*/
unregisterSandbox(sandboxId: string): void {
this.sandboxes.delete(sandboxId);
// If no more sandboxes, remove global listeners
if (this.sandboxes.size === 0) {
// Remove iframe listener
if (this.messageListener) {
window.removeEventListener("message", this.messageListener);
this.messageListener = null;
}
// Remove user script listener
if (this.userScriptMessageListener && typeof chrome !== "undefined" && chrome.runtime?.onUserScriptMessage) {
chrome.runtime.onUserScriptMessage.removeListener(this.userScriptMessageListener);
this.userScriptMessageListener = null;
}
}
}
/**
* Add a message consumer for a sandbox.
* Consumers receive broadcast messages (console, execution-complete, etc.)
*/
addConsumer(sandboxId: string, consumer: MessageConsumer): void {
const context = this.sandboxes.get(sandboxId);
if (context) {
context.consumers.add(consumer);
}
}
/**
* Remove a message consumer from a sandbox.
*/
removeConsumer(sandboxId: string, consumer: MessageConsumer): void {
const context = this.sandboxes.get(sandboxId);
if (context) {
context.consumers.delete(consumer);
}
}
/**
* Setup the global message listeners (called automatically)
*/
private setupListener(): void {
// Setup sandbox iframe listener
if (!this.messageListener) {
this.messageListener = async (e: MessageEvent) => {
const { sandboxId, messageId } = e.data;
if (!sandboxId) return;
const context = this.sandboxes.get(sandboxId);
if (!context) {
return;
}
// Create respond() function for bidirectional communication
const respond = (response: any) => {
context.iframe?.contentWindow?.postMessage(
{
type: "runtime-response",
messageId,
sandboxId,
...response,
},
"*",
);
};
// 1. Try provider handlers first (for bidirectional comm)
for (const provider of context.providers) {
if (provider.handleMessage) {
await provider.handleMessage(e.data, respond);
// Don't stop - let consumers also handle the message
}
}
// 2. Broadcast to consumers (one-way messages or lifecycle events)
for (const consumer of context.consumers) {
await consumer.handleMessage(e.data);
// Don't stop - let all consumers see the message
}
};
window.addEventListener("message", this.messageListener);
}
// Setup user script message listener
if (!this.userScriptMessageListener) {
// Guard: check if we're in extension context
if (typeof chrome === "undefined" || !chrome.runtime?.onUserScriptMessage) {
return;
}
this.userScriptMessageListener = (message: any, _sender: any, sendResponse: (response: any) => void) => {
const { sandboxId } = message;
if (!sandboxId) return false;
const context = this.sandboxes.get(sandboxId);
if (!context) return false;
const respond = (response: any) => {
sendResponse({
...response,
sandboxId,
});
};
// Route to providers (async)
(async () => {
// 1. Try provider handlers first (for bidirectional comm)
for (const provider of context.providers) {
if (provider.handleMessage) {
await provider.handleMessage(message, respond);
// Don't stop - let consumers also handle the message
}
}
// 2. Broadcast to consumers (one-way messages or lifecycle events)
for (const consumer of context.consumers) {
await consumer.handleMessage(message);
// Don't stop - let all consumers see the message
}
})();
return true; // Indicates async response
};
chrome.runtime.onUserScriptMessage.addListener(this.userScriptMessageListener);
}
}
}
/**
* Global singleton instance.
* Import this from wherever you need to interact with the message router.
*/
export const RUNTIME_MESSAGE_ROUTER = new RuntimeMessageRouter();
@@ -0,0 +1,52 @@
/**
* Interface for providing runtime capabilities to sandboxed iframes.
* Each provider injects data and runtime functions into the sandbox context.
*/
export interface SandboxRuntimeProvider {
/**
* Returns data to inject into window scope.
* Keys become window properties (e.g., { attachments: [...] } -> window.attachments)
*/
getData(): Record<string, any>;
/**
* Returns a runtime function that will be stringified and executed in the sandbox.
* The function receives sandboxId and has access to data from getData() via window.
*
* IMPORTANT: This function will be converted to string via .toString() and injected
* into the sandbox, so it cannot reference external variables or imports.
*/
getRuntime(): (sandboxId: string) => void;
/**
* Optional message handler for bidirectional communication.
* All providers receive all messages - decide internally what to handle.
*
* @param message - The message from the sandbox
* @param respond - Function to send a response back to the sandbox
*/
handleMessage?(message: any, respond: (response: any) => void): Promise<void>;
/**
* Optional documentation describing what globals/functions this provider injects.
* This will be appended to tool descriptions dynamically so the LLM knows what's available.
*/
getDescription(): string;
/**
* Optional lifecycle callback invoked when sandbox execution starts.
* Providers can use this to track abort signals for cancellation of async operations.
*
* @param sandboxId - The unique identifier for this sandbox execution
* @param signal - Optional AbortSignal that will be triggered if execution is cancelled
*/
onExecutionStart?(sandboxId: string, signal?: AbortSignal): void;
/**
* Optional lifecycle callback invoked when sandbox execution ends (success, error, or abort).
* Providers can use this to clean up any resources associated with the sandbox.
*
* @param sandboxId - The unique identifier for this sandbox execution
*/
onExecutionEnd?(sandboxId: string): void;
}