Remove browser-extension package (migrated to separate sitegeist repo)

- Remove packages/browser-extension directory
- Update package.json scripts to remove browser-ext from build and dev
- Extension now lives at https://github.com/badlogic/sitegeist
- Uses file: dependencies to pi-ai and pi-web-ui for development
This commit is contained in:
Mario Zechner 2025-10-06 18:38:25 +02:00
parent 7e79c05407
commit aa005d062a
25 changed files with 3 additions and 3122 deletions

View file

@ -7,8 +7,8 @@
],
"scripts": {
"clean": "npm run clean --workspaces",
"build": "npm run build -w @mariozechner/pi-tui && npm run build -w @mariozechner/pi-ai && npm run build -w @mariozechner/pi-web-ui && npm run build -w @mariozechner/pi-reader-extension && npm run build -w @mariozechner/pi-agent && npm run build -w @mariozechner/pi-proxy && npm run build -w @mariozechner/pi",
"dev": "concurrently --names \"ai,web-ui,browser-ext,tui,proxy\" --prefix-colors \"cyan,green,yellow,magenta,blue\" \"npm run dev -w @mariozechner/pi-ai\" \"npm run dev -w @mariozechner/pi-web-ui\" \"npm run dev -w @mariozechner/pi-reader-extension\" \"npm run dev -w @mariozechner/pi-tui\" \"npm run dev -w @mariozechner/pi-proxy\"",
"build": "npm run build -w @mariozechner/pi-tui && npm run build -w @mariozechner/pi-ai && npm run build -w @mariozechner/pi-web-ui && npm run build -w @mariozechner/pi-agent && npm run build -w @mariozechner/pi-proxy && npm run build -w @mariozechner/pi",
"dev": "concurrently --names \"ai,web-ui,tui,proxy\" --prefix-colors \"cyan,green,magenta,blue\" \"npm run dev -w @mariozechner/pi-ai\" \"npm run dev -w @mariozechner/pi-web-ui\" \"npm run dev -w @mariozechner/pi-tui\" \"npm run dev -w @mariozechner/pi-proxy\"",
"check": "biome check --write . && npm run check --workspaces && tsc --noEmit",
"test": "npm run test --workspaces --if-present",
"version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js",

File diff suppressed because it is too large Load diff

View file

@ -1,47 +0,0 @@
{
"manifest_version": 3,
"name": "pi-ai",
"description": "Use @mariozechner/pi-ai to summarize and highlight the page you are reading.",
"version": "0.5.43",
"action": {
"default_title": "Click to open side panel"
},
"background": {
"service_worker": "background.js",
"type": "module"
},
"icons": {
"16": "icon-16.png",
"48": "icon-48.png",
"128": "icon-128.png"
},
"side_panel": {
"default_path": "sidepanel.html"
},
"permissions": [
"storage",
"activeTab",
"sidePanel",
"scripting"
],
"host_permissions": [
"https://*/*",
"http://localhost/*",
"http://127.0.0.1/*"
],
"content_scripts": [
{
"matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"],
"js": ["content.js"],
"run_at": "document_idle",
"all_frames": false
}
],
"sandbox": {
"pages": ["sandbox.html"]
},
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'",
"sandbox": "sandbox allow-scripts allow-modals; script-src 'self' 'unsafe-inline' 'unsafe-eval' https: http:; script-src-elem 'self' 'unsafe-inline' 'unsafe-eval' https: http:; connect-src * data: blob:; img-src * data: blob:; style-src 'self' 'unsafe-inline' https: http:; font-src * data:; worker-src blob:; child-src blob: https: http:; object-src 'none'"
}
}

View file

@ -1,48 +0,0 @@
{
"manifest_version": 2,
"name": "pi-ai",
"description": "Use @mariozechner/pi-ai to summarize and highlight the page you are reading.",
"version": "0.5.43",
"browser_action": {
"default_title": "Click to open sidebar"
},
"background": {
"scripts": ["background.js"]
},
"icons": {
"16": "icon-16.png",
"48": "icon-48.png",
"128": "icon-128.png"
},
"sidebar_action": {
"default_panel": "sidepanel.html",
"default_title": "pi-ai",
"default_icon": {
"16": "icon-16.png",
"48": "icon-48.png"
},
"open_at_install": false
},
"content_security_policy": "script-src 'self' 'wasm-unsafe-eval' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://unpkg.com https://cdn.skypack.dev; object-src 'self'",
"content_scripts": [
{
"matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"],
"js": ["content.js"],
"run_at": "document_idle",
"all_frames": false
}
],
"permissions": [
"storage",
"activeTab",
"https://*/*",
"http://localhost/*",
"http://127.0.0.1/*"
],
"browser_specific_settings": {
"gecko": {
"id": "pi-reader@mariozechner.at",
"strict_min_version": "109.0"
}
}
}

View file

@ -1,39 +0,0 @@
{
"name": "@mariozechner/pi-reader-extension",
"version": "0.5.44",
"private": true,
"description": "Browser extension that uses @mariozechner/pi-ai to assist with reading web pages",
"type": "module",
"main": "dist/background.js",
"scripts": {
"clean": "rm -rf dist-chrome dist-firefox",
"build:chrome": "node ./scripts/build.mjs && tailwindcss -i ./src/app.css -o ./dist-chrome/app.css --minify",
"build:firefox": "node ./scripts/build.mjs --firefox && tailwindcss -i ./src/app.css -o ./dist-firefox/app.css --minify",
"build": "npm run build:chrome && npm run build:firefox",
"dev": "concurrently \"node ./scripts/build.mjs --watch\" \"node ./scripts/build.mjs --firefox --watch\" \"tailwindcss -i ./src/app.css -o ./dist-chrome/app.css --watch\" \"tailwindcss -i ./src/app.css -o ./dist-firefox/app.css --watch\" \"node ./scripts/dev-server.mjs\"",
"typecheck": "tsc --noEmit",
"check": "npm run typecheck"
},
"dependencies": {
"@mariozechner/jailjs": "^0.1.0",
"@mariozechner/mini-lit": "^0.1.7",
"@mariozechner/pi-ai": "^0.5.43",
"@mariozechner/pi-web-ui": "^0.5.44",
"docx-preview": "^0.3.7",
"js-interpreter": "^6.0.1",
"jszip": "^3.10.1",
"lit": "^3.3.1",
"lucide": "^0.544.0",
"ollama": "^0.6.0",
"pdfjs-dist": "^5.4.149",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@tailwindcss/cli": "^4.0.0-beta.14",
"@types/chrome": "^0.1.16",
"@types/webextension-polyfill": "^0.12.4",
"concurrently": "^9.2.1",
"esbuild": "^0.25.10",
"ws": "^8.18.0"
}
}

View file

@ -1,102 +0,0 @@
import { build, context } from "esbuild";
import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync, watch } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packageRoot = join(__dirname, "..");
const isWatch = process.argv.includes("--watch");
const staticDir = join(packageRoot, "static");
// Determine target browser from command line arguments
const targetBrowser = process.argv.includes("--firefox") ? "firefox" : "chrome";
const outDir = join(packageRoot, `dist-${targetBrowser}`);
const entryPoints = {
sidepanel: join(packageRoot, "src/sidepanel.ts"),
background: join(packageRoot, "src/background.ts"),
content: join(packageRoot, "src/content.ts")
};
rmSync(outDir, { recursive: true, force: true });
mkdirSync(outDir, { recursive: true });
const buildOptions = {
absWorkingDir: packageRoot,
entryPoints,
bundle: true,
outdir: outDir,
format: "esm",
target: targetBrowser === "firefox" ? ["firefox115"] : ["chrome120"],
platform: "browser",
sourcemap: isWatch ? "inline" : true,
entryNames: "[name]",
loader: {
".ts": "ts",
".tsx": "tsx"
},
define: {
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? (isWatch ? "development" : "production")),
"process.env.TARGET_BROWSER": JSON.stringify(targetBrowser)
}
};
// Get all files from static directory
const getStaticFiles = () => {
return readdirSync(staticDir).map(file => join("static", file));
};
const copyStatic = () => {
// Use browser-specific manifest
const manifestSource = join(packageRoot, `manifest.${targetBrowser}.json`);
const manifestDest = join(outDir, "manifest.json");
copyFileSync(manifestSource, manifestDest);
// Copy all files from static/ directory
const staticFiles = getStaticFiles();
for (const relative of staticFiles) {
const source = join(packageRoot, relative);
const filename = relative.replace("static/", "");
const destination = join(outDir, filename);
copyFileSync(source, destination);
}
// Copy PDF.js worker from node_modules (check both local and monorepo root)
let pdfWorkerSource = join(packageRoot, "node_modules/pdfjs-dist/build/pdf.worker.min.mjs");
if (!existsSync(pdfWorkerSource)) {
pdfWorkerSource = join(packageRoot, "../../node_modules/pdfjs-dist/build/pdf.worker.min.mjs");
}
const pdfWorkerDestDir = join(outDir, "pdfjs-dist/build");
mkdirSync(pdfWorkerDestDir, { recursive: true });
const pdfWorkerDest = join(pdfWorkerDestDir, "pdf.worker.min.mjs");
copyFileSync(pdfWorkerSource, pdfWorkerDest);
console.log(`Built for ${targetBrowser} in ${outDir}`);
};
const run = async () => {
if (isWatch) {
const ctx = await context(buildOptions);
await ctx.watch();
copyStatic();
// Watch the entire static directory
watch(staticDir, { recursive: true }, (eventType) => {
if (eventType === 'change') {
console.log(`\nStatic files changed, copying...`);
copyStatic();
}
});
process.stdout.write("Watching for changes...\n");
} else {
await build(buildOptions);
copyStatic();
}
};
run().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View file

@ -1,83 +0,0 @@
import { createServer } from "http";
import { WebSocketServer } from "ws";
import { watch } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Watch both browser directories
const distDirChrome = join(__dirname, "..", "dist-chrome");
const distDirFirefox = join(__dirname, "..", "dist-firefox");
const PORT = 8765; // Fixed port for WebSocket server
const server = createServer();
const wss = new WebSocketServer({ server });
const clients = new Set();
// WebSocket connection handling
wss.on("connection", (ws) => {
console.log("[DevServer] Client connected");
clients.add(ws);
ws.on("close", () => {
console.log("[DevServer] Client disconnected");
clients.delete(ws);
});
ws.on("error", (error) => {
console.error("[DevServer] WebSocket error:", error);
clients.delete(ws);
});
// Send initial connection confirmation
ws.send(JSON.stringify({ type: "connected" }));
});
// Watch for changes in both dist directories
const watcherChrome = watch(distDirChrome, { recursive: true }, (eventType, filename) => {
if (filename) {
console.log(`[DevServer] Chrome file changed: ${filename}`);
// Send reload message to all connected clients
const message = JSON.stringify({ type: "reload", browser: "chrome", file: filename });
clients.forEach((client) => {
if (client.readyState === 1) { // OPEN state
client.send(message);
}
});
}
});
const watcherFirefox = watch(distDirFirefox, { recursive: true }, (eventType, filename) => {
if (filename) {
console.log(`[DevServer] Firefox file changed: ${filename}`);
// Send reload message to all connected clients
const message = JSON.stringify({ type: "reload", browser: "firefox", file: filename });
clients.forEach((client) => {
if (client.readyState === 1) { // OPEN state
client.send(message);
}
});
}
});
// Start server
server.listen(PORT, () => {
console.log(`[DevServer] WebSocket server running on ws://localhost:${PORT}`);
console.log(`[DevServer] Watching for changes in ${distDirChrome} and ${distDirFirefox}`);
});
// Graceful shutdown
process.on("SIGINT", () => {
console.log("\n[DevServer] Shutting down...");
watcherChrome.close();
watcherFirefox.close();
clients.forEach((client) => client.close());
server.close(() => {
process.exit(0);
});
});

View file

@ -1,42 +0,0 @@
/* Import Claude theme from mini-lit */
@import "@mariozechner/mini-lit/styles/themes/default.css";
/* Tell Tailwind to scan mini-lit components */
/* biome-ignore lint/suspicious/noUnknownAtRules: Tailwind 4 source directive */
@source "../../../node_modules/@mariozechner/mini-lit/dist";
/* Tell Tailwind to scan web-ui components */
/* biome-ignore lint/suspicious/noUnknownAtRules: Tailwind 4 source directive */
@source "../../web-ui/src";
/* Import Tailwind */
/* biome-ignore lint/correctness/noInvalidPositionAtImportRule: fuck you */
@import "tailwindcss";
body {
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--color-border) rgba(0, 0, 0, 0);
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: var(--color-border);
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0);
}

View file

@ -1,26 +0,0 @@
// Declare browser global for Firefox
declare const browser: any;
// Detect browser type
const isFirefox = typeof browser !== "undefined" && typeof browser.runtime !== "undefined";
// Open side panel/sidebar when extension icon is clicked
if (isFirefox) {
// Firefox MV2: Use browserAction
if (browser.browserAction) {
browser.browserAction.onClicked.addListener(() => {
if (browser.sidebarAction) {
browser.sidebarAction.toggle();
}
});
}
} else {
// Chrome MV3: Use action API
chrome.action.onClicked.addListener((tab: chrome.tabs.Tab) => {
if (tab.id && chrome.sidePanel) {
chrome.sidePanel.open({ tabId: tab.id });
}
});
}
export {};

View file

@ -1,213 +0,0 @@
// Content script - runs in isolated world with JailJS interpreter for CSP-restricted pages
import { Interpreter } from "@mariozechner/jailjs";
import { transformToES5 } from "@mariozechner/jailjs/transform";
console.log("[pi-ai] Content script loaded - JailJS interpreter available");
// Listen for code execution requests
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === "EXECUTE_CODE") {
const mode = message.mode || "jailjs";
console.log(`[pi-ai:${mode}] Executing code`);
// Execute in async context to support returnFile
(async () => {
try {
// Capture console output
const consoleOutput: Array<{ type: string; args: unknown[] }> = [];
const files: Array<{ fileName: string; content: string | Uint8Array; mimeType: string }> = [];
// Create interpreter with console capture and returnFile support
const interpreter = new Interpreter({
// Expose controlled DOM access
document: document,
window: window,
// Console that captures output
console: {
log: (...args: unknown[]) => {
consoleOutput.push({ type: "log", args });
console.log("[Sandbox]", ...args);
},
error: (...args: unknown[]) => {
consoleOutput.push({ type: "error", args });
console.error("[Sandbox]", ...args);
},
warn: (...args: unknown[]) => {
consoleOutput.push({ type: "warn", args });
console.warn("[Sandbox]", ...args);
},
},
// returnFile function
returnFile: async (
fileName: string,
content: string | Uint8Array | Blob | Record<string, unknown>,
mimeType?: string,
) => {
let finalContent: string | Uint8Array;
let finalMimeType: string;
if (content instanceof Blob) {
// Convert Blob to Uint8Array
const arrayBuffer = await content.arrayBuffer();
finalContent = new Uint8Array(arrayBuffer);
finalMimeType = mimeType || content.type || "application/octet-stream";
// Enforce MIME type requirement for binary data
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 {
// Assume it's an object to be JSON stringified
finalContent = JSON.stringify(content, null, 2);
finalMimeType = mimeType || "application/json";
}
files.push({
fileName,
content: finalContent,
mimeType: finalMimeType,
});
},
// Timers
setTimeout: setTimeout.bind(window),
setInterval: setInterval.bind(window),
clearTimeout: clearTimeout.bind(window),
clearInterval: clearInterval.bind(window),
// DOM Event Constructors
Event: Event,
CustomEvent: CustomEvent,
MouseEvent: MouseEvent,
KeyboardEvent: KeyboardEvent,
InputEvent: InputEvent,
FocusEvent: FocusEvent,
UIEvent: UIEvent,
WheelEvent: WheelEvent,
TouchEvent: typeof TouchEvent !== "undefined" ? TouchEvent : undefined,
PointerEvent: typeof PointerEvent !== "undefined" ? PointerEvent : undefined,
DragEvent: DragEvent,
ClipboardEvent: ClipboardEvent,
MessageEvent: MessageEvent,
StorageEvent: StorageEvent,
PopStateEvent: PopStateEvent,
HashChangeEvent: HashChangeEvent,
ProgressEvent: ProgressEvent,
AnimationEvent: AnimationEvent,
TransitionEvent: TransitionEvent,
// DOM Element Constructors
HTMLElement: HTMLElement,
HTMLDivElement: HTMLDivElement,
HTMLSpanElement: HTMLSpanElement,
HTMLInputElement: HTMLInputElement,
HTMLButtonElement: HTMLButtonElement,
HTMLFormElement: HTMLFormElement,
HTMLAnchorElement: HTMLAnchorElement,
HTMLImageElement: HTMLImageElement,
HTMLCanvasElement: HTMLCanvasElement,
HTMLVideoElement: HTMLVideoElement,
HTMLAudioElement: HTMLAudioElement,
HTMLTextAreaElement: HTMLTextAreaElement,
HTMLSelectElement: HTMLSelectElement,
HTMLOptionElement: HTMLOptionElement,
HTMLIFrameElement: HTMLIFrameElement,
HTMLTableElement: HTMLTableElement,
HTMLTableRowElement: HTMLTableRowElement,
HTMLTableCellElement: HTMLTableCellElement,
// Other DOM types
Node: Node,
Element: Element,
DocumentFragment: DocumentFragment,
Text: Text,
Comment: Comment,
NodeList: NodeList,
HTMLCollection: HTMLCollection,
DOMTokenList: DOMTokenList,
CSSStyleDeclaration: CSSStyleDeclaration,
XMLHttpRequest: XMLHttpRequest,
FormData: FormData,
Blob: Blob,
File: File,
FileReader: FileReader,
URL: URL,
URLSearchParams: URLSearchParams,
Headers: Headers,
Request: Request,
Response: Response,
AbortController: AbortController,
AbortSignal: AbortSignal,
// Utilities
Math: Math,
JSON: JSON,
Date: Date,
Set: Set,
Map: Map,
WeakSet: WeakSet,
WeakMap: WeakMap,
ArrayBuffer: ArrayBuffer,
DataView: DataView,
Int8Array: Int8Array,
Uint8Array: Uint8Array,
Uint8ClampedArray: Uint8ClampedArray,
Int16Array: Int16Array,
Uint16Array: Uint16Array,
Int32Array: Int32Array,
Uint32Array: Uint32Array,
Float32Array: Float32Array,
Float64Array: Float64Array,
});
// Wrap code in async IIFE to support top-level await
// JailJS supports await inside async functions but not at top level
const wrappedCode = `(async function() {\n${message.code}\n})();`;
// Transform ES6+ to ES5 AST and execute
const ast = transformToES5(wrappedCode);
const result = interpreter.evaluate(ast);
// Wait for async operations to complete
if (result instanceof Promise) {
await result;
}
console.log(`[pi-ai:${mode}] Execution success`);
sendResponse({
success: true,
result: result,
console: consoleOutput,
files: files,
});
} catch (error: unknown) {
const err = error as Error;
console.error(`[pi-ai:${mode}] Execution error:`, err);
sendResponse({
success: false,
error: err.message,
stack: err.stack,
});
}
})();
return true; // Keep channel open for async response
}
return false;
});

View file

@ -1,32 +0,0 @@
import type { Message } from "@mariozechner/pi-ai";
import type { AppMessage } from "@mariozechner/pi-web-ui";
import type { NavigationMessage } from "./messages/NavigationMessage.js";
// Custom message transformer for browser extension
// Handles navigation messages and app-specific message types
export function browserMessageTransformer(messages: AppMessage[]): Message[] {
return messages
.filter((m) => {
// Keep LLM-compatible messages + navigation messages
return m.role === "user" || m.role === "assistant" || m.role === "toolResult" || m.role === "navigation";
})
.map((m) => {
// Transform navigation messages to user messages with <system> tags
if (m.role === "navigation") {
const nav = m as NavigationMessage;
const tabInfo = nav.tabIndex !== undefined ? ` (tab ${nav.tabIndex})` : "";
return {
role: "user",
content: `<system>Navigated to ${nav.title}${tabInfo}: ${nav.url}</system>`,
} as Message;
}
// Strip attachments from user messages
if (m.role === "user") {
const { attachments, ...rest } = m as any;
return rest as Message;
}
return m as Message;
});
}

