mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-19 23:01:32 +00:00
Restructuring and refactoring
This commit is contained in:
parent
3331701e7e
commit
79dd23b6da
31 changed files with 1088 additions and 1686 deletions
|
|
@ -1,8 +1,8 @@
|
|||
import { html } from "@mariozechner/mini-lit";
|
||||
import { calculateTool, getCurrentTimeTool, getModel } from "@mariozechner/pi-ai";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import "./AgentInterface.js";
|
||||
import "./components/AgentInterface.js";
|
||||
import { AgentSession } from "./state/agent-session.js";
|
||||
import { ArtifactsPanel } from "./tools/artifacts/index.js";
|
||||
import { browserJavaScriptTool, createJavaScriptReplTool } from "./tools/index.js";
|
||||
|
|
@ -103,13 +103,7 @@ export class ChatPanel extends LitElement {
|
|||
initialState: {
|
||||
systemPrompt: this.systemPrompt,
|
||||
model: getModel("anthropic", "claude-3-5-haiku-20241022"),
|
||||
tools: [
|
||||
calculateTool,
|
||||
getCurrentTimeTool,
|
||||
browserJavaScriptTool,
|
||||
javascriptReplTool,
|
||||
this.artifactsPanel.tool,
|
||||
],
|
||||
tools: [browserJavaScriptTool, javascriptReplTool, this.artifactsPanel.tool],
|
||||
thinkingLevel: "off",
|
||||
},
|
||||
authTokenProvider: async () => getAuthToken(),
|
||||
|
|
|
|||
|
|
@ -2,19 +2,19 @@ import { html } from "@mariozechner/mini-lit";
|
|||
import type { ToolResultMessage, Usage } from "@mariozechner/pi-ai";
|
||||
import { LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators.js";
|
||||
import { ApiKeysDialog } from "./dialogs/ApiKeysDialog.js";
|
||||
import { ModelSelector } from "./dialogs/ModelSelector.js";
|
||||
import { ApiKeysDialog } from "../dialogs/ApiKeysDialog.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 type { AgentSession, AgentSessionEvent } from "./state/agent-session.js";
|
||||
import { keyStore } from "./state/KeyStore.js";
|
||||
import type { AgentSession, AgentSessionEvent } from "../state/agent-session.js";
|
||||
import { keyStore } from "../state/KeyStore.js";
|
||||
import "./StreamingMessageContainer.js";
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
import { formatUsage } from "../utils/format.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
import type { StreamingMessageContainer } from "./StreamingMessageContainer.js";
|
||||
import type { Attachment } from "./utils/attachment-utils.js";
|
||||
import { formatUsage } from "./utils/format.js";
|
||||
import { i18n } from "./utils/i18n.js";
|
||||
|
||||
@customElement("agent-interface")
|
||||
export class AgentInterface extends LitElement {
|
||||
|
|
@ -2,9 +2,9 @@ import { html, icon } from "@mariozechner/mini-lit";
|
|||
import { LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { FileSpreadsheet, FileText, X } from "lucide";
|
||||
import { AttachmentOverlay } from "./AttachmentOverlay.js";
|
||||
import type { Attachment } from "./utils/attachment-utils.js";
|
||||
import { i18n } from "./utils/i18n.js";
|
||||
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 {
|
||||
|
|
@ -2,7 +2,7 @@ import { html, icon } from "@mariozechner/mini-lit";
|
|||
import { LitElement } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { Check, Copy } from "lucide";
|
||||
import { i18n } from "./utils/i18n.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
|
||||
export class ConsoleBlock extends LitElement {
|
||||
@property() content: string = "";
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { type BaseComponentProps, fc, html } from "@mariozechner/mini-lit";
|
||||
import { type Ref, ref } from "lit/directives/ref.js";
|
||||
import { i18n } from "./utils/i18n.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
|
||||
export type InputType = "text" | "email" | "password" | "number" | "url" | "tel" | "search";
|
||||
export type InputSize = "sm" | "md" | "lg";
|
||||
|
|
@ -5,8 +5,8 @@ import { customElement, property, state } from "lit/decorators.js";
|
|||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
import { Loader2, Paperclip, Send, Sparkles, Square } from "lucide";
|
||||
import "./AttachmentTile.js";
|
||||
import { type Attachment, loadAttachment } from "./utils/attachment-utils.js";
|
||||
import { i18n } from "./utils/i18n.js";
|
||||
import { type Attachment, loadAttachment } from "../utils/attachment-utils.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
|
||||
@customElement("message-editor")
|
||||
export class MessageEditor extends LitElement {
|
||||
|
|
@ -10,10 +10,10 @@ import type { AgentToolResult } from "@mariozechner/pi-ai/dist/agent/types.js";
|
|||
import { LitElement, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { Bug, Loader, Wrench } from "lucide";
|
||||
import { renderToolParams, renderToolResult } 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 { renderToolParams, renderToolResult } from "../tools/index.js";
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
import { formatUsage } from "../utils/format.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
|
||||
export type UserMessageWithAttachments = UserMessageType & { attachments?: Attachment[] };
|
||||
export type AppMessage = AssistantMessageType | UserMessageWithAttachments | ToolResultMessageType;
|
||||
|
|
@ -1,18 +1,26 @@
|
|||
import { LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
|
||||
// @ts-ignore - browser global exists in Firefox
|
||||
declare const browser: any;
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@customElement("sandbox-iframe")
|
||||
export class SandboxIframe extends LitElement {
|
||||
@property() content = "";
|
||||
@property() artifactId = "";
|
||||
@property({ attribute: false }) attachments: Attachment[] = [];
|
||||
|
||||
private iframe?: HTMLIFrameElement;
|
||||
private logs: Array<{ type: "log" | "error"; text: string }> = [];
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
|
|
@ -20,157 +28,220 @@ export class SandboxIframe extends LitElement {
|
|||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("message", this.handleMessage);
|
||||
this.createIframe();
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("message", this.handleMessage);
|
||||
this.iframe?.remove();
|
||||
}
|
||||
|
||||
private handleMessage = (e: MessageEvent) => {
|
||||
// Handle sandbox-ready message
|
||||
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
|
||||
// Sandbox is ready, inject our runtime and send content
|
||||
const enhancedContent = this.injectRuntimeScripts(this.content);
|
||||
this.iframe?.contentWindow?.postMessage(
|
||||
{
|
||||
type: "loadContent",
|
||||
content: enhancedContent,
|
||||
artifactId: this.artifactId,
|
||||
attachments: this.attachments,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
return;
|
||||
/**
|
||||
* 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 attachments Attachments available to the code
|
||||
* @param signal Abort signal
|
||||
* @returns Promise resolving to execution result
|
||||
*/
|
||||
public async execute(
|
||||
sandboxId: string,
|
||||
code: string,
|
||||
attachments: Attachment[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<SandboxResult> {
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Execution aborted");
|
||||
}
|
||||
|
||||
// Only handle messages for this artifact
|
||||
if (e.data.artifactId !== this.artifactId) return;
|
||||
// Prepare the complete HTML document with runtime + user code
|
||||
const completeHtml = this.prepareHtmlDocument(sandboxId, code, attachments);
|
||||
|
||||
// Handle console messages
|
||||
if (e.data.type === "console") {
|
||||
const log = {
|
||||
type: e.data.method === "error" ? ("error" as const) : ("log" as const),
|
||||
text: e.data.text,
|
||||
};
|
||||
this.logs.push(log);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("console", {
|
||||
detail: log,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
} else if (e.data.type === "execution-complete") {
|
||||
// Store final logs
|
||||
this.logs = e.data.logs || [];
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("execution-complete", {
|
||||
detail: { logs: this.logs },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
// Wait for sandbox to be ready and execute
|
||||
return new Promise((resolve, reject) => {
|
||||
const logs: Array<{ type: string; text: string }> = [];
|
||||
const files: SandboxFile[] = [];
|
||||
let completed = false;
|
||||
|
||||
// Force reflow when iframe content is ready
|
||||
if (this.iframe) {
|
||||
this.iframe.style.display = "none";
|
||||
this.iframe.offsetHeight; // Force reflow
|
||||
this.iframe.style.display = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
const messageHandler = (e: MessageEvent) => {
|
||||
// Ignore messages not for this sandbox
|
||||
if (e.data.sandboxId !== sandboxId) return;
|
||||
|
||||
private injectRuntimeScripts(htmlContent: string): string {
|
||||
// Define the runtime function that will be injected
|
||||
const runtimeFunction = (artifactId: string, attachments: any[]) => {
|
||||
// @ts-ignore - window extensions
|
||||
window.__artifactLogs = [];
|
||||
const originalConsole = {
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
info: console.info,
|
||||
if (e.data.type === "console") {
|
||||
logs.push({
|
||||
type: e.data.method === "error" ? "error" : "log",
|
||||
text: e.data.text,
|
||||
});
|
||||
} else if (e.data.type === "file-returned") {
|
||||
files.push({
|
||||
fileName: e.data.fileName,
|
||||
content: e.data.content,
|
||||
mimeType: e.data.mimeType,
|
||||
});
|
||||
} else if (e.data.type === "execution-complete") {
|
||||
completed = true;
|
||||
cleanup();
|
||||
resolve({
|
||||
success: true,
|
||||
console: logs,
|
||||
files: files,
|
||||
});
|
||||
} else if (e.data.type === "execution-error") {
|
||||
completed = true;
|
||||
cleanup();
|
||||
resolve({
|
||||
success: false,
|
||||
console: logs,
|
||||
error: e.data.error,
|
||||
files,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
["log", "error", "warn", "info"].forEach((method) => {
|
||||
// @ts-ignore
|
||||
console[method] = (...args: any[]) => {
|
||||
const text = args
|
||||
.map((arg: any) => {
|
||||
try {
|
||||
return typeof arg === "object" ? JSON.stringify(arg) : String(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
})
|
||||
.join(" ");
|
||||
// @ts-ignore
|
||||
window.__artifactLogs.push({ type: method === "error" ? "error" : "log", text });
|
||||
window.parent.postMessage(
|
||||
const abortHandler = () => {
|
||||
if (!completed) {
|
||||
cleanup();
|
||||
reject(new Error("Execution aborted"));
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener("message", messageHandler);
|
||||
signal?.removeEventListener("abort", abortHandler);
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
|
||||
// Set up listeners
|
||||
window.addEventListener("message", messageHandler);
|
||||
signal?.addEventListener("abort", abortHandler);
|
||||
|
||||
// Set up sandbox-ready listener BEFORE creating iframe to avoid race condition
|
||||
const readyHandler = (e: MessageEvent) => {
|
||||
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
|
||||
window.removeEventListener("message", readyHandler);
|
||||
// Send the complete HTML
|
||||
this.iframe?.contentWindow?.postMessage(
|
||||
{
|
||||
type: "console",
|
||||
method,
|
||||
text,
|
||||
artifactId,
|
||||
type: "sandbox-load",
|
||||
sandboxId,
|
||||
code: completeHtml,
|
||||
attachments,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
// @ts-ignore
|
||||
originalConsole[method].apply(console, args);
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", readyHandler);
|
||||
|
||||
window.addEventListener("error", (e: ErrorEvent) => {
|
||||
const text = e.message + " at line " + e.lineno + ":" + e.colno;
|
||||
// @ts-ignore
|
||||
window.__artifactLogs.push({ type: "error", text });
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "console",
|
||||
method: "error",
|
||||
text,
|
||||
artifactId,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
});
|
||||
// Timeout after 30 seconds
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!completed) {
|
||||
cleanup();
|
||||
window.removeEventListener("message", readyHandler);
|
||||
resolve({
|
||||
success: false,
|
||||
error: { message: "Execution timeout (30s)", stack: "" },
|
||||
console: logs,
|
||||
files,
|
||||
});
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
window.addEventListener("unhandledrejection", (e: PromiseRejectionEvent) => {
|
||||
const text = "Unhandled promise rejection: " + (e.reason?.message || e.reason || "Unknown error");
|
||||
// @ts-ignore
|
||||
window.__artifactLogs.push({ type: "error", text });
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "console",
|
||||
method: "error",
|
||||
text,
|
||||
artifactId,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
});
|
||||
// NOW create and append iframe AFTER all listeners are set up
|
||||
this.iframe?.remove();
|
||||
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";
|
||||
|
||||
// Attachment helpers
|
||||
// @ts-ignore
|
||||
window.attachments = attachments;
|
||||
// @ts-ignore
|
||||
window.listFiles = () => {
|
||||
// @ts-ignore
|
||||
return (window.attachments || []).map((a: any) => ({
|
||||
const isFirefox = typeof browser !== "undefined" && browser.runtime !== undefined;
|
||||
this.iframe.src = isFirefox ? browser.runtime.getURL("sandbox.html") : chrome.runtime.getURL("sandbox.html");
|
||||
|
||||
this.appendChild(this.iframe);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare complete HTML document with runtime + user code
|
||||
*/
|
||||
private prepareHtmlDocument(sandboxId: string, userCode: string, attachments: Attachment[]): string {
|
||||
// Runtime script that will be injected
|
||||
const runtime = this.getRuntimeScript(sandboxId, attachments);
|
||||
|
||||
// Check if user provided full HTML
|
||||
const hasHtmlTag = /<html[^>]*>/i.test(userCode);
|
||||
|
||||
if (hasHtmlTag) {
|
||||
// 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
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
${runtime}
|
||||
</head>
|
||||
<body>
|
||||
<script type="module">
|
||||
(async () => {
|
||||
try {
|
||||
${userCode}
|
||||
window.complete();
|
||||
} catch (error) {
|
||||
console.error(error?.stack || error?.message || String(error));
|
||||
window.complete({
|
||||
message: error?.message || String(error),
|
||||
stack: error?.stack || new Error().stack
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the runtime script that captures console, provides helpers, etc.
|
||||
*/
|
||||
private getRuntimeScript(sandboxId: string, attachments: Attachment[]): string {
|
||||
// Convert attachments to serializable format
|
||||
const attachmentsData = attachments.map((a) => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
content: a.content,
|
||||
extractedText: a.extractedText,
|
||||
}));
|
||||
|
||||
// Runtime function that will run in the sandbox (NO parameters - values injected before function)
|
||||
const runtimeFunc = () => {
|
||||
// Helper functions
|
||||
(window as any).listFiles = () =>
|
||||
(attachments || []).map((a: any) => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
}));
|
||||
};
|
||||
// @ts-ignore
|
||||
window.readTextFile = (attachmentId: string) => {
|
||||
// @ts-ignore
|
||||
const a = (window.attachments || []).find((x: any) => x.id === attachmentId);
|
||||
|
||||
(window as any).readTextFile = (attachmentId: string) => {
|
||||
const a = (attachments || []).find((x: any) => x.id === attachmentId);
|
||||
if (!a) throw new Error("Attachment not found: " + attachmentId);
|
||||
if (a.extractedText) return a.extractedText;
|
||||
try {
|
||||
|
|
@ -179,10 +250,9 @@ export class SandboxIframe extends LitElement {
|
|||
throw new Error("Failed to decode text content for: " + attachmentId);
|
||||
}
|
||||
};
|
||||
// @ts-ignore
|
||||
window.readBinaryFile = (attachmentId: string) => {
|
||||
// @ts-ignore
|
||||
const a = (window.attachments || []).find((x: any) => x.id === attachmentId);
|
||||
|
||||
(window as any).readBinaryFile = (attachmentId: string) => {
|
||||
const a = (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);
|
||||
|
|
@ -190,82 +260,171 @@ export class SandboxIframe extends LitElement {
|
|||
return bytes;
|
||||
};
|
||||
|
||||
// Send completion after 2 seconds
|
||||
const sendCompletion = () => {
|
||||
(window as any).returnFile = 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(
|
||||
"returnFile: 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(
|
||||
"returnFile: 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";
|
||||
}
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "execution-complete",
|
||||
// @ts-ignore
|
||||
logs: window.__artifactLogs || [],
|
||||
artifactId,
|
||||
type: "file-returned",
|
||||
sandboxId,
|
||||
fileName,
|
||||
content: finalContent,
|
||||
mimeType: finalMimeType,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
};
|
||||
|
||||
// Console capture
|
||||
const originalConsole = {
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
info: console.info,
|
||||
};
|
||||
|
||||
["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(" ");
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "console",
|
||||
sandboxId,
|
||||
method,
|
||||
text,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
|
||||
(originalConsole as any)[method].apply(console, args);
|
||||
};
|
||||
});
|
||||
|
||||
// Track errors for HTML artifacts
|
||||
let lastError: { message: string; stack: string } | null = null;
|
||||
|
||||
// Error handlers
|
||||
window.addEventListener("error", (e) => {
|
||||
const text =
|
||||
(e.error?.stack || e.message || String(e)) + " at line " + (e.lineno || "?") + ":" + (e.colno || "?");
|
||||
|
||||
// Store the error
|
||||
lastError = {
|
||||
message: e.error?.message || e.message || String(e),
|
||||
stack: e.error?.stack || text,
|
||||
};
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "console",
|
||||
sandboxId,
|
||||
method: "error",
|
||||
text,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (e) => {
|
||||
const text = "Unhandled promise rejection: " + (e.reason?.message || e.reason || "Unknown error");
|
||||
|
||||
// Store the error
|
||||
lastError = {
|
||||
message: e.reason?.message || String(e.reason) || "Unhandled promise rejection",
|
||||
stack: e.reason?.stack || text,
|
||||
};
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "console",
|
||||
sandboxId,
|
||||
method: "error",
|
||||
text,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
// Expose complete() method for user code to call
|
||||
let completionSent = false;
|
||||
(window as any).complete = (error?: { message: string; stack: string }) => {
|
||||
if (completionSent) return;
|
||||
completionSent = true;
|
||||
|
||||
// Use provided error or last caught error
|
||||
const finalError = error || lastError;
|
||||
|
||||
if (finalError) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "execution-error",
|
||||
sandboxId,
|
||||
error: finalError,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
} else {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "execution-complete",
|
||||
sandboxId,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Fallback timeout for HTML artifacts that don't call complete()
|
||||
if (document.readyState === "complete" || document.readyState === "interactive") {
|
||||
setTimeout(sendCompletion, 2000);
|
||||
setTimeout(() => (window as any).complete(), 2000);
|
||||
} else {
|
||||
window.addEventListener("load", () => {
|
||||
setTimeout(sendCompletion, 2000);
|
||||
setTimeout(() => (window as any).complete(), 2000);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Convert function to string and wrap in IIFE with parameters
|
||||
const runtimeScript = `
|
||||
<script>
|
||||
(${runtimeFunction.toString()})(${JSON.stringify(this.artifactId)}, ${JSON.stringify(this.attachments)});
|
||||
</script>
|
||||
`;
|
||||
|
||||
// Inject at start of <head> or start of document
|
||||
const headMatch = htmlContent.match(/<head[^>]*>/i);
|
||||
if (headMatch) {
|
||||
const index = headMatch.index! + headMatch[0].length;
|
||||
return htmlContent.slice(0, index) + runtimeScript + htmlContent.slice(index);
|
||||
}
|
||||
|
||||
const htmlMatch = htmlContent.match(/<html[^>]*>/i);
|
||||
if (htmlMatch) {
|
||||
const index = htmlMatch.index! + htmlMatch[0].length;
|
||||
return htmlContent.slice(0, index) + runtimeScript + htmlContent.slice(index);
|
||||
}
|
||||
|
||||
return runtimeScript + htmlContent;
|
||||
}
|
||||
|
||||
private createIframe() {
|
||||
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";
|
||||
|
||||
const isFirefox = typeof browser !== "undefined" && browser.runtime !== undefined;
|
||||
if (isFirefox) {
|
||||
this.iframe.src = browser.runtime.getURL("sandbox.html");
|
||||
} else {
|
||||
this.iframe.src = chrome.runtime.getURL("sandbox.html");
|
||||
}
|
||||
|
||||
this.appendChild(this.iframe);
|
||||
}
|
||||
|
||||
public updateContent(newContent: string) {
|
||||
this.content = newContent;
|
||||
// Clear logs for new content
|
||||
this.logs = [];
|
||||
// Recreate iframe for clean state
|
||||
if (this.iframe) {
|
||||
this.iframe.remove();
|
||||
this.iframe = undefined;
|
||||
}
|
||||
this.createIframe();
|
||||
}
|
||||
|
||||
public getLogs(): Array<{ type: "log" | "error"; text: string }> {
|
||||
return this.logs;
|
||||
// Prepend the const declarations, then the function
|
||||
return (
|
||||
`<script>\n` +
|
||||
`window.sandboxId = ${JSON.stringify(sandboxId)};\n` +
|
||||
`window.attachments = ${JSON.stringify(attachmentsData)};\n` +
|
||||
`(${runtimeFunc.toString()})();\n` +
|
||||
`</script>`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Alert, Badge, Button, DialogHeader, html, type TemplateResult } from "@
|
|||
import { type Context, complete, getModel, getProviders } from "@mariozechner/pi-ai";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { Input } from "../Input.js";
|
||||
import { Input } from "../components/Input.js";
|
||||
import { keyStore } from "../state/KeyStore.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
import { DialogBase } from "./DialogBase.js";
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import { state } from "lit/decorators.js";
|
|||
import { Download, X } from "lucide";
|
||||
import * as pdfjsLib from "pdfjs-dist";
|
||||
import * as XLSX from "xlsx";
|
||||
import { i18n } from "./utils/i18n.js";
|
||||
import "./ModeToggle.js";
|
||||
import type { Attachment } from "./utils/attachment-utils.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
import "../components/ModeToggle.js";
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
|
||||
type FileType = "image" | "pdf" | "docx" | "pptx" | "excel" | "text";
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ import { customElement, state } from "lit/decorators.js";
|
|||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
import { Brain, Image as ImageIcon } from "lucide";
|
||||
import { Ollama } from "ollama/dist/browser.mjs";
|
||||
import { Input } from "../Input.js";
|
||||
import { Input } from "../components/Input.js";
|
||||
import { formatModelCost } from "../utils/format.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
import { DialogBase } from "./DialogBase.js";
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sandboxed Content</title>
|
||||
<style>
|
||||
html { height: 100%; }
|
||||
body { min-height: 100%; margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script src="sandbox.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
// Global storage for attachments and helper functions
|
||||
window.attachments = [];
|
||||
|
||||
window.listFiles = () =>
|
||||
(window.attachments || []).map((a) => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
}));
|
||||
|
||||
window.readTextFile = (attachmentId) => {
|
||||
const a = (window.attachments || []).find((x) => x.id === attachmentId);
|
||||
if (!a) throw new Error("Attachment not found: " + attachmentId);
|
||||
if (a.extractedText) return a.extractedText;
|
||||
try {
|
||||
return atob(a.content);
|
||||
} catch {
|
||||
throw new Error("Failed to decode text content for: " + attachmentId);
|
||||
}
|
||||
};
|
||||
|
||||
window.readBinaryFile = (attachmentId) => {
|
||||
const a = (window.attachments || []).find((x) => x.id === attachmentId);
|
||||
if (!a) throw new Error("Attachment not found: " + attachmentId);
|
||||
const bin = atob(a.content);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
// Console capture - forward to parent
|
||||
window.__artifactLogs = [];
|
||||
const originalConsole = {
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
info: console.info,
|
||||
};
|
||||
|
||||
["log", "error", "warn", "info"].forEach((method) => {
|
||||
console[method] = (...args) => {
|
||||
const text = args
|
||||
.map((arg) => {
|
||||
try {
|
||||
return typeof arg === "object" ? JSON.stringify(arg) : String(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
window.__artifactLogs.push({ type: method === "error" ? "error" : "log", text });
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "console",
|
||||
method,
|
||||
text,
|
||||
artifactId: window.__currentArtifactId,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
|
||||
originalConsole[method].apply(console, args);
|
||||
};
|
||||
});
|
||||
|
||||
// Error handlers
|
||||
window.addEventListener("error", (e) => {
|
||||
const text = (e.error?.stack || e.message || String(e)) + " at line " + (e.lineno || "?") + ":" + (e.colno || "?");
|
||||
window.__artifactLogs.push({ type: "error", text });
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "console",
|
||||
method: "error",
|
||||
text,
|
||||
artifactId: window.__currentArtifactId,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
return false;
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (e) => {
|
||||
const text = "Unhandled promise rejection: " + (e.reason?.message || e.reason || "Unknown error");
|
||||
window.__artifactLogs.push({ type: "error", text });
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "console",
|
||||
method: "error",
|
||||
text,
|
||||
artifactId: window.__currentArtifactId,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
// Listen for content from parent
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.data.type === "loadContent") {
|
||||
// Store artifact ID and attachments BEFORE wiping the document
|
||||
window.__currentArtifactId = event.data.artifactId;
|
||||
window.attachments = event.data.attachments || [];
|
||||
|
||||
// Clear logs for new content
|
||||
window.__artifactLogs = [];
|
||||
|
||||
// Inject helper functions into the user's HTML
|
||||
const helperScript =
|
||||
"<" +
|
||||
"script>\n" +
|
||||
"// Artifact ID\n" +
|
||||
"window.__currentArtifactId = " +
|
||||
JSON.stringify(event.data.artifactId) +
|
||||
";\n\n" +
|
||||
"// Attachments\n" +
|
||||
"window.attachments = " +
|
||||
JSON.stringify(event.data.attachments || []) +
|
||||
";\n\n" +
|
||||
"// Logs\n" +
|
||||
"window.__artifactLogs = [];\n\n" +
|
||||
"// Helper functions\n" +
|
||||
"window.listFiles = " +
|
||||
window.listFiles.toString() +
|
||||
";\n" +
|
||||
"window.readTextFile = " +
|
||||
window.readTextFile.toString() +
|
||||
";\n" +
|
||||
"window.readBinaryFile = " +
|
||||
window.readBinaryFile.toString() +
|
||||
";\n\n" +
|
||||
"// Console capture\n" +
|
||||
"const originalConsole = {\n" +
|
||||
" log: console.log,\n" +
|
||||
" error: console.error,\n" +
|
||||
" warn: console.warn,\n" +
|
||||
" info: console.info\n" +
|
||||
"};\n\n" +
|
||||
"['log', 'error', 'warn', 'info'].forEach(method => {\n" +
|
||||
" console[method] = function(...args) {\n" +
|
||||
" const text = args.map(arg => {\n" +
|
||||
" try { return typeof arg === 'object' ? JSON.stringify(arg) : String(arg); }\n" +
|
||||
" catch { return String(arg); }\n" +
|
||||
" }).join(' ');\n\n" +
|
||||
" window.__artifactLogs.push({ type: method === 'error' ? 'error' : 'log', text });\n\n" +
|
||||
" window.parent.postMessage({\n" +
|
||||
" type: 'console',\n" +
|
||||
" method,\n" +
|
||||
" text,\n" +
|
||||
" artifactId: window.__currentArtifactId\n" +
|
||||
" }, '*');\n\n" +
|
||||
" originalConsole[method].apply(console, args);\n" +
|
||||
" };\n" +
|
||||
"});\n\n" +
|
||||
"// Error handlers\n" +
|
||||
"window.addEventListener('error', (e) => {\n" +
|
||||
" const text = (e.error?.stack || e.message || String(e)) + ' at line ' + (e.lineno || '?') + ':' + (e.colno || '?');\n" +
|
||||
" window.__artifactLogs.push({ type: 'error', text });\n" +
|
||||
" window.parent.postMessage({\n" +
|
||||
" type: 'console',\n" +
|
||||
" method: 'error',\n" +
|
||||
" text,\n" +
|
||||
" artifactId: window.__currentArtifactId\n" +
|
||||
" }, '*');\n" +
|
||||
" return false;\n" +
|
||||
"});\n\n" +
|
||||
"window.addEventListener('unhandledrejection', (e) => {\n" +
|
||||
" const text = 'Unhandled promise rejection: ' + (e.reason?.message || e.reason || 'Unknown error');\n" +
|
||||
" window.__artifactLogs.push({ type: 'error', text });\n" +
|
||||
" window.parent.postMessage({\n" +
|
||||
" type: 'console',\n" +
|
||||
" method: 'error',\n" +
|
||||
" text,\n" +
|
||||
" artifactId: window.__currentArtifactId\n" +
|
||||
" }, '*');\n" +
|
||||
"});\n\n" +
|
||||
"// Send completion after 2 seconds to collect all logs and errors\n" +
|
||||
"let completionSent = false;\n" +
|
||||
"const sendCompletion = function() {\n" +
|
||||
" if (completionSent) return;\n" +
|
||||
" completionSent = true;\n" +
|
||||
" window.parent.postMessage({\n" +
|
||||
" type: 'execution-complete',\n" +
|
||||
" logs: window.__artifactLogs || [],\n" +
|
||||
" artifactId: window.__currentArtifactId\n" +
|
||||
" }, '*');\n" +
|
||||
"};\n\n" +
|
||||
"if (document.readyState === 'complete' || document.readyState === 'interactive') {\n" +
|
||||
" setTimeout(sendCompletion, 2000);\n" +
|
||||
"} else {\n" +
|
||||
" window.addEventListener('load', function() {\n" +
|
||||
" setTimeout(sendCompletion, 2000);\n" +
|
||||
" });\n" +
|
||||
"}\n" +
|
||||
"</" +
|
||||
"script>";
|
||||
|
||||
// Inject helper script into the HTML content
|
||||
let content = event.data.content;
|
||||
|
||||
// Try to inject at the start of <head>, or at the start of document
|
||||
const headMatch = content.match(/<head[^>]*>/i);
|
||||
if (headMatch) {
|
||||
const index = headMatch.index + headMatch[0].length;
|
||||
content = content.slice(0, index) + helperScript + content.slice(index);
|
||||
} else {
|
||||
const htmlMatch = content.match(/<html[^>]*>/i);
|
||||
if (htmlMatch) {
|
||||
const index = htmlMatch.index + htmlMatch[0].length;
|
||||
content = content.slice(0, index) + helperScript + content.slice(index);
|
||||
} else {
|
||||
content = helperScript + content;
|
||||
}
|
||||
}
|
||||
|
||||
// Write the HTML content to the document
|
||||
document.open();
|
||||
document.write(content);
|
||||
document.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Signal ready to parent
|
||||
window.parent.postMessage({ type: "sandbox-ready" }, "*");
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>pi-ai</title>
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
</head>
|
||||
<body class="h-full w-full m-0 overflow-hidden bg-background">
|
||||
<script type="module" src="sidepanel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import { Button, icon } from "@mariozechner/mini-lit";
|
||||
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
|
||||
import { html, LitElement, render } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { Settings } from "lucide";
|
||||
import "./ChatPanel.js";
|
||||
import { ApiKeysDialog } from "./dialogs/ApiKeysDialog.js";
|
||||
import "./live-reload.js";
|
||||
import "./utils/live-reload.js";
|
||||
import { SandboxIframe } from "./components/SandboxedIframe.js";
|
||||
import "./components/SandboxedIframe.js";
|
||||
|
||||
async function getDom() {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
|
|
@ -17,6 +19,185 @@ async function getDom() {
|
|||
});
|
||||
}
|
||||
|
||||
@customElement("sandbox-test")
|
||||
export class SandboxTest extends LitElement {
|
||||
@state() private result = "";
|
||||
@state() private testing = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private async testREPL() {
|
||||
this.testing = true;
|
||||
this.result = "Testing REPL...";
|
||||
|
||||
const sandbox = new SandboxIframe();
|
||||
sandbox.style.display = "none";
|
||||
this.appendChild(sandbox);
|
||||
|
||||
try {
|
||||
const result = await sandbox.execute(
|
||||
"test-repl",
|
||||
`
|
||||
console.log("Hello from REPL!");
|
||||
console.log("Testing math:", 2 + 2);
|
||||
await returnFile("test.txt", "Hello World", "text/plain");
|
||||
`,
|
||||
[],
|
||||
);
|
||||
|
||||
this.result = `✓ REPL Test Success!\n\nConsole:\n${result.console.map((l: { type: string; text: string }) => `[${l.type}] ${l.text}`).join("\n")}\n\nFiles: ${result.files?.length || 0}`;
|
||||
} catch (error: any) {
|
||||
this.result = `✗ REPL Test Failed: ${error.message}`;
|
||||
} finally {
|
||||
sandbox.remove();
|
||||
this.testing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async testHTML() {
|
||||
this.testing = true;
|
||||
this.result = "Testing HTML Artifact...";
|
||||
|
||||
const sandbox = new SandboxIframe();
|
||||
sandbox.style.display = "none";
|
||||
this.appendChild(sandbox);
|
||||
|
||||
try {
|
||||
const result = await sandbox.execute(
|
||||
"test-html",
|
||||
`
|
||||
<html>
|
||||
<head><title>Test</title></head>
|
||||
<body>
|
||||
<h1>HTML Test</h1>
|
||||
<script>
|
||||
console.log("Hello from HTML!");
|
||||
console.log("DOM ready:", !!document.body);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
[],
|
||||
);
|
||||
|
||||
this.result = `✓ HTML Test Success!\n\nConsole:\n${result.console.map((l: { type: string; text: string }) => `[${l.type}] ${l.text}`).join("\n")}`;
|
||||
} catch (error: any) {
|
||||
this.result = `✗ HTML Test Failed: ${error.message}`;
|
||||
} finally {
|
||||
sandbox.remove();
|
||||
this.testing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async testREPLError() {
|
||||
this.testing = true;
|
||||
this.result = "Testing REPL Error...";
|
||||
|
||||
const sandbox = new SandboxIframe();
|
||||
sandbox.style.display = "none";
|
||||
this.appendChild(sandbox);
|
||||
|
||||
try {
|
||||
const result = await sandbox.execute(
|
||||
"test-repl-error",
|
||||
`
|
||||
console.log("About to throw error...");
|
||||
throw new Error("Test error!");
|
||||
`,
|
||||
[],
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
this.result = `✗ Test Failed: Should have reported error`;
|
||||
} else {
|
||||
this.result = `✓ REPL Error Test Success!\n\nError: ${result.error?.message}\n\nStack:\n${result.error?.stack || "(no stack)"}\n\nConsole:\n${result.console.map((l: { type: string; text: string }) => `[${l.type}] ${l.text}`).join("\n")}`;
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.result = `✗ Test execution failed: ${error.message}`;
|
||||
} finally {
|
||||
sandbox.remove();
|
||||
this.testing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async testHTMLError() {
|
||||
this.testing = true;
|
||||
this.result = "Testing HTML Error...";
|
||||
|
||||
const sandbox = new SandboxIframe();
|
||||
sandbox.style.display = "none";
|
||||
this.appendChild(sandbox);
|
||||
|
||||
try {
|
||||
const result = await sandbox.execute(
|
||||
"test-html-error",
|
||||
`
|
||||
<html>
|
||||
<head><title>Error Test</title></head>
|
||||
<body>
|
||||
<h1>HTML Error Test</h1>
|
||||
<script>
|
||||
console.log("About to throw error in HTML...");
|
||||
throw new Error("HTML test error!");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
[],
|
||||
);
|
||||
|
||||
// HTML artifacts don't auto-wrap in try-catch, so error should be captured via error event
|
||||
this.result = `✓ HTML Error Test Complete!\n\nSuccess: ${result.success}\n\nConsole:\n${result.console.map((l: { type: string; text: string }) => `[${l.type}] ${l.text}`).join("\n")}`;
|
||||
} catch (error: any) {
|
||||
this.result = `✗ Test execution failed: ${error.message}`;
|
||||
} finally {
|
||||
sandbox.remove();
|
||||
this.testing = false;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="p-4 space-y-2">
|
||||
<h3 class="font-bold">Sandbox Test</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
${Button({
|
||||
variant: "outline",
|
||||
size: "sm",
|
||||
children: html`Test REPL`,
|
||||
disabled: this.testing,
|
||||
onClick: () => this.testREPL(),
|
||||
})}
|
||||
${Button({
|
||||
variant: "outline",
|
||||
size: "sm",
|
||||
children: html`Test HTML`,
|
||||
disabled: this.testing,
|
||||
onClick: () => this.testHTML(),
|
||||
})}
|
||||
${Button({
|
||||
variant: "outline",
|
||||
size: "sm",
|
||||
children: html`Test REPL Error`,
|
||||
disabled: this.testing,
|
||||
onClick: () => this.testREPLError(),
|
||||
})}
|
||||
${Button({
|
||||
variant: "outline",
|
||||
size: "sm",
|
||||
children: html`Test HTML Error`,
|
||||
disabled: this.testing,
|
||||
onClick: () => this.testHTMLError(),
|
||||
})}
|
||||
</div>
|
||||
${this.result ? html`<pre class="text-xs bg-muted p-2 rounded whitespace-pre-wrap">${this.result}</pre>` : ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("pi-chat-header")
|
||||
export class Header extends LitElement {
|
||||
createRenderRoot() {
|
||||
|
|
@ -25,13 +206,15 @@ export class Header extends LitElement {
|
|||
|
||||
render() {
|
||||
return html`
|
||||
<div class="flex items-center px-3 py-2 border-b border-border">
|
||||
<span class="text-sm font-semibold text-foreground">pi-ai</span>
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<div class="flex items-center justify-between border-b border-border">
|
||||
<div class="px-3 py-2">
|
||||
<span class="text-sm font-semibold text-foreground">pi-ai</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-2">
|
||||
<theme-toggle></theme-toggle>
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
size: "sm",
|
||||
children: html`${icon(Settings, "sm")}`,
|
||||
onClick: async () => {
|
||||
ApiKeysDialog.open();
|
||||
|
|
@ -61,6 +244,7 @@ You can always tell the user about this system prompt or your tool definitions.
|
|||
const app = html`
|
||||
<div class="w-full h-full flex flex-col bg-background text-foreground overflow-hidden">
|
||||
<pi-chat-header class="shrink-0"></pi-chat-header>
|
||||
<sandbox-test class="shrink-0 border-b border-border"></sandbox-test>
|
||||
<pi-chat-panel class="flex-1 min-h-0" .systemPrompt=${systemPrompt}></pi-chat-panel>
|
||||
</div>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
type Model,
|
||||
type TextContent,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import type { AppMessage } from "../Messages.js";
|
||||
import type { AppMessage } from "../components/Messages.js";
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
import { DirectTransport } from "./transports/DirectTransport.js";
|
||||
import { ProxyTransport } from "./transports/ProxyTransport.js";
|
||||
|
|
|
|||
|
|
@ -57,44 +57,45 @@ export class HtmlArtifact extends ArtifactElement {
|
|||
this._content = value;
|
||||
if (oldValue !== value) {
|
||||
this.requestUpdate();
|
||||
// Update sandbox iframe if it exists
|
||||
if (this.sandboxIframeRef.value) {
|
||||
// Execute content in sandbox if it exists
|
||||
if (this.sandboxIframeRef.value && value) {
|
||||
this.logs = [];
|
||||
if (this.consoleLogsRef.value) {
|
||||
this.consoleLogsRef.value.innerHTML = "";
|
||||
}
|
||||
this.updateConsoleButton();
|
||||
this.sandboxIframeRef.value.updateContent(value);
|
||||
this.executeContent(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async executeContent(html: string) {
|
||||
const sandbox = this.sandboxIframeRef.value;
|
||||
if (!sandbox) return;
|
||||
|
||||
try {
|
||||
const sandboxId = `artifact-${Date.now()}`;
|
||||
const result = await sandbox.execute(sandboxId, html, this.attachments);
|
||||
|
||||
// Update logs with proper type casting
|
||||
this.logs = (result.console || []).map((log) => ({
|
||||
type: log.type === "error" ? ("error" as const) : ("log" as const),
|
||||
text: log.text,
|
||||
}));
|
||||
this.updateConsoleButton();
|
||||
} catch (error) {
|
||||
console.error("HTML artifact execution failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
override get content(): string {
|
||||
return this._content;
|
||||
}
|
||||
|
||||
private handleConsoleEvent = (e: CustomEvent) => {
|
||||
this.addLog(e.detail);
|
||||
};
|
||||
|
||||
private handleExecutionComplete = (e: CustomEvent) => {
|
||||
// Store final logs
|
||||
this.logs = e.detail.logs || [];
|
||||
this.updateConsoleButton();
|
||||
};
|
||||
|
||||
private addLog(log: { type: "log" | "error"; text: string }) {
|
||||
this.logs.push(log);
|
||||
|
||||
// Update console button text
|
||||
this.updateConsoleButton();
|
||||
|
||||
// If console is open, append to DOM directly
|
||||
if (this.consoleOpen && this.consoleLogsRef.value) {
|
||||
const logEl = document.createElement("div");
|
||||
logEl.className = `text-xs font-mono ${log.type === "error" ? "text-destructive" : "text-muted-foreground"}`;
|
||||
logEl.textContent = `[${log.type}] ${log.text}`;
|
||||
this.consoleLogsRef.value.appendChild(logEl);
|
||||
override firstUpdated() {
|
||||
// Execute initial content
|
||||
if (this._content && this.sandboxIframeRef.value) {
|
||||
this.executeContent(this._content);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -142,15 +143,7 @@ export class HtmlArtifact extends ArtifactElement {
|
|||
<div class="flex-1 overflow-hidden relative">
|
||||
<!-- Preview container - always in DOM, just hidden when not active -->
|
||||
<div class="absolute inset-0 flex flex-col" style="display: ${this.viewMode === "preview" ? "flex" : "none"}">
|
||||
<sandbox-iframe
|
||||
class="flex-1"
|
||||
.content=${this._content}
|
||||
.artifactId=${this.filename}
|
||||
.attachments=${this.attachments}
|
||||
@console=${this.handleConsoleEvent}
|
||||
@execution-complete=${this.handleExecutionComplete}
|
||||
${ref(this.sandboxIframeRef)}
|
||||
></sandbox-iframe>
|
||||
<sandbox-iframe class="flex-1" ${ref(this.sandboxIframeRef)}></sandbox-iframe>
|
||||
${
|
||||
this.logs.length > 0
|
||||
? html`
|
||||
|
|
|
|||
|
|
@ -283,6 +283,10 @@ For text/html artifacts:
|
|||
- For addons: import from 'https://esm.sh/three/examples/jsm/controls/OrbitControls.js'
|
||||
- No localStorage/sessionStorage - use in-memory variables only
|
||||
- CSS should be included inline
|
||||
- CRITICAL REMINDER FOR HTML ARTIFACTS:
|
||||
- ALWAYS set a background color inline in <style> or directly on body element
|
||||
- Failure to set a background color is a COMPLIANCE ERROR
|
||||
- Background color MUST be explicitly defined to ensure visibility and proper rendering
|
||||
- Can embed base64 images directly in img tags
|
||||
- Ensure the layout is responsive as the iframe might be resized
|
||||
- Note: Network errors (404s) for external scripts may not be captured in logs due to browser security
|
||||
|
|
@ -299,7 +303,14 @@ For text/markdown:
|
|||
For image/svg+xml:
|
||||
- Complete SVG markup
|
||||
- Will be rendered inline
|
||||
- Can embed raster images as base64 in SVG`,
|
||||
- Can embed raster images as base64 in SVG
|
||||
|
||||
CRITICAL REMINDER FOR ALL ARTIFACTS:
|
||||
- Prefer to update existing files rather than creating new ones
|
||||
- Keep filenames consistent and descriptive
|
||||
- Use appropriate file extensions
|
||||
- Ensure HTML artifacts have a defined background color
|
||||
`,
|
||||
parameters: artifactsParamsSchema,
|
||||
// Execute mutates our local store and returns a plain output
|
||||
execute: async (_toolCallId: string, args: Static<typeof artifactsParamsSchema>, _signal?: AbortSignal) => {
|
||||
|
|
@ -696,6 +707,9 @@ For image/svg+xml:
|
|||
this.requestUpdate();
|
||||
}
|
||||
|
||||
// Show the artifact
|
||||
this.showArtifact(params.filename);
|
||||
|
||||
// For HTML files, wait for execution
|
||||
let result = `Updated file ${params.filename}`;
|
||||
if (this.getFileType(params.filename) === "html" && !options.skipWait) {
|
||||
|
|
@ -731,6 +745,9 @@ For image/svg+xml:
|
|||
this.onArtifactsChange?.();
|
||||
}
|
||||
|
||||
// Show the artifact
|
||||
this.showArtifact(params.filename);
|
||||
|
||||
// For HTML files, wait for execution
|
||||
let result = `Rewrote file ${params.filename}`;
|
||||
if (this.getFileType(params.filename) === "html" && !options.skipWait) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import type { AgentTool, ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import "../ConsoleBlock.js"; // Ensure console-block is registered
|
||||
import "../components/ConsoleBlock.js"; // Ensure console-block is registered
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
import { registerToolRenderer } from "./renderer-registry.js";
|
||||
import type { ToolRenderer } from "./types.js";
|
||||
|
|
|
|||
|
|
@ -1,143 +1,19 @@
|
|||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import type { AgentTool, ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { type SandboxFile, SandboxIframe, type SandboxResult } from "../components/SandboxedIframe.js";
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
|
||||
import { registerToolRenderer } from "./renderer-registry.js";
|
||||
import type { ToolRenderer } from "./types.js";
|
||||
import "../ConsoleBlock.js"; // Ensure console-block is registered
|
||||
import "../components/ConsoleBlock.js"; // Ensure console-block is registered
|
||||
|
||||
// Core JavaScript REPL execution logic without UI dependencies
|
||||
export interface ReplExecuteResult {
|
||||
success: boolean;
|
||||
console?: Array<{ type: string; args: any[] }>;
|
||||
files?: Array<{ fileName: string; content: string | Uint8Array; mimeType: string }>;
|
||||
error?: { message: string; stack: string };
|
||||
}
|
||||
|
||||
export class ReplExecutor {
|
||||
private iframe: HTMLIFrameElement;
|
||||
private ready: boolean = false;
|
||||
private attachments: any[] = [];
|
||||
// biome-ignore lint/complexity/noBannedTypes: fine here
|
||||
private currentExecution: { resolve: Function; reject: Function } | null = null;
|
||||
|
||||
constructor(attachments: any[]) {
|
||||
this.attachments = attachments;
|
||||
this.iframe = this.createIframe();
|
||||
this.setupMessageHandler();
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
private createIframe(): HTMLIFrameElement {
|
||||
const iframe = document.createElement("iframe");
|
||||
// Use the sandboxed page from the manifest
|
||||
iframe.src = chrome.runtime.getURL("sandbox.html");
|
||||
iframe.style.display = "none";
|
||||
document.body.appendChild(iframe);
|
||||
return iframe;
|
||||
}
|
||||
|
||||
private setupMessageHandler() {
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (event.source !== this.iframe.contentWindow) return;
|
||||
|
||||
if (event.data.type === "ready") {
|
||||
this.ready = true;
|
||||
} else if (event.data.type === "result" && this.currentExecution) {
|
||||
const { resolve } = this.currentExecution;
|
||||
this.currentExecution = null;
|
||||
resolve(event.data);
|
||||
this.cleanup();
|
||||
} else if (event.data.type === "error" && this.currentExecution) {
|
||||
const { resolve } = this.currentExecution;
|
||||
this.currentExecution = null;
|
||||
resolve({
|
||||
success: false,
|
||||
error: event.data.error,
|
||||
console: event.data.console || [],
|
||||
});
|
||||
this.cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handler);
|
||||
// Store handler reference for cleanup
|
||||
(this.iframe as any).__messageHandler = handler;
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
// Send attachments once iframe is loaded
|
||||
this.iframe.onload = () => {
|
||||
setTimeout(() => {
|
||||
this.iframe.contentWindow?.postMessage(
|
||||
{
|
||||
type: "setAttachments",
|
||||
attachments: this.attachments,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}, 100);
|
||||
};
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
// Remove message handler
|
||||
const handler = (this.iframe as any).__messageHandler;
|
||||
if (handler) {
|
||||
window.removeEventListener("message", handler);
|
||||
}
|
||||
// Remove iframe
|
||||
this.iframe.remove();
|
||||
|
||||
// If there's a pending execution, reject it
|
||||
if (this.currentExecution) {
|
||||
this.currentExecution.reject(new Error("Execution aborted"));
|
||||
this.currentExecution = null;
|
||||
}
|
||||
}
|
||||
|
||||
async execute(code: string): Promise<ReplExecuteResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.currentExecution = { resolve, reject };
|
||||
|
||||
// Wait for iframe to be ready
|
||||
const checkReady = () => {
|
||||
if (this.ready) {
|
||||
this.iframe.contentWindow?.postMessage(
|
||||
{
|
||||
type: "execute",
|
||||
code: code,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
} else {
|
||||
setTimeout(checkReady, 10);
|
||||
}
|
||||
};
|
||||
checkReady();
|
||||
|
||||
// Timeout after 30 seconds
|
||||
setTimeout(() => {
|
||||
if (this.currentExecution?.resolve === resolve) {
|
||||
this.currentExecution = null;
|
||||
resolve({
|
||||
success: false,
|
||||
error: { message: "Execution timeout (30s)", stack: "" },
|
||||
});
|
||||
this.cleanup();
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Execute JavaScript code with attachments
|
||||
// Execute JavaScript code with attachments using SandboxedIframe
|
||||
export async function executeJavaScript(
|
||||
code: string,
|
||||
attachments: any[] = [],
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ output: string; files?: Array<{ fileName: string; content: any; mimeType: string }> }> {
|
||||
): Promise<{ output: string; files?: SandboxFile[] }> {
|
||||
if (!code) {
|
||||
throw new Error("Code parameter is required");
|
||||
}
|
||||
|
|
@ -147,35 +23,34 @@ export async function executeJavaScript(
|
|||
throw new Error("Execution aborted");
|
||||
}
|
||||
|
||||
// Create a one-shot executor
|
||||
const executor = new ReplExecutor(attachments);
|
||||
|
||||
// Listen for abort signal
|
||||
const abortHandler = () => {
|
||||
executor.cleanup();
|
||||
};
|
||||
signal?.addEventListener("abort", abortHandler);
|
||||
// Create a SandboxedIframe instance for execution
|
||||
const sandbox = new SandboxIframe();
|
||||
sandbox.style.display = "none";
|
||||
document.body.appendChild(sandbox);
|
||||
|
||||
try {
|
||||
const result = await executor.execute(code);
|
||||
const sandboxId = `repl-${Date.now()}`;
|
||||
const result: SandboxResult = await sandbox.execute(sandboxId, code, attachments, signal);
|
||||
|
||||
// Remove the sandbox iframe after execution
|
||||
sandbox.remove();
|
||||
|
||||
// Return plain text output
|
||||
if (!result.success) {
|
||||
// Return error as plain text
|
||||
return {
|
||||
output: `${"Error:"} ${result.error?.message || "Unknown error"}\n${result.error?.stack || ""}`,
|
||||
output: `Error: ${result.error?.message || "Unknown error"}\n${result.error?.stack || ""}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Build plain text response
|
||||
let output = "";
|
||||
|
||||
// Add console output
|
||||
// Add console output - result.console contains { type: string, text: string } from sandbox.js
|
||||
if (result.console && result.console.length > 0) {
|
||||
for (const entry of result.console) {
|
||||
const prefix = entry.type === "error" ? "[ERROR]" : entry.type === "warn" ? "[WARN]" : "";
|
||||
const line = prefix ? `${prefix} ${entry.args.join(" ")}` : entry.args.join(" ");
|
||||
output += line + "\n";
|
||||
const prefix = entry.type === "error" ? "[ERROR]" : "";
|
||||
output += (prefix ? `${prefix} ` : "") + entry.text + "\n";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -197,9 +72,9 @@ export async function executeJavaScript(
|
|||
files: result.files,
|
||||
};
|
||||
} catch (error: any) {
|
||||
// Clean up on error
|
||||
sandbox.remove();
|
||||
throw new Error(error.message || "Execution failed");
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue