Polish email tasks and window controls

This commit is contained in:
pewdiepie-archdaemon
2026-06-01 20:56:11 +09:00
parent 5c390d6b3e
commit 5ed9b74cd0
14 changed files with 919 additions and 203 deletions
+21 -8
View File
@@ -15,6 +15,7 @@ and `email_pollers.py` (the background loops):
import os import os
import imaplib import imaplib
import smtplib import smtplib
import ssl
import email as email_mod import email as email_mod
import email.header import email.header
import email.utils import email.utils
@@ -50,17 +51,29 @@ def _send_smtp_message(cfg: dict, from_addr: str, recipients: list[str], message
port = int(cfg.get("smtp_port") or 465) port = int(cfg.get("smtp_port") or 465)
user = cfg.get("smtp_user") or "" user = cfg.get("smtp_user") or ""
password = cfg.get("smtp_password") or "" password = cfg.get("smtp_password") or ""
if port == 587: def _send_starttls(starttls_port: int = 587) -> None:
with smtplib.SMTP(host, port, timeout=timeout) as smtp: with smtplib.SMTP(host, starttls_port, timeout=timeout) as smtp:
smtp.starttls() smtp.starttls()
if user and password: if user and password:
smtp.login(user, password) smtp.login(user, password)
smtp.sendmail(from_addr, recipients, message) smtp.sendmail(from_addr, recipients, message)
if port == 587:
_send_starttls(587)
return return
with smtplib.SMTP_SSL(host, port, timeout=timeout) as smtp:
if user and password: try:
smtp.login(user, password) with smtplib.SMTP_SSL(host, port, timeout=timeout) as smtp:
smtp.sendmail(from_addr, recipients, message) if user and password:
smtp.login(user, password)
smtp.sendmail(from_addr, recipients, message)
return
except (TimeoutError, ssl.SSLError) as e:
if port == 465:
logger.warning("SMTP implicit TLS on %s:465 failed (%s); retrying STARTTLS on 587", host, e)
_send_starttls(587)
return
raise
def _strip_think(text: str) -> str: def _strip_think(text: str) -> str:
@@ -82,8 +95,8 @@ def _strip_think(text: str) -> str:
import re as _re_reply import re as _re_reply
# Accept REPLY / SUMMARY / OUTPUT as the opening fence so the same extractor # Accept REPLY / SUMMARY / OUTPUT as the opening fence so the same extractor
# serves replies and summaries (any fenced final-output block). # serves replies and summaries (any fenced final-output block).
_REPLY_OPEN_RE = _re_reply.compile(r"<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>>", _re_reply.I) _REPLY_OPEN_RE = _re_reply.compile(r"<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+", _re_reply.I)
_REPLY_CLOSE_RE = _re_reply.compile(r"<<<\s*END\s*>>>", _re_reply.I) _REPLY_CLOSE_RE = _re_reply.compile(r"<<<\s*END\s*>>+", _re_reply.I)
def _extract_reply(text: str) -> str: def _extract_reply(text: str) -> str:
+94 -24
View File
@@ -23,6 +23,7 @@ import json
import re import re
import html import html
import logging import logging
import inspect
from datetime import datetime from datetime import datetime
from email.mime.text import MIMEText from email.mime.text import MIMEText
@@ -46,10 +47,22 @@ logger = logging.getLogger(__name__)
# ── Routes ── # ── Routes ──
async def _emit_progress(progress_cb, message: str):
if not progress_cb:
return
try:
res = progress_cb(message)
if inspect.isawaitable(res):
await res
except Exception:
logger.debug("Email task progress callback failed", exc_info=True)
async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = True, async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = True,
do_tag: bool = False, do_spam: bool = False, do_tag: bool = False, do_spam: bool = False,
do_calendar: bool = False, do_calendar: bool = False,
days_back: int = 1) -> str: days_back: int = 1,
progress_cb=None) -> str:
"""One iteration of the email scan. Temporarily flips settings flags """One iteration of the email scan. Temporarily flips settings flags
so the existing background-loop logic runs exactly once for the requested ops.""" so the existing background-loop logic runs exactly once for the requested ops."""
settings = _load_settings() settings = _load_settings()
@@ -63,7 +76,7 @@ async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = Tru
settings["email_auto_calendar"] = bool(do_calendar) settings["email_auto_calendar"] = bool(do_calendar)
_save_settings(settings) _save_settings(settings)
try: try:
return await _auto_summarize_pass(days_back=days_back) return await _auto_summarize_pass(days_back=days_back, progress_cb=progress_cb)
finally: finally:
s2 = _load_settings() s2 = _load_settings()
for k, v in prev.items(): for k, v in prev.items():
@@ -71,7 +84,7 @@ async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = Tru
_save_settings(s2) _save_settings(s2)
async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None) -> str: async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None, progress_cb=None) -> str:
"""Single pass of the auto-summarize/reply scan. """Single pass of the auto-summarize/reply scan.
When account_id is None, iterates over every enabled account in When account_id is None, iterates over every enabled account in
@@ -98,20 +111,21 @@ async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None
names = {} names = {}
if len(ids) <= 1: if len(ids) <= 1:
# Single-account (or zero rows — fallback to legacy settings.json lookup) # Single-account (or zero rows — fallback to legacy settings.json lookup)
return await _auto_summarize_pass_single(days_back=days_back, account_id=(ids[0] if ids else None)) return await _auto_summarize_pass_single(days_back=days_back, account_id=(ids[0] if ids else None), progress_cb=progress_cb)
outs = [] outs = []
for aid in ids: for idx, aid in enumerate(ids, start=1):
try: try:
result = await _auto_summarize_pass_single(days_back=days_back, account_id=aid) await _emit_progress(progress_cb, f"{names.get(aid, aid[:8])}: starting ({idx}/{len(ids)})")
result = await _auto_summarize_pass_single(days_back=days_back, account_id=aid, progress_cb=progress_cb)
outs.append(f"[{names.get(aid, aid[:8])}] {result}") outs.append(f"[{names.get(aid, aid[:8])}] {result}")
except Exception as e: except Exception as e:
logger.warning(f"auto-summarize pass failed for account {aid}: {e}") logger.warning(f"auto-summarize pass failed for account {aid}: {e}")
outs.append(f"[{names.get(aid, aid[:8])}] error: {e}") outs.append(f"[{names.get(aid, aid[:8])}] error: {e}")
return "\n".join(outs) return "\n".join(outs)
return await _auto_summarize_pass_single(days_back=days_back, account_id=account_id) return await _auto_summarize_pass_single(days_back=days_back, account_id=account_id, progress_cb=progress_cb)
async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None = None) -> str: async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None = None, progress_cb=None) -> str:
"""Single pass of the auto-summarize/reply scan for ONE account. """Single pass of the auto-summarize/reply scan for ONE account.
Reads current settings flags.""" Reads current settings flags."""
import asyncio import asyncio
@@ -130,11 +144,13 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
return "Nothing to do" return "Nothing to do"
try: try:
await _emit_progress(progress_cb, "Connecting to mail…")
conn = _imap_connect(account_id) conn = _imap_connect(account_id)
from datetime import timedelta as _td from datetime import timedelta as _td
since = (datetime.utcnow() - _td(days=max(1, days_back))).strftime("%d-%b-%Y") since = (datetime.utcnow() - _td(days=max(1, days_back))).strftime("%d-%b-%Y")
# uid_list now carries (folder, uid) tuples — for calendar extraction we # uid_list carries real IMAP UIDs, matching the email UI/read routes.
# also scan Sent so the LLM sees confirmation/cancellation replies the user wrote. # Using sequence numbers here made background-cached replies miss when
# the user clicked the same visible message in the UI.
uid_list = [] uid_list = []
folders_to_scan = ["INBOX"] folders_to_scan = ["INBOX"]
if auto_cal: if auto_cal:
@@ -149,17 +165,33 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
for folder in folders_to_scan: for folder in folders_to_scan:
try: try:
conn.select(_q(folder), readonly=True) conn.select(_q(folder), readonly=True)
status, data = conn.search(None, f'(SINCE {since})') status, data = conn.uid("SEARCH", None, f'(SINCE {since})')
if status == "OK" and data[0]: if status == "OK" and data[0]:
for u in data[0].split()[-30:]: for u in reversed(data[0].split()[-30:]):
uid_list.append((folder, u)) uid_list.append((folder, u))
except Exception as _e: except Exception as _e:
logger.warning(f"Folder {folder} scan failed: {_e}") logger.warning(f"Folder {folder} scan failed: {_e}")
# Some IMAP servers/accounts give unreliable results for SINCE
# because of INTERNALDATE/date-header quirks. If the user manually
# runs a cacheable email task and SINCE finds nothing, fall back to
# the latest visible inbox messages so Clear cache -> Run again can
# actually repopulate AI reply/summary/tag caches.
if not uid_list:
try:
conn.select("INBOX", readonly=True)
status, data = conn.uid("SEARCH", None, "ALL")
if status == "OK" and data and data[0]:
for u in reversed(data[0].split()[-8:]):
uid_list.append(("INBOX", u))
logger.info("Email task SINCE scan found no messages; fell back to latest INBOX messages")
except Exception as _e:
logger.warning(f"Latest-INBOX fallback scan failed: {_e}")
# Re-select INBOX as default for downstream code # Re-select INBOX as default for downstream code
conn.select("INBOX", readonly=True) conn.select("INBOX", readonly=True)
if not uid_list: if not uid_list:
conn.logout() conn.logout()
return "No recent emails" return "No recent emails"
await _emit_progress(progress_cb, f"Found {len(uid_list)} recent email(s); checking cache…")
_c = _sql3.connect(SCHEDULED_DB) _c = _sql3.connect(SCHEDULED_DB)
_sum_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_summaries").fetchall()} _sum_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_summaries").fetchall()}
@@ -198,10 +230,15 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
too_short = 0 too_short = 0
no_msgid = 0 no_msgid = 0
examined = 0 examined = 0
_summaries_created = 0
_events_created = 0 _events_created = 0
_replies_drafted = 0
_reply_failed = 0
_detail_lines = []
_current_folder = "INBOX" _current_folder = "INBOX"
_max_process = 5
for _entry in uid_list: for _entry in uid_list:
if processed >= 10: if processed >= _max_process:
break break
# entry can be either a bare UID (legacy callers) or (folder, uid) tuple (new code) # entry can be either a bare UID (legacy callers) or (folder, uid) tuple (new code)
if isinstance(_entry, tuple): if isinstance(_entry, tuple):
@@ -212,7 +249,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
if _folder != _current_folder: if _folder != _current_folder:
conn.select(_q(_folder), readonly=True) conn.select(_q(_folder), readonly=True)
_current_folder = _folder _current_folder = _folder
st, msg_data = conn.fetch(uid, "(RFC822)") st, msg_data = conn.uid("FETCH", uid if isinstance(uid, bytes) else str(uid).encode(), "(RFC822)")
if st != "OK": if st != "OK":
continue continue
examined += 1 examined += 1
@@ -253,6 +290,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
and not _is_self_mail) and not _is_self_mail)
if not need_sum and not need_reply and not need_class and not need_cal and not need_urgent: if not need_sum and not need_reply and not need_class and not need_cal and not need_urgent:
already_cached += 1 already_cached += 1
await _emit_progress(progress_cb, f"Checked {examined}/{len(uid_list)} · {already_cached} already cached")
continue continue
subject = _decode_header(msg.get("Subject", "")) subject = _decode_header(msg.get("Subject", ""))
sender = _decode_header(msg.get("From", "")) sender = _decode_header(msg.get("From", ""))
@@ -267,12 +305,16 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
att_text = _extract_attachment_text(msg, max_chars=6000) att_text = _extract_attachment_text(msg, max_chars=6000)
except Exception as _ae: except Exception as _ae:
logger.debug(f"attachment text extraction failed for uid={uid}: {_ae}") logger.debug(f"attachment text extraction failed for uid={uid}: {_ae}")
# No threshold for calendar — even "see you tmrw 5pm" matters. # No threshold for calendar or reply drafting — even "can you
# Summary/reply/classify still need ≥100 chars to be worth the LLM cost. # confirm?" needs a reply. Summary/classify still need enough
# text to be worth the LLM cost.
# If body is short but attachments have content, treat it as enough. # If body is short but attachments have content, treat it as enough.
if need_cal: if need_cal:
if not body: if not body:
body = subject # at minimum send the subject line body = subject # at minimum send the subject line
elif need_reply:
if not body:
body = subject
elif (not body or len(body) < 100) and not att_text: elif (not body or len(body) < 100) and not att_text:
too_short += 1 too_short += 1
continue continue
@@ -317,16 +359,26 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
_c.execute(""" _c.execute("""
INSERT OR REPLACE INTO email_summaries INSERT OR REPLACE INTO email_summaries
(message_id, uid, folder, subject, sender, summary, model_used, created_at) (message_id, uid, folder, subject, sender, summary, model_used, created_at)
VALUES (?, ?, 'INBOX', ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (message_id, uid.decode(), subject, sender, summary, model, datetime.utcnow().isoformat())) """, (message_id, uid.decode() if isinstance(uid, bytes) else str(uid), _folder, subject, sender, summary, model, datetime.utcnow().isoformat()))
_c.commit() _c.commit()
_c.close() _c.close()
_sum_existing.add(message_id) _sum_existing.add(message_id)
_summaries_created += 1
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
_detail_lines.append(f"summary · {_folder}#{_uid_text} · {subject or '(no subject)'}{sender or '(unknown sender)'}")
except Exception as e: except Exception as e:
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
_detail_lines.append(f"summary failed · {_folder}#{_uid_text} · {subject or '(no subject)'}{sender or '(unknown sender)'}")
logger.warning(f"Auto-summary {uid} failed: {e}") logger.warning(f"Auto-summary {uid} failed: {e}")
if need_reply: if need_reply:
context_snippets, _terms = _pre_retrieve_context(body, sender) await _emit_progress(progress_cb, f"Drafting reply {processed + 1}/{_max_process} · checked {examined}/{len(uid_list)}")
# Background reply drafting should not make the whole app
# feel busy. Keep it lightweight: no extra IMAP context
# mining here; manual AI Reply can still do that when the
# user explicitly asks for a draft on one email.
context_snippets, _terms = [], []
sys_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE sys_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE
if att_text: if att_text:
sys_prompt += "\n\nThe email has attachments (PDFs / docs) — their contents follow the body marked '--- ATTACHMENTS ---'. Reference them in your reply when relevant (e.g. acknowledge the invoice/contract, address specific clauses or amounts)." sys_prompt += "\n\nThe email has attachments (PDFs / docs) — their contents follow the body marked '--- ATTACHMENTS ---'. Reference them in your reply when relevant (e.g. acknowledge the invoice/contract, address specific clauses or amounts)."
@@ -341,8 +393,8 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
{"role": "system", "content": sys_prompt}, {"role": "system", "content": sys_prompt},
{"role": "user", "content": f"Original email:\nFrom: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}\n\nDraft a reply. Return only the reply body text."}, {"role": "user", "content": f"Original email:\nFrom: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}\n\nDraft a reply. Return only the reply body text."},
], ],
temperature=0.7, max_tokens=16384, temperature=0.7, max_tokens=1024,
headers=req_headers, timeout=240, headers=req_headers, timeout=90,
) )
reply = _apply_email_style_mechanics(_extract_reply(reply or "")) reply = _apply_email_style_mechanics(_extract_reply(reply or ""))
if reply: if reply:
@@ -350,12 +402,20 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
_c.execute(""" _c.execute("""
INSERT OR REPLACE INTO email_ai_replies INSERT OR REPLACE INTO email_ai_replies
(message_id, uid, folder, reply, model_used, created_at) (message_id, uid, folder, reply, model_used, created_at)
VALUES (?, ?, 'INBOX', ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
""", (message_id, uid.decode(), reply, model, datetime.utcnow().isoformat())) """, (message_id, uid.decode() if isinstance(uid, bytes) else str(uid), _folder, reply, model, datetime.utcnow().isoformat()))
_c.commit() _c.commit()
_c.close() _c.close()
_reply_existing.add(message_id) _reply_existing.add(message_id)
_replies_drafted += 1
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
_detail_lines.append(f"reply · {_folder}#{_uid_text} · {subject or '(no subject)'}{sender or '(unknown sender)'}")
await _emit_progress(progress_cb, f"Drafted {_replies_drafted} repl" + ("y" if _replies_drafted == 1 else "ies") + f" · checked {examined}/{len(uid_list)}")
except Exception as e: except Exception as e:
_reply_failed += 1
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
_detail_lines.append(f"reply failed · {_folder}#{_uid_text} · {subject or '(no subject)'}{sender or '(unknown sender)'}")
await _emit_progress(progress_cb, f"Reply failed {_reply_failed} · checked {examined}/{len(uid_list)}")
logger.warning(f"Auto-reply {uid} failed: {e}") logger.warning(f"Auto-reply {uid} failed: {e}")
# ── Calendar event extraction (independent of reply drafting) ── # ── Calendar event extraction (independent of reply drafting) ──
@@ -805,6 +865,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
continue continue
conn.logout() conn.logout()
await _emit_progress(progress_cb, "Finishing…")
if processed > 0: if processed > 0:
logger.info(f"Auto-processed {processed} new email(s) for summary/reply/classify") logger.info(f"Auto-processed {processed} new email(s) for summary/reply/classify")
# Build a clear status message # Build a clear status message
@@ -817,6 +878,12 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
parts = [f"Scanned {len(uid_list)} email(s) ({ops_label})"] parts = [f"Scanned {len(uid_list)} email(s) ({ops_label})"]
if processed: if processed:
parts.append(f"processed {processed} new") parts.append(f"processed {processed} new")
if auto_sum:
parts.append(f"summarized {_summaries_created}")
if auto_reply:
parts.append(f"drafted {_replies_drafted} repl" + ("y" if _replies_drafted == 1 else "ies"))
if _reply_failed:
parts.append(f"{_reply_failed} reply failed")
if already_cached: if already_cached:
parts.append(f"{already_cached} already cached") parts.append(f"{already_cached} already cached")
if too_short: if too_short:
@@ -827,7 +894,10 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
parts.append(f"created {_events_created} calendar event(s)") parts.append(f"created {_events_created} calendar event(s)")
if processed == 0 and already_cached == 0 and too_short == 0: if processed == 0 and already_cached == 0 and too_short == 0:
parts.append("nothing to do") parts.append("nothing to do")
return " · ".join(parts) summary = " · ".join(parts)
if _detail_lines:
summary += "\n\nProcessed:\n" + "\n".join(f"- {line}" for line in _detail_lines[:20])
return summary
except Exception as e: except Exception as e:
logger.warning(f"Auto-summarize pass error: {e}") logger.warning(f"Auto-summarize pass error: {e}")
return f"Error: {e}" return f"Error: {e}"
+42 -20
View File
@@ -1198,7 +1198,7 @@ def setup_email_routes():
(message_id.strip(),), (message_id.strip(),),
).fetchone() ).fetchone()
if _row2: if _row2:
cached_ai_reply = _row2[0] cached_ai_reply = _apply_email_style_mechanics(_extract_reply(_row2[0] or ""))
_row3 = _c.execute( _row3 = _c.execute(
"SELECT sig_start, quote_start, turns_json FROM email_boundaries WHERE message_id = ?", "SELECT sig_start, quote_start, turns_json FROM email_boundaries WHERE message_id = ?",
(message_id.strip(),), (message_id.strip(),),
@@ -1254,6 +1254,7 @@ def setup_email_routes():
return { return {
"uid": uid, "uid": uid,
"folder": folder,
"message_id": message_id.strip(), "message_id": message_id.strip(),
"subject": subject, "subject": subject,
"from_name": sender_name or sender_addr, "from_name": sender_name or sender_addr,
@@ -2539,10 +2540,31 @@ def setup_email_routes():
message_id = (data.get("message_id") or "").strip() message_id = (data.get("message_id") or "").strip()
source_uid = (data.get("uid") or "").strip() source_uid = (data.get("uid") or "").strip()
source_folder = (data.get("folder") or "INBOX").strip() source_folder = (data.get("folder") or "INBOX").strip()
fast_reply = bool(data.get("fast", False))
if not original_body: if not original_body:
return {"success": False, "error": "No email body provided"} return {"success": False, "error": "No email body provided"}
if message_id:
try:
_c = _sql3.connect(SCHEDULED_DB)
_row = _c.execute(
"SELECT reply, model_used FROM email_ai_replies WHERE message_id = ?",
(message_id,),
).fetchone()
_c.close()
if _row and _row[0]:
cached_reply = _apply_email_style_mechanics(_extract_reply(_row[0] or ""))
if cached_reply:
return {
"success": True,
"reply": cached_reply,
"model_used": _row[1] or "cached",
"cached": True,
}
except Exception as e:
logger.warning(f"AI reply cache lookup failed: {e}")
settings = _load_settings() settings = _load_settings()
style = settings.get("email_writing_style", "") style = settings.get("email_writing_style", "")
@@ -2618,8 +2640,12 @@ def setup_email_routes():
logger.info(f"AI reply using model={model} url={url}") logger.info(f"AI reply using model={model} url={url}")
# Pre-retrieval: mine names/topics from the original email, search past mail + contacts # Manual AI Reply should feel immediate. The heavier context mining
context_snippets, _terms = _pre_retrieve_context(original_body, to) # can involve multiple IMAP folder searches and attachment parsing;
# reserve that for callers that explicitly opt out of fast mode.
context_snippets, _terms = ([], [])
if not fast_reply:
context_snippets, _terms = _pre_retrieve_context(original_body, to)
# NEW: also pull the last few emails from the original sender + # NEW: also pull the last few emails from the original sender +
# their attachments. The "to" field on this endpoint is the # their attachments. The "to" field on this endpoint is the
@@ -2627,16 +2653,17 @@ def setup_email_routes():
# sender we're answering. So `to` doubles as the address we want # sender we're answering. So `to` doubles as the address we want
# the thread context for. # the thread context for.
referenced = "" referenced = ""
try: if not fast_reply:
from_addr_for_ctx = email.utils.parseaddr(to or "")[1] try:
referenced = _fetch_sender_thread_context( from_addr_for_ctx = email.utils.parseaddr(to or "")[1]
sender_addr=from_addr_for_ctx, referenced = _fetch_sender_thread_context(
exclude_uid=source_uid, sender_addr=from_addr_for_ctx,
exclude_folder=source_folder, exclude_uid=source_uid,
limit=3, exclude_folder=source_folder,
) limit=3,
except Exception as _e: )
logger.warning(f"sender-thread-context failed: {_e}") except Exception as _e:
logger.warning(f"sender-thread-context failed: {_e}")
system_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE system_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE
if style: if style:
@@ -2705,12 +2732,8 @@ def setup_email_routes():
{"role": "user", "content": user_msg}, {"role": "user", "content": user_msg},
], ],
temperature=0.7, temperature=0.7,
# Match the background poller's reply budget (16384). The old max_tokens=1024 if fast_reply else 6144,
# 4096 cap let a local reasoning model (Qwen3 / R1) spend the timeout=60 if fast_reply else 180,
# whole budget inside <think>, so _strip_think left nothing —
# surfacing as "LLM returned empty response".
max_tokens=16384,
timeout=300,
) )
except Exception as e: except Exception as e:
detail = getattr(e, "detail", None) or str(e) detail = getattr(e, "detail", None) or str(e)
@@ -2724,7 +2747,6 @@ def setup_email_routes():
# Cache so next click is instant # Cache so next click is instant
if message_id: if message_id:
try: try:
import sqlite3 as _sql3
_c = _sql3.connect(SCHEDULED_DB) _c = _sql3.connect(SCHEDULED_DB)
_c.execute(""" _c.execute("""
INSERT OR REPLACE INTO email_ai_replies INSERT OR REPLACE INTO email_ai_replies
+90
View File
@@ -427,6 +427,79 @@ def setup_task_routes(task_scheduler) -> APIRouter:
notes = task_scheduler.pop_notifications(owner=user) notes = task_scheduler.pop_notifications(owner=user)
return {"notifications": notes} return {"notifications": notes}
@router.post("/{task_id}/clear-cache")
async def clear_task_cache(request: Request, task_id: str):
"""Clear derived cache for one built-in task."""
user = _owner(request)
db = SessionLocal()
try:
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
if not task:
raise HTTPException(404, "Task not found")
if user and task.owner != user:
raise HTTPException(403, "Access denied")
action = task.action or ""
finally:
db.close()
cache_tables = {
"summarize_emails": ("email_summaries",),
"draft_email_replies": ("email_ai_replies",),
"extract_email_events": ("email_calendar_extractions",),
"mark_email_boundaries": ("email_boundaries",),
"learn_sender_signatures": ("sender_signatures",),
"check_email_urgency": ("email_tags", "email_urgency_alerts"),
}
tables = cache_tables.get(action)
if not tables:
raise HTTPException(400, "This task has no clearable cache")
import sqlite3
from pathlib import Path
from routes.email_helpers import SCHEDULED_DB
cleared = {}
conn = sqlite3.connect(SCHEDULED_DB)
try:
for table in tables:
try:
if table == "email_tags" and user:
before = conn.execute(
"SELECT COUNT(*) FROM email_tags WHERE owner = ? OR owner = ''",
(user,),
).fetchone()[0]
conn.execute("DELETE FROM email_tags WHERE owner = ? OR owner = ''", (user,))
else:
before = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
conn.execute(f"DELETE FROM {table}")
cleared[table] = int(before or 0)
except sqlite3.OperationalError:
cleared[table] = 0
conn.commit()
finally:
conn.close()
removed_files = 0
if action == "check_email_urgency":
cache_dir = Path("data/email_urgency_cache")
if cache_dir.exists():
for child in cache_dir.glob("*.json"):
try:
child.unlink()
removed_files += 1
except Exception:
pass
owner_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (user or "default"))
for state_path in [Path(f"data/email_urgency_state_{owner_slug}.json")]:
try:
if state_path.exists():
state_path.unlink()
removed_files += 1
except Exception:
pass
return {"ok": True, "action": action, "cleared": cleared, "files": removed_files}
@router.get("/{task_id}") @router.get("/{task_id}")
async def get_task(request: Request, task_id: str): async def get_task(request: Request, task_id: str):
user = _owner(request) user = _owner(request)
@@ -638,6 +711,23 @@ def setup_task_routes(task_scheduler) -> APIRouter:
raise HTTPException(409, "Task is already running") raise HTTPException(409, "Task is already running")
return {"ok": True, "message": "Task triggered" + (" in parallel" if force else "")} return {"ok": True, "message": "Task triggered" + (" in parallel" if force else "")}
@router.post("/{task_id}/stop")
async def stop_task_now(request: Request, task_id: str):
user = _owner(request)
db = SessionLocal()
try:
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
if not task:
raise HTTPException(404, "Task not found")
if user and task.owner != user:
raise HTTPException(403, "Access denied")
finally:
db.close()
stopped = await task_scheduler.stop_task(task_id)
if not stopped:
raise HTTPException(404, "Task is not running")
return {"ok": True, "message": "Task stopped"}
@router.get("/runs/recent") @router.get("/runs/recent")
async def list_recent_runs(request: Request, limit: int = 50): async def list_recent_runs(request: Request, limit: int = 50):
"""Recent task runs across ALL tasks for this owner. Drives the Activity view.""" """Recent task runs across ALL tasks for this owner. Drives the Activity view."""
+6 -1
View File
@@ -469,7 +469,12 @@ async def action_draft_email_replies(owner: str, **kwargs) -> Tuple[str, bool]:
"""Run one pass of AI reply drafting.""" """Run one pass of AI reply drafting."""
try: try:
from routes.email_pollers import _run_auto_summarize_once from routes.email_pollers import _run_auto_summarize_once
result = await _run_auto_summarize_once(do_summary=False, do_reply=True) result = await _run_auto_summarize_once(
do_summary=False,
do_reply=True,
days_back=7,
progress_cb=kwargs.get("progress_cb"),
)
if not _result_has_work(result): if not _result_has_work(result):
raise TaskNoop(f"draft replies: {result or 'no new emails'}") raise TaskNoop(f"draft replies: {result or 'no new emails'}")
return result, True return result, True
+86 -3
View File
@@ -222,6 +222,24 @@ class TaskScheduler:
# This is a hard guarantee, not configurable. # This is a hard guarantee, not configurable.
self._run_semaphore = asyncio.Semaphore(1) self._run_semaphore = asyncio.Semaphore(1)
self._concurrency_cap = 1 self._concurrency_cap = 1
self._task_handles = {}
def _set_run_progress(self, run_id: str, message: str):
"""Persist short live progress text for Activity while a run is active."""
if not run_id:
return
try:
from core.database import SessionLocal, TaskRun
db = SessionLocal()
try:
run = db.query(TaskRun).filter(TaskRun.id == run_id).first()
if run and run.status in ("queued", "running"):
run.result = (message or "")[:4000]
db.commit()
finally:
db.close()
except Exception:
logger.debug("Task progress update failed", exc_info=True)
def add_notification(self, task_name: str, status: str, task_id: str = None, owner: str = None, body: str = None): def add_notification(self, task_name: str, status: str, task_id: str = None, owner: str = None, body: str = None):
"""Store a notification about a completed task run. Tagged with the """Store a notification about a completed task run. Tagged with the
@@ -516,6 +534,9 @@ class TaskScheduler:
# line behind another. Once we acquire the slot, flip to "running" # line behind another. Once we acquire the slot, flip to "running"
# and hand off to _execute_task_locked. # and hand off to _execute_task_locked.
from core.database import SessionLocal, TaskRun from core.database import SessionLocal, TaskRun
current = asyncio.current_task()
if current:
self._task_handles[task_id] = current
run_id = str(uuid.uuid4()) run_id = str(uuid.uuid4())
_q_db = SessionLocal() _q_db = SessionLocal()
try: try:
@@ -524,6 +545,7 @@ class TaskScheduler:
task_id=task_id, task_id=task_id,
started_at=datetime.utcnow(), started_at=datetime.utcnow(),
status="queued", status="queued",
result="Queued — waiting for a free slot…",
) )
_q_db.add(run) _q_db.add(run)
_q_db.commit() _q_db.commit()
@@ -563,6 +585,7 @@ class TaskScheduler:
if run: if run:
run.status = "running" run.status = "running"
run.started_at = datetime.utcnow() run.started_at = datetime.utcnow()
run.result = "Starting…"
db.commit() db.commit()
else: else:
# Defensive: row may have been wiped; recreate so the rest of # Defensive: row may have been wiped; recreate so the rest of
@@ -572,6 +595,7 @@ class TaskScheduler:
task_id=task.id, task_id=task.id,
started_at=datetime.utcnow(), started_at=datetime.utcnow(),
status="running", status="running",
result="Starting…",
) )
db.add(run) db.add(run)
db.commit() db.commit()
@@ -586,7 +610,7 @@ class TaskScheduler:
self._last_run_model = None self._last_run_model = None
try: try:
if task_type == "action": if task_type == "action":
result, success = await self._execute_action(task) result, success = await self._execute_action(task, run_id=run_id)
run.status = "success" if success else "error" run.status = "success" if success else "error"
run.result = result run.result = result
if not success: if not success:
@@ -622,6 +646,27 @@ class TaskScheduler:
task.next_run = when task.next_run = when
db.commit() db.commit()
return return
except asyncio.CancelledError:
logger.info("Task '%s' stopped by user", task.name)
run_obj = db.query(TaskRun).filter(TaskRun.id == run_id).first()
if run_obj:
run_obj.status = "aborted"
run_obj.error = "Stopped by user"
run_obj.result = run_obj.result or "Stopped by user"
run_obj.finished_at = datetime.utcnow()
task.last_run = datetime.utcnow()
if (task.trigger_type or "schedule") == "schedule":
task.next_run = compute_next_run(
task.schedule, task.scheduled_time,
task.scheduled_day, task.scheduled_date,
after=datetime.utcnow(),
cron_expression=task.cron_expression,
tz_name=_resolve_task_timezone(db, task),
)
else:
task.next_run = None
db.commit()
return
except TaskNoop as noop: except TaskNoop as noop:
# Action reported "nothing to do". Mark the run as `skipped` # Action reported "nothing to do". Mark the run as `skipped`
# with the reason in `result` so it surfaces in Activity as a # with the reason in `result` so it surfaces in Activity as a
@@ -783,6 +828,9 @@ class TaskScheduler:
logger.exception("Task %s error-path failed unexpectedly", task_id) logger.exception("Task %s error-path failed unexpectedly", task_id)
finally: finally:
db.close() db.close()
handle = self._task_handles.get(task_id)
if handle is asyncio.current_task():
self._task_handles.pop(task_id, None)
if release_executing: if release_executing:
async with self._executing_lock: async with self._executing_lock:
self._executing.discard(task_id) self._executing.discard(task_id)
@@ -853,7 +901,7 @@ class TaskScheduler:
category=(task.name or "Task"), category=(task.name or "Task"),
) )
async def _execute_action(self, task) -> tuple: async def _execute_action(self, task, run_id: str | None = None) -> tuple:
"""Execute a built-in action (no LLM needed).""" """Execute a built-in action (no LLM needed)."""
from src.builtin_actions import BUILTIN_ACTIONS from src.builtin_actions import BUILTIN_ACTIONS
@@ -864,7 +912,10 @@ class TaskScheduler:
from src.builtin_actions import TaskNoop from src.builtin_actions import TaskNoop
try: try:
# Pass task prompt as script/command for ssh_command/run_script actions. # Pass task prompt as script/command for ssh_command/run_script actions.
kwargs = {"owner": task.owner, "task_name": task.name} def _progress(message: str):
self._set_run_progress(run_id, message)
kwargs = {"owner": task.owner, "task_name": task.name, "progress_cb": _progress}
if task.action in ("run_script", "run_local", "ssh_command") and task.prompt: if task.action in ("run_script", "run_local", "ssh_command") and task.prompt:
kwargs["script" if task.action in ("run_script", "run_local") else "command"] = task.prompt kwargs["script" if task.action in ("run_script", "run_local") else "command"] = task.prompt
result, success = await action_fn(**kwargs) result, success = await action_fn(**kwargs)
@@ -1752,6 +1803,38 @@ class TaskScheduler:
asyncio.create_task(self._execute_task(task_id)) asyncio.create_task(self._execute_task(task_id))
return True return True
async def stop_task(self, task_id: str) -> bool:
"""Request cancellation of a running/queued task and mark its run aborted."""
handle = self._task_handles.get(task_id)
stopped = False
if handle and not handle.done():
handle.cancel()
stopped = True
async with self._executing_lock:
if task_id in self._executing:
self._executing.discard(task_id)
stopped = True
from core.database import SessionLocal, TaskRun
db = SessionLocal()
try:
run = (
db.query(TaskRun)
.filter(TaskRun.task_id == task_id, TaskRun.status.in_(("queued", "running")))
.order_by(TaskRun.started_at.desc())
.first()
)
if run:
run.status = "aborted"
run.error = "Stopped by user"
run.result = run.result or "Stopped by user"
run.finished_at = datetime.utcnow()
db.commit()
stopped = True
finally:
db.close()
return stopped
async def ensure_defaults(self, owner: str): async def ensure_defaults(self, owner: str):
"""Create default housekeeping tasks for this owner (idempotent per action).""" """Create default housekeeping tasks for this owner (idempotent per action)."""
from core.database import SessionLocal, ScheduledTask from core.database import SessionLocal, ScheduledTask
+11 -4
View File
@@ -697,10 +697,9 @@
<div style="position:relative; display:inline-block; display:flex; gap:4px; align-items:center;"> <div style="position:relative; display:inline-block; display:flex; gap:4px; align-items:center;">
<button type="button" class="section-header-btn chats-manage-btn" id="chats-library-btn" title="Manage Chats (Library)"> <button type="button" class="section-header-btn chats-manage-btn" id="chats-library-btn" title="Manage Chats (Library)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"></rect> <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
<rect x="14" y="3" width="7" height="7"></rect> <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
<rect x="14" y="14" width="7" height="7"></rect> <path d="M9 7h6M9 11h4"/>
<rect x="3" y="14" width="7" height="7"></rect>
</svg> </svg>
</button> </button>
<button type="button" class="section-header-btn" id="session-sort-btn" title="Sort sessions"> <button type="button" class="section-header-btn" id="session-sort-btn" title="Sort sessions">
@@ -1878,6 +1877,14 @@
</div> </div>
</div> </div>
<div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M9 16l2 2 4-4"/></svg>Email Tasks</h2>
<div class="settings-row" style="align-items:center;">
<div class="admin-toggle-sub" style="margin:0;flex:1;">Manage email background tasks in Tasks.</div>
<button class="admin-btn-add" id="set-email-open-tasks">Open Tasks</button>
</div>
</div>
<div class="admin-card"> <div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><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.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>Writing Style</h2> <h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><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.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>Writing Style</h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">AI-extracted from your sent emails. Used when AI drafts replies.</div> <div class="admin-toggle-sub" style="margin-bottom:8px">AI-extracted from your sent emails. Used when AI drafts replies.</div>
+143 -32
View File
@@ -2306,6 +2306,48 @@ import * as Modals from './modalManager.js';
return r && r.style.display !== 'none' ? r : null; return r && r.style.display !== 'none' ? r : null;
} }
function _stripEmailReplyQuoteText(text) {
const original = String(text || '');
if (!original) return { body: '', stripped: false };
const lines = original.split('\n');
const quoteIdx = lines.findIndex(line =>
/^-{5,}\s*Previous message\s*-{5,}$/i.test(line.trim())
|| /^On .+ wrote:\s*$/i.test(line.trim())
);
if (quoteIdx <= 0) return { body: original.trim(), stripped: false };
const body = lines.slice(0, quoteIdx).join('\n').trim();
return { body, stripped: !!body };
}
function _emailReplyOwnText(text) {
return _stripEmailReplyQuoteText(text).body;
}
function _setEmailBodyText(textarea, value) {
if (!textarea) return;
textarea.value = value || '';
syncHighlighting();
const rich = _emailRichbodyActive();
if (rich) rich.innerHTML = _emailBodyToHtml(textarea.value);
}
async function _streamEmailBodyText(textarea, value) {
if (!textarea) return;
const finalText = String(value || '');
const maxFrames = 90;
const chunk = Math.max(8, Math.ceil(finalText.length / maxFrames));
textarea.value = '';
const rich = _emailRichbodyActive();
if (rich) rich.innerHTML = '';
for (let i = 0; i < finalText.length; i += chunk) {
const next = finalText.slice(0, i + chunk);
textarea.value = next;
if (rich) rich.innerHTML = _emailBodyToHtml(next);
await new Promise(resolve => requestAnimationFrame(resolve));
}
_setEmailBodyText(textarea, finalText);
}
function _focusEmailBodyEnd() { function _focusEmailBodyEnd() {
const target = _emailRichbodyActive() || document.getElementById('doc-editor-textarea'); const target = _emailRichbodyActive() || document.getElementById('doc-editor-textarea');
if (!target) return; if (!target) return;
@@ -2795,10 +2837,12 @@ import * as Modals from './modalManager.js';
const references = document.getElementById('doc-email-references')?.value?.trim(); const references = document.getElementById('doc-email-references')?.value?.trim();
const sourceUid = document.getElementById('doc-email-source-uid')?.value?.trim(); const sourceUid = document.getElementById('doc-email-source-uid')?.value?.trim();
const sourceFolder = document.getElementById('doc-email-source-folder')?.value?.trim() || 'INBOX'; const sourceFolder = document.getElementById('doc-email-source-folder')?.value?.trim() || 'INBOX';
const body = document.getElementById('doc-editor-textarea')?.value?.trim();
// WYSIWYG: the rich body's HTML becomes the email's HTML part (server // WYSIWYG: the rich body's HTML becomes the email's HTML part (server
// sanitizes it). `body` (plain text mirror) stays the text/plain fallback. // sanitizes it). `body` (plain text mirror) stays the text/plain fallback.
const _rich = _emailRichbodyActive(); const _rich = _emailRichbodyActive();
if (_rich) _syncEmailRichbody(_rich);
const textarea = document.getElementById('doc-editor-textarea');
const body = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim();
const bodyHtml = _rich ? _rich.innerHTML : null; const bodyHtml = _rich ? _rich.innerHTML : null;
const doc = docs.get(activeDocId); const doc = docs.get(activeDocId);
const attachments = (doc?._composeAtts || []).map(a => a.token); const attachments = (doc?._composeAtts || []).map(a => a.token);
@@ -2806,6 +2850,10 @@ import * as Modals from './modalManager.js';
if (uiModule) uiModule.showError('To and body are required'); if (uiModule) uiModule.showError('To and body are required');
return; return;
} }
if (inReplyTo && !_emailReplyOwnText(body)) {
if (uiModule) uiModule.showError('Reply body is empty');
return;
}
// Warn if body mentions attachments but none are actually attached // Warn if body mentions attachments but none are actually attached
if (attachments.length === 0 && _bodyMentionsAttachment(body)) { if (attachments.length === 0 && _bodyMentionsAttachment(body)) {
const proceed = await _confirmMissingAttachment(); const proceed = await _confirmMissingAttachment();
@@ -2829,12 +2877,13 @@ import * as Modals from './modalManager.js';
let canceled = false; let canceled = false;
if (uiModule) { if (uiModule) {
uiModule.showToast('Sending', { uiModule.showToast('Sending', {
duration: 1200, duration: 3200,
leadingIcon: 'spinner',
action: 'Cancel', action: 'Cancel',
onAction: () => { canceled = true; }, onAction: () => { canceled = true; },
}); });
} }
await _sleep(1000); await _sleep(3000);
if (!canceled) detachedEmailDoc = _detachActiveEmailForBackground(sendDocId); if (!canceled) detachedEmailDoc = _detachActiveEmailForBackground(sendDocId);
await _sleep(200); await _sleep(200);
if (canceled) { if (canceled) {
@@ -2844,28 +2893,10 @@ import * as Modals from './modalManager.js';
return; return;
} }
let undone = false;
if (uiModule) {
uiModule.showToast('Message sent', {
duration: 2200,
leadingIcon: 'check',
action: 'Undo',
actionHint: 'undo send',
onAction: () => { undone = true; },
});
}
await _sleep(2200);
if (undone) {
_restoreDetachedEmailDoc(detachedEmailDoc);
detachedEmailDoc = null;
if (uiModule) uiModule.showToast('Send undone');
return;
}
if (uiModule) uiModule.showToast('Sending...', 2000);
const activeAccountId = await _resolveComposeSendAccountId(); const activeAccountId = await _resolveComposeSendAccountId();
const res = await fetch(`${API_BASE}/api/email/send`, { const res = await fetch(`${API_BASE}/api/email/send`, {
method: 'POST', method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
to, cc: cc || null, bcc: bcc || null, subject, body, body_html: bodyHtml, to, cc: cc || null, bcc: bcc || null, subject, body, body_html: bodyHtml,
@@ -2875,7 +2906,13 @@ import * as Modals from './modalManager.js';
wait_for_delivery: true, wait_for_delivery: true,
}), }),
}); });
const data = await res.json(); let data = null;
try {
data = await res.json();
} catch (_) {
data = { success: false, error: `Send failed (${res.status})` };
}
if (!res.ok && data && !data.error) data.error = `Send failed (${res.status})`;
if (data.success) { if (data.success) {
if (uiModule) { if (uiModule) {
uiModule.showToast('Message sent', { uiModule.showToast('Message sent', {
@@ -2961,8 +2998,10 @@ import * as Modals from './modalManager.js';
const subject = document.getElementById('doc-email-subject')?.value?.trim(); const subject = document.getElementById('doc-email-subject')?.value?.trim();
const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim(); const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim();
const references = document.getElementById('doc-email-references')?.value?.trim(); const references = document.getElementById('doc-email-references')?.value?.trim();
const body = document.getElementById('doc-editor-textarea')?.value?.trim();
const _rich = _emailRichbodyActive(); const _rich = _emailRichbodyActive();
if (_rich) _syncEmailRichbody(_rich);
const textarea = document.getElementById('doc-editor-textarea');
const body = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim();
const bodyHtml = _rich ? _rich.innerHTML : null; const bodyHtml = _rich ? _rich.innerHTML : null;
const btn = document.getElementById('doc-email-draft-btn'); const btn = document.getElementById('doc-email-draft-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Saving...'; } if (btn) { btn.disabled = true; btn.textContent = 'Saving...'; }
@@ -3074,6 +3113,32 @@ import * as Modals from './modalManager.js';
const textarea = document.getElementById('doc-editor-textarea'); const textarea = document.getElementById('doc-editor-textarea');
if (!textarea) return; if (!textarea) return;
const currentBody = textarea.value || ''; const currentBody = textarea.value || '';
const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim() || '';
const sourceUid = document.getElementById('doc-email-source-uid')?.value?.trim() || '';
const sourceFolder = document.getElementById('doc-email-source-folder')?.value?.trim() || 'INBOX';
const cleanAiReplyText = (text) => {
if (!text) return '';
let t = String(text);
const open = /<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/i;
const close = /<<<\s*END\s*>>+/i;
const m = open.exec(t);
if (m) {
const rest = t.slice(m.index + m[0].length);
const c = close.exec(rest);
t = c ? rest.slice(0, c.index) : rest;
}
return t
.replace(/<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/gi, '')
.replace(/<<<\s*END\s*>>+/gi, '')
.trim();
};
const shouldUseFastAiReply = () => {
const text = `${subject}\n${currentBody}`.toLowerCase();
if (/\b(attach(?:ed|ment)?|pdf|document|contract|invoice|receipt|quote|estimate|proposal|question|questions|details|schedule|booking|reservation|meeting|calendar|availability|confirm|confirmation|review|sign|signature)\b/.test(text)) {
return false;
}
return currentBody.length < 2500;
};
// Use the current chat model // Use the current chat model
let currentModel = ''; let currentModel = '';
@@ -3096,22 +3161,24 @@ import * as Modals from './modalManager.js';
original_body: currentBody, original_body: currentBody,
model: currentModel, model: currentModel,
session_id: currentSessionId, session_id: currentSessionId,
message_id: inReplyTo,
uid: sourceUid,
folder: sourceFolder,
fast: shouldUseFastAiReply(),
}), }),
}); });
const data = await res.json(); const data = await res.json();
if (data.success && data.reply) { if (data.success && data.reply) {
const cleanReply = cleanAiReplyText(data.reply);
const lines = currentBody.split('\n'); const lines = currentBody.split('\n');
const quoteIdx = lines.findIndex(l => l.startsWith('On ') && l.includes(' wrote:')); const quoteIdx = lines.findIndex(l => l.startsWith('On ') && l.includes(' wrote:'));
let newBody = '';
if (quoteIdx > 0) { if (quoteIdx > 0) {
const newBody = data.reply + '\n\n' + lines.slice(quoteIdx).join('\n'); newBody = cleanReply + '\n\n' + lines.slice(quoteIdx).join('\n');
textarea.value = newBody;
} else { } else {
textarea.value = data.reply + (currentBody ? '\n\n' + currentBody : ''); newBody = cleanReply + (currentBody ? '\n\n' + currentBody : '');
} }
syncHighlighting(); await _streamEmailBodyText(textarea, newBody);
// Mirror into the WYSIWYG rich body if it's the active editor.
const _rb = _emailRichbodyActive();
if (_rb) _rb.innerHTML = _emailBodyToHtml(textarea.value);
if (uiModule) uiModule.showToast(`AI draft inserted (${data.model_used || 'AI'})`); if (uiModule) uiModule.showToast(`AI draft inserted (${data.model_used || 'AI'})`);
} else { } else {
if (uiModule) uiModule.showError(data.error || 'Failed to generate reply'); if (uiModule) uiModule.showError(data.error || 'Failed to generate reply');
@@ -3130,7 +3197,12 @@ import * as Modals from './modalManager.js';
const subject = document.getElementById('doc-email-subject')?.value?.trim(); const subject = document.getElementById('doc-email-subject')?.value?.trim();
const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim(); const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim();
const references = document.getElementById('doc-email-references')?.value?.trim(); const references = document.getElementById('doc-email-references')?.value?.trim();
const body = document.getElementById('doc-editor-textarea')?.value?.trim(); const _rich = _emailRichbodyActive();
if (_rich) _syncEmailRichbody(_rich);
const body = (_rich
? (_rich.innerText || _rich.textContent || '')
: (document.getElementById('doc-editor-textarea')?.value || '')
).trim();
const doc = docs.get(activeDocId); const doc = docs.get(activeDocId);
const attachments = (doc?._composeAtts || []).map(a => a.token); const attachments = (doc?._composeAtts || []).map(a => a.token);
@@ -3138,6 +3210,10 @@ import * as Modals from './modalManager.js';
if (uiModule) uiModule.showError('To and body are required'); if (uiModule) uiModule.showError('To and body are required');
return; return;
} }
if (inReplyTo && !_emailReplyOwnText(body)) {
if (uiModule) uiModule.showError('Reply body is empty');
return;
}
if (attachments.length === 0 && _bodyMentionsAttachment(body)) { if (attachments.length === 0 && _bodyMentionsAttachment(body)) {
const proceed = await _confirmMissingAttachment(); const proceed = await _confirmMissingAttachment();
if (!proceed) return; if (!proceed) return;
@@ -5680,6 +5756,41 @@ import * as Modals from './modalManager.js';
})); }));
} }
export async function replaceEmailReplyBody(docId, replyText) {
const doc = docs.get(docId);
if (!doc) return;
const fields = _parseEmailHeader(doc.content || '');
const lines = String(fields.body || '').split('\n');
const quoteIdx = lines.findIndex(line =>
/^-{5,}\s*Previous message\s*-{5,}$/i.test(line.trim())
|| /^On .+ wrote:\s*$/i.test(line.trim())
);
const quote = quoteIdx >= 0 ? lines.slice(quoteIdx).join('\n') : '';
const ownText = _emailReplyOwnText(fields.body || '');
if (ownText && !/^(\[AI reply draft will appear here\]|Drafting AI reply)/i.test(ownText)) {
if (uiModule) uiModule.showToast('AI reply ready, but draft was edited');
return;
}
const body = String(replyText || '').trim() + (quote ? `\n\n${quote}` : '');
doc.content = _buildEmailContent(
fields.to,
fields.subject,
fields.inReplyTo,
fields.references,
body,
fields.sourceUid,
fields.sourceFolder,
fields.cc,
fields.bcc,
);
if (activeDocId === docId) {
const textarea = document.getElementById('doc-editor-textarea');
if (textarea) await _streamEmailBodyText(textarea, body);
}
clearTimeout(_autoSaveDebounce);
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800);
}
// Force the panel into a genuinely-open state. `isOpen` can be true while the // Force the panel into a genuinely-open state. `isOpen` can be true while the
// pane was torn down by another full-screen view (e.g. opening a doc from the // pane was torn down by another full-screen view (e.g. opening a doc from the
// email modal): in that case openPanel() early-returns and nothing mounts, so // email modal): in that case openPanel() early-returns and nothing mounts, so
+81 -46
View File
@@ -26,6 +26,36 @@ const _starIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" s
const _starFilledIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>'; const _starFilledIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>';
const _bellIcon = '<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="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>'; const _bellIcon = '<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="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`; const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
const _replySeparator = '---------- Previous message ----------';
function _cleanAiReplyText(text) {
if (!text) return '';
let t = String(text);
const open = /<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/i;
const close = /<<<\s*END\s*>>+/i;
const m = open.exec(t);
if (m) {
const rest = t.slice(m.index + m[0].length);
const c = close.exec(rest);
t = c ? rest.slice(0, c.index) : rest;
}
return t
.replace(/<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/gi, '')
.replace(/<<<\s*END\s*>>+/gi, '')
.trim();
}
function _shouldUseFastAiReply(data) {
const body = String(data?.body || data?.body_html || '');
const subject = String(data?.subject || '');
const atts = Array.isArray(data?.attachments) ? data.attachments : [];
if (atts.length > 0) return false;
const text = `${subject}\n${body}`.toLowerCase();
if (/\b(attach(?:ed|ment)?|pdf|document|contract|invoice|receipt|quote|estimate|proposal|question|questions|details|schedule|booking|reservation|meeting|calendar|availability|confirm|confirmation|review|sign|signature)\b/.test(text)) {
return false;
}
return body.length < 2500;
}
let _emails = []; let _emails = [];
let _currentFolder = 'INBOX'; let _currentFolder = 'INBOX';
@@ -609,52 +639,9 @@ function _createEmailItem(em) {
} }
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') { async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') {
// If AI Reply mode: use cached reply if available, otherwise generate const wantsAiReply = mode === 'ai-reply';
let aiSuggestedBody = null; let aiSuggestedBody = null;
if (mode === 'ai-reply' && preloadedData) { if (wantsAiReply) {
const data = preloadedData;
// Check for pre-generated cached reply first (instant!)
if (data.cached_ai_reply) {
aiSuggestedBody = data.cached_ai_reply;
} else {
// No cache — generate on demand
try {
let currentModel = '';
let currentSessionId = '';
try {
currentModel = sessionModule?.getCurrentModel() || '';
currentSessionId = sessionModule?.getCurrentSessionId() || '';
} catch (_) {}
const res = await fetch(`${API_BASE}/api/email/ai-reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: data.from_address,
subject: `Re: ${data.subject}`,
original_body: data.body,
model: currentModel,
session_id: currentSessionId,
message_id: data.message_id || '',
uid: String(em.uid || ''),
folder: _currentFolder,
}),
});
const result = await res.json();
if (result.success && result.reply) {
aiSuggestedBody = result.reply;
} else {
// Don't silently open a blank draft — tell the user it failed so a
// model/endpoint problem (e.g. empty response) is visible.
// uiModule isn't statically imported here; use the dynamic pattern.
const _msg = result.error || 'AI reply could not be generated';
console.error('AI reply generation failed:', _msg);
import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + _msg)).catch(() => {});
}
} catch (e) {
console.error('AI reply generation failed:', e);
import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + (e.message || e))).catch(() => {});
}
}
// Fall through to reply-all (not plain reply) so the generated AI // Fall through to reply-all (not plain reply) so the generated AI
// draft addresses everyone on the original thread. On single- // draft addresses everyone on the original thread. On single-
// recipient emails this collapses to a regular reply since there's // recipient emails this collapses to a regular reply since there's
@@ -682,6 +669,54 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') {
console.error('Failed to read email:', data.error); console.error('Failed to read email:', data.error);
return; return;
} }
if (wantsAiReply) {
if (data.cached_ai_reply) {
aiSuggestedBody = _cleanAiReplyText(data.cached_ai_reply);
} else {
let draftToastTimer = null;
draftToastTimer = setTimeout(() => {
import('./ui.js').then(m => m.showToast && m.showToast('Drafting AI reply', { duration: 3000, leadingIcon: 'spinner' })).catch(() => {});
}, 450);
try {
let currentModel = '';
let currentSessionId = '';
try {
currentModel = sessionModule?.getCurrentModel() || '';
currentSessionId = sessionModule?.getCurrentSessionId() || '';
} catch (_) {}
const res = await fetch(`${API_BASE}/api/email/ai-reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: data.from_address,
subject: `Re: ${data.subject}`,
original_body: data.body,
model: currentModel,
session_id: currentSessionId,
message_id: data.message_id || '',
uid: String(em.uid || ''),
folder: _currentFolder,
fast: _shouldUseFastAiReply(data),
}),
});
const result = await res.json();
if (draftToastTimer) clearTimeout(draftToastTimer);
if (result.success && result.reply) {
aiSuggestedBody = _cleanAiReplyText(result.reply);
} else {
const _msg = result.error || 'AI reply could not be generated';
console.error('AI reply generation failed:', _msg);
import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + _msg)).catch(() => {});
return;
}
} catch (e) {
if (draftToastTimer) clearTimeout(draftToastTimer);
console.error('AI reply generation failed:', e);
import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + (e.message || e))).catch(() => {});
return;
}
}
}
em.is_read = true; em.is_read = true;
if (itemEl) itemEl.classList.remove('email-unread'); if (itemEl) itemEl.classList.remove('email-unread');
@@ -772,7 +807,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') {
} else { } else {
content += '\n\n'; content += '\n\n';
} }
content += `On ${niceDate}, ${data.from_name} <${data.from_address}> wrote:\n${quotedBody}`; content += `${_replySeparator}\nOn ${niceDate}, ${data.from_name} <${data.from_address}> wrote:\n${quotedBody}`;
} }
if (_docModule) { if (_docModule) {
+80 -39
View File
@@ -84,8 +84,6 @@ window.addEventListener('email-answered', (e) => {
function _toggleUnreadEmails() { function _toggleUnreadEmails() {
if (state._libFolder === '__scheduled__') state._libFolder = 'INBOX'; if (state._libFolder === '__scheduled__') state._libFolder = 'INBOX';
state._libFilter = state._libFilter === 'unread' ? 'all' : 'unread'; state._libFilter = state._libFilter === 'unread' ? 'all' : 'unread';
state._libOffset = 0;
state._libEmails = [];
_syncUnreadWindowGlow(); _syncUnreadWindowGlow();
const folderEl = document.getElementById('email-lib-folder'); const folderEl = document.getElementById('email-lib-folder');
const filterEl = document.getElementById('email-lib-filter'); const filterEl = document.getElementById('email-lib-filter');
@@ -93,7 +91,7 @@ function _toggleUnreadEmails() {
if (filterEl) filterEl.value = state._libFilter; if (filterEl) filterEl.value = state._libFilter;
document.getElementById('email-undone-btn')?.classList.remove('active'); document.getElementById('email-undone-btn')?.classList.remove('active');
document.getElementById('email-reminder-btn')?.classList.remove('active'); document.getElementById('email-reminder-btn')?.classList.remove('active');
_loadEmails(); _loadEmailsFresh();
} }
function _syncUnreadTabBadge(count) { function _syncUnreadTabBadge(count) {
@@ -433,6 +431,22 @@ function _libCachePut(key, value) {
} }
} }
function _resetEmailListForFreshLoad() {
state._libOffset = 0;
state._libEmails = [];
state._libTotal = 0;
_libLoadSeq += 1;
const grid = document.getElementById('email-lib-grid');
if (grid) grid.innerHTML = '';
const stats = document.getElementById('email-lib-stats');
if (stats) stats.textContent = 'Loading...';
}
function _loadEmailsFresh() {
_resetEmailListForFreshLoad();
return _loadEmails({ force: true, useCache: false });
}
export function prewarmEmailLibrary({ delay = 2500 } = {}) { export function prewarmEmailLibrary({ delay = 2500 } = {}) {
if (_libPrewarmTimer || _libPrewarmPromise) return; if (_libPrewarmTimer || _libPrewarmPromise) return;
const elapsed = Date.now() - _libLastPrewarmAt; const elapsed = Date.now() - _libLastPrewarmAt;
@@ -742,17 +756,13 @@ export function openEmailLibrary(opts = {}) {
document.getElementById('email-lib-folder').addEventListener('change', (e) => { document.getElementById('email-lib-folder').addEventListener('change', (e) => {
state._libFolder = e.target.value; state._libFolder = e.target.value;
state._libOffset = 0; _loadEmailsFresh();
state._libEmails = [];
_loadEmails();
}); });
document.getElementById('email-lib-filter').addEventListener('change', (e) => { document.getElementById('email-lib-filter').addEventListener('change', (e) => {
state._libFilter = e.target.value; state._libFilter = e.target.value;
state._libOffset = 0;
state._libEmails = [];
_syncUnreadWindowGlow(); _syncUnreadWindowGlow();
_syncReminderClearButton(); _syncReminderClearButton();
_loadEmails(); _loadEmailsFresh();
// Sync quick-toggle active states so they mirror the dropdown. // Sync quick-toggle active states so they mirror the dropdown.
document.getElementById('email-undone-btn')?.classList.toggle('active', state._libFilter === 'undone'); document.getElementById('email-undone-btn')?.classList.toggle('active', state._libFilter === 'undone');
document.getElementById('email-reminder-btn')?.classList.toggle('active', state._libFilter === 'reminders'); document.getElementById('email-reminder-btn')?.classList.toggle('active', state._libFilter === 'reminders');
@@ -761,10 +771,8 @@ export function openEmailLibrary(opts = {}) {
const btn = document.getElementById('email-attach-btn'); const btn = document.getElementById('email-attach-btn');
state._libHasAttachments = !state._libHasAttachments; state._libHasAttachments = !state._libHasAttachments;
btn?.classList.toggle('active', state._libHasAttachments); btn?.classList.toggle('active', state._libHasAttachments);
state._libOffset = 0;
state._libEmails = [];
_syncReminderClearButton(); _syncReminderClearButton();
_loadEmails(); _loadEmailsFresh();
}); });
document.getElementById('email-reminders-clear-btn')?.addEventListener('click', async () => { document.getElementById('email-reminders-clear-btn')?.addEventListener('click', async () => {
const ok = await styledConfirm('Permanently delete all Odysseus reminder emails?', { const ok = await styledConfirm('Permanently delete all Odysseus reminder emails?', {
@@ -790,10 +798,8 @@ export function openEmailLibrary(opts = {}) {
const filterEl = document.getElementById('email-lib-filter'); const filterEl = document.getElementById('email-lib-filter');
if (filterEl) filterEl.value = 'all'; if (filterEl) filterEl.value = 'all';
document.getElementById('email-reminder-btn')?.classList.remove('active'); document.getElementById('email-reminder-btn')?.classList.remove('active');
state._libOffset = 0;
state._libEmails = [];
_syncReminderClearButton(); _syncReminderClearButton();
_loadEmails(); _loadEmailsFresh();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
showToast('Failed to clear reminder emails'); showToast('Failed to clear reminder emails');
@@ -812,11 +818,9 @@ export function openEmailLibrary(opts = {}) {
btn.classList.add('active'); btn.classList.add('active');
document.getElementById('email-reminder-btn')?.classList.remove('active'); document.getElementById('email-reminder-btn')?.classList.remove('active');
} }
state._libOffset = 0;
state._libEmails = [];
_syncUnreadWindowGlow(); _syncUnreadWindowGlow();
_syncReminderClearButton(); _syncReminderClearButton();
_loadEmails(); _loadEmailsFresh();
}); });
document.getElementById('email-reminder-btn')?.addEventListener('click', () => { document.getElementById('email-reminder-btn')?.addEventListener('click', () => {
const btn = document.getElementById('email-reminder-btn'); const btn = document.getElementById('email-reminder-btn');
@@ -831,11 +835,9 @@ export function openEmailLibrary(opts = {}) {
btn.classList.add('active'); btn.classList.add('active');
document.getElementById('email-undone-btn')?.classList.remove('active'); document.getElementById('email-undone-btn')?.classList.remove('active');
} }
state._libOffset = 0;
state._libEmails = [];
_syncUnreadWindowGlow(); _syncUnreadWindowGlow();
_syncReminderClearButton(); _syncReminderClearButton();
_loadEmails(); _loadEmailsFresh();
}); });
// The old "sort" dropdown (Latest / Unread first / Favorites first) was merged // The old "sort" dropdown (Latest / Unread first / Favorites first) was merged
// into the filter dropdown above — "Favorites" is now a filter (server-side // into the filter dropdown above — "Favorites" is now a filter (server-side
@@ -1081,8 +1083,6 @@ function _renderAccountsStrip() {
const strip = document.getElementById('email-lib-accounts'); const strip = document.getElementById('email-lib-accounts');
if (!strip) return; if (!strip) return;
strip.style.display = 'flex'; strip.style.display = 'flex';
// No accounts loaded yet — leave the row empty (New button still shows alongside).
if (!state._libAccounts.length) { strip.innerHTML = ''; return; }
const esc = s => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;'); const esc = s => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
const allActive = !state._libAccountId ? ' active' : ''; const allActive = !state._libAccountId ? ' active' : '';
let html = `<button class="memory-toolbar-btn gallery-chip${allActive}" data-acc-id="">All (default)</button>`; let html = `<button class="memory-toolbar-btn gallery-chip${allActive}" data-acc-id="">All (default)</button>`;
@@ -1096,11 +1096,10 @@ function _renderAccountsStrip() {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
state._libAccountId = btn.dataset.accId || null; state._libAccountId = btn.dataset.accId || null;
_publishActiveAccount(); _publishActiveAccount();
state._libOffset = 0; _resetEmailListForFreshLoad();
state._libEmails = [];
_renderAccountsStrip(); _renderAccountsStrip();
await _loadFolders({ resetMissing: true }); await _loadFolders({ resetMissing: true });
_loadEmails({ force: true }); _loadEmails({ force: true, useCache: false });
}); });
}); });
_publishActiveAccount(); _publishActiveAccount();
@@ -1358,7 +1357,7 @@ async function _refreshUnreadBadge() {
} catch (_) { _syncUnreadTabBadge(0); } } catch (_) { _syncUnreadTabBadge(0); }
} }
async function _loadEmails({ force = false } = {}) { async function _loadEmails({ force = false, useCache = true } = {}) {
const seq = ++_libLoadSeq; const seq = ++_libLoadSeq;
state._libLoading = true; state._libLoading = true;
const accountAtStart = state._libAccountId || ''; const accountAtStart = state._libAccountId || '';
@@ -1375,15 +1374,16 @@ async function _loadEmails({ force = false } = {}) {
// paint the cached list immediately (no spinner, no blank grid) and // paint the cached list immediately (no spinner, no blank grid) and
// then quietly refetch behind it. Pagination, search, and the // then quietly refetch behind it. Pagination, search, and the
// scheduled virtual folder skip the cache and use the old spinner // scheduled virtual folder skip the cache and use the old spinner
// path. `force` (Refresh button) still consults the cache for // path. `force` (Refresh button) can still consult the cache for
// perceptual continuity, but adds a cache-buster so the server's 8s // perceptual continuity, but adds a cache-buster so the server's 8s
// list cache is bypassed too. // list cache is bypassed too. Account/folder/filter changes pass
// `useCache: false` so stale rows from the previous view never flash.
const cacheable = const cacheable =
offsetAtStart === 0 && offsetAtStart === 0 &&
!searchAtStart && !searchAtStart &&
folderAtStart !== '__scheduled__'; folderAtStart !== '__scheduled__';
const ck = cacheable ? _libCacheKey() : null; const ck = cacheable ? _libCacheKey() : null;
const cached = cacheable ? _libCacheGet(ck) : null; const cached = (useCache && cacheable) ? _libCacheGet(ck) : null;
let sp = null; let sp = null;
if (cached) { if (cached) {
@@ -1881,6 +1881,9 @@ function _prefetchAdjacentEmails(card, count = 3) {
} }
async function _toggleCardPreview(card, em) { async function _toggleCardPreview(card, em) {
const accountAtStart = state._libAccountId || '';
const folderAtStart = state._libFolder || 'INBOX';
const uidAtStart = String(em?.uid || card?.dataset?.uid || '');
const grid = card.closest('.doclib-grid'); const grid = card.closest('.doclib-grid');
const gridRect = grid?.getBoundingClientRect?.(); const gridRect = grid?.getBoundingClientRect?.();
const modal = document.getElementById('email-lib-modal'); const modal = document.getElementById('email-lib-modal');
@@ -1921,7 +1924,7 @@ async function _toggleCardPreview(card, em) {
card.style.minHeight = `${Math.round(stableOpenHeight)}px`; card.style.minHeight = `${Math.round(stableOpenHeight)}px`;
if (!em.is_read) { if (!em.is_read) {
_syncEmailReadState(em.uid, true); _syncEmailReadState(em.uid, true);
fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }) fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`, { method: 'POST' })
.catch(err => console.error('Failed to mark email read:', err)); .catch(err => console.error('Failed to mark email read:', err));
} }
// Class hook on the modal so the header-hide / padding rules work on // Class hook on the modal so the header-hide / padding rules work on
@@ -1944,8 +1947,17 @@ async function _toggleCardPreview(card, em) {
card.appendChild(reader); card.appendChild(reader);
try { try {
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`); const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`);
const data = await res.json(); const data = await res.json();
if (
accountAtStart !== (state._libAccountId || '') ||
folderAtStart !== (state._libFolder || 'INBOX') ||
uidAtStart !== String(card?.dataset?.uid || '') ||
!card.isConnected ||
!card.classList.contains('email-card-expanded')
) {
return;
}
if (data.error) { if (data.error) {
reader.innerHTML = `<div style="padding:20px;color:var(--red,#e55)">Error: ${_esc(data.error)}</div>`; reader.innerHTML = `<div style="padding:20px;color:var(--red,#e55)">Error: ${_esc(data.error)}</div>`;
return; return;
@@ -2013,7 +2025,7 @@ async function _toggleCardPreview(card, em) {
</div> </div>
</div> </div>
${attsHtml} ${attsHtml}
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_renderEmailBody(data)}</div> <div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
`; `;
reader.classList.remove('email-card-reader-loading'); reader.classList.remove('email-card-reader-loading');
reader.style.minHeight = ''; reader.style.minHeight = '';
@@ -2252,6 +2264,23 @@ function _setBubblesDisabled(v) {
} }
function _renderEmailBody(data) { function _renderEmailBody(data) {
const plain = (typeof data?.body === 'string' && data.body.length) ? data.body : '';
const folder = String(data?.folder || '').toLowerCase();
const isSentFolder = folder.includes('sent');
const fromAddr = String(data?.from_address || '').toLowerCase().trim();
const isMine = !!fromAddr && _meEmailAddrs().has(fromAddr);
// Messages authored by the user (Sent folder or self-sent copies in INBOX)
// are current authored text. Do not let cached boundaries or HTML
// blockquote parsing hide the whole thing behind "Earlier reply".
if ((isSentFolder || isMine) && plain) {
const plainTurns = _renderPlaintextThread(plain);
if (plainTurns && !/^\s*<details\b/i.test(plainTurns.trim())) {
return _foldSignature(plainTurns, null);
}
return _foldSignature(_escLinkify(plain).replace(/\n/g, '<br>'), null);
}
// Prefer the server-cached thread parse — that's the richest structure // Prefer the server-cached thread parse — that's the richest structure
// and the one the chat-bubble layout is built around. Skip when the user // and the one the chat-bubble layout is built around. Skip when the user
// has manually disabled bubble rendering. // has manually disabled bubble rendering.
@@ -2263,7 +2292,6 @@ function _renderEmailBody(data) {
} }
const b = data && data.boundaries; const b = data && data.boundaries;
// Use cached boundaries when present AND we have plain-text body to slice // Use cached boundaries when present AND we have plain-text body to slice
const plain = (typeof data.body === 'string' && data.body.length) ? data.body : '';
if (b && plain && (b.sig_start >= 0 || b.quote_start >= 0)) { if (b && plain && (b.sig_start >= 0 || b.quote_start >= 0)) {
// Pick the EARLIER of the two as the cut for "everything below this is // Pick the EARLIER of the two as the cut for "everything below this is
// foldable", but render sig and quote with their own labels. // foldable", but render sig and quote with their own labels.
@@ -2327,6 +2355,18 @@ function _renderEmailBody(data) {
return _foldSignature(_foldQuotedReplies(rendered), hintSig); return _foldSignature(_foldQuotedReplies(rendered), hintSig);
} }
function _safeRenderEmailBody(data) {
try {
return _renderEmailBody(data);
} catch (e) {
console.error('email body render failed:', e);
const plain = (typeof data?.body === 'string') ? data.body : '';
if (plain) return _escLinkify(plain).replace(/\n/g, '<br>');
if (data?.body_html) return _sanitizeHtml(data.body_html);
return '<span style="opacity:.65">No body</span>';
}
}
// ── Chat-bubble rendering for email threads ── // ── Chat-bubble rendering for email threads ──
// Each parsed turn renders as a chat bubble. Bubbles for the active // Each parsed turn renders as a chat bubble. Bubbles for the active
// account's outgoing replies align right; everyone else aligns left. // account's outgoing replies align right; everyone else aligns left.
@@ -2636,12 +2676,13 @@ function _renderPlaintextThread(text) {
const lvl = levels[i]; const lvl = levels[i];
const raw = lines[i]; const raw = lines[i];
const stripped = lvl > 0 ? raw.replace(/^(?:>\s?)+/, '') : raw; const stripped = lvl > 0 ? raw.replace(/^(?:>\s?)+/, '') : raw;
const isSeparatorLine = lvl === 0 && /^-{5,}\s*Previous message\s*-{5,}$/i.test(raw.trim());
const isAttribLine = lvl === 0 const isAttribLine = lvl === 0
&& (new RegExp(`^\\s*On\\s.+?\\s${_TALON_WROTE}\\s*:\\s*$`, 'i').test(raw) && (new RegExp(`^\\s*On\\s.+?\\s${_TALON_WROTE}\\s*:\\s*$`, 'i').test(raw)
|| _TALON_ORIG_RE.test('\n' + raw)); || _TALON_ORIG_RE.test('\n' + raw));
if (isAttribLine) { if (isSeparatorLine || isAttribLine) {
flush(); flush();
pendingMeta = _extractQuoteMeta(raw) || raw.trim(); pendingMeta = isSeparatorLine ? null : (_extractQuoteMeta(raw) || raw.trim());
curLevel = 1; curLevel = 1;
continue; continue;
} }
@@ -3699,7 +3740,7 @@ async function _openEmailAsTab(em, folder) {
</div> </div>
</div> </div>
${attsHtml} ${attsHtml}
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_renderEmailBody(data)}</div> <div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
`; `;
try { _wireAttachmentHandlers(reader, useFolder); } catch {} try { _wireAttachmentHandlers(reader, useFolder); } catch {}
const attsWrap = reader.querySelector('.email-reader-atts-wrap'); const attsWrap = reader.querySelector('.email-reader-atts-wrap');
@@ -3854,7 +3895,7 @@ async function _openEmailWindow(em, folder) {
</div> </div>
</div> </div>
${attsHtml} ${attsHtml}
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_renderEmailBody(data)}</div> <div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
`; `;
// Wire all the same action handlers the inline reader has. // Wire all the same action handlers the inline reader has.
try { _wireAttachmentHandlers(bodyEl, useFolder); } catch {} try { _wireAttachmentHandlers(bodyEl, useFolder); } catch {}
@@ -3971,7 +4012,7 @@ async function _swapReaderToUid(reader, uid, folder) {
} else if (oldAtts) { } else if (oldAtts) {
oldAtts.remove(); oldAtts.remove();
} }
body.innerHTML = _renderEmailBody(data); body.innerHTML = _safeRenderEmailBody(data);
body.classList.toggle('html-body', !!data.body_html); body.classList.toggle('html-body', !!data.body_html);
// Wire click handlers for the newly-rendered attachment chips. Without // Wire click handlers for the newly-rendered attachment chips. Without
// this, after swapping to a different email via the sidebar, clicking // this, after swapping to a different email via the sidebar, clicking
+14
View File
@@ -2457,6 +2457,20 @@ async function initEmailAccountsSettings() {
manageBtn.dataset.bound = '1'; manageBtn.dataset.bound = '1';
manageBtn.addEventListener('click', () => open('integrations')); manageBtn.addEventListener('click', () => open('integrations'));
} }
const tasksBtn = el('set-email-open-tasks');
if (tasksBtn && tasksBtn.dataset.bound !== '1') {
tasksBtn.dataset.bound = '1';
tasksBtn.addEventListener('click', async () => {
try {
const mod = await import('./tasks.js');
const openTasks = mod.openTasks || (mod.default && mod.default.openTasks);
if (typeof openTasks === 'function') openTasks();
else document.getElementById('tool-tasks-btn')?.click();
} catch (_) {
document.getElementById('tool-tasks-btn')?.click();
}
});
}
const listEl = el('set-email-accounts-list'); const listEl = el('set-email-accounts-list');
const msgEl = el('set-email-accounts-msg'); const msgEl = el('set-email-accounts-msg');
const formEl = el('set-email-accounts-form'); const formEl = el('set-email-accounts-form');
+109 -16
View File
@@ -23,7 +23,7 @@ const DAYS_OF_WEEK = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'S
async function _fetchTasks() { async function _fetchTasks() {
try { try {
const res = await fetch(`${API_BASE}/api/tasks?include_last_run=true`, { credentials: 'same-origin' }); const res = await fetch(`${API_BASE}/api/tasks`, { credentials: 'same-origin' });
const data = await res.json(); const data = await res.json();
_tasks = data.tasks || []; _tasks = data.tasks || [];
} catch (e) { } catch (e) {
@@ -127,6 +127,21 @@ async function _runNow(id, force = false) {
} }
} }
async function _stopTask(id) {
const res = await fetch(`${API_BASE}/api/tasks/${id}/stop`, {
method: 'POST',
credentials: 'same-origin',
});
if (!res.ok) {
let msg = `Failed to stop task (${res.status})`;
try {
const data = await res.json();
if (data && data.detail) msg = data.detail;
} catch (_) {}
throw new Error(msg);
}
}
async function _fetchRuns(taskId, limit = 10) { async function _fetchRuns(taskId, limit = 10) {
const res = await fetch(`${API_BASE}/api/tasks/${taskId}/runs?limit=${limit}`, { const res = await fetch(`${API_BASE}/api/tasks/${taskId}/runs?limit=${limit}`, {
credentials: 'same-origin', credentials: 'same-origin',
@@ -568,6 +583,19 @@ function _renderTaskChips() {
for (const c of cats) mkChip(`${c} (${counts[c]})`, c, _taskFilter === c); for (const c of cats) mkChip(`${c} (${counts[c]})`, c, _taskFilter === c);
} }
const _TASK_CACHE_LABELS = {
summarize_emails: 'email summaries',
draft_email_replies: 'AI reply drafts',
extract_email_events: 'email calendar cache',
mark_email_boundaries: 'email boundaries',
learn_sender_signatures: 'sender signatures',
check_email_urgency: 'email tags',
};
function _taskClearCacheLabel(taskOrEntry) {
return _TASK_CACHE_LABELS[taskOrEntry?.action || ''] || '';
}
function _renderList() { function _renderList() {
const list = document.getElementById('tasks-list'); const list = document.getElementById('tasks-list');
if (!list) return; if (!list) return;
@@ -630,7 +658,7 @@ function _renderList() {
const statusBadge = task.status === 'paused' const statusBadge = task.status === 'paused'
? `<span class="task-status-badge task-paused-badge" data-task-status-action="resume" title="Click to resume" style="position:relative;top:4px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg> paused</span>` ? `<span class="task-status-badge task-paused-badge" data-task-status-action="resume" title="Click to resume" style="position:relative;top:4px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg> paused</span>`
: task.status === 'active' : task.status === 'active'
? `<span class="task-status-badge task-active-badge" data-task-status-action="pause" title="Click to pause" style="position:relative;top:4px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><polygon points="7 4 19 12 7 20 7 4"/></svg> active</span>` ? `<span class="task-status-badge task-active-badge" data-task-status-action="pause" title="Click to pause" style="position:relative;top:4px;">active</span>`
: ''; : '';
const builtinBadge = task.is_builtin const builtinBadge = task.is_builtin
? `<span class="task-builtin-badge${task.is_modified ? ' modified' : ''}" title="${task.is_modified ? 'Built-in task — edited from its default' : 'Built-in task'}">built-in${task.is_modified ? ' · edited' : ''}</span>` ? `<span class="task-builtin-badge${task.is_modified ? ' modified' : ''}" title="${task.is_modified ? 'Built-in task — edited from its default' : 'Built-in task'}">built-in${task.is_modified ? ' · edited' : ''}</span>`
@@ -659,6 +687,9 @@ function _renderList() {
if (task.is_builtin && task.is_modified) { if (task.is_builtin && task.is_modified) {
items.push({ label: 'Revert to default', icon: '<polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>', action: () => _doRevert(task.id) }); items.push({ label: 'Revert to default', icon: '<polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>', action: () => _doRevert(task.id) });
} }
if (_taskClearCacheLabel(task)) {
items.push({ label: 'Clear cache', icon: '<path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v5"/><path d="M14 11v5"/>', action: () => _doClearTaskCache(task.id, _taskClearCacheLabel(task)) });
}
items.push({ label: 'Delete', icon: '<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/>', action: () => _doDelete(task.id), danger: true }); items.push({ label: 'Delete', icon: '<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/>', action: () => _doDelete(task.id), danger: true });
_showTaskDropdown(menuBtn, items); _showTaskDropdown(menuBtn, items);
}); });
@@ -667,10 +698,10 @@ function _renderList() {
// manual triggering. Hidden for completed tasks (same gate as before). // manual triggering. Hidden for completed tasks (same gate as before).
if (task.status !== 'completed') { if (task.status !== 'completed') {
const runBtn = document.createElement('button'); const runBtn = document.createElement('button');
runBtn.className = 'memory-item-btn task-card-run-btn'; runBtn.className = 'task-status-badge task-run-now-badge task-card-run-btn';
runBtn.title = 'Run now'; runBtn.title = 'Run now';
runBtn.style.cssText = 'position:relative;top:4px;margin-right:4px;display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:2px 6px;'; runBtn.style.cssText = 'position:relative;top:1px;margin-right:4px;';
runBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg><span>Run</span>'; runBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg><span>Run now</span>';
runBtn.addEventListener('click', (e) => { e.stopPropagation(); _doRunNow(task.id); }); runBtn.addEventListener('click', (e) => { e.stopPropagation(); _doRunNow(task.id); });
actionsWrap.insertBefore(runBtn, menuBtn); actionsWrap.insertBefore(runBtn, menuBtn);
} }
@@ -1578,6 +1609,25 @@ async function _doRevert(id) {
} catch (e) { if (uiModule) uiModule.showError(e.message); } } catch (e) { if (uiModule) uiModule.showError(e.message); }
} }
async function _doClearTaskCache(id, label = 'cache') {
const ok = uiModule?.styledConfirm
? await uiModule.styledConfirm(`Clear cached ${label} for this task?`, { confirmText: 'Clear' })
: confirm(`Clear cached ${label} for this task?`);
if (!ok) return;
try {
const res = await fetch(`${API_BASE}/api/tasks/${encodeURIComponent(id)}/clear-cache`, {
method: 'POST',
credentials: 'same-origin',
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) throw new Error(data.detail || data.error || `HTTP ${res.status}`);
const n = Object.values(data.cleared || {}).reduce((a, b) => a + Number(b || 0), 0) + Number(data.files || 0);
if (uiModule) uiModule.showToast(`Cleared ${label}${n ? ` (${n})` : ''}`);
} catch (e) {
if (uiModule) uiModule.showError(`Clear cache failed: ${e.message || e}`);
}
}
async function _doToggleAll() { async function _doToggleAll() {
// If any task is active → pause all. Else resume all paused tasks. // If any task is active → pause all. Else resume all paused tasks.
const hasActive = _tasks.some(t => t.status === 'active'); const hasActive = _tasks.some(t => t.status === 'active');
@@ -1680,10 +1730,6 @@ async function _renderActivityView() {
document.getElementById('tasks-activity-refresh').addEventListener('click', _renderActivityView); document.getElementById('tasks-activity-refresh').addEventListener('click', _renderActivityView);
// Loading placeholder matches the document library: app whirlpool + label.
const _actList = document.getElementById('tasks-activity-list');
if (_actList) _actList.appendChild(spinnerModule.createLoadingRow('Loading…'));
// Solo filter: clicking a chip shows ONLY that group (a category, or // Solo filter: clicking a chip shows ONLY that group (a category, or
// Errors). Clicking the active chip again clears the filter (show all). // Errors). Clicking the active chip again clears the filter (show all).
// At most one chip is active at a time. _solo holds the active key, or null. // At most one chip is active at a time. _solo holds the active key, or null.
@@ -1771,6 +1817,14 @@ async function _renderActivityView() {
const searchEl = document.getElementById('tasks-activity-search'); const searchEl = document.getElementById('tasks-activity-search');
if (searchEl) searchEl.addEventListener('input', () => { _afQuery = searchEl.value; _buildChips(); _applyFilter(); }); if (searchEl) searchEl.addEventListener('input', () => { _afQuery = searchEl.value; _buildChips(); _applyFilter(); });
const _actList = document.getElementById('tasks-activity-list');
if (_activityEntries.length) {
_buildChips();
_applyFilter();
} else if (_actList) {
_actList.appendChild(spinnerModule.createLoadingRow('Loading…'));
}
try { try {
const res = await fetch(`${API_BASE}/api/tasks/runs/recent?limit=100`, { credentials: 'same-origin' }); const res = await fetch(`${API_BASE}/api/tasks/runs/recent?limit=100`, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -1796,6 +1850,7 @@ async function _renderActivityView() {
kind: r.task_type || 'llm', kind: r.task_type || 'llm',
taskName: r.task_name || (r.task_type === 'action' ? (r.action || 'Action') : 'Task'), taskName: r.task_name || (r.task_type === 'action' ? (r.action || 'Action') : 'Task'),
taskId: r.task_id, taskId: r.task_id,
action: r.action || '',
result: resultText, result: resultText,
prompt: '', prompt: '',
ts: r.finished_at || r.started_at, ts: r.finished_at || r.started_at,
@@ -1916,9 +1971,9 @@ function _wireActivityRows(list) {
// counter). No-op when there's nothing to tick. // counter). No-op when there's nothing to tick.
_startActivityTimers(list); _startActivityTimers(list);
list.querySelectorAll('.task-log-row').forEach(row => { list.querySelectorAll('.task-log-row').forEach(row => {
// Click anywhere on the (non-running, non-skipped) row to toggle expand. // Click anywhere on the row to toggle expand.
// Buttons inside still get their own handlers via stopPropagation. // Buttons inside still get their own handlers via stopPropagation.
if (!row.classList.contains('is-running') && !row.classList.contains('is-skipped')) { if (!row.classList.contains('is-skipped')) {
row.addEventListener('click', () => row.classList.toggle('expanded')); row.addEventListener('click', () => row.classList.toggle('expanded'));
} }
row.querySelector('.task-log-row-toggle')?.addEventListener('click', (e) => { row.querySelector('.task-log-row-toggle')?.addEventListener('click', (e) => {
@@ -1943,6 +1998,25 @@ function _wireActivityRows(list) {
const entry = _activityEntries[idx]; const entry = _activityEntries[idx];
if (entry?.taskId) _doRunNow(entry.taskId, true); if (entry?.taskId) _doRunNow(entry.taskId, true);
}); });
row.querySelector('.task-log-stop')?.addEventListener('click', async (e) => {
e.stopPropagation();
const idx = parseInt(row.dataset.entryIdx, 10);
const entry = _activityEntries[idx];
if (!entry?.taskId) return;
try {
await _stopTask(entry.taskId);
uiModule.showToast('Task stopped');
_renderActivityView();
} catch (err) {
uiModule.showError(err.message || 'Failed to stop task');
}
});
row.querySelector('.task-log-run-again')?.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(row.dataset.entryIdx, 10);
const entry = _activityEntries[idx];
if (entry?.taskId) _doRunNow(entry.taskId);
});
row.querySelector('.task-log-copy')?.addEventListener('click', (e) => { row.querySelector('.task-log-copy')?.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
const idx = parseInt(row.dataset.entryIdx, 10); const idx = parseInt(row.dataset.entryIdx, 10);
@@ -1954,6 +2028,12 @@ function _wireActivityRows(list) {
uiModule.showToast('Log copied'); uiModule.showToast('Log copied');
} catch (_) { uiModule.showError('Copy failed'); } } catch (_) { uiModule.showError('Copy failed'); }
}); });
row.querySelector('.task-log-clear-cache')?.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(row.dataset.entryIdx, 10);
const entry = _activityEntries[idx];
if (entry?.taskId) _doClearTaskCache(entry.taskId, _taskClearCacheLabel(entry));
});
}); });
} }
@@ -2113,13 +2193,11 @@ function _renderActivityEntry(entry) {
const statusDot = `<span class="task-log-status task-log-status-${status}" title="${status}"></span>`; const statusDot = `<span class="task-log-status task-log-status-${status}" title="${status}"></span>`;
// Render the result through markdown so code blocks, lists, links look right. // Render the result through markdown so code blocks, lists, links look right.
let resultHtml; let resultHtml;
// Running / queued rows: body stays empty — the status now lives on the
// right side of the head row ("Running <whirlpool>"), wired below.
const _isRunning = entry.status === 'running' || entry.status === 'queued'; const _isRunning = entry.status === 'running' || entry.status === 'queued';
// Skipped (noop) rows: render as a slim, dimmed one-liner — no body, no // Skipped (noop) rows: render as a slim, dimmed one-liner — no body, no
// actions, just `· name · skipped — reason · time`. CSS via .is-skipped. // actions, just `· name · skipped — reason · time`. CSS via .is-skipped.
const _isSkipped = entry.status === 'skipped'; const _isSkipped = entry.status === 'skipped';
if (_isRunning) { if (_isRunning && !(entry.result || '').trim()) {
resultHtml = ''; resultHtml = '';
} else { } else {
try { try {
@@ -2155,6 +2233,7 @@ function _renderActivityEntry(entry) {
// CSS vars feed the colored title + accent stripe. // CSS vars feed the colored title + accent stripe.
const styleVars = `--cat-hue:${hue};`; const styleVars = `--cat-hue:${hue};`;
const hasResult = !!(entry.result && entry.result.trim() && entry.status !== 'running' && entry.status !== 'queued'); const hasResult = !!(entry.result && entry.result.trim() && entry.status !== 'running' && entry.status !== 'queued');
const hasRunningProgress = !!(entry.result && entry.result.trim() && (entry.status === 'running' || entry.status === 'queued'));
// "Open in chat" only makes sense for runs whose result is a real assistant // "Open in chat" only makes sense for runs whose result is a real assistant
// message (Prompt / Research tasks). Action/event runs are just log lines // message (Prompt / Research tasks). Action/event runs are just log lines
// (e.g. "No recent emails", "Tidied N memories") — for those, replace the // (e.g. "No recent emails", "Tidied N memories") — for those, replace the
@@ -2179,6 +2258,19 @@ function _renderActivityEntry(entry) {
Copy log Copy log
</button>`; </button>`;
} }
const clearLabel = _taskClearCacheLabel(entry);
if (hasResult && clearLabel && entry.taskId) {
actionBtn += `<button class="task-log-clear-cache" type="button" title="Clear cached ${_escHtml(clearLabel)} for this task">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v5"/><path d="M14 11v5"/></svg>
Clear cache
</button>`;
}
if (hasResult && entry.taskId) {
actionBtn += `<button class="task-log-run-again" type="button" title="Run this task again">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
Run again
</button>`;
}
// Running rows replace the relative-time on the right with "Running NN" + a // Running rows replace the relative-time on the right with "Running NN" + a
// live whirlpool spinner. Queued shows "Queued" the same way (no timer — // live whirlpool spinner. Queued shows "Queued" the same way (no timer —
// hasn't actually started yet). The elapsed counter ticks every second via // hasn't actually started yet). The elapsed counter ticks every second via
@@ -2191,7 +2283,8 @@ function _renderActivityEntry(entry) {
const startMs = entry.ts ? new Date(entry.ts).getTime() : Date.now(); const startMs = entry.ts ? new Date(entry.ts).getTime() : Date.now();
const elapsedInit = isQueued ? '' : `<span class="task-log-running-elapsed" data-since="${startMs}">${_fmtElapsed(Date.now() - startMs)}</span>`; const elapsedInit = isQueued ? '' : `<span class="task-log-running-elapsed" data-since="${startMs}">${_fmtElapsed(Date.now() - startMs)}</span>`;
const forceBtn = isQueued && entry.taskId ? `<button class="task-log-force-run" type="button" title="Start now in parallel, bypassing the queue" style="border:0;background:transparent;box-shadow:none;margin-left:5px;padding:0;width:12px;height:12px;display:inline-flex;align-items:center;justify-content:center;font-size:10px;line-height:1;color:inherit;opacity:.8;"><svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor" style="display:block;"><polygon points="6 4 20 12 6 20 6 4"/></svg></button>` : ''; const forceBtn = isQueued && entry.taskId ? `<button class="task-log-force-run" type="button" title="Start now in parallel, bypassing the queue" style="border:0;background:transparent;box-shadow:none;margin-left:5px;padding:0;width:12px;height:12px;display:inline-flex;align-items:center;justify-content:center;font-size:10px;line-height:1;color:inherit;opacity:.8;"><svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor" style="display:block;"><polygon points="6 4 20 12 6 20 6 4"/></svg></button>` : '';
rightHtml = `<span class="task-log-running-inline"><span class="task-log-running-label">${label}</span>${elapsedInit}<span data-spin-here="1"></span>${forceBtn}</span>`; const stopBtn = entry.taskId ? `<button class="task-log-stop" type="button" title="Stop this task"><svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="1"/></svg></button>` : '';
rightHtml = `<span class="task-log-running-inline"><span class="task-log-running-label">${label}</span>${elapsedInit}<span data-spin-here="1"></span>${forceBtn}${stopBtn}</span>`;
} else { } else {
rightHtml = `<span class="task-log-time" title="${_escHtml(tsAbs)}">${_escHtml(tsLabel)}</span>`; rightHtml = `<span class="task-log-time" title="${_escHtml(tsAbs)}">${_escHtml(tsLabel)}</span>`;
} }
@@ -2223,7 +2316,7 @@ function _renderActivityEntry(entry) {
<span style="flex:1"></span> <span style="flex:1"></span>
${rightHtml} ${rightHtml}
</div> </div>
${_isRunning ? '' : `<div class="task-log-row-body">${resultHtml}</div>`} ${(_isRunning && !hasRunningProgress) ? '' : `<div class="task-log-row-body">${resultHtml}</div>`}
${promptHtml} ${promptHtml}
<div class="task-log-row-actions"> <div class="task-log-row-actions">
${long ? '<button class="task-log-row-toggle" type="button">Show more</button>' : '<span></span>'} ${long ? '<button class="task-log-row-toggle" type="button">Show more</button>' : '<span></span>'}
+81 -2
View File
@@ -6,12 +6,15 @@
import themeModule from './theme.js'; import themeModule from './theme.js';
import * as Modals from './modalManager.js'; import * as Modals from './modalManager.js';
import spinnerModule from './spinner.js';
let toastEl = null; let toastEl = null;
let autoScrollEnabled = true; let autoScrollEnabled = true;
let hoveredToggleCard = null; let hoveredToggleCard = null;
let hoveredToggleWindow = null; let hoveredToggleWindow = null;
let hoveredDockChip = null; let hoveredDockChip = null;
let _lastPointerClientX = null;
let _lastPointerClientY = null;
// Smooth scroll state // Smooth scroll state
let _scrollRafId = null; let _scrollRafId = null;
@@ -74,6 +77,66 @@ function _spaceWindowId(win) {
return null; return null;
} }
function _windowAtPointer() {
if (_lastPointerClientX == null || _lastPointerClientY == null) return null;
const x = _lastPointerClientX;
const y = _lastPointerClientY;
const candidates = [
...document.querySelectorAll('.modal:not(.hidden):not(.modal-minimized) .modal-content'),
...document.querySelectorAll('.doc-editor-pane'),
].filter(el => {
if (!document.contains(el)) return false;
const r = el.getBoundingClientRect();
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
});
if (!candidates.length) return null;
return candidates.reduce((top, el) => {
const mz = parseInt(getComputedStyle(el.closest('.modal') || el).zIndex, 10) || 0;
const tz = parseInt(getComputedStyle(top.closest('.modal') || top).zIndex, 10) || 0;
return mz >= tz ? el : top;
});
}
function _containsPointer(el) {
if (!el || _lastPointerClientX == null || _lastPointerClientY == null) return false;
const r = el.getBoundingClientRect();
return _lastPointerClientX >= r.left && _lastPointerClientX <= r.right
&& _lastPointerClientY >= r.top && _lastPointerClientY <= r.bottom;
}
function _closeHoveredWindow() {
let win = _windowAtPointer();
if (!win) {
try {
const underPointer = document.elementFromPoint(_lastPointerClientX, _lastPointerClientY);
win = underPointer?.closest?.('.modal:not(.hidden):not(.modal-minimized) .modal-content, .doc-editor-pane') || null;
} catch {}
}
if (!win) win = hoveredToggleWindow;
if (!win || !document.contains(win)) return false;
const modalForWin = win.closest?.('.modal[id]');
if (modalForWin?.id === 'email-lib-modal') {
const closeBtn = document.getElementById('email-lib-close') || modalForWin.querySelector('.close-btn');
if (closeBtn) {
try { closeBtn.click(); return true; } catch {}
}
try { modalForWin.remove(); return true; } catch {}
}
const id = _spaceWindowId(win);
if (id && Modals.isRegistered(id)) {
Modals.close(id);
return true;
}
const modal = _visibleModalForSpace(win);
if (!modal) return false;
const closeBtn = modal.querySelector('.close-btn, .modal-close, .modal-close-btn, [data-action="close"]');
if (closeBtn) {
try { closeBtn.click(); return true; } catch {}
}
try { modal.classList.add('hidden'); return true; } catch {}
return false;
}
function _spaceIsBlocked(e, surface) { function _spaceIsBlocked(e, surface) {
const target = _targetEl(e.target); const target = _targetEl(e.target);
if (!target) return false; if (!target) return false;
@@ -103,6 +166,8 @@ function _initHoverCardSpaceToggle() {
if (document._odysseusHoverCardSpaceToggle) return; if (document._odysseusHoverCardSpaceToggle) return;
document._odysseusHoverCardSpaceToggle = true; document._odysseusHoverCardSpaceToggle = true;
document.addEventListener('pointerover', (e) => { document.addEventListener('pointerover', (e) => {
_lastPointerClientX = e.clientX;
_lastPointerClientY = e.clientY;
const chip = e.target?.closest?.('.minimized-dock-chip[data-modal-id]'); const chip = e.target?.closest?.('.minimized-dock-chip[data-modal-id]');
if (chip) hoveredDockChip = chip; if (chip) hoveredDockChip = chip;
const card = e.target?.closest?.(SPACE_CARD_SELECTOR); const card = e.target?.closest?.(SPACE_CARD_SELECTOR);
@@ -110,6 +175,10 @@ function _initHoverCardSpaceToggle() {
const win = e.target?.closest?.('.modal:not(.hidden):not(.modal-minimized) .modal-content, .doc-editor-pane'); const win = e.target?.closest?.('.modal:not(.hidden):not(.modal-minimized) .modal-content, .doc-editor-pane');
if (win) hoveredToggleWindow = win; if (win) hoveredToggleWindow = win;
}, true); }, true);
document.addEventListener('pointermove', (e) => {
_lastPointerClientX = e.clientX;
_lastPointerClientY = e.clientY;
}, true);
document.addEventListener('pointerout', (e) => { document.addEventListener('pointerout', (e) => {
const next = e.relatedTarget; const next = e.relatedTarget;
if (hoveredDockChip && (!next || !hoveredDockChip.contains(next))) hoveredDockChip = null; if (hoveredDockChip && (!next || !hoveredDockChip.contains(next))) hoveredDockChip = null;
@@ -252,6 +321,12 @@ export function showToast(msg, durationOrOpts) {
icon.className = 'toast-checkmark'; icon.className = 'toast-checkmark';
icon.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>'; icon.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
toastEl.appendChild(icon); toastEl.appendChild(icon);
} else if (leadingIcon === 'spinner') {
const wp = spinnerModule.createWhirlpool(14);
const icon = wp.element;
icon.classList.add('toast-whirlpool');
icon.style.cssText = 'width:14px;height:14px;margin:0 8px 0 0;display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;';
toastEl.appendChild(icon);
} }
textSpan.textContent = msg; textSpan.textContent = msg;
toastEl.appendChild(textSpan); toastEl.appendChild(textSpan);
@@ -1114,8 +1189,6 @@ if (!window._odyEscExpandGuard) {
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape' || e.defaultPrevented) return; if (e.key !== 'Escape' || e.defaultPrevented) return;
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
// Find the single thing to close, in priority order. The first hit wins. // Find the single thing to close, in priority order. The first hit wins.
// Important: if a thinking block is open we MUST handle it ourselves and // Important: if a thinking block is open we MUST handle it ourselves and
@@ -1123,6 +1196,12 @@ if (!window._odyEscExpandGuard) {
// (the live-stream chat rebuilds thinking DOM mid-stream so the header // (the live-stream chat rebuilds thinking DOM mid-stream so the header
// can briefly be absent). Toggling the `expanded` class directly is the // can briefly be absent). Toggling the `expanded` class directly is the
// fallback so ESC never bypasses the thinking block to hit a modal. // fallback so ESC never bypasses the thinking block to hit a modal.
if (_closeHoveredWindow()) {
e.stopImmediatePropagation(); e.preventDefault();
return;
}
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
const expanded = document.querySelector('.doclib-card-expanded'); const expanded = document.querySelector('.doclib-card-expanded');
const think = document.querySelector('.thinking-content.expanded'); const think = document.querySelector('.thinking-content.expanded');
if (expanded) { if (expanded) {
+61 -8
View File
@@ -3533,6 +3533,11 @@ body.bg-pattern-sparkles {
box-shadow: 0 4px 12px rgba(0,0,0,0.2); box-shadow: 0 4px 12px rgba(0,0,0,0.2);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
max-width: min(360px, calc(100vw - 32px)); max-width: min(360px, calc(100vw - 32px));
min-width: min(220px, calc(100vw - 32px));
min-height: 34px;
display: inline-flex;
align-items: center;
box-sizing: border-box;
} }
.toast.show { opacity:1; transform: translateX(0); } .toast.show { opacity:1; transform: translateX(0); }
.toast .toast-checkmark { .toast .toast-checkmark {
@@ -9984,6 +9989,17 @@ textarea.memory-add-input {
background: color-mix(in srgb, var(--green, #50fa7b) 20%, transparent); background: color-mix(in srgb, var(--green, #50fa7b) 20%, transparent);
border-color: color-mix(in srgb, var(--green, #50fa7b) 35%, transparent); border-color: color-mix(in srgb, var(--green, #50fa7b) 35%, transparent);
} }
.task-run-now-badge {
color: var(--accent, var(--red));
background: color-mix(in srgb, var(--accent, var(--red)) 16%, transparent);
border-color: color-mix(in srgb, var(--accent, var(--red)) 34%, transparent);
}
.task-card-run-btn {
appearance: none;
height: 20px;
min-height: 0;
box-sizing: border-box;
}
.task-status-badge:hover { .task-status-badge:hover {
filter: brightness(1.08) saturate(1.15); filter: brightness(1.08) saturate(1.15);
} }
@@ -9995,6 +10011,10 @@ textarea.memory-add-input {
background: color-mix(in srgb, var(--green, #50fa7b) 28%, transparent); background: color-mix(in srgb, var(--green, #50fa7b) 28%, transparent);
border-color: color-mix(in srgb, var(--green, #50fa7b) 55%, transparent); border-color: color-mix(in srgb, var(--green, #50fa7b) 55%, transparent);
} }
.task-run-now-badge:hover {
background: color-mix(in srgb, var(--accent, var(--red)) 24%, transparent);
border-color: color-mix(in srgb, var(--accent, var(--red)) 52%, transparent);
}
.task-builtin-badge { .task-builtin-badge {
font-size: 9px; font-size: 9px;
@@ -20518,11 +20538,10 @@ body:not(.welcome-ready) #welcome-screen {
margin-bottom: 0; margin-bottom: 0;
} }
.task-log-row.expanded .task-log-row-head { margin-bottom: 4px; } .task-log-row.expanded .task-log-row-head { margin-bottom: 4px; }
/* Collapsed: body + footer hidden. Expanded: visible. Running/skipped rows /* Collapsed: body + footer hidden. Expanded: visible. */
don't expand at all (no body to show). */ .task-log-row:not(.expanded):not(.is-skipped) .task-log-row-body,
.task-log-row:not(.expanded):not(.is-running):not(.is-skipped) .task-log-row-body, .task-log-row:not(.expanded):not(.is-skipped) .task-log-row-actions,
.task-log-row:not(.expanded):not(.is-running):not(.is-skipped) .task-log-row-actions, .task-log-row:not(.expanded):not(.is-skipped) .task-log-prompt {
.task-log-row:not(.expanded):not(.is-running):not(.is-skipped) .task-log-prompt {
display: none; display: none;
} }
.task-log-name { .task-log-name {
@@ -20571,6 +20590,26 @@ body:not(.welcome-ready) #welcome-screen {
opacity: 0.6; opacity: 0.6;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.task-log-stop {
border: 0;
background: transparent;
color: inherit;
opacity: .72;
padding: 0;
margin-left: 6px;
width: 12px;
height: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
top: -2px;
}
.task-log-stop:hover {
opacity: 1;
color: var(--red, #f87171);
}
/* Slim single-line row for skipped (noop) runs body/actions stripped, font /* Slim single-line row for skipped (noop) runs body/actions stripped, font
shrunk, opacity dropped. Distinguishes "task ran but had nothing to do" shrunk, opacity dropped. Distinguishes "task ran but had nothing to do"
@@ -20718,7 +20757,10 @@ body:not(.welcome-ready) #welcome-screen {
margin-top: 4px; margin-top: 4px;
} }
.task-log-open-chat, .task-log-open-chat,
.task-log-copy { .task-log-open-report,
.task-log-copy,
.task-log-clear-cache,
.task-log-run-again {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 3px; gap: 3px;
@@ -20734,11 +20776,22 @@ body:not(.welcome-ready) #welcome-screen {
line-height: 1.4; line-height: 1.4;
} }
.task-log-open-chat:hover, .task-log-open-chat:hover,
.task-log-copy:hover { .task-log-open-report:hover,
.task-log-copy:hover,
.task-log-clear-cache:hover,
.task-log-run-again:hover {
color: var(--fg); color: var(--fg);
border-color: color-mix(in srgb, var(--fg) 30%, transparent); border-color: color-mix(in srgb, var(--fg) 30%, transparent);
background: color-mix(in srgb, var(--fg) 5%, transparent); background: color-mix(in srgb, var(--fg) 5%, transparent);
} }
.task-log-row-actions > .task-log-open-chat,
.task-log-row-actions > .task-log-copy {
margin-left: auto;
}
.task-log-clear-cache svg {
position: relative;
top: 2px;
}
/* Activity filter chips toggle-out model: ON by default (solid), /* Activity filter chips toggle-out model: ON by default (solid),
click to toggle OFF (dimmed + strikethrough) to hide that group. */ click to toggle OFF (dimmed + strikethrough) to hide that group. */
.tasks-af-chip { .tasks-af-chip {
@@ -27694,7 +27747,7 @@ body.doc-find-active mark.doc-find-mark.current {
} }
/* Cc toggle and attach button are absolute so they don't steal width from the To input */ /* Cc toggle and attach button are absolute so they don't steal width from the To input */
.email-field .email-cc-toggle { .email-field .email-cc-toggle {
position: absolute; right: 6px; top: 50%; transform: translateY(-50%); position: absolute; right: 6px; top: calc(50% + 4px); transform: translateY(-50%);
z-index: 2; z-index: 2;
} }
.email-field input { padding-right: 60px; } .email-field input { padding-right: 60px; }