This commit is contained in:
ZacharyZhang-NY committed 2026-04-16 14:48:51 -04:00
1 parent 9779c4d1ca
commit 1b578c111a
719 files changed
+191593 -98

No files matched your search

@@ -0,0 +1,75 @@
import { customElement, state } from "lit/decorators.js";
import "../components/ProviderKeyInput.js";
import { DialogContent, DialogHeader } from "@mariozechner/mini-lit/dist/Dialog.js";
import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
import { html } from "lit";
import { getAppStorage } from "../storage/app-storage.js";
import { i18n } from "../utils/i18n.js";
@customElement("api-key-prompt-dialog")
export class ApiKeyPromptDialog extends DialogBase {
@state() private provider = "";
private resolvePromise?: (success: boolean) => void;
private unsubscribe?: () => void;
protected modalWidth = "min(500px, 90vw)";
protected modalHeight = "auto";
static async prompt(provider: string): Promise<boolean> {
const dialog = new ApiKeyPromptDialog();
dialog.provider = provider;
dialog.open();
return new Promise((resolve) => {
dialog.resolvePromise = resolve;
});
}
override async connectedCallback() {
super.connectedCallback();
// Poll for key existence - when key is added, resolve and close
const checkInterval = setInterval(async () => {
const hasKey = !!(await getAppStorage().providerKeys.get(this.provider));
if (hasKey) {
clearInterval(checkInterval);
if (this.resolvePromise) {
this.resolvePromise(true);
this.resolvePromise = undefined;
}
this.close();
}
}, 500);
this.unsubscribe = () => clearInterval(checkInterval);
}
override disconnectedCallback() {
super.disconnectedCallback();
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = undefined;
}
}
override close() {
super.close();
if (this.resolvePromise) {
this.resolvePromise(false);
}
}
protected override renderContent() {
return html`
${DialogContent({
children: html`
${DialogHeader({
title: i18n("API Key Required"),
})}
<provider-key-input .provider=${this.provider}></provider-key-input>
`,
})}
`;
}
}
@@ -0,0 +1,636 @@
import "@mariozechner/mini-lit/dist/ModeToggle.js";
import { icon } from "@mariozechner/mini-lit";
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import { renderAsync } from "docx-preview";
import { html, LitElement } from "lit";
import { state } from "lit/decorators.js";
import { Download, X } from "lucide";
import * as pdfjsLib from "pdfjs-dist";
import * as XLSX from "xlsx";
import type { Attachment } from "../utils/attachment-utils.js";
import { i18n } from "../utils/i18n.js";
type FileType = "image" | "pdf" | "docx" | "pptx" | "excel" | "text";
export class AttachmentOverlay extends LitElement {
@state() private attachment?: Attachment;
@state() private showExtractedText = false;
@state() private error: string | null = null;
// Track current loading task to cancel if needed
private currentLoadingTask: any = null;
private onCloseCallback?: () => void;
private boundHandleKeyDown?: (e: KeyboardEvent) => void;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
static open(attachment: Attachment, onClose?: () => void) {
const overlay = new AttachmentOverlay();
overlay.attachment = attachment;
overlay.onCloseCallback = onClose;
document.body.appendChild(overlay);
overlay.setupEventListeners();
}
private setupEventListeners() {
this.boundHandleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
this.close();
}
};
window.addEventListener("keydown", this.boundHandleKeyDown);
}
private close() {
this.cleanup();
if (this.boundHandleKeyDown) {
window.removeEventListener("keydown", this.boundHandleKeyDown);
}
this.onCloseCallback?.();
this.remove();
}
private getFileType(): FileType {
if (!this.attachment) return "text";
if (this.attachment.type === "image") return "image";
if (this.attachment.mimeType === "application/pdf") return "pdf";
if (this.attachment.mimeType?.includes("wordprocessingml")) return "docx";
if (
this.attachment.mimeType?.includes("presentationml") ||
this.attachment.fileName.toLowerCase().endsWith(".pptx")
)
return "pptx";
if (
this.attachment.mimeType?.includes("spreadsheetml") ||
this.attachment.mimeType?.includes("ms-excel") ||
this.attachment.fileName.toLowerCase().endsWith(".xlsx") ||
this.attachment.fileName.toLowerCase().endsWith(".xls")
)
return "excel";
return "text";
}
private getFileTypeLabel(): string {
const type = this.getFileType();
switch (type) {
case "pdf":
return i18n("PDF");
case "docx":
return i18n("Document");
case "pptx":
return i18n("Presentation");
case "excel":
return i18n("Spreadsheet");
default:
return "";
}
}
private handleBackdropClick = () => {
this.close();
};
private handleDownload = () => {
if (!this.attachment) return;
// Create a blob from the base64 content
const byteCharacters = atob(this.attachment.content);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: this.attachment.mimeType });
// Create download link
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = this.attachment.fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
private cleanup() {
this.showExtractedText = false;
this.error = null;
// Cancel any loading PDF task when closing
if (this.currentLoadingTask) {
this.currentLoadingTask.destroy();
this.currentLoadingTask = null;
}
}
override render() {
if (!this.attachment) return html``;
return html`
<!-- Full screen overlay -->
<div class="fixed inset-0 bg-black/90 z-50 flex flex-col" @click=${this.handleBackdropClick}>
<!-- Compact header bar -->
<div class="bg-background/95 backdrop-blur border-b border-border" @click=${(e: Event) => e.stopPropagation()}>
<div class="px-4 py-2 flex items-center justify-between">
<div class="flex items-center gap-3 min-w-0">
<span class="text-sm font-medium text-foreground truncate">${this.attachment.fileName}</span>
</div>
<div class="flex items-center gap-2">
${this.renderToggle()}
${Button({
variant: "ghost",
size: "icon",
onClick: this.handleDownload,
children: icon(Download, "sm"),
className: "h-8 w-8",
})}
${Button({
variant: "ghost",
size: "icon",
onClick: () => this.close(),
children: icon(X, "sm"),
className: "h-8 w-8",
})}
</div>
</div>
</div>
<!-- Content container -->
<div class="flex-1 flex items-center justify-center overflow-auto" @click=${(e: Event) => e.stopPropagation()}>
${this.renderContent()}
</div>
</div>
`;
}
private renderToggle() {
if (!this.attachment) return html``;
const fileType = this.getFileType();
const hasExtractedText = !!this.attachment.extractedText;
const showToggle = fileType !== "image" && fileType !== "text" && fileType !== "pptx" && hasExtractedText;
if (!showToggle) return html``;
const fileTypeLabel = this.getFileTypeLabel();
return html`
<mode-toggle
.modes=${[fileTypeLabel, i18n("Text")]}
.selectedIndex=${this.showExtractedText ? 1 : 0}
@mode-change=${(e: CustomEvent<{ index: number; mode: string }>) => {
e.stopPropagation();
this.showExtractedText = e.detail.index === 1;
this.error = null;
}}
></mode-toggle>
`;
}
private renderContent() {
if (!this.attachment) return html``;
// Error state
if (this.error) {
return html`
<div class="bg-destructive/10 border border-destructive text-destructive p-4 rounded-lg max-w-2xl">
<div class="font-medium mb-1">${i18n("Error loading file")}</div>
<div class="text-sm opacity-90">${this.error}</div>
</div>
`;
}
// Content based on file type
return this.renderFileContent();
}
private renderFileContent() {
if (!this.attachment) return html``;
const fileType = this.getFileType();
// Show extracted text if toggled
if (this.showExtractedText && fileType !== "image") {
return html`
<div class="bg-card border border-border text-foreground p-6 w-full h-full max-w-4xl overflow-auto">
<pre class="whitespace-pre-wrap font-mono text-xs leading-relaxed">${
this.attachment.extractedText || i18n("No text content available")
}</pre>
</div>
`;
}
// Render based on file type
switch (fileType) {
case "image": {
const imageUrl = `data:${this.attachment.mimeType};base64,${this.attachment.content}`;
return html`
<img src="${imageUrl}" class="max-w-full max-h-full object-contain rounded-lg shadow-lg" alt="${this.attachment.fileName}" />
`;
}
case "pdf":
return html`
<div
id="pdf-container"
class="bg-card text-foreground overflow-auto shadow-lg border border-border w-full h-full max-w-[1000px]"
></div>
`;
case "docx":
return html`
<div
id="docx-container"
class="bg-card text-foreground overflow-auto shadow-lg border border-border w-full h-full max-w-[1000px]"
></div>
`;
case "excel":
return html` <div id="excel-container" class="bg-card text-foreground overflow-auto w-full h-full"></div> `;
case "pptx":
return html`
<div
id="pptx-container"
class="bg-card text-foreground overflow-auto shadow-lg border border-border w-full h-full max-w-[1000px]"
></div>
`;
default:
return html`
<div class="bg-card border border-border text-foreground p-6 w-full h-full max-w-4xl overflow-auto">
<pre class="whitespace-pre-wrap font-mono text-sm">${
this.attachment.extractedText || i18n("No content available")
}</pre>
</div>
`;
}
}
override async updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
// Only process if we need to render the actual file (not extracted text)
if (
(changedProperties.has("attachment") || changedProperties.has("showExtractedText")) &&
this.attachment &&
!this.showExtractedText &&
!this.error
) {
const fileType = this.getFileType();
switch (fileType) {
case "pdf":
await this.renderPdf();
break;
case "docx":
await this.renderDocx();
break;
case "excel":
await this.renderExcel();
break;
case "pptx":
await this.renderExtractedText();
break;
}
}
}
private async renderPdf() {
const container = this.querySelector("#pdf-container");
if (!container || !this.attachment) return;
let pdf: any = null;
try {
// Convert base64 to ArrayBuffer
const arrayBuffer = this.base64ToArrayBuffer(this.attachment.content);
// Cancel any existing loading task
if (this.currentLoadingTask) {
this.currentLoadingTask.destroy();
}
// Load the PDF
this.currentLoadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
pdf = await this.currentLoadingTask.promise;
this.currentLoadingTask = null;
// Clear container and add wrapper
container.innerHTML = "";
const wrapper = document.createElement("div");
wrapper.className = "";
container.appendChild(wrapper);
// Render all pages
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
const page = await pdf.getPage(pageNum);
// Create a container for each page
const pageContainer = document.createElement("div");
pageContainer.className = "mb-4 last:mb-0";
// Create canvas for this page
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
// Set scale for reasonable resolution
const viewport = page.getViewport({ scale: 1.5 });
canvas.height = viewport.height;
canvas.width = viewport.width;
// Style the canvas
canvas.className = "w-full max-w-full h-auto block mx-auto bg-white rounded shadow-sm border border-border";
// Fill white background for proper PDF rendering
if (context) {
context.fillStyle = "white";
context.fillRect(0, 0, canvas.width, canvas.height);
}
// Render page
await page.render({
canvasContext: context!,
viewport: viewport,
canvas: canvas,
}).promise;
pageContainer.appendChild(canvas);
// Add page separator for multi-page documents
if (pageNum < pdf.numPages) {
const separator = document.createElement("div");
separator.className = "h-px bg-border my-4";
pageContainer.appendChild(separator);
}
wrapper.appendChild(pageContainer);
}
} catch (error: any) {
console.error("Error rendering PDF:", error);
this.error = error?.message || i18n("Failed to load PDF");
} finally {
if (pdf) {
pdf.destroy();
}
}
}
private async renderDocx() {
const container = this.querySelector("#docx-container");
if (!container || !this.attachment) return;
try {
// Convert base64 to ArrayBuffer
const arrayBuffer = this.base64ToArrayBuffer(this.attachment.content);
// Clear container first
container.innerHTML = "";
// Create a wrapper div for the document
const wrapper = document.createElement("div");
wrapper.className = "docx-wrapper-custom";
container.appendChild(wrapper);
// Render the DOCX file into the wrapper
await renderAsync(arrayBuffer, wrapper as HTMLElement, undefined, {
className: "docx",
inWrapper: true,
ignoreWidth: true, // Let it be responsive
ignoreHeight: false,
ignoreFonts: false,
breakPages: true,
ignoreLastRenderedPageBreak: true,
experimental: false,
trimXmlDeclaration: true,
useBase64URL: false,
renderHeaders: true,
renderFooters: true,
renderFootnotes: true,
renderEndnotes: true,
});
// Apply custom styles to match theme and fix sizing
const style = document.createElement("style");
style.textContent = `
#docx-container {
padding: 0;
}
#docx-container .docx-wrapper-custom {
max-width: 100%;
overflow-x: auto;
}
#docx-container .docx-wrapper {
max-width: 100% !important;
margin: 0 !important;
background: transparent !important;
padding: 0em !important;
}
#docx-container .docx-wrapper > section.docx {
box-shadow: none !important;
border: none !important;
border-radius: 0 !important;
margin: 0 !important;
padding: 2em !important;
background: white !important;
color: black !important;
max-width: 100% !important;
width: 100% !important;
min-width: 0 !important;
overflow-x: auto !important;
}
/* Fix tables and wide content */
#docx-container table {
max-width: 100% !important;
width: auto !important;
overflow-x: auto !important;
display: block !important;
}
#docx-container img {
max-width: 100% !important;
height: auto !important;
}
/* Fix paragraphs and text */
#docx-container p,
#docx-container span,
#docx-container div {
max-width: 100% !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
}
/* Hide page breaks in web view */
#docx-container .docx-page-break {
display: none !important;
}
`;
container.appendChild(style);
} catch (error: any) {
console.error("Error rendering DOCX:", error);
this.error = error?.message || i18n("Failed to load document");
}
}
private async renderExcel() {
const container = this.querySelector("#excel-container");
if (!container || !this.attachment) return;
try {
// Convert base64 to ArrayBuffer
const arrayBuffer = this.base64ToArrayBuffer(this.attachment.content);
// Read the workbook
const workbook = XLSX.read(arrayBuffer, { type: "array" });
// Clear container
container.innerHTML = "";
const wrapper = document.createElement("div");
wrapper.className = "overflow-auto h-full flex flex-col";
container.appendChild(wrapper);
// Create tabs for multiple sheets
if (workbook.SheetNames.length > 1) {
const tabContainer = document.createElement("div");
tabContainer.className = "flex gap-2 mb-4 border-b border-border sticky top-0 bg-card z-10";
const sheetContents: HTMLElement[] = [];
workbook.SheetNames.forEach((sheetName, index) => {
// Create tab button
const tab = document.createElement("button");
tab.textContent = sheetName;
tab.className =
index === 0
? "px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary"
: "px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-b-2 hover:border-border transition-colors";
// Create sheet content
const sheetDiv = document.createElement("div");
sheetDiv.style.display = index === 0 ? "flex" : "none";
sheetDiv.className = "flex-1 overflow-auto";
sheetDiv.appendChild(this.renderExcelSheet(workbook.Sheets[sheetName], sheetName));
sheetContents.push(sheetDiv);
// Tab click handler
tab.onclick = () => {
// Update tab styles
tabContainer.querySelectorAll("button").forEach((btn, btnIndex) => {
if (btnIndex === index) {
btn.className = "px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary";
} else {
btn.className =
"px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-b-2 hover:border-border transition-colors";
}
});
// Show/hide sheets
sheetContents.forEach((content, contentIndex) => {
content.style.display = contentIndex === index ? "flex" : "none";
});
};
tabContainer.appendChild(tab);
});
wrapper.appendChild(tabContainer);
sheetContents.forEach((content) => {
wrapper.appendChild(content);
});
} else {
// Single sheet
const sheetName = workbook.SheetNames[0];
wrapper.appendChild(this.renderExcelSheet(workbook.Sheets[sheetName], sheetName));
}
} catch (error: any) {
console.error("Error rendering Excel:", error);
this.error = error?.message || i18n("Failed to load spreadsheet");
}
}
private renderExcelSheet(worksheet: any, sheetName: string): HTMLElement {
const sheetDiv = document.createElement("div");
// Generate HTML table
const htmlTable = XLSX.utils.sheet_to_html(worksheet, { id: `sheet-${sheetName}` });
const tempDiv = document.createElement("div");
tempDiv.innerHTML = htmlTable;
// Find and style the table
const table = tempDiv.querySelector("table");
if (table) {
table.className = "w-full border-collapse text-foreground";
// Style all cells
table.querySelectorAll("td, th").forEach((cell) => {
const cellEl = cell as HTMLElement;
cellEl.className = "border border-border px-3 py-2 text-sm text-left";
});
// Style header row
const headerCells = table.querySelectorAll("thead th, tr:first-child td");
if (headerCells.length > 0) {
headerCells.forEach((th) => {
const thEl = th as HTMLElement;
thEl.className =
"border border-border px-3 py-2 text-sm font-semibold bg-muted text-foreground sticky top-0";
});
}
// Alternate row colors
table.querySelectorAll("tbody tr:nth-child(even)").forEach((row) => {
const rowEl = row as HTMLElement;
rowEl.className = "bg-muted/30";
});
sheetDiv.appendChild(table);
}
return sheetDiv;
}
private base64ToArrayBuffer(base64: string): ArrayBuffer {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
private async renderExtractedText() {
const container = this.querySelector("#pptx-container");
if (!container || !this.attachment) return;
try {
// Display the extracted text content
container.innerHTML = "";
const wrapper = document.createElement("div");
wrapper.className = "p-6 overflow-auto";
// Create a pre element to preserve formatting
const pre = document.createElement("pre");
pre.className = "whitespace-pre-wrap text-sm text-foreground font-mono";
pre.textContent = this.attachment.extractedText || i18n("No text content available");
wrapper.appendChild(pre);
container.appendChild(wrapper);
} catch (error: any) {
console.error("Error rendering extracted text:", error);
this.error = error?.message || i18n("Failed to display text content");
}
}
}
// Register the custom element only once
if (!customElements.get("attachment-overlay")) {
customElements.define("attachment-overlay", AttachmentOverlay);
}
@@ -0,0 +1,274 @@
import { i18n } from "@mariozechner/mini-lit";
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
import { Input } from "@mariozechner/mini-lit/dist/Input.js";
import { Label } from "@mariozechner/mini-lit/dist/Label.js";
import { Select } from "@mariozechner/mini-lit/dist/Select.js";
import type { Model } from "@mariozechner/pi-ai";
import { html, type TemplateResult } from "lit";
import { state } from "lit/decorators.js";
import { getAppStorage } from "../storage/app-storage.js";
import type { CustomProvider, CustomProviderType } from "../storage/stores/custom-providers-store.js";
import { discoverModels } from "../utils/model-discovery.js";
export class CustomProviderDialog extends DialogBase {
private provider?: CustomProvider;
private initialType?: CustomProviderType;
private onSaveCallback?: () => void;
@state() private name = "";
@state() private type: CustomProviderType = "openai-completions";
@state() private baseUrl = "";
@state() private apiKey = "";
@state() private testing = false;
@state() private testError = "";
@state() private discoveredModels: Model<any>[] = [];
protected modalWidth = "min(800px, 90vw)";
protected modalHeight = "min(700px, 90vh)";
static async open(
provider: CustomProvider | undefined,
initialType: CustomProviderType | undefined,
onSave?: () => void,
) {
const dialog = new CustomProviderDialog();
dialog.provider = provider;
dialog.initialType = initialType;
dialog.onSaveCallback = onSave;
document.body.appendChild(dialog);
dialog.initializeFromProvider();
dialog.open();
dialog.requestUpdate();
}
private initializeFromProvider() {
if (this.provider) {
this.name = this.provider.name;
this.type = this.provider.type;
this.baseUrl = this.provider.baseUrl;
this.apiKey = this.provider.apiKey || "";
this.discoveredModels = this.provider.models || [];
} else {
this.name = "";
this.type = this.initialType || "openai-completions";
this.baseUrl = "";
this.updateDefaultBaseUrl();
this.apiKey = "";
this.discoveredModels = [];
}
this.testError = "";
this.testing = false;
}
private updateDefaultBaseUrl() {
if (this.baseUrl) return;
const defaults: Record<string, string> = {
ollama: "http://localhost:11434",
"llama.cpp": "http://localhost:8080",
vllm: "http://localhost:8000",
lmstudio: "http://localhost:1234",
"openai-completions": "",
"openai-responses": "",
"anthropic-messages": "",
};
this.baseUrl = defaults[this.type] || "";
}
private isAutoDiscoveryType(): boolean {
return this.type === "ollama" || this.type === "llama.cpp" || this.type === "vllm" || this.type === "lmstudio";
}
private async testConnection() {
if (!this.isAutoDiscoveryType()) return;
this.testing = true;
this.testError = "";
this.discoveredModels = [];
try {
const models = await discoverModels(
this.type as "ollama" | "llama.cpp" | "vllm" | "lmstudio",
this.baseUrl,
this.apiKey || undefined,
);
this.discoveredModels = models.map((model) => ({
...model,
provider: this.name || this.type,
}));
this.testError = "";
} catch (error) {
this.testError = error instanceof Error ? error.message : String(error);
this.discoveredModels = [];
} finally {
this.testing = false;
this.requestUpdate();
}
}
private async save() {
if (!this.name || !this.baseUrl) {
alert(i18n("Please fill in all required fields"));
return;
}
try {
const storage = getAppStorage();
const provider: CustomProvider = {
id: this.provider?.id || crypto.randomUUID(),
name: this.name,
type: this.type,
baseUrl: this.baseUrl,
apiKey: this.apiKey || undefined,
models: this.isAutoDiscoveryType() ? undefined : this.provider?.models || [],
};
await storage.customProviders.set(provider);
if (this.onSaveCallback) {
this.onSaveCallback();
}
this.close();
} catch (error) {
console.error("Failed to save provider:", error);
alert(i18n("Failed to save provider"));
}
}
protected override renderContent(): TemplateResult {
const providerTypes = [
{ value: "ollama", label: "Ollama (auto-discovery)" },
{ value: "llama.cpp", label: "llama.cpp (auto-discovery)" },
{ value: "vllm", label: "vLLM (auto-discovery)" },
{ value: "lmstudio", label: "LM Studio (auto-discovery)" },
{ value: "openai-completions", label: "OpenAI Completions Compatible" },
{ value: "openai-responses", label: "OpenAI Responses Compatible" },
{ value: "anthropic-messages", label: "Anthropic Messages Compatible" },
];
return html`
<div class="flex flex-col h-full overflow-hidden">
<div class="p-6 flex-shrink-0 border-b border-border">
<h2 class="text-lg font-semibold text-foreground">
${this.provider ? i18n("Edit Provider") : i18n("Add Provider")}
</h2>
</div>
<div class="flex-1 overflow-y-auto p-6">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
${Label({ htmlFor: "provider-name", children: i18n("Provider Name") })}
${Input({
value: this.name,
placeholder: i18n("e.g., My Ollama Server"),
onInput: (e: Event) => {
this.name = (e.target as HTMLInputElement).value;
this.requestUpdate();
},
})}
</div>
<div class="flex flex-col gap-2">
${Label({ htmlFor: "provider-type", children: i18n("Provider Type") })}
${Select({
value: this.type,
options: providerTypes.map((pt) => ({
value: pt.value,
label: pt.label,
})),
onChange: (value: string) => {
this.type = value as CustomProviderType;
this.baseUrl = "";
this.updateDefaultBaseUrl();
this.requestUpdate();
},
width: "100%",
})}
</div>
<div class="flex flex-col gap-2">
${Label({ htmlFor: "base-url", children: i18n("Base URL") })}
${Input({
value: this.baseUrl,
placeholder: i18n("e.g., http://localhost:11434"),
onInput: (e: Event) => {
this.baseUrl = (e.target as HTMLInputElement).value;
this.requestUpdate();
},
})}
</div>
<div class="flex flex-col gap-2">
${Label({ htmlFor: "api-key", children: i18n("API Key (Optional)") })}
${Input({
type: "password",
value: this.apiKey,
placeholder: i18n("Leave empty if not required"),
onInput: (e: Event) => {
this.apiKey = (e.target as HTMLInputElement).value;
this.requestUpdate();
},
})}
</div>
${
this.isAutoDiscoveryType()
? html`
<div class="flex flex-col gap-2">
${Button({
onClick: () => this.testConnection(),
variant: "outline",
disabled: this.testing || !this.baseUrl,
children: this.testing ? i18n("Testing...") : i18n("Test Connection"),
})}
${this.testError ? html` <div class="text-sm text-destructive">${this.testError}</div> ` : ""}
${
this.discoveredModels.length > 0
? html`
<div class="text-sm text-muted-foreground">
${i18n("Discovered")} ${this.discoveredModels.length} ${i18n("models")}:
<ul class="list-disc list-inside mt-2">
${this.discoveredModels.slice(0, 5).map((model) => html`<li>${model.name}</li>`)}
${
this.discoveredModels.length > 5
? html`<li>...${i18n("and")} ${this.discoveredModels.length - 5} ${i18n("more")}</li>`
: ""
}
</ul>
</div>
`
: ""
}
</div>
`
: html` <div class="text-sm text-muted-foreground">
${i18n("For manual provider types, add models after saving the provider.")}
</div>`
}
</div>
</div>
<div class="p-6 flex-shrink-0 border-t border-border flex justify-end gap-2">
${Button({
onClick: () => this.close(),
variant: "ghost",
children: i18n("Cancel"),
})}
${Button({
onClick: () => this.save(),
variant: "default",
disabled: !this.name || !this.baseUrl,
children: i18n("Save"),
})}
</div>
</div>
`;
}
}
customElements.define("custom-provider-dialog", CustomProviderDialog);
@@ -0,0 +1,370 @@
import { icon } from "@mariozechner/mini-lit";
import { Badge } from "@mariozechner/mini-lit/dist/Badge.js";
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import { DialogHeader } from "@mariozechner/mini-lit/dist/Dialog.js";
import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
import { getModels, getProviders, type Model, modelsAreEqual } from "@mariozechner/pi-ai";
import { html, type PropertyValues, type TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import { Brain, Image as ImageIcon } from "lucide";
import { Input } from "../components/Input.js";
import { getAppStorage } from "../storage/app-storage.js";
import type { AutoDiscoveryProviderType } from "../storage/stores/custom-providers-store.js";
import { formatModelCost } from "../utils/format.js";
import { i18n } from "../utils/i18n.js";
import { discoverModels } from "../utils/model-discovery.js";
/**
* Score a query against a text using subsequence matching.
* All query characters must appear in order in the text.
* Higher score = tighter match (fewer gaps between matched characters).
* Returns 0 if no match.
*/
function subsequenceScore(query: string, text: string): number {
let qi = 0;
let ti = 0;
let gaps = 0;
let lastMatchIndex = -1;
while (qi < query.length && ti < text.length) {
if (query[qi] === text[ti]) {
if (lastMatchIndex >= 0) {
gaps += ti - lastMatchIndex - 1;
}
lastMatchIndex = ti;
qi++;
}
ti++;
}
// All query chars must match
if (qi < query.length) return 0;
// Score: longer query match = better, fewer gaps = better
// Normalize so exact substring gets highest score
return query.length / (query.length + gaps);
}
@customElement("agent-model-selector")
export class ModelSelector extends DialogBase {
@state() currentModel: Model<any> | null = null;
@state() searchQuery = "";
@state() filterThinking = false;
@state() filterVision = false;
@state() customProvidersLoading = false;
@state() selectedIndex = 0;
@state() private navigationMode: "mouse" | "keyboard" = "mouse";
@state() private customProviderModels: Model<any>[] = [];
private onSelectCallback?: (model: Model<any>) => void;
private allowedProviders?: Set<string>;
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,
allowedProviders?: string[],
) {
const selector = new ModelSelector();
selector.currentModel = currentModel;
selector.onSelectCallback = onSelect;
if (allowedProviders) {
selector.allowedProviders = new Set(allowedProviders);
}
selector.open();
selector.loadCustomProviders();
}
override async firstUpdated(changedProperties: PropertyValues): Promise<void> {
super.firstUpdated(changedProperties);
// Wait for dialog to be fully rendered
await this.updateComplete;
// Focus the search input when dialog opens
this.searchInputRef.value?.focus();
// Track actual mouse movement
this.addEventListener("mousemove", (e: MouseEvent) => {
// Check if mouse actually moved
if (e.clientX !== this.lastMousePosition.x || e.clientY !== this.lastMousePosition.y) {
this.lastMousePosition = { x: e.clientX, y: e.clientY };
// Only switch to mouse mode on actual mouse movement
if (this.navigationMode === "keyboard") {
this.navigationMode = "mouse";
// Update selection to the item under the mouse
const target = e.target as HTMLElement;
const modelItem = target.closest("[data-model-item]");
if (modelItem) {
const allItems = this.scrollContainerRef.value?.querySelectorAll("[data-model-item]");
if (allItems) {
const index = Array.from(allItems).indexOf(modelItem);
if (index !== -1) {
this.selectedIndex = index;
}
}
}
}
}
});
// Add global keyboard handler for the dialog
this.addEventListener("keydown", (e: KeyboardEvent) => {
// Ignore key events during IME composition (e.g. CJK input)
if (e.isComposing || e.key === "Process") return;
// Get filtered models to know the bounds
const filteredModels = this.getFilteredModels();
if (e.key === "ArrowDown") {
e.preventDefault();
this.navigationMode = "keyboard";
this.selectedIndex = Math.min(this.selectedIndex + 1, filteredModels.length - 1);
this.scrollToSelected();
} else if (e.key === "ArrowUp") {
e.preventDefault();
this.navigationMode = "keyboard";
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.scrollToSelected();
} else if (e.key === "Enter") {
e.preventDefault();
if (filteredModels[this.selectedIndex]) {
this.handleSelect(filteredModels[this.selectedIndex].model);
}
}
});
}
private async loadCustomProviders() {
this.customProvidersLoading = true;
const allCustomModels: Model<any>[] = [];
try {
const storage = getAppStorage();
const customProviders = await storage.customProviders.getAll();
// Load models from custom providers
for (const provider of customProviders) {
const isAutoDiscovery: boolean =
provider.type === "ollama" ||
provider.type === "llama.cpp" ||
provider.type === "vllm" ||
provider.type === "lmstudio";
if (isAutoDiscovery) {
try {
const models = await discoverModels(
provider.type as AutoDiscoveryProviderType,
provider.baseUrl,
provider.apiKey,
);
const modelsWithProvider = models.map((model) => ({
...model,
provider: provider.name,
}));
allCustomModels.push(...modelsWithProvider);
} catch (error) {
console.debug(`Failed to load models from ${provider.name}:`, error);
}
} else if (provider.models) {
// Manual provider - models already defined
allCustomModels.push(...provider.models);
}
}
} catch (error) {
console.error("Failed to load custom providers:", error);
} finally {
this.customProviderModels = allCustomModels;
this.customProvidersLoading = false;
this.requestUpdate();
}
}
private formatTokens(tokens: number): string {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(0)}M`;
if (tokens >= 1000) return `${(tokens / 1000).toFixed(0)}`;
return String(tokens);
}
private handleSelect(model: Model<any>) {
if (model) {
this.onSelectCallback?.(model);
this.close();
}
}
private getFilteredModels(): Array<{ provider: string; id: string; model: any }> {
// Collect all models from known providers
const allModels: Array<{ provider: string; id: string; model: any }> = [];
const knownProviders = getProviders();
for (const provider of knownProviders) {
const models = getModels(provider as any);
for (const model of models) {
allModels.push({ provider, id: model.id, model });
}
}
// Add custom provider models
for (const model of this.customProviderModels) {
allModels.push({ provider: model.provider, id: model.id, model });
}
// Filter by allowed providers if set
if (this.allowedProviders) {
const allowed = this.allowedProviders;
allModels.splice(0, allModels.length, ...allModels.filter(({ provider }) => allowed.has(provider)));
}
// Filter models based on search and capability filters
let filteredModels = allModels;
// Apply search filter (subsequence match: characters must appear in order)
if (this.searchQuery) {
const query = this.searchQuery.toLowerCase().replace(/\s+/g, "");
if (query) {
const scored: Array<{ item: (typeof allModels)[0]; score: number }> = [];
for (const entry of filteredModels) {
const searchText = `${entry.provider} ${entry.id} ${entry.model.name}`.toLowerCase();
const score = subsequenceScore(query, searchText);
if (score > 0) {
scored.push({ item: entry, score });
}
}
scored.sort((a, b) => b.score - a.score);
filteredModels = scored.map((s) => s.item);
}
}
// Apply capability filters
if (this.filterThinking) {
filteredModels = filteredModels.filter(({ model }) => model.reasoning);
}
if (this.filterVision) {
filteredModels = filteredModels.filter(({ model }) => model.input.includes("image"));
}
// Sort: when not searching, current model first then by provider.
// When searching, preserve the score-based order from above,
// but still float the current model to the top.
if (!this.searchQuery) {
filteredModels.sort((a, b) => {
const aIsCurrent = modelsAreEqual(this.currentModel, a.model);
const bIsCurrent = modelsAreEqual(this.currentModel, b.model);
if (aIsCurrent && !bIsCurrent) return -1;
if (!aIsCurrent && bIsCurrent) return 1;
return a.provider.localeCompare(b.provider);
});
}
return filteredModels;
}
private scrollToSelected() {
requestAnimationFrame(() => {
const scrollContainer = this.scrollContainerRef.value;
const selectedElement = scrollContainer?.querySelectorAll("[data-model-item]")[
this.selectedIndex
] as HTMLElement;
if (selectedElement) {
selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
});
}
protected override renderContent(): TemplateResult {
const filteredModels = this.getFilteredModels();
return html`
<!-- Header and Search -->
<div class="p-6 pb-4 flex flex-col gap-4 border-b border-border flex-shrink-0">
${DialogHeader({ title: i18n("Select Model") })}
${Input({
placeholder: i18n("Search models..."),
value: this.searchQuery,
inputRef: this.searchInputRef,
onInput: (e: Event) => {
this.searchQuery = (e.target as HTMLInputElement).value;
this.selectedIndex = 0;
// Reset scroll position when search changes
if (this.scrollContainerRef.value) {
this.scrollContainerRef.value.scrollTop = 0;
}
},
})}
<div class="flex gap-2">
${Button({
variant: this.filterThinking ? "default" : "secondary",
size: "sm",
onClick: () => {
this.filterThinking = !this.filterThinking;
this.selectedIndex = 0;
if (this.scrollContainerRef.value) {
this.scrollContainerRef.value.scrollTop = 0;
}
},
className: "rounded-full",
children: html`<span class="inline-flex items-center gap-1">${icon(Brain, "sm")} ${i18n("Thinking")}</span>`,
})}
${Button({
variant: this.filterVision ? "default" : "secondary",
size: "sm",
onClick: () => {
this.filterVision = !this.filterVision;
this.selectedIndex = 0;
if (this.scrollContainerRef.value) {
this.scrollContainerRef.value.scrollTop = 0;
}
},
className: "rounded-full",
children: html`<span class="inline-flex items-center gap-1">${icon(ImageIcon, "sm")} ${i18n("Vision")}</span>`,
})}
</div>
</div>
<!-- Scrollable model list -->
<div class="flex-1 overflow-y-auto" ${ref(this.scrollContainerRef)}>
${filteredModels.map(({ provider, id, model }, index) => {
const isCurrent = modelsAreEqual(this.currentModel, model);
const isSelected = index === this.selectedIndex;
return html`
<div
data-model-item
class="px-4 py-3 ${
this.navigationMode === "mouse" ? "hover:bg-muted" : ""
} cursor-pointer border-b border-border ${isSelected ? "bg-accent" : ""}"
@click=${() => this.handleSelect(model)}
@mouseenter=${() => {
// Only update selection in mouse mode
if (this.navigationMode === "mouse") {
this.selectedIndex = index;
}
}}
>
<div class="flex items-center justify-between gap-2 mb-1">
<div class="flex items-center gap-2 flex-1 min-w-0">
<span class="text-sm font-medium text-foreground truncate">${id}</span>
${isCurrent ? html`<span class="text-green-500">✓</span>` : ""}
</div>
${Badge(provider, "outline")}
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<div class="flex items-center gap-2">
<span class="${model.reasoning ? "" : "opacity-30"}">${icon(Brain, "sm")}</span>
<span class="${model.input.includes("image") ? "" : "opacity-30"}">${icon(ImageIcon, "sm")}</span>
<span>${this.formatTokens(model.contextWindow)}K/${this.formatTokens(model.maxTokens)}K</span>
</div>
<span>${formatModelCost(model.cost)}</span>
</div>
</div>
`;
})}
</div>
`;
}
}
@@ -0,0 +1,144 @@
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import { DialogContent, DialogHeader } from "@mariozechner/mini-lit/dist/Dialog.js";
import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { i18n } from "../utils/i18n.js";
@customElement("persistent-storage-dialog")
export class PersistentStorageDialog extends DialogBase {
@state() private requesting = false;
private resolvePromise?: (userApproved: boolean) => void;
protected modalWidth = "min(500px, 90vw)";
protected modalHeight = "auto";
/**
* Request persistent storage permission.
* Returns true if browser granted persistent storage, false otherwise.
*/
static async request(): Promise<boolean> {
// Check if already persisted
if (navigator.storage?.persisted) {
const alreadyPersisted = await navigator.storage.persisted();
if (alreadyPersisted) {
console.log("✓ Persistent storage already granted");
return true;
}
}
// Show dialog and wait for user response
const dialog = new PersistentStorageDialog();
dialog.open();
const userApproved = await new Promise<boolean>((resolve) => {
dialog.resolvePromise = resolve;
});
if (!userApproved) {
console.warn("⚠ User declined persistent storage - sessions may be lost");
return false;
}
// User approved, request from browser
if (!navigator.storage?.persist) {
console.warn("⚠ Persistent storage API not available");
return false;
}
try {
const granted = await navigator.storage.persist();
if (granted) {
console.log("✓ Persistent storage granted - sessions will be preserved");
} else {
console.warn("⚠ Browser denied persistent storage - sessions may be lost under storage pressure");
}
return granted;
} catch (error) {
console.error("Failed to request persistent storage:", error);
return false;
}
}
private handleGrant() {
if (this.resolvePromise) {
this.resolvePromise(true);
this.resolvePromise = undefined;
}
this.close();
}
private handleDeny() {
if (this.resolvePromise) {
this.resolvePromise(false);
this.resolvePromise = undefined;
}
this.close();
}
override close() {
super.close();
if (this.resolvePromise) {
this.resolvePromise(false);
}
}
protected override renderContent() {
return html`
${DialogContent({
children: html`
${DialogHeader({
title: i18n("Storage Permission Required"),
description: i18n("This app needs persistent storage to save your conversations"),
})}
<div class="mt-4 flex flex-col gap-4">
<div class="flex gap-3 p-4 bg-warning/10 border border-warning/20 rounded-lg">
<div class="flex-shrink-0 text-warning">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
</div>
<div class="text-sm">
<p class="font-medium text-foreground mb-1">${i18n("Why is this needed?")}</p>
<p class="text-muted-foreground">
${i18n(
"Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.",
)}
</p>
</div>
</div>
<div class="text-sm text-muted-foreground">
<p class="mb-2">${i18n("What this means:")}</p>
<ul class="list-disc list-inside space-y-1 ml-2">
<li>${i18n("Your conversations will be saved locally in your browser")}</li>
<li>${i18n("Data will not be deleted automatically to free up space")}</li>
<li>${i18n("You can still manually clear data at any time")}</li>
<li>${i18n("No data is sent to external servers")}</li>
</ul>
</div>
</div>
<div class="mt-6 flex gap-3 justify-end">
${Button({
variant: "outline",
onClick: () => this.handleDeny(),
disabled: this.requesting,
children: i18n("Continue Anyway"),
})}
${Button({
variant: "default",
onClick: () => this.handleGrant(),
disabled: this.requesting,
children: this.requesting ? i18n("Requesting...") : i18n("Grant Permission"),
})}
</div>
`,
})}
`;
}
}
@@ -0,0 +1,212 @@
import { i18n } from "@mariozechner/mini-lit";
import { Select } from "@mariozechner/mini-lit/dist/Select.js";
import { getProviders } from "@mariozechner/pi-ai";
import { html, type TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import "../components/CustomProviderCard.js";
import "../components/ProviderKeyInput.js";
import { getAppStorage } from "../storage/app-storage.js";
import type {
AutoDiscoveryProviderType,
CustomProvider,
CustomProviderType,
} from "../storage/stores/custom-providers-store.js";
import { discoverModels } from "../utils/model-discovery.js";
import { CustomProviderDialog } from "./CustomProviderDialog.js";
import { SettingsTab } from "./SettingsDialog.js";
@customElement("providers-models-tab")
export class ProvidersModelsTab extends SettingsTab {
@state() private customProviders: CustomProvider[] = [];
@state() private providerStatus: Map<
string,
{ modelCount: number; status: "connected" | "disconnected" | "checking" }
> = new Map();
override async connectedCallback() {
super.connectedCallback();
await this.loadCustomProviders();
}
private async loadCustomProviders() {
try {
const storage = getAppStorage();
this.customProviders = await storage.customProviders.getAll();
// Check status for auto-discovery providers
for (const provider of this.customProviders) {
const isAutoDiscovery =
provider.type === "ollama" ||
provider.type === "llama.cpp" ||
provider.type === "vllm" ||
provider.type === "lmstudio";
if (isAutoDiscovery) {
this.checkProviderStatus(provider);
}
}
} catch (error) {
console.error("Failed to load custom providers:", error);
}
}
getTabName(): string {
return "Providers & Models";
}
private async checkProviderStatus(provider: CustomProvider) {
this.providerStatus.set(provider.id, { modelCount: 0, status: "checking" });
this.requestUpdate();
try {
const models = await discoverModels(
provider.type as AutoDiscoveryProviderType,
provider.baseUrl,
provider.apiKey,
);
this.providerStatus.set(provider.id, { modelCount: models.length, status: "connected" });
} catch (_error) {
this.providerStatus.set(provider.id, { modelCount: 0, status: "disconnected" });
}
this.requestUpdate();
}
private renderKnownProviders(): TemplateResult {
const providers = getProviders();
return html`
<div class="flex flex-col gap-6">
<div>
<h3 class="text-sm font-semibold text-foreground mb-2">Cloud Providers</h3>
<p class="text-sm text-muted-foreground mb-4">
Cloud LLM providers with predefined models. API keys are stored locally in your browser.
</p>
</div>
<div class="flex flex-col gap-6">
${providers.map((provider) => html` <provider-key-input .provider=${provider}></provider-key-input> `)}
</div>
</div>
`;
}
private renderCustomProviders(): TemplateResult {
const isAutoDiscovery = (type: string) =>
type === "ollama" || type === "llama.cpp" || type === "vllm" || type === "lmstudio";
return html`
<div class="flex flex-col gap-6">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-semibold text-foreground mb-2">Custom Providers</h3>
<p class="text-sm text-muted-foreground">
User-configured servers with auto-discovered or manually defined models.
</p>
</div>
${Select({
placeholder: i18n("Add Provider"),
options: [
{ value: "ollama", label: "Ollama" },
{ value: "llama.cpp", label: "llama.cpp" },
{ value: "vllm", label: "vLLM" },
{ value: "lmstudio", label: "LM Studio" },
{ value: "openai-completions", label: i18n("OpenAI Completions Compatible") },
{ value: "openai-responses", label: i18n("OpenAI Responses Compatible") },
{ value: "anthropic-messages", label: i18n("Anthropic Messages Compatible") },
],
onChange: (value: string) => this.addCustomProvider(value as CustomProviderType),
variant: "outline",
size: "sm",
})}
</div>
${
this.customProviders.length === 0
? html`
<div class="text-sm text-muted-foreground text-center py-8">
No custom providers configured. Click 'Add Provider' to get started.
</div>
`
: html`
<div class="flex flex-col gap-4">
${this.customProviders.map(
(provider) => html`
<custom-provider-card
.provider=${provider}
.isAutoDiscovery=${isAutoDiscovery(provider.type)}
.status=${this.providerStatus.get(provider.id)}
.onRefresh=${(p: CustomProvider) => this.refreshProvider(p)}
.onEdit=${(p: CustomProvider) => this.editProvider(p)}
.onDelete=${(p: CustomProvider) => this.deleteProvider(p)}
></custom-provider-card>
`,
)}
</div>
`
}
</div>
`;
}
private async addCustomProvider(type: CustomProviderType) {
await CustomProviderDialog.open(undefined, type, async () => {
await this.loadCustomProviders();
this.requestUpdate();
});
}
private async editProvider(provider: CustomProvider) {
await CustomProviderDialog.open(provider, undefined, async () => {
await this.loadCustomProviders();
this.requestUpdate();
});
}
private async refreshProvider(provider: CustomProvider) {
this.providerStatus.set(provider.id, { modelCount: 0, status: "checking" });
this.requestUpdate();
try {
const models = await discoverModels(
provider.type as AutoDiscoveryProviderType,
provider.baseUrl,
provider.apiKey,
);
this.providerStatus.set(provider.id, { modelCount: models.length, status: "connected" });
this.requestUpdate();
console.log(`Refreshed ${models.length} models from ${provider.name}`);
} catch (error) {
this.providerStatus.set(provider.id, { modelCount: 0, status: "disconnected" });
this.requestUpdate();
console.error(`Failed to refresh provider ${provider.name}:`, error);
alert(`Failed to refresh provider: ${error instanceof Error ? error.message : String(error)}`);
}
}
private async deleteProvider(provider: CustomProvider) {
if (!confirm("Are you sure you want to delete this provider?")) {
return;
}
try {
const storage = getAppStorage();
await storage.customProviders.delete(provider.id);
await this.loadCustomProviders();
this.requestUpdate();
} catch (error) {
console.error("Failed to delete provider:", error);
}
}
render(): TemplateResult {
return html`
<div class="flex flex-col gap-8">
${this.renderKnownProviders()}
<div class="border-t border-border"></div>
${this.renderCustomProviders()}
</div>
`;
}
}
@@ -0,0 +1,150 @@
import { DialogContent, DialogHeader } from "@mariozechner/mini-lit/dist/Dialog.js";
import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { getAppStorage } from "../storage/app-storage.js";
import type { SessionMetadata } from "../storage/types.js";
import { formatUsage } from "../utils/format.js";
import { i18n } from "../utils/i18n.js";
@customElement("session-list-dialog")
export class SessionListDialog extends DialogBase {
@state() private sessions: SessionMetadata[] = [];
@state() private loading = true;
private onSelectCallback?: (sessionId: string) => void;
private onDeleteCallback?: (sessionId: string) => void;
private deletedSessions = new Set<string>();
private closedViaSelection = false;
protected modalWidth = "min(600px, 90vw)";
protected modalHeight = "min(700px, 90vh)";
static async open(onSelect: (sessionId: string) => void, onDelete?: (sessionId: string) => void) {
const dialog = new SessionListDialog();
dialog.onSelectCallback = onSelect;
dialog.onDeleteCallback = onDelete;
dialog.open();
await dialog.loadSessions();
}
private async loadSessions() {
this.loading = true;
try {
const storage = getAppStorage();
this.sessions = await storage.sessions.getAllMetadata();
} catch (err) {
console.error("Failed to load sessions:", err);
this.sessions = [];
} finally {
this.loading = false;
}
}
private async handleDelete(sessionId: string, event: Event) {
event.stopPropagation();
if (!confirm(i18n("Delete this session?"))) {
return;
}
try {
const storage = getAppStorage();
if (!storage.sessions) return;
await storage.sessions.deleteSession(sessionId);
await this.loadSessions();
// Track deleted session
this.deletedSessions.add(sessionId);
} catch (err) {
console.error("Failed to delete session:", err);
}
}
override close() {
super.close();
// Only notify about deleted sessions if dialog wasn't closed via selection
if (!this.closedViaSelection && this.onDeleteCallback && this.deletedSessions.size > 0) {
for (const sessionId of this.deletedSessions) {
this.onDeleteCallback(sessionId);
}
}
}
private handleSelect(sessionId: string) {
this.closedViaSelection = true;
if (this.onSelectCallback) {
this.onSelectCallback(sessionId);
}
this.close();
}
private formatDate(isoString: string): string {
const date = new Date(isoString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
return i18n("Today");
} else if (days === 1) {
return i18n("Yesterday");
} else if (days < 7) {
return i18n("{days} days ago").replace("{days}", days.toString());
} else {
return date.toLocaleDateString();
}
}
protected override renderContent() {
return html`
${DialogContent({
className: "h-full flex flex-col",
children: html`
${DialogHeader({
title: i18n("Sessions"),
description: i18n("Load a previous conversation"),
})}
<div class="flex-1 overflow-y-auto mt-4 space-y-2">
${
this.loading
? html`<div class="text-center py-8 text-muted-foreground">${i18n("Loading...")}</div>`
: this.sessions.length === 0
? html`<div class="text-center py-8 text-muted-foreground">${i18n("No sessions yet")}</div>`
: this.sessions.map(
(session) => html`
<div
class="group flex items-start gap-3 p-3 rounded-lg border border-border hover:bg-secondary/50 cursor-pointer transition-colors"
@click=${() => this.handleSelect(session.id)}
>
<div class="flex-1 min-w-0">
<div class="font-medium text-sm text-foreground truncate">${session.title}</div>
<div class="text-xs text-muted-foreground mt-1">${this.formatDate(session.lastModified)}</div>
<div class="text-xs text-muted-foreground mt-1">
${session.messageCount} ${i18n("messages")} · ${formatUsage(session.usage)}
</div>
</div>
<button
class="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-destructive/10 text-destructive transition-opacity"
@click=${(e: Event) => this.handleDelete(session.id, e)}
title=${i18n("Delete")}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"></path>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
</svg>
</button>
</div>
`,
)
}
</div>
`,
})}
`;
}
}
@@ -0,0 +1,218 @@
import { i18n } from "@mariozechner/mini-lit";
import { Dialog, DialogContent, DialogHeader } from "@mariozechner/mini-lit/dist/Dialog.js";
import { Input } from "@mariozechner/mini-lit/dist/Input.js";
import { Label } from "@mariozechner/mini-lit/dist/Label.js";
import { Switch } from "@mariozechner/mini-lit/dist/Switch.js";
import { getProviders } from "@mariozechner/pi-ai";
import { html, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import "../components/ProviderKeyInput.js";
import { getAppStorage } from "../storage/app-storage.js";
// Base class for settings tabs
export abstract class SettingsTab extends LitElement {
abstract getTabName(): string;
protected createRenderRoot() {
return this;
}
}
// API Keys Tab
@customElement("api-keys-tab")
export class ApiKeysTab extends SettingsTab {
getTabName(): string {
return i18n("API Keys");
}
render(): TemplateResult {
const providers = getProviders();
return html`
<div class="flex flex-col gap-6">
<p class="text-sm text-muted-foreground">
${i18n("Configure API keys for LLM providers. Keys are stored locally in your browser.")}
</p>
${providers.map((provider) => html`<provider-key-input .provider=${provider}></provider-key-input>`)}
</div>
`;
}
}
// Proxy Tab
@customElement("proxy-tab")
export class ProxyTab extends SettingsTab {
@state() private proxyEnabled = false;
@state() private proxyUrl = "http://localhost:3001";
override async connectedCallback() {
super.connectedCallback();
// Load proxy settings when tab is connected
try {
const storage = getAppStorage();
const enabled = await storage.settings.get<boolean>("proxy.enabled");
const url = await storage.settings.get<string>("proxy.url");
if (enabled !== null) this.proxyEnabled = enabled;
if (url !== null) this.proxyUrl = url;
} catch (error) {
console.error("Failed to load proxy settings:", error);
}
}
private async saveProxySettings() {
try {
const storage = getAppStorage();
await storage.settings.set("proxy.enabled", this.proxyEnabled);
await storage.settings.set("proxy.url", this.proxyUrl);
} catch (error) {
console.error("Failed to save proxy settings:", error);
}
}
getTabName(): string {
return i18n("Proxy");
}
render(): TemplateResult {
return html`
<div class="flex flex-col gap-4">
<p class="text-sm text-muted-foreground">
${i18n("Allows browser-based apps to bypass CORS restrictions when calling LLM providers. Required for Z-AI and Anthropic with OAuth token.")}
</p>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-foreground">${i18n("Use CORS Proxy")}</span>
${Switch({
checked: this.proxyEnabled,
onChange: (checked: boolean) => {
this.proxyEnabled = checked;
this.saveProxySettings();
},
})}
</div>
<div class="space-y-2">
${Label({ children: i18n("Proxy URL") })}
${Input({
type: "text",
value: this.proxyUrl,
disabled: !this.proxyEnabled,
onInput: (e) => {
this.proxyUrl = (e.target as HTMLInputElement).value;
},
onChange: () => this.saveProxySettings(),
})}
<p class="text-xs text-muted-foreground">
${i18n("Format: The proxy must accept requests as <proxy-url>/?url=<target-url>")}
</p>
</div>
</div>
`;
}
}
@customElement("settings-dialog")
export class SettingsDialog extends LitElement {
@property({ type: Array, attribute: false }) tabs: SettingsTab[] = [];
@state() private isOpen = false;
@state() private activeTabIndex = 0;
protected createRenderRoot() {
return this;
}
private onCloseCallback?: () => void;
static async open(tabs: SettingsTab[], onClose?: () => void) {
const dialog = new SettingsDialog();
dialog.tabs = tabs;
dialog.onCloseCallback = onClose;
dialog.isOpen = true;
document.body.appendChild(dialog);
}
private setActiveTab(index: number) {
this.activeTabIndex = index;
}
private renderSidebarItem(tab: SettingsTab, index: number): TemplateResult {
const isActive = this.activeTabIndex === index;
return html`
<button
class="w-full text-left px-4 py-3 rounded-md transition-colors ${
isActive
? "bg-secondary text-foreground font-medium"
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground"
}"
@click=${() => this.setActiveTab(index)}
>
${tab.getTabName()}
</button>
`;
}
private renderMobileTab(tab: SettingsTab, index: number): TemplateResult {
const isActive = this.activeTabIndex === index;
return html`
<button
class="px-3 py-2 text-sm font-medium transition-colors ${
isActive ? "border-b-2 border-primary text-foreground" : "text-muted-foreground hover:text-foreground"
}"
@click=${() => this.setActiveTab(index)}
>
${tab.getTabName()}
</button>
`;
}
render() {
if (this.tabs.length === 0) {
return html``;
}
return Dialog({
isOpen: this.isOpen,
onClose: () => {
this.isOpen = false;
this.remove();
this.onCloseCallback?.();
},
width: "min(1000px, 90vw)",
height: "min(800px, 90vh)",
backdropClassName: "bg-black/50 backdrop-blur-sm",
children: html`
${DialogContent({
className: "h-full p-6",
children: html`
<div class="flex flex-col h-full overflow-hidden">
<!-- Header -->
<div class="pb-4 flex-shrink-0">${DialogHeader({ title: i18n("Settings") })}</div>
<!-- Mobile Tabs -->
<div class="md:hidden flex flex-shrink-0 pb-4">
${this.tabs.map((tab, index) => this.renderMobileTab(tab, index))}
</div>
<!-- Layout -->
<div class="flex flex-1 overflow-hidden">
<!-- Sidebar (desktop only) -->
<div class="hidden md:block w-64 flex-shrink-0 space-y-1">
${this.tabs.map((tab, index) => this.renderSidebarItem(tab, index))}
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto md:pl-6">
${this.tabs.map(
(tab, index) =>
html`<div style="display: ${this.activeTabIndex === index ? "block" : "none"}">${tab}</div>`,
)}
</div>
</div>
</div>
`,
})}
`,
});
}
}