mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-15 09:15:29 -04:00
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.
This commit is contained in:
+97
-9
@@ -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,
|
||||
|
||||
+51
-3
@@ -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<flags>[^)]*)\)\s+\"[^\"]*\"\s+(?P<name>.+)", 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)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+133
@@ -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()
|
||||
Executable
+341
@@ -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()
|
||||
+79
-1
@@ -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 <addr>'\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
|
||||
|
||||
+41
-8
@@ -1287,7 +1287,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
set_theme <preset> — Apply a built-in theme preset (dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute)
|
||||
create_theme <name> <bg> <fg> <panel> <border> <accent> [key=val ...] — Create custom theme. Optional key=val: advanced color overrides AND background effects: bgPattern=<none|dots|synapse|rain|constellations|perlin-flow|petals|sparkles|embers>, bgEffectColor=#RRGGBB, bgEffectIntensity=<num>, bgEffectSize=<num>, frosted=true|false
|
||||
open_panel <name> — Open a panel (documents, gallery, email, sessions, notes, memories, skills, settings, cookbook)
|
||||
open_email_reply <uid> [folder] [reply|reply-all|ai-reply] — Open a reply draft document for an email; does not send
|
||||
open_email_reply <uid> [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 <uid> [folder] [reply|reply-all|ai-reply]
|
||||
# open_email_reply <uid> [folder] [reply|reply-all|ai-reply]
|
||||
# <body text on subsequent lines or after the mode token>
|
||||
# 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 <uid> [folder] [reply|reply-all|ai-reply]"}
|
||||
return {"error": "open_email_reply needs: open_email_reply <uid> [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} <your reply text here>`. "
|
||||
"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 {
|
||||
|
||||
@@ -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]):
|
||||
|
||||
+1
-1
@@ -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 <name>`. Use `open_email_reply <uid> <folder> 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 <name>`. Use `open_email_reply <uid> <folder> 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 <uid> <folder> reply <body text>`. 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.",
|
||||
|
||||
+47
-1
@@ -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"
|
||||
/>
|
||||
<span class="cal-quickadd-hint" id="cal-quickadd-hint" aria-hidden="true"><span class="qa-hint-accent">Quick add</span> — return home to Ithaca 1pm tmrw <svg class="qa-hint-enter" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg></span>
|
||||
<span class="cal-quickadd-hint" id="cal-quickadd-hint" aria-hidden="true"><span class="qa-hint-accent">Quick add</span> — <span class="qa-hint-example" id="qa-hint-example">return home to Ithaca 1pm tmrw</span> <svg class="qa-hint-enter" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg></span>
|
||||
<span class="cal-quickadd-status" id="cal-quickadd-status"></span>
|
||||
</div>`;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
+17
-1
@@ -792,6 +792,22 @@ async function _fetchDependencies() {
|
||||
return `<button class="cookbook-dep-tag cookbook-dep-install" data-dep-pip="${esc(pkg.pip)}" data-dep-target="${isLocal ? 'local' : 'remote'}">Install</button>`;
|
||||
};
|
||||
|
||||
// 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: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>',
|
||||
sglang: '<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
|
||||
llama_cpp: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M8 12h8M12 8v8"/></svg>',
|
||||
ollama: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 10a6 6 0 0 1 12 0v4a4 4 0 0 1-8 0v-1"/><circle cx="10" cy="9" r="1"/><circle cx="14" cy="9" r="1"/></svg>',
|
||||
diffusers: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
|
||||
};
|
||||
const _depGlyphHtml = (name) => {
|
||||
const g = _DEP_GLYPHS[name];
|
||||
return g ? `<span class="cookbook-dep-glyph" aria-hidden="true" style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;color:var(--accent, var(--red));margin-right:5px;vertical-align:-2px;">${g}</span>` : '';
|
||||
};
|
||||
|
||||
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 `<div class="cookbook-dep-row${winBlocked ? ' cookbook-dep-blocked' : ''}" data-pkg-name="${esc(pkg.name)}" data-dep-pip="${esc(pkg.pip || '')}" data-dep-target="${isLocal ? 'local' : 'remote'}" data-dep-kind="${esc(pkg.kind || 'python')}">`
|
||||
+ `<div class="cookbook-dep-info">`
|
||||
+ `<div class="memory-item-title">${esc(pkg.name)}</div>`
|
||||
+ `<div class="memory-item-title">${_depGlyphHtml(pkg.name)}${esc(pkg.name)}</div>`
|
||||
+ `<div class="memory-item-meta" style="font-size:10px;opacity:0.5;margin-top:2px;">${esc(pkg.desc)}</div>`
|
||||
+ note
|
||||
+ updateNote
|
||||
|
||||
@@ -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 = '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">' +
|
||||
// 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 = '<div class="admin-card" style="display:flex;flex-direction:column;">' +
|
||||
'<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">' +
|
||||
'<h2 style="margin:0;padding:0;line-height:1;">Active <span id="running-count" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal">' + activeCount + '</span></h2>' +
|
||||
'</div>' +
|
||||
@@ -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)
|
||||
|
||||
+142
-17
@@ -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 = `
|
||||
<div style="display:flex;flex-direction:column;gap:6px;min-width:200px;">
|
||||
<textarea data-note-input rows="2" placeholder="Add context (optional)" style="width:100%;box-sizing:border-box;resize:vertical;min-height:42px;font-family:inherit;font-size:11px;padding:5px 6px;border-radius:5px;border:1px solid var(--border,#333);background:var(--bg-elev,#1a1a1a);color:var(--fg);"></textarea>
|
||||
<div style="display:flex;align-items:center;gap:4px;">
|
||||
<button class="memory-toolbar-btn" data-mode="ai-reply-fast" title="Shorter, faster draft" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="var(--accent, var(--red))" aria-hidden="true"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
Fast
|
||||
</button>
|
||||
<button class="memory-toolbar-btn" data-mode="ai-reply-full" title="Fuller reply with more context" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="color:var(--accent, var(--red));"><circle cx="12" cy="12" r="6"/></svg>
|
||||
Full
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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 = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>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 <date>, <name> 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 = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>AI Reply'; }
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="color:var(--accent, var(--red));flex-shrink:0;position:relative;top:-1px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg><span style="font-size:11px;margin-left:4px;">Reply</span>'; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3813,7 +3916,6 @@ import * as Modals from './modalManager.js';
|
||||
<button id="doc-export-pdf-btn" class="doc-action-icon-btn" title="Export PDF" style="display:none;opacity:0.7;gap:4px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><polyline points="9 15 12 18 15 15"/></svg> <span style="font-size:11px;">Export PDF</span></button>
|
||||
<button id="doc-pdf-view-btn" class="doc-action-icon-btn" title="Toggle PDF view" style="display:none;opacity:0.7;gap:4px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> <span style="font-size:11px;">PDF</span></button>
|
||||
<select id="doc-language-select" class="doc-language-select">
|
||||
<option value="">type</option>
|
||||
<option value="python">python</option>
|
||||
<option value="javascript">javascript</option>
|
||||
<option value="typescript">typescript</option>
|
||||
@@ -3881,13 +3983,14 @@ import * as Modals from './modalManager.js';
|
||||
<div class="doc-md-toolbar" id="doc-md-toolbar" style="display:none">
|
||||
<div class="md-toolbar-items" id="md-toolbar-items">
|
||||
<span class="md-view-toggle" id="doc-md-view-toggle" style="display:none" role="group" aria-label="Edit or preview">
|
||||
<button type="button" class="md-view-opt" data-mdview="edit" title="Edit source"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
|
||||
<button type="button" class="md-view-opt" data-mdview="preview" title="Preview"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button type="button" class="md-view-opt" data-mdview="edit" title="Edit source (Ctrl+Alt+M to toggle)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
|
||||
<button type="button" class="md-view-opt" data-mdview="preview" title="Preview (Ctrl+Alt+M to toggle)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
</span>
|
||||
<span class="md-view-toggle" id="doc-render-view-toggle" style="display:none" role="group" aria-label="Code or run">
|
||||
<button type="button" class="md-view-opt" data-renderview="code" title="Edit code"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></button>
|
||||
<button type="button" class="md-view-opt" data-renderview="run" title="Run / Preview"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
|
||||
</span>
|
||||
<button id="doc-email-ai-reply-btn" class="doc-action-icon-btn md-toolbar-email-only" type="button" title="Draft a reply with AI (Fast / Full + optional context)" style="display:none;align-items:center;gap:4px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="color:var(--accent, var(--red));flex-shrink:0;position:relative;top:-1px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg><span style="font-size:11px;">Reply</span></button>
|
||||
<button id="doc-fontsize-btn" class="doc-action-icon-btn" title="Font size" style="position:relative;width:28px;height:26px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;"><path d="M4 7V4h16v3"/><path d="M12 4v16"/><path d="M8 20h8"/></svg><span class="doc-fontsize-levels"><i data-sz="s">S</i><i data-sz="m">M</i><i data-sz="l">L</i></span></button>
|
||||
<button id="doc-diff-toggle-btn" class="doc-action-icon-btn" title="Compare changes" style="opacity:0.7;display:none;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="M5 12H2l5-5 5 5H9"/><path d="M19 12h3l-5 5-5-5h3"/></svg></button>
|
||||
<span class="md-toolbar-sep"></span>
|
||||
@@ -4397,6 +4500,24 @@ import * as Modals from './modalManager.js';
|
||||
}
|
||||
});
|
||||
}
|
||||
// Ctrl+Alt+M (and Cmd+Opt+M on mac) flips Edit ↔ Preview on a markdown
|
||||
// doc. Bound once globally; gated on the doc panel being open and the
|
||||
// active doc being markdown so it doesn't fire while the user is typing
|
||||
// in a non-markdown context.
|
||||
if (!window._docMdToggleBound) {
|
||||
window._docMdToggleBound = true;
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.altKey && !e.shiftKey && (e.key === 'm' || e.key === 'M' || e.code === 'KeyM')) {
|
||||
if (!isOpen) return;
|
||||
const doc = activeDocId && docs.get(activeDocId);
|
||||
const lang = (doc?.language || 'markdown').toLowerCase();
|
||||
if (lang !== 'markdown') return;
|
||||
e.preventDefault();
|
||||
toggleMarkdownPreview();
|
||||
_syncHeaderActions();
|
||||
}
|
||||
});
|
||||
}
|
||||
document.getElementById('doc-email-draft-btn')?.addEventListener('click', () => {
|
||||
document.getElementById('doc-email-more-menu').style.display = 'none';
|
||||
_saveDraft();
|
||||
@@ -4411,7 +4532,11 @@ import * as Modals from './modalManager.js';
|
||||
document.getElementById('doc-email-more-menu').style.display = 'none';
|
||||
_scheduleSend(anchor);
|
||||
});
|
||||
document.getElementById('doc-email-ai-reply-btn')?.addEventListener('click', _aiReply);
|
||||
document.getElementById('doc-email-ai-reply-btn')?.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
_showDocAiReplyChoice(ev.currentTarget);
|
||||
});
|
||||
|
||||
const collapseBtn = document.getElementById('doc-email-collapse-btn');
|
||||
if (collapseBtn && !collapseBtn._emailCollapseWired) {
|
||||
|
||||
@@ -125,12 +125,12 @@ export function init(documentModule) {
|
||||
_watchDocOpenToReDockEmail();
|
||||
}
|
||||
|
||||
export async function openReplyDraft(uid, folder = 'INBOX', mode = 'reply') {
|
||||
export async function openReplyDraft(uid, folder = 'INBOX', mode = 'reply', prefilledBody = '') {
|
||||
if (!uid) return;
|
||||
const previousFolder = _currentFolder;
|
||||
_currentFolder = folder || 'INBOX';
|
||||
try {
|
||||
await _openEmail({ uid: String(uid), subject: '' }, null, null, mode || 'reply');
|
||||
await _openEmail({ uid: String(uid), subject: '' }, null, null, mode || 'reply', '', prefilledBody || '');
|
||||
} finally {
|
||||
_currentFolder = previousFolder || _currentFolder;
|
||||
}
|
||||
@@ -630,10 +630,13 @@ function _createEmailItem(em) {
|
||||
return item;
|
||||
}
|
||||
|
||||
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', noteHint = '') {
|
||||
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', noteHint = '', prefilledBody = '') {
|
||||
const aiReplyMode = mode === 'ai-reply-fast' ? 'fast' : (mode === 'ai-reply-full' ? 'full' : '');
|
||||
const wantsAiReply = mode === 'ai-reply' || !!aiReplyMode;
|
||||
let aiSuggestedBody = null;
|
||||
// Body pre-fill from the agent's open_email_reply tool call takes the
|
||||
// same insertion slot as an AI-suggested body — both land just before
|
||||
// the quoted-original block.
|
||||
let aiSuggestedBody = (typeof prefilledBody === 'string' && prefilledBody.trim()) ? prefilledBody.trim() : null;
|
||||
if (wantsAiReply) {
|
||||
// Fall through to reply-all (not plain reply) so the generated AI
|
||||
// draft addresses everyone on the original thread. On single-
|
||||
|
||||
+272
-12
@@ -30,6 +30,7 @@ let _libLoadSeq = 0;
|
||||
let _libFolderSeq = 0;
|
||||
let _libSearchSeq = 0;
|
||||
let _libSearchHadResults = false;
|
||||
let _libSearchInFlight = false;
|
||||
let _activeEmailReaderForSelectAll = null;
|
||||
|
||||
function _isEmailTypingTarget(t) {
|
||||
@@ -62,6 +63,52 @@ function _markEmailReaderActive(reader) {
|
||||
reader.addEventListener('focusin', () => { _activeEmailReaderForSelectAll = reader; }, true);
|
||||
}
|
||||
|
||||
// Stash the email identity (uid + folder + account) on the reader element
|
||||
// so chat submits and other code paths can ask "what email is the user
|
||||
// currently looking at?" without re-deriving from the DOM hierarchy.
|
||||
function _stampReaderContext(reader, em, folder, account) {
|
||||
if (!reader || !em) return;
|
||||
reader.dataset.emailUid = String(em.uid || '');
|
||||
reader.dataset.emailFolder = String(folder || state._libFolder || 'INBOX');
|
||||
reader.dataset.emailAccount = String(account || state._libAccountId || '');
|
||||
if (em.subject) reader.dataset.emailSubject = String(em.subject);
|
||||
if (em.from_address || em.from_name) {
|
||||
reader.dataset.emailFrom = String(em.from_address || em.from_name);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns { uid, folder, account, subject, from } for the email the user
|
||||
// is most likely referring to — the last reader they interacted with, then
|
||||
// any open reader-modal as a fallback. Returns null when no email reader
|
||||
// is open. Exported below for chat.js to read on submit.
|
||||
function _getActiveEmailContext() {
|
||||
const candidates = [];
|
||||
if (_activeEmailReaderForSelectAll && _activeEmailReaderForSelectAll.isConnected) {
|
||||
candidates.push(_activeEmailReaderForSelectAll);
|
||||
}
|
||||
// Visible reader-tab modals (popped-out windows).
|
||||
document.querySelectorAll('.modal[id^="email-reader-"]:not(.hidden):not(.modal-minimized) .email-card-reader').forEach(el => candidates.push(el));
|
||||
// Expanded inline reader in the library list.
|
||||
document.querySelectorAll('#email-lib-modal:not(.hidden) .doclib-card.email-card-expanded .email-card-reader').forEach(el => candidates.push(el));
|
||||
for (const r of candidates) {
|
||||
const uid = r?.dataset?.emailUid;
|
||||
if (uid) {
|
||||
return {
|
||||
uid,
|
||||
folder: r.dataset.emailFolder || 'INBOX',
|
||||
account: r.dataset.emailAccount || '',
|
||||
subject: r.dataset.emailSubject || '',
|
||||
from: r.dataset.emailFrom || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Frontend reads via the global so chat.js doesn't need a separate import
|
||||
// path (emailLibrary loads lazily in some entry points).
|
||||
try { window.__odysseusGetActiveEmailContext = _getActiveEmailContext; } catch (_) {}
|
||||
|
||||
const _COPY_EMAIL_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
||||
|
||||
function _decodeAttrValue(v) {
|
||||
@@ -710,7 +757,7 @@ async function _prewarmDefaultEmailView() {
|
||||
} catch (_) {}
|
||||
|
||||
const accountQS = accountId ? `&account_id=${encodeURIComponent(accountId)}` : '';
|
||||
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folder)}${accountQS}&limit=100&offset=0&filter=${filter}`, {
|
||||
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folder)}${accountQS}&limit=500&offset=0&filter=${filter}`, {
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!res.ok) return;
|
||||
@@ -1876,8 +1923,21 @@ function _applyPillFilter() {
|
||||
return;
|
||||
}
|
||||
const source = _libPreSearchEmails || state._libEmails || [];
|
||||
const draftPill = draft.length >= 1 ? { type: 'text', text: draft } : null;
|
||||
const effective = draftPill ? pills.concat([draftPill]) : pills;
|
||||
// If the active server search covers a piece of text (either the live
|
||||
// draft OR an Enter-committed text pill), skip the local re-filter for
|
||||
// it — _emailMatchesPill only checks subject/from_name/from_address/
|
||||
// snippet (no BODY), so it was dropping legitimate server hits where
|
||||
// the match was in body text. Real pills (contact, filter chips) still
|
||||
// apply, and other text pills with different strings still apply.
|
||||
const libSearchLower = (_libSearchHadResults ? (state._libSearch || '').trim().toLowerCase() : '');
|
||||
const serverHandledDraft = !!(libSearchLower && draft && libSearchLower === draft.toLowerCase());
|
||||
const draftPill = (!serverHandledDraft && draft.length >= 1) ? { type: 'text', text: draft } : null;
|
||||
// Filter out text pills whose text matches the active server search —
|
||||
// those were the trigger for the IMAP query and don't need re-checking.
|
||||
const effectiveBasePills = libSearchLower
|
||||
? pills.filter(p => !(p.type === 'text' && (p.text || '').toLowerCase() === libSearchLower))
|
||||
: pills;
|
||||
const effective = draftPill ? effectiveBasePills.concat([draftPill]) : effectiveBasePills;
|
||||
// AND across pills — "alice + bob" should mean both alice AND bob are
|
||||
// somewhere on the email (from/to/cc), not "from alice OR from bob".
|
||||
const filtered = source.filter(em => effective.every(p => _emailMatchesPill(em, p)));
|
||||
@@ -1990,6 +2050,24 @@ function _removeSearchPillAt(idx) {
|
||||
state._libSearchPills.splice(idx, 1);
|
||||
if (removed && removed.type === 'filter') _clearFilterPillSideEffect();
|
||||
_renderSearchPills();
|
||||
// Pill cleared all the way: if we got into search-result mode via the
|
||||
// IMAP search, the pre-search snapshot is now those results too (set
|
||||
// in _doSearch). Restoring from it would leave the user staring at
|
||||
// the same results with the pill bar empty. Re-fetch the real inbox
|
||||
// so removing the last pill genuinely "goes back".
|
||||
const noPillsLeft = (state._libSearchPills || []).length === 0
|
||||
&& !(state._libSearchDraft || '').trim();
|
||||
if (noPillsLeft && _libSearchHadResults) {
|
||||
_libSearchHadResults = false;
|
||||
_libPreSearchEmails = null;
|
||||
_libPreSearchTotal = 0;
|
||||
state._libSearch = '';
|
||||
state._libOffset = 0;
|
||||
const _searchInput = document.getElementById('email-lib-search');
|
||||
if (_searchInput) _searchInput.value = '';
|
||||
_loadEmails({ useCache: true });
|
||||
return;
|
||||
}
|
||||
_applyPillFilter();
|
||||
}
|
||||
|
||||
@@ -2059,6 +2137,15 @@ function _acceptSuggestion(s) {
|
||||
return;
|
||||
} else {
|
||||
_addSearchPill({ type: 'contact', name: s.name, email: s.email });
|
||||
// Same as the text-pill path in the Enter handler: trigger the IMAP
|
||||
// search so unloaded emails (older than the current page) show up
|
||||
// when picking a contact. The local pill filter then narrows the
|
||||
// search results to that contact's address.
|
||||
const _q = (s.email || s.name || '').trim();
|
||||
if (_q && _q.length >= 2) {
|
||||
state._libSearch = _q;
|
||||
_doSearch();
|
||||
}
|
||||
}
|
||||
if (input) input.value = '';
|
||||
state._libSearchDraft = '';
|
||||
@@ -2099,10 +2186,47 @@ async function _initEmailSearchChipBar() {
|
||||
};
|
||||
|
||||
input.addEventListener('focus', _refreshSuggestions);
|
||||
// Debounced IMAP search — fires ~500ms after the user stops typing so
|
||||
// searches for names/text not in the current inbox page actually surface
|
||||
// hits, instead of just locally filtering the visible window.
|
||||
//
|
||||
// Live local filtering on EVERY keystroke was clobbering server hits:
|
||||
// _emailMatchesPill / _matchesQuery check subject/from_name/from_address/
|
||||
// snippet but never body, so intermediate text like "sam" reduced the
|
||||
// 61 server results to whatever matched just those four fields (often
|
||||
// 0). User saw "no emails" while typing. So local filter is gone from
|
||||
// the typing path — debounced server search drives the grid. Pill
|
||||
// add/remove still re-runs the local filter through _applyPillFilter
|
||||
// directly.
|
||||
let _libSearchTypingTimer = null;
|
||||
input.addEventListener('input', async () => {
|
||||
state._libSearchDraft = input.value;
|
||||
try { console.log('[email-search] input event, value=', JSON.stringify(input.value)); } catch {}
|
||||
await _refreshSuggestions();
|
||||
_applyPillFilter();
|
||||
if (_libSearchTypingTimer) clearTimeout(_libSearchTypingTimer);
|
||||
const v = input.value.trim();
|
||||
if (v.length >= 2) {
|
||||
_libSearchTypingTimer = setTimeout(() => {
|
||||
const cur = (input.value || '').trim();
|
||||
if (cur === v && cur.length >= 2) {
|
||||
state._libSearch = cur;
|
||||
try { console.log('[email-search] firing _doSearch for', cur); } catch {}
|
||||
_doSearch();
|
||||
} else {
|
||||
try { console.log('[email-search] debounce expired but input changed (was', v, 'now', cur, ')'); } catch {}
|
||||
}
|
||||
}, 500);
|
||||
} else if (!v && _libSearchHadResults) {
|
||||
// Cleared the input → restore the inbox the same way the pill-clear
|
||||
// path does. Otherwise the stale search results stayed up after the
|
||||
// user backspaced everything out.
|
||||
_libSearchHadResults = false;
|
||||
_libPreSearchEmails = null;
|
||||
_libPreSearchTotal = 0;
|
||||
state._libSearch = '';
|
||||
state._libOffset = 0;
|
||||
_loadEmails({ useCache: true });
|
||||
}
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
// Delay so click/mousedown on a suggestion fires first.
|
||||
@@ -2154,6 +2278,14 @@ async function _initEmailSearchChipBar() {
|
||||
input.value = '';
|
||||
state._libSearchDraft = '';
|
||||
_hideSearchSuggestions();
|
||||
// Pill-only filtering used to only check emails already loaded into
|
||||
// state._libEmails (the visible page of the inbox). Searches for
|
||||
// names/text that aren't in the current page returned "no emails"
|
||||
// even when matches existed on the server. Trigger the IMAP
|
||||
// search so state._libEmails is replaced with the actual hits,
|
||||
// then the pill filter narrows to matches.
|
||||
state._libSearch = v;
|
||||
_doSearch();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2232,9 +2364,16 @@ async function _doSearch() {
|
||||
const stats = document.getElementById('email-lib-stats');
|
||||
const originalStatsText = stats?.textContent || '';
|
||||
if (stats) stats.textContent = 'Searching…';
|
||||
_libSearchInFlight = true;
|
||||
// Force a re-render so the "Searching…" empty-state shows (and any
|
||||
// existing "No emails" gets replaced) while the fetch is in flight.
|
||||
_renderGrid();
|
||||
|
||||
const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
|
||||
try {
|
||||
const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
|
||||
// Single fast fetch — limit=100 so the IMAP fetch loop doesn't spend
|
||||
// 60 s pulling 500 headers serially. We can wire "Load more" later
|
||||
// off `state._libTotal` if needed.
|
||||
const res = await fetch(`${API_BASE}/api/email/search?folder=${encodeURIComponent(folderAtStart)}${accountQS}&q=${encodeURIComponent(q)}&limit=100`);
|
||||
const data = await res.json();
|
||||
if (
|
||||
@@ -2251,11 +2390,36 @@ async function _doSearch() {
|
||||
_libSearchHadResults = true;
|
||||
state._libEmails = results; // temporarily replace with search results
|
||||
state._libTotal = data.total || results.length;
|
||||
// Refresh the pre-search snapshot so any subsequent _applyPillFilter
|
||||
// call (focus / pill edit / etc.) sources from the actual search
|
||||
// results, not the stale inbox page that was loaded before the
|
||||
// search ran. Without this, active pills (a contact pill from the
|
||||
// suggestion the user just clicked) would filter the inbox snapshot
|
||||
// → near-always empty → user sees "no emails" even though the
|
||||
// server search succeeded.
|
||||
_libPreSearchEmails = results.slice();
|
||||
_libPreSearchTotal = state._libTotal;
|
||||
// If pills are active (and they usually are after a contact-pill or
|
||||
// text-pill add), re-run the pill filter so the visible grid is the
|
||||
// pill-narrowed intersection of the new search results. Otherwise
|
||||
// _renderGrid below would render the raw server response, which
|
||||
// might not match the active pills the user just added.
|
||||
if ((state._libSearchPills || []).length) {
|
||||
_applyPillFilter();
|
||||
// Fall back to rendering the raw results if the pill intersection
|
||||
// hid everything but the user just confirmed they want this query.
|
||||
if (!(state._libEmails || []).length) state._libEmails = results;
|
||||
}
|
||||
_renderGrid();
|
||||
|
||||
if (stats) stats.textContent = `${data.total || results.length} match${(data.total || results.length) === 1 ? '' : 'es'}`;
|
||||
const count = data.total || results.length;
|
||||
if (stats) stats.textContent = `${count} match${count === 1 ? '' : 'es'} on server`;
|
||||
try { console.log('[email-search]', JSON.stringify({ q, folder: folderAtStart, count, returned: results.length })); } catch {}
|
||||
} catch (e) {
|
||||
if (stats) stats.textContent = originalStatsText || 'Search failed';
|
||||
try { console.error('[email-search] fetch failed:', e); } catch {}
|
||||
} finally {
|
||||
_libSearchInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2584,6 +2748,7 @@ function _renderGrid() {
|
||||
grid.innerHTML = '';
|
||||
|
||||
let filtered = state._libEmails;
|
||||
try { console.log('[email-search] _renderGrid: state._libEmails.length=', (state._libEmails || []).length, 'pills=', (state._libSearchPills || []).length, 'draft=', JSON.stringify(state._libSearchDraft || ''), 'libSearch=', JSON.stringify(state._libSearch || '')); } catch {}
|
||||
|
||||
// Apply sort
|
||||
if (state._libSort === 'unread') {
|
||||
@@ -2592,8 +2757,39 @@ function _renderGrid() {
|
||||
filtered = [...filtered].sort((a, b) => Number(b.is_flagged) - Number(a.is_flagged));
|
||||
}
|
||||
// 'recent' is the default order from the API
|
||||
// Stable secondary sort: favorited (is_flagged) emails ALWAYS bubble to
|
||||
// the top of whatever order the sort above produced. This pins the
|
||||
// user's flagged items so they're the first thing in the inbox no
|
||||
// matter which sort mode is active.
|
||||
filtered = [...filtered].sort((a, b) => Number(!!b.is_flagged) - Number(!!a.is_flagged));
|
||||
|
||||
if (filtered.length === 0) {
|
||||
// Active search — don't flash "No emails": the IMAP fetch is still
|
||||
// running. Show a "Searching…" placeholder until _doSearch resolves
|
||||
// and renders again. Without this the user saw an empty state
|
||||
// smiley for ~500ms between the optimistic pill-filter clear and
|
||||
// the server response landing.
|
||||
if (_libSearchInFlight) {
|
||||
grid.innerHTML = '';
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'email-loading';
|
||||
wrap.style.cssText = 'display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;padding:24px;opacity:0.75;';
|
||||
grid.appendChild(wrap);
|
||||
// Whirlpool spinner for parity with the rest of the cookbook /
|
||||
// doclib loaders. Falls back to plain text if the import fails.
|
||||
import('./spinner.js').then(sp => {
|
||||
if (!wrap.isConnected) return;
|
||||
const w = sp.default.createWhirlpool(20);
|
||||
w.element.style.cssText = 'margin:0;display:block;';
|
||||
wrap.appendChild(w.element);
|
||||
const lbl = document.createElement('span');
|
||||
lbl.textContent = 'Searching…';
|
||||
wrap.appendChild(lbl);
|
||||
}).catch(() => {
|
||||
wrap.textContent = 'Searching…';
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Inbox-zero is a win — pair the message with a small smiley so the
|
||||
// empty state reads as "all caught up", not "something's broken".
|
||||
const _smileyIco = '<span style="vertical-align:-3px;margin-left:6px;">' + emptyStateIcon('smiley') + '</span>';
|
||||
@@ -2930,13 +3126,19 @@ function _prefetchAdjacentEmails(card, count = 1) {
|
||||
const target = targets.find(t => t?.dataset?.uid);
|
||||
const uid = target?.dataset?.uid;
|
||||
if (!uid) return;
|
||||
const key = `${state._libAccountId || ''}|${state._libFolder}|${uid}`;
|
||||
// Use the email's actual folder when it was stamped by the search
|
||||
// endpoint; otherwise default to the currently-selected folder.
|
||||
const _emFold = (() => {
|
||||
const emObj = (state._libEmails || []).find(e => String(e.uid) === String(uid));
|
||||
return (emObj && emObj.folder) || state._libFolder || 'INBOX';
|
||||
})();
|
||||
const key = `${state._libAccountId || ''}|${_emFold}|${uid}`;
|
||||
if (_emailReadPrefetching.has(key) || _emailReadPrefetching.size > 0) return;
|
||||
if (_emailReadPrefetchTimer) clearTimeout(_emailReadPrefetchTimer);
|
||||
_emailReadPrefetchTimer = setTimeout(() => {
|
||||
_emailReadPrefetchTimer = null;
|
||||
_emailReadPrefetching.add(key);
|
||||
fetch(`${API_BASE}/api/email/read/${encodeURIComponent(uid)}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&mark_seen=false`)
|
||||
fetch(`${API_BASE}/api/email/read/${encodeURIComponent(uid)}?folder=${encodeURIComponent(_emFold)}${_acct()}&mark_seen=false`)
|
||||
.catch(() => {})
|
||||
.finally(() => _emailReadPrefetching.delete(key));
|
||||
}, 900);
|
||||
@@ -2944,7 +3146,10 @@ function _prefetchAdjacentEmails(card, count = 1) {
|
||||
|
||||
async function _toggleCardPreview(card, em) {
|
||||
const accountAtStart = state._libAccountId || '';
|
||||
const folderAtStart = state._libFolder || 'INBOX';
|
||||
// Prefer the per-email folder stamped by the search endpoint (results
|
||||
// from "All Mail" carry folder="[Gmail]/All Mail"). Falls back to the
|
||||
// currently-selected folder for normal inbox cards.
|
||||
const folderAtStart = (em && em.folder) || state._libFolder || 'INBOX';
|
||||
const uidAtStart = String(em?.uid || card?.dataset?.uid || '');
|
||||
const grid = card.closest('.doclib-grid');
|
||||
const gridRect = grid?.getBoundingClientRect?.();
|
||||
@@ -3036,6 +3241,7 @@ async function _toggleCardPreview(card, em) {
|
||||
// Mark as read locally
|
||||
_syncEmailReadState(em.uid, true);
|
||||
_prefetchAdjacentEmails(card);
|
||||
_stampReaderContext(reader, { ...em, ...data }, state._libFolder, state._libAccountId);
|
||||
|
||||
// Build the attachments wrap using the shared helper so the signature-
|
||||
// image filter (small inline PNGs/JPGs, Outlook image001 placeholders,
|
||||
@@ -3083,10 +3289,10 @@ async function _toggleCardPreview(card, em) {
|
||||
${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${buildRecipients(data.cc)}</span></div>` : ''}
|
||||
</div>` : ''}
|
||||
<div class="email-reader-actions-inline">
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply (suggest a draft)'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="reply" title="Reply"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg><span class="reader-btn-label">Reply</span></button>
|
||||
${_hasMultipleRecipients(data) ? `<button class="memory-toolbar-btn reader-icon-btn" data-act="reply-all" title="Reply All"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 17 2 12 7 7"/><polyline points="12 17 7 12 12 7"/><path d="M22 18v-2a4 4 0 0 0-4-4H7"/></svg><span class="reader-btn-label">Reply all</span></button>` : ''}
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="forward" title="Forward"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 17 20 12 15 7"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/></svg><span class="reader-btn-label">Forward</span></button>
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply (suggest a draft)'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="summarize" title="Summarize">${_summaryIcon(data)}<span class="reader-btn-label">Summary</span></button>
|
||||
<div class="email-reader-more-wrap" style="position:relative">
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="more" title="More actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg><span class="reader-btn-label">More</span></button>
|
||||
@@ -4758,6 +4964,7 @@ async function _openEmailAsTab(em, folder) {
|
||||
return;
|
||||
}
|
||||
_syncEmailReadState(em.uid, true);
|
||||
_stampReaderContext(reader, { ...em, ...data }, useFolder, state._libAccountId);
|
||||
const buildChips = (str) => {
|
||||
if (!str) return '';
|
||||
return _splitRecipientList(str).map(a => {
|
||||
@@ -4780,10 +4987,10 @@ async function _openEmailAsTab(em, folder) {
|
||||
${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${buildChips(data.cc)}</span></div>` : ''}
|
||||
</div>` : ''}
|
||||
<div class="email-reader-actions-inline">
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="reply" title="Reply"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg><span class="reader-btn-label">Reply</span></button>
|
||||
${_hasMultipleRecipients(data) ? `<button class="memory-toolbar-btn reader-icon-btn" data-act="reply-all" title="Reply All"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 17 2 12 7 7"/><polyline points="12 17 7 12 12 7"/><path d="M22 18v-2a4 4 0 0 0-4-4H7"/></svg><span class="reader-btn-label">Reply all</span></button>` : ''}
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="forward" title="Forward"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 17 20 12 15 7"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/></svg><span class="reader-btn-label">Forward</span></button>
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="summarize" title="Summarize">${_summaryIcon(data)}<span class="reader-btn-label">Summary</span></button>
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="from-sender" title="Search text in this thread"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg><span class="reader-btn-label">Search</span></button>
|
||||
<div class="email-reader-more-wrap" style="position:relative">
|
||||
@@ -4936,10 +5143,10 @@ async function _openEmailWindow(em, folder) {
|
||||
${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${_chipsFor(data.cc)}</span></div>` : ''}
|
||||
</div>` : ''}
|
||||
<div class="email-reader-actions-inline">
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply (suggest a draft)'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="reply" title="Reply"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg><span class="reader-btn-label">Reply</span></button>
|
||||
${_hasMultipleRecipients(data) ? `<button class="memory-toolbar-btn reader-icon-btn" data-act="reply-all" title="Reply All"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 17 2 12 7 7"/><polyline points="12 17 7 12 12 7"/><path d="M22 18v-2a4 4 0 0 0-4-4H7"/></svg><span class="reader-btn-label">Reply all</span></button>` : ''}
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="forward" title="Forward"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 17 20 12 15 7"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/></svg><span class="reader-btn-label">Forward</span></button>
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply (suggest a draft)'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="summarize" title="Summarize">${_summaryIcon(data)}<span class="reader-btn-label">Summary</span></button>
|
||||
<div class="email-reader-more-wrap" style="position:relative">
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="more" title="More actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg><span class="reader-btn-label">More</span></button>
|
||||
@@ -5337,6 +5544,27 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
_renderGrid();
|
||||
},
|
||||
},
|
||||
{
|
||||
// Favorite (pin to top). Same bookmark glyph we use for the
|
||||
// sidebar-pin / favorites filter so the visual language stays
|
||||
// consistent. Toggling updates em.is_flagged and re-sorts via
|
||||
// _renderGrid (favorited rows are always pinned at the top).
|
||||
label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)',
|
||||
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="' + (em.is_flagged ? 'currentColor' : 'none') + '" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>',
|
||||
action: async () => {
|
||||
const next = !em.is_flagged;
|
||||
em.is_flagged = next;
|
||||
_renderGrid();
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' });
|
||||
} catch (e) {
|
||||
// Roll back the optimistic flip if the server didn't take it.
|
||||
em.is_flagged = !next;
|
||||
_renderGrid();
|
||||
console.error('Failed to toggle favorite:', e);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: em.is_answered ? 'Mark as Not Done' : 'Mark as Done',
|
||||
icon: _checkIcon,
|
||||
@@ -5551,6 +5779,22 @@ function _showCardMenu(em, anchor) {
|
||||
}
|
||||
},
|
||||
});
|
||||
actions.push({
|
||||
label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)',
|
||||
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="' + (em.is_flagged ? 'currentColor' : 'none') + '" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>',
|
||||
action: async () => {
|
||||
const next = !em.is_flagged;
|
||||
em.is_flagged = next;
|
||||
_renderGrid();
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' });
|
||||
} catch (e) {
|
||||
em.is_flagged = !next;
|
||||
_renderGrid();
|
||||
console.error('Failed to toggle favorite:', e);
|
||||
}
|
||||
},
|
||||
});
|
||||
actions.push({
|
||||
label: 'Archive',
|
||||
icon: _archIcon,
|
||||
@@ -5563,6 +5807,22 @@ function _showCardMenu(em, anchor) {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
actions.push({
|
||||
label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)',
|
||||
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="' + (em.is_flagged ? 'currentColor' : 'none') + '" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>',
|
||||
action: async () => {
|
||||
const next = !em.is_flagged;
|
||||
em.is_flagged = next;
|
||||
_renderGrid();
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' });
|
||||
} catch (e) {
|
||||
em.is_flagged = !next;
|
||||
_renderGrid();
|
||||
console.error('Failed to toggle favorite:', e);
|
||||
}
|
||||
},
|
||||
});
|
||||
actions.push({
|
||||
label: 'Archive',
|
||||
icon: _archIcon,
|
||||
|
||||
+82
-4
@@ -18,6 +18,80 @@ let selectedIds = new Set();
|
||||
|
||||
const MEMORY_CATEGORIES = ['fact', 'identity', 'preference', 'contact', 'project', 'goal', 'task'];
|
||||
|
||||
// Sort-option icons for the custom Memory sort picker (and Skills picker
|
||||
// once it reuses the same markup). Each value maps to a 13px Feather-style
|
||||
// SVG so the icon visually distinguishes Newest / Oldest / A-Z / Most used.
|
||||
const _MEMORY_SORT_ICONS = {
|
||||
newest: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
|
||||
oldest: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><polyline points="3 3 3 8 8 8"/><polyline points="12 7 12 12 16 14"/></svg>',
|
||||
alpha: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4h6"/><path d="M3 10h6"/><path d="M3 16h4"/><path d="M14 4l4 12"/><path d="M16 12h4"/><polyline points="17 18 21 14 17 10"/><line x1="21" y1="14" x2="13" y2="14"/></svg>',
|
||||
uses: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></svg>',
|
||||
};
|
||||
|
||||
function _memorySortIcon(value) {
|
||||
return _MEMORY_SORT_ICONS[value] || _MEMORY_SORT_ICONS.newest;
|
||||
}
|
||||
|
||||
function _renderMemorySortPickerCurrent() {
|
||||
const sel = document.getElementById('memory-sort');
|
||||
const btn = document.getElementById('memory-sort-btn');
|
||||
if (!sel || !btn) return;
|
||||
const value = sel.value || 'newest';
|
||||
const opt = sel.querySelector(`option[value="${CSS.escape(value)}"]`);
|
||||
const label = opt ? opt.textContent : value;
|
||||
const iconWrap = btn.querySelector('.memory-sort-icon-cur');
|
||||
const labelEl = btn.querySelector('.memory-sort-label');
|
||||
if (iconWrap) iconWrap.innerHTML = _memorySortIcon(value);
|
||||
if (labelEl) labelEl.textContent = label;
|
||||
}
|
||||
|
||||
function _initMemorySortPicker() {
|
||||
const sel = document.getElementById('memory-sort');
|
||||
const picker = document.getElementById('memory-sort-picker');
|
||||
const btn = document.getElementById('memory-sort-btn');
|
||||
const menu = document.getElementById('memory-sort-menu');
|
||||
if (!sel || !picker || !btn || !menu || picker._wired) return;
|
||||
picker._wired = true;
|
||||
|
||||
const items = Array.from(sel.children)
|
||||
.filter(o => o.tagName === 'OPTION')
|
||||
.map(o => ({ value: o.value, label: o.textContent }));
|
||||
|
||||
menu.innerHTML = items.map(it => `
|
||||
<button type="button" role="option" class="memory-sort-item" data-value="${it.value}">
|
||||
<span class="memory-sort-item-icon">${_memorySortIcon(it.value)}</span>
|
||||
<span class="memory-sort-item-label">${it.label}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
const close = () => { menu.hidden = true; btn.setAttribute('aria-expanded', 'false'); };
|
||||
const open = () => { menu.hidden = false; btn.setAttribute('aria-expanded', 'true'); };
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (menu.hidden) open(); else close();
|
||||
});
|
||||
menu.addEventListener('click', (e) => {
|
||||
const item = e.target.closest('.memory-sort-item');
|
||||
if (!item) return;
|
||||
sel.value = item.dataset.value;
|
||||
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
_renderMemorySortPickerCurrent();
|
||||
close();
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!menu.hidden && !picker.contains(e.target)) close();
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !menu.hidden) {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}
|
||||
}, { capture: true });
|
||||
|
||||
_renderMemorySortPickerCurrent();
|
||||
}
|
||||
|
||||
function _ensureNewMemoryCategorySelect() {
|
||||
const sel = document.getElementById('new-memory-category');
|
||||
if (!sel || sel.dataset.wired === '1') return;
|
||||
@@ -334,13 +408,16 @@ export async function loadMemories() {
|
||||
|
||||
// ---- Bulk select mode ----
|
||||
|
||||
const _SELECT_BTN_DOT_SVG = '<svg class="memory-select-btn-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>';
|
||||
const _SELECT_BTN_X_SVG = '<svg class="memory-select-btn-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-2px;margin-right:3px;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||||
|
||||
function enterSelectMode() {
|
||||
selectMode = true;
|
||||
selectedIds.clear();
|
||||
const bulkBar = document.getElementById('memory-bulk-bar');
|
||||
const selectBtn = document.getElementById('memory-select-btn');
|
||||
if (bulkBar) bulkBar.classList.remove('hidden');
|
||||
if (selectBtn) { selectBtn.classList.add('active'); selectBtn.textContent = 'Cancel'; }
|
||||
if (selectBtn) { selectBtn.classList.add('active'); selectBtn.innerHTML = _SELECT_BTN_X_SVG + 'Cancel'; }
|
||||
updateBulkCount();
|
||||
renderMemoryList();
|
||||
}
|
||||
@@ -352,7 +429,7 @@ function exitSelectMode() {
|
||||
const selectBtn = document.getElementById('memory-select-btn');
|
||||
const selectAll = document.getElementById('memory-select-all');
|
||||
if (bulkBar) bulkBar.classList.add('hidden');
|
||||
if (selectBtn) { selectBtn.classList.remove('active'); selectBtn.textContent = 'Select'; }
|
||||
if (selectBtn) { selectBtn.classList.remove('active'); selectBtn.innerHTML = _SELECT_BTN_DOT_SVG + 'Select'; }
|
||||
if (selectAll) selectAll.checked = false;
|
||||
renderMemoryList();
|
||||
}
|
||||
@@ -449,7 +526,7 @@ export async function tidyMemories() {
|
||||
const data = await res.json();
|
||||
if ((data.removed || 0) === 0) {
|
||||
if (tidySpinner) tidySpinner.destroy();
|
||||
if (tidyBtn) { tidyBtn.disabled = false; tidyBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy'; }
|
||||
if (tidyBtn) { tidyBtn.disabled = false; tidyBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;color:var(--accent, var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy'; }
|
||||
showToast('Already clean');
|
||||
return;
|
||||
}
|
||||
@@ -492,7 +569,7 @@ export async function tidyMemories() {
|
||||
tidyBtn.disabled = false;
|
||||
tidyBtn.style.border = '';
|
||||
tidyBtn.style.background = '';
|
||||
tidyBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy';
|
||||
tidyBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;color:var(--accent, var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1387,6 +1464,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
renderMemoryList();
|
||||
});
|
||||
}
|
||||
_initMemorySortPicker();
|
||||
|
||||
const tidyBtn = document.getElementById('memory-tidy-btn');
|
||||
if (tidyBtn) tidyBtn.addEventListener('click', tidyMemories);
|
||||
|
||||
Reference in New Issue
Block a user