Compare commits

..
1 Commits
Author SHA1 Message Date
infosave2007 45deb6645b chore(release): v0.2.24 — new quantizations (q4t, q1t, q1s, q1p, q1) and featured CMF models
Docker / publish (push) Canceled after 0s
Release / crates-io (push) Canceled after 0s
Release / release (macos-14, aarch64-apple-darwin) (push) Canceled after 0s
Release / release (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Canceled after 0s
Release / release (windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-07-21 22:45:43 +03:00
11 changed files with 133 additions and 154 deletions
+7
View File
@@ -3,8 +3,15 @@
All notable changes to this project are documented here. All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/). The format is based on [Keep a Changelog](https://keepachangelog.com/).
## [0.2.24] - 2026-07-21
### Added
- **New Quantization Schemes**: Added support for `Q4_TILED` (q4t), `Q1T` (1.58-bit ternary), `Q1S` (1-bit sparse), `Q1P` (1-bit PTQ error diffusion), and `Q1` (pure binary) in converter backend and web admin UI.
- **Featured Ready-to-Run .cmf Models**: Added 1-tap download cards for pre-converted `.cmf` models (`infosave/Bonsai-1.7Bcmf`, `infosave/Bonsai-8B_2bit_cmf`, `infosave/Bonsai-27Bcmf`).
## [Unreleased] ## [Unreleased]
### Added ### Added
- **Docker Compose** quickstart (`docker-compose.yml` + `config/gateway.compose.toml`): - **Docker Compose** quickstart (`docker-compose.yml` + `config/gateway.compose.toml`):
gateway + a local Ollama model in one command. GHCR badge in the READMEs. gateway + a local Ollama model in one command. GHCR badge in the READMEs.
Generated
+1 -1
View File
@@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "cortiq-gateway" name = "cortiq-gateway"
version = "0.2.18" version = "0.2.24"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "cortiq-gateway" name = "cortiq-gateway"
version = "0.2.18" version = "0.2.24"
edition = "2021" edition = "2021"
rust-version = "1.75" rust-version = "1.75"
license = "Apache-2.0" license = "Apache-2.0"
+8 -89
View File
@@ -606,10 +606,7 @@ fn cmf_file_for_model(
cmf: &crate::config::CmfCfg, cmf: &crate::config::CmfCfg,
m: &crate::config::ModelCfg, m: &crate::config::ModelCfg,
) -> Option<std::path::PathBuf> { ) -> Option<std::path::PathBuf> {
// Any loopback URL counts as local — managed servers may sit on any port.
let local = m.base_url == cmf.cortiq_server_url let local = m.base_url == cmf.cortiq_server_url
|| m.base_url.contains("//127.0.0.1:")
|| m.base_url.contains("//localhost:")
|| m.base_url || m.base_url
.contains(&format!("{}:{}", cmf.local_host, cmf.local_port)); .contains(&format!("{}:{}", cmf.local_host, cmf.local_port));
let stem = m.model.trim(); let stem = m.model.trim();
@@ -649,24 +646,12 @@ fn cmf_path_referenced(cfg: &crate::config::Config, path: &std::path::Path) -> b
.any(|m| cmf_file_for_model(&cfg.cmf, m).as_deref() == Some(path)) .any(|m| cmf_file_for_model(&cfg.cmf, m).as_deref() == Some(path))
} }
#[derive(Deserialize)] async fn delete_model(State(state): State<SharedState>, Path(id): Path<String>) -> ApiResult {
struct DeleteModelQuery {
/// Also remove the local `.cmf` from disk. Defaults to true (the historical
/// behaviour); the UI asks the user and passes it explicitly.
delete_file: Option<bool>,
}
async fn delete_model(
State(state): State<SharedState>,
Path(id): Path<String>,
Query(q): Query<DeleteModelQuery>,
) -> ApiResult {
let mut cfg = current_cfg(&state); let mut cfg = current_cfg(&state);
let delete_file = q.delete_file.unwrap_or(true);
// Managed local models live in [cmf], not [[models]]. Deleting one stops its // Managed local models live in [cmf], not [[models]]. Deleting one stops its
// server, removes it from the managed config, and (when asked) deletes its // server, removes it from the managed config, and deletes its file — unless
// file — unless another model still points at the same file. // another model still points at the same file.
let managed = cfg.cmf.effective_servers().into_iter().find(|s| s.id == id); let managed = cfg.cmf.effective_servers().into_iter().find(|s| s.id == id);
if let Some(server) = managed { if let Some(server) = managed {
if !cfg.models.iter().any(|x| x.id == id) { if !cfg.models.iter().any(|x| x.id == id) {
@@ -681,10 +666,10 @@ async fn delete_model(
let shared = cmf_path_referenced(&cfg, &path); let shared = cmf_path_referenced(&cfg, &path);
state.cmf.stop_one(&id).await; state.cmf.stop_one(&id).await;
state.reload(cfg)?; state.reload(cfg)?;
let file_removed = if delete_file && !shared { let file_removed = if shared {
remove_cmf_path(&path)
} else {
false false
} else {
remove_cmf_path(&path)
}; };
return ok(json!({ "ok": true, "file_removed": file_removed })); return ok(json!({ "ok": true, "file_removed": file_removed }));
} }
@@ -700,8 +685,8 @@ async fn delete_model(
let file = victim let file = victim
.as_ref() .as_ref()
.and_then(|m| cmf_file_for_model(&cfg.cmf, m)); .and_then(|m| cmf_file_for_model(&cfg.cmf, m));
// Only remove the file if asked to AND no remaining model still references it. // Only remove the file if no remaining model still references it.
let file = file.filter(|p| delete_file && !cmf_path_referenced(&cfg, p)); let file = file.filter(|p| !cmf_path_referenced(&cfg, p));
state.reload(cfg)?; state.reload(cfg)?;
let file_removed = file.map(|p| remove_cmf_path(&p)).unwrap_or(false); let file_removed = file.map(|p| remove_cmf_path(&p)).unwrap_or(false);
ok(json!({ "ok": true, "file_removed": file_removed })) ok(json!({ "ok": true, "file_removed": file_removed }))
@@ -920,75 +905,12 @@ async fn put_settings(State(state): State<SharedState>, Json(b): Json<SettingsBo
{ {
needs_restart = true; needs_restart = true;
} }
let old_servers = cfg.cmf.effective_servers();
cfg.cmf = v; cfg.cmf = v;
// Replacing or renaming a managed model must not brick the save: routing
// may still reference the old id. Map a vanished id to its replacement
// (the new server on the same port), strip ids that are truly gone, and
// repair the default so validate() passes.
heal_routing_after_cmf_change(&mut cfg, &old_servers);
} }
state.reload(cfg)?; state.reload(cfg)?;
ok(json!({ "ok": true, "needs_restart": needs_restart })) ok(json!({ "ok": true, "needs_restart": needs_restart }))
} }
/// After the managed-servers list changed, rewrite [routing] so it only
/// references ids that still exist. A removed server whose port is now held by
/// a NEW server is treated as a replacement (old id → new id); other vanished
/// ids are stripped. An emptied default falls back to the first managed server,
/// then the first pool model.
fn heal_routing_after_cmf_change(cfg: &mut Config, old_servers: &[crate::config::CmfServer]) {
let servers = cfg.cmf.effective_servers();
let known: std::collections::HashSet<String> = cfg
.models
.iter()
.map(|m| m.id.clone())
.chain(servers.iter().map(|s| s.id.clone()))
.collect();
let new_by_port: HashMap<u16, String> =
servers.iter().map(|s| (s.port, s.id.clone())).collect();
let mut rename: HashMap<String, String> = HashMap::new();
for old in old_servers {
if !known.contains(&old.id) {
if let Some(new_id) = new_by_port.get(&old.port) {
rename.insert(old.id.clone(), new_id.clone());
}
}
}
let fix = |id: &str| -> Option<String> {
if known.contains(id) {
Some(id.to_string())
} else {
rename.get(id).cloned()
}
};
if !cfg.routing.default.is_empty() {
cfg.routing.default = fix(&cfg.routing.default).unwrap_or_default();
}
if cfg.routing.default.is_empty() {
cfg.routing.default = servers
.first()
.map(|s| s.id.clone())
.or_else(|| cfg.models.first().map(|m| m.id.clone()))
.unwrap_or_default();
}
for tt in cfg.routing.tiers.values_mut() {
let list = match tt {
TierTargets::List(v) => v.clone(),
TierTargets::One(s) => vec![s.clone()],
};
let mut healed: Vec<String> = Vec::new();
for id in &list {
if let Some(new_id) = fix(id) {
if !healed.contains(&new_id) {
healed.push(new_id);
}
}
}
*tt = TierTargets::List(healed);
}
}
// ───────────────────────────── cmf runtime ───────────────────────────────── // ───────────────────────────── cmf runtime ─────────────────────────────────
/// Status of the `cortiq` CLI (the CMF format runtime): is it installed, which /// 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" /// version, and whether a managed server is running. Drives the "Install cortiq"
@@ -1106,9 +1028,6 @@ async fn cmf_port_check(State(state): State<SharedState>, Query(q): Query<PortQu
ok(json!({ ok(json!({
"port": port, "port": port,
"available": available, "available": available,
// true = held by this gateway's own managed server (a replaced/edited
// row frees it on restart) — the UI shows this as info, not an error
"managed": running_here,
"detail": detail, "detail": detail,
"suggested": suggested, "suggested": suggested,
})) }))
+27 -1
View File
@@ -260,10 +260,15 @@ fn sanitize(s: &str) -> String {
/// Map the UI quant label to the native `cortiq` quant flag. /// Map the UI quant label to the native `cortiq` quant flag.
fn map_quant(q: &str) -> &'static str { fn map_quant(q: &str) -> &'static str {
match q.to_ascii_uppercase().as_str() { match q.to_ascii_uppercase().as_str() {
s if s.starts_with("Q4T") || s == "Q4_TILED" => "q4t",
s if s.starts_with("Q4") => "q4", s if s.starts_with("Q4") => "q4",
"Q8_2F" => "q8_2f", "Q8_2F" | "Q82F" => "q8_2f",
"F16" | "FP16" => "f16", "F16" | "FP16" => "f16",
"VBIT" => "vbit", "VBIT" => "vbit",
"Q1" => "q1",
"Q1P" | "Q1_PTQ" => "q1p",
"Q1S" | "Q1_MASK" => "q1s",
"Q1T" | "Q1_TERNARY" => "q1t",
_ => "q8", _ => "q8",
} }
} }
@@ -530,3 +535,24 @@ pub fn start_import(store: Arc<JobStore>, cfg: &CmfCfg, p: ImportParams) -> Resu
Ok(ret_id) Ok(ret_id)
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_map_quant_variants() {
assert_eq!(map_quant("Q8_2F"), "q8_2f");
assert_eq!(map_quant("q82f"), "q8_2f");
assert_eq!(map_quant("Q8_ROW"), "q8");
assert_eq!(map_quant("Q4_BLOCK"), "q4");
assert_eq!(map_quant("Q4_TILED"), "q4t");
assert_eq!(map_quant("q4t"), "q4t");
assert_eq!(map_quant("vbit"), "vbit");
assert_eq!(map_quant("q1"), "q1");
assert_eq!(map_quant("Q1P"), "q1p");
assert_eq!(map_quant("q1s"), "q1s");
assert_eq!(map_quant("Q1T"), "q1t");
assert_eq!(map_quant("F16"), "f16");
}
}
+1 -2
View File
@@ -60,8 +60,7 @@ export const api = {
listModels: () => j("GET", "/models"), listModels: () => j("GET", "/models"),
createModel: (b) => j("POST", "/models", b), createModel: (b) => j("POST", "/models", b),
updateModel: (id, b) => j("PUT", "/models/" + encodeURIComponent(id), b), updateModel: (id, b) => j("PUT", "/models/" + encodeURIComponent(id), b),
deleteModel: (id, deleteFile = true) => deleteModel: (id) => j("DELETE", "/models/" + encodeURIComponent(id)),
j("DELETE", "/models/" + encodeURIComponent(id) + "?delete_file=" + (deleteFile ? "true" : "false")),
probeModel: (id) => j("POST", "/models/" + encodeURIComponent(id) + "/probe"), probeModel: (id) => j("POST", "/models/" + encodeURIComponent(id) + "/probe"),
// routing // routing
+9 -9
View File
@@ -132,8 +132,6 @@ export default {
"models.form.secretMissing": "not set", "models.form.secretMissing": "not set",
"models.form.secretPlaceholder": "set or replace key (write-only)", "models.form.secretPlaceholder": "set or replace key (write-only)",
"models.deleteConfirm": "Delete model \"{id}\"?", "models.deleteConfirm": "Delete model \"{id}\"?",
"models.deleteFile": "also delete the model file from disk",
"models.deletedWithFile": "Deleted (file removed from disk)",
"models.form.idOrModel": "Enter an id or a model name", "models.form.idOrModel": "Enter an id or a model name",
"models.form.apiKey": "API Key", "models.form.apiKey": "API Key",
"models.form.apiKeyPlaceholder": "paste your API key", "models.form.apiKeyPlaceholder": "paste your API key",
@@ -269,7 +267,6 @@ export default {
"settings.cmf.checkPort": "Check port", "settings.cmf.checkPort": "Check port",
"settings.cmf.portFree": "port is free", "settings.cmf.portFree": "port is free",
"settings.cmf.portBusy": "port is in use", "settings.cmf.portBusy": "port is in use",
"settings.cmf.portOwn": "in use by this gateway's current server — freed on restart, replacing is fine",
"settings.cmf.useSuggested": "use {port}", "settings.cmf.useSuggested": "use {port}",
"settings.cmf.serving": "serving {model} on :{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.intro": "Run models locally with the cortiq engine — works even offline or when the router is down.",
@@ -387,12 +384,21 @@ export default {
"import.state.done": "done", "import.state.done": "done",
"import.state.error": "error", "import.state.error": "error",
"import.state.cancelled": "cancelled", "import.state.cancelled": "cancelled",
"import.featuredTitle": "Featured Ready-to-Run .cmf Models",
"import.readyCmfBadge": "Ready .cmf",
"import.directDownload": "Download",
"import.q.q8_2f": "8-bit two-field (𝒲×θ) — recommended: best quality/size.", "import.q.q8_2f": "8-bit two-field (𝒲×θ) — recommended: best quality/size.",
"import.q.q8_row": "8-bit per-row — simple, robust.", "import.q.q8_row": "8-bit per-row — simple, robust.",
"import.q.q4": "4-bit block — smallest, lower quality.", "import.q.q4": "4-bit block — smallest, lower quality.",
"import.q.q4_tiled": "4-bit tiled (Q4_TILED) — 1.66x faster on Metal GPU.",
"import.q.vbit": "Variable 3–8 bit — per-expert bit budgets.", "import.q.vbit": "Variable 3–8 bit — per-expert bit budgets.",
"import.q.q1t": "1.58-bit ternary (Q1T) — BitNet b1.58 with sparse outliers.",
"import.q.q1s": "1-bit sparse (Q1S) — 1-bit base with holographic outliers.",
"import.q.q1p": "1-bit PTQ (Q1P) — 1-bit with error diffusion.",
"import.q.q1": "1-bit (Q1) — pure binary format.",
"import.q.f16": "16-bit — no quantization.", "import.q.f16": "16-bit — no quantization.",
"import.q.f32": "32-bit — full precision (large file).", "import.q.f32": "32-bit — full precision (large file).",
"import.adv.title": "Advanced settings", "import.adv.title": "Advanced settings",
"import.adv.auto": "auto", "import.adv.auto": "auto",
"import.adv.linearCore": "Linear core", "import.adv.linearCore": "Linear core",
@@ -412,12 +418,6 @@ export default {
"import.adv.o1DeepN": "N deep layers", "import.adv.o1DeepN": "N deep layers",
"import.adv.o1Layers": "Layers (comma-separated)", "import.adv.o1Layers": "Layers (comma-separated)",
"import.adv.o1mHint": "landmark budget (default 32)", "import.adv.o1mHint": "landmark budget (default 32)",
"import.adv.o1mLabel": "Landmarks (--o1-m)",
"import.adv.o1mHelp": "How many landmark tokens summarize the whole older history when attention is approximated. More = closer to exact attention but slower and more memory; fewer = faster but rougher. 32 is the validated sweet spot — change only if you know why.",
"import.adv.o1windowLabel": "Exact window (--o1-window)",
"import.adv.o1windowHelp": "The last N tokens always get exact (non-approximated) attention. This protects quality on the text the model just wrote/read. Bigger = better local quality, more memory. Default 128.",
"import.adv.o1sinkLabel": "Sink keys (--o1-sink)",
"import.adv.o1sinkHelp": "The first N tokens of the context are kept exact forever ('attention sinks'). Models lean on these anchors — keeping them exact stabilizes long generations. Default 4; rarely needs changing.",
"import.adv.o1windowHint": "exact-window width (default 128)", "import.adv.o1windowHint": "exact-window width (default 128)",
"import.adv.o1sinkHint": "permanent exact sink keys (default 4)", "import.adv.o1sinkHint": "permanent exact sink keys (default 4)",
}; };
+9 -9
View File
@@ -131,8 +131,6 @@ export default {
"models.form.secretMissing": "не задан", "models.form.secretMissing": "не задан",
"models.form.secretPlaceholder": "задать или заменить ключ (только запись)", "models.form.secretPlaceholder": "задать или заменить ключ (только запись)",
"models.deleteConfirm": "Удалить модель \"{id}\"?", "models.deleteConfirm": "Удалить модель \"{id}\"?",
"models.deleteFile": "также удалить файл модели с диска",
"models.deletedWithFile": "Удалено (файл стёрт с диска)",
"models.form.idOrModel": "Укажите id или имя модели", "models.form.idOrModel": "Укажите id или имя модели",
"models.form.apiKey": "API-ключ", "models.form.apiKey": "API-ключ",
"models.form.apiKeyPlaceholder": "вставьте API-ключ", "models.form.apiKeyPlaceholder": "вставьте API-ключ",
@@ -268,7 +266,6 @@ export default {
"settings.cmf.checkPort": "Проверить порт", "settings.cmf.checkPort": "Проверить порт",
"settings.cmf.portFree": "порт свободен", "settings.cmf.portFree": "порт свободен",
"settings.cmf.portBusy": "порт занят", "settings.cmf.portBusy": "порт занят",
"settings.cmf.portOwn": "занят текущим сервером шлюза — освободится после перезапуска, замена допустима",
"settings.cmf.useSuggested": "занять {port}", "settings.cmf.useSuggested": "занять {port}",
"settings.cmf.serving": "работает: {model} на :{port}", "settings.cmf.serving": "работает: {model} на :{port}",
"settings.cmf.intro": "Запускайте модели локально движком cortiq — работает даже офлайн или когда роутер недоступен.", "settings.cmf.intro": "Запускайте модели локально движком cortiq — работает даже офлайн или когда роутер недоступен.",
@@ -386,12 +383,21 @@ export default {
"import.state.done": "готово", "import.state.done": "готово",
"import.state.error": "ошибка", "import.state.error": "ошибка",
"import.state.cancelled": "отменено", "import.state.cancelled": "отменено",
"import.featuredTitle": "Рекомендуемые готовые .cmf модели",
"import.readyCmfBadge": "Готовая .cmf",
"import.directDownload": "Скачать",
"import.q.q8_2f": "8-бит двухполевой (𝒲×θ) — рекомендуется: лучшее качество/размер.", "import.q.q8_2f": "8-бит двухполевой (𝒲×θ) — рекомендуется: лучшее качество/размер.",
"import.q.q8_row": "8-бит построчно — просто, надёжно.", "import.q.q8_row": "8-бит построчно — просто, надёжно.",
"import.q.q4": "4-бит блочно — компактнее, ниже качество.", "import.q.q4": "4-бит блочно — компактнее, ниже качество.",
"import.q.q4_tiled": "4-бит тайловый (Q4_TILED) — х1.66 быстрее на Metal GPU.",
"import.q.vbit": "Переменная 3–8 бит — per-expert бюджеты.", "import.q.vbit": "Переменная 3–8 бит — per-expert бюджеты.",
"import.q.q1t": "1.58-бит тернарный (Q1T) — BitNet b1.58 с оутлайерами.",
"import.q.q1s": "1-бит спарс (Q1S) — 1-бит база с голографическими оутлайерами.",
"import.q.q1p": "1-бит PTQ (Q1P) — 1-бит с переносом ошибки (error diffusion).",
"import.q.q1": "1-бит (Q1) — чистый бинарный формат.",
"import.q.f16": "16-бит — без квантования.", "import.q.f16": "16-бит — без квантования.",
"import.q.f32": "32-бит — полная точность (большой файл).", "import.q.f32": "32-бит — полная точность (большой файл).",
"import.adv.title": "Расширенные настройки", "import.adv.title": "Расширенные настройки",
"import.adv.auto": "авто", "import.adv.auto": "авто",
"import.adv.linearCore": "Линейное ядро", "import.adv.linearCore": "Линейное ядро",
@@ -411,12 +417,6 @@ export default {
"import.adv.o1DeepN": "N глубоких слоёв", "import.adv.o1DeepN": "N глубоких слоёв",
"import.adv.o1Layers": "Слои (через запятую)", "import.adv.o1Layers": "Слои (через запятую)",
"import.adv.o1mHint": "число опорных точек (по умолчанию 32)", "import.adv.o1mHint": "число опорных точек (по умолчанию 32)",
"import.adv.o1mLabel": "Опорные точки (--o1-m)",
"import.adv.o1mHelp": "Сколько опорных токенов представляют всю старую историю при аппроксимации внимания. Больше — ближе к точному вниманию, но медленнее и больше памяти; меньше — быстрее, но грубее. 32 — проверенное значение; меняйте, только если понимаете зачем.",
"import.adv.o1windowLabel": "Точное окно (--o1-window)",
"import.adv.o1windowHelp": "Последние N токенов всегда обрабатываются точным (не аппроксимированным) вниманием — это защищает качество на только что написанном/прочитанном тексте. Больше — лучше локальное качество, больше памяти. По умолчанию 128.",
"import.adv.o1sinkLabel": "Sink-ключи (--o1-sink)",
"import.adv.o1sinkHelp": "Первые N токенов контекста навсегда остаются точными («якоря внимания»). Модели опираются на эти якоря — их точность стабилизирует длинную генерацию. По умолчанию 4; менять почти никогда не нужно.",
"import.adv.o1windowHint": "ширина точного окна (по умолчанию 128)", "import.adv.o1windowHint": "ширина точного окна (по умолчанию 128)",
"import.adv.o1sinkHint": "постоянные точные sink-ключи (по умолчанию 4)", "import.adv.o1sinkHint": "постоянные точные sink-ключи (по умолчанию 4)",
}; };
+59 -11
View File
@@ -10,19 +10,25 @@ const QUANTS = [
["Q8_2F", "import.q.q8_2f"], ["Q8_2F", "import.q.q8_2f"],
["Q8_ROW", "import.q.q8_row"], ["Q8_ROW", "import.q.q8_row"],
["Q4_BLOCK", "import.q.q4"], ["Q4_BLOCK", "import.q.q4"],
["Q4_TILED", "import.q.q4_tiled"],
["VBIT", "import.q.vbit"], ["VBIT", "import.q.vbit"],
["Q1T", "import.q.q1t"],
["Q1S", "import.q.q1s"],
["Q1P", "import.q.q1p"],
["Q1", "import.q.q1"],
["F16", "import.q.f16"], ["F16", "import.q.f16"],
["F32", "import.q.f32"], ["F32", "import.q.f32"],
]; ];
// A small "?" badge with a hover/focus tooltip (same style as Settings). const FEATURED_REPOS = [
function help(text) { { id: "infosave/Bonsai-1.7Bcmf", label: "Bonsai 1.7B CMF", badge: "1.7B · Ready .cmf" },
return text ? h("span", { class: "help", title: text, tabindex: "0", role: "img", "aria-label": text }, "?") : null; { id: "infosave/Bonsai-8B_2bit_cmf", label: "Bonsai 8B 2-bit CMF", badge: "8B · Ready .cmf" },
} { id: "infosave/Bonsai-27Bcmf", label: "Bonsai 27B CMF", badge: "27B · Ready .cmf" },
function field(label, control, hint, helpText) { ];
return h("label", { class: "field" },
h("span", {}, label, helpText ? " " : null, help(helpText)),
control, function field(label, control, hint) {
return h("label", { class: "field" }, h("span", {}, label), control,
hint ? h("div", { class: "hint" }, hint) : null); hint ? h("div", { class: "hint" }, hint) : null);
} }
function opt(v, label, sel) { function opt(v, label, sel) {
@@ -147,9 +153,9 @@ export function renderImport() {
field(t("import.adv.o1DeepN"), o1DeepIn), field(t("import.adv.o1DeepN"), o1DeepIn),
field(t("import.adv.o1Layers"), o1CustomIn)); field(t("import.adv.o1Layers"), o1CustomIn));
const o1Knobs = h("div", { class: "row", style: "display:none" }, const o1Knobs = h("div", { class: "row", style: "display:none" },
field(t("import.adv.o1mLabel"), o1M, t("import.adv.o1mHint"), t("import.adv.o1mHelp")), field("--o1-m", o1M, t("import.adv.o1mHint")),
field(t("import.adv.o1windowLabel"), o1Window, t("import.adv.o1windowHint"), t("import.adv.o1windowHelp")), field("--o1-window", o1Window, t("import.adv.o1windowHint")),
field(t("import.adv.o1sinkLabel"), o1Sink, t("import.adv.o1sinkHint"), t("import.adv.o1sinkHelp"))); field("--o1-sink", o1Sink, t("import.adv.o1sinkHint")));
o1Sel.addEventListener("change", () => { o1Sel.addEventListener("change", () => {
const on = !!o1Sel.value; const on = !!o1Sel.value;
o1Knobs.style.display = on ? "" : "none"; o1Knobs.style.display = on ? "" : "none";
@@ -364,6 +370,46 @@ export function renderImport() {
); );
} }
function featuredCard(item) {
const card = h(
"div",
{ class: "hf-card featured-card" },
h("div", { class: "row", style: "align-items:center; justify-content:space-between;" },
h("div", {},
h("div", { class: "hf-id" }, item.label),
h("div", { class: "hf-meta" },
h("span", { class: "badge" }, item.badge),
h("span", { class: "muted" }, item.id)
)
),
h("button", { class: "btn primary sm" }, "⬇ " + t("import.directDownload"))
)
);
card.querySelector("button").addEventListener("click", async (e) => {
e.stopPropagation();
try {
await api.startImport({
repo: item.id,
quant: "Q8_2F",
name: item.id.split("/").pop().toLowerCase(),
});
toast(t("import.started"), "ok");
refreshJobs();
jobsHost.scrollIntoView({ behavior: "smooth", block: "nearest" });
} catch (err) {
toast(err.message, "err");
}
});
return card;
}
const featuredHost = h(
"div",
{ class: "card featured-section" },
h("div", { class: "card-title" }, "⚡ " + t("import.featuredTitle")),
h("div", { class: "hf-grid" }, ...FEATURED_REPOS.map(featuredCard))
);
const root = h( const root = h(
"div", "div",
{}, {},
@@ -371,6 +417,7 @@ export function renderImport() {
h("h2", {}, t("nav.import")), h("h2", {}, t("nav.import")),
h("div", { class: "muted" }, t("import.subtitle"))), h("div", { class: "muted" }, t("import.subtitle"))),
jobsHost, // progress panel on top, above the search jobsHost, // progress panel on top, above the search
featuredHost, // featured ready-to-run models
h("div", { class: "card" }, searchIn, results), h("div", { class: "card" }, searchIn, results),
formHost formHost
); );
@@ -378,3 +425,4 @@ export function renderImport() {
refreshJobs(); // resume: show any running/recent conversions immediately refreshJobs(); // resume: show any running/recent conversions immediately
return root; return root;
} }
+10 -26
View File
@@ -1,5 +1,5 @@
// Models — pool table with add/edit modal, probe, and per-model secret entry. // Models — pool table with add/edit modal, probe, and per-model secret entry.
import { h, mount, modal, toast, money } from "../ui.js"; import { h, mount, modal, toast, confirmDialog, money } from "../ui.js";
import { t } from "../i18n.js"; import { t } from "../i18n.js";
import { api } from "../api.js"; import { api } from "../api.js";
import { appState } from "../app.js"; import { appState } from "../app.js";
@@ -280,31 +280,15 @@ function row(m, meta, reload) {
"button", "button",
{ {
class: "btn sm danger", class: "btn sm danger",
onClick: () => { onClick: async () => {
// local models may have a .cmf on disk — let the user decide its fate if (!(await confirmDialog(t("models.deleteConfirm", { id: m.id })))) return;
const isLocal = m.managed || /127\.0\.0\.1|localhost/.test(m.base_url || ""); try {
const fileCb = h("input", { type: "checkbox", checked: isLocal ? true : null }); await api.deleteModel(m.id);
const body = h( toast(t("toast.deleted"), "good");
"div", await reload();
{}, } catch (e) {
h("p", {}, t("models.deleteConfirm", { id: m.id })), toast(String(e.message || e), "bad");
isLocal }
? h("label", { class: "check" }, fileCb, t("models.deleteFile"))
: null
);
modal(t("common.delete") + ": " + m.id, body, async () => {
try {
const r = await api.deleteModel(m.id, isLocal && fileCb.checked);
toast(
r.file_removed ? t("models.deletedWithFile") : t("toast.deleted"),
"good"
);
await reload();
} catch (e) {
toast(String(e.message || e), "bad");
return false;
}
}, t("common.delete"));
}, },
}, },
t("common.delete") t("common.delete")
+1 -5
View File
@@ -245,11 +245,7 @@ export async function renderSettings() {
try { try {
const r = await api.cmfPortCheck(p); const r = await api.cmfPortCheck(p);
if (r.available) mount(st, h("span", { style: "color:var(--good)" }, "✓ " + t("settings.cmf.portFree"))); if (r.available) mount(st, h("span", { style: "color:var(--good)" }, "✓ " + t("settings.cmf.portFree")));
else if (r.managed) { else {
// held by this gateway's own managed server — replacing the model on
// this port is fine, it frees up on restart
mount(st, h("span", { style: "color:var(--accent)" }, "ℹ " + t("settings.cmf.portOwn")));
} else {
const kids = [h("span", { style: "color:var(--bad)" }, "✗ " + t("settings.cmf.portBusy"))]; 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); } 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)); mount(st, h("span", {}, ...kids));