Skip to content

Commit 1ce13ef

Browse files
zoomdbzentitycs
authored andcommitted
fix(cookbook): treat local Windows as Windows for serve commands (odysseus-dev#3975)
* fix(cookbook): prefer native llama-server on local Windows * fix(cookbook): harden local llama-server launch commands * fix(cookbook): build serve commands for selected target
1 parent ce15afb commit 1ce13ef

3 files changed

Lines changed: 119 additions & 125 deletions

File tree

routes/cookbook_routes.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ def _mask_secret(value: str) -> str:
7474
return "stored"
7575
return f"{value[:4]}...{value[-4:]}"
7676

77+
def _client_host_platform() -> str:
78+
return "windows" if IS_WINDOWS else ""
79+
7780
def _decrypt_secret(value: str | None) -> str:
7881
if not value:
7982
return ""
@@ -246,11 +249,15 @@ def _state_for_client(state):
246249
"""Return cookbook state without raw secrets for browser clients."""
247250
_strip_task_secrets(state)
248251
env = state.get("env") if isinstance(state, dict) else None
252+
if isinstance(state, dict) and not isinstance(env, dict):
253+
env = {}
254+
state["env"] = env
249255
if isinstance(env, dict):
250256
token = _decrypt_secret(env.get("hfToken"))
251257
env.pop("hfToken", None)
252258
env["hfTokenConfigured"] = bool(token)
253259
env["hfTokenMasked"] = _mask_secret(token)
260+
env["hostPlatform"] = _client_host_platform()
254261
return state
255262

256263
def _state_for_storage(state, on_disk=None):
@@ -269,6 +276,7 @@ def _state_for_storage(state, on_disk=None):
269276
env.pop("hfToken", None)
270277
env.pop("hfTokenMasked", None)
271278
env.pop("hfTokenConfigured", None)
279+
env.pop("hostPlatform", None)
272280
return state
273281

274282
def _load_stored_hf_token() -> str:
@@ -1528,6 +1536,10 @@ async def model_serve(request: Request, req: ServeRequest):
15281536
# shell resolves the bundled python3/hf, mirroring the download flow.
15291537
if not remote:
15301538
runner_lines.append(_local_tooling_path_export(sys.executable))
1539+
if local_windows:
1540+
# Detached Git Bash runs do not always inherit recently edited
1541+
# user PATH entries from the already-running Odysseus process.
1542+
runner_lines.append('export PATH="$HOME/bin:$HOME/llama.cpp/build-cuda/bin/Release:$HOME/llama.cpp/build/bin/Release:$HOME/llama.cpp/build/bin/Debug:$HOME/llama.cpp/build/bin:$PATH"')
15311543
runner_lines.append("export FLASHINFER_DISABLE_VERSION_CHECK=1")
15321544
if req.hf_token:
15331545
runner_lines.append(f"export HF_TOKEN='{_bash_squote(req.hf_token)}'")
@@ -1542,7 +1554,8 @@ async def model_serve(request: Request, req: ServeRequest):
15421554
runner_lines.append(_HF_TOKEN_STATUS_SNIPPET)
15431555
handled_ollama_serve = False
15441556
# Auto-install inference engine if missing
1545-
if "llama_cpp" in req.cmd or "llama-server" in req.cmd:
1557+
local_windows_llama_cmd = local_windows and ("llama_cpp" in req.cmd or "llama-server" in req.cmd)
1558+
if ("llama_cpp" in req.cmd or "llama-server" in req.cmd) and not local_windows_llama_cmd:
15461559
# Prefer the NATIVE llama-server binary — its minja templating
15471560
# renders modern GGUF chat templates that the Python bindings'
15481561
# Jinja2 rejects (do_tojson ensure_ascii). Build it once from
@@ -2445,8 +2458,8 @@ async def get_cookbook_state(request: Request):
24452458
try:
24462459
return _state_for_client(json.loads(_cookbook_state_path.read_text(encoding="utf-8")))
24472460
except Exception:
2448-
return {}
2449-
return {}
2461+
return _state_for_client({})
2462+
return _state_for_client({})
24502463

24512464
@router.post("/api/cookbook/state")
24522465
async def save_cookbook_state(request: Request):

static/js/cookbookServe.js

Lines changed: 102 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -46,30 +46,6 @@ const SERVE_STATE_KEY = 'cookbook-serve-state';
4646
const SERVE_FAVORITES_KEY = 'cookbook-serve-favorite-models';
4747

4848
let _cachedAllModels = [];
49-
const _CACHED_MODELS_SCAN_KEY = 'cookbook_cached_models_scan_v1';
50-
const _CACHED_MODELS_SCAN_TTL = 6 * 3600 * 1000;
51-
52-
function _readCachedModelScan(sig) {
53-
try {
54-
const all = JSON.parse(localStorage.getItem(_CACHED_MODELS_SCAN_KEY) || '{}');
55-
const entry = all[sig];
56-
if (entry && Date.now() - (entry.ts || 0) < _CACHED_MODELS_SCAN_TTL) return entry.data || null;
57-
} catch {}
58-
return null;
59-
}
60-
61-
function _writeCachedModelScan(sig, data) {
62-
try {
63-
const all = JSON.parse(localStorage.getItem(_CACHED_MODELS_SCAN_KEY) || '{}');
64-
all[sig] = { ts: Date.now(), data };
65-
const keys = Object.keys(all);
66-
if (keys.length > 12) {
67-
keys.sort((a, b) => (all[a].ts || 0) - (all[b].ts || 0));
68-
for (const k of keys.slice(0, keys.length - 12)) delete all[k];
69-
}
70-
localStorage.setItem(_CACHED_MODELS_SCAN_KEY, JSON.stringify(all));
71-
} catch {}
72-
}
7349

7450
function _loadServeFavorites() {
7551
try {
@@ -551,7 +527,7 @@ function _selectedServeTarget(panel) {
551527
env: server?.env || '',
552528
port: host ? (server?.port || _getPort(host) || '') : '',
553529
venv,
554-
platform: server?.platform || _envState.platform || '',
530+
platform: host ? (server?.platform || '') : (_envState.hostPlatform || ''),
555531
label,
556532
};
557533
}
@@ -682,6 +658,12 @@ function _selectedGgufSizeGb(model, relPath) {
682658
return bytes / (1024 ** 3);
683659
}
684660

661+
function _projectorGgufFiles(model) {
662+
return _ggufFilesForModel(model)
663+
.filter(f => (f.role || '') === 'projector' || /(^|\/)mmproj[^/]*\.gguf$/i.test(f.rel_path || f.name || ''))
664+
.sort((a, b) => String(a.rel_path || a.name || '').localeCompare(String(b.rel_path || b.name || '')));
665+
}
666+
685667
function _ggufFileLabel(file) {
686668
const base = (file.name || file.rel_path || '').split('/').pop();
687669
const size = _formatGgufSize(file.size_bytes);
@@ -1222,6 +1204,7 @@ function _rerenderCachedModels() {
12221204
panelHtml += `<div class="hwfit-serve-warn" style="margin:0 0 8px;padding:6px 10px;border-radius:5px;font-size:11px;background:color-mix(in srgb, var(--color-warning, #f0ad4e) 14%, transparent);border:1px solid color-mix(in srgb, var(--color-warning, #f0ad4e) 40%, transparent);color:var(--color-warning, #f0ad4e);display:flex;gap:6px;align-items:flex-start;line-height:1.4;"><span aria-hidden="true">⚠</span><span>${_warnText}</span></div>`;
12231205
}
12241206
panelHtml += `<div class="hwfit-serve-preset-row">${_slotsHtml}</div>`;
1207+
panelHtml += `<div class="hwfit-serve-vision-warn" style="display:none;margin:0 0 8px;padding:6px 10px;border-radius:5px;font-size:11px;background:color-mix(in srgb, var(--color-warning, #f0ad4e) 14%, transparent);border:1px solid color-mix(in srgb, var(--color-warning, #f0ad4e) 40%, transparent);color:var(--color-warning, #f0ad4e);gap:6px;align-items:flex-start;line-height:1.4;"><span aria-hidden="true">⚠</span><span>Vision is enabled, but no mmproj GGUF projector was found in the cached model scan. Download an mmproj-*.gguf for this model, then refresh the cached model list before launching.</span></div>`;
12251208
// Row 1: Engine + Server + Env
12261209
panelHtml += `<div class="hwfit-serve-row">`;
12271210
const backendOpts = _backendChoices.map(([v,l]) => `<option value="${v}"${defaultBackend===v?' selected':''}>${l}</option>`).join('');
@@ -1548,6 +1531,11 @@ function _rerenderCachedModels() {
15481531
if (el.type === 'checkbox') f[el.dataset.field] = el.checked;
15491532
else f[el.dataset.field] = el.value;
15501533
});
1534+
const buildTarget = _selectedServeTarget(panel);
1535+
f.host = buildTarget.host || '';
1536+
f.platform = buildTarget.platform || '';
1537+
const hostField = panel.querySelector('[data-field="host"]');
1538+
if (hostField) hostField.value = f.host;
15511539
const backend = f.backend || 'vllm';
15521540
const serveModel = (f.model_path || '').trim() || (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo);
15531541
if (backend === 'llamacpp') {
@@ -1567,11 +1555,11 @@ function _rerenderCachedModels() {
15671555
: m.is_local_dir && m.path
15681556
? `$({ find ${_ldir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${_ldir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`
15691557
: `$({ find ${dir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${dir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`;
1570-
// Vision: auto-find the mmproj (CLIP/projector) file in the same dir.
1571-
// Resolved at runtime so the toggle just works if an mmproj-*.gguf is
1572-
// present (downloaded alongside the model). Empty if none → cmd omits it.
1573-
const _vsearchdir = (m.is_local_dir && m.path) ? _ldir : dir;
1574-
f._mmproj_path = `$(find ${_vsearchdir} -iname 'mmproj*.gguf' 2>/dev/null | sort | head -1)`;
1558+
// Vision: use the scanned projector (CLIP/mmproj) file when present.
1559+
// Keeping this as a printf path avoids generating a command substitution
1560+
// that the backend serve-command validator must reject as unsafe.
1561+
const selectedProjector = _projectorGgufFiles(m)[0];
1562+
f._mmproj_path = selectedProjector ? _selectedGgufExpr(m, repo, selectedProjector.rel_path) : '';
15751563
}
15761564
if (f.reasoning_parser) {
15771565
const _rpEl2 = panel.querySelector('[data-field="reasoning_parser"]');
@@ -1587,6 +1575,10 @@ function _rerenderCachedModels() {
15871575
}
15881576
let cmd = _buildServeCmd(f, serveModel, backend);
15891577
if (f.extra && f.extra.trim()) cmd += ' ' + f.extra.trim();
1578+
const missingVisionProjector = backend === 'llamacpp' && !!f.vision && !f._mmproj_path;
1579+
panel._visionMissingProjector = missingVisionProjector;
1580+
const _visionWarn = panel.querySelector('.hwfit-serve-vision-warn');
1581+
if (_visionWarn) _visionWarn.style.display = missingVisionProjector ? 'flex' : 'none';
15901582
const _ce2 = panel.querySelector('.hwfit-serve-cmd'); _ce2.value = _formatServeCmdPreview(cmd); _ce2.style.height = 'auto'; _ce2.style.height = _ce2.scrollHeight + 'px';
15911583
panel._cmd = cmd;
15921584
panel._host = f.host || '';
@@ -2962,12 +2954,16 @@ function _rerenderCachedModels() {
29622954
});
29632955
serveState.backend = serveState.backend || (_detectBackend(m).backend) || 'vllm';
29642956
const launchTarget = _selectedServeTarget(panel);
2957+
if (serveState.backend === 'llamacpp' && serveState.vision && !/(?:^|\s)(?:--mmproj|--clip_model_path)\b/.test(launchCmd)) {
2958+
_restoreLaunchBtn();
2959+
uiModule.showToast('Vision is checked, but no mmproj projector is in the launch command. Refresh cached models after downloading mmproj, or add --mmproj manually.', 8000);
2960+
return;
2961+
}
29652962
if (serveState.backend === 'diffusers' && _remoteWindowsDiffusersUnsupported(launchTarget)) {
29662963
_restoreLaunchBtn();
29672964
uiModule.showToast('Diffusers serving is not supported on remote Windows servers yet. Use local Windows or a Linux server.', 9000);
29682965
return;
29692966
}
2970-
29712967
// Pre-launch: check our own task list for a serve already running
29722968
// on this host. Offer to stop+launch as the default action — the
29732969
// SSH-based port probe below is more thorough but it can miss
@@ -3596,91 +3592,12 @@ export async function openServePanelForRepo(repo, fields) {
35963592

35973593
// ── Fetch cached models from server ──
35983594

3599-
function _renderCachedModelsData(list, data, host) {
3600-
// CHANGELOG: 'ready' already excludes partial downloads;
3601-
// show every complete model regardless of size/backend.
3602-
const ready = (data.models || []).filter(m => m.status === 'ready');
3603-
3604-
const downloading = (data.models || []).filter(m => m.status === 'downloading');
3605-
const allModels = [...ready, ...downloading];
3606-
_cachedAllModels = allModels;
3607-
3608-
if (!allModels.length) {
3609-
if (!host) {
3610-
list.innerHTML = '<div class="hwfit-loading" style="flex-direction:column;gap:6px;text-align:center;"><div>No cached models found</div><div style="font-size:11px;opacity:0.55;max-width:420px;line-height:1.4;">Docker Local uses Odysseus’s cache in <code>data/huggingface</code>. Download a model here, or copy an existing host HuggingFace cache into that folder once.</div></div>';
3611-
} else {
3612-
list.innerHTML = '<div class="hwfit-loading">No cached models found</div>';
3613-
}
3614-
const tagContainer = document.getElementById('serve-tags');
3615-
if (tagContainer) tagContainer.innerHTML = '';
3616-
return;
3617-
}
3618-
3619-
// Auto-detect type + family tags
3620-
const _tagMap = {};
3621-
const _familyMap = {};
3622-
const _families = [
3623-
[/qwen/i, 'qwen'], [/llama/i, 'llama'], [/mistral|mixtral/i, 'mistral'],
3624-
[/deepseek/i, 'deepseek'], [/gemma/i, 'gemma'], [/phi/i, 'phi'],
3625-
[/minimax/i, 'minimax'], [/glm/i, 'glm'], [/flux/i, 'flux'],
3626-
[/stable.?diffusion|sdxl/i, 'sd'], [/z-image/i, 'z-image'],
3627-
[/whisper/i, 'whisper'], [/command|cohere/i, 'cohere'],
3628-
[/yi-/i, 'yi'], [/intern/i, 'intern'], [/falcon/i, 'falcon'],
3629-
];
3630-
for (const m of allModels) {
3631-
const n = (m.repo_id || '').toLowerCase();
3632-
let tag = 'other';
3633-
if (m.backend === 'ollama' || m.is_ollama) tag = 'llm';
3634-
else if (m.is_diffusion || /flux|sdxl|stable-diffusion|z-image|qwen-image|diffusion|dreamshar/i.test(n)) tag = 'image';
3635-
else if (/whisper|stt|asr/i.test(n)) tag = 'stt';
3636-
else if (/tts|cosyvoice|parler/i.test(n)) tag = 'tts';
3637-
else if (/embed|bge|minilm|e5-/i.test(n)) tag = 'embedding';
3638-
else if (/lora|adapter/i.test(n)) tag = 'lora';
3639-
else tag = 'llm';
3640-
m._tag = tag;
3641-
_tagMap[tag] = (_tagMap[tag] || 0) + 1;
3642-
m._family = '';
3643-
for (const [re, fam] of _families) {
3644-
if (re.test(n)) { m._family = fam; _familyMap[fam] = (_familyMap[fam] || 0) + 1; break; }
3645-
}
3646-
if ((m.backend === 'ollama' || m.is_ollama) && !m._family) {
3647-
m._family = 'ollama';
3648-
_familyMap.ollama = (_familyMap.ollama || 0) + 1;
3649-
}
3650-
}
3651-
3652-
// Render tag chips
3653-
const tagContainer = document.getElementById('serve-tags');
3654-
if (tagContainer) {
3655-
const tagOrder = ['llm', 'image', 'lora', 'embedding', 'tts', 'stt', 'other'];
3656-
let tagHtml = `<button class="memory-cat-chip active" data-serve-tag="">All (${allModels.length})</button>`;
3657-
for (const t of tagOrder) {
3658-
if (!_tagMap[t]) continue;
3659-
tagHtml += `<button class="memory-cat-chip" data-serve-tag="${t}">${t} (${_tagMap[t]})</button>`;
3660-
}
3661-
const sortedFamilies = Object.entries(_familyMap).sort((a, b) => b[1] - a[1]);
3662-
if (sortedFamilies.length) {
3663-
for (const [fam, count] of sortedFamilies) {
3664-
const logo = providerLogo(fam);
3665-
const logoHtml = logo ? `<span style="width:12px;height:12px;display:inline-flex;align-items:center;vertical-align:-2px;margin-right:2px;opacity:0.6;">${logo}</span>` : '';
3666-
tagHtml += `<button class="memory-cat-chip" data-serve-tag="fam:${fam}">${logoHtml}${fam} (${count})</button>`;
3667-
}
3668-
}
3669-
tagContainer.innerHTML = tagHtml;
3670-
}
3671-
3672-
_rerenderCachedModels();
3673-
}
3674-
3675-
export async function _fetchCachedModels(fresh = false) {
3595+
export async function _fetchCachedModels() {
36763596
const list = document.getElementById('hwfit-cached-list');
36773597
if (!list) return;
36783598

36793599
list.innerHTML = '';
3680-
const _dlWp = spinnerModule.createWhirlpool(22);
3681-
_dlWp.element.classList.add('cookbook-section-loading-wp');
3682-
_dlWp.element.style.width = '22px';
3683-
_dlWp.element.style.height = '22px';
3600+
const _dlWp = spinnerModule.createWhirlpool(18);
36843601
const _dlWrap = document.createElement('div');
36853602
_dlWrap.className = 'hwfit-loading';
36863603
_dlWrap.style.cssText = 'flex-direction:column;gap:6px;';
@@ -3742,13 +3659,6 @@ export async function _fetchCachedModels(fresh = false) {
37423659
if (host) { qp.set('host', host); const _sp4 = _getPort(host); if (_sp4) qp.set('ssh_port', _sp4); const _plat = _getPlatform(host); if (_plat) qp.set('platform', _plat); }
37433660
if (modelDirs.length) qp.set('model_dir', modelDirs.join(','));
37443661
const params = qp.toString() ? `?${qp}` : '';
3745-
const scanSig = params || 'local';
3746-
const cached = fresh ? null : _readCachedModelScan(scanSig);
3747-
if (cached) {
3748-
_dlWp.destroy();
3749-
_renderCachedModelsData(list, cached, host);
3750-
return;
3751-
}
37523662
const res = await fetch(`/api/model/cached${params}`);
37533663
if (!res.ok) {
37543664
const body = await res.text().catch(() => '');
@@ -3763,9 +3673,80 @@ export async function _fetchCachedModels(fresh = false) {
37633673
throw new Error(`HTTP ${res.status} ${res.statusText}${msg ? `: ${msg}` : ''}`);
37643674
}
37653675
const data = await res.json();
3766-
_writeCachedModelScan(scanSig, data);
37673676
_dlWp.destroy();
3768-
_renderCachedModelsData(list, data, host);
3677+
3678+
// CHANGELOG: 'ready' already excludes partial downloads;
3679+
// show every complete model regardless of size/backend.
3680+
const ready = data.models.filter(m => m.status === 'ready');
3681+
3682+
const downloading = data.models.filter(m => m.status === 'downloading');
3683+
const allModels = [...ready, ...downloading];
3684+
_cachedAllModels = allModels;
3685+
3686+
if (!allModels.length) {
3687+
if (!host) {
3688+
list.innerHTML = '<div class="hwfit-loading" style="flex-direction:column;gap:6px;text-align:center;"><div>No cached models found</div><div style="font-size:11px;opacity:0.55;max-width:420px;line-height:1.4;">Docker Local uses Odysseus’s cache in <code>data/huggingface</code>. Download a model here, or copy an existing host HuggingFace cache into that folder once.</div></div>';
3689+
} else {
3690+
list.innerHTML = '<div class="hwfit-loading">No cached models found</div>';
3691+
}
3692+
document.getElementById('serve-tags').innerHTML = '';
3693+
return;
3694+
}
3695+
3696+
// Auto-detect type + family tags
3697+
const _tagMap = {};
3698+
const _familyMap = {};
3699+
const _families = [
3700+
[/qwen/i, 'qwen'], [/llama/i, 'llama'], [/mistral|mixtral/i, 'mistral'],
3701+
[/deepseek/i, 'deepseek'], [/gemma/i, 'gemma'], [/phi/i, 'phi'],
3702+
[/minimax/i, 'minimax'], [/glm/i, 'glm'], [/flux/i, 'flux'],
3703+
[/stable.?diffusion|sdxl/i, 'sd'], [/z-image/i, 'z-image'],
3704+
[/whisper/i, 'whisper'], [/command|cohere/i, 'cohere'],
3705+
[/yi-/i, 'yi'], [/intern/i, 'intern'], [/falcon/i, 'falcon'],
3706+
];
3707+
for (const m of allModels) {
3708+
const n = (m.repo_id || '').toLowerCase();
3709+
let tag = 'other';
3710+
if (m.backend === 'ollama' || m.is_ollama) tag = 'llm';
3711+
else if (m.is_diffusion || /flux|sdxl|stable-diffusion|z-image|qwen-image|diffusion|dreamshar/i.test(n)) tag = 'image';
3712+
else if (/whisper|stt|asr/i.test(n)) tag = 'stt';
3713+
else if (/tts|cosyvoice|parler/i.test(n)) tag = 'tts';
3714+
else if (/embed|bge|minilm|e5-/i.test(n)) tag = 'embedding';
3715+
else if (/lora|adapter/i.test(n)) tag = 'lora';
3716+
else tag = 'llm';
3717+
m._tag = tag;
3718+
_tagMap[tag] = (_tagMap[tag] || 0) + 1;
3719+
m._family = '';
3720+
for (const [re, fam] of _families) {
3721+
if (re.test(n)) { m._family = fam; _familyMap[fam] = (_familyMap[fam] || 0) + 1; break; }
3722+
}
3723+
if ((m.backend === 'ollama' || m.is_ollama) && !m._family) {
3724+
m._family = 'ollama';
3725+
_familyMap.ollama = (_familyMap.ollama || 0) + 1;
3726+
}
3727+
}
3728+
3729+
// Render tag chips
3730+
const tagContainer = document.getElementById('serve-tags');
3731+
if (tagContainer) {
3732+
const tagOrder = ['llm', 'image', 'lora', 'embedding', 'tts', 'stt', 'other'];
3733+
let tagHtml = `<button class="memory-cat-chip active" data-serve-tag="">All (${allModels.length})</button>`;
3734+
for (const t of tagOrder) {
3735+
if (!_tagMap[t]) continue;
3736+
tagHtml += `<button class="memory-cat-chip" data-serve-tag="${t}">${t} (${_tagMap[t]})</button>`;
3737+
}
3738+
const sortedFamilies = Object.entries(_familyMap).sort((a, b) => b[1] - a[1]);
3739+
if (sortedFamilies.length) {
3740+
for (const [fam, count] of sortedFamilies) {
3741+
const logo = providerLogo(fam);
3742+
const logoHtml = logo ? `<span style="width:12px;height:12px;display:inline-flex;align-items:center;vertical-align:-2px;margin-right:2px;opacity:0.6;">${logo}</span>` : '';
3743+
tagHtml += `<button class="memory-cat-chip" data-serve-tag="fam:${fam}">${logoHtml}${fam} (${count})</button>`;
3744+
}
3745+
}
3746+
tagContainer.innerHTML = tagHtml;
3747+
}
3748+
3749+
_rerenderCachedModels();
37693750
} catch (e) {
37703751
_dlWp.destroy();
37713752
list.innerHTML = `<div class="hwfit-loading">Failed: ${esc(e.message)}</div>`;

0 commit comments

Comments
 (0)