mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 08:03:39 +00:00
Integrate JailJS for CSP-restricted execution in browser extension
Major changes: - Migrate browser-extension to use web-ui package (85% code reduction) - Add JailJS content script with ES6+ transform support - Expose DOM constructors (Event, KeyboardEvent, etc.) to JailJS - Support top-level await by wrapping code in async IIFE - Add returnFile() support in JailJS execution - Refactor KeyStore into pluggable storage-adapter pattern - Make ChatPanel configurable with sandboxUrlProvider and additionalTools - Update jailjs to 0.1.1 Files deleted (33 duplicate files): - All browser-extension components, dialogs, state, tools, utils - Now using web-ui versions via @mariozechner/pi-web-ui Files added: - packages/browser-extension/src/content.ts (JailJS content script) - packages/web-ui/src/state/storage-adapter.ts - packages/web-ui/src/state/key-store.ts Browser extension now has only 5 source files (down from 38).
This commit is contained in:
parent
f2eecb78d2
commit
aaea0f4600
61 changed files with 633 additions and 9270 deletions
69
package-lock.json
generated
69
package-lock.json
generated
|
|
@ -10,6 +10,9 @@
|
|||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@mariozechner/jailjs": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.1.4",
|
||||
"@types/node": "^22.10.5",
|
||||
|
|
@ -31,6 +34,61 @@
|
|||
"anthropic-ai-sdk": "bin/cli"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
|
||||
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
|
||||
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.4"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/standalone": {
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/standalone/-/standalone-7.28.4.tgz",
|
||||
"integrity": "sha512-Qc1BNCfuJZBKs2SC5lqRmSYOw7Ka0X7urZQ7oVsGIax4eGDUIHX+CDg752N4jDxC2rbBh3li098ReGOtjT0x4g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
|
||||
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.2.5.tgz",
|
||||
|
|
@ -735,6 +793,16 @@
|
|||
"@lit-labs/ssr-dom-shim": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mariozechner/jailjs": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/jailjs/-/jailjs-0.1.1.tgz",
|
||||
"integrity": "sha512-vC4+ZDNJRkQVCnBPKKmawWS1BIEhV34O7qphm3nWZYvQb5ikSfgpZY2SzKkJrAo6LKmQe8Jo4fvggu7fPGMcZg==",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.26.10",
|
||||
"@babel/standalone": "^7.28.4",
|
||||
"@babel/types": "^7.26.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@mariozechner/mini-lit": {
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/mini-lit/-/mini-lit-0.1.7.tgz",
|
||||
|
|
@ -5291,6 +5359,7 @@
|
|||
"name": "@mariozechner/pi-reader-extension",
|
||||
"version": "0.5.43",
|
||||
"dependencies": {
|
||||
"@mariozechner/jailjs": "^0.1.0",
|
||||
"@mariozechner/mini-lit": "^0.1.7",
|
||||
"@mariozechner/pi-ai": "^0.5.43",
|
||||
"@mariozechner/pi-web-ui": "^0.5.43",
|
||||
|
|
|
|||
|
|
@ -31,5 +31,8 @@
|
|||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"version": "0.0.1"
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@mariozechner/jailjs": "^0.1.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
packages/browser-extension/dist-chrome.zip
Normal file
BIN
packages/browser-extension/dist-chrome.zip
Normal file
Binary file not shown.
|
|
@ -29,6 +29,14 @@
|
|||
"http://localhost/*",
|
||||
"http://127.0.0.1/*"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle",
|
||||
"all_frames": false
|
||||
}
|
||||
],
|
||||
"sandbox": {
|
||||
"pages": ["sandbox.html"]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -24,6 +24,14 @@
|
|||
"open_at_install": false
|
||||
},
|
||||
"content_security_policy": "script-src 'self' 'wasm-unsafe-eval' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://unpkg.com https://cdn.skypack.dev; object-src 'self'",
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle",
|
||||
"all_frames": false
|
||||
}
|
||||
],
|
||||
"permissions": [
|
||||
"storage",
|
||||
"activeTab",
|
||||
|
|
|
|||
|
|
@ -15,8 +15,10 @@
|
|||
"check": "npm run typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mariozechner/jailjs": "^0.1.0",
|
||||
"@mariozechner/mini-lit": "^0.1.7",
|
||||
"@mariozechner/pi-ai": "^0.5.43",
|
||||
"@mariozechner/pi-web-ui": "^0.5.43",
|
||||
"docx-preview": "^0.3.7",
|
||||
"js-interpreter": "^6.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ const outDir = join(packageRoot, `dist-${targetBrowser}`);
|
|||
|
||||
const entryPoints = {
|
||||
sidepanel: join(packageRoot, "src/sidepanel.ts"),
|
||||
background: join(packageRoot, "src/background.ts")
|
||||
background: join(packageRoot, "src/background.ts"),
|
||||
content: join(packageRoot, "src/content.ts")
|
||||
};
|
||||
|
||||
rmSync(outDir, { recursive: true, force: true });
|
||||
|
|
|
|||
|
|
@ -1,204 +0,0 @@
|
|||
import { Badge, html } from "@mariozechner/mini-lit";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import "./components/AgentInterface.js";
|
||||
import { AgentSession, type AgentSessionState, type ThinkingLevel } from "./state/agent-session.js";
|
||||
import { ArtifactsPanel } from "./tools/artifacts/index.js";
|
||||
import { browserJavaScriptTool, createJavaScriptReplTool } from "./tools/index.js";
|
||||
import { registerToolRenderer } from "./tools/renderer-registry.js";
|
||||
import { getAuthToken } from "./utils/auth-token.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() private session!: AgentSession;
|
||||
@state() private artifactsPanel!: ArtifactsPanel;
|
||||
@state() private hasArtifacts = false;
|
||||
@state() private artifactCount = 0;
|
||||
@state() private showArtifactsPanel = false;
|
||||
@state() private windowWidth = window.innerWidth;
|
||||
@property({ type: String }) systemPrompt = "You are a helpful AI assistant.";
|
||||
|
||||
private resizeHandler = () => {
|
||||
this.windowWidth = window.innerWidth;
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
override async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// Listen to window resize
|
||||
window.addEventListener("resize", this.resizeHandler);
|
||||
|
||||
// Ensure panel fills height and allows flex layout
|
||||
this.style.display = "flex";
|
||||
this.style.flexDirection = "column";
|
||||
this.style.height = "100%";
|
||||
this.style.minHeight = "0";
|
||||
|
||||
// Create JavaScript REPL tool with attachments provider
|
||||
const javascriptReplTool = createJavaScriptReplTool();
|
||||
|
||||
// Set up artifacts panel
|
||||
this.artifactsPanel = new ArtifactsPanel();
|
||||
registerToolRenderer("artifacts", this.artifactsPanel);
|
||||
|
||||
// Attachments provider for both REPL and artifacts
|
||||
const getAttachments = () => {
|
||||
// Get all attachments from conversation messages
|
||||
const attachments: any[] = [];
|
||||
for (const message of this.session.state.messages) {
|
||||
if (message.role === "user") {
|
||||
const content = Array.isArray(message.content) ? message.content : [message.content];
|
||||
for (const block of content) {
|
||||
if (typeof block !== "string" && block.type === "image") {
|
||||
attachments.push({
|
||||
id: `image-${attachments.length}`,
|
||||
fileName: "image.png",
|
||||
mimeType: block.mimeType || "image/png",
|
||||
size: 0,
|
||||
content: block.data,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return attachments;
|
||||
};
|
||||
|
||||
javascriptReplTool.attachmentsProvider = getAttachments;
|
||||
this.artifactsPanel.attachmentsProvider = getAttachments;
|
||||
|
||||
this.artifactsPanel.onArtifactsChange = () => {
|
||||
const count = this.artifactsPanel.artifacts?.size ?? 0;
|
||||
const created = count > this.artifactCount;
|
||||
this.hasArtifacts = count > 0;
|
||||
this.artifactCount = count;
|
||||
|
||||
// Auto-open when new artifacts are created
|
||||
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();
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
systemPrompt: this.systemPrompt,
|
||||
model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
|
||||
tools: [browserJavaScriptTool, javascriptReplTool, this.artifactsPanel.tool],
|
||||
thinkingLevel: "off" as ThinkingLevel,
|
||||
messages: [],
|
||||
} satisfies Partial<AgentSessionState>;
|
||||
// initialState = { ...initialState, ...(simpleHtml as any) };
|
||||
// initialState = { ...initialState, ...(longSession as any) };
|
||||
|
||||
// Create agent session first so attachments provider works
|
||||
this.session = new AgentSession({
|
||||
initialState,
|
||||
authTokenProvider: async () => getAuthToken(),
|
||||
transportMode: "direct", // Use direct mode by default (API keys from KeyStore)
|
||||
});
|
||||
|
||||
// Reconstruct artifacts panel from initial messages (session must exist first)
|
||||
await this.artifactsPanel.reconstructFromMessages(initialState.messages);
|
||||
this.hasArtifacts = this.artifactsPanel.artifacts.size > 0;
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("resize", this.resizeHandler);
|
||||
}
|
||||
|
||||
// Expose method to toggle artifacts panel
|
||||
public toggleArtifactsPanel() {
|
||||
this.showArtifactsPanel = !this.showArtifactsPanel;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
// Check if artifacts panel is currently visible
|
||||
public get artifactsPanelVisible(): boolean {
|
||||
return this.showArtifactsPanel;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.session) {
|
||||
return html`<div class="flex items-center justify-center h-full">
|
||||
<div class="text-muted-foreground">Loading...</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%;"}">
|
||||
<agent-interface
|
||||
.session=${this.session}
|
||||
.enableAttachments=${true}
|
||||
.enableModelSelector=${true}
|
||||
.enableThinking=${true}
|
||||
.showThemeToggle=${false}
|
||||
.showDebugToggle=${false}
|
||||
></agent-interface>
|
||||
</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>
|
||||
${
|
||||
this.artifactCount > 1
|
||||
? html`<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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,10 @@
|
|||
/* biome-ignore lint/suspicious/noUnknownAtRules: Tailwind 4 source directive */
|
||||
@source "../../../node_modules/@mariozechner/mini-lit/dist";
|
||||
|
||||
/* Tell Tailwind to scan web-ui components */
|
||||
/* biome-ignore lint/suspicious/noUnknownAtRules: Tailwind 4 source directive */
|
||||
@source "../../web-ui/src";
|
||||
|
||||
/* Import Tailwind */
|
||||
/* biome-ignore lint/correctness/noInvalidPositionAtImportRule: fuck you */
|
||||
@import "tailwindcss";
|
||||
|
|
|
|||
|
|
@ -1,312 +0,0 @@
|
|||
import { html } from "@mariozechner/mini-lit";
|
||||
import type { ToolResultMessage, Usage } from "@mariozechner/pi-ai";
|
||||
import { LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators.js";
|
||||
import { ApiKeysDialog } from "../dialogs/ApiKeysDialog.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 type { AgentSession, AgentSessionEvent } from "../state/agent-session.js";
|
||||
import { keyStore } from "../state/KeyStore.js";
|
||||
import "./StreamingMessageContainer.js";
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
import { formatUsage } from "../utils/format.js";
|
||||
import { i18n } from "../utils/i18n.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?: AgentSession;
|
||||
@property() enableAttachments = true;
|
||||
@property() enableModelSelector = true;
|
||||
@property() enableThinking = true;
|
||||
@property() showThemeToggle = false;
|
||||
@property() showDebugToggle = false;
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// Attach debug listener if session provided
|
||||
if (this.session) {
|
||||
this.session = this.session; // explicitly set to trigger subscription
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
this._unsubscribeSession = this.session.subscribe(async (ev: AgentSessionEvent) => {
|
||||
if (ev.type === "state-update") {
|
||||
if (this._streamingContainer) {
|
||||
this._streamingContainer.isStreaming = ev.state.isStreaming;
|
||||
this._streamingContainer.setMessage(ev.state.streamMessage, !ev.state.isStreaming);
|
||||
}
|
||||
this.requestUpdate();
|
||||
} else if (ev.type === "error-no-model") {
|
||||
// TODO show some UI feedback
|
||||
} else if (ev.type === "error-no-api-key") {
|
||||
// Open API keys dialog to configure the missing key
|
||||
ApiKeysDialog.open();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
let apiKey = await keyStore.getKey(provider);
|
||||
|
||||
// If no API key, open the API keys dialog
|
||||
if (!apiKey) {
|
||||
await ApiKeysDialog.open();
|
||||
// Check again after dialog closes
|
||||
apiKey = await keyStore.getKey(provider);
|
||||
// If still no API key, abort the send
|
||||
if (!apiKey) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
await this.session?.prompt(input, attachments);
|
||||
}
|
||||
|
||||
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}
|
||||
></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}
|
||||
></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,
|
||||
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 ? 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}
|
||||
.showThinking=${this.enableThinking}
|
||||
.onSend=${(input: string, attachments: Attachment[]) => {
|
||||
this.sendMessage(input, attachments);
|
||||
}}
|
||||
.onAbort=${() => session.abort()}
|
||||
.onModelSelect=${() => {
|
||||
ModelSelector.open(state.model, (model) => session.setModel(model));
|
||||
}}
|
||||
.onThinkingChange=${
|
||||
this.enableThinking
|
||||
? (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);
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
import { html, icon } from "@mariozechner/mini-lit";
|
||||
import { LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.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 isDocx =
|
||||
this.attachment.mimeType?.includes("wordprocessingml") ||
|
||||
this.attachment.fileName.toLowerCase().endsWith(".docx");
|
||||
const isPptx =
|
||||
this.attachment.mimeType?.includes("presentationml") ||
|
||||
this.attachment.fileName.toLowerCase().endsWith(".pptx");
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
import { html, icon } from "@mariozechner/mini-lit";
|
||||
import { LitElement } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { Check, Copy } from "lucide";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
|
||||
export class ConsoleBlock extends LitElement {
|
||||
@property() content: string = "";
|
||||
@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() {
|
||||
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 text-foreground font-mono whitespace-pre-wrap">
|
||||
${this.content || ""}</pre
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Register custom element
|
||||
if (!customElements.get("console-block")) {
|
||||
customElements.define("console-block", ConsoleBlock);
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
import { type BaseComponentProps, fc, html } from "@mariozechner/mini-lit";
|
||||
import { type Ref, ref } from "lit/directives/ref.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
|
||||
export type InputType = "text" | "email" | "password" | "number" | "url" | "tel" | "search";
|
||||
export type InputSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface InputProps extends BaseComponentProps {
|
||||
type?: InputType;
|
||||
size?: InputSize;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
name?: string;
|
||||
autocomplete?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
onInput?: (e: Event) => void;
|
||||
onChange?: (e: Event) => void;
|
||||
onKeyDown?: (e: KeyboardEvent) => void;
|
||||
onKeyUp?: (e: KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
export const Input = fc<InputProps>(
|
||||
({
|
||||
type = "text",
|
||||
size = "md",
|
||||
value = "",
|
||||
placeholder = "",
|
||||
label = "",
|
||||
error = "",
|
||||
disabled = false,
|
||||
required = false,
|
||||
name = "",
|
||||
autocomplete = "",
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
inputRef,
|
||||
onInput,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
className = "",
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
sm: "h-8 px-3 py-1 text-sm",
|
||||
md: "h-9 px-3 py-1 text-sm md:text-sm",
|
||||
lg: "h-10 px-4 py-1 text-base",
|
||||
};
|
||||
|
||||
const baseClasses =
|
||||
"flex w-full min-w-0 rounded-md border bg-transparent text-foreground shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium";
|
||||
const interactionClasses =
|
||||
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground";
|
||||
const focusClasses = "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]";
|
||||
const darkClasses = "dark:bg-input/30";
|
||||
const stateClasses = error
|
||||
? "border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40"
|
||||
: "border-input";
|
||||
const disabledClasses = "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50";
|
||||
|
||||
const handleInput = (e: Event) => {
|
||||
onInput?.(e);
|
||||
};
|
||||
|
||||
const handleChange = (e: Event) => {
|
||||
onChange?.(e);
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="flex flex-col gap-1.5 ${className}">
|
||||
${
|
||||
label
|
||||
? html`
|
||||
<label class="text-sm font-medium text-foreground">
|
||||
${label} ${required ? html`<span class="text-destructive">${i18n("*")}</span>` : ""}
|
||||
</label>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
<input
|
||||
type="${type}"
|
||||
class="${baseClasses} ${
|
||||
sizeClasses[size]
|
||||
} ${interactionClasses} ${focusClasses} ${darkClasses} ${stateClasses} ${disabledClasses}"
|
||||
.value=${value}
|
||||
placeholder="${placeholder}"
|
||||
?disabled=${disabled}
|
||||
?required=${required}
|
||||
?aria-invalid=${!!error}
|
||||
name="${name}"
|
||||
autocomplete="${autocomplete}"
|
||||
min="${min ?? ""}"
|
||||
max="${max ?? ""}"
|
||||
step="${step ?? ""}"
|
||||
@input=${handleInput}
|
||||
@change=${handleChange}
|
||||
@keydown=${onKeyDown}
|
||||
@keyup=${onKeyUp}
|
||||
${inputRef ? ref(inputRef) : ""}
|
||||
/>
|
||||
${error ? html`<span class="text-sm text-destructive">${error}</span>` : ""}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
);
|
||||
|
|
@ -1,272 +0,0 @@
|
|||
import { Button, html, icon } from "@mariozechner/mini-lit";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
import { Loader2, Paperclip, Send, Sparkles, Square } from "lucide";
|
||||
import "./AttachmentTile.js";
|
||||
import { type Attachment, loadAttachment } from "../utils/attachment-utils.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
|
||||
@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);
|
||||
this.updateComplete.then(() => {
|
||||
const textarea = this.textareaRef.value;
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@property() isStreaming = false;
|
||||
@property() currentModel?: Model<any>;
|
||||
@property() showAttachmentButton = true;
|
||||
@property() showModelSelector = true;
|
||||
@property() showThinking = false; // Disabled for now
|
||||
@property() onInput?: (value: string) => void;
|
||||
@property() onSend?: (input: string, attachments: Attachment[]) => void;
|
||||
@property() onAbort?: () => void;
|
||||
@property() onModelSelect?: () => 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;
|
||||
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;
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
||||
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 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 adjustTextareaHeight() {
|
||||
const textarea = this.textareaRef.value;
|
||||
if (textarea) {
|
||||
// Reset height to auto to get accurate scrollHeight
|
||||
textarea.style.height = "auto";
|
||||
// Only adjust if there's content, otherwise keep minimal height
|
||||
if (this.value.trim()) {
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
const textarea = this.textareaRef.value;
|
||||
if (textarea) {
|
||||
// Don't adjust height on first render - let it be minimal
|
||||
textarea.focus();
|
||||
}
|
||||
}
|
||||
|
||||
override updated() {
|
||||
// Only adjust height when component updates if there's content
|
||||
if (this.value) {
|
||||
this.adjustTextareaHeight();
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="bg-card rounded-xl border border-border shadow-sm">
|
||||
<!-- 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;"
|
||||
.value=${this.value}
|
||||
@input=${this.handleTextareaInput}
|
||||
@keydown=${this.handleKeyDown}
|
||||
${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 quick action buttons -->
|
||||
<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"),
|
||||
})}
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
import { html } from "@mariozechner/mini-lit";
|
||||
import type {
|
||||
AgentTool,
|
||||
AssistantMessage as AssistantMessageType,
|
||||
Message,
|
||||
ToolResultMessage as ToolResultMessageType,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { LitElement, type TemplateResult } from "lit";
|
||||
import { property } from "lit/decorators.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
|
||||
export class MessageList extends LitElement {
|
||||
@property({ type: Array }) messages: Message[] = [];
|
||||
@property({ type: Array }) tools: AgentTool[] = [];
|
||||
@property({ type: Object }) pendingToolCalls?: Set<string>;
|
||||
@property({ type: Boolean }) isStreaming: boolean = false;
|
||||
|
||||
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) {
|
||||
if (msg.role === "user") {
|
||||
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=${this.isStreaming}
|
||||
.pendingToolCalls=${this.pendingToolCalls}
|
||||
.toolResultsById=${resultByCallId}
|
||||
.hideToolCalls=${false}
|
||||
></assistant-message>`,
|
||||
});
|
||||
index++;
|
||||
} else {
|
||||
// Skip standalone toolResult messages; they are rendered via paired tool-message above
|
||||
// For completeness, other roles are not expected
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
|
@ -1,310 +0,0 @@
|
|||
import { Button, html, icon } from "@mariozechner/mini-lit";
|
||||
import type {
|
||||
AgentTool,
|
||||
AssistantMessage as AssistantMessageType,
|
||||
ToolCall,
|
||||
ToolResultMessage as ToolResultMessageType,
|
||||
UserMessage as UserMessageType,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import type { AgentToolResult } from "@mariozechner/pi-ai/dist/agent/types.js";
|
||||
import { LitElement, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { Bug, Loader, Wrench } from "lucide";
|
||||
import { renderToolParams, renderToolResult } from "../tools/index.js";
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
import { formatUsage } from "../utils/format.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
|
||||
export type UserMessageWithAttachments = UserMessageType & { attachments?: Attachment[] };
|
||||
export type AppMessage = AssistantMessageType | UserMessageWithAttachments | ToolResultMessageType;
|
||||
|
||||
@customElement("user-message")
|
||||
export class UserMessage extends LitElement {
|
||||
@property({ type: Object }) message!: UserMessageWithAttachments;
|
||||
|
||||
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="py-2 px-4 border-l-4 border-accent-foreground/60 text-primary-foreground">
|
||||
<markdown-block .content=${content}></markdown-block>
|
||||
${
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
|
||||
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` <markdown-block .content=${chunk.thinking} .isThinking=${true}></markdown-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);
|
||||
const aborted = !pending && !result && !this.isStreaming;
|
||||
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
|
||||
? 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: String }) result?: AgentToolResult<any>;
|
||||
@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 output = this.pretty(this.result?.output);
|
||||
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;
|
||||
@state() private _showDebug = false;
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
}
|
||||
|
||||
private toggleDebug = () => {
|
||||
this._showDebug = !this._showDebug;
|
||||
};
|
||||
|
||||
override render() {
|
||||
const toolLabel = this.tool?.label || this.toolCall.name;
|
||||
const toolName = this.tool?.name || this.toolCall.name;
|
||||
const isError = this.result?.isError === true;
|
||||
const hasResult = !!this.result;
|
||||
|
||||
let statusIcon: TemplateResult;
|
||||
if (this.pending || (this.isStreaming && !hasResult)) {
|
||||
statusIcon = html`<span class="inline-block text-muted-foreground animate-spin">${icon(Loader, "sm")}</span>`;
|
||||
} else if (this.aborted && !hasResult) {
|
||||
statusIcon = html`<span class="inline-block text-destructive">${icon(Wrench, "sm")}</span>`;
|
||||
} else if (hasResult && isError) {
|
||||
statusIcon = html`<span class="inline-block text-destructive">${icon(Wrench, "sm")}</span>`;
|
||||
} else if (hasResult) {
|
||||
statusIcon = html`<span class="inline-block text-muted-foreground">${icon(Wrench, "sm")}</span>`;
|
||||
} else {
|
||||
statusIcon = html`<span class="inline-block text-muted-foreground">${icon(Wrench, "sm")}</span>`;
|
||||
}
|
||||
|
||||
// Normalize error text
|
||||
let errorMessage = this.result?.output || "";
|
||||
if (isError) {
|
||||
try {
|
||||
const parsed = JSON.parse(errorMessage);
|
||||
if ((parsed as any).error) errorMessage = (parsed as any).error;
|
||||
else if ((parsed as any).message) errorMessage = (parsed as any).message;
|
||||
} catch {}
|
||||
errorMessage = errorMessage.replace(/^(Tool )?Error:\s*/i, "");
|
||||
errorMessage = errorMessage.replace(/^Error:\s*/i, "");
|
||||
}
|
||||
|
||||
const paramsTpl = renderToolParams(
|
||||
toolName,
|
||||
this.toolCall.arguments,
|
||||
this.isStreaming || (this.pending && !hasResult),
|
||||
);
|
||||
const resultTpl =
|
||||
hasResult && !isError ? renderToolResult(toolName, this.toolCall.arguments, this.result!) : undefined;
|
||||
|
||||
return html`
|
||||
<div class="p-2.5 border border-border rounded-md bg-card text-card-foreground">
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div class="flex items-center gap-2">
|
||||
${statusIcon}
|
||||
<span class="font-medium">${toolLabel}</span>
|
||||
</div>
|
||||
${Button({
|
||||
variant: this._showDebug ? "default" : "ghost",
|
||||
size: "sm",
|
||||
onClick: this.toggleDebug,
|
||||
children: icon(Bug, "sm"),
|
||||
className: "text-muted-foreground",
|
||||
})}
|
||||
</div>
|
||||
|
||||
${
|
||||
this._showDebug
|
||||
? html`<tool-message-debug
|
||||
.callArgs=${this.toolCall.arguments}
|
||||
.result=${this.result}
|
||||
.hasResult=${!!this.result}
|
||||
></tool-message-debug>`
|
||||
: html`
|
||||
<div class="mt-2 text-sm text-muted-foreground">${paramsTpl}</div>
|
||||
${
|
||||
this.pending && !hasResult
|
||||
? html`<div class="mt-2 text-sm text-muted-foreground">${i18n("Waiting for tool result…")}</div>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
this.aborted && !hasResult
|
||||
? html`<div class="mt-2 text-sm text-muted-foreground">${i18n("Call was aborted; no result.")}</div>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
hasResult && isError
|
||||
? html`<div class="mt-2 p-2 border border-destructive rounded bg-destructive/10 text-sm text-destructive">
|
||||
${errorMessage}
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
${resultTpl ? html`<div class="mt-2">${resultTpl}</div>` : ""}
|
||||
`
|
||||
}
|
||||
</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>`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,470 +0,0 @@
|
|||
import { LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
|
||||
declare const browser: any;
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@customElement("sandbox-iframe")
|
||||
export class SandboxIframe extends LitElement {
|
||||
private iframe?: HTMLIFrameElement;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
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 attachments Attachments available
|
||||
*/
|
||||
public loadContent(sandboxId: string, htmlContent: string, attachments: Attachment[]): void {
|
||||
const completeHtml = this.prepareHtmlDocument(sandboxId, htmlContent, attachments);
|
||||
|
||||
// Wait for sandbox-ready and send content
|
||||
const readyHandler = (e: MessageEvent) => {
|
||||
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
|
||||
window.removeEventListener("message", readyHandler);
|
||||
this.iframe?.contentWindow?.postMessage(
|
||||
{
|
||||
type: "sandbox-load",
|
||||
sandboxId,
|
||||
code: completeHtml,
|
||||
attachments,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", readyHandler);
|
||||
|
||||
// Always recreate iframe to ensure fresh sandbox and sandbox-ready message
|
||||
this.iframe?.remove();
|
||||
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";
|
||||
|
||||
const isFirefox = typeof browser !== "undefined" && browser.runtime !== undefined;
|
||||
this.iframe.src = isFirefox ? browser.runtime.getURL("sandbox.html") : chrome.runtime.getURL("sandbox.html");
|
||||
|
||||
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 attachments Attachments available to the code
|
||||
* @param signal Abort signal
|
||||
* @returns Promise resolving to execution result
|
||||
*/
|
||||
public async execute(
|
||||
sandboxId: string,
|
||||
code: string,
|
||||
attachments: Attachment[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<SandboxResult> {
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Execution aborted");
|
||||
}
|
||||
|
||||
// Prepare the complete HTML document with runtime + user code
|
||||
const completeHtml = this.prepareHtmlDocument(sandboxId, code, attachments);
|
||||
|
||||
// Wait for sandbox to be ready and execute
|
||||
return new Promise((resolve, reject) => {
|
||||
const logs: Array<{ type: string; text: string }> = [];
|
||||
const files: SandboxFile[] = [];
|
||||
let completed = false;
|
||||
|
||||
const messageHandler = (e: MessageEvent) => {
|
||||
// Ignore messages not for this sandbox
|
||||
if (e.data.sandboxId !== sandboxId) return;
|
||||
|
||||
if (e.data.type === "console") {
|
||||
logs.push({
|
||||
type: e.data.method === "error" ? "error" : "log",
|
||||
text: e.data.text,
|
||||
});
|
||||
} else if (e.data.type === "file-returned") {
|
||||
files.push({
|
||||
fileName: e.data.fileName,
|
||||
content: e.data.content,
|
||||
mimeType: e.data.mimeType,
|
||||
});
|
||||
} else if (e.data.type === "execution-complete") {
|
||||
completed = true;
|
||||
cleanup();
|
||||
resolve({
|
||||
success: true,
|
||||
console: logs,
|
||||
files: files,
|
||||
});
|
||||
} else if (e.data.type === "execution-error") {
|
||||
completed = true;
|
||||
cleanup();
|
||||
resolve({
|
||||
success: false,
|
||||
console: logs,
|
||||
error: e.data.error,
|
||||
files,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const abortHandler = () => {
|
||||
if (!completed) {
|
||||
cleanup();
|
||||
reject(new Error("Execution aborted"));
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener("message", messageHandler);
|
||||
signal?.removeEventListener("abort", abortHandler);
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
|
||||
// Set up listeners
|
||||
window.addEventListener("message", messageHandler);
|
||||
signal?.addEventListener("abort", abortHandler);
|
||||
|
||||
// Set up sandbox-ready listener BEFORE creating iframe to avoid race condition
|
||||
const readyHandler = (e: MessageEvent) => {
|
||||
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
|
||||
window.removeEventListener("message", readyHandler);
|
||||
// Send the complete HTML
|
||||
this.iframe?.contentWindow?.postMessage(
|
||||
{
|
||||
type: "sandbox-load",
|
||||
sandboxId,
|
||||
code: completeHtml,
|
||||
attachments,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", readyHandler);
|
||||
|
||||
// Timeout after 30 seconds
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!completed) {
|
||||
cleanup();
|
||||
window.removeEventListener("message", readyHandler);
|
||||
resolve({
|
||||
success: false,
|
||||
error: { message: "Execution timeout (30s)", stack: "" },
|
||||
console: logs,
|
||||
files,
|
||||
});
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// NOW create and append iframe AFTER all listeners are set up
|
||||
this.iframe?.remove();
|
||||
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";
|
||||
|
||||
const isFirefox = typeof browser !== "undefined" && browser.runtime !== undefined;
|
||||
this.iframe.src = isFirefox ? browser.runtime.getURL("sandbox.html") : chrome.runtime.getURL("sandbox.html");
|
||||
|
||||
this.appendChild(this.iframe);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare complete HTML document with runtime + user code
|
||||
*/
|
||||
private prepareHtmlDocument(sandboxId: string, userCode: string, attachments: Attachment[]): string {
|
||||
// Runtime script that will be injected
|
||||
const runtime = this.getRuntimeScript(sandboxId, attachments);
|
||||
|
||||
// Check if user provided full HTML
|
||||
const hasHtmlTag = /<html[^>]*>/i.test(userCode);
|
||||
|
||||
if (hasHtmlTag) {
|
||||
// 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
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
${runtime}
|
||||
</head>
|
||||
<body>
|
||||
<script type="module">
|
||||
(async () => {
|
||||
try {
|
||||
${userCode}
|
||||
window.complete();
|
||||
} catch (error) {
|
||||
console.error(error?.stack || error?.message || String(error));
|
||||
window.complete({
|
||||
message: error?.message || String(error),
|
||||
stack: error?.stack || new Error().stack
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the runtime script that captures console, provides helpers, etc.
|
||||
*/
|
||||
private getRuntimeScript(sandboxId: string, attachments: Attachment[]): string {
|
||||
// Convert attachments to serializable format
|
||||
const attachmentsData = attachments.map((a) => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
content: a.content,
|
||||
extractedText: a.extractedText,
|
||||
}));
|
||||
|
||||
// Runtime function that will run in the sandbox (NO parameters - values injected before function)
|
||||
const runtimeFunc = () => {
|
||||
// Helper functions
|
||||
(window as any).listFiles = () =>
|
||||
(attachments || []).map((a: any) => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
}));
|
||||
|
||||
(window as any).readTextFile = (attachmentId: string) => {
|
||||
const a = (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).readBinaryFile = (attachmentId: string) => {
|
||||
const a = (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;
|
||||
};
|
||||
|
||||
(window as any).returnFile = 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(
|
||||
"returnFile: MIME type is required for Blob content. Please provide a mimeType parameter (e.g., 'image/png').",
|
||||
);
|
||||
}
|
||||
} else if (content instanceof Uint8Array) {
|
||||
finalContent = content;
|
||||
if (!mimeType) {
|
||||
throw new Error(
|
||||
"returnFile: MIME type is required for Uint8Array content. Please provide a mimeType parameter (e.g., 'image/png').",
|
||||
);
|
||||
}
|
||||
finalMimeType = mimeType;
|
||||
} else if (typeof content === "string") {
|
||||
finalContent = content;
|
||||
finalMimeType = mimeType || "text/plain";
|
||||
} else {
|
||||
finalContent = JSON.stringify(content, null, 2);
|
||||
finalMimeType = mimeType || "application/json";
|
||||
}
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "file-returned",
|
||||
sandboxId,
|
||||
fileName,
|
||||
content: finalContent,
|
||||
mimeType: finalMimeType,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
};
|
||||
|
||||
// Console capture
|
||||
const originalConsole = {
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
info: console.info,
|
||||
};
|
||||
|
||||
["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(" ");
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "console",
|
||||
sandboxId,
|
||||
method,
|
||||
text,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
|
||||
(originalConsole as any)[method].apply(console, args);
|
||||
};
|
||||
});
|
||||
|
||||
// Track errors for HTML artifacts
|
||||
let lastError: { message: string; stack: string } | null = null;
|
||||
|
||||
// Error handlers
|
||||
window.addEventListener("error", (e) => {
|
||||
const text =
|
||||
(e.error?.stack || e.message || String(e)) + " at line " + (e.lineno || "?") + ":" + (e.colno || "?");
|
||||
|
||||
// Store the error
|
||||
lastError = {
|
||||
message: e.error?.message || e.message || String(e),
|
||||
stack: e.error?.stack || text,
|
||||
};
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "console",
|
||||
sandboxId,
|
||||
method: "error",
|
||||
text,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (e) => {
|
||||
const text = "Unhandled promise rejection: " + (e.reason?.message || e.reason || "Unknown error");
|
||||
|
||||
// Store the error
|
||||
lastError = {
|
||||
message: e.reason?.message || String(e.reason) || "Unhandled promise rejection",
|
||||
stack: e.reason?.stack || text,
|
||||
};
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "console",
|
||||
sandboxId,
|
||||
method: "error",
|
||||
text,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
// Expose complete() method for user code to call
|
||||
let completionSent = false;
|
||||
(window as any).complete = (error?: { message: string; stack: string }) => {
|
||||
if (completionSent) return;
|
||||
completionSent = true;
|
||||
|
||||
// Use provided error or last caught error
|
||||
const finalError = error || lastError;
|
||||
|
||||
if (finalError) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "execution-error",
|
||||
sandboxId,
|
||||
error: finalError,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
} else {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "execution-complete",
|
||||
sandboxId,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Fallback timeout for HTML artifacts that don't call complete()
|
||||
if (document.readyState === "complete" || document.readyState === "interactive") {
|
||||
setTimeout(() => (window as any).complete(), 2000);
|
||||
} else {
|
||||
window.addEventListener("load", () => {
|
||||
setTimeout(() => (window as any).complete(), 2000);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Prepend the const declarations, then the function
|
||||
return (
|
||||
`<script>\n` +
|
||||
`window.sandboxId = ${JSON.stringify(sandboxId)};\n` +
|
||||
`window.attachments = ${JSON.stringify(attachmentsData)};\n` +
|
||||
`(${runtimeFunc.toString()})();\n` +
|
||||
`</script>`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
import { html } from "@mariozechner/mini-lit";
|
||||
import type { AgentTool, Message, ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { 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>;
|
||||
|
||||
@state() private _message: Message | null = null;
|
||||
private _pendingMessage: Message | 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: Message | 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") {
|
||||
// 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}
|
||||
></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);
|
||||
}
|
||||
213
packages/browser-extension/src/content.ts
Normal file
213
packages/browser-extension/src/content.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
// Content script - runs in isolated world with JailJS interpreter for CSP-restricted pages
|
||||
import { Interpreter } from "@mariozechner/jailjs";
|
||||
import { transformToES5 } from "@mariozechner/jailjs/transform";
|
||||
|
||||
console.log("[pi-ai] Content script loaded - JailJS interpreter available");
|
||||
|
||||
// Listen for code execution requests
|
||||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
if (message.type === "EXECUTE_CODE") {
|
||||
const mode = message.mode || "jailjs";
|
||||
console.log(`[pi-ai:${mode}] Executing code`);
|
||||
|
||||
// Execute in async context to support returnFile
|
||||
(async () => {
|
||||
try {
|
||||
// Capture console output
|
||||
const consoleOutput: Array<{ type: string; args: unknown[] }> = [];
|
||||
const files: Array<{ fileName: string; content: string | Uint8Array; mimeType: string }> = [];
|
||||
|
||||
// Create interpreter with console capture and returnFile support
|
||||
const interpreter = new Interpreter({
|
||||
// Expose controlled DOM access
|
||||
document: document,
|
||||
window: window,
|
||||
|
||||
// Console that captures output
|
||||
console: {
|
||||
log: (...args: unknown[]) => {
|
||||
consoleOutput.push({ type: "log", args });
|
||||
console.log("[Sandbox]", ...args);
|
||||
},
|
||||
error: (...args: unknown[]) => {
|
||||
consoleOutput.push({ type: "error", args });
|
||||
console.error("[Sandbox]", ...args);
|
||||
},
|
||||
warn: (...args: unknown[]) => {
|
||||
consoleOutput.push({ type: "warn", args });
|
||||
console.warn("[Sandbox]", ...args);
|
||||
},
|
||||
},
|
||||
|
||||
// returnFile function
|
||||
returnFile: async (
|
||||
fileName: string,
|
||||
content: string | Uint8Array | Blob | Record<string, unknown>,
|
||||
mimeType?: string,
|
||||
) => {
|
||||
let finalContent: string | Uint8Array;
|
||||
let finalMimeType: string;
|
||||
|
||||
if (content instanceof Blob) {
|
||||
// Convert Blob to Uint8Array
|
||||
const arrayBuffer = await content.arrayBuffer();
|
||||
finalContent = new Uint8Array(arrayBuffer);
|
||||
finalMimeType = mimeType || content.type || "application/octet-stream";
|
||||
|
||||
// Enforce MIME type requirement for binary data
|
||||
if (!mimeType && !content.type) {
|
||||
throw new Error(
|
||||
`returnFile: MIME type is required for Blob content. Please provide a mimeType parameter (e.g., "image/png").`,
|
||||
);
|
||||
}
|
||||
} else if (content instanceof Uint8Array) {
|
||||
finalContent = content;
|
||||
if (!mimeType) {
|
||||
throw new Error(
|
||||
`returnFile: MIME type is required for Uint8Array content. Please provide a mimeType parameter (e.g., "image/png").`,
|
||||
);
|
||||
}
|
||||
finalMimeType = mimeType;
|
||||
} else if (typeof content === "string") {
|
||||
finalContent = content;
|
||||
finalMimeType = mimeType || "text/plain";
|
||||
} else {
|
||||
// Assume it's an object to be JSON stringified
|
||||
finalContent = JSON.stringify(content, null, 2);
|
||||
finalMimeType = mimeType || "application/json";
|
||||
}
|
||||
|
||||
files.push({
|
||||
fileName,
|
||||
content: finalContent,
|
||||
mimeType: finalMimeType,
|
||||
});
|
||||
},
|
||||
|
||||
// Timers
|
||||
setTimeout: setTimeout.bind(window),
|
||||
setInterval: setInterval.bind(window),
|
||||
clearTimeout: clearTimeout.bind(window),
|
||||
clearInterval: clearInterval.bind(window),
|
||||
|
||||
// DOM Event Constructors
|
||||
Event: Event,
|
||||
CustomEvent: CustomEvent,
|
||||
MouseEvent: MouseEvent,
|
||||
KeyboardEvent: KeyboardEvent,
|
||||
InputEvent: InputEvent,
|
||||
FocusEvent: FocusEvent,
|
||||
UIEvent: UIEvent,
|
||||
WheelEvent: WheelEvent,
|
||||
TouchEvent: typeof TouchEvent !== "undefined" ? TouchEvent : undefined,
|
||||
PointerEvent: typeof PointerEvent !== "undefined" ? PointerEvent : undefined,
|
||||
DragEvent: DragEvent,
|
||||
ClipboardEvent: ClipboardEvent,
|
||||
MessageEvent: MessageEvent,
|
||||
StorageEvent: StorageEvent,
|
||||
PopStateEvent: PopStateEvent,
|
||||
HashChangeEvent: HashChangeEvent,
|
||||
ProgressEvent: ProgressEvent,
|
||||
AnimationEvent: AnimationEvent,
|
||||
TransitionEvent: TransitionEvent,
|
||||
|
||||
// DOM Element Constructors
|
||||
HTMLElement: HTMLElement,
|
||||
HTMLDivElement: HTMLDivElement,
|
||||
HTMLSpanElement: HTMLSpanElement,
|
||||
HTMLInputElement: HTMLInputElement,
|
||||
HTMLButtonElement: HTMLButtonElement,
|
||||
HTMLFormElement: HTMLFormElement,
|
||||
HTMLAnchorElement: HTMLAnchorElement,
|
||||
HTMLImageElement: HTMLImageElement,
|
||||
HTMLCanvasElement: HTMLCanvasElement,
|
||||
HTMLVideoElement: HTMLVideoElement,
|
||||
HTMLAudioElement: HTMLAudioElement,
|
||||
HTMLTextAreaElement: HTMLTextAreaElement,
|
||||
HTMLSelectElement: HTMLSelectElement,
|
||||
HTMLOptionElement: HTMLOptionElement,
|
||||
HTMLIFrameElement: HTMLIFrameElement,
|
||||
HTMLTableElement: HTMLTableElement,
|
||||
HTMLTableRowElement: HTMLTableRowElement,
|
||||
HTMLTableCellElement: HTMLTableCellElement,
|
||||
|
||||
// Other DOM types
|
||||
Node: Node,
|
||||
Element: Element,
|
||||
DocumentFragment: DocumentFragment,
|
||||
Text: Text,
|
||||
Comment: Comment,
|
||||
NodeList: NodeList,
|
||||
HTMLCollection: HTMLCollection,
|
||||
DOMTokenList: DOMTokenList,
|
||||
CSSStyleDeclaration: CSSStyleDeclaration,
|
||||
XMLHttpRequest: XMLHttpRequest,
|
||||
FormData: FormData,
|
||||
Blob: Blob,
|
||||
File: File,
|
||||
FileReader: FileReader,
|
||||
URL: URL,
|
||||
URLSearchParams: URLSearchParams,
|
||||
Headers: Headers,
|
||||
Request: Request,
|
||||
Response: Response,
|
||||
AbortController: AbortController,
|
||||
AbortSignal: AbortSignal,
|
||||
|
||||
// Utilities
|
||||
Math: Math,
|
||||
JSON: JSON,
|
||||
Date: Date,
|
||||
Set: Set,
|
||||
Map: Map,
|
||||
WeakSet: WeakSet,
|
||||
WeakMap: WeakMap,
|
||||
ArrayBuffer: ArrayBuffer,
|
||||
DataView: DataView,
|
||||
Int8Array: Int8Array,
|
||||
Uint8Array: Uint8Array,
|
||||
Uint8ClampedArray: Uint8ClampedArray,
|
||||
Int16Array: Int16Array,
|
||||
Uint16Array: Uint16Array,
|
||||
Int32Array: Int32Array,
|
||||
Uint32Array: Uint32Array,
|
||||
Float32Array: Float32Array,
|
||||
Float64Array: Float64Array,
|
||||
});
|
||||
|
||||
// Wrap code in async IIFE to support top-level await
|
||||
// JailJS supports await inside async functions but not at top level
|
||||
const wrappedCode = `(async function() {\n${message.code}\n})();`;
|
||||
|
||||
// Transform ES6+ to ES5 AST and execute
|
||||
const ast = transformToES5(wrappedCode);
|
||||
const result = interpreter.evaluate(ast);
|
||||
|
||||
// Wait for async operations to complete
|
||||
if (result instanceof Promise) {
|
||||
await result;
|
||||
}
|
||||
|
||||
console.log(`[pi-ai:${mode}] Execution success`);
|
||||
sendResponse({
|
||||
success: true,
|
||||
result: result,
|
||||
console: consoleOutput,
|
||||
files: files,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error(`[pi-ai:${mode}] Execution error:`, err);
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return true; // Keep channel open for async response
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
import { Alert, Badge, Button, DialogBase, DialogHeader, html, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import { type Context, complete, getModel, getProviders } from "@mariozechner/pi-ai";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { Input } from "../components/Input.js";
|
||||
import { keyStore } from "../state/KeyStore.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
|
||||
// Test models for each provider - known to be reliable and cheap
|
||||
const TEST_MODELS: Record<string, string> = {
|
||||
anthropic: "claude-3-5-haiku-20241022",
|
||||
openai: "gpt-4o-mini",
|
||||
google: "gemini-2.0-flash-exp",
|
||||
groq: "llama-3.3-70b-versatile",
|
||||
openrouter: "openai/gpt-4o-mini",
|
||||
cerebras: "llama3.1-8b",
|
||||
xai: "grok-2-1212",
|
||||
zai: "glm-4-plus",
|
||||
};
|
||||
|
||||
@customElement("api-keys-dialog")
|
||||
export class ApiKeysDialog extends DialogBase {
|
||||
@state() apiKeys: Record<string, boolean> = {}; // provider -> configured
|
||||
@state() apiKeyInputs: Record<string, string> = {};
|
||||
@state() testResults: Record<string, "success" | "error" | "testing"> = {};
|
||||
@state() savingProvider = "";
|
||||
@state() testingProvider = "";
|
||||
@state() error = "";
|
||||
|
||||
protected override modalWidth = "min(600px, 90vw)";
|
||||
protected override modalHeight = "min(600px, 80vh)";
|
||||
|
||||
static async open() {
|
||||
const dialog = new ApiKeysDialog();
|
||||
dialog.open();
|
||||
await dialog.loadKeys();
|
||||
}
|
||||
|
||||
override async firstUpdated(changedProperties: PropertyValues): Promise<void> {
|
||||
super.firstUpdated(changedProperties);
|
||||
await this.loadKeys();
|
||||
}
|
||||
|
||||
private async loadKeys() {
|
||||
this.apiKeys = await keyStore.getAllKeys();
|
||||
}
|
||||
|
||||
private async testApiKey(provider: string, apiKey: string): Promise<boolean> {
|
||||
try {
|
||||
// Get the test model for this provider
|
||||
const modelId = TEST_MODELS[provider];
|
||||
if (!modelId) {
|
||||
this.error = `No test model configured for ${provider}`;
|
||||
return false;
|
||||
}
|
||||
|
||||
const model = getModel(provider as any, modelId);
|
||||
if (!model) {
|
||||
this.error = `Test model ${modelId} not found for ${provider}`;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Simple test prompt
|
||||
const context: Context = {
|
||||
messages: [{ role: "user", content: "Reply with exactly: test successful" }],
|
||||
};
|
||||
const response = await complete(model, context, {
|
||||
apiKey,
|
||||
maxTokens: 10, // Keep it minimal for testing
|
||||
} as any);
|
||||
|
||||
// Check if response contains expected text
|
||||
const text = response.content
|
||||
.filter((b) => b.type === "text")
|
||||
.map((b) => b.text)
|
||||
.join("");
|
||||
|
||||
return text.toLowerCase().includes("test successful");
|
||||
} catch (error) {
|
||||
console.error(`API key test failed for ${provider}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveKey(provider: string) {
|
||||
const key = this.apiKeyInputs[provider];
|
||||
if (!key) return;
|
||||
|
||||
this.savingProvider = provider;
|
||||
this.testResults[provider] = "testing";
|
||||
this.error = "";
|
||||
|
||||
try {
|
||||
// Test the key first
|
||||
const isValid = await this.testApiKey(provider, key);
|
||||
|
||||
if (isValid) {
|
||||
await keyStore.setKey(provider, key);
|
||||
this.apiKeyInputs[provider] = ""; // Clear input
|
||||
await this.loadKeys();
|
||||
this.testResults[provider] = "success";
|
||||
} else {
|
||||
this.testResults[provider] = "error";
|
||||
this.error = `Invalid API key for ${provider}`;
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.testResults[provider] = "error";
|
||||
this.error = `Failed to save key for ${provider}: ${err.message}`;
|
||||
} finally {
|
||||
this.savingProvider = "";
|
||||
|
||||
// Clear test result after 3 seconds
|
||||
setTimeout(() => {
|
||||
delete this.testResults[provider];
|
||||
this.requestUpdate();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
private async testExistingKey(provider: string) {
|
||||
this.testingProvider = provider;
|
||||
this.testResults[provider] = "testing";
|
||||
this.error = "";
|
||||
|
||||
try {
|
||||
const apiKey = await keyStore.getKey(provider);
|
||||
if (!apiKey) {
|
||||
this.testResults[provider] = "error";
|
||||
this.error = `No API key found for ${provider}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = await this.testApiKey(provider, apiKey);
|
||||
|
||||
if (isValid) {
|
||||
this.testResults[provider] = "success";
|
||||
} else {
|
||||
this.testResults[provider] = "error";
|
||||
this.error = `API key for ${provider} is no longer valid`;
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.testResults[provider] = "error";
|
||||
this.error = `Test failed for ${provider}: ${err.message}`;
|
||||
} finally {
|
||||
this.testingProvider = "";
|
||||
|
||||
// Clear test result after 3 seconds
|
||||
setTimeout(() => {
|
||||
delete this.testResults[provider];
|
||||
this.requestUpdate();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
private async removeKey(provider: string) {
|
||||
if (!confirm(`Remove API key for ${provider}?`)) return;
|
||||
|
||||
await keyStore.removeKey(provider);
|
||||
this.apiKeyInputs[provider] = "";
|
||||
await this.loadKeys();
|
||||
}
|
||||
|
||||
protected override renderContent(): TemplateResult {
|
||||
const providers = getProviders();
|
||||
|
||||
return html`
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Header -->
|
||||
<div class="p-6 pb-4 border-b border-border flex-shrink-0">
|
||||
${DialogHeader({ title: i18n("API Keys Configuration") })}
|
||||
<p class="text-sm text-muted-foreground mt-2">
|
||||
${i18n("Configure API keys for LLM providers. Keys are stored locally in your browser.")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
${
|
||||
this.error
|
||||
? html`
|
||||
<div class="px-6 pt-4">${Alert(this.error, "destructive")}</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
<!-- test-->
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<div class="space-y-6">
|
||||
${providers.map(
|
||||
(provider) => html`
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-muted-foreground capitalize">${provider}</span>
|
||||
${
|
||||
this.apiKeys[provider]
|
||||
? Badge({ children: i18n("Configured"), variant: "default" })
|
||||
: Badge({ children: i18n("Not configured"), variant: "secondary" })
|
||||
}
|
||||
${
|
||||
this.testResults[provider] === "success"
|
||||
? Badge({ children: i18n("✓ Valid"), variant: "default" })
|
||||
: this.testResults[provider] === "error"
|
||||
? Badge({ children: i18n("✗ Invalid"), variant: "destructive" })
|
||||
: this.testResults[provider] === "testing"
|
||||
? Badge({ children: i18n("Testing..."), variant: "secondary" })
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
${Input({
|
||||
type: "password",
|
||||
placeholder: this.apiKeys[provider] ? i18n("Update API key") : i18n("Enter API key"),
|
||||
value: this.apiKeyInputs[provider] || "",
|
||||
onInput: (e: Event) => {
|
||||
this.apiKeyInputs[provider] = (e.target as HTMLInputElement).value;
|
||||
this.requestUpdate();
|
||||
},
|
||||
className: "flex-1",
|
||||
})}
|
||||
|
||||
${Button({
|
||||
onClick: () => this.saveKey(provider),
|
||||
variant: "default",
|
||||
size: "sm",
|
||||
disabled: !this.apiKeyInputs[provider] || this.savingProvider === provider,
|
||||
loading: this.savingProvider === provider,
|
||||
children:
|
||||
this.savingProvider === provider
|
||||
? i18n("Testing...")
|
||||
: this.apiKeys[provider]
|
||||
? i18n("Update")
|
||||
: i18n("Save"),
|
||||
})}
|
||||
|
||||
${
|
||||
this.apiKeys[provider]
|
||||
? html`
|
||||
${Button({
|
||||
onClick: () => this.testExistingKey(provider),
|
||||
variant: "outline",
|
||||
size: "sm",
|
||||
loading: this.testingProvider === provider,
|
||||
disabled: this.testingProvider !== "" && this.testingProvider !== provider,
|
||||
children:
|
||||
this.testingProvider === provider ? i18n("Testing...") : i18n("Test"),
|
||||
})}
|
||||
${Button({
|
||||
onClick: () => this.removeKey(provider),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: i18n("Remove"),
|
||||
})}
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with help text -->
|
||||
<div class="p-6 pt-4 border-t border-border flex-shrink-0">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
${i18n("API keys are required to use AI models. Get your keys from the provider's website.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,635 +0,0 @@
|
|||
import { Button, html, icon } from "@mariozechner/mini-lit";
|
||||
import "@mariozechner/mini-lit/dist/ModeToggle.js";
|
||||
import { renderAsync } from "docx-preview";
|
||||
import { LitElement } from "lit";
|
||||
import { state } from "lit/decorators.js";
|
||||
import { Download, X } from "lucide";
|
||||
import * as pdfjsLib from "pdfjs-dist";
|
||||
import * as XLSX from "xlsx";
|
||||
import 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);
|
||||
}
|
||||
|
|
@ -1,324 +0,0 @@
|
|||
import { Badge, Button, DialogBase, DialogHeader, html, icon, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { MODELS } from "@mariozechner/pi-ai/dist/models.generated.js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
import { Brain, Image as ImageIcon } from "lucide";
|
||||
import { Ollama } from "ollama/dist/browser.mjs";
|
||||
import { Input } from "../components/Input.js";
|
||||
import { formatModelCost } from "../utils/format.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
|
||||
@customElement("agent-model-selector")
|
||||
export class ModelSelector extends DialogBase {
|
||||
@state() currentModel: Model<any> | null = null;
|
||||
@state() searchQuery = "";
|
||||
@state() filterThinking = false;
|
||||
@state() filterVision = false;
|
||||
@state() ollamaModels: Model<any>[] = [];
|
||||
@state() ollamaError: string | null = null;
|
||||
@state() selectedIndex = 0;
|
||||
@state() private navigationMode: "mouse" | "keyboard" = "mouse";
|
||||
|
||||
private onSelectCallback?: (model: Model<any>) => void;
|
||||
private scrollContainerRef = createRef<HTMLDivElement>();
|
||||
private searchInputRef = createRef<HTMLInputElement>();
|
||||
private lastMousePosition = { x: 0, y: 0 };
|
||||
|
||||
protected override modalWidth = "min(400px, 90vw)";
|
||||
|
||||
static async open(currentModel: Model<any> | null, onSelect: (model: Model<any>) => void) {
|
||||
const selector = new ModelSelector();
|
||||
selector.currentModel = currentModel;
|
||||
selector.onSelectCallback = onSelect;
|
||||
selector.open();
|
||||
selector.fetchOllamaModels();
|
||||
}
|
||||
|
||||
override async firstUpdated(changedProperties: PropertyValues): Promise<void> {
|
||||
super.firstUpdated(changedProperties);
|
||||
// Wait for dialog to be fully rendered
|
||||
await this.updateComplete;
|
||||
// Focus the search input when dialog opens
|
||||
this.searchInputRef.value?.focus();
|
||||
|
||||
// Track actual mouse movement
|
||||
this.addEventListener("mousemove", (e: MouseEvent) => {
|
||||
// Check if mouse actually moved
|
||||
if (e.clientX !== this.lastMousePosition.x || e.clientY !== this.lastMousePosition.y) {
|
||||
this.lastMousePosition = { x: e.clientX, y: e.clientY };
|
||||
// Only switch to mouse mode on actual mouse movement
|
||||
if (this.navigationMode === "keyboard") {
|
||||
this.navigationMode = "mouse";
|
||||
// Update selection to the item under the mouse
|
||||
const target = e.target as HTMLElement;
|
||||
const modelItem = target.closest("[data-model-item]");
|
||||
if (modelItem) {
|
||||
const allItems = this.scrollContainerRef.value?.querySelectorAll("[data-model-item]");
|
||||
if (allItems) {
|
||||
const index = Array.from(allItems).indexOf(modelItem);
|
||||
if (index !== -1) {
|
||||
this.selectedIndex = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add global keyboard handler for the dialog
|
||||
this.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
// Get filtered models to know the bounds
|
||||
const filteredModels = this.getFilteredModels();
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
this.navigationMode = "keyboard";
|
||||
this.selectedIndex = Math.min(this.selectedIndex + 1, filteredModels.length - 1);
|
||||
this.scrollToSelected();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
this.navigationMode = "keyboard";
|
||||
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
||||
this.scrollToSelected();
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (filteredModels[this.selectedIndex]) {
|
||||
this.handleSelect(filteredModels[this.selectedIndex].model);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchOllamaModels() {
|
||||
try {
|
||||
// Create Ollama client
|
||||
const ollama = new Ollama({ host: "http://localhost:11434" });
|
||||
|
||||
// Get list of available models
|
||||
const { models } = await ollama.list();
|
||||
|
||||
// Fetch details for each model and convert to Model format
|
||||
const ollamaModelPromises: Promise<Model<any> | null>[] = models
|
||||
.map(async (model) => {
|
||||
try {
|
||||
// Get model details
|
||||
const details = await ollama.show({
|
||||
model: model.name,
|
||||
});
|
||||
|
||||
// Some Ollama servers don't report capabilities; don't filter on them
|
||||
|
||||
// Extract model info
|
||||
const modelInfo: any = details.model_info || {};
|
||||
|
||||
// Get context window size - look for architecture-specific keys
|
||||
const architecture = modelInfo["general.architecture"] || "";
|
||||
const contextKey = `${architecture}.context_length`;
|
||||
const contextWindow = parseInt(modelInfo[contextKey] || "8192", 10);
|
||||
const maxTokens = 4096; // Default max output tokens
|
||||
|
||||
// Create Model object manually since ollama models aren't in MODELS constant
|
||||
const ollamaModel: Model<any> = {
|
||||
id: model.name,
|
||||
name: model.name,
|
||||
api: "openai-completions" as any,
|
||||
provider: "ollama",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: contextWindow,
|
||||
maxTokens: maxTokens,
|
||||
};
|
||||
|
||||
return ollamaModel;
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch details for model ${model.name}:`, err);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((m) => m !== null);
|
||||
|
||||
const results = await Promise.all(ollamaModelPromises);
|
||||
this.ollamaModels = results.filter((m): m is Model<any> => m !== null);
|
||||
} catch (err) {
|
||||
// Ollama not available or other error - silently ignore
|
||||
console.debug("Ollama not available:", err);
|
||||
this.ollamaError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
private formatTokens(tokens: number): string {
|
||||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(0)}M`;
|
||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(0)}`;
|
||||
return String(tokens);
|
||||
}
|
||||
|
||||
private handleSelect(model: Model<any>) {
|
||||
if (model) {
|
||||
this.onSelectCallback?.(model);
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
private getFilteredModels(): Array<{ provider: string; id: string; model: any }> {
|
||||
// Collect all models from all providers
|
||||
const allModels: Array<{ provider: string; id: string; model: any }> = [];
|
||||
for (const [provider, providerData] of Object.entries(MODELS)) {
|
||||
for (const [modelId, model] of Object.entries(providerData)) {
|
||||
allModels.push({ provider, id: modelId, model });
|
||||
}
|
||||
}
|
||||
|
||||
// Add Ollama models
|
||||
for (const ollamaModel of this.ollamaModels) {
|
||||
allModels.push({
|
||||
id: ollamaModel.id,
|
||||
provider: "ollama",
|
||||
model: ollamaModel,
|
||||
});
|
||||
}
|
||||
|
||||
// Filter models based on search and capability filters
|
||||
let filteredModels = allModels;
|
||||
|
||||
// Apply search filter
|
||||
if (this.searchQuery) {
|
||||
filteredModels = filteredModels.filter(({ provider, id, model }) => {
|
||||
const searchTokens = this.searchQuery.split(/\s+/).filter((t) => t);
|
||||
const searchText = `${provider} ${id} ${model.name}`.toLowerCase();
|
||||
return searchTokens.every((token) => searchText.includes(token));
|
||||
});
|
||||
}
|
||||
|
||||
// Apply capability filters
|
||||
if (this.filterThinking) {
|
||||
filteredModels = filteredModels.filter(({ model }) => model.reasoning);
|
||||
}
|
||||
if (this.filterVision) {
|
||||
filteredModels = filteredModels.filter(({ model }) => model.input.includes("image"));
|
||||
}
|
||||
|
||||
// Sort: current model first, then by provider
|
||||
filteredModels.sort((a, b) => {
|
||||
const aIsCurrent = this.currentModel?.id === a.model.id;
|
||||
const bIsCurrent = this.currentModel?.id === b.model.id;
|
||||
if (aIsCurrent && !bIsCurrent) return -1;
|
||||
if (!aIsCurrent && bIsCurrent) return 1;
|
||||
return a.provider.localeCompare(b.provider);
|
||||
});
|
||||
|
||||
return filteredModels;
|
||||
}
|
||||
|
||||
private scrollToSelected() {
|
||||
requestAnimationFrame(() => {
|
||||
const scrollContainer = this.scrollContainerRef.value;
|
||||
const selectedElement = scrollContainer?.querySelectorAll("[data-model-item]")[
|
||||
this.selectedIndex
|
||||
] as HTMLElement;
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected override renderContent(): TemplateResult {
|
||||
const filteredModels = this.getFilteredModels();
|
||||
|
||||
return html`
|
||||
<!-- Header and Search -->
|
||||
<div class="p-6 pb-4 flex flex-col gap-4 border-b border-border flex-shrink-0">
|
||||
${DialogHeader({ title: i18n("Select Model") })}
|
||||
${Input({
|
||||
placeholder: i18n("Search models..."),
|
||||
value: this.searchQuery,
|
||||
inputRef: this.searchInputRef,
|
||||
onInput: (e: Event) => {
|
||||
this.searchQuery = (e.target as HTMLInputElement).value;
|
||||
this.selectedIndex = 0;
|
||||
// Reset scroll position when search changes
|
||||
if (this.scrollContainerRef.value) {
|
||||
this.scrollContainerRef.value.scrollTop = 0;
|
||||
}
|
||||
},
|
||||
})}
|
||||
<div class="flex gap-2">
|
||||
${Button({
|
||||
variant: this.filterThinking ? "default" : "secondary",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
this.filterThinking = !this.filterThinking;
|
||||
this.selectedIndex = 0;
|
||||
if (this.scrollContainerRef.value) {
|
||||
this.scrollContainerRef.value.scrollTop = 0;
|
||||
}
|
||||
},
|
||||
className: "rounded-full",
|
||||
children: html`<span class="inline-flex items-center gap-1">${icon(Brain, "sm")} ${i18n("Thinking")}</span>`,
|
||||
})}
|
||||
${Button({
|
||||
variant: this.filterVision ? "default" : "secondary",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
this.filterVision = !this.filterVision;
|
||||
this.selectedIndex = 0;
|
||||
if (this.scrollContainerRef.value) {
|
||||
this.scrollContainerRef.value.scrollTop = 0;
|
||||
}
|
||||
},
|
||||
className: "rounded-full",
|
||||
children: html`<span class="inline-flex items-center gap-1">${icon(ImageIcon, "sm")} ${i18n("Vision")}</span>`,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable model list -->
|
||||
<div class="flex-1 overflow-y-auto" ${ref(this.scrollContainerRef)}>
|
||||
${filteredModels.map(({ provider, id, model }, index) => {
|
||||
// Check if this is the current model by comparing IDs
|
||||
const isCurrent = this.currentModel?.id === model.id;
|
||||
const isSelected = index === this.selectedIndex;
|
||||
return html`
|
||||
<div
|
||||
data-model-item
|
||||
class="px-4 py-3 ${
|
||||
this.navigationMode === "mouse" ? "hover:bg-muted" : ""
|
||||
} cursor-pointer border-b border-border ${isSelected ? "bg-accent" : ""}"
|
||||
@click=${() => this.handleSelect(model)}
|
||||
@mouseenter=${() => {
|
||||
// Only update selection in mouse mode
|
||||
if (this.navigationMode === "mouse") {
|
||||
this.selectedIndex = index;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 mb-1">
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span class="text-sm font-medium text-foreground truncate">${id}</span>
|
||||
${isCurrent ? html`<span class="text-green-500">✓</span>` : ""}
|
||||
</div>
|
||||
${Badge(provider, "outline")}
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="${model.reasoning ? "" : "opacity-30"}">${icon(Brain, "sm")}</span>
|
||||
<span class="${model.input.includes("image") ? "" : "opacity-30"}">${icon(ImageIcon, "sm")}</span>
|
||||
<span>${this.formatTokens(model.contextWindow)}K/${this.formatTokens(model.maxTokens)}K</span>
|
||||
</div>
|
||||
<span>${formatModelCost(model.cost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,24 @@
|
|||
import { Button, icon } from "@mariozechner/mini-lit";
|
||||
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
|
||||
import { ApiKeysDialog, ChromeStorageAdapter, LocalStorageKeyStore, setKeyStore } from "@mariozechner/pi-web-ui";
|
||||
import "@mariozechner/pi-web-ui"; // Import all web-ui components
|
||||
import { html, LitElement, render } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { Plus, RefreshCw, Settings } from "lucide";
|
||||
import "./ChatPanel.js";
|
||||
import "./components/SandboxedIframe.js";
|
||||
import { ApiKeysDialog } from "./dialogs/ApiKeysDialog.js";
|
||||
import { browserJavaScriptTool } from "./tools/index.js";
|
||||
import "./utils/live-reload.js";
|
||||
|
||||
declare const browser: any;
|
||||
|
||||
// Initialize browser extension storage
|
||||
setKeyStore(new LocalStorageKeyStore(new ChromeStorageAdapter()));
|
||||
|
||||
// Get sandbox URL for extension CSP restrictions
|
||||
const getSandboxUrl = () => {
|
||||
const isFirefox = typeof browser !== "undefined" && browser.runtime !== undefined;
|
||||
return isFirefox ? browser.runtime.getURL("sandbox.html") : chrome.runtime.getURL("sandbox.html");
|
||||
};
|
||||
|
||||
async function getDom() {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab || !tab.id) return;
|
||||
|
|
@ -98,6 +109,8 @@ class App extends LitElement {
|
|||
const newPanel = document.createElement("pi-chat-panel") as any;
|
||||
newPanel.className = "flex-1 min-h-0";
|
||||
newPanel.systemPrompt = systemPrompt;
|
||||
newPanel.additionalTools = [browserJavaScriptTool];
|
||||
newPanel.sandboxUrlProvider = getSandboxUrl;
|
||||
|
||||
const container = this.querySelector(".w-full");
|
||||
if (container) {
|
||||
|
|
@ -109,7 +122,12 @@ class App extends LitElement {
|
|||
return html`
|
||||
<div class="w-full h-full flex flex-col bg-background text-foreground overflow-hidden">
|
||||
<pi-chat-header class="shrink-0" .onNewSession=${() => this.handleNewSession()}></pi-chat-header>
|
||||
<pi-chat-panel class="flex-1 min-h-0" .systemPrompt=${systemPrompt}></pi-chat-panel>
|
||||
<pi-chat-panel
|
||||
class="flex-1 min-h-0"
|
||||
.systemPrompt=${systemPrompt}
|
||||
.additionalTools=${[browserJavaScriptTool]}
|
||||
.sandboxUrlProvider=${getSandboxUrl}
|
||||
></pi-chat-panel>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
import { getProviders } from "@mariozechner/pi-ai";
|
||||
|
||||
declare const browser: any;
|
||||
|
||||
/**
|
||||
* Interface for API key storage
|
||||
*/
|
||||
export interface KeyStore {
|
||||
getKey(provider: string): Promise<string | null>;
|
||||
setKey(provider: string, key: string): Promise<void>;
|
||||
removeKey(provider: string): Promise<void>;
|
||||
getAllKeys(): Promise<Record<string, boolean>>; // provider -> isConfigured
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-browser storage implementation of KeyStore
|
||||
*/
|
||||
class BrowserKeyStore implements KeyStore {
|
||||
private readonly prefix = "apiKey_";
|
||||
private readonly storage: typeof chrome.storage.local;
|
||||
|
||||
constructor() {
|
||||
// Use browser.storage in Firefox, chrome.storage in Chrome
|
||||
const isFirefox = typeof browser !== "undefined" && browser.storage !== undefined;
|
||||
this.storage = isFirefox ? browser.storage.local : chrome.storage.local;
|
||||
}
|
||||
|
||||
async getKey(provider: string): Promise<string | null> {
|
||||
const key = `${this.prefix}${provider}`;
|
||||
const result = await this.storage.get(key);
|
||||
return result[key] || null;
|
||||
}
|
||||
|
||||
async setKey(provider: string, key: string): Promise<void> {
|
||||
const storageKey = `${this.prefix}${provider}`;
|
||||
await this.storage.set({ [storageKey]: key });
|
||||
}
|
||||
|
||||
async removeKey(provider: string): Promise<void> {
|
||||
const key = `${this.prefix}${provider}`;
|
||||
await this.storage.remove(key);
|
||||
}
|
||||
|
||||
async getAllKeys(): Promise<Record<string, boolean>> {
|
||||
const providers = getProviders();
|
||||
const storage = await this.storage.get();
|
||||
const result: Record<string, boolean> = {};
|
||||
|
||||
for (const provider of providers) {
|
||||
const key = `${this.prefix}${provider}`;
|
||||
result[provider] = !!storage[key];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const keyStore = new BrowserKeyStore();
|
||||
|
|
@ -1,311 +0,0 @@
|
|||
import type { Context } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
type AgentTool,
|
||||
type AssistantMessage as AssistantMessageType,
|
||||
getModel,
|
||||
type ImageContent,
|
||||
type Message,
|
||||
type Model,
|
||||
type TextContent,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import type { AppMessage } from "../components/Messages.js";
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
import { DirectTransport } from "./transports/DirectTransport.js";
|
||||
import { ProxyTransport } from "./transports/ProxyTransport.js";
|
||||
import type { AgentRunConfig, AgentTransport } from "./transports/types.js";
|
||||
import type { DebugLogEntry } from "./types.js";
|
||||
|
||||
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high";
|
||||
|
||||
export interface AgentSessionState {
|
||||
id: string;
|
||||
systemPrompt: string;
|
||||
model: Model<any> | null;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
tools: AgentTool<any>[];
|
||||
messages: AppMessage[];
|
||||
isStreaming: boolean;
|
||||
streamMessage: Message | null;
|
||||
pendingToolCalls: Set<string>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type AgentSessionEvent =
|
||||
| { type: "state-update"; state: AgentSessionState }
|
||||
| { type: "error-no-model" }
|
||||
| { type: "error-no-api-key"; provider: string };
|
||||
|
||||
export type TransportMode = "direct" | "proxy";
|
||||
|
||||
export interface AgentSessionOptions {
|
||||
initialState?: Partial<AgentSessionState>;
|
||||
messagePreprocessor?: (messages: AppMessage[]) => Promise<Message[]>;
|
||||
debugListener?: (entry: DebugLogEntry) => void;
|
||||
transportMode?: TransportMode;
|
||||
authTokenProvider?: () => Promise<string | undefined>;
|
||||
}
|
||||
|
||||
export class AgentSession {
|
||||
private _state: AgentSessionState = {
|
||||
id: "default",
|
||||
systemPrompt: "",
|
||||
model: getModel("google", "gemini-2.5-flash-lite-preview-06-17"),
|
||||
thinkingLevel: "off",
|
||||
tools: [],
|
||||
messages: [],
|
||||
isStreaming: false,
|
||||
streamMessage: null,
|
||||
pendingToolCalls: new Set<string>(),
|
||||
error: undefined,
|
||||
};
|
||||
private listeners = new Set<(e: AgentSessionEvent) => void>();
|
||||
private abortController?: AbortController;
|
||||
private transport: AgentTransport;
|
||||
private messagePreprocessor?: (messages: AppMessage[]) => Promise<Message[]>;
|
||||
private debugListener?: (entry: DebugLogEntry) => void;
|
||||
|
||||
constructor(opts: AgentSessionOptions = {}) {
|
||||
this._state = { ...this._state, ...opts.initialState };
|
||||
this.messagePreprocessor = opts.messagePreprocessor;
|
||||
this.debugListener = opts.debugListener;
|
||||
|
||||
const mode = opts.transportMode || "direct";
|
||||
|
||||
if (mode === "proxy") {
|
||||
this.transport = new ProxyTransport(async () => this.preprocessMessages());
|
||||
} else {
|
||||
this.transport = new DirectTransport(async () => this.preprocessMessages());
|
||||
}
|
||||
}
|
||||
|
||||
private async preprocessMessages(): Promise<Message[]> {
|
||||
const filtered = this._state.messages.map((m) => {
|
||||
if (m.role === "user") {
|
||||
const { attachments, ...rest } = m as AppMessage & { attachments?: Attachment[] };
|
||||
return rest;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
return this.messagePreprocessor ? this.messagePreprocessor(filtered as AppMessage[]) : (filtered as Message[]);
|
||||
}
|
||||
|
||||
get state(): AgentSessionState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
subscribe(fn: (e: AgentSessionEvent) => void): () => void {
|
||||
this.listeners.add(fn);
|
||||
fn({ type: "state-update", state: this._state });
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
// Mutators
|
||||
setSystemPrompt(v: string) {
|
||||
this.patch({ systemPrompt: v });
|
||||
}
|
||||
setModel(m: Model<any> | null) {
|
||||
this.patch({ model: m });
|
||||
}
|
||||
setThinkingLevel(l: ThinkingLevel) {
|
||||
this.patch({ thinkingLevel: l });
|
||||
}
|
||||
setTools(t: AgentTool<any>[]) {
|
||||
this.patch({ tools: t });
|
||||
}
|
||||
replaceMessages(ms: AppMessage[]) {
|
||||
this.patch({ messages: ms.slice() });
|
||||
}
|
||||
appendMessage(m: AppMessage) {
|
||||
this.patch({ messages: [...this._state.messages, m] });
|
||||
}
|
||||
clearMessages() {
|
||||
this.patch({ messages: [] });
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.abortController?.abort();
|
||||
}
|
||||
|
||||
async prompt(input: string, attachments?: Attachment[]) {
|
||||
const model = this._state.model;
|
||||
if (!model) {
|
||||
this.emit({ type: "error-no-model" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Build user message with attachments
|
||||
const content: Array<TextContent | ImageContent> = [{ type: "text", text: input }];
|
||||
if (attachments?.length) {
|
||||
for (const a of attachments) {
|
||||
if (a.type === "image") {
|
||||
content.push({ type: "image", data: a.content, mimeType: a.mimeType });
|
||||
} else if (a.type === "document" && a.extractedText) {
|
||||
content.push({
|
||||
type: "text",
|
||||
text: `\n\n[Document: ${a.fileName}]\n${a.extractedText}`,
|
||||
isDocument: true,
|
||||
} as TextContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userMessage: AppMessage = {
|
||||
role: "user",
|
||||
content,
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
};
|
||||
|
||||
this.abortController = new AbortController();
|
||||
this.patch({ isStreaming: true, streamMessage: null, error: undefined });
|
||||
|
||||
const reasoning =
|
||||
this._state.thinkingLevel === "off"
|
||||
? undefined
|
||||
: this._state.thinkingLevel === "minimal"
|
||||
? "low"
|
||||
: this._state.thinkingLevel;
|
||||
const cfg: AgentRunConfig = {
|
||||
systemPrompt: this._state.systemPrompt,
|
||||
tools: this._state.tools,
|
||||
model,
|
||||
reasoning,
|
||||
};
|
||||
|
||||
try {
|
||||
let partial: Message | null = null;
|
||||
let turnDebug: DebugLogEntry | null = null;
|
||||
let turnStart = 0;
|
||||
for await (const ev of this.transport.run(userMessage as Message, cfg, this.abortController.signal)) {
|
||||
switch (ev.type) {
|
||||
case "turn_start": {
|
||||
turnStart = performance.now();
|
||||
// Build request context snapshot
|
||||
const existing = this._state.messages as Message[];
|
||||
const ctx: Context = {
|
||||
systemPrompt: this._state.systemPrompt,
|
||||
messages: [...existing],
|
||||
tools: this._state.tools,
|
||||
};
|
||||
turnDebug = {
|
||||
timestamp: new Date().toISOString(),
|
||||
request: {
|
||||
provider: cfg.model.provider,
|
||||
model: cfg.model.id,
|
||||
context: { ...ctx },
|
||||
},
|
||||
sseEvents: [],
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "message_start":
|
||||
case "message_update": {
|
||||
partial = ev.message;
|
||||
// Collect SSE-like events for debug (drop heavy partial)
|
||||
if (ev.type === "message_update" && ev.assistantMessageEvent && turnDebug) {
|
||||
const copy: any = { ...ev.assistantMessageEvent };
|
||||
if (copy && "partial" in copy) delete copy.partial;
|
||||
turnDebug.sseEvents.push(JSON.stringify(copy));
|
||||
if (!turnDebug.ttft) turnDebug.ttft = performance.now() - turnStart;
|
||||
}
|
||||
this.patch({ streamMessage: ev.message });
|
||||
break;
|
||||
}
|
||||
case "message_end": {
|
||||
partial = null;
|
||||
this.appendMessage(ev.message as AppMessage);
|
||||
this.patch({ streamMessage: null });
|
||||
if (turnDebug) {
|
||||
if (ev.message.role !== "assistant" && ev.message.role !== "toolResult") {
|
||||
turnDebug.request.context.messages.push(ev.message);
|
||||
}
|
||||
if (ev.message.role === "assistant") turnDebug.response = ev.message as any;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_execution_start": {
|
||||
const s = new Set(this._state.pendingToolCalls);
|
||||
s.add(ev.toolCallId);
|
||||
this.patch({ pendingToolCalls: s });
|
||||
break;
|
||||
}
|
||||
case "tool_execution_end": {
|
||||
const s = new Set(this._state.pendingToolCalls);
|
||||
s.delete(ev.toolCallId);
|
||||
this.patch({ pendingToolCalls: s });
|
||||
break;
|
||||
}
|
||||
case "turn_end": {
|
||||
// finalize current turn
|
||||
if (turnDebug) {
|
||||
turnDebug.totalTime = performance.now() - turnStart;
|
||||
this.debugListener?.(turnDebug);
|
||||
turnDebug = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "agent_end": {
|
||||
this.patch({ streamMessage: null });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (partial && partial.role === "assistant" && partial.content.length > 0) {
|
||||
const onlyEmpty = !partial.content.some(
|
||||
(c) =>
|
||||
(c.type === "thinking" && c.thinking.trim().length > 0) ||
|
||||
(c.type === "text" && c.text.trim().length > 0) ||
|
||||
(c.type === "toolCall" && c.name.trim().length > 0),
|
||||
);
|
||||
if (!onlyEmpty) {
|
||||
this.appendMessage(partial as AppMessage);
|
||||
} else {
|
||||
if (this.abortController?.signal.aborted) {
|
||||
throw new Error("Request was aborted");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (String(err?.message || err) === "no-api-key") {
|
||||
this.emit({ type: "error-no-api-key", provider: model.provider });
|
||||
} else {
|
||||
const msg: AssistantMessageType = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "" }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: this.abortController?.signal.aborted ? "aborted" : "error",
|
||||
errorMessage: err?.message || String(err),
|
||||
};
|
||||
this.appendMessage(msg as AppMessage);
|
||||
this.patch({ error: err?.message || String(err) });
|
||||
}
|
||||
} finally {
|
||||
this.patch({ isStreaming: false, streamMessage: null, pendingToolCalls: new Set<string>() });
|
||||
this.abortController = undefined;
|
||||
}
|
||||
{
|
||||
const { systemPrompt, model, messages } = this._state;
|
||||
console.log("final state:", { systemPrompt, model, messages });
|
||||
}
|
||||
}
|
||||
|
||||
private patch(p: Partial<AgentSessionState>): void {
|
||||
this._state = { ...this._state, ...p };
|
||||
this.emit({ type: "state-update", state: this._state });
|
||||
}
|
||||
|
||||
private emit(e: AgentSessionEvent) {
|
||||
for (const listener of this.listeners) {
|
||||
listener(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { type AgentContext, agentLoop, type Message, type PromptConfig, type UserMessage } from "@mariozechner/pi-ai";
|
||||
import { keyStore } from "../KeyStore.js";
|
||||
import type { AgentRunConfig, AgentTransport } from "./types.js";
|
||||
|
||||
export class DirectTransport implements AgentTransport {
|
||||
constructor(private readonly getMessages: () => Promise<Message[]>) {}
|
||||
|
||||
async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
|
||||
// Get API key from KeyStore
|
||||
const apiKey = await keyStore.getKey(cfg.model.provider);
|
||||
if (!apiKey) {
|
||||
throw new Error("no-api-key");
|
||||
}
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: cfg.systemPrompt,
|
||||
messages: await this.getMessages(),
|
||||
tools: cfg.tools,
|
||||
};
|
||||
|
||||
const pc: PromptConfig = {
|
||||
model: cfg.model,
|
||||
reasoning: cfg.reasoning,
|
||||
apiKey,
|
||||
};
|
||||
|
||||
// Yield events from agentLoop
|
||||
for await (const ev of agentLoop(userMessage as unknown as UserMessage, context, pc, signal)) {
|
||||
yield ev;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,358 +0,0 @@
|
|||
import type {
|
||||
AgentContext,
|
||||
AssistantMessage,
|
||||
AssistantMessageEvent,
|
||||
Context,
|
||||
Message,
|
||||
Model,
|
||||
PromptConfig,
|
||||
SimpleStreamOptions,
|
||||
ToolCall,
|
||||
UserMessage,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { agentLoop } from "@mariozechner/pi-ai";
|
||||
import { AssistantMessageEventStream } from "@mariozechner/pi-ai/dist/utils/event-stream.js";
|
||||
import { parseStreamingJson } from "@mariozechner/pi-ai/dist/utils/json-parse.js";
|
||||
import { clearAuthToken, getAuthToken } from "../../utils/auth-token.js";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import type { ProxyAssistantMessageEvent } from "./proxy-types.js";
|
||||
import type { AgentRunConfig, AgentTransport } from "./types.js";
|
||||
|
||||
/**
|
||||
* Stream function that proxies through a server instead of calling providers directly.
|
||||
* The server strips the partial field from delta events to reduce bandwidth.
|
||||
* We reconstruct the partial message client-side.
|
||||
*/
|
||||
function streamSimpleProxy(
|
||||
model: Model<any>,
|
||||
context: Context,
|
||||
options: SimpleStreamOptions & { authToken: string },
|
||||
proxyUrl: string,
|
||||
): AssistantMessageEventStream {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
|
||||
(async () => {
|
||||
// Initialize the partial message that we'll build up from events
|
||||
const partial: AssistantMessage = {
|
||||
role: "assistant",
|
||||
stopReason: "stop",
|
||||
content: [],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
||||
|
||||
// Set up abort handler to cancel the reader
|
||||
const abortHandler = () => {
|
||||
if (reader) {
|
||||
reader.cancel("Request aborted by user").catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
if (options.signal) {
|
||||
options.signal.addEventListener("abort", abortHandler);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${proxyUrl}/api/stream`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${options.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
context,
|
||||
options: {
|
||||
temperature: options.temperature,
|
||||
maxTokens: options.maxTokens,
|
||||
reasoning: options.reasoning,
|
||||
// Don't send apiKey or signal - those are added server-side
|
||||
},
|
||||
}),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Proxy error: ${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData.error) {
|
||||
errorMessage = `Proxy error: ${errorData.error}`;
|
||||
}
|
||||
} catch {
|
||||
// Couldn't parse error response, use default message
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Parse SSE stream
|
||||
reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
// Check if aborted after reading
|
||||
if (options.signal?.aborted) {
|
||||
throw new Error("Request aborted by user");
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6).trim();
|
||||
if (data) {
|
||||
const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent;
|
||||
let event: AssistantMessageEvent | undefined;
|
||||
|
||||
// Handle different event types
|
||||
// Server sends events with partial for non-delta events,
|
||||
// and without partial for delta events
|
||||
switch (proxyEvent.type) {
|
||||
case "start":
|
||||
event = { type: "start", partial };
|
||||
break;
|
||||
|
||||
case "text_start":
|
||||
partial.content[proxyEvent.contentIndex] = {
|
||||
type: "text",
|
||||
text: "",
|
||||
};
|
||||
event = { type: "text_start", contentIndex: proxyEvent.contentIndex, partial };
|
||||
break;
|
||||
|
||||
case "text_delta": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "text") {
|
||||
content.text += proxyEvent.delta;
|
||||
event = {
|
||||
type: "text_delta",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
delta: proxyEvent.delta,
|
||||
partial,
|
||||
};
|
||||
} else {
|
||||
throw new Error("Received text_delta for non-text content");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "text_end": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "text") {
|
||||
content.textSignature = proxyEvent.contentSignature;
|
||||
event = {
|
||||
type: "text_end",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
content: content.text,
|
||||
partial,
|
||||
};
|
||||
} else {
|
||||
throw new Error("Received text_end for non-text content");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "thinking_start":
|
||||
partial.content[proxyEvent.contentIndex] = {
|
||||
type: "thinking",
|
||||
thinking: "",
|
||||
};
|
||||
event = { type: "thinking_start", contentIndex: proxyEvent.contentIndex, partial };
|
||||
break;
|
||||
|
||||
case "thinking_delta": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "thinking") {
|
||||
content.thinking += proxyEvent.delta;
|
||||
event = {
|
||||
type: "thinking_delta",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
delta: proxyEvent.delta,
|
||||
partial,
|
||||
};
|
||||
} else {
|
||||
throw new Error("Received thinking_delta for non-thinking content");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "thinking_end": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "thinking") {
|
||||
content.thinkingSignature = proxyEvent.contentSignature;
|
||||
event = {
|
||||
type: "thinking_end",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
content: content.thinking,
|
||||
partial,
|
||||
};
|
||||
} else {
|
||||
throw new Error("Received thinking_end for non-thinking content");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "toolcall_start":
|
||||
partial.content[proxyEvent.contentIndex] = {
|
||||
type: "toolCall",
|
||||
id: proxyEvent.id,
|
||||
name: proxyEvent.toolName,
|
||||
arguments: {},
|
||||
partialJson: "",
|
||||
} satisfies ToolCall & { partialJson: string } as ToolCall;
|
||||
event = { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial };
|
||||
break;
|
||||
|
||||
case "toolcall_delta": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "toolCall") {
|
||||
(content as any).partialJson += proxyEvent.delta;
|
||||
content.arguments = parseStreamingJson((content as any).partialJson) || {};
|
||||
event = {
|
||||
type: "toolcall_delta",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
delta: proxyEvent.delta,
|
||||
partial,
|
||||
};
|
||||
partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity
|
||||
} else {
|
||||
throw new Error("Received toolcall_delta for non-toolCall content");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "toolcall_end": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "toolCall") {
|
||||
delete (content as any).partialJson;
|
||||
event = {
|
||||
type: "toolcall_end",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
toolCall: content,
|
||||
partial,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "done":
|
||||
partial.stopReason = proxyEvent.reason;
|
||||
partial.usage = proxyEvent.usage;
|
||||
event = { type: "done", reason: proxyEvent.reason, message: partial };
|
||||
break;
|
||||
|
||||
case "error":
|
||||
partial.stopReason = proxyEvent.reason;
|
||||
partial.errorMessage = proxyEvent.errorMessage;
|
||||
partial.usage = proxyEvent.usage;
|
||||
event = { type: "error", reason: proxyEvent.reason, error: partial };
|
||||
break;
|
||||
|
||||
default: {
|
||||
// Exhaustive check
|
||||
const _exhaustiveCheck: never = proxyEvent;
|
||||
console.warn(`Unhandled event type: ${(proxyEvent as any).type}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Push the event to stream
|
||||
if (event) {
|
||||
stream.push(event);
|
||||
} else {
|
||||
throw new Error("Failed to create event from proxy event");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if aborted after reading
|
||||
if (options.signal?.aborted) {
|
||||
throw new Error("Request aborted by user");
|
||||
}
|
||||
|
||||
stream.end();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (errorMessage.toLowerCase().includes("proxy") && errorMessage.includes("Unauthorized")) {
|
||||
clearAuthToken();
|
||||
}
|
||||
partial.stopReason = options.signal?.aborted ? "aborted" : "error";
|
||||
partial.errorMessage = errorMessage;
|
||||
stream.push({
|
||||
type: "error",
|
||||
reason: partial.stopReason,
|
||||
error: partial,
|
||||
} satisfies AssistantMessageEvent);
|
||||
stream.end();
|
||||
} finally {
|
||||
// Clean up abort handler
|
||||
if (options.signal) {
|
||||
options.signal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
// Proxy transport executes the turn using a remote proxy server
|
||||
export class ProxyTransport implements AgentTransport {
|
||||
// Hardcoded proxy URL for now - will be made configurable later
|
||||
private readonly proxyUrl = "https://genai.mariozechner.at";
|
||||
|
||||
constructor(private readonly getMessages: () => Promise<Message[]>) {}
|
||||
|
||||
async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
|
||||
const authToken = await getAuthToken();
|
||||
if (!authToken) {
|
||||
throw new Error(i18n("Auth token is required for proxy transport"));
|
||||
}
|
||||
|
||||
// Use proxy - no local API key needed
|
||||
const streamFn = (model: Model<any>, context: Context, options: SimpleStreamOptions | undefined) => {
|
||||
return streamSimpleProxy(
|
||||
model,
|
||||
context,
|
||||
{
|
||||
...options,
|
||||
authToken,
|
||||
},
|
||||
this.proxyUrl,
|
||||
);
|
||||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: cfg.systemPrompt,
|
||||
messages: await this.getMessages(),
|
||||
tools: cfg.tools,
|
||||
};
|
||||
|
||||
const pc: PromptConfig = {
|
||||
model: cfg.model,
|
||||
reasoning: cfg.reasoning,
|
||||
};
|
||||
|
||||
// Yield events from the upstream agentLoop iterator
|
||||
// Pass streamFn as the 5th parameter to use proxy
|
||||
for await (const ev of agentLoop(userMessage as unknown as UserMessage, context, pc, signal, streamFn)) {
|
||||
yield ev;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export * from "./DirectTransport.js";
|
||||
export * from "./ProxyTransport.js";
|
||||
export * from "./types.js";
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import type { StopReason, Usage } from "@mariozechner/pi-ai";
|
||||
|
||||
export type ProxyAssistantMessageEvent =
|
||||
| { type: "start" }
|
||||
| { type: "text_start"; contentIndex: number }
|
||||
| { type: "text_delta"; contentIndex: number; delta: string }
|
||||
| { type: "text_end"; contentIndex: number; contentSignature?: string }
|
||||
| { type: "thinking_start"; contentIndex: number }
|
||||
| { type: "thinking_delta"; contentIndex: number; delta: string }
|
||||
| { type: "thinking_end"; contentIndex: number; contentSignature?: string }
|
||||
| { type: "toolcall_start"; contentIndex: number; id: string; toolName: string }
|
||||
| { type: "toolcall_delta"; contentIndex: number; delta: string }
|
||||
| { type: "toolcall_end"; contentIndex: number }
|
||||
| { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse">; usage: Usage }
|
||||
| { type: "error"; reason: Extract<StopReason, "aborted" | "error">; errorMessage: string; usage: Usage };
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import type { AgentEvent, AgentTool, Message, Model } from "@mariozechner/pi-ai";
|
||||
|
||||
// The minimal configuration needed to run a turn.
|
||||
export interface AgentRunConfig {
|
||||
systemPrompt: string;
|
||||
tools: AgentTool<any>[];
|
||||
model: Model<any>;
|
||||
reasoning?: "low" | "medium" | "high";
|
||||
}
|
||||
|
||||
// Events yielded by transports must match the @mariozechner/pi-ai prompt() events.
|
||||
// We re-export the Message type above; consumers should use the upstream AgentEvent type.
|
||||
|
||||
export interface AgentTransport {
|
||||
run(userMessage: Message, config: AgentRunConfig, signal?: AbortSignal): AsyncIterable<AgentEvent>; // passthrough of AgentEvent from upstream
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import type { AssistantMessage, Context } from "@mariozechner/pi-ai";
|
||||
|
||||
export interface DebugLogEntry {
|
||||
timestamp: string;
|
||||
request: { provider: string; model: string; context: Context };
|
||||
response?: AssistantMessage;
|
||||
error?: unknown;
|
||||
sseEvents: string[];
|
||||
ttft?: number;
|
||||
totalTime?: number;
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { LitElement, type TemplateResult } from "lit";
|
||||
|
||||
export abstract class ArtifactElement extends LitElement {
|
||||
public filename = "";
|
||||
public displayTitle = "";
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
import { CopyButton, DownloadButton, PreviewCodeToggle } from "@mariozechner/mini-lit";
|
||||
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 type { SandboxIframe } from "../../components/SandboxedIframe.js";
|
||||
import type { Attachment } from "../../utils/attachment-utils.js";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import "../../components/SandboxedIframe.js";
|
||||
import { ArtifactElement } from "./ArtifactElement.js";
|
||||
|
||||
@customElement("html-artifact")
|
||||
export class HtmlArtifact extends ArtifactElement {
|
||||
@property() override filename = "";
|
||||
@property({ attribute: false }) override displayTitle = "";
|
||||
@property({ attribute: false }) attachments: Attachment[] = [];
|
||||
|
||||
private _content = "";
|
||||
private logs: Array<{ type: "log" | "error"; text: string }> = [];
|
||||
|
||||
// Refs for DOM elements
|
||||
private sandboxIframeRef: Ref<SandboxIframe> = createRef();
|
||||
private consoleLogsRef: Ref<HTMLDivElement> = createRef();
|
||||
private consoleButtonRef: Ref<HTMLButtonElement> = createRef();
|
||||
|
||||
// Store message handler so we can remove it
|
||||
private messageHandler?: (e: MessageEvent) => void;
|
||||
|
||||
@state() private viewMode: "preview" | "code" = "preview";
|
||||
@state() private consoleOpen = false;
|
||||
|
||||
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;
|
||||
|
||||
return html`
|
||||
<div class="flex items-center gap-2">
|
||||
${toggle}
|
||||
${copyButton}
|
||||
${DownloadButton({ content: this._content, 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 = [];
|
||||
if (this.consoleLogsRef.value) {
|
||||
this.consoleLogsRef.value.innerHTML = "";
|
||||
}
|
||||
this.requestUpdate();
|
||||
// Execute content in sandbox if it exists
|
||||
if (this.sandboxIframeRef.value && value) {
|
||||
this.updateConsoleButton();
|
||||
this.executeContent(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private executeContent(html: string) {
|
||||
const sandbox = this.sandboxIframeRef.value;
|
||||
if (!sandbox) return;
|
||||
|
||||
// Remove previous message handler if it exists
|
||||
if (this.messageHandler) {
|
||||
window.removeEventListener("message", this.messageHandler);
|
||||
}
|
||||
|
||||
const sandboxId = `artifact-${this.filename}`;
|
||||
|
||||
// Set up message listener to collect logs
|
||||
this.messageHandler = (e: MessageEvent) => {
|
||||
if (e.data.sandboxId !== sandboxId) return;
|
||||
|
||||
if (e.data.type === "console") {
|
||||
this.logs.push({
|
||||
type: e.data.method === "error" ? "error" : "log",
|
||||
text: e.data.text,
|
||||
});
|
||||
this.updateConsoleButton();
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", this.messageHandler);
|
||||
|
||||
// Load content (iframe persists, doesn't get removed)
|
||||
sandbox.loadContent(sandboxId, html, this.attachments);
|
||||
}
|
||||
|
||||
override get content(): string {
|
||||
return this._content;
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
// Clean up message handler when element is removed from DOM
|
||||
if (this.messageHandler) {
|
||||
window.removeEventListener("message", this.messageHandler);
|
||||
this.messageHandler = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
// Execute initial content
|
||||
if (this._content && this.sandboxIframeRef.value) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private updateConsoleButton() {
|
||||
const button = this.consoleButtonRef.value;
|
||||
if (!button) return;
|
||||
|
||||
const errorCount = this.logs.filter((l) => l.type === "error").length;
|
||||
const text =
|
||||
errorCount > 0
|
||||
? `${i18n("console")} <span class="text-destructive">${errorCount} errors</span>`
|
||||
: `${i18n("console")} (${this.logs.length})`;
|
||||
button.innerHTML = `<span>${text}</span><span>${this.consoleOpen ? "▼" : "▶"}</span>`;
|
||||
}
|
||||
|
||||
private toggleConsole() {
|
||||
this.consoleOpen = !this.consoleOpen;
|
||||
this.requestUpdate();
|
||||
|
||||
// Populate console logs if opening
|
||||
if (this.consoleOpen) {
|
||||
requestAnimationFrame(() => {
|
||||
if (this.consoleLogsRef.value) {
|
||||
// Populate with existing logs
|
||||
this.consoleLogsRef.value.innerHTML = "";
|
||||
this.logs.forEach((log) => {
|
||||
const logEl = document.createElement("div");
|
||||
logEl.className = `text-xs font-mono ${log.type === "error" ? "text-destructive" : "text-muted-foreground"}`;
|
||||
logEl.textContent = `[${log.type}] ${log.text}`;
|
||||
this.consoleLogsRef.value!.appendChild(logEl);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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`
|
||||
<div class="border-t border-border">
|
||||
<button
|
||||
@click=${() => this.toggleConsole()}
|
||||
class="w-full px-3 py-1 text-xs text-left hover:bg-muted flex items-center justify-between"
|
||||
${ref(this.consoleButtonRef)}
|
||||
>
|
||||
<span
|
||||
>${i18n("console")}
|
||||
${
|
||||
this.logs.filter((l) => l.type === "error").length > 0
|
||||
? html`<span class="text-destructive">${this.logs.filter((l) => l.type === "error").length} errors</span>`
|
||||
: `(${this.logs.length})`
|
||||
}</span
|
||||
>
|
||||
<span>${this.consoleOpen ? "▼" : "▶"}</span>
|
||||
</button>
|
||||
${this.consoleOpen ? html` <div class="max-h-48 overflow-y-auto bg-muted/50 p-2" ${ref(this.consoleLogsRef)}></div> ` : ""}
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
import { CopyButton, DownloadButton, PreviewCodeToggle } from "@mariozechner/mini-lit";
|
||||
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 { ArtifactElement } from "./ArtifactElement.js";
|
||||
|
||||
@customElement("markdown-artifact")
|
||||
export class MarkdownArtifact extends ArtifactElement {
|
||||
@property() override filename = "";
|
||||
@property({ attribute: false }) override displayTitle = "";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
import { CopyButton, DownloadButton, PreviewCodeToggle } from "@mariozechner/mini-lit";
|
||||
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 = "";
|
||||
@property({ attribute: false }) override displayTitle = "";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
import { CopyButton, DownloadButton } from "@mariozechner/mini-lit";
|
||||
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 = "";
|
||||
@property({ attribute: false }) override displayTitle = "";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,882 +0,0 @@
|
|||
import { Button, Diff, icon } from "@mariozechner/mini-lit";
|
||||
import { type AgentTool, type Message, StringEnum, type ToolCall, type ToolResultMessage } 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 { Attachment } from "../../utils/attachment-utils.js";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import type { ToolRenderer } from "../types.js";
|
||||
import type { ArtifactElement } from "./ArtifactElement.js";
|
||||
import { HtmlArtifact } from "./HtmlArtifact.js";
|
||||
import { MarkdownArtifact } from "./MarkdownArtifact.js";
|
||||
import { SvgArtifact } from "./SvgArtifact.js";
|
||||
import { TextArtifact } from "./TextArtifact.js";
|
||||
import "@mariozechner/mini-lit/dist/MarkdownBlock.js";
|
||||
import "@mariozechner/mini-lit/dist/CodeBlock.js";
|
||||
|
||||
// Simple artifact model
|
||||
export interface Artifact {
|
||||
filename: string;
|
||||
title: 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')" }),
|
||||
title: Type.Optional(Type.String({ description: "Display title for the tab (defaults to filename)" })),
|
||||
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>;
|
||||
|
||||
// Minimal helper to render plain text outputs consistently
|
||||
function plainOutput(text: string): TemplateResult {
|
||||
return html`<div class="text-xs text-muted-foreground whitespace-pre-wrap font-mono">${text}</div>`;
|
||||
}
|
||||
|
||||
@customElement("artifacts-panel")
|
||||
export class ArtifactsPanel extends LitElement implements ToolRenderer<ArtifactsParams, undefined> {
|
||||
@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();
|
||||
|
||||
// External provider for attachments (decouples panel from AgentInterface)
|
||||
@property({ attribute: false }) attachmentsProvider?: () => Attachment[];
|
||||
// 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;
|
||||
}
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this; // light DOM for shared styles
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
// 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" | "text" {
|
||||
const ext = filename.split(".").pop()?.toLowerCase();
|
||||
if (ext === "html") return "html";
|
||||
if (ext === "svg") return "svg";
|
||||
if (ext === "md" || ext === "markdown") return "markdown";
|
||||
return "text";
|
||||
}
|
||||
|
||||
// Helper to determine language for syntax highlighting
|
||||
private 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";
|
||||
}
|
||||
|
||||
// Get or create artifact element
|
||||
private getOrCreateArtifactElement(filename: string, content: string, title: string): ArtifactElement {
|
||||
let element = this.artifactElements.get(filename);
|
||||
|
||||
if (!element) {
|
||||
const type = this.getFileType(filename);
|
||||
if (type === "html") {
|
||||
element = new HtmlArtifact();
|
||||
(element as HtmlArtifact).attachments = this.attachmentsProvider?.() || [];
|
||||
} else if (type === "svg") {
|
||||
element = new SvgArtifact();
|
||||
} else if (type === "markdown") {
|
||||
element = new MarkdownArtifact();
|
||||
} else {
|
||||
element = new TextArtifact();
|
||||
}
|
||||
element.filename = filename;
|
||||
element.displayTitle = title;
|
||||
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;
|
||||
element.displayTitle = title;
|
||||
if (element instanceof HtmlArtifact) {
|
||||
element.attachments = this.attachmentsProvider?.() || [];
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Open panel and focus an artifact tab by filename
|
||||
private 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",
|
||||
description: `Creates and manages file artifacts. Each artifact is a file with a filename and content.
|
||||
|
||||
IMPORTANT: Always prefer updating existing files over creating new ones. Check available files first.
|
||||
|
||||
Commands:
|
||||
1. create: Create a new file
|
||||
- filename: Name with extension (required, e.g., 'index.html', 'script.js', 'README.md')
|
||||
- title: Display name for the tab (optional, defaults to filename)
|
||||
- content: File content (required)
|
||||
|
||||
2. update: Update part of an existing file
|
||||
- filename: File to update (required)
|
||||
- old_str: Exact string to replace (required)
|
||||
- new_str: Replacement string (required)
|
||||
|
||||
3. rewrite: Completely replace a file's content
|
||||
- filename: File to rewrite (required)
|
||||
- content: New content (required)
|
||||
- title: Optionally update display title
|
||||
|
||||
4. get: Retrieve the full content of a file
|
||||
- filename: File to retrieve (required)
|
||||
- Returns the complete file content
|
||||
|
||||
5. delete: Delete a file
|
||||
- filename: File to delete (required)
|
||||
|
||||
6. logs: Get console logs and errors (HTML files only)
|
||||
- filename: HTML file to get logs for (required)
|
||||
- Returns all console output and runtime errors
|
||||
|
||||
For text/html artifacts with attachments:
|
||||
- HTML artifacts automatically have access to user attachments via JavaScript
|
||||
- Available global functions in HTML artifacts:
|
||||
* listFiles() - Returns array of {id, fileName, mimeType, size} for all attachments
|
||||
* readTextFile(attachmentId) - Returns text content of attachment (for CSV, JSON, text files)
|
||||
* readBinaryFile(attachmentId) - Returns Uint8Array of binary data (for images, Excel, etc.)
|
||||
- Example HTML artifact that processes a CSV attachment:
|
||||
<script>
|
||||
// List available files
|
||||
const files = listFiles();
|
||||
console.log('Available files:', files);
|
||||
|
||||
// Find CSV file
|
||||
const csvFile = files.find(f => f.mimeType === 'text/csv');
|
||||
if (csvFile) {
|
||||
const csvContent = readTextFile(csvFile.id);
|
||||
// Process CSV data...
|
||||
}
|
||||
|
||||
// Display image
|
||||
const imageFile = files.find(f => f.mimeType.startsWith('image/'));
|
||||
if (imageFile) {
|
||||
const bytes = readBinaryFile(imageFile.id);
|
||||
const blob = new Blob([bytes], {type: imageFile.mimeType});
|
||||
const url = URL.createObjectURL(blob);
|
||||
document.body.innerHTML = '<img src="' + url + '">';
|
||||
}
|
||||
</script>
|
||||
|
||||
For text/html artifacts:
|
||||
- Must be a single self-contained file
|
||||
- External scripts: Use CDNs like https://esm.sh, https://unpkg.com, or https://cdnjs.cloudflare.com
|
||||
- Preferred: Use https://esm.sh for npm packages (e.g., https://esm.sh/three for Three.js)
|
||||
- For ES modules, use: <script type="module">import * as THREE from 'https://esm.sh/three';</script>
|
||||
- For Three.js specifically: import from 'https://esm.sh/three' or 'https://esm.sh/three@0.160.0'
|
||||
- For addons: import from 'https://esm.sh/three/examples/jsm/controls/OrbitControls.js'
|
||||
- No localStorage/sessionStorage - use in-memory variables only
|
||||
- CSS should be included inline
|
||||
- CRITICAL REMINDER FOR HTML ARTIFACTS:
|
||||
- ALWAYS set a background color inline in <style> or directly on body element
|
||||
- Failure to set a background color is a COMPLIANCE ERROR
|
||||
- Background color MUST be explicitly defined to ensure visibility and proper rendering
|
||||
- Can embed base64 images directly in img tags
|
||||
- Ensure the layout is responsive as the iframe might be resized
|
||||
- Note: Network errors (404s) for external scripts may not be captured in logs due to browser security
|
||||
|
||||
For application/vnd.ant.code artifacts:
|
||||
- Include the language parameter for syntax highlighting
|
||||
- Supports all major programming languages
|
||||
|
||||
For text/markdown:
|
||||
- Standard markdown syntax
|
||||
- Will be rendered with full formatting
|
||||
- Can include base64 images using markdown syntax
|
||||
|
||||
For image/svg+xml:
|
||||
- Complete SVG markup
|
||||
- Will be rendered inline
|
||||
- Can embed raster images as base64 in SVG
|
||||
|
||||
CRITICAL REMINDER FOR ALL ARTIFACTS:
|
||||
- Prefer to update existing files rather than creating new ones
|
||||
- Keep filenames consistent and descriptive
|
||||
- Use appropriate file extensions
|
||||
- Ensure HTML artifacts have a defined background color
|
||||
`,
|
||||
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 { output, details: undefined };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ToolRenderer implementation
|
||||
renderParams(params: ArtifactsParams, isStreaming?: boolean): TemplateResult {
|
||||
if (isStreaming && !params.command) {
|
||||
return html`<div class="text-sm text-muted-foreground">${i18n("Processing artifact...")}</div>`;
|
||||
}
|
||||
|
||||
let commandLabel = i18n("Processing");
|
||||
if (params.command) {
|
||||
switch (params.command) {
|
||||
case "create":
|
||||
commandLabel = i18n("Create");
|
||||
break;
|
||||
case "update":
|
||||
commandLabel = i18n("Update");
|
||||
break;
|
||||
case "rewrite":
|
||||
commandLabel = i18n("Rewrite");
|
||||
break;
|
||||
case "get":
|
||||
commandLabel = i18n("Get");
|
||||
break;
|
||||
case "delete":
|
||||
commandLabel = i18n("Delete");
|
||||
break;
|
||||
case "logs":
|
||||
commandLabel = i18n("Get logs");
|
||||
break;
|
||||
default:
|
||||
commandLabel = params.command.charAt(0).toUpperCase() + params.command.slice(1);
|
||||
}
|
||||
}
|
||||
const filename = params.filename || "";
|
||||
|
||||
switch (params.command) {
|
||||
case "create":
|
||||
return html`
|
||||
<div
|
||||
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
|
||||
@click=${() => this.openArtifact(params.filename)}
|
||||
>
|
||||
<div>
|
||||
<span class="font-medium">${i18n("Create")}</span>
|
||||
<span class="text-muted-foreground ml-1">${filename}</span>
|
||||
</div>
|
||||
${
|
||||
params.content
|
||||
? html`<code-block
|
||||
.code=${params.content}
|
||||
language=${this.getLanguageFromFilename(params.filename)}
|
||||
class="mt-2"
|
||||
></code-block>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
case "update":
|
||||
return html`
|
||||
<div
|
||||
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
|
||||
@click=${() => this.openArtifact(params.filename)}
|
||||
>
|
||||
<div>
|
||||
<span class="font-medium">${i18n("Update")}</span>
|
||||
<span class="text-muted-foreground ml-1">${filename}</span>
|
||||
</div>
|
||||
${
|
||||
params.old_str !== undefined && params.new_str !== undefined
|
||||
? Diff({ oldText: params.old_str, newText: params.new_str, className: "mt-2" })
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
case "rewrite":
|
||||
return html`
|
||||
<div
|
||||
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
|
||||
@click=${() => this.openArtifact(params.filename)}
|
||||
>
|
||||
<div>
|
||||
<span class="font-medium">${i18n("Rewrite")}</span>
|
||||
<span class="text-muted-foreground ml-1">${filename}</span>
|
||||
</div>
|
||||
${
|
||||
params.content
|
||||
? html`<code-block
|
||||
.code=${params.content}
|
||||
language=${this.getLanguageFromFilename(params.filename)}
|
||||
class="mt-2"
|
||||
></code-block>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
case "get":
|
||||
return html`
|
||||
<div
|
||||
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
|
||||
@click=${() => this.openArtifact(params.filename)}
|
||||
>
|
||||
<span class="font-medium">${i18n("Get")}</span>
|
||||
<span class="text-muted-foreground ml-1">${filename}</span>
|
||||
</div>
|
||||
`;
|
||||
case "delete":
|
||||
return html`
|
||||
<div
|
||||
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
|
||||
@click=${() => this.openArtifact(params.filename)}
|
||||
>
|
||||
<span class="font-medium">${i18n("Delete")}</span>
|
||||
<span class="text-muted-foreground ml-1">${filename}</span>
|
||||
</div>
|
||||
`;
|
||||
case "logs":
|
||||
return html`
|
||||
<div
|
||||
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
|
||||
@click=${() => this.openArtifact(params.filename)}
|
||||
>
|
||||
<span class="font-medium">${i18n("Get logs")}</span>
|
||||
<span class="text-muted-foreground ml-1">${filename}</span>
|
||||
</div>
|
||||
`;
|
||||
default:
|
||||
// Fallback for any command not yet handled during streaming
|
||||
return html`
|
||||
<div
|
||||
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
|
||||
@click=${() => this.openArtifact(params.filename)}
|
||||
>
|
||||
<span class="font-medium">${commandLabel}</span>
|
||||
<span class="text-muted-foreground ml-1">${filename}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
renderResult(params: ArtifactsParams, result: ToolResultMessage<undefined>): TemplateResult {
|
||||
// Make result clickable to focus the referenced file when applicable
|
||||
const content = result.output || i18n("(no output)");
|
||||
return html`
|
||||
<div class="cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1" @click=${() => this.openArtifact(params.filename)}>
|
||||
${plainOutput(content)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Re-apply artifacts by scanning a message list (optional utility)
|
||||
public async reconstructFromMessages(messages: Array<Message | { role: "aborted" }>): 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 === "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
|
||||
type FinalArtifact = { title: string; content: string };
|
||||
const finalArtifacts = new Map<string, FinalArtifact>();
|
||||
for (const op of operations) {
|
||||
const filename = op.filename;
|
||||
switch (op.command) {
|
||||
case "create": {
|
||||
if (op.content) {
|
||||
finalArtifacts.set(filename, { title: op.title || filename, content: op.content });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "rewrite": {
|
||||
if (op.content) {
|
||||
// If file didn't exist earlier but rewrite succeeded, treat as fresh content
|
||||
const existing = finalArtifacts.get(filename);
|
||||
finalArtifacts.set(filename, { title: op.title || existing?.title || filename, content: op.content });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "update": {
|
||||
const 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.content = existing.content.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, { title, content }] of finalArtifacts.entries()) {
|
||||
const createParams: ArtifactsParams = { command: "create", filename, title, 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) => {
|
||||
let resolved = false;
|
||||
|
||||
// Listen for the execution-complete message
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
if (event.data?.type === "execution-complete" && event.data?.artifactId === filename) {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
window.removeEventListener("message", messageHandler);
|
||||
|
||||
// Get the logs from the element
|
||||
const logs = element.getLogs();
|
||||
if (logs.includes("[error]")) {
|
||||
resolve(`\n\nExecution completed with errors:\n${logs}`);
|
||||
} else if (logs !== `No logs for ${filename}`) {
|
||||
resolve(`\n\nExecution logs:\n${logs}`);
|
||||
} else {
|
||||
resolve("");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", messageHandler);
|
||||
|
||||
// Fallback timeout in case the message never arrives
|
||||
setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
window.removeEventListener("message", messageHandler);
|
||||
|
||||
// Get whatever logs we have so far
|
||||
const logs = element.getLogs();
|
||||
if (logs.includes("[error]")) {
|
||||
resolve(`\n\nExecution timed out with errors:\n${logs}`);
|
||||
} else if (logs !== `No logs for ${filename}`) {
|
||||
resolve(`\n\nExecution timed out. Partial logs:\n${logs}`);
|
||||
} else {
|
||||
resolve("");
|
||||
}
|
||||
}
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
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 title = params.title || params.filename;
|
||||
const artifact: Artifact = {
|
||||
filename: params.filename,
|
||||
title: title,
|
||||
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, title);
|
||||
if (!options.silent) {
|
||||
this.showArtifact(params.filename);
|
||||
this.onArtifactsChange?.();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
// 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 += 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, artifact.title);
|
||||
if (!options.silent) {
|
||||
this.onArtifactsChange?.();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
// Show the artifact
|
||||
this.showArtifact(params.filename);
|
||||
|
||||
// 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 += 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;
|
||||
if (params.title) artifact.title = params.title;
|
||||
artifact.updatedAt = new Date();
|
||||
this._artifacts.set(params.filename, artifact);
|
||||
|
||||
// Update element
|
||||
this.getOrCreateArtifactElement(params.filename, artifact.content, artifact.title);
|
||||
if (!options.silent) {
|
||||
this.onArtifactsChange?.();
|
||||
}
|
||||
|
||||
// Show the artifact
|
||||
this.showArtifact(params.filename);
|
||||
|
||||
// For HTML files, wait for execution
|
||||
let result = `Rewrote file ${params.filename}`;
|
||||
if (this.getFileType(params.filename) === "html" && !options.skipWait) {
|
||||
const logs = await this.waitForHtmlExecution(params.filename);
|
||||
result += 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();
|
||||
|
||||
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}"
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export { ArtifactElement } from "./ArtifactElement.js";
|
||||
export { type Artifact, ArtifactsPanel, type ArtifactsParams } from "./artifacts.js";
|
||||
export { HtmlArtifact } from "./HtmlArtifact.js";
|
||||
export { MarkdownArtifact } from "./MarkdownArtifact.js";
|
||||
export { SvgArtifact } from "./SvgArtifact.js";
|
||||
export { TextArtifact } from "./TextArtifact.js";
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import type { AgentTool, ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { type Attachment, registerToolRenderer, type ToolRenderer } from "@mariozechner/pi-web-ui";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import "../components/ConsoleBlock.js"; // Ensure console-block is registered
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
import { registerToolRenderer } from "./renderer-registry.js";
|
||||
import type { ToolRenderer } from "./types.js";
|
||||
import "@mariozechner/pi-web-ui"; // Ensure all components are registered
|
||||
|
||||
// Cross-browser API compatibility
|
||||
// @ts-expect-error - browser global exists in Firefox, chrome in Chrome
|
||||
|
|
@ -211,13 +209,111 @@ This ensures reliable execution.`,
|
|||
const canUseEval = cspCheckResults[0]?.result?.canEval ?? false;
|
||||
const canUseScriptTag = cspCheckResults[0]?.result?.canUseScriptTag ?? false;
|
||||
|
||||
// If neither method works, return error immediately
|
||||
// If neither method works, fallback to JailJS via content script
|
||||
if (!canUseEval && !canUseScriptTag) {
|
||||
console.log("[pi-ai] CSP blocks eval and script injection, falling back to JailJS");
|
||||
|
||||
// Send execution request to content script
|
||||
const response = await new Promise<{
|
||||
success: boolean;
|
||||
result?: unknown;
|
||||
console?: Array<{ type: string; args: unknown[] }>;
|
||||
files?: Array<{ fileName: string; content: string | Uint8Array; mimeType: string }>;
|
||||
error?: string;
|
||||
stack?: string;
|
||||
}>((resolve) => {
|
||||
browser.tabs.sendMessage(
|
||||
tab.id,
|
||||
{
|
||||
type: "EXECUTE_CODE",
|
||||
mode: "jailjs",
|
||||
code: args.code,
|
||||
},
|
||||
resolve,
|
||||
);
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
return {
|
||||
output: `JailJS Execution Error: ${response.error}\n\nStack:\n${response.stack || "No stack trace"}`,
|
||||
isError: true,
|
||||
details: { files: [] },
|
||||
};
|
||||
}
|
||||
|
||||
// Format console output
|
||||
const formatArg = (arg: unknown): string => {
|
||||
if (arg === null) return "null";
|
||||
if (arg === undefined) return "undefined";
|
||||
if (typeof arg === "string") return arg;
|
||||
if (typeof arg === "number" || typeof arg === "boolean") return String(arg);
|
||||
try {
|
||||
return JSON.stringify(arg, null, 2);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
};
|
||||
|
||||
// Build output with console logs
|
||||
let output = "";
|
||||
|
||||
// Add console output
|
||||
if (response.console && response.console.length > 0) {
|
||||
for (const entry of response.console) {
|
||||
const prefix = entry.type === "error" ? "[ERROR]" : entry.type === "warn" ? "[WARN]" : "";
|
||||
const formattedArgs = entry.args.map(formatArg).join(" ");
|
||||
const line = prefix ? `${prefix} ${formattedArgs}` : formattedArgs;
|
||||
output += line + "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Add file notifications
|
||||
if (response.files && response.files.length > 0) {
|
||||
output += `\n[Files returned: ${response.files.length}]\n`;
|
||||
for (const file of response.files) {
|
||||
output += ` - ${file.fileName} (${file.mimeType})\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert files to base64 for transport
|
||||
const files = (response.files || []).map(
|
||||
(f: { fileName: string; content: string | Uint8Array; mimeType: string }) => {
|
||||
const toBase64 = (input: string | Uint8Array): { base64: string; size: number } => {
|
||||
if (input instanceof Uint8Array) {
|
||||
let binary = "";
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < input.length; i += chunk) {
|
||||
binary += String.fromCharCode(...input.subarray(i, i + chunk));
|
||||
}
|
||||
return { base64: btoa(binary), size: input.length };
|
||||
} else {
|
||||
const enc = new TextEncoder();
|
||||
const bytes = enc.encode(input);
|
||||
let binary = "";
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
||||
}
|
||||
return { base64: btoa(binary), size: bytes.length };
|
||||
}
|
||||
};
|
||||
|
||||
const { base64, size } = toBase64(f.content);
|
||||
return {
|
||||
fileName: f.fileName || "file",
|
||||
mimeType: f.mimeType || "application/octet-stream",
|
||||
size,
|
||||
contentBase64: base64,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
output:
|
||||
"Cannot execute JavaScript on this page. The page's Content Security Policy blocks both eval() and inline script injection. This is common on sites with strict CSP.",
|
||||
isError: true,
|
||||
details: { files: [] },
|
||||
output.trim() ||
|
||||
"Code executed successfully (no output)\n\n⚠️ Note: CSP blocked direct execution. Code ran via JailJS interpreter.",
|
||||
isError: false,
|
||||
details: { files },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import type { TemplateResult } from "@mariozechner/mini-lit";
|
||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { getToolRenderer, registerToolRenderer } from "./renderer-registry.js";
|
||||
import { BashRenderer } from "./renderers/BashRenderer.js";
|
||||
import { CalculateRenderer } from "./renderers/CalculateRenderer.js";
|
||||
import { DefaultRenderer } from "./renderers/DefaultRenderer.js";
|
||||
import { GetCurrentTimeRenderer } from "./renderers/GetCurrentTimeRenderer.js";
|
||||
import "./javascript-repl.js"; // Import for side effects (registers renderer)
|
||||
import {
|
||||
BashRenderer,
|
||||
CalculateRenderer,
|
||||
createJavaScriptReplTool,
|
||||
GetCurrentTimeRenderer,
|
||||
javascriptReplTool,
|
||||
registerToolRenderer,
|
||||
} from "@mariozechner/pi-web-ui";
|
||||
import "./browser-javascript.js"; // Import for side effects (registers renderer)
|
||||
|
||||
// Register all built-in tool renderers
|
||||
|
|
@ -13,30 +13,6 @@ registerToolRenderer("calculate", new CalculateRenderer());
|
|||
registerToolRenderer("get_current_time", new GetCurrentTimeRenderer());
|
||||
registerToolRenderer("bash", new BashRenderer());
|
||||
|
||||
const defaultRenderer = new DefaultRenderer();
|
||||
|
||||
/**
|
||||
* Render tool call parameters
|
||||
*/
|
||||
export function renderToolParams(toolName: string, params: any, isStreaming?: boolean): TemplateResult {
|
||||
const renderer = getToolRenderer(toolName);
|
||||
if (renderer) {
|
||||
return renderer.renderParams(params, isStreaming);
|
||||
}
|
||||
return defaultRenderer.renderParams(params, isStreaming);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render tool result
|
||||
*/
|
||||
export function renderToolResult(toolName: string, params: any, result: ToolResultMessage): TemplateResult {
|
||||
const renderer = getToolRenderer(toolName);
|
||||
if (renderer) {
|
||||
return renderer.renderResult(params, result);
|
||||
}
|
||||
return defaultRenderer.renderResult(params, result);
|
||||
}
|
||||
|
||||
export { registerToolRenderer, getToolRenderer };
|
||||
// Re-export for convenience
|
||||
export { createJavaScriptReplTool, javascriptReplTool };
|
||||
export { browserJavaScriptTool } from "./browser-javascript.js";
|
||||
export { createJavaScriptReplTool, javascriptReplTool } from "./javascript-repl.js";
|
||||
|
|
|
|||
|
|
@ -1,304 +0,0 @@
|
|||
import { html, i18n, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import type { AgentTool, ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { type SandboxFile, SandboxIframe, type SandboxResult } from "../components/SandboxedIframe.js";
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
|
||||
import { registerToolRenderer } from "./renderer-registry.js";
|
||||
import type { ToolRenderer } from "./types.js";
|
||||
import "../components/ConsoleBlock.js"; // Ensure console-block is registered
|
||||
|
||||
// Execute JavaScript code with attachments using SandboxedIframe
|
||||
export async function executeJavaScript(
|
||||
code: string,
|
||||
attachments: any[] = [],
|
||||
signal?: AbortSignal,
|
||||
): 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();
|
||||
sandbox.style.display = "none";
|
||||
document.body.appendChild(sandbox);
|
||||
|
||||
try {
|
||||
const sandboxId = `repl-${Date.now()}`;
|
||||
const result: SandboxResult = await sandbox.execute(sandboxId, code, attachments, signal);
|
||||
|
||||
// Remove the sandbox iframe after execution
|
||||
sandbox.remove();
|
||||
|
||||
// Return plain text output
|
||||
if (!result.success) {
|
||||
// Return error as plain text
|
||||
return {
|
||||
output: `Error: ${result.error?.message || "Unknown error"}\n${result.error?.stack || ""}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const prefix = entry.type === "error" ? "[ERROR]" : "";
|
||||
output += (prefix ? `${prefix} ` : "") + entry.text + "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Add file notifications
|
||||
if (result.files && result.files.length > 0) {
|
||||
output += `\n[Files returned: ${result.files.length}]\n`;
|
||||
for (const file of result.files) {
|
||||
output += ` - ${file.fileName} (${file.mimeType})\n`;
|
||||
}
|
||||
} 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: any) {
|
||||
// Clean up on error
|
||||
sandbox.remove();
|
||||
throw new Error(error.message || "Execution failed");
|
||||
}
|
||||
}
|
||||
|
||||
export type JavaScriptReplToolResult = {
|
||||
files?:
|
||||
| {
|
||||
fileName: string;
|
||||
contentBase64: any;
|
||||
mimeType: string;
|
||||
}[]
|
||||
| undefined;
|
||||
};
|
||||
|
||||
const javascriptReplSchema = Type.Object({
|
||||
code: Type.String({ description: "JavaScript code to execute" }),
|
||||
});
|
||||
|
||||
export function createJavaScriptReplTool(): AgentTool<typeof javascriptReplSchema, JavaScriptReplToolResult> & {
|
||||
attachmentsProvider?: () => any[];
|
||||
} {
|
||||
return {
|
||||
label: "JavaScript REPL",
|
||||
name: "javascript_repl",
|
||||
attachmentsProvider: () => [], // default to empty array
|
||||
description: `Execute JavaScript code in a sandboxed browser environment with full modern browser capabilities.
|
||||
|
||||
Environment: Modern browser with ALL Web APIs available:
|
||||
- ES2023+ JavaScript (async/await, optional chaining, nullish coalescing, etc.)
|
||||
- DOM APIs (document, window, Canvas, WebGL, etc.)
|
||||
- Fetch API for HTTP requests
|
||||
|
||||
Loading external libraries via dynamic imports (use esm.run):
|
||||
- XLSX (Excel files): const XLSX = await import('https://esm.run/xlsx');
|
||||
- Papa Parse (CSV): const Papa = (await import('https://esm.run/papaparse')).default;
|
||||
- Lodash: const _ = await import('https://esm.run/lodash-es');
|
||||
- D3.js: const d3 = await import('https://esm.run/d3');
|
||||
- Chart.js: const Chart = (await import('https://esm.run/chart.js/auto')).default;
|
||||
- Three.js: const THREE = await import('https://esm.run/three');
|
||||
- Any npm package: await import('https://esm.run/package-name')
|
||||
|
||||
IMPORTANT for graphics/canvas:
|
||||
- Use fixed dimensions like 400x400 or 800x600, NOT window.innerWidth/Height
|
||||
- For Three.js: renderer.setSize(400, 400) and camera aspect ratio of 1
|
||||
- For Chart.js: Set options: { responsive: false, animation: false } to ensure immediate rendering
|
||||
- Web Storage (localStorage, sessionStorage, IndexedDB)
|
||||
- Web Workers, WebAssembly, WebSockets
|
||||
- Media APIs (Audio, Video, WebRTC)
|
||||
- File APIs (Blob, FileReader, etc.)
|
||||
- Crypto API for cryptography
|
||||
- And much more - anything a modern browser supports!
|
||||
|
||||
Output:
|
||||
- console.log() - All output is captured as text
|
||||
- await returnFile(filename, content, mimeType?) - Create downloadable files (async function!)
|
||||
* Always use await with returnFile
|
||||
* REQUIRED: For Blob/Uint8Array binary content, you MUST supply a proper MIME type (e.g., "image/png").
|
||||
If omitted, the REPL throws an Error with stack trace pointing to the offending line.
|
||||
* Strings without a MIME default to text/plain.
|
||||
* Objects are auto-JSON stringified and default to application/json unless a MIME is provided.
|
||||
* Canvas images: Use toBlob() with await Promise wrapper
|
||||
* Examples:
|
||||
- await returnFile('data.txt', 'Hello World', 'text/plain')
|
||||
- await returnFile('data.json', {key: 'value'}, 'application/json')
|
||||
- await returnFile('data.csv', 'name,age\\nJohn,30', 'text/csv')
|
||||
- Chart.js example:
|
||||
const Chart = (await import('https://esm.run/chart.js/auto')).default;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 400; canvas.height = 300;
|
||||
document.body.appendChild(canvas);
|
||||
new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ['Jan', 'Feb', 'Mar', 'Apr'],
|
||||
datasets: [{ label: 'Sales', data: [10, 20, 15, 25], borderColor: 'blue' }]
|
||||
},
|
||||
options: { responsive: false, animation: false }
|
||||
});
|
||||
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
|
||||
await returnFile('chart.png', blob, 'image/png');
|
||||
|
||||
Global variables:
|
||||
- attachments[] - Array of attachment objects from user messages
|
||||
* Properties:
|
||||
- id: string (unique identifier)
|
||||
- fileName: string (e.g., "data.xlsx")
|
||||
- mimeType: string (e.g., "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
- size: number (bytes)
|
||||
* Helper functions:
|
||||
- listFiles() - Returns array of {id, fileName, mimeType, size} for all attachments
|
||||
- readTextFile(attachmentId) - Returns text content of attachment (for CSV, JSON, text files)
|
||||
- readBinaryFile(attachmentId) - Returns Uint8Array of binary data (for images, Excel, etc.)
|
||||
* Examples:
|
||||
- const files = listFiles();
|
||||
- const csvContent = readTextFile(files[0].id); // Read CSV as text
|
||||
- const xlsxBytes = readBinaryFile(files[0].id); // Read Excel as binary
|
||||
- All standard browser globals (window, document, fetch, etc.)`,
|
||||
parameters: javascriptReplSchema,
|
||||
execute: async function (_toolCallId: string, args: Static<typeof javascriptReplSchema>, signal?: AbortSignal) {
|
||||
const attachments = this.attachmentsProvider?.() || [];
|
||||
const result = await executeJavaScript(args.code, attachments, signal);
|
||||
// Convert files to JSON-serializable with base64 payloads
|
||||
const files = (result.files || []).map((f) => {
|
||||
const toBase64 = (input: any): { 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 as any).content);
|
||||
return {
|
||||
fileName: (f as any).fileName || "file",
|
||||
mimeType: (f as any).mimeType || "application/octet-stream",
|
||||
size,
|
||||
contentBase64: base64,
|
||||
};
|
||||
});
|
||||
return { output: result.output, details: { files } };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Export a default instance for backward compatibility
|
||||
export const javascriptReplTool = createJavaScriptReplTool();
|
||||
|
||||
// JavaScript REPL renderer with streaming support
|
||||
|
||||
interface JavaScriptReplParams {
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface JavaScriptReplResult {
|
||||
output?: string;
|
||||
files?: Array<{
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
contentBase64: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const javascriptReplRenderer: ToolRenderer<JavaScriptReplParams, JavaScriptReplResult> = {
|
||||
renderParams(params: JavaScriptReplParams, isStreaming?: boolean): TemplateResult {
|
||||
if (isStreaming && (!params.code || params.code.length === 0)) {
|
||||
return html`<div class="text-sm text-muted-foreground">${"Writing JavaScript code..."}</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="text-sm text-muted-foreground mb-2">${i18n("Executing JavaScript")}</div>
|
||||
<code-block .code=${params.code || ""} language="javascript"></code-block>
|
||||
`;
|
||||
},
|
||||
|
||||
renderResult(_params: JavaScriptReplParams, result: ToolResultMessage<JavaScriptReplResult>): TemplateResult {
|
||||
// Console output is in the main output field, files are in details
|
||||
const output = result.output || "";
|
||||
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 html`
|
||||
<div class="flex flex-col gap-3">
|
||||
${output ? html`<console-block .content=${output}></console-block>` : ""}
|
||||
${
|
||||
attachments.length
|
||||
? html`<div class="flex flex-wrap gap-2">
|
||||
${attachments.map((att) => html`<attachment-tile .attachment=${att}></attachment-tile>`)}
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
};
|
||||
|
||||
// Auto-register the renderer
|
||||
registerToolRenderer(javascriptReplTool.name, javascriptReplRenderer);
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import type { ToolRenderer } from "../types.js";
|
||||
|
||||
interface BashParams {
|
||||
command: string;
|
||||
}
|
||||
|
||||
// Bash tool has undefined details (only uses output)
|
||||
export class BashRenderer implements ToolRenderer<BashParams, undefined> {
|
||||
renderParams(params: BashParams, isStreaming?: boolean): TemplateResult {
|
||||
if (isStreaming && (!params.command || params.command.length === 0)) {
|
||||
return html`<div class="text-sm text-muted-foreground">${i18n("Writing command...")}</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<span>${i18n("Running command:")}</span>
|
||||
<code class="ml-1 px-1.5 py-0.5 bg-muted rounded text-xs font-mono">${params.command}</code>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderResult(_params: BashParams, result: ToolResultMessage<undefined>): TemplateResult {
|
||||
const output = result.output || "";
|
||||
const isError = result.isError === true;
|
||||
|
||||
if (isError) {
|
||||
return html`
|
||||
<div class="text-sm">
|
||||
<div class="text-destructive font-medium mb-1">${i18n("Command failed:")}</div>
|
||||
<pre class="text-xs font-mono text-destructive bg-destructive/10 p-2 rounded overflow-x-auto">${output}</pre>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Display the command output
|
||||
return html`
|
||||
<div class="text-sm">
|
||||
<pre class="text-xs font-mono text-foreground bg-muted/50 p-2 rounded overflow-x-auto">${output}</pre>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import type { ToolRenderer } from "../types.js";
|
||||
|
||||
interface CalculateParams {
|
||||
expression: string;
|
||||
}
|
||||
|
||||
// Calculate tool has undefined details (only uses output)
|
||||
export class CalculateRenderer implements ToolRenderer<CalculateParams, undefined> {
|
||||
renderParams(params: CalculateParams, isStreaming?: boolean): TemplateResult {
|
||||
if (isStreaming && !params.expression) {
|
||||
return html`<div class="text-sm text-muted-foreground">${i18n("Writing expression...")}</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<span>${i18n("Calculating")}</span>
|
||||
<code class="mx-1 px-1.5 py-0.5 bg-muted rounded text-xs font-mono">${params.expression}</code>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderResult(_params: CalculateParams, result: ToolResultMessage<undefined>): TemplateResult {
|
||||
// Parse the output to make it look nicer
|
||||
const output = result.output || "";
|
||||
const isError = result.isError === true;
|
||||
|
||||
if (isError) {
|
||||
return html`<div class="text-sm text-destructive">${output}</div>`;
|
||||
}
|
||||
|
||||
// Try to split on = to show expression and result separately
|
||||
const parts = output.split(" = ");
|
||||
if (parts.length === 2) {
|
||||
return html`
|
||||
<div class="text-sm font-mono">
|
||||
<span class="text-muted-foreground">${parts[0]}</span>
|
||||
<span class="text-muted-foreground mx-1">=</span>
|
||||
<span class="text-foreground font-semibold">${parts[1]}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Fallback to showing the whole output
|
||||
return html`<div class="text-sm font-mono text-foreground">${output}</div>`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import type { ToolRenderer } from "../types.js";
|
||||
|
||||
export class DefaultRenderer implements ToolRenderer {
|
||||
renderParams(params: any, isStreaming?: boolean): TemplateResult {
|
||||
let text: string;
|
||||
let isJson = false;
|
||||
|
||||
try {
|
||||
text = JSON.stringify(JSON.parse(params), null, 2);
|
||||
isJson = true;
|
||||
} catch {
|
||||
try {
|
||||
text = JSON.stringify(params, null, 2);
|
||||
isJson = true;
|
||||
} catch {
|
||||
text = String(params);
|
||||
}
|
||||
}
|
||||
|
||||
if (isStreaming && (!text || text === "{}" || text === "null")) {
|
||||
return html`<div class="text-sm text-muted-foreground">${i18n("Preparing tool parameters...")}</div>`;
|
||||
}
|
||||
|
||||
return html`<console-block .content=${text}></console-block>`;
|
||||
}
|
||||
|
||||
renderResult(_params: any, result: ToolResultMessage): TemplateResult {
|
||||
// Just show the output field - that's what was sent to the LLM
|
||||
const text = result.output || i18n("(no output)");
|
||||
|
||||
return html`<div class="text-sm text-muted-foreground whitespace-pre-wrap font-mono">${text}</div>`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import type { ToolRenderer } from "../types.js";
|
||||
|
||||
interface GetCurrentTimeParams {
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
// GetCurrentTime tool has undefined details (only uses output)
|
||||
export class GetCurrentTimeRenderer implements ToolRenderer<GetCurrentTimeParams, undefined> {
|
||||
renderParams(params: GetCurrentTimeParams, isStreaming?: boolean): TemplateResult {
|
||||
if (params.timezone) {
|
||||
return html`
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<span>${i18n("Getting current time in")}</span>
|
||||
<code class="mx-1 px-1.5 py-0.5 bg-muted rounded text-xs font-mono">${params.timezone}</code>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<span>${i18n("Getting current date and time")}${isStreaming ? "..." : ""}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderResult(_params: GetCurrentTimeParams, result: ToolResultMessage<undefined>): TemplateResult {
|
||||
const output = result.output || "";
|
||||
const isError = result.isError === true;
|
||||
|
||||
if (isError) {
|
||||
return html`<div class="text-sm text-destructive">${output}</div>`;
|
||||
}
|
||||
|
||||
// Display the date/time result
|
||||
return html`<div class="text-sm font-mono text-foreground">${output}</div>`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import type { TemplateResult } from "lit";
|
||||
|
||||
export interface ToolRenderer<TParams = any, TDetails = any> {
|
||||
renderParams(params: TParams, isStreaming?: boolean): TemplateResult;
|
||||
renderResult(params: TParams, result: ToolResultMessage<TDetails>): TemplateResult;
|
||||
}
|
||||
|
|
@ -1,472 +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)}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { PromptDialog } from "@mariozechner/mini-lit";
|
||||
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`);
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -1,308 +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;
|
||||
"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;
|
||||
"Writing expression...": string;
|
||||
Calculating: string;
|
||||
"Getting current time in": string;
|
||||
"Getting current date and time": 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;
|
||||
// Artifacts strings
|
||||
"Processing artifact...": 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;
|
||||
"Copy SVG": string;
|
||||
"Download SVG": string;
|
||||
"Copy Markdown": string;
|
||||
"Download Markdown": string;
|
||||
Download: string;
|
||||
"No logs for {filename}": string;
|
||||
}
|
||||
}
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
...defaultEnglish,
|
||||
Free: "Free",
|
||||
"Input Required": "Input Required",
|
||||
Cancel: "Cancel",
|
||||
Confirm: "Confirm",
|
||||
"Select Model": "Select Model",
|
||||
"Search models...": "Search models...",
|
||||
Format: "Format",
|
||||
Thinking: "Thinking",
|
||||
Vision: "Vision",
|
||||
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",
|
||||
"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)",
|
||||
"Writing expression...": "Writing expression...",
|
||||
Calculating: "Calculating",
|
||||
"Getting current time in": "Getting current time in",
|
||||
"Getting current date and time": "Getting current date and time",
|
||||
"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",
|
||||
// Artifacts strings
|
||||
"Processing artifact...": "Processing artifact...",
|
||||
Processing: "Processing",
|
||||
Create: "Create",
|
||||
Rewrite: "Rewrite",
|
||||
Get: "Get",
|
||||
Delete: "Delete",
|
||||
"Get logs": "Get logs",
|
||||
"Show artifacts": "Show artifacts",
|
||||
"Close artifacts": "Close artifacts",
|
||||
Artifacts: "Artifacts",
|
||||
"Copy HTML": "Copy HTML",
|
||||
"Download HTML": "Download 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}",
|
||||
},
|
||||
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...": "Testet...",
|
||||
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",
|
||||
"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)",
|
||||
"Writing expression...": "Schreibe Ausdruck...",
|
||||
Calculating: "Berechne",
|
||||
"Getting current time in": "Hole aktuelle Zeit in",
|
||||
"Getting current date and time": "Hole aktuelles Datum und Uhrzeit",
|
||||
"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",
|
||||
// Artifacts strings
|
||||
"Processing artifact...": "Verarbeite Artefakt...",
|
||||
Processing: "Verarbeitung",
|
||||
Create: "Erstellen",
|
||||
Rewrite: "Überschreiben",
|
||||
Get: "Abrufen",
|
||||
Delete: "Löschen",
|
||||
"Get logs": "Logs abrufen",
|
||||
"Show artifacts": "Artefakte anzeigen",
|
||||
"Close artifacts": "Artefakte schließen",
|
||||
Artifacts: "Artefakte",
|
||||
"Copy HTML": "HTML kopieren",
|
||||
"Download HTML": "HTML herunterladen",
|
||||
"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}",
|
||||
},
|
||||
};
|
||||
|
||||
setTranslations(translations);
|
||||
|
||||
export * from "@mariozechner/mini-lit/dist/i18n.js";
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -9,7 +9,6 @@ import { createJavaScriptReplTool } from "./tools/javascript-repl.js";
|
|||
import { registerToolRenderer } from "./tools/renderer-registry.js";
|
||||
import { getAuthToken } from "./utils/auth-token.js";
|
||||
import { i18n } from "./utils/i18n.js";
|
||||
import { simpleHtml } from "./utils/test-sessions.js";
|
||||
|
||||
const BREAKPOINT = 800; // px - switch between overlay and side-by-side
|
||||
|
||||
|
|
@ -23,6 +22,7 @@ export class ChatPanel extends LitElement {
|
|||
@state() private windowWidth = window.innerWidth;
|
||||
@property({ type: String }) systemPrompt = "You are a helpful AI assistant.";
|
||||
@property({ type: Array }) additionalTools: AgentTool<any, any>[] = [];
|
||||
@property({ attribute: false }) sandboxUrlProvider?: () => string;
|
||||
|
||||
private resizeHandler = () => {
|
||||
this.windowWidth = window.innerWidth;
|
||||
|
|
@ -45,8 +45,17 @@ export class ChatPanel extends LitElement {
|
|||
this.style.height = "100%";
|
||||
this.style.minHeight = "0";
|
||||
|
||||
// Create JavaScript REPL tool with attachments provider
|
||||
const javascriptReplTool = createJavaScriptReplTool();
|
||||
if (this.sandboxUrlProvider) {
|
||||
javascriptReplTool.sandboxUrlProvider = this.sandboxUrlProvider;
|
||||
}
|
||||
|
||||
// Set up artifacts panel
|
||||
this.artifactsPanel = new ArtifactsPanel();
|
||||
if (this.sandboxUrlProvider) {
|
||||
this.artifactsPanel.sandboxUrlProvider = this.sandboxUrlProvider;
|
||||
}
|
||||
registerToolRenderer("artifacts", this.artifactsPanel);
|
||||
|
||||
// Attachments provider for both REPL and artifacts
|
||||
|
|
@ -72,11 +81,8 @@ export class ChatPanel extends LitElement {
|
|||
return attachments;
|
||||
};
|
||||
|
||||
this.artifactsPanel.attachmentsProvider = getAttachments;
|
||||
|
||||
// Set up JavaScript REPL tool with attachments provider
|
||||
const javascriptReplTool = createJavaScriptReplTool();
|
||||
javascriptReplTool.attachmentsProvider = getAttachments;
|
||||
this.artifactsPanel.attachmentsProvider = getAttachments;
|
||||
|
||||
this.artifactsPanel.onArtifactsChange = () => {
|
||||
const count = this.artifactsPanel.artifacts?.size ?? 0;
|
||||
|
|
@ -101,14 +107,14 @@ export class ChatPanel extends LitElement {
|
|||
this.requestUpdate();
|
||||
};
|
||||
|
||||
let initialState = {
|
||||
const initialState = {
|
||||
systemPrompt: this.systemPrompt,
|
||||
model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
|
||||
tools: [...this.additionalTools, javascriptReplTool, this.artifactsPanel.tool],
|
||||
thinkingLevel: "off" as ThinkingLevel,
|
||||
messages: [],
|
||||
} satisfies Partial<AgentSessionState>;
|
||||
initialState = { ...initialState, ...(simpleHtml as any) };
|
||||
// initialState = { ...initialState, ...(simpleHtml as any) };
|
||||
// initialState = { ...initialState, ...(longSession as any) };
|
||||
|
||||
// Create agent session first so attachments provider works
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import "./MessageEditor.js";
|
|||
import "./MessageList.js";
|
||||
import "./Messages.js"; // Import for side effects to register the custom elements
|
||||
import type { AgentSession, AgentSessionEvent } from "../state/agent-session.js";
|
||||
import { keyStore } from "../state/KeyStore.js";
|
||||
import { getKeyStore } from "../state/key-store.js";
|
||||
import "./StreamingMessageContainer.js";
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
import { formatUsage } from "../utils/format.js";
|
||||
|
|
@ -166,13 +166,13 @@ export class AgentInterface extends LitElement {
|
|||
|
||||
// Check if API key exists for the provider (only needed in direct mode)
|
||||
const provider = session.state.model.provider;
|
||||
let apiKey = await keyStore.getKey(provider);
|
||||
let apiKey = await getKeyStore().getKey(provider);
|
||||
|
||||
// If no API key, open the API keys dialog
|
||||
if (!apiKey) {
|
||||
await ApiKeysDialog.open();
|
||||
// Check again after dialog closes
|
||||
apiKey = await keyStore.getKey(provider);
|
||||
apiKey = await getKeyStore().getKey(provider);
|
||||
// If still no API key, abort the send
|
||||
if (!apiKey) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -3,19 +3,19 @@ import { type Context, complete, getModel, getProviders } from "@mariozechner/pi
|
|||
import type { PropertyValues } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { Input } from "../components/Input.js";
|
||||
import { keyStore } from "../state/KeyStore.js";
|
||||
import { getKeyStore } from "../state/key-store.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
|
||||
// Test models for each provider - known to be reliable and cheap
|
||||
const TEST_MODELS: Record<string, string> = {
|
||||
anthropic: "claude-3-5-haiku-20241022",
|
||||
openai: "gpt-4o-mini",
|
||||
google: "gemini-2.0-flash-exp",
|
||||
groq: "llama-3.3-70b-versatile",
|
||||
openrouter: "openai/gpt-4o-mini",
|
||||
cerebras: "llama3.1-8b",
|
||||
xai: "grok-2-1212",
|
||||
zai: "glm-4-plus",
|
||||
google: "gemini-2.5-flash",
|
||||
groq: "openai/gpt-oss-20b",
|
||||
openrouter: "z-ai/glm-4.6",
|
||||
cerebras: "gpt-oss-120b",
|
||||
xai: "grok-4-fast-non-reasoning",
|
||||
zai: "glm-4.5-air",
|
||||
};
|
||||
|
||||
@customElement("api-keys-dialog")
|
||||
|
|
@ -42,7 +42,7 @@ export class ApiKeysDialog extends DialogBase {
|
|||
}
|
||||
|
||||
private async loadKeys() {
|
||||
this.apiKeys = await keyStore.getAllKeys();
|
||||
this.apiKeys = await getKeyStore().getAllKeys();
|
||||
}
|
||||
|
||||
private async testApiKey(provider: string, apiKey: string): Promise<boolean> {
|
||||
|
|
@ -69,13 +69,7 @@ export class ApiKeysDialog extends DialogBase {
|
|||
maxTokens: 10, // Keep it minimal for testing
|
||||
} as any);
|
||||
|
||||
// Check if response contains expected text
|
||||
const text = response.content
|
||||
.filter((b) => b.type === "text")
|
||||
.map((b) => b.text)
|
||||
.join("");
|
||||
|
||||
return text.toLowerCase().includes("test successful");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`API key test failed for ${provider}:`, error);
|
||||
return false;
|
||||
|
|
@ -95,7 +89,7 @@ export class ApiKeysDialog extends DialogBase {
|
|||
const isValid = await this.testApiKey(provider, key);
|
||||
|
||||
if (isValid) {
|
||||
await keyStore.setKey(provider, key);
|
||||
await getKeyStore().setKey(provider, key);
|
||||
this.apiKeyInputs[provider] = ""; // Clear input
|
||||
await this.loadKeys();
|
||||
this.testResults[provider] = "success";
|
||||
|
|
@ -123,7 +117,7 @@ export class ApiKeysDialog extends DialogBase {
|
|||
this.error = "";
|
||||
|
||||
try {
|
||||
const apiKey = await keyStore.getKey(provider);
|
||||
const apiKey = await getKeyStore().getKey(provider);
|
||||
if (!apiKey) {
|
||||
this.testResults[provider] = "error";
|
||||
this.error = `No API key found for ${provider}`;
|
||||
|
|
@ -155,7 +149,7 @@ export class ApiKeysDialog extends DialogBase {
|
|||
private async removeKey(provider: string) {
|
||||
if (!confirm(`Remove API key for ${provider}?`)) return;
|
||||
|
||||
await keyStore.removeKey(provider);
|
||||
await getKeyStore().removeKey(provider);
|
||||
this.apiKeyInputs[provider] = "";
|
||||
await this.loadKeys();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,11 +21,13 @@ export { ApiKeysDialog } from "./dialogs/ApiKeysDialog.js";
|
|||
export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js";
|
||||
// Dialogs
|
||||
export { ModelSelector } from "./dialogs/ModelSelector.js";
|
||||
|
||||
export type { AgentSessionState, ThinkingLevel } from "./state/agent-session.js";
|
||||
// State management
|
||||
export { AgentSession } from "./state/agent-session.js";
|
||||
export type { KeyStore, StorageAdapter } from "./state/KeyStore.js";
|
||||
export { KeyStoreImpl, keyStore } from "./state/KeyStore.js";
|
||||
export type { KeyStore } from "./state/key-store.js";
|
||||
export { getKeyStore, LocalStorageKeyStore, setKeyStore } from "./state/key-store.js";
|
||||
export type { StorageAdapter } from "./state/storage-adapter.js";
|
||||
export { ChromeStorageAdapter, LocalStorageAdapter } from "./state/storage-adapter.js";
|
||||
|
||||
// Transports
|
||||
export { DirectTransport } from "./state/transports/DirectTransport.js";
|
||||
|
|
@ -41,6 +43,7 @@ export { SvgArtifact } from "./tools/artifacts/SvgArtifact.js";
|
|||
export { TextArtifact } from "./tools/artifacts/TextArtifact.js";
|
||||
// Tools
|
||||
export { getToolRenderer, registerToolRenderer, renderToolParams, renderToolResult } from "./tools/index.js";
|
||||
export { createJavaScriptReplTool, javascriptReplTool } from "./tools/javascript-repl.js";
|
||||
export { BashRenderer } from "./tools/renderers/BashRenderer.js";
|
||||
export { CalculateRenderer } from "./tools/renderers/CalculateRenderer.js";
|
||||
// Tool renderers
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
import { getProviders } from "@mariozechner/pi-ai";
|
||||
|
||||
/**
|
||||
* Generic storage adapter interface
|
||||
*/
|
||||
export interface StorageAdapter {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string): Promise<void>;
|
||||
remove(key: string): Promise<void>;
|
||||
getAll(): Promise<Record<string, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for API key storage
|
||||
*/
|
||||
export interface KeyStore {
|
||||
getKey(provider: string): Promise<string | null>;
|
||||
setKey(provider: string, key: string): Promise<void>;
|
||||
removeKey(provider: string): Promise<void>;
|
||||
getAllKeys(): Promise<Record<string, boolean>>; // provider -> isConfigured
|
||||
}
|
||||
|
||||
/**
|
||||
* Default localStorage implementation for web
|
||||
*/
|
||||
class LocalStorageAdapter implements StorageAdapter {
|
||||
async get(key: string): Promise<string | null> {
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
|
||||
async set(key: string, value: string): Promise<void> {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
async getAll(): Promise<Record<string, string>> {
|
||||
const result: Record<string, string> = {};
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key) {
|
||||
const value = localStorage.getItem(key);
|
||||
if (value) result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic KeyStore implementation
|
||||
*/
|
||||
class GenericKeyStore implements KeyStore {
|
||||
private readonly prefix = "apiKey_";
|
||||
private readonly storage: StorageAdapter;
|
||||
|
||||
constructor(storage?: StorageAdapter) {
|
||||
this.storage = storage || new LocalStorageAdapter();
|
||||
}
|
||||
|
||||
async getKey(provider: string): Promise<string | null> {
|
||||
const key = `${this.prefix}${provider}`;
|
||||
return await this.storage.get(key);
|
||||
}
|
||||
|
||||
async setKey(provider: string, key: string): Promise<void> {
|
||||
const storageKey = `${this.prefix}${provider}`;
|
||||
await this.storage.set(storageKey, key);
|
||||
}
|
||||
|
||||
async removeKey(provider: string): Promise<void> {
|
||||
const key = `${this.prefix}${provider}`;
|
||||
await this.storage.remove(key);
|
||||
}
|
||||
|
||||
async getAllKeys(): Promise<Record<string, boolean>> {
|
||||
const providers = getProviders();
|
||||
const allStorage = await this.storage.getAll();
|
||||
const result: Record<string, boolean> = {};
|
||||
|
||||
for (const provider of providers) {
|
||||
const key = `${this.prefix}${provider}`;
|
||||
result[provider] = !!allStorage[key];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance (uses localStorage by default)
|
||||
export const keyStore = new GenericKeyStore();
|
||||
|
||||
// Export class for custom storage implementations
|
||||
export { GenericKeyStore as KeyStoreImpl };
|
||||
67
packages/web-ui/src/state/key-store.ts
Normal file
67
packages/web-ui/src/state/key-store.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { getProviders } from "@mariozechner/pi-ai";
|
||||
import { LocalStorageAdapter, type StorageAdapter } from "./storage-adapter.js";
|
||||
|
||||
/**
|
||||
* API key storage interface
|
||||
*/
|
||||
export interface KeyStore {
|
||||
getKey(provider: string): Promise<string | null>;
|
||||
setKey(provider: string, key: string): Promise<void>;
|
||||
removeKey(provider: string): Promise<void>;
|
||||
getAllKeys(): Promise<Record<string, boolean>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* API key storage implementation using a pluggable storage adapter
|
||||
*/
|
||||
export class LocalStorageKeyStore implements KeyStore {
|
||||
private readonly prefix = "apiKey_";
|
||||
|
||||
constructor(private readonly storage: StorageAdapter) {}
|
||||
|
||||
async getKey(provider: string): Promise<string | null> {
|
||||
const key = `${this.prefix}${provider}`;
|
||||
return await this.storage.get(key);
|
||||
}
|
||||
|
||||
async setKey(provider: string, key: string): Promise<void> {
|
||||
const storageKey = `${this.prefix}${provider}`;
|
||||
await this.storage.set(storageKey, key);
|
||||
}
|
||||
|
||||
async removeKey(provider: string): Promise<void> {
|
||||
const key = `${this.prefix}${provider}`;
|
||||
await this.storage.remove(key);
|
||||
}
|
||||
|
||||
async getAllKeys(): Promise<Record<string, boolean>> {
|
||||
const providers = getProviders();
|
||||
const allStorage = await this.storage.getAll();
|
||||
const result: Record<string, boolean> = {};
|
||||
|
||||
for (const provider of providers) {
|
||||
const key = `${this.prefix}${provider}`;
|
||||
result[provider] = !!allStorage[key];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Default instance using localStorage
|
||||
let _keyStore: KeyStore = new LocalStorageKeyStore(new LocalStorageAdapter());
|
||||
|
||||
/**
|
||||
* Get the current KeyStore instance
|
||||
*/
|
||||
export function getKeyStore(): KeyStore {
|
||||
return _keyStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a custom KeyStore implementation
|
||||
* Call this once at application startup before any components are initialized
|
||||
*/
|
||||
export function setKeyStore(store: KeyStore): void {
|
||||
_keyStore = store;
|
||||
}
|
||||
77
packages/web-ui/src/state/storage-adapter.ts
Normal file
77
packages/web-ui/src/state/storage-adapter.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Generic storage adapter interface for key/value persistence
|
||||
*/
|
||||
export interface StorageAdapter {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string): Promise<void>;
|
||||
remove(key: string): Promise<void>;
|
||||
getAll(): Promise<Record<string, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* LocalStorage implementation
|
||||
*/
|
||||
export class LocalStorageAdapter implements StorageAdapter {
|
||||
async get(key: string): Promise<string | null> {
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
|
||||
async set(key: string, value: string): Promise<void> {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
async getAll(): Promise<Record<string, string>> {
|
||||
const result: Record<string, string> = {};
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key) {
|
||||
const value = localStorage.getItem(key);
|
||||
if (value) result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chrome/Firefox extension storage implementation
|
||||
*/
|
||||
export class ChromeStorageAdapter implements StorageAdapter {
|
||||
private readonly storage: any;
|
||||
|
||||
constructor() {
|
||||
const isBrowser = typeof globalThis !== "undefined";
|
||||
const hasChrome = isBrowser && (globalThis as any).chrome?.storage;
|
||||
const hasBrowser = isBrowser && (globalThis as any).browser?.storage;
|
||||
|
||||
if (hasBrowser) {
|
||||
this.storage = (globalThis as any).browser.storage.local;
|
||||
} else if (hasChrome) {
|
||||
this.storage = (globalThis as any).chrome.storage.local;
|
||||
} else {
|
||||
throw new Error("Chrome/Browser storage not available");
|
||||
}
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
const result = await this.storage.get(key);
|
||||
return result[key] || null;
|
||||
}
|
||||
|
||||
async set(key: string, value: string): Promise<void> {
|
||||
await this.storage.set({ [key]: value });
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
await this.storage.remove(key);
|
||||
}
|
||||
|
||||
async getAll(): Promise<Record<string, string>> {
|
||||
const result = await this.storage.get();
|
||||
return result || {};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { type AgentContext, agentLoop, type Message, type PromptConfig, type UserMessage } from "@mariozechner/pi-ai";
|
||||
import { keyStore } from "../KeyStore.js";
|
||||
import { getKeyStore } from "../key-store.js";
|
||||
import type { AgentRunConfig, AgentTransport } from "./types.js";
|
||||
|
||||
export class DirectTransport implements AgentTransport {
|
||||
|
|
@ -7,7 +7,7 @@ export class DirectTransport implements AgentTransport {
|
|||
|
||||
async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
|
||||
// Get API key from KeyStore
|
||||
const apiKey = await keyStore.getKey(cfg.model.provider);
|
||||
const apiKey = await getKeyStore().getKey(cfg.model.provider);
|
||||
if (!apiKey) {
|
||||
throw new Error("no-api-key");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export async function executeJavaScript(
|
|||
code: string,
|
||||
attachments: Attachment[] = [],
|
||||
signal?: AbortSignal,
|
||||
sandboxUrlProvider?: () => string,
|
||||
): Promise<{ output: string; files?: SandboxFile[] }> {
|
||||
if (!code) {
|
||||
throw new Error("Code parameter is required");
|
||||
|
|
@ -24,6 +25,9 @@ export async function executeJavaScript(
|
|||
|
||||
// Create a SandboxedIframe instance for execution
|
||||
const sandbox = new SandboxIframe();
|
||||
if (sandboxUrlProvider) {
|
||||
sandbox.sandboxUrlProvider = sandboxUrlProvider;
|
||||
}
|
||||
sandbox.style.display = "none";
|
||||
document.body.appendChild(sandbox);
|
||||
|
||||
|
|
@ -93,11 +97,13 @@ const javascriptReplSchema = Type.Object({
|
|||
|
||||
export function createJavaScriptReplTool(): AgentTool<typeof javascriptReplSchema, JavaScriptReplToolResult> & {
|
||||
attachmentsProvider?: () => Attachment[];
|
||||
sandboxUrlProvider?: () => string;
|
||||
} {
|
||||
return {
|
||||
label: "JavaScript REPL",
|
||||
name: "javascript_repl",
|
||||
attachmentsProvider: () => [], // default to empty array
|
||||
sandboxUrlProvider: undefined, // optional, for browser extensions
|
||||
description: `Execute JavaScript code in a sandboxed browser environment with full modern browser capabilities.
|
||||
|
||||
Environment: Modern browser with ALL Web APIs available:
|
||||
|
|
@ -173,7 +179,7 @@ Global variables:
|
|||
parameters: javascriptReplSchema,
|
||||
execute: async function (_toolCallId: string, args: Static<typeof javascriptReplSchema>, signal?: AbortSignal) {
|
||||
const attachments = this.attachmentsProvider?.() || [];
|
||||
const result = await executeJavaScript(args.code, attachments, signal);
|
||||
const result = await executeJavaScript(args.code, attachments, 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 } => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue