Startup no longer needs arguments: the config is created on first launch, a busy port is swapped for a free one, the console is open by default, and the browser lands on a setup wizard that downloads a ready-made .cmf model and wires it into routing (managed server + routing.default) when you press Finish. Download progress was reported from curl's own meter, which prints a full 0→100% bar for the -L redirect hop before the transfer starts — so every job showed 100% and then hung. Progress now comes from bytes on disk against the size from the HuggingFace tree (or a HEAD request). The UI and the gateway also disagreed about the output file name, so a finished download never marked its card as downloaded; the gateway now returns the exact stem and the UI sends it back verbatim for download, progress and delete. Featured models are read live from the infosave HF account in both the wizard and Import, and all 432 UI strings now exist in every one of the 7 languages.
311 lines
9.9 KiB
JavaScript
Vendored
311 lines
9.9 KiB
JavaScript
Vendored
// App bootstrap: login gate, sidebar, topbar, hash router, language + theme.
|
||
import { h, mount, toast } from "./ui.js";
|
||
import { t, getLang, setLang, onLangChange, LANGS } from "./i18n.js";
|
||
import { api, getToken, setToken, setUnauthorizedHandler } from "./api.js";
|
||
import { renderLogin } from "./views/login.js";
|
||
import { renderDashboard } from "./views/dashboard.js";
|
||
import { renderModels } from "./views/models.js?v=3";
|
||
import { renderImport } from "./views/import.js";
|
||
import { renderRouting } from "./views/routing.js";
|
||
import { renderProtocols } from "./views/protocols.js";
|
||
import { renderKeys } from "./views/keys.js";
|
||
import { renderPlayground } from "./views/playground.js";
|
||
import { renderSettings } from "./views/settings.js";
|
||
import { renderOnboarding } from "./views/onboarding.js";
|
||
|
||
const ROUTES = [
|
||
{ id: "dashboard", icon: "▤", view: renderDashboard },
|
||
{ id: "models", icon: "▦", view: renderModels },
|
||
{ id: "import", icon: "⬇", view: renderImport },
|
||
{ id: "routing", icon: "⇄", view: renderRouting },
|
||
{ id: "protocols", icon: "⇆", view: renderProtocols },
|
||
{ id: "keys", icon: "🔑", view: renderKeys },
|
||
{ id: "playground", icon: "✦", view: renderPlayground },
|
||
{ id: "settings", icon: "⚙", view: renderSettings },
|
||
{ id: "onboarding", icon: "✦", view: renderOnboarding, hidden: true },
|
||
];
|
||
|
||
export const appState = { meta: null, health: null, version: "" };
|
||
|
||
// allaigate site — where router keys are issued and topped up
|
||
export const SITE_URL = "https://api.allaigate.com/";
|
||
|
||
let mobileNavOpen = false;
|
||
|
||
function setMobileNav(open) {
|
||
mobileNavOpen = !!open;
|
||
const app = document.querySelector(".app");
|
||
if (app) app.classList.toggle("nav-open", mobileNavOpen);
|
||
document.body.classList.toggle("mobile-nav-open", mobileNavOpen);
|
||
const toggle = document.querySelector(".menu-toggle");
|
||
if (toggle) toggle.setAttribute("aria-expanded", String(mobileNavOpen));
|
||
}
|
||
|
||
function currentRoute() {
|
||
const id = location.hash.replace(/^#\/?/, "") || "dashboard";
|
||
return ROUTES.find((r) => r.id === id) || ROUTES[0];
|
||
}
|
||
|
||
const FAVICON =
|
||
"data:image/svg+xml;utf8," +
|
||
encodeURIComponent(
|
||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="%233b82f6"/><stop offset=".5" stop-color="%238b5cf6"/><stop offset="1" stop-color="%2306b6d4"/></linearGradient></defs><rect width="32" height="32" rx="8" fill="url(%23g)"/><path d="M9 16h14M17 10l6 6-6 6" stroke="white" stroke-width="2.4" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>'
|
||
);
|
||
|
||
function sidebar(activeId) {
|
||
const visibleRoutes = ROUTES.filter((r) => !r.hidden);
|
||
return h(
|
||
"aside",
|
||
{ class: "sidebar", id: "sidebar", "aria-label": "Main navigation" },
|
||
h(
|
||
"div",
|
||
{ class: "brand" },
|
||
h("img", { src: FAVICON, alt: "" }),
|
||
h("div", { class: "brand-copy" }, h("b", {}, "allaigate"), h("div", { class: "sub" }, t("brand.sub"))),
|
||
h("button", { class: "icon-btn nav-close", type: "button", title: "Close menu", "aria-label": "Close menu", onClick: () => setMobileNav(false) }, "×")
|
||
),
|
||
...visibleRoutes.map((r) =>
|
||
h(
|
||
"a",
|
||
{ class: "nav-item" + (r.id === activeId ? " active" : ""), href: "#/" + r.id, onClick: () => setMobileNav(false) },
|
||
h("span", { class: "ico" }, r.icon),
|
||
t("nav." + r.id)
|
||
)
|
||
),
|
||
h("div", { class: "nav-spacer" }),
|
||
h(
|
||
"a",
|
||
{ class: "nav-item", href: SITE_URL, target: "_blank", rel: "noopener" },
|
||
h("span", { class: "ico" }, "◈"),
|
||
t("nav.billing"),
|
||
h("span", { style: "margin-left:auto;opacity:.55" }, "↗")
|
||
),
|
||
h("div", { class: "nav-foot" }, t("nav.foot", { v: appState.version || "—" }))
|
||
);
|
||
}
|
||
|
||
function statusPills() {
|
||
const h_ = appState.health;
|
||
const gwOk = !!appState.meta;
|
||
const routerOk = h_?.router?.reachable;
|
||
// reachable but no key resolves → warn: routing silently degrades to default
|
||
const keyMissing = h_?.router?.key_source === "missing";
|
||
const routerDot = h_ ? (routerOk ? (keyMissing ? "warn" : "ok") : "bad") : "warn";
|
||
const routerText = h_
|
||
? routerOk
|
||
? keyMissing
|
||
? t("status.noKey")
|
||
: t("status.online")
|
||
: t("status.offline")
|
||
: t("status.checking");
|
||
return h(
|
||
"div",
|
||
{ class: "flex status-pills" },
|
||
h(
|
||
"span",
|
||
{ class: "pill" },
|
||
h("span", { class: "dot " + (gwOk ? "ok" : "bad") }),
|
||
t("status.gateway") + ": " + (gwOk ? t("status.online") : t("status.offline"))
|
||
),
|
||
h(
|
||
"span",
|
||
{ class: "pill", title: h_?.router?.url || "" },
|
||
h("span", { class: "dot " + routerDot }),
|
||
t("status.router") + ": " + routerText
|
||
)
|
||
);
|
||
}
|
||
|
||
function langSelect() {
|
||
const sel = h(
|
||
"select",
|
||
{
|
||
class: "lang-select",
|
||
title: "Language",
|
||
onChange: (e) => setLang(e.target.value),
|
||
},
|
||
...LANGS.map((l) => h("option", { value: l.code, selected: l.code === getLang() }, `${l.flag} ${l.label}`))
|
||
);
|
||
return sel;
|
||
}
|
||
|
||
function themeToggle() {
|
||
const cur = document.documentElement.getAttribute("data-theme") || "dark";
|
||
return h(
|
||
"button",
|
||
{
|
||
class: "icon-btn",
|
||
title: t("theme.toggle"),
|
||
onClick: () => {
|
||
const next = (document.documentElement.getAttribute("data-theme") || "dark") === "dark" ? "light" : "dark";
|
||
document.documentElement.setAttribute("data-theme", next);
|
||
localStorage.setItem("allaigate_theme", next);
|
||
renderShell();
|
||
},
|
||
},
|
||
cur === "dark" ? "☀" : "☾"
|
||
);
|
||
}
|
||
|
||
function topbar(activeId) {
|
||
return h(
|
||
"header",
|
||
{ class: "topbar" },
|
||
h(
|
||
"button",
|
||
{
|
||
class: "icon-btn menu-toggle",
|
||
type: "button",
|
||
title: "Menu",
|
||
"aria-label": "Open menu",
|
||
"aria-controls": "sidebar",
|
||
"aria-expanded": String(mobileNavOpen),
|
||
onClick: () => setMobileNav(!mobileNavOpen),
|
||
},
|
||
h("span", { class: "hamburger", "aria-hidden": "true" }, h("i"), h("i"), h("i"))
|
||
),
|
||
h("div", { class: "title" }, t("nav." + activeId)),
|
||
h(
|
||
"div",
|
||
{ class: "right" },
|
||
statusPills(),
|
||
themeToggle(),
|
||
langSelect(),
|
||
h(
|
||
"button",
|
||
{
|
||
class: "icon-btn",
|
||
title: t("logout"),
|
||
onClick: () => {
|
||
setToken("");
|
||
boot();
|
||
},
|
||
},
|
||
"⎋"
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
async function renderView() {
|
||
const route = currentRoute();
|
||
const view = document.getElementById("view");
|
||
if (!view) return;
|
||
mount(view, h("div", { class: "empty" }, h("span", { class: "spinner" }), " ", t("common.loading")));
|
||
try {
|
||
const node = await route.view();
|
||
mount(view, node);
|
||
} catch (e) {
|
||
if (String(e.message) === "unauthorized") return;
|
||
mount(view, h("div", { class: "callout" }, String(e.message || e)));
|
||
}
|
||
}
|
||
|
||
function renderShell() {
|
||
const route = currentRoute();
|
||
mobileNavOpen = false;
|
||
document.body.classList.remove("mobile-nav-open");
|
||
// onboarding — full screen without sidebar/topbar
|
||
if (route.id === "onboarding") {
|
||
mount(
|
||
document.getElementById("app"),
|
||
h("div", { class: "onb-shell" }, h("div", { class: "onb-content", id: "view" }))
|
||
);
|
||
renderView();
|
||
return;
|
||
}
|
||
mount(
|
||
document.getElementById("app"),
|
||
h(
|
||
"div",
|
||
{ class: "app" },
|
||
sidebar(route.id),
|
||
h("button", { class: "mobile-nav-backdrop", type: "button", tabindex: "-1", "aria-label": "Close menu", onClick: () => setMobileNav(false) }),
|
||
h("main", { class: "main" }, topbar(route.id), h("div", { class: "content", id: "view" }))
|
||
)
|
||
);
|
||
renderView();
|
||
}
|
||
|
||
function showLogin() {
|
||
mount(document.getElementById("app"), renderLogin(boot));
|
||
}
|
||
|
||
async function refreshHealth() {
|
||
try {
|
||
appState.health = await api.health();
|
||
} catch {
|
||
appState.health = null;
|
||
}
|
||
// re-render topbar only if shell present
|
||
const route = currentRoute();
|
||
const main = document.querySelector(".main");
|
||
if (main) {
|
||
const old = main.querySelector(".topbar");
|
||
if (old) old.replaceWith(topbar(route.id));
|
||
}
|
||
}
|
||
|
||
export async function boot() {
|
||
// convenience: /admin?token=… logs in once, then is stripped from the URL
|
||
const urlTok = new URLSearchParams(location.search).get("token");
|
||
if (urlTok) {
|
||
setToken(urlTok);
|
||
history.replaceState({}, "", location.pathname + location.hash);
|
||
}
|
||
// try without token first — open mode (no CORTIQ_ADMIN_TOKEN) succeeds,
|
||
// protected mode returns 401 and we show login
|
||
try {
|
||
appState.meta = await api.meta();
|
||
appState.version = appState.meta.version || "";
|
||
} catch (e) {
|
||
if (String(e.message) === "unauthorized") {
|
||
// if we have no token stored, show login; with a stale token also show login
|
||
showLogin();
|
||
return;
|
||
}
|
||
// gateway reachable but error — still show shell
|
||
// if meta failed for other reason but token was present, fall through
|
||
if (!getToken()) {
|
||
// open mode but gateway unreachable — still try shell; health will show
|
||
try {
|
||
appState.meta = await api.meta();
|
||
appState.version = appState.meta.version || "";
|
||
} catch {}
|
||
}
|
||
}
|
||
// first-run check — redirect to onboarding if needed (unless already there)
|
||
try {
|
||
const st = await api.setupStatus();
|
||
if (st.needs_setup && location.hash !== "#/onboarding") {
|
||
location.hash = "#/onboarding";
|
||
}
|
||
} catch {}
|
||
if (!location.hash) location.hash = "#/dashboard";
|
||
renderShell();
|
||
refreshHealth();
|
||
}
|
||
|
||
setUnauthorizedHandler(() => {
|
||
toast(t("login.failed"), "bad");
|
||
setToken("");
|
||
showLogin();
|
||
});
|
||
onLangChange(() => {
|
||
if (document.querySelector(".app")) renderShell();
|
||
});
|
||
window.addEventListener("hashchange", () => {
|
||
if (document.querySelector(".app")) renderShell();
|
||
});
|
||
window.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && mobileNavOpen) setMobileNav(false);
|
||
});
|
||
window.addEventListener("resize", () => {
|
||
if (window.innerWidth > 980 && mobileNavOpen) setMobileNav(false);
|
||
});
|
||
setInterval(() => {
|
||
if (document.querySelector(".app")) refreshHealth();
|
||
}, 20000);
|
||
|
||
boot();
|