update
This commit is contained in:
1 parent
1261c2431f
commit
dac59f56c2
680 files changed
+192574
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;
|
||||
}
|
||||
}
|
||||
+66
@@ -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;
|
||||
}
|
||||
}
|
||||
+110
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user