Skip to content

Commit 2cbd55b

Browse files
Open email context for agent, email search across All Mail, cookbook serve polish
- 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.
1 parent 1fcec32 commit 2cbd55b

18 files changed

Lines changed: 1417 additions & 66 deletions

routes/chat_routes.py

Lines changed: 97 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import time
77
import logging
88
from datetime import datetime
9-
from typing import Dict, Any, AsyncGenerator, List
9+
from typing import Dict, Any, AsyncGenerator, List, Optional
1010

1111
from fastapi import APIRouter, Request, HTTPException, Form, Query
1212
from fastapi.responses import StreamingResponse
@@ -492,6 +492,66 @@ async def chat_stream(request: Request) -> StreamingResponse:
492492
active_doc_id = form_data.get("active_doc_id", "").strip()
493493
logger.info(f"[doc-inject] chat_mode={chat_mode}, active_doc_id={active_doc_id!r}")
494494

495+
# Active email reader — when the user has an email open in the UI, the
496+
# frontend passes its uid/folder/account so "reply", "summarize this",
497+
# etc. resolve to the real email instead of the agent inventing a
498+
# fake markdown draft.
499+
active_email_uid = form_data.get("active_email_uid", "").strip()
500+
active_email_folder = form_data.get("active_email_folder", "INBOX").strip() or "INBOX"
501+
active_email_account = form_data.get("active_email_account", "").strip()
502+
active_email_ctx: Optional[Dict[str, str]] = None
503+
# Always reset between requests so a stale active-email pointer from
504+
# a previous turn (different reader closed, different account, etc.)
505+
# can't leak in when the user has no email open this turn.
506+
try:
507+
from src.tool_implementations import clear_active_email
508+
clear_active_email()
509+
except Exception:
510+
pass
511+
if active_email_uid:
512+
active_email_ctx = {
513+
"uid": active_email_uid,
514+
"folder": active_email_folder,
515+
"account": active_email_account,
516+
}
517+
# Try to enrich with subject + from so the agent's system prompt
518+
# block can quote them. Best-effort: a stale cache is fine, a
519+
# missing email just means we pass uid/folder/account only.
520+
try:
521+
from routes.email_routes import _read_cache_get, _read_cache_key
522+
_ck = _read_cache_key(active_email_account or None, active_email_folder, active_email_uid, owner=get_current_user(request))
523+
_cached_email = _read_cache_get(_ck)
524+
if _cached_email and isinstance(_cached_email, dict):
525+
active_email_ctx["subject"] = str(_cached_email.get("subject") or "")
526+
active_email_ctx["from"] = str(
527+
_cached_email.get("from_address")
528+
or _cached_email.get("from")
529+
or _cached_email.get("from_name")
530+
or ""
531+
)
532+
_body_preview = (_cached_email.get("body") or "")[:2000]
533+
if _body_preview:
534+
active_email_ctx["body_preview"] = _body_preview
535+
except Exception as _e:
536+
logger.debug(f"[email-inject] cache enrich skipped: {_e}")
537+
# Stash so email tools can resolve "this email" without UID guessing.
538+
try:
539+
from src.tool_implementations import set_active_email
540+
set_active_email(
541+
uid=active_email_uid,
542+
folder=active_email_folder,
543+
account=active_email_account or None,
544+
subject=active_email_ctx.get("subject"),
545+
sender=active_email_ctx.get("from"),
546+
)
547+
except Exception as _e:
548+
logger.debug(f"[email-inject] set_active_email failed: {_e}")
549+
logger.info(
550+
"[email-inject] active_email uid=%s folder=%s account=%s subject=%r",
551+
active_email_uid, active_email_folder, active_email_account or "(default)",
552+
active_email_ctx.get("subject", ""),
553+
)
554+
495555
try:
496556
# Attachment-only sends: skip the message-required check when the
497557
# user has attached one or more files (the attachment IS the action).
@@ -607,15 +667,27 @@ async def chat_stream(request: Request) -> StreamingResponse:
607667
active_doc_id,
608668
)
609669
active_doc = None
610-
elif doc_session and doc_session != session:
611-
logger.warning(
612-
"[doc-inject] ignoring stale active_doc_id %s from session %s while in session %s",
613-
active_doc_id,
614-
doc_session,
615-
session,
616-
)
617-
active_doc = None
618670
else:
671+
# NOTE: previously dropped the doc when doc.session_id
672+
# != current chat session — but that broke the common
673+
# case of "open an email draft from one chat, ask a
674+
# different chat to write into it". The frontend only
675+
# sends active_doc_id for docs currently visible in
676+
# the UI, and we already owner-checked above, so trust
677+
# the explicit signal. We just log the mismatch and
678+
# re-bind the doc to the current session so future
679+
# turns find it via the session-fallback path too.
680+
if doc_session and doc_session != session:
681+
logger.info(
682+
"[doc-inject] cross-session active_doc_id %s (was session %s, now %s) — accepting and rebinding",
683+
active_doc_id, doc_session, session,
684+
)
685+
try:
686+
active_doc.session_id = session
687+
_doc_db.commit()
688+
except Exception as _e:
689+
_doc_db.rollback()
690+
logger.warning(f"[doc-inject] session rebind failed: {_e}")
619691
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 '')}")
620692
else:
621693
logger.warning(f"[doc-inject] NOT FOUND by ID {active_doc_id}")
@@ -671,6 +743,21 @@ async def chat_stream(request: Request) -> StreamingResponse:
671743
"manage_skills", # skill presets tied to user
672744
})
673745

746+
# Active email reader open → strip the tools that let the agent
747+
# "drift" to a new compose: create_document (writes a fake email-
748+
# shaped .md file) and send_email (sends fresh to a recipient the
749+
# agent invented). With those gone, the only paths left for "write
750+
# email saying X" are ui_control open_email_reply (draft) and
751+
# reply_to_email (immediate send) — both of which use the open
752+
# email's UID. Code-level enforcement instead of relying on a
753+
# prompt rule the model can ignore.
754+
if active_email_ctx and active_email_ctx.get("uid"):
755+
disabled_tools.update({
756+
"create_document",
757+
"send_email",
758+
"mcp__email__send_email",
759+
})
760+
674761
# Enforce per-user privileges
675762
_privs = {}
676763
_user = ctx.user
@@ -1130,6 +1217,7 @@ def _on_research_done(_sid, _result, _sources, _findings):
11301217
max_rounds=_max_rounds,
11311218
context_length=ctx.context_length,
11321219
active_document=active_doc,
1220+
active_email=active_email_ctx,
11331221
session_id=session,
11341222
disabled_tools=disabled_tools if disabled_tools else None,
11351223
tool_policy=tool_policy,

routes/email_routes.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,23 +1068,48 @@ async def search_emails(
10681068
account_id: str | None = Query(None),
10691069
owner: str = Depends(require_owner),
10701070
):
1071-
"""Search emails server-side via IMAP SEARCH. Matches subject, from, or body text."""
1071+
"""Search emails server-side via IMAP SEARCH. Matches subject, from, or body text.
1072+
1073+
When the caller asks for INBOX and the account has an "All Mail"
1074+
folder (Gmail does), we transparently swap to All Mail so the
1075+
search surfaces archived / labelled emails too. Plain IMAP
1076+
accounts fall back to whatever folder the caller specified."""
10721077
if not q or len(q) < 2:
10731078
return {"emails": [], "total": 0, "query": q}
10741079
# CRLF in q would terminate the IMAP command early — reject defensively.
10751080
if "\r" in q or "\n" in q:
10761081
raise HTTPException(400, "Invalid query")
10771082
try:
10781083
with _imap(account_id, owner=owner) as conn:
1079-
conn.select(_q(folder), readonly=True)
1084+
# If the user asked for INBOX, try to upgrade to All Mail —
1085+
# one folder == every email on Gmail-class servers.
1086+
effective_folder = folder
1087+
if (folder or "").upper() == "INBOX":
1088+
try:
1089+
status, folder_lines = conn.list()
1090+
if status == "OK" and folder_lines:
1091+
for raw in folder_lines:
1092+
if isinstance(raw, bytes):
1093+
raw = raw.decode("utf-8", errors="replace")
1094+
m = re.match(r"\((?P<flags>[^)]*)\)\s+\"[^\"]*\"\s+(?P<name>.+)", raw)
1095+
if not m:
1096+
continue
1097+
flags = (m.group("flags") or "").lower()
1098+
name = m.group("name").strip().strip('"')
1099+
if "\\all" in flags or "all mail" in name.lower():
1100+
effective_folder = name
1101+
break
1102+
except Exception:
1103+
pass
1104+
conn.select(_q(effective_folder), readonly=True)
10801105

10811106
# Escape backslash and quote for the IMAP-SEARCH quoted-string.
10821107
q_escaped = q.replace('\\', '\\\\').replace('"', '\\"')
10831108
search_cmd = f'(OR OR FROM "{q_escaped}" SUBJECT "{q_escaped}" TEXT "{q_escaped}")'
10841109

10851110
status, data = _imap_uid_search(conn, search_cmd)
10861111
if status != "OK" or not data[0]:
1087-
return {"emails": [], "total": 0, "query": q}
1112+
return {"emails": [], "total": 0, "query": q, "folder": effective_folder}
10881113

10891114
uid_list = data[0].split()
10901115
total = len(uid_list)
@@ -1148,6 +1173,13 @@ async def search_emails(
11481173
"is_flagged": "\\Flagged" in flags,
11491174
"flags": flags,
11501175
"has_attachments": has_attachments,
1176+
# Stamp the folder so the frontend opens each
1177+
# email from the folder it actually lives in
1178+
# (the search may have run against All Mail
1179+
# even though the caller asked for INBOX),
1180+
# otherwise clicks open whatever happens to
1181+
# have the same UID in INBOX → wrong email.
1182+
"folder": effective_folder,
11511183
})
11521184
except Exception as e:
11531185
logger.warning(f"Error parsing search result {uid}: {e}")
@@ -1693,6 +1725,22 @@ async def mark_unread(uid: str, folder: str = Query("INBOX"), account_id: str |
16931725
logger.error(f"Failed to mark unread {uid}: {e}")
16941726
return {"success": False, "error": "Mail operation failed"}
16951727

1728+
@router.post("/flag/{uid}")
1729+
async def flag_email(uid: str, folder: str = Query("INBOX"), account_id: str | None = Query(None),
1730+
on: bool = Query(True), owner: str = Depends(require_owner)):
1731+
"""Toggle the \\Flagged flag (a.k.a. favorite / star) on an email.
1732+
Pass `on=true` to favorite, `on=false` to unfavorite."""
1733+
try:
1734+
with _imap(account_id, owner=owner) as conn:
1735+
conn.select(_q(folder))
1736+
if not _store_email_flag(conn, uid, "\\Flagged", add=bool(on)):
1737+
return {"success": False, "error": "Email not found"}
1738+
_invalidate_list_cache(account_id, folder)
1739+
return {"success": True, "flagged": bool(on)}
1740+
except Exception as e:
1741+
logger.error(f"Failed to flag {uid}: {e}")
1742+
return {"success": False, "error": "Mail operation failed"}
1743+
16961744
@router.post("/mark-read/{uid}")
16971745
async def mark_read(uid: str, folder: str = Query("INBOX"), account_id: str | None = Query(None), owner: str = Depends(require_owner)):
16981746
"""Mark an email as read (set \\Seen flag)."""

routes/hwfit_routes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ def get_system(host: str = "", ssh_port: str = "", platform: str = "", fresh: bo
108108
return detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh)
109109

110110
@router.get("/models")
111-
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):
111+
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):
112112
"""Rank LLM models against detected hardware and return scored results.
113113
gpu_count: override GPU count (0 = CPU only, 1-N = simulate N GPUs of the
114114
active group). gpu_group: index into system.gpu_groups (the homogeneous
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
#!/usr/bin/env python3
2+
"""Backfill release_date on entries in services/hwfit/data/hf_models.json.
3+
4+
Why: the `newest` sort in the cookbook ranks rows by release_date. Anything
5+
missing a date sorts to the bottom. This script pulls `created_at` from the
6+
HuggingFace API for each catalog entry without one (or all entries when
7+
--refresh is passed) and writes the catalog back.
8+
9+
Usage:
10+
python scripts/backfill_model_release_dates.py # missing only
11+
python scripts/backfill_model_release_dates.py --refresh # all entries
12+
python scripts/backfill_model_release_dates.py --limit 50 # cap requests
13+
python scripts/backfill_model_release_dates.py --dry-run # show, don't write
14+
15+
Auth: set HF_TOKEN env var (or huggingface-cli login) to access gated repos.
16+
"""
17+
import argparse
18+
import json
19+
import os
20+
import sys
21+
import time
22+
from datetime import datetime
23+
from pathlib import Path
24+
25+
try:
26+
from huggingface_hub import HfApi
27+
from huggingface_hub.utils import HfHubHTTPError
28+
except ImportError:
29+
print("Install huggingface_hub: pip install huggingface_hub", file=sys.stderr)
30+
sys.exit(1)
31+
32+
33+
CATALOG_PATH = Path(__file__).resolve().parent.parent / "services" / "hwfit" / "data" / "hf_models.json"
34+
35+
36+
def fetch_release_date(api: HfApi, repo_id: str) -> str | None:
37+
"""Return YYYY-MM-DD release date, or None on miss / error."""
38+
try:
39+
info = api.model_info(repo_id, files_metadata=False)
40+
except HfHubHTTPError as e:
41+
# 401 = gated/private, 404 = renamed/deleted. Either way, no date.
42+
status = getattr(getattr(e, "response", None), "status_code", None)
43+
print(f" {repo_id}: HTTP {status or '?'}", file=sys.stderr)
44+
return None
45+
except Exception as e:
46+
print(f" {repo_id}: {type(e).__name__}: {e}", file=sys.stderr)
47+
return None
48+
created = getattr(info, "created_at", None)
49+
if not created:
50+
return None
51+
return created.strftime("%Y-%m-%d")
52+
53+
54+
def main():
55+
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
56+
p.add_argument("--refresh", action="store_true", help="Overwrite existing release_date too (default: only fill missing).")
57+
p.add_argument("--limit", type=int, default=0, help="Stop after N API calls (0 = no limit).")
58+
p.add_argument("--dry-run", action="store_true", help="Don't write back; just report.")
59+
p.add_argument("--sleep", type=float, default=0.05, help="Seconds to sleep between requests (default 0.05).")
60+
args = p.parse_args()
61+
62+
if not CATALOG_PATH.exists():
63+
print(f"Catalog not found: {CATALOG_PATH}", file=sys.stderr)
64+
sys.exit(2)
65+
66+
with CATALOG_PATH.open(encoding="utf-8") as f:
67+
catalog = json.load(f)
68+
69+
candidates = []
70+
for i, m in enumerate(catalog):
71+
name = m.get("name")
72+
if not name:
73+
continue
74+
existing = (m.get("release_date") or "").strip()
75+
if existing and not args.refresh:
76+
continue
77+
candidates.append(i)
78+
79+
if args.limit:
80+
candidates = candidates[: args.limit]
81+
82+
print(f"Catalog: {CATALOG_PATH}")
83+
print(f"Total entries: {len(catalog)}")
84+
print(f"Targets ({'refresh all' if args.refresh else 'missing only'}{'' if not args.limit else f', capped at {args.limit}'}): {len(candidates)}")
85+
if not candidates:
86+
print("Nothing to do.")
87+
return
88+
89+
api = HfApi(token=os.environ.get("HF_TOKEN") or None)
90+
updated = 0
91+
skipped = 0
92+
started = time.time()
93+
for n, idx in enumerate(candidates, start=1):
94+
entry = catalog[idx]
95+
name = entry["name"]
96+
old = (entry.get("release_date") or "").strip()
97+
new = fetch_release_date(api, name)
98+
if new is None:
99+
skipped += 1
100+
tag = "skip"
101+
elif new == old:
102+
tag = "unchanged"
103+
else:
104+
entry["release_date"] = new
105+
updated += 1
106+
tag = f"set {new}" + (f" (was {old})" if old else "")
107+
print(f"[{n}/{len(candidates)}] {name}{tag}")
108+
if args.sleep:
109+
time.sleep(args.sleep)
110+
111+
elapsed = time.time() - started
112+
print()
113+
print(f"Done in {elapsed:.1f}s — {updated} updated, {skipped} skipped (HF unavailable / gated / missing date).")
114+
115+
if args.dry_run:
116+
print("Dry run — no write.")
117+
return
118+
119+
if updated:
120+
# Atomic write: tmp file in the same dir, then rename. Keeps the
121+
# catalog usable even if the process dies mid-write.
122+
tmp = CATALOG_PATH.with_suffix(".json.tmp")
123+
with tmp.open("w", encoding="utf-8") as f:
124+
json.dump(catalog, f, indent=1, ensure_ascii=False)
125+
f.write("\n")
126+
tmp.replace(CATALOG_PATH)
127+
print(f"Wrote {CATALOG_PATH}")
128+
else:
129+
print("No changes to write.")
130+
131+
132+
if __name__ == "__main__":
133+
main()

0 commit comments

Comments
 (0)