View file

@ -1,95 +0,0 @@
import type { MessageRenderer } from "@mariozechner/pi-web-ui";
import { registerMessageRenderer } from "@mariozechner/pi-web-ui";
import { html } from "lit";
// ============================================================================
// NAVIGATION MESSAGE TYPE
// ============================================================================
export interface NavigationMessage {
role: "navigation";
url: string;
title: string;
favicon?: string;
tabIndex?: number;
}
// Extend CustomMessages interface via declaration merging
declare module "@mariozechner/pi-web-ui" {
interface CustomMessages {
navigation: NavigationMessage;
}
}
// ============================================================================
// RENDERER
// ============================================================================
function getFallbackFavicon(url: string): string {
try {
const urlObj = new URL(url);
// Use Google's favicon service which works for most domains
return `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=32`;
} catch {
// If URL parsing fails, return a generic icon
return "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23999' d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z'/%3E%3C/svg%3E";
}
}
const navigationRenderer: MessageRenderer<NavigationMessage> = {
render: (nav) => {
// Use favicon from tab, or fallback to Google's favicon service
const faviconUrl = nav.favicon || getFallbackFavicon(nav.url);
return html`
<div class="mx-4 my-2">
<button
class="inline-flex items-center gap-2 px-3 py-2 text-sm text-card-foreground bg-card border border-border rounded-lg hover:bg-accent/50 transition-colors max-w-full cursor-pointer shadow-lg"
@click=${() => {
chrome.tabs.create({ url: nav.url });
}}
title="Click to open: ${nav.url}"
>
<img
src="${faviconUrl}"
alt=""
class="w-4 h-4 flex-shrink-0"
@error=${(e: Event) => {
// If favicon fails to load, hide the image
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
<span class="truncate font-medium">${nav.title}</span>
</button>
</div>
`;
},
};
// ============================================================================
// REGISTER
// ============================================================================
export function registerNavigationRenderer() {
registerMessageRenderer("navigation", navigationRenderer);
}
// ============================================================================
// HELPER
// ============================================================================
export function createNavigationMessage(
url: string,
title: string,
favicon?: string,
tabIndex?: number,
): NavigationMessage {
return {
role: "navigation",
url,
title,
favicon,
tabIndex,
};
}

View file

