Merge pull request #253 from getcompanion-ai/codex-chat-durable-gateway-recovery

feat: persist chat threads and recover gateway sessions
This commit is contained in:
Hari 2026-03-07 20:19:08 -05:00 committed by GitHub
commit 19f11932e4
102 changed files with 305 additions and 18092 deletions

View file

@ -42,7 +42,7 @@ fi
RUN_BROWSER_SMOKE=0 RUN_BROWSER_SMOKE=0
for file in $STAGED_FILES; do for file in $STAGED_FILES; do
case "$file" in case "$file" in
packages/ai/*|packages/web-ui/*|package.json|package-lock.json) packages/ai/*|package.json|package-lock.json)
RUN_BROWSER_SMOKE=1 RUN_BROWSER_SMOKE=1
break break
;; ;;

View file

@ -27,12 +27,9 @@
"includes": [ "includes": [
"packages/*/src/**/*.ts", "packages/*/src/**/*.ts",
"packages/*/test/**/*.ts", "packages/*/test/**/*.ts",
"packages/web-ui/src/**/*.ts",
"packages/web-ui/example/**/*.ts",
"!**/node_modules/**/*", "!**/node_modules/**/*",
"!**/test-sessions.ts", "!**/test-sessions.ts",
"!**/models.generated.ts", "!**/models.generated.ts",
"!packages/web-ui/src/app.css",
"!!**/node_modules" "!!**/node_modules"
] ]
} }

53
package-lock.json generated
View file

@ -8,8 +8,7 @@
"name": "pi", "name": "pi",
"version": "0.0.3", "version": "0.0.3",
"workspaces": [ "workspaces": [
"packages/*", "packages/*"
"packages/web-ui/example"
], ],
"dependencies": { "dependencies": {
"@mariozechner/jiti": "^2.6.5", "@mariozechner/jiti": "^2.6.5",
@ -1761,10 +1760,6 @@
"resolved": "packages/tui", "resolved": "packages/tui",
"link": true "link": true
}, },
"node_modules/@mariozechner/pi-web-ui": {
"resolved": "packages/web-ui",
"link": true
},
"node_modules/@mistralai/mistralai": { "node_modules/@mistralai/mistralai": {
"version": "1.14.1", "version": "1.14.1",
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.14.1.tgz", "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.14.1.tgz",
@ -7062,10 +7057,6 @@
"resolved": "packages/pi-teams", "resolved": "packages/pi-teams",
"link": true "link": true
}, },
"node_modules/pi-web-ui-example": {
"resolved": "packages/web-ui/example",
"link": true
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -9343,48 +9334,6 @@
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
},
"packages/web-ui": {
"name": "@mariozechner/pi-web-ui",
"version": "0.56.2",
"license": "MIT",
"dependencies": {
"@lmstudio/sdk": "^1.5.0",
"@mariozechner/pi-ai": "^0.56.2",
"@mariozechner/pi-tui": "^0.56.2",
"docx-preview": "^0.3.7",
"jszip": "^3.10.1",
"lucide": "^0.544.0",
"ollama": "^0.6.0",
"pdfjs-dist": "5.4.394",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
},
"devDependencies": {
"@mariozechner/mini-lit": "^0.2.0",
"@tailwindcss/cli": "^4.0.0-beta.14",
"concurrently": "^9.2.1",
"typescript": "^5.7.3"
},
"peerDependencies": {
"@mariozechner/mini-lit": "^0.2.0",
"lit": "^3.3.1"
}
},
"packages/web-ui/example": {
"name": "pi-web-ui-example",
"version": "1.44.2",
"dependencies": {
"@mariozechner/mini-lit": "^0.2.0",
"@mariozechner/pi-ai": "file:../../ai",
"@mariozechner/pi-web-ui": "file:../",
"@tailwindcss/vite": "^4.1.17",
"lit": "^3.3.1",
"lucide": "^0.544.0"
},
"devDependencies": {
"typescript": "^5.7.3",
"vite": "^7.1.6"
}
} }
} }
} }

View file

@ -11,15 +11,14 @@
"url": "git+https://github.com/getcompanion-ai/co-mono.git" "url": "git+https://github.com/getcompanion-ai/co-mono.git"
}, },
"workspaces": [ "workspaces": [
"packages/*", "packages/*"
"packages/web-ui/example"
], ],
"scripts": { "scripts": {
"clean": "npm run clean --workspaces", "clean": "npm run clean --workspaces",
"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build && cd ../web-ui && npm run build", "build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build",
"dev": "concurrently --names \"ai,agent,coding-agent,web-ui,tui\" --prefix-colors \"cyan,yellow,red,green,magenta\" \"cd packages/ai && npm run dev\" \"cd packages/agent && npm run dev\" \"cd packages/coding-agent && npm run dev\" \"cd packages/web-ui && npm run dev\" \"cd packages/tui && npm run dev\"", "dev": "concurrently --names \"ai,agent,coding-agent,tui\" --prefix-colors \"cyan,yellow,red,magenta\" \"cd packages/ai && npm run dev\" \"cd packages/agent && npm run dev\" \"cd packages/coding-agent && npm run dev\" \"cd packages/tui && npm run dev\"",
"dev:tsc": "concurrently --names \"ai,web-ui\" --prefix-colors \"cyan,green\" \"cd packages/ai && npm run dev:tsc\" \"cd packages/web-ui && npm run dev:tsc\"", "dev:tsc": "cd packages/ai && npm run dev:tsc",
"check": "biome check --write --error-on-warnings . && tsgo --noEmit && npm run check:browser-smoke && cd packages/web-ui && npm run check", "check": "biome check --write --error-on-warnings . && tsgo --noEmit && npm run check:browser-smoke",
"check:browser-smoke": "sh -c 'esbuild scripts/browser-smoke-entry.ts --bundle --platform=browser --format=esm --log-limit=0 --outfile=/tmp/pi-browser-smoke.js > /tmp/pi-browser-smoke-errors.log 2>&1 || { echo \"Browser smoke check failed. See /tmp/pi-browser-smoke-errors.log\"; exit 1; }'", "check:browser-smoke": "sh -c 'esbuild scripts/browser-smoke-entry.ts --bundle --platform=browser --format=esm --log-limit=0 --outfile=/tmp/pi-browser-smoke.js > /tmp/pi-browser-smoke-errors.log 2>&1 || { echo \"Browser smoke check failed. See /tmp/pi-browser-smoke-errors.log\"; exit 1; }'",
"test": "npm run test --workspaces --if-present", "test": "npm run test --workspaces --if-present",
"version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install", "version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install",

View file

@ -0,0 +1,29 @@
import type { AgentSession } from "../agent-session.js";
export function extractMessageText(message: { content: unknown }): string {
if (!Array.isArray(message.content)) {
return "";
}
return message.content
.filter((part): part is { type: "text"; text: string } => {
return (
typeof part === "object" &&
part !== null &&
"type" in part &&
"text" in part &&
part.type === "text"
);
})
.map((part) => part.text)
.join("");
}
export function getLastAssistantText(session: AgentSession): string {
for (let index = session.messages.length - 1; index >= 0; index--) {
const message = session.messages[index];
if (message.role === "assistant") {
return extractMessageText(message);
}
}
return "";
}

View file

@ -0,0 +1,19 @@
export {
createGatewaySessionManager,
GatewayRuntime,
getActiveGatewayRuntime,
sanitizeSessionKey,
setActiveGatewayRuntime,
} from "./runtime.js";
export type {
ChannelStatus,
GatewayConfig,
GatewayMessageRequest,
GatewayMessageResult,
GatewayRuntimeOptions,
GatewaySessionFactory,
GatewaySessionSnapshot,
HistoryMessage,
HistoryPart,
ModelInfo,
} from "./runtime.js";

View file

@ -0,0 +1,76 @@
import type { AgentSession } from "../agent-session.js";
import type {
GatewayMessageRequest,
GatewayMessageResult,
GatewaySessionSnapshot,
} from "./types.js";
export interface GatewayQueuedMessage {
request: GatewayMessageRequest;
resolve: (result: GatewayMessageResult) => void;
onStart?: () => void;
onFinish?: () => void;
}
export type GatewayEvent =
| { type: "hello"; sessionKey: string; snapshot: GatewaySessionSnapshot }
| {
type: "session_state";
sessionKey: string;
snapshot: GatewaySessionSnapshot;
}
| { type: "turn_start"; sessionKey: string }
| { type: "turn_end"; sessionKey: string }
| { type: "message_start"; sessionKey: string; role?: string }
| { type: "token"; sessionKey: string; delta: string; contentIndex: number }
| {
type: "thinking";
sessionKey: string;
delta: string;
contentIndex: number;
}
| {
type: "tool_start";
sessionKey: string;
toolCallId: string;
toolName: string;
args: unknown;
}
| {
type: "tool_update";
sessionKey: string;
toolCallId: string;
toolName: string;
partialResult: unknown;
}
| {
type: "tool_complete";
sessionKey: string;
toolCallId: string;
toolName: string;
result: unknown;
isError: boolean;
}
| { type: "message_complete"; sessionKey: string; text: string }
| { type: "error"; sessionKey: string; error: string }
| { type: "aborted"; sessionKey: string };
export interface ManagedGatewaySession {
sessionKey: string;
session: AgentSession;
queue: GatewayQueuedMessage[];
processing: boolean;
createdAt: number;
lastActiveAt: number;
listeners: Set<(event: GatewayEvent) => void>;
unsubscribe: () => void;
}
export class HttpError extends Error {
constructor(
public readonly statusCode: number,
message: string,
) {
super(message);
}
}

View file

@ -4,177 +4,54 @@ import {
type Server, type Server,
type ServerResponse, type ServerResponse,
} from "node:http"; } from "node:http";
import { rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { URL } from "node:url"; import { URL } from "node:url";
import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ImageContent } from "@mariozechner/pi-ai"; import type { AgentSession, AgentSessionEvent } from "../agent-session.js";
import type { AgentSession, AgentSessionEvent } from "./agent-session.js"; import { extractMessageText, getLastAssistantText } from "./helpers.js";
import { SessionManager } from "./session-manager.js"; import {
import type { Settings } from "./settings-manager.js"; type GatewayEvent,
type GatewayQueuedMessage,
HttpError,
type ManagedGatewaySession,
} from "./internal-types.js";
import { sanitizeSessionKey } from "./session-manager.js";
import type {
ChannelStatus,
GatewayConfig,
GatewayMessageRequest,
GatewayMessageResult,
GatewayRuntimeOptions,
GatewaySessionFactory,
GatewaySessionSnapshot,
HistoryMessage,
HistoryPart,
ModelInfo,
} from "./types.js";
import type { Settings } from "../settings-manager.js";
import { import {
createVercelStreamListener, createVercelStreamListener,
errorVercelStream, errorVercelStream,
extractUserText, extractUserText,
finishVercelStream, finishVercelStream,
} from "./vercel-ai-stream.js"; } from "./vercel-ai-stream.js";
export {
export interface GatewayConfig { createGatewaySessionManager,
bind: string; sanitizeSessionKey,
port: number; } from "./session-manager.js";
bearerToken?: string; export type {
session: { ChannelStatus,
idleMinutes: number; GatewayConfig,
maxQueuePerSession: number; GatewayMessageRequest,
}; GatewayMessageResult,
webhook: { GatewayRuntimeOptions,
enabled: boolean; GatewaySessionFactory,
basePath: string; GatewaySessionSnapshot,
secret?: string; HistoryMessage,
}; HistoryPart,
} ModelInfo,
} from "./types.js";
export type GatewaySessionFactory = (
sessionKey: string,
) => Promise<AgentSession>;
export interface GatewayMessageRequest {
sessionKey: string;
text: string;
source?: "interactive" | "rpc" | "extension";
images?: ImageContent[];
metadata?: Record<string, unknown>;
}
export interface GatewayMessageResult {
ok: boolean;
response: string;
error?: string;
sessionKey: string;
}
export interface GatewaySessionSnapshot {
sessionKey: string;
sessionId: string;
messageCount: number;
queueDepth: number;
processing: boolean;
lastActiveAt: number;
createdAt: number;
name?: string;
lastMessagePreview?: string;
updatedAt: number;
}
export interface ModelInfo {
provider: string;
modelId: string;
displayName: string;
capabilities?: string[];
}
export interface HistoryMessage {
id: string;
role: "user" | "assistant" | "toolResult";
parts: HistoryPart[];
timestamp: number;
}
export type HistoryPart =
| { type: "text"; text: string }
| { type: "reasoning"; text: string }
| {
type: "tool-invocation";
toolCallId: string;
toolName: string;
args: unknown;
state: string;
result?: unknown;
};
export interface ChannelStatus {
id: string;
name: string;
connected: boolean;
error?: string;
}
export interface GatewayRuntimeOptions {
config: GatewayConfig;
primarySessionKey: string;
primarySession: AgentSession;
createSession: GatewaySessionFactory;
log?: (message: string) => void;
}
interface GatewayQueuedMessage {
request: GatewayMessageRequest;
resolve: (result: GatewayMessageResult) => void;
onStart?: () => void;
onFinish?: () => void;
}
type GatewayEvent =
| { type: "hello"; sessionKey: string; snapshot: GatewaySessionSnapshot }
| {
type: "session_state";
sessionKey: string;
snapshot: GatewaySessionSnapshot;
}
| { type: "turn_start"; sessionKey: string }
| { type: "turn_end"; sessionKey: string }
| { type: "message_start"; sessionKey: string; role?: string }
| { type: "token"; sessionKey: string; delta: string; contentIndex: number }
| {
type: "thinking";
sessionKey: string;
delta: string;
contentIndex: number;
}
| {
type: "tool_start";
sessionKey: string;
toolCallId: string;
toolName: string;
args: unknown;
}
| {
type: "tool_update";
sessionKey: string;
toolCallId: string;
toolName: string;
partialResult: unknown;
}
| {
type: "tool_complete";
sessionKey: string;
toolCallId: string;
toolName: string;
result: unknown;
isError: boolean;
}
| { type: "message_complete"; sessionKey: string; text: string }
| { type: "error"; sessionKey: string; error: string }
| { type: "aborted"; sessionKey: string };
interface ManagedGatewaySession {
sessionKey: string;
session: AgentSession;
queue: GatewayQueuedMessage[];
processing: boolean;
createdAt: number;
lastActiveAt: number;
listeners: Set<(event: GatewayEvent) => void>;
unsubscribe: () => void;
}
class HttpError extends Error {
constructor(
public readonly statusCode: number,
message: string,
) {
super(message);
}
}
let activeGatewayRuntime: GatewayRuntime | null = null; let activeGatewayRuntime: GatewayRuntime | null = null;
@ -759,7 +636,7 @@ export class GatewayRuntime {
const action = sessionMatch[2]; const action = sessionMatch[2];
if (!action && method === "GET") { if (!action && method === "GET") {
const session = this.getManagedSessionOrThrow(sessionKey); const session = await this.ensureSession(sessionKey);
this.writeJson(response, 200, { session: this.createSnapshot(session) }); this.writeJson(response, 200, { session: this.createSnapshot(session) });
return; return;
} }
@ -818,7 +695,7 @@ export class GatewayRuntime {
if (action === "history" && method === "GET") { if (action === "history" && method === "GET") {
const limitParam = url.searchParams.get("limit"); const limitParam = url.searchParams.get("limit");
const messages = this.handleGetHistory( const messages = await this.handleGetHistory(
sessionKey, sessionKey,
limitParam ? parseInt(limitParam, 10) : undefined, limitParam ? parseInt(limitParam, 10) : undefined,
); );
@ -1067,7 +944,7 @@ export class GatewayRuntime {
provider: string, provider: string,
modelId: string, modelId: string,
): Promise<{ ok: true; model: { provider: string; modelId: string } }> { ): Promise<{ ok: true; model: { provider: string; modelId: string } }> {
const managed = this.getManagedSessionOrThrow(sessionKey); const managed = await this.ensureSession(sessionKey);
const found = managed.session.modelRegistry.find(provider, modelId); const found = managed.session.modelRegistry.find(provider, modelId);
if (!found) { if (!found) {
throw new HttpError(404, `Model not found: ${provider}/${modelId}`); throw new HttpError(404, `Model not found: ${provider}/${modelId}`);
@ -1076,17 +953,17 @@ export class GatewayRuntime {
return { ok: true, model: { provider, modelId } }; return { ok: true, model: { provider, modelId } };
} }
private handleGetHistory( private async handleGetHistory(
sessionKey: string, sessionKey: string,
limit?: number, limit?: number,
): HistoryMessage[] { ): Promise<HistoryMessage[]> {
if (limit !== undefined && (!Number.isFinite(limit) || limit < 1)) { if (limit !== undefined && (!Number.isFinite(limit) || limit < 1)) {
throw new HttpError(400, "History limit must be a positive integer"); throw new HttpError(400, "History limit must be a positive integer");
} }
const managed = this.getManagedSessionOrThrow(sessionKey); const managed = await this.ensureSession(sessionKey);
const rawMessages = managed.session.messages; const rawMessages = managed.session.messages;
const messages: HistoryMessage[] = []; const messages: HistoryMessage[] = [];
for (const msg of rawMessages) { for (const [index, msg] of rawMessages.entries()) {
if ( if (
msg.role !== "user" && msg.role !== "user" &&
msg.role !== "assistant" && msg.role !== "assistant" &&
@ -1095,7 +972,7 @@ export class GatewayRuntime {
continue; continue;
} }
messages.push({ messages.push({
id: `${msg.timestamp}-${msg.role}`, id: `${msg.timestamp}-${msg.role}-${index}`,
role: msg.role, role: msg.role,
parts: this.messageContentToParts(msg), parts: this.messageContentToParts(msg),
timestamp: msg.timestamp, timestamp: msg.timestamp,
@ -1108,7 +985,7 @@ export class GatewayRuntime {
sessionKey: string, sessionKey: string,
patch: { name?: string }, patch: { name?: string },
): Promise<void> { ): Promise<void> {
const managed = this.getManagedSessionOrThrow(sessionKey); const managed = await this.ensureSession(sessionKey);
if (patch.name !== undefined) { if (patch.name !== undefined) {
// Labels in pi-mono are per-entry; we label the current leaf entry // Labels in pi-mono are per-entry; we label the current leaf entry
const leafId = managed.session.sessionManager.getLeafId(); const leafId = managed.session.sessionManager.getLeafId();
@ -1126,14 +1003,20 @@ export class GatewayRuntime {
if (sessionKey === this.primarySessionKey) { if (sessionKey === this.primarySessionKey) {
throw new HttpError(400, "Cannot delete primary session"); throw new HttpError(400, "Cannot delete primary session");
} }
const managed = this.getManagedSessionOrThrow(sessionKey); const managed = this.sessions.get(sessionKey);
if (managed.processing) { if (managed) {
await managed.session.abort(); if (managed.processing) {
await managed.session.abort();
}
this.rejectQueuedMessages(managed, `Session deleted: ${sessionKey}`);
managed.unsubscribe();
managed.session.dispose();
this.sessions.delete(sessionKey);
} }
this.rejectQueuedMessages(managed, `Session deleted: ${sessionKey}`); await rm(this.getGatewaySessionDir(sessionKey), {
managed.unsubscribe(); recursive: true,
managed.session.dispose(); force: true,
this.sessions.delete(sessionKey); }).catch(() => undefined);
} }
private getPublicConfig(): Record<string, unknown> { private getPublicConfig(): Record<string, unknown> {
@ -1179,7 +1062,7 @@ export class GatewayRuntime {
} }
private async handleReloadSession(sessionKey: string): Promise<void> { private async handleReloadSession(sessionKey: string): Promise<void> {
const managed = this.getManagedSessionOrThrow(sessionKey); const managed = await this.ensureSession(sessionKey);
// Reloading config by calling settingsManager.reload() on the session // Reloading config by calling settingsManager.reload() on the session
managed.session.settingsManager.reload(); managed.session.settingsManager.reload();
} }
@ -1269,46 +1152,3 @@ export class GatewayRuntime {
return join(this.sessionDirRoot, sanitizeSessionKey(sessionKey)); return join(this.sessionDirRoot, sanitizeSessionKey(sessionKey));
} }
} }
function extractMessageText(message: { content: unknown }): string {
if (!Array.isArray(message.content)) {
return "";
}
return message.content
.filter((part): part is { type: "text"; text: string } => {
return (
typeof part === "object" &&
part !== null &&
"type" in part &&
"text" in part &&
part.type === "text"
);
})
.map((part) => part.text)
.join("");
}
function getLastAssistantText(session: AgentSession): string {
for (let index = session.messages.length - 1; index >= 0; index--) {
const message = session.messages[index];
if (message.role === "assistant") {
return extractMessageText(message);
}
}
return "";
}
export function sanitizeSessionKey(sessionKey: string): string {
return sessionKey.replace(/[^a-zA-Z0-9._-]/g, "_");
}
export function createGatewaySessionManager(
cwd: string,
sessionKey: string,
sessionDirRoot: string,
): SessionManager {
return SessionManager.create(
cwd,
join(sessionDirRoot, sanitizeSessionKey(sessionKey)),
);
}

View file

@ -0,0 +1,17 @@
import { join } from "node:path";
import { SessionManager } from "../session-manager.js";
export function sanitizeSessionKey(sessionKey: string): string {
return sessionKey.replace(/[^a-zA-Z0-9._-]/g, "_");
}
export function createGatewaySessionManager(
cwd: string,
sessionKey: string,
sessionDirRoot: string,
): SessionManager {
return SessionManager.continueRecent(
cwd,
join(sessionDirRoot, sanitizeSessionKey(sessionKey)),
);
}

View file

@ -0,0 +1,90 @@
import type { ImageContent } from "@mariozechner/pi-ai";
import type { AgentSession } from "../agent-session.js";
export interface GatewayConfig {
bind: string;
port: number;
bearerToken?: string;
session: {
idleMinutes: number;
maxQueuePerSession: number;
};
webhook: {
enabled: boolean;
basePath: string;
secret?: string;
};
}
export type GatewaySessionFactory = (
sessionKey: string,
) => Promise<AgentSession>;
export interface GatewayMessageRequest {
sessionKey: string;
text: string;
source?: "interactive" | "rpc" | "extension";
images?: ImageContent[];
metadata?: Record<string, unknown>;
}
export interface GatewayMessageResult {
ok: boolean;
response: string;
error?: string;
sessionKey: string;
}
export interface GatewaySessionSnapshot {
sessionKey: string;
sessionId: string;
messageCount: number;
queueDepth: number;
processing: boolean;
lastActiveAt: number;
createdAt: number;
name?: string;
lastMessagePreview?: string;
updatedAt: number;
}
export interface ModelInfo {
provider: string;
modelId: string;
displayName: string;
capabilities?: string[];
}
export interface HistoryMessage {
id: string;
role: "user" | "assistant" | "toolResult";
parts: HistoryPart[];
timestamp: number;
}
export type HistoryPart =
| { type: "text"; text: string }
| { type: "reasoning"; text: string }
| {
type: "tool-invocation";
toolCallId: string;
toolName: string;
args: unknown;
state: string;
result?: unknown;
};
export interface ChannelStatus {
id: string;
name: string;
connected: boolean;
error?: string;
}
export interface GatewayRuntimeOptions {
config: GatewayConfig;
primarySessionKey: string;
primarySession: AgentSession;
createSession: GatewaySessionFactory;
log?: (message: string) => void;
}

View file

@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { ServerResponse } from "node:http"; import type { ServerResponse } from "node:http";
import type { AgentSessionEvent } from "./agent-session.js"; import type { AgentSessionEvent } from "../agent-session.js";
/** /**
* Write a single Vercel AI SDK v5+ SSE chunk to the response. * Write a single Vercel AI SDK v5+ SSE chunk to the response.

View file

@ -156,7 +156,7 @@ export {
getActiveGatewayRuntime, getActiveGatewayRuntime,
sanitizeSessionKey, sanitizeSessionKey,
setActiveGatewayRuntime, setActiveGatewayRuntime,
} from "./core/gateway-runtime.js"; } from "./core/gateway/index.js";
export { convertToLlm } from "./core/messages.js"; export { convertToLlm } from "./core/messages.js";
export { ModelRegistry } from "./core/model-registry.js"; export { ModelRegistry } from "./core/model-registry.js";
export type { export type {

View file

@ -22,7 +22,7 @@ import { APP_NAME, getAgentDir, getModelsPath, VERSION } from "./config.js";
import { AuthStorage } from "./core/auth-storage.js"; import { AuthStorage } from "./core/auth-storage.js";
import { exportFromFile } from "./core/export-html/index.js"; import { exportFromFile } from "./core/export-html/index.js";
import type { LoadExtensionsResult } from "./core/extensions/index.js"; import type { LoadExtensionsResult } from "./core/extensions/index.js";
import { createGatewaySessionManager } from "./core/gateway-runtime.js"; import { createGatewaySessionManager } from "./core/gateway/index.js";
import { KeybindingsManager } from "./core/keybindings.js"; import { KeybindingsManager } from "./core/keybindings.js";
import { ModelRegistry } from "./core/model-registry.js"; import { ModelRegistry } from "./core/model-registry.js";
import { import {

View file

@ -12,7 +12,7 @@ import {
GatewayRuntime, GatewayRuntime,
type GatewaySessionFactory, type GatewaySessionFactory,
setActiveGatewayRuntime, setActiveGatewayRuntime,
} from "../core/gateway-runtime.js"; } from "../core/gateway/index.js";
import type { GatewaySettings } from "../core/settings-manager.js"; import type { GatewaySettings } from "../core/settings-manager.js";
/** /**

View file

@ -1,287 +0,0 @@
# Changelog
## [Unreleased]
## [0.56.2] - 2026-03-05
## [0.56.1] - 2026-03-05
## [0.56.0] - 2026-03-04
## [0.55.4] - 2026-03-02
## [0.55.3] - 2026-02-27
## [0.55.2] - 2026-02-27
## [0.55.1] - 2026-02-26
## [0.55.0] - 2026-02-24
## [0.54.2] - 2026-02-23
## [0.54.1] - 2026-02-22
## [0.54.0] - 2026-02-19
## [0.53.1] - 2026-02-19
## [0.53.0] - 2026-02-17
## [0.52.12] - 2026-02-13
## [0.52.11] - 2026-02-13
## [0.52.10] - 2026-02-12
### Fixed
- Made model selector search case-insensitive by normalizing query tokens, fixing auto-capitalized mobile input filtering ([#1443](https://github.com/badlogic/pi-mono/issues/1443))
## [0.52.9] - 2026-02-08
## [0.52.8] - 2026-02-07
## [0.52.7] - 2026-02-06
## [0.52.6] - 2026-02-05
## [0.52.5] - 2026-02-05
## [0.52.4] - 2026-02-05
## [0.52.3] - 2026-02-05
## [0.52.2] - 2026-02-05
## [0.52.1] - 2026-02-05
## [0.52.0] - 2026-02-05
## [0.51.6] - 2026-02-04
## [0.51.5] - 2026-02-04
## [0.51.4] - 2026-02-03
## [0.51.3] - 2026-02-03
## [0.51.2] - 2026-02-03
## [0.51.1] - 2026-02-02
## [0.51.0] - 2026-02-01
## [0.50.9] - 2026-02-01
## [0.50.8] - 2026-02-01
## [0.50.7] - 2026-01-31
## [0.50.6] - 2026-01-30
## [0.50.5] - 2026-01-30
## [0.50.3] - 2026-01-29
## [0.50.2] - 2026-01-29
### Added
- Exported `CustomProviderCard`, `ProviderKeyInput`, `AbortedMessage`, and `ToolMessageDebugView` components for custom UIs ([#1015](https://github.com/badlogic/pi-mono/issues/1015))
## [0.50.1] - 2026-01-26
## [0.50.0] - 2026-01-26
## [0.49.3] - 2026-01-22
### Changed
- Updated tsgo to 7.0.0-dev.20260120.1 for decorator support ([#873](https://github.com/badlogic/pi-mono/issues/873))
## [0.49.2] - 2026-01-19
## [0.49.1] - 2026-01-18
## [0.49.0] - 2026-01-17
## [0.48.0] - 2026-01-16
## [0.47.0] - 2026-01-16
## [0.46.0] - 2026-01-15
## [0.45.7] - 2026-01-13
## [0.45.6] - 2026-01-13
## [0.45.5] - 2026-01-13
## [0.45.4] - 2026-01-13
## [0.45.3] - 2026-01-13
## [0.45.2] - 2026-01-13
## [0.45.1] - 2026-01-13
## [0.45.0] - 2026-01-13
## [0.44.0] - 2026-01-12
## [0.43.0] - 2026-01-11
## [0.42.5] - 2026-01-11
## [0.42.4] - 2026-01-10
## [0.42.3] - 2026-01-10
## [0.42.2] - 2026-01-10
## [0.42.1] - 2026-01-09
## [0.42.0] - 2026-01-09
## [0.41.0] - 2026-01-09
## [0.40.1] - 2026-01-09
## [0.40.0] - 2026-01-08
## [0.39.1] - 2026-01-08
## [0.39.0] - 2026-01-08
## [0.38.0] - 2026-01-08
## [0.37.8] - 2026-01-07
## [0.37.7] - 2026-01-07
## [0.37.6] - 2026-01-06
## [0.37.5] - 2026-01-06
## [0.37.4] - 2026-01-06
## [0.37.3] - 2026-01-06
## [0.37.2] - 2026-01-05
## [0.37.1] - 2026-01-05
## [0.37.0] - 2026-01-05
## [0.36.0] - 2026-01-05
## [0.35.0] - 2026-01-05
## [0.34.2] - 2026-01-04
## [0.34.1] - 2026-01-04
## [0.34.0] - 2026-01-04
## [0.33.0] - 2026-01-04
## [0.32.3] - 2026-01-03
## [0.32.2] - 2026-01-03
## [0.32.1] - 2026-01-03
## [0.32.0] - 2026-01-03
## [0.31.1] - 2026-01-02
## [0.31.0] - 2026-01-02
### Breaking Changes
- **Agent class moved to `@mariozechner/pi-agent-core`**: The `Agent` class, `AgentState`, and related types are no longer exported from this package. Import them from `@mariozechner/pi-agent-core` instead.
- **Transport abstraction removed**: `ProviderTransport`, `AppTransport`, `AgentTransport` interface, and related types have been removed. The `Agent` class now uses `streamFn` for custom streaming.
- **`AppMessage` renamed to `AgentMessage`**: Now imported from `@mariozechner/pi-agent-core`. Custom message types use declaration merging on `CustomAgentMessages` interface.
- **`UserMessageWithAttachments` is now a custom message type**: Has `role: "user-with-attachments"` instead of `role: "user"`. Use `isUserMessageWithAttachments()` type guard.
- **`CustomMessages` interface removed**: Use declaration merging on `CustomAgentMessages` from `@mariozechner/pi-agent-core` instead.
- **`agent.appendMessage()` removed**: Use `agent.queueMessage()` instead.
- **Agent event types changed**: `AgentInterface` now handles new event types from `@mariozechner/pi-agent-core`: `message_start`, `message_end`, `message_update`, `turn_start`, `turn_end`, `agent_start`, `agent_end`.
### Added
- **`defaultConvertToLlm`**: Default message transformer that handles `UserMessageWithAttachments` and `ArtifactMessage`. Apps can extend this for custom message types.
- **`convertAttachments`**: Utility to convert `Attachment[]` to LLM content blocks (images and extracted document text).
- **`isUserMessageWithAttachments` / `isArtifactMessage`**: Type guard functions for custom message types.
- **`createStreamFn`**: Creates a stream function with CORS proxy support. Reads proxy settings on each call for dynamic configuration.
- **Default `streamFn` and `getApiKey`**: `AgentInterface` now sets sensible defaults if not provided:
- `streamFn`: Uses `createStreamFn` with proxy settings from storage
- `getApiKey`: Reads from `providerKeys` storage
- **Proxy utilities exported**: `applyProxyIfNeeded`, `shouldUseProxyForProvider`, `isCorsError`, `createStreamFn`
### Removed
- `Agent` class (moved to `@mariozechner/pi-agent-core`)
- `ProviderTransport` class
- `AppTransport` class
- `AgentTransport` interface
- `AgentRunConfig` type
- `ProxyAssistantMessageEvent` type
- `test-sessions.ts` example file
### Migration Guide
**Before (0.30.x):**
```typescript
import { Agent, ProviderTransport, type AppMessage } from '@mariozechner/pi-web-ui';
const agent = new Agent({
transport: new ProviderTransport(),
messageTransformer: (messages: AppMessage[]) => messages.filter(...)
});
```
**After:**
```typescript
import { Agent, type AgentMessage } from "@mariozechner/pi-agent-core";
import { defaultConvertToLlm } from "@mariozechner/pi-web-ui";
const agent = new Agent({
convertToLlm: (messages: AgentMessage[]) => {
// Extend defaultConvertToLlm for custom types
return defaultConvertToLlm(messages);
},
});
// AgentInterface will set streamFn and getApiKey defaults automatically
```
**Custom message types:**
```typescript
// Before: declaration merging on CustomMessages
declare module "@mariozechner/pi-web-ui" {
interface CustomMessages {
"my-message": MyMessage;
}
}
// After: declaration merging on CustomAgentMessages
declare module "@mariozechner/pi-agent-core" {
interface CustomAgentMessages {
"my-message": MyMessage;
}
}
```

View file

@ -1,650 +0,0 @@
# @mariozechner/pi-web-ui
Reusable web UI components for building AI chat interfaces powered by [@mariozechner/pi-ai](../ai) and [@mariozechner/pi-agent-core](../agent).
Built with [mini-lit](https://github.com/badlogic/mini-lit) web components and Tailwind CSS v4.
## Features
- **Chat UI**: Complete interface with message history, streaming, and tool execution
- **Tools**: JavaScript REPL, document extraction, and artifacts (HTML, SVG, Markdown, etc.)
- **Attachments**: PDF, DOCX, XLSX, PPTX, images with preview and text extraction
- **Artifacts**: Interactive HTML, SVG, Markdown with sandboxed execution
- **Storage**: IndexedDB-backed storage for sessions, API keys, and settings
- **CORS Proxy**: Automatic proxy handling for browser environments
- **Custom Providers**: Support for Ollama, LM Studio, vLLM, and OpenAI-compatible APIs
## Installation
```bash
npm install @mariozechner/pi-web-ui @mariozechner/pi-agent-core @mariozechner/pi-ai
```
## Quick Start
See the [example](./example) directory for a complete working application.
```typescript
import { Agent } from "@mariozechner/pi-agent-core";
import { getModel } from "@mariozechner/pi-ai";
import {
ChatPanel,
AppStorage,
IndexedDBStorageBackend,
ProviderKeysStore,
SessionsStore,
SettingsStore,
setAppStorage,
defaultConvertToLlm,
ApiKeyPromptDialog,
} from "@mariozechner/pi-web-ui";
import "@mariozechner/pi-web-ui/app.css";
// Set up storage
const settings = new SettingsStore();
const providerKeys = new ProviderKeysStore();
const sessions = new SessionsStore();
const backend = new IndexedDBStorageBackend({
dbName: "my-app",
version: 1,
stores: [
settings.getConfig(),
providerKeys.getConfig(),
sessions.getConfig(),
SessionsStore.getMetadataConfig(),
],
});
settings.setBackend(backend);
providerKeys.setBackend(backend);
sessions.setBackend(backend);
const storage = new AppStorage(
settings,
providerKeys,
sessions,
undefined,
backend,
);
setAppStorage(storage);
// Create agent
const agent = new Agent({
initialState: {
systemPrompt: "You are a helpful assistant.",
model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
thinkingLevel: "off",
messages: [],
tools: [],
},
convertToLlm: defaultConvertToLlm,
});
// Create chat panel
const chatPanel = new ChatPanel();
await chatPanel.setAgent(agent, {
onApiKeyRequired: (provider) => ApiKeyPromptDialog.prompt(provider),
});
document.body.appendChild(chatPanel);
```
## Architecture
```
┌─────────────────────────────────────────────────────┐
│ ChatPanel │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ AgentInterface │ │ ArtifactsPanel │ │
│ │ (messages, input) │ │ (HTML, SVG, MD) │ │
│ └─────────────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ Agent (from pi-agent-core) │
│ - State management (messages, model, tools) │
│ - Event emission (agent_start, message_update, ...) │
│ - Tool execution │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ AppStorage │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Settings │ │ Provider │ │ Sessions │ │
│ │ Store │ │Keys Store│ │ Store │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │ │
│ IndexedDBStorageBackend │
└─────────────────────────────────────────────────────┘
```
## Components
### ChatPanel
High-level chat interface with built-in artifacts panel.
```typescript
const chatPanel = new ChatPanel();
await chatPanel.setAgent(agent, {
// Prompt for API key when needed
onApiKeyRequired: async (provider) => ApiKeyPromptDialog.prompt(provider),
// Hook before sending messages
onBeforeSend: async () => {
/* save draft, etc. */
},
// Handle cost display click
onCostClick: () => {
/* show cost breakdown */
},
// Custom sandbox URL for browser extensions
sandboxUrlProvider: () => chrome.runtime.getURL("sandbox.html"),
// Add custom tools
toolsFactory: (
agent,
agentInterface,
artifactsPanel,
runtimeProvidersFactory,
) => {
const replTool = createJavaScriptReplTool();
replTool.runtimeProvidersFactory = runtimeProvidersFactory;
return [replTool];
},
});
```
### AgentInterface
Lower-level chat interface for custom layouts.
```typescript
const chat = document.createElement("agent-interface") as AgentInterface;
chat.session = agent;
chat.enableAttachments = true;
chat.enableModelSelector = true;
chat.enableThinkingSelector = true;
chat.onApiKeyRequired = async (provider) => {
/* ... */
};
chat.onBeforeSend = async () => {
/* ... */
};
```
Properties:
- `session`: Agent instance
- `enableAttachments`: Show attachment button (default: true)
- `enableModelSelector`: Show model selector (default: true)
- `enableThinkingSelector`: Show thinking level selector (default: true)
- `showThemeToggle`: Show theme toggle (default: false)
### Agent (from pi-agent-core)
```typescript
import { Agent } from '@mariozechner/pi-agent-core';
const agent = new Agent({
initialState: {
model: getModel('anthropic', 'claude-sonnet-4-5-20250929'),
systemPrompt: 'You are helpful.',
thinkingLevel: 'off',
messages: [],
tools: [],
},
convertToLlm: defaultConvertToLlm,
});
// Events
agent.subscribe((event) => {
switch (event.type) {
case 'agent_start': // Agent loop started
case 'agent_end': // Agent loop finished
case 'turn_start': // LLM call started
case 'turn_end': // LLM call finished
case 'message_start':
case 'message_update': // Streaming update
case 'message_end':
break;
}
});
// Send message
await agent.prompt('Hello!');
await agent.prompt({ role: 'user-with-attachments', content: 'Check this', attachments, timestamp: Date.now() });
// Control
agent.abort();
agent.setModel(newModel);
agent.setThinkingLevel('medium');
agent.setTools([...]);
agent.queueMessage(customMessage);
```
## Message Types
### UserMessageWithAttachments
User message with file attachments:
```typescript
const message: UserMessageWithAttachments = {
role: "user-with-attachments",
content: "Analyze this document",
attachments: [pdfAttachment],
timestamp: Date.now(),
};
// Type guard
if (isUserMessageWithAttachments(msg)) {
console.log(msg.attachments);
}
```
### ArtifactMessage
For session persistence of artifacts:
```typescript
const artifact: ArtifactMessage = {
role: "artifact",
action: "create", // or 'update', 'delete'
filename: "chart.html",
content: "<div>...</div>",
timestamp: new Date().toISOString(),
};
// Type guard
if (isArtifactMessage(msg)) {
console.log(msg.filename);
}
```
### Custom Message Types
Extend via declaration merging:
```typescript
interface SystemNotification {
role: "system-notification";
message: string;
level: "info" | "warning" | "error";
timestamp: string;
}
declare module "@mariozechner/pi-agent-core" {
interface CustomAgentMessages {
"system-notification": SystemNotification;
}
}
// Register renderer
registerMessageRenderer("system-notification", {
render: (msg) => html`<div class="alert">${msg.message}</div>`,
});
// Extend convertToLlm
function myConvertToLlm(messages: AgentMessage[]): Message[] {
const processed = messages.map((m) => {
if (m.role === "system-notification") {
return {
role: "user",
content: `<system>${m.message}</system>`,
timestamp: Date.now(),
};
}
return m;
});
return defaultConvertToLlm(processed);
}
```
## Message Transformer
`convertToLlm` transforms app messages to LLM-compatible format:
```typescript
import {
defaultConvertToLlm,
convertAttachments,
} from "@mariozechner/pi-web-ui";
// defaultConvertToLlm handles:
// - UserMessageWithAttachments → user message with image/text content blocks
// - ArtifactMessage → filtered out (UI-only)
// - Standard messages (user, assistant, toolResult) → passed through
```
## Tools
### JavaScript REPL
Execute JavaScript in a sandboxed browser environment:
```typescript
import { createJavaScriptReplTool } from "@mariozechner/pi-web-ui";
const replTool = createJavaScriptReplTool();
// Configure runtime providers for artifact/attachment access
replTool.runtimeProvidersFactory = () => [
new AttachmentsRuntimeProvider(attachments),
new ArtifactsRuntimeProvider(artifactsPanel, agent, true), // read-write
];
agent.setTools([replTool]);
```
### Extract Document
Extract text from documents at URLs:
```typescript
import { createExtractDocumentTool } from "@mariozechner/pi-web-ui";
const extractTool = createExtractDocumentTool();
extractTool.corsProxyUrl = "https://corsproxy.io/?";
agent.setTools([extractTool]);
```
### Artifacts Tool
Built into ArtifactsPanel, supports: HTML, SVG, Markdown, text, JSON, images, PDF, DOCX, XLSX.
```typescript
const artifactsPanel = new ArtifactsPanel();
artifactsPanel.agent = agent;
// The tool is available as artifactsPanel.tool
agent.setTools([artifactsPanel.tool]);
```
### Custom Tool Renderers
```typescript
import {
registerToolRenderer,
type ToolRenderer,
} from "@mariozechner/pi-web-ui";
const myRenderer: ToolRenderer = {
render(params, result, isStreaming) {
return {
content: html`<div>...</div>`,
isCustom: false, // true = no card wrapper
};
},
};
registerToolRenderer("my_tool", myRenderer);
```
## Storage
### Setup
```typescript
import {
AppStorage,
IndexedDBStorageBackend,
SettingsStore,
ProviderKeysStore,
SessionsStore,
CustomProvidersStore,
setAppStorage,
getAppStorage,
} from "@mariozechner/pi-web-ui";
// Create stores
const settings = new SettingsStore();
const providerKeys = new ProviderKeysStore();
const sessions = new SessionsStore();
const customProviders = new CustomProvidersStore();
// Create backend with all store configs
const backend = new IndexedDBStorageBackend({
dbName: "my-app",
version: 1,
stores: [
settings.getConfig(),
providerKeys.getConfig(),
sessions.getConfig(),
SessionsStore.getMetadataConfig(),
customProviders.getConfig(),
],
});
// Wire stores to backend
settings.setBackend(backend);
providerKeys.setBackend(backend);
sessions.setBackend(backend);
customProviders.setBackend(backend);
// Create and set global storage
const storage = new AppStorage(
settings,
providerKeys,
sessions,
customProviders,
backend,
);
setAppStorage(storage);
```
### SettingsStore
Key-value settings:
```typescript
await storage.settings.set("proxy.enabled", true);
await storage.settings.set("proxy.url", "https://proxy.example.com");
const enabled = await storage.settings.get<boolean>("proxy.enabled");
```
### ProviderKeysStore
API keys by provider:
```typescript
await storage.providerKeys.set("anthropic", "sk-ant-...");
const key = await storage.providerKeys.get("anthropic");
const providers = await storage.providerKeys.list();
```
### SessionsStore
Chat sessions with metadata:
```typescript
// Save session
await storage.sessions.save(sessionData, metadata);
// Load session
const data = await storage.sessions.get(sessionId);
const metadata = await storage.sessions.getMetadata(sessionId);
// List sessions (sorted by lastModified)
const allMetadata = await storage.sessions.getAllMetadata();
// Update title
await storage.sessions.updateTitle(sessionId, "New Title");
// Delete
await storage.sessions.delete(sessionId);
```
### CustomProvidersStore
Custom LLM providers:
```typescript
const provider: CustomProvider = {
id: crypto.randomUUID(),
name: "My Ollama",
type: "ollama",
baseUrl: "http://localhost:11434",
};
await storage.customProviders.set(provider);
const all = await storage.customProviders.getAll();
```
## Attachments
Load and process files:
```typescript
import { loadAttachment, type Attachment } from "@mariozechner/pi-web-ui";
// From File input
const file = inputElement.files[0];
const attachment = await loadAttachment(file);
// From URL
const attachment = await loadAttachment("https://example.com/doc.pdf");
// From ArrayBuffer
const attachment = await loadAttachment(arrayBuffer, "document.pdf");
// Attachment structure
interface Attachment {
id: string;
type: "image" | "document";
fileName: string;
mimeType: string;
size: number;
content: string; // base64 encoded
extractedText?: string; // For documents
preview?: string; // base64 preview image
}
```
Supported formats: PDF, DOCX, XLSX, PPTX, images, text files.
## CORS Proxy
For browser environments with CORS restrictions:
```typescript
import {
createStreamFn,
shouldUseProxyForProvider,
isCorsError,
} from "@mariozechner/pi-web-ui";
// AgentInterface auto-configures proxy from settings
// For manual setup:
agent.streamFn = createStreamFn(async () => {
const enabled = await storage.settings.get<boolean>("proxy.enabled");
return enabled ? await storage.settings.get<string>("proxy.url") : undefined;
});
// Providers requiring proxy:
// - zai: always
// - anthropic: only OAuth tokens (sk-ant-oat-*)
```
## Dialogs
### SettingsDialog
```typescript
import {
SettingsDialog,
ProvidersModelsTab,
ProxyTab,
ApiKeysTab,
} from "@mariozechner/pi-web-ui";
SettingsDialog.open([
new ProvidersModelsTab(), // Custom providers + model list
new ProxyTab(), // CORS proxy settings
new ApiKeysTab(), // API keys per provider
]);
```
### SessionListDialog
```typescript
import { SessionListDialog } from "@mariozechner/pi-web-ui";
SessionListDialog.open(
async (sessionId) => {
/* load session */
},
(deletedId) => {
/* handle deletion */
},
);
```
### ApiKeyPromptDialog
```typescript
import { ApiKeyPromptDialog } from "@mariozechner/pi-web-ui";
const success = await ApiKeyPromptDialog.prompt("anthropic");
```
### ModelSelector
```typescript
import { ModelSelector } from "@mariozechner/pi-web-ui";
ModelSelector.open(currentModel, (selectedModel) => {
agent.setModel(selectedModel);
});
```
## Styling
Import the pre-built CSS:
```typescript
import "@mariozechner/pi-web-ui/app.css";
```
Or use Tailwind with custom config:
```css
@import "@mariozechner/mini-lit/themes/claude.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
```
## Internationalization
```typescript
import { i18n, setLanguage, translations } from "@mariozechner/pi-web-ui";
// Add translations
translations.de = {
"Loading...": "Laden...",
"No sessions yet": "Noch keine Sitzungen",
};
setLanguage("de");
console.log(i18n("Loading...")); // "Laden..."
```
## Examples
- [example/](./example) - Complete web app with sessions, artifacts, custom messages
- [sitegeist](https://sitegeist.ai) - Browser extension using pi-web-ui
## Known Issues
- **PersistentStorageDialog**: Currently broken
## License
MIT

View file

@ -1,3 +0,0 @@
node_modules
dist
.DS_Store

View file

@ -1,61 +0,0 @@
# Pi Web UI - Example
This is a minimal example showing how to use `@mariozechner/pi-web-ui` in a web application.
## Setup
```bash
npm install
```
## Development
```bash
npm run dev
```
Open [http://localhost:5173](http://localhost:5173) in your browser.
## What's Included
This example demonstrates:
- **ChatPanel** - The main chat interface component
- **System Prompt** - Custom configuration for the AI assistant
- **Tools** - JavaScript REPL and artifacts tool
## Configuration
### API Keys
The example uses **Direct Mode** by default, which means it calls AI provider APIs directly from the browser.
To use the chat:
1. Click the settings icon (⚙️) in the chat interface
2. Click "Manage API Keys"
3. Add your API key for your preferred provider:
- **Anthropic**: Get a key from [console.anthropic.com](https://console.anthropic.com/)
- **OpenAI**: Get a key from [platform.openai.com](https://platform.openai.com/)
- **Google**: Get a key from [makersuite.google.com](https://makersuite.google.com/)
API keys are stored in your browser's localStorage and never sent to any server except the AI provider's API.
## Project Structure
```
example/
├── src/
│ ├── main.ts # Main application entry point
│ └── app.css # Tailwind CSS configuration
├── index.html # HTML entry point
├── package.json # Dependencies
├── vite.config.ts # Vite configuration
└── tsconfig.json # TypeScript configuration
```
## Learn More
- [Pi Web UI Documentation](../README.md)
- [Pi AI Documentation](../../ai/README.md)
- [Mini Lit Documentation](https://github.com/badlogic/mini-lit)

View file

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pi Web UI - Example</title>
<meta name="description" content="Example usage of @mariozechner/pi-web-ui - Reusable AI chat interface" />
</head>
<body class="bg-background">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -1,25 +0,0 @@
{
"name": "pi-web-ui-example",
"version": "1.44.2",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "tsgo --noEmit",
"clean": "shx rm -rf dist"
},
"dependencies": {
"@mariozechner/mini-lit": "^0.2.0",
"@mariozechner/pi-ai": "file:../../ai",
"@mariozechner/pi-web-ui": "file:../",
"@tailwindcss/vite": "^4.1.17",
"lit": "^3.3.1",
"lucide": "^0.544.0"
},
"devDependencies": {
"typescript": "^5.7.3",
"vite": "^7.1.6"
}
}

View file

@ -1 +0,0 @@
@import "../../dist/app.css";

View file

@ -1,104 +0,0 @@
import { Alert } from "@mariozechner/mini-lit/dist/Alert.js";
import type { Message } from "@mariozechner/pi-ai";
import type { AgentMessage, MessageRenderer } from "@mariozechner/pi-web-ui";
import {
defaultConvertToLlm,
registerMessageRenderer,
} from "@mariozechner/pi-web-ui";
import { html } from "lit";
// ============================================================================
// 1. EXTEND AppMessage TYPE VIA DECLARATION MERGING
// ============================================================================
// Define custom message types
export interface SystemNotificationMessage {
role: "system-notification";
message: string;
variant: "default" | "destructive";
timestamp: string;
}
// Extend CustomAgentMessages interface via declaration merging
// This must target pi-agent-core where CustomAgentMessages is defined
declare module "@mariozechner/pi-agent-core" {
interface CustomAgentMessages {
"system-notification": SystemNotificationMessage;
}
}
// ============================================================================
// 2. CREATE CUSTOM RENDERER (TYPED TO SystemNotificationMessage)
// ============================================================================
const systemNotificationRenderer: MessageRenderer<SystemNotificationMessage> = {
render: (notification) => {
// notification is fully typed as SystemNotificationMessage!
return html`
<div class="px-4">
${Alert({
variant: notification.variant,
children: html`
<div class="flex flex-col gap-1">
<div>${notification.message}</div>
<div class="text-xs opacity-70">
${new Date(notification.timestamp).toLocaleTimeString()}
</div>
</div>
`,
})}
</div>
`;
},
};
// ============================================================================
// 3. REGISTER RENDERER
// ============================================================================
export function registerCustomMessageRenderers() {
registerMessageRenderer("system-notification", systemNotificationRenderer);
}
// ============================================================================
// 4. HELPER TO CREATE CUSTOM MESSAGES
// ============================================================================
export function createSystemNotification(
message: string,
variant: "default" | "destructive" = "default",
): SystemNotificationMessage {
return {
role: "system-notification",
message,
variant,
timestamp: new Date().toISOString(),
};
}
// ============================================================================
// 5. CUSTOM MESSAGE TRANSFORMER
// ============================================================================
/**
* Custom message transformer that extends defaultConvertToLlm.
* Handles system-notification messages by converting them to user messages.
*/
export function customConvertToLlm(messages: AgentMessage[]): Message[] {
// First, handle our custom system-notification type
const processed = messages.map((m): AgentMessage => {
if (m.role === "system-notification") {
const notification = m as SystemNotificationMessage;
// Convert to user message with <system> tags
return {
role: "user",
content: `<system>${notification.message}</system>`,
timestamp: Date.now(),
};
}
return m;
});
// Then use defaultConvertToLlm for standard handling
return defaultConvertToLlm(processed);
}

View file

@ -1,473 +0,0 @@
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
import { Agent, type AgentMessage } from "@mariozechner/pi-agent-core";
import { getModel } from "@mariozechner/pi-ai";
import {
type AgentState,
ApiKeyPromptDialog,
AppStorage,
ChatPanel,
CustomProvidersStore,
createJavaScriptReplTool,
IndexedDBStorageBackend,
// PersistentStorageDialog, // TODO: Fix - currently broken
ProviderKeysStore,
ProvidersModelsTab,
ProxyTab,
SessionListDialog,
SessionsStore,
SettingsDialog,
SettingsStore,
setAppStorage,
} from "@mariozechner/pi-web-ui";
import { html, render } from "lit";
import { Bell, History, Plus, Settings } from "lucide";
import "./app.css";
import { icon } from "@mariozechner/mini-lit";
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import { Input } from "@mariozechner/mini-lit/dist/Input.js";
import {
createSystemNotification,
customConvertToLlm,
registerCustomMessageRenderers,
} from "./custom-messages.js";
// Register custom message renderers
registerCustomMessageRenderers();
// Create stores
const settings = new SettingsStore();
const providerKeys = new ProviderKeysStore();
const sessions = new SessionsStore();
const customProviders = new CustomProvidersStore();
// Gather configs
const configs = [
settings.getConfig(),
SessionsStore.getMetadataConfig(),
providerKeys.getConfig(),
customProviders.getConfig(),
sessions.getConfig(),
];
// Create backend
const backend = new IndexedDBStorageBackend({
dbName: "pi-web-ui-example",
version: 2, // Incremented for custom-providers store
stores: configs,
});
// Wire backend to stores
settings.setBackend(backend);
providerKeys.setBackend(backend);
customProviders.setBackend(backend);
sessions.setBackend(backend);
// Create and set app storage
const storage = new AppStorage(
settings,
providerKeys,
sessions,
customProviders,
backend,
);
setAppStorage(storage);
let currentSessionId: string | undefined;
let currentTitle = "";
let isEditingTitle = false;
let agent: Agent;
let chatPanel: ChatPanel;
let agentUnsubscribe: (() => void) | undefined;
const generateTitle = (messages: AgentMessage[]): string => {
const firstUserMsg = messages.find(
(m) => m.role === "user" || m.role === "user-with-attachments",
);
if (
!firstUserMsg ||
(firstUserMsg.role !== "user" &&
firstUserMsg.role !== "user-with-attachments")
)
return "";
let text = "";
const content = firstUserMsg.content;
if (typeof content === "string") {
text = content;
} else {
const textBlocks = content.filter((c: any) => c.type === "text");
text = textBlocks.map((c: any) => c.text || "").join(" ");
}
text = text.trim();
if (!text) return "";
const sentenceEnd = text.search(/[.!?]/);
if (sentenceEnd > 0 && sentenceEnd <= 50) {
return text.substring(0, sentenceEnd + 1);
}
return text.length <= 50 ? text : `${text.substring(0, 47)}...`;
};
const shouldSaveSession = (messages: AgentMessage[]): boolean => {
const hasUserMsg = messages.some(
(m: any) => m.role === "user" || m.role === "user-with-attachments",
);
const hasAssistantMsg = messages.some((m: any) => m.role === "assistant");
return hasUserMsg && hasAssistantMsg;
};
const saveSession = async () => {
if (!storage.sessions || !currentSessionId || !agent || !currentTitle) return;
const state = agent.state;
if (!shouldSaveSession(state.messages)) return;
try {
// Create session data
const sessionData = {
id: currentSessionId,
title: currentTitle,
model: state.model!,
thinkingLevel: state.thinkingLevel,
messages: state.messages,
createdAt: new Date().toISOString(),
lastModified: new Date().toISOString(),
};
// Create session metadata
const metadata = {
id: currentSessionId,
title: currentTitle,
createdAt: sessionData.createdAt,
lastModified: sessionData.lastModified,
messageCount: state.messages.length,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
},
modelId: state.model?.id || null,
thinkingLevel: state.thinkingLevel,
preview: generateTitle(state.messages),
};
await storage.sessions.save(sessionData, metadata);
} catch (err) {
console.error("Failed to save session:", err);
}
};
const updateUrl = (sessionId: string) => {
const url = new URL(window.location.href);
url.searchParams.set("session", sessionId);
window.history.replaceState({}, "", url);
};
const createAgent = async (initialState?: Partial<AgentState>) => {
if (agentUnsubscribe) {
agentUnsubscribe();
}
agent = new Agent({
initialState: initialState || {
systemPrompt: `You are a helpful AI assistant with access to various tools.
Available tools:
- JavaScript REPL: Execute JavaScript code in a sandboxed browser environment (can do calculations, get time, process data, create visualizations, etc.)
- Artifacts: Create interactive HTML, SVG, Markdown, and text artifacts
Feel free to use these tools when needed to provide accurate and helpful responses.`,
model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
thinkingLevel: "off",
messages: [],
tools: [],
},
// Custom transformer: convert custom messages to LLM-compatible format
convertToLlm: customConvertToLlm,
});
agentUnsubscribe = agent.subscribe((event: any) => {
if (event.type === "state-update") {
const messages = event.state.messages;
// Generate title after first successful response
if (!currentTitle && shouldSaveSession(messages)) {
currentTitle = generateTitle(messages);
}
// Create session ID on first successful save
if (!currentSessionId && shouldSaveSession(messages)) {
currentSessionId = crypto.randomUUID();
updateUrl(currentSessionId);
}
// Auto-save
if (currentSessionId) {
saveSession();
}
renderApp();
}
});
await chatPanel.setAgent(agent, {
onApiKeyRequired: async (provider: string) => {
return await ApiKeyPromptDialog.prompt(provider);
},
toolsFactory: (
_agent,
_agentInterface,
_artifactsPanel,
runtimeProvidersFactory,
) => {
// Create javascript_repl tool with access to attachments + artifacts
const replTool = createJavaScriptReplTool();
replTool.runtimeProvidersFactory = runtimeProvidersFactory;
return [replTool];
},
});
};
const loadSession = async (sessionId: string): Promise<boolean> => {
if (!storage.sessions) return false;
const sessionData = await storage.sessions.get(sessionId);
if (!sessionData) {
console.error("Session not found:", sessionId);
return false;
}
currentSessionId = sessionId;
const metadata = await storage.sessions.getMetadata(sessionId);
currentTitle = metadata?.title || "";
await createAgent({
model: sessionData.model,
thinkingLevel: sessionData.thinkingLevel,
messages: sessionData.messages,
tools: [],
});
updateUrl(sessionId);
renderApp();
return true;
};
const newSession = () => {
const url = new URL(window.location.href);
url.search = "";
window.location.href = url.toString();
};
// ============================================================================
// RENDER
// ============================================================================
const renderApp = () => {
const app = document.getElementById("app");
if (!app) return;
const appHtml = html`
<div
class="w-full h-screen flex flex-col bg-background text-foreground overflow-hidden"
>
<!-- Header -->
<div
class="flex items-center justify-between border-b border-border shrink-0"
>
<div class="flex items-center gap-2 px-4 py-">
${Button({
variant: "ghost",
size: "sm",
children: icon(History, "sm"),
onClick: () => {
SessionListDialog.open(
async (sessionId) => {
await loadSession(sessionId);
},
(deletedSessionId) => {
// Only reload if the current session was deleted
if (deletedSessionId === currentSessionId) {
newSession();
}
},
);
},
title: "Sessions",
})}
${Button({
variant: "ghost",
size: "sm",
children: icon(Plus, "sm"),
onClick: newSession,
title: "New Session",
})}
${currentTitle
? isEditingTitle
? html`<div class="flex items-center gap-2">
${Input({
type: "text",
value: currentTitle,
className: "text-sm w-64",
onChange: async (e: Event) => {
const newTitle = (
e.target as HTMLInputElement
).value.trim();
if (
newTitle &&
newTitle !== currentTitle &&
storage.sessions &&
currentSessionId
) {
await storage.sessions.updateTitle(
currentSessionId,
newTitle,
);
currentTitle = newTitle;
}
isEditingTitle = false;
renderApp();
},
onKeyDown: async (e: KeyboardEvent) => {
if (e.key === "Enter") {
const newTitle = (
e.target as HTMLInputElement
).value.trim();
if (
newTitle &&
newTitle !== currentTitle &&
storage.sessions &&
currentSessionId
) {
await storage.sessions.updateTitle(
currentSessionId,
newTitle,
);
currentTitle = newTitle;
}
isEditingTitle = false;
renderApp();
} else if (e.key === "Escape") {
isEditingTitle = false;
renderApp();
}
},
})}
</div>`
: html`<button
class="px-2 py-1 text-sm text-foreground hover:bg-secondary rounded transition-colors"
@click=${() => {
isEditingTitle = true;
renderApp();
requestAnimationFrame(() => {
const input = app?.querySelector(
'input[type="text"]',
) as HTMLInputElement;
if (input) {
input.focus();
input.select();
}
});
}}
title="Click to edit title"
>
${currentTitle}
</button>`
: html`<span class="text-base font-semibold text-foreground"
>Pi Web UI Example</span
>`}
</div>
<div class="flex items-center gap-1 px-2">
${Button({
variant: "ghost",
size: "sm",
children: icon(Bell, "sm"),
onClick: () => {
// Demo: Inject custom message (will appear on next agent run)
if (agent) {
agent.steer(
createSystemNotification(
"This is a custom message! It appears in the UI but is never sent to the LLM.",
),
);
}
},
title: "Demo: Add Custom Notification",
})}
<theme-toggle></theme-toggle>
${Button({
variant: "ghost",
size: "sm",
children: icon(Settings, "sm"),
onClick: () =>
SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]),
title: "Settings",
})}
</div>
</div>
<!-- Chat Panel -->
${chatPanel}
</div>
`;
render(appHtml, app);
};
// ============================================================================
// INIT
// ============================================================================
async function initApp() {
const app = document.getElementById("app");
if (!app) throw new Error("App container not found");
// Show loading
render(
html`
<div
class="w-full h-screen flex items-center justify-center bg-background text-foreground"
>
<div class="text-muted-foreground">Loading...</div>
</div>
`,
app,
);
// TODO: Fix PersistentStorageDialog - currently broken
// Request persistent storage
// if (storage.sessions) {
// await PersistentStorageDialog.request();
// }
// Create ChatPanel
chatPanel = new ChatPanel();
// Check for session in URL
const urlParams = new URLSearchParams(window.location.search);
const sessionIdFromUrl = urlParams.get("session");
if (sessionIdFromUrl) {
const loaded = await loadSession(sessionIdFromUrl);
if (!loaded) {
// Session doesn't exist, redirect to new session
newSession();
return;
}
} else {
await createAgent();
}
renderApp();
}
initApp();

View file

@ -1,23 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"paths": {
"*": ["./*"],
"@mariozechner/pi-agent-core": ["../../agent/dist/index.d.ts"],
"@mariozechner/pi-ai": ["../../ai/dist/index.d.ts"],
"@mariozechner/pi-tui": ["../../tui/dist/index.d.ts"],
"@mariozechner/pi-web-ui": ["../dist/index.d.ts"]
},
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"useDefineForClassFields": false
},
"include": ["src/**/*"],
"exclude": ["../src"]
}

View file

@ -1,6 +0,0 @@
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [tailwindcss()],
});

View file

@ -1,51 +0,0 @@
{
"name": "@mariozechner/pi-web-ui",
"version": "0.56.2",
"description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": "./dist/index.js",
"./app.css": "./dist/app.css"
},
"scripts": {
"clean": "shx rm -rf dist",
"build": "tsgo -p tsconfig.build.json && tailwindcss -i ./src/app.css -o ./dist/app.css --minify",
"dev": "concurrently --names \"build,example\" --prefix-colors \"cyan,green\" \"tsc -p tsconfig.build.json --watch --preserveWatchOutput\" \"tailwindcss -i ./src/app.css -o ./dist/app.css --watch\" \"npm run dev --prefix example\"",
"dev:tsc": "concurrently --names \"build\" --prefix-colors \"cyan\" \"tsc -p tsconfig.build.json --watch --preserveWatchOutput\" \"tailwindcss -i ./src/app.css -o ./dist/app.css --watch\"",
"check": "biome check --write --error-on-warnings . && tsc --noEmit && cd example && biome check --write --error-on-warnings . && tsc --noEmit"
},
"dependencies": {
"@lmstudio/sdk": "^1.5.0",
"@mariozechner/pi-ai": "^0.56.2",
"@mariozechner/pi-tui": "^0.56.2",
"docx-preview": "^0.3.7",
"jszip": "^3.10.1",
"lucide": "^0.544.0",
"ollama": "^0.6.0",
"pdfjs-dist": "5.4.394",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
},
"peerDependencies": {
"@mariozechner/mini-lit": "^0.2.0",
"lit": "^3.3.1"
},
"devDependencies": {
"@mariozechner/mini-lit": "^0.2.0",
"@tailwindcss/cli": "^4.0.0-beta.14",
"concurrently": "^9.2.1",
"typescript": "^5.7.3"
},
"keywords": [
"ai",
"chat",
"ui",
"components",
"llm",
"web-components",
"mini-lit"
],
"author": "Mario Zechner",
"license": "MIT"
}

View file

@ -1,91 +0,0 @@
#!/usr/bin/env tsx
/**
* Count tokens in system prompts using Anthropic's token counter API
*/
import * as prompts from "../src/prompts/prompts.js";
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
if (!ANTHROPIC_API_KEY) {
console.error("Error: ANTHROPIC_API_KEY environment variable not set");
process.exit(1);
}
interface TokenCountResponse {
input_tokens: number;
}
async function countTokens(text: string): Promise<number> {
const response = await fetch(
"https://api.anthropic.com/v1/messages/count_tokens",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-3-5-sonnet-20241022",
messages: [
{
role: "user",
content: text,
},
],
}),
},
);
if (!response.ok) {
const error = await response.text();
throw new Error(`API error: ${response.status} ${error}`);
}
const data = (await response.json()) as TokenCountResponse;
return data.input_tokens;
}
async function main() {
console.log("Counting tokens in prompts...\n");
const promptsToCount: Array<{ name: string; content: string }> = [
{
name: "ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW",
content: prompts.ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW,
},
{
name: "ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO",
content: prompts.ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO,
},
{
name: "ATTACHMENTS_RUNTIME_DESCRIPTION",
content: prompts.ATTACHMENTS_RUNTIME_DESCRIPTION,
},
{
name: "JAVASCRIPT_REPL_TOOL_DESCRIPTION (without runtime providers)",
content: prompts.JAVASCRIPT_REPL_TOOL_DESCRIPTION([]),
},
{
name: "ARTIFACTS_TOOL_DESCRIPTION (without runtime providers)",
content: prompts.ARTIFACTS_TOOL_DESCRIPTION([]),
},
];
let total = 0;
for (const prompt of promptsToCount) {
try {
const tokens = await countTokens(prompt.content);
total += tokens;
console.log(`${prompt.name}: ${tokens.toLocaleString()} tokens`);
} catch (error) {
console.error(`Error counting tokens for ${prompt.name}:`, error);
}
}
console.log(`\nTotal: ${total.toLocaleString()} tokens`);
}
main();

View file

@ -1,239 +0,0 @@
import { Badge } from "@mariozechner/mini-lit/dist/Badge.js";
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import "./components/AgentInterface.js";
import type { Agent, AgentTool } from "@mariozechner/pi-agent-core";
import type { AgentInterface } from "./components/AgentInterface.js";
import { ArtifactsRuntimeProvider } from "./components/sandbox/ArtifactsRuntimeProvider.js";
import { AttachmentsRuntimeProvider } from "./components/sandbox/AttachmentsRuntimeProvider.js";
import type { SandboxRuntimeProvider } from "./components/sandbox/SandboxRuntimeProvider.js";
import {
ArtifactsPanel,
ArtifactsToolRenderer,
} from "./tools/artifacts/index.js";
import { registerToolRenderer } from "./tools/renderer-registry.js";
import type { Attachment } from "./utils/attachment-utils.js";
import { i18n } from "./utils/i18n.js";
const BREAKPOINT = 800; // px - switch between overlay and side-by-side
@customElement("pi-chat-panel")
export class ChatPanel extends LitElement {
@state() public agent?: Agent;
@state() public agentInterface?: AgentInterface;
@state() public artifactsPanel?: ArtifactsPanel;
@state() private hasArtifacts = false;
@state() private artifactCount = 0;
@state() private showArtifactsPanel = false;
@state() private windowWidth = 0;
private resizeHandler = () => {
this.windowWidth = window.innerWidth;
this.requestUpdate();
};
createRenderRoot() {
return this;
}
override connectedCallback() {
super.connectedCallback();
this.windowWidth = window.innerWidth; // Set initial width after connection
window.addEventListener("resize", this.resizeHandler);
this.style.display = "flex";
this.style.flexDirection = "column";
this.style.height = "100%";
this.style.minHeight = "0";
// Update width after initial render
requestAnimationFrame(() => {
this.windowWidth = window.innerWidth;
this.requestUpdate();
});
}
override disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("resize", this.resizeHandler);
}
async setAgent(
agent: Agent,
config?: {
onApiKeyRequired?: (provider: string) => Promise<boolean>;
onBeforeSend?: () => void | Promise<void>;
onCostClick?: () => void;
sandboxUrlProvider?: () => string;
toolsFactory?: (
agent: Agent,
agentInterface: AgentInterface,
artifactsPanel: ArtifactsPanel,
runtimeProvidersFactory: () => SandboxRuntimeProvider[],
) => AgentTool<any>[];
},
) {
this.agent = agent;
// Create AgentInterface
this.agentInterface = document.createElement(
"agent-interface",
) as AgentInterface;
this.agentInterface.session = agent;
this.agentInterface.enableAttachments = true;
this.agentInterface.enableModelSelector = true;
this.agentInterface.enableThinkingSelector = true;
this.agentInterface.showThemeToggle = false;
this.agentInterface.onApiKeyRequired = config?.onApiKeyRequired;
this.agentInterface.onBeforeSend = config?.onBeforeSend;
this.agentInterface.onCostClick = config?.onCostClick;
// Set up artifacts panel
this.artifactsPanel = new ArtifactsPanel();
this.artifactsPanel.agent = agent; // Pass agent for HTML artifact runtime providers
if (config?.sandboxUrlProvider) {
this.artifactsPanel.sandboxUrlProvider = config.sandboxUrlProvider;
}
// Register the standalone tool renderer (not the panel itself)
registerToolRenderer(
"artifacts",
new ArtifactsToolRenderer(this.artifactsPanel),
);
// Runtime providers factory for REPL tools (read-write access)
const runtimeProvidersFactory = () => {
const attachments: Attachment[] = [];
for (const message of this.agent!.state.messages) {
if (message.role === "user-with-attachments") {
message.attachments?.forEach((a) => {
attachments.push(a);
});
}
}
const providers: SandboxRuntimeProvider[] = [];
// Add attachments provider if there are attachments
if (attachments.length > 0) {
providers.push(new AttachmentsRuntimeProvider(attachments));
}
// Add artifacts provider with read-write access (for REPL)
providers.push(
new ArtifactsRuntimeProvider(this.artifactsPanel!, this.agent!, true),
);
return providers;
};
this.artifactsPanel.onArtifactsChange = () => {
const count = this.artifactsPanel?.artifacts?.size ?? 0;
const created = count > this.artifactCount;
this.hasArtifacts = count > 0;
this.artifactCount = count;
if (this.hasArtifacts && created) {
this.showArtifactsPanel = true;
}
this.requestUpdate();
};
this.artifactsPanel.onClose = () => {
this.showArtifactsPanel = false;
this.requestUpdate();
};
this.artifactsPanel.onOpen = () => {
this.showArtifactsPanel = true;
this.requestUpdate();
};
// Set tools on the agent
// Pass runtimeProvidersFactory so consumers can configure their own REPL tools
const additionalTools =
config?.toolsFactory?.(
agent,
this.agentInterface,
this.artifactsPanel,
runtimeProvidersFactory,
) || [];
const tools = [this.artifactsPanel.tool, ...additionalTools];
this.agent.setTools(tools);
// Reconstruct artifacts from existing messages
// Temporarily disable the onArtifactsChange callback to prevent auto-opening on load
const originalCallback = this.artifactsPanel.onArtifactsChange;
this.artifactsPanel.onArtifactsChange = undefined;
await this.artifactsPanel.reconstructFromMessages(
this.agent.state.messages,
);
this.artifactsPanel.onArtifactsChange = originalCallback;
this.hasArtifacts = this.artifactsPanel.artifacts.size > 0;
this.artifactCount = this.artifactsPanel.artifacts.size;
this.requestUpdate();
}
render() {
if (!this.agent || !this.agentInterface) {
return html`<div class="flex items-center justify-center h-full">
<div class="text-muted-foreground">No agent set</div>
</div>`;
}
const isMobile = this.windowWidth < BREAKPOINT;
// Set panel props
if (this.artifactsPanel) {
this.artifactsPanel.collapsed = !this.showArtifactsPanel;
this.artifactsPanel.overlay = isMobile;
}
return html`
<div class="relative w-full h-full overflow-hidden flex">
<div
class="h-full"
style="${!isMobile && this.showArtifactsPanel && this.hasArtifacts
? "width: 50%;"
: "width: 100%;"}"
>
${this.agentInterface}
</div>
<!-- Floating pill when artifacts exist and panel is collapsed -->
${this.hasArtifacts && !this.showArtifactsPanel
? html`
<button
class="absolute z-30 top-4 left-1/2 -translate-x-1/2 pointer-events-auto"
@click=${() => {
this.showArtifactsPanel = true;
this.requestUpdate();
}}
title=${i18n("Show artifacts")}
>
${Badge(html`
<span class="inline-flex items-center gap-1">
<span>${i18n("Artifacts")}</span>
<span
class="text-[10px] leading-none bg-primary-foreground/20 text-primary-foreground rounded px-1 font-mono tabular-nums"
>${this.artifactCount}</span
>
</span>
`)}
</button>
`
: ""}
<div
class="h-full ${isMobile
? "absolute inset-0 pointer-events-none"
: ""}"
style="${!isMobile
? !this.hasArtifacts || !this.showArtifactsPanel
? "display: none;"
: "width: 50%;"
: ""}"
>
${this.artifactsPanel}
</div>
</div>
`;
}
}

View file

@ -1,68 +0,0 @@
/* Import Claude theme from mini-lit */
@import "@mariozechner/mini-lit/styles/themes/default.css";
/* Tell Tailwind to scan mini-lit components */
/* biome-ignore lint/suspicious/noUnknownAtRules: Tailwind 4 source directive */
@source "../../../node_modules/@mariozechner/mini-lit/dist";
/* Import Tailwind */
/* biome-ignore lint/correctness/noInvalidPositionAtImportRule: fuck you */
@import "tailwindcss";
body {
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--color-border) rgba(0, 0, 0, 0);
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: var(--color-border);
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0);
}
/* Fix cursor for dialog close buttons */
.fixed.inset-0 button[aria-label*="Close"],
.fixed.inset-0 button[type="button"] {
cursor: pointer;
}
/* Shimmer animation for thinking text */
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.animate-shimmer {
animation: shimmer 2s ease-in-out infinite;
}
/* User message with fancy pill styling */
.user-message-container {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
background: linear-gradient(135deg, rgba(217, 79, 0, 0.12), rgba(255, 107, 0, 0.12), rgba(212, 165, 0, 0.12));
border: 1px solid rgba(255, 107, 0, 0.25);
backdrop-filter: blur(10px);
max-width: 100%;
}

View file

@ -1,428 +0,0 @@
import {
streamSimple,
type ToolResultMessage,
type Usage,
} from "@mariozechner/pi-ai";
import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import { ModelSelector } from "../dialogs/ModelSelector.js";
import type { MessageEditor } from "./MessageEditor.js";
import "./MessageEditor.js";
import "./MessageList.js";
import "./Messages.js"; // Import for side effects to register the custom elements
import { getAppStorage } from "../storage/app-storage.js";
import "./StreamingMessageContainer.js";
import type { Agent, AgentEvent } from "@mariozechner/pi-agent-core";
import type { Attachment } from "../utils/attachment-utils.js";
import { formatUsage } from "../utils/format.js";
import { i18n } from "../utils/i18n.js";
import { createStreamFn } from "../utils/proxy-utils.js";
import type { UserMessageWithAttachments } from "./Messages.js";
import type { StreamingMessageContainer } from "./StreamingMessageContainer.js";
@customElement("agent-interface")
export class AgentInterface extends LitElement {
// Optional external session: when provided, this component becomes a view over the session
@property({ attribute: false }) session?: Agent;
@property({ type: Boolean }) enableAttachments = true;
@property({ type: Boolean }) enableModelSelector = true;
@property({ type: Boolean }) enableThinkingSelector = true;
@property({ type: Boolean }) showThemeToggle = false;
// Optional custom API key prompt handler - if not provided, uses default dialog
@property({ attribute: false }) onApiKeyRequired?: (
provider: string,
) => Promise<boolean>;
// Optional callback called before sending a message
@property({ attribute: false }) onBeforeSend?: () => void | Promise<void>;
// Optional callback called before executing a tool call - return false to prevent execution
@property({ attribute: false }) onBeforeToolCall?: (
toolName: string,
args: any,
) => boolean | Promise<boolean>;
// Optional callback called when cost display is clicked
@property({ attribute: false }) onCostClick?: () => void;
// References
@query("message-editor") private _messageEditor!: MessageEditor;
@query("streaming-message-container")
private _streamingContainer!: StreamingMessageContainer;
private _autoScroll = true;
private _lastScrollTop = 0;
private _lastClientHeight = 0;
private _scrollContainer?: HTMLElement;
private _resizeObserver?: ResizeObserver;
private _unsubscribeSession?: () => void;
public setInput(text: string, attachments?: Attachment[]) {
const update = () => {
if (!this._messageEditor) requestAnimationFrame(update);
else {
this._messageEditor.value = text;
this._messageEditor.attachments = attachments || [];
}
};
update();
}
public setAutoScroll(enabled: boolean) {
this._autoScroll = enabled;
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override willUpdate(changedProperties: Map<string, any>) {
super.willUpdate(changedProperties);
// Re-subscribe when session property changes
if (changedProperties.has("session")) {
this.setupSessionSubscription();
}
}
override async connectedCallback() {
super.connectedCallback();
this.style.display = "flex";
this.style.flexDirection = "column";
this.style.height = "100%";
this.style.minHeight = "0";
// Wait for first render to get scroll container
await this.updateComplete;
this._scrollContainer = this.querySelector(
".overflow-y-auto",
) as HTMLElement;
if (this._scrollContainer) {
// Set up ResizeObserver to detect content changes
this._resizeObserver = new ResizeObserver(() => {
if (this._autoScroll && this._scrollContainer) {
this._scrollContainer.scrollTop = this._scrollContainer.scrollHeight;
}
});
// Observe the content container inside the scroll container
const contentContainer =
this._scrollContainer.querySelector(".max-w-3xl");
if (contentContainer) {
this._resizeObserver.observe(contentContainer);
}
// Set up scroll listener with better detection
this._scrollContainer.addEventListener("scroll", this._handleScroll);
}
// Subscribe to external session if provided
this.setupSessionSubscription();
}
override disconnectedCallback() {
super.disconnectedCallback();
// Clean up observers and listeners
if (this._resizeObserver) {
this._resizeObserver.disconnect();
this._resizeObserver = undefined;
}
if (this._scrollContainer) {
this._scrollContainer.removeEventListener("scroll", this._handleScroll);
}
if (this._unsubscribeSession) {
this._unsubscribeSession();
this._unsubscribeSession = undefined;
}
}
private setupSessionSubscription() {
if (this._unsubscribeSession) {
this._unsubscribeSession();
this._unsubscribeSession = undefined;
}
if (!this.session) return;
// Set default streamFn with proxy support if not already set
if (this.session.streamFn === streamSimple) {
this.session.streamFn = createStreamFn(async () => {
const enabled =
await getAppStorage().settings.get<boolean>("proxy.enabled");
return enabled
? (await getAppStorage().settings.get<string>("proxy.url")) ||
undefined
: undefined;
});
}
// Set default getApiKey if not already set
if (!this.session.getApiKey) {
this.session.getApiKey = async (provider: string) => {
const key = await getAppStorage().providerKeys.get(provider);
return key ?? undefined;
};
}
this._unsubscribeSession = this.session.subscribe(
async (ev: AgentEvent) => {
switch (ev.type) {
case "message_start":
case "message_end":
case "turn_start":
case "turn_end":
case "agent_start":
this.requestUpdate();
break;
case "agent_end":
// Clear streaming container when agent finishes
if (this._streamingContainer) {
this._streamingContainer.isStreaming = false;
this._streamingContainer.setMessage(null, true);
}
this.requestUpdate();
break;
case "message_update":
if (this._streamingContainer) {
const isStreaming = this.session?.state.isStreaming || false;
this._streamingContainer.isStreaming = isStreaming;
this._streamingContainer.setMessage(ev.message, !isStreaming);
}
this.requestUpdate();
break;
}
},
);
}
private _handleScroll = (_ev: any) => {
if (!this._scrollContainer) return;
const currentScrollTop = this._scrollContainer.scrollTop;
const scrollHeight = this._scrollContainer.scrollHeight;
const clientHeight = this._scrollContainer.clientHeight;
const distanceFromBottom = scrollHeight - currentScrollTop - clientHeight;
// Ignore relayout due to message editor getting pushed up by stats
if (clientHeight < this._lastClientHeight) {
this._lastClientHeight = clientHeight;
return;
}
// Only disable auto-scroll if user scrolled UP or is far from bottom
if (
currentScrollTop !== 0 &&
currentScrollTop < this._lastScrollTop &&
distanceFromBottom > 50
) {
this._autoScroll = false;
} else if (distanceFromBottom < 10) {
// Re-enable if very close to bottom
this._autoScroll = true;
}
this._lastScrollTop = currentScrollTop;
this._lastClientHeight = clientHeight;
};
public async sendMessage(input: string, attachments?: Attachment[]) {
if (
(!input.trim() && attachments?.length === 0) ||
this.session?.state.isStreaming
)
return;
const session = this.session;
if (!session) throw new Error("No session set on AgentInterface");
if (!session.state.model) throw new Error("No model set on AgentInterface");
// Check if API key exists for the provider (only needed in direct mode)
const provider = session.state.model.provider;
const apiKey = await getAppStorage().providerKeys.get(provider);
// If no API key, prompt for it
if (!apiKey) {
if (!this.onApiKeyRequired) {
console.error(
"No API key configured and no onApiKeyRequired handler set",
);
return;
}
const success = await this.onApiKeyRequired(provider);
// If still no API key, abort the send
if (!success) {
return;
}
}
// Call onBeforeSend hook before sending
if (this.onBeforeSend) {
await this.onBeforeSend();
}
// Only clear editor after we know we can send
this._messageEditor.value = "";
this._messageEditor.attachments = [];
this._autoScroll = true; // Enable auto-scroll when sending a message
// Compose message with attachments if any
if (attachments && attachments.length > 0) {
const message: UserMessageWithAttachments = {
role: "user-with-attachments",
content: input,
attachments,
timestamp: Date.now(),
};
await this.session?.prompt(message);
} else {
await this.session?.prompt(input);
}
}
private renderMessages() {
if (!this.session)
return html`<div class="p-4 text-center text-muted-foreground">
${i18n("No session available")}
</div>`;
const state = this.session.state;
// Build a map of tool results to allow inline rendering in assistant messages
const toolResultsById = new Map<string, ToolResultMessage<any>>();
for (const message of state.messages) {
if (message.role === "toolResult") {
toolResultsById.set(message.toolCallId, message);
}
}
return html`
<div class="flex flex-col gap-3">
<!-- Stable messages list - won't re-render during streaming -->
<message-list
.messages=${this.session.state.messages}
.tools=${state.tools}
.pendingToolCalls=${this.session
? this.session.state.pendingToolCalls
: new Set<string>()}
.isStreaming=${state.isStreaming}
.onCostClick=${this.onCostClick}
></message-list>
<!-- Streaming message container - manages its own updates -->
<streaming-message-container
class="${state.isStreaming ? "" : "hidden"}"
.tools=${state.tools}
.isStreaming=${state.isStreaming}
.pendingToolCalls=${state.pendingToolCalls}
.toolResultsById=${toolResultsById}
.onCostClick=${this.onCostClick}
></streaming-message-container>
</div>
`;
}
private renderStats() {
if (!this.session) return html`<div class="text-xs h-5"></div>`;
const state = this.session.state;
const totals = state.messages
.filter((m) => m.role === "assistant")
.reduce(
(acc, msg: any) => {
const usage = msg.usage;
if (usage) {
acc.input += usage.input;
acc.output += usage.output;
acc.cacheRead += usage.cacheRead;
acc.cacheWrite += usage.cacheWrite;
acc.cost.total += usage.cost.total;
}
return acc;
},
{
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
} satisfies Usage,
);
const hasTotals =
totals.input || totals.output || totals.cacheRead || totals.cacheWrite;
const totalsText = hasTotals ? formatUsage(totals) : "";
return html`
<div
class="text-xs text-muted-foreground flex justify-between items-center h-5"
>
<div class="flex items-center gap-1">
${this.showThemeToggle ? html`<theme-toggle></theme-toggle>` : html``}
</div>
<div class="flex ml-auto items-center gap-3">
${totalsText
? this.onCostClick
? html`<span
class="cursor-pointer hover:text-foreground transition-colors"
@click=${this.onCostClick}
>${totalsText}</span
>`
: html`<span>${totalsText}</span>`
: ""}
</div>
</div>
`;
}
override render() {
if (!this.session)
return html`<div class="p-4 text-center text-muted-foreground">
${i18n("No session set")}
</div>`;
const session = this.session;
const state = this.session.state;
return html`
<div class="flex flex-col h-full bg-background text-foreground">
<!-- Messages Area -->
<div class="flex-1 overflow-y-auto">
<div class="max-w-3xl mx-auto p-4 pb-0">${this.renderMessages()}</div>
</div>
<!-- Input Area -->
<div class="shrink-0">
<div class="max-w-3xl mx-auto px-2">
<message-editor
.isStreaming=${state.isStreaming}
.currentModel=${state.model}
.thinkingLevel=${state.thinkingLevel}
.showAttachmentButton=${this.enableAttachments}
.showModelSelector=${this.enableModelSelector}
.showThinkingSelector=${this.enableThinkingSelector}
.onSend=${(input: string, attachments: Attachment[]) => {
this.sendMessage(input, attachments);
}}
.onAbort=${() => session.abort()}
.onModelSelect=${() => {
ModelSelector.open(state.model, (model) =>
session.setModel(model),
);
}}
.onThinkingChange=${this.enableThinkingSelector
? (level: "off" | "minimal" | "low" | "medium" | "high") => {
session.setThinkingLevel(level);
}
: undefined}
></message-editor>
${this.renderStats()}
</div>
</div>
</div>
`;
}
}
// Register custom element with guard
if (!customElements.get("agent-interface")) {
customElements.define("agent-interface", AgentInterface);
}

View file

@ -1,107 +0,0 @@
import { icon } from "@mariozechner/mini-lit/dist/icons.js";
import { LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { html } from "lit/html.js";
import { FileSpreadsheet, FileText, X } from "lucide";
import { AttachmentOverlay } from "../dialogs/AttachmentOverlay.js";
import type { Attachment } from "../utils/attachment-utils.js";
import { i18n } from "../utils/i18n.js";
@customElement("attachment-tile")
export class AttachmentTile extends LitElement {
@property({ type: Object }) attachment!: Attachment;
@property({ type: Boolean }) showDelete = false;
@property() onDelete?: () => void;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
this.classList.add("max-h-16");
}
private handleClick = () => {
AttachmentOverlay.open(this.attachment);
};
override render() {
const hasPreview = !!this.attachment.preview;
const isImage = this.attachment.type === "image";
const isPdf = this.attachment.mimeType === "application/pdf";
const isExcel =
this.attachment.mimeType?.includes("spreadsheetml") ||
this.attachment.fileName.toLowerCase().endsWith(".xlsx") ||
this.attachment.fileName.toLowerCase().endsWith(".xls");
// Choose the appropriate icon
const getDocumentIcon = () => {
if (isExcel) return icon(FileSpreadsheet, "md");
return icon(FileText, "md");
};
return html`
<div class="relative group inline-block">
${hasPreview
? html`
<div class="relative">
<img
src="data:${isImage
? this.attachment.mimeType
: "image/png"};base64,${this.attachment.preview}"
class="w-16 h-16 object-cover rounded-lg border border-input cursor-pointer hover:opacity-80 transition-opacity"
alt="${this.attachment.fileName}"
title="${this.attachment.fileName}"
@click=${this.handleClick}
/>
${isPdf
? html`
<!-- PDF badge overlay -->
<div
class="absolute bottom-0 left-0 right-0 bg-background/90 px-1 py-0.5 rounded-b-lg"
>
<div
class="text-[10px] text-muted-foreground text-center font-medium"
>
${i18n("PDF")}
</div>
</div>
`
: ""}
</div>
`
: html`
<!-- Fallback: document icon + filename -->
<div
class="w-16 h-16 rounded-lg border border-input cursor-pointer hover:opacity-80 transition-opacity bg-muted text-muted-foreground flex flex-col items-center justify-center p-2"
@click=${this.handleClick}
title="${this.attachment.fileName}"
>
${getDocumentIcon()}
<div class="text-[10px] text-center truncate w-full">
${this.attachment.fileName.length > 10
? `${this.attachment.fileName.substring(0, 8)}...`
: this.attachment.fileName}
</div>
</div>
`}
${this.showDelete
? html`
<button
@click=${(e: Event) => {
e.stopPropagation();
this.onDelete?.();
}}
class="absolute -top-1 -right-1 w-5 h-5 bg-background hover:bg-muted text-muted-foreground hover:text-foreground rounded-full flex items-center justify-center opacity-100 hover:opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 transition-opacity border border-input shadow-sm"
title="${i18n("Remove")}"
>
${icon(X, "xs")}
</button>
`
: ""}
</div>
`;
}
}

View file

@ -1,80 +0,0 @@
import { icon } from "@mariozechner/mini-lit";
import { LitElement } from "lit";
import { property, state } from "lit/decorators.js";
import { html } from "lit/html.js";
import { Check, Copy } from "lucide";
import { i18n } from "../utils/i18n.js";
export class ConsoleBlock extends LitElement {
@property() content: string = "";
@property() variant: "default" | "error" = "default";
@state() private copied = false;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
private async copy() {
try {
await navigator.clipboard.writeText(this.content || "");
this.copied = true;
setTimeout(() => {
this.copied = false;
}, 1500);
} catch (e) {
console.error("Copy failed", e);
}
}
override updated() {
// Auto-scroll to bottom on content changes
const container = this.querySelector(
".console-scroll",
) as HTMLElement | null;
if (container) {
container.scrollTop = container.scrollHeight;
}
}
override render() {
const isError = this.variant === "error";
const textClass = isError ? "text-destructive" : "text-foreground";
return html`
<div class="border border-border rounded-lg overflow-hidden">
<div
class="flex items-center justify-between px-3 py-1.5 bg-muted border-b border-border"
>
<span class="text-xs text-muted-foreground font-mono"
>${i18n("console")}</span
>
<button
@click=${() => this.copy()}
class="flex items-center gap-1 px-2 py-0.5 text-xs rounded hover:bg-accent text-muted-foreground hover:text-accent-foreground transition-colors"
title="${i18n("Copy output")}"
>
${this.copied ? icon(Check, "sm") : icon(Copy, "sm")}
${this.copied ? html`<span>${i18n("Copied!")}</span>` : ""}
</button>
</div>
<div class="console-scroll overflow-auto max-h-64">
<pre
class="!bg-background !border-0 !rounded-none m-0 p-3 text-xs ${textClass} font-mono whitespace-pre-wrap"
>
${this.content || ""}</pre
>
</div>
</div>
`;
}
}
// Register custom element
if (!customElements.get("console-block")) {
customElements.define("console-block", ConsoleBlock);
}

View file

@ -1,99 +0,0 @@
import { i18n } from "@mariozechner/mini-lit";
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import { html, LitElement, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { CustomProvider } from "../storage/stores/custom-providers-store.js";
@customElement("custom-provider-card")
export class CustomProviderCard extends LitElement {
@property({ type: Object }) provider!: CustomProvider;
@property({ type: Boolean }) isAutoDiscovery = false;
@property({ type: Object }) status?: {
modelCount: number;
status: "connected" | "disconnected" | "checking";
};
@property() onRefresh?: (provider: CustomProvider) => void;
@property() onEdit?: (provider: CustomProvider) => void;
@property() onDelete?: (provider: CustomProvider) => void;
protected createRenderRoot() {
return this;
}
private renderStatus(): TemplateResult {
if (!this.isAutoDiscovery) {
return html`
<div class="text-xs text-muted-foreground mt-1">
${i18n("Models")}: ${this.provider.models?.length || 0}
</div>
`;
}
if (!this.status) return html``;
const statusIcon =
this.status.status === "connected"
? html`<span class="text-green-500">●</span>`
: this.status.status === "checking"
? html`<span class="text-yellow-500">●</span>`
: html`<span class="text-red-500">●</span>`;
const statusText =
this.status.status === "connected"
? `${this.status.modelCount} ${i18n("models")}`
: this.status.status === "checking"
? i18n("Checking...")
: i18n("Disconnected");
return html`
<div class="text-xs text-muted-foreground mt-1 flex items-center gap-1">
${statusIcon} ${statusText}
</div>
`;
}
render(): TemplateResult {
return html`
<div class="border border-border rounded-lg p-4 space-y-2">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="font-medium text-sm text-foreground">
${this.provider.name}
</div>
<div class="text-xs text-muted-foreground mt-1">
<span class="capitalize">${this.provider.type}</span>
${this.provider.baseUrl ? html`${this.provider.baseUrl}` : ""}
</div>
${this.renderStatus()}
</div>
<div class="flex gap-2">
${this.isAutoDiscovery && this.onRefresh
? Button({
onClick: () => this.onRefresh?.(this.provider),
variant: "ghost",
size: "sm",
children: i18n("Refresh"),
})
: ""}
${this.onEdit
? Button({
onClick: () => this.onEdit?.(this.provider),
variant: "ghost",
size: "sm",
children: i18n("Edit"),
})
: ""}
${this.onDelete
? Button({
onClick: () => this.onDelete?.(this.provider),
variant: "ghost",
size: "sm",
children: i18n("Delete"),
})
: ""}
</div>
</div>
</div>
`;
}
}

View file

@ -1,48 +0,0 @@
import { icon } from "@mariozechner/mini-lit";
import { html, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ChevronDown, ChevronRight } from "lucide";
/**
* Reusable expandable section component for tool renderers.
* Captures children in connectedCallback and re-renders them in the details area.
*/
@customElement("expandable-section")
export class ExpandableSection extends LitElement {
@property() summary!: string;
@property({ type: Boolean }) defaultExpanded = false;
@state() private expanded = false;
private capturedChildren: Node[] = [];
protected createRenderRoot() {
return this; // light DOM
}
override connectedCallback() {
super.connectedCallback();
// Capture children before first render
this.capturedChildren = Array.from(this.childNodes);
// Clear children (we'll re-insert them in render)
this.innerHTML = "";
this.expanded = this.defaultExpanded;
}
override render(): TemplateResult {
return html`
<div>
<button
@click=${() => {
this.expanded = !this.expanded;
}}
class="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full text-left"
>
${icon(this.expanded ? ChevronDown : ChevronRight, "sm")}
<span>${this.summary}</span>
</button>
${this.expanded
? html`<div class="mt-2">${this.capturedChildren}</div>`
: ""}
</div>
`;
}
}

View file

@ -1,128 +0,0 @@
import {
type BaseComponentProps,
fc,
} from "@mariozechner/mini-lit/dist/mini.js";
import { html } from "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

@ -1,444 +0,0 @@
import { icon } from "@mariozechner/mini-lit";
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import {
Select,
type SelectOption,
} from "@mariozechner/mini-lit/dist/Select.js";
import type { Model } from "@mariozechner/pi-ai";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import { Brain, Loader2, Paperclip, Send, Sparkles, Square } from "lucide";
import { type Attachment, loadAttachment } from "../utils/attachment-utils.js";
import { i18n } from "../utils/i18n.js";
import "./AttachmentTile.js";
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
@customElement("message-editor")
export class MessageEditor extends LitElement {
private _value = "";
private textareaRef = createRef<HTMLTextAreaElement>();
@property()
get value() {
return this._value;
}
set value(val: string) {
const oldValue = this._value;
this._value = val;
this.requestUpdate("value", oldValue);
}
@property() isStreaming = false;
@property() currentModel?: Model<any>;
@property() thinkingLevel: ThinkingLevel = "off";
@property() showAttachmentButton = true;
@property() showModelSelector = true;
@property() showThinkingSelector = true;
@property() onInput?: (value: string) => void;
@property() onSend?: (input: string, attachments: Attachment[]) => void;
@property() onAbort?: () => void;
@property() onModelSelect?: () => void;
@property() onThinkingChange?: (
level: "off" | "minimal" | "low" | "medium" | "high",
) => void;
@property() onFilesChange?: (files: Attachment[]) => void;
@property() attachments: Attachment[] = [];
@property() maxFiles = 10;
@property() maxFileSize = 20 * 1024 * 1024; // 20MB
@property() acceptedTypes =
"image/*,application/pdf,.docx,.pptx,.xlsx,.xls,.txt,.md,.json,.xml,.html,.css,.js,.ts,.jsx,.tsx,.yml,.yaml";
@state() processingFiles = false;
@state() isDragging = false;
private fileInputRef = createRef<HTMLInputElement>();
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
private handleTextareaInput = (e: Event) => {
const textarea = e.target as HTMLTextAreaElement;
this.value = textarea.value;
this.onInput?.(this.value);
};
private handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (
!this.isStreaming &&
!this.processingFiles &&
(this.value.trim() || this.attachments.length > 0)
) {
this.handleSend();
}
} else if (e.key === "Escape" && this.isStreaming) {
e.preventDefault();
this.onAbort?.();
}
};
private handlePaste = async (e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
const imageFiles: File[] = [];
// Check for image items in clipboard
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (file) {
imageFiles.push(file);
}
}
}
// If we found images, process them
if (imageFiles.length > 0) {
e.preventDefault(); // Prevent default paste behavior
if (imageFiles.length + this.attachments.length > this.maxFiles) {
alert(`Maximum ${this.maxFiles} files allowed`);
return;
}
this.processingFiles = true;
const newAttachments: Attachment[] = [];
for (const file of imageFiles) {
try {
if (file.size > this.maxFileSize) {
alert(
`Image exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`,
);
continue;
}
const attachment = await loadAttachment(file);
newAttachments.push(attachment);
} catch (error) {
console.error("Error processing pasted image:", error);
alert(`Failed to process pasted image: ${String(error)}`);
}
}
this.attachments = [...this.attachments, ...newAttachments];
this.onFilesChange?.(this.attachments);
this.processingFiles = false;
}
};
private handleSend = () => {
this.onSend?.(this.value, this.attachments);
};
private handleAttachmentClick = () => {
this.fileInputRef.value?.click();
};
private async handleFilesSelected(e: Event) {
const input = e.target as HTMLInputElement;
const files = Array.from(input.files || []);
if (files.length === 0) return;
if (files.length + this.attachments.length > this.maxFiles) {
alert(`Maximum ${this.maxFiles} files allowed`);
input.value = "";
return;
}
this.processingFiles = true;
const newAttachments: Attachment[] = [];
for (const file of files) {
try {
if (file.size > this.maxFileSize) {
alert(
`${file.name} exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`,
);
continue;
}
const attachment = await loadAttachment(file);
newAttachments.push(attachment);
} catch (error) {
console.error(`Error processing ${file.name}:`, error);
alert(`Failed to process ${file.name}: ${String(error)}`);
}
}
this.attachments = [...this.attachments, ...newAttachments];
this.onFilesChange?.(this.attachments);
this.processingFiles = false;
input.value = ""; // Reset input
}
private removeFile(fileId: string) {
this.attachments = this.attachments.filter((f) => f.id !== fileId);
this.onFilesChange?.(this.attachments);
}
private handleDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!this.isDragging) {
this.isDragging = true;
}
};
private handleDragLeave = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only set isDragging to false if we're leaving the entire component
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (
x <= rect.left ||
x >= rect.right ||
y <= rect.top ||
y >= rect.bottom
) {
this.isDragging = false;
}
};
private handleDrop = async (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
this.isDragging = false;
const files = Array.from(e.dataTransfer?.files || []);
if (files.length === 0) return;
if (files.length + this.attachments.length > this.maxFiles) {
alert(`Maximum ${this.maxFiles} files allowed`);
return;
}
this.processingFiles = true;
const newAttachments: Attachment[] = [];
for (const file of files) {
try {
if (file.size > this.maxFileSize) {
alert(
`${file.name} exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`,
);
continue;
}
const attachment = await loadAttachment(file);
newAttachments.push(attachment);
} catch (error) {
console.error(`Error processing ${file.name}:`, error);
alert(`Failed to process ${file.name}: ${String(error)}`);
}
}
this.attachments = [...this.attachments, ...newAttachments];
this.onFilesChange?.(this.attachments);
this.processingFiles = false;
};
override firstUpdated() {
const textarea = this.textareaRef.value;
if (textarea) {
textarea.focus();
}
}
override render() {
// Check if current model supports thinking/reasoning
const model = this.currentModel;
const supportsThinking = model?.reasoning === true; // Models with reasoning:true support thinking
return html`
<div
class="bg-card rounded-xl border shadow-sm relative ${this.isDragging
? "border-primary border-2 bg-primary/5"
: "border-border"}"
@dragover=${this.handleDragOver}
@dragleave=${this.handleDragLeave}
@drop=${this.handleDrop}
>
<!-- Drag overlay -->
${this.isDragging
? html`
<div
class="absolute inset-0 bg-primary/10 rounded-xl pointer-events-none z-10 flex items-center justify-center"
>
<div class="text-primary font-medium">
${i18n("Drop files here")}
</div>
</div>
`
: ""}
<!-- Attachments -->
${this.attachments.length > 0
? html`
<div class="px-4 pt-3 pb-2 flex flex-wrap gap-2">
${this.attachments.map(
(attachment) => html`
<attachment-tile
.attachment=${attachment}
.showDelete=${true}
.onDelete=${() => this.removeFile(attachment.id)}
></attachment-tile>
`,
)}
</div>
`
: ""}
<textarea
class="w-full bg-transparent p-4 text-foreground placeholder-muted-foreground outline-none resize-none overflow-y-auto"
placeholder=${i18n("Type a message...")}
rows="1"
style="max-height: 200px; field-sizing: content; min-height: 1lh; height: auto;"
.value=${this.value}
@input=${this.handleTextareaInput}
@keydown=${this.handleKeyDown}
@paste=${this.handlePaste}
${ref(this.textareaRef)}
></textarea>
<!-- Hidden file input -->
<input
type="file"
${ref(this.fileInputRef)}
@change=${this.handleFilesSelected}
accept=${this.acceptedTypes}
multiple
style="display: none;"
/>
<!-- Button Row -->
<div class="px-2 pb-2 flex items-center justify-between">
<!-- Left side - attachment and thinking selector -->
<div class="flex gap-2 items-center">
${this.showAttachmentButton
? this.processingFiles
? html`
<div class="h-8 w-8 flex items-center justify-center">
${icon(
Loader2,
"sm",
"animate-spin text-muted-foreground",
)}
</div>
`
: html`
${Button({
variant: "ghost",
size: "icon",
className: "h-8 w-8",
onClick: this.handleAttachmentClick,
children: icon(Paperclip, "sm"),
})}
`
: ""}
${supportsThinking && this.showThinkingSelector
? html`
${Select({
value: this.thinkingLevel,
placeholder: i18n("Off"),
options: [
{
value: "off",
label: i18n("Off"),
icon: icon(Brain, "sm"),
},
{
value: "minimal",
label: i18n("Minimal"),
icon: icon(Brain, "sm"),
},
{
value: "low",
label: i18n("Low"),
icon: icon(Brain, "sm"),
},
{
value: "medium",
label: i18n("Medium"),
icon: icon(Brain, "sm"),
},
{
value: "high",
label: i18n("High"),
icon: icon(Brain, "sm"),
},
] as SelectOption[],
onChange: (value: string) => {
this.onThinkingChange?.(
value as "off" | "minimal" | "low" | "medium" | "high",
);
},
width: "80px",
size: "sm",
variant: "ghost",
fitContent: true,
})}
`
: ""}
</div>
<!-- Model selector and send on the right -->
<div class="flex gap-2 items-center">
${this.showModelSelector && this.currentModel
? html`
${Button({
variant: "ghost",
size: "sm",
onClick: () => {
// Focus textarea before opening model selector so focus returns there
this.textareaRef.value?.focus();
// Wait for next frame to ensure focus takes effect before dialog captures it
requestAnimationFrame(() => {
this.onModelSelect?.();
});
},
children: html`
${icon(Sparkles, "sm")}
<span class="ml-1">${this.currentModel.id}</span>
`,
className: "h-8 text-xs truncate",
})}
`
: ""}
${this.isStreaming
? html`
${Button({
variant: "ghost",
size: "icon",
onClick: this.onAbort,
children: icon(Square, "sm"),
className: "h-8 w-8",
})}
`
: html`
${Button({
variant: "ghost",
size: "icon",
onClick: this.handleSend,
disabled:
(!this.value.trim() && this.attachments.length === 0) ||
this.processingFiles,
children: html`<div style="transform: rotate(-45deg)">
${icon(Send, "sm")}
</div>`,
className: "h-8 w-8",
})}
`}
</div>
</div>
</div>
`;
}
}

View file

@ -1,98 +0,0 @@
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
import type {
AssistantMessage as AssistantMessageType,
ToolResultMessage as ToolResultMessageType,
} from "@mariozechner/pi-ai";
import { html, LitElement, type TemplateResult } from "lit";
import { property } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
import { renderMessage } from "./message-renderer-registry.js";
export class MessageList extends LitElement {
@property({ type: Array }) messages: AgentMessage[] = [];
@property({ type: Array }) tools: AgentTool[] = [];
@property({ type: Object }) pendingToolCalls?: Set<string>;
@property({ type: Boolean }) isStreaming: boolean = false;
@property({ attribute: false }) onCostClick?: () => void;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
private buildRenderItems() {
// Map tool results by call id for quick lookup
const resultByCallId = new Map<string, ToolResultMessageType>();
for (const message of this.messages) {
if (message.role === "toolResult") {
resultByCallId.set(message.toolCallId, message);
}
}
const items: Array<{ key: string; template: TemplateResult }> = [];
let index = 0;
for (const msg of this.messages) {
// Skip artifact messages - they're for session persistence only, not UI display
if (msg.role === "artifact") {
continue;
}
// Try custom renderer first
const customTemplate = renderMessage(msg);
if (customTemplate) {
items.push({ key: `msg:${index}`, template: customTemplate });
index++;
continue;
}
// Fall back to built-in renderers
if (msg.role === "user" || msg.role === "user-with-attachments") {
items.push({
key: `msg:${index}`,
template: html`<user-message .message=${msg}></user-message>`,
});
index++;
} else if (msg.role === "assistant") {
const amsg = msg as AssistantMessageType;
items.push({
key: `msg:${index}`,
template: html`<assistant-message
.message=${amsg}
.tools=${this.tools}
.isStreaming=${false}
.pendingToolCalls=${this.pendingToolCalls}
.toolResultsById=${resultByCallId}
.hideToolCalls=${false}
.hidePendingToolCalls=${this.isStreaming}
.onCostClick=${this.onCostClick}
></assistant-message>`,
});
index++;
} else {
// Skip standalone toolResult messages; they are rendered via paired tool-message above
// Skip unknown roles
}
}
return items;
}
override render() {
const items = this.buildRenderItems();
return html`<div class="flex flex-col gap-3">
${repeat(
items,
(it) => it.key,
(it) => it.template,
)}
</div>`;
}
}
// Register custom element
if (!customElements.get("message-list")) {
customElements.define("message-list", MessageList);
}

View file

@ -1,436 +0,0 @@
import type {
AssistantMessage as AssistantMessageType,
ImageContent,
TextContent,
ToolCall,
ToolResultMessage as ToolResultMessageType,
UserMessage as UserMessageType,
} from "@mariozechner/pi-ai";
import { html, LitElement, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { renderTool } from "../tools/index.js";
import type { Attachment } from "../utils/attachment-utils.js";
import { formatUsage } from "../utils/format.js";
import { i18n } from "../utils/i18n.js";
import "./ThinkingBlock.js";
import type { AgentTool } from "@mariozechner/pi-agent-core";
export type UserMessageWithAttachments = {
role: "user-with-attachments";
content: string | (TextContent | ImageContent)[];
timestamp: number;
attachments?: Attachment[];
};
// Artifact message type for session persistence
export interface ArtifactMessage {
role: "artifact";
action: "create" | "update" | "delete";
filename: string;
content?: string;
title?: string;
timestamp: string;
}
declare module "@mariozechner/pi-agent-core" {
interface CustomAgentMessages {
"user-with-attachments": UserMessageWithAttachments;
artifact: ArtifactMessage;
}
}
@customElement("user-message")
export class UserMessage extends LitElement {
@property({ type: Object }) message!:
| UserMessageWithAttachments
| UserMessageType;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
override render() {
const content =
typeof this.message.content === "string"
? this.message.content
: this.message.content.find((c) => c.type === "text")?.text || "";
return html`
<div class="flex justify-start mx-4">
<div class="user-message-container py-2 px-4 rounded-xl">
<markdown-block .content=${content}></markdown-block>
${this.message.role === "user-with-attachments" &&
this.message.attachments &&
this.message.attachments.length > 0
? html`
<div class="mt-3 flex flex-wrap gap-2">
${this.message.attachments.map(
(attachment) => html`
<attachment-tile
.attachment=${attachment}
></attachment-tile>
`,
)}
</div>
`
: ""}
</div>
</div>
`;
}
}
@customElement("assistant-message")
export class AssistantMessage extends LitElement {
@property({ type: Object }) message!: AssistantMessageType;
@property({ type: Array }) tools?: AgentTool<any>[];
@property({ type: Object }) pendingToolCalls?: Set<string>;
@property({ type: Boolean }) hideToolCalls = false;
@property({ type: Object }) toolResultsById?: Map<
string,
ToolResultMessageType
>;
@property({ type: Boolean }) isStreaming: boolean = false;
@property({ type: Boolean }) hidePendingToolCalls = false;
@property({ attribute: false }) onCostClick?: () => void;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
override render() {
// Render content in the order it appears
const orderedParts: TemplateResult[] = [];
for (const chunk of this.message.content) {
if (chunk.type === "text" && chunk.text.trim() !== "") {
orderedParts.push(
html`<markdown-block .content=${chunk.text}></markdown-block>`,
);
} else if (chunk.type === "thinking" && chunk.thinking.trim() !== "") {
orderedParts.push(
html`<thinking-block
.content=${chunk.thinking}
.isStreaming=${this.isStreaming}
></thinking-block>`,
);
} else if (chunk.type === "toolCall") {
if (!this.hideToolCalls) {
const tool = this.tools?.find((t) => t.name === chunk.name);
const pending = this.pendingToolCalls?.has(chunk.id) ?? false;
const result = this.toolResultsById?.get(chunk.id);
// Skip rendering pending tool calls when hidePendingToolCalls is true
// (used to prevent duplication when StreamingMessageContainer is showing them)
if (this.hidePendingToolCalls && pending && !result) {
continue;
}
// A tool call is aborted if the message was aborted and there's no result for this tool call
const aborted = this.message.stopReason === "aborted" && !result;
orderedParts.push(
html`<tool-message
.tool=${tool}
.toolCall=${chunk}
.result=${result}
.pending=${pending}
.aborted=${aborted}
.isStreaming=${this.isStreaming}
></tool-message>`,
);
}
}
}
return html`
<div>
${orderedParts.length
? html` <div class="px-4 flex flex-col gap-3">${orderedParts}</div> `
: ""}
${this.message.usage && !this.isStreaming
? this.onCostClick
? html`
<div
class="px-4 mt-2 text-xs text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
@click=${this.onCostClick}
>
${formatUsage(this.message.usage)}
</div>
`
: html`
<div class="px-4 mt-2 text-xs text-muted-foreground">
${formatUsage(this.message.usage)}
</div>
`
: ""}
${this.message.stopReason === "error" && this.message.errorMessage
? html`
<div
class="mx-4 mt-3 p-3 bg-destructive/10 text-destructive rounded-lg text-sm overflow-hidden"
>
<strong>${i18n("Error:")}</strong> ${this.message.errorMessage}
</div>
`
: ""}
${this.message.stopReason === "aborted"
? html`<span class="text-sm text-destructive italic"
>${i18n("Request aborted")}</span
>`
: ""}
</div>
`;
}
}
@customElement("tool-message-debug")
export class ToolMessageDebugView extends LitElement {
@property({ type: Object }) callArgs: any;
@property({ type: Object }) result?: ToolResultMessageType;
@property({ type: Boolean }) hasResult: boolean = false;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this; // light DOM for shared styles
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
private pretty(value: unknown): { content: string; isJson: boolean } {
try {
if (typeof value === "string") {
const maybeJson = JSON.parse(value);
return { content: JSON.stringify(maybeJson, null, 2), isJson: true };
}
return { content: JSON.stringify(value, null, 2), isJson: true };
} catch {
return {
content: typeof value === "string" ? value : String(value),
isJson: false,
};
}
}
override render() {
const textOutput =
this.result?.content
?.filter((c) => c.type === "text")
.map((c: any) => c.text)
.join("\n") || "";
const output = this.pretty(textOutput);
const details = this.pretty(this.result?.details);
return html`
<div class="mt-3 flex flex-col gap-2">
<div>
<div class="text-xs font-medium mb-1 text-muted-foreground">
${i18n("Call")}
</div>
<code-block
.code=${this.pretty(this.callArgs).content}
language="json"
></code-block>
</div>
<div>
<div class="text-xs font-medium mb-1 text-muted-foreground">
${i18n("Result")}
</div>
${this.hasResult
? html`<code-block
.code=${output.content}
language="${output.isJson ? "json" : "text"}"
></code-block>
<code-block
.code=${details.content}
language="${details.isJson ? "json" : "text"}"
></code-block>`
: html`<div class="text-xs text-muted-foreground">
${i18n("(no result)")}
</div>`}
</div>
</div>
`;
}
}
@customElement("tool-message")
export class ToolMessage extends LitElement {
@property({ type: Object }) toolCall!: ToolCall;
@property({ type: Object }) tool?: AgentTool<any>;
@property({ type: Object }) result?: ToolResultMessageType;
@property({ type: Boolean }) pending: boolean = false;
@property({ type: Boolean }) aborted: boolean = false;
@property({ type: Boolean }) isStreaming: boolean = false;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
override render() {
const toolName = this.tool?.name || this.toolCall.name;
// Render tool content (renderer handles errors and styling)
const result: ToolResultMessageType<any> | undefined = this.aborted
? {
role: "toolResult",
isError: true,
content: [],
toolCallId: this.toolCall.id,
toolName: this.toolCall.name,
timestamp: Date.now(),
}
: this.result;
const renderResult = renderTool(
toolName,
this.toolCall.arguments,
result,
!this.aborted && (this.isStreaming || this.pending),
);
// Handle custom rendering (no card wrapper)
if (renderResult.isCustom) {
return renderResult.content;
}
// Default: wrap in card
return html`
<div
class="p-2.5 border border-border rounded-md bg-card text-card-foreground shadow-xs"
>
${renderResult.content}
</div>
`;
}
}
@customElement("aborted-message")
export class AbortedMessage extends LitElement {
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
protected override render(): unknown {
return html`<span class="text-sm text-destructive italic"
>${i18n("Request aborted")}</span
>`;
}
}
// ============================================================================
// Default Message Transformer
// ============================================================================
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { Message } from "@mariozechner/pi-ai";
/**
* Convert attachments to content blocks for LLM.
* - Images become ImageContent blocks
* - Documents with extractedText become TextContent blocks with filename header
*/
export function convertAttachments(
attachments: Attachment[],
): (TextContent | ImageContent)[] {
const content: (TextContent | ImageContent)[] = [];
for (const attachment of attachments) {
if (attachment.type === "image") {
content.push({
type: "image",
data: attachment.content,
mimeType: attachment.mimeType,
} as ImageContent);
} else if (attachment.type === "document" && attachment.extractedText) {
content.push({
type: "text",
text: `\n\n[Document: ${attachment.fileName}]\n${attachment.extractedText}`,
} as TextContent);
}
}
return content;
}
/**
* Check if a message is a UserMessageWithAttachments.
*/
export function isUserMessageWithAttachments(
msg: AgentMessage,
): msg is UserMessageWithAttachments {
return (msg as UserMessageWithAttachments).role === "user-with-attachments";
}
/**
* Check if a message is an ArtifactMessage.
*/
export function isArtifactMessage(msg: AgentMessage): msg is ArtifactMessage {
return (msg as ArtifactMessage).role === "artifact";
}
/**
* Default convertToLlm for web-ui apps.
*
* Handles:
* - UserMessageWithAttachments: converts to user message with content blocks
* - ArtifactMessage: filtered out (UI-only, for session reconstruction)
* - Standard LLM messages (user, assistant, toolResult): passed through
*/
export function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
return messages
.filter((m) => {
// Filter out artifact messages - they're for session reconstruction only
if (isArtifactMessage(m)) {
return false;
}
return true;
})
.map((m): Message | null => {
// Convert user-with-attachments to user message with content blocks
if (isUserMessageWithAttachments(m)) {
const textContent: (TextContent | ImageContent)[] =
typeof m.content === "string"
? [{ type: "text", text: m.content }]
: [...m.content];
if (m.attachments) {
textContent.push(...convertAttachments(m.attachments));
}
return {
role: "user",
content: textContent,
timestamp: m.timestamp,
} as Message;
}
// Pass through standard LLM roles
if (
m.role === "user" ||
m.role === "assistant" ||
m.role === "toolResult"
) {
return m as Message;
}
// Filter out unknown message types
return null;
})
.filter((m): m is Message => m !== null);
}

View file

@ -1,165 +0,0 @@
import { i18n } from "@mariozechner/mini-lit";
import { Badge } from "@mariozechner/mini-lit/dist/Badge.js";
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import { type Context, complete, getModel } from "@mariozechner/pi-ai";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { getAppStorage } from "../storage/app-storage.js";
import { applyProxyIfNeeded } from "../utils/proxy-utils.js";
import { Input } from "./Input.js";
// Test models for each provider
const TEST_MODELS: Record<string, string> = {
anthropic: "claude-3-5-haiku-20241022",
openai: "gpt-4o-mini",
google: "gemini-2.5-flash",
groq: "openai/gpt-oss-20b",
openrouter: "z-ai/glm-4.6",
"vercel-ai-gateway": "anthropic/claude-opus-4.5",
cerebras: "gpt-oss-120b",
xai: "grok-4-fast-non-reasoning",
zai: "glm-4.5-air",
};
@customElement("provider-key-input")
export class ProviderKeyInput extends LitElement {
@property() provider = "";
@state() private keyInput = "";
@state() private testing = false;
@state() private failed = false;
@state() private hasKey = false;
@state() private inputChanged = false;
protected createRenderRoot() {
return this;
}
override async connectedCallback() {
super.connectedCallback();
await this.checkKeyStatus();
}
private async checkKeyStatus() {
try {
const key = await getAppStorage().providerKeys.get(this.provider);
this.hasKey = !!key;
} catch (error) {
console.error("Failed to check key status:", error);
}
}
private async testApiKey(provider: string, apiKey: string): Promise<boolean> {
try {
const modelId = TEST_MODELS[provider];
// Returning true here for Ollama and friends. Can' know which model to use for testing
if (!modelId) return true;
let model = getModel(provider as any, modelId);
if (!model) return false;
// Get proxy URL from settings (if available)
const proxyEnabled =
await getAppStorage().settings.get<boolean>("proxy.enabled");
const proxyUrl = await getAppStorage().settings.get<string>("proxy.url");
// Apply proxy only if this provider/key combination requires it
model = applyProxyIfNeeded(
model,
apiKey,
proxyEnabled ? proxyUrl || undefined : undefined,
);
const context: Context = {
messages: [
{ role: "user", content: "Reply with: ok", timestamp: Date.now() },
],
};
const result = await complete(model, context, {
apiKey,
maxTokens: 200,
} as any);
return result.stopReason === "stop";
} catch (error) {
console.error(`API key test failed for ${provider}:`, error);
return false;
}
}
private async saveKey() {
if (!this.keyInput) return;
this.testing = true;
this.failed = false;
const success = await this.testApiKey(this.provider, this.keyInput);
this.testing = false;
if (success) {
try {
await getAppStorage().providerKeys.set(this.provider, this.keyInput);
this.hasKey = true;
this.inputChanged = false;
this.requestUpdate();
} catch (error) {
console.error("Failed to save API key:", error);
this.failed = true;
setTimeout(() => {
this.failed = false;
this.requestUpdate();
}, 5000);
}
} else {
this.failed = true;
setTimeout(() => {
this.failed = false;
this.requestUpdate();
}, 5000);
}
}
render() {
return html`
<div class="space-y-3">
<div class="flex items-center gap-2">
<span class="text-sm font-medium capitalize text-foreground"
>${this.provider}</span
>
${this.testing
? Badge({ children: i18n("Testing..."), variant: "secondary" })
: this.hasKey
? html`<span class="text-green-600 dark:text-green-400">✓</span>`
: ""}
${this.failed
? Badge({ children: i18n("✗ Invalid"), variant: "destructive" })
: ""}
</div>
<div class="flex items-center gap-2">
${Input({
type: "password",
placeholder: this.hasKey ? "••••••••••••" : i18n("Enter API key"),
value: this.keyInput,
onInput: (e: Event) => {
this.keyInput = (e.target as HTMLInputElement).value;
this.inputChanged = true;
this.requestUpdate();
},
className: "flex-1",
})}
${Button({
onClick: () => this.saveKey(),
variant: "default",
size: "sm",
disabled:
!this.keyInput ||
this.testing ||
(this.hasKey && !this.inputChanged),
children: i18n("Save"),
})}
</div>
</div>
`;
}
}

View file

@ -1,672 +0,0 @@
import { LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ConsoleRuntimeProvider } from "./sandbox/ConsoleRuntimeProvider.js";
import { RuntimeMessageBridge } from "./sandbox/RuntimeMessageBridge.js";
import {
type MessageConsumer,
RUNTIME_MESSAGE_ROUTER,
} from "./sandbox/RuntimeMessageRouter.js";
import type { SandboxRuntimeProvider } from "./sandbox/SandboxRuntimeProvider.js";
export interface SandboxFile {
fileName: string;
content: string | Uint8Array;
mimeType: string;
}
export interface SandboxResult {
success: boolean;
console: Array<{ type: string; text: string }>;
files?: SandboxFile[];
error?: { message: string; stack: string };
returnValue?: any;
}
/**
* Function that returns the URL to the sandbox HTML file.
* Used in browser extensions to load sandbox.html via chrome.runtime.getURL().
*/
export type SandboxUrlProvider = () => string;
/**
* Configuration for prepareHtmlDocument
*/
export interface PrepareHtmlOptions {
/** True if this is an HTML artifact (inject into existing HTML), false if REPL (wrap in HTML) */
isHtmlArtifact: boolean;
/** True if this is a standalone download (no runtime bridge, no navigation interceptor) */
isStandalone?: boolean;
}
/**
* Escape HTML special sequences in code to prevent premature tag closure
* @param code Code that will be injected into <script> tags
* @returns Escaped code safe for injection
*/
function escapeScriptContent(code: string): string {
return code.replace(/<\/script/gi, "<\\/script");
}
@customElement("sandbox-iframe")
export class SandboxIframe extends LitElement {
private iframe?: HTMLIFrameElement;
/**
* Optional: Provide a function that returns the sandbox HTML URL.
* If provided, the iframe will use this URL instead of srcdoc.
* This is required for browser extensions with strict CSP.
*/
@property({ attribute: false }) sandboxUrlProvider?: SandboxUrlProvider;
createRenderRoot() {
return this;
}
override connectedCallback() {
super.connectedCallback();
}
override disconnectedCallback() {
super.disconnectedCallback();
// Note: We don't unregister the sandbox here for loadContent() mode
// because the caller (HtmlArtifact) owns the sandbox lifecycle.
// For execute() mode, the sandbox is unregistered in the cleanup function.
this.iframe?.remove();
}
/**
* Load HTML content into sandbox and keep it displayed (for HTML artifacts)
* @param sandboxId Unique ID
* @param htmlContent Full HTML content
* @param providers Runtime providers to inject
* @param consumers Message consumers to register (optional)
*/
public loadContent(
sandboxId: string,
htmlContent: string,
providers: SandboxRuntimeProvider[] = [],
consumers: MessageConsumer[] = [],
): void {
// Unregister previous sandbox if exists
try {
RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
} catch {
// Sandbox might not exist, that's ok
}
providers = [new ConsoleRuntimeProvider(), ...providers];
RUNTIME_MESSAGE_ROUTER.registerSandbox(sandboxId, providers, consumers);
// loadContent is always used for HTML artifacts (not standalone)
const completeHtml = this.prepareHtmlDocument(
sandboxId,
htmlContent,
providers,
{
isHtmlArtifact: true,
isStandalone: false,
},
);
// Validate HTML before loading
const validationError = this.validateHtml(completeHtml);
if (validationError) {
console.error("HTML validation failed:", validationError);
// Show error in iframe instead of crashing
this.iframe?.remove();
this.iframe = document.createElement("iframe");
this.iframe.style.cssText = "width: 100%; height: 100%; border: none;";
this.iframe.srcdoc = `
<html>
<body style="font-family: monospace; padding: 20px; background: #fff; color: #000;">
<h3 style="color: #c00;">HTML Validation Error</h3>
<pre style="background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; white-space: pre-wrap;">${validationError}</pre>
</body>
</html>
`;
this.appendChild(this.iframe);
return;
}
// Remove previous iframe if exists
this.iframe?.remove();
if (this.sandboxUrlProvider) {
// Browser extension mode: use sandbox.html with postMessage
this.loadViaSandboxUrl(sandboxId, completeHtml);
} else {
// Web mode: use srcdoc
this.loadViaSrcdoc(sandboxId, completeHtml);
}
}
private loadViaSandboxUrl(sandboxId: string, completeHtml: string): void {
// Create iframe pointing to sandbox URL
this.iframe = document.createElement("iframe");
this.iframe.sandbox.add("allow-scripts");
this.iframe.sandbox.add("allow-modals");
this.iframe.style.width = "100%";
this.iframe.style.height = "100%";
this.iframe.style.border = "none";
this.iframe.src = this.sandboxUrlProvider!();
// Update router with iframe reference BEFORE appending to DOM
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
// Listen for open-external-url messages from iframe
const externalUrlHandler = (e: MessageEvent) => {
if (
e.data.type === "open-external-url" &&
e.source === this.iframe?.contentWindow
) {
// Use chrome.tabs API to open in new tab
const chromeAPI = (globalThis as any).chrome;
if (chromeAPI?.tabs) {
chromeAPI.tabs.create({ url: e.data.url });
} else {
// Fallback for non-extension context
window.open(e.data.url, "_blank");
}
}
};
window.addEventListener("message", externalUrlHandler);
// Listen for sandbox-ready and sandbox-error messages directly
const readyHandler = (e: MessageEvent) => {
if (
e.data.type === "sandbox-ready" &&
e.source === this.iframe?.contentWindow
) {
window.removeEventListener("message", readyHandler);
window.removeEventListener("message", errorHandler);
// Send content to sandbox
this.iframe?.contentWindow?.postMessage(
{
type: "sandbox-load",
sandboxId,
code: completeHtml,
},
"*",
);
}
};
const errorHandler = (e: MessageEvent) => {
if (
e.data.type === "sandbox-error" &&
e.source === this.iframe?.contentWindow
) {
window.removeEventListener("message", readyHandler);
window.removeEventListener("message", errorHandler);
// The sandbox.js already sent us the error via postMessage.
// We need to convert it to an execution-error message that the execute() consumer will handle.
// Simulate receiving an execution-error from the sandbox
window.postMessage(
{
sandboxId: sandboxId,
type: "execution-error",
error: { message: e.data.error, stack: e.data.stack },
},
"*",
);
}
};
window.addEventListener("message", readyHandler);
window.addEventListener("message", errorHandler);
this.appendChild(this.iframe);
}
private loadViaSrcdoc(sandboxId: string, completeHtml: string): void {
// Create iframe with srcdoc
this.iframe = document.createElement("iframe");
this.iframe.sandbox.add("allow-scripts");
this.iframe.sandbox.add("allow-modals");
this.iframe.style.width = "100%";
this.iframe.style.height = "100%";
this.iframe.style.border = "none";
this.iframe.srcdoc = completeHtml;
// Update router with iframe reference BEFORE appending to DOM
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
// Listen for open-external-url messages from iframe
const externalUrlHandler = (e: MessageEvent) => {
if (
e.data.type === "open-external-url" &&
e.source === this.iframe?.contentWindow
) {
// Fallback for non-extension context
window.open(e.data.url, "_blank");
}
};
window.addEventListener("message", externalUrlHandler);
this.appendChild(this.iframe);
}
/**
* Execute code in sandbox
* @param sandboxId Unique ID for this execution
* @param code User code (plain JS for REPL, or full HTML for artifacts)
* @param providers Runtime providers to inject
* @param consumers Additional message consumers (optional, execute has its own internal consumer)
* @param signal Abort signal
* @returns Promise resolving to execution result
*/
public async execute(
sandboxId: string,
code: string,
providers: SandboxRuntimeProvider[] = [],
consumers: MessageConsumer[] = [],
signal?: AbortSignal,
isHtmlArtifact: boolean = false,
): Promise<SandboxResult> {
if (signal?.aborted) {
throw new Error("Execution aborted");
}
const consoleProvider = new ConsoleRuntimeProvider();
providers = [consoleProvider, ...providers];
RUNTIME_MESSAGE_ROUTER.registerSandbox(sandboxId, providers, consumers);
// Notify providers that execution is starting
for (const provider of providers) {
provider.onExecutionStart?.(sandboxId, signal);
}
const files: SandboxFile[] = [];
let completed = false;
return new Promise((resolve, reject) => {
// 4. Create execution consumer for lifecycle messages
const executionConsumer: MessageConsumer = {
async handleMessage(message: any): Promise<void> {
if (message.type === "file-returned") {
files.push({
fileName: message.fileName,
content: message.content,
mimeType: message.mimeType,
});
} else if (message.type === "execution-complete") {
completed = true;
cleanup();
resolve({
success: true,
console: consoleProvider.getLogs(),
files,
returnValue: message.returnValue,
});
} else if (message.type === "execution-error") {
completed = true;
cleanup();
resolve({
success: false,
console: consoleProvider.getLogs(),
error: message.error,
files,
});
}
},
};
RUNTIME_MESSAGE_ROUTER.addConsumer(sandboxId, executionConsumer);
const cleanup = () => {
// Notify providers that execution has ended
for (const provider of providers) {
provider.onExecutionEnd?.(sandboxId);
}
RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
signal?.removeEventListener("abort", abortHandler);
clearTimeout(timeoutId);
this.iframe?.remove();
this.iframe = undefined;
};
// Abort handler
const abortHandler = () => {
if (!completed) {
completed = true;
cleanup();
reject(new Error("Execution aborted"));
}
};
if (signal) {
signal.addEventListener("abort", abortHandler);
}
// Timeout handler (30 seconds)
const timeoutId = setTimeout(() => {
if (!completed) {
completed = true;
cleanup();
resolve({
success: false,
console: consoleProvider.getLogs(),
error: { message: "Execution timeout (120s)", stack: "" },
files,
});
}
}, 120000);
// 4. Prepare HTML and create iframe
const completeHtml = this.prepareHtmlDocument(
sandboxId,
code,
providers,
{
isHtmlArtifact,
isStandalone: false,
},
);
// 5. Validate HTML before sending to sandbox
const validationError = this.validateHtml(completeHtml);
if (validationError) {
reject(new Error(`HTML validation failed: ${validationError}`));
return;
}
if (this.sandboxUrlProvider) {
// Browser extension mode: wait for sandbox-ready
this.iframe = document.createElement("iframe");
this.iframe.sandbox.add("allow-scripts", "allow-modals");
this.iframe.style.cssText = "width: 100%; height: 100%; border: none;";
this.iframe.src = this.sandboxUrlProvider();
// Update router with iframe reference BEFORE appending to DOM
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
// Listen for sandbox-ready and sandbox-error messages
const readyHandler = (e: MessageEvent) => {
if (
e.data.type === "sandbox-ready" &&
e.source === this.iframe?.contentWindow
) {
window.removeEventListener("message", readyHandler);
window.removeEventListener("message", errorHandler);
// Send content to sandbox
this.iframe?.contentWindow?.postMessage(
{
type: "sandbox-load",
sandboxId,
code: completeHtml,
},
"*",
);
}
};
const errorHandler = (e: MessageEvent) => {
if (
e.data.type === "sandbox-error" &&
e.source === this.iframe?.contentWindow
) {
window.removeEventListener("message", readyHandler);
window.removeEventListener("message", errorHandler);
// Convert sandbox-error to execution-error for the execution consumer
window.postMessage(
{
sandboxId: sandboxId,
type: "execution-error",
error: { message: e.data.error, stack: e.data.stack },
},
"*",
);
}
};
window.addEventListener("message", readyHandler);
window.addEventListener("message", errorHandler);
this.appendChild(this.iframe);
} else {
// Web mode: use srcdoc
this.iframe = document.createElement("iframe");
this.iframe.sandbox.add("allow-scripts", "allow-modals");
this.iframe.style.cssText =
"width: 100%; height: 100%; border: none; display: none;";
this.iframe.srcdoc = completeHtml;
// Update router with iframe reference BEFORE appending to DOM
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
this.appendChild(this.iframe);
}
});
}
/**
* Validate HTML using DOMParser - returns error message if invalid, null if valid
* Note: JavaScript syntax validation is done in sandbox.js to avoid CSP restrictions
*/
private validateHtml(html: string): string | null {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// Check for parser errors
const parserError = doc.querySelector("parsererror");
if (parserError) {
return parserError.textContent || "Unknown parse error";
}
return null;
} catch (error: any) {
return error.message || "Unknown validation error";
}
}
/**
* Prepare complete HTML document with runtime + user code
* PUBLIC so HtmlArtifact can use it for download button
*/
public prepareHtmlDocument(
sandboxId: string,
userCode: string,
providers: SandboxRuntimeProvider[] = [],
options?: PrepareHtmlOptions,
): string {
// Default options
const opts: PrepareHtmlOptions = {
isHtmlArtifact: false,
isStandalone: false,
...options,
};
// Runtime script that will be injected
const runtime = this.getRuntimeScript(
sandboxId,
providers,
opts.isStandalone || false,
);
// Only check for HTML tags if explicitly marked as HTML artifact
// For javascript_repl, userCode is JavaScript that may contain HTML in string literals
if (opts.isHtmlArtifact) {
// HTML Artifact - inject runtime into existing HTML
const headMatch = userCode.match(/<head[^>]*>/i);
if (headMatch) {
const index = headMatch.index! + headMatch[0].length;
return userCode.slice(0, index) + runtime + userCode.slice(index);
}
const htmlMatch = userCode.match(/<html[^>]*>/i);
if (htmlMatch) {
const index = htmlMatch.index! + htmlMatch[0].length;
return userCode.slice(0, index) + runtime + userCode.slice(index);
}
// Fallback: prepend runtime
return runtime + userCode;
} else {
// REPL - wrap code in HTML with runtime and call complete() when done
// Escape </script> in user code to prevent premature tag closure
const escapedUserCode = escapeScriptContent(userCode);
return `<!DOCTYPE html>
<html>
<head>
${runtime}
</head>
<body>
<script type="module">
(async () => {
try {
// Wrap user code in async function to capture return value
const userCodeFunc = async () => {
${escapedUserCode}
};
const returnValue = await userCodeFunc();
// Call completion callbacks before complete()
if (window.__completionCallbacks && window.__completionCallbacks.length > 0) {
try {
await Promise.all(window.__completionCallbacks.map(cb => cb(true)));
} catch (e) {
console.error('Completion callback error:', e);
}
}
await window.complete(null, returnValue);
} catch (error) {
// Call completion callbacks before complete() (error path)
if (window.__completionCallbacks && window.__completionCallbacks.length > 0) {
try {
await Promise.all(window.__completionCallbacks.map(cb => cb(false)));
} catch (e) {
console.error('Completion callback error:', e);
}
}
await window.complete({
message: error?.message || String(error),
stack: error?.stack || new Error().stack
});
}
})();
</script>
</body>
</html>`;
}
}
/**
* Generate runtime script from providers
* @param sandboxId Unique sandbox ID
* @param providers Runtime providers
* @param isStandalone If true, skip runtime bridge and navigation interceptor (for standalone downloads)
*/
private getRuntimeScript(
sandboxId: string,
providers: SandboxRuntimeProvider[] = [],
isStandalone: boolean = false,
): string {
// Collect all data from providers
const allData: Record<string, any> = {};
for (const provider of providers) {
Object.assign(allData, provider.getData());
}
// Generate bridge code (skip if standalone)
const bridgeCode = isStandalone
? ""
: RuntimeMessageBridge.generateBridgeCode({
context: "sandbox-iframe",
sandboxId,
});
// Collect all runtime functions - pass sandboxId as string literal
const runtimeFunctions: string[] = [];
for (const provider of providers) {
runtimeFunctions.push(
`(${provider.getRuntime().toString()})(${JSON.stringify(sandboxId)});`,
);
}
// Build script with HTML escaping
// Escape </script> to prevent premature tag closure in HTML parser
const dataInjection = Object.entries(allData)
.map(([key, value]) => {
const jsonStr = JSON.stringify(value).replace(
/<\/script/gi,
"<\\/script",
);
return `window.${key} = ${jsonStr};`;
})
.join("\n");
// TODO the font-size is needed, as chrome seems to inject a stylesheet into iframes
// found in an extension context like sidepanel, setting body { font-size: 75% }. It's
// definitely not our code doing that.
// See https://stackoverflow.com/questions/71480433/chrome-is-injecting-some-stylesheet-in-popup-ui-which-reduces-the-font-size-to-7
// Navigation interceptor (only if NOT standalone)
const navigationInterceptor = isStandalone
? ""
: `
// Navigation interceptor: prevent all navigation and open externally
(function() {
// Intercept link clicks
document.addEventListener('click', function(e) {
const link = e.target.closest('a');
if (link && link.href) {
// Check if it's an external link (not javascript: or #hash)
if (link.href.startsWith('http://') || link.href.startsWith('https://')) {
e.preventDefault();
e.stopPropagation();
window.parent.postMessage({ type: 'open-external-url', url: link.href }, '*');
}
}
}, true);
// Intercept form submissions
document.addEventListener('submit', function(e) {
const form = e.target;
if (form && form.action) {
e.preventDefault();
e.stopPropagation();
window.parent.postMessage({ type: 'open-external-url', url: form.action }, '*');
}
}, true);
// Prevent window.location changes (only if not already redefined)
try {
const originalLocation = window.location;
Object.defineProperty(window, 'location', {
get: function() { return originalLocation; },
set: function(url) {
window.parent.postMessage({ type: 'open-external-url', url: url.toString() }, '*');
}
});
} catch (e) {
// Already defined, skip
}
})();
`;
return `<style>
html, body {
font-size: initial;
}
</style>
<script>
window.sandboxId = ${JSON.stringify(sandboxId)};
${dataInjection}
${bridgeCode}
${runtimeFunctions.join("\n")}
${navigationInterceptor}
</script>`;
}
}

View file

@ -1,112 +0,0 @@
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { html, LitElement } from "lit";
import { property, state } from "lit/decorators.js";
export class StreamingMessageContainer extends LitElement {
@property({ type: Array }) tools: AgentTool[] = [];
@property({ type: Boolean }) isStreaming = false;
@property({ type: Object }) pendingToolCalls?: Set<string>;
@property({ type: Object }) toolResultsById?: Map<string, ToolResultMessage>;
@property({ attribute: false }) onCostClick?: () => void;
@state() private _message: AgentMessage | null = null;
private _pendingMessage: AgentMessage | null = null;
private _updateScheduled = false;
private _immediateUpdate = false;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
// Public method to update the message with batching for performance
public setMessage(message: AgentMessage | null, immediate = false) {
// Store the latest message
this._pendingMessage = message;
// If this is an immediate update (like clearing), apply it right away
if (immediate || message === null) {
this._immediateUpdate = true;
this._message = message;
this.requestUpdate();
// Cancel any pending updates since we're clearing
this._pendingMessage = null;
this._updateScheduled = false;
return;
}
// Otherwise batch updates for performance during streaming
if (!this._updateScheduled) {
this._updateScheduled = true;
requestAnimationFrame(async () => {
// Only apply the update if we haven't been cleared
if (!this._immediateUpdate && this._pendingMessage !== null) {
// Deep clone the message to ensure Lit detects changes in nested properties
// (like toolCall.arguments being mutated during streaming)
this._message = JSON.parse(JSON.stringify(this._pendingMessage));
this.requestUpdate();
}
// Reset for next batch
this._pendingMessage = null;
this._updateScheduled = false;
this._immediateUpdate = false;
});
}
}
override render() {
// Show loading indicator if loading but no message yet
if (!this._message) {
if (this.isStreaming)
return html`<div class="flex flex-col gap-3 mb-3">
<span
class="mx-4 inline-block w-2 h-4 bg-muted-foreground animate-pulse"
></span>
</div>`;
return html``; // Empty until a message is set
}
const msg = this._message;
if (msg.role === "toolResult") {
// Skip standalone tool result in streaming; the stable list will render paired tool-message
return html``;
} else if (msg.role === "user" || msg.role === "user-with-attachments") {
// Skip standalone tool result in streaming; the stable list will render it immediiately
return html``;
} else if (msg.role === "assistant") {
// Assistant message - render inline tool messages during streaming
return html`
<div class="flex flex-col gap-3 mb-3">
<assistant-message
.message=${msg}
.tools=${this.tools}
.isStreaming=${this.isStreaming}
.pendingToolCalls=${this.pendingToolCalls}
.toolResultsById=${this.toolResultsById}
.hideToolCalls=${false}
.onCostClick=${this.onCostClick}
></assistant-message>
${this.isStreaming
? html`<span
class="mx-4 inline-block w-2 h-4 bg-muted-foreground animate-pulse"
></span>`
: ""}
</div>
`;
}
}
}
// Register custom element
if (!customElements.get("streaming-message-container")) {
customElements.define(
"streaming-message-container",
StreamingMessageContainer,
);
}

View file

@ -1,53 +0,0 @@
import { icon } from "@mariozechner/mini-lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ChevronRight } from "lucide";
@customElement("thinking-block")
export class ThinkingBlock extends LitElement {
@property() content!: string;
@property({ type: Boolean }) isStreaming = false;
@state() private isExpanded = false;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
private toggleExpanded() {
this.isExpanded = !this.isExpanded;
}
override render() {
const shimmerClasses = this.isStreaming
? "animate-shimmer bg-gradient-to-r from-muted-foreground via-foreground to-muted-foreground bg-[length:200%_100%] bg-clip-text text-transparent"
: "";
return html`
<div class="thinking-block">
<div
class="thinking-header cursor-pointer select-none flex items-center gap-2 py-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
@click=${this.toggleExpanded}
>
<span
class="transition-transform inline-block ${this.isExpanded
? "rotate-90"
: ""}"
>${icon(ChevronRight, "sm")}</span
>
<span class="${shimmerClasses}">Thinking...</span>
</div>
${this.isExpanded
? html`<markdown-block
.content=${this.content}
.isThinking=${true}
></markdown-block>`
: ""}
</div>
`;
}
}

View file

@ -1,32 +0,0 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { TemplateResult } from "lit";
// Extract role type from AppMessage union
export type MessageRole = AgentMessage["role"];
// Generic message renderer typed to specific message type
export interface MessageRenderer<TMessage extends AgentMessage = AgentMessage> {
render(message: TMessage): TemplateResult;
}
// Registry of custom message renderers by role
const messageRenderers = new Map<MessageRole, MessageRenderer<any>>();
export function registerMessageRenderer<TRole extends MessageRole>(
role: TRole,
renderer: MessageRenderer<Extract<AgentMessage, { role: TRole }>>,
): void {
messageRenderers.set(role, renderer);
}
export function getMessageRenderer(
role: MessageRole,
): MessageRenderer | undefined {
return messageRenderers.get(role);
}
export function renderMessage(
message: AgentMessage,
): TemplateResult | undefined {
return messageRenderers.get(message.role)?.render(message);
}

View file

@ -1,239 +0,0 @@
import {
ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO,
ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW,
} from "../../prompts/prompts.js";
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
// Define minimal interface for ArtifactsPanel to avoid circular dependencies
interface ArtifactsPanelLike {
artifacts: Map<string, { content: string }>;
tool: {
execute(
toolCallId: string,
args: { command: string; filename: string; content?: string },
): Promise<any>;
};
}
interface AgentLike {
appendMessage(message: any): void;
}
/**
* Artifacts Runtime Provider
*
* Provides programmatic access to session artifacts from sandboxed code.
* Allows code to create, read, update, and delete artifacts dynamically.
* Supports both online (extension) and offline (downloaded HTML) modes.
*/
export class ArtifactsRuntimeProvider implements SandboxRuntimeProvider {
constructor(
private artifactsPanel: ArtifactsPanelLike,
private agent?: AgentLike,
private readWrite: boolean = true,
) {}
getData(): Record<string, any> {
// Inject artifact snapshot for offline mode
const snapshot: Record<string, string> = {};
this.artifactsPanel.artifacts.forEach((artifact, filename) => {
snapshot[filename] = artifact.content;
});
return { artifacts: snapshot };
}
getRuntime(): (sandboxId: string) => void {
// This function will be stringified, so no external references!
return (_sandboxId: string) => {
// Auto-parse/stringify for .json files
const isJsonFile = (filename: string) => filename.endsWith(".json");
(window as any).listArtifacts = async (): Promise<string[]> => {
// Online: ask extension
if ((window as any).sendRuntimeMessage) {
const response = await (window as any).sendRuntimeMessage({
type: "artifact-operation",
action: "list",
});
if (!response.success) throw new Error(response.error);
return response.result;
}
// Offline: return snapshot keys
else {
return Object.keys((window as any).artifacts || {});
}
};
(window as any).getArtifact = async (filename: string): Promise<any> => {
let content: string;
// Online: ask extension
if ((window as any).sendRuntimeMessage) {
const response = await (window as any).sendRuntimeMessage({
type: "artifact-operation",
action: "get",
filename,
});
if (!response.success) throw new Error(response.error);
content = response.result;
}
// Offline: read snapshot
else {
if (!(window as any).artifacts?.[filename]) {
throw new Error(`Artifact not found (offline mode): ${filename}`);
}
content = (window as any).artifacts[filename];
}
// Auto-parse .json files
if (isJsonFile(filename)) {
try {
return JSON.parse(content);
} catch (e) {
throw new Error(`Failed to parse JSON from ${filename}: ${e}`);
}
}
return content;
};
(window as any).createOrUpdateArtifact = async (
filename: string,
content: any,
mimeType?: string,
): Promise<void> => {
if (!(window as any).sendRuntimeMessage) {
throw new Error(
"Cannot create/update artifacts in offline mode (read-only)",
);
}
let finalContent = content;
// Auto-stringify .json files
if (isJsonFile(filename) && typeof content !== "string") {
finalContent = JSON.stringify(content, null, 2);
} else if (typeof content !== "string") {
finalContent = JSON.stringify(content, null, 2);
}
const response = await (window as any).sendRuntimeMessage({
type: "artifact-operation",
action: "createOrUpdate",
filename,
content: finalContent,
mimeType,
});
if (!response.success) throw new Error(response.error);
};
(window as any).deleteArtifact = async (
filename: string,
): Promise<void> => {
if (!(window as any).sendRuntimeMessage) {
throw new Error(
"Cannot delete artifacts in offline mode (read-only)",
);
}
const response = await (window as any).sendRuntimeMessage({
type: "artifact-operation",
action: "delete",
filename,
});
if (!response.success) throw new Error(response.error);
};
};
}
async handleMessage(
message: any,
respond: (response: any) => void,
): Promise<void> {
if (message.type !== "artifact-operation") {
return;
}
const { action, filename, content } = message;
try {
switch (action) {
case "list": {
const filenames = Array.from(this.artifactsPanel.artifacts.keys());
respond({ success: true, result: filenames });
break;
}
case "get": {
const artifact = this.artifactsPanel.artifacts.get(filename);
if (!artifact) {
respond({
success: false,
error: `Artifact not found: ${filename}`,
});
} else {
respond({ success: true, result: artifact.content });
}
break;
}
case "createOrUpdate": {
try {
const exists = this.artifactsPanel.artifacts.has(filename);
const command = exists ? "rewrite" : "create";
const action = exists ? "update" : "create";
await this.artifactsPanel.tool.execute("", {
command,
filename,
content,
});
this.agent?.appendMessage({
role: "artifact",
action,
filename,
content,
...(action === "create" && { title: filename }),
timestamp: new Date().toISOString(),
});
respond({ success: true });
} catch (err: any) {
respond({ success: false, error: err.message });
}
break;
}
case "delete": {
try {
await this.artifactsPanel.tool.execute("", {
command: "delete",
filename,
});
this.agent?.appendMessage({
role: "artifact",
action: "delete",
filename,
timestamp: new Date().toISOString(),
});
respond({ success: true });
} catch (err: any) {
respond({ success: false, error: err.message });
}
break;
}
default:
respond({
success: false,
error: `Unknown artifact action: ${action}`,
});
}
} catch (error: any) {
respond({ success: false, error: error.message });
}
}
getDescription(): string {
return this.readWrite
? ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW
: ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO;
}
}

View file

@ -1,70 +0,0 @@
import { ATTACHMENTS_RUNTIME_DESCRIPTION } from "../../prompts/prompts.js";
import type { Attachment } from "../../utils/attachment-utils.js";
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
/**
* Attachments Runtime Provider
*
* OPTIONAL provider that provides file access APIs to sandboxed code.
* Only needed when attachments are present.
* Attachments are read-only snapshot data - no messaging needed.
*/
export class AttachmentsRuntimeProvider implements SandboxRuntimeProvider {
constructor(private attachments: Attachment[]) {}
getData(): Record<string, any> {
const attachmentsData = this.attachments.map((a) => ({
id: a.id,
fileName: a.fileName,
mimeType: a.mimeType,
size: a.size,
content: a.content,
extractedText: a.extractedText,
}));
return { attachments: attachmentsData };
}
getRuntime(): (sandboxId: string) => void {
// This function will be stringified, so no external references!
// These functions read directly from window.attachments
// Works both online AND offline (no messaging needed!)
return (_sandboxId: string) => {
(window as any).listAttachments = () =>
((window as any).attachments || []).map((a: any) => ({
id: a.id,
fileName: a.fileName,
mimeType: a.mimeType,
size: a.size,
}));
(window as any).readTextAttachment = (attachmentId: string) => {
const a = ((window as any).attachments || []).find(
(x: any) => x.id === attachmentId,
);
if (!a) throw new Error(`Attachment not found: ${attachmentId}`);
if (a.extractedText) return a.extractedText;
try {
return atob(a.content);
} catch {
throw new Error(`Failed to decode text content for: ${attachmentId}`);
}
};
(window as any).readBinaryAttachment = (attachmentId: string) => {
const a = ((window as any).attachments || []).find(
(x: any) => x.id === attachmentId,
);
if (!a) throw new Error(`Attachment not found: ${attachmentId}`);
const bin = atob(a.content);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
};
};
}
getDescription(): string {
return ATTACHMENTS_RUNTIME_DESCRIPTION;
}
}

View file

@ -1,197 +0,0 @@
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
export interface ConsoleLog {
type: "log" | "warn" | "error" | "info";
text: string;
args?: unknown[];
}
/**
* Console Runtime Provider
*
* REQUIRED provider that should always be included first.
* Provides console capture, error handling, and execution lifecycle management.
* Collects console output for retrieval by caller.
*/
export class ConsoleRuntimeProvider implements SandboxRuntimeProvider {
private logs: ConsoleLog[] = [];
private completionError: { message: string; stack: string } | null = null;
private completed = false;
getData(): Record<string, any> {
// No data needed
return {};
}
getDescription(): string {
return "";
}
getRuntime(): (sandboxId: string) => void {
return (_sandboxId: string) => {
// Store truly original console methods on first wrap only
// This prevents accumulation of wrapper functions across multiple executions
if (!(window as any).__originalConsole) {
(window as any).__originalConsole = {
log: console.log.bind(console),
error: console.error.bind(console),
warn: console.warn.bind(console),
info: console.info.bind(console),
};
}
// Always use the truly original console, not the current (possibly wrapped) one
const originalConsole = (window as any).__originalConsole;
// Track pending send promises to wait for them in onCompleted
const pendingSends: Promise<any>[] = [];
["log", "error", "warn", "info"].forEach((method) => {
(console as any)[method] = (...args: any[]) => {
const text = args
.map((arg) => {
try {
return typeof arg === "object"
? JSON.stringify(arg)
: String(arg);
} catch {
return String(arg);
}
})
.join(" ");
// Always log locally too (using truly original console)
(originalConsole as any)[method].apply(console, args);
// Send immediately and track the promise (only in extension context)
if ((window as any).sendRuntimeMessage) {
const sendPromise = (window as any)
.sendRuntimeMessage({
type: "console",
method,
text,
args,
})
.catch(() => {});
pendingSends.push(sendPromise);
}
};
});
// Register completion callback to wait for all pending sends
if ((window as any).onCompleted) {
(window as any).onCompleted(async (_success: boolean) => {
// Wait for all pending console sends to complete
if (pendingSends.length > 0) {
await Promise.all(pendingSends);
}
});
}
// Track errors for HTML artifacts
let lastError: { message: string; stack: string } | null = null;
// Error handlers - track errors but don't log them
// (they'll be shown via execution-error message)
window.addEventListener("error", (e) => {
const text = `${e.error?.stack || e.message || String(e)} at line ${e.lineno || "?"}:${e.colno || "?"}`;
lastError = {
message: e.error?.message || e.message || String(e),
stack: e.error?.stack || text,
};
});
window.addEventListener("unhandledrejection", (e) => {
const text = `Unhandled promise rejection: ${e.reason?.message || e.reason || "Unknown error"}`;
lastError = {
message:
e.reason?.message ||
String(e.reason) ||
"Unhandled promise rejection",
stack: e.reason?.stack || text,
};
});
// Expose complete() method for user code to call
let completionSent = false;
(window as any).complete = async (
error?: { message: string; stack: string },
returnValue?: any,
) => {
if (completionSent) return;
completionSent = true;
const finalError = error || lastError;
if ((window as any).sendRuntimeMessage) {
if (finalError) {
await (window as any).sendRuntimeMessage({
type: "execution-error",
error: finalError,
});
} else {
await (window as any).sendRuntimeMessage({
type: "execution-complete",
returnValue,
});
}
}
};
};
}
async handleMessage(
message: any,
respond: (response: any) => void,
): Promise<void> {
if (message.type === "console") {
// Collect console output
this.logs.push({
type:
message.method === "error"
? "error"
: message.method === "warn"
? "warn"
: message.method === "info"
? "info"
: "log",
text: message.text,
args: message.args,
});
// Acknowledge receipt
respond({ success: true });
}
}
/**
* Get collected console logs
*/
getLogs(): ConsoleLog[] {
return this.logs;
}
/**
* Get completion status
*/
isCompleted(): boolean {
return this.completed;
}
/**
* Get completion error if any
*/
getCompletionError(): { message: string; stack: string } | null {
return this.completionError;
}
/**
* Reset state for reuse
*/
reset(): void {
this.logs = [];
this.completionError = null;
this.completed = false;
}
}

View file

@ -1,121 +0,0 @@
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
export interface DownloadableFile {
fileName: string;
content: string | Uint8Array;
mimeType: string;
}
/**
* File Download Runtime Provider
*
* Provides returnDownloadableFile() for creating user downloads.
* Files returned this way are NOT accessible to the LLM later (one-time download).
* Works both online (sends to extension) and offline (triggers browser download directly).
* Collects files for retrieval by caller.
*/
export class FileDownloadRuntimeProvider implements SandboxRuntimeProvider {
private files: DownloadableFile[] = [];
getData(): Record<string, any> {
// No data needed
return {};
}
getRuntime(): (sandboxId: string) => void {
return (_sandboxId: string) => {
(window as any).returnDownloadableFile = async (
fileName: string,
content: any,
mimeType?: string,
) => {
let finalContent: any, finalMimeType: string;
if (content instanceof Blob) {
const arrayBuffer = await content.arrayBuffer();
finalContent = new Uint8Array(arrayBuffer);
finalMimeType =
mimeType || content.type || "application/octet-stream";
if (!mimeType && !content.type) {
throw new Error(
"returnDownloadableFile: MIME type is required for Blob content. Please provide a mimeType parameter (e.g., 'image/png').",
);
}
} else if (content instanceof Uint8Array) {
finalContent = content;
if (!mimeType) {
throw new Error(
"returnDownloadableFile: MIME type is required for Uint8Array content. Please provide a mimeType parameter (e.g., 'image/png').",
);
}
finalMimeType = mimeType;
} else if (typeof content === "string") {
finalContent = content;
finalMimeType = mimeType || "text/plain";
} else {
finalContent = JSON.stringify(content, null, 2);
finalMimeType = mimeType || "application/json";
}
// Send to extension if in extension context (online mode)
if ((window as any).sendRuntimeMessage) {
const response = await (window as any).sendRuntimeMessage({
type: "file-returned",
fileName,
content: finalContent,
mimeType: finalMimeType,
});
if (response.error) throw new Error(response.error);
} else {
// Offline mode: trigger browser download directly
const blob = new Blob(
[finalContent instanceof Uint8Array ? finalContent : finalContent],
{
type: finalMimeType,
},
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
};
};
}
async handleMessage(
message: any,
respond: (response: any) => void,
): Promise<void> {
if (message.type === "file-returned") {
// Collect file for caller
this.files.push({
fileName: message.fileName,
content: message.content,
mimeType: message.mimeType,
});
respond({ success: true });
}
}
/**
* Get collected files
*/
getFiles(): DownloadableFile[] {
return this.files;
}
/**
* Reset state for reuse
*/
reset(): void {
this.files = [];
}
getDescription(): string {
return "returnDownloadableFile(filename, content, mimeType?) - Create downloadable file for user (one-time download, not accessible later)";
}
}

View file

@ -1,82 +0,0 @@
/**
* Generates sendRuntimeMessage() function for injection into execution contexts.
* Provides unified messaging API that works in both sandbox iframe and user script contexts.
*/
export type MessageType = "request-response" | "fire-and-forget";
export interface RuntimeMessageBridgeOptions {
context: "sandbox-iframe" | "user-script";
sandboxId: string;
}
// biome-ignore lint/complexity/noStaticOnlyClass: fine
export class RuntimeMessageBridge {
/**
* Generate sendRuntimeMessage() function as injectable string.
* Returns the function source code to be injected into target context.
*/
static generateBridgeCode(options: RuntimeMessageBridgeOptions): string {
if (options.context === "sandbox-iframe") {
return RuntimeMessageBridge.generateSandboxBridge(options.sandboxId);
} else {
return RuntimeMessageBridge.generateUserScriptBridge(options.sandboxId);
}
}
private static generateSandboxBridge(sandboxId: string): string {
// Returns stringified function that uses window.parent.postMessage
return `
window.__completionCallbacks = [];
window.sendRuntimeMessage = async (message) => {
const messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9);
return new Promise((resolve, reject) => {
const handler = (e) => {
if (e.data.type === 'runtime-response' && e.data.messageId === messageId) {
window.removeEventListener('message', handler);
if (e.data.success) {
resolve(e.data);
} else {
reject(new Error(e.data.error || 'Operation failed'));
}
}
};
window.addEventListener('message', handler);
window.parent.postMessage({
...message,
sandboxId: ${JSON.stringify(sandboxId)},
messageId: messageId
}, '*');
// Timeout after 30s
setTimeout(() => {
window.removeEventListener('message', handler);
reject(new Error('Runtime message timeout'));
}, 30000);
});
};
window.onCompleted = (callback) => {
window.__completionCallbacks.push(callback);
};
`.trim();
}
private static generateUserScriptBridge(sandboxId: string): string {
// Returns stringified function that uses chrome.runtime.sendMessage
return `
window.__completionCallbacks = [];
window.sendRuntimeMessage = async (message) => {
return await chrome.runtime.sendMessage({
...message,
sandboxId: ${JSON.stringify(sandboxId)}
});
};
window.onCompleted = (callback) => {
window.__completionCallbacks.push(callback);
};
`.trim();
}
}

View file

@ -1,239 +0,0 @@
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
// Type declaration for chrome extension API (when available)
declare const chrome: any;
/**
* Message consumer interface - components that want to receive messages from sandboxes
*/
export interface MessageConsumer {
/**
* Handle a message from a sandbox.
* All consumers receive all messages - decide internally what to handle.
*/
handleMessage(message: any): Promise<void>;
}
/**
* Sandbox context - tracks active sandboxes and their consumers
*/
interface SandboxContext {
sandboxId: string;
iframe: HTMLIFrameElement | null; // null until setSandboxIframe() or null for user scripts
providers: SandboxRuntimeProvider[];
consumers: Set<MessageConsumer>;
}
/**
* Centralized message router for all runtime communication.
*
* This singleton replaces all individual window.addEventListener("message") calls
* with a single global listener that routes messages to the appropriate handlers.
* Also handles user script messages from chrome.runtime.onUserScriptMessage.
*
* Benefits:
* - Single global listener instead of multiple independent listeners
* - Automatic cleanup when sandboxes are destroyed
* - Support for bidirectional communication (providers) and broadcasting (consumers)
* - Works with both sandbox iframes and user scripts
* - Clear lifecycle management
*/
export class RuntimeMessageRouter {
private sandboxes = new Map<string, SandboxContext>();
private messageListener: ((e: MessageEvent) => void) | null = null;
private userScriptMessageListener:
| ((
message: any,
sender: any,
sendResponse: (response: any) => void,
) => boolean)
| null = null;
/**
* Register a new sandbox with its runtime providers.
* Call this BEFORE creating the iframe (for sandbox contexts) or executing user script.
*/
registerSandbox(
sandboxId: string,
providers: SandboxRuntimeProvider[],
consumers: MessageConsumer[],
): void {
this.sandboxes.set(sandboxId, {
sandboxId,
iframe: null, // Will be set via setSandboxIframe() for sandbox contexts
providers,
consumers: new Set(consumers),
});
// Setup global listener if not already done
this.setupListener();
}
/**
* Update the iframe reference for a sandbox.
* Call this AFTER creating the iframe.
* This is needed so providers can send responses back to the sandbox.
*/
setSandboxIframe(sandboxId: string, iframe: HTMLIFrameElement): void {
const context = this.sandboxes.get(sandboxId);
if (context) {
context.iframe = iframe;
}
}
/**
* Unregister a sandbox and remove all its consumers.
* Call this when the sandbox is destroyed.
*/
unregisterSandbox(sandboxId: string): void {
this.sandboxes.delete(sandboxId);
// If no more sandboxes, remove global listeners
if (this.sandboxes.size === 0) {
// Remove iframe listener
if (this.messageListener) {
window.removeEventListener("message", this.messageListener);
this.messageListener = null;
}
// Remove user script listener
if (
this.userScriptMessageListener &&
typeof chrome !== "undefined" &&
chrome.runtime?.onUserScriptMessage
) {
chrome.runtime.onUserScriptMessage.removeListener(
this.userScriptMessageListener,
);
this.userScriptMessageListener = null;
}
}
}
/**
* Add a message consumer for a sandbox.
* Consumers receive broadcast messages (console, execution-complete, etc.)
*/
addConsumer(sandboxId: string, consumer: MessageConsumer): void {
const context = this.sandboxes.get(sandboxId);
if (context) {
context.consumers.add(consumer);
}
}
/**
* Remove a message consumer from a sandbox.
*/
removeConsumer(sandboxId: string, consumer: MessageConsumer): void {
const context = this.sandboxes.get(sandboxId);
if (context) {
context.consumers.delete(consumer);
}
}
/**
* Setup the global message listeners (called automatically)
*/
private setupListener(): void {
// Setup sandbox iframe listener
if (!this.messageListener) {
this.messageListener = async (e: MessageEvent) => {
const { sandboxId, messageId } = e.data;
if (!sandboxId) return;
const context = this.sandboxes.get(sandboxId);
if (!context) {
return;
}
// Create respond() function for bidirectional communication
const respond = (response: any) => {
context.iframe?.contentWindow?.postMessage(
{
type: "runtime-response",
messageId,
sandboxId,
...response,
},
"*",
);
};
// 1. Try provider handlers first (for bidirectional comm)
for (const provider of context.providers) {
if (provider.handleMessage) {
await provider.handleMessage(e.data, respond);
// Don't stop - let consumers also handle the message
}
}
// 2. Broadcast to consumers (one-way messages or lifecycle events)
for (const consumer of context.consumers) {
await consumer.handleMessage(e.data);
// Don't stop - let all consumers see the message
}
};
window.addEventListener("message", this.messageListener);
}
// Setup user script message listener
if (!this.userScriptMessageListener) {
// Guard: check if we're in extension context
if (
typeof chrome === "undefined" ||
!chrome.runtime?.onUserScriptMessage
) {
return;
}
this.userScriptMessageListener = (
message: any,
_sender: any,
sendResponse: (response: any) => void,
) => {
const { sandboxId } = message;
if (!sandboxId) return false;
const context = this.sandboxes.get(sandboxId);
if (!context) return false;
const respond = (response: any) => {
sendResponse({
...response,
sandboxId,
});
};
// Route to providers (async)
(async () => {
// 1. Try provider handlers first (for bidirectional comm)
for (const provider of context.providers) {
if (provider.handleMessage) {
await provider.handleMessage(message, respond);
// Don't stop - let consumers also handle the message
}
}
// 2. Broadcast to consumers (one-way messages or lifecycle events)
for (const consumer of context.consumers) {
await consumer.handleMessage(message);
// Don't stop - let all consumers see the message
}
})();
return true; // Indicates async response
};
chrome.runtime.onUserScriptMessage.addListener(
this.userScriptMessageListener,
);
}
}
}
/**
* Global singleton instance.
* Import this from wherever you need to interact with the message router.
*/
export const RUNTIME_MESSAGE_ROUTER = new RuntimeMessageRouter();

View file

@ -1,52 +0,0 @@
/**
* Interface for providing runtime capabilities to sandboxed iframes.
* Each provider injects data and runtime functions into the sandbox context.
*/
export interface SandboxRuntimeProvider {
/**
* Returns data to inject into window scope.
* Keys become window properties (e.g., { attachments: [...] } -> window.attachments)
*/
getData(): Record<string, any>;
/**
* Returns a runtime function that will be stringified and executed in the sandbox.
* The function receives sandboxId and has access to data from getData() via window.
*
* IMPORTANT: This function will be converted to string via .toString() and injected
* into the sandbox, so it cannot reference external variables or imports.
*/
getRuntime(): (sandboxId: string) => void;
/**
* Optional message handler for bidirectional communication.
* All providers receive all messages - decide internally what to handle.
*
* @param message - The message from the sandbox
* @param respond - Function to send a response back to the sandbox
*/
handleMessage?(message: any, respond: (response: any) => void): Promise<void>;
/**
* Optional documentation describing what globals/functions this provider injects.
* This will be appended to tool descriptions dynamically so the LLM knows what's available.
*/
getDescription(): string;
/**
* Optional lifecycle callback invoked when sandbox execution starts.
* Providers can use this to track abort signals for cancellation of async operations.
*
* @param sandboxId - The unique identifier for this sandbox execution
* @param signal - Optional AbortSignal that will be triggered if execution is cancelled
*/
onExecutionStart?(sandboxId: string, signal?: AbortSignal): void;
/**
* Optional lifecycle callback invoked when sandbox execution ends (success, error, or abort).
* Providers can use this to clean up any resources associated with the sandbox.
*
* @param sandboxId - The unique identifier for this sandbox execution
*/
onExecutionEnd?(sandboxId: string): void;
}

View file

@ -1,78 +0,0 @@
import { customElement, state } from "lit/decorators.js";
import "../components/ProviderKeyInput.js";
import {
DialogContent,
DialogHeader,
} from "@mariozechner/mini-lit/dist/Dialog.js";
import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
import { html } from "lit";
import { getAppStorage } from "../storage/app-storage.js";
import { i18n } from "../utils/i18n.js";
@customElement("api-key-prompt-dialog")
export class ApiKeyPromptDialog extends DialogBase {
@state() private provider = "";
private resolvePromise?: (success: boolean) => void;
private unsubscribe?: () => void;
protected modalWidth = "min(500px, 90vw)";
protected modalHeight = "auto";
static async prompt(provider: string): Promise<boolean> {
const dialog = new ApiKeyPromptDialog();
dialog.provider = provider;
dialog.open();
return new Promise((resolve) => {
dialog.resolvePromise = resolve;
});
}
override async connectedCallback() {
super.connectedCallback();
// Poll for key existence - when key is added, resolve and close
const checkInterval = setInterval(async () => {
const hasKey = !!(await getAppStorage().providerKeys.get(this.provider));
if (hasKey) {
clearInterval(checkInterval);
if (this.resolvePromise) {
this.resolvePromise(true);
this.resolvePromise = undefined;
}
this.close();
}
}, 500);
this.unsubscribe = () => clearInterval(checkInterval);
}
override disconnectedCallback() {
super.disconnectedCallback();
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = undefined;
}
}
override close() {
super.close();
if (this.resolvePromise) {
this.resolvePromise(false);
}
}
protected override renderContent() {
return html`
${DialogContent({
children: html`
${DialogHeader({
title: i18n("API Key Required"),
})}
<provider-key-input .provider=${this.provider}></provider-key-input>
`,
})}
`;
}
}

View file

@ -1,677 +0,0 @@
import "@mariozechner/mini-lit/dist/ModeToggle.js";
import { icon } from "@mariozechner/mini-lit";
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import { renderAsync } from "docx-preview";
import { html, 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 type { Attachment } from "../utils/attachment-utils.js";
import { i18n } from "../utils/i18n.js";
type FileType = "image" | "pdf" | "docx" | "pptx" | "excel" | "text";
export class AttachmentOverlay extends LitElement {
@state() private attachment?: Attachment;
@state() private showExtractedText = false;
@state() private error: string | null = null;
// Track current loading task to cancel if needed
private currentLoadingTask: any = null;
private onCloseCallback?: () => void;
private boundHandleKeyDown?: (e: KeyboardEvent) => void;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
static open(attachment: Attachment, onClose?: () => void) {
const overlay = new AttachmentOverlay();
overlay.attachment = attachment;
overlay.onCloseCallback = onClose;
document.body.appendChild(overlay);
overlay.setupEventListeners();
}
private setupEventListeners() {
this.boundHandleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
this.close();
}
};
window.addEventListener("keydown", this.boundHandleKeyDown);
}
private close() {
this.cleanup();
if (this.boundHandleKeyDown) {
window.removeEventListener("keydown", this.boundHandleKeyDown);
}
this.onCloseCallback?.();
this.remove();
}
private getFileType(): FileType {
if (!this.attachment) return "text";
if (this.attachment.type === "image") return "image";
if (this.attachment.mimeType === "application/pdf") return "pdf";
if (this.attachment.mimeType?.includes("wordprocessingml")) return "docx";
if (
this.attachment.mimeType?.includes("presentationml") ||
this.attachment.fileName.toLowerCase().endsWith(".pptx")
)
return "pptx";
if (
this.attachment.mimeType?.includes("spreadsheetml") ||
this.attachment.mimeType?.includes("ms-excel") ||
this.attachment.fileName.toLowerCase().endsWith(".xlsx") ||
this.attachment.fileName.toLowerCase().endsWith(".xls")
)
return "excel";
return "text";
}
private getFileTypeLabel(): string {
const type = this.getFileType();
switch (type) {
case "pdf":
return i18n("PDF");
case "docx":
return i18n("Document");
case "pptx":
return i18n("Presentation");
case "excel":
return i18n("Spreadsheet");
default:
return "";
}
}
private handleBackdropClick = () => {
this.close();
};
private handleDownload = () => {
if (!this.attachment) return;
// Create a blob from the base64 content
const byteCharacters = atob(this.attachment.content);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: this.attachment.mimeType });
// Create download link
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = this.attachment.fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
private cleanup() {
this.showExtractedText = false;
this.error = null;
// Cancel any loading PDF task when closing
if (this.currentLoadingTask) {
this.currentLoadingTask.destroy();
this.currentLoadingTask = null;
}
}
override render() {
if (!this.attachment) return html``;
return html`
<!-- Full screen overlay -->
<div
class="fixed inset-0 bg-black/90 z-50 flex flex-col"
@click=${this.handleBackdropClick}
>
<!-- Compact header bar -->
<div
class="bg-background/95 backdrop-blur border-b border-border"
@click=${(e: Event) => e.stopPropagation()}
>
<div class="px-4 py-2 flex items-center justify-between">
<div class="flex items-center gap-3 min-w-0">
<span class="text-sm font-medium text-foreground truncate"
>${this.attachment.fileName}</span
>
</div>
<div class="flex items-center gap-2">
${this.renderToggle()}
${Button({
variant: "ghost",
size: "icon",
onClick: this.handleDownload,
children: icon(Download, "sm"),
className: "h-8 w-8",
})}
${Button({
variant: "ghost",
size: "icon",
onClick: () => this.close(),
children: icon(X, "sm"),
className: "h-8 w-8",
})}
</div>
</div>
</div>
<!-- Content container -->
<div
class="flex-1 flex items-center justify-center overflow-auto"
@click=${(e: Event) => e.stopPropagation()}
>
${this.renderContent()}
</div>
</div>
`;
}
private renderToggle() {
if (!this.attachment) return html``;
const fileType = this.getFileType();
const hasExtractedText = !!this.attachment.extractedText;
const showToggle =
fileType !== "image" &&
fileType !== "text" &&
fileType !== "pptx" &&
hasExtractedText;
if (!showToggle) return html``;
const fileTypeLabel = this.getFileTypeLabel();
return html`
<mode-toggle
.modes=${[fileTypeLabel, i18n("Text")]}
.selectedIndex=${this.showExtractedText ? 1 : 0}
@mode-change=${(e: CustomEvent<{ index: number; mode: string }>) => {
e.stopPropagation();
this.showExtractedText = e.detail.index === 1;
this.error = null;
}}
></mode-toggle>
`;
}
private renderContent() {
if (!this.attachment) return html``;
// Error state
if (this.error) {
return html`
<div
class="bg-destructive/10 border border-destructive text-destructive p-4 rounded-lg max-w-2xl"
>
<div class="font-medium mb-1">${i18n("Error loading file")}</div>
<div class="text-sm opacity-90">${this.error}</div>
</div>
`;
}
// Content based on file type
return this.renderFileContent();
}
private renderFileContent() {
if (!this.attachment) return html``;
const fileType = this.getFileType();
// Show extracted text if toggled
if (this.showExtractedText && fileType !== "image") {
return html`
<div
class="bg-card border border-border text-foreground p-6 w-full h-full max-w-4xl overflow-auto"
>
<pre class="whitespace-pre-wrap font-mono text-xs leading-relaxed">
${this.attachment.extractedText || i18n("No text content available")}</pre
>
</div>
`;
}
// Render based on file type
switch (fileType) {
case "image": {
const imageUrl = `data:${this.attachment.mimeType};base64,${this.attachment.content}`;
return html`
<img
src="${imageUrl}"
class="max-w-full max-h-full object-contain rounded-lg shadow-lg"
alt="${this.attachment.fileName}"
/>
`;
}
case "pdf":
return html`
<div
id="pdf-container"
class="bg-card text-foreground overflow-auto shadow-lg border border-border w-full h-full max-w-[1000px]"
></div>
`;
case "docx":
return html`
<div
id="docx-container"
class="bg-card text-foreground overflow-auto shadow-lg border border-border w-full h-full max-w-[1000px]"
></div>
`;
case "excel":
return html`
<div
id="excel-container"
class="bg-card text-foreground overflow-auto w-full h-full"
></div>
`;
case "pptx":
return html`
<div
id="pptx-container"
class="bg-card text-foreground overflow-auto shadow-lg border border-border w-full h-full max-w-[1000px]"
></div>
`;
default:
return html`
<div
class="bg-card border border-border text-foreground p-6 w-full h-full max-w-4xl overflow-auto"
>
<pre class="whitespace-pre-wrap font-mono text-sm">
${this.attachment.extractedText || i18n("No content available")}</pre
>
</div>
`;
}
}
override async updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
// Only process if we need to render the actual file (not extracted text)
if (
(changedProperties.has("attachment") ||
changedProperties.has("showExtractedText")) &&
this.attachment &&
!this.showExtractedText &&
!this.error
) {
const fileType = this.getFileType();
switch (fileType) {
case "pdf":
await this.renderPdf();
break;
case "docx":
await this.renderDocx();
break;
case "excel":
await this.renderExcel();
break;
case "pptx":
await this.renderExtractedText();
break;
}
}
}
private async renderPdf() {
const container = this.querySelector("#pdf-container");
if (!container || !this.attachment) return;
let pdf: any = null;
try {
// Convert base64 to ArrayBuffer
const arrayBuffer = this.base64ToArrayBuffer(this.attachment.content);
// Cancel any existing loading task
if (this.currentLoadingTask) {
this.currentLoadingTask.destroy();
}
// Load the PDF
this.currentLoadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
pdf = await this.currentLoadingTask.promise;
this.currentLoadingTask = null;
// Clear container and add wrapper
container.innerHTML = "";
const wrapper = document.createElement("div");
wrapper.className = "";
container.appendChild(wrapper);
// Render all pages
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
const page = await pdf.getPage(pageNum);
// Create a container for each page
const pageContainer = document.createElement("div");
pageContainer.className = "mb-4 last:mb-0";
// Create canvas for this page
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
// Set scale for reasonable resolution
const viewport = page.getViewport({ scale: 1.5 });
canvas.height = viewport.height;
canvas.width = viewport.width;
// Style the canvas
canvas.className =
"w-full max-w-full h-auto block mx-auto bg-white rounded shadow-sm border border-border";
// Fill white background for proper PDF rendering
if (context) {
context.fillStyle = "white";
context.fillRect(0, 0, canvas.width, canvas.height);
}
// Render page
await page.render({
canvasContext: context!,
viewport: viewport,
canvas: canvas,
}).promise;
pageContainer.appendChild(canvas);
// Add page separator for multi-page documents
if (pageNum < pdf.numPages) {
const separator = document.createElement("div");
separator.className = "h-px bg-border my-4";
pageContainer.appendChild(separator);
}
wrapper.appendChild(pageContainer);
}
} catch (error: any) {
console.error("Error rendering PDF:", error);
this.error = error?.message || i18n("Failed to load PDF");
} finally {
if (pdf) {
pdf.destroy();
}
}
}
private async renderDocx() {
const container = this.querySelector("#docx-container");
if (!container || !this.attachment) return;
try {
// Convert base64 to ArrayBuffer
const arrayBuffer = this.base64ToArrayBuffer(this.attachment.content);
// Clear container first
container.innerHTML = "";
// Create a wrapper div for the document
const wrapper = document.createElement("div");
wrapper.className = "docx-wrapper-custom";
container.appendChild(wrapper);
// Render the DOCX file into the wrapper
await renderAsync(arrayBuffer, wrapper as HTMLElement, undefined, {
className: "docx",
inWrapper: true,
ignoreWidth: true, // Let it be responsive
ignoreHeight: false,
ignoreFonts: false,
breakPages: true,
ignoreLastRenderedPageBreak: true,
experimental: false,
trimXmlDeclaration: true,
useBase64URL: false,
renderHeaders: true,
renderFooters: true,
renderFootnotes: true,
renderEndnotes: true,
});
// Apply custom styles to match theme and fix sizing
const style = document.createElement("style");
style.textContent = `
#docx-container {
padding: 0;
}
#docx-container .docx-wrapper-custom {
max-width: 100%;
overflow-x: auto;
}
#docx-container .docx-wrapper {
max-width: 100% !important;
margin: 0 !important;
background: transparent !important;
padding: 0em !important;
}
#docx-container .docx-wrapper > section.docx {
box-shadow: none !important;
border: none !important;
border-radius: 0 !important;
margin: 0 !important;
padding: 2em !important;
background: white !important;
color: black !important;
max-width: 100% !important;
width: 100% !important;
min-width: 0 !important;
overflow-x: auto !important;
}
/* Fix tables and wide content */
#docx-container table {
max-width: 100% !important;
width: auto !important;
overflow-x: auto !important;
display: block !important;
}
#docx-container img {
max-width: 100% !important;
height: auto !important;
}
/* Fix paragraphs and text */
#docx-container p,
#docx-container span,
#docx-container div {
max-width: 100% !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
}
/* Hide page breaks in web view */
#docx-container .docx-page-break {
display: none !important;
}
`;
container.appendChild(style);
} catch (error: any) {
console.error("Error rendering DOCX:", error);
this.error = error?.message || i18n("Failed to load document");
}
}
private async renderExcel() {
const container = this.querySelector("#excel-container");
if (!container || !this.attachment) return;
try {
// Convert base64 to ArrayBuffer
const arrayBuffer = this.base64ToArrayBuffer(this.attachment.content);
// Read the workbook
const workbook = XLSX.read(arrayBuffer, { type: "array" });
// Clear container
container.innerHTML = "";
const wrapper = document.createElement("div");
wrapper.className = "overflow-auto h-full flex flex-col";
container.appendChild(wrapper);
// Create tabs for multiple sheets
if (workbook.SheetNames.length > 1) {
const tabContainer = document.createElement("div");
tabContainer.className =
"flex gap-2 mb-4 border-b border-border sticky top-0 bg-card z-10";
const sheetContents: HTMLElement[] = [];
workbook.SheetNames.forEach((sheetName, index) => {
// Create tab button
const tab = document.createElement("button");
tab.textContent = sheetName;
tab.className =
index === 0
? "px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary"
: "px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-b-2 hover:border-border transition-colors";
// Create sheet content
const sheetDiv = document.createElement("div");
sheetDiv.style.display = index === 0 ? "flex" : "none";
sheetDiv.className = "flex-1 overflow-auto";
sheetDiv.appendChild(
this.renderExcelSheet(workbook.Sheets[sheetName], sheetName),
);
sheetContents.push(sheetDiv);
// Tab click handler
tab.onclick = () => {
// Update tab styles
tabContainer.querySelectorAll("button").forEach((btn, btnIndex) => {
if (btnIndex === index) {
btn.className =
"px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary";
} else {
btn.className =
"px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-b-2 hover:border-border transition-colors";
}
});
// Show/hide sheets
sheetContents.forEach((content, contentIndex) => {
content.style.display = contentIndex === index ? "flex" : "none";
});
};
tabContainer.appendChild(tab);
});
wrapper.appendChild(tabContainer);
sheetContents.forEach((content) => {
wrapper.appendChild(content);
});
} else {
// Single sheet
const sheetName = workbook.SheetNames[0];
wrapper.appendChild(
this.renderExcelSheet(workbook.Sheets[sheetName], sheetName),
);
}
} catch (error: any) {
console.error("Error rendering Excel:", error);
this.error = error?.message || i18n("Failed to load spreadsheet");
}
}
private renderExcelSheet(worksheet: any, sheetName: string): HTMLElement {
const sheetDiv = document.createElement("div");
// Generate HTML table
const htmlTable = XLSX.utils.sheet_to_html(worksheet, {
id: `sheet-${sheetName}`,
});
const tempDiv = document.createElement("div");
tempDiv.innerHTML = htmlTable;
// Find and style the table
const table = tempDiv.querySelector("table");
if (table) {
table.className = "w-full border-collapse text-foreground";
// Style all cells
table.querySelectorAll("td, th").forEach((cell) => {
const cellEl = cell as HTMLElement;
cellEl.className = "border border-border px-3 py-2 text-sm text-left";
});
// Style header row
const headerCells = table.querySelectorAll("thead th, tr:first-child td");
if (headerCells.length > 0) {
headerCells.forEach((th) => {
const thEl = th as HTMLElement;
thEl.className =
"border border-border px-3 py-2 text-sm font-semibold bg-muted text-foreground sticky top-0";
});
}
// Alternate row colors
table.querySelectorAll("tbody tr:nth-child(even)").forEach((row) => {
const rowEl = row as HTMLElement;
rowEl.className = "bg-muted/30";
});
sheetDiv.appendChild(table);
}
return sheetDiv;
}
private base64ToArrayBuffer(base64: string): ArrayBuffer {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
private async renderExtractedText() {
const container = this.querySelector("#pptx-container");
if (!container || !this.attachment) return;
try {
// Display the extracted text content
container.innerHTML = "";
const wrapper = document.createElement("div");
wrapper.className = "p-6 overflow-auto";
// Create a pre element to preserve formatting
const pre = document.createElement("pre");
pre.className = "whitespace-pre-wrap text-sm text-foreground font-mono";
pre.textContent =
this.attachment.extractedText || i18n("No text content available");
wrapper.appendChild(pre);
container.appendChild(wrapper);
} catch (error: any) {
console.error("Error rendering extracted text:", error);
this.error = error?.message || i18n("Failed to display text content");
}
}
}
// Register the custom element only once
if (!customElements.get("attachment-overlay")) {
customElements.define("attachment-overlay", AttachmentOverlay);
}

View file

@ -1,306 +0,0 @@
import { i18n } from "@mariozechner/mini-lit";
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
import { Input } from "@mariozechner/mini-lit/dist/Input.js";
import { Label } from "@mariozechner/mini-lit/dist/Label.js";
import { Select } from "@mariozechner/mini-lit/dist/Select.js";
import type { Model } from "@mariozechner/pi-ai";
import { html, type TemplateResult } from "lit";
import { state } from "lit/decorators.js";
import { getAppStorage } from "../storage/app-storage.js";
import type {
CustomProvider,
CustomProviderType,
} from "../storage/stores/custom-providers-store.js";
import { discoverModels } from "../utils/model-discovery.js";
export class CustomProviderDialog extends DialogBase {
private provider?: CustomProvider;
private initialType?: CustomProviderType;
private onSaveCallback?: () => void;
@state() private name = "";
@state() private type: CustomProviderType = "openai-completions";
@state() private baseUrl = "";
@state() private apiKey = "";
@state() private testing = false;
@state() private testError = "";
@state() private discoveredModels: Model<any>[] = [];
protected modalWidth = "min(800px, 90vw)";
protected modalHeight = "min(700px, 90vh)";
static async open(
provider: CustomProvider | undefined,
initialType: CustomProviderType | undefined,
onSave?: () => void,
) {
const dialog = new CustomProviderDialog();
dialog.provider = provider;
dialog.initialType = initialType;
dialog.onSaveCallback = onSave;
document.body.appendChild(dialog);
dialog.initializeFromProvider();
dialog.open();
dialog.requestUpdate();
}
private initializeFromProvider() {
if (this.provider) {
this.name = this.provider.name;
this.type = this.provider.type;
this.baseUrl = this.provider.baseUrl;
this.apiKey = this.provider.apiKey || "";
this.discoveredModels = this.provider.models || [];
} else {
this.name = "";
this.type = this.initialType || "openai-completions";
this.baseUrl = "";
this.updateDefaultBaseUrl();
this.apiKey = "";
this.discoveredModels = [];
}
this.testError = "";
this.testing = false;
}
private updateDefaultBaseUrl() {
if (this.baseUrl) return;
const defaults: Record<string, string> = {
ollama: "http://localhost:11434",
"llama.cpp": "http://localhost:8080",
vllm: "http://localhost:8000",
lmstudio: "http://localhost:1234",
"openai-completions": "",
"openai-responses": "",
"anthropic-messages": "",
};
this.baseUrl = defaults[this.type] || "";
}
private isAutoDiscoveryType(): boolean {
return (
this.type === "ollama" ||
this.type === "llama.cpp" ||
this.type === "vllm" ||
this.type === "lmstudio"
);
}
private async testConnection() {
if (!this.isAutoDiscoveryType()) return;
this.testing = true;
this.testError = "";
this.discoveredModels = [];
try {
const models = await discoverModels(
this.type as "ollama" | "llama.cpp" | "vllm" | "lmstudio",
this.baseUrl,
this.apiKey || undefined,
);
this.discoveredModels = models.map((model) => ({
...model,
provider: this.name || this.type,
}));
this.testError = "";
} catch (error) {
this.testError = error instanceof Error ? error.message : String(error);
this.discoveredModels = [];
} finally {
this.testing = false;
this.requestUpdate();
}
}
private async save() {
if (!this.name || !this.baseUrl) {
alert(i18n("Please fill in all required fields"));
return;
}
try {
const storage = getAppStorage();
const provider: CustomProvider = {
id: this.provider?.id || crypto.randomUUID(),
name: this.name,
type: this.type,
baseUrl: this.baseUrl,
apiKey: this.apiKey || undefined,
models: this.isAutoDiscoveryType()
? undefined
: this.provider?.models || [],
};
await storage.customProviders.set(provider);
if (this.onSaveCallback) {
this.onSaveCallback();
}
this.close();
} catch (error) {
console.error("Failed to save provider:", error);
alert(i18n("Failed to save provider"));
}
}
protected override renderContent(): TemplateResult {
const providerTypes = [
{ value: "ollama", label: "Ollama (auto-discovery)" },
{ value: "llama.cpp", label: "llama.cpp (auto-discovery)" },
{ value: "vllm", label: "vLLM (auto-discovery)" },
{ value: "lmstudio", label: "LM Studio (auto-discovery)" },
{ value: "openai-completions", label: "OpenAI Completions Compatible" },
{ value: "openai-responses", label: "OpenAI Responses Compatible" },
{ value: "anthropic-messages", label: "Anthropic Messages Compatible" },
];
return html`
<div class="flex flex-col h-full overflow-hidden">
<div class="p-6 flex-shrink-0 border-b border-border">
<h2 class="text-lg font-semibold text-foreground">
${this.provider ? i18n("Edit Provider") : i18n("Add Provider")}
</h2>
</div>
<div class="flex-1 overflow-y-auto p-6">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
${Label({
htmlFor: "provider-name",
children: i18n("Provider Name"),
})}
${Input({
value: this.name,
placeholder: i18n("e.g., My Ollama Server"),
onInput: (e: Event) => {
this.name = (e.target as HTMLInputElement).value;
this.requestUpdate();
},
})}
</div>
<div class="flex flex-col gap-2">
${Label({
htmlFor: "provider-type",
children: i18n("Provider Type"),
})}
${Select({
value: this.type,
options: providerTypes.map((pt) => ({
value: pt.value,
label: pt.label,
})),
onChange: (value: string) => {
this.type = value as CustomProviderType;
this.baseUrl = "";
this.updateDefaultBaseUrl();
this.requestUpdate();
},
width: "100%",
})}
</div>
<div class="flex flex-col gap-2">
${Label({ htmlFor: "base-url", children: i18n("Base URL") })}
${Input({
value: this.baseUrl,
placeholder: i18n("e.g., http://localhost:11434"),
onInput: (e: Event) => {
this.baseUrl = (e.target as HTMLInputElement).value;
this.requestUpdate();
},
})}
</div>
<div class="flex flex-col gap-2">
${Label({
htmlFor: "api-key",
children: i18n("API Key (Optional)"),
})}
${Input({
type: "password",
value: this.apiKey,
placeholder: i18n("Leave empty if not required"),
onInput: (e: Event) => {
this.apiKey = (e.target as HTMLInputElement).value;
this.requestUpdate();
},
})}
</div>
${this.isAutoDiscoveryType()
? html`
<div class="flex flex-col gap-2">
${Button({
onClick: () => this.testConnection(),
variant: "outline",
disabled: this.testing || !this.baseUrl,
children: this.testing
? i18n("Testing...")
: i18n("Test Connection"),
})}
${this.testError
? html`
<div class="text-sm text-destructive">
${this.testError}
</div>
`
: ""}
${this.discoveredModels.length > 0
? html`
<div class="text-sm text-muted-foreground">
${i18n("Discovered")}
${this.discoveredModels.length} ${i18n("models")}:
<ul class="list-disc list-inside mt-2">
${this.discoveredModels
.slice(0, 5)
.map((model) => html`<li>${model.name}</li>`)}
${this.discoveredModels.length > 5
? html`<li>
...${i18n("and")}
${this.discoveredModels.length - 5}
${i18n("more")}
</li>`
: ""}
</ul>
</div>
`
: ""}
</div>
`
: html` <div class="text-sm text-muted-foreground">
${i18n(
"For manual provider types, add models after saving the provider.",
)}
</div>`}
</div>
</div>
<div
class="p-6 flex-shrink-0 border-t border-border flex justify-end gap-2"
>
${Button({
onClick: () => this.close(),
variant: "ghost",
children: i18n("Cancel"),
})}
${Button({
onClick: () => this.save(),
variant: "default",
disabled: !this.name || !this.baseUrl,
children: i18n("Save"),
})}
</div>
</div>
`;
}
}
customElements.define("custom-provider-dialog", CustomProviderDialog);

View file

@ -1,367 +0,0 @@
import { icon } from "@mariozechner/mini-lit";
import { Badge } from "@mariozechner/mini-lit/dist/Badge.js";
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import { DialogHeader } from "@mariozechner/mini-lit/dist/Dialog.js";
import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
import {
getModels,
getProviders,
type Model,
modelsAreEqual,
} from "@mariozechner/pi-ai";
import { html, type PropertyValues, type TemplateResult } 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 { Input } from "../components/Input.js";
import { getAppStorage } from "../storage/app-storage.js";
import type { AutoDiscoveryProviderType } from "../storage/stores/custom-providers-store.js";
import { formatModelCost } from "../utils/format.js";
import { i18n } from "../utils/i18n.js";
import { discoverModels } from "../utils/model-discovery.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() customProvidersLoading = false;
@state() selectedIndex = 0;
@state() private navigationMode: "mouse" | "keyboard" = "mouse";
@state() private customProviderModels: Model<any>[] = [];
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.loadCustomProviders();
}
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 loadCustomProviders() {
this.customProvidersLoading = true;
const allCustomModels: Model<any>[] = [];
try {
const storage = getAppStorage();
const customProviders = await storage.customProviders.getAll();
// Load models from custom providers
for (const provider of customProviders) {
const isAutoDiscovery: boolean =
provider.type === "ollama" ||
provider.type === "llama.cpp" ||
provider.type === "vllm" ||
provider.type === "lmstudio";
if (isAutoDiscovery) {
try {
const models = await discoverModels(
provider.type as AutoDiscoveryProviderType,
provider.baseUrl,
provider.apiKey,
);
const modelsWithProvider = models.map((model) => ({
...model,
provider: provider.name,
}));
allCustomModels.push(...modelsWithProvider);
} catch (error) {
console.debug(
`Failed to load models from ${provider.name}:`,
error,
);
}
} else if (provider.models) {
// Manual provider - models already defined
allCustomModels.push(...provider.models);
}
}
} catch (error) {
console.error("Failed to load custom providers:", error);
} finally {
this.customProviderModels = allCustomModels;
this.customProvidersLoading = false;
this.requestUpdate();
}
}
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 known providers
const allModels: Array<{ provider: string; id: string; model: any }> = [];
const knownProviders = getProviders();
for (const provider of knownProviders) {
const models = getModels(provider as any);
for (const model of models) {
allModels.push({ provider, id: model.id, model });
}
}
// Add custom provider models
for (const model of this.customProviderModels) {
allModels.push({ provider: model.provider, id: model.id, model });
}
// 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
.toLowerCase()
.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 = modelsAreEqual(this.currentModel, a.model);
const bIsCurrent = modelsAreEqual(this.currentModel, b.model);
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) => {
const isCurrent = modelsAreEqual(this.currentModel, model);
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

@ -1,178 +0,0 @@
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import {
DialogContent,
DialogHeader,
} from "@mariozechner/mini-lit/dist/Dialog.js";
import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { i18n } from "../utils/i18n.js";
@customElement("persistent-storage-dialog")
export class PersistentStorageDialog extends DialogBase {
@state() private requesting = false;
private resolvePromise?: (userApproved: boolean) => void;
protected modalWidth = "min(500px, 90vw)";
protected modalHeight = "auto";
/**
* Request persistent storage permission.
* Returns true if browser granted persistent storage, false otherwise.
*/
static async request(): Promise<boolean> {
// Check if already persisted
if (navigator.storage?.persisted) {
const alreadyPersisted = await navigator.storage.persisted();
if (alreadyPersisted) {
console.log("✓ Persistent storage already granted");
return true;
}
}
// Show dialog and wait for user response
const dialog = new PersistentStorageDialog();
dialog.open();
const userApproved = await new Promise<boolean>((resolve) => {
dialog.resolvePromise = resolve;
});
if (!userApproved) {
console.warn("⚠ User declined persistent storage - sessions may be lost");
return false;
}
// User approved, request from browser
if (!navigator.storage?.persist) {
console.warn("⚠ Persistent storage API not available");
return false;
}
try {
const granted = await navigator.storage.persist();
if (granted) {
console.log(
"✓ Persistent storage granted - sessions will be preserved",
);
} else {
console.warn(
"⚠ Browser denied persistent storage - sessions may be lost under storage pressure",
);
}
return granted;
} catch (error) {
console.error("Failed to request persistent storage:", error);
return false;
}
}
private handleGrant() {
if (this.resolvePromise) {
this.resolvePromise(true);
this.resolvePromise = undefined;
}
this.close();
}
private handleDeny() {
if (this.resolvePromise) {
this.resolvePromise(false);
this.resolvePromise = undefined;
}
this.close();
}
override close() {
super.close();
if (this.resolvePromise) {
this.resolvePromise(false);
}
}
protected override renderContent() {
return html`
${DialogContent({
children: html`
${DialogHeader({
title: i18n("Storage Permission Required"),
description: i18n(
"This app needs persistent storage to save your conversations",
),
})}
<div class="mt-4 flex flex-col gap-4">
<div
class="flex gap-3 p-4 bg-warning/10 border border-warning/20 rounded-lg"
>
<div class="flex-shrink-0 text-warning">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
</div>
<div class="text-sm">
<p class="font-medium text-foreground mb-1">
${i18n("Why is this needed?")}
</p>
<p class="text-muted-foreground">
${i18n(
"Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.",
)}
</p>
</div>
</div>
<div class="text-sm text-muted-foreground">
<p class="mb-2">${i18n("What this means:")}</p>
<ul class="list-disc list-inside space-y-1 ml-2">
<li>
${i18n(
"Your conversations will be saved locally in your browser",
)}
</li>
<li>
${i18n(
"Data will not be deleted automatically to free up space",
)}
</li>
<li>
${i18n("You can still manually clear data at any time")}
</li>
<li>${i18n("No data is sent to external servers")}</li>
</ul>
</div>
</div>
<div class="mt-6 flex gap-3 justify-end">
${Button({
variant: "outline",
onClick: () => this.handleDeny(),
disabled: this.requesting,
children: i18n("Continue Anyway"),
})}
${Button({
variant: "default",
onClick: () => this.handleGrant(),
disabled: this.requesting,
children: this.requesting
? i18n("Requesting...")
: i18n("Grant Permission"),
})}
</div>
`,
})}
`;
}
}

View file

@ -1,249 +0,0 @@
import { i18n } from "@mariozechner/mini-lit";
import { Select } from "@mariozechner/mini-lit/dist/Select.js";
import { getProviders } from "@mariozechner/pi-ai";
import { html, type TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import "../components/CustomProviderCard.js";
import "../components/ProviderKeyInput.js";
import { getAppStorage } from "../storage/app-storage.js";
import type {
AutoDiscoveryProviderType,
CustomProvider,
CustomProviderType,
} from "../storage/stores/custom-providers-store.js";
import { discoverModels } from "../utils/model-discovery.js";
import { CustomProviderDialog } from "./CustomProviderDialog.js";
import { SettingsTab } from "./SettingsDialog.js";
@customElement("providers-models-tab")
export class ProvidersModelsTab extends SettingsTab {
@state() private customProviders: CustomProvider[] = [];
@state() private providerStatus: Map<
string,
{ modelCount: number; status: "connected" | "disconnected" | "checking" }
> = new Map();
override async connectedCallback() {
super.connectedCallback();
await this.loadCustomProviders();
}
private async loadCustomProviders() {
try {
const storage = getAppStorage();
this.customProviders = await storage.customProviders.getAll();
// Check status for auto-discovery providers
for (const provider of this.customProviders) {
const isAutoDiscovery =
provider.type === "ollama" ||
provider.type === "llama.cpp" ||
provider.type === "vllm" ||
provider.type === "lmstudio";
if (isAutoDiscovery) {
this.checkProviderStatus(provider);
}
}
} catch (error) {
console.error("Failed to load custom providers:", error);
}
}
getTabName(): string {
return "Providers & Models";
}
private async checkProviderStatus(provider: CustomProvider) {
this.providerStatus.set(provider.id, { modelCount: 0, status: "checking" });
this.requestUpdate();
try {
const models = await discoverModels(
provider.type as AutoDiscoveryProviderType,
provider.baseUrl,
provider.apiKey,
);
this.providerStatus.set(provider.id, {
modelCount: models.length,
status: "connected",
});
} catch (_error) {
this.providerStatus.set(provider.id, {
modelCount: 0,
status: "disconnected",
});
}
this.requestUpdate();
}
private renderKnownProviders(): TemplateResult {
const providers = getProviders();
return html`
<div class="flex flex-col gap-6">
<div>
<h3 class="text-sm font-semibold text-foreground mb-2">
Cloud Providers
</h3>
<p class="text-sm text-muted-foreground mb-4">
Cloud LLM providers with predefined models. API keys are stored
locally in your browser.
</p>
</div>
<div class="flex flex-col gap-6">
${providers.map(
(provider) => html`
<provider-key-input .provider=${provider}></provider-key-input>
`,
)}
</div>
</div>
`;
}
private renderCustomProviders(): TemplateResult {
const isAutoDiscovery = (type: string) =>
type === "ollama" ||
type === "llama.cpp" ||
type === "vllm" ||
type === "lmstudio";
return html`
<div class="flex flex-col gap-6">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-semibold text-foreground mb-2">
Custom Providers
</h3>
<p class="text-sm text-muted-foreground">
User-configured servers with auto-discovered or manually defined
models.
</p>
</div>
${Select({
placeholder: i18n("Add Provider"),
options: [
{ value: "ollama", label: "Ollama" },
{ value: "llama.cpp", label: "llama.cpp" },
{ value: "vllm", label: "vLLM" },
{ value: "lmstudio", label: "LM Studio" },
{
value: "openai-completions",
label: i18n("OpenAI Completions Compatible"),
},
{
value: "openai-responses",
label: i18n("OpenAI Responses Compatible"),
},
{
value: "anthropic-messages",
label: i18n("Anthropic Messages Compatible"),
},
],
onChange: (value: string) =>
this.addCustomProvider(value as CustomProviderType),
variant: "outline",
size: "sm",
})}
</div>
${this.customProviders.length === 0
? html`
<div class="text-sm text-muted-foreground text-center py-8">
No custom providers configured. Click 'Add Provider' to get
started.
</div>
`
: html`
<div class="flex flex-col gap-4">
${this.customProviders.map(
(provider) => html`
<custom-provider-card
.provider=${provider}
.isAutoDiscovery=${isAutoDiscovery(provider.type)}
.status=${this.providerStatus.get(provider.id)}
.onRefresh=${(p: CustomProvider) =>
this.refreshProvider(p)}
.onEdit=${(p: CustomProvider) => this.editProvider(p)}
.onDelete=${(p: CustomProvider) => this.deleteProvider(p)}
></custom-provider-card>
`,
)}
</div>
`}
</div>
`;
}
private async addCustomProvider(type: CustomProviderType) {
await CustomProviderDialog.open(undefined, type, async () => {
await this.loadCustomProviders();
this.requestUpdate();
});
}
private async editProvider(provider: CustomProvider) {
await CustomProviderDialog.open(provider, undefined, async () => {
await this.loadCustomProviders();
this.requestUpdate();
});
}
private async refreshProvider(provider: CustomProvider) {
this.providerStatus.set(provider.id, { modelCount: 0, status: "checking" });
this.requestUpdate();
try {
const models = await discoverModels(
provider.type as AutoDiscoveryProviderType,
provider.baseUrl,
provider.apiKey,
);
this.providerStatus.set(provider.id, {
modelCount: models.length,
status: "connected",
});
this.requestUpdate();
console.log(`Refreshed ${models.length} models from ${provider.name}`);
} catch (error) {
this.providerStatus.set(provider.id, {
modelCount: 0,
status: "disconnected",
});
this.requestUpdate();
console.error(`Failed to refresh provider ${provider.name}:`, error);
alert(
`Failed to refresh provider: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
private async deleteProvider(provider: CustomProvider) {
if (!confirm("Are you sure you want to delete this provider?")) {
return;
}
try {
const storage = getAppStorage();
await storage.customProviders.delete(provider.id);
await this.loadCustomProviders();
this.requestUpdate();
} catch (error) {
console.error("Failed to delete provider:", error);
}
}
render(): TemplateResult {
return html`
<div class="flex flex-col gap-8">
${this.renderKnownProviders()}
<div class="border-t border-border"></div>
${this.renderCustomProviders()}
</div>
`;
}
}

View file

@ -1,179 +0,0 @@
import {
DialogContent,
DialogHeader,
} from "@mariozechner/mini-lit/dist/Dialog.js";
import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { getAppStorage } from "../storage/app-storage.js";
import type { SessionMetadata } from "../storage/types.js";
import { formatUsage } from "../utils/format.js";
import { i18n } from "../utils/i18n.js";
@customElement("session-list-dialog")
export class SessionListDialog extends DialogBase {
@state() private sessions: SessionMetadata[] = [];
@state() private loading = true;
private onSelectCallback?: (sessionId: string) => void;
private onDeleteCallback?: (sessionId: string) => void;
private deletedSessions = new Set<string>();
private closedViaSelection = false;
protected modalWidth = "min(600px, 90vw)";
protected modalHeight = "min(700px, 90vh)";
static async open(
onSelect: (sessionId: string) => void,
onDelete?: (sessionId: string) => void,
) {
const dialog = new SessionListDialog();
dialog.onSelectCallback = onSelect;
dialog.onDeleteCallback = onDelete;
dialog.open();
await dialog.loadSessions();
}
private async loadSessions() {
this.loading = true;
try {
const storage = getAppStorage();
this.sessions = await storage.sessions.getAllMetadata();
} catch (err) {
console.error("Failed to load sessions:", err);
this.sessions = [];
} finally {
this.loading = false;
}
}
private async handleDelete(sessionId: string, event: Event) {
event.stopPropagation();
if (!confirm(i18n("Delete this session?"))) {
return;
}
try {
const storage = getAppStorage();
if (!storage.sessions) return;
await storage.sessions.deleteSession(sessionId);
await this.loadSessions();
// Track deleted session
this.deletedSessions.add(sessionId);
} catch (err) {
console.error("Failed to delete session:", err);
}
}
override close() {
super.close();
// Only notify about deleted sessions if dialog wasn't closed via selection
if (
!this.closedViaSelection &&
this.onDeleteCallback &&
this.deletedSessions.size > 0
) {
for (const sessionId of this.deletedSessions) {
this.onDeleteCallback(sessionId);
}
}
}
private handleSelect(sessionId: string) {
this.closedViaSelection = true;
if (this.onSelectCallback) {
this.onSelectCallback(sessionId);
}
this.close();
}
private formatDate(isoString: string): string {
const date = new Date(isoString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
return i18n("Today");
} else if (days === 1) {
return i18n("Yesterday");
} else if (days < 7) {
return i18n("{days} days ago").replace("{days}", days.toString());
} else {
return date.toLocaleDateString();
}
}
protected override renderContent() {
return html`
${DialogContent({
className: "h-full flex flex-col",
children: html`
${DialogHeader({
title: i18n("Sessions"),
description: i18n("Load a previous conversation"),
})}
<div class="flex-1 overflow-y-auto mt-4 space-y-2">
${this.loading
? html`<div class="text-center py-8 text-muted-foreground">
${i18n("Loading...")}
</div>`
: this.sessions.length === 0
? html`<div class="text-center py-8 text-muted-foreground">
${i18n("No sessions yet")}
</div>`
: this.sessions.map(
(session) => html`
<div
class="group flex items-start gap-3 p-3 rounded-lg border border-border hover:bg-secondary/50 cursor-pointer transition-colors"
@click=${() => this.handleSelect(session.id)}
>
<div class="flex-1 min-w-0">
<div
class="font-medium text-sm text-foreground truncate"
>
${session.title}
</div>
<div class="text-xs text-muted-foreground mt-1">
${this.formatDate(session.lastModified)}
</div>
<div class="text-xs text-muted-foreground mt-1">
${session.messageCount} ${i18n("messages")} ·
${formatUsage(session.usage)}
</div>
</div>
<button
class="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-destructive/10 text-destructive transition-opacity"
@click=${(e: Event) =>
this.handleDelete(session.id, e)}
title=${i18n("Delete")}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 6h18"></path>
<path
d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"
></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
</svg>
</button>
</div>
`,
)}
</div>
`,
})}
`;
}
}

View file

@ -1,241 +0,0 @@
import { i18n } from "@mariozechner/mini-lit";
import {
Dialog,
DialogContent,
DialogHeader,
} from "@mariozechner/mini-lit/dist/Dialog.js";
import { Input } from "@mariozechner/mini-lit/dist/Input.js";
import { Label } from "@mariozechner/mini-lit/dist/Label.js";
import { Switch } from "@mariozechner/mini-lit/dist/Switch.js";
import { getProviders } from "@mariozechner/pi-ai";
import { html, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import "../components/ProviderKeyInput.js";
import { getAppStorage } from "../storage/app-storage.js";
// Base class for settings tabs
export abstract class SettingsTab extends LitElement {
abstract getTabName(): string;
protected createRenderRoot() {
return this;
}
}
// API Keys Tab
@customElement("api-keys-tab")
export class ApiKeysTab extends SettingsTab {
getTabName(): string {
return i18n("API Keys");
}
render(): TemplateResult {
const providers = getProviders();
return html`
<div class="flex flex-col gap-6">
<p class="text-sm text-muted-foreground">
${i18n(
"Configure API keys for LLM providers. Keys are stored locally in your browser.",
)}
</p>
${providers.map(
(provider) =>
html`<provider-key-input
.provider=${provider}
></provider-key-input>`,
)}
</div>
`;
}
}
// Proxy Tab
@customElement("proxy-tab")
export class ProxyTab extends SettingsTab {
@state() private proxyEnabled = false;
@state() private proxyUrl = "http://localhost:3001";
override async connectedCallback() {
super.connectedCallback();
// Load proxy settings when tab is connected
try {
const storage = getAppStorage();
const enabled = await storage.settings.get<boolean>("proxy.enabled");
const url = await storage.settings.get<string>("proxy.url");
if (enabled !== null) this.proxyEnabled = enabled;
if (url !== null) this.proxyUrl = url;
} catch (error) {
console.error("Failed to load proxy settings:", error);
}
}
private async saveProxySettings() {
try {
const storage = getAppStorage();
await storage.settings.set("proxy.enabled", this.proxyEnabled);
await storage.settings.set("proxy.url", this.proxyUrl);
} catch (error) {
console.error("Failed to save proxy settings:", error);
}
}
getTabName(): string {
return i18n("Proxy");
}
render(): TemplateResult {
return html`
<div class="flex flex-col gap-4">
<p class="text-sm text-muted-foreground">
${i18n(
"Allows browser-based apps to bypass CORS restrictions when calling LLM providers. Required for Z-AI and Anthropic with OAuth token.",
)}
</p>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-foreground"
>${i18n("Use CORS Proxy")}</span
>
${Switch({
checked: this.proxyEnabled,
onChange: (checked: boolean) => {
this.proxyEnabled = checked;
this.saveProxySettings();
},
})}
</div>
<div class="space-y-2">
${Label({ children: i18n("Proxy URL") })}
${Input({
type: "text",
value: this.proxyUrl,
disabled: !this.proxyEnabled,
onInput: (e) => {
this.proxyUrl = (e.target as HTMLInputElement).value;
},
onChange: () => this.saveProxySettings(),
})}
<p class="text-xs text-muted-foreground">
${i18n(
"Format: The proxy must accept requests as <proxy-url>/?url=<target-url>",
)}
</p>
</div>
</div>
`;
}
}
@customElement("settings-dialog")
export class SettingsDialog extends LitElement {
@property({ type: Array, attribute: false }) tabs: SettingsTab[] = [];
@state() private isOpen = false;
@state() private activeTabIndex = 0;
protected createRenderRoot() {
return this;
}
static async open(tabs: SettingsTab[]) {
const dialog = new SettingsDialog();
dialog.tabs = tabs;
dialog.isOpen = true;
document.body.appendChild(dialog);
}
private setActiveTab(index: number) {
this.activeTabIndex = index;
}
private renderSidebarItem(tab: SettingsTab, index: number): TemplateResult {
const isActive = this.activeTabIndex === index;
return html`
<button
class="w-full text-left px-4 py-3 rounded-md transition-colors ${isActive
? "bg-secondary text-foreground font-medium"
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground"}"
@click=${() => this.setActiveTab(index)}
>
${tab.getTabName()}
</button>
`;
}
private renderMobileTab(tab: SettingsTab, index: number): TemplateResult {
const isActive = this.activeTabIndex === index;
return html`
<button
class="px-3 py-2 text-sm font-medium transition-colors ${isActive
? "border-b-2 border-primary text-foreground"
: "text-muted-foreground hover:text-foreground"}"
@click=${() => this.setActiveTab(index)}
>
${tab.getTabName()}
</button>
`;
}
render() {
if (this.tabs.length === 0) {
return html``;
}
return Dialog({
isOpen: this.isOpen,
onClose: () => {
this.isOpen = false;
this.remove();
},
width: "min(1000px, 90vw)",
height: "min(800px, 90vh)",
backdropClassName: "bg-black/50 backdrop-blur-sm",
children: html`
${DialogContent({
className: "h-full p-6",
children: html`
<div class="flex flex-col h-full overflow-hidden">
<!-- Header -->
<div class="pb-4 flex-shrink-0">
${DialogHeader({ title: i18n("Settings") })}
</div>
<!-- Mobile Tabs -->
<div class="md:hidden flex flex-shrink-0 pb-4">
${this.tabs.map((tab, index) =>
this.renderMobileTab(tab, index),
)}
</div>
<!-- Layout -->
<div class="flex flex-1 overflow-hidden">
<!-- Sidebar (desktop only) -->
<div class="hidden md:block w-64 flex-shrink-0 space-y-1">
${this.tabs.map((tab, index) =>
this.renderSidebarItem(tab, index),
)}
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto md:pl-6">
${this.tabs.map(
(tab, index) =>
html`<div
style="display: ${this.activeTabIndex === index
? "block"
: "none"}"
>
${tab}
</div>`,
)}
</div>
</div>
</div>
`,
})}
`,
});
}
}

View file

@ -1,167 +0,0 @@
// Main chat interface
export type {
Agent,
AgentMessage,
AgentState,
ThinkingLevel,
} from "@mariozechner/pi-agent-core";
export type { Model } from "@mariozechner/pi-ai";
export { ChatPanel } from "./ChatPanel.js";
// Components
export { AgentInterface } from "./components/AgentInterface.js";
export { AttachmentTile } from "./components/AttachmentTile.js";
export { ConsoleBlock } from "./components/ConsoleBlock.js";
export { CustomProviderCard } from "./components/CustomProviderCard.js";
export { ExpandableSection } from "./components/ExpandableSection.js";
export { Input } from "./components/Input.js";
export { MessageEditor } from "./components/MessageEditor.js";
export { MessageList } from "./components/MessageList.js";
// Message components
export type {
ArtifactMessage,
UserMessageWithAttachments,
} from "./components/Messages.js";
export {
AbortedMessage,
AssistantMessage,
convertAttachments,
defaultConvertToLlm,
isArtifactMessage,
isUserMessageWithAttachments,
ToolMessage,
ToolMessageDebugView,
UserMessage,
} from "./components/Messages.js";
// Message renderer registry
export {
getMessageRenderer,
type MessageRenderer,
type MessageRole,
registerMessageRenderer,
renderMessage,
} from "./components/message-renderer-registry.js";
export { ProviderKeyInput } from "./components/ProviderKeyInput.js";
export {
type SandboxFile,
SandboxIframe,
type SandboxResult,
type SandboxUrlProvider,
} from "./components/SandboxedIframe.js";
export { StreamingMessageContainer } from "./components/StreamingMessageContainer.js";
// Sandbox Runtime Providers
export { ArtifactsRuntimeProvider } from "./components/sandbox/ArtifactsRuntimeProvider.js";
export { AttachmentsRuntimeProvider } from "./components/sandbox/AttachmentsRuntimeProvider.js";
export {
type ConsoleLog,
ConsoleRuntimeProvider,
} from "./components/sandbox/ConsoleRuntimeProvider.js";
export {
type DownloadableFile,
FileDownloadRuntimeProvider,
} from "./components/sandbox/FileDownloadRuntimeProvider.js";
export { RuntimeMessageBridge } from "./components/sandbox/RuntimeMessageBridge.js";
export { RUNTIME_MESSAGE_ROUTER } from "./components/sandbox/RuntimeMessageRouter.js";
export type { SandboxRuntimeProvider } from "./components/sandbox/SandboxRuntimeProvider.js";
export { ThinkingBlock } from "./components/ThinkingBlock.js";
export { ApiKeyPromptDialog } from "./dialogs/ApiKeyPromptDialog.js";
export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js";
// Dialogs
export { ModelSelector } from "./dialogs/ModelSelector.js";
export { PersistentStorageDialog } from "./dialogs/PersistentStorageDialog.js";
export { ProvidersModelsTab } from "./dialogs/ProvidersModelsTab.js";
export { SessionListDialog } from "./dialogs/SessionListDialog.js";
export {
ApiKeysTab,
ProxyTab,
SettingsDialog,
SettingsTab,
} from "./dialogs/SettingsDialog.js";
// Prompts
export {
ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO,
ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW,
ATTACHMENTS_RUNTIME_DESCRIPTION,
} from "./prompts/prompts.js";
// Storage
export {
AppStorage,
getAppStorage,
setAppStorage,
} from "./storage/app-storage.js";
export { IndexedDBStorageBackend } from "./storage/backends/indexeddb-storage-backend.js";
export { Store } from "./storage/store.js";
export type {
AutoDiscoveryProviderType,
CustomProvider,
CustomProviderType,
} from "./storage/stores/custom-providers-store.js";
export { CustomProvidersStore } from "./storage/stores/custom-providers-store.js";
export { ProviderKeysStore } from "./storage/stores/provider-keys-store.js";
export { SessionsStore } from "./storage/stores/sessions-store.js";
export { SettingsStore } from "./storage/stores/settings-store.js";
export type {
IndexConfig,
IndexedDBConfig,
SessionData,
SessionMetadata,
StorageBackend,
StorageTransaction,
StoreConfig,
} from "./storage/types.js";
// Artifacts
export { ArtifactElement } from "./tools/artifacts/ArtifactElement.js";
export { ArtifactPill } from "./tools/artifacts/ArtifactPill.js";
export {
type Artifact,
ArtifactsPanel,
type ArtifactsParams,
} from "./tools/artifacts/artifacts.js";
export { ArtifactsToolRenderer } from "./tools/artifacts/artifacts-tool-renderer.js";
export { HtmlArtifact } from "./tools/artifacts/HtmlArtifact.js";
export { ImageArtifact } from "./tools/artifacts/ImageArtifact.js";
export { MarkdownArtifact } from "./tools/artifacts/MarkdownArtifact.js";
export { SvgArtifact } from "./tools/artifacts/SvgArtifact.js";
export { TextArtifact } from "./tools/artifacts/TextArtifact.js";
export {
createExtractDocumentTool,
extractDocumentTool,
} from "./tools/extract-document.js";
// Tools
export {
getToolRenderer,
registerToolRenderer,
renderTool,
setShowJsonMode,
} from "./tools/index.js";
export {
createJavaScriptReplTool,
javascriptReplTool,
} from "./tools/javascript-repl.js";
export {
renderCollapsibleHeader,
renderHeader,
} from "./tools/renderer-registry.js";
export { BashRenderer } from "./tools/renderers/BashRenderer.js";
export { CalculateRenderer } from "./tools/renderers/CalculateRenderer.js";
// Tool renderers
export { DefaultRenderer } from "./tools/renderers/DefaultRenderer.js";
export { GetCurrentTimeRenderer } from "./tools/renderers/GetCurrentTimeRenderer.js";
export type { ToolRenderer, ToolRenderResult } from "./tools/types.js";
export type { Attachment } from "./utils/attachment-utils.js";
// Utils
export { loadAttachment } from "./utils/attachment-utils.js";
export { clearAuthToken, getAuthToken } from "./utils/auth-token.js";
export {
formatCost,
formatModelCost,
formatTokenCount,
formatUsage,
} from "./utils/format.js";
export { i18n, setLanguage, translations } from "./utils/i18n.js";
export {
applyProxyIfNeeded,
createStreamFn,
isCorsError,
shouldUseProxyForProvider,
} from "./utils/proxy-utils.js";

View file

@ -1,286 +0,0 @@
/**
* Centralized tool prompts/descriptions.
* Each prompt is either a string constant or a template function.
*/
// ============================================================================
// JavaScript REPL Tool
// ============================================================================
export const JAVASCRIPT_REPL_TOOL_DESCRIPTION = (
runtimeProviderDescriptions: string[],
) => `# JavaScript REPL
## Purpose
Execute JavaScript code in a sandboxed browser environment with full Web APIs.
## When to Use
- Quick calculations or data transformations
- Testing JavaScript code snippets in isolation
- Processing data with libraries (XLSX, CSV, etc.)
- Creating artifacts from data
## Environment
- ES2023+ JavaScript (async/await, optional chaining, nullish coalescing, etc.)
- All browser APIs: DOM, Canvas, WebGL, Fetch, Web Workers, WebSockets, Crypto, etc.
- Import any npm package: await import('https://esm.run/package-name')
## Common Libraries
- XLSX: const XLSX = await import('https://esm.run/xlsx');
- CSV: const Papa = (await import('https://esm.run/papaparse')).default;
- Chart.js: const Chart = (await import('https://esm.run/chart.js/auto')).default;
- Three.js: const THREE = await import('https://esm.run/three');
## Persistence between tool calls
- Objects stored on global scope do not persist between calls.
- Use artifacts as a key-value JSON object store:
- Use createOrUpdateArtifact(filename, content) to persist data between calls. JSON objects are auto-stringified.
- Use listArtifacts() and getArtifact(filename) to read persisted data. JSON files are auto-parsed to objects.
- Prefer to use a single artifact throughout the session to store intermediate data (e.g. 'data.json').
## Input
- You have access to the user's attachments via listAttachments(), readTextAttachment(id), and readBinaryAttachment(id)
- You have access to previously created artifacts via listArtifacts() and getArtifact(filename)
## Output
- All console.log() calls are captured for you to inspect. The user does not see these logs.
- Create artifacts for file results (images, JSON, CSV, etc.) which persiste throughout the
session and are accessible to you and the user.
## Example
const data = [10, 20, 15, 25];
const sum = data.reduce((a, b) => a + b, 0);
const avg = sum / data.length;
console.log('Sum:', sum, 'Average:', avg);
## Important Notes
- Graphics: Use fixed dimensions (800x600), NOT window.innerWidth/Height
- Chart.js: Set options: { responsive: false, animation: false }
- Three.js: renderer.setSize(800, 600) with matching aspect ratio
## Helper Functions (Automatically Available)
These functions are injected into the execution environment and available globally:
${runtimeProviderDescriptions.join("\n\n")}
`;
// ============================================================================
// Artifacts Tool
// ============================================================================
export const ARTIFACTS_TOOL_DESCRIPTION = (
runtimeProviderDescriptions: string[],
) => `# Artifacts
Create and manage persistent files that live alongside the conversation.
## When to Use - Artifacts Tool vs REPL
**Use artifacts tool when YOU are the author:**
- Writing research summaries, analysis, ideas, documentation
- Creating markdown notes for user to read
- Building HTML applications/visualizations that present data
- Creating HTML artifacts that render charts from programmatically generated data
**Use repl + artifact storage functions when CODE processes data:**
- Scraping workflows that extract and store data
- Processing CSV/Excel files programmatically
- Data transformation pipelines
- Binary file generation requiring libraries (PDF, DOCX)
**Pattern: REPL generates data Artifacts tool creates HTML that visualizes it**
Example: repl scrapes products stores products.json you author dashboard.html that reads products.json and renders Chart.js visualizations
## Input
- { action: "create", filename: "notes.md", content: "..." } - Create new file
- { action: "update", filename: "notes.md", old_str: "...", new_str: "..." } - Update part of file (PREFERRED)
- { action: "rewrite", filename: "notes.md", content: "..." } - Replace entire file (LAST RESORT)
- { action: "get", filename: "data.json" } - Retrieve file content
- { action: "delete", filename: "old.csv" } - Delete file
- { action: "htmlArtifactLogs", filename: "app.html" } - Get console logs from HTML artifact
## Returns
Depends on action:
- create/update/rewrite/delete: Success status or error
- get: File content
- htmlArtifactLogs: Console logs and errors
## Supported File Types
Text-based files you author: .md, .txt, .html, .js, .css, .json, .csv, .svg
Binary files requiring libraries (use repl): .pdf, .docx
## Critical - Prefer Update Over Rewrite
NEVER: get entire file + rewrite to change small sections
ALWAYS: update for targeted edits (token efficient)
Ask: Can I describe the change as old_str new_str? Use update.
---
## HTML Artifacts
Interactive HTML applications that can visualize data from other artifacts.
### Data Access
- Can read artifacts created by repl and user attachments
- Use to build dashboards, visualizations, interactive tools
- See Helper Functions section below for available functions
### Requirements
- Self-contained single file
- Import ES modules from esm.sh: <script type="module">import X from 'https://esm.sh/pkg';</script>
- Use Tailwind CDN: <script src="https://cdn.tailwindcss.com"></script>
- Can embed images from any domain: <img src="https://example.com/image.jpg">
- MUST set background color explicitly (avoid transparent)
- Inline CSS or Tailwind utility classes
- No localStorage/sessionStorage
### Styling
- Use Tailwind utility classes for clean, functional designs
- Ensure responsive layout (iframe may be resized)
- Avoid purple gradients, AI aesthetic clichés, and emojis
### Helper Functions (Automatically Available)
These functions are injected into HTML artifact sandbox:
${runtimeProviderDescriptions.join("\n\n")}
`;
// ============================================================================
// Artifacts Runtime Provider
// ============================================================================
export const ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW = `
### Artifacts Storage
Create, read, update, and delete files in artifacts storage.
#### When to Use
- Store intermediate results between tool calls
- Save generated files (images, CSVs, processed data) for user to view and download
#### Do NOT Use For
- Content you author directly, like summaries of content you read (use artifacts tool instead)
#### Functions
- listArtifacts() - List all artifact filenames, returns Promise<string[]>
- getArtifact(filename) - Read artifact content, returns Promise<string | object>. JSON files auto-parse to objects, binary files return base64 string
- createOrUpdateArtifact(filename, content, mimeType?) - Create or update artifact, returns Promise<void>. JSON files auto-stringify objects, binary requires base64 string with mimeType
- deleteArtifact(filename) - Delete artifact, returns Promise<void>
#### Example
JSON workflow:
\`\`\`javascript
// Fetch and save
const response = await fetch('https://api.example.com/products');
const products = await response.json();
await createOrUpdateArtifact('products.json', products);
// Later: read and filter
const all = await getArtifact('products.json');
const cheap = all.filter(p => p.price < 100);
await createOrUpdateArtifact('cheap.json', cheap);
\`\`\`
Binary file (image):
\`\`\`javascript
const canvas = document.createElement('canvas');
canvas.width = 800; canvas.height = 600;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'blue';
ctx.fillRect(0, 0, 800, 600);
// Remove data:image/png;base64, prefix
const base64 = canvas.toDataURL().split(',')[1];
await createOrUpdateArtifact('chart.png', base64, 'image/png');
\`\`\`
`;
export const ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO = `
### Artifacts Storage
Read files from artifacts storage.
#### When to Use
- Read artifacts created by REPL or artifacts tool
- Access data from other HTML artifacts
- Load configuration or data files
#### Do NOT Use For
- Creating new artifacts (not available in HTML artifacts)
- Modifying artifacts (read-only access)
#### Functions
- listArtifacts() - List all artifact filenames, returns Promise<string[]>
- getArtifact(filename) - Read artifact content, returns Promise<string | object>. JSON files auto-parse to objects, binary files return base64 string
#### Example
JSON data:
\`\`\`javascript
const products = await getArtifact('products.json');
const html = products.map(p => \`<div>\${p.name}: $\${p.price}</div>\`).join('');
document.body.innerHTML = html;
\`\`\`
Binary image:
\`\`\`javascript
const base64 = await getArtifact('chart.png');
const img = document.createElement('img');
img.src = 'data:image/png;base64,' + base64;
document.body.appendChild(img);
\`\`\`
`;
// ============================================================================
// Attachments Runtime Provider
// ============================================================================
export const ATTACHMENTS_RUNTIME_DESCRIPTION = `
### User Attachments
Read files the user uploaded to the conversation.
#### When to Use
- Process user-uploaded files (CSV, JSON, Excel, images, PDFs)
#### Functions
- listAttachments() - List all attachments, returns array of {id, fileName, mimeType, size}
- readTextAttachment(id) - Read attachment as text, returns string
- readBinaryAttachment(id) - Read attachment as binary data, returns Uint8Array
#### Example
CSV file:
\`\`\`javascript
const files = listAttachments();
const csvFile = files.find(f => f.fileName.endsWith('.csv'));
const csvData = readTextAttachment(csvFile.id);
const rows = csvData.split('\\n').map(row => row.split(','));
\`\`\`
Excel file:
\`\`\`javascript
const XLSX = await import('https://esm.run/xlsx');
const files = listAttachments();
const xlsxFile = files.find(f => f.fileName.endsWith('.xlsx'));
const bytes = readBinaryAttachment(xlsxFile.id);
const workbook = XLSX.read(bytes);
const data = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]]);
\`\`\`
`;
// ============================================================================
// Extract Document Tool
// ============================================================================
export const EXTRACT_DOCUMENT_DESCRIPTION = `# Extract Document
Extract plain text from documents on the web (PDF, DOCX, XLSX, PPTX).
## When to Use
User wants you to read a document at a URL.
## Input
- { url: "https://example.com/document.pdf" } - URL to PDF, DOCX, XLSX, or PPTX
## Returns
Structured plain text with page/sheet/slide delimiters.`;

View file

@ -1,64 +0,0 @@
import type { CustomProvidersStore } from "./stores/custom-providers-store.js";
import type { ProviderKeysStore } from "./stores/provider-keys-store.js";
import type { SessionsStore } from "./stores/sessions-store.js";
import type { SettingsStore } from "./stores/settings-store.js";
import type { StorageBackend } from "./types.js";
/**
* High-level storage API providing access to all storage operations.
* Subclasses can extend this to add domain-specific stores.
*/
export class AppStorage {
readonly backend: StorageBackend;
readonly settings: SettingsStore;
readonly providerKeys: ProviderKeysStore;
readonly sessions: SessionsStore;
readonly customProviders: CustomProvidersStore;
constructor(
settings: SettingsStore,
providerKeys: ProviderKeysStore,
sessions: SessionsStore,
customProviders: CustomProvidersStore,
backend: StorageBackend,
) {
this.settings = settings;
this.providerKeys = providerKeys;
this.sessions = sessions;
this.customProviders = customProviders;
this.backend = backend;
}
async getQuotaInfo(): Promise<{
usage: number;
quota: number;
percent: number;
}> {
return this.backend.getQuotaInfo();
}
async requestPersistence(): Promise<boolean> {
return this.backend.requestPersistence();
}
}
// Global instance management
let globalAppStorage: AppStorage | null = null;
/**
* Get the global AppStorage instance.
* Throws if not initialized.
*/
export function getAppStorage(): AppStorage {
if (!globalAppStorage) {
throw new Error("AppStorage not initialized. Call setAppStorage() first.");
}
return globalAppStorage;
}
/**
* Set the global AppStorage instance.
*/
export function setAppStorage(storage: AppStorage): void {
globalAppStorage = storage;
}

View file

@ -1,210 +0,0 @@
import type {
IndexedDBConfig,
StorageBackend,
StorageTransaction,
} from "../types.js";
/**
* IndexedDB implementation of StorageBackend.
* Provides multi-store key-value storage with transactions and quota management.
*/
export class IndexedDBStorageBackend implements StorageBackend {
private dbPromise: Promise<IDBDatabase> | null = null;
constructor(private config: IndexedDBConfig) {}
private async getDB(): Promise<IDBDatabase> {
if (!this.dbPromise) {
this.dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(this.config.dbName, this.config.version);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (_event) => {
const db = request.result;
// Create object stores from config
for (const storeConfig of this.config.stores) {
if (!db.objectStoreNames.contains(storeConfig.name)) {
const store = db.createObjectStore(storeConfig.name, {
keyPath: storeConfig.keyPath,
autoIncrement: storeConfig.autoIncrement,
});
// Create indices
if (storeConfig.indices) {
for (const indexConfig of storeConfig.indices) {
store.createIndex(indexConfig.name, indexConfig.keyPath, {
unique: indexConfig.unique,
});
}
}
}
}
};
});
}
return this.dbPromise;
}
private promisifyRequest<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async get<T = unknown>(storeName: string, key: string): Promise<T | null> {
const db = await this.getDB();
const tx = db.transaction(storeName, "readonly");
const store = tx.objectStore(storeName);
const result = await this.promisifyRequest(store.get(key));
return result ?? null;
}
async set<T = unknown>(
storeName: string,
key: string,
value: T,
): Promise<void> {
const db = await this.getDB();
const tx = db.transaction(storeName, "readwrite");
const store = tx.objectStore(storeName);
// If store has keyPath, only pass value (in-line key)
// Otherwise pass both value and key (out-of-line key)
if (store.keyPath) {
await this.promisifyRequest(store.put(value));
} else {
await this.promisifyRequest(store.put(value, key));
}
}
async delete(storeName: string, key: string): Promise<void> {
const db = await this.getDB();
const tx = db.transaction(storeName, "readwrite");
const store = tx.objectStore(storeName);
await this.promisifyRequest(store.delete(key));
}
async keys(storeName: string, prefix?: string): Promise<string[]> {
const db = await this.getDB();
const tx = db.transaction(storeName, "readonly");
const store = tx.objectStore(storeName);
if (prefix) {
// Use IDBKeyRange for efficient prefix filtering
const range = IDBKeyRange.bound(prefix, `${prefix}\uffff`, false, false);
const keys = await this.promisifyRequest(store.getAllKeys(range));
return keys.map((k) => String(k));
} else {
const keys = await this.promisifyRequest(store.getAllKeys());
return keys.map((k) => String(k));
}
}
async getAllFromIndex<T = unknown>(
storeName: string,
indexName: string,
direction: "asc" | "desc" = "asc",
): Promise<T[]> {
const db = await this.getDB();
const tx = db.transaction(storeName, "readonly");
const store = tx.objectStore(storeName);
const index = store.index(indexName);
return new Promise((resolve, reject) => {
const results: T[] = [];
const request = index.openCursor(
null,
direction === "desc" ? "prev" : "next",
);
request.onsuccess = () => {
const cursor = request.result;
if (cursor) {
results.push(cursor.value as T);
cursor.continue();
} else {
resolve(results);
}
};
request.onerror = () => reject(request.error);
});
}
async clear(storeName: string): Promise<void> {
const db = await this.getDB();
const tx = db.transaction(storeName, "readwrite");
const store = tx.objectStore(storeName);
await this.promisifyRequest(store.clear());
}
async has(storeName: string, key: string): Promise<boolean> {
const db = await this.getDB();
const tx = db.transaction(storeName, "readonly");
const store = tx.objectStore(storeName);
const result = await this.promisifyRequest(store.getKey(key));
return result !== undefined;
}
async transaction<T>(
storeNames: string[],
mode: "readonly" | "readwrite",
operation: (tx: StorageTransaction) => Promise<T>,
): Promise<T> {
const db = await this.getDB();
const idbTx = db.transaction(storeNames, mode);
const storageTx: StorageTransaction = {
get: async <T>(storeName: string, key: string) => {
const store = idbTx.objectStore(storeName);
const result = await this.promisifyRequest(store.get(key));
return (result ?? null) as T | null;
},
set: async <T>(storeName: string, key: string, value: T) => {
const store = idbTx.objectStore(storeName);
// If store has keyPath, only pass value (in-line key)
// Otherwise pass both value and key (out-of-line key)
if (store.keyPath) {
await this.promisifyRequest(store.put(value));
} else {
await this.promisifyRequest(store.put(value, key));
}
},
delete: async (storeName: string, key: string) => {
const store = idbTx.objectStore(storeName);
await this.promisifyRequest(store.delete(key));
},
};
return operation(storageTx);
}
async getQuotaInfo(): Promise<{
usage: number;
quota: number;
percent: number;
}> {
if (navigator.storage?.estimate) {
const estimate = await navigator.storage.estimate();
return {
usage: estimate.usage || 0,
quota: estimate.quota || 0,
percent: estimate.quota
? ((estimate.usage || 0) / estimate.quota) * 100
: 0,
};
}
return { usage: 0, quota: 0, percent: 0 };
}
async requestPersistence(): Promise<boolean> {
if (navigator.storage?.persist) {
return await navigator.storage.persist();
}
return false;
}
}

View file

@ -1,33 +0,0 @@
import type { StorageBackend, StoreConfig } from "./types.js";
/**
* Base class for all storage stores.
* Each store defines its IndexedDB schema and provides domain-specific methods.
*/
export abstract class Store {
private backend: StorageBackend | null = null;
/**
* Returns the IndexedDB configuration for this store.
* Defines store name, key path, and indices.
*/
abstract getConfig(): StoreConfig;
/**
* Sets the storage backend. Called by AppStorage after backend creation.
*/
setBackend(backend: StorageBackend): void {
this.backend = backend;
}
/**
* Gets the storage backend. Throws if backend not set.
* Concrete stores must use this to access the backend.
*/
protected getBackend(): StorageBackend {
if (!this.backend) {
throw new Error(`Backend not set on ${this.constructor.name}`);
}
return this.backend;
}
}

View file

@ -1,66 +0,0 @@
import type { Model } from "@mariozechner/pi-ai";
import { Store } from "../store.js";
import type { StoreConfig } from "../types.js";
export type AutoDiscoveryProviderType =
| "ollama"
| "llama.cpp"
| "vllm"
| "lmstudio";
export type CustomProviderType =
| AutoDiscoveryProviderType // Auto-discovery - models fetched on-demand
| "openai-completions" // Manual models - stored in provider.models
| "openai-responses" // Manual models - stored in provider.models
| "anthropic-messages"; // Manual models - stored in provider.models
export interface CustomProvider {
id: string; // UUID
name: string; // Display name, also used as Model.provider
type: CustomProviderType;
baseUrl: string;
apiKey?: string; // Optional, applies to all models
// For manual types ONLY - models stored directly on provider
// Auto-discovery types: models fetched on-demand, never stored
models?: Model<any>[];
}
/**
* Store for custom LLM providers (auto-discovery servers + manual providers).
*/
export class CustomProvidersStore extends Store {
getConfig(): StoreConfig {
return {
name: "custom-providers",
};
}
async get(id: string): Promise<CustomProvider | null> {
return this.getBackend().get("custom-providers", id);
}
async set(provider: CustomProvider): Promise<void> {
await this.getBackend().set("custom-providers", provider.id, provider);
}
async delete(id: string): Promise<void> {
await this.getBackend().delete("custom-providers", id);
}
async getAll(): Promise<CustomProvider[]> {
const keys = await this.getBackend().keys("custom-providers");
const providers: CustomProvider[] = [];
for (const key of keys) {
const provider = await this.get(key);
if (provider) {
providers.push(provider);
}
}
return providers;
}
async has(id: string): Promise<boolean> {
return this.getBackend().has("custom-providers", id);
}
}

View file

@ -1,33 +0,0 @@
import { Store } from "../store.js";
import type { StoreConfig } from "../types.js";
/**
* Store for LLM provider API keys (Anthropic, OpenAI, etc.).
*/
export class ProviderKeysStore extends Store {
getConfig(): StoreConfig {
return {
name: "provider-keys",
};
}
async get(provider: string): Promise<string | null> {
return this.getBackend().get("provider-keys", provider);
}
async set(provider: string, key: string): Promise<void> {
await this.getBackend().set("provider-keys", provider, key);
}
async delete(provider: string): Promise<void> {
await this.getBackend().delete("provider-keys", provider);
}
async list(): Promise<string[]> {
return this.getBackend().keys("provider-keys");
}
async has(provider: string): Promise<boolean> {
return this.getBackend().has("provider-keys", provider);
}
}

View file

@ -1,152 +0,0 @@
import type { AgentState } from "@mariozechner/pi-agent-core";
import { Store } from "../store.js";
import type { SessionData, SessionMetadata, StoreConfig } from "../types.js";
/**
* Store for chat sessions (data and metadata).
* Uses two object stores: sessions (full data) and sessions-metadata (lightweight).
*/
export class SessionsStore extends Store {
getConfig(): StoreConfig {
return {
name: "sessions",
keyPath: "id",
indices: [{ name: "lastModified", keyPath: "lastModified" }],
};
}
/**
* Additional config for sessions-metadata store.
* Must be included when creating the backend.
*/
static getMetadataConfig(): StoreConfig {
return {
name: "sessions-metadata",
keyPath: "id",
indices: [{ name: "lastModified", keyPath: "lastModified" }],
};
}
async save(data: SessionData, metadata: SessionMetadata): Promise<void> {
await this.getBackend().transaction(
["sessions", "sessions-metadata"],
"readwrite",
async (tx) => {
await tx.set("sessions", data.id, data);
await tx.set("sessions-metadata", metadata.id, metadata);
},
);
}
async get(id: string): Promise<SessionData | null> {
return this.getBackend().get("sessions", id);
}
async getMetadata(id: string): Promise<SessionMetadata | null> {
return this.getBackend().get("sessions-metadata", id);
}
async getAllMetadata(): Promise<SessionMetadata[]> {
// Use the lastModified index to get sessions sorted by most recent first
return this.getBackend().getAllFromIndex<SessionMetadata>(
"sessions-metadata",
"lastModified",
"desc",
);
}
async delete(id: string): Promise<void> {
await this.getBackend().transaction(
["sessions", "sessions-metadata"],
"readwrite",
async (tx) => {
await tx.delete("sessions", id);
await tx.delete("sessions-metadata", id);
},
);
}
// Alias for backward compatibility
async deleteSession(id: string): Promise<void> {
return this.delete(id);
}
async updateTitle(id: string, title: string): Promise<void> {
const metadata = await this.getMetadata(id);
if (metadata) {
metadata.title = title;
await this.getBackend().set("sessions-metadata", id, metadata);
}
// Also update in full session data
const data = await this.get(id);
if (data) {
data.title = title;
await this.getBackend().set("sessions", id, data);
}
}
async getQuotaInfo(): Promise<{
usage: number;
quota: number;
percent: number;
}> {
return this.getBackend().getQuotaInfo();
}
async requestPersistence(): Promise<boolean> {
return this.getBackend().requestPersistence();
}
// Alias methods for backward compatibility
async saveSession(
id: string,
state: AgentState,
metadata: SessionMetadata | undefined,
title?: string,
): Promise<void> {
// If metadata is provided, use it; otherwise create it from state
const meta: SessionMetadata = metadata || {
id,
title: title || "",
createdAt: new Date().toISOString(),
lastModified: new Date().toISOString(),
messageCount: state.messages?.length || 0,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
thinkingLevel: state.thinkingLevel || "off",
preview: "",
};
const data: SessionData = {
id,
title: title || meta.title,
model: state.model,
thinkingLevel: state.thinkingLevel,
messages: state.messages || [],
createdAt: meta.createdAt,
lastModified: new Date().toISOString(),
};
await this.save(data, meta);
}
async loadSession(id: string): Promise<SessionData | null> {
return this.get(id);
}
async getLatestSessionId(): Promise<string | null> {
const allMetadata = await this.getAllMetadata();
if (allMetadata.length === 0) return null;
// Sort by lastModified descending
allMetadata.sort((a, b) => b.lastModified.localeCompare(a.lastModified));
return allMetadata[0].id;
}
}

View file

@ -1,34 +0,0 @@
import { Store } from "../store.js";
import type { StoreConfig } from "../types.js";
/**
* Store for application settings (theme, proxy config, etc.).
*/
export class SettingsStore extends Store {
getConfig(): StoreConfig {
return {
name: "settings",
// No keyPath - uses out-of-line keys
};
}
async get<T>(key: string): Promise<T | null> {
return this.getBackend().get("settings", key);
}
async set<T>(key: string, value: T): Promise<void> {
await this.getBackend().set("settings", key, value);
}
async delete(key: string): Promise<void> {
await this.getBackend().delete("settings", key);
}
async list(): Promise<string[]> {
return this.getBackend().keys("settings");
}
async clear(): Promise<void> {
await this.getBackend().clear("settings");
}
}

View file

@ -1,210 +0,0 @@
import type { AgentMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai";
/**
* Transaction interface for atomic operations across stores.
*/
export interface StorageTransaction {
/**
* Get a value by key from a specific store.
*/
get<T = unknown>(storeName: string, key: string): Promise<T | null>;
/**
* Set a value for a key in a specific store.
*/
set<T = unknown>(storeName: string, key: string, value: T): Promise<void>;
/**
* Delete a key from a specific store.
*/
delete(storeName: string, key: string): Promise<void>;
}
/**
* Base interface for all storage backends.
* Multi-store key-value storage abstraction that can be implemented
* by IndexedDB, remote APIs, or any other multi-collection storage system.
*/
export interface StorageBackend {
/**
* Get a value by key from a specific store. Returns null if key doesn't exist.
*/
get<T = unknown>(storeName: string, key: string): Promise<T | null>;
/**
* Set a value for a key in a specific store.
*/
set<T = unknown>(storeName: string, key: string, value: T): Promise<void>;
/**
* Delete a key from a specific store.
*/
delete(storeName: string, key: string): Promise<void>;
/**
* Get all keys from a specific store, optionally filtered by prefix.
*/
keys(storeName: string, prefix?: string): Promise<string[]>;
/**
* Get all values from a specific store, ordered by an index.
* @param storeName - The store to query
* @param indexName - The index to use for ordering
* @param direction - Sort direction ("asc" or "desc")
*/
getAllFromIndex<T = unknown>(
storeName: string,
indexName: string,
direction?: "asc" | "desc",
): Promise<T[]>;
/**
* Clear all data from a specific store.
*/
clear(storeName: string): Promise<void>;
/**
* Check if a key exists in a specific store.
*/
has(storeName: string, key: string): Promise<boolean>;
/**
* Execute atomic operations across multiple stores.
*/
transaction<T>(
storeNames: string[],
mode: "readonly" | "readwrite",
operation: (tx: StorageTransaction) => Promise<T>,
): Promise<T>;
/**
* Get storage quota information.
* Used for warning users when approaching limits.
*/
getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }>;
/**
* Request persistent storage (prevents eviction).
* Returns true if granted, false otherwise.
*/
requestPersistence(): Promise<boolean>;
}
/**
* Lightweight session metadata for listing and searching.
* Stored separately from full session data for performance.
*/
export interface SessionMetadata {
/** Unique session identifier (UUID v4) */
id: string;
/** User-defined title or auto-generated from first message */
title: string;
/** ISO 8601 UTC timestamp of creation */
createdAt: string;
/** ISO 8601 UTC timestamp of last modification */
lastModified: string;
/** Total number of messages (user + assistant + tool results) */
messageCount: number;
/** Cumulative usage statistics */
usage: {
/** Total input tokens */
input: number;
/** Total output tokens */
output: number;
/** Total cache read tokens */
cacheRead: number;
/** Total cache write tokens */
cacheWrite: number;
/** Total tokens processed */
totalTokens: number;
/** Total cost breakdown */
cost: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
total: number;
};
};
/** Last used thinking level */
thinkingLevel: ThinkingLevel;
/**
* Preview text for search and display.
* First 2KB of conversation text (user + assistant messages in sequence).
* Tool calls and tool results are excluded.
*/
preview: string;
}
/**
* Full session data including all messages.
* Only loaded when user opens a specific session.
*/
export interface SessionData {
/** Unique session identifier (UUID v4) */
id: string;
/** User-defined title or auto-generated from first message */
title: string;
/** Last selected model */
model: Model<any>;
/** Last selected thinking level */
thinkingLevel: ThinkingLevel;
/** Full conversation history (with attachments inline) */
messages: AgentMessage[];
/** ISO 8601 UTC timestamp of creation */
createdAt: string;
/** ISO 8601 UTC timestamp of last modification */
lastModified: string;
}
/**
* Configuration for IndexedDB backend.
*/
export interface IndexedDBConfig {
/** Database name */
dbName: string;
/** Database version */
version: number;
/** Object stores to create */
stores: StoreConfig[];
}
/**
* Configuration for an IndexedDB object store.
*/
export interface StoreConfig {
/** Store name */
name: string;
/** Key path (optional, for auto-extracting keys from objects) */
keyPath?: string;
/** Auto-increment keys (optional) */
autoIncrement?: boolean;
/** Indices to create on this store */
indices?: IndexConfig[];
}
/**
* Configuration for an IndexedDB index.
*/
export interface IndexConfig {
/** Index name */
name: string;
/** Key path to index on */
keyPath: string;
/** Unique constraint (optional) */
unique?: boolean;
}

View file

@ -1,14 +0,0 @@
import { LitElement, type TemplateResult } from "lit";
export abstract class ArtifactElement extends LitElement {
public filename = "";
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this; // light DOM for shared styles
}
public abstract get content(): string;
public abstract set content(value: string);
abstract getHeaderButtons(): TemplateResult | HTMLElement;
}

View file

@ -1,29 +0,0 @@
import { icon } from "@mariozechner/mini-lit";
import { html, type TemplateResult } from "lit";
import { FileCode2 } from "lucide";
import type { ArtifactsPanel } from "./artifacts.js";
export function ArtifactPill(
filename: string,
artifactsPanel?: ArtifactsPanel,
): TemplateResult {
const handleClick = (e: Event) => {
if (!artifactsPanel) return;
e.preventDefault();
e.stopPropagation();
// openArtifact will show the artifact and call onOpen() to open the panel if needed
artifactsPanel.openArtifact(filename);
};
return html`
<span
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-muted/50 border border-border rounded ${artifactsPanel
? "cursor-pointer hover:bg-muted transition-colors"
: ""}"
@click=${artifactsPanel ? handleClick : null}
>
${icon(FileCode2, "sm")}
<span class="text-foreground">${filename}</span>
</span>
`;
}

View file

@ -1,106 +0,0 @@
import { icon } from "@mariozechner/mini-lit";
import "@mariozechner/mini-lit/dist/CopyButton.js";
import { html, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, type Ref, ref } from "lit/directives/ref.js";
import { repeat } from "lit/directives/repeat.js";
import { ChevronDown, ChevronRight, ChevronsDown, Lock } from "lucide";
import { i18n } from "../../utils/i18n.js";
interface LogEntry {
type: "log" | "error";
text: string;
}
@customElement("artifact-console")
export class Console extends LitElement {
@property({ attribute: false }) logs: LogEntry[] = [];
@state() private expanded = false;
@state() private autoscroll = true;
private logsContainerRef: Ref<HTMLDivElement> = createRef();
protected createRenderRoot() {
return this; // light DOM
}
override updated() {
// Autoscroll to bottom when new logs arrive
if (this.autoscroll && this.expanded && this.logsContainerRef.value) {
this.logsContainerRef.value.scrollTop =
this.logsContainerRef.value.scrollHeight;
}
}
private getLogsText(): string {
return this.logs.map((l) => `[${l.type}] ${l.text}`).join("\n");
}
override render(): TemplateResult {
const errorCount = this.logs.filter((l) => l.type === "error").length;
const summary =
errorCount > 0
? `${i18n("console")} (${errorCount} ${errorCount === 1 ? "error" : "errors"})`
: `${i18n("console")} (${this.logs.length})`;
return html`
<div class="border-t border-border p-2">
<div class="flex items-center gap-2 w-full">
<button
@click=${() => {
this.expanded = !this.expanded;
}}
class="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors flex-1 text-left"
>
${icon(this.expanded ? ChevronDown : ChevronRight, "sm")}
<span>${summary}</span>
</button>
${this.expanded
? html`
<button
@click=${() => {
this.autoscroll = !this.autoscroll;
}}
class="p-1 rounded transition-colors ${this.autoscroll
? "bg-accent text-accent-foreground"
: "hover:bg-muted"}"
title=${this.autoscroll
? i18n("Autoscroll enabled")
: i18n("Autoscroll disabled")}
>
${icon(this.autoscroll ? ChevronsDown : Lock, "sm")}
</button>
<copy-button
.text=${this.getLogsText()}
title=${i18n("Copy logs")}
.showText=${false}
class="!bg-transparent hover:!bg-accent"
></copy-button>
`
: ""}
</div>
${this.expanded
? html`
<div
class="max-h-48 overflow-y-auto space-y-1 mt-2"
${ref(this.logsContainerRef)}
>
${repeat(
this.logs,
(_log, index) => index,
(log) => html`
<div
class="text-xs font-mono ${log.type === "error"
? "text-destructive"
: "text-muted-foreground"}"
>
[${log.type}] ${log.text}
</div>
`,
)}
</div>
`
: ""}
</div>
`;
}
}

View file

@ -1,218 +0,0 @@
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
import { renderAsync } from "docx-preview";
import { html, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { i18n } from "../../utils/i18n.js";
import { ArtifactElement } from "./ArtifactElement.js";
@customElement("docx-artifact")
export class DocxArtifact extends ArtifactElement {
@property({ type: String }) private _content = "";
@state() private error: string | null = null;
get content(): string {
return this._content;
}
set content(value: string) {
this._content = value;
this.error = null;
this.requestUpdate();
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
this.style.height = "100%";
}
private base64ToArrayBuffer(base64: string): ArrayBuffer {
// Remove data URL prefix if present
let base64Data = base64;
if (base64.startsWith("data:")) {
const base64Match = base64.match(/base64,(.+)/);
if (base64Match) {
base64Data = base64Match[1];
}
}
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
private decodeBase64(): Uint8Array {
let base64Data = this._content;
if (this._content.startsWith("data:")) {
const base64Match = this._content.match(/base64,(.+)/);
if (base64Match) {
base64Data = base64Match[1];
}
}
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
public getHeaderButtons() {
return html`
<div class="flex items-center gap-1">
${DownloadButton({
content: this.decodeBase64(),
filename: this.filename,
mimeType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
title: i18n("Download"),
})}
</div>
`;
}
override async updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has("_content") && this._content && !this.error) {
await this.renderDocx();
}
}
private async renderDocx() {
const container = this.querySelector("#docx-container");
if (!container || !this._content) return;
try {
const arrayBuffer = this.base64ToArrayBuffer(this._content);
// Clear container first
container.innerHTML = "";
// Create a wrapper div for the document
const wrapper = document.createElement("div");
wrapper.className = "docx-wrapper-custom";
container.appendChild(wrapper);
// Render the DOCX file into the wrapper
await renderAsync(arrayBuffer, wrapper as HTMLElement, undefined, {
className: "docx",
inWrapper: true,
ignoreWidth: true,
ignoreHeight: false,
ignoreFonts: false,
breakPages: true,
ignoreLastRenderedPageBreak: true,
experimental: false,
trimXmlDeclaration: true,
useBase64URL: false,
renderHeaders: true,
renderFooters: true,
renderFootnotes: true,
renderEndnotes: true,
});
// Apply custom styles to match theme and fix sizing
const style = document.createElement("style");
style.textContent = `
#docx-container {
padding: 0;
}
#docx-container .docx-wrapper-custom {
max-width: 100%;
overflow-x: auto;
}
#docx-container .docx-wrapper {
max-width: 100% !important;
margin: 0 !important;
background: transparent !important;
padding: 0em !important;
}
#docx-container .docx-wrapper > section.docx {
box-shadow: none !important;
border: none !important;
border-radius: 0 !important;
margin: 0 !important;
padding: 2em !important;
background: white !important;
color: black !important;
max-width: 100% !important;
width: 100% !important;
min-width: 0 !important;
overflow-x: auto !important;
}
/* Fix tables and wide content */
#docx-container table {
max-width: 100% !important;
width: auto !important;
overflow-x: auto !important;
display: block !important;
}
#docx-container img {
max-width: 100% !important;
height: auto !important;
}
/* Fix paragraphs and text */
#docx-container p,
#docx-container span,
#docx-container div {
max-width: 100% !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
}
/* Hide page breaks in web view */
#docx-container .docx-page-break {
display: none !important;
}
`;
container.appendChild(style);
} catch (error: any) {
console.error("Error rendering DOCX:", error);
this.error = error?.message || i18n("Failed to load document");
}
}
override render(): TemplateResult {
if (this.error) {
return html`
<div class="h-full flex items-center justify-center bg-background p-4">
<div
class="bg-destructive/10 border border-destructive text-destructive p-4 rounded-lg max-w-2xl"
>
<div class="font-medium mb-1">
${i18n("Error loading document")}
</div>
<div class="text-sm opacity-90">${this.error}</div>
</div>
</div>
`;
}
return html`
<div class="h-full flex flex-col bg-background overflow-auto">
<div id="docx-container" class="flex-1 overflow-auto"></div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"docx-artifact": DocxArtifact;
}
}

View file

@ -1,243 +0,0 @@
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
import { html, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import * as XLSX from "xlsx";
import { i18n } from "../../utils/i18n.js";
import { ArtifactElement } from "./ArtifactElement.js";
@customElement("excel-artifact")
export class ExcelArtifact extends ArtifactElement {
@property({ type: String }) private _content = "";
@state() private error: string | null = null;
get content(): string {
return this._content;
}
set content(value: string) {
this._content = value;
this.error = null;
this.requestUpdate();
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
this.style.height = "100%";
}
private base64ToArrayBuffer(base64: string): ArrayBuffer {
// Remove data URL prefix if present
let base64Data = base64;
if (base64.startsWith("data:")) {
const base64Match = base64.match(/base64,(.+)/);
if (base64Match) {
base64Data = base64Match[1];
}
}
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
private decodeBase64(): Uint8Array {
let base64Data = this._content;
if (this._content.startsWith("data:")) {
const base64Match = this._content.match(/base64,(.+)/);
if (base64Match) {
base64Data = base64Match[1];
}
}
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
private getMimeType(): string {
const ext = this.filename.split(".").pop()?.toLowerCase();
if (ext === "xls") return "application/vnd.ms-excel";
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
}
public getHeaderButtons() {
return html`
<div class="flex items-center gap-1">
${DownloadButton({
content: this.decodeBase64(),
filename: this.filename,
mimeType: this.getMimeType(),
title: i18n("Download"),
})}
</div>
`;
}
override async updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has("_content") && this._content && !this.error) {
await this.renderExcel();
}
}
private async renderExcel() {
const container = this.querySelector("#excel-container");
if (!container || !this._content) return;
try {
const arrayBuffer = this.base64ToArrayBuffer(this._content);
const workbook = XLSX.read(arrayBuffer, { type: "array" });
container.innerHTML = "";
const wrapper = document.createElement("div");
wrapper.className = "overflow-auto h-full flex flex-col";
container.appendChild(wrapper);
// Create tabs for multiple sheets
if (workbook.SheetNames.length > 1) {
const tabContainer = document.createElement("div");
tabContainer.className =
"flex gap-2 mb-4 border-b border-border sticky top-0 bg-background z-10";
const sheetContents: HTMLElement[] = [];
workbook.SheetNames.forEach((sheetName, index) => {
// Create tab button
const tab = document.createElement("button");
tab.textContent = sheetName;
tab.className =
index === 0
? "px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary"
: "px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-b-2 hover:border-border transition-colors";
// Create sheet content
const sheetDiv = document.createElement("div");
sheetDiv.style.display = index === 0 ? "flex" : "none";
sheetDiv.className = "flex-1 overflow-auto";
sheetDiv.appendChild(
this.renderExcelSheet(workbook.Sheets[sheetName], sheetName),
);
sheetContents.push(sheetDiv);
// Tab click handler
tab.onclick = () => {
// Update tab styles
tabContainer.querySelectorAll("button").forEach((btn, btnIndex) => {
if (btnIndex === index) {
btn.className =
"px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary";
} else {
btn.className =
"px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-b-2 hover:border-border transition-colors";
}
});
// Show/hide sheets
sheetContents.forEach((content, contentIndex) => {
content.style.display = contentIndex === index ? "flex" : "none";
});
};
tabContainer.appendChild(tab);
});
wrapper.appendChild(tabContainer);
sheetContents.forEach((content) => {
wrapper.appendChild(content);
});
} else {
// Single sheet
const sheetName = workbook.SheetNames[0];
wrapper.appendChild(
this.renderExcelSheet(workbook.Sheets[sheetName], sheetName),
);
}
} catch (error: any) {
console.error("Error rendering Excel:", error);
this.error = error?.message || i18n("Failed to load spreadsheet");
}
}
private renderExcelSheet(worksheet: any, sheetName: string): HTMLElement {
const sheetDiv = document.createElement("div");
// Generate HTML table
const htmlTable = XLSX.utils.sheet_to_html(worksheet, {
id: `sheet-${sheetName}`,
});
const tempDiv = document.createElement("div");
tempDiv.innerHTML = htmlTable;
// Find and style the table
const table = tempDiv.querySelector("table");
if (table) {
table.className = "w-full border-collapse text-foreground";
// Style all cells
table.querySelectorAll("td, th").forEach((cell) => {
const cellEl = cell as HTMLElement;
cellEl.className = "border border-border px-3 py-2 text-sm text-left";
});
// Style header row
const headerCells = table.querySelectorAll("thead th, tr:first-child td");
if (headerCells.length > 0) {
headerCells.forEach((th) => {
const thEl = th as HTMLElement;
thEl.className =
"border border-border px-3 py-2 text-sm font-semibold bg-muted text-foreground sticky top-0";
});
}
// Alternate row colors
table.querySelectorAll("tbody tr:nth-child(even)").forEach((row) => {
const rowEl = row as HTMLElement;
rowEl.className = "bg-muted/30";
});
sheetDiv.appendChild(table);
}
return sheetDiv;
}
override render(): TemplateResult {
if (this.error) {
return html`
<div class="h-full flex items-center justify-center bg-background p-4">
<div
class="bg-destructive/10 border border-destructive text-destructive p-4 rounded-lg max-w-2xl"
>
<div class="font-medium mb-1">
${i18n("Error loading spreadsheet")}
</div>
<div class="text-sm opacity-90">${this.error}</div>
</div>
</div>
`;
}
return html`
<div class="h-full flex flex-col bg-background overflow-auto">
<div id="excel-container" class="flex-1 overflow-auto"></div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"excel-artifact": ExcelArtifact;
}
}

View file

@ -1,120 +0,0 @@
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
import { html, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { i18n } from "../../utils/i18n.js";
import { ArtifactElement } from "./ArtifactElement.js";
@customElement("generic-artifact")
export class GenericArtifact extends ArtifactElement {
@property({ type: String }) private _content = "";
get content(): string {
return this._content;
}
set content(value: string) {
this._content = value;
this.requestUpdate();
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
this.style.height = "100%";
}
private decodeBase64(): Uint8Array {
let base64Data = this._content;
if (this._content.startsWith("data:")) {
const base64Match = this._content.match(/base64,(.+)/);
if (base64Match) {
base64Data = base64Match[1];
}
}
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
private getMimeType(): string {
const ext = this.filename.split(".").pop()?.toLowerCase();
// Add common MIME types
const mimeTypes: Record<string, string> = {
pdf: "application/pdf",
zip: "application/zip",
tar: "application/x-tar",
gz: "application/gzip",
rar: "application/vnd.rar",
"7z": "application/x-7z-compressed",
mp3: "audio/mpeg",
mp4: "video/mp4",
avi: "video/x-msvideo",
mov: "video/quicktime",
wav: "audio/wav",
ogg: "audio/ogg",
json: "application/json",
xml: "application/xml",
bin: "application/octet-stream",
};
return mimeTypes[ext || ""] || "application/octet-stream";
}
public getHeaderButtons() {
return html`
<div class="flex items-center gap-1">
${DownloadButton({
content: this.decodeBase64(),
filename: this.filename,
mimeType: this.getMimeType(),
title: i18n("Download"),
})}
</div>
`;
}
override render(): TemplateResult {
return html`
<div class="h-full flex items-center justify-center bg-background p-8">
<div class="text-center max-w-md">
<div class="text-muted-foreground text-lg mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 mx-auto mb-4 text-muted-foreground/50"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
<div class="font-medium text-foreground mb-2">${this.filename}</div>
<p class="text-sm">
${i18n("Preview not available for this file type.")}
${i18n(
"Click the download button above to view it on your computer.",
)}
</p>
</div>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"generic-artifact": GenericArtifact;
}
}

View file

@ -1,232 +0,0 @@
import hljs from "highlight.js";
import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, type Ref, ref } from "lit/directives/ref.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { RefreshCw } from "lucide";
import type { SandboxIframe } from "../../components/SandboxedIframe.js";
import {
type MessageConsumer,
RUNTIME_MESSAGE_ROUTER,
} from "../../components/sandbox/RuntimeMessageRouter.js";
import type { SandboxRuntimeProvider } from "../../components/sandbox/SandboxRuntimeProvider.js";
import { i18n } from "../../utils/i18n.js";
import "../../components/SandboxedIframe.js";
import { ArtifactElement } from "./ArtifactElement.js";
import type { Console } from "./Console.js";
import "./Console.js";
import { icon } from "@mariozechner/mini-lit";
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import { CopyButton } from "@mariozechner/mini-lit/dist/CopyButton.js";
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
import { PreviewCodeToggle } from "@mariozechner/mini-lit/dist/PreviewCodeToggle.js";
@customElement("html-artifact")
export class HtmlArtifact extends ArtifactElement {
@property() override filename = "";
@property({ attribute: false }) runtimeProviders: SandboxRuntimeProvider[] =
[];
@property({ attribute: false }) sandboxUrlProvider?: () => string;
private _content = "";
private logs: Array<{ type: "log" | "error"; text: string }> = [];
// Refs for DOM elements
public sandboxIframeRef: Ref<SandboxIframe> = createRef();
private consoleRef: Ref<Console> = createRef();
@state() private viewMode: "preview" | "code" = "preview";
private setViewMode(mode: "preview" | "code") {
this.viewMode = mode;
}
public getHeaderButtons() {
const toggle = new PreviewCodeToggle();
toggle.mode = this.viewMode;
toggle.addEventListener("mode-change", (e: Event) => {
this.setViewMode((e as CustomEvent).detail);
});
const copyButton = new CopyButton();
copyButton.text = this._content;
copyButton.title = i18n("Copy HTML");
copyButton.showText = false;
// Generate standalone HTML with all runtime code injected for download
const sandbox = this.sandboxIframeRef.value;
const sandboxId = `artifact-${this.filename}`;
const downloadContent =
sandbox?.prepareHtmlDocument(
sandboxId,
this._content,
this.runtimeProviders || [],
{
isHtmlArtifact: true,
isStandalone: true, // Skip runtime bridge and navigation interceptor for standalone downloads
},
) || this._content;
return html`
<div class="flex items-center gap-2">
${toggle}
${Button({
variant: "ghost",
size: "sm",
onClick: () => {
this.logs = [];
this.executeContent(this._content);
},
title: i18n("Reload HTML"),
children: icon(RefreshCw, "sm"),
})}
${copyButton}
${DownloadButton({
content: downloadContent,
filename: this.filename,
mimeType: "text/html",
title: i18n("Download HTML"),
})}
</div>
`;
}
override set content(value: string) {
const oldValue = this._content;
this._content = value;
if (oldValue !== value) {
// Reset logs when content changes
this.logs = [];
this.requestUpdate();
// Execute content in sandbox if it exists
if (this.sandboxIframeRef.value && value) {
this.executeContent(value);
}
}
}
public executeContent(html: string) {
const sandbox = this.sandboxIframeRef.value;
if (!sandbox) return;
// Configure sandbox URL provider if provided (for browser extensions)
if (this.sandboxUrlProvider) {
sandbox.sandboxUrlProvider = this.sandboxUrlProvider;
}
const sandboxId = `artifact-${this.filename}`;
// Create consumer for console messages
const consumer: MessageConsumer = {
handleMessage: async (message: any): Promise<void> => {
if (message.type === "console") {
// Create new array reference for Lit reactivity
this.logs = [
...this.logs,
{
type: message.method === "error" ? "error" : "log",
text: message.text,
},
];
this.requestUpdate(); // Re-render to show console
}
},
};
// Inject window.complete() call at the end of the HTML to signal when page is loaded
// HTML artifacts don't time out - they call complete() when ready
let modifiedHtml = html;
if (modifiedHtml.includes("</html>")) {
modifiedHtml = modifiedHtml.replace(
"</html>",
"<script>if (window.complete) window.complete();</script></html>",
);
} else {
// If no closing </html> tag, append the script
modifiedHtml +=
"<script>if (window.complete) window.complete();</script>";
}
// Load content - this handles sandbox registration, consumer registration, and iframe creation
sandbox.loadContent(sandboxId, modifiedHtml, this.runtimeProviders, [
consumer,
]);
}
override get content(): string {
return this._content;
}
override disconnectedCallback() {
super.disconnectedCallback();
// Unregister sandbox when element is removed from DOM
const sandboxId = `artifact-${this.filename}`;
RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
}
override firstUpdated() {
// Execute initial content
if (this._content && this.sandboxIframeRef.value) {
this.executeContent(this._content);
}
}
override updated(changedProperties: Map<string | number | symbol, unknown>) {
super.updated(changedProperties);
// If we have content but haven't executed yet (e.g., during reconstruction),
// execute when the iframe ref becomes available
if (
this._content &&
this.sandboxIframeRef.value &&
this.logs.length === 0
) {
this.executeContent(this._content);
}
}
public getLogs(): string {
if (this.logs.length === 0)
return i18n("No logs for {filename}").replace(
"{filename}",
this.filename,
);
return this.logs.map((l) => `[${l.type}] ${l.text}`).join("\n");
}
override render() {
return html`
<div class="h-full flex flex-col">
<div class="flex-1 overflow-hidden relative">
<!-- Preview container - always in DOM, just hidden when not active -->
<div
class="absolute inset-0 flex flex-col"
style="display: ${this.viewMode === "preview" ? "flex" : "none"}"
>
<sandbox-iframe
class="flex-1"
${ref(this.sandboxIframeRef)}
></sandbox-iframe>
${this.logs.length > 0
? html`<artifact-console
.logs=${this.logs}
${ref(this.consoleRef)}
></artifact-console>`
: ""}
</div>
<!-- Code view - always in DOM, just hidden when not active -->
<div
class="absolute inset-0 overflow-auto bg-background"
style="display: ${this.viewMode === "code" ? "block" : "none"}"
>
<pre
class="m-0 p-4 text-xs"
><code class="hljs language-html">${unsafeHTML(
hljs.highlight(this._content, { language: "html" }).value,
)}</code></pre>
</div>
</div>
</div>
`;
}
}

View file

@ -1,116 +0,0 @@
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
import { html, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { i18n } from "../../utils/i18n.js";
import { ArtifactElement } from "./ArtifactElement.js";
@customElement("image-artifact")
export class ImageArtifact extends ArtifactElement {
@property({ type: String }) private _content = "";
get content(): string {
return this._content;
}
set content(value: string) {
this._content = value;
this.requestUpdate();
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
this.style.height = "100%";
}
private getMimeType(): string {
const ext = this.filename.split(".").pop()?.toLowerCase();
if (ext === "jpg" || ext === "jpeg") return "image/jpeg";
if (ext === "gif") return "image/gif";
if (ext === "webp") return "image/webp";
if (ext === "svg") return "image/svg+xml";
if (ext === "bmp") return "image/bmp";
if (ext === "ico") return "image/x-icon";
return "image/png";
}
private getImageUrl(): string {
// If content is already a data URL, use it directly
if (this._content.startsWith("data:")) {
return this._content;
}
// Otherwise assume it's base64 and construct data URL
return `data:${this.getMimeType()};base64,${this._content}`;
}
private decodeBase64(): Uint8Array {
let base64Data: string;
// If content is a data URL, extract the base64 part
if (this._content.startsWith("data:")) {
const base64Match = this._content.match(/base64,(.+)/);
if (base64Match) {
base64Data = base64Match[1];
} else {
// Not a base64 data URL, return empty
return new Uint8Array(0);
}
} else {
// Otherwise use content as-is
base64Data = this._content;
}
// Decode base64 to binary string
const binaryString = atob(base64Data);
// Convert binary string to Uint8Array
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
public getHeaderButtons() {
return html`
<div class="flex items-center gap-1">
${DownloadButton({
content: this.decodeBase64(),
filename: this.filename,
mimeType: this.getMimeType(),
title: i18n("Download"),
})}
</div>
`;
}
override render(): TemplateResult {
return html`
<div class="h-full flex flex-col bg-background overflow-auto">
<div class="flex-1 flex items-center justify-center p-4">
<img
src="${this.getImageUrl()}"
alt="${this.filename}"
class="max-w-full max-h-full object-contain"
@error=${(e: Event) => {
const target = e.target as HTMLImageElement;
target.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext x='50' y='50' text-anchor='middle' dominant-baseline='middle' fill='%23999'%3EImage Error%3C/text%3E%3C/svg%3E";
}}
/>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"image-artifact": ImageArtifact;
}
}

View file

@ -1,86 +0,0 @@
import hljs from "highlight.js";
import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { i18n } from "../../utils/i18n.js";
import "@mariozechner/mini-lit/dist/MarkdownBlock.js";
import { CopyButton } from "@mariozechner/mini-lit/dist/CopyButton.js";
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
import { PreviewCodeToggle } from "@mariozechner/mini-lit/dist/PreviewCodeToggle.js";
import { ArtifactElement } from "./ArtifactElement.js";
@customElement("markdown-artifact")
export class MarkdownArtifact extends ArtifactElement {
@property() override filename = "";
private _content = "";
override get content(): string {
return this._content;
}
override set content(value: string) {
this._content = value;
this.requestUpdate();
}
@state() private viewMode: "preview" | "code" = "preview";
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this; // light DOM
}
private setViewMode(mode: "preview" | "code") {
this.viewMode = mode;
}
public getHeaderButtons() {
const toggle = new PreviewCodeToggle();
toggle.mode = this.viewMode;
toggle.addEventListener("mode-change", (e: Event) => {
this.setViewMode((e as CustomEvent).detail);
});
const copyButton = new CopyButton();
copyButton.text = this._content;
copyButton.title = i18n("Copy Markdown");
copyButton.showText = false;
return html`
<div class="flex items-center gap-2">
${toggle} ${copyButton}
${DownloadButton({
content: this._content,
filename: this.filename,
mimeType: "text/markdown",
title: i18n("Download Markdown"),
})}
</div>
`;
}
override render() {
return html`
<div class="h-full flex flex-col">
<div class="flex-1 overflow-auto">
${this.viewMode === "preview"
? html`<div class="p-4">
<markdown-block .content=${this.content}></markdown-block>
</div>`
: html`<pre
class="m-0 p-4 text-xs whitespace-pre-wrap break-words"
><code class="hljs language-markdown">${unsafeHTML(
hljs.highlight(this.content, {
language: "markdown",
ignoreIllegals: true,
}).value,
)}</code></pre>`}
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"markdown-artifact": MarkdownArtifact;
}
}

View file

@ -1,207 +0,0 @@
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
import { html, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import * as pdfjsLib from "pdfjs-dist";
import { i18n } from "../../utils/i18n.js";
import { ArtifactElement } from "./ArtifactElement.js";
// Configure PDF.js worker
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
@customElement("pdf-artifact")
export class PdfArtifact extends ArtifactElement {
@property({ type: String }) private _content = "";
@state() private error: string | null = null;
private currentLoadingTask: any = null;
get content(): string {
return this._content;
}
set content(value: string) {
this._content = value;
this.error = null;
this.requestUpdate();
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
this.style.height = "100%";
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.cleanup();
}
private cleanup() {
if (this.currentLoadingTask) {
this.currentLoadingTask.destroy();
this.currentLoadingTask = null;
}
}
private base64ToArrayBuffer(base64: string): ArrayBuffer {
// Remove data URL prefix if present
let base64Data = base64;
if (base64.startsWith("data:")) {
const base64Match = base64.match(/base64,(.+)/);
if (base64Match) {
base64Data = base64Match[1];
}
}
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
private decodeBase64(): Uint8Array {
let base64Data = this._content;
if (this._content.startsWith("data:")) {
const base64Match = this._content.match(/base64,(.+)/);
if (base64Match) {
base64Data = base64Match[1];
}
}
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
public getHeaderButtons() {
return html`
<div class="flex items-center gap-1">
${DownloadButton({
content: this.decodeBase64(),
filename: this.filename,
mimeType: "application/pdf",
title: i18n("Download"),
})}
</div>
`;
}
override async updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has("_content") && this._content && !this.error) {
await this.renderPdf();
}
}
private async renderPdf() {
const container = this.querySelector("#pdf-container");
if (!container || !this._content) return;
let pdf: any = null;
try {
const arrayBuffer = this.base64ToArrayBuffer(this._content);
// Cancel any existing loading task
if (this.currentLoadingTask) {
this.currentLoadingTask.destroy();
}
// Load the PDF
this.currentLoadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
pdf = await this.currentLoadingTask.promise;
this.currentLoadingTask = null;
// Clear container
container.innerHTML = "";
const wrapper = document.createElement("div");
wrapper.className = "p-4";
container.appendChild(wrapper);
// Render all pages
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
const page = await pdf.getPage(pageNum);
const pageContainer = document.createElement("div");
pageContainer.className = "mb-4 last:mb-0";
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
const viewport = page.getViewport({ scale: 1.5 });
canvas.height = viewport.height;
canvas.width = viewport.width;
canvas.className =
"w-full max-w-full h-auto block mx-auto bg-white rounded shadow-sm border border-border";
if (context) {
context.fillStyle = "white";
context.fillRect(0, 0, canvas.width, canvas.height);
}
await page.render({
canvasContext: context!,
viewport: viewport,
canvas: canvas,
}).promise;
pageContainer.appendChild(canvas);
if (pageNum < pdf.numPages) {
const separator = document.createElement("div");
separator.className = "h-px bg-border my-4";
pageContainer.appendChild(separator);
}
wrapper.appendChild(pageContainer);
}
} catch (error: any) {
console.error("Error rendering PDF:", error);
this.error = error?.message || i18n("Failed to load PDF");
} finally {
if (pdf) {
pdf.destroy();
}
}
}
override render(): TemplateResult {
if (this.error) {
return html`
<div class="h-full flex items-center justify-center bg-background p-4">
<div
class="bg-destructive/10 border border-destructive text-destructive p-4 rounded-lg max-w-2xl"
>
<div class="font-medium mb-1">${i18n("Error loading PDF")}</div>
<div class="text-sm opacity-90">${this.error}</div>
</div>
</div>
`;
}
return html`
<div class="h-full flex flex-col bg-background overflow-auto">
<div id="pdf-container" class="flex-1 overflow-auto"></div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"pdf-artifact": PdfArtifact;
}
}

View file

@ -1,90 +0,0 @@
import { CopyButton } from "@mariozechner/mini-lit/dist/CopyButton.js";
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
import { PreviewCodeToggle } from "@mariozechner/mini-lit/dist/PreviewCodeToggle.js";
import hljs from "highlight.js";
import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { i18n } from "../../utils/i18n.js";
import { ArtifactElement } from "./ArtifactElement.js";
@customElement("svg-artifact")
export class SvgArtifact extends ArtifactElement {
@property() override filename = "";
private _content = "";
override get content(): string {
return this._content;
}
override set content(value: string) {
this._content = value;
this.requestUpdate();
}
@state() private viewMode: "preview" | "code" = "preview";
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this; // light DOM
}
private setViewMode(mode: "preview" | "code") {
this.viewMode = mode;
}
public getHeaderButtons() {
const toggle = new PreviewCodeToggle();
toggle.mode = this.viewMode;
toggle.addEventListener("mode-change", (e: Event) => {
this.setViewMode((e as CustomEvent).detail);
});
const copyButton = new CopyButton();
copyButton.text = this._content;
copyButton.title = i18n("Copy SVG");
copyButton.showText = false;
return html`
<div class="flex items-center gap-2">
${toggle} ${copyButton}
${DownloadButton({
content: this._content,
filename: this.filename,
mimeType: "image/svg+xml",
title: i18n("Download SVG"),
})}
</div>
`;
}
override render() {
return html`
<div class="h-full flex flex-col">
<div class="flex-1 overflow-auto">
${this.viewMode === "preview"
? html`<div class="h-full flex items-center justify-center">
${unsafeHTML(
this.content.replace(
/<svg(\s|>)/i,
(_m, p1) => `<svg class="w-full h-full"${p1}`,
),
)}
</div>`
: html`<pre
class="m-0 p-4 text-xs"
><code class="hljs language-xml">${unsafeHTML(
hljs.highlight(this.content, {
language: "xml",
ignoreIllegals: true,
}).value,
)}</code></pre>`}
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"svg-artifact": SvgArtifact;
}
}

View file

@ -1,150 +0,0 @@
import { CopyButton } from "@mariozechner/mini-lit/dist/CopyButton.js";
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
import hljs from "highlight.js";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { i18n } from "../../utils/i18n.js";
import { ArtifactElement } from "./ArtifactElement.js";
// Known code file extensions for highlighting
const CODE_EXTENSIONS = [
"js",
"javascript",
"ts",
"typescript",
"jsx",
"tsx",
"py",
"python",
"java",
"c",
"cpp",
"cs",
"php",
"rb",
"ruby",
"go",
"rust",
"swift",
"kotlin",
"scala",
"dart",
"html",
"css",
"scss",
"sass",
"less",
"json",
"xml",
"yaml",
"yml",
"toml",
"sql",
"sh",
"bash",
"ps1",
"bat",
"r",
"matlab",
"julia",
"lua",
"perl",
"vue",
"svelte",
];
@customElement("text-artifact")
export class TextArtifact extends ArtifactElement {
@property() override filename = "";
private _content = "";
override get content(): string {
return this._content;
}
override set content(value: string) {
this._content = value;
this.requestUpdate();
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this; // light DOM
}
private isCode(): boolean {
const ext = this.filename.split(".").pop()?.toLowerCase() || "";
return CODE_EXTENSIONS.includes(ext);
}
private getLanguageFromExtension(ext: string): string {
const languageMap: Record<string, string> = {
js: "javascript",
ts: "typescript",
py: "python",
rb: "ruby",
yml: "yaml",
ps1: "powershell",
bat: "batch",
};
return languageMap[ext] || ext;
}
private getMimeType(): string {
const ext = this.filename.split(".").pop()?.toLowerCase() || "";
if (ext === "svg") return "image/svg+xml";
if (ext === "md" || ext === "markdown") return "text/markdown";
return "text/plain";
}
public getHeaderButtons() {
const copyButton = new CopyButton();
copyButton.text = this.content;
copyButton.title = i18n("Copy");
copyButton.showText = false;
return html`
<div class="flex items-center gap-1">
${copyButton}
${DownloadButton({
content: this.content,
filename: this.filename,
mimeType: this.getMimeType(),
title: i18n("Download"),
})}
</div>
`;
}
override render() {
const isCode = this.isCode();
const ext = this.filename.split(".").pop() || "";
return html`
<div class="h-full flex flex-col">
<div class="flex-1 overflow-auto">
${isCode
? html`
<pre
class="m-0 p-4 text-xs"
><code class="hljs language-${this.getLanguageFromExtension(
ext.toLowerCase(),
)}">${unsafeHTML(
hljs.highlight(this.content, {
language: this.getLanguageFromExtension(ext.toLowerCase()),
ignoreIllegals: true,
}).value,
)}</code></pre>
`
: html`
<pre class="m-0 p-4 text-xs font-mono">${this.content}</pre>
`}
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"text-artifact": TextArtifact;
}
}

View file

@ -1,483 +0,0 @@
import "@mariozechner/mini-lit/dist/CodeBlock.js";
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { createRef, ref } from "lit/directives/ref.js";
import { FileCode2 } from "lucide";
import "../../components/ConsoleBlock.js";
import { Diff } from "@mariozechner/mini-lit/dist/Diff.js";
import { html, type TemplateResult } from "lit";
import { i18n } from "../../utils/i18n.js";
import { renderCollapsibleHeader, renderHeader } from "../renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "../types.js";
import { ArtifactPill } from "./ArtifactPill.js";
import type { ArtifactsPanel, ArtifactsParams } from "./artifacts.js";
// Helper to extract text from content blocks
function getTextOutput(result: ToolResultMessage<any> | undefined): string {
if (!result) return "";
return (
result.content
?.filter((c) => c.type === "text")
.map((c: any) => c.text)
.join("\n") || ""
);
}
// Helper to determine language for syntax highlighting
function getLanguageFromFilename(filename?: string): string {
if (!filename) return "text";
const ext = filename.split(".").pop()?.toLowerCase();
const languageMap: Record<string, string> = {
js: "javascript",
jsx: "javascript",
ts: "typescript",
tsx: "typescript",
html: "html",
css: "css",
scss: "scss",
json: "json",
py: "python",
md: "markdown",
svg: "xml",
xml: "xml",
yaml: "yaml",
yml: "yaml",
sh: "bash",
bash: "bash",
sql: "sql",
java: "java",
c: "c",
cpp: "cpp",
cs: "csharp",
go: "go",
rs: "rust",
php: "php",
rb: "ruby",
swift: "swift",
kt: "kotlin",
r: "r",
};
return languageMap[ext || ""] || "text";
}
export class ArtifactsToolRenderer implements ToolRenderer<
ArtifactsParams,
undefined
> {
constructor(public artifactsPanel?: ArtifactsPanel) {}
render(
params: ArtifactsParams | undefined,
result: ToolResultMessage<undefined> | undefined,
isStreaming?: boolean,
): ToolRenderResult {
const state = result
? result.isError
? "error"
: "complete"
: isStreaming
? "inprogress"
: "complete";
// Create refs for collapsible sections
const contentRef = createRef<HTMLDivElement>();
const chevronRef = createRef<HTMLSpanElement>();
// Helper to get command labels
const getCommandLabels = (
command: string,
): { streaming: string; complete: string } => {
const labels: Record<string, { streaming: string; complete: string }> = {
create: {
streaming: i18n("Creating artifact"),
complete: i18n("Created artifact"),
},
update: {
streaming: i18n("Updating artifact"),
complete: i18n("Updated artifact"),
},
rewrite: {
streaming: i18n("Rewriting artifact"),
complete: i18n("Rewrote artifact"),
},
get: {
streaming: i18n("Getting artifact"),
complete: i18n("Got artifact"),
},
delete: {
streaming: i18n("Deleting artifact"),
complete: i18n("Deleted artifact"),
},
logs: { streaming: i18n("Getting logs"), complete: i18n("Got logs") },
};
return (
labels[command] || {
streaming: i18n("Processing artifact"),
complete: i18n("Processed artifact"),
}
);
};
// Helper to render header text with inline artifact pill
const renderHeaderWithPill = (
labelText: string,
filename?: string,
): TemplateResult => {
if (filename) {
return html`<span
>${labelText} ${ArtifactPill(filename, this.artifactsPanel)}</span
>`;
}
return html`<span>${labelText}</span>`;
};
// Error handling
if (result?.isError) {
const command = params?.command;
const filename = params?.filename;
const labels = command
? getCommandLabels(command)
: {
streaming: i18n("Processing artifact"),
complete: i18n("Processed artifact"),
};
const headerText = labels.streaming;
// For create/update/rewrite errors, show code block + console/error
if (
command === "create" ||
command === "update" ||
command === "rewrite"
) {
const content = params?.content || "";
const { old_str, new_str } = params || {};
const isDiff = command === "update";
const diffContent =
old_str !== undefined && new_str !== undefined
? Diff({ oldText: old_str, newText: new_str })
: "";
const isHtml = filename?.endsWith(".html");
return {
content: html`
<div>
${renderCollapsibleHeader(
state,
FileCode2,
renderHeaderWithPill(headerText, filename),
contentRef,
chevronRef,
false,
)}
<div
${ref(contentRef)}
class="max-h-0 overflow-hidden transition-all duration-300 space-y-3"
>
${isDiff
? diffContent
: content
? html`<code-block
.code=${content}
language=${getLanguageFromFilename(filename)}
></code-block>`
: ""}
${isHtml
? html`<console-block
.content=${getTextOutput(result) ||
i18n("An error occurred")}
variant="error"
></console-block>`
: html`<div class="text-sm text-destructive">
${getTextOutput(result) || i18n("An error occurred")}
</div>`}
</div>
</div>
`,
isCustom: false,
};
}
// For other errors, just show error message
return {
content: html`
<div class="space-y-3">
${renderHeader(state, FileCode2, headerText)}
<div class="text-sm text-destructive">
${getTextOutput(result) || i18n("An error occurred")}
</div>
</div>
`,
isCustom: false,
};
}
// Full params + result
if (result && params) {
const { command, filename, content } = params;
const labels = command
? getCommandLabels(command)
: {
streaming: i18n("Processing artifact"),
complete: i18n("Processed artifact"),
};
const headerText = labels.complete;
// GET command: show code block with file content
if (command === "get") {
const fileContent = getTextOutput(result) || i18n("(no output)");
return {
content: html`
<div>
${renderCollapsibleHeader(
state,
FileCode2,
renderHeaderWithPill(headerText, filename),
contentRef,
chevronRef,
false,
)}
<div
${ref(contentRef)}
class="max-h-0 overflow-hidden transition-all duration-300"
>
<code-block
.code=${fileContent}
language=${getLanguageFromFilename(filename)}
></code-block>
</div>
</div>
`,
isCustom: false,
};
}
// LOGS command: show console block
if (command === "logs") {
const logs = getTextOutput(result) || i18n("(no output)");
return {
content: html`
<div>
${renderCollapsibleHeader(
state,
FileCode2,
renderHeaderWithPill(headerText, filename),
contentRef,
chevronRef,
false,
)}
<div
${ref(contentRef)}
class="max-h-0 overflow-hidden transition-all duration-300"
>
<console-block .content=${logs}></console-block>
</div>
</div>
`,
isCustom: false,
};
}
// CREATE/UPDATE/REWRITE: always show code block, + console block for .html files
if (command === "create" || command === "rewrite") {
const codeContent = content || "";
const isHtml = filename?.endsWith(".html");
const logs = getTextOutput(result) || "";
return {
content: html`
<div>
${renderCollapsibleHeader(
state,
FileCode2,
renderHeaderWithPill(headerText, filename),
contentRef,
chevronRef,
false,
)}
<div
${ref(contentRef)}
class="max-h-0 overflow-hidden transition-all duration-300 space-y-3"
>
${codeContent
? html`<code-block
.code=${codeContent}
language=${getLanguageFromFilename(filename)}
></code-block>`
: ""}
${isHtml && logs
? html`<console-block .content=${logs}></console-block>`
: ""}
</div>
</div>
`,
isCustom: false,
};
}
if (command === "update") {
const isHtml = filename?.endsWith(".html");
const logs = getTextOutput(result) || "";
return {
content: html`
<div>
${renderCollapsibleHeader(
state,
FileCode2,
renderHeaderWithPill(headerText, filename),
contentRef,
chevronRef,
false,
)}
<div
${ref(contentRef)}
class="max-h-0 overflow-hidden transition-all duration-300 space-y-3"
>
${Diff({
oldText: params.old_str || "",
newText: params.new_str || "",
})}
${isHtml && logs
? html`<console-block .content=${logs}></console-block>`
: ""}
</div>
</div>
`,
isCustom: false,
};
}
// For DELETE, just show header
return {
content: html`
<div class="space-y-3">
${renderHeader(
state,
FileCode2,
renderHeaderWithPill(headerText, filename),
)}
</div>
`,
isCustom: false,
};
}
// Params only (streaming or waiting for result)
if (params) {
const { command, filename, content, old_str, new_str } = params;
// If no command yet
if (!command) {
return {
content: renderHeader(
state,
FileCode2,
i18n("Preparing artifact..."),
),
isCustom: false,
};
}
const labels = getCommandLabels(command);
const headerText = labels.streaming;
// Render based on command type
switch (command) {
case "create":
case "rewrite":
return {
content: html`
<div>
${renderCollapsibleHeader(
state,
FileCode2,
renderHeaderWithPill(headerText, filename),
contentRef,
chevronRef,
false,
)}
<div
${ref(contentRef)}
class="max-h-0 overflow-hidden transition-all duration-300"
>
${content
? html`<code-block
.code=${content}
language=${getLanguageFromFilename(filename)}
></code-block>`
: ""}
</div>
</div>
`,
isCustom: false,
};
case "update":
return {
content: html`
<div>
${renderCollapsibleHeader(
state,
FileCode2,
renderHeaderWithPill(headerText, filename),
contentRef,
chevronRef,
false,
)}
<div
${ref(contentRef)}
class="max-h-0 overflow-hidden transition-all duration-300"
>
${old_str !== undefined && new_str !== undefined
? Diff({ oldText: old_str, newText: new_str })
: ""}
</div>
</div>
`,
isCustom: false,
};
case "get":
case "logs":
return {
content: html`
<div>
${renderCollapsibleHeader(
state,
FileCode2,
renderHeaderWithPill(headerText, filename),
contentRef,
chevronRef,
false,
)}
<div
${ref(contentRef)}
class="max-h-0 overflow-hidden transition-all duration-300"
></div>
</div>
`,
isCustom: false,
};
default:
return {
content: html`
<div>
${renderHeader(
state,
FileCode2,
renderHeaderWithPill(headerText, filename),
)}
</div>
`,
isCustom: false,
};
}
}
// No params or result yet
return {
content: renderHeader(state, FileCode2, i18n("Preparing artifact...")),
isCustom: false,
};
}
}

View file

@ -1,776 +0,0 @@
import { icon } from "@mariozechner/mini-lit";
import "@mariozechner/mini-lit/dist/MarkdownBlock.js";
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import type {
Agent,
AgentMessage,
AgentTool,
} from "@mariozechner/pi-agent-core";
import { StringEnum, type ToolCall } from "@mariozechner/pi-ai";
import { type Static, Type } from "@sinclair/typebox";
import { html, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, type Ref, ref } from "lit/directives/ref.js";
import { X } from "lucide";
import type { ArtifactMessage } from "../../components/Messages.js";
import { ArtifactsRuntimeProvider } from "../../components/sandbox/ArtifactsRuntimeProvider.js";
import { AttachmentsRuntimeProvider } from "../../components/sandbox/AttachmentsRuntimeProvider.js";
import type { SandboxRuntimeProvider } from "../../components/sandbox/SandboxRuntimeProvider.js";
import {
ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO,
ARTIFACTS_TOOL_DESCRIPTION,
ATTACHMENTS_RUNTIME_DESCRIPTION,
} from "../../prompts/prompts.js";
import type { Attachment } from "../../utils/attachment-utils.js";
import { i18n } from "../../utils/i18n.js";
import type { ArtifactElement } from "./ArtifactElement.js";
import { DocxArtifact } from "./DocxArtifact.js";
import { ExcelArtifact } from "./ExcelArtifact.js";
import { GenericArtifact } from "./GenericArtifact.js";
import { HtmlArtifact } from "./HtmlArtifact.js";
import { ImageArtifact } from "./ImageArtifact.js";
import { MarkdownArtifact } from "./MarkdownArtifact.js";
import { PdfArtifact } from "./PdfArtifact.js";
import { SvgArtifact } from "./SvgArtifact.js";
import { TextArtifact } from "./TextArtifact.js";
// Simple artifact model
export interface Artifact {
filename: string;
content: string;
createdAt: Date;
updatedAt: Date;
}
// JSON-schema friendly parameters object (LLM-facing)
const artifactsParamsSchema = Type.Object({
command: StringEnum(
["create", "update", "rewrite", "get", "delete", "logs"],
{
description: "The operation to perform",
},
),
filename: Type.String({
description:
"Filename including extension (e.g., 'index.html', 'script.js')",
}),
content: Type.Optional(Type.String({ description: "File content" })),
old_str: Type.Optional(
Type.String({ description: "String to replace (for update command)" }),
),
new_str: Type.Optional(
Type.String({ description: "Replacement string (for update command)" }),
),
});
export type ArtifactsParams = Static<typeof artifactsParamsSchema>;
@customElement("artifacts-panel")
export class ArtifactsPanel extends LitElement {
@state() private _artifacts = new Map<string, Artifact>();
@state() private _activeFilename: string | null = null;
// Programmatically managed artifact elements
private artifactElements = new Map<string, ArtifactElement>();
private contentRef: Ref<HTMLDivElement> = createRef();
// Agent reference (needed to get attachments for HTML artifacts)
@property({ attribute: false }) agent?: Agent;
// Sandbox URL provider for browser extensions (optional)
@property({ attribute: false }) sandboxUrlProvider?: () => string;
// Callbacks
@property({ attribute: false }) onArtifactsChange?: () => void;
@property({ attribute: false }) onClose?: () => void;
@property({ attribute: false }) onOpen?: () => void;
// Collapsed mode: hides panel content but can show a floating reopen pill
@property({ type: Boolean }) collapsed = false;
// Overlay mode: when true, panel renders full-screen overlay (mobile)
@property({ type: Boolean }) overlay = false;
// Public getter for artifacts
get artifacts() {
return this._artifacts;
}
// Get runtime providers for HTML artifacts (read-only: attachments + artifacts)
private getHtmlArtifactRuntimeProviders(): SandboxRuntimeProvider[] {
const providers: SandboxRuntimeProvider[] = [];
// Get attachments from agent messages
if (this.agent) {
const attachments: Attachment[] = [];
for (const message of this.agent.state.messages) {
if (message.role === "user-with-attachments" && message.attachments) {
attachments.push(...message.attachments);
}
}
if (attachments.length > 0) {
providers.push(new AttachmentsRuntimeProvider(attachments));
}
}
// Add read-only artifacts provider
providers.push(new ArtifactsRuntimeProvider(this, this.agent, false));
return providers;
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this; // light DOM for shared styles
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
this.style.height = "100%";
// Reattach existing artifact elements when panel is re-inserted into the DOM
requestAnimationFrame(() => {
const container = this.contentRef.value;
if (!container) return;
// Ensure we have an active filename
if (!this._activeFilename && this._artifacts.size > 0) {
this._activeFilename = Array.from(this._artifacts.keys())[0];
}
this.artifactElements.forEach((element, name) => {
if (!element.parentElement) container.appendChild(element);
element.style.display =
name === this._activeFilename ? "block" : "none";
});
});
}
override disconnectedCallback() {
super.disconnectedCallback();
// Do not tear down artifact elements; keep them to restore on next mount
}
// Helper to determine file type from extension
private getFileType(
filename: string,
):
| "html"
| "svg"
| "markdown"
| "image"
| "pdf"
| "excel"
| "docx"
| "text"
| "generic" {
const ext = filename.split(".").pop()?.toLowerCase();
if (ext === "html") return "html";
if (ext === "svg") return "svg";
if (ext === "md" || ext === "markdown") return "markdown";
if (ext === "pdf") return "pdf";
if (ext === "xlsx" || ext === "xls") return "excel";
if (ext === "docx") return "docx";
if (
ext === "png" ||
ext === "jpg" ||
ext === "jpeg" ||
ext === "gif" ||
ext === "webp" ||
ext === "bmp" ||
ext === "ico"
)
return "image";
// Text files
if (
ext === "txt" ||
ext === "json" ||
ext === "xml" ||
ext === "yaml" ||
ext === "yml" ||
ext === "csv" ||
ext === "js" ||
ext === "ts" ||
ext === "jsx" ||
ext === "tsx" ||
ext === "py" ||
ext === "java" ||
ext === "c" ||
ext === "cpp" ||
ext === "h" ||
ext === "css" ||
ext === "scss" ||
ext === "sass" ||
ext === "less" ||
ext === "sh"
)
return "text";
// Everything else gets generic fallback
return "generic";
}
// Get or create artifact element
private getOrCreateArtifactElement(
filename: string,
content: string,
): ArtifactElement {
let element = this.artifactElements.get(filename);
if (!element) {
const type = this.getFileType(filename);
if (type === "html") {
element = new HtmlArtifact();
(element as HtmlArtifact).runtimeProviders =
this.getHtmlArtifactRuntimeProviders();
if (this.sandboxUrlProvider) {
(element as HtmlArtifact).sandboxUrlProvider =
this.sandboxUrlProvider;
}
} else if (type === "svg") {
element = new SvgArtifact();
} else if (type === "markdown") {
element = new MarkdownArtifact();
} else if (type === "image") {
element = new ImageArtifact();
} else if (type === "pdf") {
element = new PdfArtifact();
} else if (type === "excel") {
element = new ExcelArtifact();
} else if (type === "docx") {
element = new DocxArtifact();
} else if (type === "text") {
element = new TextArtifact();
} else {
element = new GenericArtifact();
}
element.filename = filename;
element.content = content;
element.style.display = "none";
element.style.height = "100%";
// Store element
this.artifactElements.set(filename, element);
// Add to DOM - try immediately if container exists, otherwise schedule
const newElement = element;
if (this.contentRef.value) {
this.contentRef.value.appendChild(newElement);
} else {
requestAnimationFrame(() => {
if (this.contentRef.value && !newElement.parentElement) {
this.contentRef.value.appendChild(newElement);
}
});
}
} else {
// Just update content
element.content = content;
if (element instanceof HtmlArtifact) {
element.runtimeProviders = this.getHtmlArtifactRuntimeProviders();
}
}
return element;
}
// Show/hide artifact elements
private showArtifact(filename: string) {
// Ensure the active element is in the DOM
requestAnimationFrame(() => {
this.artifactElements.forEach((element, name) => {
if (this.contentRef.value && !element.parentElement) {
this.contentRef.value.appendChild(element);
}
element.style.display = name === filename ? "block" : "none";
});
});
this._activeFilename = filename;
this.requestUpdate(); // Only for tab bar update
// Scroll the active tab into view after render
requestAnimationFrame(() => {
const activeButton = this.querySelector(
`button[data-filename="${filename}"]`,
);
if (activeButton) {
activeButton.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
});
}
});
}
// Open panel and focus an artifact tab by filename
public openArtifact(filename: string) {
if (this._artifacts.has(filename)) {
this.showArtifact(filename);
// Ask host to open panel (AgentInterface demo listens to onOpen)
this.onOpen?.();
}
}
// Build the AgentTool (no details payload; return only output strings)
public get tool(): AgentTool<typeof artifactsParamsSchema, undefined> {
return {
label: "Artifacts",
name: "artifacts",
get description() {
// HTML artifacts have read-only access to attachments and artifacts
const runtimeProviderDescriptions = [
ATTACHMENTS_RUNTIME_DESCRIPTION,
ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO,
];
return ARTIFACTS_TOOL_DESCRIPTION(runtimeProviderDescriptions);
},
parameters: artifactsParamsSchema,
// Execute mutates our local store and returns a plain output
execute: async (
_toolCallId: string,
args: Static<typeof artifactsParamsSchema>,
_signal?: AbortSignal,
) => {
const output = await this.executeCommand(args);
return {
content: [{ type: "text", text: output }],
details: undefined,
};
},
};
}
// Re-apply artifacts by scanning a message list (optional utility)
public async reconstructFromMessages(
messages: Array<AgentMessage | { role: "aborted" } | { role: "artifact" }>,
): Promise<void> {
const toolCalls = new Map<string, ToolCall>();
const artifactToolName = "artifacts";
// 1) Collect tool calls from assistant messages
for (const message of messages) {
if (message.role === "assistant") {
for (const block of message.content) {
if (block.type === "toolCall" && block.name === artifactToolName) {
toolCalls.set(block.id, block);
}
}
}
}
// 2) Build an ordered list of successful artifact operations
const operations: Array<ArtifactsParams> = [];
for (const m of messages) {
if ((m as any).role === "artifact") {
const artifactMsg = m as ArtifactMessage;
switch (artifactMsg.action) {
case "create":
operations.push({
command: "create",
filename: artifactMsg.filename,
content: artifactMsg.content,
});
break;
case "update":
operations.push({
command: "rewrite",
filename: artifactMsg.filename,
content: artifactMsg.content,
});
break;
case "delete":
operations.push({
command: "delete",
filename: artifactMsg.filename,
});
break;
}
}
// Handle tool result messages (from artifacts tool calls)
else if (
(m as any).role === "toolResult" &&
(m as any).toolName === artifactToolName &&
!(m as any).isError
) {
const toolCallId = (m as any).toolCallId as string;
const call = toolCalls.get(toolCallId);
if (!call) continue;
const params = call.arguments as ArtifactsParams;
if (params.command === "get" || params.command === "logs") continue; // no state change
operations.push(params);
}
}
// 3) Compute final state per filename by simulating operations in-memory
const finalArtifacts = new Map<string, string>();
for (const op of operations) {
const filename = op.filename;
switch (op.command) {
case "create": {
if (op.content) {
finalArtifacts.set(filename, op.content);
}
break;
}
case "rewrite": {
if (op.content) {
finalArtifacts.set(filename, op.content);
}
break;
}
case "update": {
let existing = finalArtifacts.get(filename);
if (!existing) break; // skip invalid update (shouldn't happen for successful results)
if (op.old_str !== undefined && op.new_str !== undefined) {
existing = existing.replace(op.old_str, op.new_str);
finalArtifacts.set(filename, existing);
}
break;
}
case "delete": {
finalArtifacts.delete(filename);
break;
}
case "get":
case "logs":
// Ignored above, just for completeness
break;
}
}
// 4) Reset current UI state before bulk create
this._artifacts.clear();
this.artifactElements.forEach((el) => {
el.remove();
});
this.artifactElements.clear();
this._activeFilename = null;
this._artifacts = new Map(this._artifacts);
// 5) Create artifacts in a single pass without waiting for iframe execution or tab switching
for (const [filename, content] of finalArtifacts.entries()) {
const createParams: ArtifactsParams = {
command: "create",
filename,
content,
} as const;
try {
await this.createArtifact(createParams, {
skipWait: true,
silent: true,
});
} catch {
// Ignore failures during reconstruction
}
}
// 6) Show first artifact if any exist, and notify listeners once
if (!this._activeFilename && this._artifacts.size > 0) {
this.showArtifact(Array.from(this._artifacts.keys())[0]);
}
this.onArtifactsChange?.();
this.requestUpdate();
}
// Core command executor
private async executeCommand(
params: ArtifactsParams,
options: { skipWait?: boolean; silent?: boolean } = {},
): Promise<string> {
switch (params.command) {
case "create":
return await this.createArtifact(params, options);
case "update":
return await this.updateArtifact(params, options);
case "rewrite":
return await this.rewriteArtifact(params, options);
case "get":
return this.getArtifact(params);
case "delete":
return this.deleteArtifact(params);
case "logs":
return this.getLogs(params);
default:
// Should never happen with TypeBox validation
return `Error: Unknown command ${(params as any).command}`;
}
}
// Wait for HTML artifact execution and get logs
private async waitForHtmlExecution(filename: string): Promise<string> {
const element = this.artifactElements.get(filename);
if (!(element instanceof HtmlArtifact)) {
return "";
}
return new Promise((resolve) => {
// Fallback timeout - just get logs after execution should complete
setTimeout(() => {
// Get whatever logs we have
const logs = element.getLogs();
resolve(logs);
}, 1500);
});
}
// Reload all HTML artifacts (called when any artifact changes)
private reloadAllHtmlArtifacts() {
this.artifactElements.forEach((element) => {
if (element instanceof HtmlArtifact && element.sandboxIframeRef.value) {
// Update runtime providers with latest artifact state
element.runtimeProviders = this.getHtmlArtifactRuntimeProviders();
// Re-execute the HTML content
element.executeContent(element.content);
}
});
}
private async createArtifact(
params: ArtifactsParams,
options: { skipWait?: boolean; silent?: boolean } = {},
): Promise<string> {
if (!params.filename || !params.content) {
return "Error: create command requires filename and content";
}
if (this._artifacts.has(params.filename)) {
return `Error: File ${params.filename} already exists`;
}
const artifact: Artifact = {
filename: params.filename,
content: params.content,
createdAt: new Date(),
updatedAt: new Date(),
};
this._artifacts.set(params.filename, artifact);
this._artifacts = new Map(this._artifacts);
// Create or update element
this.getOrCreateArtifactElement(params.filename, params.content);
if (!options.silent) {
this.showArtifact(params.filename);
this.onArtifactsChange?.();
this.requestUpdate();
}
// Reload all HTML artifacts since they might depend on this new artifact
this.reloadAllHtmlArtifacts();
// For HTML files, wait for execution
let result = `Created file ${params.filename}`;
if (this.getFileType(params.filename) === "html" && !options.skipWait) {
const logs = await this.waitForHtmlExecution(params.filename);
result += `\n${logs}`;
}
return result;
}
private async updateArtifact(
params: ArtifactsParams,
options: { skipWait?: boolean; silent?: boolean } = {},
): Promise<string> {
const artifact = this._artifacts.get(params.filename);
if (!artifact) {
const files = Array.from(this._artifacts.keys());
if (files.length === 0)
return `Error: File ${params.filename} not found. No files have been created yet.`;
return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`;
}
if (!params.old_str || params.new_str === undefined) {
return "Error: update command requires old_str and new_str";
}
if (!artifact.content.includes(params.old_str)) {
return `Error: String not found in file. Here is the full content:\n\n${artifact.content}`;
}
artifact.content = artifact.content.replace(params.old_str, params.new_str);
artifact.updatedAt = new Date();
this._artifacts.set(params.filename, artifact);
// Update element
this.getOrCreateArtifactElement(params.filename, artifact.content);
if (!options.silent) {
this.onArtifactsChange?.();
this.requestUpdate();
}
// Show the artifact
this.showArtifact(params.filename);
// Reload all HTML artifacts since they might depend on this updated artifact
this.reloadAllHtmlArtifacts();
// For HTML files, wait for execution
let result = `Updated file ${params.filename}`;
if (this.getFileType(params.filename) === "html" && !options.skipWait) {
const logs = await this.waitForHtmlExecution(params.filename);
result += `\n${logs}`;
}
return result;
}
private async rewriteArtifact(
params: ArtifactsParams,
options: { skipWait?: boolean; silent?: boolean } = {},
): Promise<string> {
const artifact = this._artifacts.get(params.filename);
if (!artifact) {
const files = Array.from(this._artifacts.keys());
if (files.length === 0)
return `Error: File ${params.filename} not found. No files have been created yet.`;
return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`;
}
if (!params.content) {
return "Error: rewrite command requires content";
}
artifact.content = params.content;
artifact.updatedAt = new Date();
this._artifacts.set(params.filename, artifact);
// Update element
this.getOrCreateArtifactElement(params.filename, artifact.content);
if (!options.silent) {
this.onArtifactsChange?.();
}
// Show the artifact
this.showArtifact(params.filename);
// Reload all HTML artifacts since they might depend on this rewritten artifact
this.reloadAllHtmlArtifacts();
// For HTML files, wait for execution
let result = "";
if (this.getFileType(params.filename) === "html" && !options.skipWait) {
const logs = await this.waitForHtmlExecution(params.filename);
result += `\n${logs}`;
}
return result;
}
private getArtifact(params: ArtifactsParams): string {
const artifact = this._artifacts.get(params.filename);
if (!artifact) {
const files = Array.from(this._artifacts.keys());
if (files.length === 0)
return `Error: File ${params.filename} not found. No files have been created yet.`;
return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`;
}
return artifact.content;
}
private deleteArtifact(params: ArtifactsParams): string {
const artifact = this._artifacts.get(params.filename);
if (!artifact) {
const files = Array.from(this._artifacts.keys());
if (files.length === 0)
return `Error: File ${params.filename} not found. No files have been created yet.`;
return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`;
}
this._artifacts.delete(params.filename);
this._artifacts = new Map(this._artifacts);
// Remove element
const element = this.artifactElements.get(params.filename);
if (element) {
element.remove();
this.artifactElements.delete(params.filename);
}
// Show another artifact if this was active
if (this._activeFilename === params.filename) {
const remaining = Array.from(this._artifacts.keys());
if (remaining.length > 0) {
this.showArtifact(remaining[0]);
} else {
this._activeFilename = null;
this.requestUpdate();
}
}
this.onArtifactsChange?.();
this.requestUpdate();
// Reload all HTML artifacts since they might have depended on this deleted artifact
this.reloadAllHtmlArtifacts();
return `Deleted file ${params.filename}`;
}
private getLogs(params: ArtifactsParams): string {
const element = this.artifactElements.get(params.filename);
if (!element) {
const files = Array.from(this._artifacts.keys());
if (files.length === 0)
return `Error: File ${params.filename} not found. No files have been created yet.`;
return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`;
}
if (!(element instanceof HtmlArtifact)) {
return `Error: File ${params.filename} is not an HTML file. Logs are only available for HTML files.`;
}
return element.getLogs();
}
override render(): TemplateResult {
const artifacts = Array.from(this._artifacts.values());
// Panel is hidden when collapsed OR when there are no artifacts
const showPanel = artifacts.length > 0 && !this.collapsed;
return html`
<div
class="${showPanel ? "" : "hidden"} ${this.overlay
? "fixed inset-0 z-40 pointer-events-auto backdrop-blur-sm bg-background/95"
: "relative"} h-full flex flex-col bg-background text-card-foreground ${!this
.overlay
? "border-l border-border"
: ""} overflow-hidden shadow-xl"
>
<!-- Tab bar (always shown when there are artifacts) -->
<div
class="flex items-center justify-between border-b border-border bg-background"
>
<div class="flex overflow-x-auto">
${artifacts.map((a) => {
const isActive = a.filename === this._activeFilename;
const activeClass = isActive
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground";
return html`
<button
class="px-3 py-2 whitespace-nowrap border-b-2 ${activeClass}"
data-filename="${a.filename}"
@click=${() => this.showArtifact(a.filename)}
>
<span class="font-mono text-xs">${a.filename}</span>
</button>
`;
})}
</div>
<div class="flex items-center gap-1 px-2">
${(() => {
const active = this._activeFilename
? this.artifactElements.get(this._activeFilename)
: undefined;
return active ? active.getHeaderButtons() : "";
})()}
${Button({
variant: "ghost",
size: "sm",
onClick: () => this.onClose?.(),
title: i18n("Close artifacts"),
children: icon(X, "sm"),
})}
</div>
</div>
<!-- Content area where artifact elements are added programmatically -->
<div class="flex-1 overflow-hidden" ${ref(this.contentRef)}></div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"artifacts-panel": ArtifactsPanel;
}
}

View file

@ -1,11 +0,0 @@
export { ArtifactElement } from "./ArtifactElement.js";
export {
type Artifact,
ArtifactsPanel,
type ArtifactsParams,
} from "./artifacts.js";
export { ArtifactsToolRenderer } from "./artifacts-tool-renderer.js";
export { HtmlArtifact } from "./HtmlArtifact.js";
export { MarkdownArtifact } from "./MarkdownArtifact.js";
export { SvgArtifact } from "./SvgArtifact.js";
export { TextArtifact } from "./TextArtifact.js";

View file

@ -1,321 +0,0 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { type Static, Type } from "@sinclair/typebox";
import { html } from "lit";
import { createRef, ref } from "lit/directives/ref.js";
import { FileText } from "lucide";
import { EXTRACT_DOCUMENT_DESCRIPTION } from "../prompts/prompts.js";
import { loadAttachment } from "../utils/attachment-utils.js";
import { isCorsError } from "../utils/proxy-utils.js";
import {
registerToolRenderer,
renderCollapsibleHeader,
renderHeader,
} from "./renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "./types.js";
// ============================================================================
// TYPES
// ============================================================================
const extractDocumentSchema = Type.Object({
url: Type.String({
description:
"URL of the document to extract text from (PDF, DOCX, XLSX, or PPTX)",
}),
});
export type ExtractDocumentParams = Static<typeof extractDocumentSchema>;
export interface ExtractDocumentResult {
extractedText: string;
format: string;
fileName: string;
size: number;
}
// ============================================================================
// TOOL
// ============================================================================
export function createExtractDocumentTool(): AgentTool<
typeof extractDocumentSchema,
ExtractDocumentResult
> & {
corsProxyUrl?: string;
} {
const tool = {
label: "Extract Document",
name: "extract_document",
corsProxyUrl: undefined as string | undefined, // Can be set by consumer (e.g., from user settings)
description: EXTRACT_DOCUMENT_DESCRIPTION,
parameters: extractDocumentSchema,
execute: async (
_toolCallId: string,
args: ExtractDocumentParams,
signal?: AbortSignal,
) => {
if (signal?.aborted) {
throw new Error("Extract document aborted");
}
const url = args.url.trim();
if (!url) {
throw new Error("URL is required");
}
// Validate URL format
try {
new URL(url);
} catch {
throw new Error(`Invalid URL: ${url}`);
}
// Size limit: 50MB
const MAX_SIZE = 50 * 1024 * 1024;
// Helper function to fetch and process document
const fetchAndProcess = async (fetchUrl: string) => {
const response = await fetch(fetchUrl, { signal });
if (!response.ok) {
throw new Error(
`TELL USER: Unable to download the document (${response.status} ${response.statusText}). The site likely blocks automated downloads.\n\n` +
`INSTRUCT USER: Please download the file manually and attach it to your message using the attachment button (paperclip icon) in the message input area. I can then extract the text from the attached file.`,
);
}
// Check size before downloading
const contentLength = response.headers.get("content-length");
if (contentLength) {
const size = Number.parseInt(contentLength, 10);
if (size > MAX_SIZE) {
throw new Error(
`Document is too large (${(size / 1024 / 1024).toFixed(1)}MB). Maximum supported size is 50MB.`,
);
}
}
// Download the document
const arrayBuffer = await response.arrayBuffer();
const size = arrayBuffer.byteLength;
if (size > MAX_SIZE) {
throw new Error(
`Document is too large (${(size / 1024 / 1024).toFixed(1)}MB). Maximum supported size is 50MB.`,
);
}
return arrayBuffer;
};
// Try without proxy first, fallback to proxy on CORS error
let arrayBuffer: ArrayBuffer;
try {
// Attempt direct fetch first
arrayBuffer = await fetchAndProcess(url);
} catch (directError: any) {
// If CORS error and proxy is available, retry with proxy
if (isCorsError(directError) && tool.corsProxyUrl) {
try {
const proxiedUrl = tool.corsProxyUrl + encodeURIComponent(url);
arrayBuffer = await fetchAndProcess(proxiedUrl);
} catch (proxyError: any) {
// Proxy fetch also failed - throw helpful message
throw new Error(
`TELL USER: Unable to fetch the document due to CORS restrictions.\n\n` +
`Tried with proxy but it also failed: ${proxyError.message}\n\n` +
`INSTRUCT USER: Please download the file manually and attach it to your message using the attachment button (paperclip icon) in the message input area. I can then extract the text from the attached file.`,
);
}
} else if (isCorsError(directError) && !tool.corsProxyUrl) {
// CORS error but no proxy configured
throw new Error(
`TELL USER: Unable to fetch the document due to CORS restrictions (the server blocks requests from browser extensions).\n\n` +
`To fix this, you need to configure a CORS proxy in Sitegeist settings:\n` +
`1. Open Sitegeist settings\n` +
`2. Find "CORS Proxy URL" setting\n` +
`3. Enter a proxy URL like: https://corsproxy.io/?\n` +
`4. Save and try again\n\n` +
`Alternatively, download the file manually and attach it to your message using the attachment button (paperclip icon).`,
);
} else {
// Not a CORS error - re-throw
throw directError;
}
}
// Extract filename from URL
const urlParts = url.split("/");
let fileName = urlParts[urlParts.length - 1]?.split("?")[0] || "document";
if (url.startsWith("https://arxiv.org/")) {
fileName = `${fileName}.pdf`;
}
// Use loadAttachment to process the document
const attachment = await loadAttachment(arrayBuffer, fileName);
if (!attachment.extractedText) {
throw new Error(
`Document format not supported. Supported formats:\n` +
`- PDF (.pdf)\n` +
`- Word (.docx)\n` +
`- Excel (.xlsx, .xls)\n` +
`- PowerPoint (.pptx)`,
);
}
// Determine format from attachment
let format = "unknown";
if (attachment.mimeType.includes("pdf")) {
format = "pdf";
} else if (attachment.mimeType.includes("wordprocessingml")) {
format = "docx";
} else if (
attachment.mimeType.includes("spreadsheetml") ||
attachment.mimeType.includes("ms-excel")
) {
format = "xlsx";
} else if (attachment.mimeType.includes("presentationml")) {
format = "pptx";
}
return {
content: [{ type: "text" as const, text: attachment.extractedText }],
details: {
extractedText: attachment.extractedText,
format,
fileName: attachment.fileName,
size: attachment.size,
},
};
},
};
return tool;
}
// Export a default instance
export const extractDocumentTool = createExtractDocumentTool();
// ============================================================================
// RENDERER
// ============================================================================
export const extractDocumentRenderer: ToolRenderer<
ExtractDocumentParams,
ExtractDocumentResult
> = {
render(
params: ExtractDocumentParams | undefined,
result: ToolResultMessage<ExtractDocumentResult> | undefined,
isStreaming?: boolean,
): ToolRenderResult {
// Determine status
const state = result
? result.isError
? "error"
: "complete"
: isStreaming
? "inprogress"
: "complete";
// Create refs for collapsible sections
const contentRef = createRef<HTMLDivElement>();
const chevronRef = createRef<HTMLSpanElement>();
// With result: show params + result
if (result && params) {
const details = result.details;
const title = details
? result.isError
? `Failed to extract ${details.fileName || "document"}`
: `Extracted text from ${details.fileName} (${details.format.toUpperCase()}, ${(details.size / 1024).toFixed(1)}KB)`
: result.isError
? "Failed to extract document"
: "Extracted text from document";
const output =
result.content
?.filter((c) => c.type === "text")
.map((c: any) => c.text)
.join("\n") || "";
return {
content: html`
<div>
${renderCollapsibleHeader(
state,
FileText,
title,
contentRef,
chevronRef,
false,
)}
<div
${ref(contentRef)}
class="max-h-0 overflow-hidden transition-all duration-300 space-y-3"
>
${params.url
? html`<div class="text-sm text-gray-600 dark:text-gray-400">
<strong>URL:</strong> ${params.url}
</div>`
: ""}
${output && !result.isError
? html`<code-block
.code=${output}
language="plaintext"
></code-block>`
: ""}
${result.isError && output
? html`<console-block
.content=${output}
.variant=${"error"}
></console-block>`
: ""}
</div>
</div>
`,
isCustom: false,
};
}
// Just params (streaming or waiting for result)
if (params) {
const title = "Extracting document...";
return {
content: html`
<div>
${renderCollapsibleHeader(
state,
FileText,
title,
contentRef,
chevronRef,
false,
)}
<div
${ref(contentRef)}
class="max-h-0 overflow-hidden transition-all duration-300"
>
<div class="text-sm text-gray-600 dark:text-gray-400">
<strong>URL:</strong> ${params.url}
</div>
</div>
</div>
`,
isCustom: false,
};
}
// No params or result yet
return {
content: renderHeader(state, FileText, "Preparing extraction..."),
isCustom: false,
};
},
};
// Auto-register the renderer
registerToolRenderer("extract_document", extractDocumentRenderer);

View file

@ -1,46 +0,0 @@
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import "./javascript-repl.js"; // Auto-registers the renderer
import "./extract-document.js"; // Auto-registers the renderer
import { getToolRenderer, registerToolRenderer } from "./renderer-registry.js";
import { BashRenderer } from "./renderers/BashRenderer.js";
import { DefaultRenderer } from "./renderers/DefaultRenderer.js";
import type { ToolRenderResult } from "./types.js";
// Register all built-in tool renderers
registerToolRenderer("bash", new BashRenderer());
const defaultRenderer = new DefaultRenderer();
// Global flag to force default JSON rendering for all tools
let showJsonMode = false;
/**
* Enable or disable show JSON mode
* When enabled, all tool renderers will use the default JSON renderer
*/
export function setShowJsonMode(enabled: boolean): void {
showJsonMode = enabled;
}
/**
* Render tool - unified function that handles params, result, and streaming state
*/
export function renderTool(
toolName: string,
params: any | undefined,
result: ToolResultMessage | undefined,
isStreaming?: boolean,
): ToolRenderResult {
// If showJsonMode is enabled, always use the default renderer
if (showJsonMode) {
return defaultRenderer.render(params, result, isStreaming);
}
const renderer = getToolRenderer(toolName);
if (renderer) {
return renderer.render(params, result, isStreaming);
}
return defaultRenderer.render(params, result, isStreaming);
}
export { getToolRenderer, registerToolRenderer };

View file

@ -1,369 +0,0 @@
import { i18n } from "@mariozechner/mini-lit";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { type Static, Type } from "@sinclair/typebox";
import { html } from "lit";
import { createRef, ref } from "lit/directives/ref.js";
import { Code } from "lucide";
import {
type SandboxFile,
SandboxIframe,
type SandboxResult,
} from "../components/SandboxedIframe.js";
import type { SandboxRuntimeProvider } from "../components/sandbox/SandboxRuntimeProvider.js";
import { JAVASCRIPT_REPL_TOOL_DESCRIPTION } from "../prompts/prompts.js";
import type { Attachment } from "../utils/attachment-utils.js";
import {
registerToolRenderer,
renderCollapsibleHeader,
renderHeader,
} from "./renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "./types.js";
// Execute JavaScript code with attachments using SandboxedIframe
export async function executeJavaScript(
code: string,
runtimeProviders: SandboxRuntimeProvider[],
signal?: AbortSignal,
sandboxUrlProvider?: () => string,
): Promise<{ output: string; files?: SandboxFile[] }> {
if (!code) {
throw new Error("Code parameter is required");
}
// Check for abort before starting
if (signal?.aborted) {
throw new Error("Execution aborted");
}
// Create a SandboxedIframe instance for execution
const sandbox = new SandboxIframe();
if (sandboxUrlProvider) {
sandbox.sandboxUrlProvider = sandboxUrlProvider;
}
sandbox.style.display = "none";
document.body.appendChild(sandbox);
try {
const sandboxId = `repl-${Date.now()}-${Math.random().toString(36).substring(7)}`;
// Pass providers to execute (router handles all message routing)
// No additional consumers needed - execute() has its own internal consumer
const result: SandboxResult = await sandbox.execute(
sandboxId,
code,
runtimeProviders,
[],
signal,
);
// Remove the sandbox iframe after execution
sandbox.remove();
// Build plain text response
let output = "";
// Add console output - result.console contains { type: string, text: string } from sandbox.js
if (result.console && result.console.length > 0) {
for (const entry of result.console) {
output += `${entry.text}\n`;
}
}
// Add error if execution failed
if (!result.success) {
if (output) output += "\n";
output += `Error: ${result.error?.message || "Unknown error"}\n${result.error?.stack || ""}`;
// Throw error so tool call is marked as failed
throw new Error(output.trim());
}
// Add return value if present
if (result.returnValue !== undefined) {
if (output) output += "\n";
output += `=> ${typeof result.returnValue === "object" ? JSON.stringify(result.returnValue, null, 2) : result.returnValue}`;
}
// Add file notifications
if (result.files && result.files.length > 0) {
output += `\n[Files returned: ${result.files.length}]\n`;
for (const file of result.files) {
output += ` - ${file.fileName} (${file.mimeType})\n`;
}
} else {
// Explicitly note when no files were returned (helpful for debugging)
if (code.includes("returnFile")) {
output += "\n[No files returned - check async operations]";
}
}
return {
output: output.trim() || "Code executed successfully (no output)",
files: result.files,
};
} catch (error: unknown) {
// Clean up on error
sandbox.remove();
throw new Error((error as Error).message || "Execution failed");
}
}
export type JavaScriptReplToolResult = {
files?:
| {
fileName: string;
contentBase64: string;
mimeType: string;
}[]
| undefined;
};
const javascriptReplSchema = Type.Object({
title: Type.String({
description:
"Brief title describing what the code snippet tries to achieve in active form, e.g. 'Calculating sum'",
}),
code: Type.String({ description: "JavaScript code to execute" }),
});
export type JavaScriptReplParams = Static<typeof javascriptReplSchema>;
interface JavaScriptReplResult {
output?: string;
files?: Array<{
fileName: string;
mimeType: string;
size: number;
contentBase64: string;
}>;
}
export function createJavaScriptReplTool(): AgentTool<
typeof javascriptReplSchema,
JavaScriptReplToolResult
> & {
runtimeProvidersFactory?: () => SandboxRuntimeProvider[];
sandboxUrlProvider?: () => string;
} {
return {
label: "JavaScript REPL",
name: "javascript_repl",
runtimeProvidersFactory: () => [], // default to empty array
sandboxUrlProvider: undefined, // optional, for browser extensions
get description() {
const runtimeProviderDescriptions =
this.runtimeProvidersFactory?.()
.map((d) => d.getDescription())
.filter((d) => d.trim().length > 0) || [];
return JAVASCRIPT_REPL_TOOL_DESCRIPTION(runtimeProviderDescriptions);
},
parameters: javascriptReplSchema,
execute: async function (
_toolCallId: string,
args: Static<typeof javascriptReplSchema>,
signal?: AbortSignal,
) {
const result = await executeJavaScript(
args.code,
this.runtimeProvidersFactory?.() ?? [],
signal,
this.sandboxUrlProvider,
);
// Convert files to JSON-serializable with base64 payloads
const files = (result.files || []).map((f) => {
const toBase64 = (
input: string | Uint8Array,
): { base64: string; size: number } => {
if (input instanceof Uint8Array) {
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < input.length; i += chunk) {
binary += String.fromCharCode(...input.subarray(i, i + chunk));
}
return { base64: btoa(binary), size: input.length };
} else if (typeof input === "string") {
const enc = new TextEncoder();
const bytes = enc.encode(input);
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return { base64: btoa(binary), size: bytes.length };
} else {
const s = String(input);
const enc = new TextEncoder();
const bytes = enc.encode(s);
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return { base64: btoa(binary), size: bytes.length };
}
};
const { base64, size } = toBase64(f.content);
return {
fileName: f.fileName || "file",
mimeType: f.mimeType || "application/octet-stream",
size,
contentBase64: base64,
};
});
return {
content: [{ type: "text", text: result.output }],
details: { files },
};
},
};
}
// Export a default instance for backward compatibility
export const javascriptReplTool = createJavaScriptReplTool();
export const javascriptReplRenderer: ToolRenderer<
JavaScriptReplParams,
JavaScriptReplResult
> = {
render(
params: JavaScriptReplParams | undefined,
result: ToolResultMessage<JavaScriptReplResult> | undefined,
isStreaming?: boolean,
): ToolRenderResult {
// Determine status
const state = result
? result.isError
? "error"
: "complete"
: isStreaming
? "inprogress"
: "complete";
// Create refs for collapsible code section
const codeContentRef = createRef<HTMLDivElement>();
const codeChevronRef = createRef<HTMLSpanElement>();
// With result: show params + result
if (result && params) {
const output =
result.content
?.filter((c) => c.type === "text")
.map((c: any) => c.text)
.join("\n") || "";
const files = result.details?.files || [];
const attachments: Attachment[] = files.map((f, i) => {
// Decode base64 content for text files to show in overlay
let extractedText: string | undefined;
const isTextBased =
f.mimeType?.startsWith("text/") ||
f.mimeType === "application/json" ||
f.mimeType === "application/javascript" ||
f.mimeType?.includes("xml");
if (isTextBased && f.contentBase64) {
try {
extractedText = atob(f.contentBase64);
} catch (_e) {
console.warn("Failed to decode base64 content for", f.fileName);
}
}
return {
id: `repl-${Date.now()}-${i}`,
type: f.mimeType?.startsWith("image/") ? "image" : "document",
fileName: f.fileName || `file-${i}`,
mimeType: f.mimeType || "application/octet-stream",
size: f.size ?? 0,
content: f.contentBase64,
preview: f.mimeType?.startsWith("image/")
? f.contentBase64
: undefined,
extractedText,
};
});
return {
content: html`
<div>
${renderCollapsibleHeader(
state,
Code,
params.title ? params.title : i18n("Executing JavaScript"),
codeContentRef,
codeChevronRef,
false,
)}
<div
${ref(codeContentRef)}
class="max-h-0 overflow-hidden transition-all duration-300 space-y-3"
>
<code-block
.code=${params.code || ""}
language="javascript"
></code-block>
${output
? html`<console-block
.content=${output}
.variant=${result.isError ? "error" : "default"}
></console-block>`
: ""}
</div>
${attachments.length
? html`<div class="flex flex-wrap gap-2 mt-3">
${attachments.map(
(att) =>
html`<attachment-tile
.attachment=${att}
></attachment-tile>`,
)}
</div>`
: ""}
</div>
`,
isCustom: false,
};
}
// Just params (streaming or waiting for result)
if (params) {
return {
content: html`
<div>
${renderCollapsibleHeader(
state,
Code,
params.title ? params.title : i18n("Executing JavaScript"),
codeContentRef,
codeChevronRef,
false,
)}
<div
${ref(codeContentRef)}
class="max-h-0 overflow-hidden transition-all duration-300"
>
${params.code
? html`<code-block
.code=${params.code}
language="javascript"
></code-block>`
: ""}
</div>
</div>
`,
isCustom: false,
};
}
// No params or result yet
return {
content: renderHeader(state, Code, i18n("Preparing JavaScript...")),
isCustom: false,
};
},
};
// Auto-register the renderer
registerToolRenderer(javascriptReplTool.name, javascriptReplRenderer);

View file

@ -1,144 +0,0 @@
import { icon } from "@mariozechner/mini-lit";
import { html, type TemplateResult } from "lit";
import type { Ref } from "lit/directives/ref.js";
import { ref } from "lit/directives/ref.js";
import { ChevronsUpDown, ChevronUp, Loader } from "lucide";
import type { ToolRenderer } from "./types.js";
// Registry of tool renderers
export const toolRenderers = new Map<string, ToolRenderer>();
/**
* Register a custom tool renderer
*/
export function registerToolRenderer(
toolName: string,
renderer: ToolRenderer,
): void {
toolRenderers.set(toolName, renderer);
}
/**
* Get a tool renderer by name
*/
export function getToolRenderer(toolName: string): ToolRenderer | undefined {
return toolRenderers.get(toolName);
}
/**
* Helper to render a header for tool renderers
* Shows icon on left when complete/error, spinner on right when in progress
*/
export function renderHeader(
state: "inprogress" | "complete" | "error",
toolIcon: any,
text: string | TemplateResult,
): TemplateResult {
const statusIcon = (iconComponent: any, color: string) =>
html`<span class="inline-block ${color}"
>${icon(iconComponent, "sm")}</span
>`;
switch (state) {
case "inprogress":
return html`
<div
class="flex items-center justify-between gap-2 text-sm text-muted-foreground"
>
<div class="flex items-center gap-2">
${statusIcon(toolIcon, "text-foreground")} ${text}
</div>
${statusIcon(Loader, "text-foreground animate-spin")}
</div>
`;
case "complete":
return html`
<div class="flex items-center gap-2 text-sm text-muted-foreground">
${statusIcon(toolIcon, "text-green-600 dark:text-green-500")} ${text}
</div>
`;
case "error":
return html`
<div class="flex items-center gap-2 text-sm text-muted-foreground">
${statusIcon(toolIcon, "text-destructive")} ${text}
</div>
`;
}
}
/**
* Helper to render a collapsible header for tool renderers
* Same as renderHeader but with a chevron button that toggles visibility of content
*/
export function renderCollapsibleHeader(
state: "inprogress" | "complete" | "error",
toolIcon: any,
text: string | TemplateResult,
contentRef: Ref<HTMLElement>,
chevronRef: Ref<HTMLElement>,
defaultExpanded = false,
): TemplateResult {
const statusIcon = (iconComponent: any, color: string) =>
html`<span class="inline-block ${color}"
>${icon(iconComponent, "sm")}</span
>`;
const toggleContent = (e: Event) => {
e.preventDefault();
const content = contentRef.value;
const chevron = chevronRef.value;
if (content && chevron) {
const isCollapsed = content.classList.contains("max-h-0");
if (isCollapsed) {
content.classList.remove("max-h-0");
content.classList.add("max-h-[2000px]", "mt-3");
// Show ChevronUp, hide ChevronsUpDown
const upIcon = chevron.querySelector(".chevron-up");
const downIcon = chevron.querySelector(".chevrons-up-down");
if (upIcon && downIcon) {
upIcon.classList.remove("hidden");
downIcon.classList.add("hidden");
}
} else {
content.classList.remove("max-h-[2000px]", "mt-3");
content.classList.add("max-h-0");
// Show ChevronsUpDown, hide ChevronUp
const upIcon = chevron.querySelector(".chevron-up");
const downIcon = chevron.querySelector(".chevrons-up-down");
if (upIcon && downIcon) {
upIcon.classList.add("hidden");
downIcon.classList.remove("hidden");
}
}
}
};
const toolIconColor =
state === "complete"
? "text-green-600 dark:text-green-500"
: state === "error"
? "text-destructive"
: "text-foreground";
return html`
<button
@click=${toggleContent}
class="flex items-center justify-between gap-2 text-sm text-muted-foreground w-full text-left hover:text-foreground transition-colors cursor-pointer"
>
<div class="flex items-center gap-2">
${state === "inprogress"
? statusIcon(Loader, "text-foreground animate-spin")
: ""}
${statusIcon(toolIcon, toolIconColor)} ${text}
</div>
<span class="inline-block text-muted-foreground" ${ref(chevronRef)}>
<span class="chevron-up ${defaultExpanded ? "" : "hidden"}"
>${icon(ChevronUp, "sm")}</span
>
<span class="chevrons-up-down ${defaultExpanded ? "hidden" : ""}"
>${icon(ChevronsUpDown, "sm")}</span
>
</span>
</button>
`;
}

View file

@ -1,71 +0,0 @@
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { html } from "lit";
import { SquareTerminal } from "lucide";
import { i18n } from "../../utils/i18n.js";
import { renderHeader } from "../renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "../types.js";
interface BashParams {
command: string;
}
// Bash tool has undefined details (only uses output)
export class BashRenderer implements ToolRenderer<BashParams, undefined> {
render(
params: BashParams | undefined,
result: ToolResultMessage<undefined> | undefined,
): ToolRenderResult {
const state = result
? result.isError
? "error"
: "complete"
: "inprogress";
// With result: show command + output
if (result && params?.command) {
const output =
result.content
?.filter((c) => c.type === "text")
.map((c: any) => c.text)
.join("\n") || "";
const combined = output
? `> ${params.command}\n\n${output}`
: `> ${params.command}`;
return {
content: html`
<div class="space-y-3">
${renderHeader(state, SquareTerminal, i18n("Running command..."))}
<console-block
.content=${combined}
.variant=${result.isError ? "error" : "default"}
></console-block>
</div>
`,
isCustom: false,
};
}
// Just params (streaming or waiting)
if (params?.command) {
return {
content: html`
<div class="space-y-3">
${renderHeader(state, SquareTerminal, i18n("Running command..."))}
<console-block .content=${`> ${params.command}`}></console-block>
</div>
`,
isCustom: false,
};
}
// No params yet
return {
content: renderHeader(
state,
SquareTerminal,
i18n("Waiting for command..."),
),
isCustom: false,
};
}
}

View file

@ -1,89 +0,0 @@
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { html } from "lit";
import { Calculator } from "lucide";
import { i18n } from "../../utils/i18n.js";
import { renderHeader } from "../renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "../types.js";
interface CalculateParams {
expression: string;
}
// Calculate tool has undefined details (only uses output)
export class CalculateRenderer implements ToolRenderer<
CalculateParams,
undefined
> {
render(
params: CalculateParams | undefined,
result: ToolResultMessage<undefined> | undefined,
): ToolRenderResult {
const state = result
? result.isError
? "error"
: "complete"
: "inprogress";
// Full params + full result
if (result && params?.expression) {
const output =
result.content
?.filter((c) => c.type === "text")
.map((c: any) => c.text)
.join("\n") || "";
// Error: show expression in header, error below
if (result.isError) {
return {
content: html`
<div class="space-y-3">
${renderHeader(state, Calculator, params.expression)}
<div class="text-sm text-destructive">${output}</div>
</div>
`,
isCustom: false,
};
}
// Success: show expression = result in header
return {
content: renderHeader(
state,
Calculator,
`${params.expression} = ${output}`,
),
isCustom: false,
};
}
// Full params, no result: just show header with expression in it
if (params?.expression) {
return {
content: renderHeader(
state,
Calculator,
`${i18n("Calculating")} ${params.expression}`,
),
isCustom: false,
};
}
// Partial params (empty expression), no result
if (params && !params.expression) {
return {
content: renderHeader(state, Calculator, i18n("Writing expression...")),
isCustom: false,
};
}
// No params, no result
return {
content: renderHeader(
state,
Calculator,
i18n("Waiting for expression..."),
),
isCustom: false,
};
}
}

View file

@ -1,121 +0,0 @@
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { html } from "lit";
import { Code } from "lucide";
import { i18n } from "../../utils/i18n.js";
import { renderHeader } from "../renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "../types.js";
export class DefaultRenderer implements ToolRenderer {
render(
params: any | undefined,
result: ToolResultMessage | undefined,
isStreaming?: boolean,
): ToolRenderResult {
const state = result
? result.isError
? "error"
: "complete"
: isStreaming
? "inprogress"
: "complete";
// Format params as JSON
let paramsJson = "";
if (params) {
try {
paramsJson = JSON.stringify(JSON.parse(params), null, 2);
} catch {
try {
paramsJson = JSON.stringify(params, null, 2);
} catch {
paramsJson = String(params);
}
}
}
// With result: show header + params + result
if (result) {
let outputJson =
result.content
?.filter((c) => c.type === "text")
.map((c: any) => c.text)
.join("\n") || i18n("(no output)");
let outputLanguage = "text";
// Try to parse and pretty-print if it's valid JSON
try {
const parsed = JSON.parse(outputJson);
outputJson = JSON.stringify(parsed, null, 2);
outputLanguage = "json";
} catch {
// Not valid JSON, leave as-is and use text highlighting
}
return {
content: html`
<div class="space-y-3">
${renderHeader(state, Code, "Tool Call")}
${paramsJson
? html`<div>
<div class="text-xs font-medium mb-1 text-muted-foreground">
${i18n("Input")}
</div>
<code-block .code=${paramsJson} language="json"></code-block>
</div>`
: ""}
<div>
<div class="text-xs font-medium mb-1 text-muted-foreground">
${i18n("Output")}
</div>
<code-block
.code=${outputJson}
language="${outputLanguage}"
></code-block>
</div>
</div>
`,
isCustom: false,
};
}
// Just params (streaming or waiting for result)
if (params) {
if (
isStreaming &&
(!paramsJson || paramsJson === "{}" || paramsJson === "null")
) {
return {
content: html`
<div>
${renderHeader(state, Code, "Preparing tool parameters...")}
</div>
`,
isCustom: false,
};
}
return {
content: html`
<div class="space-y-3">
${renderHeader(state, Code, "Tool Call")}
<div>
<div class="text-xs font-medium mb-1 text-muted-foreground">
${i18n("Input")}
</div>
<code-block .code=${paramsJson} language="json"></code-block>
</div>
</div>
`,
isCustom: false,
};
}
// No params or result yet
return {
content: html`
<div>${renderHeader(state, Code, "Preparing tool...")}</div>
`,
isCustom: false,
};
}
}

View file

@ -1,124 +0,0 @@
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { html } from "lit";
import { Clock } from "lucide";
import { i18n } from "../../utils/i18n.js";
import { renderHeader } from "../renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "../types.js";
interface GetCurrentTimeParams {
timezone?: string;
}
// GetCurrentTime tool has undefined details (only uses output)
export class GetCurrentTimeRenderer implements ToolRenderer<
GetCurrentTimeParams,
undefined
> {
render(
params: GetCurrentTimeParams | undefined,
result: ToolResultMessage<undefined> | undefined,
): ToolRenderResult {
const state = result
? result.isError
? "error"
: "complete"
: "inprogress";
// Full params + full result
if (result && params) {
const output =
result.content
?.filter((c) => c.type === "text")
.map((c: any) => c.text)
.join("\n") || "";
const headerText = params.timezone
? `${i18n("Getting current time in")} ${params.timezone}`
: i18n("Getting current date and time");
// Error: show header, error below
if (result.isError) {
return {
content: html`
<div class="space-y-3">
${renderHeader(state, Clock, headerText)}
<div class="text-sm text-destructive">${output}</div>
</div>
`,
isCustom: false,
};
}
// Success: show time in header
return {
content: renderHeader(state, Clock, `${headerText}: ${output}`),
isCustom: false,
};
}
// Full result, no params
if (result) {
const output =
result.content
?.filter((c) => c.type === "text")
.map((c: any) => c.text)
.join("\n") || "";
// Error: show header, error below
if (result.isError) {
return {
content: html`
<div class="space-y-3">
${renderHeader(
state,
Clock,
i18n("Getting current date and time"),
)}
<div class="text-sm text-destructive">${output}</div>
</div>
`,
isCustom: false,
};
}
// Success: show time in header
return {
content: renderHeader(
state,
Clock,
`${i18n("Getting current date and time")}: ${output}`,
),
isCustom: false,
};
}
// Full params, no result: show timezone info in header
if (params?.timezone) {
return {
content: renderHeader(
state,
Clock,
`${i18n("Getting current time in")} ${params.timezone}`,
),
isCustom: false,
};
}
// Partial params (no timezone) or empty params, no result
if (params) {
return {
content: renderHeader(
state,
Clock,
i18n("Getting current date and time"),
),
isCustom: false,
};
}
// No params, no result
return {
content: renderHeader(state, Clock, i18n("Getting time...")),
isCustom: false,
};
}
}

View file

@ -1,15 +0,0 @@
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import type { TemplateResult } from "lit";
export interface ToolRenderResult {
content: TemplateResult;
isCustom: boolean; // true = no card wrapper, false = wrap in card
}
export interface ToolRenderer<TParams = any, TDetails = any> {
render(
params: TParams | undefined,
result: ToolResultMessage<TDetails> | undefined,
isStreaming?: boolean,
): ToolRenderResult;
}

View file

@ -1,509 +0,0 @@
import { parseAsync } from "docx-preview";
import JSZip from "jszip";
import type { PDFDocumentProxy } from "pdfjs-dist";
import * as pdfjsLib from "pdfjs-dist";
import * as XLSX from "xlsx";
import { i18n } from "./i18n.js";
// Configure PDF.js worker - we'll need to bundle this
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
export interface Attachment {
id: string;
type: "image" | "document";
fileName: string;
mimeType: string;
size: number;
content: string; // base64 encoded original data (without data URL prefix)
extractedText?: string; // For documents: <pdf filename="..."><page number="1">text</page></pdf>
preview?: string; // base64 image preview (first page for PDFs, or same as content for images)
}
/**
* Load an attachment from various sources
* @param source - URL string, File, Blob, or ArrayBuffer
* @param fileName - Optional filename override
* @returns Promise<Attachment>
* @throws Error if loading fails
*/
export async function loadAttachment(
source: string | File | Blob | ArrayBuffer,
fileName?: string,
): Promise<Attachment> {
let arrayBuffer: ArrayBuffer;
let detectedFileName = fileName || "unnamed";
let mimeType = "application/octet-stream";
let size = 0;
// Convert source to ArrayBuffer
if (typeof source === "string") {
// It's a URL - fetch it
const response = await fetch(source);
if (!response.ok) {
throw new Error(i18n("Failed to fetch file"));
}
arrayBuffer = await response.arrayBuffer();
size = arrayBuffer.byteLength;
mimeType = response.headers.get("content-type") || mimeType;
if (!fileName) {
// Try to extract filename from URL
const urlParts = source.split("/");
detectedFileName = urlParts[urlParts.length - 1] || "document";
}
} else if (source instanceof File) {
arrayBuffer = await source.arrayBuffer();
size = source.size;
mimeType = source.type || mimeType;
detectedFileName = fileName || source.name;
} else if (source instanceof Blob) {
arrayBuffer = await source.arrayBuffer();
size = source.size;
mimeType = source.type || mimeType;
} else if (source instanceof ArrayBuffer) {
arrayBuffer = source;
size = source.byteLength;
} else {
throw new Error(i18n("Invalid source type"));
}
// Convert ArrayBuffer to base64 - handle large files properly
const uint8Array = new Uint8Array(arrayBuffer);
let binary = "";
const chunkSize = 0x8000; // Process in 32KB chunks to avoid stack overflow
for (let i = 0; i < uint8Array.length; i += chunkSize) {
const chunk = uint8Array.slice(i, i + chunkSize);
binary += String.fromCharCode(...chunk);
}
const base64Content = btoa(binary);
// Detect type and process accordingly
const id = `${detectedFileName}_${Date.now()}_${Math.random()}`;
// Check if it's a PDF
if (
mimeType === "application/pdf" ||
detectedFileName.toLowerCase().endsWith(".pdf")
) {
const { extractedText, preview } = await processPdf(
arrayBuffer,
detectedFileName,
);
return {
id,
type: "document",
fileName: detectedFileName,
mimeType: "application/pdf",
size,
content: base64Content,
extractedText,
preview,
};
}
// Check if it's a DOCX file
if (
mimeType ===
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
detectedFileName.toLowerCase().endsWith(".docx")
) {
const { extractedText } = await processDocx(arrayBuffer, detectedFileName);
return {
id,
type: "document",
fileName: detectedFileName,
mimeType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
size,
content: base64Content,
extractedText,
};
}
// Check if it's a PPTX file
if (
mimeType ===
"application/vnd.openxmlformats-officedocument.presentationml.presentation" ||
detectedFileName.toLowerCase().endsWith(".pptx")
) {
const { extractedText } = await processPptx(arrayBuffer, detectedFileName);
return {
id,
type: "document",
fileName: detectedFileName,
mimeType:
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
size,
content: base64Content,
extractedText,
};
}
// Check if it's an Excel file (XLSX/XLS)
const excelMimeTypes = [
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-excel",
];
if (
excelMimeTypes.includes(mimeType) ||
detectedFileName.toLowerCase().endsWith(".xlsx") ||
detectedFileName.toLowerCase().endsWith(".xls")
) {
const { extractedText } = await processExcel(arrayBuffer, detectedFileName);
return {
id,
type: "document",
fileName: detectedFileName,
mimeType: mimeType.startsWith("application/vnd")
? mimeType
: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
size,
content: base64Content,
extractedText,
};
}
// Check if it's an image
if (mimeType.startsWith("image/")) {
return {
id,
type: "image",
fileName: detectedFileName,
mimeType,
size,
content: base64Content,
preview: base64Content, // For images, preview is the same as content
};
}
// Check if it's a text document
const textExtensions = [
".txt",
".md",
".json",
".xml",
".html",
".css",
".js",
".ts",
".jsx",
".tsx",
".yml",
".yaml",
];
const isTextFile =
mimeType.startsWith("text/") ||
textExtensions.some((ext) => detectedFileName.toLowerCase().endsWith(ext));
if (isTextFile) {
const decoder = new TextDecoder();
const text = decoder.decode(arrayBuffer);
return {
id,
type: "document",
fileName: detectedFileName,
mimeType: mimeType.startsWith("text/") ? mimeType : "text/plain",
size,
content: base64Content,
extractedText: text,
};
}
throw new Error(`Unsupported file type: ${mimeType}`);
}
async function processPdf(
arrayBuffer: ArrayBuffer,
fileName: string,
): Promise<{ extractedText: string; preview?: string }> {
let pdf: PDFDocumentProxy | null = null;
try {
pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
// Extract text with page structure
let extractedText = `<pdf filename="${fileName}">`;
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items
.map((item: any) => item.str)
.filter((str: string) => str.trim())
.join(" ");
extractedText += `\n<page number="${i}">\n${pageText}\n</page>`;
}
extractedText += "\n</pdf>";
// Generate preview from first page
const preview = await generatePdfPreview(pdf);
return { extractedText, preview };
} catch (error) {
console.error("Error processing PDF:", error);
throw new Error(`Failed to process PDF: ${String(error)}`);
} finally {
// Clean up PDF resources
if (pdf) {
pdf.destroy();
}
}
}
async function generatePdfPreview(
pdf: PDFDocumentProxy,
): Promise<string | undefined> {
try {
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale: 1.0 });
// Create canvas with reasonable size for thumbnail (160x160 max)
const scale = Math.min(160 / viewport.width, 160 / viewport.height);
const scaledViewport = page.getViewport({ scale });
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (!context) {
return undefined;
}
canvas.height = scaledViewport.height;
canvas.width = scaledViewport.width;
const renderContext = {
canvasContext: context,
viewport: scaledViewport,
canvas: canvas,
};
await page.render(renderContext).promise;
// Return base64 without data URL prefix
return canvas.toDataURL("image/png").split(",")[1];
} catch (error) {
console.error("Error generating PDF preview:", error);
return undefined;
}
}
async function processDocx(
arrayBuffer: ArrayBuffer,
fileName: string,
): Promise<{ extractedText: string }> {
try {
// Parse document structure
const wordDoc = await parseAsync(arrayBuffer);
// Extract structured text from document body
let extractedText = `<docx filename="${fileName}">\n<page number="1">\n`;
const body = wordDoc.documentPart?.body;
if (body?.children) {
// Walk through document elements and extract text
const texts: string[] = [];
for (const element of body.children) {
const text = extractTextFromElement(element);
if (text) {
texts.push(text);
}
}
extractedText += texts.join("\n");
}
extractedText += `\n</page>\n</docx>`;
return { extractedText };
} catch (error) {
console.error("Error processing DOCX:", error);
throw new Error(`Failed to process DOCX: ${String(error)}`);
}
}
function extractTextFromElement(element: any): string {
let text = "";
// Check type with lowercase
const elementType = element.type?.toLowerCase() || "";
// Handle paragraphs
if (elementType === "paragraph" && element.children) {
for (const child of element.children) {
const childType = child.type?.toLowerCase() || "";
if (childType === "run" && child.children) {
for (const textChild of child.children) {
const textType = textChild.type?.toLowerCase() || "";
if (textType === "text") {
text += textChild.text || "";
}
}
} else if (childType === "text") {
text += child.text || "";
}
}
}
// Handle tables
else if (elementType === "table") {
if (element.children) {
const tableTexts: string[] = [];
for (const row of element.children) {
const rowType = row.type?.toLowerCase() || "";
if (rowType === "tablerow" && row.children) {
const rowTexts: string[] = [];
for (const cell of row.children) {
const cellType = cell.type?.toLowerCase() || "";
if (cellType === "tablecell" && cell.children) {
const cellTexts: string[] = [];
for (const cellElement of cell.children) {
const cellText = extractTextFromElement(cellElement);
if (cellText) cellTexts.push(cellText);
}
if (cellTexts.length > 0) rowTexts.push(cellTexts.join(" "));
}
}
if (rowTexts.length > 0) tableTexts.push(rowTexts.join(" | "));
}
}
if (tableTexts.length > 0) {
text = `\n[Table]\n${tableTexts.join("\n")}\n[/Table]\n`;
}
}
}
// Recursively handle other container elements
else if (element.children && Array.isArray(element.children)) {
const childTexts: string[] = [];
for (const child of element.children) {
const childText = extractTextFromElement(child);
if (childText) childTexts.push(childText);
}
text = childTexts.join(" ");
}
return text.trim();
}
async function processPptx(
arrayBuffer: ArrayBuffer,
fileName: string,
): Promise<{ extractedText: string }> {
try {
// Load the PPTX file as a ZIP
const zip = await JSZip.loadAsync(arrayBuffer);
// PPTX slides are stored in ppt/slides/slide[n].xml
let extractedText = `<pptx filename="${fileName}">`;
// Get all slide files and sort them numerically
const slideFiles = Object.keys(zip.files)
.filter((name) => name.match(/ppt\/slides\/slide\d+\.xml$/))
.sort((a, b) => {
const numA = Number.parseInt(
a.match(/slide(\d+)\.xml$/)?.[1] || "0",
10,
);
const numB = Number.parseInt(
b.match(/slide(\d+)\.xml$/)?.[1] || "0",
10,
);
return numA - numB;
});
// Extract text from each slide
for (let i = 0; i < slideFiles.length; i++) {
const slideFile = zip.file(slideFiles[i]);
if (slideFile) {
const slideXml = await slideFile.async("text");
// Extract text from XML (simple regex approach)
// Looking for <a:t> tags which contain text in PPTX
const textMatches = slideXml.match(/<a:t[^>]*>([^<]+)<\/a:t>/g);
if (textMatches) {
extractedText += `\n<slide number="${i + 1}">`;
const slideTexts = textMatches
.map((match) => {
const textMatch = match.match(/<a:t[^>]*>([^<]+)<\/a:t>/);
return textMatch ? textMatch[1] : "";
})
.filter((t) => t.trim());
if (slideTexts.length > 0) {
extractedText += `\n${slideTexts.join("\n")}`;
}
extractedText += "\n</slide>";
}
}
}
// Also try to extract text from notes
const notesFiles = Object.keys(zip.files)
.filter((name) => name.match(/ppt\/notesSlides\/notesSlide\d+\.xml$/))
.sort((a, b) => {
const numA = Number.parseInt(
a.match(/notesSlide(\d+)\.xml$/)?.[1] || "0",
10,
);
const numB = Number.parseInt(
b.match(/notesSlide(\d+)\.xml$/)?.[1] || "0",
10,
);
return numA - numB;
});
if (notesFiles.length > 0) {
extractedText += "\n<notes>";
for (const noteFile of notesFiles) {
const file = zip.file(noteFile);
if (file) {
const noteXml = await file.async("text");
const textMatches = noteXml.match(/<a:t[^>]*>([^<]+)<\/a:t>/g);
if (textMatches) {
const noteTexts = textMatches
.map((match) => {
const textMatch = match.match(/<a:t[^>]*>([^<]+)<\/a:t>/);
return textMatch ? textMatch[1] : "";
})
.filter((t) => t.trim());
if (noteTexts.length > 0) {
const slideNum = noteFile.match(/notesSlide(\d+)\.xml$/)?.[1];
extractedText += `\n[Slide ${slideNum} notes]: ${noteTexts.join(" ")}`;
}
}
}
}
extractedText += "\n</notes>";
}
extractedText += "\n</pptx>";
return { extractedText };
} catch (error) {
console.error("Error processing PPTX:", error);
throw new Error(`Failed to process PPTX: ${String(error)}`);
}
}
async function processExcel(
arrayBuffer: ArrayBuffer,
fileName: string,
): Promise<{ extractedText: string }> {
try {
// Read the workbook
const workbook = XLSX.read(arrayBuffer, { type: "array" });
let extractedText = `<excel filename="${fileName}">`;
// Process each sheet
for (const [index, sheetName] of workbook.SheetNames.entries()) {
const worksheet = workbook.Sheets[sheetName];
// Extract text as CSV for the extractedText field
const csvText = XLSX.utils.sheet_to_csv(worksheet);
extractedText += `\n<sheet name="${sheetName}" index="${index + 1}">\n${csvText}\n</sheet>`;
}
extractedText += "\n</excel>";
return { extractedText };
} catch (error) {
console.error("Error processing Excel:", error);
throw new Error(`Failed to process Excel: ${String(error)}`);
}
}

View file

@ -1,27 +0,0 @@
import PromptDialog from "@mariozechner/mini-lit/dist/PromptDialog.js";
import { i18n } from "./i18n.js";
export async function getAuthToken(): Promise<string | undefined> {
let authToken: string | undefined = localStorage.getItem(`auth-token`) || "";
if (authToken) return authToken;
while (true) {
authToken = (
await PromptDialog.ask(
i18n("Enter Auth Token"),
i18n("Please enter your auth token."),
"",
true,
)
)?.trim();
if (authToken) {
localStorage.setItem(`auth-token`, authToken);
break;
}
}
return authToken?.trim() || undefined;
}
export async function clearAuthToken() {
localStorage.removeItem(`auth-token`);
}

View file

@ -1,42 +0,0 @@
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

@ -1,675 +0,0 @@
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;
You: string;
Assistant: string;
"Thinking...": string;
"Type your message...": string;
"API Keys Configuration": string;
"Configure API keys for LLM providers. Keys are stored locally in your browser.": string;
Configured: string;
"Not configured": string;
"✓ Valid": string;
"✗ Invalid": string;
"Testing...": string;
Update: string;
Test: string;
Remove: string;
Save: string;
"Update API key": string;
"Enter API key": string;
"Type a message...": string;
"Failed to fetch file": string;
"Invalid source type": string;
PDF: string;
Document: string;
Presentation: string;
Spreadsheet: string;
Text: string;
"Error loading file": string;
"No text content available": string;
"Failed to load PDF": string;
"Failed to load document": string;
"Failed to load spreadsheet": string;
"Error loading PDF": string;
"Error loading document": string;
"Error loading spreadsheet": string;
"Preview not available for this file type.": string;
"Click the download button above to view it on your computer.": string;
"No content available": string;
"Failed to display text content": string;
"API keys are required to use AI models. Get your keys from the provider's website.": string;
console: string;
"Copy output": string;
"Copied!": string;
"Error:": string;
"Request aborted": string;
Call: string;
Result: string;
"(no result)": string;
"Waiting for tool result…": string;
"Call was aborted; no result.": string;
"No session available": string;
"No session set": string;
"Preparing tool parameters...": string;
"(no output)": string;
Input: string;
Output: string;
"Writing expression...": string;
"Waiting for expression...": string;
Calculating: string;
"Getting current time in": string;
"Getting current date and time": string;
"Waiting for command...": string;
"Writing command...": string;
"Running command...": string;
"Command failed:": string;
"Enter Auth Token": string;
"Please enter your auth token.": string;
"Auth token is required for proxy transport": string;
// JavaScript REPL strings
"Execution aborted": string;
"Code parameter is required": string;
"Unknown error": string;
"Code executed successfully (no output)": string;
"Execution failed": string;
"JavaScript REPL": string;
"JavaScript code to execute": string;
"Writing JavaScript code...": string;
"Executing JavaScript": string;
"Preparing JavaScript...": string;
"Preparing command...": string;
"Preparing calculation...": string;
"Preparing tool...": string;
"Getting time...": string;
// Artifacts strings
"Processing artifact...": string;
"Preparing artifact...": string;
"Processing artifact": string;
"Processed artifact": string;
"Creating artifact": string;
"Created artifact": string;
"Updating artifact": string;
"Updated artifact": string;
"Rewriting artifact": string;
"Rewrote artifact": string;
"Getting artifact": string;
"Got artifact": string;
"Deleting artifact": string;
"Deleted artifact": string;
"Getting logs": string;
"Got logs": string;
"An error occurred": string;
"Copy logs": string;
"Autoscroll enabled": string;
"Autoscroll disabled": string;
Processing: string;
Create: string;
Rewrite: string;
Get: string;
Delete: string;
"Get logs": string;
"Show artifacts": string;
"Close artifacts": string;
Artifacts: string;
"Copy HTML": string;
"Download HTML": string;
"Reload HTML": string;
"Copy SVG": string;
"Download SVG": string;
"Copy Markdown": string;
"Download Markdown": string;
Download: string;
"No logs for {filename}": string;
"API Keys Settings": string;
Settings: string;
"API Keys": string;
Proxy: string;
"Use CORS Proxy": string;
"Proxy URL": string;
"Format: The proxy must accept requests as <proxy-url>/?url=<target-url>": string;
"Settings are stored locally in your browser": string;
Clear: string;
"API Key Required": string;
"Enter your API key for {provider}": string;
"Allows browser-based apps to bypass CORS restrictions when calling LLM providers. Required for Z-AI and Anthropic with OAuth token.": string;
Off: string;
Minimal: string;
Low: string;
Medium: string;
High: string;
"Storage Permission Required": string;
"This app needs persistent storage to save your conversations": string;
"Why is this needed?": string;
"Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.": string;
"What this means:": string;
"Your conversations will be saved locally in your browser": string;
"Data will not be deleted automatically to free up space": string;
"You can still manually clear data at any time": string;
"No data is sent to external servers": string;
"Continue Anyway": string;
"Requesting...": string;
"Grant Permission": string;
Sessions: string;
"Load a previous conversation": string;
"No sessions yet": string;
"Delete this session?": string;
Today: string;
Yesterday: string;
"{days} days ago": string;
messages: string;
tokens: string;
"Drop files here": string;
// Providers & Models
"Providers & Models": string;
"Cloud Providers": string;
"Cloud LLM providers with predefined models. API keys are stored locally in your browser.": string;
"Custom Providers": string;
"User-configured servers with auto-discovered or manually defined models.": string;
"Add Provider": string;
"No custom providers configured. Click 'Add Provider' to get started.": string;
Models: string;
"auto-discovered": string;
Refresh: string;
Edit: string;
"Are you sure you want to delete this provider?": string;
"Edit Provider": string;
"Provider Name": string;
"e.g., My Ollama Server": string;
"Provider Type": string;
"Base URL": string;
"e.g., http://localhost:11434": string;
"API Key (Optional)": string;
"Leave empty if not required": string;
"Test Connection": string;
Discovered: string;
models: string;
and: string;
more: string;
"For manual provider types, add models after saving the provider.": string;
"Please fill in all required fields": string;
"Failed to save provider": string;
"OpenAI Completions Compatible": string;
"OpenAI Responses Compatible": string;
"Anthropic Messages Compatible": string;
"Checking...": string;
Disconnected: string;
}
}
export 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",
You: "You",
Assistant: "Assistant",
"Thinking...": "Thinking...",
"Type your message...": "Type your message...",
"API Keys Configuration": "API Keys Configuration",
"Configure API keys for LLM providers. Keys are stored locally in your browser.":
"Configure API keys for LLM providers. Keys are stored locally in your browser.",
Configured: "Configured",
"Not configured": "Not configured",
"✓ Valid": "✓ Valid",
"✗ Invalid": "✗ Invalid",
"Testing...": "Testing...",
Update: "Update",
Test: "Test",
Remove: "Remove",
Save: "Save",
"Update API key": "Update API key",
"Enter API key": "Enter API key",
"Type a message...": "Type a message...",
"Failed to fetch file": "Failed to fetch file",
"Invalid source type": "Invalid source type",
PDF: "PDF",
Document: "Document",
Presentation: "Presentation",
Spreadsheet: "Spreadsheet",
Text: "Text",
"Error loading file": "Error loading file",
"No text content available": "No text content available",
"Failed to load PDF": "Failed to load PDF",
"Failed to load document": "Failed to load document",
"Failed to load spreadsheet": "Failed to load spreadsheet",
"Error loading PDF": "Error loading PDF",
"Error loading document": "Error loading document",
"Error loading spreadsheet": "Error loading spreadsheet",
"Preview not available for this file type.":
"Preview not available for this file type.",
"Click the download button above to view it on your computer.":
"Click the download button above to view it on your computer.",
"No content available": "No content available",
"Failed to display text content": "Failed to display text content",
"API keys are required to use AI models. Get your keys from the provider's website.":
"API keys are required to use AI models. Get your keys from the provider's website.",
console: "console",
"Copy output": "Copy output",
"Copied!": "Copied!",
"Error:": "Error:",
"Request aborted": "Request aborted",
Call: "Call",
Result: "Result",
"(no result)": "(no result)",
"Waiting for tool result…": "Waiting for tool result…",
"Call was aborted; no result.": "Call was aborted; no result.",
"No session available": "No session available",
"No session set": "No session set",
"Preparing tool parameters...": "Preparing tool parameters...",
"(no output)": "(no output)",
Input: "Input",
Output: "Output",
"Waiting for expression...": "Waiting for expression...",
"Writing expression...": "Writing expression...",
Calculating: "Calculating",
"Getting current time in": "Getting current time in",
"Getting current date and time": "Getting current date and time",
"Waiting for command...": "Waiting for command...",
"Writing command...": "Writing command...",
"Running command...": "Running command...",
"Command failed": "Command failed",
"Enter Auth Token": "Enter Auth Token",
"Please enter your auth token.": "Please enter your auth token.",
"Auth token is required for proxy transport":
"Auth token is required for proxy transport",
// JavaScript REPL strings
"Execution aborted": "Execution aborted",
"Code parameter is required": "Code parameter is required",
"Unknown error": "Unknown error",
"Code executed successfully (no output)":
"Code executed successfully (no output)",
"Execution failed": "Execution failed",
"JavaScript REPL": "JavaScript REPL",
"JavaScript code to execute": "JavaScript code to execute",
"Writing JavaScript code...": "Writing JavaScript code...",
"Executing JavaScript": "Executing JavaScript",
"Preparing JavaScript...": "Preparing JavaScript...",
"Preparing command...": "Preparing command...",
"Preparing calculation...": "Preparing calculation...",
"Preparing tool...": "Preparing tool...",
"Getting time...": "Getting time...",
// Artifacts strings
"Processing artifact...": "Processing artifact...",
"Preparing artifact...": "Preparing artifact...",
"Processing artifact": "Processing artifact",
"Processed artifact": "Processed artifact",
"Creating artifact": "Creating artifact",
"Created artifact": "Created artifact",
"Updating artifact": "Updating artifact",
"Updated artifact": "Updated artifact",
"Rewriting artifact": "Rewriting artifact",
"Rewrote artifact": "Rewrote artifact",
"Getting artifact": "Getting artifact",
"Got artifact": "Got artifact",
"Deleting artifact": "Deleting artifact",
"Deleted artifact": "Deleted artifact",
"Getting logs": "Getting logs",
"Got logs": "Got logs",
"An error occurred": "An error occurred",
"Copy logs": "Copy logs",
"Autoscroll enabled": "Autoscroll enabled",
"Autoscroll disabled": "Autoscroll disabled",
Processing: "Processing",
Create: "Create",
Rewrite: "Rewrite",
Get: "Get",
"Get logs": "Get logs",
"Show artifacts": "Show artifacts",
"Close artifacts": "Close artifacts",
Artifacts: "Artifacts",
"Copy HTML": "Copy HTML",
"Download HTML": "Download HTML",
"Reload HTML": "Reload HTML",
"Copy SVG": "Copy SVG",
"Download SVG": "Download SVG",
"Copy Markdown": "Copy Markdown",
"Download Markdown": "Download Markdown",
Download: "Download",
"No logs for {filename}": "No logs for {filename}",
"API Keys Settings": "API Keys Settings",
Settings: "Settings",
"API Keys": "API Keys",
Proxy: "Proxy",
"Use CORS Proxy": "Use CORS Proxy",
"Proxy URL": "Proxy URL",
"Format: The proxy must accept requests as <proxy-url>/?url=<target-url>":
"Format: The proxy must accept requests as <proxy-url>/?url=<target-url>",
"Settings are stored locally in your browser":
"Settings are stored locally in your browser",
Clear: "Clear",
"API Key Required": "API Key Required",
"Enter your API key for {provider}": "Enter your API key for {provider}",
"Allows browser-based apps to bypass CORS restrictions when calling LLM providers. Required for Z-AI and Anthropic with OAuth token.":
"Allows browser-based apps to bypass CORS restrictions when calling LLM providers. Required for Z-AI and Anthropic with OAuth token.",
Off: "Off",
Minimal: "Minimal",
Low: "Low",
Medium: "Medium",
High: "High",
"Storage Permission Required": "Storage Permission Required",
"This app needs persistent storage to save your conversations":
"This app needs persistent storage to save your conversations",
"Why is this needed?": "Why is this needed?",
"Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.":
"Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.",
"What this means:": "What this means:",
"Your conversations will be saved locally in your browser":
"Your conversations will be saved locally in your browser",
"Data will not be deleted automatically to free up space":
"Data will not be deleted automatically to free up space",
"You can still manually clear data at any time":
"You can still manually clear data at any time",
"No data is sent to external servers":
"No data is sent to external servers",
"Continue Anyway": "Continue Anyway",
"Requesting...": "Requesting...",
"Grant Permission": "Grant Permission",
Sessions: "Sessions",
"Load a previous conversation": "Load a previous conversation",
"No sessions yet": "No sessions yet",
"Delete this session?": "Delete this session?",
Today: "Today",
Yesterday: "Yesterday",
"{days} days ago": "{days} days ago",
messages: "messages",
tokens: "tokens",
Delete: "Delete",
"Drop files here": "Drop files here",
"Command failed:": "Command failed:",
// Providers & Models
"Providers & Models": "Providers & Models",
"Cloud Providers": "Cloud Providers",
"Cloud LLM providers with predefined models. API keys are stored locally in your browser.":
"Cloud LLM providers with predefined models. API keys are stored locally in your browser.",
"Custom Providers": "Custom Providers",
"User-configured servers with auto-discovered or manually defined models.":
"User-configured servers with auto-discovered or manually defined models.",
"Add Provider": "Add Provider",
"No custom providers configured. Click 'Add Provider' to get started.":
"No custom providers configured. Click 'Add Provider' to get started.",
"auto-discovered": "auto-discovered",
Refresh: "Refresh",
Edit: "Edit",
"Are you sure you want to delete this provider?":
"Are you sure you want to delete this provider?",
"Edit Provider": "Edit Provider",
"Provider Name": "Provider Name",
"e.g., My Ollama Server": "e.g., My Ollama Server",
"Provider Type": "Provider Type",
"Base URL": "Base URL",
"e.g., http://localhost:11434": "e.g., http://localhost:11434",
"API Key (Optional)": "API Key (Optional)",
"Leave empty if not required": "Leave empty if not required",
"Test Connection": "Test Connection",
Discovered: "Discovered",
Models: "Models",
models: "models",
and: "and",
more: "more",
"For manual provider types, add models after saving the provider.":
"For manual provider types, add models after saving the provider.",
"Please fill in all required fields": "Please fill in all required fields",
"Failed to save provider": "Failed to save provider",
"OpenAI Completions Compatible": "OpenAI Completions Compatible",
"OpenAI Responses Compatible": "OpenAI Responses Compatible",
"Anthropic Messages Compatible": "Anthropic Messages Compatible",
"Checking...": "Checking...",
Disconnected: "Disconnected",
},
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",
You: "Sie",
Assistant: "Assistent",
"Thinking...": "Denkt nach...",
"Type your message...": "Geben Sie Ihre Nachricht ein...",
"API Keys Configuration": "API-Schlüssel-Konfiguration",
"Configure API keys for LLM providers. Keys are stored locally in your browser.":
"Konfigurieren Sie API-Schlüssel für LLM-Anbieter. Schlüssel werden lokal in Ihrem Browser gespeichert.",
Configured: "Konfiguriert",
"Not configured": "Nicht konfiguriert",
"✓ Valid": "✓ Gültig",
"✗ Invalid": "✗ Ungültig",
"Testing...": "Teste...",
Update: "Aktualisieren",
Test: "Testen",
Remove: "Entfernen",
Save: "Speichern",
"Update API key": "API-Schlüssel aktualisieren",
"Enter API key": "API-Schlüssel eingeben",
"Type a message...": "Nachricht eingeben...",
"Failed to fetch file": "Datei konnte nicht abgerufen werden",
"Invalid source type": "Ungültiger Quellentyp",
PDF: "PDF",
Document: "Dokument",
Presentation: "Präsentation",
Spreadsheet: "Tabelle",
Text: "Text",
"Error loading file": "Fehler beim Laden der Datei",
"No text content available": "Kein Textinhalt verfügbar",
"Failed to load PDF": "PDF konnte nicht geladen werden",
"Failed to load document": "Dokument konnte nicht geladen werden",
"Failed to load spreadsheet": "Tabelle konnte nicht geladen werden",
"Error loading PDF": "Fehler beim Laden des PDFs",
"Error loading document": "Fehler beim Laden des Dokuments",
"Error loading spreadsheet": "Fehler beim Laden der Tabelle",
"Preview not available for this file type.":
"Vorschau für diesen Dateityp nicht verfügbar.",
"Click the download button above to view it on your computer.":
"Klicken Sie oben auf die Download-Schaltfläche, um die Datei auf Ihrem Computer anzuzeigen.",
"No content available": "Kein Inhalt verfügbar",
"Failed to display text content":
"Textinhalt konnte nicht angezeigt werden",
"API keys are required to use AI models. Get your keys from the provider's website.":
"API-Schlüssel sind erforderlich, um KI-Modelle zu verwenden. Holen Sie sich Ihre Schlüssel von der Website des Anbieters.",
console: "Konsole",
"Copy output": "Ausgabe kopieren",
"Copied!": "Kopiert!",
"Error:": "Fehler:",
"Request aborted": "Anfrage abgebrochen",
Call: "Aufruf",
Result: "Ergebnis",
"(no result)": "(kein Ergebnis)",
"Waiting for tool result…": "Warte auf Tool-Ergebnis…",
"Call was aborted; no result.": "Aufruf wurde abgebrochen; kein Ergebnis.",
"No session available": "Keine Sitzung verfügbar",
"No session set": "Keine Sitzung gesetzt",
"Preparing tool parameters...": "Bereite Tool-Parameter vor...",
"(no output)": "(keine Ausgabe)",
Input: "Eingabe",
Output: "Ausgabe",
"Waiting for expression...": "Warte auf Ausdruck",
"Writing expression...": "Schreibe Ausdruck...",
Calculating: "Berechne",
"Getting current time in": "Hole aktuelle Zeit in",
"Getting current date and time": "Hole aktuelles Datum und Uhrzeit",
"Waiting for command...": "Warte auf Befehl...",
"Writing command...": "Schreibe Befehl...",
"Running command...": "Führe Befehl aus...",
"Command failed": "Befehl fehlgeschlagen",
"Enter Auth Token": "Auth-Token eingeben",
"Please enter your auth token.": "Bitte geben Sie Ihr Auth-Token ein.",
"Auth token is required for proxy transport":
"Auth-Token ist für Proxy-Transport erforderlich",
// JavaScript REPL strings
"Execution aborted": "Ausführung abgebrochen",
"Code parameter is required": "Code-Parameter ist erforderlich",
"Unknown error": "Unbekannter Fehler",
"Code executed successfully (no output)":
"Code erfolgreich ausgeführt (keine Ausgabe)",
"Execution failed": "Ausführung fehlgeschlagen",
"JavaScript REPL": "JavaScript REPL",
"JavaScript code to execute": "Auszuführender JavaScript-Code",
"Writing JavaScript code...": "Schreibe JavaScript-Code...",
"Executing JavaScript": "Führe JavaScript aus",
"Preparing JavaScript...": "Bereite JavaScript vor...",
"Preparing command...": "Bereite Befehl vor...",
"Preparing calculation...": "Bereite Berechnung vor...",
"Preparing tool...": "Bereite Tool vor...",
"Getting time...": "Hole Zeit...",
// Artifacts strings
"Processing artifact...": "Verarbeite Artefakt...",
"Preparing artifact...": "Bereite Artefakt vor...",
"Processing artifact": "Verarbeite Artefakt",
"Processed artifact": "Artefakt verarbeitet",
"Creating artifact": "Erstelle Artefakt",
"Created artifact": "Artefakt erstellt",
"Updating artifact": "Aktualisiere Artefakt",
"Updated artifact": "Artefakt aktualisiert",
"Rewriting artifact": "Überschreibe Artefakt",
"Rewrote artifact": "Artefakt überschrieben",
"Getting artifact": "Hole Artefakt",
"Got artifact": "Artefakt geholt",
"Deleting artifact": "Lösche Artefakt",
"Deleted artifact": "Artefakt gelöscht",
"Getting logs": "Hole Logs",
"Got logs": "Logs geholt",
"An error occurred": "Ein Fehler ist aufgetreten",
"Copy logs": "Logs kopieren",
"Autoscroll enabled": "Automatisches Scrollen aktiviert",
"Autoscroll disabled": "Automatisches Scrollen deaktiviert",
Processing: "Verarbeitung",
Create: "Erstellen",
Rewrite: "Überschreiben",
Get: "Abrufen",
"Get logs": "Logs abrufen",
"Show artifacts": "Artefakte anzeigen",
"Close artifacts": "Artefakte schließen",
Artifacts: "Artefakte",
"Copy HTML": "HTML kopieren",
"Download HTML": "HTML herunterladen",
"Reload HTML": "HTML neu laden",
"Copy SVG": "SVG kopieren",
"Download SVG": "SVG herunterladen",
"Copy Markdown": "Markdown kopieren",
"Download Markdown": "Markdown herunterladen",
Download: "Herunterladen",
"No logs for {filename}": "Keine Logs für {filename}",
"API Keys Settings": "API-Schlüssel Einstellungen",
Settings: "Einstellungen",
"API Keys": "API-Schlüssel",
Proxy: "Proxy",
"Use CORS Proxy": "CORS-Proxy verwenden",
"Proxy URL": "Proxy-URL",
"Format: The proxy must accept requests as <proxy-url>/?url=<target-url>":
"Format: Der Proxy muss Anfragen als <proxy-url>/?url=<ziel-url> akzeptieren",
"Settings are stored locally in your browser":
"Einstellungen werden lokal in Ihrem Browser gespeichert",
Clear: "Löschen",
"API Key Required": "API-Schlüssel erforderlich",
"Enter your API key for {provider}":
"Geben Sie Ihren API-Schlüssel für {provider} ein",
"Allows browser-based apps to bypass CORS restrictions when calling LLM providers. Required for Z-AI and Anthropic with OAuth token.":
"Ermöglicht browserbasierten Anwendungen, CORS-Einschränkungen beim Aufruf von LLM-Anbietern zu umgehen. Erforderlich für Z-AI und Anthropic mit OAuth-Token.",
Off: "Aus",
Minimal: "Minimal",
Low: "Niedrig",
Medium: "Mittel",
High: "Hoch",
"Storage Permission Required": "Speicherberechtigung erforderlich",
"This app needs persistent storage to save your conversations":
"Diese App benötigt dauerhaften Speicher, um Ihre Konversationen zu speichern",
"Why is this needed?": "Warum wird das benötigt?",
"Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.":
"Ohne dauerhaften Speicher kann Ihr Browser gespeicherte Konversationen löschen, wenn Speicherplatz benötigt wird. Diese Berechtigung stellt sicher, dass Ihr Chatverlauf erhalten bleibt.",
"What this means:": "Was das bedeutet:",
"Your conversations will be saved locally in your browser":
"Ihre Konversationen werden lokal in Ihrem Browser gespeichert",
"Data will not be deleted automatically to free up space":
"Daten werden nicht automatisch gelöscht, um Speicherplatz freizugeben",
"You can still manually clear data at any time":
"Sie können Daten jederzeit manuell löschen",
"No data is sent to external servers":
"Keine Daten werden an externe Server gesendet",
"Continue Anyway": "Trotzdem fortfahren",
"Requesting...": "Anfrage läuft...",
"Grant Permission": "Berechtigung erteilen",
Sessions: "Sitzungen",
"Load a previous conversation": "Frühere Konversation laden",
"No sessions yet": "Noch keine Sitzungen",
"Delete this session?": "Diese Sitzung löschen?",
Today: "Heute",
Yesterday: "Gestern",
"{days} days ago": "vor {days} Tagen",
messages: "Nachrichten",
tokens: "Tokens",
Delete: "Löschen",
"Drop files here": "Dateien hier ablegen",
"Command failed:": "Befehl fehlgeschlagen:",
// Providers & Models
"Providers & Models": "Anbieter & Modelle",
"Cloud Providers": "Cloud-Anbieter",
"Cloud LLM providers with predefined models. API keys are stored locally in your browser.":
"Cloud-LLM-Anbieter mit vordefinierten Modellen. API-Schlüssel werden lokal in Ihrem Browser gespeichert.",
"Custom Providers": "Benutzerdefinierte Anbieter",
"User-configured servers with auto-discovered or manually defined models.":
"Benutzerkonfigurierte Server mit automatisch erkannten oder manuell definierten Modellen.",
"Add Provider": "Anbieter hinzufügen",
"No custom providers configured. Click 'Add Provider' to get started.":
"Keine benutzerdefinierten Anbieter konfiguriert. Klicken Sie auf 'Anbieter hinzufügen', um zu beginnen.",
"auto-discovered": "automatisch erkannt",
Refresh: "Aktualisieren",
Edit: "Bearbeiten",
"Are you sure you want to delete this provider?":
"Sind Sie sicher, dass Sie diesen Anbieter löschen möchten?",
"Edit Provider": "Anbieter bearbeiten",
"Provider Name": "Anbietername",
"e.g., My Ollama Server": "z.B. Mein Ollama Server",
"Provider Type": "Anbietertyp",
"Base URL": "Basis-URL",
"e.g., http://localhost:11434": "z.B. http://localhost:11434",
"API Key (Optional)": "API-Schlüssel (Optional)",
"Leave empty if not required": "Leer lassen, falls nicht erforderlich",
"Test Connection": "Verbindung testen",
Discovered: "Erkannt",
Models: "Modelle",
models: "Modelle",
and: "und",
more: "mehr",
"For manual provider types, add models after saving the provider.":
"Für manuelle Anbietertypen fügen Sie Modelle nach dem Speichern des Anbieters hinzu.",
"Please fill in all required fields":
"Bitte füllen Sie alle erforderlichen Felder aus",
"Failed to save provider": "Fehler beim Speichern des Anbieters",
"OpenAI Completions Compatible": "OpenAI Completions Kompatibel",
"OpenAI Responses Compatible": "OpenAI Responses Kompatibel",
"Anthropic Messages Compatible": "Anthropic Messages Kompatibel",
"Checking...": "Überprüfe...",
Disconnected: "Getrennt",
},
};
setTranslations(translations);
export * from "@mariozechner/mini-lit/dist/i18n.js";

View file

@ -1,306 +0,0 @@
import { LMStudioClient } from "@lmstudio/sdk";
import type { Model } from "@mariozechner/pi-ai";
import { Ollama } from "ollama/browser";
/**
* Discover models from an Ollama server.
* @param baseUrl - Base URL of the Ollama server (e.g., "http://localhost:11434")
* @param apiKey - Optional API key (currently unused by Ollama)
* @returns Array of discovered models
*/
export async function discoverOllamaModels(
baseUrl: string,
_apiKey?: string,
): Promise<Model<any>[]> {
try {
// Create Ollama client
const ollama = new Ollama({ host: baseUrl });
// 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: any) => {
try {
// Get model details
const details = await ollama.show({
model: model.name,
});
// Check capabilities - filter out models that don't support tools
const capabilities: string[] = (details as any).capabilities || [];
if (!capabilities.includes("tools")) {
console.debug(
`Skipping model ${model.name}: does not support tools`,
);
return null;
}
// 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);
// Ollama caps max tokens at 10x context length
const maxTokens = contextWindow * 10;
// Ollama only supports completions API
const ollamaModel: Model<any> = {
id: model.name,
name: model.name,
api: "openai-completions" as any,
provider: "", // Will be set by caller
baseUrl: `${baseUrl}/v1`,
reasoning: capabilities.includes("thinking"),
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;
}
},
);
const results = await Promise.all(ollamaModelPromises);
return results.filter((m): m is Model<any> => m !== null);
} catch (err) {
console.error("Failed to discover Ollama models:", err);
throw new Error(
`Ollama discovery failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/**
* Discover models from a llama.cpp server via OpenAI-compatible /v1/models endpoint.
* @param baseUrl - Base URL of the llama.cpp server (e.g., "http://localhost:8080")
* @param apiKey - Optional API key
* @returns Array of discovered models
*/
export async function discoverLlamaCppModels(
baseUrl: string,
apiKey?: string,
): Promise<Model<any>[]> {
try {
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
const response = await fetch(`${baseUrl}/v1/models`, {
method: "GET",
headers,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (!data.data || !Array.isArray(data.data)) {
throw new Error("Invalid response format from llama.cpp server");
}
return data.data.map((model: any) => {
// llama.cpp doesn't always provide context window info
const contextWindow = model.context_length || 8192;
const maxTokens = model.max_tokens || 4096;
const llamaModel: Model<any> = {
id: model.id,
name: model.id,
api: "openai-completions" as any,
provider: "", // Will be set by caller
baseUrl: `${baseUrl}/v1`,
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: contextWindow,
maxTokens: maxTokens,
};
return llamaModel;
});
} catch (err) {
console.error("Failed to discover llama.cpp models:", err);
throw new Error(
`llama.cpp discovery failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/**
* Discover models from a vLLM server via OpenAI-compatible /v1/models endpoint.
* @param baseUrl - Base URL of the vLLM server (e.g., "http://localhost:8000")
* @param apiKey - Optional API key
* @returns Array of discovered models
*/
export async function discoverVLLMModels(
baseUrl: string,
apiKey?: string,
): Promise<Model<any>[]> {
try {
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
const response = await fetch(`${baseUrl}/v1/models`, {
method: "GET",
headers,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (!data.data || !Array.isArray(data.data)) {
throw new Error("Invalid response format from vLLM server");
}
return data.data.map((model: any) => {
// vLLM provides max_model_len which is the context window
const contextWindow = model.max_model_len || 8192;
const maxTokens = Math.min(contextWindow, 4096); // Cap max tokens
const vllmModel: Model<any> = {
id: model.id,
name: model.id,
api: "openai-completions" as any,
provider: "", // Will be set by caller
baseUrl: `${baseUrl}/v1`,
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: contextWindow,
maxTokens: maxTokens,
};
return vllmModel;
});
} catch (err) {
console.error("Failed to discover vLLM models:", err);
throw new Error(
`vLLM discovery failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/**
* Discover models from an LM Studio server using the LM Studio SDK.
* @param baseUrl - Base URL of the LM Studio server (e.g., "http://localhost:1234")
* @param apiKey - Optional API key (unused for LM Studio SDK)
* @returns Array of discovered models
*/
export async function discoverLMStudioModels(
baseUrl: string,
_apiKey?: string,
): Promise<Model<any>[]> {
try {
// Extract host and port from baseUrl
const url = new URL(baseUrl);
const port = url.port ? parseInt(url.port, 10) : 1234;
// Create LM Studio client
const client = new LMStudioClient({
baseUrl: `ws://${url.hostname}:${port}`,
});
// List all downloaded models
const models = await client.system.listDownloadedModels();
// Filter to only LLM models and map to our Model format
return models
.filter((model) => model.type === "llm")
.map((model) => {
const contextWindow = model.maxContextLength;
// Use 10x context length like Ollama does
const maxTokens = contextWindow;
const lmStudioModel: Model<any> = {
id: model.path,
name: model.displayName || model.path,
api: "openai-completions" as any,
provider: "", // Will be set by caller
baseUrl: `${baseUrl}/v1`,
reasoning: model.trainedForToolUse || false,
input: model.vision ? ["text", "image"] : ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: contextWindow,
maxTokens: maxTokens,
};
return lmStudioModel;
});
} catch (err) {
console.error("Failed to discover LM Studio models:", err);
throw new Error(
`LM Studio discovery failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/**
* Convenience function to discover models based on provider type.
* @param type - Provider type
* @param baseUrl - Base URL of the server
* @param apiKey - Optional API key
* @returns Array of discovered models
*/
export async function discoverModels(
type: "ollama" | "llama.cpp" | "vllm" | "lmstudio",
baseUrl: string,
apiKey?: string,
): Promise<Model<any>[]> {
switch (type) {
case "ollama":
return discoverOllamaModels(baseUrl, apiKey);
case "llama.cpp":
return discoverLlamaCppModels(baseUrl, apiKey);
case "vllm":
return discoverVLLMModels(baseUrl, apiKey);
case "lmstudio":
return discoverLMStudioModels(baseUrl, apiKey);
}
}

View file

@ -1,150 +0,0 @@
import type {
Api,
Context,
Model,
SimpleStreamOptions,
} from "@mariozechner/pi-ai";
import { streamSimple } from "@mariozechner/pi-ai";
/**
* Centralized proxy decision logic.
*
* Determines whether to use a CORS proxy for LLM API requests based on:
* - Provider name
* - API key pattern (for providers where it matters)
*/
/**
* Check if a provider/API key combination requires a CORS proxy.
*
* @param provider - Provider name (e.g., "anthropic", "openai", "zai")
* @param apiKey - API key for the provider
* @returns true if proxy is required, false otherwise
*/
export function shouldUseProxyForProvider(
provider: string,
apiKey: string,
): boolean {
switch (provider.toLowerCase()) {
case "zai":
// Z-AI always requires proxy
return true;
case "anthropic":
// Anthropic OAuth tokens (sk-ant-oat-*) require proxy
// Regular API keys (sk-ant-api-*) do NOT require proxy
return apiKey.startsWith("sk-ant-oat");
// These providers work without proxy
case "openai":
case "google":
case "groq":
case "openrouter":
case "cerebras":
case "xai":
case "ollama":
case "lmstudio":
return false;
// Unknown providers - assume no proxy needed
// This allows new providers to work by default
default:
return false;
}
}
/**
* Apply CORS proxy to a model's baseUrl if needed.
*
* @param model - The model to potentially proxy
* @param apiKey - API key for the provider
* @param proxyUrl - CORS proxy URL (e.g., "https://proxy.mariozechner.at/proxy")
* @returns Model with modified baseUrl if proxy is needed, otherwise original model
*/
export function applyProxyIfNeeded<T extends Api>(
model: Model<T>,
apiKey: string,
proxyUrl?: string,
): Model<T> {
// If no proxy URL configured, return original model
if (!proxyUrl) {
return model;
}
// If model has no baseUrl, can't proxy it
if (!model.baseUrl) {
return model;
}
// Check if this provider/key needs proxy
if (!shouldUseProxyForProvider(model.provider, apiKey)) {
return model;
}
// Apply proxy to baseUrl
return {
...model,
baseUrl: `${proxyUrl}/?url=${encodeURIComponent(model.baseUrl)}`,
};
}
/**
* Check if an error is likely a CORS error.
*
* CORS errors in browsers typically manifest as:
* - TypeError with message "Failed to fetch"
* - NetworkError
*
* @param error - The error to check
* @returns true if error is likely a CORS error
*/
export function isCorsError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
// Check for common CORS error patterns
const message = error.message.toLowerCase();
// "Failed to fetch" is the standard CORS error in most browsers
if (error.name === "TypeError" && message.includes("failed to fetch")) {
return true;
}
// Some browsers report "NetworkError"
if (error.name === "NetworkError") {
return true;
}
// CORS-specific messages
if (message.includes("cors") || message.includes("cross-origin")) {
return true;
}
return false;
}
/**
* Create a streamFn that applies CORS proxy when needed.
* Reads proxy settings from storage on each call.
*
* @param getProxyUrl - Async function to get current proxy URL (or undefined if disabled)
* @returns A streamFn compatible with Agent's streamFn option
*/
export function createStreamFn(getProxyUrl: () => Promise<string | undefined>) {
return async (
model: Model<any>,
context: Context,
options?: SimpleStreamOptions,
) => {
const apiKey = options?.apiKey;
const proxyUrl = await getProxyUrl();
if (!apiKey || !proxyUrl) {
return streamSimple(model, context, options);
}
const proxiedModel = applyProxyIfNeeded(model, apiKey, proxyUrl);
return streamSimple(proxiedModel, context, options);
};
}

File diff suppressed because one or more lines are too long

View file

@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"useDefineForClassFields": false,
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src/**/*"]
}

Some files were not shown because too many files have changed in this diff Show more