/* global React, ReactDOM */ const { useState, useEffect, useRef, useCallback } = React; /* ---------------------------------------------------------- Router ---------------------------------------------------------- */ function useRouter() { const [path, setPath] = useState(window.location.pathname); useEffect(() => { const handler = () => setPath(window.location.pathname); window.addEventListener("popstate", handler); return () => window.removeEventListener("popstate", handler); }, []); const navigate = (to) => { window.history.pushState(null, "", to); setPath(to); }; return { path, navigate }; } /* ---------------------------------------------------------- Shared bits ---------------------------------------------------------- */ const Wordmark = () =>
; const XMark = ({ size = 32, radius }) => ( ); const Icon = { Sun: () => ( ), Moon: () => ( ), Discord: () => ( ), Plus: () => ( ), Send: () => ( ), Check: () => ( ), Lock: () => ( ), Zap: () => ( ), Cpu: () => ( ), History: () => ( ), Close: () => ( ), Sparkle: () => ( ), Gear: () => ( ), Palette: () => ( ), Globe: () => ( ), Brain: () => ( ), Robot: () => ( ), Crown: () => ( ), Code: () => ( ), Search: () => ( ), Web: () => ( ), Doc: () => ( ), Menu: () => ( ), ArrowRight: () => ( ), Shield: () => ( ), Server: () => ( ), Speed: () => ( ), Trash: () => ( ), Pencil: () => ( ), }; /* ---------------------------------------------------------- i18n + config ---------------------------------------------------------- */ const STRINGS = { de: { pitch: "Dein privater KI-Assistent — 100 % lokal, keine Cloud, keine Daten verlassen dein Netzwerk.", f_free: "5 freie Prompts", f_pro: "Unlimited mit Pro", f_stream: "Streaming-Antworten", f_history: "Chat-Verlauf", discord_login: "Mit Discord anmelden", or_terminal: "oder via Terminal (Xera Pro)", copy: "Kopieren", copied: "Kopiert", footer: "END-TO-END LOKAL", no_telemetry: "NULL TELEMETRIE", new_chat: "Neuer Chat", history: "Verlauf", search_history: "Chats durchsuchen...", today: "Heute", yesterday: "Gestern", last_7_days: "Letzte 7 Tage", older: "Aelter", delete_chat: "Chat loeschen", rename_chat: "Umbenennen", connected: "lokal verbunden", sign_out: "Abmelden", welcome_title: "Willkommen bei Xera AI", welcome_sub: "Stell mir eine Frage oder gib mir eine Aufgabe. Alles laeuft lokal — niemand schaut zu.", placeholder: "Nachricht an Xera AI...", disclaimer_local: "LOKAL", disclaimer_text: "KEINE DATEN VERLASSEN DEIN NETZWERK", you: "Du", prompts: "Prompts", unlimited: "unlimited", limit_title: "Prompt-Limit erreicht", limit_body_1: "Du hast deine 5 freien Prompts aufgebraucht.", limit_body_2: "Tritt unserem Discord bei und erhalte die Xera Pro Rolle fuer unlimitierten Zugang.", limit_body_guest: "Melde dich mit Discord an, um weiterzuchatten.", join_discord: "Discord beitreten", settings: "Einstellungen", sec_appearance: "Erscheinungsbild", sec_language: "Sprache", sec_model: "Modell", sec_agents: "Agents", sec_subscription: "Abonnement", sec_terminal: "Terminal", terminal_hint: "CLI-Zugang zu Xera AI (nur Xera Pro).", theme: "Theme", theme_dark: "Dunkel", theme_light: "Hell", accent: "Akzentfarbe", language_label: "Sprache der Oberflaeche", model_active: "Aktives Modell", model_hint: "Lokal geladenes Sprachmodell. Groesser = bessere Antworten, mehr RAM.", agents_hint: "Aktiviere Agents, um Xera AI mit speziellen Faehigkeiten zu erweitern.", plan_free: "Free", plan_pro: "Pro", free_p1: "5 Prompts insgesamt", free_p2: "Basis-Modell", free_p3: "Chat-Verlauf", pro_p1: "Unlimitierte Prompts", pro_p2: "Alle Modelle", pro_p3: "Alle Agents", pro_p4: "Prioritaet bei Updates", current: "AKTIV", upgrade: "UPGRADE", err_server: "Fehler: Server nicht erreichbar.", err_timeout: "Zeitueberschreitung — bitte erneut versuchen.", err_connection: "Verbindung fehlgeschlagen — bitte pruefen ob der Server erreichbar ist.", guest_welcome: "Teste Xera AI — 5 Prompts gratis, ohne Anmeldung.", try_free: "Kostenlos testen", login: "Anmelden", landing_hero: "Dein privater KI-Assistent", landing_sub: "100 % lokal. Keine Cloud. Keine Daten verlassen dein Netzwerk. Powered by deinem eigenen GPU-Server.", landing_feat1_title: "Komplett lokal", landing_feat1_desc: "Laeuft auf deiner eigenen Hardware — keine externen APIs, keine Abhaengigkeiten.", landing_feat2_title: "Privat & sicher", landing_feat2_desc: "Deine Daten bleiben in deinem Netzwerk. Kein Tracking, keine Telemetrie.", landing_feat3_title: "Blitzschnell", landing_feat3_desc: "36.4 Tokens/s Streaming auf deinem GPU-Cluster. Antworten in Echtzeit.", landing_stat1: "Tokens/s", landing_stat2: "Lokal", landing_stat3: "Daten gesendet", landing_about_title: "Built by", landing_about_text: "Lernender Informatiker EFZ Plattformentwicklung.", landing_demo_title: "Erlebe es selbst", landing_demo_user: "Erklaere mir Docker in 2 Saetzen.", landing_demo_ai: "Docker packt Anwendungen in isolierte Container — sie laufen ueberall gleich, egal ob auf deinem Laptop oder Server. Statt einer ganzen VM teilen sich Container den Host-Kernel und starten in Sekunden.", creator: "Built by", }, en: { pitch: "Your private AI assistant — 100 % local, no cloud, no data leaves your network.", f_free: "5 free prompts", f_pro: "Unlimited with Pro", f_stream: "Streaming responses", f_history: "Chat history", discord_login: "Sign in with Discord", or_terminal: "or via terminal (Xera Pro)", copy: "Copy", copied: "Copied", footer: "END-TO-END LOCAL", no_telemetry: "ZERO TELEMETRY", new_chat: "New chat", history: "History", search_history: "Search chats...", today: "Today", yesterday: "Yesterday", last_7_days: "Last 7 days", older: "Older", delete_chat: "Delete chat", rename_chat: "Rename", connected: "local connected", sign_out: "Sign out", welcome_title: "Welcome to Xera AI", welcome_sub: "Ask me a question or give me a task. Everything runs locally — no one's watching.", placeholder: "Message Xera AI...", disclaimer_local: "LOCAL", disclaimer_text: "NO DATA LEAVES YOUR NETWORK", you: "You", prompts: "prompts", unlimited: "unlimited", limit_title: "Prompt limit reached", limit_body_1: "You've used your 5 free prompts.", limit_body_2: "Join our Discord and grab the Xera Pro role for unlimited access.", limit_body_guest: "Sign in with Discord to continue chatting.", join_discord: "Join Discord", settings: "Settings", sec_appearance: "Appearance", sec_language: "Language", sec_model: "Model", sec_agents: "Agents", sec_subscription: "Subscription", sec_terminal: "Terminal", terminal_hint: "CLI access to Xera AI (Xera Pro only).", theme: "Theme", theme_dark: "Dark", theme_light: "Light", accent: "Accent color", language_label: "Interface language", model_active: "Active model", model_hint: "Locally loaded language model. Bigger = better answers, more RAM.", agents_hint: "Enable agents to extend Xera AI with specialised capabilities.", plan_free: "Free", plan_pro: "Pro", free_p1: "5 prompts total", free_p2: "Base model", free_p3: "Chat history", pro_p1: "Unlimited prompts", pro_p2: "All models", pro_p3: "All agents", pro_p4: "Priority updates", current: "ACTIVE", upgrade: "UPGRADE", err_server: "Error: Server not reachable.", err_timeout: "Timeout — please try again.", err_connection: "Connection failed — please check if the server is reachable.", guest_welcome: "Try Xera AI — 5 free prompts, no sign-up required.", try_free: "Try for free", login: "Sign in", landing_hero: "Your private AI assistant", landing_sub: "100 % local. No cloud. No data leaves your network. Powered by your own GPU server.", landing_feat1_title: "Fully local", landing_feat1_desc: "Runs on your own hardware — no external APIs, no dependencies.", landing_feat2_title: "Private & secure", landing_feat2_desc: "Your data stays in your network. No tracking, no telemetry.", landing_feat3_title: "Lightning fast", landing_feat3_desc: "36.4 tokens/s streaming on your GPU cluster. Real-time responses.", landing_stat1: "Tokens/s", landing_stat2: "Local", landing_stat3: "Data sent", landing_about_title: "Built by", landing_about_text: "Apprentice IT specialist EFZ platform development.", landing_demo_title: "See it in action", landing_demo_user: "Explain Docker in 2 sentences.", landing_demo_ai: "Docker packages applications into isolated containers — they run the same everywhere, whether on your laptop or server. Instead of a full VM, containers share the host kernel and start in seconds.", creator: "Built by", }, }; const MODELS = [ { id: "xera-35b", name: "Xera Local 35B", size: "35B", hint_de: "Standard — beste Allround-Performance", hint_en: "Standard — best all-round performance" }, { id: "xera-13b", name: "Xera Local 13B", size: "13B", hint_de: "Schneller, weniger RAM", hint_en: "Faster, less RAM" }, { id: "xera-7b", name: "Xera Local 7B", size: "7B", hint_de: "Minimal — laeuft auf jedem Laptop", hint_en: "Minimal — runs on any laptop" }, { id: "xera-code-22b", name: "Xera Code 22B", size: "22B", hint_de: "Spezialisiert auf Code & Tools", hint_en: "Specialised for code & tools" }, ]; const AGENTS = [ { id: "code", name_de: "Code Agent", name_en: "Code Agent", hint_de: "Liest, schreibt und debuggt Code lokal", hint_en: "Reads, writes and debugs code locally", IconKey: "Code" }, { id: "search", name_de: "Search Agent", name_en: "Search Agent", hint_de: "Durchsucht deine lokalen Dateien & Notizen", hint_en: "Searches your local files & notes", IconKey: "Search" }, { id: "web", name_de: "Web Agent", name_en: "Web Agent", hint_de: "Holt Infos aus dem Web (opt-in)", hint_en: "Pulls info from the web (opt-in)", IconKey: "Web" }, { id: "docs", name_de: "Docs Agent", name_en: "Docs Agent", hint_de: "Liest PDFs, Markdown, Notion-Exports", hint_en: "Reads PDFs, Markdown, Notion exports", IconKey: "Doc" }, ]; const SUGGESTIONS = [ { label: "Code", text: "Schreib mir ein Python-Script, das alle .log Dateien aelter als 7 Tage loescht." }, { label: "Erklaer", text: "Erklaer den Unterschied zwischen Promises und async/await." }, { label: "Debug", text: "Warum bekomme ich 'CORS error: No Access-Control-Allow-Origin' beim fetch?" }, { label: "Schreib", text: "Formuliere eine hoefliche E-Mail-Absage auf eine Bewerbung." }, ]; /* ---------------------------------------------------------- Theme + topbar ---------------------------------------------------------- */ function ThemeToggle({ theme, setTheme }) { return (
); } function SettingsButton({ onClick }) { return ( ); } /* ---------------------------------------------------------- Settings Modal ---------------------------------------------------------- */ function SettingsModal({ open, onClose, config, setConfig, user, t }) { const [tab, setTab] = useState("appearance"); const [copied, setCopied] = useState(false); if (!open) return null; const isPro = user && (user.is_pro || user.is_admin); const isGuest = !user || user.is_guest; const stop = (e) => e.stopPropagation(); const toggleAgent = (id) => { setConfig(c => ({ ...c, agents: c.agents.includes(id) ? c.agents.filter(a => a !== id) : [...c.agents, id], })); }; const copyCmd = () => { navigator.clipboard?.writeText("ssh cli@xera-app.com").catch(() => {}); setCopied(true); setTimeout(() => setCopied(false), 1600); }; const tabs = [ { id: "appearance", label: t.sec_appearance, Icon: Icon.Palette }, { id: "language", label: t.sec_language, Icon: Icon.Globe }, { id: "model", label: t.sec_model, Icon: Icon.Brain }, { id: "agents", label: t.sec_agents, Icon: Icon.Robot }, ...(!isGuest && isPro ? [{ id: "terminal", label: t.sec_terminal, Icon: Icon.Code }] : []), { id: "subscription", label: t.sec_subscription, Icon: Icon.Crown }, ]; return (

{tabs.find(x => x.id === tab)?.label}

{tab === "appearance" && ( <>
{t.theme}
{t.accent}
{[ { h: 282, n: "Violet" }, { h: 260, n: "Indigo" }, { h: 200, n: "Cyan" }, { h: 320, n: "Pink" }, ].map(opt => ( ))}
)} {tab === "language" && (
{t.language_label}
)} {tab === "model" && ( <>
{config.language === "en" ? "Coming soon" : "Kommt bald"}
{config.language === "en" ? "Multi-model selection is planned for a future update." : "Multi-Modell-Auswahl ist fuer ein zukuenftiges Update geplant."}
{MODELS.map(m => (
{m.name} {m.size}
{config.language === "en" ? m.hint_en : m.hint_de}
))}
)} {tab === "agents" && ( <>
{t.agents_hint}
{AGENTS.map(a => { const on = config.agents.includes(a.id); const I = Icon[a.IconKey]; return (
toggleAgent(a.id)}>
{config.language === "en" ? a.name_en : a.name_de}
{config.language === "en" ? a.hint_en : a.hint_de}
); })}
)} {tab === "terminal" && isPro && ( <>
{t.terminal_hint}
$ ssh cli@xera-app.com
)} {tab === "subscription" && (
{[ { id: "free", name: t.plan_free, price: config.language === "en" ? "Free" : "Gratis", per: "", feats: [t.free_p1, t.free_p2, t.free_p3] }, { id: "pro", name: t.plan_pro, price: "Discord", per: " role", feats: [t.pro_p1, t.pro_p2, t.pro_p3, t.pro_p4] }, ].map(p => { const isCurrent = (p.id === "pro" && config.isPro) || (p.id === "free" && !config.isPro); return ( ); })}
)}
); } /* ---------------------------------------------------------- Demo chat preview (typewriter effect) ---------------------------------------------------------- */ function DemoPreview({ t }) { const [aiText, setAiText] = useState(""); const [done, setDone] = useState(false); const fullText = t.landing_demo_ai; const intervalRef = useRef(null); useEffect(() => { let i = 0; setAiText(""); setDone(false); const delay = setTimeout(() => { intervalRef.current = setInterval(() => { i++; setAiText(fullText.slice(0, i)); if (i >= fullText.length) { clearInterval(intervalRef.current); setDone(true); } }, 18); }, 1200); return () => { clearTimeout(delay); clearInterval(intervalRef.current); }; }, [fullText]); return (

{t.landing_demo_title}

xera-app.com
{t.landing_demo_user}
{aiText} {!done && }
); } /* ---------------------------------------------------------- Landing page — xera-app.com/ ---------------------------------------------------------- */ function LandingPage({ navigate, theme, setTheme, openSettings, t }) { return (
{t.footer}

{t.landing_hero}

{t.landing_sub}

36.4 {t.landing_stat1}
100% {t.landing_stat2}
0 {t.landing_stat3}

{t.landing_feat1_title}

{t.landing_feat1_desc}

{t.landing_feat2_title}

{t.landing_feat2_desc}

{t.landing_feat3_title}

{t.landing_feat3_desc}

MR

{t.landing_about_title} Miguel Rodriguez

{t.landing_about_text}

XERA AI v1.0 {t.no_telemetry}
); } /* ---------------------------------------------------------- Login page — xera-app.com/login ---------------------------------------------------------- */ function LoginPage({ navigate, theme, setTheme, openSettings, t }) { return (

{t.pitch}

{t.f_free}
{t.f_pro}
{t.f_stream}
{t.f_history}
{t.discord_login}
{t.or_terminal}

 {t.footer} {t.no_telemetry}

); } /* ---------------------------------------------------------- Markdown renderer ---------------------------------------------------------- */ function renderMarkdown(text) { const blocks = []; const parts = text.split(/```/); parts.forEach((part, idx) => { if (idx % 2 === 1) { const firstLineEnd = part.indexOf("\n"); const code = firstLineEnd >= 0 ? part.slice(firstLineEnd + 1) : part; blocks.push(
{code}
); } else { const paragraphs = part.split(/\n\n+/); paragraphs.forEach((p, pi) => { if (!p.trim()) return; if (p.split("\n").every(l => l.startsWith(">"))) { const cleaned = p.split("\n").map(l => l.replace(/^>\s?/, "")).join("\n"); blocks.push(
{cleaned}
); return; } blocks.push(

{renderInline(p)}

); }); } }); return blocks; } function renderInline(text) { const out = []; const re = /(`[^`]+`|\*\*[^*]+\*\*)/g; let last = 0, m, k = 0; while ((m = re.exec(text)) !== null) { if (m.index > last) out.push(text.slice(last, m.index)); const t = m[0]; if (t.startsWith("`")) out.push({t.slice(1, -1)}); else out.push({t.slice(2, -2)}); last = m.index + t.length; } if (last < text.length) out.push(text.slice(last)); return out; } /* ---------------------------------------------------------- Chat page — xera-app.com/c and xera-app.com/c/:id ---------------------------------------------------------- */ function ChatPage({ user, setUser, navigate, theme, setTheme, openSettings, config, setConfig, t }) { const [sessions, setSessions] = useState([]); const [activeId, setActiveId] = useState(null); const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [streaming, setStreaming] = useState(false); const [streamBuf, setStreamBuf] = useState(""); const [showLimit, setShowLimit] = useState(false); const [promptCount, setPromptCount] = useState(user?.prompt_count || 0); const [sidebarOpen, setSidebarOpen] = useState(false); const [searchQ, setSearchQ] = useState(""); const [editingId, setEditingId] = useState(null); const [editTitle, setEditTitle] = useState(""); const FREE_LIMIT = user?.limit || 5; const isPro = user?.is_pro || user?.is_admin; const isGuest = !user || user.is_guest; const messagesRef = useRef(null); const taRef = useRef(null); const activeModel = MODELS.find(m => m.id === config.model) || MODELS[0]; const isEmpty = !messages.length && !streaming; const userInitials = (user?.username || "G").slice(0, 2).toUpperCase(); // Parse session ID from URL useEffect(() => { const match = window.location.pathname.match(/^\/c\/(\d+)$/); if (match) setActiveId(parseInt(match[1])); }, []); useEffect(() => { if (isGuest) return; fetch("/api/sessions").then(r => r.json()).then(data => { if (Array.isArray(data)) setSessions(data); }).catch(() => {}); }, [isGuest]); useEffect(() => { if (!activeId) { setMessages([]); return; } if (isGuest) return; fetch(`/api/sessions/${activeId}/messages`).then(r => r.json()).then(data => { if (Array.isArray(data)) { setMessages(data.map(m => ({ role: m.role, content: m.content }))); } }).catch(() => {}); }, [activeId, isGuest]); useEffect(() => { const ta = taRef.current; if (!ta) return; ta.style.height = "auto"; ta.style.height = Math.min(ta.scrollHeight, 200) + "px"; }, [input]); useEffect(() => { const el = messagesRef.current; if (el) el.scrollTop = el.scrollHeight; }, [messages.length, streamBuf, streaming]); const newChat = () => { setActiveId(null); setMessages([]); navigate("/c"); setSidebarOpen(false); }; const openSession = (id) => { setActiveId(id); navigate(`/c/${id}`); setSidebarOpen(false); }; const deleteSession = (id, e) => { e.stopPropagation(); fetch(`/api/sessions/${id}`, { method: "DELETE" }).then(() => { setSessions(prev => prev.filter(s => s.id !== id)); if (activeId === id) newChat(); }); }; const startRename = (s, e) => { e.stopPropagation(); setEditingId(s.id); setEditTitle(s.title); }; const commitRename = (id) => { const title = editTitle.trim(); if (!title) { setEditingId(null); return; } fetch(`/api/sessions/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title }), }).then(() => { setSessions(prev => prev.map(s => s.id === id ? { ...s, title } : s)); setEditingId(null); }); }; const groupSessions = (list) => { const now = new Date(); const todayStr = now.toISOString().slice(0, 10); const yest = new Date(now); yest.setDate(yest.getDate() - 1); const yesterdayStr = yest.toISOString().slice(0, 10); const weekAgo = new Date(now); weekAgo.setDate(weekAgo.getDate() - 7); const groups = { today: [], yesterday: [], week: [], older: [] }; list.forEach(s => { const d = (s.created_at || "").slice(0, 10); if (d === todayStr) groups.today.push(s); else if (d === yesterdayStr) groups.yesterday.push(s); else if (new Date(d) >= weekAgo) groups.week.push(s); else groups.older.push(s); }); return groups; }; const filteredSessions = searchQ ? sessions.filter(s => s.title.toLowerCase().includes(searchQ.toLowerCase())) : sessions; const grouped = groupSessions(filteredSessions); const send = useCallback((overrideText) => { const text = (overrideText ?? input).trim(); if (!text || streaming) return; if (!isPro && promptCount >= FREE_LIMIT) { setShowLimit(true); return; } setInput(""); const newMessages = [...messages, { role: "user", content: text }]; setMessages(newMessages); setStreaming(true); setStreamBuf(""); const body = { messages: newMessages, session_id: activeId || undefined }; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 90000); fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal: controller.signal, }).then(async (response) => { clearTimeout(timeout); if (response.status === 403) { setStreaming(false); setShowLimit(true); return; } if (!response.ok) { setStreaming(false); setMessages(prev => [...prev, { role: "assistant", content: t.err_server }]); return; } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buf = ""; let fullResponse = ""; let sessionId = activeId; while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); const lines = buf.split("\n"); buf = lines.pop(); for (const line of lines) { if (!line.startsWith("data: ")) continue; const data = line.slice(6); if (data === "[DONE]") continue; try { const parsed = JSON.parse(data); if (parsed.session_id && !sessionId) { sessionId = parsed.session_id; setActiveId(sessionId); window.history.replaceState(null, "", `/c/${sessionId}`); } if (parsed.choices) { const delta = parsed.choices[0]?.delta?.content || ""; if (delta) { fullResponse += delta; setStreamBuf(fullResponse); } } if (parsed.content) { fullResponse += parsed.content; setStreamBuf(fullResponse); } } catch (e) {} } } setStreaming(false); setStreamBuf(""); if (fullResponse) { setMessages(prev => [...prev, { role: "assistant", content: fullResponse }]); } setPromptCount(c => c + 1); if (!isGuest) { fetch("/api/sessions").then(r => r.json()).then(data => { if (Array.isArray(data)) setSessions(data); }).catch(() => {}); } }).catch((err) => { clearTimeout(timeout); setStreaming(false); const msg = err.name === "AbortError" ? t.err_timeout : t.err_connection; setMessages(prev => [...prev, { role: "assistant", content: msg }]); }); }, [input, streaming, messages, activeId, isPro, promptCount, FREE_LIMIT, isGuest, t, navigate]); const onKey = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }; return (
setSidebarOpen(false)} />
{sessions.find(s => s.id === activeId)?.title || t.new_chat} {activeModel.size}
{isPro ? ( PRO {t.unlimited} ) : ( <> {promptCount} / {FREE_LIMIT} {t.prompts}
)}
{isEmpty && (

{t.welcome_title}

{isGuest ? t.guest_welcome : t.welcome_sub}

{SUGGESTIONS.map((s, i) => ( ))}
)} {messages.map((m, i) => (
{m.role === "user" ? userInitials : }
{m.role === "user" ? t.you : "Xera AI"}
{renderMarkdown(m.content)}
))} {streaming && (
Xera AI
{streamBuf ? renderMarkdown(streamBuf) : null}
)}