Port PDF/Office support, message editor, overlays, key setter

This commit is contained in:
Mario Zechner 2025-10-01 17:31:13 +02:00
parent b67c10dfb1
commit b3a7b35ec5
17 changed files with 2462 additions and 18 deletions

View file

@ -1,6 +1,6 @@
{
"manifest_version": 3,
"name": "Pi Reader Assistant",
"name": "pi-ai",
"description": "Use @mariozechner/pi-ai to summarize and highlight the page you are reading.",
"version": "0.5.43",
"action": {

View file

@ -1,6 +1,6 @@
{
"manifest_version": 3,
"name": "Pi Reader Assistant",
"name": "pi-ai",
"description": "Use @mariozechner/pi-ai to summarize and highlight the page you are reading.",
"version": "0.5.43",
"action": {
@ -17,7 +17,7 @@
},
"sidebar_action": {
"default_panel": "sidepanel.html",
"default_title": "Pi Reader Assistant",
"default_title": "pi-ai",
"default_icon": {
"16": "icon-16.png",
"48": "icon-48.png"

View file

@ -17,9 +17,13 @@
"dependencies": {
"@mariozechner/mini-lit": "^0.1.4",
"@mariozechner/pi-ai": "^0.5.43",
"docx-preview": "^0.3.7",
"jszip": "^3.10.1",
"lit": "^3.3.1",
"lucide": "^0.544.0",
"ollama": "^0.6.0"
"ollama": "^0.6.0",
"pdfjs-dist": "^5.4.149",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@tailwindcss/cli": "^4.0.0-beta.14",

View file

@ -1,5 +1,5 @@
import { build, context } from "esbuild";
import { copyFileSync, mkdirSync, rmSync } from "node:fs";
import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@ -63,6 +63,16 @@ const copyStatic = () => {
copyFileSync(source, destination);
}
// Copy PDF.js worker from node_modules (check both local and monorepo root)
let pdfWorkerSource = join(packageRoot, "node_modules/pdfjs-dist/build/pdf.worker.min.mjs");
if (!existsSync(pdfWorkerSource)) {
pdfWorkerSource = join(packageRoot, "../../node_modules/pdfjs-dist/build/pdf.worker.min.mjs");
}
const pdfWorkerDestDir = join(outDir, "pdfjs-dist/build");
mkdirSync(pdfWorkerDestDir, { recursive: true });
const pdfWorkerDest = join(pdfWorkerDestDir, "pdf.worker.min.mjs");
copyFileSync(pdfWorkerSource, pdfWorkerDest);
console.log(`Built for ${targetBrowser} in ${outDir}`);
};

View file

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

View file

@ -0,0 +1,112 @@
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 "./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>
`;
}
}

View file

@ -1,13 +1,68 @@
import { html, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
import { html } from "@mariozechner/mini-lit";
import type { Model } from "@mariozechner/pi-ai";
import { getModel } from "@mariozechner/pi-ai";
import { LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { ModelSelector } from "./dialogs/ModelSelector.js";
import "./MessageEditor.js";
import type { Attachment } from "./utils/attachment-utils.js";
@customElement("pi-chat-panel")
export class ChatPanel extends LitElement {
@state() currentModel: Model<any> | null = null;
@state() messageText = "";
@state() attachments: Attachment[] = [];
createRenderRoot() {
return this;
}
override async connectedCallback() {
super.connectedCallback();
// Set default model
this.currentModel = getModel("anthropic", "claude-3-5-haiku-20241022");
}
private handleSend = (text: string, attachments: Attachment[]) => {
// For now just alert and clear
alert(`Message: ${text}\nAttachments: ${attachments.length}`);
this.messageText = "";
this.attachments = [];
};
private handleModelSelect = () => {
ModelSelector.open(this.currentModel, (model) => {
this.currentModel = model;
});
};
render() {
return html`<h1>Hello world</h1>`;
return html`
<div class="flex flex-col h-full">
<!-- Messages area (empty for now) -->
<div class="flex-1 overflow-y-auto p-4">
<!-- Messages will go here -->
</div>
<!-- Message editor at the bottom -->
<div class="p-4 border-t border-border">
<message-editor
.value=${this.messageText}
.currentModel=${this.currentModel}
.attachments=${this.attachments}
.showAttachmentButton=${true}
.showThinking=${false}
.onInput=${(value: string) => {
this.messageText = value;
}}
.onSend=${this.handleSend}
.onModelSelect=${this.handleModelSelect}
.onFilesChange=${(files: Attachment[]) => {
this.attachments = files;
}}
></message-editor>
</div>
</div>
`;
}
}

View file

@ -0,0 +1,267 @@
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) {
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
}
}
override firstUpdated() {
const textarea = this.textareaRef.value;
if (textarea) {
// Set initial height properly
this.adjustTextareaHeight();
textarea.focus();
}
}
override updated() {
// Adjust height when component updates
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 button -->
<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>
`;
}
}

View file

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

View file

@ -0,0 +1,273 @@
import { Alert, Badge, Button, 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 "../Input.js";
import { keyStore } from "../state/KeyStore.js";
import { i18n } from "../utils/i18n.js";
import { DialogBase } from "./DialogBase.js";
// Test models for each provider - known to be reliable and cheap
const TEST_MODELS: Record<string, string> = {
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>
`
: ""
}
<!-- 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 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>
`;
}
}

View file

@ -2,7 +2,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pi Reader Assistant</title>
<title>pi-ai</title>
<link rel="stylesheet" href="app.css" />
</head>
<body class="h-full w-full">

View file

@ -3,9 +3,9 @@ import "./ChatPanel.js";
import "./live-reload.js";
import { customElement } from "lit/decorators.js";
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
import { Button, Input, icon } from "@mariozechner/mini-lit";
import { Button, icon } from "@mariozechner/mini-lit";
import { Settings } from "lucide";
import { ModelSelector } from "./dialogs/ModelSelector.js";
import { ApiKeysDialog } from "./dialogs/ApiKeysDialog.js";
async function getDom() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
@ -32,16 +32,14 @@ export class Header extends LitElement {
render() {
return html`
<div class="flex items-center px-4 py-2 border-b border-border mb-4">
<span class="text-muted-foreground">pi-ai webby</span>
<span class="text-muted-foreground">pi-ai</span>
<theme-toggle class="ml-auto"></theme-toggle>
${Button({
variant: "ghost",
size: "icon",
children: html`${icon(Settings, "sm")}`,
onClick: async () => {
ModelSelector.open(null, (model) => {
console.log("Selected model:", model);
});
ApiKeysDialog.open();
},
})}
</div>

View file

@ -0,0 +1,50 @@
import { getProviders } from "@mariozechner/pi-ai";
/**
* 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
}
/**
* Chrome storage implementation of KeyStore
*/
class ChromeKeyStore implements KeyStore {
private readonly prefix = "apiKey_";
async getKey(provider: string): Promise<string | null> {
const key = `${this.prefix}${provider}`;
const result = await chrome.storage.local.get(key);
return result[key] || null;
}
async setKey(provider: string, key: string): Promise<void> {
const storageKey = `${this.prefix}${provider}`;
await chrome.storage.local.set({ [storageKey]: key });
}
async removeKey(provider: string): Promise<void> {
const key = `${this.prefix}${provider}`;
await chrome.storage.local.remove(key);
}
async getAllKeys(): Promise<Record<string, boolean>> {
const providers = getProviders();
const storage = await chrome.storage.local.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 ChromeKeyStore();

View file

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

View file

@ -11,6 +11,39 @@ declare module "@mariozechner/mini-lit" {
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;
}
}
@ -26,6 +59,41 @@ const translations = {
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.",
},
de: {
...defaultGerman,
@ -38,6 +106,41 @@ const translations = {
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.",
},
};