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:
Mario Zechner 2025-10-01 04:33:56 +02:00
parent c1185c7b95
commit b67c10dfb1
33 changed files with 4453 additions and 1202 deletions

2
.gitignore vendored
View file

@ -5,6 +5,8 @@ dist/
*.tsbuildinfo
packages/*/node_modules/
packages/*/dist/
packages/*/dist-chrome/
packages/*/dist-firefox/
# Environment
.env

2041
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 B

View 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/*"
]
}

View 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"
}
}
}

View 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/*"
]
}

View 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"
}
}

View 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;
});

View 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);
});
});

View 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>`;
}
}

View 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>
`;
},
);

View 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;
}

View 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 {};

View 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(),
});
}
}

View 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>
`;
}
}

View 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;

View 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();

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

View 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);

View 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";
}

View 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";

View 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"]
}

View file

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

View file

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

View file

@ -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`);

View file

@ -15,6 +15,9 @@
"moduleResolution": "Node16",
"resolveJsonModule": true,
"allowImportingTsExtensions": false,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"useDefineForClassFields": false,
"types": ["node"]
}
}

View file

@ -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/**/*"]
}