mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 06:04:40 +00:00
feat: add cross-browser extension with AI reading assistant
- Create Pi Reader browser extension for Chrome/Firefox - Chrome uses Side Panel API, Firefox uses Sidebar Action API - Supports both browsers with separate manifests and unified codebase - Built with mini-lit components and Tailwind CSS v4 - Features model selection dialog with Ollama support - Hot reload development server watches both browser builds - Add useDefineForClassFields: false to fix LitElement reactivity
This commit is contained in:
parent
c1185c7b95
commit
b67c10dfb1
33 changed files with 4453 additions and 1202 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -5,6 +5,8 @@ dist/
|
|||
*.tsbuildinfo
|
||||
packages/*/node_modules/
|
||||
packages/*/dist/
|
||||
packages/*/dist-chrome/
|
||||
packages/*/dist-firefox/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
|
|
|||
2041
package-lock.json
generated
2041
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -7,7 +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-agent && 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-reader-extension && npm run build -w @mariozechner/pi-agent && npm run build -w @mariozechner/pi",
|
||||
"dev": "concurrently --names \"ai,browser-ext,tui\" --prefix-colors \"cyan,yellow,magenta\" \"npm run dev -w @mariozechner/pi-ai\" \"npm run dev -w @mariozechner/pi-reader-extension\" \"npm run dev -w @mariozechner/pi-tui\"",
|
||||
"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",
|
||||
|
|
@ -22,6 +23,7 @@
|
|||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.1.4",
|
||||
"@types/node": "^22.10.5",
|
||||
"concurrently": "^9.2.1",
|
||||
"husky": "^9.1.7",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.9.2"
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
"clean": "rm -rf dist",
|
||||
"generate-models": "npx tsx scripts/generate-models.ts",
|
||||
"build": "npm run generate-models && tsc -p tsconfig.build.json",
|
||||
"dev": "tsc -p tsconfig.build.json --watch",
|
||||
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
|
||||
"check": "biome check --write .",
|
||||
"test": "vitest --run",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
172
packages/browser-extension/README.md
Normal file
172
packages/browser-extension/README.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# Pi Reader Browser Extension
|
||||
|
||||
A cross-browser extension that provides an AI-powered reading assistant in a side panel (Chrome/Edge) or sidebar (Firefox), built with mini-lit components and Tailwind CSS v4.
|
||||
|
||||
## Browser Support
|
||||
|
||||
- **Chrome/Edge** - Uses Side Panel API (Manifest V3)
|
||||
- **Firefox** - Uses Sidebar Action API (Manifest V3)
|
||||
- **Opera** - Sidebar support (untested but should work with Firefox manifest)
|
||||
|
||||
## Architecture
|
||||
|
||||
The extension adapts to each browser's UI paradigm:
|
||||
- **Chrome/Edge** - Side Panel API for dedicated panel UI
|
||||
- **Firefox** - Sidebar Action API for sidebar UI
|
||||
- **Direct API Access** - Both can call AI APIs directly (no background worker needed)
|
||||
- **Page Content Access** - Uses `chrome.scripting.executeScript` to extract page text
|
||||
|
||||
## Understanding mini-lit
|
||||
|
||||
Before working on the UI, read these files to understand the component library:
|
||||
|
||||
- `node_modules/@mariozechner/mini-lit/README.md` - Complete component documentation
|
||||
- `node_modules/@mariozechner/mini-lit/llms.txt` - LLM-friendly component reference
|
||||
- `node_modules/@mariozechner/mini-lit/dist/*.ts` - Source files for specific components
|
||||
|
||||
Key concepts:
|
||||
- **Functional Components** - Stateless functions that return `TemplateResult` (Button, Badge, etc.)
|
||||
- **Custom Elements** - Stateful LitElement classes (`<theme-toggle>`, `<markdown-block>`, etc.)
|
||||
- **Reactive State** - Use `createState()` for reactive UI updates
|
||||
- **Claude Theme** - We use the Claude theme from mini-lit
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
packages/browser-extension/
|
||||
├── src/
|
||||
│ ├── app.css # Tailwind v4 entry point with Claude theme
|
||||
│ ├── background.ts # Service worker for opening side panel
|
||||
│ ├── sidepanel.html # Side panel HTML entry point
|
||||
│ └── sidepanel.ts # Main side panel app with hot reload
|
||||
├── scripts/
|
||||
│ ├── build.mjs # esbuild bundler configuration
|
||||
│ └── dev-server.mjs # WebSocket server for hot reloading
|
||||
├── manifest.chrome.json # Chrome/Edge manifest
|
||||
├── manifest.firefox.json # Firefox manifest
|
||||
├── icon-*.png # Extension icons
|
||||
├── dist-chrome/ # Chrome build (git-ignored)
|
||||
└── dist-firefox/ # Firefox build (git-ignored)
|
||||
```
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
1. Install dependencies from monorepo root:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Build the extension:
|
||||
```bash
|
||||
# Build for both browsers
|
||||
npm run build -w @mariozechner/pi-reader-extension
|
||||
|
||||
# Or build for specific browser
|
||||
npm run build:chrome -w @mariozechner/pi-reader-extension
|
||||
npm run build:firefox -w @mariozechner/pi-reader-extension
|
||||
```
|
||||
|
||||
3. Load the extension:
|
||||
|
||||
**Chrome/Edge:**
|
||||
- Open `chrome://extensions/` or `edge://extensions/`
|
||||
- Enable "Developer mode"
|
||||
- Click "Load unpacked"
|
||||
- Select `packages/browser-extension/dist-chrome/`
|
||||
|
||||
**Firefox:**
|
||||
- Open `about:debugging`
|
||||
- Click "This Firefox"
|
||||
- Click "Load Temporary Add-on"
|
||||
- Select any file in `packages/browser-extension/dist-firefox/`
|
||||
|
||||
### Development Workflow
|
||||
|
||||
1. **Start the dev server** (from monorepo root):
|
||||
```bash
|
||||
# For Chrome development
|
||||
npm run dev -w @mariozechner/pi-reader-extension
|
||||
|
||||
# For Firefox development
|
||||
npm run dev:firefox -w @mariozechner/pi-reader-extension
|
||||
```
|
||||
|
||||
This runs three processes in parallel:
|
||||
- **esbuild** - Watches and rebuilds TypeScript files
|
||||
- **Tailwind CSS v4** - Watches and rebuilds styles
|
||||
- **WebSocket server** - Watches dist/ and triggers extension reload
|
||||
|
||||
2. **Automatic reloading**:
|
||||
- Any change to source files triggers a rebuild
|
||||
- The WebSocket server detects dist/ changes
|
||||
- Side panel connects to `ws://localhost:8765`
|
||||
- Extension auto-reloads via `chrome.runtime.reload()`
|
||||
|
||||
3. **Open the side panel**:
|
||||
- Click the extension icon in Chrome toolbar
|
||||
- Or use Chrome's side panel button (top-right)
|
||||
|
||||
## Key Files
|
||||
|
||||
### `src/sidepanel.ts`
|
||||
Main application logic:
|
||||
- Extracts page content via `chrome.scripting.executeScript`
|
||||
- Manages chat UI with mini-lit components
|
||||
- Handles WebSocket connection for hot reload
|
||||
- Direct AI API calls (no background worker needed)
|
||||
|
||||
### `src/app.css`
|
||||
Tailwind v4 configuration:
|
||||
- Imports Claude theme from mini-lit
|
||||
- Uses `@source` directive to scan mini-lit components
|
||||
- Compiled to `dist/app.css` during build
|
||||
|
||||
### `scripts/build.mjs`
|
||||
Build configuration:
|
||||
- Uses esbuild for fast TypeScript bundling
|
||||
- Copies static files (HTML, manifest, icons)
|
||||
- Supports watch mode for development
|
||||
|
||||
### `scripts/dev-server.mjs`
|
||||
Hot reload server:
|
||||
- WebSocket server on port 8765
|
||||
- Watches `dist/` directory for changes
|
||||
- Sends reload messages to connected clients
|
||||
|
||||
## Working with mini-lit Components
|
||||
|
||||
### Basic Usage
|
||||
Read `../../mini-lit/llms.txt` and `../../mini-lit/README.md` in full. If in doubt, find the component in `../../mini-lit/src/` and read its source file in full.
|
||||
|
||||
### Tailwind Classes
|
||||
All standard Tailwind utilities work, plus mini-lit's theme variables:
|
||||
- `bg-background`, `text-foreground` - Theme-aware colors
|
||||
- `bg-card`, `border-border` - Component backgrounds
|
||||
- `text-muted-foreground` - Secondary text
|
||||
- `bg-primary`, `text-primary-foreground` - Primary actions
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Extension doesn't reload automatically
|
||||
- Check WebSocket server is running (port 8765)
|
||||
- Check console for connection errors
|
||||
- Manually reload at `chrome://extensions/`
|
||||
|
||||
### Side panel doesn't open
|
||||
- Check manifest permissions
|
||||
- Ensure background service worker is loaded
|
||||
- Try clicking extension icon directly
|
||||
|
||||
### Styles not updating
|
||||
- Ensure Tailwind watcher is running
|
||||
- Check `src/app.css` imports
|
||||
- Clear Chrome extension cache
|
||||
|
||||
## Building for Production
|
||||
|
||||
```bash
|
||||
npm run build -w @mariozechner/pi-reader-extension
|
||||
```
|
||||
|
||||
This creates an optimized build in `dist/` without hot reload code.
|
||||
BIN
packages/browser-extension/icon-128.png
Normal file
BIN
packages/browser-extension/icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 299 B |
BIN
packages/browser-extension/icon-16.png
Normal file
BIN
packages/browser-extension/icon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 B |
BIN
packages/browser-extension/icon-48.png
Normal file
BIN
packages/browser-extension/icon-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 125 B |
32
packages/browser-extension/manifest.chrome.json
Normal file
32
packages/browser-extension/manifest.chrome.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Pi Reader Assistant",
|
||||
"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/*"
|
||||
]
|
||||
}
|
||||
43
packages/browser-extension/manifest.firefox.json
Normal file
43
packages/browser-extension/manifest.firefox.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Pi Reader Assistant",
|
||||
"description": "Use @mariozechner/pi-ai to summarize and highlight the page you are reading.",
|
||||
"version": "0.5.43",
|
||||
"action": {
|
||||
"default_title": "Click to open sidebar"
|
||||
},
|
||||
"background": {
|
||||
"scripts": ["background.js"],
|
||||
"type": "module"
|
||||
},
|
||||
"icons": {
|
||||
"16": "icon-16.png",
|
||||
"48": "icon-48.png",
|
||||
"128": "icon-128.png"
|
||||
},
|
||||
"sidebar_action": {
|
||||
"default_panel": "sidepanel.html",
|
||||
"default_title": "Pi Reader Assistant",
|
||||
"default_icon": {
|
||||
"16": "icon-16.png",
|
||||
"48": "icon-48.png"
|
||||
},
|
||||
"open_at_install": false
|
||||
},
|
||||
"permissions": [
|
||||
"storage",
|
||||
"activeTab",
|
||||
"scripting"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://*/*",
|
||||
"http://localhost/*",
|
||||
"http://127.0.0.1/*"
|
||||
],
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "pi-reader@mariozechner.at",
|
||||
"strict_min_version": "115.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
32
packages/browser-extension/manifest.json
Normal file
32
packages/browser-extension/manifest.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Pi Reader Assistant",
|
||||
"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/*"
|
||||
]
|
||||
}
|
||||
32
packages/browser-extension/package.json
Normal file
32
packages/browser-extension/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "@mariozechner/pi-reader-extension",
|
||||
"version": "0.5.43",
|
||||
"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/mini-lit": "^0.1.4",
|
||||
"@mariozechner/pi-ai": "^0.5.43",
|
||||
"lit": "^3.3.1",
|
||||
"lucide": "^0.544.0",
|
||||
"ollama": "^0.6.0"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
84
packages/browser-extension/scripts/build.mjs
Normal file
84
packages/browser-extension/scripts/build.mjs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { build, context } from "esbuild";
|
||||
import { copyFileSync, mkdirSync, rmSync } 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");
|
||||
|
||||
// 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")
|
||||
};
|
||||
|
||||
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)
|
||||
}
|
||||
};
|
||||
|
||||
const copyStatic = () => {
|
||||
// Use browser-specific manifest
|
||||
const manifestSource = join(packageRoot, `manifest.${targetBrowser}.json`);
|
||||
const manifestDest = join(outDir, "manifest.json");
|
||||
copyFileSync(manifestSource, manifestDest);
|
||||
|
||||
// Copy other static files
|
||||
const filesToCopy = [
|
||||
"icon-16.png",
|
||||
"icon-48.png",
|
||||
"icon-128.png",
|
||||
join("src", "sidepanel.html")
|
||||
];
|
||||
|
||||
for (const relative of filesToCopy) {
|
||||
const source = join(packageRoot, relative);
|
||||
let destination = join(outDir, relative);
|
||||
if (relative.startsWith("src/")) {
|
||||
destination = join(outDir, relative.slice(4)); // Remove "src/" prefix
|
||||
}
|
||||
copyFileSync(source, destination);
|
||||
}
|
||||
|
||||
console.log(`Built for ${targetBrowser} in ${outDir}`);
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
if (isWatch) {
|
||||
const ctx = await context(buildOptions);
|
||||
await ctx.watch();
|
||||
copyStatic();
|
||||
process.stdout.write("Watching for changes...\n");
|
||||
} else {
|
||||
await build(buildOptions);
|
||||
copyStatic();
|
||||
}
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
83
packages/browser-extension/scripts/dev-server.mjs
Normal file
83
packages/browser-extension/scripts/dev-server.mjs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
13
packages/browser-extension/src/ChatPanel.ts
Normal file
13
packages/browser-extension/src/ChatPanel.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { html, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
|
||||
@customElement("pi-chat-panel")
|
||||
export class ChatPanel extends LitElement {
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<h1>Hello world</h1>`;
|
||||
}
|
||||
}
|
||||
112
packages/browser-extension/src/Input.ts
Normal file
112
packages/browser-extension/src/Input.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { type BaseComponentProps, fc, html } from "@mariozechner/mini-lit";
|
||||
import { type Ref, ref } from "lit/directives/ref.js";
|
||||
import { i18n } from "./utils/i18n.js";
|
||||
|
||||
export type InputType = "text" | "email" | "password" | "number" | "url" | "tel" | "search";
|
||||
export type InputSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface InputProps extends BaseComponentProps {
|
||||
type?: InputType;
|
||||
size?: InputSize;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
name?: string;
|
||||
autocomplete?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
onInput?: (e: Event) => void;
|
||||
onChange?: (e: Event) => void;
|
||||
onKeyDown?: (e: KeyboardEvent) => void;
|
||||
onKeyUp?: (e: KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
export const Input = fc<InputProps>(
|
||||
({
|
||||
type = "text",
|
||||
size = "md",
|
||||
value = "",
|
||||
placeholder = "",
|
||||
label = "",
|
||||
error = "",
|
||||
disabled = false,
|
||||
required = false,
|
||||
name = "",
|
||||
autocomplete = "",
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
inputRef,
|
||||
onInput,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
className = "",
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
sm: "h-8 px-3 py-1 text-sm",
|
||||
md: "h-9 px-3 py-1 text-sm md:text-sm",
|
||||
lg: "h-10 px-4 py-1 text-base",
|
||||
};
|
||||
|
||||
const baseClasses =
|
||||
"flex w-full min-w-0 rounded-md border bg-transparent text-foreground shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium";
|
||||
const interactionClasses =
|
||||
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground";
|
||||
const focusClasses = "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]";
|
||||
const darkClasses = "dark:bg-input/30";
|
||||
const stateClasses = error
|
||||
? "border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40"
|
||||
: "border-input";
|
||||
const disabledClasses = "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50";
|
||||
|
||||
const handleInput = (e: Event) => {
|
||||
onInput?.(e);
|
||||
};
|
||||
|
||||
const handleChange = (e: Event) => {
|
||||
onChange?.(e);
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="flex flex-col gap-1.5 ${className}">
|
||||
${
|
||||
label
|
||||
? html`
|
||||
<label class="text-sm font-medium text-foreground">
|
||||
${label} ${required ? html`<span class="text-destructive">${i18n("*")}</span>` : ""}
|
||||
</label>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
<input
|
||||
type="${type}"
|
||||
class="${baseClasses} ${
|
||||
sizeClasses[size]
|
||||
} ${interactionClasses} ${focusClasses} ${darkClasses} ${stateClasses} ${disabledClasses}"
|
||||
.value=${value}
|
||||
placeholder="${placeholder}"
|
||||
?disabled=${disabled}
|
||||
?required=${required}
|
||||
?aria-invalid=${!!error}
|
||||
name="${name}"
|
||||
autocomplete="${autocomplete}"
|
||||
min="${min ?? ""}"
|
||||
max="${max ?? ""}"
|
||||
step="${step ?? ""}"
|
||||
@input=${handleInput}
|
||||
@change=${handleChange}
|
||||
@keydown=${onKeyDown}
|
||||
@keyup=${onKeyUp}
|
||||
${inputRef ? ref(inputRef) : ""}
|
||||
/>
|
||||
${error ? html`<span class="text-sm text-destructive">${error}</span>` : ""}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
);
|
||||
14
packages/browser-extension/src/app.css
Normal file
14
packages/browser-extension/src/app.css
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/* Import Claude theme from mini-lit */
|
||||
@import "@mariozechner/mini-lit/styles/themes/default.css";
|
||||
|
||||
/* Tell Tailwind to scan mini-lit components */
|
||||
@source "../../../node_modules/@mariozechner/mini-lit/dist";
|
||||
|
||||
/* Import Tailwind */
|
||||
/* biome-ignore lint/correctness/noInvalidPositionAtImportRule: fuck you */
|
||||
@import "tailwindcss";
|
||||
|
||||
body {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
23
packages/browser-extension/src/background.ts
Normal file
23
packages/browser-extension/src/background.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// Declare browser global for Firefox
|
||||
declare const browser: any;
|
||||
|
||||
// Detect browser type
|
||||
const isFirefox = typeof browser !== "undefined" && typeof browser.runtime !== "undefined";
|
||||
const browserAPI = isFirefox ? browser : chrome;
|
||||
|
||||
// Open side panel/sidebar when extension icon is clicked
|
||||
browserAPI.action.onClicked.addListener((tab: chrome.tabs.Tab) => {
|
||||
if (isFirefox) {
|
||||
// Firefox: Toggle the sidebar
|
||||
if (typeof browser !== "undefined" && browser.sidebarAction) {
|
||||
browser.sidebarAction.toggle();
|
||||
}
|
||||
} else {
|
||||
// Chrome: Open the side panel
|
||||
if (tab.id && chrome.sidePanel) {
|
||||
chrome.sidePanel.open({ tabId: tab.id });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export {};
|
||||
55
packages/browser-extension/src/dialogs/DialogBase.ts
Normal file
55
packages/browser-extension/src/dialogs/DialogBase.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { Dialog } from "@mariozechner/mini-lit/dist/Dialog.js";
|
||||
import { LitElement, type TemplateResult } from "lit";
|
||||
|
||||
export abstract class DialogBase extends LitElement {
|
||||
// Modal configuration - can be overridden by subclasses
|
||||
protected modalWidth = "min(600px, 90vw)";
|
||||
protected modalHeight = "min(600px, 80vh)";
|
||||
private boundHandleKeyDown?: (e: KeyboardEvent) => void;
|
||||
private previousFocus?: HTMLElement;
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
open() {
|
||||
// Store the currently focused element
|
||||
this.previousFocus = document.activeElement as HTMLElement;
|
||||
|
||||
document.body.appendChild(this);
|
||||
this.boundHandleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", this.boundHandleKeyDown);
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.boundHandleKeyDown) {
|
||||
window.removeEventListener("keydown", this.boundHandleKeyDown);
|
||||
}
|
||||
this.remove();
|
||||
|
||||
// Restore focus to the previously focused element
|
||||
if (this.previousFocus?.focus) {
|
||||
// Use requestAnimationFrame to ensure the dialog is fully removed first
|
||||
requestAnimationFrame(() => {
|
||||
this.previousFocus?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Abstract method that subclasses must implement
|
||||
protected abstract renderContent(): TemplateResult;
|
||||
|
||||
override render() {
|
||||
return Dialog({
|
||||
isOpen: true,
|
||||
onClose: () => this.close(),
|
||||
width: this.modalWidth,
|
||||
height: this.modalHeight,
|
||||
children: this.renderContent(),
|
||||
});
|
||||
}
|
||||
}
|
||||
325
packages/browser-extension/src/dialogs/ModelSelector.ts
Normal file
325
packages/browser-extension/src/dialogs/ModelSelector.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
import { Badge, Button, DialogHeader, html, icon, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { MODELS } from "@mariozechner/pi-ai/dist/models.generated.js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
import { Brain, Image as ImageIcon } from "lucide";
|
||||
import { Ollama } from "ollama/dist/browser.mjs";
|
||||
import { Input } from "../Input.js";
|
||||
import { formatModelCost } from "../utils/format.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
import { DialogBase } from "./DialogBase.js";
|
||||
|
||||
@customElement("agent-model-selector")
|
||||
export class ModelSelector extends DialogBase {
|
||||
@state() currentModel: Model<any> | null = null;
|
||||
@state() searchQuery = "";
|
||||
@state() filterThinking = false;
|
||||
@state() filterVision = false;
|
||||
@state() ollamaModels: Model<any>[] = [];
|
||||
@state() ollamaError: string | null = null;
|
||||
@state() selectedIndex = 0;
|
||||
@state() private navigationMode: "mouse" | "keyboard" = "mouse";
|
||||
|
||||
private onSelectCallback?: (model: Model<any>) => void;
|
||||
private scrollContainerRef = createRef<HTMLDivElement>();
|
||||
private searchInputRef = createRef<HTMLInputElement>();
|
||||
private lastMousePosition = { x: 0, y: 0 };
|
||||
|
||||
protected override modalWidth = "min(400px, 90vw)";
|
||||
|
||||
static async open(currentModel: Model<any> | null, onSelect: (model: Model<any>) => void) {
|
||||
const selector = new ModelSelector();
|
||||
selector.currentModel = currentModel;
|
||||
selector.onSelectCallback = onSelect;
|
||||
selector.open();
|
||||
selector.fetchOllamaModels();
|
||||
}
|
||||
|
||||
override async firstUpdated(changedProperties: PropertyValues): Promise<void> {
|
||||
super.firstUpdated(changedProperties);
|
||||
// Wait for dialog to be fully rendered
|
||||
await this.updateComplete;
|
||||
// Focus the search input when dialog opens
|
||||
this.searchInputRef.value?.focus();
|
||||
|
||||
// Track actual mouse movement
|
||||
this.addEventListener("mousemove", (e: MouseEvent) => {
|
||||
// Check if mouse actually moved
|
||||
if (e.clientX !== this.lastMousePosition.x || e.clientY !== this.lastMousePosition.y) {
|
||||
this.lastMousePosition = { x: e.clientX, y: e.clientY };
|
||||
// Only switch to mouse mode on actual mouse movement
|
||||
if (this.navigationMode === "keyboard") {
|
||||
this.navigationMode = "mouse";
|
||||
// Update selection to the item under the mouse
|
||||
const target = e.target as HTMLElement;
|
||||
const modelItem = target.closest("[data-model-item]");
|
||||
if (modelItem) {
|
||||
const allItems = this.scrollContainerRef.value?.querySelectorAll("[data-model-item]");
|
||||
if (allItems) {
|
||||
const index = Array.from(allItems).indexOf(modelItem);
|
||||
if (index !== -1) {
|
||||
this.selectedIndex = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add global keyboard handler for the dialog
|
||||
this.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
// Get filtered models to know the bounds
|
||||
const filteredModels = this.getFilteredModels();
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
this.navigationMode = "keyboard";
|
||||
this.selectedIndex = Math.min(this.selectedIndex + 1, filteredModels.length - 1);
|
||||
this.scrollToSelected();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
this.navigationMode = "keyboard";
|
||||
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
||||
this.scrollToSelected();
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (filteredModels[this.selectedIndex]) {
|
||||
this.handleSelect(filteredModels[this.selectedIndex].model);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchOllamaModels() {
|
||||
try {
|
||||
// Create Ollama client
|
||||
const ollama = new Ollama({ host: "http://localhost:11434" });
|
||||
|
||||
// Get list of available models
|
||||
const { models } = await ollama.list();
|
||||
|
||||
// Fetch details for each model and convert to Model format
|
||||
const ollamaModelPromises: Promise<Model<any> | null>[] = models
|
||||
.map(async (model) => {
|
||||
try {
|
||||
// Get model details
|
||||
const details = await ollama.show({
|
||||
model: model.name,
|
||||
});
|
||||
|
||||
// Some Ollama servers don't report capabilities; don't filter on them
|
||||
|
||||
// Extract model info
|
||||
const modelInfo: any = details.model_info || {};
|
||||
|
||||
// Get context window size - look for architecture-specific keys
|
||||
const architecture = modelInfo["general.architecture"] || "";
|
||||
const contextKey = `${architecture}.context_length`;
|
||||
const contextWindow = parseInt(modelInfo[contextKey] || "8192", 10);
|
||||
const maxTokens = 4096; // Default max output tokens
|
||||
|
||||
// Create Model object manually since ollama models aren't in MODELS constant
|
||||
const ollamaModel: Model<any> = {
|
||||
id: model.name,
|
||||
name: model.name,
|
||||
api: "openai-completions" as any,
|
||||
provider: "ollama",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: contextWindow,
|
||||
maxTokens: maxTokens,
|
||||
};
|
||||
|
||||
return ollamaModel;
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch details for model ${model.name}:`, err);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((m) => m !== null);
|
||||
|
||||
const results = await Promise.all(ollamaModelPromises);
|
||||
this.ollamaModels = results.filter((m): m is Model<any> => m !== null);
|
||||
} catch (err) {
|
||||
// Ollama not available or other error - silently ignore
|
||||
console.debug("Ollama not available:", err);
|
||||
this.ollamaError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
private formatTokens(tokens: number): string {
|
||||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(0)}M`;
|
||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(0)}`;
|
||||
return String(tokens);
|
||||
}
|
||||
|
||||
private handleSelect(model: Model<any>) {
|
||||
if (model) {
|
||||
this.onSelectCallback?.(model);
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
private getFilteredModels(): Array<{ provider: string; id: string; model: any }> {
|
||||
// Collect all models from all providers
|
||||
const allModels: Array<{ provider: string; id: string; model: any }> = [];
|
||||
for (const [provider, providerData] of Object.entries(MODELS)) {
|
||||
for (const [modelId, model] of Object.entries(providerData)) {
|
||||
allModels.push({ provider, id: modelId, model });
|
||||
}
|
||||
}
|
||||
|
||||
// Add Ollama models
|
||||
for (const ollamaModel of this.ollamaModels) {
|
||||
allModels.push({
|
||||
id: ollamaModel.id,
|
||||
provider: "ollama",
|
||||
model: ollamaModel,
|
||||
});
|
||||
}
|
||||
|
||||
// Filter models based on search and capability filters
|
||||
let filteredModels = allModels;
|
||||
|
||||
// Apply search filter
|
||||
if (this.searchQuery) {
|
||||
filteredModels = filteredModels.filter(({ provider, id, model }) => {
|
||||
const searchTokens = this.searchQuery.split(/\s+/).filter((t) => t);
|
||||
const searchText = `${provider} ${id} ${model.name}`.toLowerCase();
|
||||
return searchTokens.every((token) => searchText.includes(token));
|
||||
});
|
||||
}
|
||||
|
||||
// Apply capability filters
|
||||
if (this.filterThinking) {
|
||||
filteredModels = filteredModels.filter(({ model }) => model.reasoning);
|
||||
}
|
||||
if (this.filterVision) {
|
||||
filteredModels = filteredModels.filter(({ model }) => model.input.includes("image"));
|
||||
}
|
||||
|
||||
// Sort: current model first, then by provider
|
||||
filteredModels.sort((a, b) => {
|
||||
const aIsCurrent = this.currentModel?.id === a.model.id;
|
||||
const bIsCurrent = this.currentModel?.id === b.model.id;
|
||||
if (aIsCurrent && !bIsCurrent) return -1;
|
||||
if (!aIsCurrent && bIsCurrent) return 1;
|
||||
return a.provider.localeCompare(b.provider);
|
||||
});
|
||||
|
||||
return filteredModels;
|
||||
}
|
||||
|
||||
private scrollToSelected() {
|
||||
requestAnimationFrame(() => {
|
||||
const scrollContainer = this.scrollContainerRef.value;
|
||||
const selectedElement = scrollContainer?.querySelectorAll("[data-model-item]")[
|
||||
this.selectedIndex
|
||||
] as HTMLElement;
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected override renderContent(): TemplateResult {
|
||||
const filteredModels = this.getFilteredModels();
|
||||
|
||||
return html`
|
||||
<!-- Header and Search -->
|
||||
<div class="p-6 pb-4 flex flex-col gap-4 border-b border-border flex-shrink-0">
|
||||
${DialogHeader({ title: i18n("Select Model") })}
|
||||
${Input({
|
||||
placeholder: i18n("Search models..."),
|
||||
value: this.searchQuery,
|
||||
inputRef: this.searchInputRef,
|
||||
onInput: (e: Event) => {
|
||||
this.searchQuery = (e.target as HTMLInputElement).value;
|
||||
this.selectedIndex = 0;
|
||||
// Reset scroll position when search changes
|
||||
if (this.scrollContainerRef.value) {
|
||||
this.scrollContainerRef.value.scrollTop = 0;
|
||||
}
|
||||
},
|
||||
})}
|
||||
<div class="flex gap-2">
|
||||
${Button({
|
||||
variant: this.filterThinking ? "default" : "secondary",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
this.filterThinking = !this.filterThinking;
|
||||
this.selectedIndex = 0;
|
||||
if (this.scrollContainerRef.value) {
|
||||
this.scrollContainerRef.value.scrollTop = 0;
|
||||
}
|
||||
},
|
||||
className: "rounded-full",
|
||||
children: html`<span class="inline-flex items-center gap-1">${icon(Brain, "sm")} ${i18n("Thinking")}</span>`,
|
||||
})}
|
||||
${Button({
|
||||
variant: this.filterVision ? "default" : "secondary",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
this.filterVision = !this.filterVision;
|
||||
this.selectedIndex = 0;
|
||||
if (this.scrollContainerRef.value) {
|
||||
this.scrollContainerRef.value.scrollTop = 0;
|
||||
}
|
||||
},
|
||||
className: "rounded-full",
|
||||
children: html`<span class="inline-flex items-center gap-1">${icon(ImageIcon, "sm")} ${i18n("Vision")}</span>`,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable model list -->
|
||||
<div class="flex-1 overflow-y-auto" ${ref(this.scrollContainerRef)}>
|
||||
${filteredModels.map(({ provider, id, model }, index) => {
|
||||
// Check if this is the current model by comparing IDs
|
||||
const isCurrent = this.currentModel?.id === model.id;
|
||||
const isSelected = index === this.selectedIndex;
|
||||
return html`
|
||||
<div
|
||||
data-model-item
|
||||
class="px-4 py-3 ${
|
||||
this.navigationMode === "mouse" ? "hover:bg-muted" : ""
|
||||
} cursor-pointer border-b border-border ${isSelected ? "bg-accent" : ""}"
|
||||
@click=${() => this.handleSelect(model)}
|
||||
@mouseenter=${() => {
|
||||
// Only update selection in mouse mode
|
||||
if (this.navigationMode === "mouse") {
|
||||
this.selectedIndex = index;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 mb-1">
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span class="text-sm font-medium text-foreground truncate">${id}</span>
|
||||
${isCurrent ? html`<span class="text-green-500">✓</span>` : ""}
|
||||
</div>
|
||||
${Badge(provider, "outline")}
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="${model.reasoning ? "" : "opacity-30"}">${icon(Brain, "sm")}</span>
|
||||
<span class="${model.input.includes("image") ? "" : "opacity-30"}">${icon(ImageIcon, "sm")}</span>
|
||||
<span>${this.formatTokens(model.contextWindow)}K/${this.formatTokens(model.maxTokens)}K</span>
|
||||
</div>
|
||||
<span>${formatModelCost(model.cost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
94
packages/browser-extension/src/dialogs/PromptDialog.ts
Normal file
94
packages/browser-extension/src/dialogs/PromptDialog.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
|
||||
import { DialogContent, DialogFooter, DialogHeader } from "@mariozechner/mini-lit/dist/Dialog.js";
|
||||
import { Input } from "@mariozechner/mini-lit/dist/Input.js";
|
||||
import { html, type PropertyValues, type TemplateResult } from "lit";
|
||||
import { customElement } from "lit/decorators/custom-element.js";
|
||||
import { property } from "lit/decorators/property.js";
|
||||
import { state } from "lit/decorators/state.js";
|
||||
import { createRef } from "lit/directives/ref.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
import { DialogBase } from "./DialogBase.js";
|
||||
|
||||
@customElement("prompt-dialog")
|
||||
export class PromptDialog extends DialogBase {
|
||||
@property() headerTitle = "";
|
||||
@property() message = "";
|
||||
@property() defaultValue = "";
|
||||
@property() isPassword = false;
|
||||
|
||||
@state() private inputValue = "";
|
||||
private resolvePromise?: (value: string | null) => void;
|
||||
private inputRef = createRef<HTMLInputElement>();
|
||||
|
||||
protected override modalWidth = "min(400px, 90vw)";
|
||||
protected override modalHeight = "auto";
|
||||
|
||||
static async ask(title: string, message: string, defaultValue = "", isPassword = false): Promise<string | null> {
|
||||
const dialog = new PromptDialog();
|
||||
dialog.headerTitle = title;
|
||||
dialog.message = message;
|
||||
dialog.defaultValue = defaultValue;
|
||||
dialog.isPassword = isPassword;
|
||||
dialog.inputValue = defaultValue;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
dialog.resolvePromise = resolve;
|
||||
dialog.open();
|
||||
});
|
||||
}
|
||||
|
||||
protected override firstUpdated(_changedProperties: PropertyValues): void {
|
||||
super.firstUpdated(_changedProperties);
|
||||
this.inputRef.value?.focus();
|
||||
}
|
||||
|
||||
private handleConfirm() {
|
||||
this.resolvePromise?.(this.inputValue);
|
||||
this.close();
|
||||
}
|
||||
|
||||
private handleCancel() {
|
||||
this.resolvePromise?.(null);
|
||||
this.close();
|
||||
}
|
||||
|
||||
protected override renderContent(): TemplateResult {
|
||||
return DialogContent({
|
||||
children: html`
|
||||
${DialogHeader({
|
||||
title: this.headerTitle || i18n("Input Required"),
|
||||
description: this.message,
|
||||
})}
|
||||
${Input({
|
||||
type: this.isPassword ? "password" : "text",
|
||||
value: this.inputValue,
|
||||
className: "w-full",
|
||||
inputRef: this.inputRef,
|
||||
onInput: (e: Event) => {
|
||||
this.inputValue = (e.target as HTMLInputElement).value;
|
||||
},
|
||||
onKeyDown: (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") this.handleConfirm();
|
||||
if (e.key === "Escape") this.handleCancel();
|
||||
},
|
||||
})}
|
||||
${DialogFooter({
|
||||
children: html`
|
||||
${Button({
|
||||
variant: "outline",
|
||||
onClick: () => this.handleCancel(),
|
||||
children: i18n("Cancel"),
|
||||
})}
|
||||
${Button({
|
||||
variant: "default",
|
||||
onClick: () => this.handleConfirm(),
|
||||
children: i18n("Confirm"),
|
||||
})}
|
||||
`,
|
||||
})}
|
||||
`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default PromptDialog;
|
||||
31
packages/browser-extension/src/live-reload.ts
Normal file
31
packages/browser-extension/src/live-reload.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// 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();
|
||||
11
packages/browser-extension/src/sidepanel.html
Normal file
11
packages/browser-extension/src/sidepanel.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Pi Reader Assistant</title>
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
</head>
|
||||
<body class="h-full w-full">
|
||||
<script type="module" src="sidepanel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
packages/browser-extension/src/sidepanel.ts
Normal file
59
packages/browser-extension/src/sidepanel.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { html, LitElement, render } from "lit";
|
||||
import "./ChatPanel.js";
|
||||
import "./live-reload.js";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
|
||||
import { Button, Input, icon } from "@mariozechner/mini-lit";
|
||||
import { Settings } from "lucide";
|
||||
import { ModelSelector } from "./dialogs/ModelSelector.js";
|
||||
|
||||
async function getDom() {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab || !tab.id) return;
|
||||
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => document.body.innerText,
|
||||
});
|
||||
}
|
||||
|
||||
@customElement("pi-chat-header")
|
||||
export class Header extends LitElement {
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
const resp = await fetch("https://genai.mariozechner.at/api/health");
|
||||
console.log(await resp.json());
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="flex items-center px-4 py-2 border-b border-border mb-4">
|
||||
<span class="text-muted-foreground">pi-ai webby</span>
|
||||
<theme-toggle class="ml-auto"></theme-toggle>
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
children: html`${icon(Settings, "sm")}`,
|
||||
onClick: async () => {
|
||||
ModelSelector.open(null, (model) => {
|
||||
console.log("Selected model:", model);
|
||||
});
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
const app = html`
|
||||
<div class="w-full h-full flex flex-col bg-background text-foreground">
|
||||
<pi-chat-header></pi-chat-header>
|
||||
<pi-chat-panel></pi-chat-panel>
|
||||
</div>
|
||||
`;
|
||||
|
||||
render(app, document.body);
|
||||
42
packages/browser-extension/src/utils/format.ts
Normal file
42
packages/browser-extension/src/utils/format.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { i18n } from "@mariozechner/mini-lit";
|
||||
import type { Usage } from "@mariozechner/pi-ai";
|
||||
|
||||
export function formatCost(cost: number): string {
|
||||
return `$${cost.toFixed(4)}`;
|
||||
}
|
||||
|
||||
export function formatModelCost(cost: any): string {
|
||||
if (!cost) return i18n("Free");
|
||||
const input = cost.input || 0;
|
||||
const output = cost.output || 0;
|
||||
if (input === 0 && output === 0) return i18n("Free");
|
||||
|
||||
// Format numbers with appropriate precision
|
||||
const formatNum = (num: number): string => {
|
||||
if (num >= 100) return num.toFixed(0);
|
||||
if (num >= 10) return num.toFixed(1).replace(/\.0$/, "");
|
||||
if (num >= 1) return num.toFixed(2).replace(/\.?0+$/, "");
|
||||
return num.toFixed(3).replace(/\.?0+$/, "");
|
||||
};
|
||||
|
||||
return `$${formatNum(input)}/$${formatNum(output)}`;
|
||||
}
|
||||
|
||||
export function formatUsage(usage: Usage) {
|
||||
if (!usage) return "";
|
||||
|
||||
const parts = [];
|
||||
if (usage.input) parts.push(`↑${formatTokenCount(usage.input)}`);
|
||||
if (usage.output) parts.push(`↓${formatTokenCount(usage.output)}`);
|
||||
if (usage.cacheRead) parts.push(`R${formatTokenCount(usage.cacheRead)}`);
|
||||
if (usage.cacheWrite) parts.push(`W${formatTokenCount(usage.cacheWrite)}`);
|
||||
if (usage.cost?.total) parts.push(formatCost(usage.cost.total));
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
export function formatTokenCount(count: number): string {
|
||||
if (count < 1000) return count.toString();
|
||||
if (count < 10000) return (count / 1000).toFixed(1) + "k";
|
||||
return Math.round(count / 1000) + "k";
|
||||
}
|
||||
46
packages/browser-extension/src/utils/i18n.ts
Normal file
46
packages/browser-extension/src/utils/i18n.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { defaultEnglish, defaultGerman, type MiniLitRequiredMessages, setTranslations } from "@mariozechner/mini-lit";
|
||||
|
||||
declare module "@mariozechner/mini-lit" {
|
||||
interface i18nMessages extends MiniLitRequiredMessages {
|
||||
Free: string;
|
||||
"Input Required": string;
|
||||
Cancel: string;
|
||||
Confirm: string;
|
||||
"Select Model": string;
|
||||
"Search models...": string;
|
||||
Format: string;
|
||||
Thinking: string;
|
||||
Vision: string;
|
||||
}
|
||||
}
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
...defaultEnglish,
|
||||
Free: "Free",
|
||||
"Input Required": "Input Required",
|
||||
Cancel: "Cancel",
|
||||
Confirm: "Confirm",
|
||||
"Select Model": "Select Model",
|
||||
"Search models...": "Search models...",
|
||||
Format: "Format",
|
||||
Thinking: "Thinking",
|
||||
Vision: "Vision",
|
||||
},
|
||||
de: {
|
||||
...defaultGerman,
|
||||
Free: "Kostenlos",
|
||||
"Input Required": "Eingabe erforderlich",
|
||||
Cancel: "Abbrechen",
|
||||
Confirm: "Bestätigen",
|
||||
"Select Model": "Modell auswählen",
|
||||
"Search models...": "Modelle suchen...",
|
||||
Format: "Formatieren",
|
||||
Thinking: "Thinking",
|
||||
Vision: "Vision",
|
||||
},
|
||||
};
|
||||
|
||||
setTranslations(translations);
|
||||
|
||||
export * from "@mariozechner/mini-lit/dist/i18n.js";
|
||||
14
packages/browser-extension/tsconfig.build.json
Normal file
14
packages/browser-extension/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"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"]
|
||||
}
|
||||
6
packages/browser-extension/tsconfig.json
Normal file
6
packages/browser-extension/tsconfig.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": "./tsconfig.build.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
"scripts": {
|
||||
"clean": "rm -rf dist",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
|
||||
"check": "biome check --write .",
|
||||
"test": "node --test --import tsx test/*.test.ts",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
import { parseStreamingJson } from "./packages/ai/dist/json-parse.js";
|
||||
|
||||
// Test cases for partial JSON parsing
|
||||
const testCases = [
|
||||
// Complete JSON
|
||||
{ input: '{"name":"test","value":42}', expected: {name: "test", value: 42} },
|
||||
|
||||
// Partial JSON - incomplete object
|
||||
{ input: '{"name":"test","val', expected: {name: "test"} },
|
||||
{ input: '{"name":"test"', expected: {name: "test"} },
|
||||
{ input: '{"name":', expected: {} },
|
||||
{ input: '{"', expected: {} },
|
||||
{ input: '{', expected: {} },
|
||||
|
||||
// Partial JSON - incomplete array
|
||||
{ input: '{"items":[1,2,3', expected: {items: [1, 2, 3]} },
|
||||
{ input: '{"items":[1,2,', expected: {items: [1, 2]} },
|
||||
{ input: '{"items":[', expected: {items: []} },
|
||||
|
||||
// Partial JSON - incomplete string
|
||||
{ input: '{"message":"Hello wor', expected: {message: "Hello wor"} },
|
||||
|
||||
// Empty or invalid
|
||||
{ input: '', expected: {} },
|
||||
{ input: null, expected: {} },
|
||||
{ input: undefined, expected: {} },
|
||||
|
||||
// Complex nested partial
|
||||
{ input: '{"user":{"name":"John","age":30,"address":{"city":"New Y', expected: {user: {name: "John", age: 30, address: {city: "New Y"}}} },
|
||||
];
|
||||
|
||||
console.log("Testing parseStreamingJson...\n");
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const test of testCases) {
|
||||
const result = parseStreamingJson(test.input);
|
||||
const success = JSON.stringify(result) === JSON.stringify(test.expected);
|
||||
|
||||
if (success) {
|
||||
console.log(`✅ PASS: "${test.input || '(empty)'}" -> ${JSON.stringify(result)}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(`❌ FAIL: "${test.input || '(empty)'}"`);
|
||||
console.log(` Expected: ${JSON.stringify(test.expected)}`);
|
||||
console.log(` Got: ${JSON.stringify(result)}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
|
|
@ -15,6 +15,9 @@
|
|||
"moduleResolution": "Node16",
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": false,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"useDefineForClassFields": false,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,5 +9,6 @@
|
|||
"@mariozechner/pi": ["./packages/pods/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["packages/*/src/**/*", "packages/*/test/**/*"]
|
||||
"include": ["packages/*/src/**/*", "packages/*/test/**/*"],
|
||||
"exclude": ["packages/browser-extension/**/*"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue