This commit is contained in:
ZacharyZhang-NY committed 2026-04-18 22:45:41 -04:00
1 parent 1261c2431f
commit dac59f56c2
680 files changed
+192574

No files matched your search

@@ -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)}`);
}
}
@@ -0,0 +1,22 @@
import PromptDialog from "@mariozechner/mini-lit/dist/PromptDialog.js";
import { i18n } from "./i18n.js";
export async function getAuthToken(): Promise<string | undefined> {
let authToken: string | undefined = localStorage.getItem(`auth-token`) || "";
if (authToken) return authToken;
while (true) {
authToken = (
await PromptDialog.ask(i18n("Enter Auth Token"), i18n("Please enter your auth token."), "", true)
)?.trim();
if (authToken) {
localStorage.setItem(`auth-token`, authToken);
break;
}
}
return authToken?.trim() || undefined;
}
export async function clearAuthToken() {
localStorage.removeItem(`auth-token`);
}
@@ -0,0 +1,42 @@
import { i18n } from "@mariozechner/mini-lit";
import type { Usage } from "@mariozechner/pi-ai";
export function formatCost(cost: number): string {
return `$${cost.toFixed(4)}`;
}
export function formatModelCost(cost: any): string {
if (!cost) return i18n("Free");
const input = cost.input || 0;
const output = cost.output || 0;
if (input === 0 && output === 0) return i18n("Free");
// Format numbers with appropriate precision
const formatNum = (num: number): string => {
if (num >= 100) return num.toFixed(0);
if (num >= 10) return num.toFixed(1).replace(/\.0$/, "");
if (num >= 1) return num.toFixed(2).replace(/\.?0+$/, "");
return num.toFixed(3).replace(/\.?0+$/, "");
};
return `$${formatNum(input)}/$${formatNum(output)}`;
}
export function formatUsage(usage: Usage) {
if (!usage) return "";
const parts = [];
if (usage.input) parts.push(`↑${formatTokenCount(usage.input)}`);
if (usage.output) parts.push(`↓${formatTokenCount(usage.output)}`);
if (usage.cacheRead) parts.push(`R${formatTokenCount(usage.cacheRead)}`);
if (usage.cacheWrite) parts.push(`W${formatTokenCount(usage.cacheWrite)}`);
if (usage.cost?.total) parts.push(formatCost(usage.cost.total));
return parts.join(" ");
}
export function formatTokenCount(count: number): string {
if (count < 1000) return count.toString();
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
return `${Math.round(count / 1000)}k`;
}
@@ -0,0 +1,653 @@
import { defaultEnglish, defaultGerman, type MiniLitRequiredMessages, setTranslations } from "@mariozechner/mini-lit";
declare module "@mariozechner/mini-lit" {
interface i18nMessages extends MiniLitRequiredMessages {
Free: string;
"Input Required": string;
Cancel: string;
Confirm: string;
"Select Model": string;
"Search models...": string;
Format: string;
Thinking: string;
Vision: string;
You: string;
Assistant: string;
"Thinking...": string;
"Type your message...": string;
"API Keys Configuration": string;
"Configure API keys for LLM providers. Keys are stored locally in your browser.": string;
Configured: string;
"Not configured": string;
"✓ Valid": string;
"✗ Invalid": string;
"Testing...": string;
Update: string;
Test: string;
Remove: string;
Save: string;
"Update API key": string;
"Enter API key": string;
"Type a message...": string;
"Failed to fetch file": string;
"Invalid source type": string;
PDF: string;
Document: string;
Presentation: string;
Spreadsheet: string;
Text: string;
"Error loading file": string;
"No text content available": string;
"Failed to load PDF": string;
"Failed to load document": string;
"Failed to load spreadsheet": string;
"Error loading PDF": string;
"Error loading document": string;
"Error loading spreadsheet": string;
"Preview not available for this file type.": string;
"Click the download button above to view it on your computer.": string;
"No content available": string;
"Failed to display text content": string;
"API keys are required to use AI models. Get your keys from the provider's website.": string;
console: string;
"Copy output": string;
"Copied!": string;
"Error:": string;
"Request aborted": string;
Call: string;
Result: string;
"(no result)": string;
"Waiting for tool result…": string;
"Call was aborted; no result.": string;
"No session available": string;
"No session set": string;
"Preparing tool parameters...": string;
"(no output)": string;
Input: string;
Output: string;
"Writing expression...": string;
"Waiting for expression...": string;
Calculating: string;
"Getting current time in": string;
"Getting current date and time": string;
"Waiting for command...": string;
"Writing command...": string;
"Running command...": string;
"Command failed:": string;
"Enter Auth Token": string;
"Please enter your auth token.": string;
"Auth token is required for proxy transport": string;
// JavaScript REPL strings
"Execution aborted": string;
"Code parameter is required": string;
"Unknown error": string;
"Code executed successfully (no output)": string;
"Execution failed": string;
"JavaScript REPL": string;
"JavaScript code to execute": string;
"Writing JavaScript code...": string;
"Executing JavaScript": string;
"Preparing JavaScript...": string;
"Preparing command...": string;
"Preparing calculation...": string;
"Preparing tool...": string;
"Getting time...": string;
// Artifacts strings
"Processing artifact...": string;
"Preparing artifact...": string;
"Processing artifact": string;
"Processed artifact": string;
"Creating artifact": string;
"Created artifact": string;
"Updating artifact": string;
"Updated artifact": string;
"Rewriting artifact": string;
"Rewrote artifact": string;
"Getting artifact": string;
"Got artifact": string;
"Deleting artifact": string;
"Deleted artifact": string;
"Getting logs": string;
"Got logs": string;
"An error occurred": string;
"Copy logs": string;
"Autoscroll enabled": string;
"Autoscroll disabled": string;
Processing: string;
Create: string;
Rewrite: string;
Get: string;
Delete: string;
"Get logs": string;
"Show artifacts": string;
"Close artifacts": string;
Artifacts: string;
"Copy HTML": string;
"Download HTML": string;
"Reload HTML": string;
"Copy SVG": string;
"Download SVG": string;
"Copy Markdown": string;
"Download Markdown": string;
Download: string;
"No logs for {filename}": string;
"API Keys Settings": string;
Settings: string;
"API Keys": string;
Proxy: string;
"Use CORS Proxy": string;
"Proxy URL": string;
"Format: The proxy must accept requests as <proxy-url>/?url=<target-url>": string;
"Settings are stored locally in your browser": string;
Clear: string;
"API Key Required": string;
"Enter your API key for {provider}": string;
"Allows browser-based apps to bypass CORS restrictions when calling LLM providers. Required for Z-AI and Anthropic with OAuth token.": string;
Off: string;
Minimal: string;
Low: string;
Medium: string;
High: string;
"Storage Permission Required": string;
"This app needs persistent storage to save your conversations": string;
"Why is this needed?": string;
"Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.": string;
"What this means:": string;
"Your conversations will be saved locally in your browser": string;
"Data will not be deleted automatically to free up space": string;
"You can still manually clear data at any time": string;
"No data is sent to external servers": string;
"Continue Anyway": string;
"Requesting...": string;
"Grant Permission": string;
Sessions: string;
"Load a previous conversation": string;
"No sessions yet": string;
"Delete this session?": string;
Today: string;
Yesterday: string;
"{days} days ago": string;
messages: string;
tokens: string;
"Drop files here": string;
// Providers & Models
"Providers & Models": string;
"Cloud Providers": string;
"Cloud LLM providers with predefined models. API keys are stored locally in your browser.": string;
"Custom Providers": string;
"User-configured servers with auto-discovered or manually defined models.": string;
"Add Provider": string;
"No custom providers configured. Click 'Add Provider' to get started.": string;
Models: string;
"auto-discovered": string;
Refresh: string;
Edit: string;
"Are you sure you want to delete this provider?": string;
"Edit Provider": string;
"Provider Name": string;
"e.g., My Ollama Server": string;
"Provider Type": string;
"Base URL": string;
"e.g., http://localhost:11434": string;
"API Key (Optional)": string;
"Leave empty if not required": string;
"Test Connection": string;
Discovered: string;
models: string;
and: string;
more: string;
"For manual provider types, add models after saving the provider.": string;
"Please fill in all required fields": string;
"Failed to save provider": string;
"OpenAI Completions Compatible": string;
"OpenAI Responses Compatible": string;
"Anthropic Messages Compatible": string;
"Checking...": string;
Disconnected: string;
}
}
export const translations = {
en: {
...defaultEnglish,
Free: "Free",
"Input Required": "Input Required",
Cancel: "Cancel",
Confirm: "Confirm",
"Select Model": "Select Model",
"Search models...": "Search models...",
Format: "Format",
Thinking: "Thinking",
Vision: "Vision",
You: "You",
Assistant: "Assistant",
"Thinking...": "Thinking...",
"Type your message...": "Type your message...",
"API Keys Configuration": "API Keys Configuration",
"Configure API keys for LLM providers. Keys are stored locally in your browser.":
"Configure API keys for LLM providers. Keys are stored locally in your browser.",
Configured: "Configured",
"Not configured": "Not configured",
"✓ Valid": "✓ Valid",
"✗ Invalid": "✗ Invalid",
"Testing...": "Testing...",
Update: "Update",
Test: "Test",
Remove: "Remove",
Save: "Save",
"Update API key": "Update API key",
"Enter API key": "Enter API key",
"Type a message...": "Type a message...",
"Failed to fetch file": "Failed to fetch file",
"Invalid source type": "Invalid source type",
PDF: "PDF",
Document: "Document",
Presentation: "Presentation",
Spreadsheet: "Spreadsheet",
Text: "Text",
"Error loading file": "Error loading file",
"No text content available": "No text content available",
"Failed to load PDF": "Failed to load PDF",
"Failed to load document": "Failed to load document",
"Failed to load spreadsheet": "Failed to load spreadsheet",
"Error loading PDF": "Error loading PDF",
"Error loading document": "Error loading document",
"Error loading spreadsheet": "Error loading spreadsheet",
"Preview not available for this file type.": "Preview not available for this file type.",
"Click the download button above to view it on your computer.":
"Click the download button above to view it on your computer.",
"No content available": "No content available",
"Failed to display text content": "Failed to display text content",
"API keys are required to use AI models. Get your keys from the provider's website.":
"API keys are required to use AI models. Get your keys from the provider's website.",
console: "console",
"Copy output": "Copy output",
"Copied!": "Copied!",
"Error:": "Error:",
"Request aborted": "Request aborted",
Call: "Call",
Result: "Result",
"(no result)": "(no result)",
"Waiting for tool result…": "Waiting for tool result…",
"Call was aborted; no result.": "Call was aborted; no result.",
"No session available": "No session available",
"No session set": "No session set",
"Preparing tool parameters...": "Preparing tool parameters...",
"(no output)": "(no output)",
Input: "Input",
Output: "Output",
"Waiting for expression...": "Waiting for expression...",
"Writing expression...": "Writing expression...",
Calculating: "Calculating",
"Getting current time in": "Getting current time in",
"Getting current date and time": "Getting current date and time",
"Waiting for command...": "Waiting for command...",
"Writing command...": "Writing command...",
"Running command...": "Running command...",
"Command failed": "Command failed",
"Enter Auth Token": "Enter Auth Token",
"Please enter your auth token.": "Please enter your auth token.",
"Auth token is required for proxy transport": "Auth token is required for proxy transport",
// JavaScript REPL strings
"Execution aborted": "Execution aborted",
"Code parameter is required": "Code parameter is required",
"Unknown error": "Unknown error",
"Code executed successfully (no output)": "Code executed successfully (no output)",
"Execution failed": "Execution failed",
"JavaScript REPL": "JavaScript REPL",
"JavaScript code to execute": "JavaScript code to execute",
"Writing JavaScript code...": "Writing JavaScript code...",
"Executing JavaScript": "Executing JavaScript",
"Preparing JavaScript...": "Preparing JavaScript...",
"Preparing command...": "Preparing command...",
"Preparing calculation...": "Preparing calculation...",
"Preparing tool...": "Preparing tool...",
"Getting time...": "Getting time...",
// Artifacts strings
"Processing artifact...": "Processing artifact...",
"Preparing artifact...": "Preparing artifact...",
"Processing artifact": "Processing artifact",
"Processed artifact": "Processed artifact",
"Creating artifact": "Creating artifact",
"Created artifact": "Created artifact",
"Updating artifact": "Updating artifact",
"Updated artifact": "Updated artifact",
"Rewriting artifact": "Rewriting artifact",
"Rewrote artifact": "Rewrote artifact",
"Getting artifact": "Getting artifact",
"Got artifact": "Got artifact",
"Deleting artifact": "Deleting artifact",
"Deleted artifact": "Deleted artifact",
"Getting logs": "Getting logs",
"Got logs": "Got logs",
"An error occurred": "An error occurred",
"Copy logs": "Copy logs",
"Autoscroll enabled": "Autoscroll enabled",
"Autoscroll disabled": "Autoscroll disabled",
Processing: "Processing",
Create: "Create",
Rewrite: "Rewrite",
Get: "Get",
"Get logs": "Get logs",
"Show artifacts": "Show artifacts",
"Close artifacts": "Close artifacts",
Artifacts: "Artifacts",
"Copy HTML": "Copy HTML",
"Download HTML": "Download HTML",
"Reload HTML": "Reload HTML",
"Copy SVG": "Copy SVG",
"Download SVG": "Download SVG",
"Copy Markdown": "Copy Markdown",
"Download Markdown": "Download Markdown",
Download: "Download",
"No logs for {filename}": "No logs for {filename}",
"API Keys Settings": "API Keys Settings",
Settings: "Settings",
"API Keys": "API Keys",
Proxy: "Proxy",
"Use CORS Proxy": "Use CORS Proxy",
"Proxy URL": "Proxy URL",
"Format: The proxy must accept requests as <proxy-url>/?url=<target-url>":
"Format: The proxy must accept requests as <proxy-url>/?url=<target-url>",
"Settings are stored locally in your browser": "Settings are stored locally in your browser",
Clear: "Clear",
"API Key Required": "API Key Required",
"Enter your API key for {provider}": "Enter your API key for {provider}",
"Allows browser-based apps to bypass CORS restrictions when calling LLM providers. Required for Z-AI and Anthropic with OAuth token.":
"Allows browser-based apps to bypass CORS restrictions when calling LLM providers. Required for Z-AI and Anthropic with OAuth token.",
Off: "Off",
Minimal: "Minimal",
Low: "Low",
Medium: "Medium",
High: "High",
"Storage Permission Required": "Storage Permission Required",
"This app needs persistent storage to save your conversations":
"This app needs persistent storage to save your conversations",
"Why is this needed?": "Why is this needed?",
"Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.":
"Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.",
"What this means:": "What this means:",
"Your conversations will be saved locally in your browser":
"Your conversations will be saved locally in your browser",
"Data will not be deleted automatically to free up space":
"Data will not be deleted automatically to free up space",
"You can still manually clear data at any time": "You can still manually clear data at any time",
"No data is sent to external servers": "No data is sent to external servers",
"Continue Anyway": "Continue Anyway",
"Requesting...": "Requesting...",
"Grant Permission": "Grant Permission",
Sessions: "Sessions",
"Load a previous conversation": "Load a previous conversation",
"No sessions yet": "No sessions yet",
"Delete this session?": "Delete this session?",
Today: "Today",
Yesterday: "Yesterday",
"{days} days ago": "{days} days ago",
messages: "messages",
tokens: "tokens",
Delete: "Delete",
"Drop files here": "Drop files here",
"Command failed:": "Command failed:",
// Providers & Models
"Providers & Models": "Providers & Models",
"Cloud Providers": "Cloud Providers",
"Cloud LLM providers with predefined models. API keys are stored locally in your browser.":
"Cloud LLM providers with predefined models. API keys are stored locally in your browser.",
"Custom Providers": "Custom Providers",
"User-configured servers with auto-discovered or manually defined models.":
"User-configured servers with auto-discovered or manually defined models.",
"Add Provider": "Add Provider",
"No custom providers configured. Click 'Add Provider' to get started.":
"No custom providers configured. Click 'Add Provider' to get started.",
"auto-discovered": "auto-discovered",
Refresh: "Refresh",
Edit: "Edit",
"Are you sure you want to delete this provider?": "Are you sure you want to delete this provider?",
"Edit Provider": "Edit Provider",
"Provider Name": "Provider Name",
"e.g., My Ollama Server": "e.g., My Ollama Server",
"Provider Type": "Provider Type",
"Base URL": "Base URL",
"e.g., http://localhost:11434": "e.g., http://localhost:11434",
"API Key (Optional)": "API Key (Optional)",
"Leave empty if not required": "Leave empty if not required",
"Test Connection": "Test Connection",
Discovered: "Discovered",
Models: "Models",
models: "models",
and: "and",
more: "more",
"For manual provider types, add models after saving the provider.":
"For manual provider types, add models after saving the provider.",
"Please fill in all required fields": "Please fill in all required fields",
"Failed to save provider": "Failed to save provider",
"OpenAI Completions Compatible": "OpenAI Completions Compatible",
"OpenAI Responses Compatible": "OpenAI Responses Compatible",
"Anthropic Messages Compatible": "Anthropic Messages Compatible",
"Checking...": "Checking...",
Disconnected: "Disconnected",
},
de: {
...defaultGerman,
Free: "Kostenlos",
"Input Required": "Eingabe erforderlich",
Cancel: "Abbrechen",
Confirm: "Bestätigen",
"Select Model": "Modell auswählen",
"Search models...": "Modelle suchen...",
Format: "Formatieren",
Thinking: "Thinking",
Vision: "Vision",
You: "Sie",
Assistant: "Assistent",
"Thinking...": "Denkt nach...",
"Type your message...": "Geben Sie Ihre Nachricht ein...",
"API Keys Configuration": "API-Schlüssel-Konfiguration",
"Configure API keys for LLM providers. Keys are stored locally in your browser.":
"Konfigurieren Sie API-Schlüssel für LLM-Anbieter. Schlüssel werden lokal in Ihrem Browser gespeichert.",
Configured: "Konfiguriert",
"Not configured": "Nicht konfiguriert",
"✓ Valid": "✓ Gültig",
"✗ Invalid": "✗ Ungültig",
"Testing...": "Teste...",
Update: "Aktualisieren",
Test: "Testen",
Remove: "Entfernen",
Save: "Speichern",
"Update API key": "API-Schlüssel aktualisieren",
"Enter API key": "API-Schlüssel eingeben",
"Type a message...": "Nachricht eingeben...",
"Failed to fetch file": "Datei konnte nicht abgerufen werden",
"Invalid source type": "Ungültiger Quellentyp",
PDF: "PDF",
Document: "Dokument",
Presentation: "Präsentation",
Spreadsheet: "Tabelle",
Text: "Text",
"Error loading file": "Fehler beim Laden der Datei",
"No text content available": "Kein Textinhalt verfügbar",
"Failed to load PDF": "PDF konnte nicht geladen werden",
"Failed to load document": "Dokument konnte nicht geladen werden",
"Failed to load spreadsheet": "Tabelle konnte nicht geladen werden",
"Error loading PDF": "Fehler beim Laden des PDFs",
"Error loading document": "Fehler beim Laden des Dokuments",
"Error loading spreadsheet": "Fehler beim Laden der Tabelle",
"Preview not available for this file type.": "Vorschau für diesen Dateityp nicht verfügbar.",
"Click the download button above to view it on your computer.":
"Klicken Sie oben auf die Download-Schaltfläche, um die Datei auf Ihrem Computer anzuzeigen.",
"No content available": "Kein Inhalt verfügbar",
"Failed to display text content": "Textinhalt konnte nicht angezeigt werden",
"API keys are required to use AI models. Get your keys from the provider's website.":
"API-Schlüssel sind erforderlich, um KI-Modelle zu verwenden. Holen Sie sich Ihre Schlüssel von der Website des Anbieters.",
console: "Konsole",
"Copy output": "Ausgabe kopieren",
"Copied!": "Kopiert!",
"Error:": "Fehler:",
"Request aborted": "Anfrage abgebrochen",
Call: "Aufruf",
Result: "Ergebnis",
"(no result)": "(kein Ergebnis)",
"Waiting for tool result…": "Warte auf Tool-Ergebnis…",
"Call was aborted; no result.": "Aufruf wurde abgebrochen; kein Ergebnis.",
"No session available": "Keine Sitzung verfügbar",
"No session set": "Keine Sitzung gesetzt",
"Preparing tool parameters...": "Bereite Tool-Parameter vor...",
"(no output)": "(keine Ausgabe)",
Input: "Eingabe",
Output: "Ausgabe",
"Waiting for expression...": "Warte auf Ausdruck",
"Writing expression...": "Schreibe Ausdruck...",
Calculating: "Berechne",
"Getting current time in": "Hole aktuelle Zeit in",
"Getting current date and time": "Hole aktuelles Datum und Uhrzeit",
"Waiting for command...": "Warte auf Befehl...",
"Writing command...": "Schreibe Befehl...",
"Running command...": "Führe Befehl aus...",
"Command failed": "Befehl fehlgeschlagen",
"Enter Auth Token": "Auth-Token eingeben",
"Please enter your auth token.": "Bitte geben Sie Ihr Auth-Token ein.",
"Auth token is required for proxy transport": "Auth-Token ist für Proxy-Transport erforderlich",
// JavaScript REPL strings
"Execution aborted": "Ausführung abgebrochen",
"Code parameter is required": "Code-Parameter ist erforderlich",
"Unknown error": "Unbekannter Fehler",
"Code executed successfully (no output)": "Code erfolgreich ausgeführt (keine Ausgabe)",
"Execution failed": "Ausführung fehlgeschlagen",
"JavaScript REPL": "JavaScript REPL",
"JavaScript code to execute": "Auszuführender JavaScript-Code",
"Writing JavaScript code...": "Schreibe JavaScript-Code...",
"Executing JavaScript": "Führe JavaScript aus",
"Preparing JavaScript...": "Bereite JavaScript vor...",
"Preparing command...": "Bereite Befehl vor...",
"Preparing calculation...": "Bereite Berechnung vor...",
"Preparing tool...": "Bereite Tool vor...",
"Getting time...": "Hole Zeit...",
// Artifacts strings
"Processing artifact...": "Verarbeite Artefakt...",
"Preparing artifact...": "Bereite Artefakt vor...",
"Processing artifact": "Verarbeite Artefakt",
"Processed artifact": "Artefakt verarbeitet",
"Creating artifact": "Erstelle Artefakt",
"Created artifact": "Artefakt erstellt",
"Updating artifact": "Aktualisiere Artefakt",
"Updated artifact": "Artefakt aktualisiert",
"Rewriting artifact": "Überschreibe Artefakt",
"Rewrote artifact": "Artefakt überschrieben",
"Getting artifact": "Hole Artefakt",
"Got artifact": "Artefakt geholt",
"Deleting artifact": "Lösche Artefakt",
"Deleted artifact": "Artefakt gelöscht",
"Getting logs": "Hole Logs",
"Got logs": "Logs geholt",
"An error occurred": "Ein Fehler ist aufgetreten",
"Copy logs": "Logs kopieren",
"Autoscroll enabled": "Automatisches Scrollen aktiviert",
"Autoscroll disabled": "Automatisches Scrollen deaktiviert",
Processing: "Verarbeitung",
Create: "Erstellen",
Rewrite: "Überschreiben",
Get: "Abrufen",
"Get logs": "Logs abrufen",
"Show artifacts": "Artefakte anzeigen",
"Close artifacts": "Artefakte schließen",
Artifacts: "Artefakte",
"Copy HTML": "HTML kopieren",
"Download HTML": "HTML herunterladen",
"Reload HTML": "HTML neu laden",
"Copy SVG": "SVG kopieren",
"Download SVG": "SVG herunterladen",
"Copy Markdown": "Markdown kopieren",
"Download Markdown": "Markdown herunterladen",
Download: "Herunterladen",
"No logs for {filename}": "Keine Logs für {filename}",
"API Keys Settings": "API-Schlüssel Einstellungen",
Settings: "Einstellungen",
"API Keys": "API-Schlüssel",
Proxy: "Proxy",
"Use CORS Proxy": "CORS-Proxy verwenden",
"Proxy URL": "Proxy-URL",
"Format: The proxy must accept requests as <proxy-url>/?url=<target-url>":
"Format: Der Proxy muss Anfragen als <proxy-url>/?url=<ziel-url> akzeptieren",
"Settings are stored locally in your browser": "Einstellungen werden lokal in Ihrem Browser gespeichert",
Clear: "Löschen",
"API Key Required": "API-Schlüssel erforderlich",
"Enter your API key for {provider}": "Geben Sie Ihren API-Schlüssel für {provider} ein",
"Allows browser-based apps to bypass CORS restrictions when calling LLM providers. Required for Z-AI and Anthropic with OAuth token.":
"Ermöglicht browserbasierten Anwendungen, CORS-Einschränkungen beim Aufruf von LLM-Anbietern zu umgehen. Erforderlich für Z-AI und Anthropic mit OAuth-Token.",
Off: "Aus",
Minimal: "Minimal",
Low: "Niedrig",
Medium: "Mittel",
High: "Hoch",
"Storage Permission Required": "Speicherberechtigung erforderlich",
"This app needs persistent storage to save your conversations":
"Diese App benötigt dauerhaften Speicher, um Ihre Konversationen zu speichern",
"Why is this needed?": "Warum wird das benötigt?",
"Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.":
"Ohne dauerhaften Speicher kann Ihr Browser gespeicherte Konversationen löschen, wenn Speicherplatz benötigt wird. Diese Berechtigung stellt sicher, dass Ihr Chatverlauf erhalten bleibt.",
"What this means:": "Was das bedeutet:",
"Your conversations will be saved locally in your browser":
"Ihre Konversationen werden lokal in Ihrem Browser gespeichert",
"Data will not be deleted automatically to free up space":
"Daten werden nicht automatisch gelöscht, um Speicherplatz freizugeben",
"You can still manually clear data at any time": "Sie können Daten jederzeit manuell löschen",
"No data is sent to external servers": "Keine Daten werden an externe Server gesendet",
"Continue Anyway": "Trotzdem fortfahren",
"Requesting...": "Anfrage läuft...",
"Grant Permission": "Berechtigung erteilen",
Sessions: "Sitzungen",
"Load a previous conversation": "Frühere Konversation laden",
"No sessions yet": "Noch keine Sitzungen",
"Delete this session?": "Diese Sitzung löschen?",
Today: "Heute",
Yesterday: "Gestern",
"{days} days ago": "vor {days} Tagen",
messages: "Nachrichten",
tokens: "Tokens",
Delete: "Löschen",
"Drop files here": "Dateien hier ablegen",
"Command failed:": "Befehl fehlgeschlagen:",
// Providers & Models
"Providers & Models": "Anbieter & Modelle",
"Cloud Providers": "Cloud-Anbieter",
"Cloud LLM providers with predefined models. API keys are stored locally in your browser.":
"Cloud-LLM-Anbieter mit vordefinierten Modellen. API-Schlüssel werden lokal in Ihrem Browser gespeichert.",
"Custom Providers": "Benutzerdefinierte Anbieter",
"User-configured servers with auto-discovered or manually defined models.":
"Benutzerkonfigurierte Server mit automatisch erkannten oder manuell definierten Modellen.",
"Add Provider": "Anbieter hinzufügen",
"No custom providers configured. Click 'Add Provider' to get started.":
"Keine benutzerdefinierten Anbieter konfiguriert. Klicken Sie auf 'Anbieter hinzufügen', um zu beginnen.",
"auto-discovered": "automatisch erkannt",
Refresh: "Aktualisieren",
Edit: "Bearbeiten",
"Are you sure you want to delete this provider?": "Sind Sie sicher, dass Sie diesen Anbieter löschen möchten?",
"Edit Provider": "Anbieter bearbeiten",
"Provider Name": "Anbietername",
"e.g., My Ollama Server": "z.B. Mein Ollama Server",
"Provider Type": "Anbietertyp",
"Base URL": "Basis-URL",
"e.g., http://localhost:11434": "z.B. http://localhost:11434",
"API Key (Optional)": "API-Schlüssel (Optional)",
"Leave empty if not required": "Leer lassen, falls nicht erforderlich",
"Test Connection": "Verbindung testen",
Discovered: "Erkannt",
Models: "Modelle",
models: "Modelle",
and: "und",
more: "mehr",
"For manual provider types, add models after saving the provider.":
"Für manuelle Anbietertypen fügen Sie Modelle nach dem Speichern des Anbieters hinzu.",
"Please fill in all required fields": "Bitte füllen Sie alle erforderlichen Felder aus",
"Failed to save provider": "Fehler beim Speichern des Anbieters",
"OpenAI Completions Compatible": "OpenAI Completions Kompatibel",
"OpenAI Responses Compatible": "OpenAI Responses Kompatibel",
"Anthropic Messages Compatible": "Anthropic Messages Kompatibel",
"Checking...": "Überprüfe...",
Disconnected: "Getrennt",
},
};
setTranslations(translations);
export * from "@mariozechner/mini-lit/dist/i18n.js";
@@ -0,0 +1,277 @@
import { LMStudioClient } from "@lmstudio/sdk";
import type { Model } from "@mariozechner/pi-ai";
import { Ollama } from "ollama/browser";
/**
* Discover models from an Ollama server.
* @param baseUrl - Base URL of the Ollama server (e.g., "http://localhost:11434")
* @param apiKey - Optional API key (currently unused by Ollama)
* @returns Array of discovered models
*/
export async function discoverOllamaModels(baseUrl: string, _apiKey?: string): Promise<Model<any>[]> {
try {
// Create Ollama client
const ollama = new Ollama({ host: baseUrl });
// Get list of available models
const { models } = await ollama.list();
// Fetch details for each model and convert to Model format
const ollamaModelPromises: Promise<Model<any> | null>[] = models.map(async (model: any) => {
try {
// Get model details
const details = await ollama.show({
model: model.name,
});
// Check capabilities - filter out models that don't support tools
const capabilities: string[] = (details as any).capabilities || [];
if (!capabilities.includes("tools")) {
console.debug(`Skipping model ${model.name}: does not support tools`);
return null;
}
// Extract model info
const modelInfo: any = details.model_info || {};
// Get context window size - look for architecture-specific keys
const architecture = modelInfo["general.architecture"] || "";
const contextKey = `${architecture}.context_length`;
const contextWindow = parseInt(modelInfo[contextKey] || "8192", 10);
// Ollama caps max tokens at 10x context length
const maxTokens = contextWindow * 10;
// Ollama only supports completions API
const ollamaModel: Model<any> = {
id: model.name,
name: model.name,
api: "openai-completions" as any,
provider: "", // Will be set by caller
baseUrl: `${baseUrl}/v1`,
reasoning: capabilities.includes("thinking"),
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: contextWindow,
maxTokens: maxTokens,
};
return ollamaModel;
} catch (err) {
console.error(`Failed to fetch details for model ${model.name}:`, err);
return null;
}
});
const results = await Promise.all(ollamaModelPromises);
return results.filter((m): m is Model<any> => m !== null);
} catch (err) {
console.error("Failed to discover Ollama models:", err);
throw new Error(`Ollama discovery failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* Discover models from a llama.cpp server via OpenAI-compatible /v1/models endpoint.
* @param baseUrl - Base URL of the llama.cpp server (e.g., "http://localhost:8080")
* @param apiKey - Optional API key
* @returns Array of discovered models
*/
export async function discoverLlamaCppModels(baseUrl: string, apiKey?: string): Promise<Model<any>[]> {
try {
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
const response = await fetch(`${baseUrl}/v1/models`, {
method: "GET",
headers,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (!data.data || !Array.isArray(data.data)) {
throw new Error("Invalid response format from llama.cpp server");
}
return data.data.map((model: any) => {
// llama.cpp doesn't always provide context window info
const contextWindow = model.context_length || 8192;
const maxTokens = model.max_tokens || 4096;
const llamaModel: Model<any> = {
id: model.id,
name: model.id,
api: "openai-completions" as any,
provider: "", // Will be set by caller
baseUrl: `${baseUrl}/v1`,
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: contextWindow,
maxTokens: maxTokens,
};
return llamaModel;
});
} catch (err) {
console.error("Failed to discover llama.cpp models:", err);
throw new Error(`llama.cpp discovery failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* Discover models from a vLLM server via OpenAI-compatible /v1/models endpoint.
* @param baseUrl - Base URL of the vLLM server (e.g., "http://localhost:8000")
* @param apiKey - Optional API key
* @returns Array of discovered models
*/
export async function discoverVLLMModels(baseUrl: string, apiKey?: string): Promise<Model<any>[]> {
try {
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
const response = await fetch(`${baseUrl}/v1/models`, {
method: "GET",
headers,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (!data.data || !Array.isArray(data.data)) {
throw new Error("Invalid response format from vLLM server");
}
return data.data.map((model: any) => {
// vLLM provides max_model_len which is the context window
const contextWindow = model.max_model_len || 8192;
const maxTokens = Math.min(contextWindow, 4096); // Cap max tokens
const vllmModel: Model<any> = {
id: model.id,
name: model.id,
api: "openai-completions" as any,
provider: "", // Will be set by caller
baseUrl: `${baseUrl}/v1`,
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: contextWindow,
maxTokens: maxTokens,
};
return vllmModel;
});
} catch (err) {
console.error("Failed to discover vLLM models:", err);
throw new Error(`vLLM discovery failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* Discover models from an LM Studio server using the LM Studio SDK.
* @param baseUrl - Base URL of the LM Studio server (e.g., "http://localhost:1234")
* @param apiKey - Optional API key (unused for LM Studio SDK)
* @returns Array of discovered models
*/
export async function discoverLMStudioModels(baseUrl: string, _apiKey?: string): Promise<Model<any>[]> {
try {
// Extract host and port from baseUrl
const url = new URL(baseUrl);
const port = url.port ? parseInt(url.port, 10) : 1234;
// Create LM Studio client
const client = new LMStudioClient({ baseUrl: `ws://${url.hostname}:${port}` });
// List all downloaded models
const models = await client.system.listDownloadedModels();
// Filter to only LLM models and map to our Model format
return models
.filter((model) => model.type === "llm")
.map((model) => {
const contextWindow = model.maxContextLength;
// Use 10x context length like Ollama does
const maxTokens = contextWindow;
const lmStudioModel: Model<any> = {
id: model.path,
name: model.displayName || model.path,
api: "openai-completions" as any,
provider: "", // Will be set by caller
baseUrl: `${baseUrl}/v1`,
reasoning: model.trainedForToolUse || false,
input: model.vision ? ["text", "image"] : ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: contextWindow,
maxTokens: maxTokens,
};
return lmStudioModel;
});
} catch (err) {
console.error("Failed to discover LM Studio models:", err);
throw new Error(`LM Studio discovery failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* Convenience function to discover models based on provider type.
* @param type - Provider type
* @param baseUrl - Base URL of the server
* @param apiKey - Optional API key
* @returns Array of discovered models
*/
export async function discoverModels(
type: "ollama" | "llama.cpp" | "vllm" | "lmstudio",
baseUrl: string,
apiKey?: string,
): Promise<Model<any>[]> {
switch (type) {
case "ollama":
return discoverOllamaModels(baseUrl, apiKey);
case "llama.cpp":
return discoverLlamaCppModels(baseUrl, apiKey);
case "vllm":
return discoverVLLMModels(baseUrl, apiKey);
case "lmstudio":
return discoverLMStudioModels(baseUrl, apiKey);
}
}
@@ -0,0 +1,139 @@
import type { Api, Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai";
import { streamSimple } from "@mariozechner/pi-ai";
/**
* Centralized proxy decision logic.
*
* Determines whether to use a CORS proxy for LLM API requests based on:
* - Provider name
* - API key pattern (for providers where it matters)
*/
/**
* Check if a provider/API key combination requires a CORS proxy.
*
* @param provider - Provider name (e.g., "anthropic", "openai", "zai")
* @param apiKey - API key for the provider
* @returns true if proxy is required, false otherwise
*/
export function shouldUseProxyForProvider(provider: string, apiKey: string): boolean {
switch (provider.toLowerCase()) {
case "zai":
// Z-AI always requires proxy
return true;
case "anthropic":
// Anthropic OAuth tokens (sk-ant-oat-*) require proxy
// Regular API keys (sk-ant-api-*) do NOT require proxy
return apiKey.startsWith("sk-ant-oat") || apiKey.startsWith("{");
case "openai-codex":
// Codex uses chatgpt.com/backend-api which has no CORS
return true;
// These providers work without proxy
case "openai":
case "google":
case "groq":
case "openrouter":
case "cerebras":
case "xai":
case "ollama":
case "lmstudio":
case "github-copilot":
return false;
// Unknown providers - assume no proxy needed
// This allows new providers to work by default
default:
return false;
}
}
/**
* Apply CORS proxy to a model's baseUrl if needed.
*
* @param model - The model to potentially proxy
* @param apiKey - API key for the provider
* @param proxyUrl - CORS proxy URL (e.g., "https://proxy.mariozechner.at/proxy")
* @returns Model with modified baseUrl if proxy is needed, otherwise original model
*/
export function applyProxyIfNeeded<T extends Api>(model: Model<T>, apiKey: string, proxyUrl?: string): Model<T> {
// If no proxy URL configured, return original model
if (!proxyUrl) {
return model;
}
// If model has no baseUrl, can't proxy it
if (!model.baseUrl) {
return model;
}
// Check if this provider/key needs proxy
if (!shouldUseProxyForProvider(model.provider, apiKey)) {
return model;
}
// Apply proxy to baseUrl
return {
...model,
baseUrl: `${proxyUrl}/?url=${encodeURIComponent(model.baseUrl)}`,
};
}
/**
* Check if an error is likely a CORS error.
*
* CORS errors in browsers typically manifest as:
* - TypeError with message "Failed to fetch"
* - NetworkError
*
* @param error - The error to check
* @returns true if error is likely a CORS error
*/
export function isCorsError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
// Check for common CORS error patterns
const message = error.message.toLowerCase();
// "Failed to fetch" is the standard CORS error in most browsers
if (error.name === "TypeError" && message.includes("failed to fetch")) {
return true;
}
// Some browsers report "NetworkError"
if (error.name === "NetworkError") {
return true;
}
// CORS-specific messages
if (message.includes("cors") || message.includes("cross-origin")) {
return true;
}
return false;
}
/**
* Create a streamFn that applies CORS proxy when needed.
* Reads proxy settings from storage on each call.
*
* @param getProxyUrl - Async function to get current proxy URL (or undefined if disabled)
* @returns A streamFn compatible with Agent's streamFn option
*/
export function createStreamFn(getProxyUrl: () => Promise<string | undefined>) {
return async (model: Model<any>, context: Context, options?: SimpleStreamOptions) => {
const apiKey = options?.apiKey;
const proxyUrl = await getProxyUrl();
if (!apiKey || !proxyUrl) {
return streamSimple(model, context, options);
}
const proxiedModel = applyProxyIfNeeded(model, apiKey, proxyUrl);
return streamSimple(proxiedModel, context, options);
};
}
File diff suppressed because it is too large. Load diff