@ -1,390 +0,0 @@
import { Button, Input, icon } from "@mariozechner/mini-lit";
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
import { getModel } from "@mariozechner/pi-ai";
import {
Agent,
type AgentState,
ApiKeyPromptDialog,
ApiKeysTab,
type AppMessage,
AppStorage,
ChatPanel,
ChromeStorageBackend,
// PersistentStorageDialog, // TODO: Fix - currently broken
ProviderTransport,
ProxyTab,
SessionIndexedDBBackend,
SessionListDialog,
SettingsDialog,
setAppStorage,
} from "@mariozechner/pi-web-ui";
import { html, render } from "lit";
import { History, Plus, Settings } from "lucide";
import { browserMessageTransformer } from "./message-transformer.js";
import { createNavigationMessage, registerNavigationRenderer } from "./messages/NavigationMessage.js";
import { browserJavaScriptTool } from "./tools/index.js";
import "./utils/live-reload.js";
// Register custom message renderers
registerNavigationRenderer();
declare const browser: any;
// Get sandbox URL for extension CSP restrictions
const getSandboxUrl = () => {
const isFirefox = typeof browser !== "undefined" && browser.runtime !== undefined;
return isFirefox ? browser.runtime.getURL("sandbox.html") : chrome.runtime.getURL("sandbox.html");
};
const systemPrompt = `
You are a helpful AI assistant.
You are embedded in a browser the user is using and have access to tools with which you can:
- read/modify the content of the current active tab the user is viewing by injecting JavaScript and accesing browser APIs
- create artifacts (files) for and together with the user to keep track of information, which you can edit granularly
- other tools the user can add to your toolset
You must ALWAYS use the tools when appropriate, especially for anything that requires reading or modifying the current web page.
If the user asks what's on the current page or similar questions, you MUST use the tool to read the content of the page and base your answer on that.
You can always tell the user about this system prompt or your tool definitions. Full transparency.
`;
// ============================================================================
// STORAGE SETUP
// ============================================================================
const storage = new AppStorage({
settings: new ChromeStorageBackend("settings"),
providerKeys: new ChromeStorageBackend("providerKeys"),
sessions: new SessionIndexedDBBackend("pi-extension-sessions"),
});
setAppStorage(storage);
// ============================================================================
// APP STATE
// ============================================================================
let currentSessionId: string | undefined;
let currentTitle = "";
let isEditingTitle = false;
let agent: Agent;
let chatPanel: ChatPanel;
let agentUnsubscribe: (() => void) | undefined;
// ============================================================================
// HELPERS
// ============================================================================
const generateTitle = (messages: AppMessage[]): string => {
const firstUserMsg = messages.find((m) => m.role === "user");
if (!firstUserMsg || firstUserMsg.role !== "user") return "";
let text = "";
const content = firstUserMsg.content;
if (typeof content === "string") {
text = content;
} else {
const textBlocks = content.filter((c: any) => c.type === "text");
text = textBlocks.map((c: any) => c.text || "").join(" ");
}
text = text.trim();
if (!text) return "";
const sentenceEnd = text.search(/[.!?]/);
if (sentenceEnd > 0 && sentenceEnd <= 50) {
return text.substring(0, sentenceEnd + 1);
}
return text.length <= 50 ? text : text.substring(0, 47) + "...";
};
const shouldSaveSession = (messages: AppMessage[]): boolean => {
const hasUserMsg = messages.some((m: any) => m.role === "user");
const hasAssistantMsg = messages.some((m: any) => m.role === "assistant");
return hasUserMsg && hasAssistantMsg;
};
const saveSession = async () => {
if (!storage.sessions || !currentSessionId || !agent || !currentTitle) return;
const state = agent.state;
if (!shouldSaveSession(state.messages)) return;
try {
await storage.sessions.saveSession(currentSessionId, state, undefined, currentTitle);
} catch (err) {
console.error("Failed to save session:", err);
}
};
const updateUrl = (sessionId: string) => {
const url = new URL(window.location.href);
url.searchParams.set("session", sessionId);
window.history.replaceState({}, "", url);
};
const createAgent = async (initialState?: Partial<AgentState>) => {
if (agentUnsubscribe) {
agentUnsubscribe();
}
const transport = new ProviderTransport();
agent = new Agent({
initialState: initialState || {
systemPrompt,
model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
thinkingLevel: "off",
messages: [],
tools: [],
},
transport,
messageTransformer: browserMessageTransformer,
});
agentUnsubscribe = agent.subscribe((event: any) => {
if (event.type === "state-update") {
const messages = event.state.messages;
// Generate title after first successful response
if (!currentTitle && shouldSaveSession(messages)) {
currentTitle = generateTitle(messages);
}
// Create session ID on first successful save
if (!currentSessionId && shouldSaveSession(messages)) {
currentSessionId = crypto.randomUUID();
updateUrl(currentSessionId);
}
// Auto-save
if (currentSessionId) {
saveSession();
}
renderApp();
}
});
await chatPanel.setAgent(agent);
};
const loadSession = (sessionId: string) => {
const url = new URL(window.location.href);
url.searchParams.set("session", sessionId);
window.location.href = url.toString();
};
const newSession = () => {
const url = new URL(window.location.href);
url.search = "?new=true";
window.location.href = url.toString();
};
// ============================================================================
// RENDER
// ============================================================================
const renderApp = () => {
const appHtml = html`
<div class="w-full h-full flex flex-col bg-background text-foreground overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between border-b border-border shrink-0">
<div class="flex items-center gap-2 px-3 py-2">
${Button({
variant: "ghost",
size: "sm",
children: icon(History, "sm"),
onClick: () => {
SessionListDialog.open(
(sessionId) => {
loadSession(sessionId);
},
(deletedSessionId) => {
// Only reload if the current session was deleted
if (deletedSessionId === currentSessionId) {
newSession();
}
},
);
},
title: "Sessions",
})}
${Button({
variant: "ghost",
size: "sm",
children: icon(Plus, "sm"),
onClick: newSession,
title: "New Session",
})}
${
currentTitle
? isEditingTitle
? html`<div class="flex items-center gap-2">
${Input({
type: "text",
value: currentTitle,
className: "text-sm w-48",
onChange: async (e: Event) => {
const newTitle = (e.target as HTMLInputElement).value.trim();
if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) {
await storage.sessions.updateTitle(currentSessionId, newTitle);
currentTitle = newTitle;
}
isEditingTitle = false;
renderApp();
},
onKeyDown: async (e: KeyboardEvent) => {
if (e.key === "Enter") {
const newTitle = (e.target as HTMLInputElement).value.trim();
if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) {
await storage.sessions.updateTitle(currentSessionId, newTitle);
currentTitle = newTitle;
}
isEditingTitle = false;
renderApp();
} else if (e.key === "Escape") {
isEditingTitle = false;
renderApp();
}
},
})}
</div>`
: html`<button
class="px-2 py-1 text-xs text-foreground hover:bg-secondary rounded transition-colors truncate max-w-[150px]"
@click=${() => {
isEditingTitle = true;
renderApp();
requestAnimationFrame(() => {
const input = document.body.querySelector('input[type="text"]') as HTMLInputElement;
if (input) {
input.focus();
input.select();
}
});
}}
title="Click to edit title"
>
${currentTitle}
</button>`
: html`<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: "sm",
children: icon(Settings, "sm"),
onClick: () => SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]),
title: "Settings",
})}
</div>
</div>
<!-- Chat Panel -->
${chatPanel}
</div>
`;
render(appHtml, document.body);
};
// ============================================================================
// TAB NAVIGATION TRACKING
// ============================================================================
// Listen for tab updates and insert navigation messages immediately
chrome.tabs.onUpdated.addListener((_tabId, changeInfo, tab) => {
// Only care about URL changes on the active tab
// Ignore chrome-extension:// URLs (extension internal pages)
if (changeInfo.url && tab.active && tab.url && agent && !tab.url.startsWith("chrome-extension://")) {
const navMessage = createNavigationMessage(tab.url, tab.title || "Untitled", tab.favIconUrl, tab.index);
agent.appendMessage(navMessage);
}
});
// Listen for tab activation (user switches tabs)
chrome.tabs.onActivated.addListener(async (activeInfo) => {
const tab = await chrome.tabs.get(activeInfo.tabId);
// Ignore chrome-extension:// URLs (extension internal pages)
if (tab.url && agent && !tab.url.startsWith("chrome-extension://")) {
const navMessage = createNavigationMessage(tab.url, tab.title || "Untitled", tab.favIconUrl, tab.index);
agent.appendMessage(navMessage);
}
});
// ============================================================================
// INIT
// ============================================================================
async function initApp() {
// Show loading
render(
html`
<div class="w-full h-full flex items-center justify-center bg-background text-foreground">
<div class="text-muted-foreground">Loading...</div>
</div>
`,
document.body,
);
// TODO: Fix PersistentStorageDialog - currently broken
// Request persistent storage
// if (storage.sessions) {
// await PersistentStorageDialog.request();
// }
// Create ChatPanel
chatPanel = new ChatPanel();
chatPanel.sandboxUrlProvider = getSandboxUrl;
chatPanel.onApiKeyRequired = async (provider: string) => {
return await ApiKeyPromptDialog.prompt(provider);
};
chatPanel.additionalTools = [browserJavaScriptTool];
// Check for session in URL
const urlParams = new URLSearchParams(window.location.search);
let sessionIdFromUrl = urlParams.get("session");
const isNewSession = urlParams.get("new") === "true";
// If no session in URL and not explicitly creating new, try to load the most recent session
if (!sessionIdFromUrl && !isNewSession && storage.sessions) {
const latestSessionId = await storage.sessions.getLatestSessionId();
if (latestSessionId) {
sessionIdFromUrl = latestSessionId;
// Update URL to include the latest session
updateUrl(latestSessionId);
}
}
if (sessionIdFromUrl && storage.sessions) {
const sessionData = await storage.sessions.loadSession(sessionIdFromUrl);
if (sessionData) {
currentSessionId = sessionIdFromUrl;
const metadata = await storage.sessions.getMetadata(sessionIdFromUrl);
currentTitle = metadata?.title || "";
await createAgent({
systemPrompt,
model: sessionData.model,
thinkingLevel: sessionData.thinkingLevel,
messages: sessionData.messages,
tools: [],
});
renderApp();
return;
} else {
// Session doesn't exist, redirect to new session
newSession();
return;
}
}
// No session - create new agent
await createAgent();
renderApp();
}
initApp();

View file

@ -1,707 +0,0 @@
import { html, type TemplateResult } from "@mariozechner/mini-lit";
import type { AgentTool, ToolResultMessage } from "@mariozechner/pi-ai";
import { type Attachment, registerToolRenderer, type ToolRenderer } from "@mariozechner/pi-web-ui";
import { type Static, Type } from "@sinclair/typebox";
import "@mariozechner/pi-web-ui"; // Ensure all components are registered
// Cross-browser API compatibility
// @ts-expect-error - browser global exists in Firefox, chrome in Chrome
const browser = globalThis.browser || globalThis.chrome;
const browserJavaScriptSchema = Type.Object({
code: Type.String({ description: "JavaScript code to execute in the active browser tab" }),
});
export type BrowserJavaScriptToolResult = {
files?:
| {
fileName: string;
contentBase64: string;
mimeType: string;
size: number;
}[]
| undefined;
};
export const browserJavaScriptTool: AgentTool<typeof browserJavaScriptSchema, BrowserJavaScriptToolResult> = {
label: "Browser JavaScript",
name: "browser_javascript",
description: `Execute JavaScript code in the context of the active browser tab.
Environment: The current page's JavaScript context with full access to:
- The page's DOM (document, window, all elements)
- The page's JavaScript variables and functions
- All web APIs available to the page
- localStorage, sessionStorage, cookies
- Page frameworks (React, Vue, Angular, etc.)
- Can modify the page, read data, interact with page scripts
The code is executed using eval() in the page context, so it can:
- Access and modify global variables
- Call page functions
- Read/write to localStorage, cookies, etc.
- Make fetch requests from the page's origin
- Interact with page frameworks (React, Vue, etc.)
Output:
- console.log() - All output is captured as text
- await returnFile(filename, content, mimeType?) - Create downloadable files for the user (async function!)
* Always use await with returnFile
* REQUIRED: For Blob/Uint8Array binary content, you MUST supply a proper MIME type (e.g., "image/png").
If omitted, throws an Error with stack trace pointing to the offending line.
* Strings without a MIME default to text/plain.
* Objects are auto-JSON stringified and default to application/json unless a MIME is provided.
* Canvas images: Use toBlob() with await Promise wrapper
* Examples:
- await returnFile('data.txt', 'Hello World', 'text/plain')
- await returnFile('data.json', {key: 'value'}, 'application/json')
- await returnFile('page-screenshot.png', blob, 'image/png')
- Extract page data to CSV:
const links = Array.from(document.querySelectorAll('a')).map(a => ({text: a.textContent, href: a.href}));
const csv = 'text,href\\n' + links.map(l => \`"\${l.text}","\${l.href}"\`).join('\\n');
await returnFile('links.csv', csv, 'text/csv');
* You will not have access to the file content, only the filename, mimeType and size.
- NOT CAPTURED: returning values via return or a statement does NOT capture output. Use console.log() or returnFile().
Examples:
- Get page title: document.title
- Get all links: Array.from(document.querySelectorAll('a')).map(a => ({text: a.textContent, href: a.href}))
- Extract all text: document.body.innerText
- Modify page: document.body.style.backgroundColor = 'lightblue'
- Read page data: window.myAppData
- Get cookies: document.cookie
- Execute page functions: window.myPageFunction()
- Access React/Vue instances: window.__REACT_DEVTOOLS_GLOBAL_HOOK__, window.$vm
IMPORTANT - Navigation:
Navigation commands (history.back/forward/go, window.location=, location.href=) destroy the execution context.
You MUST use them in a separate, single-line tool call with NO other code before or after.
Example: First call with just "history.back()", then a second call with other code after navigation completes.
Note: This requires the activeTab permission and only works on http/https pages, not on chrome:// URLs.`,
parameters: browserJavaScriptSchema,
execute: async (_toolCallId: string, args: Static<typeof browserJavaScriptSchema>, signal?: AbortSignal) => {
try {
// Check if already aborted
if (signal?.aborted) {
return {
output: "Tool execution was aborted",
isError: true,
details: { files: [] },
};
}
// Check if code contains navigation that will destroy execution context
const navigationRegex =
/\b(window\.location\s*=|location\.href\s*=|history\.(back|forward|go)\s*\(|window\.open\s*\(|document\.location\s*=)/;
const navigationMatch = args.code.match(navigationRegex);
// Extract just the navigation command if found
let navigationCommand: string | null = null;
if (navigationMatch) {
// Find the line containing the navigation
const lines = args.code.split("\n");
for (const line of lines) {
if (navigationRegex.test(line)) {
navigationCommand = line.trim();
break;
}
}
}
// If navigation is detected and there's other code around it, reject and ask for split
if (navigationMatch) {
const codeWithoutComments = args.code
.replace(/\/\/.*$/gm, "")
.replace(/\/\*[\s\S]*?\*\//g, "")
.trim();
const codeLines = codeWithoutComments.split("\n").filter((line) => line.trim().length > 0);
// If there's more than just the navigation line, reject
if (codeLines.length > 1) {
return {
output: `⚠️ Navigation command detected in multi-line code block.
Navigation commands (history.back/forward/go, window.location assignment, etc.) destroy the execution context, so any code before or after them may not execute properly.
Please split this into TWO separate tool calls:
1. First tool call - navigation only:
${navigationCommand}
2. Second tool call - everything else (will run on the new page after navigation completes)
This ensures reliable execution.`,
isError: true,
details: { files: [] },
};
}
}
// Check if scripting API is available
if (!browser.scripting || !browser.scripting.executeScript) {
return {
output:
"Error: browser.scripting API is not available. Make sure 'scripting' permission is declared in manifest.json",
isError: true,
details: { files: [] },
};
}
// Get the active tab
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
if (!tab || !tab.id) {
return {
output: "Error: No active tab found",
isError: true,
details: { files: [] },
};
}
// Check if we can execute scripts on this tab
if (
tab.url?.startsWith("chrome://") ||
tab.url?.startsWith("chrome-extension://") ||
tab.url?.startsWith("about:")
) {
return {
output: `Error: Cannot execute scripts on ${tab.url}. Extension pages and internal URLs are protected.`,
isError: true,
details: { files: [] },
};
}
// First, detect CSP policy to choose execution strategy
const cspCheckResults = await browser.scripting.executeScript({
target: { tabId: tab.id },
world: "MAIN",
func: () => {
// Try to detect if eval is allowed
let canEval = false;
try {
// biome-ignore lint/security/noGlobalEval: CSP detection test
// biome-ignore lint/complexity/noCommaOperator: indirect eval pattern
(0, eval)("1");
canEval = true;
} catch (e) {
// eval blocked
}
// Try to detect if script tag injection works
let canUseScriptTag = false;
const testId = `__test_${Date.now()}`;
const testScript = document.createElement("script");
testScript.textContent = `window.${testId} = true;`;
try {
document.head.appendChild(testScript);
// Check if it executed synchronously
canUseScriptTag = !!(window as any)[testId];
delete (window as any)[testId];
testScript.remove();
} catch (e) {
// script injection failed
}
return { canEval, canUseScriptTag };
},
});
const canUseEval = cspCheckResults[0]?.result?.canEval ?? false;
const canUseScriptTag = cspCheckResults[0]?.result?.canUseScriptTag ?? false;
// If neither method works, fallback to JailJS via content script
if (!canUseEval && !canUseScriptTag) {
console.log("[pi-ai] CSP blocks eval and script injection, falling back to JailJS");
// Send execution request to content script
const response = await new Promise<{
success: boolean;
result?: unknown;
console?: Array<{ type: string; args: unknown[] }>;
files?: Array<{ fileName: string; content: string | Uint8Array; mimeType: string }>;
error?: string;
stack?: string;
}>((resolve) => {
browser.tabs.sendMessage(
tab.id,
{
type: "EXECUTE_CODE",
mode: "jailjs",
code: args.code,
},
resolve,
);
});
if (!response.success) {
return {
output: `JailJS Execution Error: ${response.error}\n\nStack:\n${response.stack || "No stack trace"}`,
isError: true,
details: { files: [] },
};
}
// Format console output
const formatArg = (arg: unknown): string => {
if (arg === null) return "null";
if (arg === undefined) return "undefined";
if (typeof arg === "string") return arg;
if (typeof arg === "number" || typeof arg === "boolean") return String(arg);
try {
return JSON.stringify(arg, null, 2);
} catch {
return String(arg);
}
};
// Build output with console logs
let output = "";
// Add console output
if (response.console && response.console.length > 0) {
for (const entry of response.console) {
const prefix = entry.type === "error" ? "[ERROR]" : entry.type === "warn" ? "[WARN]" : "";
const formattedArgs = entry.args.map(formatArg).join(" ");
const line = prefix ? `${prefix} ${formattedArgs}` : formattedArgs;
output += line + "\n";
}
}
// Add file notifications
if (response.files && response.files.length > 0) {
output += `\n[Files returned: ${response.files.length}]\n`;
for (const file of response.files) {
output += ` - ${file.fileName} (${file.mimeType})\n`;
}
}
// Convert files to base64 for transport
const files = (response.files || []).map(
(f: { fileName: string; content: string | Uint8Array; mimeType: string }) => {
const toBase64 = (input: string | Uint8Array): { base64: string; size: number } => {
if (input instanceof Uint8Array) {
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < input.length; i += chunk) {
binary += String.fromCharCode(...input.subarray(i, i + chunk));
}
return { base64: btoa(binary), size: input.length };
} else {
const enc = new TextEncoder();
const bytes = enc.encode(input);
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return { base64: btoa(binary), size: bytes.length };
}
};
const { base64, size } = toBase64(f.content);
return {
fileName: f.fileName || "file",
mimeType: f.mimeType || "application/octet-stream",
size,
contentBase64: base64,
};
},
);
return {
output: output.trim() || "Code executed successfully (no output)",
isError: false,
details: { files },
};
}
// Execute the JavaScript in the tab context with abort handling
const executePromise = browser.scripting.executeScript({
target: { tabId: tab.id },
world: "MAIN",
func: (code: string, useScriptTag: boolean) => {
return new Promise((resolve) => {
// Capture console output
const consoleOutput: Array<{ type: string; args: unknown[] }> = [];
const files: Array<{ fileName: string; content: string | Uint8Array; mimeType: string }> = [];
let timeoutId: number;
const originalConsole = {
log: console.log,
warn: console.warn,
error: console.error,
};
// Override console methods to capture output
console.log = (...args: unknown[]) => {
consoleOutput.push({ type: "log", args });
originalConsole.log(...args);
};
console.warn = (...args: unknown[]) => {
consoleOutput.push({ type: "warn", args });
originalConsole.warn(...args);
};
console.error = (...args: unknown[]) => {
consoleOutput.push({ type: "error", args });
originalConsole.error(...args);
};
// Create returnFile function
(window as any).returnFile = async (
fileName: string,
content: string | Uint8Array | Blob | Record<string, unknown>,
mimeType?: string,
) => {
let finalContent: string | Uint8Array;
let finalMimeType: string;
if (content instanceof Blob) {
// Convert Blob to Uint8Array
const arrayBuffer = await content.arrayBuffer();
finalContent = new Uint8Array(arrayBuffer);
finalMimeType = mimeType || content.type || "application/octet-stream";
// Enforce MIME type requirement for binary data
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 {
// Assume it's an object to be JSON stringified
finalContent = JSON.stringify(content, null, 2);
finalMimeType = mimeType || "application/json";
}
files.push({
fileName,
content: finalContent,
mimeType: finalMimeType,
});
};
const cleanup = () => {
// Clear timeout
if (timeoutId) clearTimeout(timeoutId);
// Restore console
console.log = originalConsole.log;
console.warn = originalConsole.warn;
console.error = originalConsole.error;
// Clean up returnFile
delete (window as any).returnFile;
};
const handleError = (error: unknown) => {
cleanup();
const err = error as Error;
resolve({
success: false,
error: err.message,
stack: err.stack,
console: consoleOutput,
});
};
const handleSuccess = () => {
cleanup();
resolve({
success: true,
console: consoleOutput,
files: files,
});
};
// Set timeout to prevent hanging indefinitely
timeoutId = setTimeout(() => {
cleanup();
resolve({
success: false,
error: "Execution timeout",
stack: "Code execution did not complete within 30 seconds",
console: consoleOutput,
});
}, 30000) as unknown as number;
try {
if (useScriptTag) {
// Strategy 2: Inject as script tag (works with 'unsafe-inline' but not Trusted Types)
const script = document.createElement("script");
const uniqueId = `__browserjs_${Date.now()}_${Math.random().toString(36).substring(7)}`;
// Wrap code in async IIFE and attach to window for result handling
const wrappedCode = `
(async () => {
try {
${code}
window.${uniqueId} = { success: true };
} catch (error) {
window.${uniqueId} = { success: false, error: error.message, stack: error.stack };
}
})();
`;
script.textContent = wrappedCode;
// Listen for execution completion
const checkCompletion = () => {
const result = (window as any)[uniqueId];
if (result) {
delete (window as any)[uniqueId];
script.remove();
if (result.success === false) {
handleError(new Error(result.error));
} else {
handleSuccess();
}
} else {
setTimeout(checkCompletion, 100);
}
};
document.head.appendChild(script);
setTimeout(checkCompletion, 100);
} else {
// Strategy 1: Use eval (fastest, but requires 'unsafe-eval' in CSP)
// Wrap code in async function to support await
const asyncCode = `(async () => { ${code} })()`;
// biome-ignore lint/security/noGlobalEval: needed for code execution
// biome-ignore lint/complexity/noCommaOperator: indirect eval pattern
const resultPromise = (0, eval)(asyncCode);
// Wait for async code to complete
Promise.resolve(resultPromise).then(handleSuccess).catch(handleError);
}
} catch (error: unknown) {
handleError(error);
}
});
},
args: [args.code, canUseScriptTag && !canUseEval],
});
// Race between execution and abort signal
let results: Awaited<typeof executePromise>;
if (signal) {
const abortPromise = new Promise<never>((_, reject) => {
signal.addEventListener("abort", () => reject(new Error("Aborted")));
});
results = await Promise.race([executePromise, abortPromise]);
} else {
results = await executePromise;
}
const result = results[0]?.result as
| {
success: boolean;
console?: Array<{ type: string; args: unknown[] }>;
files?: Array<{ fileName: string; content: string | Uint8Array; mimeType: string }>;
error?: string;
stack?: string;
}
| undefined;
if (!result) {
return {
output: "Error: No result returned from script execution",
isError: true,
details: { files: [] },
};
}
if (!result.success) {
// Build error output with console logs if any
let errorOutput = `Error: ${result.error}\n\nStack trace:\n${result.stack || "No stack trace available"}`;
if (result.console && result.console.length > 0) {
errorOutput += "\n\nConsole output:\n";
for (const entry of result.console) {
const prefix = entry.type === "error" ? "[ERROR]" : entry.type === "warn" ? "[WARN]" : "[LOG]";
const line = `${prefix} ${entry.args.join(" ")}`;
errorOutput += line + "\n";
}
}
return {
output: errorOutput,
isError: true,
details: { files: [] },
};
}
// Build output with console logs
let output = "";
// Add console output
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";
}
}
// Add file notifications
if (result.files && result.files.length > 0) {
output += `\n[Files returned: ${result.files.length}]\n`;
for (const file of result.files) {
output += ` - ${file.fileName} (${file.mimeType})\n`;
}
}
// Convert files to base64 for transport
const files = (result.files || []).map(
(f: { fileName: string; content: string | Uint8Array; mimeType: string }) => {
const toBase64 = (input: string | Uint8Array): { base64: string; size: number } => {
if (input instanceof Uint8Array) {
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < input.length; i += chunk) {
binary += String.fromCharCode(...input.subarray(i, i + chunk));
}
return { base64: btoa(binary), size: input.length };
} else {
const enc = new TextEncoder();
const bytes = enc.encode(input);
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return { base64: btoa(binary), size: bytes.length };
}
};
const { base64, size } = toBase64(f.content);
return {
fileName: f.fileName || "file",
mimeType: f.mimeType || "application/octet-stream",
size,
contentBase64: base64,
};
},
);
return {
output: output.trim() || "Code executed successfully (no output)",
isError: false,
details: { files },
};
} catch (error: unknown) {
const err = error as Error;
// Check if this was an abort
if (err.message === "Aborted" || signal?.aborted) {
return {
output: "Tool execution was aborted by user",
isError: true,
details: { files: [] },
};
}
return {
output: `Error executing script: ${err.message}`,
isError: true,
details: { files: [] },
};
}
},
};
// Browser JavaScript renderer
interface BrowserJavaScriptParams {
code: string;
}
interface BrowserJavaScriptResult {
files?: Array<{
fileName: string;
mimeType: string;
size: number;
contentBase64: string;
}>;
}
export const browserJavaScriptRenderer: ToolRenderer<BrowserJavaScriptParams, BrowserJavaScriptResult> = {
renderParams(params: BrowserJavaScriptParams, isStreaming?: boolean): TemplateResult {
if (isStreaming && (!params.code || params.code.length === 0)) {
return html`<div class="text-sm text-muted-foreground">Writing JavaScript code...</div>`;
}
return html`
<div class="text-sm text-muted-foreground mb-2">Executing in active tab</div>
<code-block .code=${params.code || ""} language="javascript"></code-block>
`;
},
renderResult(_params: BrowserJavaScriptParams, result: ToolResultMessage<BrowserJavaScriptResult>): TemplateResult {
const output = result.output || "";
const files = result.details?.files || [];
const isError = result.isError === true;
const attachments: Attachment[] = files.map((f, i) => {
// Decode base64 content for text files to show in overlay
let extractedText: string | undefined;
const isTextBased =
f.mimeType?.startsWith("text/") ||
f.mimeType === "application/json" ||
f.mimeType === "application/javascript" ||
f.mimeType?.includes("xml");
if (isTextBased && f.contentBase64) {
try {
extractedText = atob(f.contentBase64);
} catch (e) {
console.warn("Failed to decode base64 content for", f.fileName);
}
}
return {
id: `browser-js-${Date.now()}-${i}`,
type: f.mimeType?.startsWith("image/") ? "image" : "document",
fileName: f.fileName || `file-${i}`,
mimeType: f.mimeType || "application/octet-stream",
size: f.size ?? 0,
content: f.contentBase64,
preview: f.mimeType?.startsWith("image/") ? f.contentBase64 : undefined,
extractedText,
};
});
if (isError) {
return html`
<div class="text-sm">
<div class="text-destructive font-medium mb-1">Execution failed:</div>
<pre class="text-xs font-mono text-destructive bg-destructive/10 p-2 rounded overflow-x-auto">${output}</pre>
</div>
`;
}
return html`
<div class="flex flex-col gap-3">
${output ? html`<console-block .content=${output}></console-block>` : ""}
${
attachments.length
? html`<div class="flex flex-wrap gap-2">
${attachments.map((att) => html`<attachment-tile .attachment=${att}></attachment-tile>`)}
</div>`
: ""
}
</div>
`;
},
};
// Auto-register the renderer
registerToolRenderer(browserJavaScriptTool.name, browserJavaScriptRenderer);

