diff --git a/.env.example b/.env.example index 0f4dcd4499..e96dd151cd 100644 --- a/.env.example +++ b/.env.example @@ -169,6 +169,26 @@ SEARXNG_INSTANCE=http://localhost:8080 # ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB) # ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB) +# ============================================================ +# Host Docker access (explicit opt-in) +# ============================================================ +# Default Docker Compose does not mount /var/run/docker.sock. Existing +# Ollama, vLLM, and other OpenAI-compatible endpoints remain usable without it. +# +# Enable this only for intentional Cookbook/local Docker-daemon management. +# Raw socket access is high-trust and can grant broad control over the host +# Docker daemon. Set DOCKER_GID to the host docker group's numeric GID. +# Put these values in .env, or export them before running docker compose. +# COMPOSE_FILE=docker-compose.yml:docker/host-docker.yml +# DOCKER_GID=963 +# docker/host-docker.yml sets this inside the container. Keep it paired +# with the socket overlay; setting it alone is not sufficient. +# ODYSSEUS_ENABLE_HOST_DOCKER=true +# +# Host Docker access can be combined with one GPU overlay: +# COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml:docker/host-docker.yml +# COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml:docker/host-docker.yml + # ============================================================ # GPU support (Docker Compose) # ============================================================ diff --git a/.github/scripts/focused_test_guidance.py b/.github/scripts/focused_test_guidance.py new file mode 100644 index 0000000000..1426d35fa6 --- /dev/null +++ b/.github/scripts/focused_test_guidance.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Report focused pytest guidance for changed paths under tests/.""" + +from __future__ import annotations + +import argparse +import os +import shlex +import subprocess +import sys +from collections.abc import Iterable +from pathlib import PurePosixPath + + +def parse_paths(raw_paths: bytes) -> list[str]: + """Decode the NUL-delimited output of ``git diff --name-only -z``.""" + return [os.fsdecode(path) for path in raw_paths.split(b"\0") if path] + + +def changed_paths_from_merge_base(base_sha: str, head_sha: str) -> list[str]: + """Return changed ``tests/`` paths using GitHub PR three-dot semantics. + + GitHub PR changed files are based on the merge base and the PR head, not a + direct endpoint diff between the current base branch tip and the PR head. + Using the direct endpoint diff can include files changed only on the base + branch when the PR branch is stale. + """ + merge_base = subprocess.check_output( + ["git", "merge-base", base_sha, head_sha], + stderr=subprocess.DEVNULL, + ).strip() + raw_paths = subprocess.check_output( + [ + "git", + "diff", + "--name-only", + "--diff-filter=ACMRT", + "-z", + os.fsdecode(merge_base), + head_sha, + "--", + "tests/", + ], + ) + return parse_paths(raw_paths) + + +def select_test_paths(paths: Iterable[str]) -> list[str]: + """Return unique, repository-relative paths contained by tests/.""" + selected: set[str] = set() + for raw_path in paths: + path = PurePosixPath(raw_path) + if path.is_absolute() or ".." in path.parts: + continue + parts = tuple(part for part in path.parts if part != ".") + if len(parts) >= 2 and parts[0] == "tests": + selected.add(PurePosixPath(*parts).as_posix()) + return sorted(selected) + + +def is_pytest_file(path: str) -> bool: + """Return whether a changed path follows this repository's pytest naming.""" + name = PurePosixPath(path).name + return name.endswith(".py") and ( + name.startswith("test_") or name.endswith("_test.py") + ) + + +def pytest_command(paths: Iterable[str]) -> str: + """Build a copyable pytest command for changed runnable test files.""" + command = ["python3", "-m", "pytest", "-q", *paths] + return shlex.join(command) + + +def format_report(paths: Iterable[str]) -> str: + """Format focused guidance for CI logs and the workflow summary.""" + changed_paths = select_test_paths(paths) + runnable_paths = [path for path in changed_paths if is_pytest_file(path)] + lines = ["## Focused test guidance (report-only)", ""] + if not changed_paths: + lines.append("No changed paths under `tests/`.") + else: + lines.extend(["Changed paths under `tests/`:", ""]) + lines.extend(f"- `{path}`" for path in changed_paths) + lines.extend(["", "Suggested focused validation:", ""]) + if runnable_paths: + lines.append(f"```sh\n{pytest_command(runnable_paths)}\n```") + else: + lines.append("No directly runnable pytest files changed.") + lines.extend( + [ + "", + "This guidance does not infer tests from source changes. " + "Existing blocking CI remains the source of truth.", + ] + ) + return "\n".join(lines) + + +def _parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Report focused pytest guidance for changed tests/ paths.", + ) + parser.add_argument("--base-sha", help="Pull request base commit SHA.") + parser.add_argument("--head-sha", help="Pull request head commit SHA.") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(sys.argv[1:] if argv is None else argv) + if bool(args.base_sha) != bool(args.head_sha): + raise SystemExit("--base-sha and --head-sha must be provided together") + + if args.base_sha and args.head_sha: + paths = changed_paths_from_merge_base(args.base_sha, args.head_sha) + else: + paths = parse_paths(sys.stdin.buffer.read()) + + print(format_report(paths)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 787bd9dea0..f7d3659e85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,60 @@ concurrency: cancel-in-progress: true jobs: + focused-test-guidance: + name: Focused test guidance (report-only) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + - name: Report changed test paths + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + report_file="$RUNNER_TEMP/focused-test-guidance.md" + publish_report() { + cat "$report_file" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + cat "$report_file" >> "$GITHUB_STEP_SUMMARY" || true + fi + return 0 + } + + report_unavailable() { + { + printf '%s\n\n' '## Focused test guidance unavailable (report-only)' + printf '%s\n\n' "$1" + printf '%s\n' 'Existing blocking CI remains the source of truth.' + } > "$report_file" + publish_report + exit 0 + } + + if [ -z "$BASE_SHA" ] || [ -z "$HEAD_SHA" ]; then + report_unavailable "Pull request base/head metadata is missing." + fi + + if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then + report_unavailable "The pull request base commit is unavailable locally." + fi + + if ! git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then + report_unavailable "The pull request head commit is unavailable locally." + fi + + if ! python3 .github/scripts/focused_test_guidance.py \ + --base-sha "$BASE_SHA" \ + --head-sha "$HEAD_SHA" > "$report_file"; then + report_unavailable "The focused test guidance helper could not produce a report." + fi + + publish_report + python-syntax: name: Python syntax (compileall) runs-on: ubuntu-latest diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml new file mode 100644 index 0000000000..8d402586bf --- /dev/null +++ b/.github/workflows/static.yml @@ -0,0 +1,43 @@ +# Simple workflow for deploying static content to GitHub Pages +name: Deploy static content to Pages + +on: + # Runs on pushes targeting the default branch + push: + branches: ["dev"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Single deploy job since we're just deploying + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Pages + uses: actions/configure-pages@v5 + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + # Upload entire repository + path: '.' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index fdf55c48a2..94092c6ca1 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -86,6 +86,7 @@ Bundled in `static/fonts/`: | [Fira Code](https://github.com/tonsky/FiraCode) | SIL Open Font License 1.1 | Nikita Prokopov & contributors | | [Inter](https://github.com/rsms/inter) | SIL Open Font License 1.1 | Rasmus Andersson | | [GohuFont](https://font.gohu.org/) (`fonts/custom/GohuFont.ttf`) | WTFPL | Hugo Chargois | +| [OpenDyslexic](https://opendyslexic.org/) (`fonts/OpenDyslexic-{Regular,Bold}.woff2`) | SIL Open Font License 1.1 ([`licenses/OpenDyslexic-OFL.txt`](licenses/OpenDyslexic-OFL.txt)) | Abbie Gonzalez | ## Python dependencies diff --git a/Dockerfile b/Dockerfile index bed5e20028..9b305569cf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,14 @@ +# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ---- +# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'], +# which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so +# the final image / Cookbook never has to compile the broken sdists. See +# docker/build-realesrgan-wheels.sh for the full rationale. +FROM python:3.14-slim AS realesrgan-wheels +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* +COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh +RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels + FROM python:3.14-slim # System deps. tmux is required by Cookbook for background downloads/serves. @@ -18,8 +29,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ tmux \ openssh-client \ gosu \ + libgl1 \ + libglib2.0-0t64 \ + libxcb1 \ + libmagic1 \ && rm -rf /var/lib/apt/lists/* +# libgl1/libglib2.0-0t64/libxcb1 are runtime shared libs (libGL.so.1, +# libglib-2.0/libgthread, libxcb.so.1) that opencv-python (cv2) loads. The +# slim base omits them, so the Cookbook "install realesrgan" path imports cv2 +# and dies with `libxcb.so.1: cannot open shared object file` despite a clean +# pip install. Using full opencv-python (not -headless) because basicsr/gfpgan/ +# facexlib/realesrgan all depend on the `opencv-python` distribution by name. +# +# libmagic1 is the shared lib (libmagic.so.1) that python-magic dlopens for +# content-based MIME sniffing in src/upload_handler.py. We install both here +# (libmagic1 + the python-magic wrapper, below) rather than in requirements.txt +# because python-magic resolves libmagic at import time: where the lib is +# absent the import can block or raise, so keeping it image-only avoids +# regressing pip/venv installs on hosts without libmagic. Debian always has the +# lib here, so the import is instant and detection actually works. + # Docker CLI (client only — daemon stays on the host via the # /var/run/docker.sock mount). The Debian `docker.io` package ships # dockerd but not the client binary on slim, so grab the static client @@ -46,6 +76,20 @@ COPY requirements.txt requirements-optional.txt ./ RUN pip install --no-cache-dir -r requirements.txt \ && if [ "$INSTALL_OPTIONAL" = "true" ]; then pip install --no-cache-dir -r requirements-optional.txt; fi +# python-magic powers content-based MIME sniffing in src/upload_handler.py. +# Image-only (not in requirements.txt) because it needs the libmagic1 system +# lib installed above; see the apt note near the top of this stage. +RUN pip install --no-cache-dir python-magic==0.4.27 + +# Pre-install the patched basicsr/gfpgan/facexlib wheels built in the +# realesrgan-wheels stage (--no-deps keeps the image lean — torch & friends are +# pulled only when realesrgan is actually installed). With these dists already +# satisfied, the Cookbook's plain `pip install realesrgan` resolves them from +# wheels instead of rebuilding the sdists that fail on Python 3.14. +COPY --from=realesrgan-wheels /wheels/ /tmp/odysseus-wheels/ +RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \ + && rm -rf /tmp/odysseus-wheels + # Copy app code COPY . . diff --git a/ROADMAP.md b/ROADMAP.md index 7c59c1f6a6..d29ac5c752 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,6 @@ the codebase, you are probably right to stay away. and WSL all need coverage. - Integration audit: do integrations even work? Confirm what works, what needs setup docs, and what should be removed or hidden. -- Self-host troubleshooting cookbook. Document the weird 30-second fixes that otherwise become 30-minute searches: Dovecot cleartext auth for local stacks, ntfy Android Instant Delivery for non-ntfy.sh servers, clipboard limits on plain-HTTP Tailscale URLs, Radicale collection URLs, and similar traps. - Cookbook reliability on other computers. This is probably the area most likely to need work across different machines, GPUs, drivers, shells, and Python environments. - Cookbook SGLang support across platforms. Make sure SGLang setup/serve works predictably on Linux, Windows/WSL, macOS where possible, Docker, and common diff --git a/app.py b/app.py index 57d091efdc..a89f80143c 100644 --- a/app.py +++ b/app.py @@ -2,6 +2,17 @@ import mimetypes import os import sys +import asyncio +import time + +# On Windows, asyncio.create_subprocess_exec/shell require the ProactorEventLoop. +# When started via `python -m uvicorn` from a terminal, uvicorn sets this +# automatically. But the VS Code debugger (and other non-uvicorn entrypoints) +# use the default SelectorEventLoop, which raises NotImplementedError on any +# subprocess call. Force ProactorEventLoop here so the right loop is always +# used, regardless of how the process is launched. +if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) def register_static_mime_types() -> None: @@ -44,7 +55,7 @@ def register_static_mime_types() -> None: from contextlib import asynccontextmanager from fastapi import FastAPI, Request, HTTPException -from fastapi.responses import JSONResponse, FileResponse, HTMLResponse +from fastapi.responses import JSONResponse, FileResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from starlette.middleware.base import BaseHTTPMiddleware @@ -65,7 +76,7 @@ def register_static_mime_types() -> None: import bcrypt as _bcrypt -from src.app_helpers import abs_join +from src.app_helpers import abs_join, serve_html_with_nonce from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path from starlette.responses import RedirectResponse @@ -187,7 +198,50 @@ async def dispatch(self, request, call_next): ) +class _InteractiveActivityMiddleware(_BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + from src.interactive_gate import should_track_interactive_request, track_interactive_request + + path = request.url.path or "" + if not should_track_interactive_request(path, request.method): + return await call_next(request) + async def _stop_background(): + try: + await task_scheduler.stop_background_tasks_for_foreground(reason=f"foreground request {request.method} {path}") + except Exception: + logging.getLogger("app.foreground_gate").debug("foreground task stop failed", exc_info=True) + asyncio.create_task(_stop_background()) + async with track_interactive_request(path, request.method): + return await call_next(request) + + +class _SlowRequestLogMiddleware(_BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + start = time.perf_counter() + status = 500 + try: + response = await call_next(request) + status = getattr(response, "status_code", 0) or 0 + return response + finally: + elapsed = time.perf_counter() - start + try: + threshold = float(os.getenv("ODYSSEUS_SLOW_REQUEST_LOG_SECONDS", "0.75") or "0.75") + except Exception: + threshold = 0.75 + if elapsed >= threshold: + logging.getLogger("app.slow_request").warning( + "slow_request method=%s path=%s status=%s elapsed=%.3fs", + request.method, + request.url.path, + status, + elapsed, + ) + + app.add_middleware(_RequestTimeoutMiddleware) +app.add_middleware(_InteractiveActivityMiddleware) +app.add_middleware(_SlowRequestLogMiddleware) # ========= AUTH ========= from routes.auth_routes import setup_auth_routes, SESSION_COOKIE @@ -573,6 +627,20 @@ async def web_search_error_handler(request: Request, exc: WebSearchError): auth_router = setup_auth_routes(auth_manager) app.include_router(auth_router) + +@app.post("/api/activity/heartbeat") +async def activity_heartbeat(): + from src.interactive_gate import mark_browser_activity + await mark_browser_activity() + async def _stop_background(): + try: + await task_scheduler.stop_background_tasks_for_foreground(reason="browser heartbeat") + except Exception: + logging.getLogger("app.foreground_gate").debug("heartbeat task stop failed", exc_info=True) + asyncio.create_task(_stop_background()) + return {"ok": True} + + # Uploads from routes.upload_routes import setup_upload_routes upload_router, upload_cleanup_func = setup_upload_routes(upload_handler) @@ -594,7 +662,7 @@ async def web_search_error_handler(request: Request, exc: WebSearchError): app.include_router(setup_admin_wipe_routes(session_manager)) # Memory -from routes.memory_routes import setup_memory_routes +from routes.memory.memory_routes import setup_memory_routes memory_router = setup_memory_routes(memory_manager, session_manager, memory_vector=memory_vector) app.include_router(memory_router) from routes.skills_routes import setup_skills_routes @@ -611,11 +679,11 @@ async def web_search_error_handler(request: Request, exc: WebSearchError): )) # Research (background deep-research tasks) -from routes.research_routes import setup_research_routes +from routes.research.research_routes import setup_research_routes app.include_router(setup_research_routes(research_handler, session_manager=session_manager)) # History -from routes.history_routes import setup_history_routes +from routes.history.history_routes import setup_history_routes app.include_router(setup_history_routes(session_manager)) # Search @@ -675,7 +743,7 @@ async def web_search_error_handler(request: Request, exc: WebSearchError): app.include_router(setup_signature_routes()) # Gallery (image library) -from routes.gallery_routes import setup_gallery_routes +from routes.gallery.gallery_routes import setup_gallery_routes app.include_router(setup_gallery_routes()) # Persisted image-editor drafts (server-backed projects) @@ -783,7 +851,7 @@ async def web_search_error_handler(request: Request, exc: WebSearchError): app.include_router(setup_vault_routes()) # Contacts (CardDAV) -from routes.contacts_routes import setup_contacts_routes +from routes.contacts.contacts_routes import setup_contacts_routes app.include_router(setup_contacts_routes()) from companion import setup_companion_routes @@ -791,23 +859,17 @@ async def web_search_error_handler(request: Request, exc: WebSearchError): # ========= ROUTES (kept in app.py) ========= -def _serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse: - """Read an HTML file and inject the CSP nonce into inline - - + + @@ -581,6 +587,7 @@

Font & Layout

+
@@ -591,6 +598,13 @@

Font & Layout

+
+ + +
@@ -2046,6 +2061,16 @@

+
+

Model Defaults

+
+
+
Share defaults with users
+
When on, users without a personal default inherit the global default model (only if those models are allowed for them).
+
+ +
+

Users

Loading...
@@ -2069,7 +2094,7 @@

-

Add Local Models (Endpoint) +

Add Local Models (Endpoint)

@@ -2446,10 +2471,10 @@

Danger Zone

- + - + @@ -2459,7 +2484,7 @@

Danger Zone

