feat: add cross-browser extension with AI reading assistant

- Create Pi Reader browser extension for Chrome/Firefox
- Chrome uses Side Panel API, Firefox uses Sidebar Action API
- Supports both browsers with separate manifests and unified codebase
- Built with mini-lit components and Tailwind CSS v4
- Features model selection dialog with Ollama support
- Hot reload development server watches both browser builds
- Add useDefineForClassFields: false to fix LitElement reactivity
This commit is contained in:
Mario Zechner 2025-10-01 04:33:56 +02:00
parent c1185c7b95
commit b67c10dfb1
33 changed files with 4453 additions and 1202 deletions

View file

@ -0,0 +1,13 @@
import { html, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("pi-chat-panel")
export class ChatPanel extends LitElement {
createRenderRoot() {
return this;
}
render() {
return html`<h1>Hello world</h1>`;
}
}

View file

@ -0,0 +1,112 @@
import { type BaseComponentProps, fc, html } from "@mariozechner/mini-lit";
import { type Ref, ref } from "lit/directives/ref.js";
import { i18n } from "./utils/i18n.js";
export type InputType = "text" | "email" | "password" | "number" | "url" | "tel" | "search";
export type InputSize = "sm" | "md" | "lg";
export interface InputProps extends BaseComponentProps {
type?: InputType;
size?: InputSize;
value?: string;
placeholder?: string;
label?: string;
error?: string;
disabled?: boolean;
required?: boolean;
name?: string;
autocomplete?: string;
min?: number;
max?: number;
step?: number;
inputRef?: Ref<HTMLInputElement>;
onInput?: (e: Event) => void;
onChange?: (e: Event) => void;
onKeyDown?: (e: KeyboardEvent) => void;
onKeyUp?: (e: KeyboardEvent) => void;
}
export const Input = fc<InputProps>(
({
type = "text",
size = "md",
value = "",
placeholder = "",
label = "",
error = "",
disabled = false,
required = false,
name = "",
autocomplete = "",
min,
max,
step,
inputRef,
onInput,
onChange,
onKeyDown,
onKeyUp,
className = "",
}) => {
const sizeClasses = {
sm: "h-8 px-3 py-1 text-sm",
md: "h-9 px-3 py-1 text-sm md:text-sm",
lg: "h-10 px-4 py-1 text-base",
};
const baseClasses =
"flex w-full min-w-0 rounded-md border bg-transparent text-foreground shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium";
const interactionClasses =
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground";
const focusClasses = "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]";
const darkClasses = "dark:bg-input/30";
const stateClasses = error
? "border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40"
: "border-input";
const disabledClasses = "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50";
const handleInput = (e: Event) => {
onInput?.(e);
};
const handleChange = (e: Event) => {
onChange?.(e);
};
return html`
<div class="flex flex-col gap-1.5 ${className}">
${
label
? html`
<label class="text-sm font-medium text-foreground">
${label} ${required ? html`<span class="text-destructive">${i18n("*")}</span>` : ""}
</label>
`
: ""
}
<input
type="${type}"
class="${baseClasses} ${
sizeClasses[size]
} ${interactionClasses} ${focusClasses} ${darkClasses} ${stateClasses} ${disabledClasses}"
.value=${value}
placeholder="${placeholder}"
?disabled=${disabled}
?required=${required}
?aria-invalid=${!!error}
name="${name}"
autocomplete="${autocomplete}"
min="${min ?? ""}"
max="${max ?? ""}"
step="${step ?? ""}"
@input=${handleInput}
@change=${handleChange}
@keydown=${onKeyDown}
@keyup=${onKeyUp}
${inputRef ? ref(inputRef) : ""}
/>
${error ? html`<span class="text-sm text-destructive">${error}</span>` : ""}
</div>
`;
},
);

View file

@ -0,0 +1,14 @@
/* Import Claude theme from mini-lit */
@import "@mariozechner/mini-lit/styles/themes/default.css";
/* Tell Tailwind to scan mini-lit components */
@source "../../../node_modules/@mariozechner/mini-lit/dist";
/* Import Tailwind */
/* biome-ignore lint/correctness/noInvalidPositionAtImportRule: fuck you */
@import "tailwindcss";
body {
font-size: 16px;
-webkit-font-smoothing: antialiased;
}

View file

@ -0,0 +1,23 @@
// Declare browser global for Firefox
declare const browser: any;
// Detect browser type
const isFirefox = typeof browser !== "undefined" && typeof browser.runtime !== "undefined";
const browserAPI = isFirefox ? browser : chrome;
// Open side panel/sidebar when extension icon is clicked
browserAPI.action.onClicked.addListener((tab: chrome.tabs.Tab) => {
if (isFirefox) {
// Firefox: Toggle the sidebar
if (typeof browser !== "undefined" && browser.sidebarAction) {
browser.sidebarAction.toggle();
}
} else {
// Chrome: Open the side panel
if (tab.id && chrome.sidePanel) {
chrome.sidePanel.open({ tabId: tab.id });
}
}
});
export {};

View file

@ -0,0 +1,55 @@
import { Dialog } from "@mariozechner/mini-lit/dist/Dialog.js";
import { LitElement, type TemplateResult } from "lit";
export abstract class DialogBase extends LitElement {
// Modal configuration - can be overridden by subclasses
protected modalWidth = "min(600px, 90vw)";
protected modalHeight = "min(600px, 80vh)";
private boundHandleKeyDown?: (e: KeyboardEvent) => void;
private previousFocus?: HTMLElement;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
open() {
// Store the currently focused element
this.previousFocus = document.activeElement as HTMLElement;
document.body.appendChild(this);
this.boundHandleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
this.close();
}
};
window.addEventListener("keydown", this.boundHandleKeyDown);
}
close() {
if (this.boundHandleKeyDown) {
window.removeEventListener("keydown", this.boundHandleKeyDown);
}
this.remove();
// Restore focus to the previously focused element
if (this.previousFocus?.focus) {
// Use requestAnimationFrame to ensure the dialog is fully removed first
requestAnimationFrame(() => {
this.previousFocus?.focus();
});
}
}
// Abstract method that subclasses must implement
protected abstract renderContent(): TemplateResult;
override render() {
return Dialog({
isOpen: true,
onClose: () => this.close(),
width: this.modalWidth,
height: this.modalHeight,
children: this.renderContent(),
});
}
}

