diff --git a/static/js/cookbook.js b/static/js/cookbook.js index 9625cbd3e..bee7bb5b6 100644 --- a/static/js/cookbook.js +++ b/static/js/cookbook.js @@ -1067,6 +1067,36 @@ export function _persistEnvState() { // ── Dependencies ── // Category colors removed — using theme CSS classes instead +const _pendingDepInstalls = new Set(); +const _ACTIVE_DEP_STATUSES = new Set(['queued', 'running']); + +function _depHostKey(host) { + const h = String(host || '').trim(); + return (!h || h === 'local' || h === 'localhost' || h === '127.0.0.1') ? 'local' : h; +} + +function _depInstallKey(pipName, host = '', port = '', envPath = '') { + return [ + String(pipName || '').trim(), + _depHostKey(host), + String(port || '').trim(), + String(envPath || '').trim(), + ].join('\n'); +} + +function _taskDepInstallKey(task) { + if (!task?.payload?._dep || !task.payload.repo_id) return ''; + return _depInstallKey( + task.payload.repo_id, + task.remoteHost || task.payload.remote_host || '', + task.sshPort || task.payload.ssh_port || '', + task.payload.env_path || task.payload._envPath || '' + ); +} + +function _isActiveDepTask(task) { + return !!task?.payload?._dep && _ACTIVE_DEP_STATUSES.has(task.status || ''); +} async function _fetchDependencies() { const list = document.getElementById('cookbook-deps-list'); @@ -1128,9 +1158,31 @@ async function _fetchDependencies() { if (!pkgs.length) { list.innerHTML = '
No packages found
'; return; } const _winUnsupported = new Set(['hf_transfer', 'vllm', 'rembg', 'gfpgan']); const _systemInstallable = new Set(['tmux']); + const _activeDepTasks = new Map(); + _loadTasks().forEach(task => { + if (!_isActiveDepTask(task)) return; + const key = _taskDepInstallKey(task); + if (key && !_activeDepTasks.has(key)) _activeDepTasks.set(key, task); + }); + _pendingDepInstalls.forEach(key => { + if (key && !_activeDepTasks.has(key)) _activeDepTasks.set(key, { _pending: true }); + }); - const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => { + const _depTarget = (isLocal) => isLocal + ? { host: '', port: '', envPath: '' } + : { host: _depHost, port: _depPort, envPath: _depVenv }; + const _activeDepFor = (pkg, isLocal) => { + if (!pkg?.pip) return null; + const target = _depTarget(isLocal); + return _activeDepTasks.get(_depInstallKey(pkg.pip, target.host, target.port, target.envPath)) || null; + }; + + const _statusTag = (pkg, isLocal, isSystemDep, winBlocked, activeDep) => { if (winBlocked) return `N/A`; + if (activeDep) { + const session = activeDep.sessionId ? ` (${activeDep.sessionId})` : ''; + return `downloading`; + } if (pkg.installed && isSystemDep) return `Installed`; if (pkg.installed && pkg.pip_update_available === false && pkg.name !== 'llama_cpp') { const tip = esc(pkg.update_note || pkg.status_note || 'Found externally; update outside Odysseus.'); @@ -1174,6 +1226,7 @@ async function _fetchDependencies() { const isLocal = pkg.target === 'local'; const isSystemDep = pkg.kind === 'system'; const winBlocked = !isLocal && _isWindows() && _winUnsupported.has(pkg.name); + const activeDep = _activeDepFor(pkg, isLocal); const note = pkg.status_note ? `
${esc(pkg.status_note)}
` : ''; const updateNote = pkg.installed && pkg.pip_update_available === false && pkg.update_note ? `
${esc(pkg.update_note)}
` : ''; // Inline rebuild/reinstall tag. Styled as a .cookbook-dep-tag so it @@ -1183,7 +1236,11 @@ async function _fetchDependencies() { // diagnosis-style `_launchServeTask` with `pip install --force-reinstall` // so the user can watch the pip install in the Running tab. let _rebuildBtn = ''; - if (pkg.name === 'vllm' && pkg.installed) { + if (activeDep) { + _rebuildBtn = ''; + } else if (pkg.name === 'llama_cpp') { + _rebuildBtn = ``; + } else if (pkg.name === 'vllm' && pkg.installed) { _rebuildBtn = ``; } else if (pkg.name === 'sglang' && pkg.installed) { _rebuildBtn = ``; @@ -1229,7 +1286,7 @@ async function _fetchDependencies() { + _rebuildBtn + _buildDepsBtn + `${esc(pkg.category)}` - + _statusTag(pkg, isLocal, isSystemDep, winBlocked) + + _statusTag(pkg, isLocal, isSystemDep, winBlocked, activeDep) + recipeCaret + `` + recipePanel; @@ -1417,6 +1474,28 @@ async function _fetchDependencies() { } const targetPlatform = isLocalOnly ? (_envState.hostPlatform || _envState.platform || '') : (targetServer?.platform || _envState.platform || ''); const targetRemoteHost = isLocalOnly ? '' : (targetServer?.host || _envState.remoteHost || ''); + const activeKey = _depInstallKey( + pipName, + targetRemoteHost || '', + isLocalOnly ? '' : (_getPort(targetRemoteHost) || ''), + targetEnvPath || '' + ); + if (_pendingDepInstalls.has(activeKey) || _loadTasks().some(task => _isActiveDepTask(task) && _taskDepInstallKey(task) === activeKey)) { + uiModule.showToast(`${pkgName} is already downloading on ${targetHost}.`); + if (statusEl) { + statusEl.textContent = 'downloading'; + statusEl.disabled = true; + statusEl.classList.add('cookbook-dep-downloading'); + } + return; + } + _pendingDepInstalls.add(activeKey); + if (statusEl) { + statusEl.textContent = 'downloading'; + statusEl.disabled = true; + statusEl.classList.add('cookbook-dep-downloading'); + statusEl.title = `${pkgName} is already downloading`; + } // Always go through `python -m pip` so the leading token is `python` // — matches the /api/model/serve allow-list (bare `pip` is blocked). // Inside a venv/conda env, `--user` is invalid (pip refuses), so we @@ -1488,13 +1567,20 @@ async function _fetchDependencies() { action: 'OK', onAction: () => {}, }); + _pendingDepInstalls.delete(activeKey); + if (statusEl) { + statusEl.textContent = upgrade ? 'Update' : 'Install'; + statusEl.disabled = false; + statusEl.classList.remove('cookbook-dep-downloading'); + statusEl.title = ''; + } return; } // _dep flags this as a pip dependency/driver install (not a servable // model) so the running-task card doesn't offer a "Serve →" button. - const payload = { repo_id: depTaskId, _cmd: cmd, remote_host: targetRemoteHost || '', _dep: true, env_path: targetEnvPath || '', platform: targetPlatform || '' }; + const payload = { repo_id: depTaskId, _cmd: cmd, remote_host: targetRemoteHost || '', ssh_port: isLocalOnly ? '' : (_getPort(targetRemoteHost) || ''), _dep: true, _dep_action: upgrade ? 'update' : 'install', env_path: targetEnvPath || '', platform: targetPlatform || '' }; _addTask(data.session_id, 'pip ' + pkgName, 'download', payload); - if (statusEl) { statusEl.textContent = upgrade ? 'Updating...' : 'Installing...'; statusEl.disabled = true; } + _pendingDepInstalls.delete(activeKey); uiModule.showToast(`${upgrade ? 'Updating' : 'Installing'} ${pkgName} on ${targetHost}...`); } catch (err) { uiModule.showToast('Install failed: ' + err.message, { @@ -1502,6 +1588,13 @@ async function _fetchDependencies() { action: 'OK', onAction: () => {}, }); + _pendingDepInstalls.delete(activeKey); + if (statusEl) { + statusEl.textContent = upgrade ? 'Update' : 'Install'; + statusEl.disabled = false; + statusEl.classList.remove('cookbook-dep-downloading'); + statusEl.title = ''; + } } } diff --git a/static/js/cookbookRunning.js b/static/js/cookbookRunning.js index 5057d40d5..c6ad4767d 100644 --- a/static/js/cookbookRunning.js +++ b/static/js/cookbookRunning.js @@ -969,6 +969,9 @@ function _updateTask(sessionId, updates) { if (uptime) uptime.style.display = 'none'; } } + if (task?.type === 'download' && task.payload?._dep && updates.status && !['queued', 'running'].includes(task.status || '')) { + _refreshDepsAfterInstall(task); + } } function _refreshDepsAfterInstall(task) { @@ -980,8 +983,10 @@ function _refreshDepsAfterInstall(task) { export function _removeTask(sessionId) { _tombstoneTask(sessionId); // so sync/poll can't resurrect it - const tasks = _loadTasks().filter(t => t.sessionId !== sessionId); - _saveTasks(tasks); + const tasks = _loadTasks(); + const task = tasks.find(t => t.sessionId === sessionId); + _saveTasks(tasks.filter(t => t.sessionId !== sessionId)); + _refreshDepsAfterInstall(task); _renderRunningTab(); } diff --git a/static/style.css b/static/style.css index 73fdbcd5b..928c4e98b 100644 --- a/static/style.css +++ b/static/style.css @@ -20632,6 +20632,14 @@ body.gallery-selecting .gallery-dl-btn, padding: 0 10px; box-sizing: border-box; } +.cookbook-dep-downloading { + background: color-mix(in srgb, var(--orange, #ffb86c) 18%, transparent); + color: var(--orange, #ffb86c); + border: 1px solid color-mix(in srgb, var(--orange, #ffb86c) 35%, transparent); + min-width: 75.85px; + padding: 0 10px; + box-sizing: border-box; +} .cookbook-dep-na { background: color-mix(in srgb, var(--fg) 8%, transparent); color: color-mix(in srgb, var(--fg) 60%, transparent); @@ -20660,6 +20668,13 @@ body.gallery-selecting .gallery-dl-btn, -webkit-appearance: none; -moz-appearance: none; } +.cookbook-dep-install.cookbook-dep-downloading { + background: color-mix(in srgb, var(--orange, #ffb86c) 18%, transparent); + color: var(--orange, #ffb86c); + border: 1px solid color-mix(in srgb, var(--orange, #ffb86c) 35%, transparent); + cursor: default; + opacity: 1; +} /* Conditional line under the Download h2: only when the section is folded (collapsed). When expanded, the body content provides separation; the underline reads as clutter. */ @@ -20693,6 +20708,7 @@ body.gallery-selecting .gallery-dl-btn, padding-left: 0.5px; } .cookbook-dep-install:hover { opacity: 0.85; } +.cookbook-dep-install.cookbook-dep-downloading:hover { opacity: 1; } /* Installed split button: "Installed" label + separator + ▾ caret; clicking it opens the actions menu (Update). Replaces the old ⋮ button. */ .cookbook-dep-installed-btn {