- + diff --git a/static/js/admin.js b/static/js/admin.js index 58b8765a59..d409614a83 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -343,6 +343,28 @@ function initSignupToggle() { }); } +function initShareDefaultsToggle() { + const toggle = el('adm-shareDefaultsToggle'); + fetch('/api/auth/settings', { credentials: 'same-origin' }) + .then(r => r.json()) + .then(d => { toggle.checked = !!d.share_defaults_with_users; }) + .catch(e => console.warn('Settings fetch failed:', e)); + toggle.addEventListener('change', async () => { + try { + const res = await fetch('/api/auth/settings', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ share_defaults_with_users: toggle.checked }), + }); + const data = await res.json(); + toggle.checked = !!data.share_defaults_with_users; + } catch (e) { + toggle.checked = !toggle.checked; + } + }); +} + function initAddUser() { fetch('/api/auth/policy', { credentials: 'same-origin' }) .then(r => r.ok ? r.json() : null) @@ -449,7 +471,7 @@ async function loadEndpoints() { const listLegacy = el('adm-epList'); // Refresh model picker so new endpoints show up in chat if (window.modelsModule && window.modelsModule.refreshModels) { - window.modelsModule.refreshModels(); + window.modelsModule.refreshModels(true); setTimeout(() => { if (window.sessionModule && window.sessionModule.updateModelPicker) { window.sessionModule.updateModelPicker(); @@ -477,7 +499,8 @@ async function loadEndpoints() { return; } const rowHtml = data.map(ep => { - const visibleCount = ep.models.length; + const epModels = Array.isArray(ep.models) ? ep.models : []; + const visibleCount = epModels.length; const totalCount = visibleCount + (ep.hidden_count || 0); // `ep.models` is the *visible* set — when every model is hidden it's // empty, but we still need to render the expand panel so the user can @@ -1371,34 +1394,77 @@ function initEndpointForm() { _refreshOfflineCount(); } - const probeAllBtn = el('adm-epProbeAllBtn'); - if (probeAllBtn) { - probeAllBtn.addEventListener('click', async () => { + const _fetchWithTimeout = async (url, opts = {}, timeoutMs = 25000) => { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + try { + return await fetch(url, { ...opts, signal: ctrl.signal }); + } finally { + clearTimeout(timer); + } + }; + const _collectAddedEndpointIds = async () => { + const domIds = Array.from(document.querySelectorAll('[data-adm-ep-id]')) + .map(r => r.getAttribute('data-adm-ep-id')) + .filter(Boolean); + if (domIds.length) return Array.from(new Set(domIds)); + try { + const res = await fetch('/api/model-endpoints', { credentials: 'same-origin' }); + const data = await res.json().catch(() => []); + return (Array.isArray(data) ? data : []).map(ep => ep && ep.id).filter(Boolean); + } catch (_) { + return []; + } + }; + const _setProbeAllButtonLabel = async (btn, text, whirlpoolRef) => { + btn.innerHTML = ''; + if (whirlpoolRef && whirlpoolRef.element) btn.appendChild(whirlpoolRef.element); + btn.appendChild(document.createTextNode(text)); + }; + if (!window.__admEpProbeAllWired) { + window.__admEpProbeAllWired = true; + document.addEventListener('click', async (ev) => { + const probeAllBtn = ev.target.closest('#adm-epProbeAllBtn'); + if (!probeAllBtn || probeAllBtn.disabled) return; + ev.preventDefault(); probeAllBtn.disabled = true; const origHTML = probeAllBtn.innerHTML; let _wp = null; try { - const sp = window.spinnerModule || (await import('./spinner.js')).default; - _wp = sp.createWhirlpool(11); - _wp.element.style.cssText = 'display:inline-flex;width:11px;height:11px;margin:0 4px 0 0;'; - probeAllBtn.innerHTML = ''; - probeAllBtn.appendChild(_wp.element); - probeAllBtn.appendChild(document.createTextNode('Probing')); - } catch (_) { - probeAllBtn.innerHTML = 'Probing…'; - } - try { - // Hit the bulk local probe (same one the model picker uses). - await fetch('/api/model-endpoints/probe-local', { credentials: 'same-origin' }).catch(() => {}); - // Then per-endpoint /probe for the rest so API/cloud endpoints - // refresh too. Parallel — capped to 6 at a time so we don't - // hammer the backend on a big list. - const ids = Array.from(document.querySelectorAll('[data-adm-ep-id]')).map(r => r.getAttribute('data-adm-ep-id')).filter(Boolean); + try { + const sp = window.spinnerModule || (await import('./spinner.js')).default; + _wp = sp.createWhirlpool(11); + _wp.element.style.cssText = 'display:inline-flex;width:11px;height:11px;margin:0 4px 0 0;'; + await _setProbeAllButtonLabel(probeAllBtn, 'Probing', _wp); + } catch (_) { + probeAllBtn.innerHTML = 'Probing...'; + } + await _fetchWithTimeout('/api/model-endpoints/probe-local', { credentials: 'same-origin' }, 12000).catch(() => null); + const ids = await _collectAddedEndpointIds(); + if (!ids.length) { + await loadEndpoints(); + if (uiModule && uiModule.showToast) uiModule.showToast('No endpoints to probe', 1800); + return; + } + let done = 0; + let failed = 0; const lane = async (id) => { - try { await fetch(`/api/model-endpoints/${id}/probe`, { credentials: 'same-origin' }); } catch (_) {} + try { + const res = await _fetchWithTimeout(`/api/model-endpoints/${encodeURIComponent(id)}/models?refresh=true&refresh_timeout=20`, { + credentials: 'same-origin' + }, 25000); + if (!res || !res.ok || res.headers.get('X-Model-Refresh-Status') === 'failed') failed += 1; + else await res.json().catch(() => null); + } catch (err) { + failed += 1; + console.warn('Endpoint probe failed', id, err); + } finally { + done += 1; + try { await _setProbeAllButtonLabel(probeAllBtn, `Probing ${done}/${ids.length}`, _wp); } catch (_) {} + } }; const queue = [...ids]; - const workers = Array.from({length: Math.min(6, queue.length)}, () => (async () => { + const workers = Array.from({ length: Math.min(4, queue.length) }, () => (async () => { while (queue.length) { const id = queue.shift(); if (id) await lane(id); @@ -1406,7 +1472,11 @@ function initEndpointForm() { })()); await Promise.all(workers); await loadEndpoints(); - if (uiModule && uiModule.showToast) uiModule.showToast('Endpoint status refreshed', 1800); + _refreshOfflineCount(); + if (uiModule && uiModule.showToast) { + const ok = Math.max(0, ids.length - failed); + uiModule.showToast(failed ? `Probed ${ok}/${ids.length} endpoints; ${failed} failed` : `Probed ${ids.length} endpoints`, failed ? 4200 : 1800); + } } finally { if (_wp) { try { _wp.destroy(); } catch (_) {} } probeAllBtn.innerHTML = origHTML; @@ -1581,8 +1651,8 @@ function initEndpointForm() { wrap.style.cssText = 'display:flex;align-items:center;padding:8px 0;'; wrap.appendChild(wp.element); const txt = document.createElement('span'); - txt.textContent = 'Scanning ports 8000-8020 and 11434 for model servers...'; - txt.style.cssText = 'opacity:0.7;'; + txt.textContent = 'Scanning ports 8000-8020, 8080, 1234, 11434, and 11435 for model servers...'; + txt.style.cssText = 'font-size:12px;opacity:0.7;'; wrap.appendChild(txt); msg.appendChild(wrap); discoverBtn._wp = wp; @@ -1597,12 +1667,24 @@ function initEndpointForm() { } else { // Auto-add each discovered endpoint. Server dedupes on base_url // and returns `existing: true` for already-registered ones. + // Map fingerprinted provider IDs to friendly display names. + const _PROVIDER_DISPLAY = { + llamacpp: 'llama.cpp', lmstudio: 'LM Studio', vllm: 'vLLM', + ollama: 'Ollama', + }; let added = 0; let skipped = 0; for (const item of items) { const base = item.url.replace('/chat/completions', '').replace(/\/$/, ''); + const providerDisplay = _PROVIDER_DISPLAY[item.provider] || null; const fd = new FormData(); fd.append('base_url', base); + if (providerDisplay) { + // Use "Provider (host:port)" so the endpoint is immediately + // identifiable in the list, e.g. "llama.cpp (localhost:8080)". + const hostPart = base.replace(/^https?:\/\//, '').split('/')[0]; + fd.append('name', `${providerDisplay} (${hostPart})`); + } fd.append('endpoint_kind', 'local'); fd.append('model_refresh_mode', 'auto'); fd.append('skip_probe', 'false'); @@ -1616,7 +1698,12 @@ function initEndpointForm() { } } const totalModels = items.reduce((n, i) => n + (i.models ? i.models.length : 0), 0); - const parts = [`Found ${items.length} server${items.length !== 1 ? 's' : ''} with ${totalModels} model${totalModels !== 1 ? 's' : ''}`]; + const serverNames = items.map(i => + (_PROVIDER_DISPLAY[i.provider] || i.url.replace(/^https?:\/\//, '').split('/')[0]) + ); + const parts = [ + `Found ${items.length} server${items.length !== 1 ? 's' : ''} (${serverNames.join(', ')}) with ${totalModels} model${totalModels !== 1 ? 's' : ''}`, + ]; if (added) parts.push(`added ${added} new`); if (skipped) parts.push(`${skipped} already added`); msg.innerHTML = parts.join(' — '); @@ -2986,7 +3073,7 @@ function initLogsView() { function initAll() { modalEl = el('settings-modal'); const inits = [ - initSignupToggle, initAddUser, initEndpointForm, initMcpForm, + initSignupToggle, initShareDefaultsToggle, initAddUser, initEndpointForm, initMcpForm, initCalDAV, initBackup, initDangerZone, initTokenForm, initLogsView, () => settingsModule.initIntegrations() ]; diff --git a/static/js/calendar.js b/static/js/calendar.js index 2b14d024a6..bc0cc89ad3 100644 --- a/static/js/calendar.js +++ b/static/js/calendar.js @@ -5,6 +5,7 @@ import uiModule from './ui.js'; import spinnerModule from './spinner.js'; import * as Modals from './modalManager.js'; +import { topPortalZ } from './toolWindowZOrder.js'; import { makeWindowDraggable } from './windowDrag.js'; import { attachColorPicker } from './colorPicker.js'; import { bindMenuDismiss } from './escMenuStack.js'; @@ -12,7 +13,7 @@ import { WEEKDAYS, WEEKDAYS_SUN, MONTHS, MON_SHORT, CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE, _trashIcon, _moreIcon, _bellIcon, - _isCalBgImage, _calBgImageUrl, _calBgCss, + _isCalBgImage, _calBgImageUrl, _calBgCss, _cssUrlEscape, _calReadableTextColor, _ds, _addDays, _shiftDT, _tzOffset, _localDateOf, } from './calendar/utils.js'; @@ -300,7 +301,7 @@ async function _updateEvent(uid, data) { return { ok: true }; } -async function _deleteEvent(uid) { +async function _deleteEvent(uid, { scope = 'series' } = {}) { // Multiple "sibling" UIDs may need to vanish optimistically: // 1. The exact uid the user clicked. // 2. If the user clicked a RECURRING occurrence (uid contains "::"), @@ -311,9 +312,12 @@ async function _deleteEvent(uid) { // other days kept rendering until the next full refresh. // 3. If the user clicked the master, strip every "master::*" // expansion (same prefix scan). + const deleteOccurrenceOnly = scope === 'occurrence' && uid.includes('::'); const masterUid = uid.includes('::') ? uid.split('::')[0] : uid; const backups = {}; - const _matches = (k) => k === uid || k === masterUid || k.startsWith(masterUid + '::'); + const _matches = deleteOccurrenceOnly + ? (k) => k === uid + : (k) => k === uid || k === masterUid || k.startsWith(masterUid + '::'); for (const k of Object.keys(_allEvents)) { if (_matches(k)) { @@ -327,7 +331,8 @@ async function _deleteEvent(uid) { if (_open) _render(); _updateBadge && _updateBadge(); const isRecurring = uid.includes('::'); - fetch(`${API_BASE}/api/calendar/events/${encodeURIComponent(uid)}`, { + const scopeParam = deleteOccurrenceOnly ? '?scope=occurrence' : ''; + fetch(`${API_BASE}/api/calendar/events/${encodeURIComponent(uid)}${scopeParam}`, { method: 'DELETE', credentials: 'same-origin', }).then(r => { // 404 = the event was already deleted by another session/device. That's @@ -413,8 +418,8 @@ function _calEventFg(ev) { // Returns '' for normal solid-color events. function _calItemBgStyle(ev) { if (!_isCalBgImage(ev.color)) return ''; - const url = _calBgImageUrl(ev.color).replace(/'/g, "\\'").replace(/"/g, "%22"); - return `background-image: linear-gradient(color-mix(in srgb, var(--bg) 70%, transparent), color-mix(in srgb, var(--bg) 70%, transparent)), url('${url}'); background-size: cover; background-position: center;`; + const url = _calBgImageUrl(ev.color); + return `background-image: linear-gradient(color-mix(in srgb, var(--bg) 70%, transparent), color-mix(in srgb, var(--bg) 70%, transparent)), url('${_cssUrlEscape(url)}'); background-size: cover; background-position: center;`; } function _todayCount() { @@ -429,14 +434,77 @@ function _todayCount() { }).length; } -// Per-event ⋮ menu: Remind me / Delete +function _findEventByUid(uid) { + return _allEvents[uid] || _events.find(e => e && e.uid === uid) || null; +} + +function _isRecurringEvent(ev) { + return !!(ev && (ev.is_recurrence || ev.uid?.includes('::') || ev.rrule)); +} + +function _chooseRecurringDeleteScope(ev) { + return new Promise(resolve => { + const name = ev?.summary ? `"${ev.summary}"` : 'this event'; + const overlay = document.createElement('div'); + overlay.className = 'modal'; + overlay.style.display = ''; + overlay.innerHTML = ` + `; + document.body.appendChild(overlay); + const close = (value) => { + document.removeEventListener('keydown', onKey); + overlay.remove(); + resolve(value); + }; + const onKey = (e) => { + if (e.key === 'Escape') { + e.preventDefault(); + close(null); + } + }; + overlay.addEventListener('click', (e) => { + if (e.target === overlay) return close(null); + const btn = e.target.closest('[data-choice]'); + if (!btn) return; + const choice = btn.dataset.choice; + close(choice === 'cancel' ? null : choice); + }); + document.addEventListener('keydown', onKey); + overlay.querySelector('[data-choice="occurrence"]')?.focus(); + }); +} + +async function _confirmAndDeleteEvent(ev) { + if (!ev) return; + const name = ev.summary ? `"${ev.summary}"` : 'this event'; + let scope = 'series'; + if (_isRecurringEvent(ev)) { + scope = await _chooseRecurringDeleteScope(ev); + if (!scope) return; + } else { + const ok = await uiModule.styledConfirm(`Delete ${name}?`, { confirmText: 'Delete', danger: true }); + if (!ok) return; + } + try { await _deleteEvent(ev.uid, { scope }); setTimeout(() => _render(), 100); } + catch (_) { uiModule.showToast('Failed to delete'); } +} + +// Per-event ⋮ menu: Edit / Delete function _wireQuickDelete(body) { body.querySelectorAll('.cal-event-more').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const uid = btn.dataset.uid; if (!uid) return; - const ev = _allEvents[uid]; + const ev = _findEventByUid(uid); if (!ev) return; _showEventMoreMenu(ev, btn); }); @@ -470,7 +538,7 @@ function _showEventMoreMenu(ev, anchor) { dropdown.className = 'cal-event-dropdown'; let closeMenu = () => dropdown.remove(); const rect = anchor.getBoundingClientRect(); - dropdown.style.cssText = `position:fixed;z-index:10001;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:0px;visibility:hidden;`; + dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:0px;visibility:hidden;`; const _item = (icon, label, onClick, danger) => { const it = document.createElement('div'); @@ -489,10 +557,7 @@ function _showEventMoreMenu(ev, anchor) { dropdown.appendChild(_item(_trashIcon, 'Delete', async () => { closeMenu(); - const name = ev.summary ? `"${ev.summary}"` : 'this event'; - const ok = await uiModule.styledConfirm(`Delete ${name}?`, { confirmText: 'Delete', danger: true }); - if (!ok) return; - try { await _deleteEvent(ev.uid); setTimeout(() => _render(), 100); } catch (_) {} + await _confirmAndDeleteEvent(ev); }, true)); document.body.appendChild(dropdown); @@ -1260,8 +1325,8 @@ async function _renderWeek() { // events keep the original tinted treatment. let bgDecl; if (_isCalBgImage(ev.color)) { - const _url = _calBgImageUrl(ev.color).replace(/'/g, "\\'").replace(/"/g, "%22"); - bgDecl = `background-image: linear-gradient(color-mix(in srgb, var(--bg) 55%, transparent), color-mix(in srgb, var(--bg) 55%, transparent)), url('${_url}'); background-size: cover; background-position: center;`; + const _url = _calBgImageUrl(ev.color); + bgDecl = `background-image: linear-gradient(color-mix(in srgb, var(--bg) 55%, transparent), color-mix(in srgb, var(--bg) 55%, transparent)), url('${_cssUrlEscape(_url)}'); background-size: cover; background-position: center;`; } else { bgDecl = `background:color-mix(in srgb, ${_calColor(ev)} 18%, var(--bg));`; } @@ -1813,7 +1878,7 @@ function _dayDetailHTML(dateStr) { `; if (_searchQuery) { const q = _searchQuery.toLowerCase(); - const results = _events + const results = Object.values(_allEvents || {}) .filter(_eventVisible) .filter(e => (e.summary || '').toLowerCase().includes(q) || @@ -2853,7 +2918,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) { let bg; if (isCustom) { const url = _calBgImageUrl(cur); - bg = url ? `center/cover no-repeat url('${url}')` : _CAL_CUSTOM_GRADIENT; + bg = url ? `center/cover no-repeat url('${_cssUrlEscape(url)}')` : _CAL_CUSTOM_GRADIENT; } else { bg = c.hex || 'var(--border)'; } @@ -2928,7 +2993,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) { // stays readable. Chrome accent falls back to the theme accent. const url = _calBgImageUrl(hex); _formCard.style.setProperty('--ev-color', 'var(--accent)'); - _formCard.style.backgroundImage = `linear-gradient(color-mix(in srgb, var(--panel) 65%, transparent), color-mix(in srgb, var(--panel) 65%, transparent)), url('${url.replace(/'/g, "\\'")}')`; + _formCard.style.backgroundImage = `linear-gradient(color-mix(in srgb, var(--panel) 65%, transparent), color-mix(in srgb, var(--panel) 65%, transparent)), url('${_cssUrlEscape(url)}')`; _formCard.style.backgroundSize = 'cover'; _formCard.style.backgroundPosition = 'center'; _formCard.classList.add('cal-form-bg-image'); @@ -2950,7 +3015,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) { if (!url) return; const sentinel = 'bg:' + url; dot.dataset.color = sentinel; - dot.style.background = `center/cover no-repeat url('${url}')`; + dot.style.background = `center/cover no-repeat url('${_cssUrlEscape(url)}')`; document.querySelectorAll('#cal-f-colors .note-color-dot').forEach(d => d.classList.remove('active')); dot.classList.add('active'); _applyFormTint(sentinel); @@ -3115,7 +3180,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) { all_day: isAD, description: document.getElementById('cal-f-desc').value, location: document.getElementById('cal-f-loc').value, - rrule: document.getElementById('cal-f-rrule').value || undefined, + rrule: document.getElementById('cal-f-rrule').value || '', calendar_href: document.getElementById('cal-f-cal')?.value || (_calendars[0]?.href || ''), color: colorVal || undefined, }; @@ -3141,11 +3206,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) { } catch (e) { uiModule.showToast('Failed to save'); } }); document.getElementById('cal-f-del')?.addEventListener('click', async () => { - const name = existing && existing.summary ? `"${existing.summary}"` : 'this event'; - const ok = await uiModule.styledConfirm(`Delete ${name}?`, { confirmText: 'Delete', danger: true }); - if (!ok) return; - try { await _deleteEvent(existing.uid); _render(); } - catch (e) { uiModule.showToast('Failed to delete'); } + await _confirmAndDeleteEvent(existing); }); // ── Bespoke-form behavior ────────────────────────────────────────── const formEl = body.querySelector('.cal-form'); @@ -3454,8 +3515,18 @@ function openCalendar() { // Layer Esc: close the topmost calendar surface first, only fall through // to closing the whole calendar when nothing else is on top. const settings = document.getElementById('cal-settings-panel'); - if (settings) { settings.remove(); return; } - if (document.querySelector('.cal-form')) { _render(); return; } + if (settings) { + e.preventDefault(); + e.stopPropagation(); + settings.remove(); + return; + } + if (document.querySelector('.cal-form')) { + e.preventDefault(); + e.stopPropagation(); + _render(); + return; + } closeCalendar(); } else if (e.key === 'ArrowLeft') document.getElementById('cal-prev')?.click(); @@ -3484,14 +3555,25 @@ async function openCalendarTo(target) { if (!target) return; try { await _fetchCalendars(); + const targetStr = String(target || '').trim(); + if (targetStr.startsWith('search:')) { + _searchQuery = targetStr.slice('search:'.length).trim(); + const now = new Date(); + await _fetchEvents(`${now.getFullYear()}-01-01`, `${now.getFullYear() + 2}-01-01`); + _currentDate = now; + _selectedDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + _view = 'month'; + _render(); + return; + } // If target looks like an ISO date (YYYY-MM-DD...), go straight there. let dt = null; - const isoMatch = /^\d{4}-\d{2}-\d{2}/.test(String(target)); + const isoMatch = /^\d{4}-\d{2}-\d{2}/.test(targetStr); if (isoMatch) { - dt = new Date(target); + dt = new Date(targetStr); } else { // Treat as an event uid — find it among loaded events. - const ev = (_events || []).find(e => e.uid === target || (e.uid || '').startsWith(target)); + const ev = Object.values(_allEvents || {}).find(e => e.uid === targetStr || (e.uid || '').startsWith(targetStr)); if (ev && ev.dtstart) dt = new Date(ev.dtstart); if (ev) _highlightEventUid = ev.uid; } diff --git a/static/js/calendar/utils.js b/static/js/calendar/utils.js index 7e6dd68e81..a0743cc123 100644 --- a/static/js/calendar/utils.js +++ b/static/js/calendar/utils.js @@ -65,13 +65,25 @@ export function _calBgImageUrl(c) { return _isCalBgImage(c) ? c.slice(3) : ''; } +// Escape a value for safe embedding inside a single-quoted CSS `url('...')`. +// Backslashes MUST be escaped first: otherwise a trailing/embedded `\` in the +// (CalDAV-syncable, untrusted) bg-image URL would escape the closing quote we +// add for `'` and let the value break out of the string (CodeQL +// js/incomplete-sanitization). `"` is percent-encoded for good measure. +export function _cssUrlEscape(s) { + return String(s == null ? '' : s) + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/"/g, '%22'); +} + // Returns a value safe to drop into `style="background:..."`. Falls back to // the calendar default for bg-image events in spots where an image would be // too small to render usefully (small grid dots, multi-day bars). export function _calBgCss(c, fallback) { if (_isCalBgImage(c)) { const u = _calBgImageUrl(c); - return u ? `center/cover no-repeat url('${u.replace(/'/g, "\\'")}')` : (fallback || 'var(--accent)'); + return u ? `center/cover no-repeat url('${_cssUrlEscape(u)}')` : (fallback || 'var(--accent)'); } return c || fallback || 'var(--accent)'; } diff --git a/static/js/chat.js b/static/js/chat.js index c0b91f9804..f9a035a8c2 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -12,7 +12,6 @@ import chatRenderer from './chatRenderer.js'; import chatStream from './chatStream.js'; import { addAITTSButton } from './tts-ai.js'; import markdownModule from './markdown.js'; -import { svgifyEmoji } from './markdown.js'; import spinnerModule from './spinner.js'; import presetsModule from './presets.js'; import fileHandlerModule from './fileHandler.js'; @@ -44,7 +43,58 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer let _sendInFlight = false; // covers the window from click → streaming start let _displayOverride = null; // Override visible user bubble text (hides injected prompts) let _hideUserBubble = false; // Skip user bubble entirely (e.g. continue after stop) + + function _setForegroundChatBusy(active) { + try { + window.__odysseusChatBusy = !!active; + window.__odysseusChatBusyUntil = active ? Date.now() + 120000 : Date.now() + 1200; + window.dispatchEvent(new CustomEvent('odysseus:chat-busy-change', { detail: { active: !!active } })); + } catch (_) {} + } let _pendingContinue = null; // Stores the stopped AI element to merge with new response + function _createChatSendPerf() { + const started = (performance && performance.now) ? performance.now() : Date.now(); + let last = started; + let reported = false; + const stages = []; + const now = () => (performance && performance.now) ? performance.now() : Date.now(); + return { + mark(name) { + const t = now(); + stages.push({ name, delta_ms: Math.round(t - last), at_ms: Math.round(t - started) }); + last = t; + }, + report(extra) { + if (reported) return; + const total = Math.round(now() - started); + const slowStage = stages.some(s => (s.delta_ms || 0) >= 1500); + if (total < 1500 && !slowStage) return; + reported = true; + const payload = JSON.stringify({ + type: 'chat_send', + total_ms: total, + stages, + extra: extra || '', + session: sessionModule && sessionModule.getCurrentSessionId ? sessionModule.getCurrentSessionId() : '', + }); + try { + if (navigator.sendBeacon) { + navigator.sendBeacon(`${API_BASE}/api/client-perf`, new Blob([payload], { type: 'application/json' })); + return; + } + } catch (_) {} + try { + fetch(`${API_BASE}/api/client-perf`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: payload, + keepalive: true, + credentials: 'same-origin', + }).catch(() => {}); + } catch (_) {} + } + }; + } // ── Auto-recovery: when a turn's stream silently dies (connection drop) or // goes quiet while the connection is alive, re-engage the model with a // completion handshake instead of leaving it hung. Capped so it can't loop. @@ -100,6 +150,114 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer const body = msgEl.querySelector('.body'); if (body) chatRenderer.appendReportButton(body, sessionId); } + + function _stripDocumentFenceForChat(text, { final = false } = {}) { + let s = String(text || '').replace(/?/g, ''); + const markerMatch = /```(?:create_document|documen(?:t)?)\s*\n/i.exec(s); + if (!markerMatch) return s; + const before = s.slice(0, markerMatch.index).trimEnd(); + const fenceStart = markerMatch.index; + const openingEnd = s.indexOf('\n', fenceStart); + const closeIdx = openingEnd >= 0 ? s.indexOf('\n```', openingEnd + 1) : -1; + const after = closeIdx >= 0 ? s.slice(closeIdx + 4).trimStart() : ''; + const visible = [before, after].filter(Boolean).join('\n\n').trim(); + return final && !visible ? 'Done.' : visible; + } + + function _stripIncompleteRawToolJsonForChat(text) { + const s = String(text || ''); + const starts = ['[{"function"', '[\n{"function"', '{"function"']; + let idx = -1; + for (const marker of starts) idx = Math.max(idx, s.lastIndexOf(marker)); + if (idx < 0) return s; + const tail = s.slice(idx); + // Complete raw OpenAI-style function blobs are removed by stripToolBlocks. + // While the stream is still mid-JSON, hide the tail so it never flashes in + // the chat bubble as prose. + if (!/"type"\s*:\s*"function"/.test(tail) || !/\}\s*\]?\s*(?:<\/?\|(?:assistant|assistan|user|system|tool)\|>?)?\s*$/i.test(tail)) { + return s.slice(0, idx); + } + return s; + } + + function _streamDisplayText(text, opts = {}) { + return stripToolBlocks(_stripIncompleteRawToolJsonForChat(_stripDocumentFenceForChat(text, opts))); + } + + function _showDocumentWritingStatus(contentEl) { + const msg = contentEl && contentEl.closest ? contentEl.closest('.msg') : null; + const chatBox = document.getElementById('chat-history'); + if (!msg || !chatBox) { + if (contentEl) contentEl.textContent = 'Writing...'; + return; + } + let thread = msg._docWritingThread; + if (!thread || !thread.isConnected) { + thread = document.createElement('div'); + thread.className = 'agent-thread streaming has-bottom'; + thread.dataset.docWriting = '1'; + const prev = msg.previousElementSibling; + if (prev && (prev.classList.contains('msg') || prev.classList.contains('agent-thread'))) { + thread.classList.add('has-top'); + } + const node = document.createElement('div'); + node.className = 'agent-thread-node running'; + node.innerHTML = '
Writing▁▂▃
'; + thread.appendChild(node); + chatBox.insertBefore(thread, msg); + msg._docWritingThread = thread; + + const waveEl = node.querySelector('.agent-thread-wave'); + if (waveEl) { + const waveFrames = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▇', '▆▅▄', '▅▄▃', '▄▃▂']; + let waveIdx = 0; + node._waveInterval = setInterval(() => { + waveIdx = (waveIdx + 1) % waveFrames.length; + waveEl.textContent = waveFrames[waveIdx]; + }, 100); + } + node._startTime = Date.now(); + node._elapsedTicker = setInterval(() => { + const hdr = node.querySelector('.agent-thread-header'); + if (!hdr) return; + let el = hdr.querySelector('.agent-thread-elapsed'); + if (!el) { + el = document.createElement('span'); + el.className = 'agent-thread-elapsed'; + const icon = hdr.querySelector('.agent-thread-icon'); + if (icon && icon.nextSibling) hdr.insertBefore(el, icon.nextSibling); + else hdr.appendChild(el); + } + const s = (Date.now() - node._startTime) / 1000; + el.textContent = s < 60 ? `${s.toFixed(2)}s` : `${Math.floor(s / 60)}m ${(s % 60).toFixed(2).padStart(5, '0')}s`; + }, 50); + } + msg.style.display = 'none'; + } + + function _finishDocumentWritingStatus(msg, ok = true) { + const thread = msg && msg._docWritingThread; + if (!thread || !thread.isConnected) return; + thread.classList.remove('streaming'); + const node = thread.querySelector('.agent-thread-node'); + if (!node) return; + if (node._waveInterval) { clearInterval(node._waveInterval); node._waveInterval = null; } + if (node._elapsedTicker) { clearInterval(node._elapsedTicker); node._elapsedTicker = null; } + node.classList.remove('running'); + if (!ok) node.classList.add('error'); + const icon = node.querySelector('.agent-thread-icon'); + if (icon) icon.textContent = ok ? '✓' : '✗'; + const wave = node.querySelector('.agent-thread-wave'); + if (wave) wave.remove(); + if (!node.querySelector('.agent-thread-status')) { + const status = document.createElement('span'); + status.className = 'agent-thread-status'; + status.textContent = ok ? 'done' : 'failed'; + const header = node.querySelector('.agent-thread-header'); + if (header) header.appendChild(status); + } + } + let currentAccumulated = ''; // Track accumulated text across function scope let currentHolder = null; // Track current message holder let currentSpinner = null; // Track current spinner for stop cleanup @@ -110,6 +268,8 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer let _streamSessionId = null; // Session ID for the currently active reader loop let _lastReaderActivity = 0; // Timestamp of last reader.read() success — used to detect frozen streams let _webLockRelease = null; // Function to release the Web Lock held during streaming + let _staleStreamProbeInFlight = false; + const STALE_LOCAL_STREAM_MS = 15000; /** Check if an SSE reader is still actively connected for a session. */ function hasActiveStream(sessionId) { @@ -232,7 +392,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer // arrow out for the stop icon — otherwise the swap happens mid-flight // and the user sees nothing fly out. setTimeout(() => { - submitBtn.innerHTML = _stopSvg; + if (submitBtn.dataset.mode !== 'streaming') return; + const msgInput = uiModule.el('message'); + const hasQueuedText = !!(msgInput && msgInput.value && msgInput.value.trim()); + submitBtn.innerHTML = hasQueuedText && icons ? icons.send : _stopSvg; + submitBtn.dataset.phase = hasQueuedText ? 'queue' : 'processing'; + submitBtn.title = hasQueuedText ? 'Queue message' : 'Stop generation'; submitBtn.classList.remove('anim-launch'); void submitBtn.offsetWidth; submitBtn.classList.add('anim-land'); @@ -242,12 +407,14 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer submitBtn.dataset.mode = 'streaming'; submitBtn.dataset.phase = 'processing'; isStreaming = true; + _setForegroundChatBusy(true); _startStallWatchdog(); } else if (state === 'idle') { submitBtn.dataset.mode = ''; delete submitBtn.dataset.phase; submitBtn.classList.remove('recording'); isStreaming = false; + _setForegroundChatBusy(false); _stopStallWatchdog(); // Defer to global updater which handles mic/newchat/send modes if (window._updateSendBtnIcon) { @@ -268,6 +435,144 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer // API key pattern for the guard in handleChatSubmit const API_KEY_RE = /^(sk-[a-zA-Z0-9_\-]{20,}|gsk_[a-zA-Z0-9]{20,}|AIza[a-zA-Z0-9_\-]{30,}|xai-[a-zA-Z0-9]{20,})$/; + const _queuedAgentRequests = []; + let _queuedDrainTimer = null; + let _queuedPromoteTimer = null; + let _queuedRequestSeq = 0; + let _queuedBubbleHost = null; + + function _escapeQueueText(s) { + return String(s || '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + } + + function _ensureQueuedBubbleHost() { + const chatBox = document.getElementById('chat-history'); + if (!chatBox) return null; + if (_queuedBubbleHost && _queuedBubbleHost.isConnected) return _queuedBubbleHost; + let host = document.getElementById('chat-queued-bubble-host'); + if (!host) { + host = document.createElement('div'); + host.id = 'chat-queued-bubble-host'; + host.className = 'chat-queued-bubble-host'; + } + chatBox.appendChild(host); + _queuedBubbleHost = host; + return host; + } + + function _createQueuedBubble(item) { + const host = _ensureQueuedBubbleHost(); + if (!host) return null; + const wrap = document.createElement('div'); + wrap.className = 'msg msg-user msg-user-queued'; + wrap.dataset.queueId = item.id; + wrap.title = 'Queued - click to send now and stop the current response'; + wrap.innerHTML = `
You Queued
${_escapeQueueText(item.message)}
`; + wrap.addEventListener('click', (ev) => { + if (ev.target && ev.target.closest && ev.target.closest('button, a, textarea, input')) return; + _promoteQueuedRequest(item.id); + }); + host.appendChild(wrap); + uiModule.scrollHistory(); + return wrap; + } + + function _removeQueuedRequest(id) { + const idx = _queuedAgentRequests.findIndex(item => item.id === id); + if (idx < 0) return null; + const [item] = _queuedAgentRequests.splice(idx, 1); + if (item && item.el && item.el.parentNode) item.el.remove(); + return item; + } + + function _setComposerAndSend(message) { + const input = uiModule.el('message'); + if (!input) return false; + input.value = message; + input.dispatchEvent(new Event('input', { bubbles: true })); + if (uiModule.autoResize) uiModule.autoResize(input); + setTimeout(() => { + handleChatSubmit({ preventDefault() {} }).catch(err => { + console.error('queued send failed', err); + try { uiModule.showError && uiModule.showError('Queued send failed: ' + (err?.message || err)); } catch (_) {} + }); + }, 0); + return true; + } + + function _sendQueuedWhenIdle(item) { + if (!item) return; + const trySend = () => { + if (isStreaming || _sendInFlight) { + _queuedPromoteTimer = setTimeout(trySend, 220); + return; + } + _queuedPromoteTimer = null; + _setComposerAndSend(item.message); + }; + if (_queuedPromoteTimer) clearTimeout(_queuedPromoteTimer); + _queuedPromoteTimer = setTimeout(trySend, 320); + } + + function _promoteQueuedRequest(id) { + const item = _removeQueuedRequest(id); + if (!item) return; + if (!isStreaming && !_sendInFlight) { + _setComposerAndSend(item.message); + return; + } + try { uiModule.showToast && uiModule.showToast('Sending queued request now'); } catch (_) {} + const input = uiModule.el('message'); + const submitBtn = document.querySelector('.send-btn'); + if (input) { + input.value = ''; + input.dispatchEvent(new Event('input', { bubbles: true })); + } + if (submitBtn) submitBtn.click(); + _sendQueuedWhenIdle(item); + } + + function _queueAgentRequest(message) { + const msg = String(message || '').trim(); + if (!msg) return false; + const item = { id: `q${++_queuedRequestSeq}`, message: msg, createdAt: Date.now(), el: null }; + item.el = _createQueuedBubble(item); + _queuedAgentRequests.push(item); + try { uiModule.showToast && uiModule.showToast(_queuedAgentRequests.length === 1 ? 'Queued for after this response' : `${_queuedAgentRequests.length} requests queued`); } catch (_) {} + return true; + } + + export function queueStreamingComposerRequest() { + if (!isStreaming) return false; + const queuedInput = uiModule.el('message'); + const queuedText = (queuedInput && queuedInput.value || '').trim(); + if (!queuedText) return false; + if (fileHandlerModule.getPendingCount && fileHandlerModule.getPendingCount()) { + try { uiModule.showError && uiModule.showError('Finish the current response before queueing messages with attachments.'); } catch (_) {} + return true; + } + if (_queueAgentRequest(queuedText)) { + queuedInput.value = ''; + queuedInput.dispatchEvent(new Event('input', { bubbles: true })); + if (uiModule.autoResize) uiModule.autoResize(queuedInput); + try { window._updateSendBtnIcon && window._updateSendBtnIcon(); } catch (_) {} + } + return true; + } + + function _drainQueuedAgentRequests() { + if (isStreaming || _sendInFlight || !_queuedAgentRequests.length) return; + if (_queuedDrainTimer) return; + _queuedDrainTimer = setTimeout(() => { + _queuedDrainTimer = null; + if (isStreaming || _sendInFlight || !_queuedAgentRequests.length) return; + const next = _queuedAgentRequests[0]; + if (!next) return; + _removeQueuedRequest(next.id); + _setComposerAndSend(next.message); + }, 180); + } + /** * Handle chat form submission @@ -291,8 +596,18 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer return; } - // If currently streaming, stop it + // If currently streaming, keyboard Enter can queue a non-empty composer. + // Clicking the stop icon should still stop normally, even if text exists. if (isStreaming) { + const queueRequestedAt = Number(window.__odysseusQueueStreamingSubmit || 0); + const shouldQueueStreamingSubmit = queueRequestedAt && Date.now() - queueRequestedAt < 1200; + window.__odysseusQueueStreamingSubmit = 0; + if (shouldQueueStreamingSubmit && queueStreamingComposerRequest()) { + return; + } + if (fileHandlerModule.isUploading && fileHandlerModule.isUploading()) { + fileHandlerModule.cancelUpload && fileHandlerModule.cancelUpload(); + } // Cancel server-side research if in progress const _cancelSid = sessionModule.getCurrentSessionId(); if (_cancelSid && _researchingStreamIds.has(_cancelSid)) { @@ -341,6 +656,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer const messageInput = uiModule.el('message'); if (messageInput) messageInput.disabled = false; currentAccumulated = ''; + _drainQueuedAgentRequests(); return; } // Render whatever was accumulated so far @@ -417,7 +733,9 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer // --- Send-path entry: block re-clicks between submit and stream start --- if (_sendInFlight) return; + const _sendPerf = _createChatSendPerf(); _sendInFlight = true; + _setForegroundChatBusy(true); // Instant visual feedback so the user sees their click was accepted // even before the streaming button state kicks in below. const _earlyMessageInput = uiModule.el('message'); @@ -425,6 +743,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer if (submitBtn) submitBtn.classList.add('send-pending'); const _releaseSendFlag = () => { _sendInFlight = false; + _setForegroundChatBusy(isStreaming); if (_earlyMessageInput) _earlyMessageInput.disabled = false; if (submitBtn) submitBtn.classList.remove('send-pending'); }; @@ -473,18 +792,34 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer // Materialize pending session (deferred from model click) on first message if (sessionModule.hasPendingChat && sessionModule.hasPendingChat()) { + _sendPerf.mark('pending_session_begin'); const ok = await sessionModule.materializePendingSession(); + _sendPerf.mark('pending_session_done'); if (!ok || !sessionModule.getCurrentSessionId()) { _releaseSendFlag(); return; } } + if (!sessionModule.getCurrentSessionId()) { + // Auto-create a session using default chat config. Always fetch fresh + // so that a recent Settings change takes effect without a page reload. + try { + const pending = sessionModule.getPendingChat && sessionModule.getPendingChat(); + if (pending && pending.url && pending.modelId) { + const ok = await sessionModule.materializePendingSession(); + if (!ok || !sessionModule.getCurrentSessionId()) { _releaseSendFlag(); return; } + } + } catch (_) {} + } + if (!sessionModule.getCurrentSessionId()) { // Auto-create a session using default chat config. Always fetch fresh // so that a recent Settings change takes effect without a page reload. try { let dc = null; try { + _sendPerf.mark('default_chat_fetch_begin'); const dcRes = await fetch('/api/default-chat'); dc = await dcRes.json(); + _sendPerf.mark('default_chat_fetch_done'); if (dc && dc.endpoint_url && dc.model) { try { window.__odysseusDefaultChat = dc; } catch (_) {} } @@ -492,8 +827,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer dc = (typeof window !== 'undefined' && window.__odysseusDefaultChat) || null; } if (dc.endpoint_url && dc.model) { + _sendPerf.mark('direct_chat_create_begin'); await sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id); + _sendPerf.mark('direct_chat_create_done'); const ok = await sessionModule.materializePendingSession(); + _sendPerf.mark('direct_chat_materialize_done'); if (!ok || !sessionModule.getCurrentSessionId()) { _releaseSendFlag(); return; } } else { el('message').value = ''; @@ -649,6 +987,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer if (!skipBubble) { _userMsgEl = addMessage('user', userDisplay, null, _pendingAttachInfo ? { attachments: _pendingAttachInfo } : null); } + _sendPerf.mark('user_bubble_visible'); messageInput.value = ''; messageInput.style.height = ''; messageInput.dispatchEvent(new Event('input')); @@ -684,9 +1023,21 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer let ids = []; try { - ids = await fileHandlerModule.uploadPending(); + _sendPerf.mark('upload_begin'); + ids = await fileHandlerModule.uploadPending({ sessionId: sessionModule.getCurrentSessionId() }); + _sendPerf.mark('upload_done'); } catch(e) { console.error('upload failed', e); + _sendPerf.mark('upload_failed'); + } + if (_pendingAttachInfo && !ids.length && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) { + if (_userMsgEl && _userMsgEl.parentNode) _userMsgEl.remove(); + if (fileHandlerModule.wasLastUploadCancelled && !fileHandlerModule.wasLastUploadCancelled()) { + uiModule.showError && uiModule.showError('Upload failed. Attachment kept so you can retry.'); + } + updateSubmitButton('idle', submitBtn); + _releaseSendFlag(); + return; } // Carry over the original message's file-ids on a regenerate so the new @@ -769,8 +1120,24 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer } // Auto-save document editor content before sending so the AI sees latest text - if (documentModule && documentModule.isPanelOpen() && documentModule.getCurrentDocId()) { - try { await documentModule.saveDocument(); } catch(e) { console.warn('doc auto-save failed', e); } + const activeEmailComposerCtx = documentModule && typeof documentModule.getActiveEmailComposerContext === 'function' + ? documentModule.getActiveEmailComposerContext() + : null; + let activeDocIdForSend = documentModule && typeof documentModule.getCurrentDocId === 'function' + ? documentModule.getCurrentDocId() + : null; + if (activeEmailComposerCtx?.docId) { + activeDocIdForSend = activeEmailComposerCtx.docId; + } + if (documentModule && activeDocIdForSend) { + try { + _sendPerf.mark('doc_save_begin'); + await documentModule.saveDocument(); + _sendPerf.mark('doc_save_done'); + } catch(e) { + console.warn('doc auto-save failed', e); + _sendPerf.mark('doc_save_failed'); + } } // Inject document selection context if present @@ -801,9 +1168,15 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer fd.append('session', streamSessionId); if (ids.length) fd.append('attachments', JSON.stringify(ids)); // Auto-save & send active doc ID so the backend sees latest content - if (documentModule && documentModule.isPanelOpen() && documentModule.getCurrentDocId()) { - try { await documentModule.saveDocument({ silent: true }); } catch (_e) { /* best-effort */ } - fd.append('active_doc_id', documentModule.getCurrentDocId()); + if (documentModule && activeDocIdForSend) { + try { + _sendPerf.mark('doc_silent_save_begin'); + await documentModule.saveDocument({ silent: true }); + _sendPerf.mark('doc_silent_save_done'); + } catch (_e) { + _sendPerf.mark('doc_silent_save_failed'); + } + fd.append('active_doc_id', activeDocIdForSend); } // Active email context — when an email reader is open, pass its // uid/folder/account so "reply", "summarize", "what does this say" @@ -812,29 +1185,36 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer try { const getEmailCtx = window.__odysseusGetActiveEmailContext; const emCtx = typeof getEmailCtx === 'function' ? getEmailCtx() : null; - if (emCtx && emCtx.uid) { + if (activeEmailComposerCtx && activeEmailComposerCtx.sourceUid) { + fd.append('active_email_uid', String(activeEmailComposerCtx.sourceUid)); + fd.append('active_email_folder', String(activeEmailComposerCtx.sourceFolder || 'INBOX')); + } else if (emCtx && emCtx.uid) { fd.append('active_email_uid', String(emCtx.uid)); fd.append('active_email_folder', String(emCtx.folder || 'INBOX')); if (emCtx.account) fd.append('active_email_account', String(emCtx.account)); } } catch (_e) { /* best-effort */ } - // Web toggle: pre-search in Chat mode, tool permission in Agent mode + // Web toggle: pre-search in Chat mode only. Agent mode should not + // opportunistically hit SearXNG just because the chat search toggle is + // on; explicit web/current-info requests are handled by the backend + // intent gate. const toggleState = Storage.loadToggleState(); let isAgentMode = (toggleState.mode || 'chat') === 'agent'; + const incognitoChk = el('incognito-toggle'); + const isIncognito = !!(incognitoChk && incognitoChk.checked); // Auto-escalate to agent mode when a document is open — the user expects // the AI to see the document and have tools to edit it - if (!isAgentMode && documentModule && documentModule.isPanelOpen() && documentModule.getCurrentDocId()) { + if (!isIncognito && !isAgentMode && documentModule && activeDocIdForSend) { isAgentMode = true; } fd.append('mode', isAgentMode ? 'agent' : 'chat'); if (el('web-toggle').checked) { - if (isAgentMode) { - fd.append('allow_web_search', 'true'); - } else { + if (!isAgentMode) { fd.append('use_web', 'true'); } - } else if (isAgentMode) { - fd.append('allow_web_search', 'false'); + } + if (isAgentMode) { + fd.append('allow_web_search', el('web-toggle').checked ? 'true' : 'false'); } if (el('research-toggle').checked) { fd.append('use_research', 'true'); @@ -846,8 +1226,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer if (ragChk && !ragChk.checked) { fd.append('use_rag', 'false'); } - const incognitoChk = el('incognito-toggle'); - if (incognitoChk && incognitoChk.checked) { + if (isIncognito) { fd.append('incognito', 'true'); } const _ws = (Storage.KEYS && Storage.get(Storage.KEYS.WORKSPACE, '')) || ''; @@ -970,12 +1349,15 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch { return ''; } })(); + _sendPerf.mark('chat_stream_post_begin'); const res = await fetch(`${API_BASE}/api/chat_stream`, { method: 'POST', body: fd, headers: { 'X-Tz-Offset': String(_tzOffsetMin), 'X-Tz-Name': _tzName }, signal: abortCtrl.signal }); + _sendPerf.mark('chat_stream_headers'); + _sendPerf.report('headers_received'); if (!res.ok) { clearResponseTimeout(); @@ -1021,6 +1403,8 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer if (_chatLog) _chatLog.setAttribute('aria-busy', 'true'); const reader = res.body.getReader(); + _sendPerf.mark('reader_ready'); + _sendPerf.report('reader_ready'); const decoder = new TextDecoder(); let buffer = ''; let metrics = null; @@ -1033,6 +1417,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer let roundHolder = holder; // Current AI text bubble (changes per round) let roundText = ''; // Text accumulated for current round let currentToolBubble = null; // Current tool execution bubble + let lastToolThread = null; // Visible tool timeline for tool-only turns let roundFinalized = false; // Whether current round's text is finalized let _sourcesHtml = ''; // Sources box HTML to prepend to body let _sourcesExpanded = false; // Track if user expanded sources during stream @@ -1040,6 +1425,14 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer let _sourcesType = ''; // 'web' or 'research' let _findingsData = null; // Raw findings data for collapsible box // _keepResearchOn removed — clarification state now persisted server-side via DB mode + function _metricsTargetForTurn() { + const visibleRound = (roundHolder && roundHolder.style.display !== 'none') ? roundHolder : null; + const visibleText = visibleRound ? (visibleRound.querySelector('.body')?.textContent || '').trim() : ''; + if (lastToolThread && lastToolThread.isConnected && (!visibleRound || !visibleText || visibleText === 'Done.')) { + return lastToolThread; + } + return visibleRound || holder; + } // Insert sources box as a stable DOM node that won't be replaced during streaming. // Returns the content container to use for innerHTML updates. function _ensureStreamLayout(body) { @@ -1054,6 +1447,32 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer } return contentDiv; } + function _ensureVisibleRoundForDelta() { + if (!roundHolder || roundHolder.style.display !== 'none') return; + const box = document.getElementById('chat-history'); + if (!box) { + roundHolder.style.display = ''; + return; + } + const newWrap = document.createElement('div'); + newWrap.className = 'msg msg-ai msg-continuation streaming'; + const newRole = document.createElement('div'); + newRole.className = 'role'; + const metaS = sessionModule.getSessions().find(s => s.id === streamSessionId); + const requested = holder?._requestedModel || metaS?.model || modelName; + const actual = holder?._actualModel || requested; + newRole.textContent = _modelRouteLabel(requested, actual) || ''; + _applyModelColor(newRole, actual); + newWrap.appendChild(newRole); + const newBody = document.createElement('div'); + newBody.className = 'body'; + newWrap.appendChild(newBody); + box.appendChild(newWrap); + if (lastToolThread && lastToolThread.isConnected) lastToolThread.classList.add('has-bottom'); + roundHolder = newWrap; + roundText = ''; + roundFinalized = false; + } const esc = uiModule.esc; // Remove thinking spinner helper _removeThinkingSpinner = () => { @@ -1071,12 +1490,14 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer 'web_search': 'Searching', 'bash': 'Running', 'python': 'Running', - 'create_document': 'Writing', - 'update_document': 'Writing', 'read_document': 'Reading', 'edit_file': 'Editing', 'read_file': 'Reading', 'write_file': 'Writing', + 'create_document': 'Writing', + 'edit_document': 'Editing', + 'update_document': 'Rewriting', + 'suggest_document': 'Reviewing', 'list_files': 'Browsing', 'image_gen': 'Generating', 'generate_image': 'Generating', @@ -1175,7 +1596,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer // Direct render helper for streaming text _renderStream = () => { - let dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(roundText)); + let dt = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText)); const bodyEl = roundHolder.querySelector('.body'); const contentEl = _ensureStreamLayout(bodyEl); @@ -1254,6 +1675,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer // what keeps code-block hover buttons from flickering and avoids the O(N^2) // re-parse/re-highlight of the whole message on every token. // See streamingRenderer.js / streamingSegmenter.js. + if (_docFenceOpened && !dt.trim()) { + _showDocumentWritingStatus(contentEl); + uiModule.scrollHistory(); + return; + } const renderer = contentEl._streamRenderer || (contentEl._streamRenderer = createStreamRenderer(contentEl, { render: (t) => markdownModule.processWithThinking(markdownModule.squashOutsideCode(t)), @@ -1317,7 +1743,9 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer _streamSawDone = true; // Always update background map if entry exists (even if user switched back) var bgDone = _backgroundStreams.get(streamSessionId); - if (bgDone) { + if (bgDone && !_isBg) { + _backgroundStreams.delete(streamSessionId); + } else if (bgDone) { bgDone.status = 'completed'; bgDone.accumulated = accumulated; if (_isBg) { @@ -1423,27 +1851,29 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer if (!_thinkOpen) { _delta = '' + _delta; _thinkOpen = true; } } else if (_thinkOpen) { _delta = '' + _delta; _thinkOpen = false; - } - const wasEmpty = !accumulated; - accumulated += _delta; - roundText += _delta; - currentAccumulated = accumulated; // Update global tracker - // First token arrived — switch stop button from processing to streaming - if (wasEmpty && submitBtn && !_isBg) { - submitBtn.dataset.phase = 'receiving'; + } + const wasEmpty = !accumulated; + accumulated += _delta; + currentAccumulated = accumulated; // Update global tracker + // First token arrived — switch stop button from processing to streaming + if (wasEmpty && submitBtn && !_isBg) { + submitBtn.dataset.phase = 'receiving'; } // Update background map if running in background if (_isBg) { var bgEntry = _backgroundStreams.get(streamSessionId); - if (bgEntry) bgEntry.accumulated = accumulated; - continue; // Skip all DOM writes - } - - // --- Text-fence doc streaming (for models that don't use native tool calls) --- - if (!_docFenceOpened && documentModule && roundText.includes('```create_document\n')) { - const fenceIdx = roundText.indexOf('```create_document\n'); - const afterFence = roundText.slice(fenceIdx + '```create_document\n'.length); + if (bgEntry) bgEntry.accumulated = accumulated; + continue; // Skip all DOM writes + } + _ensureVisibleRoundForDelta(); + roundText += _delta; + + // --- Text-fence doc streaming (for models that don't use native tool calls) --- + if (!_docFenceOpened && documentModule && (roundText.includes('```create_document\n') || roundText.includes('```document\n') || roundText.includes('```documen\n'))) { + const fenceMarker = roundText.includes('```document\n') ? '```document\n' : (roundText.includes('```documen\n') ? '```documen\n' : '```create_document\n'); + const fenceIdx = roundText.indexOf(fenceMarker); + const afterFence = roundText.slice(fenceIdx + fenceMarker.length); const fenceLines = afterFence.split('\n'); if (fenceLines.length >= 1 && fenceLines[0].trim()) { _docFenceOpened = true; @@ -1452,7 +1882,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer const knownLangs = ['python','py','javascript','js','typescript','ts','html','css','json','yaml','bash','sql','rust','go','java','c','cpp','markdown','text','plain','ruby','swift','kotlin','php','email','csv','xml','toml','ini']; const isLang = fenceLines.length >= 2 && knownLangs.includes(fenceLines[1].trim().toLowerCase()); const lang = isLang ? fenceLines[1].trim() : ''; - _docFenceContentStart = fenceIdx + '```create_document\n'.length + title.length + 1 + (isLang ? fenceLines[1].length + 1 : 0); + _docFenceContentStart = fenceIdx + fenceMarker.length + title.length + 1 + (isLang ? fenceLines[1].length + 1 : 0); documentModule.streamDocOpen(title, lang); } } @@ -1539,9 +1969,9 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
Thinking\u2026
- + -
+
`; @@ -1574,7 +2004,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer } else if (hasUnclosedThink && isThinking) { if (_liveThinkInner) { // Extract raw thinking text (strip known thinking wrappers and prefixes) - var thinkText = markdownModule.normalizeThinkingMarkup(roundText) + var thinkText = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText)) .replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '') .replace(/<\|channel>thought\s*\n?/gi, '') .replace(/<\|channel>response\s*\n?/gi, '') @@ -1587,13 +2017,15 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer _liveThinkTimerEl.textContent = _formatThinkStats(_elapsedLive, _liveThinkTokenCount); } // Keep thinking box scrolled to bottom, but let user scroll up + var _followThinking = true; var thinkBox = _liveThinkInner.closest('.thinking-content'); if (thinkBox) { var nearBottom = thinkBox.scrollHeight - thinkBox.clientHeight - thinkBox.scrollTop < 80; if (nearBottom) thinkBox.scrollTop = thinkBox.scrollHeight; + _followThinking = nearBottom; } } - uiModule.scrollHistory(); + if (_followThinking) uiModule.scrollHistory(); continue; } else if (!hasUnclosedThink && isThinking) { isThinking = false; @@ -2005,6 +2437,14 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer if (!_isBg) { uiModule.showToast('Context compacted — older messages summarized'); } + } else if (json.type === 'context_trimmed') { + if (!_isBg) { + const d = json.data || {}; + const before = Number(d.messages_before || 0); + const after = Number(d.messages_after || 0); + const detail = before && after && before > after ? ` (${after}/${before} messages sent)` : ''; + uiModule.showToast(`Context trimmed for this model${detail}`); + } } else if (json.type === 'metrics') { metrics = json.data; if (!_isBg && holder && metrics) { @@ -2016,6 +2456,10 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer if (bgM) bgM.metrics = json.data; continue; } + if (metrics) { + const metricsTarget = _metricsTargetForTurn(); + if (metricsTarget) displayMetrics(metricsTarget, metrics); + } } else if (json.type === 'message_saved') { // Wire the persisted DB id onto the just-streamed bubble so it @@ -2047,7 +2491,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer if (!roundFinalized) { roundFinalized = true; if (spinner && spinner.element) spinner.destroy(); - const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(roundText)); + const dt = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText)); if (dt.trim()) { var _body3 = roundHolder.querySelector('.body'); var _contentEl3 = _ensureStreamLayout(_body3); @@ -2096,6 +2540,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer chatBox.appendChild(threadWrap); } threadWrap.classList.add('streaming'); + lastToolThread = threadWrap; const toolLabel = _toolLabels[json.tool.toLowerCase()] || json.tool; const toolIcon = _toolIcons[json.tool.toLowerCase()] || '\u25B6'; const node = document.createElement('div') @@ -2270,6 +2715,25 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer if (json.ui_event) { chatStream.handleUIControl(json); } + // Native document tool calls can arrive as a completed + // tool_output without the text-fence streaming path. Open the + // document editor from the real doc metadata carried on the + // tool result so "create a document" never leaves only a chat + // link behind if the later doc_update event is missed. + if ( + documentModule + && json.doc_id + && ['create_document', 'update_document', 'edit_document'].includes(json.tool) + ) { + documentModule.handleDocUpdate({ + type: 'doc_update', + doc_id: json.doc_id, + title: json.document_title || '', + language: json.document_language || '', + version: json.document_version || 1, + content: json.document_content || '', + }); + } // Schedule a thinking spinner between tool rounds (short delay so // agent_step in the same SSE chunk can cancel it before it shows) @@ -2321,148 +2785,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer } else if (json.type === 'ask_user') { if (_isBg) continue; // The agent posed a multiple-choice question; the turn has ended. - // Render clickable options at the bottom of the history. The - // user's pick is sent as the next message and the agent resumes. + // Use the shared history renderer so the live and restored + // versions have identical behavior. _cancelThinkingTimer(); _removeThinkingSpinner(); - const _aq = json.data || {}; - const _opts = Array.isArray(_aq.options) ? _aq.options : []; - if (_aq.question && _opts.length) { - const chatBox = document.getElementById('chat-history'); - // Drop any prior unanswered card so only the latest shows. - chatBox.querySelectorAll('.ask-user-card').forEach(n => n.remove()); - const card = document.createElement('div'); - card.className = 'ask-user-card'; - const multi = !!_aq.multi; - // Group the choices for assistive tech and label the group with - // the question (set below); make the card focusable so it can be - // moved to when it appears. - card.setAttribute('role', 'group'); - card.tabIndex = -1; - // Render any emoji in agent-supplied text through the app's - // pipeline: escape, then svgify to monochrome theme-tinted - // glyphs (project rule: never colorful emoji; respects the - // "Text-only Emojis" setting like the rest of the chat). - const _emo = (s) => svgifyEmoji(uiModule.esc(String(s))); - - // Header row holds the close (×) to dismiss the affordances and - // just type a reply instead. - const head = document.createElement('div'); - head.className = 'ask-user-head'; - const closeBtn = document.createElement('button'); - closeBtn.type = 'button'; - closeBtn.className = 'modal-close ask-user-close'; - closeBtn.setAttribute('aria-label', 'Dismiss question'); - closeBtn.textContent = '×'; - closeBtn.addEventListener('click', () => { - card.remove(); - const mi = uiModule.el('message'); - if (mi) mi.focus(); - }); - head.appendChild(closeBtn); - card.appendChild(head); - - // Render the question inside the card so it's self-contained: - // some models call ask_user without first narrating the question - // as assistant text, in which case the card would otherwise show - // bare options with no prompt. - if (_aq.question) { - const q = document.createElement('div'); - q.className = 'ask-user-question'; - q.id = `ask-user-q-${Date.now()}-${Math.floor(Math.random() * 1e4)}`; - q.innerHTML = _emo(_aq.question); - card.appendChild(q); - // Label the choice group with the question for screen readers. - card.setAttribute('aria-labelledby', q.id); - } else { - card.setAttribute('aria-label', 'Question from the assistant'); - } - - const list = document.createElement('div'); - list.className = 'ask-user-options'; - card.appendChild(list); - - const _send = (text) => { - if (!text) return; - // Remove the card once answered — the choice is sent as a - // normal user message (and the question persists as the - // assistant text above), so the affordances are spent. - card.remove(); - const mi = uiModule.el('message'); - if (mi) mi.value = text; - const sb = document.querySelector('.send-btn'); - if (sb) sb.click(); - }; - - _opts.forEach((opt, i) => { - const label = (opt && opt.label) ? String(opt.label) : String(opt || ''); - if (!label) return; - const descr = (opt && opt.description) ? String(opt.description) : ''; - const row = document.createElement(multi ? 'label' : 'button'); - row.className = 'ask-user-option'; - if (multi) { - const cb = document.createElement('input'); - cb.type = 'checkbox'; - cb.value = label; - row.appendChild(cb); - } - const txt = document.createElement('span'); - txt.className = 'ask-user-option-label'; - txt.innerHTML = _emo(label); - row.appendChild(txt); - if (descr) { - const d = document.createElement('span'); - d.className = 'ask-user-option-desc'; - d.innerHTML = _emo(descr); - row.appendChild(d); - } - if (!multi) { - row.type = 'button'; - row.addEventListener('click', () => _send(label)); - } - list.appendChild(row); - }); - - // Free-text "Other" — type a custom answer + send (Enter or →). - const other = document.createElement('div'); - other.className = 'ask-user-other'; - const otherInput = document.createElement('input'); - otherInput.type = 'text'; - otherInput.className = 'styled-prompt-input ask-user-other-input'; - otherInput.placeholder = multi ? 'Other (added to selection)…' : 'Other… (type your own answer)'; - otherInput.setAttribute('aria-label', multi ? 'Add a custom option' : 'Type a custom answer'); - const otherSend = document.createElement('button'); - otherSend.type = 'button'; - otherSend.className = 'confirm-btn confirm-btn-primary ask-user-other-send'; - otherSend.setAttribute('aria-label', 'Send answer'); - otherSend.textContent = multi ? 'Send selection' : 'Send'; - const _submit = () => { - const free = otherInput.value.trim(); - if (multi) { - const picked = Array.from(card.querySelectorAll('.ask-user-option input:checked')).map(c => c.value); - if (free) picked.push(free); - if (picked.length) _send(picked.join(', ')); - } else if (free) { - _send(free); - } - }; - otherSend.addEventListener('click', _submit); - otherInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { - e.preventDefault(); - _submit(); - } - }); - other.appendChild(otherInput); - other.appendChild(otherSend); - card.appendChild(other); - - chatBox.appendChild(card); - card.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - // Move focus to the card so keyboard/screen-reader users land on - // the question + choices when it appears. - try { card.focus(); } catch (_) {} - } + chatRenderer.renderAskUserCard(json.data || {}); } else if (json.type === 'plan_update') { if (_isBg) continue; @@ -2591,6 +2918,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer } _renderStream(); + if (spinner && spinner.element) { try { spinner.destroy(); } catch (_) {} spinner = null; } _cancelThinkingTimer(); _removeThinkingSpinner(); // Stop any thread pulse animations @@ -2652,9 +2980,13 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer // Clear streaming minHeight lock const _streamContent = roundHolder.querySelector('.stream-content'); if (_streamContent) _streamContent.style.minHeight = ''; + if (_docFenceOpened) { + _finishDocumentWritingStatus(roundHolder, true); + roundHolder.style.display = ''; + } // Finalize the last round's bubble — flatten stream-content wrapper for clean DOM - const finalDisplay = stripToolBlocks(roundText); + const finalDisplay = _streamDisplayText(roundText, { final: _docFenceOpened }); if (finalDisplay.trim()) { var _body4 = roundHolder.querySelector('.body'); // Preserve sources expanded state before final render @@ -2720,11 +3052,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer _body4b.innerHTML = _sourcesData ? _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded2) : _sourcesHtml; } else if (roundHolder !== holder) { // Check if there's thinking content worth showing - const _thinkingOnly = markdownModule.extractThinkingBlocks(roundText); + const _thinkingOnly = markdownModule.extractThinkingBlocks(_streamDisplayText(roundText)); if (_thinkingOnly.thinkingBlocks?.length && !_thinkingOnly.content) { // Show thinking in a collapsed section even if no visible reply text const _body4c = roundHolder.querySelector('.body'); - if (_body4c) _body4c.innerHTML = markdownModule.processWithThinking(roundText); + if (_body4c) _body4c.innerHTML = markdownModule.processWithThinking(_streamDisplayText(roundText)); } else { roundHolder.style.display = 'none'; // Thread above expected a bubble below — remove has-bottom since bubble is hidden @@ -2770,7 +3102,9 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer // Attach footer to the last visible bubble (roundHolder for multi-round agent, holder for single) const footerTarget = (roundHolder && roundHolder !== holder && roundHolder.style.display !== 'none') ? roundHolder : holder; - footerTarget.appendChild(createMsgFooter(footerTarget)); + if (!footerTarget.querySelector('.msg-footer')) { + footerTarget.appendChild(createMsgFooter(footerTarget)); + } // Add "View Report" link for completed research if (_researchingStreamIds.has(streamSessionId)) { _appendViewReportLink(footerTarget, streamSessionId); @@ -2810,7 +3144,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer } } if (metrics) { - displayMetrics(footerTarget, metrics); + displayMetrics(_metricsTargetForTurn() || footerTarget, metrics); } // Attach variant navigation if this was a regeneration _attachVariantNav(footerTarget); @@ -2937,6 +3271,21 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer return; } + if (abortReason === 'stale-local') { + const staleMsg = 'Stream connection ended. Composer unlocked; send again if needed.'; + if (holder && !accumulated) { + holder.querySelector('.body').innerHTML = + `
[${staleMsg}]
`; + } else if (holder && accumulated) { + const staleNote = document.createElement('div'); + staleNote.className = 'stopped-indicator'; + staleNote.innerHTML = `[${staleMsg}]`; + holder.querySelector('.body').appendChild(staleNote); + } + currentAbort = null; + return; + } + // User-initiated stop (or browser navigation abort). // Stopped before any text arrived — keep the bubble as a // "Cancelled by user" record (so it survives a refresh). @@ -3124,6 +3473,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer sessionModule.loadSessions(); } }, 3000); + _drainQueuedAgentRequests(); } } @@ -3242,12 +3592,51 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer box.appendChild(bar); if (uiModule.scrollHistory) uiModule.scrollHistory(); } + async function _probeStaleLocalStream() { + if (!isStreaming || _staleStreamProbeInFlight) return; + if (Date.now() - _lastReaderActivity < STALE_LOCAL_STREAM_MS) return; + const sid = _streamSessionId || (sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId()); + if (!sid) return; + if (_backgroundStreams.has(sid) || (sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId() !== sid)) return; + _staleStreamProbeInFlight = true; + try { + const res = await fetch(`${API_BASE}/api/chat/stream_status/${encodeURIComponent(sid)}`, { + credentials: 'same-origin', + cache: 'no-store', + }); + if (!isStreaming || _backgroundStreams.has(sid)) return; + if (res.status !== 404) return; + + console.warn('[stream-watchdog] Local stream was stale and server has no active stream. Unlocking composer.'); + if (currentAbort && !currentAbort.signal.aborted) { + currentAbort._reason = 'stale-local'; + currentAbort.abort(); + } + isStreaming = false; + _setForegroundChatBusy(false); + _sendInFlight = false; + if (_webLockRelease) { + _webLockRelease(); + _webLockRelease = null; + } + const submitBtn = document.querySelector('.send-btn'); + if (submitBtn) updateSubmitButton('idle', submitBtn); + const messageInput = uiModule.el('message'); + if (messageInput) messageInput.disabled = false; + _drainQueuedAgentRequests(); + } catch (err) { + console.warn('[stream-watchdog] Stream status probe failed:', err); + } finally { + _staleStreamProbeInFlight = false; + } + } + function _startStallWatchdog() { - // Disabled: the server-side stall detector / auto-continue (agent - // loop-breaker) handles quiet/stalled streams now, so the manual - // "Quiet for Nm — still working?" banner is redundant (and annoying). + // Keep the old noisy stall banner disabled. This watchdog only unlocks + // a dead local stream after the backend confirms no active stream exists. if (_stallWatchdog) { clearInterval(_stallWatchdog); _stallWatchdog = null; } _removeStallBanner(); + _stallWatchdog = setInterval(_probeStaleLocalStream, 5000); } function _stopStallWatchdog() { if (_stallWatchdog) { clearInterval(_stallWatchdog); _stallWatchdog = null; } @@ -3331,6 +3720,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer // Clear local state WITHOUT aborting the fetch currentAbort = null; isStreaming = false; + _setForegroundChatBusy(false); currentHolder = null; currentAccumulated = ''; // Reset submit button so the new chat is ready to send @@ -3393,6 +3783,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer const decoder = new TextDecoder(); let buffer = ''; let roundText = ''; + let docFenceOpened = false; let gotDelta = false; let leftSession = false; let metricsData = null; @@ -3407,8 +3798,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer }; const renderDelta = () => { - const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(roundText)); - contentDiv.innerHTML = markdownModule.mdToHtml(markdownModule.squashOutsideCode(dt)); + const dt = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText, { final: docFenceOpened })); + if (docFenceOpened && !dt.trim()) { + _showDocumentWritingStatus(contentDiv); + } else { + contentDiv.innerHTML = markdownModule.mdToHtml(markdownModule.squashOutsideCode(dt)); + } uiModule.scrollHistory(); }; @@ -3439,6 +3834,10 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer try { json = JSON.parse(payload); } catch (_) { continue; } if (json.delta) { roundText += json.delta; + if (!docFenceOpened && (roundText.includes('```create_document\n') || roundText.includes('```document\n') || roundText.includes('```documen\n'))) { + docFenceOpened = true; + rich = true; + } if (!gotDelta) { gotDelta = true; try { spinner.destroy(); } catch (_) {} } renderDelta(); } else if (json.type === 'doc_stream_open') { @@ -3446,7 +3845,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer if (documentModule) documentModule.streamDocOpen(json.title || '', json.lang || ''); } else if (json.type === 'doc_stream_delta') { rich = true; - if (documentModule && json.delta) documentModule.streamDocDelta(json.delta); + if (documentModule) documentModule.streamDocDelta(json.content || json.delta || ''); } else if (json.type === 'metrics') { metricsData = json.data || metricsData; } else if (json.type === 'tool_start' || json.type === 'tool_output' || @@ -3463,6 +3862,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer } cleanup(); + if (docFenceOpened) _finishDocumentWritingStatus(holder, true); if (leftSession) { if (holder.parentNode) holder.remove(); return true; } const onThisSession = sessionModule.getCurrentSessionId && @@ -3482,6 +3882,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer // Rich response (tools, sources, docs, multi-round) or user moved on: // reload from the DB for the full canonical render. + if (holder._docWritingThread && holder._docWritingThread.parentNode) holder._docWritingThread.remove(); if (holder.parentNode) holder.remove(); if (onThisSession) sessionModule.selectSession(sessionId); else sessionModule.loadSessions(); @@ -4275,7 +4676,10 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer if (!sessionId) return; try { const res = await fetch(`${API_BASE}/api/research/status/${sessionId}`); - if (!res.ok) return; // 404 = no research for this session + if (!res.ok) { + if (sessionModule && sessionModule.clearResearching) sessionModule.clearResearching(sessionId); + return; // 404 = no research for this session + } const data = await res.json(); if (data.status === 'done') { @@ -5019,7 +5423,16 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer if (!header) return; const node = header.closest('.agent-thread-node'); if (!node) return; - node.classList.toggle('open'); + const opened = node.classList.toggle('open'); + if (opened) { + // Expanding the final tool trace can push a pending ask_user card below + // the viewport. Keep that immediately-adjacent prompt visible. + const thread = node.closest('.agent-thread'); + const pendingCard = thread?.nextElementSibling; + if (pendingCard?.classList.contains('ask-user-card')) { + requestAnimationFrame(() => pendingCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' })); + } + } }); window.__odysseus_thread_click_bound = true; } diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js index 253fa57248..82e4c2b5b5 100644 --- a/static/js/chatRenderer.js +++ b/static/js/chatRenderer.js @@ -3,6 +3,7 @@ import uiModule from './ui.js'; import markdownModule from './markdown.js'; +import { svgifyEmoji } from './markdown.js'; import { addAITTSButton } from './tts-ai.js'; import { providerLogo, providerLabel } from './providers.js'; import settingsModule from './settings.js'; @@ -80,7 +81,7 @@ function _formatSize(bytes) { // Build the `.attach-cards` element for a message's attachment list. Shared by // addMessage and updateMessageAttachments so a live (optimistic) user bubble // can be re-rendered with real upload ids once the upload resolves. -function buildAttachCards(attachments) { +export function buildAttachCards(attachments) { const attachWrap = document.createElement('div'); attachWrap.className = 'attach-cards'; for (const att of attachments) { @@ -406,8 +407,64 @@ function _openVisionEditor(att, userMsgEl) { // Tool call syntax patterns to strip from displayed text const TOOL_CALL_RE = /\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/gi; -// Only strip fenced tool-call blocks that look like structured invocations, not regular code examples -const EXEC_FENCE_RE = /```(?:web_search|read_file|write_file|create_document|edit_document|update_document)\s*\n[\s\S]*?```/gi; +// Strip fenced tool-call blocks that look like structured invocations, not +// regular code examples. The tool tags are NOT hard-coded here — they are the +// backend's authoritative TOOL_TAGS set, fetched once from GET /api/tools and +// built into EXEC_FENCE_RE at load. TOOL_TAGS (src/agent_tools/__init__.py) is +// thus the single source: the live-strip list can never drift from the backend +// or miss a future tool (#3993). bash/python are carved out on purpose — they +// are languages a user may legitimately have asked the model to show, not tool +// invocations. +// +// Until the fetch resolves, EXEC_FENCE_RE stays null and exec fences aren't +// stripped — normally a sub-second window before the first stream. If the fetch +// fails it stays null for the rest of the session (logged below), so live exec +// fences won't be stripped until reload. Either way the backend already strips +// persisted history (src/tool_parsing.py builds the same regex from TOOL_TAGS), +// so a reload always renders clean. +let EXEC_FENCE_RE = null; +const EXEC_FENCE_NON_TOOL = new Set(['bash', 'python']); + +function escapeRegex(source) { + return String(source).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function stripExecutedFence(match, tag, inline, body) { + const inlineArgs = (inline || '').trim(); + if (!inlineArgs) return ''; + const bodyText = (body || '').trim(); + const content = bodyText ? `${inlineArgs}\n${bodyText}` : inlineArgs; + try { + JSON.parse(content); + } catch { + return match; + } + return ''; +} + +async function loadExecFenceRegex() { + try { + const res = await fetch('/api/tools', { credentials: 'same-origin' }); + const data = await res.json(); + const tags = (data.tools || []) + .map((t) => t.id) + .filter((id) => id && !EXEC_FENCE_NON_TOOL.has(id)); + if (tags.length) { + EXEC_FENCE_RE = new RegExp( + '```(' + tags.map(escapeRegex).join('|') + ')(?![\\w-])' + + '[ \\t]*([\\[{][^\\n]*?)?[ \\t]*(?=\\r?\\n|```)' + + '\\r?\\n?([\\s\\S]*?)```', + 'gi' + ); + } + } catch (err) { + // Surface the failure rather than swallowing it: EXEC_FENCE_RE stays null, + // so this session won't strip live exec fences until reload (persisted path + // stays clean regardless). + console.warn('chatRenderer: /api/tools fetch failed; live exec-fence stripping disabled until reload', err); + } +} +loadExecFenceRegex(); // XML-style tool calls: , , , bare const XML_TOOL_CALL_RE = /<(?:[\w]+:)?(?:tool_call|function_call)>[\s\S]*?<\/(?:[\w]+:)?(?:tool_call|function_call)>/gi; const XML_INVOKE_RE = /[\s\S]*?<\/invoke>/gi; @@ -417,6 +474,10 @@ const XML_INVOKE_RE = /[\s\S]*?<\/invoke>/gi; // (e.g. mid-stream before the closing tag arrives). const DSML_TOOL_RE = /<\s*[||]+\s*DSML\s*[||]+\s*tool_calls\s*>[\s\S]*?(?:<\s*\/\s*[||]+\s*DSML\s*[||]+\s*tool_calls\s*>|$)/gi; const DSML_STRAY_RE = /<\s*\/?\s*[||]+\s*DSML\s*[||]+[^>]*>/gi; +const DSML_INVOKE_RE = /<\s*[||]+\s*DSML\s*[||]+\s*invoke\b[^>]*>[\s\S]*?(?:<\s*\/\s*[||]+\s*DSML\s*[||]+\s*invoke\s*>|$)/gi; +const RAW_OPENAI_TOOL_JSON_RE = /(?:\[\s*)?\{\s*"function"\s*:\s*\{[\s\S]*?\}\s*,\s*"id"\s*:\s*"[^"]*"\s*,\s*"type"\s*:\s*"function"\s*\}\s*\]?/gi; +const QWEN_ROLE_MARKER_RE = /<\/?\|(?:assistant|assistan|user|system|tool)\|>?|<\/\|end\|>?/gi; +const QWEN_BARE_MARKER_RE = /(?:^|[\t\r\n ])(?:\|?end\|?|\/?\|end\|)(?=[\t\r\n ]|$)|(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)/gi; // Self-narration about tool results (model echoing stdout/exit_code) const TOOL_NARRATION_RE = /(?:The (?:result|output) shows?:?\s*)?-?\s*(?:stdout|stderr|exit_code):\s*.+/gi; @@ -852,11 +913,15 @@ export function roleTimestamp(when) { */ export function stripToolBlocks(text) { let cleaned = text.replace(TOOL_CALL_RE, ''); - cleaned = cleaned.replace(EXEC_FENCE_RE, ''); + if (EXEC_FENCE_RE) cleaned = cleaned.replace(EXEC_FENCE_RE, stripExecutedFence); cleaned = cleaned.replace(DSML_TOOL_RE, ''); + cleaned = cleaned.replace(DSML_INVOKE_RE, ''); cleaned = cleaned.replace(DSML_STRAY_RE, ''); cleaned = cleaned.replace(XML_TOOL_CALL_RE, ''); cleaned = cleaned.replace(XML_INVOKE_RE, ''); + cleaned = cleaned.replace(RAW_OPENAI_TOOL_JSON_RE, ''); + cleaned = cleaned.replace(QWEN_ROLE_MARKER_RE, ''); + cleaned = cleaned.replace(QWEN_BARE_MARKER_RE, ' '); cleaned = cleaned.replace(TOOL_NARRATION_RE, ''); cleaned = cleaned.replace(/\n{3,}/g, '\n\n'); return cleaned.trim(); @@ -1068,6 +1133,17 @@ document.addEventListener('click', function(e) { } }, true); +function resolveDocumentPlaceholderLinks(text, metadata) { + if (!text || !metadata || !Array.isArray(metadata.tool_events)) return text; + const docEvents = metadata.tool_events.filter(ev => ev && ev.doc_id); + if (!docEvents.length) return text; + return String(text).replace(/#document-(\d+)\b/g, (match, num) => { + const idx = Number(num) - 1; + const ev = Number.isInteger(idx) && idx >= 0 ? docEvents[idx] : null; + return ev && ev.doc_id ? `#document-${ev.doc_id}` : match; + }); +} + // Jump-to-entity anchors — the agent emits links like // [New Chat](#session-89effa28) // [Notes](#document-abc123) @@ -1084,17 +1160,41 @@ document.addEventListener('click', function(e) { while (_t && _t.nodeType === Node.TEXT_NODE) _t = _t.parentElement; const a = _t && _t.closest && _t.closest('a[href]'); if (!a) return; - const href = a.getAttribute('href') || ''; + const rawHref = a.getAttribute('href') || ''; + let href = rawHref; + try { + const parsed = new URL(rawHref, window.location.origin); + if (parsed.origin === window.location.origin && parsed.pathname === window.location.pathname) { + href = parsed.hash || rawHref; + } + } catch (_) {} if (!href.startsWith('#')) return; - const m = href.match(/^#(session|document|note|image|email|event|task|skill|research)-(.+)$/); + let m = href.match(/^#(session|document|note|image|email|event|task|skill|research)-(.+)$/); + if (!m) { + const noteOpen = href.match(/^#open=notes¬e=([^&]+)/); + if (noteOpen) m = ['note', 'note', decodeURIComponent(noteOpen[1])]; + } + if (!m) { + const bareSession = href.match(/^#([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i); + if (bareSession) m = ['session', 'session', bareSession[1]]; + } if (!m) return; e.preventDefault(); e.stopPropagation(); const [, kind, id] = m; if (kind === 'session') { + try { + a.classList.add('is-loading'); + a.setAttribute('aria-busy', 'true'); + } catch {} import('./sessions.js').then(mod => { const fn = mod.selectSession || (mod.default && mod.default.selectSession); - if (fn) fn(id); + if (fn) return fn(id, { showLoading: true, immediateLoading: true }); + }).finally(() => { + try { + a.classList.remove('is-loading'); + a.removeAttribute('aria-busy'); + } catch {} }); } else if (kind === 'document') { import('./document.js').then(mod => { @@ -1107,6 +1207,11 @@ document.addEventListener('click', function(e) { import('./notes.js').then(mod => { const open = mod.openNote || (mod.default && mod.default.openNote); if (open) open(id); + try { + if (/^#(?:note-|open=notes¬e=)/.test(window.location.hash || '')) { + history.replaceState(null, '', window.location.pathname + window.location.search); + } + } catch (_) {} }).catch(() => {}); } else if (kind === 'image') { import('./gallery.js').then(mod => { @@ -1140,7 +1245,7 @@ document.addEventListener('click', function(e) { if (open) open(id); }).catch(() => {}); } -}); +}, true); /** * Build a generated-image bubble element. @@ -1258,6 +1363,25 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId }); actions.appendChild(editBtn); + if (imageId) { + const galleryBtn = document.createElement('button'); + galleryBtn.className = 'footer-copy-btn footer-open-gallery-btn'; + galleryBtn.type = 'button'; + galleryBtn.title = 'Open in gallery'; + galleryBtn.innerHTML = 'Open in gallery'; + galleryBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + try { + const mod = await import('./gallery.js'); + const open = mod.openGalleryImage || (mod.default && mod.default.openGalleryImage); + if (open) open(imageId); + } catch (err) { + console.error('[chat] open in gallery failed', err); + } + }); + actions.appendChild(galleryBtn); + } + const delBtn = document.createElement('button'); delBtn.className = 'footer-copy-btn footer-delete-btn'; delBtn.type = 'button'; @@ -1689,8 +1813,9 @@ export function createUserMsgFooter(msgElement) { * Display performance metrics for a message. */ export function displayMetrics(messageElement, metrics) { - const existingMetrics = messageElement.querySelector('.response-metrics'); - if (existingMetrics) existingMetrics.remove(); + messageElement + .querySelectorAll('.response-metrics, .metrics-divider, .ctx-divider, .ctx-ring') + .forEach((el) => el.remove()); const metricsContainer = document.createElement('span'); metricsContainer.className = 'response-metrics'; @@ -1705,7 +1830,7 @@ export function displayMetrics(messageElement, metrics) { const cost = _billableCost(model, inputTokens, outputTokens); // Nothing useful to show — bail out (only if ALL metrics are missing) - if (!responseTime && !outputTokens && tps == null && !ctxPct) return; + if (!responseTime && !inputTokens && !outputTokens && tps == null && !ctxPct) return; // Accumulate session cost (only on fresh metrics, not history reload) if (!metrics._fromHistory) { @@ -1720,22 +1845,22 @@ export function displayMetrics(messageElement, metrics) { } } - // Default: show tok/s if available, else fall back to other stats + // Keep token counts in the Message Stats popup; the footer should stay slim. const costStr0 = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : null; - const metricsLabel = tps != null && tps !== 'undefined' + const hasTps = tps != null && tps !== 'undefined'; + const metricsLabel = hasTps ? `${tps} tok/s` : costStr0 - ? `${outputTokens} tok · ${costStr0}` - : outputTokens - ? `${outputTokens} tok · ${responseTime != null ? responseTime + 's' : ''}` - : responseTime != null - ? `${responseTime}s` - : ''; + ? costStr0 + : responseTime != null + ? `${responseTime}s` + : ''; if (!metricsLabel) return; metricsContainer.textContent = metricsLabel; metricsContainer.style.cursor = 'pointer'; metricsContainer.title = 'Click for details'; const metricsDivider = document.createElement('span'); + metricsDivider.className = 'metrics-divider'; metricsDivider.textContent = ' | '; metricsDivider.style.color = 'var(--color-muted-alt)'; metricsDivider.style.pointerEvents = 'none'; @@ -1948,6 +2073,13 @@ export function displayMetrics(messageElement, metrics) { } let footer = messageElement.querySelector('.msg-footer'); + if (!footer) { + footer = createMsgFooter(messageElement); + if (messageElement.classList?.contains('agent-thread')) { + footer.classList.add('agent-thread-footer'); + } + messageElement.appendChild(footer); + } if (footer) { const actions = footer.querySelector('.msg-actions'); if (actions) { @@ -1974,6 +2106,142 @@ export function displayMetrics(messageElement, metrics) { if (uiModule) uiModule.scrollHistory(); } +/** Remove any unanswered multiple-choice cards currently in the chat. */ +export function removeAskUserCards(root) { + const scope = root || document.getElementById('chat-history') || document; + scope.querySelectorAll('.ask-user-card').forEach((node) => node.remove()); +} + +/** + * Render an ask_user payload as a durable choice card. + * + * This lives in the history renderer rather than the streaming loop so the + * same UI can be used both for a live SSE event and for a persisted tool event + * after a session reload. + */ +export function renderAskUserCard(payload, options) { + const aq = payload || {}; + const opts = Array.isArray(aq.options) ? aq.options : []; + const chatBox = document.getElementById('chat-history'); + if (!chatBox || !aq.question || opts.length < 2) return null; + + const renderOptions = options || {}; + removeAskUserCards(chatBox); + + const card = document.createElement('div'); + card.className = 'ask-user-card'; + card.setAttribute('role', 'group'); + card.tabIndex = -1; + const multi = !!aq.multi; + const emojiText = (value) => svgifyEmoji(uiModule.esc(String(value))); + + const head = document.createElement('div'); + head.className = 'ask-user-head'; + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'modal-close ask-user-close'; + closeBtn.setAttribute('aria-label', 'Dismiss question'); + closeBtn.textContent = '×'; + closeBtn.addEventListener('click', () => { + card.remove(); + const input = uiModule.el('message'); + if (input) input.focus(); + }); + head.appendChild(closeBtn); + card.appendChild(head); + + const question = document.createElement('div'); + question.className = 'ask-user-question'; + question.id = `ask-user-q-${Date.now()}-${Math.floor(Math.random() * 1e4)}`; + question.innerHTML = emojiText(aq.question); + card.appendChild(question); + card.setAttribute('aria-labelledby', question.id); + + const list = document.createElement('div'); + list.className = 'ask-user-options'; + card.appendChild(list); + + const send = (text) => { + if (!text) return; + card.remove(); + const input = uiModule.el('message'); + if (input) input.value = text; + const sendButton = document.querySelector('.send-btn'); + if (sendButton) sendButton.click(); + }; + + opts.forEach((opt) => { + const label = (opt && opt.label) ? String(opt.label) : String(opt || ''); + if (!label) return; + const description = (opt && opt.description) ? String(opt.description) : ''; + const row = document.createElement(multi ? 'label' : 'button'); + row.className = 'ask-user-option'; + if (multi) { + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.value = label; + row.appendChild(checkbox); + } + const labelText = document.createElement('span'); + labelText.className = 'ask-user-option-label'; + labelText.innerHTML = emojiText(label); + row.appendChild(labelText); + if (description) { + const descriptionText = document.createElement('span'); + descriptionText.className = 'ask-user-option-desc'; + descriptionText.innerHTML = emojiText(description); + row.appendChild(descriptionText); + } + if (!multi) { + row.type = 'button'; + row.addEventListener('click', () => send(label)); + } + list.appendChild(row); + }); + + const other = document.createElement('div'); + other.className = 'ask-user-other'; + const otherInput = document.createElement('input'); + otherInput.type = 'text'; + otherInput.className = 'styled-prompt-input ask-user-other-input'; + otherInput.placeholder = multi ? 'Other (added to selection)…' : 'Other… (type your own answer)'; + otherInput.setAttribute('aria-label', multi ? 'Add a custom option' : 'Type a custom answer'); + const otherSend = document.createElement('button'); + otherSend.type = 'button'; + otherSend.className = 'confirm-btn confirm-btn-primary ask-user-other-send'; + otherSend.setAttribute('aria-label', 'Send answer'); + otherSend.textContent = multi ? 'Send selection' : 'Send'; + const submit = () => { + const freeText = otherInput.value.trim(); + if (multi) { + const picked = Array.from(card.querySelectorAll('.ask-user-option input:checked')).map((input) => input.value); + if (freeText) picked.push(freeText); + if (picked.length) send(picked.join(', ')); + } else if (freeText) { + send(freeText); + } + }; + otherSend.addEventListener('click', submit); + otherInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { + event.preventDefault(); + submit(); + } + }); + other.appendChild(otherInput); + other.appendChild(otherSend); + card.appendChild(other); + + chatBox.appendChild(card); + if (renderOptions.scroll !== false) { + card.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + if (renderOptions.focus !== false) { + try { card.focus(); } catch (_) {} + } + return card; +} + /** * Add a message to the chat history. */ @@ -1983,6 +2251,11 @@ export function addMessage(role, content, modelName, metadata) { const box = document.getElementById('chat-history'); if (!box) { console.error('Chat history element not found'); return; } + // Loading a later user message means any earlier ask_user card was + // answered. This also removes the live card as soon as a manual reply is + // appended, even when the user did not click one of its buttons. + if (role === 'user') removeAskUserCards(box); + var esc = uiModule.esc; const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content; @@ -1990,6 +2263,7 @@ export function addMessage(role, content, modelName, metadata) { if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) { const roundTexts = metadata.round_texts || []; const toolEvents = metadata.tool_events; + let pendingAskUser = null; let lastWrap = null; let firstMsgAi = null; let lastMsgAi = null; @@ -2005,7 +2279,7 @@ export function addMessage(role, content, modelName, metadata) { for (let r = 0; r < maxRound; r++) { const roundNum = r + 1; - const txt = (roundTexts[r] || '').trim(); + const txt = resolveDocumentPlaceholderLinks((roundTexts[r] || '').trim(), metadata); if (txt) { const wrap = document.createElement('div'); @@ -2066,6 +2340,7 @@ export function addMessage(role, content, modelName, metadata) { box.appendChild(threadWrap); } for (const ev of roundTools) { + if (ev.ask_user) pendingAskUser = ev.ask_user; const ok = (ev.exit_code === 0 || ev.exit_code == null); let outHtml = ''; if (ev.output && ev.output.trim()) { @@ -2129,6 +2404,12 @@ export function addMessage(role, content, modelName, metadata) { box.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b)); } if (markdownModule.renderMermaid) markdownModule.renderMermaid(box); + if (pendingAskUser) { + // Session history is rendered oldest-to-newest. A later user message + // removes this card; if there is none, the pending choice survives a + // refresh. Avoid stealing focus while the history is loading. + renderAskUserCard(pendingAskUser, { focus: false, scroll: false }); + } return lastWrap; } @@ -2186,6 +2467,9 @@ export function addMessage(role, content, modelName, metadata) { b.className = 'body'; let text = markdownModule.squashOutsideCode(stripToolBlocks(textRaw || '')); + if (role === 'assistant') { + text = resolveDocumentPlaceholderLinks(text, metadata); + } // For user messages, pull out vision-model image descriptions ([Image: name]\n // ) into a collapsible "image description" section. Done for @@ -2211,8 +2495,8 @@ export function addMessage(role, content, modelName, metadata) { .trim(); } - wrap.dataset.raw = text; - if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id; + wrap.dataset.raw = text; + if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id; // Prepend sources box if saved in metadata var sourcesPrefix = ''; var findingsSuffix = ''; @@ -2235,9 +2519,10 @@ export function addMessage(role, content, modelName, metadata) { '' + metadata.thinking + '\n\n' + text ); b.innerHTML = sourcesPrefix + thinkHtml + findingsSuffix; - } else { - b.innerHTML = sourcesPrefix + markdownModule.processWithThinking(text) + findingsSuffix; - } + } else { + b.innerHTML = sourcesPrefix + markdownModule.processWithThinking(text) + findingsSuffix; + } + b.dataset.raw = text; // The vision/OCR caption is stripped from the displayed text above (so the // bubble doesn't show the raw model output) but no longer rendered as an @@ -2461,6 +2746,8 @@ const chatRenderer = { copyMessageText, safeToolScreenshotSrc, safeDisplayImageSrc, + removeAskUserCards, + renderAskUserCard, buildSourcesBox, buildFindingsBox, appendReportButton, @@ -2470,6 +2757,7 @@ const chatRenderer = { createMsgFooter, displayMetrics, addMessage, + buildAttachCards, updateMessageAttachments, }; diff --git a/static/js/chatStream.js b/static/js/chatStream.js index fc62216ad2..7b117d7464 100644 --- a/static/js/chatStream.js +++ b/static/js/chatStream.js @@ -7,6 +7,7 @@ import Storage from './storage.js'; import themeModule from './theme.js'; import markdownModule from './markdown.js'; import sessionModule from './sessions.js'; +import documentModule from './document.js'; /** * Handle a ui_control SSE event — AI-driven UI manipulation. @@ -183,6 +184,27 @@ export function handleUIControl(uiData) { } } else if (uiEvent === 'open_email_reply' || uiData.ui_event === 'open_email_reply') { + try { + var activeCtx = documentModule && documentModule.getActiveEmailComposerContext + ? documentModule.getActiveEmailComposerContext() + : null; + var sameActiveDraft = activeCtx + && String(activeCtx.sourceUid || '') === String(uiData.uid || '') + && String(activeCtx.sourceFolder || 'INBOX') === String(uiData.folder || 'INBOX'); + var existingDocId = sameActiveDraft && activeCtx.docId + ? activeCtx.docId + : (documentModule && documentModule.findEmailDocId + ? documentModule.findEmailDocId(uiData.uid, uiData.folder || 'INBOX') + : null); + if (existingDocId && documentModule.replaceEmailReplyBody) { + if (documentModule.loadDocument) documentModule.loadDocument(existingDocId); + documentModule.replaceEmailReplyBody(existingDocId, uiData.body || '', { force: true }); + if (uiModule && uiModule.showToast) uiModule.showToast('Wrote reply into the open email'); + return; + } + } catch (e) { + console.warn('open_email_reply existing draft update failed:', e); + } import('./emailInbox.js').then(function(mod) { var fn = mod.openReplyDraft || (mod.default && mod.default.openReplyDraft); if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply', uiData.body || ''); diff --git a/static/js/compare/index.js b/static/js/compare/index.js index c8b4d8f3aa..88b23e36b6 100644 --- a/static/js/compare/index.js +++ b/static/js/compare/index.js @@ -39,6 +39,7 @@ import spinnerModule from '../spinner.js'; import themeModule from '../theme.js'; import presetsModule from '../presets.js'; import markdownModule from '../markdown.js'; +import { bindMenuDismiss } from '../escMenuStack.js'; var escapeHtml = uiModule.esc; @@ -1062,6 +1063,7 @@ function _buildComparisonMarkdown() { } let _exportMenuEl = null; +let _closeExportMenu = () => {}; function _toggleExportMenu(btn) { if (_exportMenuEl) { _closeExportMenu(); return; } const r = btn.getBoundingClientRect(); @@ -1085,10 +1087,9 @@ function _toggleExportMenu(btn) { } document.body.appendChild(m); _exportMenuEl = m; - setTimeout(() => document.addEventListener('click', _closeExportMenu, { once: true }), 0); -} -function _closeExportMenu() { - if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; } + _closeExportMenu = bindMenuDismiss(m, () => { + if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; } + }, (ev) => !m.contains(ev.target)); } async function _exportCopyMarkdown(_btn) { diff --git a/static/js/cookbook-deps-recipes.js b/static/js/cookbook-deps-recipes.js index ba4f1b4444..47c3dbf62a 100644 --- a/static/js/cookbook-deps-recipes.js +++ b/static/js/cookbook-deps-recipes.js @@ -49,6 +49,16 @@ const _RECIPES = [ }, }, + // ── MLX ─────────────────────────────────────────────────────────────── + { + backend: 'mlx_lm', + label: 'Any MLX model', + match: () => true, + variants: { + pip: { commands: ['uv pip install -U mlx-lm'] }, + }, + }, + // ── llama.cpp ───────────────────────────────────────────────────────── { backend: 'llama_cpp', @@ -75,7 +85,7 @@ export function recipeCommands(recipe, variant) { // Backends we surface a recipe panel for. Other rows in the Dependencies // list keep the existing flat Install/Reinstall button without an expand // affordance. -export const RECIPE_BACKENDS = new Set(['vllm', 'sglang', 'llama_cpp']); +export const RECIPE_BACKENDS = new Set(['vllm', 'sglang', 'mlx_lm', 'llama_cpp']); // All recipe entries for a given backend, in catalog order. The first one // is the model-specific match (when present); the last is always the diff --git a/static/js/cookbook-diagnosis.js b/static/js/cookbook-diagnosis.js index a8bb314197..1d02813fad 100644 --- a/static/js/cookbook-diagnosis.js +++ b/static/js/cookbook-diagnosis.js @@ -149,6 +149,118 @@ function _openCpuServeEdit(panel) { }); } +function _taskForDiagnosisPanel(panel) { + const taskEl = panel?.closest?.('.cookbook-task'); + const taskId = taskEl?.dataset?.taskId || ''; + if (!taskId) return null; + return (_loadTasks() || []).find(t => t.sessionId === taskId) || null; +} + +function _pythonFromServeCmd(cmd) { + const s = String(cmd || ''); + const abs = s.match(/(?:^|\s)(\/[^\s]+\/bin\/python3?)(?=\s+-m\s+(?:sglang\.launch_server|mlx_lm\.server))/); + if (abs) return abs[1]; + const rel = s.match(/(?:^|\s)(python3?)(?=\s+-m\s+(?:sglang\.launch_server|mlx_lm\.server))/); + return rel ? rel[1] : ''; +} + +function _pythonForDiagnosisPanel(panel) { + const task = _taskForDiagnosisPanel(panel); + const fromCmd = _pythonFromServeCmd(task?.payload?._cmd || ''); + if (fromCmd) return fromCmd; + return (_envState.env === 'venv' && _envState.envPath) + ? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` + : 'python3'; +} + +function _sglangKernelRepairCommand(panel) { + return `${_pythonForDiagnosisPanel(panel)} -m pip install -U --force-reinstall --no-cache-dir sglang-kernel`; +} + +function _mlxLmInstallCommand(panel) { + return `${_pythonForDiagnosisPanel(panel)} -m pip install -U mlx-lm`; +} + +async function _repairSglangKernel(panel) { + const task = _taskForDiagnosisPanel(panel); + uiModule.showToast('Repairing sglang-kernel on the selected server...'); + await _launchServeTask( + 'repair-sglang-kernel', + 'pip-update', + _sglangKernelRepairCommand(panel), + null, + task?.remoteHost || undefined, + task ? { + serverKey: task.remoteServerKey || task.remoteHost || '', + serverName: task.remoteServerName || task.remoteHost || '', + } : null, + ); +} + +async function _installMlxLm(panel) { + const task = _taskForDiagnosisPanel(panel); + uiModule.showToast('Installing MLX LM on the selected server...'); + await _launchServeTask( + 'install-mlx-lm', + 'pip-update', + _mlxLmInstallCommand(panel), + null, + task?.remoteHost || undefined, + _diagnosisTargetMeta(task), + ); +} + +function _diagnosisTargetMeta(task) { + return task ? { + serverKey: task.remoteServerKey || task.remoteHost || '', + serverName: task.remoteServerName || task.remoteHost || '', + } : null; +} + +function _gpuCleanupCommand() { + return `set -u +echo "[odysseus] Clearing GPU compute processes..." +if command -v nvidia-smi >/dev/null 2>&1; then + pids="$(nvidia-smi --query-compute-apps=pid --format=csv,noheader,nounits 2>/dev/null | tr -d " " | grep -E "^[0-9]+$" | sort -u)" + if [ -z "$pids" ]; then + echo "[odysseus] No NVIDIA compute processes found." + exit 0 + fi + echo "[odysseus] GPU PIDs: $pids" + ps -fp $pids 2>/dev/null || true + echo "[odysseus] Sending TERM..." + kill -TERM $pids || true + sleep 3 + alive="" + for pid in $pids; do + if kill -0 "$pid" 2>/dev/null; then alive="$alive $pid"; fi + done + if [ -n "$alive" ]; then + echo "[odysseus] Force killing remaining GPU PIDs:$alive" + kill -KILL $alive || true + fi + sleep 1 + remaining="$(nvidia-smi --query-compute-apps=pid,process_name,used_memory --format=csv,noheader,nounits 2>/dev/null | sed "/^$/d" || true)" + if [ -n "$remaining" ]; then + echo "[odysseus] GPU processes still remain:" + echo "$remaining" + exit 2 + fi + echo "[odysseus] GPU cleanup complete. No NVIDIA compute processes remain." +else + echo "[odysseus] nvidia-smi not found; falling back to common model-server process cleanup." + pkill -TERM -f "sglang.launch_server|vllm|llama-server|text-generation-launcher|aphrodite" || true + sleep 3 + pkill -KILL -f "sglang.launch_server|vllm|llama-server|text-generation-launcher|aphrodite" || true + echo "[odysseus] Fallback cleanup complete." +fi`; +} + +async function _clearGpuProcesses(panel) { + uiModule.showToast('Clearing GPU compute processes on the selected server...'); + await _runQuickCmd(panel, _gpuCleanupCommand()); +} + // Infer the gated base repo that single-file checkpoints need configs from function _inferBaseRepo(text) { if (!text) return null; @@ -161,6 +273,25 @@ function _inferBaseRepo(text) { } export const ERROR_PATTERNS = [ + { + pattern: /tmux is required|tmux.*not found|tmux:\s*command not found|command not found:\s*tmux|No such file or directory:\s*['"]?tmux/i, + message: 'tmux is missing on this server.', + suggestion: 'Suggested action: open Dependencies and install tmux on the selected server.', + fixes: [ + { label: 'Open tmux dependency', action: () => _openCookbookDependencies('tmux') }, + { label: 'Copy apt install', action: () => _copyText('sudo apt install -y tmux') }, + { label: 'Copy pacman install', action: () => _copyText('sudo pacman -S --needed tmux') }, + ], + }, + { + pattern: /Port \d+ is already serving|port is occupied by a different model|choose another port before launching/i, + message: 'Serve port is already occupied by another model.', + suggestion: 'Suggested action: stop the old server or choose a different port before relaunching.', + fixes: [ + { label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) }, + { label: 'Copy check command', action: () => _copyText('curl http://127.0.0.1:PORT/v1/models') }, + ], + }, { pattern: /No available memory for the cache blocks|Available KV cache memory:.*-/i, message: 'No GPU memory left for KV cache after loading model.', @@ -179,6 +310,39 @@ export const ERROR_PATTERNS = [ { label: 'Retry with --max-num-seqs 32', action: (panel) => _serveAutoRetry(panel, '--max-num-seqs 32') }, ], }, + { + pattern: /Loaded weights leave no GPU memory for the KV cache under --mem-fraction-static|Raise --mem-fraction-static above/i, + message: 'SGLang static memory fraction is too low for the loaded weights.', + suggestion: 'Suggested action: retry with --mem-fraction-static 0.80 so weights fit and KV cache can still allocate.', + fixes: [ + { label: 'Retry mem 0.80', action: (panel) => _serveAutoRetryReplace(panel, '--mem-fraction-static', '0.80') }, + { label: 'Retry mem 0.82', action: (panel) => _serveAutoRetryReplace(panel, '--mem-fraction-static', '0.82') }, + { label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) }, + ], + }, + { + pattern: /get_paged_mqa_logits_metadata|deepseek_v4_backend\.py|paged_mqa_metadata\.cuh:113.*CUDA error:\s*invalid argument/i, + message: 'SGLang DeepSeek-V4 attention metadata kernel failed on this GPU/runtime.', + suggestion: 'Suggested action: stop retrying graph/memory tweaks for this exact FP8 command. SGLang’s RTX PRO 6000 recipe uses the original deepseek-ai/DeepSeek-V4-Flash checkpoint with --moe-runner-backend marlin, not the converted sgl-project FP8 checkpoint. Try that recipe/checkpoint, official SGLang container/nightly, or supported Hopper/Blackwell hardware.', + fixes: [ + { label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) }, + { label: 'Copy error', action: (panel) => { + const task = panel.closest('.cookbook-task'); + const text = task?.querySelector('.cookbook-task-output')?.textContent || task?.textContent || ''; + _copyText(text.trim()); + } }, + ], + }, + { + pattern: /Capture cuda graph failed|cuda graph failed|paged_mqa_metadata|cuda-graph-backend-decode|cuda-graph-max-bs-decode|CUDA error:\s*invalid argument/i, + message: 'SGLang failed while capturing decode CUDA graphs.', + suggestion: 'Suggested action: disable SGLang decode CUDA graph for this launch. DeepSeek-V4 is reaching graph capture, but this kernel is failing on the target hardware.', + fixes: [ + { label: 'Disable decode graph', action: (panel) => _serveAutoRetryReplace(panel, '--cuda-graph-backend-decode', 'disabled') }, + { label: 'Retry mem 0.80', action: (panel) => _serveAutoRetryReplace(panel, '--mem-fraction-static', '0.80') }, + { label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) }, + ], + }, { pattern: /CUDA out of memory|torch\.cuda\.OutOfMemoryError|CUDA error: out of memory/i, message: 'GPU ran out of memory. Try more GPUs (higher TP) or lower context.', @@ -334,6 +498,18 @@ export const ERROR_PATTERNS = [ { label: 'Enable enforce eager', action: (panel) => _setPanelCheckbox(panel, 'enforce_eager', true) }, ], }, + { + pattern: /memory capacity is unbalanced|Some GPUs may be occupied by other processes|pre_model_load_memory=.*local_gpu_memory/i, + message: 'SGLang refused to start because free GPU memory is uneven across the selected tensor-parallel GPUs.', + suggestion: 'Suggested action: run Clear GPUs, then relaunch. If it still fails, choose only equally free GPUs or lower TP/context.', + fixes: [ + { label: 'Clear GPUs', action: (panel) => _clearGpuProcesses(panel) }, + { label: 'Copy clear command', action: () => _copyText(_gpuCleanupCommand()) }, + { label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) }, + { label: 'Set TP to 1', action: (panel) => _setPanelField(panel, 'tp', '1') }, + { label: 'Lower context', action: (panel) => _setPanelField(panel, 'ctx', '32768') }, + ], + }, { pattern: /KV cache.*too (small|large)|max_model_len.*exceeds|maximum.*context/i, message: 'Context length too large for available GPU memory.', @@ -355,11 +531,14 @@ export const ERROR_PATTERNS = [ ], }, { - pattern: /sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops)|(Python\.h|libnuma\.so\.1|common_ops)[\s\S]*sgl_kernel|Please ensure sgl_kernel is properly installed/i, - message: 'SGLang native dependencies are missing on this server.', + pattern: /sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)|(?:Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)[\s\S]*sgl_kernel|Could not load any common_ops library|Please ensure sgl_kernel is properly installed/i, + message: 'SGLang native kernel/runtime is missing or mismatched on this server.', + suggestion: 'Suggested action: relaunch with Odysseus’ venv CUDA library path fix. If the venv does not contain the matching NVIDIA runtime libs, run Repair sglang-kernel.', fixes: [ + { label: 'Edit / relaunch serve', action: (panel) => _openServeEditFromDiagnosis(panel) }, + { label: 'Repair sglang-kernel', action: (panel) => _repairSglangKernel(panel) }, + { label: 'Copy repair command', action: (panel) => _copyText(_sglangKernelRepairCommand(panel)) }, { label: 'Copy OS package command', action: () => _copyText('sudo apt-get install -y libnuma-dev python3.12-dev build-essential') }, - { label: 'Copy kernel upgrade', action: () => _copyText('python3 -m pip install --upgrade sglang-kernel') }, { label: 'Open Dependencies', action: () => _openCookbookDependencies('sglang') }, ], }, @@ -371,6 +550,30 @@ export const ERROR_PATTERNS = [ { label: 'Copy install command', action: () => _copyText('python3 -m pip install "sglang[all]"') }, ], }, + { + pattern: /No module named ['"]?mlx_lm|mlx_lm.*command not found|MLX is not installed|MLX LM is not installed/i, + message: 'MLX LM is not installed on this server.', + suggestion: 'Suggested action: install mlx-lm in the selected Python environment. MLX serving is intended for Apple Silicon Macs.', + fixes: [ + { label: 'Install MLX LM', action: (panel) => _installMlxLm(panel) }, + { label: 'Open Dependencies', action: () => _openCookbookDependencies('mlx_lm') }, + { label: 'Copy install command', action: () => _copyText('python3 -m pip install -U mlx-lm') }, + ], + }, + { + pattern: /Unable to quantize model of type |QuantizedSwitchLinear/i, + message: 'MLX-LM tried to quantize an already-quantized DeepSeek switch layer.', + suggestion: 'Suggested action: relaunch from the cached local snapshot path. Odysseus now rewrites MLX repo-id launches to the newest local Hugging Face snapshot when it exists on the selected Mac.', + fixes: [ + { label: 'Edit / relaunch serve', action: (panel) => _openServeEditFromDiagnosis(panel) }, + { label: 'Open Dependencies', action: () => _openCookbookDependencies('mlx_lm') }, + { label: 'Copy error', action: (panel) => { + const task = panel.closest('.cookbook-task'); + const text = task?.querySelector('.cookbook-task-output')?.textContent || task?.textContent || ''; + _copyText(text.trim()); + } }, + ], + }, { pattern: /No accelerator \(CUDA, XPU, HPU, NPU, MUSA, MPS\) is available|Triton is not supported on current platform/i, message: 'SGLang needs a visible GPU/accelerator on this server.', @@ -834,22 +1037,38 @@ export function _clearDiagnosis(panel) { // ── Quick command ── export async function _runQuickCmd(panel, cmd) { + const task = _taskForDiagnosisPanel(panel); let fullCmd = cmd; - if (_envState.remoteHost) { - fullCmd = _sshCmd(_envState.remoteHost, cmd); + const host = task?.remoteHost || _envState.remoteHost || ''; + const port = task?.sshPort || task?.payload?.ssh_port || _envState.sshPort || ''; + if (host) { + fullCmd = _sshCmd(host, cmd, port); } const diag = panel.querySelector('.cookbook-diagnosis'); - if (diag) { diag.classList.remove('hidden'); diag.textContent = `Running: ${fullCmd}...`; } + if (diag) { + diag.classList.remove('hidden'); + diag.innerHTML = '
Running command...
'; + } try { - const res = await fetch('/api/shell/stream', { + const res = await fetch('/api/shell/exec', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ command: fullCmd }), + body: JSON.stringify({ command: fullCmd, timeout: 60 }), }); - if (diag) diag.textContent = res.ok ? `Done: ${cmd}` : `Failed (HTTP ${res.status})`; + const data = await res.json().catch(() => ({})); + const out = [data.stdout, data.stderr].filter(Boolean).join('\n').trim(); + const ok = res.ok && Number(data.exit_code ?? 1) === 0; + if (diag) { + diag.innerHTML = '' + + `
${ok ? 'Command completed.' : 'Command failed.'}
` + + `
Exit code: ${_diagEsc(data.exit_code ?? 'unknown')}
` + + (out ? `
${_diagEsc(out)}
` : ''); + } } catch (e) { - if (diag) diag.textContent = `Error: ${e.message}`; + if (diag) { + diag.innerHTML = `
Command error.
${_diagEsc(e.message)}
`; + } } } diff --git a/static/js/cookbook-hwfit.js b/static/js/cookbook-hwfit.js index ed2b299401..3636b0dc58 100644 --- a/static/js/cookbook-hwfit.js +++ b/static/js/cookbook-hwfit.js @@ -24,6 +24,9 @@ import { _MODELDIR_CHECK_ON, _MODELDIR_CHECK_OFF, _serverEntryHtml, + _serverDefaultHtml, + _applyServerSelectColor, + _syncServerSelectColors, _copyText, // Import cookbook.js WITHOUT a ?v= query — the same plain specifier every other // importer uses. A query mismatch loads cookbook.js twice as two separate modules @@ -31,13 +34,62 @@ import { } from './cookbook.js'; import uiModule from './ui.js'; import spinnerModule from './spinner.js'; -import { _loadTasks, _tmuxGracefulKill } from './cookbookRunning.js'; +import { _loadTasks, _tmuxGracefulKill, _nextAvailablePort, _taskPort } from './cookbookRunning.js'; import { openCookbookDependencies } from './cookbook-diagnosis.js'; -// Map a serve-backend code (vllm / sglang / llamacpp) → the package name +// Map a serve-backend code (vllm / sglang / llamacpp / mlx) → the package name // the Dependencies API reports. Used to look up "is this backend installed // on the target server" before firing a launch. -const _BACKEND_PKG = { vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp' }; +const _BACKEND_PKG = { vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', mlx: 'mlx_lm' }; + +function _normalizeCookbookModelDir(dir) { + const d = String(dir || '').replaceAll('\u2715', '').replaceAll('\u2716', '').trim(); + return /^(home|mnt|media|data|opt|srv|var)\//.test(d) ? `/${d}` : d; +} + +function _wireServerColorPicker(entry) { + const wrap = entry.querySelector('.cookbook-srv-color-wrap'); + const select = entry.querySelector('.cookbook-srv-color'); + const btn = entry.querySelector('.cookbook-srv-color-btn'); + const menu = entry.querySelector('.cookbook-srv-color-menu'); + if (!wrap || !select || !btn || !menu || btn.dataset.bound) return; + btn.dataset.bound = '1'; + const close = () => { + menu.classList.add('hidden'); + btn.setAttribute('aria-expanded', 'false'); + }; + const open = () => { + document.querySelectorAll('.cookbook-srv-color-menu').forEach(m => { + if (m !== menu) m.classList.add('hidden'); + }); + menu.classList.remove('hidden'); + btn.setAttribute('aria-expanded', 'true'); + }; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + if (menu.classList.contains('hidden')) open(); + else close(); + }); + menu.querySelectorAll('.cookbook-srv-color-item').forEach(item => { + item.addEventListener('click', (e) => { + e.stopPropagation(); + const color = item.dataset.color || ''; + select.value = color; + const label = item.querySelector('span:last-child')?.textContent || 'Auto'; + const labelEl = btn.querySelector('.cookbook-srv-color-label'); + if (labelEl) labelEl.textContent = label; + const swatch = item.style.getPropertyValue('--swatch-color') || color; + if (/^#[0-9a-fA-F]{6}$/.test(swatch.trim())) { + entry.style.setProperty('--cookbook-server-color', swatch.trim()); + wrap.style.setProperty('--cookbook-server-color', swatch.trim()); + } + menu.querySelectorAll('.cookbook-srv-color-item').forEach(b => b.classList.toggle('active', b === item)); + close(); + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + }); + document.addEventListener('click', close); +} // Pre-launch: ask the deps API whether the chosen backend is present on // the target server. Returns true if it's good to go, false if we should @@ -241,8 +293,7 @@ export function _renderGpuToggles(system) { container._activeCount = undefined; // default to the new pool's max delete container.dataset.rendered; // force a count-button rebuild _renderGpuToggles(system); - _hwfitCache = null; - _hwfitFetch(); + _hwfitFetch(false, { keepPrevious: true, forceRevalidate: true }); }); } @@ -274,8 +325,7 @@ export function _renderGpuToggles(system) { } } } - _hwfitCache = null; - _hwfitFetch(); + _hwfitFetch(false, { keepPrevious: true, forceRevalidate: true }); }); } } @@ -408,15 +458,12 @@ function _manualDisplaySystem(sys, manual) { // Signature of everything that affects the result list, so we never paint a // cached list under mismatched filters. function _scanSig() { - const sortEl = document.getElementById('hwfit-sort'); const tc = document.getElementById('hwfit-gpu-toggles'); return JSON.stringify({ h: _envState.remoteHost || '', hk: _currentServerValue(), u: document.getElementById('hwfit-usecase')?.value || '', s: document.getElementById('hwfit-search')?.value?.trim() || '', - o: sortEl?.value || 'newest', - r: sortEl?.dataset.reverse === '1' ? 1 : 0, q: document.getElementById('hwfit-quant')?.value || '', c: _ctxValue(), g: (tc && typeof tc._activeCount === 'number') ? String(tc._activeCount) : '', @@ -435,6 +482,27 @@ function _readScanCache(sig) { return null; } +function _readNearestScanCache(sig) { + try { + const wanted = JSON.parse(sig || '{}'); + const all = JSON.parse(localStorage.getItem(_SCAN_CACHE_KEY) || '{}'); + let best = null; + for (const [key, entry] of Object.entries(all)) { + if (!entry || !entry.data || (Date.now() - (entry.ts || 0)) >= _SCAN_CACHE_TTL) continue; + let parsed = null; + try { parsed = JSON.parse(key); } catch { continue; } + if (!parsed) continue; + if ((parsed.h || '') !== (wanted.h || '')) continue; + if ((parsed.hk || '') !== (wanted.hk || '')) continue; + if (JSON.stringify(parsed.m || {}) !== JSON.stringify(wanted.m || {})) continue; + if (JSON.stringify(parsed.d || []) !== JSON.stringify(wanted.d || [])) continue; + if (!best || (entry.ts || 0) > (best.ts || 0)) best = entry; + } + return best?.data || null; + } catch {} + return null; +} + function _writeScanCache(sig, data) { try { const all = JSON.parse(localStorage.getItem(_SCAN_CACHE_KEY) || '{}'); @@ -468,7 +536,7 @@ function _hwfitShowError(list, host, detail) { if (rb) rb.addEventListener('click', () => { _resetGpuToggleState(); _hwfitFetch(true); }); } -// Client-side "Engine" filter (llama.cpp / vLLM / SGLang / Ollama). Empty = +// Client-side "Engine" filter (llama.cpp / vLLM / SGLang / Ollama / Diffusers). Empty = // show all. Uses the same _detectBackend() the serve commands use, so what you // filter to is exactly what would be launched. Pure view filter — no refetch // needed. Ollama rows are merged into the main list (see _ensureOllamaLib + @@ -514,6 +582,13 @@ function _olParseSize(s) { function _ollamaToHwfitRows(libModels, vramAvail, ramAvail) { const out = []; if (!Array.isArray(libModels)) return out; + const _ramFitLevel = (need, budget) => { + if (!need || !budget || need > budget) return 'too_tight'; + const ratio = need / budget; + if (ratio <= 0.50) return 'perfect'; + if (ratio <= 0.78) return 'good'; + return 'marginal'; + }; for (const m of libModels) { const sizes = (Array.isArray(m.sizes) && m.sizes.length) ? m.sizes : ['latest']; for (const sz of sizes) { @@ -524,10 +599,10 @@ function _ollamaToHwfitRows(libModels, vramAvail, ramAvail) { if (vramGb && vramAvail) { if (vramGb <= vramAvail * 0.6) fitLevel = 'perfect'; else if (vramGb <= vramAvail) fitLevel = 'good'; - else if (ramAvail && vramGb <= ramAvail) fitLevel = 'marginal'; + else if (ramAvail && vramGb <= ramAvail) fitLevel = _ramFitLevel(vramGb, ramAvail); else fitLevel = 'too_tight'; } else if (vramGb && ramAvail && vramGb <= ramAvail) { - fitLevel = 'marginal'; + fitLevel = _ramFitLevel(vramGb, ramAvail); } const tag = `${m.name}:${sz}`; const paramsLabel = params @@ -561,8 +636,11 @@ function _ollamaToHwfitRows(libModels, vramAvail, ramAvail) { return out; } -export async function _hwfitFetch(fresh = false) { +export async function _hwfitFetch(fresh = false, opts = {}) { const _tk = ++_hwfitFetchToken; + const allowNetwork = fresh || opts.allowNetwork !== false; + const keepPrevious = !!opts.keepPrevious; + const forceRevalidate = !!opts.forceRevalidate; const useCase = document.getElementById('hwfit-usecase')?.value || ''; const search = document.getElementById('hwfit-search')?.value?.trim() || ''; const remoteHost = _envState.remoteHost || ''; @@ -575,8 +653,12 @@ export async function _hwfitFetch(fresh = false) { // reload shows the last result with no spinner. We still fetch fresh below and // swap it in. If there's no cache hit, fall back to the spinner. const _sig = _scanSig(); - const _cached = fresh ? null : _readScanCache(_sig); + let _cached = fresh ? null : _readScanCache(_sig); + if (!_cached && !fresh && (!allowNetwork || keepPrevious)) { + _cached = _readNearestScanCache(_sig); + } const wp = spinnerModule.createWhirlpool(18); + const _paintedFromCache = !!_cached; if (_cached) { // Tag the restored cache with its host too (scan-sig keys cache per // host, so a hit here is always for the current remoteHost). @@ -587,28 +669,63 @@ export async function _hwfitFetch(fresh = false) { } _hwfitRenderList(list, _applyEngineFilter(_cached.models)); } else { - // Show spinner while scanning — stack the spinner above a text label - // (the .hwfit-loading class is a centered flex ROW, so force column here). - const loadingDiv = document.createElement('div'); - loadingDiv.className = 'hwfit-loading'; - loadingDiv.style.flexDirection = 'column'; - loadingDiv.style.gap = '6px'; - loadingDiv.appendChild(wp.element); - // Text label like the other cookbook tabs: "Loading…", then if the scan runs - // long (remote SSH hardware probe), switch to "Scanning hardware…". - const loadingLbl = document.createElement('div'); - loadingLbl.textContent = 'Loading…'; - loadingLbl.style.cssText = 'text-align:center;opacity:0.5;font-size:11px;'; - loadingDiv.appendChild(loadingLbl); - setTimeout(() => { if (loadingLbl.isConnected) loadingLbl.textContent = 'Scanning hardware…'; }, 2000); - list.innerHTML = ''; - list.appendChild(loadingDiv); - _hwfitCache = null; // no instant paint — clear until the fetch returns + const canKeepPrevious = keepPrevious && _hwfitCache && Array.isArray(_hwfitCache.models); + if (canKeepPrevious) { + try { wp.destroy(); } catch {} + } else if (!allowNetwork) { + _hwfitCache = null; + _hwfitRenderHw(hw, null); + const loadingDiv = document.createElement('div'); + loadingDiv.className = 'hwfit-loading'; + loadingDiv.style.cssText = 'flex-direction:column;gap:6px;text-align:center;'; + loadingDiv.appendChild(wp.element); + const loadingTitle = document.createElement('div'); + loadingTitle.textContent = 'No cached scan yet'; + loadingTitle.style.cssText = 'font-size:12px;opacity:0.7;'; + const loadingLbl = document.createElement('div'); + loadingLbl.textContent = 'Loading model list…'; + loadingLbl.style.cssText = 'font-size:11px;opacity:0.55;max-width:420px;line-height:1.4;'; + loadingDiv.appendChild(loadingTitle); + loadingDiv.appendChild(loadingLbl); + list.innerHTML = ''; + list.appendChild(loadingDiv); + setTimeout(() => { + if (_tk === _hwfitFetchToken) { + _resetGpuToggleState(); + _hwfitFetch(true, { autoFromEmpty: true }); + } + }, 60); + return; + } + if (!canKeepPrevious) { + // Show spinner while scanning — stack the spinner above a text label + // (the .hwfit-loading class is a centered flex ROW, so force column here). + const loadingDiv = document.createElement('div'); + loadingDiv.className = 'hwfit-loading'; + loadingDiv.style.flexDirection = 'column'; + loadingDiv.style.gap = '6px'; + loadingDiv.appendChild(wp.element); + // Text label like the other cookbook tabs. Only fresh rescans are hardware + // probes; normal refreshes are just model ranking/loading from cached hw. + const loadingLbl = document.createElement('div'); + loadingLbl.textContent = fresh ? 'Scanning hardware…' : 'Loading models…'; + loadingLbl.style.cssText = 'text-align:center;opacity:0.5;font-size:11px;'; + loadingDiv.appendChild(loadingLbl); + setTimeout(() => { + if (loadingLbl.isConnected) loadingLbl.textContent = fresh ? 'Scanning hardware…' : 'Loading model list…'; + }, 2000); + list.innerHTML = ''; + list.appendChild(loadingDiv); + _hwfitCache = null; // no instant paint — clear until the fetch returns + } + } + if (!allowNetwork) { + try { wp.destroy(); } catch {} + return; } // Only fetch cached model IDs when server changes, not on every search/sort const remoteKey = _currentServerValue(); if (!_cachedModelIds || _lastCacheHost() !== remoteKey) { - _setLastCacheHost(remoteKey); const _cacheSrv = _serverByVal(_envState.remoteServerKey || remoteHost); const _cachePort = _cacheSrv?.port || ''; const _cacheParams = new URLSearchParams(); @@ -620,9 +737,11 @@ export async function _hwfitFetch(fresh = false) { fetch(`/api/model/cached?${_cacheParams}`, { credentials: 'same-origin' }) .then(r => r.json()) .then(d => { + if (d && d.error) throw new Error(d.error); // Exclude stalled (download-shell) entries — a 12 KB README-only // folder shouldn't count as "downloaded" in the Scan/Download list. _cachedModelIds = new Set((d.models || []).filter(m => m.status !== 'stalled').map(m => m.repo_id)); + _setLastCacheHost(remoteKey); // Re-mark rows if already rendered list.querySelectorAll('.hwfit-row[data-model]').forEach(row => { const name = row.dataset.model; @@ -633,7 +752,10 @@ export async function _hwfitFetch(fresh = false) { } } }); - }).catch(() => {}); + }).catch((err) => { + console.warn('Cached model marker scan failed:', err); + _setLastCacheHost(''); + }); } try { const sortBy = document.getElementById('hwfit-sort')?.value || 'newest'; @@ -650,7 +772,10 @@ export async function _hwfitFetch(fresh = false) { if (!hasManualOrDismissed && toggleContainer && toggleContainer._activeGroup) { gpuGroupOverride = String(toggleContainer._activeGroup); } - const params = new URLSearchParams({ limit: '80', sort: sortBy }); + // Sorting is a table operation, not a different backend query. Fetch a + // broad candidate set once, then sort it client-side so VRAM/Params/etc. + // do not appear to "filter out" rows by returning a different top-80 slice. + const params = new URLSearchParams({ limit: '2500', sort: 'score' }); if (fresh) params.set('fresh', '1'); // bypass the hardware-scan cache if (search) params.set('search', search); if (remoteHost) { @@ -671,6 +796,9 @@ export async function _hwfitFetch(fresh = false) { if (hasManualOrDismissed) params.set('_hw_override_ts', String(Date.now())); // Image models use a separate registry/endpoint const isImageMode = useCase === 'image_gen'; + if ((fresh || (_paintedFromCache && !search)) && !isImageMode) { + params.set('refresh_catalog', '1'); // update HF-backed dynamic catalogs in the background + } if (!isImageMode) { if (useCase) params.set('use_case', useCase); if (quantPref) params.set('quant', quantPref); @@ -857,6 +985,8 @@ function _renderHwVisibilityWarning(sys) { box.querySelector('[data-hw-action="manual"]')?.addEventListener('click', () => { const panel = document.getElementById('hwfit-manual-panel'); if (panel) panel.classList.remove('hidden'); + const manualBtn = document.getElementById('hwfit-hw-manual-btn'); + if (manualBtn) manualBtn.textContent = 'CANCEL'; document.getElementById('hwfit-hw-manual-btn')?.scrollIntoView?.({ behavior: 'smooth', block: 'center', @@ -1021,6 +1151,8 @@ export function _hwfitRenderHw(el, sys) { _saveManualHwState(null); btn.closest('.hwfit-hw-chip-row')?.remove(); document.getElementById('hwfit-manual-panel')?.classList.add('hidden'); + const manualBtn = document.getElementById('hwfit-hw-manual-btn'); + if (manualBtn) manualBtn.textContent = 'EDIT'; _resetGpuToggleState(); _hwfitCache = null; _hwfitFetch(true); @@ -1041,16 +1173,20 @@ function _wireManualHardwareControls(el) { const btn = document.getElementById('hwfit-hw-manual-btn'); const panel = document.getElementById('hwfit-manual-panel'); if (!btn || !panel) return; + const syncManualButton = () => { + btn.textContent = panel.classList.contains('hidden') ? 'EDIT' : 'CANCEL'; + }; const clearManual = () => { _saveManualHwState(null); el.querySelector('.hwfit-hw-chip-manual')?.remove(); panel.classList.add('hidden'); + syncManualButton(); _resetGpuToggleState(); _hwfitCache = null; _hwfitFetch(true); }; const manual = _manualHwState(); - btn.textContent = 'EDIT'; + syncManualButton(); if (manual) { panel.querySelector('.hwfit-manual-mode').value = manual.mode || 'gpu'; panel.querySelector('.hwfit-manual-backend').value = manual.backend || 'cuda'; @@ -1066,11 +1202,13 @@ function _wireManualHardwareControls(el) { btn._hwfitManualBound = true; btn.addEventListener('click', () => { panel.classList.toggle('hidden'); + syncManualButton(); syncMode(); }); } el.querySelector('.hwfit-hw-chip-toggle[data-hw-chip="manual"]')?.addEventListener('click', () => { panel.classList.remove('hidden'); + syncManualButton(); syncMode(); }); if (!panel._hwfitManualBound) { @@ -1087,12 +1225,14 @@ function _wireManualHardwareControls(el) { _resetGpuToggleState(); _hwfitCache = null; panel.classList.add('hidden'); + syncManualButton(); _hwfitRenderHw(el, _manualDisplaySystem(window._hwfitSystemCache, manual)); _hwfitFetch(true); }); panel.querySelector('.hwfit-hw-manual-clear')?.addEventListener('click', clearManual); } syncMode(); + syncManualButton(); } export const _fitColors = { perfect: 'var(--green, #50fa7b)', good: 'var(--yellow, #f1fa8c)', marginal: 'var(--orange, #ffb86c)', too_tight: 'var(--red, #ff5555)' }; @@ -1114,9 +1254,9 @@ function _modeLabel(model) { export const _hwfitColumns = [ { key: 'fit', label: 'Fit', cls: 'hwfit-fit' }, { key: 'newest', label: 'Model (latest)', cls: 'hwfit-name' }, + { key: 'vram', label: 'VRAM', cls: 'hwfit-c-vram' }, { key: 'params',label: 'Param', cls: 'hwfit-c-params' }, { key: null, label: 'Quant', cls: 'hwfit-c-quant' }, - { key: 'vram', label: 'VRAM', cls: 'hwfit-c-vram' }, { key: 'context',label: 'Ctx', cls: 'hwfit-c-ctx' }, { key: 'speed', label: 'Speed', cls: 'hwfit-c-speed' }, { key: 'score', label: 'Score', cls: 'hwfit-c-score' }, @@ -1217,13 +1357,13 @@ export function _hwfitRenderList(el, models) { } } html += `${modelLogo(m.name)}${esc(_short)}${_quantSuffix}${moeBadge}${imgBadge}${dlDot}`; - html += `${esc(pcount)}`; + html += `${vramLabel}`; + html += `${esc(pcount)}`; // Truncate the Quant cell to 9 chars + ellipsis so long tags like // "FP4-MoE-Mixed" don't push neighboring columns. Full tag stays in title. const _qRaw = m.quant || '?'; const _qShort = _qRaw.length > 9 ? _qRaw.slice(0, 9) + '…' : _qRaw; html += `${esc(_qShort)}`; - html += `${vramLabel}`; html += `${m.is_image_gen ? '\u2014' : ctx}`; html += `${m.is_image_gen ? '\u2014' : tps + ' t/s'}`; html += `${score}`; @@ -1272,14 +1412,13 @@ export function _hwfitRenderList(el, models) { if (e.target.closest('[data-fit-dot]')) { const on = !e.target.classList.contains('active'); try { localStorage.setItem('hwfit_fit_only_v1', on ? '1' : '0'); } catch {} - // Un-toggling the fit filter (off → showing too-tight rows again) is - // typically because the user wants to see the LARGE models they can't - // run yet — re-sort by VRAM descending so the biggest surface first. + // Un-toggling the fit filter should still keep the list usable: show + // nearest/smallest VRAM first, not a wall of impossible 7000G rows. if (!on) { const sortSel = document.getElementById('hwfit-sort'); if (sortSel) { sortSel.value = 'vram'; - sortSel.dataset.reverse = '0'; // descending (biggest first) + sortSel.dataset.reverse = '1'; // ascending (smallest VRAM first) } } _hwfitCache = null; @@ -1295,7 +1434,9 @@ export function _hwfitRenderList(el, models) { sel.dataset.reverse = sel.dataset.reverse === '1' ? '0' : '1'; } else { sel.value = sortKey; - sel.dataset.reverse = '0'; + // VRAM is most useful as "what fits / closest fit first"; descending + // buries qwen/gemma-sized rows below absurd impossible footprints. + sel.dataset.reverse = sortKey === 'vram' ? '1' : '0'; } _hwfitFetch(); }); @@ -1493,36 +1634,34 @@ export function _expandModelRow(row, modelData) { } return; } + // Detect backend and port now — the pre-launch guard below needs them. + const _qrBackendDetect = _detectBackend(modelData); + const _qrRunBackend = _qrBackendDetect.backend || 'vllm'; + const _qrPort = _nextAvailablePort(); - // ─── Pre-launch: stop the model already serving on this host ─────── - // Two servers can't share port 8000. Without this, the new launch - // silently collided and the user saw no feedback. We surface the - // conflict and offer to kill the running one first as the default - // action (it's almost always what the user wants). + // ─── Pre-launch: stop colliding serves on the same port ─────── + // Different ports coexist fine (e.g. vLLM on 8000 + Qwen VL on + // 8001). Only block when the new model's port genuinely collides + // with a running serve. (Issue #4507) try { const _qrHostStr = _envState.remoteHost || ''; - const _activeServes = _loadTasks().filter(t => + const _allServes = _loadTasks().filter(t => t && t.type === 'serve' && (t.remoteHost || '') === _qrHostStr && (t.status === 'running' || t.status === 'ready' || t._serveReady) ); - if (_activeServes.length) { - const _names = _activeServes.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean); + const _clashing = _allServes.filter(t => _taskPort(t) === _qrPort); + if (_clashing.length) { + const _names = _clashing.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean); const _ok = await window.styledConfirm?.( - `${_names.length} model${_names.length === 1 ? '' : 's'} already serving on ${_qrHostStr || 'local'} (${_names.join(', ')}). Port 8000 will collide. Stop the running model and launch this one?`, + `${_clashing.length} model${_clashing.length === 1 ? '' : 's'} on port ${_qrPort} (${_names.join(', ')}). Stop it and launch this one?`, { confirmText: 'Stop & launch', cancelText: 'Cancel' } ); if (!_ok) return; - // Mark + kill each running serve, then wait briefly for the - // tmux session to actually go down before we kick off the new - // launch. Otherwise vLLM still races against the dying socket. quickRunBtn.disabled = true; quickRunBtn.textContent = 'Stopping…'; - for (const t of _activeServes) { + for (const t of _clashing) { try { - // Use that task's own Stop button if it's rendered (handles - // endpoint cleanup, Ollama unload, fade-out). Falls back to - // a direct tmux kill if the Active tab isn't in the DOM yet. const _taskEl = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`); const _stopBtn = _taskEl?.querySelector('.cookbook-task-action-stop'); if (_stopBtn) { @@ -1537,11 +1676,12 @@ export function _expandModelRow(row, modelData) { } } catch (_killErr) { /* best-effort */ } } - // Give the OS a beat to release port 8000. await new Promise(r => setTimeout(r, 2500)); } } catch (_e) { /* best-effort */ } + // -- Launch ─────────────────────────────────────────────────── + // ─── Pre-launch driver check ───────────────────────────────────── // vLLM/SGLang need a working CUDA/ROCm driver. nvidia-smi failures // surface as system.gpu_error from our hardware probe; "no GPU @@ -1550,8 +1690,6 @@ export function _expandModelRow(row, modelData) { // user watches `pip install vllm` finish, then sees a cryptic CUDA // error 10 minutes later. (llama.cpp / Ollama have CPU fallbacks // so they skip this gate.) - const _qrBackendDetect = _detectBackend(modelData); - const _qrRunBackend = _qrBackendDetect.backend || 'vllm'; if (_qrRunBackend === 'vllm' || _qrRunBackend === 'sglang') { const _sys = _hwfitCache?.system || {}; if (_sys.gpu_error) { @@ -1658,7 +1796,7 @@ export function _expandModelRow(row, modelData) { const host = _envState.remoteHost || ''; const hostIp = host.includes('@') ? host.split('@').pop() : host; - const port = '8000'; + const port = _qrPort; const detected = _detectBackend(modelData); const runBackend = detected.backend || 'vllm'; @@ -1670,10 +1808,13 @@ export function _expandModelRow(row, modelData) { cmd += ` --context-length ${maxCtx}`; cmd += ` --mem-fraction-static ${gpuUtil}`; cmd += ' --trust-remote-code'; + } else if (runBackend === 'mlx') { + const bindHost = host ? '0.0.0.0' : '127.0.0.1'; + cmd = `python3 -m mlx_lm.server --model ${_shellQuote(modelData.name)} --host ${bindHost} --port ${port}`; } else if (runBackend === 'llamacpp') { const dir = `"$HOME/.cache/huggingface/hub/models--${modelData.name.replace(/\//g, '--')}/snapshots"`; const ggufPath = `$({ find ${dir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${dir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`; - cmd = `llama-server --model "${ggufPath}" --host 0.0.0.0 --port 8080 -ngl 99 -c ${maxCtx} --flash-attn auto`; + cmd = `llama-server --model "${ggufPath}" --host 0.0.0.0 --port ${port} -ngl 99 -c ${maxCtx} --flash-attn auto`; } else { cmd = `vllm serve ${modelData.name} --host 0.0.0.0 --port ${port}`; cmd += ` --tensor-parallel-size ${tp}`; @@ -1783,6 +1924,84 @@ export function _expandModelRow(row, modelData) { } +const _HWFIT_ENGINE_GLYPHS = { + '': '', + vllm: '', + sglang: '', + mlx: '', + llamacpp: '', + ollama: '', + diffusers: '', +}; + +function _hwfitEngineGlyph(value) { + return _HWFIT_ENGINE_GLYPHS[value] || _HWFIT_ENGINE_GLYPHS['']; +} + +function _bindHwfitEnginePicker(engine) { + const wrap = engine?.closest('.hwfit-engine-wrap'); + const btn = wrap?.querySelector('[data-hwfit-engine-btn]'); + const menu = wrap?.querySelector('[data-hwfit-engine-menu]'); + const icon = wrap?.querySelector('[data-hwfit-engine-icon]'); + const label = wrap?.querySelector('[data-hwfit-engine-label]'); + if (!engine || !wrap || !btn || !menu || wrap.dataset.enginePickerBound) return; + wrap.dataset.enginePickerBound = '1'; + + const setOpen = (open) => { + menu.hidden = !open; + btn.setAttribute('aria-expanded', open ? 'true' : 'false'); + }; + const currentLabel = () => { + const opt = Array.from(engine.options).find((o) => o.value === engine.value); + return opt?.textContent || 'Engine'; + }; + const syncButton = () => { + if (label) label.textContent = currentLabel(); + if (icon) icon.innerHTML = _hwfitEngineGlyph(engine.value); + menu.querySelectorAll('[data-hwfit-engine-value]').forEach((item) => { + const active = item.dataset.hwfitEngineValue === engine.value; + item.classList.toggle('active', active); + item.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + }; + const renderMenu = () => { + menu.innerHTML = Array.from(engine.options).map((opt) => ( + `' + )).join(''); + menu.querySelectorAll('[data-hwfit-engine-value]').forEach((item) => { + item.addEventListener('click', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const next = item.dataset.hwfitEngineValue || ''; + if (engine.value !== next) { + engine.value = next; + engine.dispatchEvent(new Event('change', { bubbles: true })); + } + syncButton(); + setOpen(false); + }); + }); + syncButton(); + }; + + btn.addEventListener('click', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + setOpen(menu.hidden); + }); + engine.addEventListener('change', syncButton); + document.addEventListener('click', (ev) => { + if (!wrap.contains(ev.target)) setOpen(false); + }); + document.addEventListener('keydown', (ev) => { + if (ev.key === 'Escape') setOpen(false); + }); + renderMenu(); +} + export function _hwfitInit() { const uc = document.getElementById('hwfit-usecase'); const sort = document.getElementById('hwfit-sort'); @@ -1798,6 +2017,7 @@ export function _hwfitInit() { // Engine filter is a pure client-side view filter over the already-fetched // list (HF + Ollama merged), so just re-render from cache. const engine = document.getElementById('hwfit-engine'); + if (engine) _bindHwfitEnginePicker(engine); if (engine) engine.addEventListener('change', () => { const list = document.getElementById('hwfit-list'); if (list && _hwfitCache && Array.isArray(_hwfitCache.models)) { @@ -1881,15 +2101,22 @@ export function _hwfitInit() { ]; for (const sel of selectors) { if (!sel) continue; - const currentVal = sel.value; - let html = ``; + const currentVal = sel.value || _currentServerValue(); + const localSrv = _envState.servers.find(s => !s.host || String(s.host).toLowerCase() === 'local') || {}; + const localColor = /^#[0-9a-fA-F]{6}$/.test(String(localSrv.color || '').trim()) ? String(localSrv.color).trim() : ''; + const localLabel = localSrv.name || 'Local'; + let html = ``; _envState.servers.forEach((s, i) => { if (!s.host) return; const label = s.name || s.host || `Server ${i + 1}`; - html += ``; + const color = /^#[0-9a-fA-F]{6}$/.test(String(s.color || '').trim()) ? String(s.color).trim() : ''; + html += ``; }); sel.innerHTML = html; sel.value = currentVal; + if (sel.selectedIndex < 0) sel.value = _currentServerValue(); + if (sel.selectedIndex < 0) sel.value = 'local'; + _applyServerSelectColor(sel); } } @@ -1907,13 +2134,15 @@ export function _hwfitInit() { const port = row.querySelector('.cookbook-srv-port')?.value.trim() || ''; const env = row.querySelector('.cookbook-srv-env')?.value || 'none'; const envPath = row.querySelector('.cookbook-srv-path')?.value.trim() || ''; + const colorRaw = row.querySelector('.cookbook-srv-color')?.value?.trim() || ''; + const color = /^#[0-9a-fA-F]{6}$/.test(colorRaw) ? colorRaw : ''; // Collect model directories from tags. Read the authoritative data-dir // attribute, not textContent \u2014 the tag now also holds a download-target // icon, and textContent would fold the icon/\u2716 glyph into the path. const dirTags = entry.querySelectorAll('.cookbook-modeldir-tag'); const modelDirs = []; dirTags.forEach(tag => { - const d = (tag.dataset.dir || '').replaceAll('\u2715', '').replaceAll('\u2716', '').trim(); + const d = _normalizeCookbookModelDir(tag.dataset.dir || ''); if (d) modelDirs.push(d); }); if (!modelDirs.length) modelDirs.push('~/.cache/huggingface/hub'); @@ -1921,7 +2150,7 @@ export function _hwfitInit() { const dlEl = entry.querySelector('.cookbook-modeldir-dl.active'); const downloadDir = dlEl ? (dlEl.dataset.dlDir || '') : ''; const platform = entry.dataset.platform || ''; - _envState.servers.push({ name, host: host || '', port, env, envPath, modelDirs, modelDir: modelDirs.filter(d => d !== '~/.cache/huggingface/hub')[0] || modelDirs[0], downloadDir, platform }); + _envState.servers.push({ name, host: host || '', port, env, envPath, color, modelDirs, modelDir: modelDirs.filter(d => d !== '~/.cache/huggingface/hub')[0] || modelDirs[0], downloadDir, platform }); }); // Do NOT auto-change the selected host here. _syncServers can run while the // servers DOM is mid-render — host fields that are disabled/readonly read as @@ -1958,16 +2187,17 @@ export function _hwfitInit() { dot.className = 'cookbook-srv-status testing'; dot.title = 'Testing SSH…'; setMsg('Testing SSH...'); - const pf = port && port !== '22' ? `-p ${port} ` : ''; - const cmd = `ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new ${pf}${host} "echo ok"`; const t0 = Date.now(); try { - const res = await fetch('/api/shell/exec', { + const res = await fetch('/api/cookbook/test-ssh', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ command: cmd, timeout: 8 }), + body: JSON.stringify({ host, ssh_port: port || undefined }), }); const data = await res.json(); + if (!res.ok) { + throw new Error(data.detail || data.error || `HTTP ${res.status}`); + } const ms = Date.now() - t0; const out = (data.stdout || '').trim(); if (data.exit_code === 0 && out.startsWith('ok')) { @@ -1976,7 +2206,7 @@ export function _hwfitInit() { setMsg(`Connected · ${ms} ms`, 'var(--green,#50fa7b)'); } else { dot.className = 'cookbook-srv-status fail'; - const err = (data.stderr || data.stdout || `exit ${data.exit_code}`).toString().trim().slice(0, 240); + const err = (data.stderr || data.stdout || (data.exit_code == null ? 'no exit code' : `exit ${data.exit_code}`)).toString().trim().slice(0, 240); dot.title = `SSH failed: ${err}`; setMsg(`Failed · ${err}`, 'var(--red,#e06c75)'); } @@ -2099,8 +2329,7 @@ export function _hwfitInit() { document.querySelectorAll('.cookbook-srv-default').forEach(b => { const on = !!_envState.defaultServer && b.dataset.srvKey === _envState.defaultServer; b.classList.toggle('active', on); - // Keep the "default" label after the icon (don't overwrite it). - b.innerHTML = (on ? _MODELDIR_CHECK_ON : _MODELDIR_CHECK_OFF) + 'default'; + b.innerHTML = _serverDefaultHtml(on); b.title = on ? 'Default server — Cookbook opens here' : 'Make this the default server'; }); // Apply immediately so the dropdowns reflect it without reopening @@ -2147,10 +2376,28 @@ export function _hwfitInit() { uiModule.showToast('SSH setup command copied'); }); } + _wireServerColorPicker(entry); entry.querySelectorAll('input, select').forEach(el => { el.addEventListener('change', () => { const selectedBefore = _envState.remoteHost || ''; const entryHost = entry.querySelector('.cookbook-srv-host')?.value?.trim() || ''; + const color = entry.querySelector('.cookbook-srv-color')?.value?.trim() || ''; + const hasColor = /^#[0-9a-fA-F]{6}$/.test(color); + const colorWrap = entry.querySelector('.cookbook-srv-color-wrap'); + if (hasColor) { + entry.style.setProperty('--cookbook-server-color', color); + colorWrap?.style.setProperty('--cookbook-server-color', color); + } else { + const autoColor = (colorWrap?.style.getPropertyValue('--cookbook-server-color') || entry.style.getPropertyValue('--cookbook-server-color') || '').trim(); + if (/^#[0-9a-fA-F]{6}$/.test(autoColor)) { + entry.style.setProperty('--cookbook-server-color', autoColor); + colorWrap?.style.setProperty('--cookbook-server-color', autoColor); + } else { + entry.style.removeProperty('--cookbook-server-color'); + colorWrap?.style.removeProperty('--cookbook-server-color'); + } + } + colorWrap?.classList.toggle('has-color', true); _syncServers(); _rebuildServerSelect(); if (selectedBefore && selectedBefore === entryHost) { @@ -2160,17 +2407,19 @@ export function _hwfitInit() { if (!entry.querySelector('.cookbook-server-key-panel')?.classList.contains('hidden')) { _populateServerKeyPanel(entry, false); } + const saveBtn = entry.querySelector('.cookbook-server-save-btn.saved'); + if (saveBtn) { + saveBtn.classList.remove('saved'); + saveBtn.innerHTML = 'Save'; + } }); }); - // Auto-test when host or port blur + // Manual connectivity test after editing host or port. Existing saved + // servers are not auto-tested on panel open; unreachable hosts can stall the + // Cookbook UI and make opening the panel feel blocked. entry.querySelectorAll('.cookbook-srv-host, .cookbook-srv-port').forEach(el => { el.addEventListener('blur', () => _testServerConnection(entry)); }); - // Initial test for pre-filled rows (existing servers on tab load) - if (entry.querySelector('.cookbook-srv-host')?.value?.trim() && !entry.dataset.tested) { - entry.dataset.tested = '1'; - _testServerConnection(entry); - } // Cancel button on a brand-new server entry: discard it (no confirm — it's // unsaved) and re-sync so the dropped blank server doesn't linger. const cancelBtn = entry.querySelector('.cookbook-server-cancel-btn'); @@ -2184,7 +2433,7 @@ export function _hwfitInit() { _hwfitFetch(); }); } - // Save button on a brand-new server entry: persist + confirm with a check. + // Save button: persist + confirm with a check. const saveBtn = entry.querySelector('.cookbook-server-save-btn'); if (saveBtn && !saveBtn.dataset.bound) { saveBtn.dataset.bound = '1'; @@ -2202,6 +2451,7 @@ export function _hwfitInit() { } catch (_) {} saveBtn.classList.add('saved'); saveBtn.innerHTML = 'Saved'; + uiModule.showToast('Server saved'); }); } const rmBtn = entry.querySelector('.cookbook-server-rm'); @@ -2363,7 +2613,7 @@ export function _hwfitInit() { // Build the new entry with the SAME template as existing servers (Model // Directory header, default checkmark, platform icon) \u2014 isNew swaps the // delete button for a Save button. forceRemote keeps it editable. - const blank = { host: '', name: '', port: '', env: 'none', envPath: '', platform: '', modelDirs: ['~/.cache/huggingface/hub'] }; + const blank = { host: '', name: '', port: '', env: 'none', envPath: '', color: '', platform: '', modelDirs: ['~/.cache/huggingface/hub'] }; const wrap = document.createElement('div'); wrap.innerHTML = _serverEntryHtml(blank, idx, _envState.defaultServer || '', true, true); const entry = wrap.firstElementChild; @@ -2397,6 +2647,7 @@ export function _hwfitInit() { } } _persistEnvState(); + _applyServerSelectColor(serverSelect); // Keep the other server dropdowns (Download / Cache / Deps) in sync. The // download-input button reads #hwfit-dl-server *directly*, so without this // it kept its old value and downloads went to the wrong host even @@ -2404,6 +2655,7 @@ export function _hwfitInit() { document.querySelectorAll('#hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => { if (!sel || sel.tagName !== 'SELECT') return; sel.value = _currentServerValue(); + _applyServerSelectColor(sel); }); _hwfitCache = null; // Reset GPU-toggle state (no flicker) so the new server's hardware re-renders. @@ -2411,5 +2663,6 @@ export function _hwfitInit() { _hwfitFetch(); }); } + _syncServerSelectColors(); } diff --git a/static/js/cookbook.js b/static/js/cookbook.js index 43a3ad5d02..e209f76ae0 100644 --- a/static/js/cookbook.js +++ b/static/js/cookbook.js @@ -33,6 +33,9 @@ import { _fetchCachedModels, _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel, } from './cookbookServe.js'; +import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; +import { topPortalZ } from './toolWindowZOrder.js'; + const STORAGE_KEY = 'cookbook-presets'; const LAST_STATE_KEY = 'cookbook-last-state'; const SERVE_STATE_KEY = 'cookbook-serve-state'; @@ -57,6 +60,11 @@ if (typeof window !== 'undefined' && !window._tagScrollGuardWired) { export const _MODELDIR_CHECK_OFF = ''; export const _MODELDIR_CHECK_ON = ''; +function _normalizeCookbookModelDir(dir) { + const d = String(dir || '').replaceAll('✕', '').replaceAll('✖', '').trim(); + return /^(home|mnt|media|data|opt|srv|var)\//.test(d) ? `/${d}` : d; +} + // Monochrome platform glyphs (currentColor) for a server's OS tag: a penguin for // Linux, the four-pane logo for Windows, an Android robot for Termux/Android. function _platformIcon(platform) { @@ -73,7 +81,7 @@ function _platformIcon(platform) { return ''; } -export let _envState = { env: 'none', envPath: '', hfToken: '', hfTokenConfigured: false, hfTokenMasked: '', gpus: '', remoteHost: '', servers: [], modelPaths: [], platform: '', defaultServer: '' }; +export let _envState = { env: 'none', envPath: '', hfToken: '', hfTokenConfigured: false, hfTokenMasked: '', gpus: '', remoteHost: '', servers: [], modelPaths: [], platform: '', hostPlatform: '', defaultServer: '' }; let _lastCacheHostVal = null; let _cookbookOpeningSpinners = []; export function _lastCacheHost() { return _lastCacheHostVal; } @@ -167,6 +175,92 @@ function _gemma4ThinkingChatTemplateArg(modelName) { : ''; } +const _SERVER_COLOR_CHOICES = [ + ['', 'Auto'], + ['#bd93f9', 'Purple'], + ['#ff79c6', 'Pink'], + ['#fca5a5', 'Red'], + ['#93c5fd', 'Blue'], + ['#86efac', 'Green'], + ['#d6b37a', 'Bronze'], + ['#111827', 'Black'], + ['#f8fafc', 'White'], + ['#c0c4cc', 'Silver'], + ['#d9f99d', 'Lime'], + ['#ccfbf1', 'Mint'], +]; +const _SERVER_AUTO_COLOR_VALUES = _SERVER_COLOR_CHOICES.slice(1).map(([value]) => value); + +function _serverColorValue(value) { + const v = String(value || '').trim(); + return /^#[0-9a-fA-F]{6}$/.test(v) ? v : ''; +} + +function _serverColor(s) { + return _serverColorValue(s && s.color); +} + +function _serverColorLabel(color) { + const hit = _SERVER_COLOR_CHOICES.find(([value]) => value === color); + return hit ? hit[1] : 'Auto'; +} + +function _autoServerColor(index) { + const servers = Array.isArray(_envState.servers) ? _envState.servers : []; + const explicit = new Set(servers.map(s => _serverColor(s)).filter(Boolean)); + const used = new Set(explicit); + for (let j = 0; j <= index; j++) { + const s = servers[j] || {}; + const explicitColor = _serverColor(s); + if (explicitColor) continue; + const picked = _SERVER_AUTO_COLOR_VALUES.find(c => !used.has(c)) || _SERVER_AUTO_COLOR_VALUES[j % _SERVER_AUTO_COLOR_VALUES.length] || ''; + if (j === index) return picked; + if (picked) used.add(picked); + } + return _SERVER_AUTO_COLOR_VALUES[index % _SERVER_AUTO_COLOR_VALUES.length] || ''; +} + +function _resolvedServerColor(s, index) { + return _serverColor(s) || _autoServerColor(index); +} + +function _serverOptionLabel(label, color) { + return color ? `● ${label}` : label; +} + +function _serverOptionStyle(color) { + return color ? ` style="color:${esc(color)};"` : ''; +} + +function _serverColorOptionStyle(color) { + if (!color) return ' style="background:var(--bg);color:var(--fg);"'; + const c = String(color).toLowerCase(); + const fg = (c === '#111827') ? '#f8fafc' : (c === '#f8fafc' || c === '#fca5a5' || c === '#93c5fd' || c === '#86efac' || c === '#d9f99d' || c === '#ccfbf1' || c === '#c0c4cc') ? '#111827' : color; + return ` style="color:${esc(fg)};background-color:color-mix(in srgb, ${esc(color)} 28%, var(--bg));"`; +} + +function _serverColorForValue(value) { + const s = _serverByVal(value); + const idx = Array.isArray(_envState.servers) ? _envState.servers.indexOf(s) : -1; + return s ? _resolvedServerColor(s, idx >= 0 ? idx : 0) : ''; +} + +export function _applyServerSelectColor(sel) { + if (!sel || sel.tagName !== 'SELECT') return; + const color = _serverColorForValue(sel.value); + if (color) { + sel.style.setProperty('--cookbook-server-color', color); + sel.classList.add('cookbook-server-select-colored'); + } else { + sel.style.removeProperty('--cookbook-server-color'); + sel.classList.remove('cookbook-server-select-colored'); + } +} + +export function _syncServerSelectColors(root = document) { + root.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(_applyServerSelectColor); +} + function _buildServerOpts(excludeLocal = false) { // The local server is ALWAYS represented by the synthetic value="local" option // (showing its custom name from the "server name" feature). We must therefore @@ -174,7 +268,8 @@ function _buildServerOpts(excludeLocal = false) { const _localIdx = _envState.servers.findIndex(_isLocalEntry); const _localSrv = _localIdx >= 0 ? _envState.servers[_localIdx] : null; const _localLabel = (_localSrv && _localSrv.name) ? _localSrv.name : 'Local'; - let html = ``; + const _localColor = _localSrv ? _resolvedServerColor(_localSrv, _localIdx) : ''; + let html = ``; const selectedKey = _envState.remoteServerKey || ''; let legacyHostSelected = false; for (let i = 0; i < _envState.servers.length; i++) { @@ -183,12 +278,13 @@ function _buildServerOpts(excludeLocal = false) { if (excludeLocal && _isLocalEntry(s)) continue; const label = s.name || s.host || `Server ${i + 1}`; const value = _serverKey(s); + const color = _resolvedServerColor(s, i); let selected = selectedKey ? value === selectedKey : false; if (!selectedKey && _envState.remoteHost === s.host && !legacyHostSelected) { selected = true; legacyHostSelected = true; } - html += ``; + html += ``; } return html; } @@ -202,7 +298,7 @@ export function _sshCmd(host, cmd, port) { /** Get SSH port for a given host (or task object) */ function _getPort(hostOrTask) { if (!hostOrTask) return ''; - if (typeof hostOrTask === 'object') return hostOrTask.sshPort || _getPort(hostOrTask.remoteServerKey || hostOrTask.remoteHost); + if (typeof hostOrTask === 'object') return hostOrTask.sshPort || _getPort(hostOrTask.remoteServerKey || hostOrTask.remoteHost || hostOrTask.payload?.remote_host); const selected = hostOrTask === _envState.remoteHost ? _selectedServer() : null; const srv = selected || _serverByVal(hostOrTask); return srv?.port || ''; @@ -210,8 +306,13 @@ function _getPort(hostOrTask) { /** Get platform for a given host (or task object). Returns 'windows', 'termux', 'linux', or '' */ export function _getPlatform(hostOrTask) { - if (!hostOrTask) return _envState.platform || ''; - if (typeof hostOrTask === 'object') return hostOrTask.platform || _getPlatform(hostOrTask.remoteServerKey || hostOrTask.remoteHost); + if (hostOrTask === 'local') return _envState.hostPlatform || ''; + if (!hostOrTask) return _envState.remoteHost ? (_envState.platform || '') : (_envState.hostPlatform || ''); + if (typeof hostOrTask === 'object') { + const taskHost = hostOrTask.remoteServerKey || hostOrTask.remoteHost || ''; + if (!taskHost || taskHost === 'local') return _envState.hostPlatform || ''; + return hostOrTask.platform || _getPlatform(taskHost); + } const selected = hostOrTask === _envState.remoteHost ? _selectedServer() : null; const srv = selected || _serverByVal(hostOrTask); return srv?.platform || ''; @@ -337,6 +438,8 @@ export function _detectReasoningParser(modelName) { // MiniMax M2 / M2.5 / M2.7 — released with a dedicated parser. Catch M2 // before plain "minimax" so M2.x doesn't fall through to a wrong parser. if (n.includes('minimax') && n.match(/\bm2(?:\.\d)?\b/)) return 'minimax_m2'; + // DeepSeek-V4 has a dedicated parser in SGLang. Keep it before R1/V3. + if (n.includes('deepseek') && /\bv[-_]?4\b/.test(n)) return 'deepseek-v4'; // DeepSeek-R1 / V3-Thinking / V3.1-Thinking variants. Bare V3/V3.1 (non- // thinking) skip this — they're not reasoning models. if (n.includes('deepseek') && (n.includes('r1') || n.includes('thinking'))) return 'deepseek_r1'; @@ -374,6 +477,7 @@ export function _detectToolParser(modelName) { if (n.includes('llama-4') || n.includes('llama4')) return 'llama4_json'; if (n.includes('llama') || n.includes('nemotron')) return 'llama3_json'; if (n.includes('mistral') || n.includes('mixtral')) return 'mistral'; + if (n.includes('deepseek') && /\bv[-_]?4\b/.test(n)) return 'deepseekv4'; if (n.includes('deepseek-v3')) return 'deepseek_v3'; if (n.includes('deepseek')) return 'deepseek_v3'; if (n.includes('minimax') && /\bm3\b/.test(n)) return 'minimax_m3'; @@ -401,7 +505,7 @@ export function _detectBackend(model) { const isAppleSilicon = ['metal', 'mps', 'apple'].includes(sysBackend); const _nm = `${model.repo_id || ''} ${model.path || ''} ${model.name || ''}`.toLowerCase(); if (/\bmlx\b|mlx-|_mlx/i.test(_nm) || q.startsWith('MLX')) { - return { backend: 'unsupported', label: 'Unsupported' }; + return { backend: 'mlx', label: 'MLX' }; } const isAwqLike = /^AWQ|^GPTQ|^NVFP4/.test(q) || ['FP8', 'FP4', 'MXFP4', 'NF4', 'INT4', 'INT8', 'W4A16', 'W8A8', 'W8A16'].includes(q) || /\b(awq|gptq|fp8|fp4|nvfp4|mxfp4|nf4|int4|int8|w4a16|w8a8|w8a16)\b/i.test(_nm); const hasGgufFile = Array.isArray(model.gguf_files) @@ -434,7 +538,7 @@ export function _detectBackend(model) { // don't run on macOS; vLLM-native quantized models are already filtered out // of metal Cookbook results, so llama.cpp is always the right engine here. if (['metal', 'mps', 'apple'].includes(sysBackend)) { - return { backend: 'llamacpp', label: 'llama.cpp' }; + return { backend: 'mlx', label: 'MLX' }; } // ROCm/AMD machines should not blindly default HF safetensors models to @@ -532,13 +636,32 @@ function _venvRootFromPath(path) { return p; } +function _venvLooksWrongForPlatform(path, platform) { + const p = String(path || '').trim(); + const plat = String(platform || '').toLowerCase(); + if (!p || !plat) return false; + if ((plat === 'darwin' || plat === 'macos') && /^\/(?:home|usr\/local\/cuda|opt\/conda)\//.test(p)) return true; + if ((plat === 'linux' || plat === 'termux') && /^\/(?:Users|opt\/homebrew)\//.test(p)) return true; + return false; +} + +function _isDeepSeekV4Model(modelName) { + const n = String(modelName || '').toLowerCase(); + return n.includes('deepseek') && /\bv[-_]?4\b/.test(n); +} + +function _envHasKey(envText, key) { + return String(envText || '').split(/\s+/).some(part => part.startsWith(`${key}=`)); +} + export function _buildServeCmd(f, modelName, backend) { // When a venv is configured on the chosen server, use the venv's binaries // by absolute path. Bare `vllm` / `python3` relies on PATH, and SSH non- // interactive sessions often leave a user-site install (~/.local/bin/vllm) // ahead of the venv's bin, so the WRONG vllm gets launched even with the // venv activated. Absolute path sidesteps the whole PATH question. - const _formVenv = (f.venv ?? '').toString().trim(); + let _formVenv = (f.venv ?? '').toString().trim(); + if (_venvLooksWrongForPlatform(_formVenv, f.platform)) _formVenv = ''; const _activeVenvPath = _venvRootFromPath(_formVenv || (_envState.env === 'venv' ? (_envState.envPath || '') : '')); const _venvBin = _activeVenvPath ? (_activeVenvPath + '/bin/') : ''; const _vllmBin = _venvBin ? `${_venvBin}vllm` : 'vllm'; @@ -610,14 +733,19 @@ export function _buildServeCmd(f, modelName, backend) { // button strip is the only source for which devices to pin. const gpuId = (f.gpus || f.gpu_id || '').toString().trim(); cmd += _gpuEnvPrefix(gpuId); - const _extraEnv = (f.extra_env ?? '').toString().replace(/\s+/g, ' ').trim(); + const _isDsv4 = _isDeepSeekV4Model(modelName); + let _extraEnv = (f.extra_env ?? '').toString().replace(/\s+/g, ' ').trim(); + if (_isDsv4 && !_envHasKey(_extraEnv, 'SGLANG_DSV4_COMPRESS_STATE_DTYPE')) { + _extraEnv = (`SGLANG_DSV4_COMPRESS_STATE_DTYPE=bf16 ${_extraEnv}`).trim(); + } if (_extraEnv) cmd += _extraEnv + ' '; cmd += `${_py3Bin} -m sglang.launch_server --model-path ${modelName} --host 0.0.0.0 --port ${f.port || '30000'}`; const _gemma4ChatTemplate = _gemma4ThinkingChatTemplateArg(modelName); if (_gemma4ChatTemplate) cmd += ` --chat-template ${_gemma4ChatTemplate}`; if (f.tp && f.tp !== '1') cmd += ` --tp ${f.tp}`; if (f.ctx) cmd += ` --context-length ${f.ctx}`; - if (f.gpu_mem && f.gpu_mem !== '0.90') cmd += ` --mem-fraction-static ${f.gpu_mem}`; + const _memFraction = _isDsv4 && (!f.gpu_mem || f.gpu_mem === '0.90') ? '0.80' : f.gpu_mem; + if (_memFraction && _memFraction !== '0.90') cmd += ` --mem-fraction-static ${_memFraction}`; if (f.dtype && f.dtype !== 'auto') cmd += ` --dtype ${f.dtype}`; if (f.max_seqs && f.max_seqs.toString().trim()) cmd += ` --max-running-requests ${f.max_seqs.toString().trim()}`; if (f.trust_remote) cmd += ' --trust-remote-code'; @@ -630,12 +758,25 @@ export function _buildServeCmd(f, modelName, backend) { } if (!f.prefix_cache) cmd += ' --disable-radix-cache'; if (f.enforce_eager) cmd += ' --disable-cuda-graph'; + const _decodeGraph = String(f.sglang_decode_graph || '').trim(); + if (!f.enforce_eager && _decodeGraph === 'disabled') { + cmd += ' --cuda-graph-backend-decode disabled'; + } else if (!f.enforce_eager && _decodeGraph === 'bs16') { + cmd += ' --cuda-graph-max-bs-decode 16'; + } else if (!f.enforce_eager && _isDsv4 && !/\s--cuda-graph-max-bs-decode\b/.test(cmd) && !/\s--cuda-graph-backend-decode\b/.test(cmd)) { + cmd += ' --cuda-graph-backend-decode disabled'; + } } else if (backend === 'llamacpp') { const ggufPath = f._gguf_path || 'model.gguf'; // GPU list — read from gpus (button strip); fall back to gpu_id for // backward-compat with older saved presets that pre-date the removal. const gpuId = (f.gpus || f.gpu_id || '').toString().trim(); - const py = _isWindows() ? 'python' : 'python3'; + const _targetHost = Object.prototype.hasOwnProperty.call(f, 'host') + ? String(f.host || '').trim() + : String(_envState.remoteHost || '').trim(); + const _isWin = _targetHost ? _isWindows(_targetHost) : _isWindows('local'); + const _localWindows = _isWin && !_targetHost; + const py = _isWin ? 'python' : 'python3'; // CPU-only serve (-ngl 0): drop the GPU-only flags, otherwise the command // mixes "zero GPU layers" with CUDA unified-memory + flash-attn and fails to // start (issue #1291). Only affects the ngl=0 path; GPU serving is unchanged. @@ -657,19 +798,19 @@ export function _buildServeCmd(f, modelName, backend) { // with misleading prefixes. const _sb = String(_hwfitCache?.system?.backend || '').toLowerCase(); const _hwfitHost = String(_hwfitCache?._scannedHost || ''); - const _curHost = String(_envState.remoteHost || ''); + const _curHost = _targetHost; const _isCudaTarget = (_sb === 'cuda') && (_hwfitHost === _curHost); const lcPrefix = (() => { let p = ''; - if (f.unified_mem && !_cpuOnly && !_isWindows() && _isCudaTarget) p += `GGML_CUDA_ENABLE_UNIFIED_MEMORY=1 `; - // No GPU env var in CPU mode — `-ngl 0` already disables offload + if (f.unified_mem && !_cpuOnly && (!_isWin || _localWindows) && _isCudaTarget) p += `GGML_CUDA_ENABLE_UNIFIED_MEMORY=1 `; + // No GPU env var in CPU mode - `-ngl 0` already disables offload // so CUDA_VISIBLE_DEVICES / HIP_VISIBLE_DEVICES would be misleading // clutter ("why is CUDA pinned for a CPU run?"). - if (!_isWindows() && !_cpuOnly) p += _gpuEnvPrefix(gpuId); + if ((!_isWin || _localWindows) && !_cpuOnly) p += _gpuEnvPrefix(gpuId); return p; })(); - if (f.unified_mem && !_cpuOnly && _isWindows() && _isCudaTarget) cmd += `$env:GGML_CUDA_ENABLE_UNIFIED_MEMORY="1"; `; - if (_isWindows() && !_cpuOnly) cmd += _gpuEnvPrefix(gpuId, true); + if (f.unified_mem && !_cpuOnly && _isWin && !_localWindows && _isCudaTarget) cmd += `$env:GGML_CUDA_ENABLE_UNIFIED_MEMORY="1"; `; + if (_isWin && !_localWindows && !_cpuOnly) cmd += _gpuEnvPrefix(gpuId, true); const needsGgufPrelude = /^\$\(\{\s*find\s/.test(String(ggufPath || '')); const modelArg = needsGgufPrelude ? '"$MODEL_FILE"' : `"${ggufPath}"`; // Prefer native llama-server. The backend bootstrap resolves/builds the @@ -741,11 +882,16 @@ export function _buildServeCmd(f, modelName, backend) { // llama-cpp-python takes the projector via --clip_model_path. _lcpExtra += ` --clip_model_path "${f._mmproj_path}"`; } - if (_isWindows()) { - const _lcpServer = `${lcPrefix}${py} -m llama_cpp.server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} --n_gpu_layers ${f.ngl || '99'} --n_ctx ${f.ctx || '8192'}${_lcpExtra}`; + const _lcServer = `${lcPrefix}llama-server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} -ngl ${f.ngl || '99'} -c ${f.ctx || '8192'}${_lcExtra}`; + const _lcpServer = `${lcPrefix}${py} -m llama_cpp.server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} --n_gpu_layers ${f.ngl || '99'} --n_ctx ${f.ctx || '8192'}${_lcpExtra}`; + if (_localWindows) { + // Local Windows serve is launched through Git Bash, so use the native + // llama-server shape and let PATH resolve the CUDA Release wrapper. + cmd += _lcServer; + } else if (_isWin) { cmd += _lcpServer; } else { - cmd += `${lcPrefix}llama-server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} -ngl ${f.ngl || '99'} -c ${f.ctx || '8192'}${_lcExtra}`; + cmd += _lcServer; } if (needsGgufPrelude) { cmd = `MODEL_FILE=${ggufPath} && { [ -n "$MODEL_FILE" ] && [ -f "$MODEL_FILE" ]; } || { echo "ERROR: No GGUF found on this host"; exit 1; } && ${cmd}`; @@ -794,6 +940,13 @@ export function _buildServeCmd(f, modelName, backend) { if (f.diff_attention_slicing) cmd += ' --attention-slicing'; if (f.diff_vae_slicing) cmd += ' --vae-slicing'; if (f.diff_harmonize_gpu) cmd += ` --harmonize-gpu ${f.diff_harmonize_gpu}`; + } else if (backend === 'mlx') { + const mlxPy = _isWindows() ? 'python' : _py3Bin; + const mlxHost = f.host ? '0.0.0.0' : '127.0.0.1'; + cmd += `${mlxPy} -m mlx_lm.server --model ${_shellQuote(modelName)} --host ${mlxHost} --port ${f.port || '8080'}`; + if (/minimax|mini-max/i.test(modelName)) { + cmd += ' --temp 0.7 --top-p 0.9 --max-tokens 2048'; + } } return cmd; } @@ -873,8 +1026,9 @@ async function _fetchDependencies() { let _spin = null; try { const sp = (await import('./spinner.js')).default; - _spin = sp.createWhirlpool(28); - _spin.element.style.cssText = 'margin:24px auto 0;display:block;'; + _spin = sp.createWhirlpool(22); + _spin.element.classList.add('cookbook-section-loading-wp'); + _spin.element.style.cssText = 'margin:24px auto 0;display:block;width:22px;height:22px;'; list.appendChild(_spin.element); const label = document.createElement('div'); label.className = 'hwfit-loading'; @@ -914,6 +1068,7 @@ async function _fetchDependencies() { const pkgs = data.packages || []; if (!pkgs.length) { list.innerHTML = '
No packages found
'; return; } const _winUnsupported = new Set(['hf_transfer', 'vllm', 'rembg', 'gfpgan']); + const _systemInstallable = new Set(['tmux']); const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => { if (winBlocked) return `N/A`; @@ -925,8 +1080,12 @@ async function _fetchDependencies() { if (pkg.installed) return ``; if (isSystemDep) { const depTip = esc(pkg.install_hint || 'Install this OS package on the selected server.'); + if (pkg.applicable !== false && _systemInstallable.has(pkg.name)) { + return ``; + } const depLabel = pkg.applicable === false ? 'N/A ?' : 'Missing'; - return `${depLabel}`; + const depStyle = pkg.name === 'docker' ? ' style="width:87.7px;justify-content:center;"' : ''; + return `${depLabel}`; } return ``; }; @@ -938,6 +1097,7 @@ async function _fetchDependencies() { const _DEP_GLYPHS = { vllm: '', sglang: '', + mlx_lm: '', llama_cpp: '', ollama: '', diffusers: '', @@ -1102,22 +1262,32 @@ async function _fetchDependencies() { // "Update" item in an installed package's ⋮ menu. `upgrade` adds pip -U; // `statusEl`, when given, shows "Installing…/Updating…" and is disabled. async function _installDep(pipName, pkgName, isLocalOnly, upgrade, statusEl) { + let targetServer = null; if (isLocalOnly) { _envState.remoteHost = ''; _envState.env = 'none'; _envState.envPath = ''; } else { const depsServerSel = document.getElementById('hwfit-deps-server'); - if (depsServerSel) _applyServerSelection(depsServerSel.value); + if (depsServerSel) { + targetServer = _serverByVal(depsServerSel.value); + _applyServerSelection(depsServerSel.value); + } } - const targetHost = isLocalOnly ? 'this server' : (_envState.remoteHost || 'local'); + const targetHost = isLocalOnly ? 'this server' : ((targetServer?.host || _envState.remoteHost) || 'local'); + const targetEnv = isLocalOnly ? 'none' : (targetServer?.env || _envState.env || 'none'); + const targetEnvPath = isLocalOnly ? '' : (targetServer?.envPath || _envState.envPath || ''); + const targetPlatform = isLocalOnly ? (_envState.hostPlatform || _envState.platform || '') : (targetServer?.platform || _envState.platform || ''); + const targetRemoteHost = isLocalOnly ? '' : (targetServer?.host || _envState.remoteHost || ''); // 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 // only add `--user --break-system-packages` when there's no env — // for PEP-668-locked system pythons (Arch, newer Debian). - const _inEnv = _envState.env === 'venv' || _envState.env === 'conda'; - const _pipFlags = (!_isWindows() && !_inEnv) ? ' --user --break-system-packages' : ''; + const _inEnv = targetEnv === 'venv' || targetEnv === 'conda'; + const _platform = String(targetPlatform || '').toLowerCase(); + const _isAppleTarget = _platform === 'darwin' || _platform === 'macos' || _platform.includes('mac os'); + const _pipFlags = (!_isWindows() && !_inEnv) ? (_isAppleTarget ? ' --user' : ' --user --break-system-packages') : ''; // Use the venv's python3 by absolute path when configured. Even with the // env_prefix sourcing activate, SSH non-interactive sessions sometimes // pick a `python3` ahead of the venv's bin on PATH, so the install @@ -1125,35 +1295,35 @@ async function _fetchDependencies() { let _py; if (_isWindows()) { _py = 'python'; - } else if (_envState.env === 'venv' && _envState.envPath) { - _py = `${_envState.envPath.replace(/\/+$/, '')}/bin/python3`; + } else if (targetEnv === 'venv' && targetEnvPath) { + _py = `${targetEnvPath.replace(/\/+$/, '')}/bin/python3`; } else { _py = 'python3'; } const cmd = `${_py} -m pip install${upgrade ? ' -U' : ''}${_pipFlags} "${pipName}"`; let envPrefix = ''; if (_isWindows()) { - if (_envState.env === 'venv' && _envState.envPath) { - envPrefix = '& ' + _psQuote(_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1'); - } else if (_envState.env === 'conda' && _envState.envPath) { - envPrefix = 'conda activate ' + _psQuote(_envState.envPath); + if (targetEnv === 'venv' && targetEnvPath) { + envPrefix = '& ' + _psQuote(targetEnvPath.endsWith('\\Scripts\\Activate.ps1') ? targetEnvPath : targetEnvPath + '\\Scripts\\Activate.ps1'); + } else if (targetEnv === 'conda' && targetEnvPath) { + envPrefix = 'conda activate ' + _psQuote(targetEnvPath); } } else { - if (_envState.env === 'venv' && _envState.envPath) { - const p = _envState.envPath; + if (targetEnv === 'venv' && targetEnvPath) { + const p = targetEnvPath; envPrefix = 'source ' + _shellQuote(p.endsWith('/bin/activate') ? p : p + '/bin/activate'); - } else if (_envState.env === 'conda' && _envState.envPath) { - envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath); + } else if (targetEnv === 'conda' && targetEnvPath) { + envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(targetEnvPath); } } try { const reqBody = { repo_id: pipName, cmd: cmd, - remote_host: _envState.remoteHost || undefined, - ssh_port: _getPort(_envState.remoteHost) || undefined, + remote_host: targetRemoteHost || undefined, + ssh_port: _getPort(targetRemoteHost) || undefined, env_prefix: envPrefix || undefined, - platform: _envState.platform || undefined, + platform: targetPlatform || undefined, }; const res = await fetch('/api/model/serve', { method: 'POST', credentials: 'same-origin', @@ -1177,7 +1347,7 @@ async function _fetchDependencies() { } // _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: pipName, _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true, env_path: _envState.envPath || '' }; + const payload = { repo_id: pipName, _cmd: cmd, remote_host: targetRemoteHost || '', _dep: true, env_path: targetEnvPath || '', platform: targetPlatform || '' }; _addTask(data.session_id, 'pip ' + pkgName, 'download', payload); if (statusEl) { statusEl.textContent = upgrade ? 'Updating...' : 'Installing...'; statusEl.disabled = true; } uiModule.showToast(`${upgrade ? 'Updating' : 'Installing'} ${pkgName} on ${targetHost}...`); @@ -1315,7 +1485,7 @@ async function _fetchDependencies() { // from the row) so the user can copy-paste it without leaving // the toast. Otherwise just surface the error. const _suffix = _resolvedCmd ? `\n\nRun on ${targetLabel}: ${_resolvedCmd}` : ''; - uiModule.showToast('Build-deps install failed: ' + String(reason).slice(0, 300) + _suffix, { + uiModule.showToast('System dependency install failed: ' + String(reason).slice(0, 300) + _suffix, { duration: 25000, action: _resolvedCmd ? 'Copy command' : 'OK', onAction: async () => { @@ -1514,7 +1684,7 @@ async function _fetchDependencies() { // Wire the installed-package menu. function _showDepMenu(anchor) { - document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove()); + document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove); const row = anchor.closest('.cookbook-dep-row'); if (!row) return; const pipName = row.dataset.depPip; @@ -1527,7 +1697,7 @@ async function _fetchDependencies() { const minW = 150; let left = Math.min(rect.right - minW, window.innerWidth - minW - 8); left = Math.max(8, left); - dropdown.style.cssText = `position:fixed;display:block;z-index:10001;top:${rect.bottom + 6}px;left:${left}px;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`; + dropdown.style.cssText = `position:fixed;display:block;z-index:${topPortalZ()};top:${rect.bottom + 6}px;left:${left}px;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`; const upIco = ''; const it = document.createElement('div'); it.className = 'dropdown-item-compact'; @@ -1535,7 +1705,7 @@ async function _fetchDependencies() { it.title = `Update ${pkgName} to the latest version (pip install -U)`; it.addEventListener('click', async (e) => { e.stopPropagation(); - dropdown.remove(); + close(); await _installDep(pipName, pkgName, isLocalOnly, true, null); }); dropdown.appendChild(it); @@ -1563,19 +1733,14 @@ async function _fetchDependencies() { dropdown.appendChild(source); } document.body.appendChild(dropdown); - const close = (ev) => { - if (!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target)) { - dropdown.remove(); - document.removeEventListener('click', close, true); - } - }; - setTimeout(() => document.addEventListener('click', close, true), 10); + const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => + !dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target)); } list.querySelectorAll('.cookbook-dep-installed-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); if (document.querySelector('.cookbook-dep-menu')) { - document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove()); + document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove); return; } _showDepMenu(btn); @@ -1624,9 +1789,47 @@ function _applyServerSelection(val) { sel.value = _want; if (sel.selectedIndex < 0) sel.value = 'local'; } + _applyServerSelectColor(sel); }); } +async function _refreshScanDownloadTarget() { + const btn = document.getElementById('hwfit-hw-refresh-btn'); + if (btn && btn.disabled) return; + const selectedVal = document.getElementById('hwfit-server-select')?.value || _currentServerValue(); + if (btn) { + btn.disabled = true; + btn.style.opacity = '0.55'; + btn.style.cursor = 'wait'; + } + try { + if (selectedVal) _applyServerSelection(selectedVal); + const ok = await _syncFromServer().catch((e) => { + console.warn('[cookbook] explicit server sync failed', e); + return false; + }); + if (ok) { + try { Object.assign(_envState, _readStoredEnvState()); } catch {} + if (selectedVal) _applyServerSelection(selectedVal); + } + _resetGpuToggleState(); + await Promise.allSettled([ + _hwfitFetch(true), + _fetchCachedModels(true), + ]); + if (uiModule?.showToast) uiModule.showToast('Refreshed selected server'); + } catch (e) { + console.warn('[cookbook] scan/download refresh failed', e); + if (uiModule?.showError) uiModule.showError('Refresh failed: ' + (e?.message || e)); + } finally { + if (btn) { + btn.disabled = false; + btn.style.opacity = ''; + btn.style.cursor = ''; + } + } +} + function _wireTabEvents(body) { // Tab switching body.querySelectorAll('.cookbook-tab').forEach(tab => { @@ -1639,10 +1842,10 @@ function _wireTabEvents(body) { }); if (backend === 'Search') { _hwfitInit(); - _hwfitFetch(); + _hwfitFetch(false, { allowNetwork: false }); } if (backend === 'Serve') { - _fetchCachedModels(); + _fetchCachedModels(false, { allowNetwork: false }); } if (backend === 'Dependencies') { _fetchDependencies(); @@ -1687,17 +1890,18 @@ function _wireTabEvents(body) { const port = entry.querySelector('.cookbook-srv-port')?.value?.trim() || ''; const env = entry.querySelector('.cookbook-srv-env')?.value || 'none'; const envPath = entry.querySelector('.cookbook-srv-path')?.value?.trim() || ''; + const color = _serverColorValue(entry.querySelector('.cookbook-srv-color')?.value || ''); const platform = entry.dataset.platform || ''; const dirs = []; entry.querySelectorAll('.cookbook-modeldir-tag').forEach(tag => { // Read from data attribute (authoritative) — never parse displayed text - const d = (tag.dataset.dir || '').replaceAll('✕', '').replaceAll('✖', '').trim(); + const d = _normalizeCookbookModelDir(tag.dataset.dir || ''); if (d) dirs.push(d); }); // Directory flagged as the download target ('' = default HF cache). const dlEl = entry.querySelector('.cookbook-modeldir-dl.active'); const downloadDir = dlEl ? (dlEl.dataset.dlDir || '') : ''; - servers.push({ name, host, port, env, envPath, modelDirs: dirs, downloadDir, platform }); + servers.push({ name, host, port, env, envPath, color, modelDirs: dirs, downloadDir, platform }); }); _envState.servers = servers; // Auto-default: when the user has configured EXACTLY ONE remote server @@ -1723,13 +1927,16 @@ function _wireTabEvents(body) { Promise.resolve().then(() => { const _want = _currentServerValue(); document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => { - if (sel && sel.tagName === 'SELECT') sel.value = _want; + if (sel && sel.tagName === 'SELECT') { + sel.value = _want; + _applyServerSelectColor(sel); + } }); }); } // Wire server form inputs - document.querySelectorAll('.cookbook-srv-name, .cookbook-srv-host, .cookbook-srv-port, .cookbook-srv-path').forEach(el => { + document.querySelectorAll('.cookbook-srv-name, .cookbook-srv-color, .cookbook-srv-host, .cookbook-srv-port, .cookbook-srv-path').forEach(el => { el.addEventListener('change', _syncServers); }); document.querySelectorAll('.cookbook-srv-env').forEach(el => { @@ -1745,7 +1952,7 @@ function _wireTabEvents(body) { _applyServerSelection(dlServer.value); // Reset toggle state (no flicker) so the new server's hardware re-renders. _resetGpuToggleState(); - _hwfitFetch(); + _hwfitFetch(false, { allowNetwork: false }); }); } @@ -1774,7 +1981,7 @@ function _wireTabEvents(body) { if (cacheDirEl) cacheDirEl.value = srv.modelDir || '~/.cache/huggingface/hub'; const dirsEl = document.querySelector('.cookbook-serve-dirs'); if (dirsEl) { - const dirs = (Array.isArray(srv.modelDirs) ? srv.modelDirs : [srv.modelDir || '~/.cache/huggingface/hub']).map(d => d.replaceAll('✕', '').replaceAll('✖', '').trim()).filter(Boolean); + const dirs = (Array.isArray(srv.modelDirs) ? srv.modelDirs : [srv.modelDir || '~/.cache/huggingface/hub']).map(d => _normalizeCookbookModelDir(d)).filter(Boolean); dirsEl.innerHTML = dirs.map(d => `${esc(d)}`).join('') + 'edit'; dirsEl.querySelector('.cookbook-serve-dir-edit')?.addEventListener('click', () => { @@ -1782,13 +1989,28 @@ function _wireTabEvents(body) { if (settingsTab) settingsTab.click(); }); } - _fetchCachedModels(); + _fetchCachedModels(false, { allowNetwork: false }); }); } const scanBtn = document.getElementById('hwfit-cache-scan'); if (scanBtn) { - scanBtn.addEventListener('click', () => _fetchCachedModels()); + scanBtn.addEventListener('click', async () => { + if (scanBtn.disabled) return; + scanBtn.disabled = true; + scanBtn.classList.add('spinning'); + try { + await _fetchCachedModels(true); + } finally { + scanBtn.disabled = false; + scanBtn.classList.remove('spinning'); + } + }); + } + + const hwRefreshBtn = document.getElementById('hwfit-hw-refresh-btn'); + if (hwRefreshBtn) { + hwRefreshBtn.addEventListener('click', _refreshScanDownloadTarget); } const editDirsLink = document.querySelector('.cookbook-serve-dir-edit'); @@ -1808,6 +2030,7 @@ function _wireTabEvents(body) { _fetchDependencies(); }); } + _syncServerSelectColors(body); // "Rebuild llama.cpp" clears the cached build so the next serve recompiles. // The serve bootstrap only builds llama-server when it is missing from PATH, @@ -2286,8 +2509,9 @@ function _wireTabEvents(body) { hfList.innerHTML = ''; try { const sp = (await import('./spinner.js')).default; - const _spin = sp.createWhirlpool(28); - _spin.element.style.cssText = 'margin:24px auto 0;display:block;'; + const _spin = sp.createWhirlpool(22); + _spin.element.classList.add('cookbook-section-loading-wp'); + _spin.element.style.cssText = 'margin:24px auto 0;display:block;width:22px;height:22px;'; hfList.appendChild(_spin.element); const lbl = document.createElement('div'); lbl.className = 'hwfit-loading'; @@ -2507,11 +2731,30 @@ function _wireTabEvents(body) { // (Model Directory header, default-server checkmark, trash delete, platform icon). // forceRemote renders an editable remote entry even before a host is typed // (a new server's host is empty, which would otherwise read as "Local"). +export function _serverDefaultHtml(active) { + const check = active ? '' : ''; + return `${check}default`; +} + export function _serverEntryHtml(s, i, defaultServer, forceRemote, isNew) { const isLocal = (forceRemote || isNew) ? false : (!s.host || s.host === 'local'); - const envOpts = ['none', 'venv'].map(e => ``).join(''); + const envOpts = [['none', 'None'], ['venv', 'venv'], ['conda', 'conda']].map(([value, label]) => ``).join(''); + const srvColor = _serverColor(s); + const resolvedSrvColor = _resolvedServerColor(s, i); + const colorOpts = _SERVER_COLOR_CHOICES.map(([value, label]) => { + const displayLabel = label; + return ``; + }).join(''); + const selectedColorLabel = srvColor ? _serverColorLabel(srvColor) : `Auto · ${_serverColorLabel(resolvedSrvColor)}`; + const colorMenu = _SERVER_COLOR_CHOICES.map(([value, label]) => { + const active = value === srvColor; + const swatchColor = value || resolvedSrvColor; + const rowLabel = value ? label : `Auto · ${_serverColorLabel(resolvedSrvColor)}`; + const swatch = swatchColor ? ` style="--swatch-color:${esc(swatchColor)};"` : ''; + return ``; + }).join(''); let html = ''; - html += `
`; + html += `
`; const _srvTitle = s.name || (isLocal ? 'Local' : (s.host || `Server ${i + 1}`)); const _srvKey = isLocal ? 'local' : (s.host || ''); const _isDefaultSrv = (defaultServer || '') === _srvKey; @@ -2527,15 +2770,16 @@ export function _serverEntryHtml(s, i, defaultServer, forceRemote, isNew) { // sense once the server is saved. html += `${_checkBtn}${_keyBtn}`; } else { - html += `${!isLocal ? _checkBtn + _keyBtn : ''}${_isDefaultSrv ? _MODELDIR_CHECK_ON : _MODELDIR_CHECK_OFF}default`; + html += `${!isLocal ? _checkBtn + _keyBtn : ''}${_serverDefaultHtml(_isDefaultSrv)}`; } html += ``; html += `
`; html += ``; - html += ``; + html += ``; + html += ``; html += ``; html += ``; - html += ``; + html += ``; html += ``; html += ``; html += `
`; @@ -2552,13 +2796,15 @@ export function _serverEntryHtml(s, i, defaultServer, forceRemote, isNew) { html += `${dlBtn} ${esc(modelDirs[j])}${rmBtn}`; } html += ``; - const _btnStyle = 'margin-left:auto;position:relative;top:-2px;height:22px;box-sizing:border-box;display:inline-flex;align-items:center;'; + const _btnBaseStyle = 'position:relative;top:-2px;height:22px;box-sizing:border-box;display:inline-flex;align-items:center;'; + const _btnPushStyle = `margin-left:auto;${_btnBaseStyle}`; if (isNew) { // A brand-new server: Save (confirm) sits where Delete would be; Cancel is // top-right in the title. Save confirms with a checkmark (auto-saves on edit too). - html += ``; + html += ``; } else if (!isLocal) { - html += ``; + html += ``; + html += ``; } html += `
`; if (!isLocal) { @@ -2614,13 +2860,14 @@ function _renderRecipes() { const isLocal = !s.host || s.host.toLowerCase() === 'local'; if (isLocal) { s.host = ''; + s.platform = _envState.hostPlatform || ''; if (_localSeen) return false; _localSeen = true; } return true; }); if (!_localSeen) { - _es.servers.unshift({ host: '', env: _es.env || 'none', envPath: _es.envPath || '', modelDir: '~/.cache/huggingface/hub' }); + _es.servers.unshift({ host: '', env: _es.env || 'none', envPath: _es.envPath || '', modelDir: '~/.cache/huggingface/hub', platform: _envState.hostPlatform || '' }); } if (_es.remoteHost && !_es.servers.some(s => s.host === _es.remoteHost)) { _es.servers.push({ host: _es.remoteHost, env: _es.env || 'none', envPath: _es.envPath || '', modelDir: '~/.cache/huggingface/hub' }); @@ -2680,8 +2927,7 @@ function _renderRecipes() { html += '

Scans your hardware for what models you can run. Hardware is cached; hit the scan button to re-probe after changing GPUs.

'; html += '
'; html += ''; @@ -2690,13 +2936,21 @@ function _renderRecipes() { // levers (Engine / Quant / Context) live to the right. html += ''; html += ''; - html += ''; html += ''; html += ''; html += ''; + html += ''; html += ''; html += ''; + html += ''; html += ''; + html += ''; + html += ''; html += '?'; html += ''; // Quant (Q4/Q8/…). Default is "All" so the list shows the best-scoring @@ -2704,7 +2958,7 @@ function _renderRecipes() { html += ''; html += ''; @@ -2722,9 +2976,8 @@ function _renderRecipes() { html += _buildServerOpts(false); html += ''; html += '
'; - // (Rescan button removed — Edit handles manual hardware updates; - // automatic re-probe runs on container restart.) html += ''; + html += ''; // Sort state — the clickable column headers read/write this (pewds' original // sort paradigm). Newest is reachable by clicking the Model column header. html += ''; html += ''; html += ''; + html += ''; html += '
'; html += '
'; html += '
'; @@ -2861,7 +3115,7 @@ function _renderRecipes() { // Auto-init What Fits _hwfitInit(); - _hwfitFetch(); + _hwfitFetch(false, { allowNetwork: false }); } // ── Public API ── @@ -3073,8 +3327,48 @@ export function isVisible() { let _sharedSyncInFlight = false; let _sharedSyncLast = 0; +const SHARED_STATE_LEADER_KEY = 'odysseus-cookbook-shared-state-leader'; +const SHARED_STATE_LEADER_ID = `${Date.now()}-${Math.random().toString(36).slice(2)}`; +const SHARED_STATE_LEADER_TTL_MS = 12000; + +function _foregroundChatBusy() { + try { + return !!window.__odysseusChatBusy || Date.now() < (window.__odysseusChatBusyUntil || 0); + } catch (_) { + return false; + } +} + +function _claimSharedStateLeader() { + if (document.visibilityState !== 'visible') return false; + const now = Date.now(); + try { + const raw = localStorage.getItem(SHARED_STATE_LEADER_KEY); + const current = raw ? JSON.parse(raw) : null; + if ( + !current + || !current.id + || current.id === SHARED_STATE_LEADER_ID + || now - Number(current.ts || 0) > SHARED_STATE_LEADER_TTL_MS + ) { + localStorage.setItem(SHARED_STATE_LEADER_KEY, JSON.stringify({ id: SHARED_STATE_LEADER_ID, ts: now })); + return true; + } + return current.id === SHARED_STATE_LEADER_ID; + } catch (_) { + return true; + } +} + +function _canRefreshSharedCookbookState() { + if (!isVisible() || _sharedSyncInFlight) return false; + if (document.visibilityState !== 'visible') return false; + if (_foregroundChatBusy()) return false; + return _claimSharedStateLeader(); +} + async function _refreshSharedCookbookState(reason = '') { - if (!isVisible() || _sharedSyncInFlight) return; + if (!_canRefreshSharedCookbookState()) return; const now = Date.now(); if (now - _sharedSyncLast < 1500) return; _sharedSyncInFlight = true; @@ -3108,6 +3402,7 @@ document.addEventListener('cookbook:state-synced', () => { if (isVisible()) { const activeTab = document.querySelector('#cookbook-modal .cookbook-tab.active')?.dataset?.backend || ''; if (activeTab === 'Running') _renderRunningTab(); + else if (activeTab === 'Serve') _rerenderCachedModels(); } }); diff --git a/static/js/cookbookDownload.js b/static/js/cookbookDownload.js index 295189c284..330d7d9aa6 100644 --- a/static/js/cookbookDownload.js +++ b/static/js/cookbookDownload.js @@ -484,8 +484,12 @@ export async function _runModelDownload(panel, model, backend, hostOverride) { // they disagree on the active host. The servers LIST is consistent, so we look // up the matching server to get its env / path / platform / port. let host; + let selectedServer = null; + let selectedServerKey = ''; if (hostOverride !== undefined) { host = hostOverride || ''; + selectedServer = host ? (_serverByVal?.(host) || (_envState.servers || []).find(s => s.host === host) || null) : null; + selectedServerKey = selectedServer ? (typeof window.cookbookModule?._serverKey === 'function' ? window.cookbookModule._serverKey(selectedServer) : '') : ''; } else { // No explicit host passed: resolve from the visible server dropdown rather // than _envState.remoteHost (unreliable — multiple state copies disagree). @@ -496,15 +500,21 @@ export async function _runModelDownload(panel, model, backend, hostOverride) { const _dsrv = (_ssv && _ssv !== 'local') ? (_serverByVal?.(_ssv) || _envState.servers[parseInt(_ssv)]) : null; if (_dsrv) { host = _dsrv.host; + selectedServer = _dsrv; + selectedServerKey = _ssv || ''; } else if (ssEl && ssEl.value === 'local') { host = ''; } else { host = _envState.remoteHost || ''; + selectedServer = host ? ((_envState.servers || []).find(s => s.host === host) || _serverByVal?.(host) || null) : null; } } - const srv = _serverByVal?.(_envState.remoteServerKey || host) || {}; - const env = host ? (srv.env || 'none') : (_envState.env || 'none'); + const srv = selectedServer || _serverByVal?.(host) || {}; + let env = host ? (srv.env || 'none') : (_envState.env || 'none'); const envPath = host ? (srv.envPath || '') : (_envState.envPath || ''); + if ((!env || env === 'none') && envPath) { + env = /(?:^|\/)(?:\.?venv|env)(?:\/|$)|\/bin\/activate$/i.test(envPath) ? 'venv' : env; + } const platform = host ? (srv.platform || '') : (_envState.platform || ''); const isWin = host ? (platform === 'windows') : _isWindows(); @@ -515,7 +525,13 @@ export async function _runModelDownload(panel, model, backend, hostOverride) { // resumes cached partials more reliably. if ((model.required_gb || 0) >= 10 || backend === 'llamacpp') payload.disable_hf_transfer = true; if (_envState.hfToken) payload.hf_token = _envState.hfToken; - if (host) { payload.remote_host = host; const _sp = _getPort(host); if (_sp) payload.ssh_port = _sp; } + if (host) { + payload.remote_host = host; + if (selectedServerKey && selectedServerKey !== 'local') payload.remote_server_key = selectedServerKey; + if (srv.name) payload.remote_server_name = srv.name; + const _sp = srv.port || _getPort(host); + if (_sp) payload.ssh_port = _sp; + } if (platform) payload.platform = platform; // If this server has a directory flagged as the download target, send it so // the backend downloads into / instead of the default HF cache. @@ -562,11 +578,12 @@ export async function _runModelDownload(panel, model, backend, hostOverride) { if (zombieCandidate) { try { const _zh = zombieCandidate.remoteHost || ''; - const _zPort = (_serverByVal?.(_envState.remoteServerKey || _zh) + const _zPort = (_serverByVal?.(zombieCandidate.remoteServerKey || zombieCandidate.payload?.remote_server_key || _zh) || (_envState.servers || []).find(s => s.host === _zh) || {}).port; const _sshPf = _zh ? `ssh ${_zPort && _zPort !== '22' ? `-p ${_zPort} ` : ''}${_zh} '` : ''; const _sshSf = _zh ? `'` : ''; - const _probeCmd = `${_sshPf}tmux has-session -t ${zombieCandidate.sessionId} 2>/dev/null${_sshSf}`; + const _probePrefix = _zh ? 'PATH="$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"; ' : ''; + const _probeCmd = `${_sshPf}${_probePrefix}tmux has-session -t ${zombieCandidate.sessionId} 2>/dev/null${_sshSf}`; const _r = await fetch('/api/shell/exec', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, @@ -593,7 +610,7 @@ export async function _runModelDownload(panel, model, backend, hostOverride) { if (activeOnHost) { const queueId = `queue-${Date.now().toString(36)}`; const allTasks = _loadTasks(); - allTasks.push({ id: queueId, sessionId: queueId, name: taskName, type: 'download', status: 'queued', output: '', ts: Date.now(), payload, remoteHost: host }); + allTasks.push({ id: queueId, sessionId: queueId, name: taskName, type: 'download', status: 'queued', output: '', ts: Date.now(), payload, remoteHost: host, remoteServerKey: payload.remote_server_key || '', remoteServerName: payload.remote_server_name || '', sshPort: payload.ssh_port || '', platform: payload.platform || '' }); _saveTasks(allTasks); _renderRunningTab(); uiModule.showToast(`Queued ${shortName} — waiting for current download`); diff --git a/static/js/cookbookPorts.js b/static/js/cookbookPorts.js new file mode 100644 index 0000000000..d947908bb5 --- /dev/null +++ b/static/js/cookbookPorts.js @@ -0,0 +1,19 @@ +// Pure port helpers extracted so they're unit-testable without the +// browser-bound rest of cookbookRunning.js (issue #4507 follow-up). + +// Read the port out of a serve launch command. Handles --port 8000, +// --port=8000, -p 8000, and -p=8000. Returns '' when none is present. +export function portOf(cmd) { + const s = cmd || ''; + const m = s.match(/--port[=\s]+(\d+)/) || s.match(/(?:^|\s)-p[=\s]+(\d+)/); + return m ? m[1] : ''; +} + +// Lowest free port >= start that isn't in usedPorts (array or Set of +// numbers/strings). Returns a string to match the serve command format. +export function nextFreePort(usedPorts, start = 8000) { + const used = new Set([...usedPorts].map(p => parseInt(p, 10))); + let port = start; + while (used.has(port)) port++; + return String(port); +} diff --git a/static/js/cookbookRunning.js b/static/js/cookbookRunning.js index f2aba56417..e9ee597c4e 100644 --- a/static/js/cookbookRunning.js +++ b/static/js/cookbookRunning.js @@ -8,6 +8,7 @@ import uiModule from './ui.js'; import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis.js'; import { registerMenuDismiss } from './escMenuStack.js'; import { computeProgressSignal } from './cookbookProgressSignal.js'; +import { portOf, nextFreePort } from './cookbookPorts.js'; // Human-friendly badge label for a task's internal status. Avoids surfacing // the word "error" in the sidebar — a server the user stopped or one that @@ -28,7 +29,8 @@ function _statusLabel(status, type) { function _taskBadge(task) { if (task._unreachable && task.status === 'running') return { text: 'unreachable', cls: 'cookbook-task-error' }; if (task.type === 'download' && task.status === 'running') { - return { text: _statusLabel(task.status, task.type), cls: 'cookbook-task-downloading' }; + const progress = String(task.progress || '').trim(); + return { text: progress || _statusLabel(task.status, task.type), cls: 'cookbook-task-downloading' }; } if (task.type === 'serve' && task.status === 'running' && task.progress) { // Same green "running" pill — just with dynamic phase text, so it doesn't @@ -55,9 +57,24 @@ function _downloadDisplayName(name, task) { return part ? `${name} · ${part}` : name; } +function _downloadNameFromPayload(name, payload) { + const rawName = String(name || '').trim(); + // Defensive: failed/restarted downloads can inherit the wrapper executable + // name if older state was saved from a command preview. The row title should + // always be the model/repo, never "bash" or "python". + const looksLikeLauncher = /^(?:bash|sh|zsh|python|python3|pwsh|powershell|cmd|tmux)$/i.test(rawName); + const base = (!rawName || looksLikeLauncher) + ? String(payload?.repo_id || payload?.repo || '').split('/').pop() + : rawName; + const include = payload?.include || ''; + if (!include || String(base || '').includes(' · ')) return base || rawName || 'download'; + const part = _ggufDisplayPartFromPath(String(include).replace(/\*/g, '')); + return part ? `${base} · ${part}` : (base || rawName || 'download'); +} + function _taskDisplayName(task) { const name = String(task?.name || '').trim(); - if (task?.type === 'download') return _downloadDisplayName(name, task); + if (task?.type === 'download') return _downloadDisplayName(_downloadNameFromPayload(name, task?.payload), task); if (task?.type !== 'serve') return name; const gguf = task?.payload?._fields?.gguf_file || task?.payload?.gguf_file || ''; if (!gguf || name.includes(' · ')) return name; @@ -96,7 +113,7 @@ function _downloadOutputLooksActive(task) { function _canClearTask(task) { if (!task || task.status === 'running') return false; - if (task.type === 'serve' && (task.status === 'ready' || (task._serveReady && !['stopped', 'error', 'crashed', 'failed', 'completed'].includes(task.status)))) return false; + if (task.type === 'serve' && (task.status === 'ready' || (!['error', 'crashed', 'failed', 'completed'].includes(task.status) && _serveOutputLooksReady(task)))) return false; // If the tmux output still shows an in-flight download, the task isn't // actually finished — hide the clear/check pill so it doesn't show on a // task that's still doing work. (The next render will reflect this and @@ -266,9 +283,7 @@ function _taskHostLabel(task) { } function _taskPort(task) { - const cmd = task?.payload?._cmd || ''; - const match = cmd.match(/--port\s+(\d+)/); - return match ? match[1] : ''; + return portOf(task?.payload?._cmd || ''); } function _buildCrashReport(task, outputText) { @@ -334,6 +349,34 @@ function _taskServerSelection(task) { return { host, server, key }; } +function _serverColorForTaskGroup(key, tasks) { + const firstTask = Array.isArray(tasks) ? tasks[0] : null; + const host = firstTask?.remoteHost || firstTask?.payload?.remote_host || ''; + const savedKey = firstTask?.remoteServerKey || firstTask?.payload?.remote_server_key || key || ''; + const server = (savedKey ? _serverByVal?.(savedKey) : null) + || (key ? _serverByVal?.(key) : null) + || (host ? _serverByVal?.(host) : null) + || (key === 'local' || !key ? (_envState?.servers || []).find(s => !s.host || String(s.host).toLowerCase() === 'local') : null) + || null; + const color = String(server?.color || '').trim(); + return /^#[0-9a-fA-F]{6}$/.test(color) ? color : ''; +} + +function _serverHeaderStyle(color) { + if (!color) return ''; + const c = color.toLowerCase(); + const accent = (c === '#ffffff' || c === '#f8fafc') ? '#cbd5e1' + : (c === '#111827' || c === '#000000') ? '#64748b' + : color; + return ` style="--cookbook-server-color:${esc(color)};--cookbook-server-accent:${esc(accent)};"`; +} + +function _shouldAutoExpandTaskOutput(task) { + return task?.type === 'download' + && !task?.payload?._dep + && ['running', 'queued', 'error', 'crashed'].includes(String(task?.status || '')); +} + function _selectTaskServer(task) { const { host, server, key } = _taskServerSelection(task); _envState.remoteHost = host; @@ -366,10 +409,11 @@ let _soloExpandTaskId = null; const TASKS_KEY = 'cookbook-tasks'; const STORAGE_KEY = 'cookbook-presets'; const SERVE_STATE_KEY = 'cookbook-serve-state'; +const SERVE_FAVORITES_KEY = 'cookbook-serve-favorite-models'; // Polling / timeout intervals const TASK_POLL_INTERVAL_MS = 3000; // delay between reconnect-loop iterations -const BG_MONITOR_INTERVAL_MS = 5000; // background task status poll +const BG_MONITOR_INTERVAL_MS = 10000; // background task status poll const STALE_PROGRESS_MS = 5 * 60 * 1000; // download with no progress this long = stale const STARTUP_STALE_PROGRESS_MS = 45 * 1000; // 0%-forever startup stall: retry much sooner @@ -455,16 +499,14 @@ function _nextAvailablePort() { const usedPorts = new Set(); tasks.forEach(t => { if (t.type === 'serve' && (t.status === 'running' || t.status === 'queued')) { - const m = t.payload?._cmd?.match(/--port\s+(\d+)/); - if (m) usedPorts.add(parseInt(m[1])); + const p = _taskPort(t); + if (p) usedPorts.add(parseInt(p)); } }); presets.forEach(p => { if (p.port) usedPorts.add(parseInt(p.port)); }); - let port = 8000; - while (usedPorts.has(port)) port++; - return String(port); + return nextFreePort(usedPorts); } // ── Endpoint cleanup ── @@ -491,7 +533,7 @@ function _refreshModelsAfterEndpointChange() { pickerLabel.innerHTML = 'refreshing…'; } if (window.modelsModule && window.modelsModule.refreshModels) { - window.modelsModule.refreshModels(true); + window.modelsModule.refreshModels(false); } setTimeout(() => { if (!window.sessionModule) return; @@ -547,6 +589,53 @@ function _endpointFromAdvertisedUrl(rawUrl, currentHost, fallbackPort = '11434') } } +function _serveExpectedModel(task) { + const fields = task?.payload?._fields || {}; + return String( + fields.served_model_name || + fields.model_path || + task?.payload?.repo_id || + task?.model || + task?.name || + '' + ).trim(); +} + +function _modelIdMatchesExpected(modelId, expected) { + const got = String(modelId || '').trim().toLowerCase(); + const want = String(expected || '').trim().toLowerCase(); + if (!got || !want) return true; + if (got === want) return true; + const gotBase = got.split('/').pop(); + const wantBase = want.split('/').pop(); + return gotBase === wantBase || got.includes(wantBase) || want.includes(gotBase); +} + +function _endpointMatchesServe(ep, task) { + const expected = _serveExpectedModel(task); + const models = [...(ep?.models || []), ...(ep?.pinned_models || [])]; + if (!models.length) return true; + return models.some(mid => _modelIdMatchesExpected(mid, expected)); +} + +function _markServeEndpointMismatch(task, ep, host, port) { + const expected = _serveExpectedModel(task); + const actual = (ep?.models || []).join(', ') || 'no models'; + const msg = `Port ${host}:${port} answered, but it is serving ${actual}, not ${expected || task?.name || 'the launched model'}. The new serve likely failed or the port is occupied by an older server.`; + _updateTask(task.sessionId || task.session_id, { + status: 'error', + _serveReady: false, + _endpointAdded: false, + output: `${task.output || ''}\n\n${msg}`.trim(), + }); + uiModule.showError(msg); +} + +function _appendPinnedServeModel(fd, task) { + const expected = _serveExpectedModel(task); + if (expected) fd.append('pinned_models', expected); +} + // ── Download queue — runs one at a time per server ── function _processQueue() { @@ -766,6 +855,11 @@ function _redactStoredText(value) { .replace(/((?:api[_-]?key|token|authorization|password|passwd|secret)\s*[=:]\s*)(["']?)[^\s"']+/gi, '$1$2[redacted]'); } +function _isServeOutputPlaceholder(value) { + const text = String(value || '').trim(); + return !text || /^Launched via agent\s+—\s+waiting for tmux output/i.test(text); +} + function _redactTaskForStorage(task) { if (!task || typeof task !== 'object') return task; const safe = { ...task }; @@ -784,6 +878,7 @@ function _stripStateSecrets(state) { const safe = { ...state }; if (safe.env && typeof safe.env === 'object') { const { hfToken, ...env } = safe.env; + delete env.hostPlatform; safe.env = env; } if (Array.isArray(safe.tasks)) safe.tasks = safe.tasks.map(_redactTaskForStorage); @@ -883,18 +978,27 @@ function _animateOutThenRemove(el, sessionId) { // ── tmux / Windows session commands ── +function _taskRemoteHost(task) { + return task?.remoteHost || task?.payload?.remote_host || ''; +} + +function _remoteTmuxPrefix() { + return 'PATH="$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"; '; +} + export function _tmuxCmd(task, tmuxArgs) { if (_isWindows(task)) { return _winSessionCmd(task, tmuxArgs); } - if (task.remoteHost) { - return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} 'tmux ${tmuxArgs}' 2>/dev/null`; + const host = _taskRemoteHost(task); + if (host) { + return `ssh ${_sshPrefix(_getPort(task))}${host} '${_remoteTmuxPrefix()}tmux ${tmuxArgs}' 2>/dev/null`; } return `tmux ${tmuxArgs} 2>/dev/null`; } function _winSessionCmd(task, tmuxArgs) { - const host = task.remoteHost; + const host = _taskRemoteHost(task); const sd = host ? '$env:TEMP\\odysseus-sessions' : '$env:TEMP\\odysseus-tmux'; const sid = task.sessionId; const pf = _sshPrefix(_getPort(task)); @@ -921,17 +1025,18 @@ function _winSessionCmd(task, tmuxArgs) { : `$p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -ErrorAction SilentlyContinue }`; return _winPowerShellCmd(task, ps); } - return host ? `ssh ${pf}${host} 'tmux ${tmuxArgs}' 2>/dev/null` : `tmux ${tmuxArgs} 2>/dev/null`; + return host ? `ssh ${pf}${host} '${_remoteTmuxPrefix()}tmux ${tmuxArgs}' 2>/dev/null` : `tmux ${tmuxArgs} 2>/dev/null`; } function _winPowerShellCmd(task, ps) { const command = `powershell -Command "${ps}"`; - if (!task.remoteHost) return command; - return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} ${_shQuote(command)}`; + const host = _taskRemoteHost(task); + if (!host) return command; + return `ssh ${_sshPrefix(_getPort(task))}${host} ${_shQuote(command)}`; } function _winSessionStopTreePs(task) { - const host = task.remoteHost; + const host = _taskRemoteHost(task); const sd = host ? '$env:TEMP\\odysseus-sessions' : '$env:TEMP\\odysseus-tmux'; const sid = task.sessionId; const stopTree = `function Stop-Tree([int]$Id) { Get-CimInstance Win32_Process -Filter ('ParentProcessId = ' + $Id) -ErrorAction SilentlyContinue | ForEach-Object { Stop-Tree ([int]$_.ProcessId) }; Stop-Process -Id $Id -Force -ErrorAction SilentlyContinue }`; @@ -945,8 +1050,9 @@ export function _tmuxGracefulKill(task) { const ps = _winSessionStopTreePs(task); return _winPowerShellCmd(task, ps); } - if (task.remoteHost) { - return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} 'tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null'`; + const host = _taskRemoteHost(task); + if (host) { + return `ssh ${_sshPrefix(_getPort(task))}${host} '${_remoteTmuxPrefix()}tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null'`; } return `tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null`; } @@ -971,8 +1077,9 @@ export function _tmuxForceKill(task) { ` done; ` + `fi; ` + `tmux kill-session -t ${sid} 2>/dev/null`; - if (task.remoteHost) { - return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} ${_shQuote(inner)}`; + const host = _taskRemoteHost(task); + if (host) { + return `ssh ${_sshPrefix(_getPort(task))}${host} ${_shQuote(_remoteTmuxPrefix() + inner)}`; } return inner; } @@ -987,8 +1094,9 @@ export function _tmuxIsAliveCheck(task) { } const sid = task.sessionId; const inner = `if tmux has-session -t ${sid} 2>/dev/null; then echo ALIVE; else echo DEAD; fi`; - if (task.remoteHost) { - return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} ${_shQuote(inner)}`; + const host = _taskRemoteHost(task); + if (host) { + return `ssh ${_sshPrefix(_getPort(task))}${host} ${_shQuote(_remoteTmuxPrefix() + inner)}`; } return inner; } @@ -1023,8 +1131,9 @@ function _ollamaUnloadCommand(task, outputText = '') { const base = _ollamaBaseUrlForTask(task, outputText); const body = JSON.stringify({ model, prompt: '', keep_alive: 0, stream: false }); const inner = `curl -sf -X POST ${_shQuote(base + '/api/generate')} -H 'Content-Type: application/json' -d ${_shQuote(body)} >/dev/null 2>&1 || true`; - if (task.remoteHost) { - return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} ${_shQuote(inner)}`; + const host = _taskRemoteHost(task); + if (host) { + return `ssh ${_sshPrefix(_getPort(task))}${host} ${_shQuote(inner)}`; } return inner; } @@ -1033,7 +1142,7 @@ function _endpointUrlForTask(task, outputText = '') { if (_taskLooksOllama(task, outputText)) { return _ollamaBaseUrlForTask(task, outputText) + '/v1'; } - const host = _connectHostFromRemote(task.remoteHost); + const host = _connectHostFromRemote(_taskRemoteHost(task)); const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/); const port = portMatch ? portMatch[1] : '8000'; return `http://${host}:${port}/v1`; @@ -1211,8 +1320,13 @@ function _syncToServer() { presets: _loadPresets(), env: _envState, serveState: null, + serveFavorites: [], }; try { state.serveState = JSON.parse(localStorage.getItem(SERVE_STATE_KEY)); } catch {} + try { + const favorites = JSON.parse(localStorage.getItem(SERVE_FAVORITES_KEY) || '[]'); + state.serveFavorites = Array.isArray(favorites) ? favorites.filter(Boolean).map(String) : []; + } catch {} await fetch('/api/cookbook/state', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, @@ -1222,6 +1336,10 @@ function _syncToServer() { }, 400); } +document.addEventListener('cookbook:state-dirty', () => { + _syncToServer(); +}); + // Normalize state from server: collapse legacy duplicate keys to canonical form. // - server.modelDir (singular) → server.modelDirs[0] (canonical) // - strip ✕/✖ pollution from modelDirs @@ -1303,6 +1421,9 @@ export async function _syncFromServer() { if (state.serveState) { localStorage.setItem(SERVE_STATE_KEY, JSON.stringify(state.serveState)); } + if (Array.isArray(state.serveFavorites)) { + localStorage.setItem(SERVE_FAVORITES_KEY, JSON.stringify(state.serveFavorites.filter(Boolean).map(String))); + } document.dispatchEvent(new CustomEvent('cookbook:state-synced', { detail: state })); return true; } catch { return false; } @@ -1370,6 +1491,7 @@ async function _retryDownload(name, payload, replaceSessionId = '') { const tasks = _loadTasks(); const task = tasks.find(t => t.sessionId === replaceSessionId); if (task) { + task.name = _downloadNameFromPayload(name || task.name, _payload); task.id = data.session_id; task.sessionId = data.session_id; task.status = 'running'; @@ -1496,6 +1618,11 @@ export async function _serveAutoRetryReplace(panel, flag, value) { _animateOutThenRemove(taskEl, taskId); let newCmd = task.payload._cmd; + if (flag === '--cuda-graph-backend-decode') { + newCmd = newCmd.replace(/\s+--cuda-graph-max-bs-decode(?:\s+\S+|=\S+)/g, ''); + } else if (flag === '--cuda-graph-max-bs-decode') { + newCmd = newCmd.replace(/\s+--cuda-graph-backend-decode(?:\s+\S+|=\S+)/g, ''); + } const re = new RegExp(flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s+\\S+'); if (re.test(newCmd)) { newCmd = newCmd.replace(re, `${flag} ${value}`); @@ -1627,6 +1754,7 @@ function _parseServeCmdToFields(cmd) { const ex = (re) => { const m = cmd.match(re); return m ? m[1] : ''; }; const fields = { backend: cmd.includes('llama_cpp') || cmd.includes('llama-server') ? 'llamacpp' + : cmd.includes('mlx_lm.server') ? 'mlx' : cmd.includes('diffusion_server') ? 'diffusers' : cmd.includes('sglang') ? 'sglang' : cmd.includes('ollama') ? 'ollama' : 'vllm', @@ -1664,6 +1792,100 @@ function _parseServeCmdToFields(cmd) { return fields; } +function _serveCmdNeedsGpuPreflight(cmd, repo) { + const c = String(cmd || '').toLowerCase(); + const r = String(repo || '').toLowerCase(); + if (!c || /gpu-cleanup|sglang-kernel|mlx-lm|pip\s+install|python\d*\s+-m\s+pip/.test(`${r} ${c}`)) return false; + return /\b(vllm\s+serve|sglang(?:\.launch_server|\s+serve)|mlx_lm\.server|llama-server|llama_cpp\.server|text-generation-launcher|aphrodite|ollama\s+(?:serve|run))\b/.test(c); +} + +function _selectedGpuIndexes(gpus) { + const raw = String(gpus || '').trim(); + if (!raw) return null; + const out = new Set(); + raw.split(',').forEach(part => { + const p = part.trim(); + const range = p.match(/^(\d+)\s*-\s*(\d+)$/); + if (range) { + const a = parseInt(range[1], 10); + const b = parseInt(range[2], 10); + for (let i = Math.min(a, b); i <= Math.max(a, b); i++) out.add(i); + return; + } + const n = parseInt(p, 10); + if (Number.isFinite(n)) out.add(n); + }); + return out.size ? out : null; +} + +function _gbFromMb(mb) { + const n = Number(mb || 0); + if (!Number.isFinite(n) || n <= 0) return ''; + return n >= 1024 ? `${(n / 1024).toFixed(n >= 10240 ? 0 : 1)}G` : `${Math.round(n)}M`; +} + +function _gpuPreflightIssues(data, selected) { + const backend = String(data?.backend || data?.source || '').toLowerCase(); + const isCuda = backend.includes('cuda') || String(data?.source || '').toLowerCase().includes('nvidia'); + const rows = Array.isArray(data?.gpus) ? data.gpus : []; + const issues = []; + rows.forEach(g => { + const idx = Number(g?.index); + if (selected && !selected.has(idx)) return; + const procs = Array.isArray(g?.processes) ? g.processes : []; + if (procs.length) { + procs.slice(0, 3).forEach(p => { + const name = String(p?.name || 'process').split(/[\\/]/).pop(); + const used = _gbFromMb(p?.used_mb); + issues.push(`GPU ${idx}: ${name}${p?.pid ? ` #${p.pid}` : ''}${used ? ` (${used})` : ''}`); + }); + if (procs.length > 3) issues.push(`GPU ${idx}: +${procs.length - 3} more process${procs.length - 3 === 1 ? '' : 'es'}`); + return; + } + const total = Number(g?.total_mb || 0); + const free = Number(g?.free_mb || 0); + const used = Number(g?.used_mb || 0); + const freeRatio = total > 0 ? free / total : 1; + // CUDA can have display/runtime crumbs; warn only for meaningful occupied memory. + if (isCuda && used > 4096 && freeRatio < 0.9) { + issues.push(`GPU ${idx}: ${_gbFromMb(used)} already used (${_gbFromMb(free)} free)`); + } else if (!isCuda && total > 0 && freeRatio < 0.2) { + issues.push(`${g?.name || `GPU ${idx}`}: low free memory (${_gbFromMb(free)} free of ${_gbFromMb(total)})`); + } else if (!isCuda && g?.busy && total <= 0) { + issues.push(`${g?.name || `GPU ${idx}`}: GPU device is busy`); + } + }); + return issues; +} + +async function _confirmGpuPreflight(reqBody, shortName, repo, cmd) { + if (!_serveCmdNeedsGpuPreflight(cmd, repo)) return true; + const params = new URLSearchParams(); + if (reqBody.remote_host) params.set('host', reqBody.remote_host); + if (reqBody.ssh_port) params.set('ssh_port', reqBody.ssh_port); + try { + const res = await fetch(`/api/cookbook/gpus${params.toString() ? `?${params.toString()}` : ''}`, { + method: 'GET', + credentials: 'same-origin', + }); + const data = await res.json().catch(() => null); + if (!res.ok || !data?.ok) return true; + const selected = _selectedGpuIndexes(reqBody.gpus); + const issues = _gpuPreflightIssues(data, selected); + if (!issues.length) return true; + const where = reqBody.remote_host || 'local'; + const list = issues.slice(0, 6).join('; '); + const more = issues.length > 6 ? `; +${issues.length - 6} more` : ''; + const msg = `GPU preflight found existing load on ${where}: ${list}${more}. Launch ${shortName || 'model'} anyway?`; + const confirm = window.styledConfirm || uiModule?.styledConfirm; + if (confirm) return await confirm(msg, { confirmText: 'Launch anyway', cancelText: 'Cancel' }); + return window.confirm ? window.confirm(msg) : true; + } catch (e) { + console.warn('[cookbook] GPU preflight failed; allowing launch', e); + return true; + } +} + export async function _launchServeTask(shortName, repo, cmd, fields, hostOverride, targetMeta = null) { // Host resolution mirrors the download path: when the caller passes an explicit // host (resolved from the dropdown the user actually picked), use it and look @@ -1676,7 +1898,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid || _envState.servers.find(s => s.host === _host) || {}; const _serverMetaKey = _targetKey || (_hsrv && _serverKey ? _serverKey(_hsrv) : '') || (_host || 'local'); const _serverMetaName = targetMeta?.serverName || _hsrv.name || (_host ? _host : 'Local'); - const _hplatform = _host ? (_hsrv.platform || '') : (_envState.platform || ''); + const _hplatform = _host ? (_hsrv.platform || '') : (_envState.hostPlatform || ''); const _replaceTaskId = fields?._replaceTaskId || ''; if (_replaceTaskId) { try { @@ -1691,7 +1913,6 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid } } catch {} } - // Replace any serve already targeting this same host:port — you can't run two // servers on one port, so re-serving (or retrying) should stop & remove the // old one instead of leaving a dead duplicate behind. (The retry buttons @@ -1748,11 +1969,16 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid ssh_port: _getPort(_serverMetaKey || _host) || undefined, env_prefix: envPrefix || undefined, hf_token: _envState.hfToken || undefined, - gpus: _envState.gpus || undefined, + gpus: _usedGpus || undefined, platform: _hplatform || undefined, }; try { + const _preflightOk = await _confirmGpuPreflight(reqBody, shortName, repo, cmd); + if (!_preflightOk) { + uiModule.showToast('Launch cancelled — GPU is already in use'); + return; + } const res = await fetch('/api/model/serve', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, @@ -1862,6 +2088,7 @@ export function _renderRunningTab() { body.querySelectorAll('.cookbook-group').forEach(g => { g.classList.toggle('hidden', g.dataset.backendGroup !== 'Running'); }); + setTimeout(() => _renderRunningTab(), 0); }); } else if (runTab) { const _errCount2 = tasks.filter(t => t.status === 'error' || t.status === 'crashed').length; @@ -1975,7 +2202,8 @@ export function _renderRunningTab() { // green when reachable, red if any serve task on it is crashed/unreachable. const _secDot = (key && allTasks.some(_serveTaskFailed)) ? 'fail' : 'ok'; const _dotTitle = key ? (_secDot === 'fail' ? 'Server not responding' : 'Reachable') : 'Local (this machine)'; - sec.insertAdjacentHTML('afterbegin', `
${esc(sg.name)}
`); + const _srvColor = _serverColorForTaskGroup(key || 'local', allTasks); + sec.insertAdjacentHTML('afterbegin', `
${esc(sg.name)}
`); } } @@ -2108,6 +2336,12 @@ export function _renderRunningTab() { } const startNow = el.querySelector('.cookbook-task-start-now'); if (startNow) startNow.style.display = (task.type === 'download' && task.status === 'queued') ? '' : 'none'; + const pre = el.querySelector('.cookbook-output-pre'); + if (pre && typeof task.output === 'string' && task.output && pre.textContent !== task.output) { + const atBottom = (pre.scrollHeight - pre.scrollTop - pre.clientHeight) < 40; + pre.textContent = task.output; + if (atBottom) pre.scrollTop = pre.scrollHeight; + } const terminalDiag = _terminalServeDiagnosis(task, el.querySelector('.cookbook-output-pre')?.textContent || task.output || ''); if (terminalDiag) { _showDiagnosis(el, terminalDiag, el.querySelector('.cookbook-output-pre')?.textContent || task.output || ''); @@ -2150,7 +2384,7 @@ export function _renderRunningTab() {
${esc(task.sessionId)}${(task.type === 'download') ? `Dir: ${esc(task.payload?.local_dir || '~/.cache/huggingface/hub')}` : ''}
-
${esc(task.output || '')}
+
${esc(task.output || '')}
`; const _waveEl = el.querySelector('.cookbook-task-wave'); @@ -2285,7 +2519,23 @@ export function _renderRunningTab() { el.querySelector('.cookbook-task-header').addEventListener('click', (e) => { if (e.target.closest('button')) return; const wrap = el.querySelector('.cookbook-output-wrap'); - if (wrap) wrap.classList.toggle('cookbook-task-collapsed'); + if (!wrap) return; + const isOpening = wrap.classList.contains('cookbook-task-collapsed'); + wrap.classList.toggle('cookbook-task-collapsed'); + if (isOpening) { + _expandedTaskIds.add(task.sessionId); + _collapsedTaskIds.delete(task.sessionId); + if (task.sessionId && ['serve', 'download'].includes(task.type || '')) { + _reconnectTask(el, task); + } + } else { + _collapsedTaskIds.add(task.sessionId); + _expandedTaskIds.delete(task.sessionId); + if (el._abort) { + try { el._abort.abort(); } catch {} + el._abort = null; + } + } }); // Wire menu button (also fire from a long-press anywhere on the card so @@ -2324,10 +2574,18 @@ export function _renderRunningTab() { el.addEventListener('touchcancel', _lpCancel, { passive: true }); menuBtn.addEventListener('click', (e) => { e.stopPropagation(); + const existing = document.querySelector('.cookbook-task-dropdown'); + if (existing && existing._anchor === menuBtn) { + if (typeof existing._dismiss === 'function') existing._dismiss(); + else existing.remove(); + return; + } document.querySelectorAll('.cookbook-task-dropdown').forEach(d => { if (typeof d._dismiss === 'function') d._dismiss(); else d.remove(); }); const dropdown = document.createElement('div'); dropdown.className = 'cookbook-task-dropdown'; + dropdown._anchor = menuBtn; + menuBtn.classList.add('cookbook-menu-active'); const items = []; // ── Run section ───────────────────────────────────────────── @@ -2529,7 +2787,7 @@ export function _renderRunningTab() { } const closeHandler = (ev) => { - if (!dropdown.contains(ev.target) && ev.target !== menuBtn) { + if (!dropdown.contains(ev.target) && ev.target !== menuBtn && !menuBtn.contains(ev.target)) { _cleanup(); } }; @@ -2541,6 +2799,7 @@ export function _renderRunningTab() { const _cleanup = () => { _unreg(); _unreg = () => {}; dropdown.remove(); + menuBtn.classList.remove('cookbook-menu-active'); document.removeEventListener('click', closeHandler); window.removeEventListener('scroll', scrollClose, true); window.visualViewport?.removeEventListener('scroll', scrollClose); @@ -2712,7 +2971,9 @@ export function _renderRunningTab() { // responds; without this, the user opens the Running tab and sees // only the placeholder ("Launched by scheduled task …") because // _reconnectTask never fires for status 'ready'/'loading'/'warming'. - if (['running', 'ready', 'loading', 'warming', 'starting'].includes(task.status)) { + const _wrapForStream = el.querySelector('.cookbook-output-wrap'); + const _streamExpanded = _wrapForStream && !_wrapForStream.classList.contains('cookbook-task-collapsed'); + if (_isRunningTabVisible() && _streamExpanded && task.sessionId && ['serve', 'download'].includes(task.type || '')) { _reconnectTask(el, task); } } @@ -2744,13 +3005,19 @@ export function _renderRunningTab() { // ── Reconnect task (polling loop) ── async function _reconnectTask(el, task) { + if (!el || !task) return; + const wrap = el.querySelector('.cookbook-output-wrap'); + if (!_isRunningTabVisible() || !wrap || wrap.classList.contains('cookbook-task-collapsed')) return; + if (el._abort && !el._abort.signal?.aborted) return; const output = el.querySelector('.cookbook-output-pre'); + if (!output) return; const controller = new AbortController(); el._abort = controller; let failCount = 0; while (!controller.signal.aborted) { - if (!el.isConnected) { + const liveWrap = el.querySelector('.cookbook-output-wrap'); + if (!el.isConnected || !_isRunningTabVisible() || !liveWrap || liveWrap.classList.contains('cookbook-task-collapsed')) { controller.abort(); break; } @@ -2758,7 +3025,7 @@ async function _reconnectTask(el, task) { const res = await fetch('/api/shell/exec', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ command: _tmuxCmd(task, `capture-pane -t ${task.sessionId} -p -S -200`), timeout: 15 }), + body: JSON.stringify({ command: _tmuxCmd(task, `capture-pane -t ${task.sessionId} -p -S -500`), timeout: 15 }), }); const data = await res.json(); @@ -3338,13 +3605,17 @@ async function _reconnectTask(el, task) { // endpoints server-side. Mark so we don't retry, but STILL // refresh the picker (and probe until online) so the new model // shows up without the user having to manually refresh. + const _ex = eps.find(e => e.base_url === baseUrl); + if (_ex && !_endpointMatchesServe(_ex, task)) { + _markServeEndpointMismatch(task, _ex, host, port); + return null; + } task._endpointAdded = true; _updateTask(task.sessionId, { _endpointAdded: true }); _autoSaveWorkingConfig(task); // endpoint live → remember these settings - if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(true); + if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(false); if (window.sessionModule?.updateModelPicker) window.sessionModule.updateModelPicker(); window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated', { detail: { baseUrl, host, port, model: task.name } })); - const _ex = eps.find(e => e.base_url === baseUrl); if (_ex && _ex.id && !(_ex.models || []).length) _probeEndpointUntilOnline(_ex.id, host, port); return null; } @@ -3354,6 +3625,7 @@ async function _reconnectTask(el, task) { fd.append('name', task.name); fd.append('skip_probe', 'true'); _appendCookbookEndpointScope(fd, task.remoteHost || ''); + _appendPinnedServeModel(fd, task); if (_isDiffusion) fd.append('model_type', 'image'); return fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd }); }) @@ -3372,7 +3644,7 @@ async function _reconnectTask(el, task) { } window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated', { detail: { baseUrl, host, port, model: task.name } })); const _trySelectModel = async (attempt) => { - if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(true); + if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(false); const items = window.modelsModule?.getCachedItems?.() || []; for (const item of items) { if (item.offline) continue; @@ -3437,68 +3709,154 @@ async function _reconnectTask(el, task) { // ── Background monitor ── let _bgMonitorInterval = null; +let _bgPollInFlight = false; +const BG_LEADER_KEY = 'odysseus-cookbook-bg-leader'; +const BG_LEADER_ID = `${Date.now()}-${Math.random().toString(36).slice(2)}`; +const BG_LEADER_TTL_MS = 15000; + +function _hasLiveTasks(tasks = null) { + const list = tasks || _loadTasks(); + return list.some(t => + t.status === 'running' + || t.status === 'queued' + || t.status === 'ready' + || _downloadOutputLooksActive(t) + ); +} + +function _isRunningTabVisible() { + const modal = document.getElementById('cookbook-modal'); + if (!modal || modal.classList.contains('hidden')) return false; + const activeTab = modal.querySelector('.cookbook-tab.active')?.dataset?.backend || ''; + return activeTab === 'Running'; +} + +function _isCookbookVisible() { + try { + if (window.cookbookModule && typeof window.cookbookModule.isVisible === 'function') { + return !!window.cookbookModule.isVisible(); + } + } catch (_) {} + const modal = document.getElementById('cookbook-modal'); + return !!modal && !modal.classList.contains('hidden'); +} + +function _foregroundChatBusy() { + try { + return !!window.__odysseusChatBusy || Date.now() < (window.__odysseusChatBusyUntil || 0); + } catch { + return false; + } +} + +function _claimBackgroundLeader() { + if (document.visibilityState !== 'visible') return false; + const now = Date.now(); + try { + const raw = localStorage.getItem(BG_LEADER_KEY); + const current = raw ? JSON.parse(raw) : null; + if ( + !current + || !current.id + || current.id === BG_LEADER_ID + || now - Number(current.ts || 0) > BG_LEADER_TTL_MS + ) { + localStorage.setItem(BG_LEADER_KEY, JSON.stringify({ id: BG_LEADER_ID, ts: now })); + return true; + } + return current.id === BG_LEADER_ID; + } catch (_) { + return true; + } +} + +function _canBackgroundPoll() { + if (_foregroundChatBusy()) return false; + if (document.visibilityState !== 'visible') return false; + return _claimBackgroundLeader(); +} // Reachability check for running serve tasks. The tmux pane can stay alive // while the model server inside it has crashed (so no "Process exited" line // ever appears) — leaving the card showing "running" forever. So we actively // probe the registered endpoint (same /probe-local the model picker uses) and // flag the card "unreachable" (red) when the server stops answering. +let _serveReachabilityInFlight = false; +let _serveReachabilityLastAt = 0; async function _checkServeReachability() { + // This reaches out to local model servers. Keep it out of the normal chat + // path unless the user is actively looking at the Running tab. + if (_foregroundChatBusy()) return; + if (!_isRunningTabVisible()) return; + const now = Date.now(); + if (_serveReachabilityInFlight || now - _serveReachabilityLastAt < 10000) return; + _serveReachabilityInFlight = true; + _serveReachabilityLastAt = now; let serveTasks; try { serveTasks = _loadTasks().filter(t => t.type === 'serve' && t.status === 'running'); - } catch { return; } - if (!serveTasks.length) return; + } catch { + _serveReachabilityInFlight = false; + return; + } + if (!serveTasks.length) { + _serveReachabilityInFlight = false; + return; + } let eps = [], probe = {}; try { [eps, probe] = await Promise.all([ fetch('/api/model-endpoints', { credentials: 'same-origin' }).then(r => r.json()).catch(() => []), fetch('/api/model-endpoints/probe-local', { credentials: 'same-origin' }).then(r => r.json()).catch(() => ({})), ]); - } catch { return; } - for (const task of serveTasks) { - const host = _connectHostFromRemote(task.remoteHost); - const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/); - const port = portMatch ? portMatch[1] : '8000'; - const baseUrl = `http://${host}:${port}/v1`; - const ep = (eps || []).find(e => e.base_url === baseUrl); - if (!ep) continue; // not registered yet — can't judge - const pr = probe[ep.id]; - if (!pr || pr.alive === undefined) continue; // not probed (non-local) — skip - // Record the first time it actually answers. Until then the server is still - // LOADING/warming (the endpoint can get registered on the 300s timeout for a - // big model that hasn't finished loading), and a not-yet-answering server is - // not "unreachable" — flagging it as such while you're launching is a false - // alarm. Only treat it as unreachable once it has been reachable at least once. - if (pr.alive === true && !task._everReachable) { - task._everReachable = true; - _updateTask(task.sessionId, { _everReachable: true }); - } - const unreachable = pr.alive === false; - if (unreachable && !task._everReachable) continue; // still coming up, not crashed - if (!!task._unreachable !== unreachable) { - _updateTask(task.sessionId, { _unreachable: unreachable }); - } - const el = document.querySelector(`.cookbook-task[data-task-id="${task.sessionId}"]`); - if (el) { - el.classList.toggle('cookbook-task-unreachable', unreachable); - const badge = el.querySelector('.cookbook-task-status'); - if (badge) { - if (unreachable) { - badge.textContent = 'unreachable'; - badge.className = 'cookbook-task-status cookbook-task-error'; - badge.title = pr.error || 'Server not responding — it may have crashed'; - } else if (badge.textContent === 'unreachable') { - // Recovered — restore the normal running label. - badge.textContent = _statusLabel('running', task.type); - badge.className = 'cookbook-task-status cookbook-task-running'; - badge.title = ''; + for (const task of serveTasks) { + const host = _connectHostFromRemote(task.remoteHost); + const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/); + const port = portMatch ? portMatch[1] : '8000'; + const baseUrl = `http://${host}:${port}/v1`; + const ep = (eps || []).find(e => e.base_url === baseUrl); + if (!ep) continue; // not registered yet — can't judge + const pr = probe[ep.id]; + if (!pr || pr.alive === undefined) continue; // not probed (non-local) — skip + // Record the first time it actually answers. Until then the server is still + // LOADING/warming (the endpoint can get registered on the 300s timeout for a + // big model that hasn't finished loading), and a not-yet-answering server is + // not "unreachable" — flagging it as such while you're launching is a false + // alarm. Only treat it as unreachable once it has been reachable at least once. + if (pr.alive === true && !task._everReachable) { + task._everReachable = true; + _updateTask(task.sessionId, { _everReachable: true }); + } + const unreachable = pr.alive === false; + if (unreachable && !task._everReachable) continue; // still coming up, not crashed + if (!!task._unreachable !== unreachable) { + _updateTask(task.sessionId, { _unreachable: unreachable }); + } + const el = document.querySelector(`.cookbook-task[data-task-id="${task.sessionId}"]`); + if (el) { + el.classList.toggle('cookbook-task-unreachable', unreachable); + const badge = el.querySelector('.cookbook-task-status'); + if (badge) { + if (unreachable) { + badge.textContent = 'unreachable'; + badge.className = 'cookbook-task-status cookbook-task-error'; + badge.title = pr.error || 'Server not responding — it may have crashed'; + } else if (badge.textContent === 'unreachable') { + // Recovered — restore the normal running label. + badge.textContent = _statusLabel('running', task.type); + badge.className = 'cookbook-task-status cookbook-task-running'; + badge.title = ''; + } } } + if (unreachable) _showCookbookNotif(true); } - if (unreachable) _showCookbookNotif(true); + _refreshServerDots(); + } catch { + // Non-fatal: the normal task status poll continues separately. + } finally { + _serveReachabilityInFlight = false; } - _refreshServerDots(); } function _serveTaskFailed(task) { @@ -3650,16 +4008,21 @@ export async function _selfHealStaleTasks(opts = {}) { export function _startBackgroundMonitor() { if (_bgMonitorInterval) return; _bgMonitorInterval = setInterval(() => { + if (!_canBackgroundPoll()) return; _pollBackgroundStatus(); _checkServeReachability(); // Auto-reconnect: every cycle, look for download tasks marked finished/ // crashed/etc. whose tmux session is actually still running, and flip // them back to running. Internally throttled to 8s so a manual call from // the open path or a fast invocation doesn't double up. - _selfHealStaleTasks().catch(() => {}); + if (_hasLiveTasks() || _isRunningTabVisible()) { + _selfHealStaleTasks().catch(() => {}); + } }, BG_MONITOR_INTERVAL_MS); - _pollBackgroundStatus(); - _checkServeReachability(); + if (_canBackgroundPoll()) { + _pollBackgroundStatus(); + _checkServeReachability(); + } } function _stopBackgroundMonitor() { @@ -3679,6 +4042,7 @@ function _stopBackgroundMonitor() { // the endpoint reports models, then refreshes the picker. Bounded so a // genuinely-dead server doesn't poll forever. async function _probeEndpointUntilOnline(epId, host, port) { + if (!_isCookbookVisible() || _foregroundChatBusy()) return; if (!epId) return; // Big models (e.g. 70B+) can take several minutes to load weights before // the server answers /v1/models. Probe for up to ~5 min, easing the @@ -3687,6 +4051,7 @@ async function _probeEndpointUntilOnline(epId, host, port) { for (let i = 0; i < MAX_TRIES; i++) { const interval = i < 12 ? 5000 : 10000; // 5s for the first minute, then 10s await new Promise(r => setTimeout(r, interval)); + if (!_isCookbookVisible() || _foregroundChatBusy()) return; try { // Hit the probe endpoint — it re-probes server-side and updates // cached_models. We consume (and discard) the SSE stream. @@ -3696,7 +4061,7 @@ async function _probeEndpointUntilOnline(epId, host, port) { const eps = await fetch('/api/model-endpoints', { credentials: 'same-origin' }).then(r => r.json()).catch(() => []); const ep = (eps || []).find(e => e.id === epId); if (ep && (ep.models || []).length) { - if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(true); + if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(false); if (window.sessionModule?.updateModelPicker) window.sessionModule.updateModelPicker(); window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated', { detail: { baseUrl: ep.base_url || `http://${host}:${port}/v1`, host, port, model: (ep.models || [])[0] || '' }, @@ -3709,6 +4074,8 @@ async function _probeEndpointUntilOnline(epId, host, port) { } async function _pollBackgroundStatus() { + if (!_canBackgroundPoll() || _bgPollInFlight) return; + _bgPollInFlight = true; try { // Pull any tasks the server knows about that aren't in localStorage // yet (e.g. agent-spawned downloads/serves). Without this merge, @@ -3752,6 +4119,34 @@ async function _pollBackgroundStatus() { const localTasks = _loadTasks(); let changed = false; const completedDeps = []; + const localIds = new Set(localTasks.map(t => t.sessionId).filter(Boolean)); + for (const live of tasks) { + const sid = live?.session_id; + if (!sid || localIds.has(sid) || _isTombstoned(sid)) continue; + const liveType = live.type || 'download'; + const liveStatus = live.status === 'completed' ? 'done' : (live.status || 'running'); + const name = live.model || sid; + const remoteHost = live.remote && live.remote !== 'local' ? live.remote : ''; + localTasks.push(_redactTaskForStorage({ + id: sid, + sessionId: sid, + name, + type: liveType, + status: liveStatus, + progress: live.progress || '', + output: live.output_tail || '', + ts: Date.now(), + payload: { + repo_id: name, + remote_host: remoteHost, + _cmd: live.cmd || '(adopted from live tmux status)', + }, + remoteHost, + _adoptedExternally: true, + })); + localIds.add(sid); + changed = true; + } for (const task of localTasks) { const live = statusById.get(task.sessionId); if (!live) continue; @@ -3760,7 +4155,8 @@ async function _pollBackgroundStatus() { // "stopped" by the backend (its pip package is never in the HF cache the // dead-session check inspects). Recover "done" from the retained output's // exit-0 sentinel so a clean install isn't downgraded to crashed. - const depDone = !!task.payload?._dep && _depInstallSucceeded(task.output); + const combinedOutput = `${task.output || ''}\n${live.output_tail || ''}`; + const depDone = !!task.payload?._dep && _depInstallSucceeded(combinedOutput); // A finished model download whose tmux pane is gone is also reported // "stopped" (the dead-session check can miss the landed snapshot). // Recover "done" from the terminal `DOWNLOAD_OK` sentinel — emitted @@ -3770,19 +4166,29 @@ async function _pollBackgroundStatus() { // off the conclusive exit sentinel only, never the `/snapshots/` path, // which can be printed mid-stream for multi-file downloads. const downloadDone = task.type === 'download' - && String(task.output || '').includes('DOWNLOAD_OK'); - const nextStatus = live.status === 'completed' + && String(combinedOutput || '').includes('DOWNLOAD_OK'); + const serveReady = task.type === 'serve' + && (live.status === 'ready' || _serveOutputLooksReady({ ...task, output: live.output_tail || task.output || '' })); + const completedByOutput = depDone || downloadDone; + const nextStatus = completedByOutput + ? 'done' + : (serveReady + ? 'ready' + : (live.status === 'completed' ? 'done' : (live.status === 'error' ? 'error' : (live.status === 'stopped' ? ((depDone || downloadDone) ? 'done' : (task.type === 'download' ? 'crashed' : 'stopped')) - : null)); + : null)))); if (nextStatus && task.status !== nextStatus) { updates.status = nextStatus; if (nextStatus === 'done' && task.payload?._dep) completedDeps.push(task); } - if ((live.status === 'running' || live.status === 'ready') && task.status !== live.status) { + if (serveReady && !task._serveReady) { + updates._serveReady = true; + } + if ((live.status === 'running' || live.status === 'ready') && task.status !== live.status && !serveReady && !completedByOutput) { updates.status = live.status === 'ready' ? 'ready' : 'running'; } if (live.progress && live.progress !== task.progress) updates.progress = live.progress; @@ -3791,7 +4197,9 @@ async function _pollBackgroundStatus() { const previous = String(task.output || ''); const tail = String(live.output_tail || ''); if (tail && !previous.endsWith(tail)) { - updates.output = `${previous ? `${previous}\n` : ''}${tail}`.slice(-5000); + updates.output = _isServeOutputPlaceholder(previous) + ? tail.slice(-5000) + : `${previous ? `${previous}\n` : ''}${tail}`.slice(-5000); } } if (live.diagnosis && !task._diagnosisDismissed) { @@ -3860,6 +4268,11 @@ async function _pollBackgroundStatus() { const hostPort = `${host}:${port}`; const existing = eps.find(e => e.base_url === baseUrl || e.base_url.includes(hostPort) || e.name === t.model); if (existing) { + const taskForMatch = localTask || { sessionId: t.session_id, name: t.model, model: t.model, payload: { repo_id: t.model, _cmd } }; + if (!_endpointMatchesServe(existing, taskForMatch)) { + _markServeEndpointMismatch(taskForMatch, existing, host, port); + return null; + } // Already registered — but it may be showing offline because // it was added while the server was still warming. Kick a // re-probe so it flips online without manual toggle. @@ -3871,6 +4284,7 @@ async function _pollBackgroundStatus() { fd.append('name', t.model); fd.append('skip_probe', 'true'); _appendCookbookEndpointScope(fd, localTask?.remoteHost || t.remote || ''); + _appendPinnedServeModel(fd, localTask || { name: t.model, model: t.model, payload: { repo_id: t.model, _cmd } }); if (_isDiffusion) fd.append('model_type', 'image'); if (_supportsTools) fd.append('supports_tools', 'true'); return fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd }); @@ -3883,7 +4297,7 @@ async function _pollBackgroundStatus() { // probe, so it lands "offline". Retry-probe in the background // until /v1/models responds — no manual enable/disable needed. if (data && data.id) _probeEndpointUntilOnline(data.id, host, port); - if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(true); + if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(false); if (window.sessionModule?.updateModelPicker) window.sessionModule.updateModelPicker(); } }) @@ -3944,6 +4358,8 @@ async function _pollBackgroundStatus() { } } catch (e) { // Silent fail + } finally { + _bgPollInFlight = false; } } @@ -3972,19 +4388,17 @@ export function initRunning(shared) { _detectModelOptimizations = shared._detectModelOptimizations; _buildServeCmd = shared._buildServeCmd; - // App boot: pull authoritative state from server, then auto-start - // the background monitor unconditionally. Used to gate on "already - // has running tasks" but that meant when the agent (or anyone) - // added a task after boot, the UI never noticed. 10s poll of a - // small status endpoint is cheap and gives the agent + the UI a - // shared live picture. + // App boot: pull authoritative state from server, but don't start the + // running-task monitor unless there is real work to watch. Starting it + // unconditionally made a plain Cookbook open keep probing stale tmux/SSH + // sessions, which is expensive when a saved remote host is unreachable. (async () => { try { await _syncFromServer(); } catch {} - _startBackgroundMonitor(); + if (_hasLiveTasks()) _startBackgroundMonitor(); })(); } // Also export _retryDownload and _nextAvailablePort for use by other modules -export { _retryDownload, _nextAvailablePort, _processQueue }; +export { _retryDownload, _nextAvailablePort, _processQueue, _taskPort }; diff --git a/static/js/cookbookServe.js b/static/js/cookbookServe.js index 06a990b823..d7a2a793f3 100644 --- a/static/js/cookbookServe.js +++ b/static/js/cookbookServe.js @@ -11,6 +11,7 @@ import { modelColor } from './chatRenderer.js'; import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; import { openCookbookDependencies } from './cookbook-diagnosis.js'; import { _hwfitCache } from './cookbook-hwfit.js'; +import { topPortalZ } from './toolWindowZOrder.js'; // Shared state/functions injected by init() let _envState; @@ -45,6 +46,44 @@ const SERVE_STATE_KEY = 'cookbook-serve-state'; const SERVE_FAVORITES_KEY = 'cookbook-serve-favorite-models'; let _cachedAllModels = []; +const _CACHED_MODELS_SCAN_KEY = 'cookbook_cached_models_scan_v1'; +const _CACHED_MODELS_SCAN_TTL = 6 * 3600 * 1000; + +function _normalizeCookbookModelDir(dir) { + const d = String(dir || '').replaceAll('✕', '').replaceAll('✖', '').trim(); + return /^(home|mnt|media|data|opt|srv|var)\//.test(d) ? `/${d}` : d; +} + +function _readCachedModelScan(sig) { + try { + const all = JSON.parse(localStorage.getItem(_CACHED_MODELS_SCAN_KEY) || '{}'); + const entry = all[sig]; + if (entry && Date.now() - (entry.ts || 0) < _CACHED_MODELS_SCAN_TTL) { + const data = entry.data || null; + const models = Array.isArray(data?.models) ? data.models : []; + const staleDownloading = models.some(m => + (m?.status === 'downloading' || m?.has_incomplete) && !_isActivelyDownloading(m?.repo_id) + ); + if (!staleDownloading) return data; + delete all[sig]; + localStorage.setItem(_CACHED_MODELS_SCAN_KEY, JSON.stringify(all)); + } + } catch {} + return null; +} + +function _writeCachedModelScan(sig, data) { + try { + const all = JSON.parse(localStorage.getItem(_CACHED_MODELS_SCAN_KEY) || '{}'); + all[sig] = { ts: Date.now(), data }; + const keys = Object.keys(all); + if (keys.length > 12) { + keys.sort((a, b) => (all[a].ts || 0) - (all[b].ts || 0)); + for (const k of keys.slice(0, keys.length - 12)) delete all[k]; + } + localStorage.setItem(_CACHED_MODELS_SCAN_KEY, JSON.stringify(all)); + } catch {} +} function _loadServeFavorites() { try { @@ -58,6 +97,7 @@ function _loadServeFavorites() { function _saveServeFavorites(favorites) { try { localStorage.setItem(SERVE_FAVORITES_KEY, JSON.stringify(Array.from(favorites || []))); + document.dispatchEvent(new CustomEvent('cookbook:state-dirty', { detail: { key: SERVE_FAVORITES_KEY } })); } catch {} } @@ -193,7 +233,21 @@ function _shellSplitForPreview(cmd) { } function _formatServeCmdPreview(cmd) { - const raw = String(cmd || ''); + let raw = String(cmd || ''); + const mlxDeepSeekV4Compat = /\bmlx_lm\.server\b/i.test(raw) + && /--model\s+['"]?mlx-community\/[^'"\s]*deepseek-v4/i.test(raw); + if (mlxDeepSeekV4Compat) { + const modelMatch = raw.match(/--model\s+(['"]?)(mlx-community\/[^'"\s]*deepseek-v4[^'"\s]*)\1/i); + const homeMatch = raw.match(/((?:\/Users|\/home)\/[^/\s'"]+)/); + const shortName = modelMatch?.[2]?.split('/').pop(); + if (homeMatch && shortName) { + const shimPath = `${homeMatch[1]}/.cache/odysseus/mlx-shims/${shortName}`; + raw = raw.replace( + /--model\s+(['"]?)mlx-community\/[^'"\s]*deepseek-v4[^'"\s]*\1/i, + `--model '${shimPath}'` + ); + } + } if (raw.startsWith('MODEL_FILE=$({')) { const marker = /&&\s+([A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)?(?:llama-server|python3?\s+-m\s+llama_cpp\.server)\b/; const match = raw.match(marker); @@ -208,7 +262,7 @@ function _formatServeCmdPreview(cmd) { const lines = []; let i = 0; while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) { - lines.push(tokens[i]); + lines.push(`export ${tokens[i]}`); i++; } if (tokens[i]) { @@ -229,11 +283,43 @@ function _formatServeCmdPreview(cmd) { lines.push(t); } } - return lines.join('\n'); + const envCount = lines.findIndex(line => !line.startsWith('export ')); + const firstCmdLine = envCount < 0 ? lines.length : envCount; + const formatted = lines.map((line, idx) => { + const isCommandPart = idx >= firstCmdLine; + const hasNextCommandPart = lines.slice(idx + 1).some(next => !next.startsWith('export ')); + return isCommandPart && hasNextCommandPart ? `${line} \\` : line; + }).join('\n'); + if (mlxDeepSeekV4Compat) { + return [ + '# Odysseus runtime compatibility: using sanitized MLX DeepSeek-V4 shim.', + formatted, + ].join('\n'); + } + return formatted; } function _normalizeServeCmdForLaunch(cmd) { - return String(cmd || '') + let raw = String(cmd || ''); + const lines = raw.split(/\r?\n/) + .map(s => s.trim().replace(/\s*\\$/, '').trim()) + .filter(s => s && !s.startsWith('#')); + if (lines.some(line => /^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*=/.test(line))) { + const env = []; + const body = []; + for (const line of lines) { + const m = line.match(/^export\s+([A-Za-z_][A-Za-z0-9_]*=.*)$/); + if (m) { + env.push(m[1]); + } else if (/^[A-Za-z_][A-Za-z0-9_]*=\S+$/.test(line)) { + env.push(line); + } else { + body.push(line); + } + } + raw = [...env, ...body].join(' '); + } + return raw .replace(/MODEL_FILE=\$\(\{\s+/g, 'MODEL_FILE=$({ ') .replace(/\s+\}\s+\|\s+head\s+-1\)/g, ' } | head -1)') .replace(/\s*;\s*/g, '; ') @@ -489,12 +575,39 @@ function _estimateLlamaContextFit(model, fields, modelCtxMax, modelWeightsGb = 0 } const raw = Math.floor(freeForKv / kvGbPerToken); const rounded = Math.max(1024, Math.floor(raw / 1024) * 1024); - const ctx = Math.min(modelMax, rounded); + let ctx = Math.min(modelMax, rounded); + let reasonSuffix = ''; + if (isUnifiedMode) { + // Unified memory is not just "GPU math with a slightly bigger VRAM number". + // llama.cpp can spill into system RAM, so a conservative pure-VRAM KV + // formula makes confusing recommendations like "58G free unified" but the + // same context as GPU. Use a system-memory-style cap when there is real + // unified headroom, while keeping the GPU estimate as the minimum. + const unifiedCap = freeForKv >= 16 + ? 131072 + : (freeForKv >= 8 ? 65536 : 32768); + const unifiedCtx = Math.min(modelMax, unifiedCap); + if (unifiedCtx > ctx) { + ctx = unifiedCtx; + reasonSuffix = '; unified can spill into system RAM, slower than pure GPU'; + } + const gpuUsableGb = Math.max(1, totalVramGb - Math.max(1.0, selectedCount * 0.6)); + const gpuFreeForKv = gpuUsableGb - modelGb; + if (gpuFreeForKv > 0) { + const gpuRaw = Math.floor(gpuFreeForKv / kvGbPerToken); + const gpuRounded = Math.max(1024, Math.floor(gpuRaw / 1024) * 1024); + const gpuCtx = Math.min(modelMax, gpuRounded); + if (gpuCtx > ctx) { + ctx = gpuCtx; + reasonSuffix = '; at least the GPU estimate'; + } + } + } return { ctx, modelGb, kvGbPerToken, - reason: `~${ctx.toLocaleString()} tokens fits llama.cpp KV (${freeForKv.toFixed(1)}G free ${isUnifiedMode ? 'unified' : 'VRAM'})`, + reason: `~${ctx.toLocaleString()} tokens fits llama.cpp KV (${freeForKv.toFixed(1)}G free ${isUnifiedMode ? 'unified' : 'VRAM'}${reasonSuffix})`, }; } @@ -515,7 +628,13 @@ function _selectedServeTarget(panel) { host = server?.host || ''; } } - const venv = panel?.querySelector('[data-field="venv"]')?.value?.trim() || server?.envPath || _envState.envPath || ''; + const typedVenv = panel?.querySelector('[data-field="venv"]')?.value?.trim() || ''; + // For remote targets the server profile is authoritative. Otherwise a stale + // venv typed/loaded for another host can leak into this launch, e.g. a Linux + // /home/... Python path being used on an Apple Silicon MLX server. + const venv = host + ? (server?.envPath || typedVenv || '') + : (typedVenv || server?.envPath || _envState.envPath || ''); const label = host ? (server?.name ? `${server.name} (${host})` : host) : (server?.name || 'local server'); @@ -526,7 +645,7 @@ function _selectedServeTarget(panel) { env: server?.env || '', port: host ? (server?.port || _getPort(host) || '') : '', venv, - platform: server?.platform || _envState.platform || '', + platform: host ? (server?.platform || '') : (_envState.hostPlatform || ''), label, }; } @@ -541,8 +660,8 @@ function _backendChoicesForTarget(target) { return [['llamacpp','llama.cpp'],['diffusers','Diffusers']]; } return _isMetal() - ? [['llamacpp','llama.cpp'],['ollama','Ollama']] - : [['vllm','vLLM'],['sglang','SGLang'],['llamacpp','llama.cpp'],['ollama','Ollama'],['diffusers','Diffusers']]; + ? [['mlx','MLX'],['llamacpp','llama.cpp'],['ollama','Ollama']] + : [['vllm','vLLM'],['sglang','SGLang'],['llamacpp','llama.cpp'],['ollama','Ollama'],['mlx','MLX'],['diffusers','Diffusers']]; } async function _fetchServeRuntimePackage(panel, backend) { @@ -550,6 +669,7 @@ async function _fetchServeRuntimePackage(panel, backend) { vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', + mlx: 'mlx_lm', diffusers: 'diffusers', }; const packageName = packageByBackend[backend]; @@ -569,7 +689,7 @@ async function _fetchServeRuntimePackage(panel, backend) { } function _runtimeNoteText(backend, pkg, target) { - const labels = { vllm: 'vLLM', sglang: 'SGLang', llamacpp: 'llama.cpp', diffusers: 'Diffusers' }; + const labels = { vllm: 'vLLM', sglang: 'SGLang', llamacpp: 'llama.cpp', mlx: 'MLX', diffusers: 'Diffusers' }; const label = labels[backend] || backend; if (!pkg) return `${label} readiness unavailable for ${target.label}.`; const note = pkg.status_note || pkg.update_note || ''; @@ -657,6 +777,12 @@ function _selectedGgufSizeGb(model, relPath) { return bytes / (1024 ** 3); } +function _projectorGgufFiles(model) { + return _ggufFilesForModel(model) + .filter(f => (f.role || '') === 'projector' || /(^|\/)mmproj[^/]*\.gguf$/i.test(f.rel_path || f.name || '')) + .sort((a, b) => String(a.rel_path || a.name || '').localeCompare(String(b.rel_path || b.name || ''))); +} + function _ggufFileLabel(file) { const base = (file.name || file.rel_path || '').split('/').pop(); const size = _formatGgufSize(file.size_bytes); @@ -1019,7 +1145,7 @@ function _rerenderCachedModels() { cancelDiv.addEventListener('click', () => { closeDropdown(); }); dropdown.appendChild(cancelDiv); const rect = btn.getBoundingClientRect(); - dropdown.style.cssText = `position:fixed;z-index:10001;visibility:hidden;top:0;right:${window.innerWidth-rect.right}px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.3);font-size:12px;`; + dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};visibility:hidden;top:0;right:${window.innerWidth-rect.right}px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.3);font-size:12px;`; document.body.appendChild(dropdown); // Clamp into the VISIBLE area (visualViewport, not innerHeight — they differ // on mobile under the dynamic toolbar). Flip above the button if there's no @@ -1051,7 +1177,14 @@ function _rerenderCachedModels() { const repo = item.dataset.repo; if (!repo) return; const m = allModels.find(x => x.repo_id === repo); - if (!m || m.status !== 'ready') return; + if (!m) return; + if (m.status !== 'ready') { + if (m.status === 'downloading' && !_isActivelyDownloading(m.repo_id)) { + uiModule.showToast?.('Refreshing cached model status…'); + _fetchCachedModels(true); + } + return; + } // Toggle — close if already open if (item.classList.contains('doclib-card-expanded')) { @@ -1182,11 +1315,13 @@ function _rerenderCachedModels() { if (_replaceTaskId) { panelHtml += ``; } - // Runtime-readiness note pinned at the top of the serve area so the - // user sees "vLLM ready on …" before scrolling into the configure - // form. Hidden until the readiness probe returns. The × button - // dismisses it for this panel only (re-shows on re-expand). - panelHtml += ``; + // Runtime-readiness note shares the top line with the preset controls + // so "vLLM ready on …" reads as panel status instead of a separate + // block pushing the form down. Hidden until the readiness probe returns. + panelHtml += `
`; + panelHtml += ``; + panelHtml += `
${_slotsHtml}
`; + panelHtml += `
`; // Warn when serving a model whose download hasn't fully completed — // the user CAN still hit Launch (vLLM/llama-server will start, then // crash trying to read missing shards), but they should know. @@ -1196,7 +1331,7 @@ function _rerenderCachedModels() { : `This model's download isn't complete yet (${esc(m.size || 'partial')}). The serve will start but is likely to crash on a missing shard. Wait for the download to finish, or relaunch after it's done.`; panelHtml += `
${_warnText}
`; } - panelHtml += `
${_slotsHtml}
`; + panelHtml += ``; // Row 1: Engine + Server + Env panelHtml += `
`; const backendOpts = _backendChoices.map(([v,l]) => ``).join(''); @@ -1206,7 +1341,7 @@ function _rerenderCachedModels() { // stays as the source-of-truth so every existing change handler // (updateBackendVisibility, runtime readiness, command builder) // still fires via dispatchEvent('change') on selection. - panelHtml += ``; + panelHtml += ``; panelHtml += ``; // Inference mode pill (llama.cpp only) — lives directly to the // RIGHT of Backend in Row 1 so the engine and the GPU/CPU choice @@ -1230,9 +1365,9 @@ function _rerenderCachedModels() { const _savedUnified = !!sv('unified_mem', false); const _llamaModeRaw = sv('llama_mode', _llamaModeDefault); const _llamaMode = _savedUnified && _llamaModeRaw !== 'cpu' ? 'unified' : _llamaModeRaw; - panelHtml += ``; + panelHtml += ``; } - panelHtml += ``; + panelHtml += ``; const defaultPort = defaultBackend === 'ollama' ? '11434' : _nextAvailablePort(); panelHtml += ``; const _activeGpus = (defaultGpus || '').split(',').map(s => s.trim()).filter(Boolean); @@ -1268,13 +1403,13 @@ function _rerenderCachedModels() { // TP / Context / GPU / GPU Mem / Max Seqs / Dtype. Everything else // (Swap, KV Cache, Attention backend, Env vars, llama.cpp batch/ubatch) // moved to the Advanced fold below to keep this row scannable. - panelHtml += `
`; + panelHtml += `
`; // Order: Dtype → TP → Context → Max Seqs → GPUs → GPU Mem. // Dtype moved down from Row 1 to make space for the Inference pill // (llama.cpp GPU/CPU toggle, llamacpp-only). GPUs lives next to // GPU Mem so "which devices + how much" sit adjacent. Max Seqs // follows Context per the "request-shape" cluster. - panelHtml += ``; + panelHtml += ``; panelHtml += ``; // ctx resets to the model's max on every panel open (the real ctx slider // lives in the Scan/Download toolbar — see cookbook.js .hwfit-ctx-control). @@ -1324,29 +1459,30 @@ function _rerenderCachedModels() { ['', 'None'], ['minimax_m3_cuda', 'CUDA native sampler'], ].map(([v, label]) => ``).join(''); - panelHtml += ``; + panelHtml += ``; } // Free-text env-vars field. Anything pasted here is prepended to the // launch command verbatim. Use for CUDACXX, PATH overrides, NCCL_* // tuning, or any other KEY=VALUE pair that doesn't have a dedicated // field. After the venv activate runs, $VIRTUAL_ENV / $PATH / etc. are // already exported so they expand correctly here. - // grid-column: 1 / -1 makes Env span every column of the Advanced - // row's CSS grid (the old flex:1 1 100% did nothing in a grid - // container — left an empty trailing column gap on wide modals). - panelHtml += ``; + // CSS places this beside vLLM's Env Preset, but lets it span the full + // row for SGLang where that preset field is hidden. + panelHtml += ``; panelHtml += `
`; // Row 2b: Diffusers settings const diffDtypeOpts = ['bfloat16','float16','float32'].map(d => ``).join(''); const deviceMapOpts = ['balanced','auto','sequential'].map(d => ``).join(''); - panelHtml += `
`; + panelHtml += `
`; panelHtml += ``; panelHtml += ``; panelHtml += ``; panelHtml += ``; panelHtml += ``; panelHtml += `
`; - // Row 3: Checkboxes (vLLM) + // Row 3: Advanced toggles for vLLM/SGLang. Several concepts overlap, + // but the actual flags differ; keep labels backend-neutral where a + // shared checkbox maps to different runtime flags. // Order: Trust Remote → Auto Tool → Reasoning Parser (when the // model has one) → Enforce Eager → Prefix Caching. Reasoning // Parser was previously in a separate row below; the user wanted @@ -1357,21 +1493,22 @@ function _rerenderCachedModels() { const _rp_flag = _opts2_row3.flags.find(f => f.includes('--reasoning-parser')); const _rp_name = _rp_flag ? _rp_flag.split(' ')[1] : ''; panelHtml += `
`; - panelHtml += ``; - panelHtml += ``; + panelHtml += ``; + panelHtml += ``; // Always-render the Reasoning Parser, Expert Parallel, and MoE Env // checkboxes — the model-family detection above is a hint, not a // hard gate. User asked to keep these visible regardless so that // a borderline-undetected MoE/reasoning model can still toggle // them without dropping back to the raw command box. - panelHtml += ``; - panelHtml += ``; - panelHtml += ``; + panelHtml += ``; + panelHtml += ``; + panelHtml += ``; // Inline the previously-second vLLM checks row so Expert Parallel / // Speculative / MoE Env sit next to Prefix Caching with no gap. All // three are vLLM-only — class-gated so they hide on SGLang. Always // render so the user can flip them on for any MoE model. - panelHtml += ``; + panelHtml += ``; + panelHtml += ``; panelHtml += ``; panelHtml += ``; { @@ -1399,21 +1536,21 @@ function _rerenderCachedModels() { const llamaSplitModeOpts = ['', 'layer', 'tensor', 'row', 'none'].map(d => ``).join(''); // Group 1 — GPU placement (GPU-only, hides in CPU mode) - panelHtml += `
`; + panelHtml += `
`; panelHtml += ``; panelHtml += ``; panelHtml += ``; panelHtml += `
`; // Group 2 — Memory tuning (KV cache + MoE-on-CPU + Fit policy) - panelHtml += `
`; + panelHtml += `
`; panelHtml += ``; panelHtml += ``; panelHtml += ``; panelHtml += `
`; // Group 3 — Request batching (Batch / UBatch / Parallel) - panelHtml += `
`; + panelHtml += `
`; panelHtml += ``; panelHtml += ``; panelHtml += ``; @@ -1426,7 +1563,7 @@ function _rerenderCachedModels() { // Live VRAM / RAM-spillover monitor for the serve target's GPU. Polls // /api/cookbook/gpus while the panel is open so you can SEE whether the // config fits VRAM (fast) or spills to system RAM (slow). Populated after mount. - panelHtml += `
`; + panelHtml += `
`; panelHtml += `GPU memory:`; panelHtml += `checking…`; panelHtml += `
`; @@ -1435,20 +1572,20 @@ function _rerenderCachedModels() { // automatically in CPU mode. Order: perf-critical → safety → I/O → // niche. MTP Spec sits last because it owns its own numstep widget // and is the widest item. - panelHtml += `
`; + panelHtml += `
`; panelHtml += ``; panelHtml += ``; panelHtml += ``; panelHtml += ``; panelHtml += ``; - panelHtml += ``; + panelHtml += ``; panelHtml += `
`; // Row 3b: Checkboxes (diffusers) - panelHtml += `
`; + panelHtml += `
`; panelHtml += ``; panelHtml += ``; panelHtml += ``; - panelHtml += `
`; + panelHtml += `
`; panelHtml += ``; panelHtml += `
`; // Model-specific optimizations. The checks row always renders for the @@ -1523,6 +1660,12 @@ function _rerenderCachedModels() { if (el.type === 'checkbox') f[el.dataset.field] = el.checked; else f[el.dataset.field] = el.value; }); + const buildTarget = _selectedServeTarget(panel); + f.host = buildTarget.host || ''; + f.platform = buildTarget.platform || ''; + f.venv = buildTarget.venv || ''; + const hostField = panel.querySelector('[data-field="host"]'); + if (hostField) hostField.value = f.host; const backend = f.backend || 'vllm'; const serveModel = (f.model_path || '').trim() || (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo); if (backend === 'llamacpp') { @@ -1542,11 +1685,11 @@ function _rerenderCachedModels() { : m.is_local_dir && m.path ? `$({ find ${_ldir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${_ldir} -name '*.gguf' 2>/dev/null | sort; } | head -1)` : `$({ find ${dir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${dir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`; - // Vision: auto-find the mmproj (CLIP/projector) file in the same dir. - // Resolved at runtime so the toggle just works if an mmproj-*.gguf is - // present (downloaded alongside the model). Empty if none → cmd omits it. - const _vsearchdir = (m.is_local_dir && m.path) ? _ldir : dir; - f._mmproj_path = `$(find ${_vsearchdir} -iname 'mmproj*.gguf' 2>/dev/null | sort | head -1)`; + // Vision: use the scanned projector (CLIP/mmproj) file when present. + // Keeping this as a printf path avoids generating a command substitution + // that the backend serve-command validator must reject as unsafe. + const selectedProjector = _projectorGgufFiles(m)[0]; + f._mmproj_path = selectedProjector ? _selectedGgufExpr(m, repo, selectedProjector.rel_path) : ''; } if (f.reasoning_parser) { const _rpEl2 = panel.querySelector('[data-field="reasoning_parser"]'); @@ -1562,6 +1705,10 @@ function _rerenderCachedModels() { } let cmd = _buildServeCmd(f, serveModel, backend); if (f.extra && f.extra.trim()) cmd += ' ' + f.extra.trim(); + const missingVisionProjector = backend === 'llamacpp' && !!f.vision && !f._mmproj_path; + panel._visionMissingProjector = missingVisionProjector; + const _visionWarn = panel.querySelector('.hwfit-serve-vision-warn'); + if (_visionWarn) _visionWarn.style.display = missingVisionProjector ? 'flex' : 'none'; const _ce2 = panel.querySelector('.hwfit-serve-cmd'); _ce2.value = _formatServeCmdPreview(cmd); _ce2.style.height = 'auto'; _ce2.style.height = _ce2.scrollHeight + 'px'; panel._cmd = cmd; panel._host = f.host || ''; @@ -1803,8 +1950,9 @@ function _rerenderCachedModels() { const _BACKEND_GLYPHS = { vllm: '', sglang: '', + mlx: '', llamacpp: '', - ollama: '', + ollama: '', diffusers: '', }; @@ -1885,6 +2033,7 @@ function _rerenderCachedModels() { function updateBackendVisibility() { const b = panel.querySelector('[data-field="backend"]')?.value || 'vllm'; + panel.dataset.backendActive = b; panel.querySelectorAll('[class*="hwfit-backend-"]').forEach(el => { // Skip the entire backend-picker subtree — the picker's own // classes (`hwfit-backend-picker`, `-btn`, `-menu`, `-item`, @@ -1915,7 +2064,7 @@ function _rerenderCachedModels() { const backend = panel.querySelector('[data-field="backend"]')?.value || 'vllm'; const noteText = note.querySelector('.hwfit-serve-runtime-text'); const _writeNote = (s) => { if (noteText) noteText.textContent = s; else note.textContent = s; }; - if (!['vllm', 'sglang', 'llamacpp', 'diffusers'].includes(backend)) { + if (!['vllm', 'sglang', 'llamacpp', 'mlx', 'diffusers'].includes(backend)) { note.style.display = 'none'; _writeNote(''); return; @@ -1955,7 +2104,7 @@ function _rerenderCachedModels() { // recipe panel for this backend so the user has one click // to the fix instead of hunting for the right row. if (noteText) { - const pkgName = pkg?.name || ({ vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', diffusers: 'diffusers' }[backend]); + const pkgName = pkg?.name || ({ vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', mlx: 'mlx_lm', diffusers: 'diffusers' }[backend]); const repo = (panel.closest('.doclib-card, .memory-item')?.dataset?.repo) || ''; const link = document.createElement('a'); link.href = '#'; @@ -2010,7 +2159,7 @@ function _rerenderCachedModels() { }); } else { const fields = { - backend: cmd.includes('llama_cpp') || cmd.includes('llama-server') ? 'llamacpp' : cmd.includes('diffusion_server') ? 'diffusers' : cmd.includes('sglang') ? 'sglang' : cmd.includes('ollama') ? 'ollama' : 'vllm', + backend: cmd.includes('llama_cpp') || cmd.includes('llama-server') ? 'llamacpp' : cmd.includes('mlx_lm.server') ? 'mlx' : cmd.includes('diffusion_server') ? 'diffusers' : cmd.includes('sglang') ? 'sglang' : cmd.includes('ollama') ? 'ollama' : 'vllm', port: _ex(/--port\s+(\d+)/) || '8000', tp: _ex(/--tensor-parallel-size\s+(\d+)/) || '1', ctx: _ex(/--max-model-len\s+(\d+)/) || _ex(/--n_ctx\s+(\d+)/) || _ex(/-c\s+(\d+)/) || '8192', @@ -2166,7 +2315,7 @@ function _rerenderCachedModels() { // Cap width/height to the viewport and start hidden — we clamp the final // position after mount (below) using the menu's real measured size, so it // can't run off-screen on a narrow mobile viewport. - dropdown.style.cssText = `position:fixed;display:block;visibility:hidden;z-index:10001;top:0;left:0;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`; + dropdown.style.cssText = `position:fixed;display:block;visibility:hidden;z-index:${topPortalZ()};top:0;left:0;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`; if (!modelSlots.length) { const empty = document.createElement('div'); @@ -2937,12 +3086,16 @@ function _rerenderCachedModels() { }); serveState.backend = serveState.backend || (_detectBackend(m).backend) || 'vllm'; const launchTarget = _selectedServeTarget(panel); + if (serveState.backend === 'llamacpp' && serveState.vision && !/(?:^|\s)(?:--mmproj|--clip_model_path)\b/.test(launchCmd)) { + _restoreLaunchBtn(); + 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); + return; + } if (serveState.backend === 'diffusers' && _remoteWindowsDiffusersUnsupported(launchTarget)) { _restoreLaunchBtn(); uiModule.showToast('Diffusers serving is not supported on remote Windows servers yet. Use local Windows or a Linux server.', 9000); return; } - // Pre-launch: check our own task list for a serve already running // on this host. Offer to stop+launch as the default action — the // SSH-based port probe below is more thorough but it can miss @@ -2957,33 +3110,41 @@ function _rerenderCachedModels() { && ((t.remoteHost || '') === _hostStr || (t.remoteServerKey || '') === _serverKeyStr) && (t.status === 'running' || t.status === 'ready' || t._serveReady) ); + // Only block when the new model's port genuinely collides with + // a running serve. Different ports coexist fine (issue #4507). if (_active.length) { - const _names = _active.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean); - const _ok = await window.styledConfirm( - `${_active.length} model${_active.length === 1 ? '' : 's'} already serving on ${_hostStr || 'local'} (${_names.join(', ')}). Port 8000 will collide. Stop the running model and launch this one?`, - { title: 'Server already running', confirmText: 'Stop & launch', cancelText: 'Cancel' }, - ); - if (!_ok) { _restoreLaunchBtn(); return; } - // Kill each active serve; prefer the rendered Stop button so - // endpoint cleanup + Ollama unload run normally. Fall back to - // a raw tmux kill when the Active tab isn't in the DOM. - for (const t of _active) { - try { - const _el = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`); - const _btn = _el?.querySelector('.cookbook-task-action-stop'); - if (_btn) { - _btn.click(); - } else if (_runningMod._tmuxGracefulKill) { - await fetch('/api/shell/exec', { - method: 'POST', credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ command: _runningMod._tmuxGracefulKill(t) }), - }); - } - } catch (_killErr) { /* best-effort */ } + const _newPort = (launchCmd.match(/--port[=\s]+(\d+)/) || [])[1] || ''; + const _clashing = _newPort + ? _active.filter(t => _runningMod._taskPort(t) === _newPort) + : _active; + if (_clashing.length) { + const _names = _clashing.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean); + const _portNote = _newPort ? ` on port ${_newPort}` : ''; + const _ok = await window.styledConfirm( + `${_clashing.length} model${_clashing.length === 1 ? '' : 's'} already serving on ${_hostStr || 'local'} (${_names.join(', ')})${_portNote}. Stop it and launch this one?`, + { title: _newPort ? `Port ${_newPort} in use` : 'Server already running', confirmText: 'Stop & launch', cancelText: 'Cancel' }, + ); + if (!_ok) { _restoreLaunchBtn(); return; } + // Kill each clashing serve; prefer the rendered Stop button so + // endpoint cleanup + Ollama unload run normally. Fall back to + // a raw tmux kill when the Active tab isn't in the DOM. + for (const t of _clashing) { + try { + const _el = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`); + const _btn = _el?.querySelector('.cookbook-task-action-stop'); + if (_btn) { + _btn.click(); + } else if (_runningMod._tmuxGracefulKill) { + await fetch('/api/shell/exec', { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command: _runningMod._tmuxGracefulKill(t) }), + }); + } + } catch (_killErr) { /* best-effort */ } + } + await new Promise(r => setTimeout(r, 2500)); } - // Give the OS a beat to release port 8000. - await new Promise(r => setTimeout(r, 2500)); } } catch (_e) { /* best-effort */ } @@ -3245,7 +3406,7 @@ function _rerenderCachedModels() { // The venv field wins; otherwise fall back to the env configured for the // selected server in Settings, so the activation isn't silently dropped // when the field is left blank (the per-server venv wasn't being applied). - if (venvVal) { _envState.env = 'venv'; _envState.envPath = venvVal; } + if (venvVal) { _envState.env = (_srvEnv === 'conda' ? 'conda' : 'venv'); _envState.envPath = venvVal; } else if (_srvEnvPath) { _envState.env = (_srvEnv === 'conda' ? 'conda' : 'venv'); _envState.envPath = _srvEnvPath; } if (gpusVal) _envState.gpus = gpusVal; // Preflight: launching a GPU engine (llama.cpp / vLLM / SGLang) @@ -3563,12 +3724,95 @@ export async function openServePanelForRepo(repo, fields) { // ── Fetch cached models from server ── -export async function _fetchCachedModels() { +function _renderCachedModelsData(list, data, host) { + // CHANGELOG: 'ready' already excludes partial downloads; + // show every complete model regardless of size/backend. + const ready = (data.models || []).filter(m => m.status === 'ready'); + + const downloading = (data.models || []).filter(m => m.status === 'downloading'); + const allModels = [...ready, ...downloading]; + _cachedAllModels = allModels; + + if (!allModels.length) { + if (!host) { + list.innerHTML = '
No cached models found
Docker Local uses Odysseus’s cache in data/huggingface. Download a model here, or copy an existing host HuggingFace cache into that folder once.
'; + } else { + list.innerHTML = '
No cached models found
No complete model folders were found on this server.
'; + list.querySelector('.serve-empty-scan-btn')?.addEventListener('click', () => { + _fetchCachedModels(true); + }); + } + const tagContainer = document.getElementById('serve-tags'); + if (tagContainer) tagContainer.innerHTML = ''; + return; + } + + // Auto-detect type + family tags + const _tagMap = {}; + const _familyMap = {}; + const _families = [ + [/qwen/i, 'qwen'], [/llama/i, 'llama'], [/mistral|mixtral/i, 'mistral'], + [/deepseek/i, 'deepseek'], [/gemma/i, 'gemma'], [/phi/i, 'phi'], + [/minimax/i, 'minimax'], [/glm/i, 'glm'], [/flux/i, 'flux'], + [/stable.?diffusion|sdxl/i, 'sd'], [/z-image/i, 'z-image'], + [/whisper/i, 'whisper'], [/command|cohere/i, 'cohere'], + [/yi-/i, 'yi'], [/intern/i, 'intern'], [/falcon/i, 'falcon'], + ]; + for (const m of allModels) { + const n = (m.repo_id || '').toLowerCase(); + let tag = 'other'; + if (m.backend === 'ollama' || m.is_ollama) tag = 'llm'; + else if (m.is_diffusion || /flux|sdxl|stable-diffusion|z-image|qwen-image|diffusion|dreamshar/i.test(n)) tag = 'image'; + else if (/whisper|stt|asr/i.test(n)) tag = 'stt'; + else if (/tts|cosyvoice|parler/i.test(n)) tag = 'tts'; + else if (/embed|bge|minilm|e5-/i.test(n)) tag = 'embedding'; + else if (/lora|adapter/i.test(n)) tag = 'lora'; + else tag = 'llm'; + m._tag = tag; + _tagMap[tag] = (_tagMap[tag] || 0) + 1; + m._family = ''; + for (const [re, fam] of _families) { + if (re.test(n)) { m._family = fam; _familyMap[fam] = (_familyMap[fam] || 0) + 1; break; } + } + if ((m.backend === 'ollama' || m.is_ollama) && !m._family) { + m._family = 'ollama'; + _familyMap.ollama = (_familyMap.ollama || 0) + 1; + } + } + + // Render tag chips + const tagContainer = document.getElementById('serve-tags'); + if (tagContainer) { + const tagOrder = ['llm', 'image', 'lora', 'embedding', 'tts', 'stt', 'other']; + let tagHtml = ``; + for (const t of tagOrder) { + if (!_tagMap[t]) continue; + tagHtml += ``; + } + const sortedFamilies = Object.entries(_familyMap).sort((a, b) => b[1] - a[1]); + if (sortedFamilies.length) { + for (const [fam, count] of sortedFamilies) { + const logo = providerLogo(fam); + const logoHtml = logo ? `${logo}` : ''; + tagHtml += ``; + } + } + tagContainer.innerHTML = tagHtml; + } + + _rerenderCachedModels(); +} + +export async function _fetchCachedModels(fresh = false, opts = {}) { const list = document.getElementById('hwfit-cached-list'); if (!list) return; + const allowNetwork = fresh || opts.allowNetwork !== false; list.innerHTML = ''; - const _dlWp = spinnerModule.createWhirlpool(18); + const _dlWp = spinnerModule.createWhirlpool(22); + _dlWp.element.classList.add('cookbook-section-loading-wp'); + _dlWp.element.style.width = '22px'; + _dlWp.element.style.height = '22px'; const _dlWrap = document.createElement('div'); _dlWrap.className = 'hwfit-loading'; _dlWrap.style.cssText = 'flex-direction:column;gap:6px;'; @@ -3607,7 +3851,8 @@ export async function _fetchCachedModels() { const modelDirs = []; if (selectedServer && Array.isArray(selectedServer.modelDirs)) { for (const d of selectedServer.modelDirs) { - if (d && d !== '~/.cache/huggingface/hub') modelDirs.push(d); + const normalized = _normalizeCookbookModelDir(d); + if (normalized && normalized !== '~/.cache/huggingface/hub') modelDirs.push(normalized); } } // Sync the header dir pills to THIS server (the one whose models we're listing). @@ -3619,7 +3864,7 @@ export async function _fetchCachedModels() { const _allDirs = (Array.isArray(selectedServer.modelDirs) && selectedServer.modelDirs.length ? selectedServer.modelDirs : [selectedServer.modelDir || '~/.cache/huggingface/hub']) - .map(d => (d || '').replaceAll('✕', '').replaceAll('✖', '').trim()).filter(Boolean); + .map(d => _normalizeCookbookModelDir(d)).filter(Boolean); _dirsEl.innerHTML = _allDirs.map(d => `${esc(d)}`).join('') + 'edit'; _dirsEl.querySelector('.cookbook-serve-dir-edit')?.addEventListener('click', () => { @@ -3630,6 +3875,25 @@ export async function _fetchCachedModels() { 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); } if (modelDirs.length) qp.set('model_dir', modelDirs.join(',')); const params = qp.toString() ? `?${qp}` : ''; + const scanSig = params || 'local'; + const cached = fresh ? null : _readCachedModelScan(scanSig); + if (cached) { + _dlWp.destroy(); + _renderCachedModelsData(list, cached, host); + return; + } + if (!allowNetwork) { + _dlWp.destroy(); + const wp = spinnerModule.createWhirlpool(22); + list.innerHTML = '
No cached model scan yet
Scanning this server\'s model cache…
'; + list.querySelector('.serve-empty-auto-wp')?.appendChild(wp.element); + setTimeout(() => { + if (list.querySelector('.serve-empty-auto-scan')) _fetchCachedModels(true); + }, 60); + const tagContainer = document.getElementById('serve-tags'); + if (tagContainer) tagContainer.innerHTML = ''; + return; + } const res = await fetch(`/api/model/cached${params}`); if (!res.ok) { const body = await res.text().catch(() => ''); @@ -3644,83 +3908,16 @@ export async function _fetchCachedModels() { throw new Error(`HTTP ${res.status} ${res.statusText}${msg ? `: ${msg}` : ''}`); } const data = await res.json(); + if (data && data.error) throw new Error(data.error); + _writeCachedModelScan(scanSig, data); _dlWp.destroy(); - - // CHANGELOG: 'ready' already excludes partial downloads; - // show every complete model regardless of size/backend. - const ready = data.models.filter(m => m.status === 'ready'); - - const downloading = data.models.filter(m => m.status === 'downloading'); - const allModels = [...ready, ...downloading]; - _cachedAllModels = allModels; - - if (!allModels.length) { - if (!host) { - list.innerHTML = '
No cached models found
Docker Local uses Odysseus’s cache in data/huggingface. Download a model here, or copy an existing host HuggingFace cache into that folder once.
'; - } else { - list.innerHTML = '
No cached models found
'; - } - document.getElementById('serve-tags').innerHTML = ''; - return; - } - - // Auto-detect type + family tags - const _tagMap = {}; - const _familyMap = {}; - const _families = [ - [/qwen/i, 'qwen'], [/llama/i, 'llama'], [/mistral|mixtral/i, 'mistral'], - [/deepseek/i, 'deepseek'], [/gemma/i, 'gemma'], [/phi/i, 'phi'], - [/minimax/i, 'minimax'], [/glm/i, 'glm'], [/flux/i, 'flux'], - [/stable.?diffusion|sdxl/i, 'sd'], [/z-image/i, 'z-image'], - [/whisper/i, 'whisper'], [/command|cohere/i, 'cohere'], - [/yi-/i, 'yi'], [/intern/i, 'intern'], [/falcon/i, 'falcon'], - ]; - for (const m of allModels) { - const n = (m.repo_id || '').toLowerCase(); - let tag = 'other'; - if (m.backend === 'ollama' || m.is_ollama) tag = 'llm'; - else if (m.is_diffusion || /flux|sdxl|stable-diffusion|z-image|qwen-image|diffusion|dreamshar/i.test(n)) tag = 'image'; - else if (/whisper|stt|asr/i.test(n)) tag = 'stt'; - else if (/tts|cosyvoice|parler/i.test(n)) tag = 'tts'; - else if (/embed|bge|minilm|e5-/i.test(n)) tag = 'embedding'; - else if (/lora|adapter/i.test(n)) tag = 'lora'; - else tag = 'llm'; - m._tag = tag; - _tagMap[tag] = (_tagMap[tag] || 0) + 1; - m._family = ''; - for (const [re, fam] of _families) { - if (re.test(n)) { m._family = fam; _familyMap[fam] = (_familyMap[fam] || 0) + 1; break; } - } - if ((m.backend === 'ollama' || m.is_ollama) && !m._family) { - m._family = 'ollama'; - _familyMap.ollama = (_familyMap.ollama || 0) + 1; - } - } - - // Render tag chips - const tagContainer = document.getElementById('serve-tags'); - if (tagContainer) { - const tagOrder = ['llm', 'image', 'lora', 'embedding', 'tts', 'stt', 'other']; - let tagHtml = ``; - for (const t of tagOrder) { - if (!_tagMap[t]) continue; - tagHtml += ``; - } - const sortedFamilies = Object.entries(_familyMap).sort((a, b) => b[1] - a[1]); - if (sortedFamilies.length) { - for (const [fam, count] of sortedFamilies) { - const logo = providerLogo(fam); - const logoHtml = logo ? `${logo}` : ''; - tagHtml += ``; - } - } - tagContainer.innerHTML = tagHtml; - } - - _rerenderCachedModels(); + _renderCachedModelsData(list, data, host); } catch (e) { _dlWp.destroy(); - list.innerHTML = `
Failed: ${esc(e.message)}
`; + list.innerHTML = `
Cached model scan failed
${esc(e.message)}
`; + list.querySelector('.serve-empty-scan-btn')?.addEventListener('click', () => { + _fetchCachedModels(true); + }); } } diff --git a/static/js/document.js b/static/js/document.js index 3fb8256567..82aa5db802 100644 --- a/static/js/document.js +++ b/static/js/document.js @@ -16,6 +16,7 @@ import spinnerModule from './spinner.js'; import { openLibrary, closeLibrary, isLibraryOpen, initLibrary } from './documentLibrary.js'; import signatureModule from './signature.js'; import * as Modals from './modalManager.js'; +import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; let API_BASE = ''; let isOpen = false; @@ -31,6 +32,12 @@ import * as Modals from './modalManager.js'; let _emailAccountsCache = null; let _emailAccountsCacheAt = 0; let _emailHeaderManualExpandUntil = 0; + let _emailStreamAnimFrame = null; + let _emailStreamRenderedBody = ''; + let _emailStreamTargetBody = ''; + let _emailLocalDraftDebounce = null; + let _emailRichbodySaveDebounce = null; + const _EMAIL_LOCAL_DRAFT_PREFIX = 'odysseus.email.replyDraft.v1:'; // Diff mode state let _diffModeActive = false; @@ -38,6 +45,8 @@ import * as Modals from './modalManager.js'; let _diffNewContent = null; let _diffChunks = []; // [{id, oldLines, newLines, startLine, resolved, accepted}] let _diffUnresolvedCount = 0; + let _mdPreviewClickTimes = []; + let _mdPreviewHintLastAt = 0; // Language auto-detection config const AUTO_DETECT_DELAY = 500; @@ -666,7 +675,7 @@ import * as Modals from './modalManager.js'; overlay.className = 'modal pdf-export-overlay'; overlay.style.cssText = 'pointer-events:auto;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);'; overlay.innerHTML = ` -