diff --git a/routes/chat_routes.py b/routes/chat_routes.py index 39c17ec6c..2ac575d09 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -6,7 +6,7 @@ import os import time import logging from datetime import datetime -from typing import Dict, Any, AsyncGenerator, List +from typing import Dict, Any, AsyncGenerator, List, Optional from fastapi import APIRouter, Request, HTTPException, Form, Query from fastapi.responses import StreamingResponse @@ -492,6 +492,66 @@ def setup_chat_routes( active_doc_id = form_data.get("active_doc_id", "").strip() logger.info(f"[doc-inject] chat_mode={chat_mode}, active_doc_id={active_doc_id!r}") + # Active email reader — when the user has an email open in the UI, the + # frontend passes its uid/folder/account so "reply", "summarize this", + # etc. resolve to the real email instead of the agent inventing a + # fake markdown draft. + active_email_uid = form_data.get("active_email_uid", "").strip() + active_email_folder = form_data.get("active_email_folder", "INBOX").strip() or "INBOX" + active_email_account = form_data.get("active_email_account", "").strip() + active_email_ctx: Optional[Dict[str, str]] = None + # Always reset between requests so a stale active-email pointer from + # a previous turn (different reader closed, different account, etc.) + # can't leak in when the user has no email open this turn. + try: + from src.tool_implementations import clear_active_email + clear_active_email() + except Exception: + pass + if active_email_uid: + active_email_ctx = { + "uid": active_email_uid, + "folder": active_email_folder, + "account": active_email_account, + } + # Try to enrich with subject + from so the agent's system prompt + # block can quote them. Best-effort: a stale cache is fine, a + # missing email just means we pass uid/folder/account only. + try: + from routes.email_routes import _read_cache_get, _read_cache_key + _ck = _read_cache_key(active_email_account or None, active_email_folder, active_email_uid, owner=get_current_user(request)) + _cached_email = _read_cache_get(_ck) + if _cached_email and isinstance(_cached_email, dict): + active_email_ctx["subject"] = str(_cached_email.get("subject") or "") + active_email_ctx["from"] = str( + _cached_email.get("from_address") + or _cached_email.get("from") + or _cached_email.get("from_name") + or "" + ) + _body_preview = (_cached_email.get("body") or "")[:2000] + if _body_preview: + active_email_ctx["body_preview"] = _body_preview + except Exception as _e: + logger.debug(f"[email-inject] cache enrich skipped: {_e}") + # Stash so email tools can resolve "this email" without UID guessing. + try: + from src.tool_implementations import set_active_email + set_active_email( + uid=active_email_uid, + folder=active_email_folder, + account=active_email_account or None, + subject=active_email_ctx.get("subject"), + sender=active_email_ctx.get("from"), + ) + except Exception as _e: + logger.debug(f"[email-inject] set_active_email failed: {_e}") + logger.info( + "[email-inject] active_email uid=%s folder=%s account=%s subject=%r", + active_email_uid, active_email_folder, active_email_account or "(default)", + active_email_ctx.get("subject", ""), + ) + try: # Attachment-only sends: skip the message-required check when the # user has attached one or more files (the attachment IS the action). @@ -607,15 +667,27 @@ def setup_chat_routes( active_doc_id, ) active_doc = None - elif doc_session and doc_session != session: - logger.warning( - "[doc-inject] ignoring stale active_doc_id %s from session %s while in session %s", - active_doc_id, - doc_session, - session, - ) - active_doc = None else: + # NOTE: previously dropped the doc when doc.session_id + # != current chat session — but that broke the common + # case of "open an email draft from one chat, ask a + # different chat to write into it". The frontend only + # sends active_doc_id for docs currently visible in + # the UI, and we already owner-checked above, so trust + # the explicit signal. We just log the mismatch and + # re-bind the doc to the current session so future + # turns find it via the session-fallback path too. + if doc_session and doc_session != session: + logger.info( + "[doc-inject] cross-session active_doc_id %s (was session %s, now %s) — accepting and rebinding", + active_doc_id, doc_session, session, + ) + try: + active_doc.session_id = session + _doc_db.commit() + except Exception as _e: + _doc_db.rollback() + logger.warning(f"[doc-inject] session rebind failed: {_e}") logger.info(f"[doc-inject] found by ID: title={active_doc.title!r}, lang={active_doc.language!r}, is_active={active_doc.is_active}, content_len={len(active_doc.current_content or '')}") else: logger.warning(f"[doc-inject] NOT FOUND by ID {active_doc_id}") @@ -671,6 +743,21 @@ def setup_chat_routes( "manage_skills", # skill presets tied to user }) + # Active email reader open → strip the tools that let the agent + # "drift" to a new compose: create_document (writes a fake email- + # shaped .md file) and send_email (sends fresh to a recipient the + # agent invented). With those gone, the only paths left for "write + # email saying X" are ui_control open_email_reply (draft) and + # reply_to_email (immediate send) — both of which use the open + # email's UID. Code-level enforcement instead of relying on a + # prompt rule the model can ignore. + if active_email_ctx and active_email_ctx.get("uid"): + disabled_tools.update({ + "create_document", + "send_email", + "mcp__email__send_email", + }) + # Enforce per-user privileges _privs = {} _user = ctx.user @@ -1130,6 +1217,7 @@ def setup_chat_routes( max_rounds=_max_rounds, context_length=ctx.context_length, active_document=active_doc, + active_email=active_email_ctx, session_id=session, disabled_tools=disabled_tools if disabled_tools else None, tool_policy=tool_policy, diff --git a/routes/email_routes.py b/routes/email_routes.py index 0a4c2c5a1..1c5e1e6a4 100644 --- a/routes/email_routes.py +++ b/routes/email_routes.py @@ -1068,7 +1068,12 @@ def setup_email_routes(): account_id: str | None = Query(None), owner: str = Depends(require_owner), ): - """Search emails server-side via IMAP SEARCH. Matches subject, from, or body text.""" + """Search emails server-side via IMAP SEARCH. Matches subject, from, or body text. + + When the caller asks for INBOX and the account has an "All Mail" + folder (Gmail does), we transparently swap to All Mail so the + search surfaces archived / labelled emails too. Plain IMAP + accounts fall back to whatever folder the caller specified.""" if not q or len(q) < 2: return {"emails": [], "total": 0, "query": q} # CRLF in q would terminate the IMAP command early — reject defensively. @@ -1076,7 +1081,27 @@ def setup_email_routes(): raise HTTPException(400, "Invalid query") try: with _imap(account_id, owner=owner) as conn: - conn.select(_q(folder), readonly=True) + # If the user asked for INBOX, try to upgrade to All Mail — + # one folder == every email on Gmail-class servers. + effective_folder = folder + if (folder or "").upper() == "INBOX": + try: + status, folder_lines = conn.list() + if status == "OK" and folder_lines: + for raw in folder_lines: + if isinstance(raw, bytes): + raw = raw.decode("utf-8", errors="replace") + m = re.match(r"\((?P[^)]*)\)\s+\"[^\"]*\"\s+(?P.+)", raw) + if not m: + continue + flags = (m.group("flags") or "").lower() + name = m.group("name").strip().strip('"') + if "\\all" in flags or "all mail" in name.lower(): + effective_folder = name + break + except Exception: + pass + conn.select(_q(effective_folder), readonly=True) # Escape backslash and quote for the IMAP-SEARCH quoted-string. q_escaped = q.replace('\\', '\\\\').replace('"', '\\"') @@ -1084,7 +1109,7 @@ def setup_email_routes(): status, data = _imap_uid_search(conn, search_cmd) if status != "OK" or not data[0]: - return {"emails": [], "total": 0, "query": q} + return {"emails": [], "total": 0, "query": q, "folder": effective_folder} uid_list = data[0].split() total = len(uid_list) @@ -1148,6 +1173,13 @@ def setup_email_routes(): "is_flagged": "\\Flagged" in flags, "flags": flags, "has_attachments": has_attachments, + # Stamp the folder so the frontend opens each + # email from the folder it actually lives in + # (the search may have run against All Mail + # even though the caller asked for INBOX), + # otherwise clicks open whatever happens to + # have the same UID in INBOX → wrong email. + "folder": effective_folder, }) except Exception as e: logger.warning(f"Error parsing search result {uid}: {e}") @@ -1693,6 +1725,22 @@ def setup_email_routes(): logger.error(f"Failed to mark unread {uid}: {e}") return {"success": False, "error": "Mail operation failed"} + @router.post("/flag/{uid}") + async def flag_email(uid: str, folder: str = Query("INBOX"), account_id: str | None = Query(None), + on: bool = Query(True), owner: str = Depends(require_owner)): + """Toggle the \\Flagged flag (a.k.a. favorite / star) on an email. + Pass `on=true` to favorite, `on=false` to unfavorite.""" + try: + with _imap(account_id, owner=owner) as conn: + conn.select(_q(folder)) + if not _store_email_flag(conn, uid, "\\Flagged", add=bool(on)): + return {"success": False, "error": "Email not found"} + _invalidate_list_cache(account_id, folder) + return {"success": True, "flagged": bool(on)} + except Exception as e: + logger.error(f"Failed to flag {uid}: {e}") + return {"success": False, "error": "Mail operation failed"} + @router.post("/mark-read/{uid}") async def mark_read(uid: str, folder: str = Query("INBOX"), account_id: str | None = Query(None), owner: str = Depends(require_owner)): """Mark an email as read (set \\Seen flag).""" diff --git a/routes/hwfit_routes.py b/routes/hwfit_routes.py index eb408ac9d..4879d3610 100644 --- a/routes/hwfit_routes.py +++ b/routes/hwfit_routes.py @@ -108,7 +108,7 @@ def setup_hwfit_routes(): return detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh) @router.get("/models") - def get_models(use_case: str = "", sort: str = "score", limit: int = 50, search: str = "", host: str = "", quant: str = "", ctx: str = "", gpu_count: str = "", gpu_group: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False, fit_only: bool = False): + def get_models(use_case: str = "", sort: str = "newest", limit: int = 50, search: str = "", host: str = "", quant: str = "", ctx: str = "", gpu_count: str = "", gpu_group: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False, fit_only: bool = False): """Rank LLM models against detected hardware and return scored results. gpu_count: override GPU count (0 = CPU only, 1-N = simulate N GPUs of the active group). gpu_group: index into system.gpu_groups (the homogeneous diff --git a/scripts/backfill_model_release_dates.py b/scripts/backfill_model_release_dates.py new file mode 100755 index 000000000..741d8dac0 --- /dev/null +++ b/scripts/backfill_model_release_dates.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Backfill release_date on entries in services/hwfit/data/hf_models.json. + +Why: the `newest` sort in the cookbook ranks rows by release_date. Anything +missing a date sorts to the bottom. This script pulls `created_at` from the +HuggingFace API for each catalog entry without one (or all entries when +--refresh is passed) and writes the catalog back. + +Usage: + python scripts/backfill_model_release_dates.py # missing only + python scripts/backfill_model_release_dates.py --refresh # all entries + python scripts/backfill_model_release_dates.py --limit 50 # cap requests + python scripts/backfill_model_release_dates.py --dry-run # show, don't write + +Auth: set HF_TOKEN env var (or huggingface-cli login) to access gated repos. +""" +import argparse +import json +import os +import sys +import time +from datetime import datetime +from pathlib import Path + +try: + from huggingface_hub import HfApi + from huggingface_hub.utils import HfHubHTTPError +except ImportError: + print("Install huggingface_hub: pip install huggingface_hub", file=sys.stderr) + sys.exit(1) + + +CATALOG_PATH = Path(__file__).resolve().parent.parent / "services" / "hwfit" / "data" / "hf_models.json" + + +def fetch_release_date(api: HfApi, repo_id: str) -> str | None: + """Return YYYY-MM-DD release date, or None on miss / error.""" + try: + info = api.model_info(repo_id, files_metadata=False) + except HfHubHTTPError as e: + # 401 = gated/private, 404 = renamed/deleted. Either way, no date. + status = getattr(getattr(e, "response", None), "status_code", None) + print(f" {repo_id}: HTTP {status or '?'}", file=sys.stderr) + return None + except Exception as e: + print(f" {repo_id}: {type(e).__name__}: {e}", file=sys.stderr) + return None + created = getattr(info, "created_at", None) + if not created: + return None + return created.strftime("%Y-%m-%d") + + +def main(): + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--refresh", action="store_true", help="Overwrite existing release_date too (default: only fill missing).") + p.add_argument("--limit", type=int, default=0, help="Stop after N API calls (0 = no limit).") + p.add_argument("--dry-run", action="store_true", help="Don't write back; just report.") + p.add_argument("--sleep", type=float, default=0.05, help="Seconds to sleep between requests (default 0.05).") + args = p.parse_args() + + if not CATALOG_PATH.exists(): + print(f"Catalog not found: {CATALOG_PATH}", file=sys.stderr) + sys.exit(2) + + with CATALOG_PATH.open(encoding="utf-8") as f: + catalog = json.load(f) + + candidates = [] + for i, m in enumerate(catalog): + name = m.get("name") + if not name: + continue + existing = (m.get("release_date") or "").strip() + if existing and not args.refresh: + continue + candidates.append(i) + + if args.limit: + candidates = candidates[: args.limit] + + print(f"Catalog: {CATALOG_PATH}") + print(f"Total entries: {len(catalog)}") + print(f"Targets ({'refresh all' if args.refresh else 'missing only'}{'' if not args.limit else f', capped at {args.limit}'}): {len(candidates)}") + if not candidates: + print("Nothing to do.") + return + + api = HfApi(token=os.environ.get("HF_TOKEN") or None) + updated = 0 + skipped = 0 + started = time.time() + for n, idx in enumerate(candidates, start=1): + entry = catalog[idx] + name = entry["name"] + old = (entry.get("release_date") or "").strip() + new = fetch_release_date(api, name) + if new is None: + skipped += 1 + tag = "skip" + elif new == old: + tag = "unchanged" + else: + entry["release_date"] = new + updated += 1 + tag = f"set {new}" + (f" (was {old})" if old else "") + print(f"[{n}/{len(candidates)}] {name} — {tag}") + if args.sleep: + time.sleep(args.sleep) + + elapsed = time.time() - started + print() + print(f"Done in {elapsed:.1f}s — {updated} updated, {skipped} skipped (HF unavailable / gated / missing date).") + + if args.dry_run: + print("Dry run — no write.") + return + + if updated: + # Atomic write: tmp file in the same dir, then rename. Keeps the + # catalog usable even if the process dies mid-write. + tmp = CATALOG_PATH.with_suffix(".json.tmp") + with tmp.open("w", encoding="utf-8") as f: + json.dump(catalog, f, indent=1, ensure_ascii=False) + f.write("\n") + tmp.replace(CATALOG_PATH) + print(f"Wrote {CATALOG_PATH}") + else: + print("No changes to write.") + + +if __name__ == "__main__": + main() diff --git a/scripts/import_from_vllm_recipes.py b/scripts/import_from_vllm_recipes.py new file mode 100755 index 000000000..2dd65def8 --- /dev/null +++ b/scripts/import_from_vllm_recipes.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +"""Import models from the upstream vllm-project/recipes catalog into our +local hf_models.json. Two modes: + + --update-existing Stamp min_vllm_version + vllm_recipe=True on rows we + already carry. Cheap, no HF API calls. + --add-missing Create new catalog rows for every recipe model we + don't carry. Hits the HF API for created_at + downloads + (~1 req per missing model, paced). + +Both modes write atomically (tmp + rename) so a crashed run leaves the +catalog intact. Default with no mode flags runs both, prefer to pass them +explicitly. + +Usage: + python scripts/import_from_vllm_recipes.py --update-existing + python scripts/import_from_vllm_recipes.py --add-missing + python scripts/import_from_vllm_recipes.py --dry-run + python scripts/import_from_vllm_recipes.py --limit 10 + +Auth: set HF_TOKEN to access gated repos when --add-missing. +""" +import argparse +import json +import os +import re +import sys +import time +from datetime import datetime +from pathlib import Path + +try: + import httpx + import yaml +except ImportError: + print("pip install httpx PyYAML", file=sys.stderr) + sys.exit(1) + +try: + from huggingface_hub import HfApi + from huggingface_hub.utils import HfHubHTTPError +except ImportError: + HfApi = None + HfHubHTTPError = Exception + + +CATALOG_PATH = Path(__file__).resolve().parent.parent / "services" / "hwfit" / "data" / "hf_models.json" +RECIPES_TREE_URL = ( + "https://api.github.com/repos/vllm-project/recipes/git/trees/main?recursive=1" +) +RECIPE_RAW_URL = ( + "https://raw.githubusercontent.com/vllm-project/recipes/main/models/{repo}.yaml" +) + + +# Map recipe `precision` to the closest catalog `quantization` label that +# fit.py / models.py already understand. +_PRECISION_TO_QUANT = { + "fp8": "FP8", + "nvfp4": "NVFP4", + "mxfp4": "MXFP4", + "bf16": "BF16", + "fp16": "F16", + "f16": "F16", + "fp4": "FP4", + "int8": "INT8", + "int4": "INT4", + "awq-4bit": "AWQ-4bit", + "awq-8bit": "AWQ-8bit", +} + +# Architecture name → use_case fallback. fit.py weights use_case for filtering; +# missing field defaults to a generic bucket. +_ARCH_USE_CASE = { + "moe": "General-purpose reasoning, long-context", + "llama": "General-purpose chat", + "qwen2": "General-purpose chat", + "qwen3": "General-purpose reasoning", + "deepseek_v3_moe": "General-purpose reasoning, long-context", + "deepseek_v4_moe": "General-purpose reasoning, long-context", +} + + +def _parse_param_count(s) -> int: + """'230B' / '8.6B' / '4.2T' → integer parameter count.""" + if s is None: + return 0 + s = str(s).strip().replace(",", "") + m = re.match(r"^([\d.]+)\s*([KMBT]?)$", s, re.I) + if not m: + return 0 + num = float(m.group(1)) + unit = (m.group(2) or "").upper() + mult = {"K": 1e3, "M": 1e6, "B": 1e9, "T": 1e12, "": 1.0}[unit] + return int(num * mult) + + +def _capabilities_for(arch: str, hardware: dict, ctx_len: int, has_reasoning: bool) -> list[str]: + caps = [] + if "moe" in (arch or "").lower(): + caps.append("moe") + if has_reasoning: + caps.append("reasoning") + if ctx_len and ctx_len >= 100_000: + caps.append("long_context") + if any(hw in (hardware or {}) for hw in ("mi300x", "mi325x", "mi350x", "mi355x")): + caps.append("amd_supported") + return caps + + +def _fetch_manifest(client: httpx.Client) -> set[str]: + r = client.get(RECIPES_TREE_URL, headers={"Accept": "application/vnd.github+json"}, timeout=15) + r.raise_for_status() + tree = (r.json() or {}).get("tree") or [] + out: set[str] = set() + for e in tree: + path = (e or {}).get("path") or "" + if path.startswith("models/") and path.endswith(".yaml"): + body = path[len("models/"):-len(".yaml")] + if "/" in body: + out.add(body) + return out + + +def _fetch_recipe(client: httpx.Client, repo: str) -> dict | None: + url = RECIPE_RAW_URL.format(repo=repo) + try: + r = client.get(url, timeout=10) + if r.status_code != 200: + return None + return yaml.safe_load(r.text) or {} + except Exception: + return None + + +def _stamp_from_recipe(entry: dict, recipe: dict) -> bool: + """Mutate entry with recipe-derived fields. Returns True if anything changed.""" + model = recipe.get("model") or {} + meta = recipe.get("meta") or {} + features = recipe.get("features") or {} + + changed = False + new_min = (model.get("min_vllm_version") or "").strip() + if new_min and entry.get("min_vllm_version") != new_min: + entry["min_vllm_version"] = new_min + changed = True + if not entry.get("vllm_recipe"): + entry["vllm_recipe"] = True + changed = True + # Hardware support map — useful for filtering "which models run on my AMD box". + hw = meta.get("hardware") or {} + if hw and entry.get("recipe_hardware") != hw: + entry["recipe_hardware"] = {k: str(v) for k, v in hw.items()} + changed = True + # Tool/reasoning parser hints — purely informational at catalog level; + # the live launch command builder still reads them from the recipe API. + if features.get("reasoning") and not entry.get("has_reasoning_parser"): + entry["has_reasoning_parser"] = True + changed = True + if features.get("tool_calling") and not entry.get("has_tool_call_parser"): + entry["has_tool_call_parser"] = True + changed = True + return changed + + +def _build_new_entry(repo: str, recipe: dict, hf_info=None) -> dict | None: + """Build a fresh catalog entry from a recipe + (optional) HF model info.""" + model = recipe.get("model") or {} + meta = recipe.get("meta") or {} + features = recipe.get("features") or {} + variants = recipe.get("variants") or {} + + org, name = repo.split("/", 1) + raw_params = _parse_param_count(model.get("parameter_count")) + active_raw = _parse_param_count(model.get("active_parameters")) + ctx = model.get("context_length") or 0 + + # Pick the smallest-VRAM variant as the catalog quant — that's what most + # users land on first. NVFP4/MXFP4 typically win this on Blackwell; + # FP8 elsewhere; BF16 baseline only. + pick_quant = None + pick_vram = None + for vk, vv in variants.items(): + if not isinstance(vv, dict): + continue + prec = (vv.get("precision") or "").lower() + vram = vv.get("vram_minimum_gb") or 0 + quant = _PRECISION_TO_QUANT.get(prec) + if quant and (pick_vram is None or (vram and vram < pick_vram)): + pick_quant = quant + pick_vram = vram or pick_vram + if not pick_quant: + pick_quant = "BF16" + + arch = (model.get("architecture") or "").lower() + use_case = _ARCH_USE_CASE.get(arch, "General-purpose chat") + caps = _capabilities_for(arch, meta.get("hardware") or {}, ctx, bool(features.get("reasoning"))) + + rel_date = "" + downloads = 0 + likes = 0 + if hf_info is not None: + created = getattr(hf_info, "created_at", None) + if created: + rel_date = created.strftime("%Y-%m-%d") + downloads = int(getattr(hf_info, "downloads", 0) or 0) + likes = int(getattr(hf_info, "likes", 0) or 0) + if not rel_date: + rel_date = str(meta.get("date_updated") or datetime.utcnow().strftime("%Y-%m-%d")) + + entry: dict = { + "name": repo, + "provider": org, + "parameter_count": str(model.get("parameter_count") or "?"), + "parameters_raw": raw_params, + "is_moe": "moe" in arch, + "quantization": pick_quant, + "context_length": int(ctx or 0), + "use_case": use_case, + "capabilities": caps, + "pipeline_tag": "text-generation", + "architecture": arch or "unknown", + "hf_downloads": downloads, + "hf_likes": likes, + "release_date": rel_date, + # Recipe-derived bits. + "vllm_recipe": True, + "min_vllm_version": (model.get("min_vllm_version") or "").strip() or None, + "recipe_hardware": {k: str(v) for k, v in (meta.get("hardware") or {}).items()}, + "has_reasoning_parser": bool(features.get("reasoning")), + "has_tool_call_parser": bool(features.get("tool_calling")), + } + if active_raw: + entry["active_parameters"] = active_raw + if pick_vram: + # min_vram_gb is what hwfit uses for "does this fit". Recipe states a + # minimum for the chosen variant; round up slightly for KV-cache room. + entry["min_vram_gb"] = float(pick_vram) + entry["min_ram_gb"] = float(round(pick_vram * 0.6, 1)) + entry["recommended_ram_gb"] = float(round(pick_vram * 1.2, 1)) + # Drop empty / None fields to keep the JSON tidy. + return {k: v for k, v in entry.items() if v not in (None, "", [], {})} + + +def main(): + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--update-existing", action="store_true", help="Stamp min_vllm_version + vllm_recipe on existing rows.") + p.add_argument("--add-missing", action="store_true", help="Add new rows for recipe models not in the catalog.") + p.add_argument("--limit", type=int, default=0, help="Stop after N recipe fetches.") + p.add_argument("--dry-run", action="store_true", help="Don't write back; just report.") + p.add_argument("--sleep", type=float, default=0.05, help="Seconds between HTTP requests.") + args = p.parse_args() + if not args.update_existing and not args.add_missing: + args.update_existing = args.add_missing = True + + with CATALOG_PATH.open(encoding="utf-8") as f: + catalog = json.load(f) + by_name = {m.get("name"): m for m in catalog if m.get("name")} + + client = httpx.Client(follow_redirects=True) + print(f"Catalog: {CATALOG_PATH} ({len(catalog)} entries)") + print("Fetching upstream manifest…") + try: + manifest = _fetch_manifest(client) + except Exception as e: + print(f"FATAL: manifest fetch failed: {e}", file=sys.stderr) + sys.exit(2) + print(f"Manifest: {len(manifest)} recipes") + + existing = sorted(by_name.keys() & manifest) + missing = sorted(manifest - by_name.keys()) + print(f"Match catalog ↔ manifest: existing={len(existing)} missing={len(missing)}") + + targets: list[tuple[str, str]] = [] # (repo, action) + if args.update_existing: + targets.extend((r, "update") for r in existing) + if args.add_missing: + targets.extend((r, "add") for r in missing) + if args.limit: + targets = targets[: args.limit] + print(f"Targets: {len(targets)}") + + hf_api = HfApi(token=os.environ.get("HF_TOKEN") or None) if HfApi else None + updated = added = skipped = 0 + started = time.time() + + for n, (repo, action) in enumerate(targets, 1): + recipe = _fetch_recipe(client, repo) + if not recipe: + print(f"[{n}/{len(targets)}] {repo:55} skip (no recipe fetched)") + skipped += 1 + time.sleep(args.sleep) + continue + if action == "update": + entry = by_name[repo] + if _stamp_from_recipe(entry, recipe): + updated += 1 + print(f"[{n}/{len(targets)}] {repo:55} updated") + else: + print(f"[{n}/{len(targets)}] {repo:55} unchanged") + else: # add + hf_info = None + if hf_api: + try: + hf_info = hf_api.model_info(repo, files_metadata=False) + except HfHubHTTPError as e: + code = getattr(getattr(e, "response", None), "status_code", "?") + print(f" HF {code} for {repo} — building from recipe only", file=sys.stderr) + except Exception as e: + print(f" HF error for {repo}: {e}", file=sys.stderr) + new_entry = _build_new_entry(repo, recipe, hf_info) + if new_entry: + catalog.append(new_entry) + by_name[repo] = new_entry + added += 1 + print(f"[{n}/{len(targets)}] {repo:55} added ({new_entry.get('parameter_count','?')}, {new_entry.get('quantization','?')})") + else: + skipped += 1 + print(f"[{n}/{len(targets)}] {repo:55} skip (couldn't build entry)") + time.sleep(args.sleep) + + elapsed = time.time() - started + print() + print(f"Done in {elapsed:.1f}s — added={added}, updated={updated}, skipped={skipped}") + + if args.dry_run: + print("Dry run — no write.") + return + if added or updated: + tmp = CATALOG_PATH.with_suffix(".json.tmp") + with tmp.open("w", encoding="utf-8") as f: + json.dump(catalog, f, indent=1, ensure_ascii=False) + f.write("\n") + tmp.replace(CATALOG_PATH) + print(f"Wrote {CATALOG_PATH} ({len(catalog)} entries)") + else: + print("No changes — catalog untouched.") + + +if __name__ == "__main__": + main() diff --git a/src/agent_loop.py b/src/agent_loop.py index 7f66a2f9c..acb35e7b1 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -843,6 +843,7 @@ def _build_system_prompt( compact: bool = False, owner: Optional[str] = None, suppress_local_context: bool = False, + active_email: Optional[Dict[str, str]] = None, ) -> List[Dict]: """Build agent system prompt, inject MCP/document context, merge consecutive system msgs.""" global _cached_base_prompt, _cached_base_prompt_key @@ -1023,6 +1024,66 @@ def _build_system_prompt( else: set_active_document(None) + # Active email reader — frontend told us the user has an email open. + # Inject a context block so "reply", "summarize this", "what does it say" + # resolve to the real UID instead of the agent inventing a fresh .md + # draft with fake headers. This is the email equivalent of _doc_message. + _email_message = None + if active_email and active_email.get("uid"): + _em_uid = active_email.get("uid", "") + _em_folder = active_email.get("folder", "INBOX") + _em_account = active_email.get("account", "") + _em_subject = active_email.get("subject", "") or "(no subject)" + _em_from = active_email.get("from", "") or "(unknown sender)" + _em_preview = (active_email.get("body_preview", "") or "").strip() + _preview_block = f"\nBody preview:\n```\n{_em_preview[:1800]}\n```" if _em_preview else "" + _acct_arg = f" {_em_account}" if _em_account else "" + email_ctx = ( + f"ACTIVE EMAIL OPEN (the user has this email open in a reader window right now)\n" + f"UID: {_em_uid}\n" + f"Folder: {_em_folder}\n" + f"Account: {_em_account or '(default)'}\n" + f"From: {_em_from}\n" + f"Subject: {_em_subject}{_preview_block}\n\n" + f"CRITICAL DEFAULT — every request about email this turn refers to " + f"THIS email unless the user names a DIFFERENT specific recipient " + f"(a name, an email address, or another thread). Examples that " + f"ALL mean reply-to-the-open-email:\n" + f" • 'reply' / 'reply to this' / 'respond'\n" + f" • 'write email saying X' / 'send email saying X' / 'draft something'\n" + f" • 'tell them X' / 'say hi' / 'thanks' / 'ack' / 'lmk'\n" + f" • 'summarize it' / 'what does it say' / 'tldr'\n" + f" • 'forward this' / 'forward to '\n" + f"DO NOT ASK THE USER 'who do you want to send this to?' — the " + f"answer is ALWAYS the sender of the open email (above) unless they " + f"named someone else. Asking that is the wrong move every time.\n\n" + f"RULES for the open email:\n" + f"1. DRAFT a reply (default for any 'write/send/reply/tell them' " + f"request without a different recipient): call `ui_control` with " + f"`action=\"open_email_reply\"` and `extra=\"{_em_uid} {_em_folder} " + f"reply\"`. This opens the proper reply doc with To/Subject/" + f"In-Reply-To pre-filled by the backend. The user will see and edit " + f"it before sending. DO NOT `create_document` a markdown file with " + f"hand-written `To:` / `Subject:` / `In-Reply-To:` headers — that " + f"is wrong every time.\n" + f"2. SEND a reply immediately (skip the draft): call " + f"`reply_to_email` with the UID above. Only do this when the user " + f"explicitly says 'send' / 'send the reply' / 'reply and send'.\n" + f"3. READ the full body (the preview above may be truncated): " + f"call `read_email` with the UID/folder/account above.\n" + f"4. SUMMARIZE / answer questions about it: read it first, then " + f"answer in chat. Don't create a document for a summary unless " + f"the user explicitly asks for one.\n" + f"5. Never ask the user to paste the email or 'share it with you' " + f"— you already have its identity above and can read the full body.\n" + f"6. The ONLY time you ask 'who to send to?' is when the user " + f"explicitly says 'send a NEW email to someone else' or names a " + f"recipient you can't identify. A bare 'send email saying X' = the " + f"open email's sender.\n" + ) + _email_message = untrusted_context_message("active email reader", email_ctx) + _email_message["_protected"] = True + # Inject writing style for any email writing path. This is deliberately # broader than read/list: models may compose via send_email, reply_to_email, # or ui_control open_email_reply after the first tool round. @@ -1230,6 +1291,9 @@ def _build_system_prompt( if _doc_message: merged.insert(last_user_idx, _doc_message) last_user_idx += 1 # the document message is now at last_user_idx + if _email_message: + merged.insert(last_user_idx, _email_message) + last_user_idx += 1 if _skills_message: merged.insert(last_user_idx, _skills_message) @@ -1712,6 +1776,7 @@ async def stream_agent_loop( max_tool_calls: int = 0, context_length: int = 0, active_document=None, + active_email: Optional[Dict[str, str]] = None, session_id: Optional[str] = None, disabled_tools: Optional[Set[str]] = None, owner: Optional[str] = None, @@ -1944,6 +2009,7 @@ async def stream_agent_loop( compact=_is_api_model, owner=owner, suppress_local_context=guide_only, + active_email=active_email, ) if workspace and not guide_only: # PREPEND (not append) so it dominates the large base prompt — appended @@ -2794,7 +2860,19 @@ async def stream_agent_loop( tool_output_data = {"type": "tool_output", "tool": block.tool_type, "command": cmd_display, "output": output_text, "exit_code": result.get("exit_code")} if "ui_event" in result: tool_output_data["ui_event"] = result["ui_event"] - for k in ("toggle_name", "state", "mode", "model", "endpoint_url", "theme_name", "colors"): + for k in ( + "toggle_name", "state", "mode", "model", "endpoint_url", + "theme_name", "colors", + # ui_control open_email_reply payload — without these the + # frontend openReplyDraft bails on undefined uid and the + # reply window silently never opens. + "uid", "folder", "account_id", + # Optional pre-filled body for open_email_reply so the + # agent can compose-and-open in one tool call. + "body", + # ui_control open_panel payload + "panel", + ): if k in result: tool_output_data[k] = result[k] # Forward image data from generate_image tool diff --git a/src/ai_interaction.py b/src/ai_interaction.py index 423f80ac5..1c522748b 100644 --- a/src/ai_interaction.py +++ b/src/ai_interaction.py @@ -1287,7 +1287,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O set_theme — Apply a built-in theme preset (dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute) create_theme [key=val ...] — Create custom theme. Optional key=val: advanced color overrides AND background effects: bgPattern=, bgEffectColor=#RRGGBB, bgEffectIntensity=, bgEffectSize=, frosted=true|false open_panel — Open a panel (documents, gallery, email, sessions, notes, memories, skills, settings, cookbook) - open_email_reply [folder] [reply|reply-all|ai-reply] — Open a reply draft document for an email; does not send + open_email_reply [folder] [reply|reply-all|ai-reply] [body text] — Open a reply draft document for an email; does not send. ALWAYS append the body text when the user told you what to say (one-shot draft); only omit body when the user just asked to "open a reply" without content. get_toggles — Return current toggle states (server-side knowledge) """ lines = content.strip().split("\n") @@ -1531,21 +1531,54 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O } elif action == "open_email_reply": - reply_parts = lines[0].strip().split() - uid = reply_parts[1].strip() if len(reply_parts) > 1 else "" - folder = reply_parts[2].strip() if len(reply_parts) > 2 else "INBOX" - mode = reply_parts[3].strip().lower() if len(reply_parts) > 3 else "reply" + # Two forms supported: + # open_email_reply [folder] [reply|reply-all|ai-reply] + # open_email_reply [folder] [reply|reply-all|ai-reply] + # + # The body text (if any) gets pre-filled into the reply draft so the + # agent can compose-and-open in one tool call instead of opening an + # empty draft and leaving the user to wonder what happened. + first_line = lines[0].strip() + parts = first_line.split(maxsplit=4) + uid = parts[1].strip() if len(parts) > 1 else "" + folder = parts[2].strip() if len(parts) > 2 else "INBOX" + mode = parts[3].strip().lower() if len(parts) > 3 else "reply" + # Body: everything on the first line after the mode token, plus any + # subsequent lines. Allows multi-line bodies. + inline_body = parts[4] if len(parts) > 4 else "" + rest_lines = "\n".join(lines[1:]).strip() if len(lines) > 1 else "" + body = (inline_body + ("\n" + rest_lines if rest_lines else "")).strip() if not uid: - return {"error": "open_email_reply needs: open_email_reply [folder] [reply|reply-all|ai-reply]"} + return {"error": "open_email_reply needs: open_email_reply [folder] [reply|reply-all|ai-reply] [body text]"} if mode not in ("reply", "reply-all", "ai-reply"): mode = "reply" - return { + # Body is REQUIRED for the agent path. Opening an empty draft is what + # users do by clicking the Reply button — they don't ask the agent + # for that. Every agent invocation of open_email_reply MUST include + # the body. Reject empty so the agent retries with the content the + # user asked for. Exception: ai-reply mode triggers the existing + # AI-Reply path on the frontend which generates its own body. + if not body and mode != "ai-reply": + return { + "error": ( + "open_email_reply called without body. The agent path REQUIRES a body — " + "opening an empty draft is the wrong response when the user asked you to write. " + "Re-call with the reply text included: " + f"`open_email_reply {uid} {folder or 'INBOX'} {mode} `. " + "Compose the reply now based on the open email's content and the user's request, " + "then call this tool again with the body. Do NOT call create_document instead." + ), + } + result = { "ui_event": "open_email_reply", "uid": uid, "folder": folder or "INBOX", "mode": mode, - "results": f"Opening reply draft for email UID {uid}", + "results": f"Opening reply draft for email UID {uid}" + (" with pre-filled body" if body else ""), } + if body: + result["body"] = body + return result elif action == "get_toggles": return { diff --git a/src/tool_implementations.py b/src/tool_implementations.py index 9c9e45058..e4de5a27a 100644 --- a/src/tool_implementations.py +++ b/src/tool_implementations.py @@ -61,6 +61,37 @@ def _parse_tool_args(content): _active_document_id: Optional[str] = None _active_model: Optional[str] = None +# When the user has an email reader window open, the frontend tells the +# backend about it on each chat submit. We stash it here so email tools +# (reply_to_email, read_email, mark_email) can resolve "this email" / "the +# open one" without the agent guessing a UID. Cleared between requests by +# chat_routes after the agent loop returns. +_active_email_ref: Optional[Dict[str, str]] = None + + +def set_active_email(uid: Optional[str], folder: Optional[str] = None, account: Optional[str] = None, + subject: Optional[str] = None, sender: Optional[str] = None) -> None: + """Stash the email currently open in the UI. None clears it.""" + global _active_email_ref + if not uid: + _active_email_ref = None + return + _active_email_ref = { + "uid": str(uid), + "folder": str(folder or "INBOX"), + "account": str(account or ""), + "subject": str(subject or ""), + "from": str(sender or ""), + } + + +def get_active_email() -> Optional[Dict[str, str]]: + return _active_email_ref + + +def clear_active_email() -> None: + global _active_email_ref + _active_email_ref = None def set_active_document(doc_id: Optional[str]): diff --git a/src/tool_index.py b/src/tool_index.py index 15784d1a7..b01af7a0c 100644 --- a/src/tool_index.py +++ b/src/tool_index.py @@ -103,7 +103,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = { "search_chats": "Search past session transcripts across chats.", "ask_user": "Ask the user a multiple-choice question to get a decision or clarification. Use this when the task is genuinely ambiguous and the answer changes what you do next — pick between approaches, confirm an assumption, choose among options — instead of guessing. Provide a clear `question` and 2-6 `options` (each with a short `label`, optional `description`). Calling this ENDS your turn: the user sees clickable buttons and their choice arrives as your next message. Don't use it for things you can decide from context or sensible defaults, or for irreversible-action confirmation if a dedicated flow exists.", "update_plan": "Write back to the ACTIVE PLAN while executing an approved plan: mark steps done or revise them. After finishing a step call this with the full checklist and that step marked done; when the user asks to change the plan call it with the revised checklist. Always pass the COMPLETE markdown checklist (`- [ ]` / `- [x]`), not a diff. The user's docked plan window updates live. No effect when there is no active plan.", - "ui_control": "Control the UI and toggle tools on/off. Use this to turn off / turn on / disable / enable individual tools and features: shell (bash), search (web), research, browser, documents, incognito. Open panels (documents library, gallery, email inbox, sessions, notes, memories/brain, skills, settings, cookbook) via `open_panel `. Use `open_email_reply reply` to open an email reply draft document without sending. Also switches between chat/agent modes, changes the current model, and applies/creates themes.", + "ui_control": "Control the UI and toggle tools on/off. Use this to turn off / turn on / disable / enable individual tools and features: shell (bash), search (web), research, browser, documents, incognito. Open panels (documents library, gallery, email inbox, sessions, notes, memories/brain, skills, settings, cookbook) via `open_panel `. Use `open_email_reply reply` to open an email reply draft document without sending. To pre-fill the reply body in one shot (USE THIS whenever the user told you what to say — opening an empty draft when they asked you to write is wrong), append the body after the mode: `open_email_reply reply `. Body can continue on subsequent lines for multi-line replies. Also switches between chat/agent modes, changes the current model, and applies/creates themes.", "list_email_accounts": "List configured email accounts and default status. Use before reading or sending mail when the user mentions Gmail, work mail, custom domain mail, another mailbox, or asks to compare/check multiple inboxes.", "list_emails": "List emails for a folder/account, newest first, including read messages by default. Shows subject, sender, date, UID, account, and AI summary. Check inbox, find emails needing replies. Supports account from list_email_accounts for Gmail/work/custom mailboxes. For last/latest/newest email, use max_results=1 and unread_only=false.", "read_email": "Read the full content of a specific email by UID or Message-ID. View email body, check details. Supports account from list_email_accounts when the UID belongs to a non-default mailbox.", diff --git a/static/js/calendar.js b/static/js/calendar.js index fec9f82c8..24e8c4846 100644 --- a/static/js/calendar.js +++ b/static/js/calendar.js @@ -630,6 +630,28 @@ function _getModal() { // ── Render dispatch ── +// Quick-add hint examples — the placeholder cycles through these every few +// seconds so users see different prompt shapes (events, deadlines, recurring). +const _QA_HINT_EXAMPLES = [ + 'return home to Ithaca 1pm tmrw', + 'dinner with Penelope Friday 8pm', + 'coffee with Athena 9am Saturday', + 'call Telemachus tomorrow morning', + 'dentist appointment 3pm next Tuesday', + 'finish the wooden horse by Friday EOD', + 'gym 7am every weekday', + 'flight to Athens Sunday 6:30am', + 'crew muster 10am daily', + 'council on Ithaca Monday 2pm', +]; +function _initQuickAddHintCycle() { + const span = document.getElementById('qa-hint-example'); + if (!span) return; + // Pick one random example per calendar open — no interval cycling. + const idx = Math.floor(Math.random() * _QA_HINT_EXAMPLES.length); + span.textContent = _QA_HINT_EXAMPLES[idx]; +} + // Stash the quick-add input's state (focus + caret + value) before a // re-render so background fetches don't kick the user out mid-type. Picked // up by _wireAll after the new DOM lands. @@ -844,7 +866,7 @@ function _headerHTML() { placeholder=" " autocomplete="off" /> - + `; } @@ -1911,6 +1933,7 @@ function _wireAll(body) { // ── Quick-add input ───────────────────────────────────────────── const _qaInput = document.getElementById('cal-quickadd'); const _qaStatus = document.getElementById('cal-quickadd-status'); + _initQuickAddHintCycle(); if (_qaInput && !_qaInput._wired) { _qaInput._wired = true; const _submitQA = async () => { @@ -3061,6 +3084,29 @@ function _showEventForm(existing, defaultDate, defaultEndDate) { // mode opens already expanded when there's any detail content to see. titleInput?.addEventListener('focus', () => setExpanded(true), { once: true }); + // Live time parse: typing a time like "11pm" or "15:30" into the title + // updates the hero clock + start input on the fly. The same parser still + // runs again on submit, but doing it live makes the hero clock track + // intent immediately instead of jumping at save. + if (titleInput) { + titleInput.addEventListener('input', () => { + if (document.getElementById('cal-f-allday')?.checked) return; + const tt = _parseTitleTime(titleInput.value); + if (!tt) return; + const startEl = document.getElementById('cal-f-start'); + const endEl = document.getElementById('cal-f-end'); + const newStart = `${String(tt.h).padStart(2, '0')}:${String(tt.m).padStart(2, '0')}`; + if (!startEl || startEl.value === newStart) return; + const toMin = (v) => { const p = (v || '').split(':'); return p.length === 2 ? (+p[0]) * 60 + (+p[1]) : null; }; + const s0 = toMin(startEl.value), e0 = toMin(endEl?.value); + const dur = (s0 != null && e0 != null && e0 > s0) ? e0 - s0 : 60; + startEl.value = newStart; + const endMin = (tt.h * 60 + tt.m + dur) % 1440; + if (endEl) endEl.value = `${String(Math.floor(endMin / 60)).padStart(2, '0')}:${String(endMin % 60).padStart(2, '0')}`; + startEl.dispatchEvent(new Event('input')); + }); + } + // Location → Apple Maps. The pin button next to the input is enabled // only when there's a non-empty location, and its href tracks the live // input value. Apple's universal URL opens the native Maps app on diff --git a/static/js/chat.js b/static/js/chat.js index 65e4d17de..dd0b213a1 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -785,6 +785,19 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer try { await documentModule.saveDocument({ silent: true }); } catch (_e) { /* best-effort */ } fd.append('active_doc_id', documentModule.getCurrentDocId()); } + // Active email context — when an email reader is open, pass its + // uid/folder/account so "reply", "summarize", "what does this say" + // resolve to the email the user is actually looking at instead of + // making the agent invent a new markdown draft with fake headers. + try { + const getEmailCtx = window.__odysseusGetActiveEmailContext; + const emCtx = typeof getEmailCtx === 'function' ? getEmailCtx() : null; + if (emCtx && emCtx.uid) { + fd.append('active_email_uid', String(emCtx.uid)); + fd.append('active_email_folder', String(emCtx.folder || 'INBOX')); + if (emCtx.account) fd.append('active_email_account', String(emCtx.account)); + } + } catch (_e) { /* best-effort */ } // Web toggle: pre-search in Chat mode, tool permission in Agent mode const toggleState = Storage.loadToggleState(); let isAgentMode = (toggleState.mode || 'chat') === 'agent'; diff --git a/static/js/chatStream.js b/static/js/chatStream.js index 0cc14468e..fc62216ad 100644 --- a/static/js/chatStream.js +++ b/static/js/chatStream.js @@ -185,7 +185,7 @@ export function handleUIControl(uiData) { } else if (uiEvent === 'open_email_reply' || uiData.ui_event === 'open_email_reply') { import('./emailInbox.js').then(function(mod) { var fn = mod.openReplyDraft || (mod.default && mod.default.openReplyDraft); - if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply'); + if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply', uiData.body || ''); }).catch(function(e) { console.warn('open_email_reply failed:', e); }); diff --git a/static/js/cookbook.js b/static/js/cookbook.js index 8d758c52a..09a5dc813 100644 --- a/static/js/cookbook.js +++ b/static/js/cookbook.js @@ -792,6 +792,22 @@ async function _fetchDependencies() { return ``; }; + // Per-package inline glyphs — same accent-coloured marks used in the + // Backend picker on the Run page, so the Dependencies row visually + // matches the engine you're configuring. Unknown packages get no + // icon (the name alone is fine for librosa, hf_transfer, etc.). + const _DEP_GLYPHS = { + vllm: '', + sglang: '', + llama_cpp: '', + ollama: '', + diffusers: '', + }; + const _depGlyphHtml = (name) => { + const g = _DEP_GLYPHS[name]; + return g ? `` : ''; + }; + const _depRow = (pkg) => { const isLocal = pkg.target === 'local'; const isSystemDep = pkg.kind === 'system'; @@ -821,7 +837,7 @@ async function _fetchDependencies() { const recipePanel = hasRecipe ? _recipePanelHtml(pkg.name) : ''; return `
` + `
` - + `
${esc(pkg.name)}
` + + `
${_depGlyphHtml(pkg.name)}${esc(pkg.name)}
` + `
${esc(pkg.desc)}
` + note + updateNote diff --git a/static/js/cookbookRunning.js b/static/js/cookbookRunning.js index debcb9890..854a38590 100644 --- a/static/js/cookbookRunning.js +++ b/static/js/cookbookRunning.js @@ -807,7 +807,7 @@ function _winSessionCmd(task, tmuxArgs) { return host ? `ssh ${pf}${host} 'tmux ${tmuxArgs}' 2>/dev/null` : `tmux ${tmuxArgs} 2>/dev/null`; } -function _tmuxGracefulKill(task) { +export function _tmuxGracefulKill(task) { if (_isWindows(task)) { const host = task.remoteHost; const sd = host ? '$env:TEMP\\odysseus-sessions' : '$env:TEMP\\odysseus-tmux'; @@ -824,6 +824,48 @@ function _tmuxGracefulKill(task) { return `tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null`; } +// Force-kill escalation: SIGKILL the tmux pane's owning PID and any children, +// then nuke the session. Use AFTER the graceful kill when the process is +// still detected — vLLM sometimes ignores SIGINT during model init, and a +// stuck CUDA context can survive `tmux kill-session` alone. +export function _tmuxForceKill(task) { + if (_isWindows(task)) { + // Windows graceful path already does Stop-Process -Force, so the same + // command serves as the "force" variant. + return _tmuxGracefulKill(task); + } + const sid = task.sessionId; + const inner = + `PIDS=$(tmux list-panes -t ${sid} -F "#{pane_pid}" 2>/dev/null); ` + + `if [ -n "$PIDS" ]; then ` + + ` for P in $PIDS; do ` + + ` pkill -KILL -P "$P" 2>/dev/null; ` + + ` kill -9 "$P" 2>/dev/null; ` + + ` done; ` + + `fi; ` + + `tmux kill-session -t ${sid} 2>/dev/null`; + if (task.remoteHost) { + return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} ${_shQuote(inner)}`; + } + return inner; +} + +// Returns a shell snippet that prints "ALIVE" if the tmux session still +// exists (or its main PID is still listed in /proc), "DEAD" otherwise. +// Used by the Stop-all escalation to decide whether to force-kill. +export function _tmuxIsAliveCheck(task) { + if (_isWindows(task)) { + // Skip the check on Windows — the graceful path already force-kills. + return null; + } + const sid = task.sessionId; + const inner = `if tmux has-session -t ${sid} 2>/dev/null; then echo ALIVE; else echo DEAD; fi`; + if (task.remoteHost) { + return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} ${_shQuote(inner)}`; + } + return inner; +} + function _shQuote(value) { return "'" + String(value ?? '').replace(/'/g, "'\\''") + "'"; } @@ -1668,7 +1710,11 @@ export function _renderRunningTab() { group = document.createElement('div'); group.className = 'cookbook-group hidden'; group.dataset.backendGroup = 'Running'; - group.innerHTML = '
' + + // No `flex:1` on the card — with overflow:visible (forced via #cookbook-modal + // .cookbook-group > .admin-card), flex:1 collapsed the card to body height + // and the body's scrollHeight stopped tracking the overflowing children. + // Sized-to-content means cookbook-body's overflow-y:auto kicks in naturally. + group.innerHTML = '
' + '
' + '

Active ' + activeCount + '

' + '
' + @@ -1761,9 +1807,21 @@ export function _renderRunningTab() { btn.addEventListener('click', async (e) => { e.stopPropagation(); // don't toggle the section collapse (was an inline onclick, blocked by CSP) const host = btn.dataset.clearServer; - if (!await window.styledConfirm(`Clear finished tasks on ${_serverName(host)}?`, { confirmText: 'Clear' })) return; const allTasks = _loadTasks(); const toRemove = allTasks.filter(t => (t.remoteHost || '') === 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 _msg = stillRunning + ? `No finished tasks on ${_serverName(host)} — ${stillRunning} still running. Stop them first to clear.` + : `No finished tasks on ${_serverName(host)}.`; + if (window.uiModule?.showToast) window.uiModule.showToast(_msg); + else alert(_msg); + 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)); _saveTasks(remaining); // Fade/slide each finished card out (same exit as the per-card clear) diff --git a/static/js/document.js b/static/js/document.js index 2919f17ad..cfa2025d0 100644 --- a/static/js/document.js +++ b/static/js/document.js @@ -2472,6 +2472,8 @@ import * as Modals from './modalManager.js'; } // Hide toolbar items that have no clean WYSIWYG equivalent in email (Code). document.querySelectorAll('.md-toolbar-email-hide').forEach(el => { el.style.display = 'none'; }); + // Show email-only toolbar items (AI reply button). + document.querySelectorAll('.md-toolbar-email-only').forEach(el => { el.style.display = 'inline-flex'; }); if (emailHeader) emailHeader.style.display = ''; if (emailActions) emailActions.style.display = ''; // Emails have their own complete footer (Close / More / Send), so hide the @@ -2864,6 +2866,8 @@ import * as Modals from './modalManager.js'; if (emailActions) emailActions.style.display = 'none'; // Restore toolbar items that were hidden for email (Code dropdown). document.querySelectorAll('.md-toolbar-email-hide').forEach(el => { el.style.display = ''; }); + // Re-hide email-only toolbar items (AI reply button). + document.querySelectorAll('.md-toolbar-email-only').forEach(el => { el.style.display = 'none'; }); // Restore the generic documents action bar + its bottom footer (Close / // Copy / Export) for non-email docs. const docActions = document.getElementById('doc-editor-actions'); @@ -3206,7 +3210,95 @@ import * as Modals from './modalManager.js'; renderTabs(); } - async function _aiReply() { + // Fast/Full + optional context popover for the doc-editor email Reply button. + // Mirrors the email reader's AI reply choice popover so the UX is identical: + // textarea for an optional steering note, then Fast (lightning) or Full + // (concentric dot) buttons; both feed into _aiReply with the chosen mode. + let _docAiReplyChoiceMenu = null; + function _closeDocAiReplyChoice() { + if (_docAiReplyChoiceMenu) { + try { _docAiReplyChoiceMenu.remove(); } catch (_) {} + _docAiReplyChoiceMenu = null; + } + } + function _showDocAiReplyChoice(btn) { + _closeDocAiReplyChoice(); + if (!btn) return; + const rect = btn.getBoundingClientRect(); + const menu = document.createElement('div'); + menu.className = 'doc-ai-reply-choice'; + const menuMaxW = Math.min(240, window.innerWidth - 16); + const left = Math.max(8, Math.min(rect.left, window.innerWidth - menuMaxW - 8)); + const estHeight = 150; + const spaceBelow = window.innerHeight - rect.bottom - 8; + const spaceAbove = rect.top - 8; + const top = (spaceBelow >= estHeight || spaceBelow >= spaceAbove) + ? Math.max(8, Math.min(rect.bottom + 6, window.innerHeight - estHeight - 8)) + : Math.max(8, rect.top - estHeight - 6); + menu.style.cssText = [ + 'position:fixed', + `left:${left}px`, + `top:${top}px`, + `max-width:${menuMaxW}px`, + 'box-sizing:border-box', + 'z-index:10060', + 'display:flex', + 'gap:6px', + 'padding:6px', + 'background:var(--bg,#111)', + 'border:1px solid var(--border,#333)', + 'border-radius:7px', + 'box-shadow:0 8px 24px rgba(0,0,0,.28)', + ].join(';'); + menu.innerHTML = ` +
+ +
+ + +
+
+ `; + const noteInput = menu.querySelector('[data-note-input]'); + setTimeout(() => noteInput?.focus(), 0); + menu.addEventListener('mousedown', (ev) => ev.stopPropagation()); + menu.addEventListener('click', async (ev) => { + const choice = ev.target.closest('[data-mode]'); + if (!choice) return; + ev.preventDefault(); + ev.stopPropagation(); + const mode = choice.getAttribute('data-mode') || 'ai-reply-fast'; + const noteHint = (noteInput?.value || '').trim(); + _closeDocAiReplyChoice(); + await _aiReply({ mode, noteHint }); + }); + document.body.appendChild(menu); + _docAiReplyChoiceMenu = menu; + const outsideClose = (ev) => { + if (menu.contains(ev.target)) return; + document.removeEventListener('click', outsideClose, true); + _closeDocAiReplyChoice(); + }; + setTimeout(() => document.addEventListener('click', outsideClose, true), 0); + // Esc to close. + const escClose = (ev) => { + if (ev.key === 'Escape') { + ev.stopPropagation(); + document.removeEventListener('keydown', escClose, true); + _closeDocAiReplyChoice(); + } + }; + document.addEventListener('keydown', escClose, true); + } + + async function _aiReply(opts = {}) { + const { mode = 'auto', noteHint = '' } = (opts || {}); const to = document.getElementById('doc-email-to')?.value?.trim() || ''; const subject = document.getElementById('doc-email-subject')?.value?.trim() || ''; const textarea = document.getElementById('doc-editor-textarea'); @@ -3251,32 +3343,43 @@ import * as Modals from './modalManager.js'; if (btn) { btn.disabled = true; btn.innerHTML = 'Drafting...'; } try { + // Empty-compose path: if there's no original body, send a placeholder + // so the backend's "no body" guard doesn't fail. The user_hint carries + // the user's compose intent; the model uses To/Subject + that hint. + const bodyForApi = currentBody || (noteHint ? '(no prior email — compose a new message based on the To, Subject, and user instructions)' : currentBody); + const fastFlag = mode === 'ai-reply-fast' ? true + : mode === 'ai-reply-full' ? false + : shouldUseFastAiReply(); const res = await fetch(`${API_BASE}/api/email/ai-reply`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ to: to, subject: subject, - original_body: currentBody, + original_body: bodyForApi, model: currentModel, session_id: currentSessionId, message_id: inReplyTo, uid: sourceUid, folder: sourceFolder, - fast: shouldUseFastAiReply(), + fast: fastFlag, + user_hint: noteHint || '', }), }); const data = await res.json(); if (data.success && data.reply) { - const cleanReply = cleanAiReplyText(data.reply); - const lines = currentBody.split('\n'); - const quoteIdx = lines.findIndex(l => l.startsWith('On ') && l.includes(' wrote:')); - let newBody = ''; - if (quoteIdx > 0) { - newBody = cleanReply + '\n\n' + lines.slice(quoteIdx).join('\n'); - } else { - newBody = cleanReply + (currentBody ? '\n\n' + currentBody : ''); - } + let cleanReply = cleanAiReplyText(data.reply); + // Strip any "On , wrote:" attribution + everything + // after it from the AI's output — the model sometimes re-quotes + // the original thread, and we already have the real quote in + // currentBody. Without this, AI's invented quote stacked on top + // of the real one and looked like the history had been "edited". + cleanReply = cleanReply.replace(/\n*On\b[\s\S]*?\bwrote:[\s\S]*$/m, '').trim(); + // Never overwrite the existing draft (user's typed text + the + // quoted history below it). Always prepend the AI suggestion so + // the user can read it, copy parts, or delete it — but their + // own work and the original quote are untouched. + const newBody = currentBody ? cleanReply + '\n\n' + currentBody : cleanReply; await _streamEmailBodyText(textarea, newBody); if (uiModule) uiModule.showToast(`AI draft inserted (${data.model_used || 'AI'})`); } else { @@ -3285,7 +3388,7 @@ import * as Modals from './modalManager.js'; } catch (e) { if (uiModule) uiModule.showError('Failed to generate AI reply'); } finally { - if (btn) { btn.disabled = false; btn.innerHTML = 'AI Reply'; } + if (btn) { btn.disabled = false; btn.innerHTML = 'Reply'; } } } @@ -3813,7 +3916,6 @@ import * as Modals from './modalManager.js';