From ff93a6c63bda6d3e003374f4f0b071dc452cf441 Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Tue, 2 Jun 2026 22:38:55 +0900 Subject: [PATCH] Polish email and cookbook flows --- ROADMAP.md | 6 + routes/cookbook_routes.py | 94 +++++-- routes/email_routes.py | 4 +- routes/hwfit_routes.py | 11 +- services/hwfit/data/hf_models.json | 18 +- services/hwfit/fit.py | 57 +++- services/hwfit/models.py | 11 +- src/agent_loop.py | 8 +- src/llm_core.py | 104 +++++-- src/tool_index.py | 2 +- src/tool_schemas.py | 2 +- static/js/cookbook-hwfit.js | 74 ++++- static/js/cookbook.js | 4 + static/js/cookbookDownload.js | 16 ++ static/js/cookbookRunning.js | 259 +++++++++++++++-- static/js/document.js | 182 ++++++++++-- static/js/emailLibrary.js | 438 ++++++++++++++++++++++++----- static/js/modalManager.js | 56 +++- static/js/modalSnap.js | 18 ++ static/js/tasks.js | 2 +- static/js/windowDrag.js | 6 + static/style.css | 338 +++++++++++++++++++++- 22 files changed, 1492 insertions(+), 218 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4893bde..6e784b2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,6 +37,12 @@ the codebase, you are probably right to stay away. fetched pages, and memories should be treated as untrusted data. Keep testing whether models follow malicious instructions from those surfaces. - Better degraded-state reporting for ChromaDB, SearXNG, email, ntfy, and provider probes. +- Email performance audit. Fetching, searching, opening, deleting, and sending + email can feel slow, especially over IMAP/SMTP providers with high latency. + Need someone who knows mail performance to profile the current flow, identify + whether the bottleneck is IMAP folder select/fetch, cache invalidation, + attachment/body loading, SMTP handshakes, or frontend refresh behavior, then + propose safer caching/prefetch/batching without breaking multi-account state. - Provider setup/probing audit for Anthropic, Gemini, Groq, xAI, OpenRouter, OpenAI, and DeepSeek. ## Refactor Targets diff --git a/routes/cookbook_routes.py b/routes/cookbook_routes.py index 63f4b46..77531f6 100644 --- a/routes/cookbook_routes.py +++ b/routes/cookbook_routes.py @@ -546,12 +546,18 @@ def setup_cookbook_routes() -> APIRouter: ) # Ensure pip-user scripts (e.g. hf CLI installed via --user) are on PATH runner_lines.append('export PATH="$HOME/.local/bin:$PATH"') - # Install hf CLI + hf_transfer best-effort so future runs get the fast path. + # Install hf CLI + optional hf_transfer best-effort. Retries disable + # hf_transfer because the Rust parallel path is fast but has been + # flaky near the end of very large multi-file downloads. # Use --break-system-packages on PEP-668 systems (Arch, newer Debian) so it doesn't bail. runner_lines.append(f"command -v hf >/dev/null 2>&1 || {_pip_install_fallback_chain('huggingface_hub', python_cmd='pip', upgrade=True)}") - runner_lines.append(f"python3 -c 'import hf_transfer' 2>/dev/null || {_pip_install_fallback_chain('hf_transfer', python_cmd='pip')}") - runner_lines.append("python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1") - runner_lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=8") + if req.disable_hf_transfer: + runner_lines.append("export HF_HUB_ENABLE_HF_TRANSFER=0") + runner_lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=4") + else: + runner_lines.append(f"python3 -c 'import hf_transfer' 2>/dev/null || {_pip_install_fallback_chain('hf_transfer', python_cmd='pip')}") + runner_lines.append("python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1") + runner_lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=8") # Surface whether the HF token actually reached THIS server, so a gated # download's "not authorized" failure can be told apart from a missing # token (the token is masked — we only print applied / not-set). @@ -562,13 +568,17 @@ def setup_cookbook_routes() -> APIRouter: runner_lines.append(f' {hf_cmd} < /dev/null') runner_lines.append('elif python3 -c "import huggingface_hub" 2>/dev/null; then') runner_lines.append(' echo "hf CLI not found, using Python huggingface_hub..."') - runner_lines.append(f' python3 -c "import os; from huggingface_hub import snapshot_download; snapshot_download(\'{req.repo_id}\'{_dl_pyarg}, max_workers=8)"') + runner_lines.append(f' python3 -c "import os; from huggingface_hub import snapshot_download; snapshot_download(\'{req.repo_id}\'{_dl_pyarg}, max_workers={4 if req.disable_hf_transfer else 8})"') runner_lines.append('else') runner_lines.append(' echo "Installing huggingface-hub and dependencies..."') runner_lines.append(' pip install --no-deps -q huggingface-hub 2>/dev/null') - runner_lines.append(' pip install -q filelock fsspec packaging pyyaml tqdm typer httpx requests hf_transfer 2>/dev/null') - runner_lines.append(" python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1") - runner_lines.append(f' python3 -c "import os; from huggingface_hub import snapshot_download; snapshot_download(\'{req.repo_id}\'{_dl_pyarg}, max_workers=8)"') + if req.disable_hf_transfer: + runner_lines.append(' pip install -q filelock fsspec packaging pyyaml tqdm typer httpx requests 2>/dev/null') + runner_lines.append(' export HF_HUB_ENABLE_HF_TRANSFER=0') + else: + runner_lines.append(' pip install -q filelock fsspec packaging pyyaml tqdm typer httpx requests hf_transfer 2>/dev/null') + runner_lines.append(" python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1") + runner_lines.append(f' python3 -c "import os; from huggingface_hub import snapshot_download; snapshot_download(\'{req.repo_id}\'{_dl_pyarg}, max_workers={4 if req.disable_hf_transfer else 8})"') runner_lines.append('fi') runner_lines.append('if [ $? -eq 0 ]; then echo ""; echo "DOWNLOAD_OK"; else echo ""; echo "DOWNLOAD_FAILED (exit $?)"; fi') runner_lines.append(f"rm -f {remote_runner}") @@ -994,16 +1004,20 @@ def setup_cookbook_routes() -> APIRouter: runner_lines.append(f'ODYSSEUS_OLLAMA_HOST={_bash_squote(_ollama_host)}') runner_lines.append(f'ODYSSEUS_OLLAMA_PORT="{_ollama_port}"') runner_lines.append('ODYSSEUS_OLLAMA_URL=""') - runner_lines.append('for _ody_ollama_port in "$ODYSSEUS_OLLAMA_PORT" 11434; do') - runner_lines.append(' [ -z "$_ody_ollama_port" ] && continue') - runner_lines.append(' for _ody_ollama_host in 127.0.0.1 localhost host.docker.internal; do') - runner_lines.append(' _ody_ollama_url="http://${_ody_ollama_host}:${_ody_ollama_port}"') - runner_lines.append(' if curl -sf "$_ody_ollama_url/api/tags" >/dev/null 2>&1; then') - runner_lines.append(' ODYSSEUS_OLLAMA_URL="$_ody_ollama_url"') - runner_lines.append(' ODYSSEUS_OLLAMA_PORT="$_ody_ollama_port"') - runner_lines.append(' break 2') - runner_lines.append(' fi') + runner_lines.append('for _ody_ollama_try in $(seq 1 20); do') + runner_lines.append(' for _ody_ollama_port in "$ODYSSEUS_OLLAMA_PORT" 11434; do') + runner_lines.append(' [ -z "$_ody_ollama_port" ] && continue') + runner_lines.append(' for _ody_ollama_host in 127.0.0.1 localhost host.docker.internal; do') + runner_lines.append(' _ody_ollama_url="http://${_ody_ollama_host}:${_ody_ollama_port}"') + runner_lines.append(' if curl -sf "$_ody_ollama_url/api/tags" >/dev/null 2>&1; then') + runner_lines.append(' ODYSSEUS_OLLAMA_URL="$_ody_ollama_url"') + runner_lines.append(' ODYSSEUS_OLLAMA_PORT="$_ody_ollama_port"') + runner_lines.append(' break 3') + runner_lines.append(' fi') + runner_lines.append(' done') runner_lines.append(' done') + runner_lines.append(' [ "$_ody_ollama_try" -eq 1 ] && echo "[odysseus] Waiting for an existing Ollama API on ports ${ODYSSEUS_OLLAMA_PORT}/11434..."') + runner_lines.append(' sleep 1') runner_lines.append('done') runner_lines.append('if [ -n "$ODYSSEUS_OLLAMA_URL" ]; then') runner_lines.append(' if [ "$ODYSSEUS_OLLAMA_PORT" != "' + _ollama_port + '" ]; then') @@ -1820,6 +1834,43 @@ def setup_cookbook_routes() -> APIRouter: def _cookbook_tasks_status_sync(): import subprocess + def _download_cache_complete(repo_id: str, remote_host: str = "", ssh_port: str = "") -> bool: + """Best-effort check for a completed HF cache entry. + + tmux output can stop at a stale progress line if the pane/session + disappears before Cookbook captures the final DOWNLOAD_OK marker. + In that case, trust the cache shape: a snapshot directory with files + and no *.incomplete blobs means HuggingFace finished materializing the + model. + """ + if not repo_id or "/" not in repo_id: + return False + py = ( + "import os,sys;" + "repo=sys.argv[1];" + "base=os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub');" + "d=os.path.join(base,'models--'+repo.replace('/','--'));" + "snap=os.path.join(d,'snapshots');" + "ok=os.path.isdir(snap) and any(os.path.isdir(os.path.join(snap,x)) and os.listdir(os.path.join(snap,x)) for x in os.listdir(snap));" + "inc=False;" + "blobs=os.path.join(d,'blobs');" + "inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));" + "sys.exit(0 if ok and not inc else 1)" + ) + cmd = ["python3", "-c", py, repo_id] + try: + if remote_host: + ssh_base = ["ssh"] + if ssh_port and ssh_port != "22": + ssh_base.extend(["-p", str(ssh_port)]) + shell_cmd = " ".join(shlex.quote(x) for x in cmd) + proc = subprocess.run(ssh_base + [remote_host, shell_cmd], timeout=12, capture_output=True) + else: + proc = subprocess.run(cmd, timeout=12, capture_output=True) + return proc.returncode == 0 + except Exception: + return False + # Load saved tasks from cookbook state tasks = [] if _cookbook_state_path.exists(): @@ -1996,7 +2047,14 @@ def setup_cookbook_routes() -> APIRouter: status = "running" else: # Session is dead — check if it completed or crashed - status = "stopped" + if task_type == "download" and _download_cache_complete(_payload.get("repo_id") or model, remote, str(_tport or "")): + status = "completed" + if not progress_text: + progress_text = "Download complete" + if not full_snapshot: + full_snapshot = "DOWNLOAD_OK" + else: + status = "stopped" # Parse structured phase info — single source of truth for the UI phase_info = _parse_serve_phase(full_snapshot, task_type) if (task_type == "serve" and status == "running" and full_snapshot) else {} diff --git a/routes/email_routes.py b/routes/email_routes.py index 9870cb4..d660d91 100644 --- a/routes/email_routes.py +++ b/routes/email_routes.py @@ -457,7 +457,7 @@ def setup_email_routes(): _IMAP_POOL = {} # account_id → (conn, last_used_at) _IMAP_IDLE_MAX = 60.0 _WARMING_READS = set() - _WARM_READ_LIMIT = 3 + _WARM_READ_LIMIT = 1 _WARM_MAX_BYTES = 128 * 1024 _WARM_RECENT_SECONDS = 7 * 24 * 60 * 60 _pool_lock = _threading.Lock() @@ -1046,7 +1046,7 @@ def setup_email_routes(): # Escape backslash and quote for the IMAP-SEARCH quoted-string. q_escaped = q.replace('\\', '\\\\').replace('"', '\\"') - search_cmd = f'(OR FROM "{q_escaped}" TEXT "{q_escaped}")' + search_cmd = f'(OR OR FROM "{q_escaped}" SUBJECT "{q_escaped}" TEXT "{q_escaped}")' status, data = _imap_uid_search(conn, search_cmd) if status != "OK" or not data[0]: diff --git a/routes/hwfit_routes.py b/routes/hwfit_routes.py index 94ff90d..c377e5e 100644 --- a/routes/hwfit_routes.py +++ b/routes/hwfit_routes.py @@ -91,7 +91,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 = "", 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): + 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): """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 @@ -172,7 +172,14 @@ def setup_hwfit_routes(): # gpu_only stays off here so the default view still surfaces offload. _apply_group(grp, grp["count"]) - results = rank_models(system, use_case=use_case or None, limit=limit, search=search or None, sort=sort, quant=quant or None) + try: + target_context = int(ctx) if ctx else None + except ValueError: + target_context = None + if target_context is not None: + target_context = max(1024, min(target_context, 1000000)) + + results = rank_models(system, use_case=use_case or None, limit=limit, search=search or None, sort=sort, quant=quant or None, target_context=target_context) return {"system": system, "models": results} @router.get("/profiles") diff --git a/services/hwfit/data/hf_models.json b/services/hwfit/data/hf_models.json index 09a3dc9..9a040c3 100644 --- a/services/hwfit/data/hf_models.json +++ b/services/hwfit/data/hf_models.json @@ -4375,7 +4375,14 @@ "hf_downloads": 51135, "hf_likes": 2, "release_date": "2025-09-23", - "_discovered": true + "_discovered": true, + "gguf_sources": [ + { + "repo": "typhoon-ai/typhoon2.5-qwen3-4b-gguf", + "file": "typhoon2.5-qwen3-4b-q4_k_m.gguf", + "quant": "Q4_K_M" + } + ] }, { "name": "JunHowie/Qwen3-4B-Instruct-2507-GPTQ-Int4", @@ -8994,7 +9001,14 @@ "num_experts": 128, "active_experts": 8, "active_parameters": 3339450907, - "_discovered": true + "_discovered": true, + "gguf_sources": [ + { + "repo": "typhoon-ai/typhoon2.5-qwen3-30b-a3b-gguf", + "file": "typhoon2.5-qwen3-30b-a3b-q4_k_m.gguf", + "quant": "Q4_K_M" + } + ] }, { "name": "QuantTrio/Qwen3-Coder-30B-A3B-Instruct-AWQ", diff --git a/services/hwfit/fit.py b/services/hwfit/fit.py index 55513d4..9e80ced 100644 --- a/services/hwfit/fit.py +++ b/services/hwfit/fit.py @@ -175,8 +175,15 @@ def _quality_score(model, quant, use_case): model_uc = infer_use_case(model) if model_uc == "coding" and use_case == "coding": base += 6 + elif model_uc == "coding" and use_case in ("general", "chat"): + # Coder-specialized models are still useful generally, but they should + # not dominate the default scan. If the user wants code, the Coding + # filter gives them the boost above. + base -= 10 if model_uc == "reasoning" and use_case == "reasoning" and pb >= 13: base += 5 + elif model_uc == "reasoning" and use_case == "chat": + base -= 4 if model_uc == "multimodal" and use_case == "multimodal": base += 6 @@ -262,7 +269,30 @@ def _quant_bits(q): return 0 -def analyze_model(model, system, target_quant=None, scoring_use_case=None): +def _native_quant(model): + native_quant = model.get("quantization", "Q4_K_M") + name = (model.get("name") or "").lower() + fmt = (model.get("format") or "").lower() + text = f"{name} {fmt}" + if "nvfp4" in text: + return "NVFP4" + if re.search(r"(^|[-_/])fp8($|[-_/\s])", text): + return "FP8" + if "gptq" in text: + m = re.search(r"(?:gptq|int|w)(?:[-_]?)(\d{1,2})(?:bit)?", text) + return f"GPTQ-{m.group(1)}bit" if m else "GPTQ" + if "awq" in text: + m = re.search(r"(?:awq|int|w)(?:[-_]?)(\d{1,2})(?:bit)?", text) + return f"AWQ-{m.group(1)}bit" if m else "AWQ" + if "mlx" in text: + m = re.search(r"mlx[-_]?(\d{1,2})bit", text) + return f"mlx-{m.group(1)}bit" if m else native_quant + if not (model.get("is_gguf") or model.get("gguf_sources")) and re.search(r"(^|[-_/])(?:int)?8bit($|[-_/\s])", text): + return "INT8" + return native_quant + + +def analyze_model(model, system, target_quant=None, scoring_use_case=None, target_context=None): pb = params_b(model) if pb <= 0: return None @@ -282,11 +312,14 @@ def analyze_model(model, system, target_quant=None, scoring_use_case=None): gpu_only = bool(system.get("gpu_only")) and has_gpu and gpu_vram > 0 eff_ram = 0 if gpu_only else available_ram is_moe = model.get("is_moe", False) - ctx = model.get("context_length", 4096) or 4096 + model_ctx = model.get("context_length", 4096) or 4096 + try: + target_context = int(target_context or 0) + except (TypeError, ValueError): + target_context = 0 + ctx = min(model_ctx, target_context) if target_context > 0 else model_ctx - native_quant = model.get("quantization", "Q4_K_M") - if "nvfp4" in (model.get("name") or "").lower(): - native_quant = "NVFP4" + native_quant = _native_quant(model) preq = is_prequantized(model) # GGUF models can't be sharded across GPUs — use single GPU VRAM @@ -355,7 +388,8 @@ def analyze_model(model, system, target_quant=None, scoring_use_case=None): "score": 0, "scores": {"quality": 0, "speed": 0, "fit": 0, "context": 0}, "gguf_sources": model.get("gguf_sources", []), - "context_length": model.get("context_length", 4096), + "context_length": model_ctx, + "target_context": target_context or None, } run_mode, quant, fit_ctx, required_gb = result @@ -413,8 +447,9 @@ def analyze_model(model, system, target_quant=None, scoring_use_case=None): "context": round(c_score, 1), }, "gguf_sources": model.get("gguf_sources", []), - "context_length": model.get("context_length", 4096), + "context_length": model_ctx, "release_date": model.get("release_date", ""), + "target_context": target_context or None, } @@ -431,7 +466,7 @@ SORT_KEYS = { } -def rank_models(system, use_case=None, limit=50, search=None, sort="score", quant=None): +def rank_models(system, use_case=None, limit=50, search=None, sort="score", quant=None, target_context=None): """Rank all models against detected hardware. Returns sorted list of fit results.""" models = get_models() results = [] @@ -495,9 +530,7 @@ def rank_models(system, use_case=None, limit=50, search=None, sort="score", quan consumer_amd = system_backend == "rocm" and gpu_family == "rdna" for m in models: - native_q = m.get("quantization", "") - if "nvfp4" in (m.get("name") or "").lower(): - native_q = "NVFP4" + native_q = _native_quant(m) # MLX needs the mlx_lm runtime, which Odysseus does not generate serve # commands for. Hide it on every backend, including Metal. @@ -548,7 +581,7 @@ def rank_models(system, use_case=None, limit=50, search=None, sort="score", quan if search.lower() not in name and search.lower() not in provider: continue - result = analyze_model(m, system, target_quant=quant, scoring_use_case=(use_case or "general")) + result = analyze_model(m, system, target_quant=quant, scoring_use_case=(use_case or "general"), target_context=target_context) if result is None: continue diff --git a/services/hwfit/models.py b/services/hwfit/models.py index c6504e6..7585fb5 100644 --- a/services/hwfit/models.py +++ b/services/hwfit/models.py @@ -101,7 +101,16 @@ def _normalize_model_entry(model): def is_prequantized(model): q = model.get("quantization", "") - return any(q.startswith(p) for p in PREQUANTIZED_PREFIXES) + name = (model.get("name") or "").lower() + fmt = (model.get("format") or "").lower() + text = f"{name} {fmt}" + return ( + "nvfp4" in text + or re.search(r"(^|[-_/])fp8($|[-_/\s])", text) is not None + or (not (model.get("is_gguf") or model.get("gguf_sources")) and re.search(r"(^|[-_/])(?:int)?8bit($|[-_/\s])", text) is not None) + or any(x in text for x in ("awq", "gptq", "mlx")) + or any(q.startswith(p) for p in PREQUANTIZED_PREFIXES) + ) def params_b(model): diff --git a/src/agent_loop.py b/src/agent_loop.py index 0e3dce9..9954185 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -115,6 +115,7 @@ _API_AGENT_RULES = """\ - Keep answers concise unless the user asks for depth. - For long code or content, use document tools instead of pasting large blocks into chat. - Editing an existing document: ALWAYS use `edit_document` with find/replace. Only use `update_document` for genuine full rewrites (>50% changed) — do NOT echo the entire file back for small edits. +- If the active editor document is an email draft/compose window, treat that open email as the target for "write this", "write the email", "reply with...", "make it say...", "draft this", and similar requests. Do NOT create another document, search/list/manage documents, or open a different reply unless the user explicitly asks. Edit the open email draft with `edit_document` or `update_document`; preserve To/Cc/Bcc/Subject/In-Reply-To/References/X-* header lines unless the user asks to change them. - "Give suggestions / feedback / review / how can I improve this / what would make it better" about the OPEN document → call `suggest_document`, do NOT write a prose list of ideas in chat. It creates inline accept/reject bubbles on the doc. Give concrete `find`/`replace`/`reason` items. To suggest an ADDITION (e.g. "add a bow to the SVG", a new section), set `find` to a short existing anchor snippet and `replace` to that same snippet PLUS the new content. Only answer in prose when no document is open, or the request is purely conceptual with no concrete change to propose. - BIAS TOWARD ACTION on edit requests. If the user says "edit out X", "remove the Y paragraph", "change Z" — call the edit tool with your best interpretation. Don't ask for clarification on minor ambiguity. The user can undo. - AFTER A TOOL SUCCEEDS, do not second-guess. A success response means it worked. Reply in ONE short sentence confirming what was done. No verification thinking, no re-analyzing — move on. @@ -642,6 +643,7 @@ def _build_system_prompt( f'ACTIVE EMAIL DRAFT (open in editor — the user is looking at this right now)\n' f'Title: "{active_document.title}"\n' f'```\n{_doc_raw}\n```\n\n' + f'This is the current email compose window, not a normal document library item. If the user says "write", "draft", "reply", "make it say", or "write the email" without naming another target, edit THIS email draft.\n\n' f'When the user asks you to write, reply to, or improve this email:\n' f'1. Use `update_document` to replace the ENTIRE content — keep all the header lines (To, Subject, In-Reply-To, References, X-Source-UID, X-Source-Folder, X-Attachments) and the `---` separator EXACTLY as they are.\n' f'2. Replace ONLY the body text (the part after `---`). If there is a quoted original email (lines starting with `>`), keep that quoted block unchanged BELOW your new reply.\n' @@ -792,7 +794,7 @@ def _build_system_prompt( # When creating email documents, instruct the AI on the format if relevant_tools and (_EMAIL_TOOL_HINTS & set(relevant_tools)): agent_prompt += ( - '\n\n📧 EMAIL DOCUMENT FORMAT: When drafting email replies, use create_document with language="email". ' + '\n\n📧 EMAIL DOCUMENT FORMAT: If no email draft is already open and you need to create an email draft, use create_document with language="email". ' 'The content format is:\n' 'To: recipient@example.com\n' 'Subject: Re: Original subject\n' @@ -800,8 +802,8 @@ def _build_system_prompt( 'References: \n' '---\n' 'Body text here...\n\n' - 'The user can then edit and click Send or Draft in the editor. For an already-open email draft, ' - 'edit the current document instead of creating another one.' + 'The user can then edit and click Send or Draft in the editor. If an email draft is already open, ' + 'that open draft is the target: use update_document/edit_document on it instead of creating another document.' ) # Inject relevant skills based on the user's last message. The diff --git a/src/llm_core.py b/src/llm_core.py index 18ccba7..77afbd1 100644 --- a/src/llm_core.py +++ b/src/llm_core.py @@ -603,44 +603,98 @@ def _sanitize_llm_messages(messages: List[Dict]) -> List[Dict]: elif "content" in item: cleaned.append(item) - # Merge consecutive user messages to satisfy strict role alternation requirements. - merged = [] - for item in cleaned: + # Repair tool-call adjacency before sending to any OpenAI-compatible + # provider. Trimming/compaction/retries can leave `role:"tool"` messages + # without their immediately-preceding assistant `tool_calls` parent, which + # DeepSeek rejects with: + # "Messages with role 'tool' must be a response to a preceding message with + # 'tool_calls'". Also strip unanswered assistant tool_calls; some providers + # reject those as incomplete conversations. + repaired: List[Dict] = [] + i = 0 + while i < len(cleaned): + msg = cleaned[i] + role = msg.get("role") + + if role == "tool": + # Orphan tool result. There is no valid assistant tool_calls parent + # immediately before this batch, so it cannot be sent. + logger.debug("Dropping orphan tool message before provider request") + i += 1 + continue + + tool_calls = msg.get("tool_calls") if role == "assistant" else None + if not tool_calls: + repaired.append(msg) + i += 1 + continue + + call_ids = [ + str(tc.get("id")) + for tc in tool_calls + if isinstance(tc, dict) and tc.get("id") + ] + expected = set(call_ids) + answered_ids = [] + tool_batch = [] + j = i + 1 + while j < len(cleaned) and cleaned[j].get("role") == "tool": + tid = str(cleaned[j].get("tool_call_id") or "") + if tid in expected and tid not in answered_ids: + answered_ids.append(tid) + tool_batch.append(cleaned[j]) + else: + logger.debug("Dropping unmatched/duplicate tool message before provider request") + j += 1 + + if not tool_batch: + plain = {k: v for k, v in msg.items() if k != "tool_calls"} + if (plain.get("content") or "").strip(): + repaired.append(plain) + else: + logger.debug("Dropping unanswered assistant tool_calls before provider request") + i = j + continue + + answered = set(answered_ids) + pruned_calls = [ + tc for tc in tool_calls + if isinstance(tc, dict) and str(tc.get("id")) in answered + ] + fixed = dict(msg) + fixed["tool_calls"] = pruned_calls + if "content" not in fixed: + fixed["content"] = None + repaired.append(fixed) + repaired.extend(tool_batch) + if len(pruned_calls) != len(tool_calls): + logger.debug("Pruned unanswered assistant tool_calls before provider request") + i = j + + # Merge consecutive user messages to satisfy strict role alternation + # requirements after invalid tool-call fragments have been removed. + merged: List[Dict] = [] + for item in repaired: if not merged: merged.append(item) continue last = merged[-1] - if last["role"] == "user" and item["role"] == "user": + if last.get("role") == "user" and item.get("role") == "user": last_copy = dict(last) - - # Content: - last_content = last_copy.get("content") - item_content = item.get("content") - - # Convert contents to string if they exist, or keep None/empty - last_str = str(last_content) if last_content is not None else "" - item_str = str(item_content) if item_content is not None else "" - - if last_str and item_str: - new_content = f"{last_str}\n\n{item_str}" - elif last_str: - new_content = last_str - else: - new_content = item_str if item_str else None - - if new_content is not None: + last_str = str(last_copy.get("content")) if last_copy.get("content") is not None else "" + item_str = str(item.get("content")) if item.get("content") is not None else "" + new_content = "\n\n".join(part for part in (last_str, item_str) if part) + if new_content: last_copy["content"] = new_content - elif "content" in last_copy: - del last_copy["content"] - + else: + last_copy.pop("content", None) merged[-1] = last_copy else: merged.append(item) return merged - def _normalize_anthropic_url(url: str) -> str: """Ensure Anthropic URL points to /v1/messages.""" url = url.rstrip("/") diff --git a/src/tool_index.py b/src/tool_index.py index fa7ba31..5e27e1a 100644 --- a/src/tool_index.py +++ b/src/tool_index.py @@ -65,7 +65,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = { "web_fetch": "Fetch and read the text content of a specific URL/website the user names (e.g. 'check example.com', 'open this link'). Use when you have a concrete URL; for open-ended lookups use web_search instead.", "read_file": "Read a file from disk and return its contents. View source code, config files, logs.", "write_file": "Write content to a file on disk. Create new files, save output, update configs.", - "create_document": "Create a new document in the editor panel. For code, articles, text content longer than 15 lines. Specify title, language, and content.", + "create_document": "Create a new document in the editor panel. For code, articles, text content longer than 15 lines, unless an already-open document/email draft is the obvious target. If an email compose draft is open, edit that draft instead of creating another document.", "edit_document": "Preferred tool for editing an existing document — targeted find-and-replace. Use for any small change: add a function, fix a bug, tweak a section, rename things.", "update_document": "Replace the entire active document content. ONLY for full rewrites (>50% changed). Do not use for small edits — use edit_document instead.", "suggest_document": "Suggest changes to the active document with explanations. For code review, proofreading, feedback requests.", diff --git a/src/tool_schemas.py b/src/tool_schemas.py index 394e138..f805e8f 100644 --- a/src/tool_schemas.py +++ b/src/tool_schemas.py @@ -111,7 +111,7 @@ FUNCTION_TOOL_SCHEMAS = [ "type": "function", "function": { "name": "create_document", - "description": "Create a new document in the editor panel. ALWAYS use this when the user asks to write, create, build, or generate code, scripts, programs, games, apps, or any substantial content (>15 lines). NEVER put large code blocks directly in chat — use this tool instead.", + "description": "Create a new document in the editor panel. Use this when the user asks to write, create, build, or generate code, scripts, programs, games, apps, or any substantial content (>15 lines) AND there is no already-open document/email draft that the request refers to. If an email compose draft is open, edit that draft instead of creating another document. NEVER put large code blocks directly in chat — use this tool instead.", "parameters": { "type": "object", "properties": { diff --git a/static/js/cookbook-hwfit.js b/static/js/cookbook-hwfit.js index 98b2f16..99e391d 100644 --- a/static/js/cookbook-hwfit.js +++ b/static/js/cookbook-hwfit.js @@ -213,9 +213,33 @@ export function _renderGpuToggles(system) { // reload paints instantly, then we refresh in the background and swap. const _SCAN_CACHE_KEY = 'hwfit_scan_cache_v1'; const _MANUAL_HW_KEY = 'hwfit_manual_hardware_v1'; +const _CTX_KEY = 'hwfit_target_context_v1'; +const _CTX_PRESETS = [8192, 16384, 32768, 50000, 131072, 0]; // 0 = model max const _SCAN_CACHE_MAX = 12; // keep the newest N signatures const _SCAN_CACHE_TTL = 6 * 3600 * 1000; // 6 h — hardware rarely changes +function _ctxLabel(value) { + const n = Number(value) || 0; + if (!n) return 'Max'; + return n >= 1000 ? Math.round(n / 1000) + 'k' : String(n); +} + +function _ctxValue() { + const slider = document.getElementById('hwfit-context'); + const idx = Math.max(0, Math.min(_CTX_PRESETS.length - 1, Number(slider?.value ?? 3) || 0)); + return _CTX_PRESETS[idx] || 0; +} + +function _syncCtxControl() { + const slider = document.getElementById('hwfit-context'); + const label = document.getElementById('hwfit-context-label'); + if (!slider) return; + const saved = localStorage.getItem(_CTX_KEY); + const savedIdx = saved == null ? 3 : _CTX_PRESETS.indexOf(Number(saved)); + slider.value = String(savedIdx >= 0 ? savedIdx : 3); + if (label) label.textContent = _ctxLabel(_ctxValue()); +} + function _manualHwState() { try { const s = JSON.parse(localStorage.getItem(_MANUAL_HW_KEY) || '{}'); @@ -316,6 +340,7 @@ function _scanSig() { o: sortEl?.value || 'score', r: sortEl?.dataset.reverse === '1' ? 1 : 0, q: document.getElementById('hwfit-quant')?.value || '', + c: _ctxValue(), g: (tc && typeof tc._activeCount === 'number') ? String(tc._activeCount) : '', gg: (tc && tc._activeGroup) ? String(tc._activeGroup) : '', m: _manualHwParams(), @@ -440,6 +465,7 @@ export async function _hwfitFetch(fresh = false) { try { const sortBy = document.getElementById('hwfit-sort')?.value || 'score'; const quantPref = document.getElementById('hwfit-quant')?.value || ''; + const targetCtx = _ctxValue(); // Get active GPU count from toggles const toggleContainer = document.getElementById('hwfit-gpu-toggles'); let gpuCountOverride = ''; @@ -475,6 +501,7 @@ export async function _hwfitFetch(fresh = false) { if (!isImageMode) { if (useCase) params.set('use_case', useCase); if (quantPref) params.set('quant', quantPref); + if (targetCtx) params.set('ctx', String(targetCtx)); } const endpoint = isImageMode ? `/api/hwfit/image-models?${params}` : `/api/hwfit/models?${params}`; const res = await fetch(endpoint); @@ -770,6 +797,20 @@ function _wireManualHardwareControls(el) { export const _fitColors = { perfect: 'var(--green, #50fa7b)', good: 'var(--yellow, #f1fa8c)', marginal: 'var(--orange, #ffb86c)', too_tight: 'var(--red, #ff5555)' }; +function _requiresAcceleratorBackend(model) { + const q = String(model?.quant || model?.quantization || '').toUpperCase(); + const text = `${model?.name || ''} ${model?.repo_id || ''} ${model?.path || ''}`.toLowerCase(); + return /^AWQ|^GPTQ|^NVFP4/.test(q) || q === 'FP8' || /\b(awq|gptq|fp8|nvfp4)\b/i.test(text); +} + +function _modeLabel(model) { + if (model?.is_image_gen) return 'image'; + if (_requiresAcceleratorBackend(model)) return 'vLLM/SGLang'; + const detected = _detectBackend(model); + if (detected?.label) return detected.label; + return String(model?.run_mode || '').replace('_', '+'); +} + export const _hwfitColumns = [ { key: 'fit', label: 'Fit', cls: 'hwfit-fit' }, { key: null, label: 'Model', cls: 'hwfit-name' }, @@ -827,9 +868,7 @@ export function _hwfitRenderList(el, models) { const pcount = m.parameter_count || '?'; const ctx = m.context ? (m.context >= 1024 ? (m.context / 1024).toFixed(0) + 'k' : m.context) : '?'; const fitLabel = (m.fit_level || '').replace('_', ' '); - const modeLabel = m.run_mode === 'cpu_offload' - ? 'cpu+offload' - : (m.run_mode || '').replace(/_/g, ' '); + const modeLabel = _modeLabel(m); const vramLabel = m.required_gb ? m.required_gb.toFixed(1) + 'G' : '?'; const moeBadge = m.is_moe ? 'MoE' : ''; const imgBadge = m.is_image_gen ? 'IMG' : ''; @@ -843,7 +882,7 @@ export function _hwfitRenderList(el, models) { html += `${m.is_image_gen ? '\u2014' : ctx}`; html += `${m.is_image_gen ? '\u2014' : tps + ' t/s'}`; html += `${score}`; - html += `${m.is_image_gen ? 'image' : esc(modeLabel)}`; + html += `${esc(modeLabel)}`; html += ``; } el.innerHTML = html; @@ -943,6 +982,8 @@ export function _expandModelRow(row, modelData) { html += ``; if (modelData.is_image_gen) { html += `
${esc((modelData.capabilities || []).join(' \u00B7 ') || '')}${modelData.description ? ' \u2014 ' + esc(modelData.description) : ''}
`; + } else if (_requiresAcceleratorBackend(modelData)) { + html += `
This is a safetensors GPU-serving format. Use vLLM/SGLang with a visible CUDA/ROCm accelerator, or pick a GGUF download for llama.cpp/Ollama.
`; } html += ``; @@ -1139,8 +1180,11 @@ export function _hwfitInit() { const uc = document.getElementById('hwfit-usecase'); const sort = document.getElementById('hwfit-sort'); const qpref = document.getElementById('hwfit-quant'); + const ctx = document.getElementById('hwfit-context'); + const ctxLabel = document.getElementById('hwfit-context-label'); const search = document.getElementById('hwfit-search'); const remote = document.getElementById('hwfit-host'); + _syncCtxControl(); if (uc) uc.addEventListener('change', () => _hwfitFetch()); if (sort) sort.addEventListener('change', () => _hwfitFetch()); if (qpref) qpref.addEventListener('change', () => _hwfitFetch()); @@ -1155,6 +1199,28 @@ export function _hwfitInit() { _hwfitFetch(); } }); + if (ctx && !ctx.dataset.bound) { + ctx.dataset.bound = '1'; + ctx.addEventListener('input', () => { + if (ctxLabel) ctxLabel.textContent = _ctxLabel(_ctxValue()); + }); + ctx.addEventListener('change', () => { + const targetCtx = _ctxValue(); + try { localStorage.setItem(_CTX_KEY, String(targetCtx)); } catch {} + const sortSel = document.getElementById('hwfit-sort'); + if (sortSel) { + if (targetCtx) { + sortSel.value = 'fit'; + sortSel.dataset.reverse = '1'; + } else { + sortSel.value = 'score'; + sortSel.dataset.reverse = ''; + } + } + _hwfitCache = null; + _hwfitFetch(); + }); + } // Rescan — force a fresh hardware probe (bypasses the per-host cache). const rescan = document.getElementById('hwfit-rescan'); if (rescan && !rescan.dataset.bound) { diff --git a/static/js/cookbook.js b/static/js/cookbook.js index 2ef4e41..d4f264e 100644 --- a/static/js/cookbook.js +++ b/static/js/cookbook.js @@ -1528,6 +1528,10 @@ function _renderRecipes() { html += ''; html += ''; html += ''; + html += '?'; + html += ''; html += ''; html += '
'; html += ' - - + + - - - - - @@ -4308,6 +4409,33 @@ import * as Modals from './modalManager.js'; }); document.getElementById('doc-email-ai-reply-btn')?.addEventListener('click', _aiReply); + const collapseBtn = document.getElementById('doc-email-collapse-btn'); + if (collapseBtn && !collapseBtn._emailCollapseWired) { + collapseBtn._emailCollapseWired = true; + collapseBtn.addEventListener('pointerdown', (e) => { + e.preventDefault(); + e.stopPropagation(); + const focusState = _captureEmailBodyFocusState(); + const header = document.getElementById('doc-email-header'); + const nextCollapsed = !header?.classList.contains('doc-email-header-collapsed'); + _setEmailHeaderCollapsed(nextCollapsed); + if (!nextCollapsed) _restoreEmailBodyFocusState(focusState); + }); + collapseBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + } + ['doc-email-to', 'doc-email-cc', 'doc-email-bcc', 'doc-email-subject'].forEach(id => { + document.getElementById(id)?.addEventListener('input', _syncEmailHeaderSummary); + document.getElementById(id)?.addEventListener('focus', () => _setEmailHeaderCollapsed(false, { manual: false })); + }); + document.getElementById('doc-email-richbody')?.addEventListener('focus', _maybeAutoCollapseEmailHeader); + if (window.visualViewport && !window._docEmailViewportCollapseBound) { + window._docEmailViewportCollapseBound = true; + window.visualViewport.addEventListener('resize', _maybeAutoCollapseEmailHeader); + } + // Split-button caret toggles the send-options menu (drops up). document.getElementById('doc-email-send-caret')?.addEventListener('click', (e) => { e.stopPropagation(); @@ -4350,11 +4478,13 @@ import * as Modals from './modalManager.js'; // Cc/Bcc toggle document.getElementById('doc-email-show-cc')?.addEventListener('click', () => { + _setEmailHeaderCollapsed(false, { manual: false }); const ccRow = document.getElementById('doc-email-cc-row'); const bccRow = document.getElementById('doc-email-bcc-row'); if (ccRow) ccRow.style.display = ''; if (bccRow) bccRow.style.display = ''; document.getElementById('doc-email-show-cc').style.display = 'none'; + _syncEmailHeaderSummary(); }); // Autocomplete for To / Cc / Bcc — typed fragment after the last diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js index 806d652..9cf38c1 100644 --- a/static/js/emailLibrary.js +++ b/static/js/emailLibrary.js @@ -27,6 +27,183 @@ const API_BASE = window.location.origin; let _emailUnreadChipClickWired = false; let _libLoadSeq = 0; let _libFolderSeq = 0; +let _libSearchSeq = 0; +let _libSearchHadResults = false; +let _activeEmailReaderForSelectAll = null; + +function _isEmailTypingTarget(t) { + return !!(t && ( + t.tagName === 'INPUT' || + t.tagName === 'TEXTAREA' || + t.tagName === 'SELECT' || + t.isContentEditable + )); +} + +function _selectEmailReaderContents(reader) { + if (!reader || !reader.isConnected) return false; + const hiddenModal = reader.closest('.modal.hidden'); + if (hiddenModal) return false; + const range = document.createRange(); + range.selectNodeContents(reader); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + return true; +} + +function _markEmailReaderActive(reader) { + if (!reader) return; + _activeEmailReaderForSelectAll = reader; + if (reader.dataset.selectAllWired === '1') return; + reader.dataset.selectAllWired = '1'; + reader.addEventListener('pointerdown', () => { _activeEmailReaderForSelectAll = reader; }, true); + reader.addEventListener('focusin', () => { _activeEmailReaderForSelectAll = reader; }, true); +} + +const _COPY_EMAIL_ICON = ''; + +function _decodeAttrValue(v) { + const tmp = document.createElement('textarea'); + tmp.innerHTML = v || ''; + return tmp.value; +} + +function _emailAddressFromRecipientText(text) { + const raw = String(text || '').trim(); + const angle = raw.match(/<\s*([^<>@\s]+@[^<>\s]+)\s*>/); + if (angle) return angle[1].trim(); + const any = raw.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i); + return any ? any[0].trim() : raw; +} + +function _splitRecipientList(raw) { + const out = []; + let cur = ''; + let quote = false; + let angle = false; + const s = String(raw || ''); + for (let i = 0; i < s.length; i += 1) { + const ch = s[i]; + if (ch === '"' && s[i - 1] !== '\\') quote = !quote; + else if (ch === '<' && !quote) angle = true; + else if (ch === '>' && !quote) angle = false; + + if (ch === ',' && !quote && !angle) { + const part = cur.trim(); + if (part) out.push(part); + cur = ''; + continue; + } + cur += ch; + } + const tail = cur.trim(); + if (tail) out.push(tail); + return out; +} + +async function _copyTextToClipboard(text) { + const value = String(text || ''); + if (!value) return false; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return true; + } + } catch (_) {} + try { + const ta = document.createElement('textarea'); + ta.value = value; + ta.setAttribute('readonly', ''); + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + ta.style.top = '0'; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand('copy'); + ta.remove(); + return !!ok; + } catch (_) { + return false; + } +} + +function _recipientChipHtml(full, label, extraClass = '') { + const fullText = String(full || '').trim(); + const addr = _emailAddressFromRecipientText(fullText); + const labelText = String(label || addr || fullText || '').trim(); + const cls = `recipient-chip${extraClass ? ` ${extraClass}` : ''}`; + return `${_esc(labelText)}`; +} + +function _wireRecipientChips(root) { + if (!root || root.dataset.recipientChipsWired === '1') return; + root.dataset.recipientChipsWired = '1'; + root.addEventListener('click', async (ev) => { + const copyBtn = ev.target.closest?.('.recipient-chip-copy'); + if (copyBtn && root.contains(copyBtn)) { + ev.stopPropagation(); + ev.preventDefault(); + const chip = copyBtn.closest('.recipient-chip'); + const email = chip?.dataset.email || _emailAddressFromRecipientText(_decodeAttrValue(chip?.dataset.full || '')); + if (!email) return; + try { + const copied = await _copyTextToClipboard(email); + if (!copied) throw new Error('copy failed'); + copyBtn.classList.add('copied'); + copyBtn.title = 'Copied'; + showToast?.('Email copied'); + setTimeout(() => { + copyBtn.classList.remove('copied'); + copyBtn.title = 'Copy email'; + }, 900); + } catch (_) { + showToast?.('Copy failed'); + } + return; + } + + const chip = ev.target.closest?.('.recipient-chip'); + if (!chip || !root.contains(chip)) return; + ev.stopPropagation(); + ev.preventDefault(); + const label = chip.querySelector('.recipient-chip-label'); + const copy = chip.querySelector('.recipient-chip-copy'); + if (chip.classList.contains('expanded')) { + chip.classList.remove('expanded'); + if (label) label.textContent = chip.dataset.name || label.textContent; + if (copy) copy.hidden = true; + } else { + if (!chip.dataset.name && label) chip.dataset.name = label.textContent.trim(); + chip.classList.add('expanded'); + const expandedText = _decodeAttrValue(chip.dataset.full || '').trim() + || chip.dataset.name + || chip.dataset.email + || label?.textContent?.trim() + || ''; + if (label && expandedText) label.textContent = expandedText; + if (copy) copy.hidden = false; + } + }); +} + +function _emailReaderForSelectAllTarget(target) { + if (_isEmailTypingTarget(target)) return null; + const direct = target?.closest?.('.email-card-reader, #email-lib-modal .doclib-card.doclib-card-expanded'); + if (direct) return direct.querySelector?.('.email-card-reader') || direct; + const expanded = document.querySelector('#email-lib-modal:not(.hidden) .doclib-card.doclib-card-expanded .email-card-reader'); + if (expanded) return expanded; + return _activeEmailReaderForSelectAll; +} + +document.addEventListener('keydown', (e) => { + if (!(e.ctrlKey || e.metaKey) || String(e.key || '').toLowerCase() !== 'a') return; + const reader = _emailReaderForSelectAllTarget(e.target); + if (!_selectEmailReaderContents(reader)) return; + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation?.(); +}, true); function _syncEmailReadState(uid, isRead = true) { if (uid == null) return; @@ -1047,10 +1224,26 @@ export function openEmailLibrary(opts = {}) { _bulkAction('delete'); }); + const selectExpandedEmailText = () => { + const expanded = document.querySelector('#email-lib-modal .doclib-card.doclib-card-expanded'); + const reader = expanded?.querySelector('.email-card-reader') || expanded; + return _selectEmailReaderContents(reader); + }; + // ESC to close + Arrow nav + Delete on the selected / currently-expanded email. state._libEscHandler = (e) => { const modal = document.getElementById('email-lib-modal'); if (!modal || modal.classList.contains('hidden')) return; + if ((e.ctrlKey || e.metaKey) && String(e.key || '').toLowerCase() === 'a') { + const t = e.target; + if (_isEmailTypingTarget(t)) return; + if (selectExpandedEmailText()) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation?.(); + } + return; + } if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); @@ -1067,7 +1260,7 @@ export function openEmailLibrary(opts = {}) { } // Don't hijack arrows / delete while the user is typing somewhere. const t = e.target; - if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; + if (_isEmailTypingTarget(t)) return; const isDeleteKey = e.key === 'Delete' || e.key === 'Backspace'; if (isDeleteKey && state._selectMode && state._selectedUids.size > 0) { e.preventDefault(); @@ -1193,6 +1386,23 @@ function _makeDraggable(content, modal, fsClass) { fsClass, skipSelector: '.close-btn, .modal-close', enableLeftDock: true, // park the email on the left while replying on the right + onDragStart: ({ rect }) => { + if (!modal.classList.contains('email-snap-left')) return; + modal.classList.remove('email-snap-left'); + _clearEmailDocumentSplit(); + content.style.position = 'fixed'; + content.style.left = `${Math.round(rect.left)}px`; + content.style.top = `${Math.round(rect.top)}px`; + content.style.right = ''; + content.style.bottom = ''; + content.style.width = `${Math.max(420, Math.round(rect.width || 560))}px`; + content.style.maxWidth = ''; + content.style.height = `${Math.max(320, Math.round(rect.height || 620))}px`; + content.style.maxHeight = '85vh'; + content.style.borderRadius = ''; + content.style.transform = 'none'; + content.style.margin = '0'; + }, onEnterFullscreen: fsClass ? enterFullscreen : null, onExitFullscreen: fsClass ? exitFullscreen : null, }); @@ -1316,22 +1526,43 @@ function _crossFolderCandidates() { } async function _doSearch() { + const seq = ++_libSearchSeq; const q = state._libSearch.trim(); if (q.length < 2) { - // Empty or too short — show regular loaded emails + // Empty or too short — restore the normal folder if a previous search + // had replaced the grid contents. + if (_libSearchHadResults) { + _libSearchHadResults = false; + state._libOffset = 0; + await _loadEmails({ useCache: true }); + return; + } _renderGrid(); return; } const grid = document.getElementById('email-lib-grid'); if (!grid) return; const sp = _renderEmailLoading(grid); + const accountAtStart = state._libAccountId || ''; + const folderAtStart = state._libFolder || 'INBOX'; try { - const res = await fetch(`${API_BASE}/api/email/search?folder=${encodeURIComponent(state._libFolder)}${_acct()}&q=${encodeURIComponent(q)}&limit=100`); + const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : ''; + const res = await fetch(`${API_BASE}/api/email/search?folder=${encodeURIComponent(folderAtStart)}${accountQS}&q=${encodeURIComponent(q)}&limit=100`); const data = await res.json(); sp.destroy(); + if ( + seq !== _libSearchSeq || + q !== state._libSearch.trim() || + accountAtStart !== (state._libAccountId || '') || + folderAtStart !== (state._libFolder || 'INBOX') + ) { + return; + } + if (data.error) throw new Error(data.error); const results = data.emails || []; + _libSearchHadResults = true; state._libEmails = results; // temporarily replace with search results _renderGrid(); @@ -1895,8 +2126,9 @@ function _syncCardNavArrows(card) { } const _emailReadPrefetching = new Set(); +let _emailReadPrefetchTimer = null; -function _prefetchAdjacentEmails(card, count = 3) { +function _prefetchAdjacentEmails(card, count = 1) { if (!card || state._libFolder === '__scheduled__') return; const grid = card.closest('.doclib-grid'); if (!grid) return; @@ -1910,16 +2142,19 @@ function _prefetchAdjacentEmails(card, count = 3) { if (targets.length < count) { for (let i = 1; targets.length < count && cards[idx - i]; i++) targets.push(cards[idx - i]); } - for (const target of targets) { - const uid = target.dataset.uid; - if (!uid) continue; - const key = `${state._libAccountId || ''}|${state._libFolder}|${uid}`; - if (_emailReadPrefetching.has(key)) continue; + const target = targets.find(t => t?.dataset?.uid); + const uid = target?.dataset?.uid; + if (!uid) return; + const key = `${state._libAccountId || ''}|${state._libFolder}|${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`) .catch(() => {}) .finally(() => _emailReadPrefetching.delete(key)); - } + }, 900); } async function _toggleCardPreview(card, em) { @@ -1987,6 +2222,7 @@ async function _toggleCardPreview(card, em) { loadingWrap.appendChild(sp.element); reader.appendChild(loadingWrap); card.appendChild(reader); + _markEmailReaderActive(reader); try { const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`); @@ -2032,16 +2268,16 @@ async function _toggleCardPreview(card, em) { // Build recipient chip group from a comma-separated address list const buildRecipients = (str) => { if (!str) return ''; - const addrs = str.split(',').map(s => s.trim()).filter(Boolean); + const addrs = _splitRecipientList(str); if (addrs.length === 0) return ''; return addrs.map(a => { const name = _extractName(a); - return `${_esc(name)}`; + return _recipientChipHtml(a, name); }).join(''); }; // Build the From chip too — single chip with name, click reveals address - const fromChip = `${_esc(data.from_name || data.from_address)}`; + const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip'); reader.innerHTML = `