diff --git a/Cargo.lock b/Cargo.lock index 9fbbd6e..976a882 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "cortiq-gateway" -version = "0.2.5" +version = "0.2.6" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index b3533a9..419ab70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cortiq-gateway" -version = "0.2.5" +version = "0.2.6" edition = "2021" rust-version = "1.75" license = "Apache-2.0" diff --git a/config/gateway.example.toml b/config/gateway.example.toml index 7f269fe..98e8a2c 100644 --- a/config/gateway.example.toml +++ b/config/gateway.example.toml @@ -142,7 +142,7 @@ max_entries = 1000 manage_server = false # true → gateway spawns `cortiq serve` at startup local_model = "models/model.cmf" # path to the .cmf to serve local_host = "127.0.0.1" -local_port = 8081 # must match the cmf-local base_url above +local_port = 8081 # confirm it's free in Settings → "Check port" (8081 often clashes with Docker/Apache) model_id = "cmf-local" # id it registers under (matches [[models]]) threads = 8 # decode worker threads (CMF_THREADS); ~3x vs serial gpu = false # offload matvecs to GPU (Metal/wgpu); mainly a prefill win diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 07b66d3..3a1e555 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -26,6 +26,7 @@ use axum::{ use serde::Deserialize; use serde_json::{json, Value}; use std::collections::HashMap; +use std::net::ToSocketAddrs; use std::time::Instant; // ───────────────────────────── common types / helpers ─────────────────────── @@ -171,6 +172,9 @@ pub fn api_routes(admin_token: String) -> Router { .route("/admin/api/import/:job", get(import_status).delete(delete_import)) .route("/admin/api/import/:job/cancel", post(cancel_import)) .route("/admin/api/import/:job/register", post(register_import)) + .route("/admin/api/cmf", get(cmf_status)) + .route("/admin/api/cmf/install", post(cmf_install)) + .route("/admin/api/cmf/port", get(cmf_port_check)) .route("/admin/api/requests", get(get_requests)) .route("/admin/api/test", post(run_test)) .route("/admin/api/test/stream", post(run_test_stream)) @@ -671,6 +675,107 @@ async fn put_settings(State(state): State, Json(b): Json) -> ApiResult { + let cmf = state.live().cfg.cmf.clone(); + let ver = crate::cmf_runtime::installed_version(&cmf.cortiq_bin); + let st = state.cmf.status(); + ok(json!({ + "installed": ver.is_some(), + "version": ver, + "running": st.running, + "healthy": st.healthy, + "last_error": st.last_error, + "log": st.log, + // serving info so the card can show "serving on :" + "manage_server": cmf.manage_server, + "local_model": cmf.local_model, + "model_id": cmf.model_id, + "local_host": cmf.local_host, + "local_port": cmf.local_port, + })) +} + +/// Install/upgrade the `cortiq` CLI from crates.io on demand (spawned in the +/// background; poll `GET /admin/api/cmf` for `installed`/`version`). +async fn cmf_install(State(state): State) -> ApiResult { + let bin = state.live().cfg.cmf.cortiq_bin.clone(); + tokio::spawn(crate::cmf_runtime::install_now(state.cmf.clone(), bin)); + ok(json!({ "started": true })) +} + +#[derive(Deserialize)] +struct PortQuery { + port: u16, +} + +/// Check whether a TCP port is free to bind on the CMF local host, so a user can +/// pick an available port before enabling the managed server (avoids clashing +/// with something already on the default 8081). If the port is the one this +/// gateway's own managed server already runs on, that is reported distinctly. +/// When occupied, `suggested` holds the next free port so the UI can offer it. +async fn cmf_port_check(State(state): State, Query(q): Query) -> ApiResult { + let cmf = state.live().cfg.cmf.clone(); + let host = if cmf.local_host.trim().is_empty() { + "127.0.0.1".to_string() + } else { + cmf.local_host.clone() + }; + let st = state.cmf.status(); + let port = q.port; + let running_here = port == cmf.local_port && st.running; + + // The bind+connect probe is blocking std::net; run it off the async runtime. + let (available, detail, suggested) = tokio::task::spawn_blocking(move || { + if running_here { + return (false, "in use by this gateway's managed CMF server", None); + } + if port_is_free(&host, port) { + (true, "free", None) + } else { + (false, "occupied by another process", next_free_port(&host, port)) + } + }) + .await + .unwrap_or((false, "port check failed", None)); + + ok(json!({ + "port": port, + "available": available, + "detail": detail, + "suggested": suggested, + })) +} + +/// Whether `port` is truly free on `host`. A bind test alone is not enough: a +/// Docker-published / dual-stack listener can leave an IPv4 bind succeeding even +/// though connections to the port are forwarded to a container. So a port counts +/// as free only if it is BOTH bindable AND nothing answers a connect attempt. +fn port_is_free(host: &str, port: u16) -> bool { + if std::net::TcpListener::bind((host, port)).is_err() { + return false; // something is bound to exactly this address + } + // catch proxied/forwarded ports a bind test misses (e.g. Docker on macOS) + let reachable = (host, port) + .to_socket_addrs() + .ok() + .and_then(|mut it| it.next()) + .map(|addr| { + std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(200)) + .is_ok() + }) + .unwrap_or(false); + !reachable +} + +/// First free port in (from, from+50], or None if all are taken. +fn next_free_port(host: &str, from: u16) -> Option { + (from.saturating_add(1)..=from.saturating_add(50)).find(|p| port_is_free(host, *p)) +} + // ───────────────────────────── keys ──────────────────────────────────────── async fn list_keys(State(state): State) -> ApiResult { diff --git a/src/cmf_runtime.rs b/src/cmf_runtime.rs index 17bbf92..19db1e3 100644 --- a/src/cmf_runtime.rs +++ b/src/cmf_runtime.rs @@ -119,7 +119,7 @@ async fn latest_crates_version() -> Option { } /// ` --version` → the trailing version token (e.g. "cortiq 0.1.2" → "0.1.2"). -fn installed_version(bin: &str) -> Option { +pub(crate) fn installed_version(bin: &str) -> Option { let out = std::process::Command::new(bin).arg("--version").output().ok()?; if !out.status.success() { return None; @@ -152,6 +152,21 @@ async fn cargo_install(force: bool) -> Result<(), String> { } } +/// Install/upgrade `cortiq-cli` from crates.io on demand (the admin "Install +/// cortiq" button). Runs in the background; progress + result land in the CMF +/// status log, and `installed_version` is refreshed on success. +pub async fn install_now(rt: Arc, bin: String) { + rt.log("installing cortiq-cli from crates.io (this compiles — a few minutes)…"); + match cargo_install(true).await { + Ok(()) => { + let v = installed_version(&bin); + rt.status.lock().unwrap().installed_version = v.clone(); + rt.log(format!("installed cortiq-cli {}", v.as_deref().unwrap_or("?"))); + } + Err(e) => rt.fail(format!("cargo install cortiq-cli failed: {e}")), + } +} + /// Spawn `cortiq serve --host --port `; the child is killed /// when its handle is dropped (i.e. when the gateway shuts down). fn spawn_serve(cfg: &CmfCfg) -> Result { diff --git a/web/css/styles.css b/web/css/styles.css index 6005239..0d4c04e 100644 --- a/web/css/styles.css +++ b/web/css/styles.css @@ -177,6 +177,18 @@ textarea { resize: vertical; min-height: 64px; } .check input { width: auto; } .hint { color: var(--faint); font-size: 12px; margin-top: 4px; } +/* "?" help badge — hover/focus shows the native title tooltip explaining a setting */ +.help { + display: inline-flex; align-items: center; justify-content: center; + width: 15px; height: 15px; border-radius: 50%; + border: 1px solid var(--border); background: var(--panel); + color: var(--muted); font-size: 10px; font-weight: 700; line-height: 1; + cursor: help; vertical-align: middle; user-select: none; + transition: 0.12s; +} +.help:hover, .help:focus { color: var(--accent); border-color: var(--accent); outline: none; } +label.field > span .help { text-transform: none; } + /* ---- buttons ------------------------------------------------------------- */ .btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; diff --git a/web/js/api.js b/web/js/api.js index 74a027b..f869d26 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -95,6 +95,11 @@ export const api = { // shadow / self-warming loop shadow: () => j("GET", "/shadow"), + // CMF runtime (the `cortiq` CLI that runs the .cmf format) + cmfStatus: () => j("GET", "/cmf"), + cmfInstall: () => j("POST", "/cmf/install"), + cmfPortCheck: (port) => j("GET", "/cmf/port?port=" + encodeURIComponent(port)), + // CMF model factory (HuggingFace → local .cmf) hfSearch: (q, limit = 24) => j("GET", `/hf/search?q=${encodeURIComponent(q || "")}&limit=${limit}`), diff --git a/web/js/i18n/en.js b/web/js/i18n/en.js index 8e899d1..f68169c 100644 --- a/web/js/i18n/en.js +++ b/web/js/i18n/en.js @@ -242,6 +242,38 @@ export default { "settings.cmf.threads": "Worker threads", "settings.cmf.gpu": "Use GPU (Metal / wgpu)", "settings.cmf.speedNote": "Threads parallelise the decode matvecs (~3x faster; 0 = runtime default). GPU offload mainly helps prefill of long prompts — decode is memory-bandwidth-bound, so on Apple Silicon it is roughly neutral.", + "settings.cmf.checkPort": "Check port", + "settings.cmf.portFree": "port is free", + "settings.cmf.portBusy": "port is in use", + "settings.cmf.useSuggested": "use {port}", + "settings.cmf.serving": "serving {model} on :{port}", + "settings.cmf.intro": "Run models locally with the cortiq engine — works even offline or when the router is down.", + "settings.cmf.runtime": "cortiq runtime", + "settings.cmf.runtimeHelp": "The `cortiq` command-line tool that loads and runs .cmf models. Install it once before starting a local server.", + "settings.cmf.install": "Install cortiq", + "settings.cmf.reinstall": "Reinstall / update", + "settings.cmf.installing": "installing… (compiles, a few minutes)", + "settings.cmf.installedOk": "cortiq installed", + "settings.cmf.notInstalled": "not installed", + "settings.cmf.addModel": "Add a model", + "settings.cmf.addModelHelp": "Models are downloaded and converted in the Import tab (HuggingFace → .cmf). Then point the local server at the .cmf file below.", + "settings.cmf.importNote": "No model yet? Import one:", + "settings.cmf.importLink": "Open Import", + "settings.cmf.modelHelp": "Path to the .cmf file to serve (e.g. models/qwen.cmf), produced by the Import tab. Running several models for different tasks? Import them and switch this path.", + "settings.cmf.portHelp": "TCP port the local model server listens on (default 8081). Change only if it clashes with another service.", + "settings.cmf.threadsHelp": "How many CPU cores to use for generation. More = faster (~3× vs 1 core); 0 lets the runtime decide.", + "settings.cmf.gpuHelp": "Offload matrix math to the GPU (Metal/wgpu). On Apple Silicon this mostly speeds up long prompts — decode is memory-bandwidth-bound.", + "settings.cmf.localOnlyHelp": "Route every request to the local model, ignoring the router and any cloud providers.", + "settings.cmf.manageHelp": "The gateway starts `cortiq serve` for the .cmf below at launch and routes to it. Needs cortiq installed (step 1). Applies on restart.", + "settings.cmf.autoInstallHelp": "If cortiq is missing at startup, install it automatically from crates.io.", + "settings.cmf.autoUpdateHelp": "On startup, upgrade cortiq to the newest release when a newer one exists.", + "settings.router.enabledHelp": "Use the cortiq-router to classify each request (task type + complexity) and pick a model. Off = use the local routing table only.", + "settings.router.verifyTlsHelp": "Verify the router's TLS certificate. Turn off only for a self-signed / IP-bound certificate.", + "settings.telemetry.metricsHelp": "Expose Prometheus metrics at GET /metrics.", + "settings.cortiq.echoHelp": "Add a `cortiq` field (task, model, cost) to each response body. Response headers are always sent.", + "settings.stats.enabledHelp": "Record recent requests to power the dashboard and charts.", + "settings.cache.enabledHelp": "Semantic cache: return a saved answer when a new prompt is very similar to a past one, skipping the model call. Needs an embedding model in the pool.", + "settings.log.bodiesHelp": "Log full request/response message bodies. Off by default for privacy.", "settings.router.taxonomy": "Taxonomy id", "settings.router.getKey": "Get or top up a router key at", "settings.router.secret": "Router key (value)", diff --git a/web/js/i18n/ru.js b/web/js/i18n/ru.js index 2bc4a2a..8cfc521 100644 --- a/web/js/i18n/ru.js +++ b/web/js/i18n/ru.js @@ -241,6 +241,38 @@ export default { "settings.cmf.threads": "Рабочие потоки", "settings.cmf.gpu": "Использовать GPU (Metal / wgpu)", "settings.cmf.speedNote": "Потоки распараллеливают декод-матвеки (~×3; 0 = по умолчанию рантайма). GPU-оффлоад помогает в основном на prefill длинных промптов — декод упирается в пропускную способность памяти, поэтому на Apple Silicon почти нейтрален.", + "settings.cmf.checkPort": "Проверить порт", + "settings.cmf.portFree": "порт свободен", + "settings.cmf.portBusy": "порт занят", + "settings.cmf.useSuggested": "занять {port}", + "settings.cmf.serving": "работает: {model} на :{port}", + "settings.cmf.intro": "Запускайте модели локально движком cortiq — работает даже офлайн или когда роутер недоступен.", + "settings.cmf.runtime": "Среда cortiq", + "settings.cmf.runtimeHelp": "Инструмент командной строки `cortiq`, который загружает и запускает .cmf-модели. Установите один раз перед запуском локального сервера.", + "settings.cmf.install": "Установить cortiq", + "settings.cmf.reinstall": "Переустановить / обновить", + "settings.cmf.installing": "устанавливается… (компилируется, пара минут)", + "settings.cmf.installedOk": "cortiq установлен", + "settings.cmf.notInstalled": "не установлен", + "settings.cmf.addModel": "Добавить модель", + "settings.cmf.addModelHelp": "Модели скачиваются и конвертируются во вкладке Импорт (HuggingFace → .cmf). Затем укажите путь к .cmf ниже.", + "settings.cmf.importNote": "Ещё нет модели? Импортируйте:", + "settings.cmf.importLink": "Открыть Импорт", + "settings.cmf.modelHelp": "Путь к .cmf-файлу для запуска (напр. models/qwen.cmf), создаётся во вкладке Импорт. Несколько моделей под разные задачи? Импортируйте их и меняйте этот путь.", + "settings.cmf.portHelp": "TCP-порт локального сервера моделей (по умолчанию 8081). Меняйте, только если конфликтует с другим сервисом.", + "settings.cmf.threadsHelp": "Сколько ядер CPU использовать для генерации. Больше = быстрее (~×3 против 1 ядра); 0 — на усмотрение рантайма.", + "settings.cmf.gpuHelp": "Выгрузить матричные операции на GPU (Metal/wgpu). На Apple Silicon ускоряет в основном длинные промпты — декод упирается в пропускную способность памяти.", + "settings.cmf.localOnlyHelp": "Направлять все запросы в локальную модель, игнорируя роутер и облачные провайдеры.", + "settings.cmf.manageHelp": "Шлюз запускает `cortiq serve` для .cmf ниже при старте и маршрутизирует в него. Нужен установленный cortiq (шаг 1). Применяется после перезапуска.", + "settings.cmf.autoInstallHelp": "Если cortiq не найден при старте — установить автоматически с crates.io.", + "settings.cmf.autoUpdateHelp": "При старте обновлять cortiq до новейшей версии, когда есть новее.", + "settings.router.enabledHelp": "Использовать cortiq-router для классификации запроса (тип задачи + сложность) и выбора модели. Выкл = только локальная таблица маршрутизации.", + "settings.router.verifyTlsHelp": "Проверять TLS-сертификат роутера. Выключайте только для самоподписанного / привязанного к IP сертификата.", + "settings.telemetry.metricsHelp": "Отдавать метрики Prometheus по GET /metrics.", + "settings.cortiq.echoHelp": "Добавлять поле `cortiq` (задача, модель, стоимость) в тело каждого ответа. Заголовки ответа шлются всегда.", + "settings.stats.enabledHelp": "Записывать недавние запросы для дашборда и графиков.", + "settings.cache.enabledHelp": "Семантический кэш: возвращать сохранённый ответ, когда новый промпт очень похож на прошлый, пропуская вызов модели. Нужна embedding-модель в пуле.", + "settings.log.bodiesHelp": "Логировать полные тела запросов/ответов. По умолчанию выкл ради приватности.", "settings.router.taxonomy": "ID таксономии", "settings.router.getKey": "Получить или пополнить ключ роутера:", "settings.router.secret": "Ключ роутера (значение)", diff --git a/web/js/views/settings.js b/web/js/views/settings.js index acf8a3e..6b74289 100644 --- a/web/js/views/settings.js +++ b/web/js/views/settings.js @@ -4,12 +4,22 @@ import { t } from "../i18n.js"; import { api } from "../api.js"; import { appState, SITE_URL } from "../app.js"; -function field(label, control, hint) { - return h("label", { class: "field" }, h("span", {}, label), control, hint ? h("div", { class: "hint" }, hint) : null); +// A small "?" badge with a hover/focus tooltip explaining a setting. +function help(text) { + return text ? h("span", { class: "help", title: text, tabindex: "0", role: "img", "aria-label": text }, "?") : null; } -function check(label, checked) { +function field(label, control, hint, helpText) { + return h( + "label", + { class: "field" }, + h("span", {}, label, helpText ? " " : null, help(helpText)), + control, + hint ? h("div", { class: "hint" }, hint) : null + ); +} +function check(label, checked, helpText) { const cb = h("input", { type: "checkbox", checked: checked ? true : null }); - return { node: h("label", { class: "check" }, cb, label), cb }; + return { node: h("label", { class: "check" }, cb, label, helpText ? " " : null, help(helpText)), cb }; } // Flatten router usage (nested account/usage objects) into readable, wrapping @@ -59,8 +69,8 @@ export async function renderSettings() { const rKeyEnv = h("input", { value: s.router?.api_key_env || "", placeholder: "CORTIQ_ROUTER_KEY" }); const rTimeout = h("input", { type: "number", value: s.router?.timeout_ms ?? 800 }); const rTax = h("input", { value: s.router?.taxonomy_id || "" }); - const rVerify = check(t("settings.router.verifyTls"), s.router?.verify_tls); - const rEnabled = check(t("settings.router.enabled"), s.router?.enabled !== false); + const rVerify = check(t("settings.router.verifyTls"), s.router?.verify_tls, t("settings.router.verifyTlsHelp")); + const rEnabled = check(t("settings.router.enabled"), s.router?.enabled !== false, t("settings.router.enabledHelp")); // router key value (write-only; stored in the secret store, like model keys) const rKeySource = (secretsData.secrets || []).find((x) => x.name === (s.router?.api_key_env || "CORTIQ_ROUTER_KEY"))?.source || @@ -108,37 +118,16 @@ export async function renderSettings() { rKeyBadge.className = "badge store"; return true; } - const saveKeyBtn = h("button", { class: "btn sm" }, "✓ " + t("settings.router.saveKey")); - saveKeyBtn.addEventListener("click", async () => { - saveKeyBtn.disabled = true; - try { - if (await saveKey()) toast(t("settings.router.keySaved"), "good"); - else toast(t("settings.router.keyEmpty"), "warn"); - } catch (e) { - toast(String(e.message || e), "bad"); - } - saveKeyBtn.disabled = false; - }); - // "Test router" — real /v1/route call: tells key problems apart from a down router - const probeBtn = h("button", { class: "btn" }, t("settings.router.test")); + // Shared connection test (real /v1/route call) — tells key problems apart from + // a down router. Rendered into probeOut; also used right after saving the key. const probeOut = h("div", { class: "hint", style: "margin-top:8px" }); - probeBtn.addEventListener("click", async () => { - probeBtn.disabled = true; + async function runProbe() { mount(probeOut, h("span", { class: "spinner" })); try { - // If a key was just pasted but not saved, persist it first so the test uses it. - if (rSecret.value.trim()) { - try { - await saveKey(); - } catch (_) { - /* fall through — probe will report no_key/auth */ - } - } const r = await api.probeRouter(); const parts = [t(probeMsgKey(r.status), { ms: r.latency_ms ?? "—" })]; if (r.message) parts.push(" — " + r.message); - const line = h("span", {}, ...parts); - const out = [line]; + const out = [h("span", {}, ...parts)]; if (PROBE_SITE_STATUSES.includes(r.status)) { out.push(" · ", h("a", { href: SITE_URL, target: "_blank", rel: "noopener" }, t("dash.health.getKey") + " ↗")); } @@ -150,9 +139,37 @@ export async function renderSettings() { } mount(probeOut, h("div", {}, h("span", { class: "badge " + (r.ok ? "ok" : "bad") }, r.ok ? "OK" : "×"), " ", ...out)); toast(t(probeMsgKey(r.status), { ms: r.latency_ms ?? "—" }), r.ok ? "good" : "bad"); + return r.ok; } catch (e) { mount(probeOut, String(e.message || e)); + return false; } + } + const saveKeyBtn = h("button", { class: "btn sm" }, "✓ " + t("settings.router.saveKey")); + saveKeyBtn.addEventListener("click", async () => { + saveKeyBtn.disabled = true; + try { + if (await saveKey()) { + toast(t("settings.router.keySaved"), "good"); + // Immediately test the connection so the user sees reachable/error at once, + // instead of a stale "unavailable" until something re-checks. + if (rEnabled.cb.checked) await runProbe(); + } else { + toast(t("settings.router.keyEmpty"), "warn"); + } + } catch (e) { + toast(String(e.message || e), "bad"); + } + saveKeyBtn.disabled = false; + }); + const probeBtn = h("button", { class: "btn" }, t("settings.router.test")); + probeBtn.addEventListener("click", async () => { + probeBtn.disabled = true; + // If a key was pasted but not saved, persist it first so the test uses it. + if (rSecret.value.trim()) { + try { await saveKey(); } catch (_) { /* probe will report no_key/auth */ } + } + await runProbe(); probeBtn.disabled = false; }); // route @@ -165,18 +182,18 @@ export async function renderSettings() { const brCool = h("input", { value: s.breaker?.cooldown || "30s" }); // log const logSel = h("select", {}, ...["error", "warn", "info", "debug", "trace"].map((x) => h("option", { value: x, selected: x === s.log?.level }, x))); - const logBodies = check(t("settings.log.bodies"), s.log?.bodies); + const logBodies = check(t("settings.log.bodies"), s.log?.bodies, t("settings.log.bodiesHelp")); // telemetry / cortiq - const metricsCb = check(t("settings.telemetry.metrics"), s.telemetry?.metrics); - const echoCb = check(t("settings.cortiq.echo"), s.cortiq?.echo); + const metricsCb = check(t("settings.telemetry.metrics"), s.telemetry?.metrics, t("settings.telemetry.metricsHelp")); + const echoCb = check(t("settings.cortiq.echo"), s.cortiq?.echo, t("settings.cortiq.echoHelp")); // stats - const statsEnabled = check(t("settings.stats.enabled"), s.stats?.enabled); + const statsEnabled = check(t("settings.stats.enabled"), s.stats?.enabled, t("settings.stats.enabledHelp")); const statsFile = h("input", { value: s.stats?.file || "" }); const statsRet = h("input", { value: s.stats?.retention || "7d" }); // cache const modelsData = await api.listModels(); const embedModels = (modelsData.models || []).filter((m) => m.kind === "embedding").map((m) => m.id); - const cacheEnabled = check(t("settings.cache.enabled"), s.cache?.enabled); + const cacheEnabled = check(t("settings.cache.enabled"), s.cache?.enabled, t("settings.cache.enabledHelp")); const cacheThresh = h("input", { type: "number", step: "0.01", value: s.cache?.threshold ?? 0.92 }); const cacheTtlIn = h("input", { value: s.cache?.ttl || "1h" }); const cacheEmbedSel = h( @@ -186,15 +203,93 @@ export async function renderSettings() { ...embedModels.map((id) => h("option", { value: id, selected: id === s.cache?.embed_model }, id)) ); // local models (CMF) — run a local .cmf server and/or force local-only routing - const cmfLocalOnly = check(t("settings.cmf.localOnly"), s.cmf?.local_only); - const cmfManage = check(t("settings.cmf.manage"), s.cmf?.manage_server); - const cmfAutoInstall = check(t("settings.cmf.autoInstall"), s.cmf?.auto_install); - const cmfAutoUpdate = check(t("settings.cmf.autoUpdate"), s.cmf?.auto_update); - const cmfModel = h("input", { value: s.cmf?.local_model || "", placeholder: "models/model.cmf" }); + const cmfLocalOnly = check(t("settings.cmf.localOnly"), s.cmf?.local_only, t("settings.cmf.localOnlyHelp")); + const cmfManage = check(t("settings.cmf.manage"), s.cmf?.manage_server, t("settings.cmf.manageHelp")); + const cmfAutoInstall = check(t("settings.cmf.autoInstall"), s.cmf?.auto_install, t("settings.cmf.autoInstallHelp")); + const cmfAutoUpdate = check(t("settings.cmf.autoUpdate"), s.cmf?.auto_update, t("settings.cmf.autoUpdateHelp")); + const cmfModel = h("input", { value: s.cmf?.local_model || "", placeholder: "models/my-model.cmf" }); const cmfPort = h("input", { type: "number", value: s.cmf?.local_port ?? 8081 }); - const cmfModelId = h("input", { value: s.cmf?.model_id || "cmf-local" }); const cmfThreads = h("input", { type: "number", min: 0, value: s.cmf?.threads ?? 8 }); - const cmfGpu = check(t("settings.cmf.gpu"), s.cmf?.gpu); + const cmfGpu = check(t("settings.cmf.gpu"), s.cmf?.gpu, t("settings.cmf.gpuHelp")); + const cmfModelIdVal = s.cmf?.model_id || "cmf-local"; // auto — no manual "model id" field + // "Install cortiq" — the CLI that RUNS the .cmf format. Must be present before + // a local model server can start; this is step 1 for a new user. + const cmfStatusEl = h("span", { class: "muted" }, "…"); + const cmfInstallBtn = h("button", { class: "btn" }, t("settings.cmf.install")); + // live "serving on :" indicator for the managed local server + const cmfServingEl = h("div", { class: "hint" }); + async function refreshCmfStatus() { + try { + const st = await api.cmfStatus(); + cmfInstallBtn.textContent = st.installed ? t("settings.cmf.reinstall") : t("settings.cmf.install"); + mount( + cmfStatusEl, + st.installed + ? h("span", {}, h("span", { class: "badge ok" }, "✓"), " cortiq " + (st.version || "")) + : h("span", {}, h("span", { class: "badge bad" }, "×"), " " + t("settings.cmf.notInstalled")) + ); + // reflect the managed server state under the "run locally" toggle + if (st.manage_server && st.running) { + mount(cmfServingEl, h("span", { style: "color:var(--good)" }, + "▶ " + t("settings.cmf.serving", { model: st.model_id || "", port: st.local_port }) + (st.healthy ? "" : " …"))); + } else if (st.last_error) { + mount(cmfServingEl, h("span", { style: "color:var(--bad)" }, "✗ " + st.last_error)); + } else { + mount(cmfServingEl, ""); + } + } catch (_) { + mount(cmfStatusEl, "—"); + } + } + cmfInstallBtn.addEventListener("click", async () => { + cmfInstallBtn.disabled = true; + mount(cmfStatusEl, h("span", {}, h("span", { class: "spinner" }), " " + t("settings.cmf.installing"))); + try { + await api.cmfInstall(); + // install compiles from crates.io — poll status for a few minutes + for (let i = 0; i < 120; i++) { + await new Promise((r) => setTimeout(r, 3000)); + const st = await api.cmfStatus(); + if (st.installed) { toast(t("settings.cmf.installedOk"), "good"); break; } + if (st.last_error) { toast(st.last_error, "bad"); break; } + } + } catch (e) { + toast(String(e.message || e), "bad"); + } + await refreshCmfStatus(); + cmfInstallBtn.disabled = false; + }); + refreshCmfStatus(); + + // Port availability check — let the user confirm the chosen port is free + // before enabling the managed server (the default 8081 often clashes). + const cmfPortStatus = h("span", { class: "small", style: "margin-left:8px" }); + const cmfPortCheckBtn = h("button", { class: "btn small", type: "button" }, t("settings.cmf.checkPort")); + async function checkPort() { + const p = parseInt(cmfPort.value, 10); + if (!p) { mount(cmfPortStatus, ""); return; } + cmfPortCheckBtn.disabled = true; + mount(cmfPortStatus, t("status.checking")); + try { + const r = await api.cmfPortCheck(p); + if (r.available) { + mount(cmfPortStatus, h("span", { style: "color:var(--good)" }, "✓ " + t("settings.cmf.portFree"))); + } else { + const kids = [h("span", { style: "color:var(--bad)" }, "✗ " + t("settings.cmf.portBusy"))]; + if (r.suggested) { + const useBtn = h("button", { class: "btn small", type: "button", style: "margin-left:8px" }, + t("settings.cmf.useSuggested", { port: r.suggested })); + useBtn.addEventListener("click", () => { cmfPort.value = r.suggested; checkPort(); }); + kids.push(useBtn); + } + mount(cmfPortStatus, h("span", {}, ...kids)); + } + } catch (e) { + mount(cmfPortStatus, String(e.message || e)); + } + cmfPortCheckBtn.disabled = false; + } + cmfPortCheckBtn.addEventListener("click", checkPort); const saveBtn = h("button", { class: "btn primary" }, t("common.save")); saveBtn.addEventListener("click", async () => { @@ -242,7 +337,7 @@ export async function renderSettings() { auto_update: cmfAutoUpdate.cb.checked, local_model: cmfModel.value.trim(), local_port: parseInt(cmfPort.value) || 8081, - model_id: cmfModelId.value.trim() || "cmf-local", + model_id: cmfModelIdVal, threads: parseInt(cmfThreads.value) || 0, gpu: cmfGpu.cb.checked, }, @@ -372,13 +467,33 @@ export async function renderSettings() { "div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("settings.cmf"))), - cmfLocalOnly.node, - cmfManage.node, + h("div", { class: "hint" }, t("settings.cmf.intro")), + // Step 1 — install the cortiq runtime (the CLI that runs .cmf models). + h( + "label", + { class: "field" }, + h("span", {}, "1. " + t("settings.cmf.runtime"), " ", help(t("settings.cmf.runtimeHelp"))), + h("div", { class: "flex", style: "gap:8px;align-items:center;flex-wrap:wrap" }, cmfInstallBtn, cmfStatusEl) + ), h("div", { class: "row" }, cmfAutoInstall.node, cmfAutoUpdate.node), - field(t("settings.cmf.model"), cmfModel), - h("div", { class: "row" }, field(t("settings.cmf.port"), cmfPort), field(t("settings.cmf.modelId"), cmfModelId)), - h("div", { class: "row" }, field(t("settings.cmf.threads"), cmfThreads), cmfGpu.node), - h("div", { class: "hint" }, t("settings.cmf.note")), + h("div", { class: "divider" }), + // Step 2 — add a model. Models are imported in the Models/Import tab; here + // you just point the local server at the .cmf file it produced. + h("div", { class: "field" }, h("span", {}, "2. " + t("settings.cmf.addModel"), " ", help(t("settings.cmf.addModelHelp"))), + h("div", { class: "hint" }, t("settings.cmf.importNote"), + " ", h("a", { href: "#/import" }, t("settings.cmf.importLink") + " ↗"))), + field(t("settings.cmf.model"), cmfModel, null, t("settings.cmf.modelHelp")), + h("div", { class: "divider" }), + // Step 3 — run it locally + performance. + cmfManage.node, + cmfServingEl, + h("div", { class: "row" }, + field(t("settings.cmf.port"), + h("div", { class: "flex wrap" }, cmfPort, cmfPortCheckBtn, cmfPortStatus), + null, t("settings.cmf.portHelp")), + field(t("settings.cmf.threads"), cmfThreads, null, t("settings.cmf.threadsHelp"))), + cmfGpu.node, + cmfLocalOnly.node, h("div", { class: "hint" }, t("settings.cmf.speedNote")) ) ),