From c5042149254fb310836e4885ccf786389809d2b6 Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Sun, 21 Jun 2026 11:02:35 +0000 Subject: [PATCH] Cookbook model workflow fixes --- routes/chat_helpers.py | 37 +- routes/chat_routes.py | 11 +- routes/cookbook_helpers.py | 19 +- routes/cookbook_routes.py | 212 +++- routes/email_helpers.py | 53 +- routes/email_routes.py | 204 +++- routes/hwfit_routes.py | 96 +- routes/model_routes.py | 11 +- routes/shell_routes.py | 58 +- src/agent_loop.py | 182 +++- src/agent_tools/web_tools.py | 44 +- src/builtin_actions.py | 98 +- src/endpoint_resolver.py | 3 + src/llm_core.py | 2 + src/task_endpoint.py | 66 +- src/task_scheduler.py | 45 +- src/tool_implementations.py | 31 +- static/icons/ollama-mark-crop.png | Bin 0 -> 7498 bytes static/icons/ollama-mark.png | Bin 0 -> 7487 bytes static/icons/sglang-logo.png | Bin 0 -> 109100 bytes static/icons/sglang-mark.png | Bin 0 -> 2171 bytes static/index.html | 9 +- static/js/chat.js | 24 +- static/js/compare/index.js | 90 +- static/js/compare/stream.js | 31 +- static/js/compare/vote.js | 2 +- static/js/cookbook-hwfit.js | 12 - static/js/cookbook.js | 295 +++++- static/js/cookbookRunning.js | 79 +- static/js/cookbookServe.js | 935 +++++++++++++++--- static/js/document.js | 159 ++- static/js/emailLibrary.js | 51 +- static/js/markdown.js | 25 +- static/js/modelPicker.js | 21 + static/js/tasks.js | 25 +- static/style.css | 520 +++++++++- tests/test_cookbook_cpu_only_serve.py | 30 + tests/test_copy_message_strips_thinking_js.py | 21 + 38 files changed, 3042 insertions(+), 459 deletions(-) create mode 100644 static/icons/ollama-mark-crop.png create mode 100644 static/icons/ollama-mark.png create mode 100644 static/icons/sglang-logo.png create mode 100644 static/icons/sglang-mark.png diff --git a/routes/chat_helpers.py b/routes/chat_helpers.py index 25f12d566..f030d6c91 100644 --- a/routes/chat_helpers.py +++ b/routes/chat_helpers.py @@ -22,6 +22,31 @@ from fastapi import HTTPException logger = logging.getLogger(__name__) +_CASUAL_OPENING_RE = re.compile( + r"^\s*(?:h+i+|hey+|hello+|yo+|sup+|what'?s up|wass?up|hiya|howdy|" + r"lol|lmao|haha+|hehe+|thanks?|thank you|ty|idk|dunno|meh|bruh|bro)\b(?P.*)$", + re.IGNORECASE, +) +_CASUAL_BLOCKLIST_RE = re.compile( + r"\b(?:cookbook|serve|serving|launch|start|vllm|sglang|llama\.?cpp|ollama|" + r"download|model|email|document|doc|note|calendar|task|search|web|research|" + r"file|folder|repo|git|settings?|endpoint|api|token|mcp)\b", + re.IGNORECASE, +) + + +def _is_casual_low_signal(text: str) -> bool: + """Short greetings/slang should not pull memory, skills, RAG, or docs.""" + s = str(text or "").strip() + m = _CASUAL_OPENING_RE.match(s) + if not m: + return False + tail = m.group("tail") or "" + if _CASUAL_BLOCKLIST_RE.search(tail): + return False + tail_words = re.findall(r"[A-Za-z0-9_'-]+", tail) + return len(tail_words) <= 2 + # ── Data containers ────────────────────────────────────────────────────── # @@ -579,6 +604,7 @@ async def build_chat_context( # Resolve user prefs user = get_current_user(request) uprefs = load_prefs_for_user(user) + casual_low_signal = _is_casual_low_signal(message) # Memory enabled? mem_enabled = not incognito and not no_memory and uprefs.get("memory_enabled", True) @@ -588,6 +614,9 @@ async def build_chat_context( if not allow_tool_preprocessing: mem_enabled = False skills_enabled = False + if casual_low_signal: + mem_enabled = False + skills_enabled = False logger.debug( "Memory enabled=%s for user=%s (incognito=%s, no_memory=%s, pref=%s)", mem_enabled, user, incognito, no_memory, uprefs.get("memory_enabled", "NOT_SET"), @@ -603,11 +632,11 @@ async def build_chat_context( # Use RAG? use_rag_val = (str(use_rag).lower() != "false") if use_rag is not None else True - if incognito or not allow_tool_preprocessing or is_research_spinoff: + if incognito or not allow_tool_preprocessing or is_research_spinoff or casual_low_signal: use_rag_val = False # If pre-fetched search context was provided (compare mode), skip live web search - skip_web = bool(search_context) or not allow_tool_preprocessing + skip_web = bool(search_context) or not allow_tool_preprocessing or casual_low_signal # Build context preface # The stream path uses enhanced_message (with CoT/preprocessing applied), @@ -626,7 +655,7 @@ async def build_chat_context( incognito=incognito, use_skills=skills_enabled, ) - if use_rag is not None or is_research_spinoff: + if use_rag is not None or is_research_spinoff or casual_low_signal: _preface_kwargs["use_rag"] = use_rag_val preface, rag_sources, web_sources = chat_processor.build_context_preface(**_preface_kwargs) @@ -634,7 +663,7 @@ async def build_chat_context( used_memories = getattr(chat_processor, '_last_used_memories', []) # Inject pre-fetched search context (compare mode) - if search_context and allow_tool_preprocessing: + if search_context and allow_tool_preprocessing and not casual_low_signal: preface.append(untrusted_context_message("prefetched search context", search_context)) # YouTube transcripts diff --git a/routes/chat_routes.py b/routes/chat_routes.py index c33f7c2c7..0967667f1 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -826,7 +826,11 @@ def setup_chat_routes( from src.settings import get_setting _global_disabled = get_setting("disabled_tools", []) if _global_disabled and isinstance(_global_disabled, list): - disabled_tools.update(_global_disabled) + explicit_web_allowed = allow_web_search is not None and str(allow_web_search).lower() == "true" + if explicit_web_allowed: + disabled_tools.update(t for t in _global_disabled if t not in {"web_search", "web_fetch"}) + else: + disabled_tools.update(_global_disabled) # Light auto-escalation: the user is in chat mode and just expressed a # notes/calendar/email intent. Grant the relevant managers but withhold @@ -1256,6 +1260,10 @@ def setup_chat_routes( _max_rounds = _DEFAULT_ROUNDS _max_rounds = max(1, min(_max_rounds, 200)) + _forced_tools = None + if allow_web_search is not None and str(allow_web_search).lower() == "true": + _forced_tools = {"web_search", "web_fetch"} + async for chunk in stream_agent_loop( sess.endpoint_url, sess.model, @@ -1277,6 +1285,7 @@ def setup_chat_routes( plan_mode=plan_mode, approved_plan=approved_plan or None, workspace=workspace or None, + forced_tools=_forced_tools, ): if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): try: diff --git a/routes/cookbook_helpers.py b/routes/cookbook_helpers.py index 3600a9ad1..bf1124933 100644 --- a/routes/cookbook_helpers.py +++ b/routes/cookbook_helpers.py @@ -964,18 +964,31 @@ def _append_llama_cpp_linux_accel_build_lines(runner_lines: list[str]) -> None: runner_lines.append(' fi # end _odysseus_have_prebuilt guard') -def _llama_cpp_rebuild_cmd() -> str: +def _llama_cpp_rebuild_cmd(update_source: bool = False) -> str: """Shell command that clears the Cookbook-managed llama.cpp build. Removes the cached ``llama-server`` symlink and the ``~/llama.cpp/build*`` directory so the next llama.cpp serve recompiles from source, picking up a CUDA or HIP toolchain if one is now available. The serve bootstrap only builds when ``llama-server`` is missing from PATH, so without this an - existing CPU-only build is reused forever. It deliberately installs and - downloads nothing; the rebuild itself happens on the next serve. + existing CPU-only build is reused forever. When ``update_source`` is true, + the command also fast-forwards the Cookbook-managed ``~/llama.cpp`` checkout + if it exists. The rebuild itself happens on the next serve. """ + update_cmd = '' + if update_source: + update_cmd = ( + 'if [ -d "$HOME/llama.cpp/.git" ]; then ' + 'git -C "$HOME/llama.cpp" pull --ff-only --depth 1 || ' + 'echo "[odysseus] WARNING: llama.cpp source update failed; clearing cached build anyway."; ' + 'elif command -v git >/dev/null 2>&1; then ' + 'git clone --depth 1 https://github.com/ggml-org/llama.cpp "$HOME/llama.cpp" || ' + 'echo "[odysseus] WARNING: llama.cpp clone failed; clearing cached build anyway."; ' + 'fi && ' + ) return ( 'mkdir -p "$HOME/bin" && ' + f'{update_cmd}' 'rm -f "$HOME/bin/llama-server" && ' 'rm -rf "$HOME/llama.cpp/build" "$HOME/llama.cpp/build-vulkan" && ' 'echo "[odysseus] Cleared the cached llama.cpp build. ' diff --git a/routes/cookbook_routes.py b/routes/cookbook_routes.py index 0bd38d19f..0d8257574 100644 --- a/routes/cookbook_routes.py +++ b/routes/cookbook_routes.py @@ -273,6 +273,78 @@ def setup_cookbook_routes() -> APIRouter: def _load_stored_hf_token() -> str: return load_stored_hf_token(state_path=_cookbook_state_path) + def _normalize_minimax_m3_vllm_cmd(cmd: str) -> str: + """Patch MiniMax M3 vLLM launches into the known-good local form. + + The browser form can be stale or omit advanced-only fields. MiniMax M3 + is sensitive to several flags: using the HF repo id with block-size 128 + fails KV-cache setup, and FlashInfer sampler JIT fails on this host's + system nvcc. Normalize server-side before writing the tmux runner. + """ + if not cmd or "vllm serve" not in cmd or not re.search(r"minimax.*m3", cmd, re.I): + return cmd + try: + parts = shlex.split(cmd) + except ValueError: + return cmd + if "serve" not in parts: + return cmd + + env_re = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") + env_parts = [p for p in parts if env_re.match(p)] + body = [p for p in parts if not env_re.match(p)] + try: + serve_i = body.index("serve") + except ValueError: + return cmd + if serve_i + 1 >= len(body): + return cmd + + repo_id = "cyankiwi/MiniMax-M3-AWQ-INT4" + snapshot = ( + "/home/pewds/.cache/huggingface/hub/" + "models--cyankiwi--MiniMax-M3-AWQ-INT4/" + "snapshots/4082acbbec1236d21828d55b6bb0fe02ade4ab5b" + ) + if body[serve_i + 1] == repo_id: + body[serve_i + 1] = snapshot + + def add_env(key: str, value: str) -> None: + if not any(p.startswith(f"{key}=") for p in env_parts): + env_parts.append(f"{key}={value}") + + def has_flag(flag: str) -> bool: + return any(p == flag or p.startswith(flag + "=") for p in body) + + def set_flag(flag: str, value: str) -> None: + for i, part in enumerate(body): + if part == flag: + if i + 1 < len(body): + body[i + 1] = value + else: + body.append(value) + return + if part.startswith(flag + "="): + body[i] = f"{flag}={value}" + return + body.extend([flag, value]) + + def add_bool(flag: str) -> None: + if not has_flag(flag): + body.append(flag) + + add_env("VLLM_TARGET_DEVICE", "cuda") + add_env("VLLM_USE_FLASHINFER_SAMPLER", "0") + set_flag("--served-model-name", repo_id) + set_flag("--tool-call-parser", "minimax_m3") + set_flag("--reasoning-parser", "minimax_m3") + set_flag("--attention-backend", "TRITON_ATTN") + set_flag("--block-size", "128") + add_bool("--language-model-only") + add_bool("--disable-custom-all-reduce") + add_bool("--enable-expert-parallel") + return shlex.join(env_parts + body) + def _cookbook_ssh_dir() -> Path: # The Docker image keeps cookbook keys under /app/.ssh; that path only # exists inside the container. On Windows (and any non-container host) @@ -1249,6 +1321,7 @@ def setup_cookbook_routes() -> APIRouter: # `TypeError: argument of type 'NoneType'` (a 500 instead of a clean 400). req.cmd = _validate_serve_cmd(req.cmd) or "" req.cmd = _normalize_llama_cpp_python_cache_types(req.cmd) or "" + req.cmd = _normalize_minimax_m3_vllm_cmd(req.cmd) req.cmd = _venv_safe_local_pip_install_cmd( req.cmd, local=not bool(req.remote_host), @@ -1579,6 +1652,96 @@ def setup_cookbook_routes() -> APIRouter: runner_lines.append(' echo "ERROR: vLLM is not installed."') runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127') runner_lines.append('fi') + runner_lines.append(f"ODYSSEUS_SERVE_CMD='{_bash_squote(req.cmd)}'") + runner_lines.append('if [ -z "$ODYSSEUS_PREFLIGHT_EXIT" ]; then') + runner_lines.append(' ODYSSEUS_VLLM_HELP_CMD="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'') + runner_lines.append('import shlex, sys') + runner_lines.append('parts = shlex.split(sys.argv[1])') + runner_lines.append('try:') + runner_lines.append(' serve_i = parts.index("serve")') + runner_lines.append('except ValueError:') + runner_lines.append(' print("vllm serve --help")') + runner_lines.append('else:') + runner_lines.append(' print(shlex.join(parts[:serve_i + 1] + ["--help"]))') + runner_lines.append('PY') + runner_lines.append(')"') + runner_lines.append(' ODYSSEUS_VLLM_SUPPORTS_SWAP=0') + runner_lines.append(' if eval "$ODYSSEUS_VLLM_HELP_CMD" 2>&1 | grep -q -- "--swap-space"; then ODYSSEUS_VLLM_SUPPORTS_SWAP=1; fi') + runner_lines.append('fi') + runner_lines.append('if [ -z "$ODYSSEUS_PREFLIGHT_EXIT" ] && [ "${ODYSSEUS_VLLM_SUPPORTS_SWAP:-0}" = "1" ] && ! printf "%s" "$ODYSSEUS_SERVE_CMD" | grep -q -- "--swap-space"; then') + runner_lines.append(' echo "[odysseus] Setting vLLM --swap-space 0 so the runtime does not reserve CPU swap per GPU."') + runner_lines.append(' ODYSSEUS_SERVE_CMD="${ODYSSEUS_SERVE_CMD} --swap-space 0"') + runner_lines.append('fi') + runner_lines.append('if [ -z "$ODYSSEUS_PREFLIGHT_EXIT" ] && [ "${ODYSSEUS_VLLM_SUPPORTS_SWAP:-0}" != "1" ]; then') + runner_lines.append(' if printf "%s" "$ODYSSEUS_SERVE_CMD" | grep -q -- "--swap-space"; then') + runner_lines.append(' echo "[odysseus] vLLM serve does not expose --swap-space; removing the flag and patching the runtime default to 0."') + runner_lines.append(' ODYSSEUS_SERVE_CMD="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'') + runner_lines.append('import shlex, sys') + runner_lines.append('parts = shlex.split(sys.argv[1])') + runner_lines.append('out = []') + runner_lines.append('skip = False') + runner_lines.append('for part in parts:') + runner_lines.append(' if skip:') + runner_lines.append(' skip = False') + runner_lines.append(' continue') + runner_lines.append(' if part == "--swap-space":') + runner_lines.append(' skip = True') + runner_lines.append(' continue') + runner_lines.append(' if part.startswith("--swap-space="):') + runner_lines.append(' continue') + runner_lines.append(' out.append(part)') + runner_lines.append('print(shlex.join(out))') + runner_lines.append('PY') + runner_lines.append(')"') + runner_lines.append(' fi') + runner_lines.append(' ODYSSEUS_SERVE_CMD="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'') + runner_lines.append('import shlex, sys') + runner_lines.append('parts = shlex.split(sys.argv[1])') + runner_lines.append('patch = r"""import inspect, sys') + runner_lines.append('from vllm.engine.arg_utils import EngineArgs, AsyncEngineArgs') + runner_lines.append('def _odysseus_swap0(cls):') + runner_lines.append(' params = list(inspect.signature(cls).parameters)') + runner_lines.append(' if "swap_space" not in params:') + runner_lines.append(' return') + runner_lines.append(' idx = params.index("swap_space")') + runner_lines.append(' defaults = list(cls.__init__.__defaults__ or ())') + runner_lines.append(' if idx < len(defaults):') + runner_lines.append(' defaults[idx] = 0') + runner_lines.append(' cls.__init__.__defaults__ = tuple(defaults)') + runner_lines.append(' fields = getattr(cls, "__dataclass_fields__", {})') + runner_lines.append(' if "swap_space" in fields:') + runner_lines.append(' fields["swap_space"].default = 0') + runner_lines.append('_odysseus_swap0(EngineArgs)') + runner_lines.append('_odysseus_swap0(AsyncEngineArgs)') + runner_lines.append('try:') + runner_lines.append(' from vllm.config import CacheConfig') + runner_lines.append(' CacheConfig.swap_space = 0') + runner_lines.append('except Exception:') + runner_lines.append(' pass') + runner_lines.append('_orig_create_engine_config = EngineArgs.create_engine_config') + runner_lines.append('def _odysseus_create_engine_config(self, *args, **kwargs):') + runner_lines.append(' self.swap_space = 0') + runner_lines.append(' return _orig_create_engine_config(self, *args, **kwargs)') + runner_lines.append('EngineArgs.create_engine_config = _odysseus_create_engine_config') + runner_lines.append('AsyncEngineArgs.create_engine_config = _odysseus_create_engine_config') + runner_lines.append('from vllm.entrypoints.cli.main import main') + runner_lines.append('sys.exit(main())"""') + runner_lines.append('try:') + runner_lines.append(' serve_i = parts.index("serve")') + runner_lines.append('except ValueError:') + runner_lines.append(' print(shlex.join(parts))') + runner_lines.append('else:') + runner_lines.append(' exe_i = serve_i - 1') + runner_lines.append(' exe = parts[exe_i] if exe_i >= 0 else "vllm"') + runner_lines.append(' py = "python3"') + runner_lines.append(' if exe.endswith("/bin/vllm"):') + runner_lines.append(' py = exe[:-len("/bin/vllm")] + "/bin/python"') + runner_lines.append(' parts[exe_i:serve_i] = [py, "-c", patch]') + runner_lines.append(' print(shlex.join(parts))') + runner_lines.append('PY') + runner_lines.append(')"') + runner_lines.append(' echo "[odysseus] Patched vLLM internal swap_space default to 0 for this runtime."') + runner_lines.append('fi') elif "sglang.launch_server" in req.cmd: runner_lines.append('export PATH="$HOME/.local/bin:$PATH"') runner_lines.append('if ! command -v sglang &>/dev/null; then') @@ -1620,7 +1783,10 @@ def setup_cookbook_routes() -> APIRouter: runner_lines, keep_shell_open=not local_windows, ) - runner_lines.append(req.cmd) + if "vllm serve" in req.cmd: + runner_lines.append('eval "$ODYSSEUS_SERVE_CMD"') + else: + runner_lines.append(req.cmd) if local_windows: # Detached background process — no interactive shell to keep open. # Print the exit marker the status poller looks for, then stop. @@ -2418,16 +2584,14 @@ def setup_cookbook_routes() -> APIRouter: # Add 30% headroom for KV cache, activations, etc. needed_vram = (est_vram * 1.3) if est_vram else None - if vram_gb > 0 and needed_vram is not None and needed_vram > vram_gb: - continue - # Unknown-size models (e.g. MiniMax-M2.7, DeepSeek-V4-Flash) have no - # "NB" in the repo id, so the regex above can't extract their - # param count. Previously we dropped them entirely, which made - # brand-new flagship releases silently vanish from this list even - # on rigs with hundreds of GB of VRAM. Adapters/LoRAs are already - # filtered by _is_excluded(), so what falls through here is - # overwhelmingly full models — keep them, just without a size - # badge (the frontend handles needed_vram_gb=null gracefully). + if vram_gb > 0: + if needed_vram is None: + # The "trending models that fit" list must be conservative: + # if we cannot estimate size from the repo id/tags, do not + # present it as runnable on this hardware. + continue + if needed_vram > vram_gb: + continue out.append({ "repo_id": repo_id, @@ -2624,6 +2788,32 @@ def setup_cookbook_routes() -> APIRouter: except Exception as e: logger.warning(f"orphan sweep: state write failed: {e}") + @router.get("/api/cookbook/hf-gguf-files") + async def hf_gguf_files(repo_id: str, owner: str = Depends(require_user)): + """List GGUF files in a HuggingFace repo for the direct-download picker.""" + import httpx + + repo_id = _validate_repo_id(repo_id) + url = f"https://huggingface.co/api/models/{repo_id}" + try: + headers = {} + token = _load_stored_hf_token() + if token: + headers["Authorization"] = f"Bearer {token}" + async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: + resp = await client.get(url, headers=headers) + if resp.status_code != 200: + return {"ok": False, "files": [], "error": f"HF API HTTP {resp.status_code}"} + data = resp.json() + except Exception as e: + return {"ok": False, "files": [], "error": str(e)} + files = [ + str(s.get("rfilename") or "") + for s in data.get("siblings", []) + if str(s.get("rfilename") or "").lower().endswith(".gguf") + ] + return {"ok": True, "repo_id": repo_id, "files": files} + # In-memory cache for the Ollama library scrape. ollama.com is a public # site, but it doesn't expose a stable JSON listing — we fetch the HTML # search page and regex out the model cards. Cached for 1 h so a busy diff --git a/routes/email_helpers.py b/routes/email_helpers.py index b3df6a560..a054f4df8 100644 --- a/routes/email_helpers.py +++ b/routes/email_helpers.py @@ -1109,22 +1109,30 @@ def _list_attachments_from_msg(msg): return attachments idx = 0 for part in msg.walk(): - if part.is_multipart(): - continue cd = str(part.get("Content-Disposition", "")) ct = part.get_content_type() + is_attached_email = ct == "message/rfc822" and ("attachment" in cd.lower() or part.get_filename()) + if part.is_multipart() and not is_attached_email: + continue # Skip text/html body parts (only consider real attachments) if ct in ("text/plain", "text/html") and "attachment" not in cd: continue filename = part.get_filename() if filename: filename = _decode_header(filename) + if ct == "message/rfc822" and not re.search(r"\.[A-Za-z0-9]{1,8}$", filename): + filename = f"{filename}.eml" else: # Inline images, etc. - generate a name - ext = ct.split("/")[-1] if "/" in ct else "bin" + ext = "eml" if ct == "message/rfc822" else (ct.split("/")[-1] if "/" in ct else "bin") filename = f"attachment_{idx}.{ext}" payload = part.get_payload(decode=True) - size = len(payload) if payload else 0 + if payload is None and ct == "message/rfc822": + try: + payload = part.as_bytes() + except Exception: + payload = b"" + size = len(payload) if payload is not None else 0 attachments.append({ "index": idx, "filename": filename, @@ -1136,29 +1144,58 @@ def _list_attachments_from_msg(msg): return attachments +def _is_likely_signature_image_attachment(att: dict) -> bool: + """Match the reader's inline signature/logo image filter.""" + filename = str((att or {}).get("filename") or "").lower() + if not re.search(r"\.(png|jpe?g|gif|bmp|svg|webp)$", filename): + return False + size = int((att or {}).get("size") or 0) + if re.search(r"^image\d{3,}\.(png|jpe?g|gif)$", filename): + return True + if re.search(r"^(signature|logo|sig|footer|banner)[-_\d]*\.(png|jpe?g|gif|svg)$", filename): + return True + return 0 < size < 30 * 1024 + + +def _has_visible_attachments(msg) -> bool: + """Return True only for attachments the reader will render as chips.""" + return any( + not _is_likely_signature_image_attachment(att) + for att in _list_attachments_from_msg(msg) + ) + + def _extract_attachment_to_disk(msg, index, target_dir): """Extract a specific attachment to disk and return the file path.""" if not msg.is_multipart(): return None idx = 0 for part in msg.walk(): - if part.is_multipart(): - continue cd = str(part.get("Content-Disposition", "")) ct = part.get_content_type() + is_attached_email = ct == "message/rfc822" and ("attachment" in cd.lower() or part.get_filename()) + if part.is_multipart() and not is_attached_email: + continue if ct in ("text/plain", "text/html") and "attachment" not in cd: continue if idx == index: filename = part.get_filename() if filename: filename = _decode_header(filename) + if ct == "message/rfc822" and not re.search(r"\.[A-Za-z0-9]{1,8}$", filename): + filename = f"{filename}.eml" else: - ext = ct.split("/")[-1] if "/" in ct else "bin" + ext = "eml" if ct == "message/rfc822" else (ct.split("/")[-1] if "/" in ct else "bin") filename = f"attachment_{idx}.{ext}" # Sanitize safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip() payload = part.get_payload(decode=True) - if not payload: + if payload is None and ct == "message/rfc822": + try: + payload = part.as_bytes() + except Exception: + payload = b"" + if payload is None: return None target_dir.mkdir(parents=True, exist_ok=True) filepath = target_dir / safe_name diff --git a/routes/email_routes.py b/routes/email_routes.py index 416ad55b2..81d2b7330 100644 --- a/routes/email_routes.py +++ b/routes/email_routes.py @@ -44,7 +44,7 @@ from routes.email_helpers import ( _send_smtp_message, _smtp_security_mode, _IMAP_TIMEOUT_SECONDS, _open_imap_connection, _imap_connect, _imap, _decode_header, _detect_sent_folder, _detect_drafts_folder, - _extract_attachment_text, _list_attachments_from_msg, + _extract_attachment_text, _list_attachments_from_msg, _has_visible_attachments, _is_likely_signature_image_attachment, _extract_attachment_to_disk, _extract_html, _extract_text, _fetch_sender_thread_context, _pre_retrieve_context, _EMAIL_REPLY_SYS_PROMPT_BASE, _POOL_HOOKS, @@ -58,6 +58,7 @@ from routes.email_pollers import _start_poller logger = logging.getLogger(__name__) ODYSSEUS_MAIL_ORIGIN = "odysseus-ui" +EMAIL_READ_ATTACHMENT_VERSION = 2 def _email_tag_owner_aliases(account_id: str | None, owner: str = "") -> list[str]: @@ -244,6 +245,21 @@ def _imap_uid_fetch(conn, uid_set: str | bytes, query: str): return conn.uid("FETCH", _uid_bytes(uid_set), query) +def _imap_search_quote(value: str) -> str: + return '"' + str(value or "").replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def _message_id_chain(*values: str) -> list[str]: + seen = set() + out = [] + for value in values: + for mid in re.findall(r"<[^>]+>", value or ""): + if mid not in seen: + seen.add(mid) + out.append(mid) + return out + + def _uid_from_fetch_meta(meta_b: bytes) -> str: m = re.search(rb"\bUID\s+(\d+)\b", meta_b) return m.group(1).decode() if m else "" @@ -1003,6 +1019,65 @@ def setup_email_routes(): except Exception: pass + def _related_thread_attachments_sync( + folder: str, + account_id: str | None, + owner: str, + current_uid: str, + current_message_id: str, + in_reply_to: str, + references: str, + limit: int = 12, + ) -> list[dict]: + """Return visible attachments from referenced messages in this folder.""" + wanted_ids = _message_id_chain(references, in_reply_to) + current_mid = (current_message_id or "").strip() + wanted_ids = [mid for mid in wanted_ids if mid and mid != current_mid] + if not wanted_ids: + return [] + + related: list[dict] = [] + try: + with _imap(account_id, owner=owner) as conn: + conn.select(_q(folder), readonly=True) + # Search newest referenced messages first; cap work so opening + # a long thread stays bounded. + for mid in reversed(wanted_ids[-10:]): + if len(related) >= limit: + break + status, data = _imap_uid_search(conn, f'(HEADER Message-ID {_imap_search_quote(mid)})') + if status != "OK" or not data or not data[0]: + continue + for uid_b in reversed(data[0].split()[-3:]): + source_uid = uid_b.decode(errors="ignore") + if not source_uid or source_uid == str(current_uid): + continue + st2, msg_data = _imap_uid_fetch(conn, source_uid, "(BODY.PEEK[])") + if st2 != "OK" or not msg_data or not isinstance(msg_data[0], tuple): + continue + msg = email_mod.message_from_bytes(msg_data[0][1]) + source_from = _decode_header(msg.get("From", "")) + source_subject = _decode_header(msg.get("Subject", "")) + source_date = msg.get("Date", "") + for att in _list_attachments_from_msg(msg): + if _is_likely_signature_image_attachment(att): + continue + enriched = dict(att) + enriched.update({ + "source_uid": source_uid, + "source_folder": folder, + "source_message_id": (msg.get("Message-ID") or "").strip(), + "source_from": source_from, + "source_subject": source_subject, + "source_date": source_date, + }) + related.append(enriched) + if len(related) >= limit: + break + except Exception as e: + logger.debug(f"related thread attachment lookup failed uid={current_uid}: {e}") + return related + @router.get("/list") async def list_emails( folder: str = Query("INBOX"), @@ -1273,6 +1348,17 @@ def setup_email_routes(): sender_name, sender_addr = email.utils.parseaddr(sender) parsed_date = email.utils.parsedate_to_datetime(date_str) if date_str else None attachments = _list_attachments_from_msg(msg) + related_attachments = [] + if not _has_visible_attachments(msg): + related_attachments = _related_thread_attachments_sync( + folder, + account_id, + owner, + uid, + message_id, + in_reply_to, + references, + ) if mark_seen: # Set \Seen in a separate readwrite session so concurrent reads @@ -1381,6 +1467,8 @@ def setup_email_routes(): "body": body, "body_html": body_html, "attachments": attachments, + "related_attachments": related_attachments, + "attachment_version": EMAIL_READ_ATTACHMENT_VERSION, "cached_summary": cached_summary, "cached_ai_reply": cached_ai_reply, "boundaries": cached_boundaries, @@ -1411,6 +1499,12 @@ def setup_email_routes(): """Read email body. Cached for 30m, sync IMAP work runs in a thread.""" ck = _read_cache_key(account_id, folder, uid, owner=owner) cached = _read_cache_get(ck) + if cached is not None: + # Older cached read responses lack the thread-attachment fallback. + # Fetch once so replies that reference prior attachments can show + # those files without waiting for cache expiry. + if cached.get("attachment_version") != EMAIL_READ_ATTACHMENT_VERSION: + cached = None if cached is not None: if mark_seen: try: @@ -1599,6 +1693,65 @@ def setup_email_routes(): return None doc_session_id = _resolve_doc_session() + def _create_markdown_doc(content: str, summary: str): + from src.database import SessionLocal as _SL, Document as _Doc, DocumentVersion as _DV + doc_id = str(uuid.uuid4()) + ver_id = str(uuid.uuid4()) + _db = _SL() + try: + _db.query(_Doc).filter(_Doc.is_active == True).update({"is_active": False}) + _db.add(_Doc( + id=doc_id, session_id=doc_session_id, title=title, + language="markdown", current_content=content, + version_count=1, is_active=True, + )) + _db.add(_DV( + id=ver_id, document_id=doc_id, version_number=1, + content=content, summary=summary, source="upload", + )) + _db.commit() + finally: + _db.close() + _tag_doc_with_source(doc_id) + return doc_id + + def _attached_email_markdown(path): + raw_bytes = path.read_bytes() + if not raw_bytes: + return f"# Attached email: {base}\n\n_(empty email attachment)_" + try: + attached_msg = email_mod.message_from_bytes(raw_bytes) + except Exception as e: + return f"# Attached email: {base}\n\nCould not parse this email attachment: {e}" + + attached_subject = _decode_header(attached_msg.get("Subject", "")) or base + attached_from = _decode_header(attached_msg.get("From", "")) + attached_to = _decode_header(attached_msg.get("To", "")) + attached_cc = _decode_header(attached_msg.get("Cc", "")) + attached_date = attached_msg.get("Date", "") + attached_body = _extract_text(attached_msg).strip() + attached_atts = _list_attachments_from_msg(attached_msg) + + lines = [f"# Attached email: {attached_subject}", ""] + if attached_from: + lines.append(f"**From:** {attached_from}") + if attached_to: + lines.append(f"**To:** {attached_to}") + if attached_cc: + lines.append(f"**Cc:** {attached_cc}") + if attached_date: + lines.append(f"**Date:** {attached_date}") + lines.extend(["", "## Body", "", attached_body or "_(no readable body)_"]) + if attached_atts: + lines.extend(["", "## Attachments", ""]) + for att in attached_atts: + size = int(att.get("size") or 0) + size_label = f"{size} B" if size < 1024 else f"{round(size / 1024)} KB" + name = att.get("filename") or f"attachment_{att.get('index', '')}" + ctype = att.get("content_type") or "application/octet-stream" + lines.append(f"- {name} ({ctype}, {size_label})") + return "\n".join(lines).strip() + # ── PDF path (existing) ──────────────────────────────────── if ext == ".pdf": import shutil as _shutil @@ -1645,6 +1798,15 @@ def setup_email_routes(): _tag_doc_with_source(doc_id) return {"doc_id": doc_id, "filename": filepath.name} + # ── Attached email (.eml / message/rfc822) ──────────────── + if ext == ".eml": + try: + content = _attached_email_markdown(filepath) + except Exception as e: + return {"error": f"Failed to read email attachment: {e}", "filename": base} + doc_id = _create_markdown_doc(content, "Imported attached email") + return {"doc_id": doc_id, "filename": filepath.name} + # ── DOCX path: extract text → markdown document ─────────── if ext == ".docx": try: @@ -1682,25 +1844,7 @@ def setup_email_routes(): lines.append("") content = "\n".join(lines).strip() or f"_(empty {base})_" - from src.database import SessionLocal as _SL, Document as _Doc, DocumentVersion as _DV - doc_id = str(uuid.uuid4()) - ver_id = str(uuid.uuid4()) - _db = _SL() - try: - _db.query(_Doc).filter(_Doc.is_active == True).update({"is_active": False}) - _db.add(_Doc( - id=doc_id, session_id=doc_session_id, title=title, - language="markdown", current_content=content, - version_count=1, is_active=True, - )) - _db.add(_DV( - id=ver_id, document_id=doc_id, version_number=1, - content=content, summary="Imported from DOCX", source="upload", - )) - _db.commit() - finally: - _db.close() - _tag_doc_with_source(doc_id) + doc_id = _create_markdown_doc(content, "Imported from DOCX") return {"doc_id": doc_id, "filename": filepath.name} # ── Plain text / markdown ──────────────────────────────── @@ -1709,25 +1853,7 @@ def setup_email_routes(): content = filepath.read_text(encoding="utf-8", errors="replace") except Exception as e: return {"error": f"Failed to read text file: {e}", "filename": base} - from src.database import SessionLocal as _SL, Document as _Doc, DocumentVersion as _DV - doc_id = str(uuid.uuid4()) - ver_id = str(uuid.uuid4()) - _db = _SL() - try: - _db.query(_Doc).filter(_Doc.is_active == True).update({"is_active": False}) - _db.add(_Doc( - id=doc_id, session_id=doc_session_id, title=title, - language="markdown", current_content=content, - version_count=1, is_active=True, - )) - _db.add(_DV( - id=ver_id, document_id=doc_id, version_number=1, - content=content, summary="Imported from email attachment", source="upload", - )) - _db.commit() - finally: - _db.close() - _tag_doc_with_source(doc_id) + doc_id = _create_markdown_doc(content, "Imported from email attachment") return {"doc_id": doc_id, "filename": filepath.name} return {"error": f"Unsupported attachment type: {ext}", "filename": base} diff --git a/routes/hwfit_routes.py b/routes/hwfit_routes.py index 5e38b9ca3..0dad0ddd7 100644 --- a/routes/hwfit_routes.py +++ b/routes/hwfit_routes.py @@ -1,8 +1,13 @@ +import json +import os import re +import shlex +import subprocess from copy import deepcopy from fastapi import APIRouter, HTTPException +from core.platform_compat import run_ssh_command from routes._validators import validate_remote_host, validate_ssh_port @@ -107,6 +112,73 @@ def _apply_manual_hardware(system, manual_mode="", manual_gpu_count="", manual_v return system +def _run_model_probe(host: str, ssh_port: str, cmd: str) -> str: + try: + if host: + r = run_ssh_command( + host, + ssh_port or None, + cmd, + timeout=15, + connect_timeout=5, + strict_host_key_checking=False, + text=True, + ) + else: + r = subprocess.run(["bash", "-lc", cmd], capture_output=True, text=True, timeout=15) + if r.returncode == 0: + return (r.stdout or "").strip() + except Exception: + return "" + return "" + + +def _inspect_model_path(model_path: str, host: str = "", ssh_port: str = "") -> dict: + """Read lightweight metadata from a local or SSH-visible HF model folder.""" + path = (model_path or "").strip() + if not path or path.startswith(("http://", "https://")): + return {} + if not (path.startswith("/") or path.startswith("~")): + return {} + + qpath = shlex.quote(path) + qconfig = shlex.quote(os.path.join(path, "config.json")) + out = {} + exists = _run_model_probe(host, ssh_port, f"test -d {qpath} && printf found || printf missing") + if exists != "found": + target = host or "local container" + out["model_probe_error"] = f"Model path is not visible on {target}: {path}" + return out + raw_config = _run_model_probe(host, ssh_port, f"test -f {qconfig} && sed -n '1,240p' {qconfig}") + if raw_config: + try: + cfg = json.loads(raw_config) + except Exception: + cfg = {} + for key in ("context_length", "max_position_embeddings", "n_ctx_train", "model_max_length", "max_seq_len"): + value = cfg.get(key) + if isinstance(value, (int, float)) and value > 0: + out["model_ctx_max"] = int(value) + break + else: + out["model_probe_error"] = f"config.json not found in model path: {path}" + + size_cmd = ( + f"find {qpath} -type f \\( -name '*.safetensors' -o -name '*.bin' -o -name '*.gguf' \\) " + "-printf '%s\\n' 2>/dev/null | awk '{s+=$1} END {if (s>0) printf \"%.6f\", s/1073741824}'" + ) + weights = _run_model_probe(host, ssh_port, size_cmd) + try: + weights_gb = float(weights) + except Exception: + weights_gb = 0.0 + if weights_gb > 0: + out["model_weights_gb"] = round(weights_gb, 3) + elif "model_probe_error" not in out: + out["model_probe_error"] = f"No model weight files found in: {path}" + return out + + def setup_hwfit_routes(): router = APIRouter(prefix="/api/hwfit", tags=["hwfit"]) @@ -235,7 +307,7 @@ def setup_hwfit_routes(): return {"system": system, "models": results} @router.get("/profiles") - def get_serve_profiles(model: str = "", host: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, serve_weights_gb: float = 0.0, serve_quant: str = ""): + def get_serve_profiles(model: str = "", model_path: str = "", host: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, serve_weights_gb: float = 0.0, serve_quant: str = ""): """Compute llama.cpp serve profiles (Quality/Balanced/Speed) for `model` against the detected hardware on `host` (or local). Returns concrete flags (n_gpu_layers, n_cpu_moe, cache_type, ctx) the serve UI can apply. @@ -272,8 +344,16 @@ def setup_hwfit_routes(): if nn and (nn == want or want.endswith(nn) or nn.endswith(want)): m = entry break + path_meta = _inspect_model_path(model_path or model, host=host, ssh_port=ssh_port) if m is None: - return {"system": system, "profiles": [], "error": "model not in catalog"} + return { + "system": system, + "profiles": [], + "error": "model not in catalog", + "model_ctx_max": int(path_meta.get("model_ctx_max") or 0), + "model_weights_gb": float(path_meta.get("model_weights_gb") or 0), + "model_probe_error": path_meta.get("model_probe_error") or "", + } # Surface the model's trained context limit so the serve UI can clamp a # user-typed context down to it (asking for ctx > n_ctx_train overflows # and, with a quantized KV cache, can crash the GPU). @@ -283,6 +363,16 @@ def setup_hwfit_routes(): if isinstance(v, (int, float)) and v > 0: model_ctx_max = int(v) break + path_ctx_max = int(path_meta.get("model_ctx_max") or 0) + if path_ctx_max > 0: + model_ctx_max = max(model_ctx_max, path_ctx_max) + model_weights_gb = float(path_meta.get("model_weights_gb") or 0) + if model_weights_gb <= 0: + for k in ("min_vram_gb", "required_gb", "size_gb", "recommended_ram_gb", "min_ram_gb"): + v = m.get(k) + if isinstance(v, (int, float)) and v > 0: + model_weights_gb = float(v) + break return { "system": system, "profiles": compute_serve_profiles( @@ -291,6 +381,8 @@ def setup_hwfit_routes(): serve_quant=(serve_quant or None), ), "model_ctx_max": model_ctx_max, + "model_weights_gb": model_weights_gb, + "model_probe_error": path_meta.get("model_probe_error") or "", } @router.get("/image-models") diff --git a/routes/model_routes.py b/routes/model_routes.py index 000cd9379..69528f6dc 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -1064,9 +1064,11 @@ def setup_model_routes(model_discovery): except Exception: return 0.0 - def _failure_delay(fails: int) -> float: + def _failure_delay(fails: int, *, empty_local: bool = False) -> float: if fails <= 0: return 0.0 + if empty_local: + return min(5.0 * (2 ** max(0, fails - 1)), 30.0) return min(_REFRESH_FAILURE_BASE * (2 ** max(0, fails - 1)), _REFRESH_FAILURE_MAX) def _should_refresh_endpoint(ep: Any, now: float, force: bool = False) -> tuple[bool, Dict[str, Any]]: @@ -1097,7 +1099,12 @@ def setup_model_routes(model_discovery): fails = int(state.get("fail_count") or 0) if fails and not force: last_failure = float(state.get("last_failure") or 0.0) - if now - last_failure < _failure_delay(fails): + empty_local = ( + not cached + and category == "local" + and str(getattr(ep, "id", "") or "").startswith("local-") + ) + if now - last_failure < _failure_delay(fails, empty_local=empty_local): return False, info if cached and not force: interval = _endpoint_refresh_interval(ep, category) diff --git a/routes/shell_routes.py b/routes/shell_routes.py index 406d80bb3..245181832 100644 --- a/routes/shell_routes.py +++ b/routes/shell_routes.py @@ -330,6 +330,9 @@ def add_user_install_bins_to_path(): candidates.append(os.path.join(site.USER_BASE, 'bin')) except Exception: pass + candidates.append(os.path.expanduser('~/bin')) + candidates.append(os.path.expanduser('~/llama.cpp/build/bin')) + candidates.append(os.path.expanduser('~/llama.cpp/build-vulkan/bin')) candidates.append(os.path.expanduser('~/.local/bin')) parts = os.environ.get('PATH', '').split(os.pathsep) if os.environ.get('PATH') else [] changed = False @@ -1188,6 +1191,7 @@ def setup_shell_routes() -> APIRouter: # venv over SSH so a remote `pip install` actually reflects here. remote_status: dict = {} remote_details: dict = {} + remote_probe_error = "" remote_names = [ p["name"] for p in packages @@ -1226,8 +1230,34 @@ def setup_shell_routes() -> APIRouter: break except ValueError as e: raise HTTPException(400, str(e)) - except Exception: + except Exception as e: remote_status = {} + remote_probe_error = f"SSH package probe failed: {str(e)[:160]}" + if "llama_cpp" in remote_names: + try: + inner = ( + 'export PATH="$HOME/.local/bin:$HOME/bin:' + '$HOME/llama.cpp/build/bin:$HOME/llama.cpp/build-vulkan/bin:$PATH"; ' + "command -v llama-server 2>/dev/null || true" + ) + argv = _ssh_base_argv(host, ssh_port) + [inner] + proc = await asyncio.create_subprocess_exec( + *argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + out, _err = await asyncio.wait_for(proc.communicate(), timeout=8) + llama_server_path = out.decode("utf-8", errors="replace").strip().splitlines() + llama_server_path = llama_server_path[-1].strip() if llama_server_path else "" + if llama_server_path: + remote_status["llama_cpp"] = True + probe = remote_details.setdefault("llama_cpp", {}) + if isinstance(probe, dict): + probe.setdefault("binaries", {})["llama-server"] = llama_server_path + except Exception as e: + if not remote_probe_error: + remote_probe_error = f"SSH llama-server probe failed: {str(e)[:160]}" + pass # Union of system_names + every package's system_prereqs. Probing # the prereqs alongside the main system deps in a single SSH call # avoids a second round-trip per Cookbook → Dependencies refresh. @@ -1272,7 +1302,9 @@ def setup_shell_routes() -> APIRouter: target_os_id = _os_id_from_release("\n".join(_osrel_lines)) except ValueError as e: raise HTTPException(400, str(e)) - except Exception: + except Exception as e: + if not remote_probe_error: + remote_probe_error = f"SSH system probe failed: {str(e)[:160]}" pass elif not host: # Local target — probe in-process so the inline install command @@ -1290,7 +1322,12 @@ def setup_shell_routes() -> APIRouter: on_remote = bool(host and pkg.get("target") == "remote") probe = None if on_remote: - pkg["installed"] = bool(remote_status.get(pkg["name"], False)) + if remote_probe_error and pkg["name"] not in remote_status: + pkg["installed"] = None + pkg["probe_error"] = remote_probe_error + pkg["status_note"] = remote_probe_error + else: + pkg["installed"] = bool(remote_status.get(pkg["name"], False)) probe = remote_details.get(pkg["name"]) if isinstance(probe, dict): pkg["details"] = probe @@ -1353,9 +1390,19 @@ def setup_shell_routes() -> APIRouter: # reads "ready" green while inference runs at 3 tok/s on GPU # silicon — actively misleading. if pkg["name"] == "llama_cpp" and pkg.get("installed"): + _native_llama_server = bool( + isinstance(probe, dict) + and isinstance(probe.get("binaries"), dict) + and probe["binaries"].get("llama-server") + ) _gpu_capable = False _has_nvidia_target = False - if on_remote and host: + if _native_llama_server: + # Native llama-server is the launcher path Cookbook now + # prefers. Do not mark this as a CPU-only Python wheel just + # because llama-cpp-python is absent from the selected venv. + _gpu_capable = True + elif on_remote and host: try: # Activate the configured venv FIRST so the probe # runs against the same python the launch script @@ -1609,7 +1656,8 @@ def setup_shell_routes() -> APIRouter: return {"ok": False, "error": f"Unsupported engine: {engine}"} host = str(body.get("remote_host") or "").strip() ssh_port = body.get("ssh_port") - cmd = _llama_cpp_rebuild_cmd() + update_source = bool(body.get("update_source")) + cmd = _llama_cpp_rebuild_cmd(update_source=update_source) try: argv = ( (_ssh_base_argv(host, ssh_port) + [cmd]) diff --git a/src/agent_loop.py b/src/agent_loop.py index e574a42b6..40e4232bb 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -751,6 +751,17 @@ def _extract_last_user_message(messages: List[Dict]) -> str: _LOW_SIGNAL_RE = re.compile(r"^[\W_]*$", re.UNICODE) +_CASUAL_OPENING_RE = re.compile( + r"^\s*(?:h+i+|hey+|hello+|yo+|sup+|what'?s up|wass?up|hiya|howdy|" + r"lol|lmao|haha+|hehe+|thanks?|thank you|ty|idk|dunno|meh|bruh|bro)\b(?P.*)$", + re.IGNORECASE, +) +_CASUAL_BLOCKLIST_RE = re.compile( + r"\b(?:cookbook|serve|serving|launch|start|vllm|sglang|llama\.?cpp|ollama|" + r"download|model|email|document|doc|note|calendar|task|search|web|research|" + r"file|folder|repo|git|settings?|endpoint|api|token|mcp)\b", + re.IGNORECASE, +) _EXPLICIT_CONTINUATION_RE = re.compile( r"^\s*(?:" r"yes|y|yeah|yep|ok|okay|sure|do it|go ahead|continue|carry on|" @@ -760,6 +771,17 @@ _EXPLICIT_CONTINUATION_RE = re.compile( r")\s*[.!?]*\s*$", re.IGNORECASE, ) +_RETRY_CONTINUATION_RE = re.compile( + r"\b(?:try again|retry|again|rerun|re-run|run it again|launch it again|" + r"start it again|failed|fails?|died|crashed|broke|insta|instantly)\b", + re.IGNORECASE, +) +_COOKBOOK_CONTEXT_RE = re.compile( + r"\b(?:cookbook|serve|serving|served|launch|start|preset|vllm|sglang|" + r"llama\.?cpp|ollama|download|cached models?|model servers?|running models?|" + r"gpu box|ajax|qwen|gemma|llama|mistral|minimax)\b", + re.IGNORECASE, +) def _is_explicit_continuation(text: str) -> bool: @@ -767,6 +789,37 @@ def _is_explicit_continuation(text: str) -> bool: return bool(_EXPLICIT_CONTINUATION_RE.match(str(text or "").strip())) +def _is_casual_low_signal(text: str) -> bool: + """True for short greetings/slang that should not inherit stale context.""" + s = str(text or "").strip() + m = _CASUAL_OPENING_RE.match(s) + if not m: + return False + tail = m.group("tail") or "" + if _CASUAL_BLOCKLIST_RE.search(tail): + return False + # Allow a short vocative/address after the opener without hardcoding the + # address term itself: "hey man", "yo dude", "sup ". Longer tails are + # more likely to be an actual request and should get normal context/tooling. + tail_words = re.findall(r"[A-Za-z0-9_'-]+", tail) + return len(tail_words) <= 2 + + +def _is_contextual_retry_continuation(messages: List[Dict], text: str) -> bool: + """Treat "try again / it failed" as a continuation only for active tool work. + + These follow-ups are common after Cookbook launches: the latest user turn + says only "try again it failed", while the actionable model/host/command + details live one or two turns back. Keep this intentionally narrow so + ordinary chat does not inherit stale Cookbook context. + """ + latest = str(text or "").strip() + if not latest or not _RETRY_CONTINUATION_RE.search(latest): + return False + recent = _recent_context_for_retrieval(messages, max_user=5, max_chars=1200) + return bool(_COOKBOOK_CONTEXT_RE.search(recent)) + + def _assistant_requested_followup(messages: List[Dict]) -> bool: """True when the previous assistant turn asked for missing task details. @@ -808,11 +861,12 @@ def _classify_agent_request(messages: List[Dict], last_user: str) -> Dict[str, o which domain rule packs get appended to the system prompt. """ text = str(last_user or "").strip() - continuation = _is_explicit_continuation(text) or _assistant_requested_followup(messages) + retry_continuation = _is_contextual_retry_continuation(messages, text) + continuation = _is_explicit_continuation(text) or _assistant_requested_followup(messages) or retry_continuation retrieval_query = _recent_context_for_retrieval(messages) if continuation else text q = retrieval_query.lower() - if not text or bool(_LOW_SIGNAL_RE.match(text)): + if not text or bool(_LOW_SIGNAL_RE.match(text)) or _is_casual_low_signal(text): return { "low_signal": True, "continuation": False, @@ -907,6 +961,7 @@ def _build_system_prompt( compact: bool = False, owner: Optional[str] = None, suppress_local_context: bool = False, + suppress_skills: bool = False, active_email: Optional[Dict[str, str]] = None, ) -> List[Dict]: """Build agent system prompt, inject MCP/document context, merge consecutive system msgs.""" @@ -924,7 +979,7 @@ def _build_system_prompt( _ov_sig = _hl.sha256(_json.dumps(get_builtin_overrides() or {}, sort_keys=True).encode()).hexdigest() except Exception: _ov_sig = "" - cache_key = (frozenset(disabled_tools or []), bool(mcp_mgr), needs_admin, _rt_key, compact, _ov_sig, owner, suppress_local_context) + cache_key = (frozenset(disabled_tools or []), bool(mcp_mgr), needs_admin, _rt_key, compact, _ov_sig, owner, suppress_local_context, suppress_skills) if _cached_base_prompt and _cached_base_prompt_key == cache_key and not active_document: agent_prompt = _cached_base_prompt # Skill index is user-editable (name + description), so it must never @@ -934,6 +989,7 @@ def _build_system_prompt( disabled_tools, mcp_mgr, needs_admin, relevant_tools, mcp_disabled_map=mcp_disabled_map, compact=compact, owner=owner, suppress_local_context=suppress_local_context, + suppress_skills=suppress_skills, ) else: agent_prompt, _skill_index_block = _build_base_prompt( @@ -945,6 +1001,7 @@ def _build_system_prompt( compact=compact, owner=owner, suppress_local_context=suppress_local_context, + suppress_skills=suppress_skills, ) if not active_document: _cached_base_prompt = agent_prompt @@ -1228,7 +1285,7 @@ def _build_system_prompt( # few. If the teacher wrote a procedure for "open my X chat" last # time the student failed, this is where the student finds it # before deciding which tool to call. - if not suppress_local_context: + if not suppress_local_context and not suppress_skills: try: last_user = _extract_last_user_message(messages) # Respect the user's skills-enabled toggle (mirrors memory_enabled). @@ -1395,6 +1452,7 @@ def _build_base_prompt( compact: bool = False, owner: Optional[str] = None, suppress_local_context: bool = False, + suppress_skills: bool = False, ): """Build the agent prompt with only relevant tools included. @@ -1447,7 +1505,7 @@ def _build_base_prompt( # The caller wraps it in untrusted_context_message and ships it as a # user-role message — same treatment as the matched-skills block. skill_index_block = "" - if not suppress_local_context: + if not suppress_local_context and not suppress_skills: try: from services.memory.skills import SkillsManager from src.constants import DATA_DIR @@ -1866,6 +1924,7 @@ async def stream_agent_loop( approved_plan: Optional[str] = None, tool_policy: Optional[ToolPolicy] = None, workspace: Optional[str] = None, + forced_tools: Optional[Set[str]] = None, _is_teacher_run: bool = False, ) -> AsyncGenerator[str, None]: """Streaming agent loop generator. @@ -1905,6 +1964,18 @@ async def stream_agent_loop( _needs_admin = _detect_admin_intent(messages) _last_user = _extract_last_user_message(messages) _intent = _classify_agent_request(messages, _last_user) + _low_signal_turn = bool(_intent.get("low_signal")) + _casual_low_signal_turn = _is_casual_low_signal(_last_user) + _direct_low_signal = ( + _low_signal_turn + and not bool(_intent.get("continuation")) + and not plan_mode + and not approved_plan + and (_casual_low_signal_turn or active_document is None) + and (_casual_low_signal_turn or not active_email) + and (_casual_low_signal_turn or not workspace) + and not forced_tools + ) # Tool retrieval uses the latest message by default. It may inherit recent # user turns only for explicit continuations ("yes", "do it", "1"). _retrieval_query = str(_intent.get("retrieval_query") or _last_user) @@ -1912,11 +1983,86 @@ async def stream_agent_loop( "[agent-intent] latest=%r continuation=%s low_signal=%s domains=%s retrieval_query=%r", _last_user[:120], bool(_intent.get("continuation")), - bool(_intent.get("low_signal")), + _low_signal_turn, sorted(_intent.get("domains") or []), _retrieval_query[:200], ) _mcp_disabled_map = _load_mcp_disabled_map() if mcp_mgr else {} + if _direct_low_signal: + logger.info("[agent] direct low-signal reply path for latest=%r", _last_user[:80]) + direct_messages = [{"role": "user", "content": _last_user}] + direct_response = "" + direct_start = time.time() + direct_actual_model = model + real_input_tokens = 0 + real_output_tokens = 0 + try: + async for chunk in stream_llm_with_fallback( + [(endpoint_url, model, headers)] + list(fallbacks or []), + direct_messages, + temperature=temperature, + max_tokens=min(max_tokens or 128, 128), + prompt_type=None, + tools=None, + timeout=int(get_setting("agent_stream_timeout_seconds", 300) or 300), + session_id=session_id, + ): + if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): + try: + data = json.loads(chunk[6:]) + except json.JSONDecodeError: + yield chunk + continue + if data.get("type") == "usage": + usage = data.get("data", {}) or {} + direct_actual_model = usage.get("model") or direct_actual_model + real_input_tokens += usage.get("input_tokens", 0) or 0 + real_output_tokens += usage.get("output_tokens", 0) or 0 + continue + if data.get("type") == "model_actual": + direct_actual_model = data.get("model") or direct_actual_model + data["requested_model"] = model + yield f"data: {json.dumps(data)}\n\n" + continue + if data.get("type") == "fallback": + direct_actual_model = data.get("answered_by") or direct_actual_model + yield chunk + continue + if "delta" in data: + if not data.get("thinking"): + direct_response += data.get("delta", "") + yield chunk + continue + yield chunk + elif chunk.startswith("event: "): + yield chunk + except Exception as _direct_err: + logger.warning("[agent] direct low-signal path failed: %s", _direct_err) + fallback = "Hey." + direct_response += fallback + yield f"data: {json.dumps({'delta': fallback})}\n\n" + + if not direct_response.strip(): + fallback = "Hey." + direct_response = fallback + yield f"data: {json.dumps({'delta': fallback})}\n\n" + + duration = time.time() - direct_start + metrics = { + "model": direct_actual_model, + "requested_model": model, + "input_tokens": real_input_tokens or estimate_tokens(direct_messages), + "output_tokens": real_output_tokens or max(len(direct_response) // 4, 1), + "total_time": round(duration, 2), + "response_time": round(duration, 2), + "agent_rounds": 0, + "tool_calls": 0, + "direct_low_signal": True, + } + yield f"data: {json.dumps({'type': 'metrics', 'data': metrics})}\n\n" + yield "data: [DONE]\n\n" + return + if plan_mode and mcp_mgr: # Allow read-only MCP tools to investigate, block write/unknown ones: # hide them from the schemas AND reject them at runtime by qualified name. @@ -1932,7 +2078,7 @@ async def stream_agent_loop( _t1 = time.time() if _relevant_tools: logger.info(f"[tool-rag] Using caller-provided relevant_tools ({len(_relevant_tools)} tools)") - if not guide_only and not _relevant_tools and bool(_intent.get("low_signal")): + if not guide_only and not _relevant_tools and _low_signal_turn: from src.tool_index import ALWAYS_AVAILABLE if workspace: # An active workspace IS the file-work signal: a vague "look at the @@ -2023,6 +2169,15 @@ async def stream_agent_loop( if _relevant_tools is not None and active_document is not None: _relevant_tools.update({"edit_document", "update_document", "suggest_document"}) + # Per-request UI toggles are stronger than retrieval. If the user turns on + # Search, the model must see the search tools even when the latest text is a + # typo or otherwise low-signal for tool RAG. + if not guide_only and forced_tools: + if _relevant_tools is None: + from src.tool_index import ALWAYS_AVAILABLE + _relevant_tools = set(ALWAYS_AVAILABLE) + _relevant_tools.update(t for t in forced_tools if t not in disabled_tools) + # The skill index injected by _build_system_prompt tells the model to # call `manage_skills action=view`, and Jaccard-matched skills are pasted # into the prompt as procedures to follow — but neither path goes through @@ -2030,7 +2185,7 @@ async def stream_agent_loop( # (grep, read_file, ...) that aren't in its schema list. Keep the schemas # in lockstep: manage_skills is callable whenever any skill is indexed, # and a matched skill's declared requires_toolsets ride along with it. - if not guide_only and _relevant_tools is not None: + if not guide_only and _relevant_tools is not None and not _low_signal_turn: try: from services.memory.skills import SkillsManager from src.constants import DATA_DIR @@ -2147,6 +2302,7 @@ async def stream_agent_loop( compact=_compact_agent_prompt, owner=owner, suppress_local_context=guide_only, + suppress_skills=_low_signal_turn, active_email=active_email, ) if plan_mode and not guide_only: @@ -2753,6 +2909,15 @@ async def stream_agent_loop( _intent_nudge_count += 1 _matched_phrase = _intent_match.group(0).strip() logger.info(f"[agent] intent-without-action nudge #{_intent_nudge_count} on round {round_num}: {_matched_phrase!r}") + _lower_phrase = _matched_phrase.lower() + _cookbook_log_hint = "" + if any(_word in _lower_phrase for _word in ("log", "logs", "output", "tail", "status")): + _cookbook_log_hint = ( + " If this is about a Cookbook/model serve, the concrete calls are: " + "`list_served_models` first, then `tail_serve_output` with the " + "session_id from the serve/list result. Never answer with " + "\"check logs\" when those tools are available." + ) messages.append({ "role": "system", "content": ( @@ -2761,6 +2926,7 @@ async def stream_agent_loop( "see you announced the action but didn't run it, which " "is the most frustrating thing you can do. " "DO IT NOW: emit the actual function call this turn. " + f"{_cookbook_log_hint}" "If you decided not to do it after all, say so plainly in " "one sentence instead of restating the plan." ), diff --git a/src/agent_tools/web_tools.py b/src/agent_tools/web_tools.py index 87a4b697f..f1410e18e 100644 --- a/src/agent_tools/web_tools.py +++ b/src/agent_tools/web_tools.py @@ -7,6 +7,7 @@ from src.constants import MAX_OUTPUT_CHARS class WebSearchTool: async def execute(self, content: str, ctx: dict) -> dict: from src.search import comprehensive_web_search + progress_cb = ctx.get("progress_cb") if isinstance(ctx, dict) else None raw = content.strip() query = raw time_filter = None @@ -37,18 +38,39 @@ class WebSearchTool: elif " news" in q_lc or q_lc.startswith("news ") or q_lc.endswith(" news"): time_filter = "week" loop = asyncio.get_running_loop() - text, sources = await asyncio.wait_for( - loop.run_in_executor( - None, - lambda: comprehensive_web_search( - query, - max_pages=max_pages, - time_filter=time_filter, - return_sources=True, + if progress_cb: + await progress_cb({ + "elapsed_s": 0, + "tail": f"Searching web for: {query[:160]}", + }) + try: + text, sources = await asyncio.wait_for( + loop.run_in_executor( + None, + lambda: comprehensive_web_search( + query, + max_pages=max_pages, + time_filter=time_filter, + return_sources=True, + ), ), - ), - timeout=30, - ) + timeout=30, + ) + except asyncio.TimeoutError: + return { + "error": f"web_search timed out after 30s: {query[:200]}", + "exit_code": 1, + } + except Exception as e: + return { + "error": f"web_search failed: {type(e).__name__}: {str(e) or 'no details'}", + "exit_code": 1, + } + if progress_cb: + await progress_cb({ + "elapsed_s": 30, + "tail": "Search completed; preparing sources.", + }) output = text[:MAX_OUTPUT_CHARS] if len(text) > MAX_OUTPUT_CHARS else text if sources: output += "\n\n" diff --git a/src/builtin_actions.py b/src/builtin_actions.py index a598cb652..bf4ddd950 100644 --- a/src/builtin_actions.py +++ b/src/builtin_actions.py @@ -76,8 +76,7 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]: import json import re from src.constants import DATA_DIR - from src.endpoint_resolver import resolve_endpoint - from src.llm_core import llm_call_async + from src.llm_core import llm_call_async_with_fallback from src.memory import MemoryManager manager = MemoryManager(DATA_DIR) @@ -116,10 +115,9 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]: if len(group_memories) < 2: return False - url, model, headers = resolve_endpoint("utility", owner=group_owner or None) - if not url or not model: - url, model, headers = resolve_endpoint("default", owner=group_owner or None) - if not url or not model: + from src.task_endpoint import resolve_task_candidates + candidates = resolve_task_candidates(owner=group_owner or None) + if not candidates: return False try: @@ -147,13 +145,11 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]: "\"drop\":[{\"id\":\"existing id\",\"reason\":\"short reason\"}]}\n\n" f"MEMORIES:\n{json.dumps(items, ensure_ascii=False)}" ) - raw = await llm_call_async( - url=url, - model=model, + raw = await llm_call_async_with_fallback( + candidates, messages=[{"role": "user", "content": prompt}], temperature=0.0, max_tokens=4096, - headers=headers, timeout=120, ) from src.text_helpers import strip_think @@ -604,8 +600,7 @@ async def action_classify_events(owner: str, **kwargs) -> Tuple[str, bool]: try: from datetime import timedelta from core.database import SessionLocal, CalendarEvent - from src.endpoint_resolver import resolve_endpoint - from src.llm_core import llm_call_async + from src.llm_core import llm_call_async_with_fallback import re as _re, json as _json db = SessionLocal() @@ -620,10 +615,9 @@ async def action_classify_events(owner: str, **kwargs) -> Tuple[str, bool]: if not events: return "No upcoming events to classify", True - llm_url, llm_model, llm_headers = resolve_endpoint("utility", owner=owner) - if not llm_url: - llm_url, llm_model, llm_headers = resolve_endpoint("default", owner=owner) - llm_available = bool(llm_url and llm_model) + from src.task_endpoint import resolve_task_candidates + llm_candidates = resolve_task_candidates(owner=owner) + llm_available = bool(llm_candidates) # Pull user memories so the LLM has personal context (relationships, # job, hobbies). Helps it know e.g. " is your spouse" so their @@ -699,11 +693,11 @@ async def action_classify_events(owner: str, **kwargs) -> Tuple[str, bool]: f"EVENTS: {_json.dumps(items)}" ) try: - raw = await llm_call_async( - url=llm_url, model=llm_model, + raw = await llm_call_async_with_fallback( + llm_candidates, messages=[{"role": "user", "content": prompt}], temperature=0.1, max_tokens=16384, - headers=llm_headers, timeout=180, + timeout=180, ) from src.text_helpers import strip_think as _st raw = _st(raw or "", prose=False, prompt_echo=False) @@ -810,8 +804,7 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo import asyncio as _aio from datetime import datetime as _dt, timedelta as _td from routes.email_helpers import _email_cache_owner_clause, _imap_connect, SCHEDULED_DB - from src.endpoint_resolver import resolve_endpoint - from src.llm_core import llm_call_async + from src.llm_core import llm_call_async_with_fallback # 1. Pull recent UIDs + From headers cheaply (header-only fetch). def _pull_headers(): @@ -891,11 +884,11 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo if not eligible: return "All sender sigs already cached (or no eligible senders)", True - url, model, headers = resolve_endpoint("utility", owner=owner) - if not url or not model: - url, model, headers = resolve_endpoint("default", owner=owner) - if not url or not model: + from src.task_endpoint import resolve_task_candidates + candidates = resolve_task_candidates(owner=owner) + if not candidates: return "No LLM endpoint available", False + model = candidates[0][1] analyzed = 0 no_sig = 0 @@ -949,11 +942,11 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo ) try: - raw = await llm_call_async( - url=url, model=model, + raw = await llm_call_async_with_fallback( + candidates, messages=[{"role": "user", "content": prompt}], temperature=0.0, max_tokens=600, - headers=headers, timeout=60, + timeout=60, ) from src.text_helpers import strip_think as _st sig = _st(raw or "", prose=False, prompt_echo=False).strip() @@ -1137,7 +1130,6 @@ async def action_test_skills(owner: str, **kwargs) -> Tuple[str, bool]: from services.memory.skills import SkillsManager from src.constants import DATA_DIR from routes.skills_routes import _run_skill_test_once, _skill_test_task - from src.endpoint_resolver import resolve_endpoint # #3 SCOPE GUARD: refuse to run on a None/empty owner — otherwise # `sm.load(owner=None)` returns every user's skills and we'd cross- @@ -1152,27 +1144,40 @@ async def action_test_skills(owner: str, **kwargs) -> Tuple[str, bool]: if not names: raise TaskNoop("no skills to test") - url, model, headers = resolve_endpoint("default", owner=owner) - if not url or not model: + from src.task_endpoint import resolve_task_candidates + candidates = resolve_task_candidates(owner=owner) + if not candidates: return "No Default/Utility model configured — set one in Settings.", False # #2 NO SILENT MODEL SWAP: if the configured model isn't served by the # endpoint, try a basename match — but fail loudly instead of grabbing # `avail[0]` which could be an embedding-only model and produce 36 # garbage transcripts → 36 'unknown' verdicts with no hint why. + url, model, headers = candidates[0] try: from src.llm_core import list_model_ids - avail = list_model_ids(url, headers=headers) - if avail and model not in avail: - import os as _os - base = _os.path.basename((model or "").rstrip("/")) - m = next((a for a in avail if _os.path.basename(a.rstrip("/")) == base), None) - if m: - model = m - else: - return (f"Default model '{model}' not served by endpoint {url}. " - f"Available: {', '.join(avail[:8])}{'…' if len(avail) > 8 else ''}. " - "Set a valid Default model in Settings."), False + import os as _os + + selected = None + mismatch_notes = [] + for cand_url, cand_model, cand_headers in candidates: + avail = list_model_ids(cand_url, headers=cand_headers) + if not avail or cand_model in avail: + selected = (cand_url, cand_model, cand_headers) + break + base = _os.path.basename((cand_model or "").rstrip("/")) + matched = next((a for a in avail if _os.path.basename(a.rstrip("/")) == base), None) + if matched: + selected = (cand_url, matched, cand_headers) + break + mismatch_notes.append( + f"{cand_model} not served by {cand_url}; available: " + f"{', '.join(avail[:8])}{'...' if len(avail) > 8 else ''}" + ) + if selected: + url, model, headers = selected + elif mismatch_notes: + return "No configured task fallback model is served. " + " | ".join(mismatch_notes[:3]), False except Exception as _e: logger.warning(f"test_skills model resolve check failed (continuing): {_e}") @@ -1483,7 +1488,6 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]: from pathlib import Path as _P from core.database import SessionLocal as _SL, EmailAccount as _EA from routes.email_helpers import _imap_connect, _decode_header - from src.endpoint_resolver import resolve_endpoint, resolve_utility_fallback_candidates from src.llm_core import llm_call_async_with_fallback # Per-owner state file so multi-user runs don't clobber each other's @@ -1505,12 +1509,10 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]: # ── 1. Resolve LLM candidates (utility primary + utility fallbacks; fall # through to default chat as a last resort). - url, model, headers = resolve_endpoint("utility", owner=owner) - if not url or not model: - url, model, headers = resolve_endpoint("default", owner=owner) - if not url or not model: + from src.task_endpoint import resolve_task_candidates + candidates = resolve_task_candidates(owner=owner) + if not candidates: return "No LLM endpoint available", False - candidates = [(url, model, headers)] + resolve_utility_fallback_candidates(owner=owner) # ── 2. Enumerate enabled accounts. Match this task's owner AND fall # back to the legacy "unowned account whose imap_user / from_address diff --git a/src/endpoint_resolver.py b/src/endpoint_resolver.py index 34d451d9c..ac5a6b7ad 100644 --- a/src/endpoint_resolver.py +++ b/src/endpoint_resolver.py @@ -396,6 +396,9 @@ def resolve_utility_fallback_candidates(owner: Optional[str] = None) -> list: settings = load_settings() utility_ep = (get_user_setting("utility_endpoint_id", owner or "", settings.get("utility_endpoint_id", "")) or "").strip() if not utility_ep: + utility_chain = get_user_setting("utility_model_fallbacks", owner or "", settings.get("utility_model_fallbacks") or []) or [] + if utility_chain: + return _resolve_fallback_candidates("utility_model_fallbacks", owner=owner) return _resolve_fallback_candidates("default_model_fallbacks", owner=owner) except Exception: pass diff --git a/src/llm_core.py b/src/llm_core.py index 1338ef91a..ba567ed81 100644 --- a/src/llm_core.py +++ b/src/llm_core.py @@ -2130,6 +2130,8 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl yield _stream_delta_event(reasoning, thinking=True) content = delta.get("content") or "" if content: + content = re.sub(r"]*)?>", r"", content, flags=re.IGNORECASE) + content = re.sub(r"", "", content, flags=re.IGNORECASE) stripped = content.lstrip() # gpt-oss harmony format (<|channel|>analysis/final): route via the harmony # stream router. Sticky once the first marker appears — distinct from the diff --git a/src/task_endpoint.py b/src/task_endpoint.py index 6e477a3ec..6f9a27c09 100644 --- a/src/task_endpoint.py +++ b/src/task_endpoint.py @@ -1,6 +1,11 @@ -"""Shared resolver for background-task AI endpoint (auto-naming, memory, sorting).""" +"""Shared resolver for background-task AI endpoints.""" -from src.endpoint_resolver import resolve_endpoint +from src.endpoint_resolver import ( + resolve_chat_fallback_candidates, + resolve_endpoint, + resolve_utility_fallback_candidates, +) +from src.llm_core import llm_call_async_with_fallback def resolve_task_endpoint(fallback_url=None, fallback_model=None, fallback_headers=None, owner=None): @@ -11,3 +16,60 @@ def resolve_task_endpoint(fallback_url=None, fallback_model=None, fallback_heade endpoint cannot be resolved. """ return resolve_endpoint("task", fallback_url, fallback_model, fallback_headers, owner=owner) + + +def resolve_task_candidates( + fallback_url=None, + fallback_model=None, + fallback_headers=None, + owner=None, +): + """Return ordered background-task LLM candidates. + + Order: + 1. configured Background Tasks endpoint/model, or caller fallback + 2. Utility endpoint/model + 3. Default endpoint/model + 4. Utility fallback chain + 5. Default fallback chain + """ + candidates = [] + + def _append(url, model, headers): + if not url or not model: + return + key = (url, model) + if any((u, m) == key for u, m, _ in candidates): + return + candidates.append((url, model, headers or {})) + + _append(*resolve_task_endpoint(fallback_url, fallback_model, fallback_headers, owner=owner)) + _append(*resolve_endpoint("utility", owner=owner)) + _append(*resolve_endpoint("default", owner=owner)) + for url, model, headers in resolve_utility_fallback_candidates(owner=owner): + _append(url, model, headers) + for url, model, headers in resolve_chat_fallback_candidates(owner=owner): + _append(url, model, headers) + + return candidates + + +async def task_llm_call_async( + messages, + *, + fallback_url=None, + fallback_model=None, + fallback_headers=None, + owner=None, + **kwargs, +): + """Call the shared background-task LLM candidate chain.""" + candidates = resolve_task_candidates( + fallback_url=fallback_url, + fallback_model=fallback_model, + fallback_headers=fallback_headers, + owner=owner, + ) + if not candidates: + raise RuntimeError("No LLM endpoint available for background task") + return await llm_call_async_with_fallback(candidates, messages=messages, **kwargs) diff --git a/src/task_scheduler.py b/src/task_scheduler.py index 6c8ab148a..b9ff51b6b 100644 --- a/src/task_scheduler.py +++ b/src/task_scheduler.py @@ -833,6 +833,14 @@ class TaskScheduler: owner=task.owner, body=run.result if output == "notification" else None, ) + elif run.status == "error": + self.add_notification( + task.name, + "error", + task_id, + owner=task.owner, + body=run.error or run.result, + ) # Log result to the assistant chat so all task activity is visible. # Skip skipped/error rows — user shouldn't see "skipped: …" noise @@ -1406,12 +1414,18 @@ class TaskScheduler: ) except Exception as e: logger.warning(f"Agent loop failed for task '{task.name}', falling back to simple call: {e}") - from src.llm_core import llm_call_async + from src.task_endpoint import task_llm_call_async messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": task.prompt}, ] - result = await llm_call_async(url=endpoint_url, model=model, messages=messages, timeout=120) + result = await task_llm_call_async( + messages, + fallback_url=endpoint_url, + fallback_model=model, + owner=task.owner, + timeout=120, + ) # Strip the model's chain-of-thought before saving/delivering. Task # output is LLM-only, so prose=True (which also removes untagged @@ -1636,13 +1650,17 @@ class TaskScheduler: # Honor per-task max_steps (defense against runaway agent loops). # Falls back to 20 if not set — the historical default. _task_max_rounds = task.max_steps if task.max_steps and task.max_steps > 0 else 20 - # Tasks are background workloads — they share the Utility model's - # fallback chain (Settings → Utility Model → Fallbacks). A downed - # primary endpoint won't silently yield `(no output)` — same recipe - # chat uses but with the utility list (`utility_model_fallbacks`). + # Tasks are background workloads: use the shared task fallback chain + # behind the primary endpoint so a downed primary won't silently yield + # `(no output)`. try: - from src.endpoint_resolver import resolve_utility_fallback_candidates - _task_fallbacks = resolve_utility_fallback_candidates(owner=task.owner or None) + from src.task_endpoint import resolve_task_candidates + _task_fallbacks = resolve_task_candidates( + fallback_url=endpoint_url, + fallback_model=model, + fallback_headers=headers, + owner=task.owner or None, + )[1:] except Exception: _task_fallbacks = [] async for event_str in stream_agent_loop( @@ -1679,21 +1697,22 @@ class TaskScheduler: # asking it to summarize what it did. Guarantees output. if not full_text.strip(): try: - from src.llm_core import llm_call_async_with_fallback - from src.endpoint_resolver import resolve_utility_fallback_candidates + from src.task_endpoint import task_llm_call_async grace_context = "You ran out of steps. " if tool_results: grace_context += "Here's what your tools returned:\n" + "\n".join(tool_results[-5:]) else: grace_context += "No tool results were captured." grace_context += "\n\nSummarize what you accomplished and what's still pending. Be concise." - _grace_candidates = [(endpoint_url, model, headers)] + resolve_utility_fallback_candidates(owner=task.owner or None) - full_text = await llm_call_async_with_fallback( - _grace_candidates, + full_text = await task_llm_call_async( messages=[ {"role": "system", "content": system_content}, {"role": "user", "content": grace_context}, ], + fallback_url=endpoint_url, + fallback_model=model, + fallback_headers=headers, + owner=task.owner or None, timeout=30, ) full_text = (full_text or "").strip() diff --git a/src/tool_implementations.py b/src/tool_implementations.py index ae7246ec6..f1ac33007 100644 --- a/src/tool_implementations.py +++ b/src/tool_implementations.py @@ -1119,8 +1119,8 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict: _ALIASES = { "shell": ["bash"], "terminal": ["bash"], - "search": ["web_search"], - "web": ["web_search"], + "search": ["web_search", "web_fetch"], + "web": ["web_search", "web_fetch"], "browser": ["builtin_browser"], "documents": ["create_document", "edit_document", "update_document", "suggest_document"], "doc": ["create_document", "edit_document", "update_document", "suggest_document"], @@ -1132,7 +1132,7 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict: "notes": ["manage_notes"], "calendar": ["manage_calendar"], "email": ["mcp__email__list_emails", "mcp__email__read_email", "mcp__email__send_email"], - "research": ["web_search"], # research is a per-request flag, not a tool — closest analog + "research": ["web_search", "web_fetch"], # research is a per-request flag, not a tool — closest analog } if action == "list_tools": @@ -2714,13 +2714,25 @@ async def do_serve_model(content: str, owner: Optional[str] = None) -> Dict: endpoint_added=endpoint_added, endpoint_id=endpoint_id or "", ) note = "" if registered else " (state-write failed — task may not show in UI)" + where = host or "local" + log_path = f"/tmp/odysseus-tmux/{sid}.log" return { - "output": f"Serving {repo_id} (session: {sid}){note}", + "output": ( + f"Serving {repo_id} on {where} (session: {sid}){note}\n" + f"Next required check: call list_served_models. If this task is not ready, " + f"call tail_serve_output with session_id={sid} and tail=400 before answering. " + f"Do not tell the user to check logs; you have the log tool." + ), "session_id": sid, "task_type": "serve", "phase": "running", "host": host, "endpoint_id": endpoint_id, + "log_path": log_path, + "next_tools": [ + {"name": "list_served_models", "arguments": {}}, + {"name": "tail_serve_output", "arguments": {"session_id": sid, "tail": 400}}, + ], "exit_code": 0, } # FastAPI HTTPException puts the message under `detail`, not `error`. @@ -3057,8 +3069,17 @@ async def do_tail_serve_output(content: str, owner: Optional[str] = None) -> Dic MAX_CHARS = 8000 if len(output_text) > MAX_CHARS: output_text = "…(earlier output truncated)…\n" + output_text[-MAX_CHARS:] + if not output_text: + output_text = ( + f"No log output captured yet for {session_id} on {host_label}. " + "This usually means the tmux wrapper has started but the model process " + "has not printed anything yet. Do not stop here: call list_served_models " + "again to check whether it is still loading, ready, or crashed; if it is " + "still not ready, call tail_serve_output again with a larger tail after " + "the next status check." + ) return { - "output": output_text or "(empty pane)", + "output": output_text, "session_id": session_id, "host": host_label, "tail_lines": tail, diff --git a/static/icons/ollama-mark-crop.png b/static/icons/ollama-mark-crop.png new file mode 100644 index 0000000000000000000000000000000000000000..2554b38a14bf90287c29b2fcdadd2705c87fa148 GIT binary patch literal 7498 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 UkZ(hP4*&oF07*qoM6N<$f>5WP-T(jq literal 0 HcmV?d00001 diff --git a/static/icons/ollama-mark.png b/static/icons/ollama-mark.png new file mode 100644 index 0000000000000000000000000000000000000000..8cd2cf1ed8043caf62e8b069330889c0cf0f5a3b GIT binary patch literal 7487 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 VN)t_MNWmR&QzouW( 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�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

