diff --git a/Cargo.lock b/Cargo.lock index 5c23d8b..5934580 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "cortiq-gateway" -version = "0.2.8" +version = "0.2.9" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index d75312d..e22e103 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cortiq-gateway" -version = "0.2.8" +version = "0.2.9" edition = "2021" rust-version = "1.75" license = "Apache-2.0" diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 56e0b1e..a3c2dd0 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -178,6 +178,7 @@ pub fn api_routes(admin_token: String) -> Router { .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/cmf/files", get(cmf_files)) .route("/admin/api/requests", get(get_requests)) .route("/admin/api/test", post(run_test)) .route("/admin/api/test/stream", post(run_test_stream)) @@ -843,6 +844,36 @@ async fn cmf_install(State(state): State) -> ApiResult { ok(json!({ "started": true })) } +/// List the `.cmf` model files available under `models_dir` so the UI can offer +/// them as a dropdown instead of a hand-typed path. Includes tensor-dir `.cmf`. +async fn cmf_files(State(state): State) -> ApiResult { + let dir = state.live().cfg.cmf.models_dir.clone(); + let mut entries: Vec<(String, String, Option, bool)> = Vec::new(); + if let Ok(rd) = std::fs::read_dir(&dir) { + for e in rd.flatten() { + let name = e.file_name().to_string_lossy().to_string(); + if !name.ends_with(".cmf") { + continue; + } + let p = e.path(); + let is_dir = p.is_dir(); + let size = if is_dir { + None + } else { + std::fs::metadata(&p).ok().map(|m| m.len()) + }; + let path = format!("{}/{}", dir.trim_end_matches('/'), name); + entries.push((name, path, size, is_dir)); + } + } + entries.sort_by(|a, b| a.0.cmp(&b.0)); + let files: Vec = entries + .into_iter() + .map(|(name, path, size, is_dir)| json!({ "name": name, "path": path, "size": size, "is_dir": is_dir })) + .collect(); + ok(json!({ "dir": dir, "files": files })) +} + #[derive(Deserialize)] struct PortQuery { port: u16, diff --git a/web/js/api.js b/web/js/api.js index f869d26..6984b4a 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -99,6 +99,7 @@ export const api = { cmfStatus: () => j("GET", "/cmf"), cmfInstall: () => j("POST", "/cmf/install"), cmfPortCheck: (port) => j("GET", "/cmf/port?port=" + encodeURIComponent(port)), + cmfFiles: () => j("GET", "/cmf/files"), // CMF model factory (HuggingFace → local .cmf) hfSearch: (q, limit = 24) => diff --git a/web/js/i18n/en.js b/web/js/i18n/en.js index c081445..91d0fbb 100644 --- a/web/js/i18n/en.js +++ b/web/js/i18n/en.js @@ -244,9 +244,10 @@ 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.pick": "select a model", "settings.cmf.models": "Local models", "settings.cmf.modelsHelp": "Each row is one model the gateway runs (its own cortiq serve on its own port) and registers in the pool. Add as many as you have .cmf files and free ports.", - "settings.cmf.rowHint": "id · path to .cmf · port · threads · GPU. Changes apply on restart; deleting a row stops that server.", + "settings.cmf.rowHint": "id · .cmf file · port · threads · GPU. Changes apply on restart; deleting a row stops that server.", "settings.cmf.addServer": "+ Add local model", "settings.cmf.checkPort": "Check port", "settings.cmf.portFree": "port is free", diff --git a/web/js/i18n/ru.js b/web/js/i18n/ru.js index 7bed16c..fe30186 100644 --- a/web/js/i18n/ru.js +++ b/web/js/i18n/ru.js @@ -243,9 +243,10 @@ export default { "settings.cmf.threads": "Рабочие потоки", "settings.cmf.gpu": "Использовать GPU (Metal / wgpu)", "settings.cmf.speedNote": "Потоки распараллеливают декод-матвеки (~×3; 0 = по умолчанию рантайма). GPU-оффлоад помогает в основном на prefill длинных промптов — декод упирается в пропускную способность памяти, поэтому на Apple Silicon почти нейтрален.", + "settings.cmf.pick": "выберите модель", "settings.cmf.models": "Локальные модели", "settings.cmf.modelsHelp": "Каждая строка — отдельная модель: шлюз поднимает свой cortiq serve на своём порту и регистрирует её в пуле. Добавляйте столько, сколько есть .cmf-файлов и свободных портов.", - "settings.cmf.rowHint": "id · путь к .cmf · порт · потоки · GPU. Изменения применяются после перезапуска; удаление строки останавливает сервер.", + "settings.cmf.rowHint": "id · файл .cmf · порт · потоки · GPU. Изменения применяются после перезапуска; удаление строки останавливает сервер.", "settings.cmf.addServer": "+ Добавить модель", "settings.cmf.checkPort": "Проверить порт", "settings.cmf.portFree": "порт свободен", diff --git a/web/js/views/settings.js b/web/js/views/settings.js index 2fca222..d793cab 100644 --- a/web/js/views/settings.js +++ b/web/js/views/settings.js @@ -214,6 +214,9 @@ export async function renderSettings() { ? [{ 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, "-") : ""; @@ -221,14 +224,20 @@ export async function renderSettings() { 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" }); - const modelIn = h("input", { value: sv.model || "", placeholder: "models/model.cmf", style: "flex:2;min-width:150px" }); + // 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" }); - modelIn.addEventListener("input", () => { if (!idIn.value || idIn._auto) { idIn.value = autoId(modelIn.value); idIn._auto = true; } }); + 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; @@ -245,8 +254,8 @@ export async function renderSettings() { checkBtn.disabled = false; }); const row = h("div", { class: "flex wrap", style: "gap:6px;align-items:center;margin:6px 0" }, - idIn, modelIn, portIn, checkBtn, threadsIn, h("label", { class: "flex", style: "gap:4px" }, gpuIn, "GPU"), removeBtn, st); - const entry = { node: row, read: () => ({ id: idIn.value.trim() || autoId(modelIn.value), model: modelIn.value.trim(), port: parseInt(portIn.value) || 8090, threads: parseInt(threadsIn.value) || 0, gpu: gpuIn.checked }) }; + idIn, modelSel, portIn, checkBtn, threadsIn, h("label", { class: "flex", style: "gap:4px" }, gpuIn, "GPU"), removeBtn, st); + 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 }) }; removeBtn.addEventListener("click", () => { const i = serverRows.indexOf(entry); if (i >= 0) serverRows.splice(i, 1); row.remove(); }); serverRows.push(entry); serversWrap.appendChild(row);