{t.welcome_title}
{isGuest ? t.guest_welcome : t.welcome_sub}
{t.limit_title}
{t.limit_body_1}
{isGuest ? ( <>{t.limit_body_guest}
{t.limit_body_2}
/* 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 (ssh cli@xera-app.com
{t.landing_sub}
{t.landing_feat1_desc}
{t.landing_feat2_desc}
{t.landing_feat3_desc}
{t.landing_about_text}
{t.pitch}
{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 (
{isGuest ? t.guest_welcome : t.welcome_sub}
{t.limit_body_1}
{isGuest ? ( <>{t.limit_body_guest}
{t.limit_body_2}