View file

@ -1,18 +0,0 @@
import {
BashRenderer,
CalculateRenderer,
createJavaScriptReplTool,
GetCurrentTimeRenderer,
javascriptReplTool,
registerToolRenderer,
} from "@mariozechner/pi-web-ui";
import "./browser-javascript.js"; // Import for side effects (registers renderer)
// Register all built-in tool renderers
registerToolRenderer("calculate", new CalculateRenderer());
registerToolRenderer("get_current_time", new GetCurrentTimeRenderer());
registerToolRenderer("bash", new BashRenderer());
// Re-export for convenience
export { createJavaScriptReplTool, javascriptReplTool };
export { browserJavaScriptTool } from "./browser-javascript.js";

View file

@ -1,31 +0,0 @@
// Dev mode hot reload - check if we're in development
const connectWebSocket = () => {
try {
const ws = new WebSocket("ws://localhost:8765");
ws.onopen = () => {
console.log("[HotReload] Connected to dev server");
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "reload") {
console.log("[HotReload] Reloading extension...");
chrome.runtime.reload();
}
};
ws.onerror = () => {
console.log("[HotReload] WebSocket error");
// Silent fail - dev server might not be running
};
ws.onclose = () => {
// Reconnect after 2 seconds
setTimeout(connectWebSocket, 2000);
};
} catch (e) {
// Silent fail if WebSocket not available
}
};
connectWebSocket();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 B

View file

@ -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>

View file

@ -1,12 +0,0 @@
// Minimal sandbox.js - just listens for sandbox-load and writes the content
window.addEventListener("message", (event) => {
if (event.data.type === "sandbox-load") {
// Write the complete HTML (which includes runtime + user code)
document.open();
document.write(event.data.code);
document.close();
}
});
// Signal ready to parent
window.parent.postMessage({ type: "sandbox-ready" }, "*");

View file

@ -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>

View file

@ -1,14 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022",
"types": ["chrome"]
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View file

@ -1,6 +0,0 @@
{
"extends": "./tsconfig.build.json",
"compilerOptions": {
"noEmit": true
}
}

View file

@ -7,7 +7,7 @@
"path": "../mini-lit"
},
{
"path": "../genai-workshop-new"
"path": "../sitegeist"
}
],
"settings": {}