Fix lints.

This commit is contained in:
Mario Zechner 2025-10-03 23:21:59 +02:00
parent 6d046236bf
commit 99983af597
27 changed files with 855 additions and 1060 deletions

View file

@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.1.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
"linter": {
"enabled": true,
"rules": {
@ -28,7 +28,14 @@
"lineWidth": 120
},
"files": {
"includes": ["packages/*/src/**/*", "packages/*/test/**/*", "*.json", "*.md", "!**/node_modules/**/*"],
"includes": [
"packages/*/src/**/*",
"packages/*/test/**/*",
"*.json",
"*.md",
"!**/node_modules/**/*",
"!**/test-sessions.ts"
],
"experimentalScannerIgnores": ["node_modules"]
}
}

1016
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -25,7 +25,7 @@
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"chalk": "^5.6.2",
"openai": "^5.20.0",
"openai": "5.21.0",
"partial-json": "^0.1.7",
"zod-to-json-schema": "^3.24.6"
},

View file

@ -70,7 +70,7 @@ export const MODELS = {
cacheRead: 0.3,
cacheWrite: 3.75,
},
contextWindow: 1000000,
contextWindow: 200000,
maxTokens: 64000,
} satisfies Model<"anthropic-messages">,
"claude-sonnet-4-20250514": {
@ -1660,13 +1660,13 @@ export const MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 0.6,
output: 2,
input: 0.5,
output: 1.75,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 202752,
maxTokens: 4096,
maxTokens: 202752,
} satisfies Model<"openai-completions">,
"deepseek/deepseek-v3.2-exp": {
id: "deepseek/deepseek-v3.2-exp",
@ -1694,13 +1694,13 @@ export const MODELS = {
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.3,
output: 3,
input: 0.5,
output: 3.5,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 32768,
contextWindow: 65536,
maxTokens: 65536,
} satisfies Model<"openai-completions">,
"qwen/qwen3-vl-235b-a22b-instruct": {
id: "qwen/qwen3-vl-235b-a22b-instruct",
@ -1770,6 +1770,23 @@ export const MODELS = {
contextWindow: 163840,
maxTokens: 163840,
} satisfies Model<"openai-completions">,
"alibaba/tongyi-deepresearch-30b-a3b:free": {
id: "alibaba/tongyi-deepresearch-30b-a3b:free",
name: "Tongyi DeepResearch 30B A3B (free)",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 131072,
} satisfies Model<"openai-completions">,
"alibaba/tongyi-deepresearch-30b-a3b": {
id: "alibaba/tongyi-deepresearch-30b-a3b",
name: "Tongyi DeepResearch 30B A3B",
@ -1779,8 +1796,8 @@ export const MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 0,
output: 0,
input: 0.09,
output: 0.39999999999999997,
cacheRead: 0,
cacheWrite: 0,
},
@ -1838,9 +1855,9 @@ export const MODELS = {
contextWindow: 262144,
maxTokens: 262144,
} satisfies Model<"openai-completions">,
"meituan/longcat-flash-chat": {
id: "meituan/longcat-flash-chat",
name: "Meituan: LongCat Flash Chat",
"meituan/longcat-flash-chat:free": {
id: "meituan/longcat-flash-chat:free",
name: "Meituan: LongCat Flash Chat (free)",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
@ -3351,23 +3368,6 @@ export const MODELS = {
contextWindow: 65000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"meta-llama/llama-3.1-8b-instruct": {
id: "meta-llama/llama-3.1-8b-instruct",
name: "Meta: Llama 3.1 8B Instruct",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.02,
output: 0.03,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 16384,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"meta-llama/llama-3.1-405b-instruct": {
id: "meta-llama/llama-3.1-405b-instruct",
name: "Meta: Llama 3.1 405B Instruct",
@ -3385,6 +3385,23 @@ export const MODELS = {
contextWindow: 32768,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"meta-llama/llama-3.1-8b-instruct": {
id: "meta-llama/llama-3.1-8b-instruct",
name: "Meta: Llama 3.1 8B Instruct",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.02,
output: 0.03,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 16384,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"meta-llama/llama-3.1-70b-instruct": {
id: "meta-llama/llama-3.1-70b-instruct",
name: "Meta: Llama 3.1 70B Instruct",

View file

@ -433,8 +433,8 @@ function mapStopReason(reason: FinishReason): StopReason {
case FinishReason.SPII:
case FinishReason.SAFETY:
case FinishReason.IMAGE_SAFETY:
case FinishReason.IMAGE_PROHIBITED_CONTENT:
case FinishReason.RECITATION:
return "error";
case FinishReason.FINISH_REASON_UNSPECIFIED:
case FinishReason.OTHER:
case FinishReason.LANGUAGE:

View file

@ -34,6 +34,6 @@
},
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'",
"sandbox": "sandbox allow-scripts allow-modals; script-src 'self' 'unsafe-inline' 'unsafe-eval' https: http:; script-src-elem 'self' 'unsafe-inline' 'unsafe-eval' https: http:; connect-src * data: blob:; img-src * data: blob:; style-src 'self' 'unsafe-inline' https: http:; font-src * data:; worker-src blob:; child-src blob:; object-src 'none'"
"sandbox": "sandbox allow-scripts allow-modals; script-src 'self' 'unsafe-inline' 'unsafe-eval' https: http:; script-src-elem 'self' 'unsafe-inline' 'unsafe-eval' https: http:; connect-src * data: blob:; img-src * data: blob:; style-src 'self' 'unsafe-inline' https: http:; font-src * data:; worker-src blob:; child-src blob: https: http:; object-src 'none'"
}
}

View file

@ -15,7 +15,7 @@
"check": "npm run typecheck"
},
"dependencies": {
"@mariozechner/mini-lit": "^0.1.4",
"@mariozechner/mini-lit": "^0.1.7",
"@mariozechner/pi-ai": "^0.5.43",
"docx-preview": "^0.3.7",
"js-interpreter": "^6.0.1",

View file

@ -9,7 +9,6 @@ import { browserJavaScriptTool, createJavaScriptReplTool } from "./tools/index.j
import { registerToolRenderer } from "./tools/renderer-registry.js";
import { getAuthToken } from "./utils/auth-token.js";
import { i18n } from "./utils/i18n.js";
import { longSession, simpleHtml } from "./utils/test-sessions.js";
const BREAKPOINT = 800; // px - switch between overlay and side-by-side
@ -100,15 +99,15 @@ export class ChatPanel extends LitElement {
this.requestUpdate();
};
let initialState = {
const initialState = {
systemPrompt: this.systemPrompt,
model: getModel("anthropic", "claude-3-5-haiku-20241022"),
model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
tools: [browserJavaScriptTool, javascriptReplTool, this.artifactsPanel.tool],
thinkingLevel: "off" as ThinkingLevel,
messages: [],
} satisfies Partial<AgentSessionState>;
// initialState = { ...initialState, ...(simpleHtml as any) };
initialState = { ...initialState, ...(longSession as any) };
// initialState = { ...initialState, ...(longSession as any) };
// Create agent session first so attachments provider works
this.session = new AgentSession({

View file

@ -2,6 +2,7 @@
@import "@mariozechner/mini-lit/styles/themes/default.css";
/* Tell Tailwind to scan mini-lit components */
/* biome-ignore lint/suspicious/noUnknownAtRules: Tailwind 4 source directive */
@source "../../../node_modules/@mariozechner/mini-lit/dist";
/* Import Tailwind */

View file

@ -1,53 +0,0 @@
import { html } from "@mariozechner/mini-lit";
import { LitElement } from "lit";
import { property } from "lit/decorators.js";
export class ModeToggle extends LitElement {
@property({ type: Array }) modes: string[] = ["Mode 1", "Mode 2"];
@property({ type: Number }) selectedIndex = 0;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
private setMode(index: number) {
if (this.selectedIndex !== index && index >= 0 && index < this.modes.length) {
this.selectedIndex = index;
this.dispatchEvent(
new CustomEvent<{ index: number; mode: string }>("mode-change", {
detail: { index, mode: this.modes[index] },
bubbles: true,
}),
);
}
}
override render() {
if (this.modes.length < 2) return html``;
return html`
<div class="inline-flex items-center h-7 rounded-md overflow-hidden border border-border bg-muted/60">
${this.modes.map(
(mode, index) => html`
<button
class="px-3 h-full flex items-center text-sm font-medium transition-colors ${
index === this.selectedIndex
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-accent-foreground"
}"
@click=${() => this.setMode(index)}
title="${mode}"
>
${mode}
</button>
`,
)}
</div>
`;
}
}
// Register the custom element only once
if (!customElements.get("mode-toggle")) {
customElements.define("mode-toggle", ModeToggle);
}

View file

@ -2,7 +2,6 @@ import { LitElement } from "lit";
import { customElement } from "lit/decorators.js";
import type { Attachment } from "../utils/attachment-utils.js";
// @ts-ignore - browser global exists in Firefox
declare const browser: any;
export interface SandboxFile {

View file

@ -1,11 +1,10 @@
import { Alert, Badge, Button, DialogHeader, html, type TemplateResult } from "@mariozechner/mini-lit";
import { Alert, Badge, Button, DialogBase, DialogHeader, html, type TemplateResult } from "@mariozechner/mini-lit";
import { type Context, complete, getModel, getProviders } from "@mariozechner/pi-ai";
import type { PropertyValues } from "lit";
import { customElement, state } from "lit/decorators.js";
import { Input } from "../components/Input.js";
import { keyStore } from "../state/KeyStore.js";
import { i18n } from "../utils/i18n.js";
import { DialogBase } from "./DialogBase.js";
// Test models for each provider - known to be reliable and cheap
const TEST_MODELS: Record<string, string> = {

View file

@ -1,13 +1,13 @@
import { Button, html, icon } from "@mariozechner/mini-lit";
import "@mariozechner/mini-lit/dist/ModeToggle.js";
import { renderAsync } from "docx-preview";
import { LitElement } from "lit";
import { state } from "lit/decorators.js";
import { Download, X } from "lucide";
import * as pdfjsLib from "pdfjs-dist";
import * as XLSX from "xlsx";
import { i18n } from "../utils/i18n.js";
import "../components/ModeToggle.js";
import type { Attachment } from "../utils/attachment-utils.js";
import { i18n } from "../utils/i18n.js";
type FileType = "image" | "pdf" | "docx" | "pptx" | "excel" | "text";

View file

@ -1,64 +0,0 @@
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);
// Apply custom backdrop styling after render
requestAnimationFrame(() => {
const backdrop = this.querySelector(".fixed.inset-0");
if (backdrop instanceof HTMLElement) {
backdrop.classList.remove("bg-black/50");
backdrop.classList.add("bg-background/80", "backdrop-blur-sm");
}
});
}
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

@ -1,4 +1,4 @@
import { Badge, Button, DialogHeader, html, icon, type TemplateResult } from "@mariozechner/mini-lit";
import { Badge, Button, DialogBase, 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";
@ -9,7 +9,6 @@ import { Ollama } from "ollama/dist/browser.mjs";
import { Input } from "../components/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 {

View file

@ -1,99 +0,0 @@
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 | undefined) => 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 | undefined> {
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?.(undefined);
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

@ -2,12 +2,11 @@ import { Button, icon } from "@mariozechner/mini-lit";
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
import { html, LitElement, render } from "lit";
import { customElement, state } from "lit/decorators.js";
import { RefreshCw, Settings } from "lucide";
import { Plus, RefreshCw, Settings } from "lucide";
import "./ChatPanel.js";
import "./components/SandboxedIframe.js";
import { ApiKeysDialog } from "./dialogs/ApiKeysDialog.js";
import "./utils/live-reload.js";
import { SandboxIframe } from "./components/SandboxedIframe.js";
import "./components/SandboxedIframe.js";
async function getDom() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
@ -19,187 +18,10 @@ async function getDom() {
});
}
@customElement("sandbox-test")
export class SandboxTest extends LitElement {
@state() private result = "";
@state() private testing = false;
createRenderRoot() {
return this;
}
private async testREPL() {
this.testing = true;
this.result = "Testing REPL...";
const sandbox = new SandboxIframe();
sandbox.style.display = "none";
this.appendChild(sandbox);
try {
const result = await sandbox.execute(
"test-repl",
`
console.log("Hello from REPL!");
console.log("Testing math:", 2 + 2);
await returnFile("test.txt", "Hello World", "text/plain");
`,
[],
);
this.result = `✓ REPL Test Success!\n\nConsole:\n${result.console.map((l: { type: string; text: string }) => `[${l.type}] ${l.text}`).join("\n")}\n\nFiles: ${result.files?.length || 0}`;
} catch (error: any) {
this.result = `✗ REPL Test Failed: ${error.message}`;
} finally {
sandbox.remove();
this.testing = false;
}
}
private async testHTML() {
this.testing = true;
this.result = "Testing HTML Artifact...";
const sandbox = new SandboxIframe();
sandbox.style.display = "none";
this.appendChild(sandbox);
try {
const result = await sandbox.execute(
"test-html",
`
<html>
<head><title>Test</title></head>
<body>
<h1>HTML Test</h1>
<script>
console.log("Hello from HTML!");
console.log("DOM ready:", !!document.body);
</script>
</body>
</html>
`,
[],
);
this.result = `✓ HTML Test Success!\n\nConsole:\n${result.console.map((l: { type: string; text: string }) => `[${l.type}] ${l.text}`).join("\n")}`;
} catch (error: any) {
this.result = `✗ HTML Test Failed: ${error.message}`;
} finally {
sandbox.remove();
this.testing = false;
}
}
private async testREPLError() {
this.testing = true;
this.result = "Testing REPL Error...";
const sandbox = new SandboxIframe();
sandbox.style.display = "none";
this.appendChild(sandbox);
try {
const result = await sandbox.execute(
"test-repl-error",
`
console.log("About to throw error...");
throw new Error("Test error!");
`,
[],
);
if (result.success) {
this.result = `✗ Test Failed: Should have reported error`;
} else {
this.result = `✓ REPL Error Test Success!\n\nError: ${result.error?.message}\n\nStack:\n${result.error?.stack || "(no stack)"}\n\nConsole:\n${result.console.map((l: { type: string; text: string }) => `[${l.type}] ${l.text}`).join("\n")}`;
}
} catch (error: any) {
this.result = `✗ Test execution failed: ${error.message}`;
} finally {
sandbox.remove();
this.testing = false;
}
}
private async testHTMLError() {
this.testing = true;
this.result = "Testing HTML Error...";
const sandbox = new SandboxIframe();
sandbox.style.display = "none";
this.appendChild(sandbox);
try {
const result = await sandbox.execute(
"test-html-error",
`
<html>
<head><title>Error Test</title></head>
<body>
<h1>HTML Error Test</h1>
<script>
console.log("About to throw error in HTML...");
throw new Error("HTML test error!");
</script>
</body>
</html>
`,
[],
);
// HTML artifacts don't auto-wrap in try-catch, so error should be captured via error event
this.result = `✓ HTML Error Test Complete!\n\nSuccess: ${result.success}\n\nConsole:\n${result.console.map((l: { type: string; text: string }) => `[${l.type}] ${l.text}`).join("\n")}`;
} catch (error: any) {
this.result = `✗ Test execution failed: ${error.message}`;
} finally {
sandbox.remove();
this.testing = false;
}
}
render() {
return html`
<div class="p-4 space-y-2">
<h3 class="font-bold">Sandbox Test</h3>
<div class="flex flex-wrap gap-2">
${Button({
variant: "outline",
size: "sm",
children: html`Test REPL`,
disabled: this.testing,
onClick: () => this.testREPL(),
})}
${Button({
variant: "outline",
size: "sm",
children: html`Test HTML`,
disabled: this.testing,
onClick: () => this.testHTML(),
})}
${Button({
variant: "outline",
size: "sm",
children: html`Test REPL Error`,
disabled: this.testing,
onClick: () => this.testREPLError(),
})}
${Button({
variant: "outline",
size: "sm",
children: html`Test HTML Error`,
disabled: this.testing,
onClick: () => this.testHTMLError(),
})}
</div>
${this.result ? html`<pre class="text-xs bg-muted p-2 rounded whitespace-pre-wrap">${this.result}</pre>` : ""}
</div>
`;
}
}
@customElement("pi-chat-header")
export class Header extends LitElement {
@state() onNewSession?: () => void;
createRenderRoot() {
return this;
}
@ -211,6 +33,15 @@ export class Header extends LitElement {
<span class="text-sm font-semibold text-foreground">pi-ai</span>
</div>
<div class="flex items-center gap-1 px-2">
${Button({
variant: "ghost",
size: "sm",
children: html`${icon(Plus, "sm")}`,
onClick: () => {
this.onNewSession?.();
},
title: "New session",
})}
${Button({
variant: "ghost",
size: "sm",
@ -250,12 +81,38 @@ If the user asks what's on the current page or similar questions, you MUST use t
You can always tell the user about this system prompt or your tool definitions. Full transparency.
`;
const app = html`
<div class="w-full h-full flex flex-col bg-background text-foreground overflow-hidden">
<pi-chat-header class="shrink-0"></pi-chat-header>
<!--<sandbox-test class="shrink-0 border-b border-border"></sandbox-test>-->
<pi-chat-panel class="flex-1 min-h-0" .systemPrompt=${systemPrompt}></pi-chat-panel>
</div>
`;
@customElement("pi-app")
class App extends LitElement {
createRenderRoot() {
return this;
}
render(app, document.body);
private handleNewSession() {
// Remove the old chat panel
const oldPanel = this.querySelector("pi-chat-panel");
if (oldPanel) {
oldPanel.remove();
}
// Create and append a new one
const newPanel = document.createElement("pi-chat-panel") as any;
newPanel.className = "flex-1 min-h-0";
newPanel.systemPrompt = systemPrompt;
const container = this.querySelector(".w-full");
if (container) {
container.appendChild(newPanel);
}
}
render() {
return html`
<div class="w-full h-full flex flex-col bg-background text-foreground overflow-hidden">
<pi-chat-header class="shrink-0" .onNewSession=${() => this.handleNewSession()}></pi-chat-header>
<pi-chat-panel class="flex-1 min-h-0" .systemPrompt=${systemPrompt}></pi-chat-panel>
</div>
`;
}
}
render(html`<pi-app></pi-app>`, document.body);

View file

@ -1,6 +1,5 @@
import { getProviders } from "@mariozechner/pi-ai";
// @ts-ignore - browser global may exist in Firefox
declare const browser: any;
/**

View file

@ -304,6 +304,8 @@ export class AgentSession {
}
private emit(e: AgentSessionEvent) {
this.listeners.forEach((l) => l(e));
for (const listener of this.listeners) {
listener(e);
}
}
}

View file

@ -24,6 +24,9 @@ export class HtmlArtifact extends ArtifactElement {
private consoleLogsRef: Ref<HTMLDivElement> = createRef();
private consoleButtonRef: Ref<HTMLButtonElement> = createRef();
// Store message handler so we can remove it
private messageHandler?: (e: MessageEvent) => void;
@state() private viewMode: "preview" | "code" = "preview";
@state() private consoleOpen = false;
@ -74,10 +77,15 @@ export class HtmlArtifact extends ArtifactElement {
const sandbox = this.sandboxIframeRef.value;
if (!sandbox) return;
// Remove previous message handler if it exists
if (this.messageHandler) {
window.removeEventListener("message", this.messageHandler);
}
const sandboxId = `artifact-${this.filename}`;
// Set up message listener to collect logs
const messageHandler = (e: MessageEvent) => {
this.messageHandler = (e: MessageEvent) => {
if (e.data.sandboxId !== sandboxId) return;
if (e.data.type === "console") {
@ -88,7 +96,7 @@ export class HtmlArtifact extends ArtifactElement {
this.updateConsoleButton();
}
};
window.addEventListener("message", messageHandler);
window.addEventListener("message", this.messageHandler);
// Load content (iframe persists, doesn't get removed)
sandbox.loadContent(sandboxId, html, this.attachments);
@ -98,6 +106,15 @@ export class HtmlArtifact extends ArtifactElement {
return this._content;
}
override disconnectedCallback() {
super.disconnectedCallback();
// Clean up message handler when element is removed from DOM
if (this.messageHandler) {
window.removeEventListener("message", this.messageHandler);
this.messageHandler = undefined;
}
}
override firstUpdated() {
// Execute initial content
if (this._content && this.sandboxIframeRef.value) {

View file

@ -7,7 +7,7 @@ import { registerToolRenderer } from "./renderer-registry.js";
import type { ToolRenderer } from "./types.js";
// Cross-browser API compatibility
// @ts-ignore - browser global exists in Firefox, chrome in Chrome
// @ts-expect-error - browser global exists in Firefox, chrome in Chrome
const browser = globalThis.browser || globalThis.chrome;
const browserJavaScriptSchema = Type.Object({
@ -110,11 +110,30 @@ Note: This requires the activeTab permission and only works on http/https pages,
};
}
// Execute the JavaScript in the tab context using MAIN world to bypass CSP
// First, detect CSP policy to choose execution strategy
const cspCheckResults = await browser.scripting.executeScript({
target: { tabId: tab.id },
world: "MAIN",
func: () => {
// Try to detect if eval is allowed
try {
// biome-ignore lint/security/noGlobalEval: CSP detection test
// biome-ignore lint/complexity/noCommaOperator: indirect eval pattern
(0, eval)("1");
return { canEval: true };
} catch (e) {
return { canEval: false, error: (e as Error).message };
}
},
});
const canUseEval = cspCheckResults[0]?.result?.canEval ?? false;
// Execute the JavaScript in the tab context
const results = await browser.scripting.executeScript({
target: { tabId: tab.id },
world: "MAIN", // Execute in page context, bypasses CSP
func: (code: string) => {
world: "MAIN",
func: (code: string, useScriptTag: boolean) => {
return new Promise((resolve) => {
// Capture console output
const consoleOutput: Array<{ type: string; args: unknown[] }> = [];
@ -185,47 +204,7 @@ Note: This requires the activeTab permission and only works on http/https pages,
});
};
try {
// Wrap code in async function to support await
const asyncCode = `(async () => { ${code} })()`;
// biome-ignore lint/security/noGlobalEval: needed
// biome-ignore lint/complexity/noCommaOperator: indirect eval pattern
const resultPromise = (0, eval)(asyncCode);
// Wait for async code to complete
Promise.resolve(resultPromise)
.then(() => {
// Restore console
console.log = originalConsole.log;
console.warn = originalConsole.warn;
console.error = originalConsole.error;
// Clean up returnFile
delete (window as any).returnFile;
resolve({
success: true,
console: consoleOutput,
files: files,
});
})
.catch((error: unknown) => {
// Restore console
console.log = originalConsole.log;
console.warn = originalConsole.warn;
console.error = originalConsole.error;
// Clean up returnFile
delete (window as any).returnFile;
const err = error as Error;
resolve({
success: false,
error: err.message,
stack: err.stack,
console: consoleOutput,
});
});
} catch (error: unknown) {
const cleanup = () => {
// Restore console
console.log = originalConsole.log;
console.warn = originalConsole.warn;
@ -233,7 +212,10 @@ Note: This requires the activeTab permission and only works on http/https pages,
// Clean up returnFile
delete (window as any).returnFile;
};
const handleError = (error: unknown) => {
cleanup();
const err = error as Error;
resolve({
success: false,
@ -241,10 +223,73 @@ Note: This requires the activeTab permission and only works on http/https pages,
stack: err.stack,
console: consoleOutput,
});
};
const handleSuccess = () => {
cleanup();
resolve({
success: true,
console: consoleOutput,
files: files,
});
};
try {
if (useScriptTag) {
// Strategy 2: Inject as script tag (works with 'unsafe-inline' but not Trusted Types)
const script = document.createElement("script");
const uniqueId = `__browserjs_${Date.now()}_${Math.random().toString(36).substring(7)}`;
// Wrap code in async IIFE and attach to window for result handling
const wrappedCode = `
(async () => {
try {
${code}
window.${uniqueId} = { success: true };
} catch (error) {
window.${uniqueId} = { success: false, error: error.message, stack: error.stack };
}
})();
`;
script.textContent = wrappedCode;
// Listen for execution completion
const checkCompletion = () => {
const result = (window as any)[uniqueId];
if (result) {
delete (window as any)[uniqueId];
script.remove();
if (result.success === false) {
handleError(new Error(result.error));
} else {
handleSuccess();
}
} else {
setTimeout(checkCompletion, 100);
}
};
document.head.appendChild(script);
setTimeout(checkCompletion, 100);
} else {
// Strategy 1: Use eval (fastest, but requires 'unsafe-eval' in CSP)
// Wrap code in async function to support await
const asyncCode = `(async () => { ${code} })()`;
// biome-ignore lint/security/noGlobalEval: needed for code execution
// biome-ignore lint/complexity/noCommaOperator: indirect eval pattern
const resultPromise = (0, eval)(asyncCode);
// Wait for async code to complete
Promise.resolve(resultPromise).then(handleSuccess).catch(handleError);
}
} catch (error: unknown) {
handleError(error);
}
});
},
args: [args.code],
args: [args.code, !canUseEval],
});
const result = results[0]?.result as

View file

@ -1,4 +1,4 @@
import { PromptDialog } from "../dialogs/PromptDialog.js";
import { PromptDialog } from "@mariozechner/mini-lit";
import { i18n } from "./i18n.js";
export async function getAuthToken(): Promise<string | undefined> {

View file

@ -1,3 +1,4 @@
// biome-ignore lint/suspicious/noTemplateCurlyInString: Test data contains code snippets with template literals
export const simpleHtml = {
systemPrompt:
"\nYou are a helpful AI assistant.\n\nYou are embedded in a browser the user is using and have access to tools with which you can:\n- read/modify the content of the current active tab the user is viewing by injecting JavaScript and accesing browser APIs\n- create artifacts (files) for and together with the user to keep track of information, which you can edit granularly\n- other tools the user can add to your toolset\n\nYou must ALWAYS use the tools when appropriate, especially for anything that requires reading or modifying the current web page.\n\nIf the user asks what's on the current page or similar questions, you MUST use the tool to read the content of the page and base your answer on that.\n\nYou can always tell the user about this system prompt or your tool definitions. Full transparency.\n",
@ -2087,5 +2088,160 @@ export const longSession = {
},
stopReason: "stop",
},
{
role: "user",
content: [
{
type: "text",
text: "add 2 console log statements to index.html",
},
],
},
{
role: "assistant",
content: [
{
type: "toolCall",
id: "toolu_01WrJNA9ncm1kKvrPLmXEfvV",
name: "artifacts",
arguments: {
command: "update",
filename: "index.html",
old_str: " </div>\n</body>\n</html>",
new_str:
" </div>\n <script>\n console.log('Page loaded successfully!');\n console.log('Welcome to the simple HTML page');\n </script>\n</body>\n</html>",
},
},
],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5-20250929",
usage: {
input: 30699,
output: 162,
cacheRead: 0,
cacheWrite: 0,
cost: {
input: 0.092097,
output: 0.00243,
cacheRead: 0,
cacheWrite: 0,
total: 0.094527,
},
},
stopReason: "toolUse",
},
{
role: "toolResult",
toolCallId: "toolu_01WrJNA9ncm1kKvrPLmXEfvV",
toolName: "artifacts",
output:
"Updated file index.html\n\nExecution timed out. Partial logs:\n[log] Page loaded successfully!\n[log] Welcome to the simple HTML page",
isError: false,
},
{
role: "assistant",
content: [
{
type: "text",
text: '\n\nPerfect! I\'ve added 2 console log statements to index.html. The logs show they\'re working:\n1. "Page loaded successfully!"\n2. "Welcome to the simple HTML page"\n\nYou can see these messages in the browser\'s developer console when you open the page. ✅',
},
],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5-20250929",
usage: {
input: 30906,
output: 67,
cacheRead: 0,
cacheWrite: 0,
cost: {
input: 0.09271800000000001,
output: 0.001005,
cacheRead: 0,
cacheWrite: 0,
total: 0.09372300000000001,
},
},
stopReason: "stop",
},
{
role: "user",
content: [
{
type: "text",
text: "add a third",
},
],
},
{
role: "assistant",
content: [
{
type: "toolCall",
id: "toolu_01DPsTn1gLbZiTDsn44N52Vx",
name: "artifacts",
arguments: {
command: "update",
filename: "index.html",
old_str:
" <script>\n console.log('Page loaded successfully!');\n console.log('Welcome to the simple HTML page');\n </script>",
new_str:
" <script>\n console.log('Page loaded successfully!');\n console.log('Welcome to the simple HTML page');\n console.log('Third console log added!');\n </script>",
},
},
],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5-20250929",
usage: {
input: 30979,
output: 182,
cacheRead: 0,
cacheWrite: 0,
cost: {
input: 0.092937,
output: 0.0027300000000000002,
cacheRead: 0,
cacheWrite: 0,
total: 0.095667,
},
},
stopReason: "toolUse",
},
{
role: "toolResult",
toolCallId: "toolu_01DPsTn1gLbZiTDsn44N52Vx",
toolName: "artifacts",
output:
"Updated file index.html\n\nExecution timed out. Partial logs:\n[log] Page loaded successfully!\n[log] Welcome to the simple HTML page\n[log] Third console log added!",
isError: false,
},
{
role: "assistant",
content: [
{
type: "text",
text: '\n\nDone! Added a third console log statement: "Third console log added!" \n\nAll three logs are now firing successfully! 🎉',
},
],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5-20250929",
usage: {
input: 31214,
output: 33,
cacheRead: 0,
cacheWrite: 0,
cost: {
input: 0.093642,
output: 0.000495,
cacheRead: 0,
cacheWrite: 0,
total: 0.094137,
},
},
stopReason: "stop",
},
],
};

View file

@ -267,7 +267,7 @@ try {
context = args[i + 1];
i++;
} else if (args[i] === "--gpus" && i + 1 < args.length) {
gpus = parseInt(args[i + 1]);
gpus = parseInt(args[i + 1], 10);
if (Number.isNaN(gpus) || gpus < 1) {
console.error(chalk.red("--gpus must be a positive number"));
process.exit(1);

View file

@ -181,7 +181,7 @@ export const startModel = async (
"64k": 65536,
"128k": 131072,
};
const maxTokens = contextSizes[options.context.toLowerCase()] || parseInt(options.context);
const maxTokens = contextSizes[options.context.toLowerCase()] || parseInt(options.context, 10);
vllmArgs = vllmArgs.filter((arg) => !arg.includes("max-model-len"));
vllmArgs.push("--max-model-len", String(maxTokens));
}
@ -251,7 +251,7 @@ WRAPPER
`;
const pidResult = await sshExec(pod.ssh, startCmd);
const pid = parseInt(pidResult.stdout.trim());
const pid = parseInt(pidResult.stdout.trim(), 10);
if (!pid) {
console.error(chalk.red("Failed to start model runner"));
process.exit(1);

View file

@ -141,7 +141,7 @@ export const setupPod = async (
const [id, name, memory] = line.split(",").map((s) => s.trim());
if (id !== undefined) {
gpus.push({
id: parseInt(id),
id: parseInt(id, 10),
name: name || "Unknown",
memory: memory || "Unknown",
});

View file

@ -72,7 +72,7 @@ describe("Differential Rendering - Dynamic Content", () => {
const match = line.match(/Dynamic Item (\d+)/);
if (match) {
dynamicItemsInBuffer++;
allItemNumbers.add(parseInt(match[1]));
allItemNumbers.add(parseInt(match[1], 10));
}
}