Files
cortiq-gateway/web/js/views/settings.js
T
infosave2007 15de36be3b feat: ops & UX batch — jobs survive restarts, logs/system in the console, media queue, gallery, chat, exact stream tokens
Fifteen changes aimed at the first day of real use:

- Import and media jobs persist to <data>/jobs-*.json and reload on start
  (running ones settle as errors); the Register button no longer vanishes
  because the gateway restarted.
- Docker ships the cortiq binary (built in the builder stage) — local .cmf
  chat models and image/video generation now work in containers, where
  auto-install never could (no cargo in the runtime image). libvulkan1 added
  for optional GPU offload.
- Open admin console on 0.0.0.0 without a token now warns in the log and
  shows a dismissible banner — the VPS footgun is at least labeled.
- "Restart gateway" button in Settings: stops managed model servers, spawns
  a fresh copy (bind retried while the port frees), Docker relies on the
  container restart policy instead.
- Dashboard "System" panel: CPU sparkline + load, RAM (with the gateway's
  own RSS), free disk at the models dir — sampled by sysinfo every 2 s.
- Featured model cards warn "not enough disk" / "may not fit in RAM" before
  the download, from the same live snapshot.
- Log viewer in the sidebar: a 500-line in-memory ring fed by a second
  ANSI-free tracing layer; filter, level, pause, copy.
- Update check: crates.io polled twice a day, the console shows an
  "update available" notice.
- Media generations queue up (with position shown) instead of being
  rejected; progress gained an honest ETA from measured throughput.
- Generation gallery under the image/video tabs: lazy PPM thumbnails,
  click to reopen with seed/steps, delete.
- Playground chat is a real dialogue now: history is resent, bubbles,
  Clear, Cmd/Ctrl+Enter.
- "Connect your app" snippets (curl / Python / JS) on the keys page, with
  the real key inlined right after creation.
- One-click benchmark on the Models page via `cortiq bench --json`.
- Config export now downloads the actual gateway.toml (comments intact);
  import accepts TOML (validated, hot-reloaded) and the legacy JSON.
- Streaming token counts are exact when the model server provides usage
  (cortiq-server patch, shipped separately); the gateway fallback now
  counts content chunks (1/token on cortiq) instead of chars/4.

Wizard's Finish lands on the Playground. 37 new strings in all 7 languages.
2026-08-07 17:07:48 +03:00

668 lines
32 KiB
JavaScript
Vendored
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Settings — router/route/breaker/log/telemetry/stats + export/import config.
import { h, mount, toast, confirmDialog } from "../ui.js";
import { t } from "../i18n.js";
import { api, getToken, postRaw } from "../api.js";
import { appState, SITE_URL } from "../app.js";
// 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 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, helpText ? " " : null, help(helpText)), cb };
}
// Flatten router usage (nested account/usage objects) into readable, wrapping
// key: value rows — a raw JSON.stringify dump overflows the card horizontally.
function usageRows(obj, prefix) {
const rows = [];
for (const [k, v] of Object.entries(obj || {})) {
const key = prefix ? prefix + "." + k : k;
if (v && typeof v === "object" && !Array.isArray(v)) {
rows.push(...usageRows(v, key));
} else {
rows.push(
h("div", { class: "mono kv" },
h("span", { class: "kv-k" }, key + ": "),
h("span", { class: "kv-v" }, Array.isArray(v) ? JSON.stringify(v) : String(v)))
);
}
}
return rows;
}
// Probe status → i18n key suffix (see /admin/api/router/probe).
function probeMsgKey(status) {
const map = {
ok: "ok",
no_key: "noKey",
auth: "auth",
payment: "payment",
quota: "quota",
timeout: "timeout",
unreachable: "unreachable",
};
return "router.probe." + (map[status] || "error");
}
// Statuses where the fix is on the site (get / pay for a key).
const PROBE_SITE_STATUSES = ["no_key", "auth", "payment", "quota"];
export async function renderSettings() {
const meta = appState.meta || (await api.meta());
const [s, secretsData] = await Promise.all([api.getSettings(), api.listSecrets()]);
const root = h("div");
const listenIn = h("input", { value: s.listen || "" });
// router
const rUrl = h("input", { value: s.router?.url || "" });
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, 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 ||
"missing";
const keyStored = rKeySource === "store" || rKeySource === "env";
// When a key is already saved, show a masked "saved" hint instead of an empty
// field (an empty field reads as "nothing is set").
const rSecret = h("input", {
type: "password",
placeholder: keyStored ? t("settings.router.keyStoredMask") : t("models.form.secretPlaceholder"),
});
const rKeyBadge = h(
"span",
{ class: "badge " + (rKeySource === "store" ? "store" : rKeySource === "env" ? "env" : "bad") },
rKeySource === "store"
? t("models.form.secretStored")
: rKeySource === "env"
? t("models.form.secretEnv")
: t("models.form.secretMissing")
);
const rSecretHint = h(
"div",
{},
h(
"div",
{},
rKeyBadge,
" · ",
t("settings.router.getKey") + " ",
h("a", { href: SITE_URL, target: "_blank", rel: "noopener" }, "api.allaigate.com")
),
h("div", { class: "muted", style: "margin-top:4px" }, t("settings.router.secretHelp"))
);
// The key lives in the secret store under this env name (config default: CORTIQ_ROUTER_KEY).
const keyName = () => rKeyEnv.value.trim() || "CORTIQ_ROUTER_KEY";
// Persist just the pasted router key (independent of the big Save at page top).
async function saveKey() {
const v = rSecret.value.trim();
if (!v) return false;
if (!rKeyEnv.value.trim()) rKeyEnv.value = "CORTIQ_ROUTER_KEY";
await api.setSecret(keyName(), v);
rSecret.value = "";
rSecret.placeholder = t("settings.router.keyStoredMask"); // show "saved", not empty
rKeyBadge.textContent = t("models.form.secretStored");
rKeyBadge.className = "badge store";
return true;
}
// 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" });
async function runProbe() {
mount(probeOut, h("span", { class: "spinner" }));
try {
const r = await api.probeRouter();
const parts = [t(probeMsgKey(r.status), { ms: r.latency_ms ?? "—" })];
if (r.message) parts.push(" — " + r.message);
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") + " ↗"));
}
if (r.usage && typeof r.usage === "object") {
out.push(
h("div", { class: "usage-box", style: "margin-top:4px" },
h("b", {}, t("router.usage")), ...usageRows(r.usage))
);
}
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
const strategySel = h("select", {}, ...(meta.text_strategies || []).map((x) => h("option", { value: x, selected: x === s.route?.text_strategy }, x)));
const maxChars = h("input", { type: "number", value: s.route?.max_chars ?? 4000 });
const cacheTtl = h("input", { value: s.route?.cache_ttl || "60s" });
const profileSel = h("select", {}, ...(meta.profiles || []).map((x) => h("option", { value: x, selected: x === s.route?.profile }, x)));
// breaker
const brThresh = h("input", { type: "number", value: s.breaker?.threshold ?? 5 });
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, t("settings.log.bodiesHelp"));
// telemetry / cortiq
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, 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, 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(
"select",
{},
h("option", { value: "" }, "auto"),
...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, 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"));
// ── managed local models: one supervised `cortiq serve` per row ──
const serverRows = [];
const serversWrap = h("div", {});
const legacyServers = s.cmf?.local_model
? [{ id: s.cmf.model_id || "cmf-local", model: s.cmf.local_model, port: s.cmf.local_port ?? 8081, threads: s.cmf.threads ?? 8, gpu: !!s.cmf.gpu }]
: [];
const initServers = (s.cmf?.servers && s.cmf.servers.length) ? s.cmf.servers : legacyServers;
// available .cmf files under models_dir → offer as a dropdown, not a typed path
const cmfFiles = ((await api.cmfFiles().catch(() => ({ files: [] }))).files) || [];
const fileLabel = (f) => f.name + (f.size ? ` (${(f.size / 1073741824).toFixed(f.size >= 1073741824 ? 1 : 2)} GB)` : f.is_dir ? " (dir)" : "");
function autoId(path) {
const base = (path || "").split("/").pop().replace(/\.cmf$/i, "");
return base ? "cmf-" + base.replace(/[^a-zA-Z0-9_-]/g, "-") : "";
}
function makeServerRow(sv) {
sv = sv || { id: "", model: "", port: 8090, threads: 8, gpu: false };
const idIn = h("input", { value: sv.id || "", placeholder: "cmf-id", style: "width:130px" });
// pick from existing .cmf files rather than typing a path
const modelSel = h("select", { style: "flex:2;min-width:150px" });
const seen = new Set();
modelSel.appendChild(h("option", { value: "" }, "— " + t("settings.cmf.pick") + " —"));
cmfFiles.forEach((f) => { modelSel.appendChild(h("option", { value: f.path }, fileLabel(f))); seen.add(f.path); });
if (sv.model && !seen.has(sv.model)) modelSel.appendChild(h("option", { value: sv.model }, sv.model));
modelSel.value = sv.model || "";
const portIn = h("input", { type: "number", value: sv.port ?? 8090, style: "width:82px" });
const threadsIn = h("input", { type: "number", min: 0, value: sv.threads ?? 8, style: "width:60px", title: t("settings.cmf.threadsHelp") });
const gpuIn = h("input", { type: "checkbox", checked: sv.gpu ? true : null, title: t("settings.cmf.gpuHelp") });
const checkBtn = h("button", { class: "btn small", type: "button" }, t("settings.cmf.checkPort"));
const removeBtn = h("button", { class: "btn small danger", type: "button", title: t("common.delete") }, "✕");
const st = h("span", { class: "small" });
modelSel.addEventListener("change", () => { if (!idIn.value || idIn._auto) { idIn.value = autoId(modelSel.value); idIn._auto = true; } });
idIn.addEventListener("input", () => { idIn._auto = false; });
checkBtn.addEventListener("click", async () => {
const p = parseInt(portIn.value, 10); if (!p) return;
checkBtn.disabled = true; mount(st, t("status.checking"));
try {
const r = await api.cmfPortCheck(p);
if (r.available) mount(st, 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 b = h("button", { class: "btn small", type: "button" }, t("settings.cmf.useSuggested", { port: r.suggested })); b.addEventListener("click", () => { portIn.value = r.suggested; mount(st, ""); }); kids.push(b); }
mount(st, h("span", {}, ...kids));
}
} catch (e) { mount(st, String(e.message || e)); }
checkBtn.disabled = false;
});
// ── O(1) Nyström Attention ──
// NOT a "reasoning mode". This is a memory-efficient streaming attention
// kernel (Nyström/landmark) that replaces standard KV-cache attention,
// giving O(1) memory per decode step instead of O(n).
const o1Sel = h("select", { style: "width:120px", title: "O(1) Nyström attention layer spec: which layers replace KV-cache with streaming Nyström attention" },
h("option", { value: "" }, "O(1) авто"),
h("option", { value: "all" }, "O(1) все слои"),
h("option", { value: "deep" }, "O(1) глубокие"),
h("option", { value: "off" }, "O(1) выкл."));
if (sv.o1) o1Sel.value = sv.o1;
const o1mIn = h("input", { type: "number", min: "4", placeholder: "32", value: sv.o1_m ?? "", style: "width:55px", title: "Landmark budget (≥4, default 32). More landmarks ≠ better: m=64 measured worse." });
const o1wIn = h("input", { type: "number", min: "1", placeholder: "128", value: sv.o1_window ?? "", style: "width:60px", title: "Exact-window width — main quality lever (default 128)" });
const o1sinkIn = h("input", { type: "number", min: "0", placeholder: "4", value: sv.o1_sink ?? "", style: "width:50px", title: "Permanent exact sink keys, StreamingLLM discipline (default 4)" });
// ── Generation parameters ──
const tempIn = h("input", { type: "number", step: "0.05", min: "0", max: "2.0", placeholder: "0.7", value: sv.temperature ?? "", style: "width:60px" });
const topPIn = h("input", { type: "number", step: "0.05", min: "0", max: "1.0", placeholder: "0.9", value: sv.top_p ?? "", style: "width:60px" });
const maxTokIn = h("input", { type: "number", min: "1", placeholder: "2048", value: sv.max_tokens ?? "", style: "width:75px" });
// think_budget = 0 means reasoning disabled (enable_thinking=false);
// null = model decides; N>0 = token budget for the <think> block.
const noThinkCb = h("input", { type: "checkbox", checked: (sv.think_budget === 0) ? true : null, title: "Отключить режим размышлений (think_budget=0, enable_thinking=false для Qwen3/3.5)" });
const thinkIn = h("input", { type: "number", min: "1", placeholder: "авто", value: (sv.think_budget && sv.think_budget > 0) ? sv.think_budget : "", style: "width:75px", title: "Бюджет токенов на размышления (think_budget). Пустое = модель решает сама." });
if (sv.think_budget === 0) thinkIn.disabled = true;
noThinkCb.addEventListener("change", () => {
thinkIn.disabled = noThinkCb.checked;
if (noThinkCb.checked) thinkIn.value = "";
});
const sysIn = h("input", { placeholder: "System prompt (optional)", value: sv.system_prompt || "", style: "flex:1;min-width:200px" });
// ── MTP (Multi-Token Prediction): speculative decoding, NOT reasoning ──
const skipMtpCb = h("input", { type: "checkbox", checked: sv.skip_mtp ? true : null, title: "Disable MTP speculative decoding (env CMF_MTP=0)" });
// ── Settings drawer ──
const drawerStyle = "display:none;width:100%;margin-top:6px;padding:10px 12px;background:var(--bg-subtle);border-radius:6px;border:1px solid var(--border)";
const groupStyle = "display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:4px 0";
const labelStyle = "font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.5px;width:100%;margin-bottom:2px";
const drawer = h("div", { style: drawerStyle },
// Group 1: O(1) Nyström Attention
h("div", { style: labelStyle }, "O(1) Nyström Attention"),
h("div", { style: groupStyle },
h("span", { class: "small muted" }, "Слои:"), o1Sel,
h("span", { class: "small muted" }, "m:"), o1mIn,
h("span", { class: "small muted" }, "Window:"), o1wIn,
h("span", { class: "small muted" }, "Sink:"), o1sinkIn
),
h("hr", { style: "border:none;border-top:1px solid var(--border);margin:6px 0" }),
// Group 2: Generation
h("div", { style: labelStyle }, "Генерация"),
h("div", { style: groupStyle },
h("span", { class: "small muted" }, "Temp:"), tempIn,
h("span", { class: "small muted" }, "Top-P:"), topPIn,
h("span", { class: "small muted" }, "Max токенов:"), maxTokIn
),
h("div", { style: groupStyle },
h("span", { class: "small muted" }, "System prompt:"), sysIn
),
h("hr", { style: "border:none;border-top:1px solid var(--border);margin:6px 0" }),
// Group 3: Reasoning (thinking)
h("div", { style: labelStyle }, "Режим размышлений"),
h("div", { style: groupStyle },
h("label", { class: "check small", title: "Для моделей Qwen3/3.5: enable_thinking=false — модель отвечает напрямую без блока <think>" }, noThinkCb, "Отключить размышления"),
h("span", { class: "small muted" }, "Think budget:"), thinkIn
),
h("hr", { style: "border:none;border-top:1px solid var(--border);margin:6px 0" }),
// Group 4: Runtime
h("div", { style: labelStyle }, "Runtime"),
h("div", { style: groupStyle },
h("label", { class: "check small", title: "MTP — Multi-Token Prediction (спекулятивное декодирование). Отключение замедляет генерацию, но стабилизирует output." }, skipMtpCb, "Отключить MTP")
)
);
const gearBtn = h("button", { class: "btn small", type: "button", title: "⚙ Настройки модели" }, "⚙");
gearBtn.addEventListener("click", () => {
drawer.style.display = drawer.style.display === "none" ? "block" : "none";
});
const row = h("div", { class: "flex wrap cmf-server-row", style: "gap:6px;align-items:center;margin:6px 0" },
idIn, modelSel, portIn, checkBtn, threadsIn, h("label", { class: "flex", style: "gap:4px" }, gpuIn, "GPU"), gearBtn, removeBtn, st, drawer);
const entry = { node: row, read: () => ({
id: idIn.value.trim() || autoId(modelSel.value),
model: modelSel.value.trim(),
port: parseInt(portIn.value) || 8090,
threads: parseInt(threadsIn.value) || 0,
gpu: gpuIn.checked,
temperature: tempIn.value !== "" ? parseFloat(tempIn.value) : null,
top_p: topPIn.value !== "" ? parseFloat(topPIn.value) : null,
max_tokens: maxTokIn.value !== "" ? parseInt(maxTokIn.value) : null,
think_budget: noThinkCb.checked ? 0 : (thinkIn.value !== "" ? parseInt(thinkIn.value) : null),
o1: o1Sel.value || null,
o1_m: o1mIn.value !== "" ? parseInt(o1mIn.value) : null,
o1_window: o1wIn.value !== "" ? parseInt(o1wIn.value) : null,
o1_sink: o1sinkIn.value !== "" ? parseInt(o1sinkIn.value) : null,
skip_mtp: skipMtpCb.checked,
system_prompt: sysIn.value.trim() || null,
}) };
removeBtn.addEventListener("click", () => { const i = serverRows.indexOf(entry); if (i >= 0) serverRows.splice(i, 1); row.remove(); });
serverRows.push(entry);
serversWrap.appendChild(row);
return entry;
}
initServers.forEach((sv) => makeServerRow(sv));
const addServerBtn = h("button", { class: "btn small", type: "button" }, t("settings.cmf.addServer"));
addServerBtn.addEventListener("click", () => makeServerRow());
// "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 <model> on :<port>" 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"))
);
// per-server serving indicators
const servers = st.servers || [];
if (!servers.length) {
mount(cmfServingEl, "");
} else {
mount(cmfServingEl, h("div", {}, ...servers.map((sv) => {
if (sv.last_error) return h("div", { style: "color:var(--bad)" }, "✗ " + sv.id + ": " + sv.last_error);
if (sv.running) return h("div", { style: "color:var(--good)" },
"▶ " + t("settings.cmf.serving", { model: sv.id, port: sv.port }) + (sv.healthy ? "" : " …"));
return h("div", { class: "muted" }, "• " + sv.id + " :" + sv.port);
})));
}
} 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; }
}
} catch (e) {
toast(String(e.message || e), "bad");
}
await refreshCmfStatus();
cmfInstallBtn.disabled = false;
});
refreshCmfStatus();
const saveBtn = h("button", { class: "btn primary" }, t("common.save"));
saveBtn.addEventListener("click", async () => {
saveBtn.disabled = true;
// a pasted router key needs an env name to live under — default it
if (rSecret.value.trim() && !rKeyEnv.value.trim()) rKeyEnv.value = "CORTIQ_ROUTER_KEY";
const patch = {
listen: listenIn.value.trim(),
router: {
enabled: rEnabled.cb.checked,
url: rUrl.value.trim(),
api_key_env: rKeyEnv.value.trim() || null,
timeout_ms: parseInt(rTimeout.value) || 800,
verify_tls: rVerify.cb.checked,
taxonomy_id: rTax.value.trim() || null,
},
route: {
text_strategy: strategySel.value,
max_chars: parseInt(maxChars.value) || 4000,
cache_ttl: cacheTtl.value.trim() || "60s",
profile: profileSel.value,
},
breaker: { threshold: parseInt(brThresh.value) || 5, cooldown: brCool.value.trim() || "30s" },
log: { level: logSel.value, bodies: logBodies.cb.checked },
telemetry: { metrics: metricsCb.cb.checked, otlp_endpoint_env: s.telemetry?.otlp_endpoint_env || null },
cortiq: { echo: echoCb.cb.checked },
stats: {
enabled: statsEnabled.cb.checked,
file: statsFile.value.trim(),
retention: statsRet.value.trim() || "7d",
ring_size: s.stats?.ring_size ?? 500,
},
cache: {
enabled: cacheEnabled.cb.checked,
threshold: parseFloat(cacheThresh.value) || 0.92,
ttl: cacheTtlIn.value.trim() || "1h",
max_entries: s.cache?.max_entries ?? 1000,
embed_model: cacheEmbedSel.value || null,
},
cmf: {
...(s.cmf || {}),
local_only: cmfLocalOnly.cb.checked,
manage_server: cmfManage.cb.checked,
auto_install: cmfAutoInstall.cb.checked,
auto_update: cmfAutoUpdate.cb.checked,
// the managed-models list is the source of truth; clear the legacy single
servers: serverRows.map((r) => r.read()).filter((sv) => sv.model && sv.id),
local_model: "",
},
};
// serde: drop null optionals
if (!patch.router.api_key_env) delete patch.router.api_key_env;
if (!patch.router.taxonomy_id) delete patch.router.taxonomy_id;
if (!patch.telemetry.otlp_endpoint_env) delete patch.telemetry.otlp_endpoint_env;
if (!patch.cache.embed_model) delete patch.cache.embed_model;
try {
const r = await api.putSettings(patch);
const secretVal = rSecret.value.trim();
if (secretVal) {
await api.setSecret(rKeyEnv.value.trim(), secretVal);
rSecret.value = "";
rKeyBadge.textContent = t("models.form.secretStored");
rKeyBadge.className = "badge store";
}
toast(r.needs_restart ? t("settings.needsRestart") : t("toast.saved"), r.needs_restart ? "warn" : "good");
} catch (e) {
toast(String(e.message || e), "bad");
}
saveBtn.disabled = false;
});
async function exportConfig() {
// the real gateway.toml from disk — comments and all
const tok = getToken();
const headers = {};
if (tok) headers["Authorization"] = "Bearer " + tok;
const r = await fetch("/admin/api/config/export", { headers });
if (!r.ok) {
toast(await r.text(), "bad");
return;
}
const url = URL.createObjectURL(await r.blob());
const a = h("a", { href: url, download: "gateway.toml" });
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
const fileIn = h("input", { type: "file", accept: ".toml,.json,application/json", style: "display:none" });
fileIn.addEventListener("change", async () => {
const f = fileIn.files[0];
if (!f) return;
try {
const text = await f.text();
if (f.name.endsWith(".json")) {
await api.putConfig(JSON.parse(text)); // legacy JSON export
} else {
await postRaw("/config/import", text); // gateway.toml — validated + hot-reloaded
}
toast(t("toast.imported"), "good");
} catch (e) {
toast(String(e.message || e), "bad");
}
fileIn.value = "";
});
const restartBtn = h("button", { class: "btn danger" }, "⟳ " + t("settings.restart"));
restartBtn.addEventListener("click", async () => {
if (!(await confirmDialog(t("settings.restartConfirm")))) return;
restartBtn.disabled = true;
try {
await api.restart();
toast(t("settings.restarting"), "good");
// the gateway comes back on the same port — reload once it answers
const poll = setInterval(async () => {
try {
await api.health();
clearInterval(poll);
location.reload();
} catch {
/* still restarting */
}
}, 1000);
} catch (e) {
toast(String(e.message || e), "bad");
restartBtn.disabled = false;
}
});
mount(
root,
h(
"div",
{ class: "page-head" },
h(
"div",
{ class: "flex" },
h("div", { class: "grow" }, h("h2", {}, t("settings.title")), h("p", {}, t("settings.subtitle"))),
saveBtn
)
),
h(
"div",
{ class: "grid cols-2" },
h(
"div",
{ class: "card" },
h("div", { class: "card-head" }, h("h3", {}, t("settings.router"))),
field(t("settings.router.url"), rUrl),
field(t("settings.router.keyEnv"), rKeyEnv),
field(t("settings.router.secret"), h("div", { class: "key-row" }, rSecret, saveKeyBtn), rSecretHint),
h("div", { class: "row" }, field(t("settings.router.timeout"), rTimeout), field(t("settings.router.taxonomy"), rTax)),
rEnabled.node,
rVerify.node,
h("div", { class: "divider" }),
h("div", { class: "flex" }, probeBtn),
probeOut
),
h(
"div",
{ class: "card" },
h("div", { class: "card-head" }, h("h3", {}, t("settings.route"))),
field(t("settings.route.strategy"), strategySel, null, t("settings.route.strategyHelp")),
h("div", { class: "row" },
field(t("settings.route.maxChars"), maxChars, null, t("settings.route.maxCharsHelp")),
field(t("settings.route.cacheTtl"), cacheTtl, null, t("settings.route.cacheTtlHelp"))),
field(t("settings.route.profile"), profileSel, null, t("settings.route.profileHelp"))
),
h(
"div",
{ class: "card" },
h("div", { class: "card-head" }, h("h3", {}, t("settings.breaker"))),
h("div", { class: "row" }, field(t("settings.breaker.threshold"), brThresh), field(t("settings.breaker.cooldown"), brCool))
),
h(
"div",
{ class: "card" },
h("div", { class: "card-head" }, h("h3", {}, t("settings.log"))),
field(t("settings.log.level"), logSel),
logBodies.node
),
h(
"div",
{ class: "card" },
h("div", { class: "card-head" }, h("h3", {}, t("settings.telemetry"))),
metricsCb.node,
h("div", { class: "divider" }),
h("div", { class: "card-head" }, h("h3", {}, t("settings.cortiq"))),
echoCb.node
),
h(
"div",
{ class: "card" },
h("div", { class: "card-head" }, h("h3", {}, t("settings.stats"))),
statsEnabled.node,
field(t("settings.stats.file"), statsFile),
field(t("settings.stats.retention"), statsRet)
),
h(
"div",
{ class: "card" },
h("div", { class: "card-head" }, h("h3", {}, t("settings.cache"))),
cacheEnabled.node,
h("div", { class: "row" }, field(t("settings.cache.threshold"), cacheThresh), field(t("settings.cache.ttl"), cacheTtlIn)),
field(t("settings.cache.embed"), cacheEmbedSel),
h("div", { class: "hint" }, t("settings.cache.note"))
),
h(
"div",
{ class: "card" },
h("div", { class: "card-head" }, h("h3", {}, t("settings.cmf"))),
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),
h("div", { class: "divider" }),
// Step 2 — managed local models: one supervised cortiq serve per row, each
// on its own port. Add as many as you have .cmf files + free ports.
h("div", { class: "field" },
h("span", {}, "2. " + t("settings.cmf.models"), " ", help(t("settings.cmf.modelsHelp"))),
h("div", { class: "hint" }, t("settings.cmf.rowHint")),
h("div", { class: "hint" }, t("settings.cmf.importNote"),
" ", h("a", { href: "#/import" }, t("settings.cmf.importLink") + " ↗"))),
serversWrap,
addServerBtn,
cmfServingEl,
h("div", { class: "hint" }, t("settings.cmf.speedNote")),
h("div", { class: "divider" }),
// Step 3 — how the gateway uses them.
cmfManage.node,
cmfLocalOnly.node
)
),
h(
"div",
{ class: "card" },
h("div", { class: "card-head" }, h("h3", {}, t("settings.listen"))),
field(t("settings.listen"), listenIn),
h("div", { class: "divider" }),
h(
"div",
{ class: "flex wrap" },
h("button", { class: "btn", onClick: exportConfig }, t("settings.export")),
h("button", { class: "btn", onClick: () => fileIn.click() }, t("settings.import")),
fileIn,
h("div", { class: "grow" }),
restartBtn
)
)
);
return root;
}