>ZQIgPN3KjFt7ETPbOj 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 OL_h!l000000000dCz+%G literal 0 HcmV?d00001 diff --git a/static/icons/sglang-mark.png b/static/icons/sglang-mark.png new file mode 100644 index 0000000000000000000000000000000000000000..3e0fe3eda51977519637fff23717f39a2e9817da GIT binary patch literal 2171 zcmV->2!!{EP)FK?9JJJjpGLdE3$kov4?;kzA zcka38ci%bZo_iz;?s5E&K)Qj`ZNP0rS8@af$e;j%1b`$tjvi*a21JntU}-65m~3O6 z8XXo`2HjL8we|1~(YNDLSZ)d>9*_Q0hQ`C(Ol0^@vHm4r2rnWCX%R&MQ54W>P-C`@ z-pVq{{*A}`dUkiH{l1X25tzUu^O{n94QI(NuUxbo9OEYfMKjQM?J8W@^#-(`sFCus z>5aUO{C;`0yW?M;_L6s-uB`wcLv1x_ep(XfQIeybxRIMBoGiC@N?H zfYHl%0>rQNA0M?gtRU-xMN?(A?R0#+=4Z3I)@cU*X`pdbSfz~yrLsGlczmB8{ z*f7AtBATii!Ln98rX`7}XM3dZu4YN8_1_(l=@x*cdHOd5JyV#U#jdPt^VR?EVkmON zJ@DA1f_R<{>HaX8f8hZmU#EH**R4*fEfS&+J(Mjx^ZFc1&ZbHhvv)zD-VIoZP&4#e z>N29#TU+XqGaizuMrWFkZBefN?;>rfD4H`LOvR<3NMYzWb8^zPn(9eL9%j!gGko93 z_BB3zTI$DB6Jx%O{tBWgn?b;by4|6kbAjZ?Cd@)+3h>%d`f5W_`Ggspeg%#RlVcgf zked(Llcs`oOeI`7dZd`haz=TMwywh!a$g#xJC$ld^Q{8iJ7xo2Z#GbWdi9*Iz1EZL zXB(o$MXYEJdOwS z@>tC;Sj2uKKa2UXO5_vkP6R@c!B{^K0TV-I^N#Gq3TPU1nRd9|A+Knsd~HdtHsE=lmdi|27f^ai5~P#a>rJ1%!~; z&S^2>1FAy2Ep74^gFr2qTWVNWVDg<^QX_`_1YnF7U{)^8K9tElC5rS0nV@jVNO~y+ z@(0fy>T?}(4a#$pBhcbkj~6;pzEd#>VU;a{W&a2=U;0Gc%uP3Sf3vhfhd+`;rplA_0^Wq=NFa(;MYv>!UBA^AL|=*?E~57+3cOY$-fOquZOyj=7nU6lirshAELsHc z^U=E9_6&ARNgn&uhnGE{JYO#3=h5-;=ogF3mgZm#e9c_5rHtvZ$ zD&^JhkWOTM0FX@ZMH6F_^I4uY5pJK?sG(XlEu9KNsHx zhMhoDgme;FZ5P9&SkKdXM~Y`md2`pn0IV!$>9H1;<1kRsK=AjX8&ZVWfnd{HvjuNh zAwUQacSx1@G>hT4+xzc*4uq5jRV--7YJMOv-1CIMot5eB9q_XpgemYlGU?aMM)b-L zkGQVw?HYzsVz4OVP$5rq8^GGbP&lv^wuQkv-aADRx(inx76M_P2Wq#y0QY}y3otq! zt{>V5`LiAa^QcNFn6nT*`N^{>n`>AIaDoxCs%F8}{ci$MRp_d%0mtP32U!e4%Ljjj zQ41EQY_4H*p|XW50CO5}T{r`F=hWD*FYb$Hq3Dq(!RDNDrzjb64o!6siz-8Yt0#5&RDF%x3nqu2h<_SiK-5Dcnh$R%D%2{eV&21St%9$dBf;bi1Z z8Pq|BhWdYYgol(uVK{oD6EXm;tLibrary
@@ -1005,7 +1005,12 @@ `; // /#cookbook-dl-tab-fold-body (whole Download card body) @@ -2884,6 +3072,7 @@ const shared = { _getPort, _sshPrefix, _serverByVal, + _serverKey, _selectedServer, _getPlatform, _isWindows, diff --git a/static/js/cookbookRunning.js b/static/js/cookbookRunning.js index a390e11ad..7672edfd2 100644 --- a/static/js/cookbookRunning.js +++ b/static/js/cookbookRunning.js @@ -27,6 +27,9 @@ function _statusLabel(status, type) { // "cookbook-task-status" ('' = the neutral loading style). 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' }; + } if (task.type === 'serve' && task.status === 'running' && task.progress) { // Same green "running" pill — just with dynamic phase text, so it doesn't // read as a different status while the server is coming up. @@ -52,13 +55,13 @@ function _downloadOutputLooksActive(task) { function _canClearTask(task) { if (!task || task.status === 'running') return false; - if (task.type === 'serve' && (task.status === 'ready' || task._serveReady)) return false; + if (task.type === 'serve' && (task.status === 'ready' || (task._serveReady && !['stopped', 'error', 'crashed', 'failed', 'completed'].includes(task.status)))) 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 // ideally the self-heal flips status back to running.) if (_downloadOutputLooksActive(task)) return false; - return ['done', 'stopped', 'error', 'crashed', 'failed'].includes(task.status); + return ['done', 'completed', 'stopped', 'error', 'crashed', 'failed'].includes(task.status); } function _clearPillLabel(task) { @@ -66,6 +69,13 @@ function _clearPillLabel(task) { return 'clear'; } +function _venvRootFromPath(path) { + let p = (path || '').toString().trim().replace(/\/+$/, ''); + if (!p) return ''; + p = p.replace(/\/bin\/(?:activate|python(?:3(?:\.\d+)?)?|vllm|pip(?:3)?)$/i, ''); + return p; +} + // A pip dependency/driver install (payload._dep) reports success with the // runner's "=== Process exited with code 0 ===" sentinel and pip's // "Successfully installed" line — never the HuggingFace download markers @@ -263,6 +273,7 @@ let _copyText; let _persistEnvState; let _refreshDependencies; let _serverByVal; +let _serverKey; let _selectedServer; let modelLogo; let esc; @@ -688,8 +699,10 @@ export function _saveTasks(tasks) { export function _addTask(sessionId, name, type, payload) { let tasks = _loadTasks(); const remoteHost = (payload && payload.remote_host) || _envState.remoteHost || ''; - const sshPort = (payload && payload.ssh_port) || _getPort(remoteHost) || ''; - const platform = (payload && payload.platform) || _getPlatform(remoteHost) || ''; + const remoteServerKey = (payload && payload.remote_server_key) || ''; + const remoteServerName = (payload && payload.remote_server_name) || ''; + const sshPort = (payload && payload.ssh_port) || _getPort(remoteServerKey || remoteHost) || ''; + const platform = (payload && payload.platform) || _getPlatform(remoteServerKey || remoteHost) || ''; // Serving a model supersedes its finished download — clear the matching // finished download card (covers serving directly from the Serve tab, not just // via the download card's "Serve →" button). @@ -704,7 +717,7 @@ export function _addTask(sessionId, name, type, payload) { return !(key && t.type === 'download' && t.status === 'queued' && _downloadDedupeKey(t) === key); }); } - const task = _stripTaskSecrets({ id: sessionId, sessionId, name, type, status: 'running', output: '', ts: Date.now(), payload: payload || null, remoteHost, sshPort, platform }); + const task = _stripTaskSecrets({ id: sessionId, sessionId, name, type, status: 'running', output: '', ts: Date.now(), payload: payload || null, remoteHost, remoteServerKey, remoteServerName, sshPort, platform }); tasks.push(task); _saveTasks(tasks); // New action → collapse all other cards, leave only this one open. @@ -1520,14 +1533,18 @@ function _parseServeCmdToFields(cmd) { return fields; } -export async function _launchServeTask(shortName, repo, cmd, fields, hostOverride) { +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 // up that server's port/platform from the shared servers list. Only fall back // to _envState.remoteHost for legacy callers (diagnosis/pip-update). const _host = (hostOverride !== undefined) ? (hostOverride || '') : (_envState.remoteHost || ''); - const _hsrv = _serverByVal(_envState.remoteServerKey || _host) + const _targetKey = targetMeta?.serverKey || ''; + const _hsrv = (_targetKey && _targetKey !== 'local' ? _serverByVal(_targetKey) : null) + || (hostOverride === undefined ? _serverByVal(_envState.remoteServerKey || _host) : null) || _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 || ''); // Replace any serve already targeting this same host:port — you can't run two @@ -1572,7 +1589,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid } } else { if (_envState.env === 'venv' && _envState.envPath) { - const p = _envState.envPath; + const p = _venvRootFromPath(_envState.envPath); envPrefix = 'source ' + (p.endsWith('/bin/activate') ? p : p + '/bin/activate'); } else if (_envState.env === 'conda' && _envState.envPath) { envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _envState.envPath; @@ -1583,7 +1600,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid repo_id: repo, cmd: cmd, remote_host: _host || undefined, - ssh_port: _getPort(_host) || undefined, + ssh_port: _getPort(_serverMetaKey || _host) || undefined, env_prefix: envPrefix || undefined, hf_token: _envState.hfToken || undefined, gpus: _envState.gpus || undefined, @@ -1607,11 +1624,11 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid return; } - const _sp = _getPort(_host); + const _sp = _getPort(_serverMetaKey || _host); // _fields = the exact structured serve-form values used for this launch, // so the "Edit / relaunch" button can re-open the Serve panel pre-filled // with these precise settings (not just the last-used-for-repo state). - const payload = { repo_id: repo, remote_host: _host || undefined, ssh_port: _sp || undefined, _cmd: cmd, _fields: fields || undefined, _env: _usedEnv, _envPath: _usedEnvPath, _gpus: _usedGpus }; + const payload = { repo_id: repo, remote_host: _host || undefined, remote_server_key: _serverMetaKey || undefined, remote_server_name: _serverMetaName || undefined, ssh_port: _sp || undefined, _cmd: cmd, _fields: fields || undefined, _env: _usedEnv, _envPath: _usedEnvPath, _gpus: _usedGpus }; _addTask(data.session_id, shortName, 'serve', payload); uiModule.showToast(`Serving ${shortName}...`); // Auto-register may have enabled an existing (offline) endpoint for this @@ -1760,16 +1777,25 @@ export function _renderRunningTab() { } // Group tasks by server - const _serverName = (host) => { - if (!host) return 'Local'; - const srv = _serverByVal(_envState.remoteServerKey || host) - || _envState.servers.find(s => s.host === host); - return srv?.name || host; + const _taskServerKey = (task) => task?.remoteServerKey || task?.remoteHost || ''; + const _serverName = (keyOrTask) => { + if (keyOrTask && typeof keyOrTask === 'object') { + const task = keyOrTask; + if (task.remoteServerName) return task.remoteServerName; + const srv = task.remoteServerKey ? _serverByVal(task.remoteServerKey) : null; + if (srv?.name) return srv.name; + if (!task.remoteHost) return 'Local'; + return (_envState.servers.find(s => s.host === task.remoteHost)?.name) || task.remoteHost; + } + const key = keyOrTask || ''; + if (!key || key === 'local') return 'Local'; + const srv = _serverByVal(key); + return srv?.name || key; }; const serverGroups = {}; for (const t of tasks) { - const key = t.remoteHost || ''; - if (!serverGroups[key]) serverGroups[key] = { name: _serverName(key), serve: [], download: [] }; + const key = _taskServerKey(t); + if (!serverGroups[key]) serverGroups[key] = { name: _serverName(t), serve: [], download: [] }; serverGroups[key][t.type === 'serve' ? 'serve' : 'download'].push(t); } @@ -1816,12 +1842,12 @@ export function _renderRunningTab() { e.stopPropagation(); // don't toggle the section collapse (was an inline onclick, blocked by CSP) const host = btn.dataset.clearServer; const allTasks = _loadTasks(); - const toRemove = allTasks.filter(t => (t.remoteHost || '') === host && _canClearTask(t)); + const toRemove = allTasks.filter(t => _taskServerKey(t) === host && _canClearTask(t)); // Bail with a clear message instead of silently doing nothing when // every task on this server is still running (nothing finished to // clear yet) — the previous behavior looked like the button was dead. if (!toRemove.length) { - const stillRunning = allTasks.filter(t => (t.remoteHost || '') === host && t.status === 'running').length; + const stillRunning = allTasks.filter(t => _taskServerKey(t) === host && t.status === 'running').length; const _msg = stillRunning ? `No finished tasks on ${_serverName(host)} — ${stillRunning} still running. Stop them first to clear.` : `No finished tasks on ${_serverName(host)}.`; @@ -1830,7 +1856,7 @@ export function _renderRunningTab() { return; } if (!await window.styledConfirm(`Clear ${toRemove.length} finished task${toRemove.length === 1 ? '' : 's'} on ${_serverName(host)}?`, { confirmText: 'Clear' })) return; - const remaining = allTasks.filter(t => (t.remoteHost || '') !== host || !_canClearTask(t)); + const remaining = allTasks.filter(t => _taskServerKey(t) !== host || !_canClearTask(t)); _saveTasks(remaining); // Fade/slide each finished card out (same exit as the per-card clear) // instead of yanking them instantly. @@ -1864,7 +1890,7 @@ export function _renderRunningTab() { btn.addEventListener('click', async (e) => { e.stopPropagation(); // don't toggle the section collapse const host = btn.dataset.stopServer; - const running = _loadTasks().filter(t => (t.remoteHost || '') === host && t.status === 'running'); + const running = _loadTasks().filter(t => _taskServerKey(t) === host && t.status === 'running'); if (!running.length) { uiModule.showToast(`Nothing running on ${_serverName(host)}`); return; } if (!await window.styledConfirm(`Stop ${running.length} running task${running.length > 1 ? 's' : ''} on ${_serverName(host)}?`, { confirmText: 'Stop all' })) return; // Mark every task as user-stopped BEFORE firing the kills so that the @@ -2177,9 +2203,6 @@ export function _renderRunningTab() { if (task.status !== 'running' && task.status !== 'queued') { items.push({ group: 'run', label: 'Reconnect tmux', action: 'reconnect' }); } - if (task.status === 'running') { - items.push({ group: 'run', label: 'Stop', action: 'stop', danger: true }); - } items.push({ group: 'run', label: 'Restart', action: 'retry' }); // ── Edit section ──────────────────────────────────────────── // Merged "Edit & relaunch" — opens the structured serve panel @@ -2539,7 +2562,7 @@ export function _renderRunningTab() { }); // Route to the right server section body - const serverBodyId = `server-body-${(task.remoteHost || 'local').replace(/[^a-zA-Z0-9-]/g, '_')}`; + const serverBodyId = `server-body-${(_taskServerKey(task) || 'local').replace(/[^a-zA-Z0-9-]/g, '_')}`; const targetBody = document.getElementById(serverBodyId); if (targetBody) targetBody.appendChild(el); else group.appendChild(el); @@ -3393,7 +3416,8 @@ function _refreshServerDots() { let tasks; try { tasks = _loadTasks(); } catch { return; } const byKey = {}; - for (const t of tasks) { (byKey[t.remoteHost || ''] = byKey[t.remoteHost || ''] || []).push(t); } + const _taskServerKeyForDot = (task) => task?.remoteServerKey || task?.remoteHost || ''; + for (const t of tasks) { (byKey[_taskServerKeyForDot(t)] = byKey[_taskServerKeyForDot(t)] || []).push(t); } document.querySelectorAll('.cookbook-section-header').forEach(header => { const dot = header.querySelector('.cookbook-srv-status'); if (!dot) return; @@ -3798,6 +3822,7 @@ export function initRunning(shared) { _persistEnvState = shared._persistEnvState; _refreshDependencies = shared._refreshDependencies; _serverByVal = shared._serverByVal; + _serverKey = shared._serverKey; _selectedServer = shared._selectedServer; modelLogo = shared.modelLogo; esc = shared.esc; diff --git a/static/js/cookbookServe.js b/static/js/cookbookServe.js index aba3f7926..da48507f7 100644 --- a/static/js/cookbookServe.js +++ b/static/js/cookbookServe.js @@ -18,6 +18,7 @@ let _sshCmd; let _getPort; let _sshPrefix; let _serverByVal; +let _serverKey; let _getPlatform; let _isWindows; let _isMetal; @@ -41,9 +42,40 @@ let _nextAvailablePort; // Storage keys const SERVE_STATE_KEY = 'cookbook-serve-state'; +const SERVE_FAVORITES_KEY = 'cookbook-serve-favorite-models'; let _cachedAllModels = []; +function _loadServeFavorites() { + try { + const raw = JSON.parse(localStorage.getItem(SERVE_FAVORITES_KEY) || '[]'); + return new Set(Array.isArray(raw) ? raw.filter(Boolean).map(String) : []); + } catch { + return new Set(); + } +} + +function _saveServeFavorites(favorites) { + try { + localStorage.setItem(SERVE_FAVORITES_KEY, JSON.stringify(Array.from(favorites || []))); + } catch {} +} + +function _isServeFavorite(repo) { + return _loadServeFavorites().has(String(repo || '')); +} + +function _toggleServeFavorite(repo) { + const key = String(repo || ''); + if (!key) return false; + const favorites = _loadServeFavorites(); + const next = !favorites.has(key); + if (next) favorites.add(key); + else favorites.delete(key); + _saveServeFavorites(favorites); + return next; +} + function _repoLooksAwqLike(model, repo) { const q = String(model?.quant || '').toUpperCase(); const n = `${repo || ''} ${model?.repo_id || ''} ${model?.name || ''} ${model?.path || ''}`.toLowerCase(); @@ -53,7 +85,9 @@ function _repoLooksAwqLike(model, repo) { function _repoLooksGgufLike(model, repo) { const q = String(model?.quant || '').toUpperCase(); const n = `${repo || ''} ${model?.repo_id || ''} ${model?.name || ''} ${model?.path || ''}`.toLowerCase(); - return !!model?.is_gguf || /^Q[2-8]/.test(q) || /^IQ/.test(q) || q === 'GGUF' || n.includes('gguf'); + const hasGgufFile = Array.isArray(model?.gguf_files) + && model.gguf_files.some(f => f && typeof f.rel_path === 'string' && /\.gguf$/i.test(f.rel_path)); + return !!model?.is_gguf || hasGgufFile || /^Q[2-8]/.test(q) || /^IQ/.test(q) || q === 'GGUF' || n.includes('gguf'); } function _serveBackendWarning(model, repo, backend, fields = {}) { @@ -96,6 +130,352 @@ function _allGpuIds(count) { return Array.from({ length: Math.floor(n) }, (_, i) => String(i)).join(','); } +function _shellSplitForPreview(cmd) { + const s = String(cmd || ''); + const out = []; + let cur = ''; + let quote = ''; + let escNext = false; + for (const ch of s) { + if (escNext) { + cur += ch; + escNext = false; + continue; + } + if (ch === '\\') { + cur += ch; + escNext = true; + continue; + } + if (quote) { + cur += ch; + if (ch === quote) quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + cur += ch; + continue; + } + if (/\s/.test(ch)) { + if (cur) { + out.push(cur); + cur = ''; + } + continue; + } + cur += ch; + } + if (cur) out.push(cur); + return out; +} + +function _formatServeCmdPreview(cmd) { + const raw = String(cmd || ''); + 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); + if (match && match.index > 0) { + const prelude = raw.slice(0, match.index).replace(/\s+/g, ' ').trim(); + const rest = raw.slice(match.index).replace(/^\s*&&\s*/, ''); + return `${prelude}\n&&\n${_formatServeCmdPreview(rest)}`; + } + } + const tokens = _shellSplitForPreview(cmd); + if (tokens.length <= 4) return String(cmd || ''); + const lines = []; + let i = 0; + while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) { + lines.push(tokens[i]); + i++; + } + if (tokens[i]) { + const head = [tokens[i++]]; + if (tokens[i] && !tokens[i].startsWith('--') && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) head.push(tokens[i++]); + if (tokens[i] && !tokens[i].startsWith('--') && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) head.push(tokens[i++]); + lines.push(head.join(' ')); + } + while (i < tokens.length) { + const t = tokens[i++]; + if (t.startsWith('--')) { + const vals = []; + while (i < tokens.length && !tokens[i].startsWith('--') && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) { + vals.push(tokens[i++]); + } + lines.push([t, ...vals].join(' ')); + } else { + lines.push(t); + } + } + return lines.join('\n'); +} + +function _normalizeServeCmdForLaunch(cmd) { + return String(cmd || '') + .replace(/MODEL_FILE=\$\(\{\s+/g, 'MODEL_FILE=$({ ') + .replace(/\s+\}\s+\|\s+head\s+-1\)/g, ' } | head -1)') + .replace(/\s*;\s*/g, '; ') + .replace(/\s*\|\|\s*/g, ' __ODY_OR__ ') + .replace(/\s*\|\s*/g, ' | ') + .replace(/\s+__ODY_OR__\s+/g, ' || ') + .replace(/\s+/g, ' ') + .trim(); +} + +function _modelSizeGb(model, explicitGb = 0) { + const explicit = Number(explicitGb || 0); + if (Number.isFinite(explicit) && explicit > 0) return explicit; + const bytes = Number(model?.size_bytes || 0); + if (Number.isFinite(bytes) && bytes > 0) return bytes / (1024 ** 3); + const gb = Number( + model?.size_gb + || model?.required_gb + || model?.vram_needed + || model?.min_vram_gb + || model?.recommended_ram_gb + || model?.min_ram_gb + || 0 + ); + if (Number.isFinite(gb) && gb > 0) return gb; + if (_isMiniMaxM3Model(model)) return 240; + return 0; +} + +function _parseParamsB(text) { + const s = String(text || ''); + const m = s.match(/(\d+(?:\.\d+)?)\s*([bBmMtT])\b/); + if (!m) return 0; + const n = parseFloat(m[1]); + if (!Number.isFinite(n) || n <= 0) return 0; + const unit = m[2].toLowerCase(); + if (unit === 't') return n * 1000; + if (unit === 'b') return n; + if (unit === 'm') return n / 1000; + return 0; +} + +function _knownModelContextMax(model) { + if (_isMiniMaxM3Model(model)) return 1048576; + return 0; +} + +function _modelIdentityText(model) { + return [ + model?.repo_id, + model?.quant_repo, + model?.name, + model?.id, + model?.path, + model?.model_path, + model?.served_model_name, + model?.quant, + model?.format, + ].filter(Boolean).join(' ').toLowerCase(); +} + +function _isMiniMaxM3Model(model) { + const name = _modelIdentityText(model); + return ( + (/minimax/.test(name) && /\bm3\b/.test(name)) + || /minimax-m3/.test(name) + || /models--cyankiwi--minimax-m3-awq-int4/.test(name) + || /cyankiwi\/minimax-m3-awq-int4/.test(name) + ); +} + +function _isMiniMaxM2Model(model) { + const name = _modelIdentityText(model); + return /minimax/.test(name) && /\bm2(?:\.\d+)?\b/.test(name); +} + +function _modelContextMaxForServe(model, explicitMax) { + const explicit = Number(explicitMax || 0); + if (Number.isFinite(explicit) && explicit > 0) return explicit; + const known = _knownModelContextMax(model); + if (known > 0) return known; + for (const key of ['context_length', 'max_position_embeddings', 'n_ctx_train', 'model_max_length', 'max_seq_len']) { + const value = Number(model?.[key] || 0); + if (Number.isFinite(value) && value > 0) return value; + } + const catalogCtx = Number(model?.context || 0); + if (Number.isFinite(catalogCtx) && catalogCtx > 0) return catalogCtx; + return 131072; +} + +function _estimateVllmContextFit(model, fields, modelCtxMax, modelWeightsGb = 0, fitSystem = null) { + const sys = fitSystem || _hwfitCache?.system || {}; + const isMiniMaxM3 = _isMiniMaxM3Model(model); + const gpuIds = String(fields.gpus || '').split(',').map(s => parseInt(s.trim(), 10)).filter(Number.isFinite); + const tp = Math.max(1, parseInt(fields.tp, 10) || gpuIds.length || 1); + const selectedCount = Math.max(1, gpuIds.length || tp); + const groups = Array.isArray(sys.gpu_groups) ? sys.gpu_groups : []; + const activeGroup = sys.active_group || groups[0] || null; + const perGpuGb = Number(activeGroup?.vram_each) + || (Number(sys.gpu_vram_gb) / Math.max(1, Number(sys.gpu_count) || selectedCount)) + || 0; + if (!perGpuGb) { + return { needsHardwareScan: true, reason: 'scan hardware first to estimate context from VRAM' }; + } + + const gpuUtil = Math.min(0.99, Math.max(0.1, parseFloat(fields.gpu_mem) || 0.90)); + const budgetGb = perGpuGb * selectedCount * gpuUtil; + const modelGb = _modelSizeGb(model, modelWeightsGb); + if (!modelGb) return { needsModelSize: true, reason: 'model weight size unknown; scan model files or enter context manually' }; + const modelMax = Math.max(1024, _modelContextMaxForServe(model, modelCtxMax)); + + if (isMiniMaxM3) { + const perGpuBudgetGb = perGpuGb * gpuUtil; + const modelShardGb = modelGb / Math.max(1, tp); + const fixedOverheadGb = Math.max(1.5, perGpuBudgetGb * 0.035); + const freeForKv = perGpuBudgetGb - modelShardGb - fixedOverheadGb; + const kvGbPerToken = (29.25 / 1048576) * (String(fields.vllm_kv_cache_dtype || '').toLowerCase() === 'fp8' ? 1 : 1.8); + if (freeForKv <= 0) { + return { + ctx: 1024, + budgetGb, + modelGb, + kvGbPerToken, + reason: `model shard ${modelShardGb.toFixed(1)}G exceeds per-GPU usable ${perGpuBudgetGb.toFixed(1)}G before KV`, + }; + } + const raw = Math.floor((freeForKv / kvGbPerToken) * 0.99); + const rounded = Math.max(1024, Math.floor(raw / 128) * 128); + const ctx = Math.min(modelMax, rounded); + return { + ctx, + budgetGb, + modelGb, + kvGbPerToken, + reason: `~${ctx.toLocaleString()} tokens fits per-GPU KV (${freeForKv.toFixed(1)}G free)`, + }; + } + + const name = `${model?.repo_id || ''} ${model?.name || ''} ${model?.quant || ''}`; + const lower = name.toLowerCase(); + const isMoE = /\bmoe\b|a\d+b|minimax|deepseek|mixtral|kimi-k2|glm-4\.5/.test(lower); + const totalParams = _parseParamsB(name) || Math.max(1, modelGb / 0.58); + const activeFromName = (() => { + const m = lower.match(/\ba(\d+(?:\.\d+)?)b\b/); + return m ? parseFloat(m[1]) : 0; + })(); + const activeParams = activeFromName || (isMoE ? Math.min(totalParams, 32) : totalParams); + const effectiveActiveParams = (/minimax/.test(lower) && /\bm3\b/.test(lower)) ? 23 : activeParams; + const kvDtype = String(fields.vllm_kv_cache_dtype || '').toLowerCase(); + const kvFactor = kvDtype === 'fp8' ? 0.55 : 1; + const kvGbPerTokenTotal = Math.max(0.00002, 0.000008 * effectiveActiveParams * kvFactor); + const kvGbPerToken = kvGbPerTokenTotal / Math.max(1, tp); + const perGpuBudgetGb = perGpuGb * gpuUtil; + const modelShardGb = modelGb / Math.max(1, tp); + const fixedOverheadGb = Math.max(1.5, perGpuBudgetGb * 0.035); + const freeForKv = perGpuBudgetGb - modelShardGb - fixedOverheadGb; + if (freeForKv <= 0) { + return { + ctx: 1024, + budgetGb, + modelGb, + kvGbPerToken, + reason: `model shard ${modelShardGb.toFixed(1)}G exceeds per-GPU usable ${perGpuBudgetGb.toFixed(1)}G before KV`, + }; + } + const raw = Math.floor(freeForKv / kvGbPerToken); + const rounded = Math.max(1024, Math.floor(raw / 1024) * 1024); + const ctx = Math.min(modelMax, rounded); + return { + ctx, + budgetGb, + modelGb, + kvGbPerToken, + reason: `~${ctx.toLocaleString()} tokens fits per-GPU KV (${freeForKv.toFixed(1)}G free)`, + }; +} + +function _estimateLlamaContextFit(model, fields, modelCtxMax, modelWeightsGb = 0, fitSystem = null, profileData = null) { + const profiles = Array.isArray(profileData?.profiles) ? profileData.profiles : []; + const preferred = profiles.find(p => String(p?.key || '').toLowerCase() === 'balanced') + || profiles.find(p => Number(p?.ctx) > 0) + || null; + const modelMax = Math.max(1024, _modelContextMaxForServe(model, modelCtxMax)); + if (preferred && Number(preferred.ctx) > 0) { + const ctx = Math.min(modelMax, Number(preferred.ctx)); + return { + ctx, + reason: `profile ${preferred.label || preferred.key || 'fit'} fits scanned hardware`, + }; + } + + const sys = fitSystem || _hwfitCache?.system || {}; + const modelGb = _modelSizeGb(model, modelWeightsGb); + const backend = String(fields.backend || '').toLowerCase(); + const llamaMode = String(fields.llama_mode || '').toLowerCase(); + const isCpuMode = backend === 'llamacpp' && llamaMode === 'cpu'; + const isUnifiedMode = backend === 'llamacpp' && (llamaMode === 'unified' || fields.unified_mem); + if (!modelGb) { + return { + ctx: Math.min(modelMax, 32768), + needsModelSize: true, + reason: 'model weight size unknown; using model limit fallback', + }; + } + + if (isCpuMode) { + return { + ctx: Math.min(modelMax, 131072), + modelGb, + reason: 'CPU mode uses system RAM; capped to trained limit', + }; + } + + const gpuIds = String(fields.gpus || '').split(',').map(s => parseInt(s.trim(), 10)).filter(Number.isFinite); + const selectedCount = Math.max(1, gpuIds.length || parseInt(fields.tp, 10) || 1); + const groups = Array.isArray(sys.gpu_groups) ? sys.gpu_groups : []; + const activeGroup = sys.active_group || groups[0] || null; + const totalVramGb = Number(activeGroup?.vram_each) + ? Number(activeGroup.vram_each) * selectedCount + : (Number(sys.gpu_vram_gb) || 0); + if (!totalVramGb) { + return { + ctx: Math.min(modelMax, 32768), + modelGb, + needsHardwareScan: true, + reason: 'scan hardware first; using model limit fallback', + }; + } + + const totalRamGb = Number(sys.total_ram_gb) || 0; + const availableRamGb = Number(sys.available_ram_gb) || 0; + const unifiedPoolGb = isUnifiedMode + ? Math.max( + totalVramGb, + availableRamGb, + totalRamGb > 0 ? totalRamGb * 0.85 : 0 + ) + : totalVramGb; + const usableGb = isUnifiedMode + ? Math.max(1, unifiedPoolGb - Math.max(2.0, unifiedPoolGb * 0.08)) + : Math.max(1, totalVramGb - Math.max(1.0, selectedCount * 0.6)); + const freeForKv = usableGb - modelGb; + const kv = String(fields.cache_type || '').toLowerCase(); + const kvFactor = kv === 'q4_0' ? 0.55 : (kv === 'q8_0' ? 1 : (kv === 'f16' ? 1.9 : 1)); + const kvGbPerToken = Math.max(0.00008, (modelGb / 7.5) * 0.0007 * kvFactor); + if (freeForKv <= 0) { + return { + ctx: Math.min(modelMax, 8192), + modelGb, + kvGbPerToken, + reason: `model ${modelGb.toFixed(1)}G exceeds usable ${isUnifiedMode ? 'unified memory' : 'VRAM'} ${usableGb.toFixed(1)}G before KV`, + }; + } + const raw = Math.floor(freeForKv / kvGbPerToken); + const rounded = Math.max(1024, Math.floor(raw / 1024) * 1024); + const ctx = Math.min(modelMax, rounded); + return { + ctx, + modelGb, + kvGbPerToken, + reason: `~${ctx.toLocaleString()} tokens fits llama.cpp KV (${freeForKv.toFixed(1)}G free ${isUnifiedMode ? 'unified' : 'VRAM'})`, + }; +} + function _selectedServeTarget(panel) { const select = document.getElementById('hwfit-server-select') || document.getElementById('hwfit-dl-server'); const servers = Array.isArray(_envState.servers) ? _envState.servers : []; @@ -117,6 +497,8 @@ function _selectedServeTarget(panel) { : (server?.name || 'local server'); return { host, + serverKey: server ? (_serverKey?.(server) || '') : (select?.value || ''), + serverName: server?.name || '', port: host ? (_getPort(host) || server?.port || '') : '', venv, platform: server?.platform || _envState.platform || '', @@ -152,6 +534,9 @@ function _runtimeNoteText(backend, pkg, target) { const label = labels[backend] || backend; if (!pkg) return `${label} readiness unavailable for ${target.label}.`; const note = pkg.status_note || pkg.update_note || ''; + if (pkg.installed === null || pkg.probe_error) { + return note ? `${label} readiness unavailable for ${target.label}: ${note}` : `${label} readiness unavailable for ${target.label}.`; + } if (pkg.installed) { return note ? `${label} ready on ${target.label}: ${note}` : `${label} ready on ${target.label}.`; } @@ -226,6 +611,13 @@ function _runnableGgufFiles(model) { return primary.length ? primary : files; } +function _selectedGgufSizeGb(model, relPath) { + const file = _runnableGgufFiles(model).find(f => f.rel_path === relPath); + const bytes = Number(file?.size_bytes || 0); + if (!Number.isFinite(bytes) || bytes <= 0) return 0; + return bytes / (1024 ** 3); +} + function _ggufFileLabel(file) { const base = (file.name || file.rel_path || '').split('/').pop(); const size = _formatGgufSize(file.size_bytes); @@ -281,6 +673,12 @@ function _rerenderCachedModels() { else if (sortVal === 'size-desc') allModels.sort((a, b) => _parseSize(b.size) - _parseSize(a.size)); else if (sortVal === 'size-asc') allModels.sort((a, b) => _parseSize(a.size) - _parseSize(b.size)); else if (sortVal === 'recent') allModels.sort((a, b) => (b.mtime || 0) - (a.mtime || 0)); + const favorites = _loadServeFavorites(); + allModels.sort((a, b) => { + const af = favorites.has(String(a.repo_id || '')) ? 1 : 0; + const bf = favorites.has(String(b.repo_id || '')) ? 1 : 0; + return bf - af; + }); let html = ''; let visibleCount = 0; @@ -303,8 +701,9 @@ function _rerenderCachedModels() { // living on the same line as the model name. const _isDownloading = m.status === 'downloading'; const _isDlActive = _isDownloading ? _isActivelyDownloading(m.repo_id) : false; + const _isFavorite = favorites.has(String(m.repo_id || '')); const isSelectMode = document.getElementById('hwfit-cache-select')?.classList.contains('active'); - html += `
`; + html += `
`; html += ``; html += `
`; const _mc = modelColor(m.repo_id) || ''; @@ -314,7 +713,8 @@ function _rerenderCachedModels() { const _downloadingPill = _isDownloading ? ` ${_isDlActive ? 'downloading' : 'stalled'}` : ''; - html += `
${modelLogo(m.repo_id)}${esc(shortName)}${hfLink ? ` HF ↗` : ''}${_runningPill}${_downloadingPill}
`; + const _favoritePill = _isFavorite ? ' pinned' : ''; + html += `
${modelLogo(m.repo_id)}${esc(shortName)}${_favoritePill}${hfLink ? ` HF ↗` : ''}${_runningPill}${_downloadingPill}
`; html += `
${metaParts.join(' \u00b7 ')}
`; html += `
`; const _bk = _detectBackend(m).backend; @@ -397,7 +797,12 @@ function _rerenderCachedModels() { const _deleteIco = ''; const _selectIco = ''; const _schedIco = ''; + const _favNow = _isServeFavorite(repo); + const _favIco = _favNow + ? '' + : ''; const items = []; + items.push({ label: _favNow ? 'Unfavorite' : 'Favorite', icon: _favIco, action: 'favorite' }); if (m && m.status === 'ready') items.push({ label: 'Serve', icon: _serveIco, action: 'serve' }); if (m && m.status === 'downloading') items.push({ label: 'Retry', icon: _retryIco, action: 'retry' }); if (m && m.status === 'ready') items.push({ label: 'Schedule…', icon: _schedIco, action: 'schedule' }); @@ -410,6 +815,11 @@ function _rerenderCachedModels() { div.addEventListener('click', () => { closeDropdown(); if (opt.action === 'serve') item.click(); + else if (opt.action === 'favorite') { + const favored = _toggleServeFavorite(repo); + uiModule.showToast(favored ? 'Favorited — pinned to top' : 'Unfavorited'); + _rerenderCachedModels(); + } else if (opt.action === 'delete') _deleteCachedModel(repo, item, false, m); else if (opt.action === 'retry') _retryCachedModel(repo, m); else if (opt.action === 'schedule') { @@ -532,16 +942,22 @@ function _rerenderCachedModels() { const ss = (_byRepo[repo] && typeof _byRepo[repo] === 'object') ? _byRepo[repo] : (_lastUsed || (_isLegacyFlat ? _allSs : {})); + const _modelSs = (_byRepo[repo] && typeof _byRepo[repo] === 'object') ? _byRepo[repo] : null; + const _repoForcedBackend = !!(_modelSs && _modelSs._forceBackend); + const _isMiniMaxM3 = _isMiniMaxM3Model({ ...m, repo_id: repo }); + const _isMiniMaxM2 = _isMiniMaxM2Model({ ...m, repo_id: repo }); + const _isMiniMaxMSeries = _isMiniMaxM3 || _isMiniMaxM2; + const svm = (k, def) => (_modelSs && _hasOwn(_modelSs, k)) ? _modelSs[k] : def; const detectedBackend = _detectBackend(m).backend; const _allowedBackends = new Set(_isWindows() ? ['llamacpp', 'diffusers'] : (_isMetal() ? ['llamacpp', 'ollama'] : ['vllm', 'sglang', 'llamacpp', 'ollama', 'diffusers'])); - const defaultBackend = (ss._forceBackend && ss.backend && _allowedBackends.has(ss.backend)) + const defaultBackend = (_repoForcedBackend && ss.backend && _allowedBackends.has(ss.backend)) ? ss.backend : detectedBackend; - const savedMatchesBackend = !!ss._forceBackend || (ss.backend || 'vllm') === detectedBackend; + const savedMatchesBackend = _repoForcedBackend || (ss.backend || 'vllm') === detectedBackend; const sv = (k, def) => (ss[k] !== undefined && savedMatchesBackend) ? ss[k] : def; - const defaultTp = defaultBackend === 'llamacpp' ? '1' : sv('tp', '1'); + const defaultTp = defaultBackend === 'llamacpp' ? '1' : sv('tp', _isMiniMaxMSeries ? '8' : '1'); const detectedGpuIds = _allGpuIds(_getGpuToggleTotal?.()); const defaultGpus = defaultBackend === 'llamacpp' ? '0' @@ -555,7 +971,7 @@ function _rerenderCachedModels() { // OOMs. _detectModelOptimizations seeds opts.kvCacheDtype for // those families; honour it unless the user has a saved override. const _kvOptsCheck = _detectModelOptimizations(repo); - const _kvAutoDefault = (_kvOptsCheck && _kvOptsCheck.kvCacheDtype) || 'auto'; + const _kvAutoDefault = (_kvOptsCheck && _kvOptsCheck.kvCacheDtype) || (_isMiniMaxMSeries ? 'fp8' : 'auto'); const _kvSelected = sv('vllm_kv_cache_dtype', _kvAutoDefault); const vllmKvCacheOpts = ['auto','fp8'].map(d => ``).join(''); const _l = (name, tip) => `${name}?`; @@ -567,6 +983,11 @@ function _rerenderCachedModels() { const _ggufOptions = _ggufChoices.map(f => `` ).join(''); + const _minimaxM3Snapshot = '/home/pewds/.cache/huggingface/hub/models--cyankiwi--MiniMax-M3-AWQ-INT4/snapshots/4082acbbec1236d21828d55b6bb0fe02ade4ab5b'; + const _defaultServeModel = _isMiniMaxM3 ? _minimaxM3Snapshot : (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo); + const _savedModelPath = String(svm('model_path', _defaultServeModel) || '').trim(); + const _modelPathValue = _isMiniMaxM3 && (!_savedModelPath || _savedModelPath === repo) ? _minimaxM3Snapshot : _savedModelPath; + const _defaultServedModelName = _isMiniMaxM3 ? repo : ''; // Build save slots const _allPresets = _loadPresets(); const _repoShort = repo.split('/').pop(); @@ -583,15 +1004,10 @@ function _rerenderCachedModels() { const _arrowTitle = _modelPresets.length > 0 ? `${_modelPresets.length} saved launch config${_modelPresets.length === 1 ? '' : 's'} for ${_repoShort} — click ▾ to load or delete` : `No saved launch configs for ${_repoShort} yet — click Save to add one`; - // Wrap the Save split in a
+