From 749a47ce9b7c7c3b95294bf8a9a7702557376416 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Wed, 8 Jul 2026 17:58:31 +0300 Subject: [PATCH] chore: ship the full working frontend + backend The crates.io/release build shipped a STALE frontend (committed web/js was behind the working tree), so the admin console hung on load. Commit the current working web/js (app, api, dashboard, login, import view, i18n de/es/fr/tr/zh, css) and the backend WIP (import.rs, admin/assets.rs) so the released console matches the dev build. Verified: clean `cargo build --release`, empty-pool start, working dashboard served. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/admin/assets.rs | 14 +- src/import.rs | 32 +++- web/css/styles.css | 78 +++++++++ web/js/api.js | 16 ++ web/js/app.js | 26 ++- web/js/i18n/de.js | 15 ++ web/js/i18n/es.js | 15 ++ web/js/i18n/fr.js | 15 ++ web/js/i18n/tr.js | 15 ++ web/js/i18n/zh.js | 15 ++ web/js/views/dashboard.js | 30 +++- web/js/views/import.js | 332 ++++++++++++++++++++++++++++++++++++++ web/js/views/login.js | 8 +- 15 files changed, 597 insertions(+), 18 deletions(-) create mode 100644 web/js/views/import.js diff --git a/Cargo.lock b/Cargo.lock index beafb47..9fbbd6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "cortiq-gateway" -version = "0.2.4" +version = "0.2.5" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index a149ceb..b3533a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cortiq-gateway" -version = "0.2.4" +version = "0.2.5" edition = "2021" rust-version = "1.75" license = "Apache-2.0" diff --git a/src/admin/assets.rs b/src/admin/assets.rs index ec8800f..5860825 100644 --- a/src/admin/assets.rs +++ b/src/admin/assets.rs @@ -16,11 +16,18 @@ use rust_embed::RustEmbed; struct WebAssets; fn serve_file(path: &str) -> Response { + // The panel is rebuilt often (assets are embedded at compile time and served + // from memory), so tell the browser to always revalidate — otherwise a stale + // cached SPA hides freshly shipped fixes until a hard reload. + const NO_CACHE: &str = "no-store, must-revalidate"; match WebAssets::get(path) { Some(content) => { let mime = mime_guess::from_path(path).first_or_octet_stream(); ( - [(header::CONTENT_TYPE, mime.as_ref())], + [ + (header::CONTENT_TYPE, mime.as_ref()), + (header::CACHE_CONTROL, NO_CACHE), + ], content.data.into_owned(), ) .into_response() @@ -29,7 +36,10 @@ fn serve_file(path: &str) -> Response { // SPA: unknown path under /admin → serve index.html match WebAssets::get("index.html") { Some(content) => ( - [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + [ + (header::CONTENT_TYPE, "text/html; charset=utf-8"), + (header::CACHE_CONTROL, NO_CACHE), + ], content.data.into_owned(), ) .into_response(), diff --git a/src/import.rs b/src/import.rs index c7a93df..75f3d8f 100644 --- a/src/import.rs +++ b/src/import.rs @@ -223,6 +223,17 @@ fn sanitize(s: &str) -> String { } /// Kick off a conversion. Returns the job id immediately; progress streams +/// Map the UI quant label to the native `cortiq` quant flag. +fn map_quant(q: &str) -> &'static str { + match q.to_ascii_uppercase().as_str() { + s if s.starts_with("Q4") => "q4", + "Q8_2F" => "q8_2f", + "F16" | "FP16" => "f16", + "VBIT" => "vbit", + _ => "q8", + } +} + /// into the job log. Fails fast if the converter script is missing. pub fn start_import(store: Arc, cfg: &CmfCfg, p: ImportParams) -> Result { if p.repo.trim().is_empty() { @@ -290,18 +301,23 @@ pub fn start_import(store: Arc, cfg: &CmfCfg, p: ImportParams) -> Resu if p.skip_mtp { a.push("--skip-mtp".into()); } let wd = conv.parent().map(|d| d.to_path_buf()).unwrap_or_else(|| std::path::PathBuf::from(".")); (cfg.python_bin.clone(), a, wd) + } else if p.repo.to_ascii_lowercase().contains("gguf") { + // GGUF repo → native `cortiq import-gguf` (downloads + dequantizes any + // common ggml quant type: Q4_0/1, Q5_0/1, Q8_0, Q2_K..Q6_K, IQ4_NL/XS). + let mut a = vec![ + "import-gguf".into(), + p.repo.clone(), + "--quant".into(), map_quant(&p.quant).into(), + "--output".into(), output_abs.clone(), + ]; + if let Some(t) = &hf_token { a.push("--hf-token".into()); a.push(t.clone()); } + (cfg.cortiq_bin.clone(), a, std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))) } else { - // native Rust converter — quant name maps to cortiq's scheme. - let quant = match p.quant.to_ascii_uppercase().as_str() { - s if s.starts_with("Q4") => "q4", - "Q8_2F" => "q8_2f", - "F16" | "FP16" => "f16", - _ => "q8", - }; + // safetensors → native `cortiq convert` (dense / MoE / GatedDeltaNet). let mut a = vec![ "convert".into(), "--model".into(), p.repo.clone(), - "--quant".into(), quant.into(), + "--quant".into(), map_quant(&p.quant).into(), "--output".into(), output_abs.clone(), ]; if let Some(t) = &hf_token { a.push("--hf-token".into()); a.push(t.clone()); } diff --git a/web/css/styles.css b/web/css/styles.css index 1845488..6005239 100644 --- a/web/css/styles.css +++ b/web/css/styles.css @@ -307,3 +307,81 @@ tbody tr:hover td { background: var(--hover); } .callout { border: 1px solid var(--border); border-left: 3px solid var(--warn); background: color-mix(in srgb, var(--warn) 6%, transparent); border-radius: 10px; padding: 13px 15px; color: var(--muted); margin-bottom: 16px; } .callout.info { border-left-color: var(--accent-2); background: color-mix(in srgb, var(--accent-2) 6%, transparent); } .callout b { color: var(--text); } + +/* ── Import model (HuggingFace → CMF) ── */ +.view-head { margin-bottom: 14px; } +.view-head h2 { margin: 0 0 2px; } +.card-title { font-weight: 600; margin-bottom: 10px; } +.actions { margin-top: 12px; display: flex; gap: 8px; } +.pad { padding: 18px; } +.err { color: #e5534b; } +.btn.sm { padding: 4px 10px; font-size: 12px; } + +.hf-search { + width: 100%; box-sizing: border-box; margin-bottom: 12px; + padding: 10px 12px; font-size: 15px; + border: 1px solid var(--border, #2a2f3a); border-radius: 8px; + background: var(--bg-2, #12151c); color: inherit; +} +.hf-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 10px; max-height: 420px; overflow-y: auto; +} +.hf-card { + border: 1px solid var(--border, #2a2f3a); border-radius: 8px; + padding: 10px 12px; cursor: pointer; transition: border-color .12s, background .12s; + background: var(--bg-2, #12151c); +} +.hf-card:hover { border-color: var(--accent, #5b8def); } +.hf-card.sel { border-color: var(--accent, #5b8def); background: rgba(91,141,239,.10); } +.hf-id { font-weight: 600; font-size: 13px; word-break: break-all; margin-bottom: 6px; } +.hf-meta { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; font-size: 12px; } +.badge.bad { background: rgba(229,83,75,.15); color: #e5534b; } + +.import-form { margin-top: 14px; } +.adv { margin-top: 8px; } +.adv-body { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--border, #2a2f3a); } + +.pbar { + height: 8px; border-radius: 6px; overflow: hidden; margin: 10px 0; + background: var(--bg-2, #12151c); border: 1px solid var(--border, #2a2f3a); +} +.pbar-fill { height: 100%; width: 100%; border-radius: 6px; } +.pbar-fill.run { + width: 40%; + background: linear-gradient(90deg, transparent, var(--accent, #5b8def), transparent); + animation: pbar-slide 1.2s infinite ease-in-out; +} +.pbar-fill.ok { background: #3fb950; } +.pbar-fill.err { background: #e5534b; } +@keyframes pbar-slide { 0% { margin-left: -40%; } 100% { margin-left: 100%; } } + +.conv-log { + margin-top: 10px; max-height: 260px; overflow-y: auto; + background: var(--bg-2, #0d1017); border: 1px solid var(--border, #2a2f3a); + border-radius: 8px; padding: 10px 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; + white-space: pre-wrap; word-break: break-word; color: var(--muted, #9aa4b2); +} + +/* Router-key field: input grows, inline "Save key" button sits beside it */ +.key-row { display: flex; gap: 8px; align-items: stretch; } +.key-row input { flex: 1 1 auto; min-width: 0; } +.key-row .btn { flex: 0 0 auto; white-space: nowrap; } + +/* Determinate progress fill (known %); .run remains the indeterminate slider */ +.pbar-fill.det { + background: linear-gradient(90deg, var(--accent, #5b8def), #7aa2f7); + transition: width .4s ease; +} +/* Import job cards (multiple conversions, live) */ +.job { padding: 12px 0; border-top: 1px solid var(--border-soft, #232833); } +.job:first-of-type { border-top: 0; } +.job-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.job-head b { word-break: break-all; } +.small { font-size: 12px; } +/* Router usage: readable, wrapping key/value rows (no horizontal overflow) */ +.usage-box { max-width: 100%; overflow-x: hidden; } +.mono.kv { display: flex; gap: 6px; padding: 1px 0; white-space: normal; overflow-wrap: anywhere; word-break: break-word; } +.mono.kv .kv-k { color: var(--muted, #9aa4b2); flex: 0 0 auto; } +.mono.kv .kv-v { font-weight: 600; overflow-wrap: anywhere; word-break: break-word; min-width: 0; } diff --git a/web/js/api.js b/web/js/api.js index e3888fd..74a027b 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -50,6 +50,7 @@ export const api = { // meta / health meta: () => j("GET", "/meta"), health: () => j("GET", "/health"), + probeRouter: () => j("POST", "/router/probe"), // config getConfig: () => j("GET", "/config"), @@ -90,6 +91,21 @@ export const api = { // playground test: (b) => j("POST", "/test", b), + + // shadow / self-warming loop + shadow: () => j("GET", "/shadow"), + + // CMF model factory (HuggingFace → local .cmf) + hfSearch: (q, limit = 24) => + j("GET", `/hf/search?q=${encodeURIComponent(q || "")}&limit=${limit}`), + startImport: (b) => j("POST", "/import", b), + listImports: () => j("GET", "/import"), + importStatus: (job) => j("GET", "/import/" + encodeURIComponent(job)), + cancelImport: (job) => + j("POST", "/import/" + encodeURIComponent(job) + "/cancel"), + deleteImport: (job) => j("DELETE", "/import/" + encodeURIComponent(job)), + registerImport: (job) => + j("POST", "/import/" + encodeURIComponent(job) + "/register"), }; // Streaming playground: POST /test/stream, calls onDelta(text) per content chunk, diff --git a/web/js/app.js b/web/js/app.js index 7720e32..7999cdf 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -5,6 +5,7 @@ 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"; +import { renderImport } from "./views/import.js"; import { renderRouting } from "./views/routing.js"; import { renderProtocols } from "./views/protocols.js"; import { renderKeys } from "./views/keys.js"; @@ -14,6 +15,7 @@ import { renderSettings } from "./views/settings.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 }, @@ -23,6 +25,9 @@ const ROUTES = [ 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/"; + function currentRoute() { const id = location.hash.replace(/^#\/?/, "") || "dashboard"; return ROUTES.find((r) => r.id === id) || ROUTES[0]; @@ -53,6 +58,13 @@ function sidebar(activeId) { ) ), 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 || "—" })) ); } @@ -61,6 +73,16 @@ 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" }, @@ -73,8 +95,8 @@ function statusPills() { h( "span", { class: "pill", title: h_?.router?.url || "" }, - h("span", { class: "dot " + (h_ ? (routerOk ? "ok" : "bad") : "warn") }), - t("status.router") + ": " + (h_ ? (routerOk ? t("status.online") : t("status.offline")) : t("status.checking")) + h("span", { class: "dot " + routerDot }), + t("status.router") + ": " + routerText ) ); } diff --git a/web/js/i18n/de.js b/web/js/i18n/de.js index c14584e..689bdaf 100644 --- a/web/js/i18n/de.js +++ b/web/js/i18n/de.js @@ -33,6 +33,7 @@ export default { "nav.playground": "Playground", "nav.settings": "Einstellungen", "nav.foot": "Gateway · v{v}", + "nav.billing": "Router-Key & Abrechnung", // topbar / status "status.router": "Router", @@ -40,6 +41,7 @@ export default { "status.online": "online", "status.offline": "offline", "status.checking": "Prüfe…", + "status.noKey": "kein Key", "theme.toggle": "Design", "logout": "Abmelden", @@ -88,6 +90,7 @@ export default { "dash.health.title": "Status", "dash.health.reachable": "erreichbar", "dash.health.unreachable": "nicht erreichbar", + "dash.health.getKey": "Router-Key holen", // models "models.title": "Modelle", @@ -225,6 +228,18 @@ export default { "settings.router.timeout": "Timeout (ms)", "settings.router.verifyTls": "TLS-Zertifikat prüfen", "settings.router.taxonomy": "Taxonomie-ID", + "settings.router.getKey": "Router-Key holen oder aufladen:", + "settings.router.secret": "Router-Key (Wert)", + "settings.router.test": "Router testen", + "router.probe.ok": "Router OK ({ms} ms)", + "router.probe.noKey": "Router-Key ist nicht gesetzt", + "router.probe.auth": "Key abgelehnt — ungültig oder widerrufen", + "router.probe.payment": "Zahlung erforderlich — Tarif abgelaufen oder Kontingent erschöpft", + "router.probe.quota": "Ratenlimit überschritten", + "router.probe.timeout": "Router-Zeitüberschreitung", + "router.probe.unreachable": "Router nicht erreichbar", + "router.probe.error": "Router-Fehler", + "router.usage": "Verbrauch", "settings.route": "Routing-Eingabe", "settings.route.strategy": "Textstrategie", "settings.route.maxChars": "Max. Zeichen", diff --git a/web/js/i18n/es.js b/web/js/i18n/es.js index 2dbaa6f..fadf5dc 100644 --- a/web/js/i18n/es.js +++ b/web/js/i18n/es.js @@ -33,6 +33,7 @@ export default { "nav.playground": "Playground", "nav.settings": "Configuración", "nav.foot": "Gateway · v{v}", + "nav.billing": "Clave del router y pagos", // topbar / status "status.router": "Router", @@ -40,6 +41,7 @@ export default { "status.online": "en línea", "status.offline": "sin conexión", "status.checking": "verificando…", + "status.noKey": "sin clave", "theme.toggle": "Tema", "logout": "Cerrar sesión", @@ -88,6 +90,7 @@ export default { "dash.health.title": "Estado", "dash.health.reachable": "accesible", "dash.health.unreachable": "inaccesible", + "dash.health.getKey": "Obtener una clave del router", // models "models.title": "Modelos", @@ -225,6 +228,18 @@ export default { "settings.router.timeout": "Tiempo de espera (ms)", "settings.router.verifyTls": "Verificar certificado TLS", "settings.router.taxonomy": "ID de taxonomía", + "settings.router.getKey": "Obtén o recarga una clave del router en", + "settings.router.secret": "Clave del router (valor)", + "settings.router.test": "Probar router", + "router.probe.ok": "Router OK ({ms} ms)", + "router.probe.noKey": "La clave del router no está configurada", + "router.probe.auth": "Clave rechazada — inválida o revocada", + "router.probe.payment": "Pago requerido — plan vencido o cuota agotada", + "router.probe.quota": "Límite de tasa excedido", + "router.probe.timeout": "Tiempo de espera del router agotado", + "router.probe.unreachable": "Router inaccesible", + "router.probe.error": "Error del router", + "router.usage": "Consumo", "settings.route": "Entrada de enrutamiento", "settings.route.strategy": "Estrategia de texto", "settings.route.maxChars": "Máx. caracteres", diff --git a/web/js/i18n/fr.js b/web/js/i18n/fr.js index d55f56b..6279338 100644 --- a/web/js/i18n/fr.js +++ b/web/js/i18n/fr.js @@ -33,6 +33,7 @@ export default { "nav.playground": "Playground", "nav.settings": "Paramètres", "nav.foot": "Gateway · v{v}", + "nav.billing": "Clé routeur & facturation", // topbar / status "status.router": "Routeur", @@ -40,6 +41,7 @@ export default { "status.online": "en ligne", "status.offline": "hors ligne", "status.checking": "vérification…", + "status.noKey": "pas de clé", "theme.toggle": "Thème", "logout": "Déconnexion", @@ -88,6 +90,7 @@ export default { "dash.health.title": "Santé", "dash.health.reachable": "accessible", "dash.health.unreachable": "inaccessible", + "dash.health.getKey": "Obtenir une clé routeur", // models "models.title": "Modèles", @@ -225,6 +228,18 @@ export default { "settings.router.timeout": "Délai d'expiration (ms)", "settings.router.verifyTls": "Vérifier le certificat TLS", "settings.router.taxonomy": "ID de taxonomie", + "settings.router.getKey": "Obtenir ou recharger une clé routeur sur", + "settings.router.secret": "Clé routeur (valeur)", + "settings.router.test": "Tester le routeur", + "router.probe.ok": "Routeur OK ({ms} ms)", + "router.probe.noKey": "La clé routeur n'est pas définie", + "router.probe.auth": "Clé rejetée — invalide ou révoquée", + "router.probe.payment": "Paiement requis — forfait expiré ou quota épuisé", + "router.probe.quota": "Limite de débit dépassée", + "router.probe.timeout": "Délai du routeur dépassé", + "router.probe.unreachable": "Routeur inaccessible", + "router.probe.error": "Erreur du routeur", + "router.usage": "Consommation", "settings.route": "Entrée de routage", "settings.route.strategy": "Stratégie de texte", "settings.route.maxChars": "Caractères max", diff --git a/web/js/i18n/tr.js b/web/js/i18n/tr.js index ce214d8..32e721c 100644 --- a/web/js/i18n/tr.js +++ b/web/js/i18n/tr.js @@ -33,6 +33,7 @@ export default { "nav.playground": "Oyun alanı", "nav.settings": "Ayarlar", "nav.foot": "Gateway · v{v}", + "nav.billing": "Router anahtarı ve ödeme", // topbar / status "status.router": "Yönlendirici", @@ -40,6 +41,7 @@ export default { "status.online": "çevrimiçi", "status.offline": "çevrimdışı", "status.checking": "kontrol ediliyor…", + "status.noKey": "anahtar yok", "theme.toggle": "Tema", "logout": "Çıkış yap", @@ -88,6 +90,7 @@ export default { "dash.health.title": "Sağlık", "dash.health.reachable": "erişilebilir", "dash.health.unreachable": "erişilemiyor", + "dash.health.getKey": "Router anahtarı al", // models "models.title": "Modeller", @@ -225,6 +228,18 @@ export default { "settings.router.timeout": "Zaman aşımı (ms)", "settings.router.verifyTls": "TLS sertifikasını doğrula", "settings.router.taxonomy": "Taksonomi id", + "settings.router.getKey": "Router anahtarı almak veya bakiye yüklemek için:", + "settings.router.secret": "Router anahtarı (değer)", + "settings.router.test": "Router'ı test et", + "router.probe.ok": "Router çalışıyor ({ms} ms)", + "router.probe.noKey": "Router anahtarı ayarlanmadı", + "router.probe.auth": "Anahtar reddedildi — geçersiz veya iptal edilmiş", + "router.probe.payment": "Ödeme gerekli — plan süresi doldu veya kota tükendi", + "router.probe.quota": "Hız sınırı aşıldı", + "router.probe.timeout": "Router zaman aşımına uğradı", + "router.probe.unreachable": "Router erişilemiyor", + "router.probe.error": "Router hatası", + "router.usage": "Kullanım", "settings.route": "Yönlendirme girişi", "settings.route.strategy": "Metin stratejisi", "settings.route.maxChars": "Maks. karakter", diff --git a/web/js/i18n/zh.js b/web/js/i18n/zh.js index b7a8f7f..62dfd37 100644 --- a/web/js/i18n/zh.js +++ b/web/js/i18n/zh.js @@ -33,6 +33,7 @@ export default { "nav.playground": "沙盒", "nav.settings": "设置", "nav.foot": "网关 · v{v}", + "nav.billing": "路由器密钥与充值", // topbar / status "status.router": "路由器", @@ -40,6 +41,7 @@ export default { "status.online": "在线", "status.offline": "离线", "status.checking": "检测中…", + "status.noKey": "无密钥", "theme.toggle": "主题", "logout": "退出", @@ -88,6 +90,7 @@ export default { "dash.health.title": "健康状态", "dash.health.reachable": "可达", "dash.health.unreachable": "不可达", + "dash.health.getKey": "获取路由器密钥", // models "models.title": "模型", @@ -225,6 +228,18 @@ export default { "settings.router.timeout": "超时(ms)", "settings.router.verifyTls": "验证 TLS 证书", "settings.router.taxonomy": "分类体系 ID", + "settings.router.getKey": "获取或充值路由器密钥:", + "settings.router.secret": "路由器密钥(值)", + "settings.router.test": "测试路由器", + "router.probe.ok": "路由器正常({ms} ms)", + "router.probe.noKey": "未设置路由器密钥", + "router.probe.auth": "密钥被拒绝——无效或已吊销", + "router.probe.payment": "需要付款——套餐到期或配额用尽", + "router.probe.quota": "超出速率限制", + "router.probe.timeout": "路由器响应超时", + "router.probe.unreachable": "路由器不可达", + "router.probe.error": "路由器错误", + "router.usage": "用量", "settings.route": "路由输入", "settings.route.strategy": "文本策略", "settings.route.maxChars": "最大字符数", diff --git a/web/js/views/dashboard.js b/web/js/views/dashboard.js index 659420f..f04c04b 100644 --- a/web/js/views/dashboard.js +++ b/web/js/views/dashboard.js @@ -3,6 +3,7 @@ import { h, mount, money, num, ms, pct, timeAgo } from "../ui.js"; import { t } from "../i18n.js"; import { api } from "../api.js"; import { lineChart, barList } from "../charts.js"; +import { SITE_URL } from "../app.js"; const RANGES = ["1h", "24h", "7d"]; const GROUPS = ["model", "tier", "label", "account"]; @@ -186,7 +187,19 @@ function recentTable(rows) { ); } +function keySourceLabel(src) { + if (src === "store") return t("models.form.secretStored"); + if (src === "env") return t("models.form.secretEnv"); + return t("models.form.secretMissing"); +} + function healthCard(hh) { + const r = hh.router || {}; + const keySrc = r.key_source || "none"; + const err = r.last_error; + // problems fixed on the site (get / pay for a key) get the top-up link + const needsSite = + !r.reachable || keySrc === "missing" || (err && ["auth", "payment", "quota"].includes(err.kind)); return h( "div", { class: "card" }, @@ -196,9 +209,20 @@ function healthCard(hh) { { class: "flex wrap", style: "margin-bottom:12px" }, h( "span", - { class: "badge " + (hh.router?.reachable ? "ok" : "bad"), title: hh.router?.url || "" }, - t("status.router") + ": " + (hh.router?.reachable ? t("dash.health.reachable") : t("dash.health.unreachable")) - ) + { class: "badge " + (r.reachable ? "ok" : "bad"), title: r.url || "" }, + t("status.router") + ": " + (r.reachable ? t("dash.health.reachable") : t("dash.health.unreachable")) + ), + keySrc !== "none" + ? h( + "span", + { class: "badge " + keyBadge(keySrc), title: r.key_env || "" }, + t("models.col.key") + ": " + keySourceLabel(keySrc) + ) + : null, + err ? h("span", { class: "badge bad" }, t("router.probe." + (err.kind || "error"))) : null, + needsSite + ? h("a", { href: SITE_URL, target: "_blank", rel: "noopener" }, t("dash.health.getKey") + " ↗") + : null ), h( "div", diff --git a/web/js/views/import.js b/web/js/views/import.js new file mode 100644 index 0000000..a2ad279 --- /dev/null +++ b/web/js/views/import.js @@ -0,0 +1,332 @@ +// Import a HuggingFace model → convert to a local .cmf (our format) with a +// live progress view. Search HF, pick quantization + advanced params, watch +// the conversion (multiple in parallel, resumable across tab switches), then +// register the result as a local model. +import { h, mount, toast } from "../ui.js"; +import { t } from "../i18n.js"; +import { api } from "../api.js"; + +const QUANTS = [ + ["Q8_2F", "import.q.q8_2f"], + ["Q8_ROW", "import.q.q8_row"], + ["Q4_BLOCK", "import.q.q4"], + ["VBIT", "import.q.vbit"], + ["F16", "import.q.f16"], + ["F32", "import.q.f32"], +]; + +function field(label, control, hint) { + return h("label", { class: "field" }, h("span", {}, label), control, + hint ? h("div", { class: "hint" }, hint) : null); +} +function opt(v, label, sel) { + return h("option", { value: v, selected: sel ? true : null }, label || v); +} +function fmtNum(n) { + if (n == null) return "—"; + if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"; + if (n >= 1e3) return (n / 1e3).toFixed(1) + "k"; + return String(n); +} +function fmtBytes(n) { + if (!n) return ""; + const g = n / 1e9; + return g >= 1 ? g.toFixed(2) + " GB" : (n / 1e6).toFixed(0) + " MB"; +} +// state → localized label (falls back to the raw state) +function stateLabel(s) { + const k = "import.state." + s; + const v = t(k); + return v === k ? s : v; +} + +export function renderImport() { + let selected = null; + let lastModels = []; // cache of the last search, so re-highlighting needs no refetch + let jobsTimer = null; + + const searchIn = h("input", { + class: "hf-search", + placeholder: t("import.searchPlaceholder"), + autofocus: true, + }); + const results = h("div", { class: "hf-grid" }); + const formHost = h("div", {}); + const jobsHost = h("div", {}); + + // ── live HF search (debounced) ── + let debounce = null; + function renderCards(models) { + if (!models.length) { + mount(results, h("div", { class: "muted pad" }, t("import.noResults"))); + return; + } + mount(results, ...models.map(modelCard)); + } + async function doSearch() { + const q = searchIn.value.trim(); + mount(results, h("div", { class: "muted pad" }, t("import.searching"))); + try { + const r = await api.hfSearch(q, 24); + lastModels = r.models || []; + renderCards(lastModels); + } catch (e) { + mount(results, h("div", { class: "err pad" }, "HF: " + e.message)); + } + } + searchIn.addEventListener("input", () => { + clearTimeout(debounce); + debounce = setTimeout(doSearch, 300); + }); + + function modelCard(m) { + const card = h( + "div", + { class: "hf-card" + (selected && selected.id === m.id ? " sel" : "") }, + h("div", { class: "hf-id" }, m.id), + h( + "div", + { class: "hf-meta" }, + m.pipeline_tag ? h("span", { class: "badge" }, m.pipeline_tag) : null, + h("span", { class: "muted" }, "▼ " + fmtNum(m.downloads)), + h("span", { class: "muted" }, "♥ " + fmtNum(m.likes)), + (m.tags || []).some((x) => x === "gated") ? h("span", { class: "badge bad" }, "gated") : null + ) + ); + card.addEventListener("click", () => selectModel(m)); + return card; + } + + function selectModel(m) { + selected = m; + renderCards(lastModels); // re-highlight from cache — no refetch, no flash + renderForm(); + formHost.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + + // ── conversion form ── + function renderForm() { + if (!selected) { + mount(formHost); + return; + } + const nameIn = h("input", { value: (selected.id.split("/").pop() || "model").toLowerCase() }); + const quantSel = h("select", {}, ...QUANTS.map(([v], i) => opt(v, v, i === 0))); + const quantHint = h("div", { class: "hint" }, t("import.q.q8_2f")); + quantSel.addEventListener("change", () => { + const k = QUANTS.find(([v]) => v === quantSel.value); + quantHint.textContent = k ? t(k[1]) : ""; + }); + + // advanced (collapsed) + const linearSel = h("select", {}, opt("", t("import.adv.auto")), opt("gated_delta_net", "gated_delta_net"), opt("vmf_phase", "vmf_phase")); + const nphaseIn = h("input", { type: "number", min: "1", placeholder: "16" }); + const vbitSel = h("select", {}, opt("", t("import.adv.auto")), opt("log2", "log2"), opt("cubic", "cubic")); + const meanBitsIn = h("input", { type: "number", step: "0.1", placeholder: "5.0" }); + const shardIn = h("input", { type: "number", step: "0.5", placeholder: t("import.adv.noShard") }); + const skipMtp = h("input", { type: "checkbox" }); + const advBody = h( + "div", + { class: "adv-body", style: "display:none" }, + h("div", { class: "row" }, + field(t("import.adv.linearCore"), linearSel, t("import.adv.linearHint")), + field(t("import.adv.nphase"), nphaseIn)), + h("div", { class: "row" }, + field(t("import.adv.vbitShape"), vbitSel), + field(t("import.adv.meanBits"), meanBitsIn)), + h("div", { class: "row" }, + field(t("import.adv.shard"), shardIn), + field("MTP", h("label", { class: "check" }, skipMtp, t("import.adv.skipMtp")))), + ); + const advToggle = h("button", { class: "btn ghost sm" }, "⚙ " + t("import.adv.title")); + advToggle.addEventListener("click", () => { + advBody.style.display = advBody.style.display === "none" ? "block" : "none"; + }); + + const goBtn = h("button", { class: "btn primary" }, "⬇ " + t("import.convert")); + goBtn.addEventListener("click", async () => { + const params = { + repo: selected.id, + quant: quantSel.value, + name: nameIn.value.trim(), + skip_mtp: skipMtp.checked, + }; + if (linearSel.value) params.linear_core = linearSel.value; + if (nphaseIn.value) params.nphase = parseInt(nphaseIn.value); + if (vbitSel.value) params.vbit_shape = vbitSel.value; + if (meanBitsIn.value) params.mean_bits = parseFloat(meanBitsIn.value); + if (shardIn.value) params.shard_max_gb = parseFloat(shardIn.value); + goBtn.disabled = true; + try { + await api.startImport(params); + toast(t("import.started"), "ok"); + await refreshJobs(); // picks up the new job + (re)starts polling + jobsHost.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } catch (e) { + toast(e.message, "err"); + } + goBtn.disabled = false; + }); + + mount( + formHost, + h("div", { class: "card import-form" }, + h("div", { class: "card-title" }, t("import.configure") + ": " + selected.id), + h("div", { class: "row" }, + field(t("import.outName"), nameIn, t("import.outNameHint")), + field(t("import.quant"), quantSel)), + quantHint, + h("div", { class: "adv" }, advToggle, advBody), + h("div", { class: "actions" }, goBtn), + ) + ); + } + + // ── jobs panel: all conversions, live, resumable, cancellable ── + function ensurePolling() { + if (!jobsTimer) { + jobsTimer = setInterval(() => { + // stop once this view instance was replaced (navigated away) + if (!root.isConnected) { + stopPolling(); + return; + } + refreshJobs(); + }, 1500); + } + } + function stopPolling() { + if (jobsTimer) { + clearInterval(jobsTimer); + jobsTimer = null; + } + } + // Load + render all jobs. Runs on mount (before `root` is attached — so no + // isConnected guard here) and on each poll tick. + async function refreshJobs() { + let jobs = []; + try { + jobs = (await api.listImports()).jobs || []; + } catch { + return; + } + jobs = jobs.filter((j) => j.state !== "cancelled"); // hide user-cancelled jobs + renderJobs(jobs); + if (jobs.some((j) => j.state === "running")) ensurePolling(); + else stopPolling(); + } + + function jobCard(j) { + const running = j.state === "running"; + const pct = j.progress != null ? Math.round(j.progress * 100) : null; + // det = solid bar at a known %, run = indeterminate slider, ok/err = terminal. + let bar; + if (j.state === "done") { + bar = h("div", { class: "pbar-fill ok", style: "width:100%" }); + } else if (running && pct != null) { + bar = h("div", { class: "pbar-fill det", style: `width:${pct}%` }); + } else if (running) { + bar = h("div", { class: "pbar-fill run" }); // CSS drives width + animation + } else { + bar = h("div", { class: "pbar-fill err", style: "width:100%" }); // error / cancelled + } + const barWrap = h("div", { class: "pbar" }, bar); + const badge = h( + "span", + { class: "badge " + (j.state === "done" ? "" : running ? "" : "bad") }, + stateLabel(j.state) + + (running && pct != null ? " · " + pct + "%" : "") + + (j.size_bytes ? " · " + fmtBytes(j.size_bytes) : "") + ); + const phase = h("div", { class: "muted small" }, j.phase || ""); + const logEl = h("pre", { class: "conv-log" }, (j.log || []).slice(-10).join("\n")); + + const actions = []; + if (running) { + const c = h("button", { class: "btn ghost sm" }, "✕ " + t("import.cancel")); + c.addEventListener("click", async () => { + c.disabled = true; + try { + await api.cancelImport(j.id); + toast(t("import.cancelling"), "warn"); + refreshJobs(); + } catch (e) { + toast(e.message, "err"); + c.disabled = false; + } + }); + actions.push(c); + } + if (j.state === "done") { + const r = h("button", { class: "btn primary sm" }, "✓ " + t("import.register")); + r.addEventListener("click", async () => { + r.disabled = true; + try { + const rr = await api.registerImport(j.id); + toast(t("import.registered", { id: rr.model_id }), "ok"); + } catch (e) { + toast(e.message, "err"); + r.disabled = false; + } + }); + actions.push(r); + } + if (!running) { + // delete the job + its converted .cmf file(s) from disk + const d = h("button", { class: "btn ghost sm danger" }, "🗑 " + t("import.delete")); + d.addEventListener("click", async () => { + if (!window.confirm(t("import.deleteConfirm"))) return; + d.disabled = true; + try { + await api.deleteImport(j.id); + toast(t("import.deleted"), "ok"); + refreshJobs(); + } catch (e) { + toast(e.message, "err"); + d.disabled = false; + } + }); + actions.push(d); + } + + return h( + "div", + { class: "job" }, + h("div", { class: "job-head" }, h("b", {}, j.repo || j.id), badge), + barWrap, + phase, + logEl, + actions.length ? h("div", { class: "actions" }, ...actions) : null + ); + } + + function renderJobs(jobs) { + if (!jobs.length) { + mount(jobsHost); + return; + } + mount( + jobsHost, + h( + "div", + { class: "card" }, + h("div", { class: "card-title" }, t("import.jobs")), + ...jobs.map(jobCard) + ) + ); + } + + const root = h( + "div", + {}, + h("div", { class: "view-head" }, + h("h2", {}, t("nav.import")), + h("div", { class: "muted" }, t("import.subtitle"))), + jobsHost, // progress panel on top, above the search + h("div", { class: "card" }, searchIn, results), + formHost + ); + doSearch(); // initial trending list (mounts into `results`) + refreshJobs(); // resume: show any running/recent conversions immediately + return root; +} diff --git a/web/js/views/login.js b/web/js/views/login.js index e8ffd82..0f319e1 100644 --- a/web/js/views/login.js +++ b/web/js/views/login.js @@ -2,6 +2,7 @@ import { h } from "../ui.js"; import { t } from "../i18n.js"; import { setToken } from "../api.js"; +import { SITE_URL } from "../app.js"; export function renderLogin(onOk) { const input = h("input", { @@ -34,7 +35,12 @@ export function renderLogin(onOk) { h("p", { class: "sub" }, t("login.subtitle")), h("label", { class: "field" }, h("span", {}, t("login.token")), input), h("button", { class: "btn primary", style: "width:100%", onClick: submit }, t("login.submit")), - h("p", { class: "hint", style: "margin-top:16px" }, t("login.hint")) + h("p", { class: "hint", style: "margin-top:16px" }, t("login.hint")), + h( + "p", + { class: "hint", style: "margin-top:8px" }, + h("a", { href: SITE_URL, target: "_blank", rel: "noopener" }, t("nav.billing") + " · api.allaigate.com ↗") + ) ) ); }