View file

@ -0,0 +1,325 @@
import { Badge, Button, DialogHeader, html, icon, type TemplateResult } from "@mariozechner/mini-lit";
import type { Model } from "@mariozechner/pi-ai";
import { MODELS } from "@mariozechner/pi-ai/dist/models.generated.js";
import type { PropertyValues } from "lit";
import { customElement, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import { Brain, Image as ImageIcon } from "lucide";
import { Ollama } from "ollama/dist/browser.mjs";
import { Input } from "../Input.js";
import { formatModelCost } from "../utils/format.js";
import { i18n } from "../utils/i18n.js";
import { DialogBase } from "./DialogBase.js";
@customElement("agent-model-selector")
export class ModelSelector extends DialogBase {
@state() currentModel: Model<any> | null = null;
@state() searchQuery = "";
@state() filterThinking = false;
@state() filterVision = false;
@state() ollamaModels: Model<any>[] = [];
@state() ollamaError: string | null = null;
@state() selectedIndex = 0;
@state() private navigationMode: "mouse" | "keyboard" = "mouse";
private onSelectCallback?: (model: Model<any>) => void;
private scrollContainerRef = createRef<HTMLDivElement>();
private searchInputRef = createRef<HTMLInputElement>();
private lastMousePosition = { x: 0, y: 0 };
protected override modalWidth = "min(400px, 90vw)";
static async open(currentModel: Model<any> | null, onSelect: (model: Model<any>) => void) {
const selector = new ModelSelector();
selector.currentModel = currentModel;
selector.onSelectCallback = onSelect;
selector.open();
selector.fetchOllamaModels();
}
override async firstUpdated(changedProperties: PropertyValues): Promise<void> {
super.firstUpdated(changedProperties);
// Wait for dialog to be fully rendered
await this.updateComplete;
// Focus the search input when dialog opens
this.searchInputRef.value?.focus();
// Track actual mouse movement
this.addEventListener("mousemove", (e: MouseEvent) => {
// Check if mouse actually moved
if (e.clientX !== this.lastMousePosition.x || e.clientY !== this.lastMousePosition.y) {
this.lastMousePosition = { x: e.clientX, y: e.clientY };
// Only switch to mouse mode on actual mouse movement
if (this.navigationMode === "keyboard") {
this.navigationMode = "mouse";
// Update selection to the item under the mouse
const target = e.target as HTMLElement;
const modelItem = target.closest("[data-model-item]");
if (modelItem) {
const allItems = this.scrollContainerRef.value?.querySelectorAll("[data-model-item]");
if (allItems) {
const index = Array.from(allItems).indexOf(modelItem);
if (index !== -1) {
this.selectedIndex = index;
}
}
}
}
}
});
// Add global keyboard handler for the dialog
this.addEventListener("keydown", (e: KeyboardEvent) => {
// Get filtered models to know the bounds
const filteredModels = this.getFilteredModels();
if (e.key === "ArrowDown") {
e.preventDefault();
this.navigationMode = "keyboard";
this.selectedIndex = Math.min(this.selectedIndex + 1, filteredModels.length - 1);
this.scrollToSelected();
} else if (e.key === "ArrowUp") {
e.preventDefault();
this.navigationMode = "keyboard";
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.scrollToSelected();
} else if (e.key === "Enter") {
e.preventDefault();
if (filteredModels[this.selectedIndex]) {
this.handleSelect(filteredModels[this.selectedIndex].model);
}
}
});
}
private async fetchOllamaModels() {
try {
// Create Ollama client
const ollama = new Ollama({ host: "http://localhost:11434" });
// Get list of available models
const { models } = await ollama.list();
// Fetch details for each model and convert to Model format
const ollamaModelPromises: Promise<Model<any> | null>[] = models
.map(async (model) => {
try {
// Get model details
const details = await ollama.show({
model: model.name,
});
// Some Ollama servers don't report capabilities; don't filter on them
// Extract model info
const modelInfo: any = details.model_info || {};
// Get context window size - look for architecture-specific keys
const architecture = modelInfo["general.architecture"] || "";
const contextKey = `${architecture}.context_length`;
const contextWindow = parseInt(modelInfo[contextKey] || "8192", 10);
const maxTokens = 4096; // Default max output tokens
// Create Model object manually since ollama models aren't in MODELS constant
const ollamaModel: Model<any> = {
id: model.name,
name: model.name,
api: "openai-completions" as any,
provider: "ollama",
baseUrl: "http://localhost:11434/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: contextWindow,
maxTokens: maxTokens,
};
return ollamaModel;
} catch (err) {
console.error(`Failed to fetch details for model ${model.name}:`, err);
return null;
}
})
.filter((m) => m !== null);
const results = await Promise.all(ollamaModelPromises);
this.ollamaModels = results.filter((m): m is Model<any> => m !== null);
} catch (err) {
// Ollama not available or other error - silently ignore
console.debug("Ollama not available:", err);
this.ollamaError = err instanceof Error ? err.message : String(err);
}
}
private formatTokens(tokens: number): string {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(0)}M`;
if (tokens >= 1000) return `${(tokens / 1000).toFixed(0)}`;
return String(tokens);
}
private handleSelect(model: Model<any>) {
if (model) {
this.onSelectCallback?.(model);
this.close();
}
}
private getFilteredModels(): Array<{ provider: string; id: string; model: any }> {
// Collect all models from all providers
const allModels: Array<{ provider: string; id: string; model: any }> = [];
for (const [provider, providerData] of Object.entries(MODELS)) {
for (const [modelId, model] of Object.entries(providerData)) {
allModels.push({ provider, id: modelId, model });
}
}
// Add Ollama models
for (const ollamaModel of this.ollamaModels) {
allModels.push({
id: ollamaModel.id,
provider: "ollama",
model: ollamaModel,
});
}
// Filter models based on search and capability filters
let filteredModels = allModels;
// Apply search filter
if (this.searchQuery) {
filteredModels = filteredModels.filter(({ provider, id, model }) => {
const searchTokens = this.searchQuery.split(/\s+/).filter((t) => t);
const searchText = `${provider} ${id} ${model.name}`.toLowerCase();
return searchTokens.every((token) => searchText.includes(token));
});
}
// Apply capability filters
if (this.filterThinking) {
filteredModels = filteredModels.filter(({ model }) => model.reasoning);
}
if (this.filterVision) {
filteredModels = filteredModels.filter(({ model }) => model.input.includes("image"));
}
// Sort: current model first, then by provider
filteredModels.sort((a, b) => {
const aIsCurrent = this.currentModel?.id === a.model.id;
const bIsCurrent = this.currentModel?.id === b.model.id;
if (aIsCurrent && !bIsCurrent) return -1;
if (!aIsCurrent && bIsCurrent) return 1;
return a.provider.localeCompare(b.provider);
});
return filteredModels;
}
private scrollToSelected() {
requestAnimationFrame(() => {
const scrollContainer = this.scrollContainerRef.value;
const selectedElement = scrollContainer?.querySelectorAll("[data-model-item]")[
this.selectedIndex
] as HTMLElement;
if (selectedElement) {
selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
});
}
protected override renderContent(): TemplateResult {
const filteredModels = this.getFilteredModels();
return html`
<!-- Header and Search -->
<div class="p-6 pb-4 flex flex-col gap-4 border-b border-border flex-shrink-0">
${DialogHeader({ title: i18n("Select Model") })}
${Input({
placeholder: i18n("Search models..."),
value: this.searchQuery,
inputRef: this.searchInputRef,
onInput: (e: Event) => {
this.searchQuery = (e.target as HTMLInputElement).value;
this.selectedIndex = 0;
// Reset scroll position when search changes
if (this.scrollContainerRef.value) {
this.scrollContainerRef.value.scrollTop = 0;
}
},
})}
<div class="flex gap-2">
${Button({
variant: this.filterThinking ? "default" : "secondary",
size: "sm",
onClick: () => {
this.filterThinking = !this.filterThinking;
this.selectedIndex = 0;
if (this.scrollContainerRef.value) {
this.scrollContainerRef.value.scrollTop = 0;
}
},
className: "rounded-full",
children: html`<span class="inline-flex items-center gap-1">${icon(Brain, "sm")} ${i18n("Thinking")}</span>`,
})}
${Button({
variant: this.filterVision ? "default" : "secondary",
size: "sm",
onClick: () => {
this.filterVision = !this.filterVision;
this.selectedIndex = 0;
if (this.scrollContainerRef.value) {
this.scrollContainerRef.value.scrollTop = 0;
}
},
className: "rounded-full",
children: html`<span class="inline-flex items-center gap-1">${icon(ImageIcon, "sm")} ${i18n("Vision")}</span>`,
})}
</div>
</div>
<!-- Scrollable model list -->
<div class="flex-1 overflow-y-auto" ${ref(this.scrollContainerRef)}>
${filteredModels.map(({ provider, id, model }, index) => {
// Check if this is the current model by comparing IDs
const isCurrent = this.currentModel?.id === model.id;
const isSelected = index === this.selectedIndex;
return html`
<div
data-model-item
class="px-4 py-3 ${
this.navigationMode === "mouse" ? "hover:bg-muted" : ""
} cursor-pointer border-b border-border ${isSelected ? "bg-accent" : ""}"
@click=${() => this.handleSelect(model)}
@mouseenter=${() => {
// Only update selection in mouse mode
if (this.navigationMode === "mouse") {
this.selectedIndex = index;
}
}}
>
<div class="flex items-center justify-between gap-2 mb-1">
<div class="flex items-center gap-2 flex-1 min-w-0">
<span class="text-sm font-medium text-foreground truncate">${id}</span>
${isCurrent ? html`<span class="text-green-500">✓</span>` : ""}
</div>
${Badge(provider, "outline")}
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<div class="flex items-center gap-2">
<span class="${model.reasoning ? "" : "opacity-30"}">${icon(Brain, "sm")}</span>
<span class="${model.input.includes("image") ? "" : "opacity-30"}">${icon(ImageIcon, "sm")}</span>
<span>${this.formatTokens(model.contextWindow)}K/${this.formatTokens(model.maxTokens)}K</span>
</div>
<span>${formatModelCost(model.cost)}</span>
</div>
</div>
`;
})}
</div>
`;
}
}

View file

@ -0,0 +1,94 @@
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import { DialogContent, DialogFooter, DialogHeader } from "@mariozechner/mini-lit/dist/Dialog.js";
import { Input } from "@mariozechner/mini-lit/dist/Input.js";
import { html, type PropertyValues, type TemplateResult } from "lit";
import { customElement } from "lit/decorators/custom-element.js";
import { property } from "lit/decorators/property.js";
import { state } from "lit/decorators/state.js";
import { createRef } from "lit/directives/ref.js";
import { i18n } from "../utils/i18n.js";
import { DialogBase } from "./DialogBase.js";
@customElement("prompt-dialog")
export class PromptDialog extends DialogBase {
@property() headerTitle = "";
@property() message = "";
@property() defaultValue = "";
@property() isPassword = false;
@state() private inputValue = "";
private resolvePromise?: (value: string | null) => void;
private inputRef = createRef<HTMLInputElement>();
protected override modalWidth = "min(400px, 90vw)";
protected override modalHeight = "auto";
static async ask(title: string, message: string, defaultValue = "", isPassword = false): Promise<string | null> {
const dialog = new PromptDialog();
dialog.headerTitle = title;
dialog.message = message;
dialog.defaultValue = defaultValue;
dialog.isPassword = isPassword;
dialog.inputValue = defaultValue;
return new Promise((resolve) => {
dialog.resolvePromise = resolve;
dialog.open();
});
}
protected override firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
this.inputRef.value?.focus();
}
private handleConfirm() {
this.resolvePromise?.(this.inputValue);
this.close();
}
private handleCancel() {
this.resolvePromise?.(null);
this.close();
}
protected override renderContent(): TemplateResult {
return DialogContent({
children: html`
${DialogHeader({
title: this.headerTitle || i18n("Input Required"),
description: this.message,
})}
${Input({
type: this.isPassword ? "password" : "text",
value: this.inputValue,
className: "w-full",
inputRef: this.inputRef,
onInput: (e: Event) => {
this.inputValue = (e.target as HTMLInputElement).value;
},
onKeyDown: (e: KeyboardEvent) => {
if (e.key === "Enter") this.handleConfirm();
if (e.key === "Escape") this.handleCancel();
},
})}
${DialogFooter({
children: html`
${Button({
variant: "outline",
onClick: () => this.handleCancel(),
children: i18n("Cancel"),
})}
${Button({
variant: "default",
onClick: () => this.handleConfirm(),
children: i18n("Confirm"),
})}
`,
})}
`,
});
}
}
export default PromptDialog;

View file

@ -0,0 +1,31 @@
// Dev mode hot reload - check if we're in development
const connectWebSocket = () => {
try {
const ws = new WebSocket("ws://localhost:8765");
ws.onopen = () => {
console.log("[HotReload] Connected to dev server");
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "reload") {
console.log("[HotReload] Reloading extension...");
chrome.runtime.reload();
}
};
ws.onerror = () => {
console.log("[HotReload] WebSocket error");
// Silent fail - dev server might not be running
};
ws.onclose = () => {
// Reconnect after 2 seconds
setTimeout(connectWebSocket, 2000);
};
} catch (e) {
// Silent fail if WebSocket not available
}
};
connectWebSocket();

View file

@ -0,0 +1,11 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pi Reader Assistant</title>
<link rel="stylesheet" href="app.css" />
</head>
<body class="h-full w-full">
<script type="module" src="sidepanel.js"></script>
</body>
</html>

View file

@ -0,0 +1,59 @@
import { html, LitElement, render } from "lit";
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 { Settings } from "lucide";
import { ModelSelector } from "./dialogs/ModelSelector.js";
async function getDom() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab || !tab.id) return;
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => document.body.innerText,
});
}
@customElement("pi-chat-header")
export class Header extends LitElement {
createRenderRoot() {
return this;
}
async connectedCallback() {
super.connectedCallback();
const resp = await fetch("https://genai.mariozechner.at/api/health");
console.log(await resp.json());
}
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>
<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);
});
},
})}
</div>
`;
}
}
const app = html`
<div class="w-full h-full flex flex-col bg-background text-foreground">
<pi-chat-header></pi-chat-header>
<pi-chat-panel></pi-chat-panel>
</div>
`;
render(app, document.body);

View file

@ -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";
}

View file

@ -0,0 +1,46 @@
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;
}
}
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",
},
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",
},
};
setTranslations(translations);
export * from "@mariozechner/mini-lit/dist/i18n.js";