Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c90dad9b9 |
Generated
+1
-1
@@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "cortiq-gateway"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<SharedState> {
|
||||
.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<SharedState>, Json(b): Json<SettingsBo
|
||||
ok(json!({ "ok": true, "needs_restart": needs_restart }))
|
||||
}
|
||||
|
||||
// ───────────────────────────── cmf runtime ─────────────────────────────────
|
||||
/// Status of the `cortiq` CLI (the CMF format runtime): is it installed, which
|
||||
/// version, and whether a managed server is running. Drives the "Install cortiq"
|
||||
/// button + status in the Settings → Local models card.
|
||||
async fn cmf_status(State(state): State<SharedState>) -> 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 <model> on :<port>"
|
||||
"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<SharedState>) -> 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<SharedState>, Query(q): Query<PortQuery>) -> 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<u16> {
|
||||
(from.saturating_add(1)..=from.saturating_add(50)).find(|p| port_is_free(host, *p))
|
||||
}
|
||||
|
||||
// ───────────────────────────── keys ────────────────────────────────────────
|
||||
|
||||
async fn list_keys(State(state): State<SharedState>) -> ApiResult {
|
||||
|
||||
+16
-1
@@ -119,7 +119,7 @@ async fn latest_crates_version() -> Option<String> {
|
||||
}
|
||||
|
||||
/// `<bin> --version` → the trailing version token (e.g. "cortiq 0.1.2" → "0.1.2").
|
||||
fn installed_version(bin: &str) -> Option<String> {
|
||||
pub(crate) fn installed_version(bin: &str) -> Option<String> {
|
||||
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<CmfRuntime>, 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 <model> --host <host> --port <port>`; the child is killed
|
||||
/// when its handle is dropped (i.e. when the gateway shuts down).
|
||||
fn spawn_serve(cfg: &CmfCfg) -> Result<Child, String> {
|
||||
|
||||
Vendored
+12
@@ -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;
|
||||
|
||||
Vendored
+5
@@ -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}`),
|
||||
|
||||
Vendored
+32
@@ -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)",
|
||||
|
||||
Vendored
+32
@@ -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": "Ключ роутера (значение)",
|
||||
|
||||
Vendored
+165
-50
@@ -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 <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"))
|
||||
);
|
||||
// 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"))
|
||||
)
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user