Polish email tasks and window controls
This commit is contained in:
@@ -15,6 +15,7 @@ and `email_pollers.py` (the background loops):
|
||||
import os
|
||||
import imaplib
|
||||
import smtplib
|
||||
import ssl
|
||||
import email as email_mod
|
||||
import email.header
|
||||
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)
|
||||
user = cfg.get("smtp_user") or ""
|
||||
password = cfg.get("smtp_password") or ""
|
||||
if port == 587:
|
||||
with smtplib.SMTP(host, port, timeout=timeout) as smtp:
|
||||
def _send_starttls(starttls_port: int = 587) -> None:
|
||||
with smtplib.SMTP(host, starttls_port, timeout=timeout) as smtp:
|
||||
smtp.starttls()
|
||||
if user and password:
|
||||
smtp.login(user, password)
|
||||
smtp.sendmail(from_addr, recipients, message)
|
||||
|
||||
if port == 587:
|
||||
_send_starttls(587)
|
||||
return
|
||||
with smtplib.SMTP_SSL(host, port, timeout=timeout) as smtp:
|
||||
if user and password:
|
||||
smtp.login(user, password)
|
||||
smtp.sendmail(from_addr, recipients, message)
|
||||
|
||||
try:
|
||||
with smtplib.SMTP_SSL(host, port, timeout=timeout) as smtp:
|
||||
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:
|
||||
@@ -82,8 +95,8 @@ def _strip_think(text: str) -> str:
|
||||
import re as _re_reply
|
||||
# Accept REPLY / SUMMARY / OUTPUT as the opening fence so the same extractor
|
||||
# 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_CLOSE_RE = _re_reply.compile(r"<<<\s*END\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)
|
||||
|
||||
|
||||
def _extract_reply(text: str) -> str:
|
||||
|
||||
@@ -23,6 +23,7 @@ import json
|
||||
import re
|
||||
import html
|
||||
import logging
|
||||
import inspect
|
||||
from datetime import datetime
|
||||
|
||||
from email.mime.text import MIMEText
|
||||
@@ -46,10 +47,22 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 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,
|
||||
do_tag: bool = False, do_spam: 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
|
||||
so the existing background-loop logic runs exactly once for the requested ops."""
|
||||
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)
|
||||
_save_settings(settings)
|
||||
try:
|
||||
return await _auto_summarize_pass(days_back=days_back)
|
||||
return await _auto_summarize_pass(days_back=days_back, progress_cb=progress_cb)
|
||||
finally:
|
||||
s2 = _load_settings()
|
||||
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)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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 = {}
|
||||
if len(ids) <= 1:
|
||||
# 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 = []
|
||||
for aid in ids:
|
||||
for idx, aid in enumerate(ids, start=1):
|
||||
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}")
|
||||
except Exception as e:
|
||||
logger.warning(f"auto-summarize pass failed for account {aid}: {e}")
|
||||
outs.append(f"[{names.get(aid, aid[:8])}] error: {e}")
|
||||
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.
|
||||
Reads current settings flags."""
|
||||
import asyncio
|
||||
@@ -130,11 +144,13 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
return "Nothing to do"
|
||||
|
||||
try:
|
||||
await _emit_progress(progress_cb, "Connecting to mail…")
|
||||
conn = _imap_connect(account_id)
|
||||
from datetime import timedelta as _td
|
||||
since = (datetime.utcnow() - _td(days=max(1, days_back))).strftime("%d-%b-%Y")
|
||||
# uid_list now carries (folder, uid) tuples — for calendar extraction we
|
||||
# also scan Sent so the LLM sees confirmation/cancellation replies the user wrote.
|
||||
# uid_list carries real IMAP UIDs, matching the email UI/read routes.
|
||||
# Using sequence numbers here made background-cached replies miss when
|
||||
# the user clicked the same visible message in the UI.
|
||||
uid_list = []
|
||||
folders_to_scan = ["INBOX"]
|
||||
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:
|
||||
try:
|
||||
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]:
|
||||
for u in data[0].split()[-30:]:
|
||||
for u in reversed(data[0].split()[-30:]):
|
||||
uid_list.append((folder, u))
|
||||
except Exception as _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
|
||||
conn.select("INBOX", readonly=True)
|
||||
if not uid_list:
|
||||
conn.logout()
|
||||
return "No recent emails"
|
||||
await _emit_progress(progress_cb, f"Found {len(uid_list)} recent email(s); checking cache…")
|
||||
|
||||
_c = _sql3.connect(SCHEDULED_DB)
|
||||
_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
|
||||
no_msgid = 0
|
||||
examined = 0
|
||||
_summaries_created = 0
|
||||
_events_created = 0
|
||||
_replies_drafted = 0
|
||||
_reply_failed = 0
|
||||
_detail_lines = []
|
||||
_current_folder = "INBOX"
|
||||
_max_process = 5
|
||||
for _entry in uid_list:
|
||||
if processed >= 10:
|
||||
if processed >= _max_process:
|
||||
break
|
||||
# entry can be either a bare UID (legacy callers) or (folder, uid) tuple (new code)
|
||||
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:
|
||||
conn.select(_q(_folder), readonly=True)
|
||||
_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":
|
||||
continue
|
||||
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)
|
||||
if not need_sum and not need_reply and not need_class and not need_cal and not need_urgent:
|
||||
already_cached += 1
|
||||
await _emit_progress(progress_cb, f"Checked {examined}/{len(uid_list)} · {already_cached} already cached")
|
||||
continue
|
||||
subject = _decode_header(msg.get("Subject", ""))
|
||||
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)
|
||||
except Exception as _ae:
|
||||
logger.debug(f"attachment text extraction failed for uid={uid}: {_ae}")
|
||||
# No threshold for calendar — even "see you tmrw 5pm" matters.
|
||||
# Summary/reply/classify still need ≥100 chars to be worth the LLM cost.
|
||||
# No threshold for calendar or reply drafting — even "can you
|
||||
# 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 need_cal:
|
||||
if not body:
|
||||
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:
|
||||
too_short += 1
|
||||
continue
|
||||
@@ -317,16 +359,26 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
_c.execute("""
|
||||
INSERT OR REPLACE INTO email_summaries
|
||||
(message_id, uid, folder, subject, sender, summary, model_used, created_at)
|
||||
VALUES (?, ?, 'INBOX', ?, ?, ?, ?, ?)
|
||||
""", (message_id, uid.decode(), subject, sender, summary, model, datetime.utcnow().isoformat()))
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (message_id, uid.decode() if isinstance(uid, bytes) else str(uid), _folder, subject, sender, summary, model, datetime.utcnow().isoformat()))
|
||||
_c.commit()
|
||||
_c.close()
|
||||
_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:
|
||||
_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}")
|
||||
|
||||
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
|
||||
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)."
|
||||
@@ -341,8 +393,8 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
{"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."},
|
||||
],
|
||||
temperature=0.7, max_tokens=16384,
|
||||
headers=req_headers, timeout=240,
|
||||
temperature=0.7, max_tokens=1024,
|
||||
headers=req_headers, timeout=90,
|
||||
)
|
||||
reply = _apply_email_style_mechanics(_extract_reply(reply or ""))
|
||||
if reply:
|
||||
@@ -350,12 +402,20 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
_c.execute("""
|
||||
INSERT OR REPLACE INTO email_ai_replies
|
||||
(message_id, uid, folder, reply, model_used, created_at)
|
||||
VALUES (?, ?, 'INBOX', ?, ?, ?)
|
||||
""", (message_id, uid.decode(), reply, model, datetime.utcnow().isoformat()))
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (message_id, uid.decode() if isinstance(uid, bytes) else str(uid), _folder, reply, model, datetime.utcnow().isoformat()))
|
||||
_c.commit()
|
||||
_c.close()
|
||||
_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:
|
||||
_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}")
|
||||
|
||||
# ── 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
|
||||
|
||||
conn.logout()
|
||||
await _emit_progress(progress_cb, "Finishing…")
|
||||
if processed > 0:
|
||||
logger.info(f"Auto-processed {processed} new email(s) for summary/reply/classify")
|
||||
# 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})"]
|
||||
if processed:
|
||||
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:
|
||||
parts.append(f"{already_cached} already cached")
|
||||
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)")
|
||||
if processed == 0 and already_cached == 0 and too_short == 0:
|
||||
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:
|
||||
logger.warning(f"Auto-summarize pass error: {e}")
|
||||
return f"Error: {e}"
|
||||
|
||||
@@ -1198,7 +1198,7 @@ def setup_email_routes():
|
||||
(message_id.strip(),),
|
||||
).fetchone()
|
||||
if _row2:
|
||||
cached_ai_reply = _row2[0]
|
||||
cached_ai_reply = _apply_email_style_mechanics(_extract_reply(_row2[0] or ""))
|
||||
_row3 = _c.execute(
|
||||
"SELECT sig_start, quote_start, turns_json FROM email_boundaries WHERE message_id = ?",
|
||||
(message_id.strip(),),
|
||||
@@ -1254,6 +1254,7 @@ def setup_email_routes():
|
||||
|
||||
return {
|
||||
"uid": uid,
|
||||
"folder": folder,
|
||||
"message_id": message_id.strip(),
|
||||
"subject": subject,
|
||||
"from_name": sender_name or sender_addr,
|
||||
@@ -2539,10 +2540,31 @@ def setup_email_routes():
|
||||
message_id = (data.get("message_id") or "").strip()
|
||||
source_uid = (data.get("uid") or "").strip()
|
||||
source_folder = (data.get("folder") or "INBOX").strip()
|
||||
fast_reply = bool(data.get("fast", False))
|
||||
|
||||
if not original_body:
|
||||
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()
|
||||
style = settings.get("email_writing_style", "")
|
||||
|
||||
@@ -2618,8 +2640,12 @@ def setup_email_routes():
|
||||
|
||||
logger.info(f"AI reply using model={model} url={url}")
|
||||
|
||||
# Pre-retrieval: mine names/topics from the original email, search past mail + contacts
|
||||
context_snippets, _terms = _pre_retrieve_context(original_body, to)
|
||||
# Manual AI Reply should feel immediate. The heavier context mining
|
||||
# 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 +
|
||||
# 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
|
||||
# the thread context for.
|
||||
referenced = ""
|
||||
try:
|
||||
from_addr_for_ctx = email.utils.parseaddr(to or "")[1]
|
||||
referenced = _fetch_sender_thread_context(
|
||||
sender_addr=from_addr_for_ctx,
|
||||
exclude_uid=source_uid,
|
||||
exclude_folder=source_folder,
|
||||
limit=3,
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.warning(f"sender-thread-context failed: {_e}")
|
||||
if not fast_reply:
|
||||
try:
|
||||
from_addr_for_ctx = email.utils.parseaddr(to or "")[1]
|
||||
referenced = _fetch_sender_thread_context(
|
||||
sender_addr=from_addr_for_ctx,
|
||||
exclude_uid=source_uid,
|
||||
exclude_folder=source_folder,
|
||||
limit=3,
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.warning(f"sender-thread-context failed: {_e}")
|
||||
|
||||
system_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE
|
||||
if style:
|
||||
@@ -2705,12 +2732,8 @@ def setup_email_routes():
|
||||
{"role": "user", "content": user_msg},
|
||||
],
|
||||
temperature=0.7,
|
||||
# Match the background poller's reply budget (16384). The old
|
||||
# 4096 cap let a local reasoning model (Qwen3 / R1) spend the
|
||||
# whole budget inside <think>, so _strip_think left nothing —
|
||||
# surfacing as "LLM returned empty response".
|
||||
max_tokens=16384,
|
||||
timeout=300,
|
||||
max_tokens=1024 if fast_reply else 6144,
|
||||
timeout=60 if fast_reply else 180,
|
||||
)
|
||||
except Exception as e:
|
||||
detail = getattr(e, "detail", None) or str(e)
|
||||
@@ -2724,7 +2747,6 @@ def setup_email_routes():
|
||||
# Cache so next click is instant
|
||||
if message_id:
|
||||
try:
|
||||
import sqlite3 as _sql3
|
||||
_c = _sql3.connect(SCHEDULED_DB)
|
||||
_c.execute("""
|
||||
INSERT OR REPLACE INTO email_ai_replies
|
||||
|
||||
@@ -427,6 +427,79 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
notes = task_scheduler.pop_notifications(owner=user)
|
||||
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}")
|
||||
async def get_task(request: Request, task_id: str):
|
||||
user = _owner(request)
|
||||
@@ -638,6 +711,23 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
raise HTTPException(409, "Task is already running")
|
||||
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")
|
||||
async def list_recent_runs(request: Request, limit: int = 50):
|
||||
"""Recent task runs across ALL tasks for this owner. Drives the Activity view."""
|
||||
|
||||
@@ -469,7 +469,12 @@ async def action_draft_email_replies(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
"""Run one pass of AI reply drafting."""
|
||||
try:
|
||||
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):
|
||||
raise TaskNoop(f"draft replies: {result or 'no new emails'}")
|
||||
return result, True
|
||||
|
||||
@@ -222,6 +222,24 @@ class TaskScheduler:
|
||||
# This is a hard guarantee, not configurable.
|
||||
self._run_semaphore = asyncio.Semaphore(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):
|
||||
"""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"
|
||||
# and hand off to _execute_task_locked.
|
||||
from core.database import SessionLocal, TaskRun
|
||||
current = asyncio.current_task()
|
||||
if current:
|
||||
self._task_handles[task_id] = current
|
||||
run_id = str(uuid.uuid4())
|
||||
_q_db = SessionLocal()
|
||||
try:
|
||||
@@ -524,6 +545,7 @@ class TaskScheduler:
|
||||
task_id=task_id,
|
||||
started_at=datetime.utcnow(),
|
||||
status="queued",
|
||||
result="Queued — waiting for a free slot…",
|
||||
)
|
||||
_q_db.add(run)
|
||||
_q_db.commit()
|
||||
@@ -563,6 +585,7 @@ class TaskScheduler:
|
||||
if run:
|
||||
run.status = "running"
|
||||
run.started_at = datetime.utcnow()
|
||||
run.result = "Starting…"
|
||||
db.commit()
|
||||
else:
|
||||
# Defensive: row may have been wiped; recreate so the rest of
|
||||
@@ -572,6 +595,7 @@ class TaskScheduler:
|
||||
task_id=task.id,
|
||||
started_at=datetime.utcnow(),
|
||||
status="running",
|
||||
result="Starting…",
|
||||
)
|
||||
db.add(run)
|
||||
db.commit()
|
||||
@@ -586,7 +610,7 @@ class TaskScheduler:
|
||||
self._last_run_model = None
|
||||
try:
|
||||
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.result = result
|
||||
if not success:
|
||||
@@ -622,6 +646,27 @@ class TaskScheduler:
|
||||
task.next_run = when
|
||||
db.commit()
|
||||
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:
|
||||
# Action reported "nothing to do". Mark the run as `skipped`
|
||||
# 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)
|
||||
finally:
|
||||
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:
|
||||
async with self._executing_lock:
|
||||
self._executing.discard(task_id)
|
||||
@@ -853,7 +901,7 @@ class TaskScheduler:
|
||||
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)."""
|
||||
from src.builtin_actions import BUILTIN_ACTIONS
|
||||
|
||||
@@ -864,7 +912,10 @@ class TaskScheduler:
|
||||
from src.builtin_actions import TaskNoop
|
||||
try:
|
||||
# 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:
|
||||
kwargs["script" if task.action in ("run_script", "run_local") else "command"] = task.prompt
|
||||
result, success = await action_fn(**kwargs)
|
||||
@@ -1752,6 +1803,38 @@ class TaskScheduler:
|
||||
asyncio.create_task(self._execute_task(task_id))
|
||||
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):
|
||||
"""Create default housekeeping tasks for this owner (idempotent per action)."""
|
||||
from core.database import SessionLocal, ScheduledTask
|
||||
|
||||
@@ -697,10 +697,9 @@
|
||||
<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)">
|
||||
<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>
|
||||
<rect x="14" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="14" width="7" height="7"></rect>
|
||||
<rect x="3" y="14" width="7" height="7"></rect>
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
|
||||
<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"/>
|
||||
<path d="M9 7h6M9 11h4"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="section-header-btn" id="session-sort-btn" title="Sort sessions">
|
||||
@@ -1878,6 +1877,14 @@
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -2306,6 +2306,48 @@ import * as Modals from './modalManager.js';
|
||||
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() {
|
||||
const target = _emailRichbodyActive() || document.getElementById('doc-editor-textarea');
|
||||
if (!target) return;
|
||||
@@ -2795,10 +2837,12 @@ import * as Modals from './modalManager.js';
|
||||
const references = document.getElementById('doc-email-references')?.value?.trim();
|
||||
const sourceUid = document.getElementById('doc-email-source-uid')?.value?.trim();
|
||||
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
|
||||
// sanitizes it). `body` (plain text mirror) stays the text/plain fallback.
|
||||
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 doc = docs.get(activeDocId);
|
||||
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');
|
||||
return;
|
||||
}
|
||||
if (inReplyTo && !_emailReplyOwnText(body)) {
|
||||
if (uiModule) uiModule.showError('Reply body is empty');
|
||||
return;
|
||||
}
|
||||
// Warn if body mentions attachments but none are actually attached
|
||||
if (attachments.length === 0 && _bodyMentionsAttachment(body)) {
|
||||
const proceed = await _confirmMissingAttachment();
|
||||
@@ -2829,12 +2877,13 @@ import * as Modals from './modalManager.js';
|
||||
let canceled = false;
|
||||
if (uiModule) {
|
||||
uiModule.showToast('Sending', {
|
||||
duration: 1200,
|
||||
duration: 3200,
|
||||
leadingIcon: 'spinner',
|
||||
action: 'Cancel',
|
||||
onAction: () => { canceled = true; },
|
||||
});
|
||||
}
|
||||
await _sleep(1000);
|
||||
await _sleep(3000);
|
||||
if (!canceled) detachedEmailDoc = _detachActiveEmailForBackground(sendDocId);
|
||||
await _sleep(200);
|
||||
if (canceled) {
|
||||
@@ -2844,28 +2893,10 @@ import * as Modals from './modalManager.js';
|
||||
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 res = await fetch(`${API_BASE}/api/email/send`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
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,
|
||||
}),
|
||||
});
|
||||
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 (uiModule) {
|
||||
uiModule.showToast('Message sent', {
|
||||
@@ -2961,8 +2998,10 @@ import * as Modals from './modalManager.js';
|
||||
const subject = document.getElementById('doc-email-subject')?.value?.trim();
|
||||
const inReplyTo = document.getElementById('doc-email-in-reply-to')?.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 textarea = document.getElementById('doc-editor-textarea');
|
||||
const body = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim();
|
||||
const bodyHtml = _rich ? _rich.innerHTML : null;
|
||||
const btn = document.getElementById('doc-email-draft-btn');
|
||||
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');
|
||||
if (!textarea) return;
|
||||
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
|
||||
let currentModel = '';
|
||||
@@ -3096,22 +3161,24 @@ import * as Modals from './modalManager.js';
|
||||
original_body: currentBody,
|
||||
model: currentModel,
|
||||
session_id: currentSessionId,
|
||||
message_id: inReplyTo,
|
||||
uid: sourceUid,
|
||||
folder: sourceFolder,
|
||||
fast: shouldUseFastAiReply(),
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && data.reply) {
|
||||
const cleanReply = cleanAiReplyText(data.reply);
|
||||
const lines = currentBody.split('\n');
|
||||
const quoteIdx = lines.findIndex(l => l.startsWith('On ') && l.includes(' wrote:'));
|
||||
let newBody = '';
|
||||
if (quoteIdx > 0) {
|
||||
const newBody = data.reply + '\n\n' + lines.slice(quoteIdx).join('\n');
|
||||
textarea.value = newBody;
|
||||
newBody = cleanReply + '\n\n' + lines.slice(quoteIdx).join('\n');
|
||||
} else {
|
||||
textarea.value = data.reply + (currentBody ? '\n\n' + currentBody : '');
|
||||
newBody = cleanReply + (currentBody ? '\n\n' + currentBody : '');
|
||||
}
|
||||
syncHighlighting();
|
||||
// Mirror into the WYSIWYG rich body if it's the active editor.
|
||||
const _rb = _emailRichbodyActive();
|
||||
if (_rb) _rb.innerHTML = _emailBodyToHtml(textarea.value);
|
||||
await _streamEmailBodyText(textarea, newBody);
|
||||
if (uiModule) uiModule.showToast(`AI draft inserted (${data.model_used || 'AI'})`);
|
||||
} else {
|
||||
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 inReplyTo = document.getElementById('doc-email-in-reply-to')?.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 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');
|
||||
return;
|
||||
}
|
||||
if (inReplyTo && !_emailReplyOwnText(body)) {
|
||||
if (uiModule) uiModule.showError('Reply body is empty');
|
||||
return;
|
||||
}
|
||||
if (attachments.length === 0 && _bodyMentionsAttachment(body)) {
|
||||
const proceed = await _confirmMissingAttachment();
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -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 _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 _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 _currentFolder = 'INBOX';
|
||||
@@ -609,52 +639,9 @@ function _createEmailItem(em) {
|
||||
}
|
||||
|
||||
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;
|
||||
if (mode === 'ai-reply' && preloadedData) {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
if (wantsAiReply) {
|
||||
// Fall through to reply-all (not plain reply) so the generated AI
|
||||
// draft addresses everyone on the original thread. On single-
|
||||
// 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);
|
||||
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;
|
||||
if (itemEl) itemEl.classList.remove('email-unread');
|
||||
@@ -772,7 +807,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') {
|
||||
} else {
|
||||
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) {
|
||||
|
||||
@@ -84,8 +84,6 @@ window.addEventListener('email-answered', (e) => {
|
||||
function _toggleUnreadEmails() {
|
||||
if (state._libFolder === '__scheduled__') state._libFolder = 'INBOX';
|
||||
state._libFilter = state._libFilter === 'unread' ? 'all' : 'unread';
|
||||
state._libOffset = 0;
|
||||
state._libEmails = [];
|
||||
_syncUnreadWindowGlow();
|
||||
const folderEl = document.getElementById('email-lib-folder');
|
||||
const filterEl = document.getElementById('email-lib-filter');
|
||||
@@ -93,7 +91,7 @@ function _toggleUnreadEmails() {
|
||||
if (filterEl) filterEl.value = state._libFilter;
|
||||
document.getElementById('email-undone-btn')?.classList.remove('active');
|
||||
document.getElementById('email-reminder-btn')?.classList.remove('active');
|
||||
_loadEmails();
|
||||
_loadEmailsFresh();
|
||||
}
|
||||
|
||||
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 } = {}) {
|
||||
if (_libPrewarmTimer || _libPrewarmPromise) return;
|
||||
const elapsed = Date.now() - _libLastPrewarmAt;
|
||||
@@ -742,17 +756,13 @@ export function openEmailLibrary(opts = {}) {
|
||||
|
||||
document.getElementById('email-lib-folder').addEventListener('change', (e) => {
|
||||
state._libFolder = e.target.value;
|
||||
state._libOffset = 0;
|
||||
state._libEmails = [];
|
||||
_loadEmails();
|
||||
_loadEmailsFresh();
|
||||
});
|
||||
document.getElementById('email-lib-filter').addEventListener('change', (e) => {
|
||||
state._libFilter = e.target.value;
|
||||
state._libOffset = 0;
|
||||
state._libEmails = [];
|
||||
_syncUnreadWindowGlow();
|
||||
_syncReminderClearButton();
|
||||
_loadEmails();
|
||||
_loadEmailsFresh();
|
||||
// Sync quick-toggle active states so they mirror the dropdown.
|
||||
document.getElementById('email-undone-btn')?.classList.toggle('active', state._libFilter === 'undone');
|
||||
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');
|
||||
state._libHasAttachments = !state._libHasAttachments;
|
||||
btn?.classList.toggle('active', state._libHasAttachments);
|
||||
state._libOffset = 0;
|
||||
state._libEmails = [];
|
||||
_syncReminderClearButton();
|
||||
_loadEmails();
|
||||
_loadEmailsFresh();
|
||||
});
|
||||
document.getElementById('email-reminders-clear-btn')?.addEventListener('click', async () => {
|
||||
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');
|
||||
if (filterEl) filterEl.value = 'all';
|
||||
document.getElementById('email-reminder-btn')?.classList.remove('active');
|
||||
state._libOffset = 0;
|
||||
state._libEmails = [];
|
||||
_syncReminderClearButton();
|
||||
_loadEmails();
|
||||
_loadEmailsFresh();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToast('Failed to clear reminder emails');
|
||||
@@ -812,11 +818,9 @@ export function openEmailLibrary(opts = {}) {
|
||||
btn.classList.add('active');
|
||||
document.getElementById('email-reminder-btn')?.classList.remove('active');
|
||||
}
|
||||
state._libOffset = 0;
|
||||
state._libEmails = [];
|
||||
_syncUnreadWindowGlow();
|
||||
_syncReminderClearButton();
|
||||
_loadEmails();
|
||||
_loadEmailsFresh();
|
||||
});
|
||||
document.getElementById('email-reminder-btn')?.addEventListener('click', () => {
|
||||
const btn = document.getElementById('email-reminder-btn');
|
||||
@@ -831,11 +835,9 @@ export function openEmailLibrary(opts = {}) {
|
||||
btn.classList.add('active');
|
||||
document.getElementById('email-undone-btn')?.classList.remove('active');
|
||||
}
|
||||
state._libOffset = 0;
|
||||
state._libEmails = [];
|
||||
_syncUnreadWindowGlow();
|
||||
_syncReminderClearButton();
|
||||
_loadEmails();
|
||||
_loadEmailsFresh();
|
||||
});
|
||||
// The old "sort" dropdown (Latest / Unread first / Favorites first) was merged
|
||||
// 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');
|
||||
if (!strip) return;
|
||||
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, '&').replace(/</g, '<').replace(/"/g, '"');
|
||||
const allActive = !state._libAccountId ? ' active' : '';
|
||||
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 () => {
|
||||
state._libAccountId = btn.dataset.accId || null;
|
||||
_publishActiveAccount();
|
||||
state._libOffset = 0;
|
||||
state._libEmails = [];
|
||||
_resetEmailListForFreshLoad();
|
||||
_renderAccountsStrip();
|
||||
await _loadFolders({ resetMissing: true });
|
||||
_loadEmails({ force: true });
|
||||
_loadEmails({ force: true, useCache: false });
|
||||
});
|
||||
});
|
||||
_publishActiveAccount();
|
||||
@@ -1358,7 +1357,7 @@ async function _refreshUnreadBadge() {
|
||||
} catch (_) { _syncUnreadTabBadge(0); }
|
||||
}
|
||||
|
||||
async function _loadEmails({ force = false } = {}) {
|
||||
async function _loadEmails({ force = false, useCache = true } = {}) {
|
||||
const seq = ++_libLoadSeq;
|
||||
state._libLoading = true;
|
||||
const accountAtStart = state._libAccountId || '';
|
||||
@@ -1375,15 +1374,16 @@ async function _loadEmails({ force = false } = {}) {
|
||||
// paint the cached list immediately (no spinner, no blank grid) and
|
||||
// then quietly refetch behind it. Pagination, search, and the
|
||||
// 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
|
||||
// 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 =
|
||||
offsetAtStart === 0 &&
|
||||
!searchAtStart &&
|
||||
folderAtStart !== '__scheduled__';
|
||||
const ck = cacheable ? _libCacheKey() : null;
|
||||
const cached = cacheable ? _libCacheGet(ck) : null;
|
||||
const cached = (useCache && cacheable) ? _libCacheGet(ck) : null;
|
||||
|
||||
let sp = null;
|
||||
if (cached) {
|
||||
@@ -1881,6 +1881,9 @@ function _prefetchAdjacentEmails(card, count = 3) {
|
||||
}
|
||||
|
||||
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 gridRect = grid?.getBoundingClientRect?.();
|
||||
const modal = document.getElementById('email-lib-modal');
|
||||
@@ -1921,7 +1924,7 @@ async function _toggleCardPreview(card, em) {
|
||||
card.style.minHeight = `${Math.round(stableOpenHeight)}px`;
|
||||
if (!em.is_read) {
|
||||
_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));
|
||||
}
|
||||
// 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);
|
||||
|
||||
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();
|
||||
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) {
|
||||
reader.innerHTML = `<div style="padding:20px;color:var(--red,#e55)">Error: ${_esc(data.error)}</div>`;
|
||||
return;
|
||||
@@ -2013,7 +2025,7 @@ async function _toggleCardPreview(card, em) {
|
||||
</div>
|
||||
</div>
|
||||
${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.style.minHeight = '';
|
||||
@@ -2252,6 +2264,23 @@ function _setBubblesDisabled(v) {
|
||||
}
|
||||
|
||||
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
|
||||
// and the one the chat-bubble layout is built around. Skip when the user
|
||||
// has manually disabled bubble rendering.
|
||||
@@ -2263,7 +2292,6 @@ function _renderEmailBody(data) {
|
||||
}
|
||||
const b = data && data.boundaries;
|
||||
// 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)) {
|
||||
// Pick the EARLIER of the two as the cut for "everything below this is
|
||||
// foldable", but render sig and quote with their own labels.
|
||||
@@ -2327,6 +2355,18 @@ function _renderEmailBody(data) {
|
||||
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 ──
|
||||
// Each parsed turn renders as a chat bubble. Bubbles for the active
|
||||
// account's outgoing replies align right; everyone else aligns left.
|
||||
@@ -2636,12 +2676,13 @@ function _renderPlaintextThread(text) {
|
||||
const lvl = levels[i];
|
||||
const raw = lines[i];
|
||||
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
|
||||
&& (new RegExp(`^\\s*On\\s.+?\\s${_TALON_WROTE}\\s*:\\s*$`, 'i').test(raw)
|
||||
|| _TALON_ORIG_RE.test('\n' + raw));
|
||||
if (isAttribLine) {
|
||||
if (isSeparatorLine || isAttribLine) {
|
||||
flush();
|
||||
pendingMeta = _extractQuoteMeta(raw) || raw.trim();
|
||||
pendingMeta = isSeparatorLine ? null : (_extractQuoteMeta(raw) || raw.trim());
|
||||
curLevel = 1;
|
||||
continue;
|
||||
}
|
||||
@@ -3699,7 +3740,7 @@ async function _openEmailAsTab(em, folder) {
|
||||
</div>
|
||||
</div>
|
||||
${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 {}
|
||||
const attsWrap = reader.querySelector('.email-reader-atts-wrap');
|
||||
@@ -3854,7 +3895,7 @@ async function _openEmailWindow(em, folder) {
|
||||
</div>
|
||||
</div>
|
||||
${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.
|
||||
try { _wireAttachmentHandlers(bodyEl, useFolder); } catch {}
|
||||
@@ -3971,7 +4012,7 @@ async function _swapReaderToUid(reader, uid, folder) {
|
||||
} else if (oldAtts) {
|
||||
oldAtts.remove();
|
||||
}
|
||||
body.innerHTML = _renderEmailBody(data);
|
||||
body.innerHTML = _safeRenderEmailBody(data);
|
||||
body.classList.toggle('html-body', !!data.body_html);
|
||||
// Wire click handlers for the newly-rendered attachment chips. Without
|
||||
// this, after swapping to a different email via the sidebar, clicking
|
||||
|
||||
@@ -2457,6 +2457,20 @@ async function initEmailAccountsSettings() {
|
||||
manageBtn.dataset.bound = '1';
|
||||
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 msgEl = el('set-email-accounts-msg');
|
||||
const formEl = el('set-email-accounts-form');
|
||||
|
||||
@@ -23,7 +23,7 @@ const DAYS_OF_WEEK = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'S
|
||||
|
||||
async function _fetchTasks() {
|
||||
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();
|
||||
_tasks = data.tasks || [];
|
||||
} 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) {
|
||||
const res = await fetch(`${API_BASE}/api/tasks/${taskId}/runs?limit=${limit}`, {
|
||||
credentials: 'same-origin',
|
||||
@@ -568,6 +583,19 @@ function _renderTaskChips() {
|
||||
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() {
|
||||
const list = document.getElementById('tasks-list');
|
||||
if (!list) return;
|
||||
@@ -630,7 +658,7 @@ function _renderList() {
|
||||
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>`
|
||||
: 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
|
||||
? `<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) {
|
||||
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 });
|
||||
_showTaskDropdown(menuBtn, items);
|
||||
});
|
||||
@@ -667,10 +698,10 @@ function _renderList() {
|
||||
// manual triggering. Hidden for completed tasks (same gate as before).
|
||||
if (task.status !== 'completed') {
|
||||
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.style.cssText = 'position:relative;top:4px;margin-right:4px;display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:2px 6px;';
|
||||
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.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.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); });
|
||||
actionsWrap.insertBefore(runBtn, menuBtn);
|
||||
}
|
||||
@@ -1578,6 +1609,25 @@ async function _doRevert(id) {
|
||||
} 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() {
|
||||
// If any task is active → pause all. Else resume all paused tasks.
|
||||
const hasActive = _tasks.some(t => t.status === 'active');
|
||||
@@ -1680,10 +1730,6 @@ async function _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
|
||||
// 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.
|
||||
@@ -1771,6 +1817,14 @@ async function _renderActivityView() {
|
||||
const searchEl = document.getElementById('tasks-activity-search');
|
||||
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 {
|
||||
const res = await fetch(`${API_BASE}/api/tasks/runs/recent?limit=100`, { credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
@@ -1796,6 +1850,7 @@ async function _renderActivityView() {
|
||||
kind: r.task_type || 'llm',
|
||||
taskName: r.task_name || (r.task_type === 'action' ? (r.action || 'Action') : 'Task'),
|
||||
taskId: r.task_id,
|
||||
action: r.action || '',
|
||||
result: resultText,
|
||||
prompt: '',
|
||||
ts: r.finished_at || r.started_at,
|
||||
@@ -1916,9 +1971,9 @@ function _wireActivityRows(list) {
|
||||
// counter). No-op when there's nothing to tick.
|
||||
_startActivityTimers(list);
|
||||
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.
|
||||
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.querySelector('.task-log-row-toggle')?.addEventListener('click', (e) => {
|
||||
@@ -1943,6 +1998,25 @@ function _wireActivityRows(list) {
|
||||
const entry = _activityEntries[idx];
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
const idx = parseInt(row.dataset.entryIdx, 10);
|
||||
@@ -1954,6 +2028,12 @@ function _wireActivityRows(list) {
|
||||
uiModule.showToast('Log copied');
|
||||
} 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>`;
|
||||
// Render the result through markdown so code blocks, lists, links look right.
|
||||
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';
|
||||
// Skipped (noop) rows: render as a slim, dimmed one-liner — no body, no
|
||||
// actions, just `· name · skipped — reason · time`. CSS via .is-skipped.
|
||||
const _isSkipped = entry.status === 'skipped';
|
||||
if (_isRunning) {
|
||||
if (_isRunning && !(entry.result || '').trim()) {
|
||||
resultHtml = '';
|
||||
} else {
|
||||
try {
|
||||
@@ -2155,6 +2233,7 @@ function _renderActivityEntry(entry) {
|
||||
// CSS vars feed the colored title + accent stripe.
|
||||
const styleVars = `--cat-hue:${hue};`;
|
||||
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
|
||||
// message (Prompt / Research tasks). Action/event runs are just log lines
|
||||
// (e.g. "No recent emails", "Tidied N memories") — for those, replace the
|
||||
@@ -2179,6 +2258,19 @@ function _renderActivityEntry(entry) {
|
||||
Copy log
|
||||
</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
|
||||
// live whirlpool spinner. Queued shows "Queued" the same way (no timer —
|
||||
// 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 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>` : '';
|
||||
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 {
|
||||
rightHtml = `<span class="task-log-time" title="${_escHtml(tsAbs)}">${_escHtml(tsLabel)}</span>`;
|
||||
}
|
||||
@@ -2223,7 +2316,7 @@ function _renderActivityEntry(entry) {
|
||||
<span style="flex:1"></span>
|
||||
${rightHtml}
|
||||
</div>
|
||||
${_isRunning ? '' : `<div class="task-log-row-body">${resultHtml}</div>`}
|
||||
${(_isRunning && !hasRunningProgress) ? '' : `<div class="task-log-row-body">${resultHtml}</div>`}
|
||||
${promptHtml}
|
||||
<div class="task-log-row-actions">
|
||||
${long ? '<button class="task-log-row-toggle" type="button">Show more</button>' : '<span></span>'}
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
|
||||
import themeModule from './theme.js';
|
||||
import * as Modals from './modalManager.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
|
||||
let toastEl = null;
|
||||
let autoScrollEnabled = true;
|
||||
let hoveredToggleCard = null;
|
||||
let hoveredToggleWindow = null;
|
||||
let hoveredDockChip = null;
|
||||
let _lastPointerClientX = null;
|
||||
let _lastPointerClientY = null;
|
||||
|
||||
// Smooth scroll state
|
||||
let _scrollRafId = null;
|
||||
@@ -74,6 +77,66 @@ function _spaceWindowId(win) {
|
||||
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) {
|
||||
const target = _targetEl(e.target);
|
||||
if (!target) return false;
|
||||
@@ -103,6 +166,8 @@ function _initHoverCardSpaceToggle() {
|
||||
if (document._odysseusHoverCardSpaceToggle) return;
|
||||
document._odysseusHoverCardSpaceToggle = true;
|
||||
document.addEventListener('pointerover', (e) => {
|
||||
_lastPointerClientX = e.clientX;
|
||||
_lastPointerClientY = e.clientY;
|
||||
const chip = e.target?.closest?.('.minimized-dock-chip[data-modal-id]');
|
||||
if (chip) hoveredDockChip = chip;
|
||||
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');
|
||||
if (win) hoveredToggleWindow = win;
|
||||
}, true);
|
||||
document.addEventListener('pointermove', (e) => {
|
||||
_lastPointerClientX = e.clientX;
|
||||
_lastPointerClientY = e.clientY;
|
||||
}, true);
|
||||
document.addEventListener('pointerout', (e) => {
|
||||
const next = e.relatedTarget;
|
||||
if (hoveredDockChip && (!next || !hoveredDockChip.contains(next))) hoveredDockChip = null;
|
||||
@@ -252,6 +321,12 @@ export function showToast(msg, durationOrOpts) {
|
||||
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>';
|
||||
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;
|
||||
toastEl.appendChild(textSpan);
|
||||
@@ -1114,8 +1189,6 @@ if (!window._odyEscExpandGuard) {
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
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.
|
||||
// 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
|
||||
// can briefly be absent). Toggling the `expanded` class directly is the
|
||||
// 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 think = document.querySelector('.thinking-content.expanded');
|
||||
if (expanded) {
|
||||
|
||||
@@ -3533,6 +3533,11 @@ body.bg-pattern-sparkles {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
backdrop-filter: blur(12px);
|
||||
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 .toast-checkmark {
|
||||
@@ -9984,6 +9989,17 @@ textarea.memory-add-input {
|
||||
background: color-mix(in srgb, var(--green, #50fa7b) 20%, 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 {
|
||||
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);
|
||||
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 {
|
||||
font-size: 9px;
|
||||
@@ -20518,11 +20538,10 @@ body:not(.welcome-ready) #welcome-screen {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.task-log-row.expanded .task-log-row-head { margin-bottom: 4px; }
|
||||
/* Collapsed: body + footer hidden. Expanded: visible. Running/skipped rows
|
||||
don't expand at all (no body to show). */
|
||||
.task-log-row:not(.expanded):not(.is-running):not(.is-skipped) .task-log-row-body,
|
||||
.task-log-row:not(.expanded):not(.is-running):not(.is-skipped) .task-log-row-actions,
|
||||
.task-log-row:not(.expanded):not(.is-running):not(.is-skipped) .task-log-prompt {
|
||||
/* Collapsed: body + footer hidden. Expanded: visible. */
|
||||
.task-log-row:not(.expanded):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-skipped) .task-log-prompt {
|
||||
display: none;
|
||||
}
|
||||
.task-log-name {
|
||||
@@ -20571,6 +20590,26 @@ body:not(.welcome-ready) #welcome-screen {
|
||||
opacity: 0.6;
|
||||
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
|
||||
shrunk, opacity dropped. Distinguishes "task ran but had nothing to do"
|
||||
@@ -20718,7 +20757,10 @@ body:not(.welcome-ready) #welcome-screen {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.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;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
@@ -20734,11 +20776,22 @@ body:not(.welcome-ready) #welcome-screen {
|
||||
line-height: 1.4;
|
||||
}
|
||||
.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);
|
||||
border-color: color-mix(in srgb, var(--fg) 30%, 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),
|
||||
click to toggle OFF (dimmed + strikethrough) to hide that group. */
|
||||
.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 */
|
||||
.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;
|
||||
}
|
||||
.email-field input { padding-right: 60px; }
|
||||
|
||||
Reference in New Issue
Block a user