From 2cbd55b8bd077d3238406e5d91508a426dd1da97 Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Mon, 15 Jun 2026 20:47:51 +0900 Subject: [PATCH] Open email context for agent, email search across All Mail, cookbook serve polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Agent: pass the open email reader (uid/folder/account/from/subject/body preview) on every chat submit so 'reply to this' / 'write email saying hi' route to ui_control open_email_reply with the right UID instead of inventing a new .md draft. Code-level enforcement (chat_routes strips create_document + send_email when active_email is set); cross-session active_doc_id is now trusted instead of being silently dropped. set_active_email/clear_active_email tool-layer helpers in tool_implementations. - ui_control open_email_reply: optional body argument so the agent can open-and-write in one call; envelope now forwards uid/folder/account/ body/panel through tool_output. Tool description sharpened and the parser rejects empty bodies on reply/reply-all (forces the agent to write rather than open an empty draft). - Email library: search now runs against [Gmail]/All Mail when the current folder is INBOX (archived emails surface). Whirlpool spinner + 'Searching…' placeholder while in flight. Each search result is stamped with its source folder so clicks open the right email instead of whatever shares its UID in INBOX. Search no longer re-applies the same text pill locally (which only checks subject/from/snippet, never body) so body-only matches don't get dropped after IMAP returns them. Initial inbox load bumped 100→500. - Email favorites: 'Favorite (pin to top)' / 'Unfavorite' in both the card menu and the open-reader more menu, backed by a new /api/email/flag/{uid}?on=true|false endpoint. Flagged emails always bubble to the top of the grid regardless of active sort. - AI reply in doc editor: never overwrites existing draft text or the quoted history. AI suggestion is prepended; AI-generated 'On … wrote:' re-quotes are stripped so the original quote isn't visually edited. - Cookbook serve: pre-launch GPU driver / has_gpu / install / version- floor checks (vllm minimax_m2 needs 0.10.0+, deepseek_r1 needs 0.7.0 etc.) before the launch chain starts. Detect 'another model already running on this host' and offer Stop & launch (with graceful then force tmux kill helpers, port release wait). Per-vendor deep-link buttons (vLLM recipe / SGLang cookbook) with hardware hash. Backend picker is now a custom dropdown with accent-coloured logos for vLLM, SGLang, llama.cpp, Ollama, Diffusers; same glyphs added next to package names in Dependencies. Runtime-readiness note moved inside the panel (green when ready, red when missing) with an × dismiss. Esc collapses the expanded card; expanded card scrolls when it overflows; Trust Remote / Auto Tool / Reasoning Parser / Enforce Eager / Prefix Caching / Expert Parallel / Speculative / MoE Env on one row (Reasoning Parser auto-detected per model family). Dtype→Row 1, GPUs→Row 2 (rightmost). Removed redundant GPU 'auto' input — command builders read from the GPU button strip. Default cookbook open is Download tab. - Cookbook hwfit: 'Model (latest)' / 'Model (oldest)' header sorts by release_date; release dates can be backfilled with the new scripts/backfill_model_release_dates.py and recipe metadata pulled with scripts/import_from_vllm_recipes.py against the upstream vllm-project/recipes catalog (vllm_recipe + min_vllm_version stamped on entries). - Calendar: Quick add hint cycles a random Odysseus-themed example per open (wooden horse Friday, crew muster 10am daily, council on Ithaca, …). Typing a time like '11pm' in the event title updates the hero clock live. - Doc editor: email-mode Reply button (sparkle icon, accent) opens the same Fast/Full + context popover the email reader uses; Ctrl+Alt+M toggles markdown preview. - Memories panel: custom sort picker with per-option icons, default 'Latest', visible Enabled/Disabled toggle text matching the section description style. --- routes/chat_routes.py | 106 +++++++- routes/email_routes.py | 54 +++- routes/hwfit_routes.py | 2 +- scripts/backfill_model_release_dates.py | 133 +++++++++ scripts/import_from_vllm_recipes.py | 341 ++++++++++++++++++++++++ src/agent_loop.py | 80 +++++- src/ai_interaction.py | 49 +++- src/tool_implementations.py | 31 +++ src/tool_index.py | 2 +- static/js/calendar.js | 48 +++- static/js/chat.js | 13 + static/js/chatStream.js | 2 +- static/js/cookbook.js | 18 +- static/js/cookbookRunning.js | 64 ++++- static/js/document.js | 159 +++++++++-- static/js/emailInbox.js | 11 +- static/js/emailLibrary.js | 284 +++++++++++++++++++- static/js/memory.js | 86 +++++- 18 files changed, 1417 insertions(+), 66 deletions(-) create mode 100755 scripts/backfill_model_release_dates.py create mode 100755 scripts/import_from_vllm_recipes.py 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';