mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 09:01:14 +00:00
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:
parent
7e79c05407
commit
aa005d062a
25 changed files with 3 additions and 3122 deletions
|
|
@ -7,8 +7,8 @@
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "npm run clean --workspaces",
|
"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",
|
"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,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\"",
|
"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",
|
"check": "biome check --write . && npm run check --workspaces && tsc --noEmit",
|
||||||
"test": "npm run test --workspaces --if-present",
|
"test": "npm run test --workspaces --if-present",
|
||||||
"version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js",
|
"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
|
|
@ -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'"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
});
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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 {};
|
|
||||||
|
|
@ -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;
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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";
|
|
||||||
|
|
@ -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 |
|
|
@ -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,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" }, "*");
|
|
||||||
|
|
@ -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,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"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"extends": "./tsconfig.build.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"noEmit": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
"path": "../mini-lit"
|
"path": "../mini-lit"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../genai-workshop-new"
|
"path": "../sitegeist"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"settings": {}
|
"settings": {}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue