Polish mobile UI and editor workflows

This commit is contained in:
pewdiepie-archdaemon
2026-06-27 13:05:44 +00:00
parent 87e46e576a
commit 45ee5a71f4
48 changed files with 6455 additions and 1177 deletions
+5 -1
View File
@@ -1001,7 +1001,11 @@ async def _startup_event():
_startup_tasks.append(asyncio.create_task(_warmup_endpoints()))
# Keep-alive: ping endpoints every 60 seconds to prevent cold starts
# Keep-alive is opt-in. The ping path performs model discovery, and when
# stale LAN endpoints are configured it can add periodic backend pressure
# that delays unrelated UI requests such as Notes/Documents.
_keepalive_enabled = str(os.getenv("ODYSSEUS_MODEL_KEEPALIVE", "")).lower() in {"1", "true", "yes", "on"}
if _keepalive_enabled:
async def _keepalive_loop():
while True:
try:
+163 -3
View File
@@ -538,6 +538,148 @@ def _get_cached_summaries():
return {}
def _fixture_email_file() -> Path:
return DATA_DIR / "fixture_email_messages.json"
def _fixture_email_enabled() -> bool:
return _fixture_email_file().exists()
def _parse_fixture_date(raw_date: str) -> tuple[str, float]:
if not raw_date:
return "", 0.0
parsed = None
try:
parsed = datetime.fromisoformat(str(raw_date).replace("Z", "+00:00"))
except Exception:
try:
parsed = email.utils.parsedate_to_datetime(str(raw_date))
except Exception:
parsed = None
if parsed:
return parsed.isoformat(), parsed.timestamp()
return str(raw_date), 0.0
def _fixture_email_record(row: dict, uid_num: int, owner: str) -> dict:
sender = str(row.get("from") or "Fixture Sender <fixture@example.invalid>")
sender_name, sender_addr = email.utils.parseaddr(sender)
date_str, date_epoch = _parse_fixture_date(str(row.get("date") or ""))
subject = str(row.get("subject") or "(no subject)")
body = str(row.get("body") or "")
owner_key = re.sub(r"[^A-Za-z0-9_.-]", "-", owner or "default")
uid = str(uid_num)
return {
"uid": uid,
"message_id": f"<fixture-email-{uid}-{owner_key}@fixtures.odysseus.local>",
"subject": subject,
"from": sender_name or sender_addr or sender,
"from_address": sender_addr,
"date": date_str,
"date_epoch": date_epoch,
"summary": body[:240],
"body": body,
"account": "Fixture Inbox",
"account_email": owner or str(row.get("owner") or ""),
"account_id": "fixture-email",
"attachments": [],
}
def _fixture_email_rows(owner: str | None = None) -> list[dict]:
path = _fixture_email_file()
if not path.exists():
return []
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return []
rows = raw.get("messages") if isinstance(raw, dict) else raw
out = []
owner = str(owner or "").strip()
for i, row in enumerate(rows if isinstance(rows, list) else [], start=1):
if not isinstance(row, dict):
continue
row_owner = str(row.get("owner") or "").strip()
if owner and row_owner and row_owner != owner:
continue
out.append(_fixture_email_record(row, i, owner or row_owner))
out.sort(key=lambda item: item.get("date_epoch") or 0, reverse=True)
return out
def _fixture_account_rows() -> list[dict]:
if not _fixture_email_enabled():
return []
owner = _current_owner()
owners = []
for row in _fixture_email_rows(owner or None):
email_addr = row.get("account_email") or owner or "fixture@fixtures.odysseus.local"
if email_addr not in owners:
owners.append(email_addr)
if not owners:
owners = [owner or "fixture@fixtures.odysseus.local"]
return [
{
"id": "fixture-email",
"owner": owner or owners[0],
"name": "Fixture Inbox",
"is_default": True,
"imap_user": owners[0],
"from_address": owners[0],
}
]
def _fixture_email_matches(item: dict, query: str) -> bool:
if not query:
return True
terms = [term for term in re.split(r"\W+", str(query).lower()) if term]
haystack = "\n".join(
str(item.get(key) or "")
for key in ("subject", "from", "from_address", "body", "summary")
).lower()
return all(term in haystack for term in terms)
def _fixture_list_emails(folder="INBOX", max_results=20, unresponded_only=False,
unread_only=False, account=None) -> list[dict] | None:
if not _fixture_email_enabled():
return None
if account and str(account).strip().lower() not in {
"fixture-email",
"fixture inbox",
"fixture",
str(_current_owner()).lower(),
}:
return []
if (folder or "INBOX").upper() not in {"INBOX", "ALL", "ALL MAIL"}:
return []
return _fixture_email_rows(_current_owner())[: int(max_results or 20)]
def _fixture_search_emails(query, folders=None, max_results=20, account=None) -> list[dict] | None:
if not _fixture_email_enabled():
return None
rows = _fixture_list_emails("INBOX", max_results=1000, account=account) or []
out = [dict(row, _folder="INBOX") for row in rows if _fixture_email_matches(row, str(query or ""))]
return out[: int(max_results or 20)]
def _fixture_read_email(uid=None, message_id=None, folder="INBOX", account=None) -> dict | None:
if not _fixture_email_enabled():
return None
if (folder or "INBOX").upper() not in {"INBOX", "ALL", "ALL MAIL"}:
return {"error": f"Email UID {uid or message_id} not found"}
for item in _fixture_email_rows(_current_owner()):
if uid and str(item.get("uid")) == str(uid):
return item
if message_id and str(item.get("message_id")) == str(message_id):
return item
return {"error": f"Email not found with UID/Message-ID: {uid or message_id}"}
# ── Tool implementations ──
@@ -548,6 +690,9 @@ def _list_emails(folder="INBOX", max_results=20, unresponded_only=False,
Pass unread_only=True and/or unresponded_only=True for attention scans.
account selects mailbox (None = default).
"""
fixture = _fixture_list_emails(folder, max_results, unresponded_only, unread_only, account)
if fixture is not None:
return fixture
conn = None
try:
conn = _imap_connect(account)
@@ -629,6 +774,9 @@ def _result_sort_time(result: dict) -> datetime:
def _list_emails_across_accounts(folder="INBOX", max_results=20,
unresponded_only=False, unread_only=False):
fixture = _fixture_list_emails(folder, max_results, unresponded_only, unread_only, None)
if fixture is not None:
return fixture, []
rows = _list_accounts_raw()
combined = []
errors = []
@@ -662,6 +810,9 @@ def _search_emails(query, folders=None, max_results=20, account=None):
_list_emails plus an `_folder` tag."""
if not query or not str(query).strip():
return []
fixture = _fixture_search_emails(query, folders=folders, max_results=max_results, account=account)
if fixture is not None:
return fixture
q = str(query).replace("\\", "\\\\").replace('"', '\\"')
# Mail clients commonly use OR FROM/SUBJECT/TEXT to match either field.
# IMAP SEARCH OR is binary, so we nest it.
@@ -784,6 +935,9 @@ def _extract_attachment_to_disk(msg, index, target_dir):
def _read_email(uid=None, message_id=None, folder="INBOX", account=None):
"""Read full email content by UID or message-ID. account = mailbox selector."""
fixture = _fixture_read_email(uid=uid, message_id=message_id, folder=folder, account=account)
if fixture is not None:
return fixture
cfg = _load_config(account)
conn = None
try:
@@ -837,6 +991,9 @@ def _read_email(uid=None, message_id=None, folder="INBOX", account=None):
def _read_email_across_accounts(uid=None, message_id=None, folder="INBOX"):
fixture = _fixture_read_email(uid=uid, message_id=message_id, folder=folder, account=None)
if fixture is not None:
return fixture
rows = _list_accounts_raw()
matches = []
errors = []
@@ -1775,9 +1932,10 @@ async def list_tools() -> list[Tool]:
Tool(
name="reply_to_email",
description=(
"Reply to an existing email by UID. This sends immediately; for normal "
"assistant-written replies, prefer draft_email_reply so the user can "
"review and send from Odysseus. Automatically threads the reply with "
"Reply to an existing email by UID. This sends immediately. Do NOT use "
"for normal 'write/draft a reply saying X' requests; use "
"draft_email_reply so the user can review and send from Odysseus. "
"Only use this when the user explicitly says to send now. Automatically threads the reply with "
"In-Reply-To and References headers, prefixes 'Re:' on the subject, and "
"uses the original sender as the recipient. Set reply_all=true to also CC "
"the original To/Cc recipients. For follow-up 'reply ...' requests, use "
@@ -1991,6 +2149,8 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if name == "list_email_accounts":
rows = _filter_accounts_for_owner(all_db_accounts)
if not rows:
rows = _fixture_account_rows()
if not rows:
if all_db_accounts and owner:
return [TextContent(type="text", text="No email accounts configured for this owner.")]
+8 -8
View File
@@ -789,19 +789,19 @@ def setup_chat_routes(
"manage_skills", # skill presets tied to user
})
# Active email reader open → strip the tools that let the agent
# "drift" to a new compose: create_document (writes a fake email-
# shaped .md file) and send_email (sends fresh to a recipient the
# agent invented). With those gone, the only paths left for "write
# email saying X" are ui_control open_email_reply (draft) and
# reply_to_email (immediate send) — both of which use the open
# email's UID. Code-level enforcement instead of relying on a
# prompt rule the model can ignore.
# Active email reader open → strip the tools that let the agent drift
# away from the visible email or skip review. The only allowed compose
# path is ui_control open_email_reply, which opens the same draft editor
# as the Reply button with the generated body pre-filled. This prevents
# the model from falling back to direct SMTP when it botches a draft
# call, and prevents fake email-shaped documents.
if active_email_ctx and active_email_ctx.get("uid"):
disabled_tools.update({
"create_document",
"send_email",
"reply_to_email",
"mcp__email__send_email",
"mcp__email__reply_to_email",
})
# Enforce per-user privileges
+50 -1
View File
@@ -9,6 +9,7 @@ import shlex
import shutil
import subprocess
import sys
import urllib.request
import uuid
from pathlib import Path
@@ -400,6 +401,35 @@ def setup_cookbook_routes() -> APIRouter:
safe_chmod(key_path.with_suffix(".pub"), 0o644)
return {"ok": True, "public_key": _read_cookbook_public_key()}
class CookbookSshTestRequest(BaseModel):
host: str
ssh_port: str | None = None
@router.post("/api/cookbook/test-ssh")
async def test_cookbook_ssh(request: Request, req: CookbookSshTestRequest):
"""Test a configured Cookbook SSH target without using generic shell exec."""
require_admin(request)
host = validate_remote_host(req.host)
ssh_port = validate_ssh_port(req.ssh_port)
try:
code, stdout, stderr = await run_ssh_command_async(
host,
ssh_port,
"echo ok",
timeout=8,
connect_timeout=5,
strict_host_key_checking=False,
)
except asyncio.TimeoutError:
return {"stdout": "", "stderr": "SSH test timed out", "exit_code": 124}
except Exception as e:
return {"stdout": "", "stderr": str(e), "exit_code": -1}
return {
"stdout": stdout.decode("utf-8", errors="replace"),
"stderr": stderr.decode("utf-8", errors="replace"),
"exit_code": code,
}
def _needs_binary(cmd: str, binary: str) -> bool:
return bool(re.search(rf"(^|[\s;&|()]){re.escape(binary)}($|[\s;&|()])", cmd or ""))
@@ -1119,6 +1149,22 @@ def setup_cookbook_routes() -> APIRouter:
try:
ep = db.query(_ME).filter(_ME.id == endpoint_id).first()
if ep:
# A scheduled serve can leave old non-zero exit markers
# in tmux scrollback while the current OpenAI endpoint is
# actually alive. Verify reachability before deleting the
# endpoint row; otherwise chats fall back even though the
# served model is ready.
try:
probe_url = ep.base_url.rstrip("/") + "/models"
with urllib.request.urlopen(probe_url, timeout=3) as resp:
if 200 <= getattr(resp, "status", 0) < 300:
logger.info(
f"crash-watchdog: serve {session_id} has exit marker {exit_code} "
f"but endpoint {ep.id} is reachable; leaving it registered"
)
return
except Exception:
pass
logger.info(
f"crash-watchdog: dropping endpoint {endpoint_id} "
f"({ep.name} @ {ep.base_url}) — serve exited {exit_code}"
@@ -1205,6 +1251,8 @@ def setup_cookbook_routes() -> APIRouter:
existing.is_enabled = True
existing.model_type = "llm"
existing.name = display_name
existing.endpoint_kind = "local"
existing.model_refresh_mode = "auto"
if is_ollama_endpoint:
existing.endpoint_kind = "ollama"
if pinned_models:
@@ -1252,7 +1300,8 @@ def setup_cookbook_routes() -> APIRouter:
api_key=None,
is_enabled=True,
model_type="llm",
endpoint_kind="ollama" if is_ollama_endpoint else "auto",
endpoint_kind="ollama" if is_ollama_endpoint else "local",
model_refresh_mode="auto",
cached_models=json.dumps(pinned_models) if pinned_models else None,
pinned_models=json.dumps(pinned_models) if pinned_models else None,
supports_tools=supports_tools,
+1
View File
@@ -28,6 +28,7 @@ class DocumentCreate(BaseModel):
class DocumentUpdate(BaseModel):
content: str
summary: Optional[str] = None
force_version: bool = False
class DocumentPatch(BaseModel):
title: Optional[str] = None
+15 -3
View File
@@ -569,8 +569,9 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
raise HTTPException(404, "Document not found")
_verify_doc_owner(db, doc, user)
# Skip if content is identical
if doc.current_content == req.content:
# Skip if content is identical unless the caller explicitly wants
# a checkpoint version from the current editor state.
if doc.current_content == req.content and not req.force_version:
return _doc_to_dict(doc)
_assert_pdf_marker_upload_owned(request, req.content, user, upload_handler)
@@ -582,7 +583,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
now = datetime.now(timezone.utc)
coalesced = False
if latest_ver and latest_ver.source == "user":
if latest_ver and latest_ver.source == "user" and not req.force_version:
ver_time = latest_ver.created_at
if ver_time.tzinfo is None:
ver_time = ver_time.replace(tzinfo=timezone.utc)
@@ -798,10 +799,21 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
from src.document_actions import _JUNK_TITLES
to_delete = []
now = datetime.now(timezone.utc)
for doc in docs:
content = (doc.current_content or "").strip()
title_raw = (doc.title or "").strip()
title = title_raw.lower()
created = doc.created_at
if created and created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
is_fresh_empty = (
not content
and created is not None
and (now - created).total_seconds() < 1800
)
if is_fresh_empty:
continue
# Strip markdown noise to get a "real" character count
stripped = _re.sub(r"^#{1,6}\s+", "", content, flags=_re.MULTILINE)
+95 -9
View File
@@ -433,6 +433,19 @@ def _ensure_owner_scoped_email_cache_table(conn, table: str, create_sql: str, co
info = conn.execute(f"PRAGMA table_info({table})").fetchall()
cols = [r[1] for r in info]
pk_cols = [r[1] for r in sorted((r for r in info if r[5]), key=lambda r: r[5])]
for col in columns:
if col not in cols:
if col == "owner":
conn.execute(f"ALTER TABLE {table} ADD COLUMN owner TEXT DEFAULT ''")
elif col in {"event_uids"}:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} TEXT DEFAULT '[]'")
elif col.startswith("has_") or col.endswith("_created") or col.endswith("_count"):
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} INTEGER DEFAULT 0")
elif col == "created_at":
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} TEXT DEFAULT ''")
else:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} TEXT")
cols.append(col)
if "owner" in cols and pk_cols == ["message_id", "owner"]:
return
@@ -575,6 +588,7 @@ def _init_scheduled_db():
CREATE TABLE IF NOT EXISTS email_tags (
message_id TEXT,
owner TEXT DEFAULT '',
account_id TEXT DEFAULT '',
uid TEXT,
folder TEXT,
subject TEXT,
@@ -585,7 +599,7 @@ def _init_scheduled_db():
moved_to TEXT,
model_used TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY (message_id, owner)
PRIMARY KEY (message_id, owner, account_id)
)
""")
# Backfill migration: older installs created the table with
@@ -593,28 +607,35 @@ def _init_scheduled_db():
# promote it into the PK by rebuild-copy-swap (SQLite can't ALTER PK).
try:
_cols = [r[1] for r in conn.execute("PRAGMA table_info(email_tags)")]
_pk_cols = [r[1] for r in sorted(conn.execute("PRAGMA table_info(email_tags)").fetchall(), key=lambda row: row[5] or 99) if r[5]]
if "owner" not in _cols:
# Add the column first so reads/writes don't break mid-migration.
conn.execute("ALTER TABLE email_tags ADD COLUMN owner TEXT DEFAULT ''")
# Rebuild with composite PK. Existing rows get owner='' (legacy
# single-user); the urgency scanner will overwrite as it
# re-classifies. No data loss.
_cols.append("owner")
if "account_id" not in _cols:
conn.execute("ALTER TABLE email_tags ADD COLUMN account_id TEXT DEFAULT ''")
_cols.append("account_id")
if _pk_cols != ["message_id", "owner", "account_id"]:
# Rebuild with account-aware composite PK. Existing rows get
# account_id='' and are still readable as legacy fallback rows;
# fresh task runs write exact account ids and no longer block each
# other when two accounts share a Message-ID.
conn.execute("""
CREATE TABLE IF NOT EXISTS email_tags__new (
message_id TEXT,
owner TEXT DEFAULT '',
account_id TEXT DEFAULT '',
uid TEXT, folder TEXT, subject TEXT, sender TEXT,
tags TEXT, spam_verdict INTEGER DEFAULT 0,
spam_reason TEXT, moved_to TEXT, model_used TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY (message_id, owner)
PRIMARY KEY (message_id, owner, account_id)
)
""")
conn.execute("""
INSERT OR IGNORE INTO email_tags__new
(message_id, owner, uid, folder, subject, sender, tags,
(message_id, owner, account_id, uid, folder, subject, sender, tags,
spam_verdict, spam_reason, moved_to, model_used, created_at)
SELECT message_id, COALESCE(owner, ''), uid, folder, subject,
SELECT message_id, COALESCE(owner, ''), COALESCE(account_id, ''), uid, folder, subject,
sender, tags, spam_verdict, spam_reason, moved_to,
model_used, created_at
FROM email_tags
@@ -630,11 +651,12 @@ def _init_scheduled_db():
message_id TEXT,
owner TEXT DEFAULT '',
uid TEXT,
event_uids TEXT DEFAULT '[]',
events_created INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
PRIMARY KEY (message_id, owner)
)
""", ["message_id", "owner", "uid", "events_created", "created_at"])
""", ["message_id", "owner", "uid", "event_uids", "events_created", "created_at"])
_ensure_owner_scoped_email_cache_table(conn, "email_urgency_alerts", """
CREATE TABLE IF NOT EXISTS email_urgency_alerts (
message_id TEXT,
@@ -660,6 +682,64 @@ def _init_scheduled_db():
PRIMARY KEY (owner, account_key, folder, message_key)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS email_message_index (
owner TEXT NOT NULL DEFAULT '',
account_key TEXT NOT NULL DEFAULT '',
folder TEXT NOT NULL,
uid TEXT NOT NULL,
message_id TEXT,
subject TEXT,
from_name TEXT,
from_address TEXT,
to_text TEXT,
cc_text TEXT,
date_iso TEXT,
date_display TEXT,
date_epoch REAL DEFAULT 0,
size INTEGER DEFAULT 0,
flags TEXT DEFAULT '',
has_attachments INTEGER DEFAULT 0,
updated_at TEXT NOT NULL,
PRIMARY KEY (owner, account_key, folder, uid)
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS ix_email_message_index_folder_date
ON email_message_index(owner, account_key, folder, date_epoch DESC)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS ix_email_message_index_message_id
ON email_message_index(owner, account_key, message_id)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS email_body_preview_cache (
owner TEXT NOT NULL DEFAULT '',
account_key TEXT NOT NULL DEFAULT '',
folder TEXT NOT NULL,
uid TEXT NOT NULL,
message_id TEXT,
payload_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (owner, account_key, folder, uid)
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS ix_email_body_preview_message_id
ON email_body_preview_cache(owner, account_key, message_id)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS email_attachment_metadata_cache (
owner TEXT NOT NULL DEFAULT '',
account_key TEXT NOT NULL DEFAULT '',
folder TEXT NOT NULL,
uid TEXT NOT NULL,
message_id TEXT,
attachments_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (owner, account_key, folder, uid)
)
""")
# Boundary cache — LLM-detected sig/quote start positions in the body.
# Stored as char offsets (-1 = no boundary found). Once cached, the
# client uses these to fold without ever re-calling the LLM.
@@ -1257,12 +1337,14 @@ def _list_attachments_from_msg(msg):
except Exception:
payload = b""
size = len(payload) if payload is not None else 0
content_id = (part.get("Content-ID") or "").strip().strip("<>")
attachments.append({
"index": idx,
"filename": filename,
"content_type": ct,
"size": size,
"is_inline": "inline" in cd.lower(),
"content_id": content_id,
})
idx += 1
return attachments
@@ -1701,6 +1783,10 @@ class SendEmailRequest(BaseModel):
attachments: Optional[List[str]] = None
# Which account to send from. None = default account.
account_id: Optional[str] = None
# Source message for replies. When present, /send marks this exact message
# answered after successful delivery so it leaves undone/reply-soon views.
source_uid: Optional[str] = None
source_folder: Optional[str] = None
# Internal marker for Odysseus-generated mail (e.g. reminder, scheduled).
odysseus_kind: Optional[str] = None
# If true, /send waits for SMTP + Sent append and returns the sent UID.
+136 -89
View File
@@ -29,7 +29,7 @@ from datetime import datetime
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from src.llm_core import llm_call_async
from src.task_endpoint import resolve_task_candidates, task_llm_call_async
from routes.email_helpers import (
_strip_think, _extract_reply, _apply_email_style_mechanics, _load_settings, _save_settings, _get_email_config,
@@ -45,6 +45,35 @@ from routes.email_helpers import (
logger = logging.getLogger(__name__)
def _extract_json_array_from_text(text: str):
"""Return the last valid JSON array embedded in model output, if any."""
if not text:
return None
cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip(), flags=re.MULTILINE).strip()
decoder = json.JSONDecoder()
try:
parsed = decoder.decode(cleaned)
if isinstance(parsed, list):
return parsed
except Exception:
pass
# Models often explain themselves and finish with `[]` or `[{"action":...}]`.
# Scan every array opener and keep the last complete JSON array, rather than
# using a greedy regex that can swallow prose containing square brackets.
last = None
for idx, ch in enumerate(cleaned):
if ch != "[":
continue
try:
parsed, _end = decoder.raw_decode(cleaned[idx:])
except Exception:
continue
if isinstance(parsed, list):
last = parsed
return last
def _owner_for_email_account(account_id: str | None) -> str:
if not account_id:
return ""
@@ -77,6 +106,8 @@ async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = Tru
do_tag: bool = False, do_spam: bool = False,
do_calendar: bool = False,
days_back: int = 1,
account_id: str | None = None,
max_process: int | None = None,
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."""
@@ -91,7 +122,12 @@ 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, progress_cb=progress_cb)
return await _auto_summarize_pass(
days_back=days_back,
account_id=account_id,
max_process=max_process,
progress_cb=progress_cb,
)
finally:
s2 = _load_settings()
for k, v in prev.items():
@@ -129,7 +165,7 @@ def _latest_inbox_fallback_uids(conn, reconnect):
return [], reconnect()
async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None, progress_cb=None) -> str:
async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None, max_process: int | 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
@@ -156,28 +192,41 @@ 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), progress_cb=progress_cb)
return await _auto_summarize_pass_single(
days_back=days_back,
account_id=(ids[0] if ids else None),
max_process=max_process,
progress_cb=progress_cb,
)
outs = []
for idx, aid in enumerate(ids, start=1):
try:
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)
result = await _auto_summarize_pass_single(
days_back=days_back,
account_id=aid,
max_process=max_process,
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, progress_cb=progress_cb)
return await _auto_summarize_pass_single(
days_back=days_back,
account_id=account_id,
max_process=max_process,
progress_cb=progress_cb,
)
async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None = None, progress_cb=None) -> str:
async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None = None, max_process: int | None = None, progress_cb=None) -> str:
"""Single pass of the auto-summarize/reply scan for ONE account.
Reads current settings flags."""
import asyncio
import sqlite3 as _sql3
import requests as _req
from src.endpoint_resolver import resolve_endpoint
from src.llm_core import _uses_max_completion_tokens, _restricts_temperature
from src.llm_core import _uses_max_completion_tokens
settings = _load_settings()
auto_sum = settings.get("email_auto_summarize", False)
@@ -254,9 +303,15 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
).fetchall()}
if auto_tag or auto_spam:
if account_owner:
_tag_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_tags WHERE owner=?", (account_owner,)).fetchall()}
_tag_existing = {r[0] for r in _c.execute(
"SELECT message_id FROM email_tags WHERE owner=? AND (account_id=? OR account_id='' OR account_id IS NULL)",
(account_owner, account_id or ""),
).fetchall()}
else:
_tag_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_tags WHERE owner='' OR owner IS NULL").fetchall()}
_tag_existing = {r[0] for r in _c.execute(
"SELECT message_id FROM email_tags WHERE (owner='' OR owner IS NULL) AND (account_id=? OR account_id='' OR account_id IS NULL)",
(account_id or "",),
).fetchall()}
else:
_tag_existing = set()
_cal_existing = {r[0] for r in _c.execute(
@@ -285,11 +340,10 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
if auto_spam and not spam_folder:
logger.warning("Auto-spam enabled but no Junk/Spam folder detected — will classify but not move")
url, model, headers = resolve_endpoint("utility", owner=account_owner)
if not url:
url, model, headers = resolve_endpoint("default", owner=account_owner)
if not url or not model:
task_candidates = resolve_task_candidates(owner=account_owner)
if not task_candidates:
return "No model configured"
url, model, headers = task_candidates[0]
writing_style = settings.get("email_writing_style", "")
processed = 0
@@ -303,7 +357,14 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
_reply_failed = 0
_detail_lines = []
_current_folder = "INBOX"
_max_process = 5
# Calendar extraction is sequential and each row can involve a model
# call plus a calendar write. Keep the scheduled calendar-only pass
# below the 5-minute action budget instead of timing out mid-run.
_default_max_process = 3 if (auto_cal and not auto_sum and not auto_reply and not auto_tag and not auto_spam) else 5
try:
_max_process = max(1, int(max_process)) if max_process is not None else _default_max_process
except Exception:
_max_process = _default_max_process
for _entry in uid_list:
if processed >= _max_process:
break
@@ -395,35 +456,17 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
req_headers.update(headers)
if need_sum:
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
payload = {
"model": model,
"messages": [
try:
summary = await task_llm_call_async(
messages=[
{"role": "system", "content": "You are an email summarizer. Format: 1-3 short bullet points (use '- '). Cover: main point, action items, deadlines. If the email has attachments (marked '--- ATTACHMENTS ---'), USE THEIR CONTENTS — pull out invoice totals, deadlines, key clauses, any concrete numbers/dates in PDFs/docs, and reflect them in the bullets. Be terse.\n\nOUTPUT FORMAT: Put ONLY the bullet points between these exact markers, each on its own line:\n<<<SUMMARY>>>\n- ...\n<<<END>>>\nAny reasoning or planning must come BEFORE <<<SUMMARY>>> (ideally inside <think>...</think>). Only the text between the markers is kept."},
{"role": "user", "content": f"From: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}\n\n---\n\nSummarize the email. Output the bullets between <<<SUMMARY>>> and <<<END>>>."},
],
tok_key: 16384,
"temperature": 0.3,
"stream": False,
}
# Reasoning models (o1/o3/o4/gpt-5) reject an explicit temperature.
if _restricts_temperature(model):
payload.pop("temperature", None)
try:
# Use to_thread so this sync HTTP call doesn't freeze
# the entire event loop while the LLM thinks (240s).
resp = await asyncio.to_thread(
_req.post, url, json=payload, headers=req_headers, timeout=240
fallback_url=url, fallback_model=model, fallback_headers=headers,
owner=account_owner or None,
temperature=0.3, max_tokens=16384, timeout=240,
)
if resp.ok:
rdata = resp.json()
m = (rdata.get("choices") or [{}])[0].get("message", {})
summary = (m.get("content") or "").strip()
summary = _extract_reply(summary)
if not summary:
rc = (m.get("reasoning_content") or "").strip()
bullets = [ln.strip() for ln in rc.split("\n") if re.match(r"^[-•*]\s+|^\d+[.)]\s+", ln.strip())]
summary = "\n".join(bullets) if bullets else ""
summary = _extract_reply((summary or "").strip())
if summary:
_c = _sql3.connect(SCHEDULED_DB)
_c.execute("""
@@ -457,14 +500,14 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
if context_snippets:
sys_prompt += "\n\nRELEVANT CONTEXT FROM PAST EMAILS AND CONTACTS:\n" + "\n\n---\n\n".join(context_snippets[:5])
try:
reply = await llm_call_async(
url=url, model=model,
reply = await task_llm_call_async(
messages=[
{"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=1024,
headers=req_headers, timeout=90,
fallback_url=url, fallback_model=model, fallback_headers=headers,
owner=account_owner or None,
temperature=0.7, max_tokens=1024, timeout=90,
)
reply = _apply_email_style_mechanics(_extract_reply(reply or ""))
if reply:
@@ -491,6 +534,8 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
# ── Calendar event extraction (independent of reply drafting) ──
if need_cal:
_cal_run_count = 0
_cal_event_uids = []
_cal_parse_ok = False
try:
# Pull a snapshot of upcoming events so the LLM can decide
# create vs update vs cancel based on what already exists.
@@ -499,8 +544,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
_existing_summary = get_upcoming_events(_acct_owner, horizon_days=60, limit=40)
existing_json = json.dumps(_existing_summary)
is_sent = _folder.lower().startswith("sent") or "sent" in _folder.lower()
cal_extract = await llm_call_async(
url=url, model=model,
cal_extract = await task_llm_call_async(
messages=[
{"role": "system", "content": (
"You are a calendar assistant. The user receives emails AND sends replies "
@@ -551,8 +595,9 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
f"{body[:4000]}"
)},
],
temperature=0.1, max_tokens=16384,
headers=req_headers, timeout=180,
fallback_url=url, fallback_model=model, fallback_headers=headers,
owner=account_owner or None,
temperature=0.1, max_tokens=16384, timeout=75,
)
_raw_original = cal_extract or ""
cal_extract = _strip_think(_raw_original)
@@ -562,10 +607,10 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
if matches:
cal_extract = matches[-1].group()
logger.info(f"[cal-extract] uid={uid.decode() if isinstance(uid, bytes) else uid} folder={_folder} subj={subject[:50]!r} raw_len={len(cal_extract)} orig_len={len(_raw_original)} raw={cal_extract[:800]!r}")
jm = re.search(r'\[.*\]', cal_extract, re.DOTALL)
if jm:
ops = _extract_json_array_from_text(cal_extract)
if ops is not None:
try:
ops = json.loads(jm.group())
_cal_parse_ok = True
logger.info(f"[cal-extract] parsed {len(ops)} op(s)")
if isinstance(ops, list) and ops:
from src.tool_implementations import do_manage_calendar
@@ -595,6 +640,8 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
r = await do_manage_calendar(json.dumps(args), owner=_acct_owner)
if r.get("exit_code", 0) == 0:
logger.info(f"[cal-extract] Updated event uid={cuid}{op.get('title')} {op['date']}")
if cuid and cuid not in _cal_event_uids:
_cal_event_uids.append(cuid)
_cal_run_count += 1
else:
logger.warning(f"[cal-extract] update failed: {r.get('error')}")
@@ -675,22 +722,30 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
r = await do_manage_calendar(cal_args, owner=_acct_owner)
if r.get("exit_code", 0) == 0:
logger.info(f"[cal-extract] Created event: {op['title']} on {op['date']}")
_created_uid = (r.get("uid") or "").strip()
if _created_uid and _created_uid not in _cal_event_uids:
_cal_event_uids.append(_created_uid)
_events_created += 1
_cal_run_count += 1
else:
logger.warning(f"[cal-extract] create failed: {r.get('error')} args={cal_args[:200]}")
except Exception as je:
logger.warning(f"[cal-extract] JSON parse failed: {je} on raw={cal_extract[:200]!r}")
else:
logger.warning(f"[cal-extract] no JSON array found on raw={cal_extract[:200]!r}")
except Exception as e:
logger.warning(f"[cal-extract] Meeting extraction LLM call failed for uid={uid}: {e}")
# Record we processed this email so we don't re-LLM next run
# Record successfully parsed results so we don't re-LLM
# no-op emails. If the model returned no parseable JSON,
# leave it uncached so a future run can retry.
try:
if _cal_parse_ok:
_cc = _sql3.connect(SCHEDULED_DB)
_cc.execute(
"INSERT OR REPLACE INTO email_calendar_extractions "
"(message_id, owner, uid, events_created, created_at) VALUES (?, ?, ?, ?, ?)",
"(message_id, owner, uid, event_uids, events_created, created_at) VALUES (?, ?, ?, ?, ?, ?)",
(message_id, account_owner or "", uid.decode() if isinstance(uid, bytes) else str(uid),
_cal_run_count, datetime.utcnow().isoformat())
json.dumps(_cal_event_uids), _cal_run_count, datetime.utcnow().isoformat())
)
_cc.commit()
_cc.close()
@@ -728,9 +783,11 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
"temperature": 0,
tok_key: 200,
}
urg_raw = await llm_call_async(
url=url, model=model, messages=payload["messages"],
temperature=0, max_tokens=200, headers=req_headers, timeout=60,
urg_raw = await task_llm_call_async(
messages=payload["messages"],
fallback_url=url, fallback_model=model, fallback_headers=headers,
owner=account_owner or None,
temperature=0, max_tokens=200, timeout=60,
)
urg_raw = _strip_think(urg_raw or "")
urg_raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", urg_raw, flags=re.MULTILINE).strip()
@@ -831,8 +888,13 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
class_sys = (
"Classify the email. Return ONLY a JSON object, no prose, no markdown fences. "
"Schema: {\"tags\": [\"tag1\"], \"spam\": false, \"reason\": \"short\"}. "
"Pick 1-2 tags from: work, personal, finance, bills, receipt, travel, "
"newsletter, promo, notification, security, social, shopping, calendar.\n\n"
"Pick 1-3 tags from: work, personal, urgent, action-needed, finance, bills, "
"receipt, legal, travel, newsletter, promo, notification, security, social, "
"shopping, calendar, support.\n\n"
"Use work for professional/company/client/operations messages. "
"Use personal for friends/family/private-life messages. "
"Use urgent for real time-sensitive consequences. "
"Use action-needed when the user likely needs to reply, pay, sign, book, or decide.\n\n"
"Set spam=true for ANY of:\n"
"- Phishing, scams, chain mail, deceptive offers\n"
"- Marketing/promotional blasts (\"special offer\", \"limited time\", discount codes)\n"
@@ -849,31 +911,16 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
"If it's a mass-mailed generic update with no personal CTA, mark spam=true even if from a legitimate service. "
"Reason should be 5-10 words."
)
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
payload = {
"model": model,
"messages": [
raw_out = await task_llm_call_async(
messages=[
{"role": "system", "content": class_sys},
{"role": "user", "content": f"From: {sender}\nSubject: {subject}\n\n{body[:4000]}"},
],
tok_key: 512,
"temperature": 0.1,
"stream": False,
}
# Reasoning models (o1/o3/o4/gpt-5) reject an explicit temperature.
if _restricts_temperature(model):
payload.pop("temperature", None)
# to_thread keeps the event loop responsive during the LLM call
resp = await asyncio.to_thread(
_req.post, url, json=payload, headers=req_headers, timeout=120
fallback_url=url, fallback_model=model, fallback_headers=headers,
owner=account_owner or None,
temperature=0.1, max_tokens=512, timeout=120,
)
if not resp.ok:
logger.warning(f"Auto-classify {uid.decode() if isinstance(uid, bytes) else str(uid)} HTTP {resp.status_code}: {resp.text[:200]}")
else:
rdata = resp.json()
m = (rdata.get("choices") or [{}])[0].get("message", {})
raw_out = (m.get("content") or "").strip()
raw_out = _strip_think(raw_out)
raw_out = _strip_think((raw_out or "").strip())
raw_out = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw_out, flags=re.MULTILINE).strip()
jm = re.search(r'\{.*\}', raw_out, re.DOTALL)
parsed = None
@@ -883,15 +930,15 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
except Exception:
parsed = None
if parsed is not None:
_ALLOWED_TAGS = {"work","personal","finance","bills","receipt","travel",
"newsletter","marketing","notification","security","social",
"shopping","calendar"}
_ALLOWED_TAGS = {"work","personal","urgent","action-needed","finance","bills",
"receipt","legal","travel","newsletter","marketing","notification",
"security","social","shopping","calendar","support"}
raw_tags = parsed.get("tags") or []
if isinstance(raw_tags, str):
raw_tags = [raw_tags]
tags = [t.strip().lower().replace("_", "-") for t in raw_tags if isinstance(t, str)]
tags = ["marketing" if t == "promo" else t for t in tags]
tags = [t for t in tags if t in _ALLOWED_TAGS][:2]
tags = [t for t in tags if t in _ALLOWED_TAGS][:3]
is_spam = bool(parsed.get("spam"))
spam_reason = str(parsed.get("reason") or "")[:200]
@@ -904,10 +951,10 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
_c = _sql3.connect(SCHEDULED_DB)
_c.execute("""
INSERT OR REPLACE INTO email_tags
(message_id, owner, uid, folder, subject, sender, tags, spam_verdict,
(message_id, owner, account_id, uid, folder, subject, sender, tags, spam_verdict,
spam_reason, moved_to, model_used, created_at)
VALUES (?, ?, ?, 'INBOX', ?, ?, ?, ?, ?, ?, ?, ?)
""", (message_id, account_owner or "", uid.decode() if isinstance(uid, bytes) else str(uid), subject, sender,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (message_id, account_owner or "", account_id or "", uid.decode() if isinstance(uid, bytes) else str(uid), _folder, subject, sender,
json.dumps(tags), 1 if is_spam else 0,
spam_reason, moved_to, model, datetime.utcnow().isoformat()))
_c.commit()
+1274 -163
View File
File diff suppressed because it is too large Load Diff
+123 -17
View File
@@ -3,7 +3,8 @@
import json
import uuid
import logging
from typing import Dict, Any
import re
from typing import Dict, Any, Optional
from fastapi import APIRouter, Request, HTTPException
@@ -19,6 +20,63 @@ from routes.session_routes import (
logger = logging.getLogger(__name__)
_HISTORY_INLINE_MEDIA_THRESHOLD = 200_000
_DATA_IMAGE_RE = re.compile(r"data:image/[^;,\"]+;base64,[A-Za-z0-9+/=\s]+")
def _history_display_content(content: Any) -> Any:
"""Return a lightweight browser-display copy of stored message content.
Older multimodal user messages may be persisted as a JSON *string*
containing image_url blocks with inline base64 image bytes. Those bytes are
needed for model calls when the turn is first sent, but they should not be
sent back through /api/history every time the user opens the chat. The
attachment metadata already carries file ids/names for the UI cards.
"""
if isinstance(content, list):
text_parts = []
omitted_media = 0
for block in content:
if not isinstance(block, dict):
continue
if block.get("type") == "text":
text = block.get("text")
if isinstance(text, str) and text:
text_parts.append(text)
elif block.get("type") in {"image_url", "input_image", "audio", "input_audio"}:
omitted_media += 1
text = "\n".join(text_parts).strip()
if omitted_media and not text:
return f"[{omitted_media} media attachment{'s' if omitted_media != 1 else ''} omitted from history view]"
return text
if not isinstance(content, str):
return content
if len(content) < _HISTORY_INLINE_MEDIA_THRESHOLD and "data:image/" not in content:
return content
stripped = content.lstrip()
if stripped.startswith("["):
try:
blocks = json.loads(content)
except (json.JSONDecodeError, TypeError, ValueError):
blocks = None
if isinstance(blocks, list):
text_parts = []
for block in blocks:
if not isinstance(block, dict):
continue
if block.get("type") == "text":
text = block.get("text")
if isinstance(text, str) and text:
text_parts.append(text)
if text_parts:
return "\n".join(text_parts).strip()
if "data:image/" in content:
return _DATA_IMAGE_RE.sub("[inline image omitted from history view]", content)
return content
def _merge_continue_rows_to_delete(db_messages, db1, db2):
"""DB rows to delete when merging the last two assistant messages.
@@ -43,9 +101,69 @@ def _merge_continue_rows_to_delete(db_messages, db1, db2):
def setup_history_routes(session_manager) -> APIRouter:
router = APIRouter(tags=["history"])
def _db_history_entry(m: DbChatMessage) -> Dict[str, Any]:
entry = {"role": m.role, "content": _history_display_content(m.content)}
meta = {}
if m.meta_data:
try:
meta = json.loads(m.meta_data) or {}
except (json.JSONDecodeError, ValueError):
meta = {}
if m.timestamp and "timestamp" not in meta:
meta["timestamp"] = m.timestamp.isoformat() + "Z"
if meta:
entry["metadata"] = meta
return entry
@router.get("/api/history/{session_id}")
async def get_session_history(request: Request, session_id: str) -> Dict[str, Any]:
async def get_session_history(
request: Request,
session_id: str,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> Dict[str, Any]:
_verify_session_owner(request, session_id)
if limit is not None:
page_limit = max(1, min(int(limit), 100))
db = SessionLocal()
try:
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
if db_session is None:
raise HTTPException(404, f"Session '{session_id}' not found")
total = (
db.query(DbChatMessage)
.filter(DbChatMessage.session_id == session_id)
.count()
)
page_offset = int(offset) if offset is not None else max(total - page_limit, 0)
page_offset = max(0, min(page_offset, total))
rows = (
db.query(DbChatMessage)
.filter(DbChatMessage.session_id == session_id)
.order_by(DbChatMessage.timestamp)
.offset(page_offset)
.limit(page_limit)
.all()
)
history_dict = [
entry for entry in (_db_history_entry(m) for m in rows)
if not (entry.get("metadata") or {}).get("hidden")
]
return {
"history": history_dict,
"model": db_session.model,
"endpoint_url": db_session.endpoint_url,
"name": db_session.name,
"offset": page_offset,
"limit": page_limit,
"total": total,
"has_more_before": page_offset > 0,
"has_more_after": page_offset + len(rows) < total,
}
finally:
db.close()
try:
session = session_manager.get_session(session_id)
except KeyError:
@@ -57,7 +175,7 @@ def setup_history_routes(session_manager) -> APIRouter:
# Skip hidden messages (e.g. compaction summaries for AI context)
if msg.metadata and msg.metadata.get("hidden"):
continue
entry = {"role": msg.role, "content": msg.content}
entry = {"role": msg.role, "content": _history_display_content(msg.content)}
if msg.metadata:
entry["metadata"] = msg.metadata
history_dict.append(entry)
@@ -66,7 +184,7 @@ def setup_history_routes(session_manager) -> APIRouter:
continue
entry = {
"role": msg.get("role", ""),
"content": msg.get("content", ""),
"content": _history_display_content(msg.get("content", "")),
}
if msg.get("metadata"):
entry["metadata"] = msg["metadata"]
@@ -82,21 +200,9 @@ def setup_history_routes(session_manager) -> APIRouter:
.order_by(DbChatMessage.timestamp)
.all()
)
import json as _json
db_history = []
for m in db_messages:
entry = {"role": m.role, "content": m.content}
meta = {}
if m.meta_data:
try:
meta = _json.loads(m.meta_data) or {}
except (json.JSONDecodeError, ValueError):
meta = {}
if m.timestamp and "timestamp" not in meta:
meta["timestamp"] = m.timestamp.isoformat() + "Z"
if meta:
entry["metadata"] = meta
db_history.append(entry)
db_history.append(_db_history_entry(m))
if db_history:
# Rebuild in-memory history from the full set so hidden
# messages (e.g. compaction summaries) are kept for AI context.
+106 -3
View File
@@ -18,6 +18,7 @@ from pydantic import BaseModel
from fastapi.responses import StreamingResponse
from core.database import SessionLocal, ModelEndpoint, Session as DbSession
from core.middleware import require_admin
from src.constants import COOKBOOK_STATE_FILE
from src.llm_core import _detect_provider, _host_match, ANTHROPIC_MODELS
from src.tls_overrides import llm_verify
from src.settings import load_settings as _load_settings, save_settings as _save_settings
@@ -111,6 +112,67 @@ def _clear_endpoint_settings_for_endpoint(settings: dict, ep_id: str, *, include
return cleared
_COOKBOOK_ACTIVE_SERVE_STATUSES = {
"starting", "loading", "ready", "running", "restarting",
}
def _active_cookbook_endpoint_ids() -> set[str]:
"""Endpoint IDs owned by active Cookbook serve tasks.
Cookbook auto-registers endpoints with ids like ``local-*``. Those rows are
managed lifecycle state, not durable user configuration. If a tmux stream is
stopped or an old task lingers, the row must stop participating in model
selection and defaults.
"""
try:
if not os.path.exists(COOKBOOK_STATE_FILE):
return set()
with open(COOKBOOK_STATE_FILE, "r", encoding="utf-8") as fh:
raw = fh.read()
state = json.loads(raw)
except Exception:
return set()
out: set[str] = set()
for task in state.get("tasks") or []:
if not isinstance(task, dict) or task.get("type") != "serve":
continue
if str(task.get("status") or "").lower() not in _COOKBOOK_ACTIVE_SERVE_STATUSES:
continue
ep_id = task.get("_endpointId") or task.get("endpointId") or task.get("endpoint_id")
if ep_id:
out.add(str(ep_id))
return out
def _disable_stale_cookbook_local_endpoints(db) -> int:
"""Disable enabled cookbook endpoints whose serve task is no longer active."""
active_ids = _active_cookbook_endpoint_ids()
if not active_ids:
return 0
stale = (
db.query(ModelEndpoint)
.filter(ModelEndpoint.is_enabled == True) # noqa: E712
.filter(ModelEndpoint.id.like("local-%"))
.filter(~ModelEndpoint.id.in_(active_ids))
.all()
)
if not stale:
return 0
settings = _load_settings()
touched_settings = False
for ep in stale:
ep.is_enabled = False
ep.model_refresh_mode = "disabled"
if _clear_endpoint_settings_for_endpoint(settings, ep.id):
touched_settings = True
logger.info("Disabled stale Cookbook endpoint %s (%s @ %s)", ep.id, ep.name, ep.base_url)
if touched_settings:
_save_settings(settings)
db.commit()
return len(stale)
def _clear_user_pref_endpoint_refs(all_prefs: dict, ep_id: str) -> int:
"""Remove endpoint references from scoped or legacy-flat user preferences."""
if not isinstance(all_prefs, dict):
@@ -124,7 +186,24 @@ def _clear_user_pref_endpoint_refs(all_prefs: dict, ep_id: str) -> int:
return cleared_users
def _default_endpoint_needs_assignment(current_default_id: str, enabled_endpoint_ids) -> bool:
def _endpoint_visible_model_ids(ep: Any) -> List[str]:
"""Known visible model ids for an endpoint, including pinned/manual ids."""
if ep is None:
return []
return _visible_models(
getattr(ep, "cached_models", None),
getattr(ep, "hidden_models", None),
getattr(ep, "pinned_models", None),
)
def _default_endpoint_needs_assignment(
current_default_id: str,
enabled_endpoint_ids,
*,
current_default_endpoint: Any = None,
current_default_model: str = "",
) -> bool:
"""Whether the global default chat endpoint should be (re)assigned.
True when nothing is configured yet, or the configured default no longer
@@ -136,7 +215,12 @@ def _default_endpoint_needs_assignment(current_default_id: str, enabled_endpoint
"""
if not current_default_id:
return True
return current_default_id not in enabled_endpoint_ids
if current_default_id not in enabled_endpoint_ids:
return True
if not (current_default_model or "").strip():
return True
visible = _endpoint_visible_model_ids(current_default_endpoint)
return bool(visible and current_default_model not in visible)
# Loopback hosts a user might type for a local model server (LM Studio,
@@ -1166,6 +1250,8 @@ def setup_model_routes(model_discovery):
db = SessionLocal()
changed = False
try:
if _disable_stale_cookbook_local_endpoints(db):
changed = True
endpoints = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
now = _time.time()
groups: Dict[str, Dict[str, Any]] = {}
@@ -1239,6 +1325,8 @@ def setup_model_routes(model_discovery):
db = SessionLocal()
try:
if _disable_stale_cookbook_local_endpoints(db):
_invalidate_models_cache()
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
if owner and not is_admin:
# Regular users see: their own endpoints + null-owner
@@ -1376,6 +1464,8 @@ def setup_model_routes(model_discovery):
db = SessionLocal()
try:
if _disable_stale_cookbook_local_endpoints(db):
_invalidate_models_cache()
endpoints = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
local_eps = []
for ep in endpoints:
@@ -1608,6 +1698,8 @@ def setup_model_routes(model_discovery):
require_admin(request)
db = SessionLocal()
try:
if _disable_stale_cookbook_local_endpoints(db):
_invalidate_models_cache()
rows = db.query(ModelEndpoint).order_by(ModelEndpoint.created_at).all()
results = []
for r in rows:
@@ -1892,7 +1984,18 @@ def setup_model_routes(model_discovery):
ModelEndpoint.is_enabled == True # noqa: E712
).all()
}
if _default_endpoint_needs_assignment(settings.get("default_endpoint_id") or "", enabled_ids):
current_default_id = settings.get("default_endpoint_id") or ""
current_default_ep = None
if current_default_id:
current_default_ep = db.query(ModelEndpoint).filter(
ModelEndpoint.id == current_default_id
).first()
if _default_endpoint_needs_assignment(
current_default_id,
enabled_ids,
current_default_endpoint=current_default_ep,
current_default_model=settings.get("default_model") or "",
):
from src.endpoint_resolver import _first_chat_model
settings["default_endpoint_id"] = ep.id
settings["default_model"] = _first_chat_model(model_ids) or ""
+21 -9
View File
@@ -130,7 +130,8 @@ _API_AGENT_RULES = """\
- "Disable/turn off/enable/turn on <tool>" (shell, search, research, browser, documents, incognito, etc.) call `ui_control` with `toggle <name> <on|off>`. Aliases accepted: shellbash, searchweb, deepresearchresearch, documentsdocument_editor. NEVER record this as a memory the user wants the toggle flipped, not a note about preferring it.
- "Research X" / "do research on X" / "look into Y" / "deep dive on Z" call `trigger_research` with `topic`. This starts a live job that appears in the Deep Research sidebar (streams progress + final report). **Do NOT use `web_search` for these** saw the agent do a plain web_search for "do research on X" when the user wanted the deep-research job. "research X" is a deep-research request, not a quick lookup. (web_search is only for a single quick fact mid-task.) Do NOT POST /api/research/start via app_api either blocked. After starting, tell the user it's running in the Deep Research sidebar. Only if the user explicitly wants it inline/quick should you fall back to web_search.
- "Open/show <panel>" (documents, library, gallery, email, inbox, sessions, brain/memories, skills, settings, notes, cookbook) call `ui_control` with `open_panel <name>`. Panel aliases: library/doc/docs/documentdocuments, imagesgallery, mail/inbox/emailsemail, chats/historysessions, memory/memoriesbrain, preferencessettings, models/serve/servingcookbook. CRITICAL: "open memory/memories/brain" / "open skills" / "open notes" / "open documents" / "open cookbook" means OPEN THE PANEL call `ui_control`, NOT a manage/list tool. The "manage_*" tools list contents in chat; `ui_control open_panel` opens the visual modal the user is asking for.
- "Open/start a reply", "open a reply to <sender>", "draft a reply window" for email find/read the email if needed, then call `ui_control` with `open_email_reply <uid> <folder> reply`. This opens the same email document compose window as clicking Reply in the Email UI. Do NOT call `reply_to_email` unless the user explicitly gave body text and wants to SEND immediately.
- "Write/draft a reply saying X" for an open/read email call `ui_control` with `action="open_email_reply"`, the email `uid`/`folder`, `mode="reply"`, and `body` containing the drafted reply. This opens the same email compose document as clicking Reply and DOES NOT send. Do NOT call `reply_to_email` unless the user explicitly says to send immediately.
- "Open/start a reply", "open a reply to <sender>", "draft a reply window" with no requested body find/read the email if needed, then call `ui_control` with `open_email_reply <uid> <folder> reply`.
- Bulk email actions ("delete all those", "archive these", "mark all read") require a real email tool call. Use `bulk_email` once with UIDs from the latest `list_emails` result and the same `account`; never claim success without the tool result.
- Email UIDs are the values after `UID:` in tool output, not list row numbers. For example, row `1.` with `UID: 90186` must use `"90186"`, never `"1"`.
- "Last/latest/newest email" means call `list_emails` with `max_results: 1`, `unread_only: false`, and the right `account`, then read the UID returned by that tool if full content is needed. NEVER use a table row number like "#18" as an email UID.
@@ -230,7 +231,7 @@ _DOMAIN_RULES = {
- For latest/newest email, list with `max_results: 1`, `unread_only: false`, then read the returned UID if needed.
- For named mailboxes/accounts, call `list_email_accounts` if needed and pass the exact `account` value.
- Bulk email actions use `bulk_email` once with explicit UIDs; do not loop one message at a time.
- "Open/start a reply" means open a draft via `ui_control open_email_reply`; only `reply_to_email` when the user clearly wants to send now.""",
- "Write/draft a reply saying X" means open a pre-filled draft via `ui_control open_email_reply ... <body>` / structured `body`; only `reply_to_email` when the user clearly wants to send now.""",
"cookbook": """\
## Cookbook/model-serving rules
- Cookbook is the LLM-serving subsystem.
@@ -446,7 +447,7 @@ List recent emails from a folder, newest first, including read messages by defau
```reply_to_email
{"uid": "1234", "body": "Sounds good — talk Friday.", "account": "gmail"}
```
SEND a reply email immediately by UID. Do not use this for "open a reply" or "start a reply" those should use `ui_control` with `open_email_reply <uid> <folder> reply` to open the email draft document. For follow-up requests like "reply ..." after reading/listing email where the user clearly wants to send now, use the exact UID and account from the latest `read_email`/`list_emails` result. Never invent UID `1`. Threads automatically (In-Reply-To/References handled).
SEND a reply email immediately by UID. Do not use this for "write/draft a reply", "open a reply", or "start a reply" those should use `ui_control` with `open_email_reply <uid> <folder> reply <body>` (or structured `body`) to open the email draft document. Only use this when the user explicitly says to send now. Never invent UID `1`. Threads automatically (In-Reply-To/References handled).
CRITICAL signatures: DO NOT invent a sign-off name. End the body with just `Thanks,` or similar never type a person's name unless the user explicitly told you what to sign as. When `agent_email_confirm` is on (default), the tool returns `{pending: true, pending_id: ...}` and stages the email for the user to approve in the chat UI instead of SMTPing immediately.""",
"bulk_email": """\
@@ -477,7 +478,7 @@ If the user asks for a reminder/alarm before the event, pass `reminder_minutes`
"send_to_session": "- ```send_to_session``` — Send a message to another session. Line 1 = session_id, rest = message. Use for orchestrating work across sessions.",
"search_chats": "- ```search_chats``` — Search past session transcripts for direct conversation evidence. Use when user asks 'did we discuss X?', 'find the conversation about Y', or when prior chat context is more appropriate than persistent memory.",
"pipeline": "- ```pipeline``` — Run a multi-step AI pipeline. Args (JSON) with ordered steps, each specifying a model and prompt. Use for complex workflows.",
"ui_control": "- ```ui_control``` — Control the UI: toggle tools on/off, OPEN PANELS, open email reply drafts, switch models, change themes. Commands: `toggle <name> on/off` (names: bash/shell, web/search, research, incognito, document_editor/documents), `open_panel <name>` (panels: documents, gallery, email, sessions, notes, memories/brain, skills, settings, cookbook), `open_email_reply <uid> <folder> <reply|reply-all|ai-reply>` (opens an email compose document, does NOT send), `set_mode agent/chat`, `switch_model <name>`, `set_theme <preset>`, `create_theme <name> <bg> <fg> <panel> <border> <accent>` (optional key=val for advanced colors AND background effects: bgPattern=<none|dots|synapse|rain|constellations|perlin-flow|petals|sparkles|embers>, bgEffectColor=#RRGGBB, bgEffectIntensity=<num>, bgEffectSize=<num>, frosted=true|false). \"open documents\" / \"open library\" / \"show gallery\" / \"open inbox\" / \"open notes\" / \"open cookbook\" all map to `open_panel <name>`. Built-in theme presets: dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute. For any other vibe/name, use create_theme.",
"ui_control": "- ```ui_control``` — Control the UI: toggle tools on/off, OPEN PANELS, open email reply drafts, switch models, change themes. Commands: `toggle <name> on/off` (names: bash/shell, web/search, research, incognito, document_editor/documents), `open_panel <name>` (panels: documents, gallery, email, sessions, notes, memories/brain, skills, settings, cookbook), `open_email_reply <uid> <folder> <reply|reply-all|ai-reply> <body text>` (opens an email compose document pre-filled with body, DOES NOT send; use this for normal “write/draft a reply saying X” requests), `set_mode agent/chat`, `switch_model <name>`, `set_theme <preset>`, `create_theme <name> <bg> <fg> <panel> <border> <accent>` (optional key=val for advanced colors AND background effects: bgPattern=<none|dots|synapse|rain|constellations|perlin-flow|petals|sparkles|embers>, bgEffectColor=#RRGGBB, bgEffectIntensity=<num>, bgEffectSize=<num>, frosted=true|false). \"open documents\" / \"open library\" / \"show gallery\" / \"open inbox\" / \"open notes\" / \"open cookbook\" all map to `open_panel <name>`. Built-in theme presets: dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute. For any other vibe/name, use create_theme.",
"ask_user": "- ```ask_user``` — Ask the user a multiple-choice question when the task is genuinely ambiguous and the answer changes what you do next (pick an approach, confirm an assumption, choose a target). Args (JSON): {\"question\": \"...\", \"options\": [{\"label\": \"...\", \"description\": \"...\"?}, ...], \"multi\": false?}. 2-6 options. The user gets clickable buttons; calling this ENDS your turn and their choice comes back as your next message. Prefer sensible defaults — only ask when you truly can't proceed well without their input.",
"update_plan": "- ```update_plan``` — While executing an approved plan, write the plan back: tick steps done or revise them. Args (JSON): {\"plan\": \"- [x] done step\\n- [ ] next step\"}. Always pass the COMPLETE checklist, not a diff. Call it after finishing each step (mark it `- [x]`) and whenever the user asks to change the plan. The user's docked plan window updates live. Does nothing if there's no active plan.",
"list_served_models": "- ```list_served_models``` — Show what the Cookbook (LLM-serving subsystem) is currently running. NO args. Use this for ANY 'what's running' / 'what's serving' / 'show my cookbook' / 'is anything up' query. DO NOT shell out (`ps aux`, `docker ps`, etc.) — this tool is the source of truth. Failed serve tasks include recent logs plus diagnosis/retry suggestions; use those suggestions to call `serve_model` again with an adjusted command when appropriate.",
@@ -1215,10 +1216,11 @@ def _build_system_prompt(
f"answer is ALWAYS the sender of the open email (above) unless they "
f"named someone else. Asking that is the wrong move every time.\n\n"
f"RULES for the open email:\n"
f"1. DRAFT a reply (default for any 'write/send/reply/tell them' "
f"1. DRAFT a reply (default for any 'write/reply/tell them' "
f"request without a different recipient): call `ui_control` with "
f"`action=\"open_email_reply\"` and `extra=\"{_em_uid} {_em_folder} "
f"reply\"`. This opens the proper reply doc with To/Subject/"
f"`action=\"open_email_reply\"`, `uid=\"{_em_uid}\"`, "
f"`folder=\"{_em_folder}\"`, `mode=\"reply\"`, and `body` set to "
f"the reply text you wrote. This opens the proper reply doc with To/Subject/"
f"In-Reply-To pre-filled by the backend. The user will see and edit "
f"it before sending. DO NOT `create_document` a markdown file with "
f"hand-written `To:` / `Subject:` / `In-Reply-To:` headers — that "
@@ -3079,10 +3081,11 @@ async def stream_agent_loop(
# Build a short display string for the frontend tool bubble.
# Document tools show a brief summary instead of dumping full content.
is_doc_tool = block.tool_type in ("create_document", "update_document", "edit_document", "suggest_document")
full_command = block.content.strip()
if is_doc_tool:
cmd_display = block.content.split("\n")[0].strip()[:80]
else:
cmd_display = block.content.strip()
cmd_display = full_command
if tool_policy and tool_policy.blocks(block.tool_type):
desc = f"{block.tool_type}: BLOCKED"
@@ -3094,7 +3097,7 @@ async def stream_agent_loop(
logger.info("Tool blocked before start by policy: %s", block.tool_type)
else:
yield (
f'data: {json.dumps({"type": "tool_start", "tool": block.tool_type, "command": cmd_display, "round": round_num})}\n\n'
f'data: {json.dumps({"type": "tool_start", "tool": block.tool_type, "command": cmd_display, "full_command": full_command, "round": round_num})}\n\n'
)
# Streaming progress for long-running tools (bash, python).
@@ -3290,6 +3293,15 @@ async def stream_agent_loop(
# Emit tool_output (include ui_event data if present)
tool_output_data = {"type": "tool_output", "tool": block.tool_type, "command": cmd_display, "output": output_text, "exit_code": result.get("exit_code")}
if is_doc_tool and "action" in result:
tool_output_data.update({
"doc_id": result.get("doc_id"),
"document_action": result.get("action"),
"document_title": result.get("title", ""),
"document_language": result.get("language", ""),
"document_version": result.get("version"),
"document_content": result.get("content", ""),
})
if "ui_event" in result:
tool_output_data["ui_event"] = result["ui_event"]
for k in (
+83
View File
@@ -217,6 +217,71 @@ def parse_suggest_blocks(content: str) -> list:
return suggestions
def _pdf_source_upload_id(content: str) -> Optional[str]:
try:
from src.pdf_form_doc import find_source_upload_id
return find_source_upload_id(content or "")
except Exception:
return None
def _strip_pdf_editor_markers(content: str) -> str:
"""Turn a PDF-wrapper markdown doc into ordinary editable markdown.
PDF docs use hidden HTML comments for source-upload links, form fields, and
page annotations. Those comments are necessary for rendering/exporting the
original PDF, but they make a derived AI text edit keep showing the original
PDF preview. Remove only the editor plumbing and keep the readable text.
"""
text = content or ""
text = re.sub(r'(?im)^\s*<!--\s*pdf(?:_form)?_source\s+[^>]*-->\s*\n*', '', text)
text = re.sub(r'\s*<!--\s*field=[^>]*-->', '', text)
text = re.sub(r'\s*<!--\s*annotation\s+[^>]*-->', '', text)
return text.strip()
def _create_pdf_text_derivative(db, *, source_doc, content: str, owner: Optional[str], summary: str) -> dict:
import uuid
from src.database import Document, DocumentVersion
clean = _strip_pdf_editor_markers(content)
title_base = (getattr(source_doc, "title", None) or "PDF").strip()
title = title_base if title_base.lower().endswith("edited") else f"{title_base} edited"
doc_id = str(uuid.uuid4())
ver_id = str(uuid.uuid4())
new_doc = Document(
id=doc_id,
session_id=getattr(source_doc, "session_id", None),
title=title,
language="markdown",
current_content=clean,
version_count=1,
is_active=True,
owner=owner if owner is not None else getattr(source_doc, "owner", None),
)
ver = DocumentVersion(
id=ver_id,
document_id=doc_id,
version_number=1,
content=clean,
summary=summary,
source="ai",
)
db.add(new_doc)
db.add(ver)
db.commit()
set_active_document(doc_id)
return {
"action": "create",
"doc_id": doc_id,
"title": title,
"language": "markdown",
"content": clean,
"version": 1,
"source_doc_id": getattr(source_doc, "id", None),
}
class CreateDocumentTool:
async def execute(self, content: str, ctx: dict) -> dict:
"""Create a new document. Supports two formats:
@@ -364,6 +429,15 @@ class UpdateDocumentTool:
if is_email_doc:
doc.language = "email"
if not is_email_doc and _pdf_source_upload_id(doc.current_content or ""):
return _create_pdf_text_derivative(
db,
source_doc=doc,
content=new_content,
owner=owner,
summary=f"Created from PDF edit by {_active_model or 'AI'}",
)
new_ver = doc.version_count + 1
ver = DocumentVersion(
id=str(uuid.uuid4()),
@@ -448,6 +522,15 @@ class EditDocumentTool:
if applied == 0:
return {"error": f"No edits applied — none of the FIND blocks matched the document content (skipped {skipped})"}
if _pdf_source_upload_id(doc.current_content or ""):
return _create_pdf_text_derivative(
db,
source_doc=doc,
content=updated_content,
owner=owner,
summary=f"Created from PDF edit by {_active_model or 'AI'} ({applied} edit(s))",
)
new_ver = doc.version_count + 1
ver = DocumentVersion(
id=str(uuid.uuid4()),
+21
View File
@@ -103,6 +103,8 @@ async def list_sessions(content: str, session_id: Optional[str] = None, owner: O
sessions = _session_manager.get_sessions_for_user(owner)
rows = []
for sid, sess in sessions.items():
if (sess.name or "").startswith("SFT trace batch"):
continue
if keyword and keyword not in (sess.name or "").lower():
continue
db_row = db_rows.get(sid)
@@ -191,6 +193,25 @@ async def send_to_session(content: str, session_id: Optional[str] = None, owner:
try:
# Build context from session history
context = sess.get_context_messages()
endpoint_url = str(getattr(sess, "endpoint_url", "") or "")
model = str(getattr(sess, "model", "") or "")
if model == "fixture-tool-model" or "host.docker.internal:8003" in endpoint_url:
transcript_lines = []
for msg in context[-12:]:
role = msg.get("role", "unknown")
text = (msg.get("content") or "").strip()
if text:
transcript_lines.append(f"{role}: {text}")
transcript = "\n".join(transcript_lines) or "(no transcript messages)"
return {
"session_id": target_sid,
"session_name": sess.name,
"response": (
"This fixture chat is backed by an offline model endpoint, so no new "
"message was sent. Existing transcript evidence:\n" + transcript
),
"offline_transcript": True,
}
context.append({"role": "user", "content": message})
response = await llm_call_async(
+14 -4
View File
@@ -431,13 +431,23 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
return {"error": "Search needs line 2: query"}
query = lines[1].strip()
memories = _memory_manager.load(owner=owner)
query_lower = query.lower()
exact_results = [m for m in memories if query_lower in (m.get("text", "").lower())]
if hasattr(_memory_manager, 'get_relevant_memories'):
results = _memory_manager.get_relevant_memories(query, memories, threshold=0.05, max_items=20)
vector_results = _memory_manager.get_relevant_memories(query, memories, threshold=0.05, max_items=20)
else:
# Fallback: simple text search
query_lower = query.lower()
results = [m for m in memories if query_lower in m.get("text", "").lower()][:20]
vector_results = []
seen = set()
results = []
for m in [*exact_results, *vector_results]:
mid = m.get("id")
if mid in seen:
continue
seen.add(mid)
results.append(m)
if len(results) >= 20:
break
if not results:
return {"results": f"No memories found matching '{query}'."}
+305 -46
View File
@@ -7,6 +7,7 @@ scheduler without needing an LLM call.
import logging
import os
import json
from datetime import datetime
from typing import Tuple
@@ -497,11 +498,48 @@ def _result_has_work(result: str | None) -> bool:
return True
def _result_is_config_error(result: str | None) -> bool:
if not isinstance(result, str):
return False
low = result.lower()
return (
"no model configured" in low
or "no model endpoint configured" in low
or "no llm endpoint available" in low
)
def _email_task_account_id(kwargs) -> str | None:
prompt = (kwargs.get("prompt") or "").strip()
if not prompt:
return None
try:
data = json.loads(prompt)
if isinstance(data, dict):
val = data.get("account_id") or data.get("email_account_id")
return str(val).strip() or None
except Exception:
pass
for line in prompt.splitlines():
if "=" not in line:
continue
key, val = line.split("=", 1)
if key.strip().lower() in {"account_id", "email_account_id"}:
return val.strip() or None
return None
async def action_summarize_emails(owner: str, **kwargs) -> Tuple[str, bool]:
"""Run one pass of email summary background processing."""
try:
from routes.email_pollers import _run_auto_summarize_once
result = await _run_auto_summarize_once(do_summary=True, do_reply=False)
result = await _run_auto_summarize_once(
do_summary=True,
do_reply=False,
account_id=_email_task_account_id(kwargs),
)
if _result_is_config_error(result):
return result, False
if not _result_has_work(result):
raise TaskNoop(f"summarize: {result or 'no new emails'}")
return result, True
@@ -517,9 +555,12 @@ async def action_draft_email_replies(owner: str, **kwargs) -> Tuple[str, bool]:
result = await _run_auto_summarize_once(
do_summary=False,
do_reply=True,
account_id=_email_task_account_id(kwargs),
days_back=7,
progress_cb=kwargs.get("progress_cb"),
)
if _result_is_config_error(result):
return result, False
if not _result_has_work(result):
raise TaskNoop(f"draft replies: {result or 'no new emails'}")
return result, True
@@ -761,19 +802,44 @@ async def action_extract_email_events(owner: str, **kwargs) -> Tuple[str, bool]:
import asyncio as _aio
try:
from routes.email_pollers import _run_auto_summarize_once
account_id = _email_task_account_id(kwargs)
attempts = [
("3d window, 3 emails", 3, 3, 240),
("3d window, 2 emails", 3, 2, 150),
("1d window, 1 email", 1, 1, 90),
]
timed_out = []
last_result = ""
for label, days_back, max_process, timeout in attempts:
try:
# Hard wall-clock budget: 5 min total. Per-LLM call already has its own timeout.
result = await _aio.wait_for(
_run_auto_summarize_once(
do_summary=False, do_reply=False, do_calendar=True, days_back=3,
do_summary=False,
do_reply=False,
do_calendar=True,
days_back=days_back,
account_id=account_id,
max_process=max_process,
),
timeout=300,
timeout=timeout,
)
if not _result_has_work(result):
raise TaskNoop(f"email→calendar: {result or 'no new emails'}")
return f"{result} (3d window)", True
last_result = result or ""
if _result_is_config_error(result):
return f"{result} ({label})", False
if _result_has_work(result):
suffix = f"{label}" if not timed_out else f"{label}; retried after timeout"
return f"{result} ({suffix})", True
raise TaskNoop(f"email→calendar: {result or 'no new emails'} ({label})")
except _aio.TimeoutError:
return "Email→calendar pass exceeded 5 min budget — try fewer emails or a faster model", False
timed_out.append(label)
logger.warning(f"email calendar extraction timed out for {label}; retrying smaller batch")
continue
if timed_out:
raise TaskNoop(
"email→calendar: calendar extraction timed out on smaller batches; "
"will retry on the next scheduled run"
)
raise TaskNoop(f"email→calendar: {last_result or 'no new emails'}")
except Exception as e:
logger.error(f"extract_email_events action failed: {e}")
return str(e), False
@@ -1499,13 +1565,15 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
AGE_CUTOFF = _dt.utcnow() - _td(days=7)
TRIAGE_VERSION = 3
TRIAGE_VERSION = 10
CATEGORY_TAGS = {
"newsletter", "marketing", "notification", "finance", "bills",
"receipt", "travel", "security", "shopping", "social", "work",
"personal", "calendar",
"bills", "receipt", "travel", "calendar", "action-needed",
}
VISIBLE_EMAIL_TAGS = CATEGORY_TAGS | {"urgent", "reply-soon"}
MANAGED_TAGS = VISIBLE_EMAIL_TAGS | {
"newsletter", "marketing", "notification", "finance", "security",
"shopping", "social", "work", "personal", "legal", "support", "promo",
}
MANAGED_TAGS = CATEGORY_TAGS | {"urgent", "reply-soon", "promo"}
# ── 1. Resolve LLM candidates (utility primary + utility fallbacks; fall
# through to default chat as a last resort).
@@ -1514,6 +1582,8 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
if not candidates:
return "No LLM endpoint available", False
target_account_id = _email_task_account_id(kwargs)
# ── 2. Enumerate enabled accounts. Match this task's owner AND fall
# back to the legacy "unowned account whose imap_user / from_address
# == this owner" pattern — same rule `_get_email_config` uses, so a
@@ -1526,6 +1596,8 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
unowned = _or(_EA.owner == None, _EA.owner == "") # noqa: E711
same_mailbox = _or(_EA.imap_user == owner, _EA.from_address == owner)
q = q.filter(_or(_EA.owner == owner, _and(unowned, same_mailbox)))
if target_account_id:
q = q.filter(_EA.id == target_account_id)
accounts = q.all()
finally:
db.close()
@@ -1534,12 +1606,95 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
urgency_prompt = settings.get("urgent_email_prompt", "")
per_uid_scores = {} # key = "<acc_id>:<uid>" → {"score": 0-3, "reason": "..."}
all_unread_keys = set() # for cache pruning
all_unread_keys = set()
llm_attempts = 0
saved_classifications = 0
failed_classifications = []
tag_write_details = []
scanned = 0
def _heuristic_email_verdict(item: dict) -> dict:
blob = (
f"{item.get('headers','')}\n{item.get('from','')}\n"
f"{item.get('subject','')}\n{item.get('body','')}"
).lower()
response_tags = []
type_candidates = []
def add_response(tag: str):
if tag in CATEGORY_TAGS and tag not in response_tags:
response_tags.append(tag)
def add_type(tag: str):
if tag in CATEGORY_TAGS and tag not in type_candidates:
type_candidates.append(tag)
bulkish = bool(_re.search(
r"\b(list-unsubscribe|list-id|mailchimp|mailchimpapp|view this email in your browser|unsubscribe|newsletter|digest|precedence:\s*bulk)\b",
blob,
))
marketingish = bool(_re.search(
r"\b(advertisement|sponsored|promo|promotion|sale|discount|offer|limited time|deal|coupon|shop now|buy now|membership|rewards?)\b",
blob,
))
if bulkish or marketingish:
add_type("newsletter")
if _re.search(r"\b(receipt|order|注文|payment confirmation|delivery|shipment|tracking|お届け|購入)\b", blob):
add_type("receipt")
if _re.search(r"\b(bill|billing|amount due|overdue|pay by|payment due|subscription could not be renewed)\b", blob):
add_type("bills")
if _re.search(r"\b(court|charge|legal|lawyer|solicitor|claim|judgment|registration fee|debt)\b", blob):
add_type("legal")
if _re.search(r"\b(flight|hotel|booking|reservation|itinerary|train|ticket|trip|旅|予約)\b", blob):
add_type("travel")
if _re.search(r"\b(ticket|case|support|helpdesk|request)\b", blob):
add_type("support")
if _re.search(r"\b(meeting|appointment|calendar|invite|event|schedule|予定|保育園|連絡帳)\b", blob):
add_response("calendar")
if _re.search(
r"\b(action required|required action|please reply|please respond|deadline|by \d{1,2} |pay within|submit|sign|confirm|approval|waiting outside|locked out|can't get in|cannot get in|invoice|bill|billing|payment|balance|debt|subscription|renewal|overdue|amount due|court|charge|legal|lawyer|solicitor|claim|judgment)\b",
blob,
):
add_response("action-needed")
type_priority = ("bills", "receipt", "travel")
tags = [*response_tags]
for type_tag in type_priority:
if type_tag in type_candidates and type_tag not in tags:
tags.append(type_tag)
if len(tags) >= len(response_tags) + 2:
break
score = 0
reason = "categorized by email metadata"
if "action-needed" in response_tags:
score = 2
reason = "action likely needed"
if _re.search(r"\b(urgent|immediately|final notice|locked out|waiting outside|can't get in|cannot get in)\b", blob):
score = 3
reason = "urgent wording"
if (bulkish or marketingish) and score < 2:
score = 0
reason = "bulk marketing/newsletter"
_from_raw = item.get("from", "") or ""
if "<" in _from_raw:
_from_short = _from_raw.split("<", 1)[0].strip().strip('"') or _from_raw
else:
_from_short = _from_raw
return {
"score": max(0, min(3, score)),
"tags": tags[:4],
"spam": False,
"reason": reason,
"subject": (item.get("subject") or "")[:200],
"from": _from_short[:120],
"triage_version": TRIAGE_VERSION,
"message_id": (item.get("message_id") or "").strip(),
"unread": bool(item.get("unread")),
"ts": _time.time(),
}
# ── 3. Per-account scan: pull headers + lightweight body for new UIDs
# since 7 days ago, score via LLM, cache the verdict.
for acc in accounts:
@@ -1555,13 +1710,13 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
conn = _imap_connect(account.id)
try:
conn.select("INBOX", readonly=True)
# IMAP date is the only practical pre-filter — UNSEEN AND
# SINCE 7-days-ago. Date format is DD-Mon-YYYY.
# Tag recent inbox mail, not only unread mail. Urgency
# reminders below still only notify for unread messages.
since_str = AGE_CUTOFF.strftime("%d-%b-%Y")
status, data = conn.search(None, f'(UNSEEN SINCE {since_str})')
status, data = conn.uid("SEARCH", None, f'(SINCE {since_str})')
if status != "OK" or not data or not data[0]:
return results
uids = data[0].split()
uids = data[0].split()[-30:]
for uid_b in uids:
uid = uid_b.decode() if isinstance(uid_b, bytes) else str(uid_b)
key = f"{account.id}:{uid}"
@@ -1573,9 +1728,14 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
continue
# Pull headers + first ~800 chars of plaintext body.
try:
st, msg_data = conn.fetch(uid_b, "(RFC822.HEADER BODY.PEEK[TEXT]<0.800>)")
st, msg_data = conn.uid("FETCH", uid_b, "(UID FLAGS RFC822.HEADER BODY.PEEK[TEXT]<0.800>)")
if st != "OK" or not msg_data:
continue
flags_blob = b" ".join(
part[0] for part in msg_data
if isinstance(part, tuple) and part and isinstance(part[0], (bytes, bytearray))
)
is_unread = b"\\Seen" not in flags_blob
# Headers + body land in different tuples in the
# response — concatenate the bytes for parsing.
raw = b""
@@ -1635,6 +1795,7 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
"headers": header_blob,
"body": body_snippet.strip(),
"message_id": (msg.get("Message-ID") or "").strip(),
"unread": is_unread,
})
except Exception as _fe:
logger.debug(f"urgency: header fetch for uid {uid} failed: {_fe}")
@@ -1652,25 +1813,33 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
for item in items:
scanned += 1
key = item["key"]
if item.get("unread"):
all_unread_keys.add(key)
if item.get("cached"):
per_uid_scores[key] = item["cached"]
cached_v = dict(item["cached"])
cached_v["unread"] = bool(item.get("unread"))
per_uid_scores[key] = cached_v
continue
# Skip uids we couldn't fetch (no subject/from/body).
if not item.get("subject") and not item.get("from"):
continue
verdict = _heuristic_email_verdict(item)
cache.setdefault("uids", {})[item["uid"]] = verdict
per_uid_scores[key] = verdict
saved_classifications += 1
continue
# ── LLM-classify. JSON-only response; bullet-proof parse.
llm_attempts += 1
prompt = (
"You are triaging ONE unread email. Return ONLY JSON: "
"You are triaging ONE email. Return ONLY JSON: "
"{\"score\":0|1|2|3,\"tags\":[\"...\"],\"spam\":false,"
"\"reason\":\"one short phrase\"}.\n"
"0 = trivial / promotional · 1 = informational, no reply needed · "
"2 = should reply within a day · 3 = urgent, reply now (deadline, blocker).\n\n"
"Allowed tags: newsletter, marketing, notification, finance, bills, receipt, "
"travel, security, shopping, social, work, personal, calendar.\n"
"Use marketing for ads, promos, sales, offers, and cold sales. Use newsletter "
"for newsletters, digests, and recurring content. spam=true for scams, phishing, "
"Allowed visible tags: urgent, reply-soon, action-needed, calendar, bills, receipt, travel.\n"
"Use action-needed when the user likely needs to reply, pay, sign, book, or decide. "
"Use bills for bills or debts, receipt for purchases/deliveries, travel for reservations/trips, "
"and calendar only when a calendar event/reminder is involved. spam=true for scams, phishing, "
"junk, cold sales, generic ads, or no-personal-action bulk mail.\n"
"Important: 'I'm outside', 'I am outside', 'waiting outside', 'at the door', "
"'locked out', or 'can't get in' means score 3 unless clearly historical.\n\n"
@@ -1739,14 +1908,10 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
r"\b(advertisement|sponsored|promo|promotion|sale|discount|offer|limited time|deal|tickets?|tour|merch|stream|purchase|sold out|low tickets|coupon|shop now|buy now)\b",
_blob,
))
if "newsletter" not in tags and bulkish:
tags.append("newsletter")
if "marketing" not in tags and marketingish:
tags.append("marketing")
if (bulkish or marketingish) and score < 2:
score = 0
if not reason or "urgent" in reason.lower():
reason = "Bulk marketing/newsletter; no personal reply needed"
reason = "bulk mail; no personal reply needed"
# Strip "Name <addr>" to bare display name for compact summary.
_from_raw = item.get("from", "") or ""
if "<" in _from_raw:
@@ -1764,6 +1929,7 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
# Cache the message_id too so re-scans of already-cached
# UIDs can still write the inbox tag without re-LLM'ing.
"message_id": (item.get("message_id") or "").strip(),
"unread": bool(item.get("unread")),
"ts": _time.time(),
}
cache.setdefault("uids", {})[item["uid"]] = verdict
@@ -1778,9 +1944,9 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
logger.debug(f"urgency: LLM classify failed for {key}: {e}")
continue
# ── Prune cache entries for UIDs that are no longer unread (replied
# / archived / deleted). Compare against `items` (everything UNSEEN
# in this scan window).
# ── Prune cache entries for UIDs that are no longer in the recent
# scan window. Read messages remain cached because tags are useful
# on read mail too; unread state is refreshed per scan above.
seen_uids = {it["uid"] for it in items}
cache_uids = cache.get("uids", {})
for stale in [u for u in cache_uids if u not in seen_uids]:
@@ -1815,15 +1981,17 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
_tag = str(_tag).strip().lower().replace("_", "-")
if _tag == "promo":
_tag = "marketing"
if _tag in CATEGORY_TAGS and _tag not in _new_tags:
if _tag == "action-needed" and any(t in _new_tags for t in ("urgent", "reply-soon")):
continue
if _tag in VISIBLE_EMAIL_TAGS and _tag not in _new_tags:
_new_tags.append(_tag)
_spam = 1 if _v.get("spam") else 0
# _key is "<account_id>:<uid>" — extract uid for the row.
_uid_only = _key.split(":", 1)[-1]
_acc_id, _uid_only = (_key.split(":", 1) + [""])[:2]
_owner_key = owner or ""
_row = _conn.execute(
"SELECT tags FROM email_tags WHERE message_id=? AND owner=?",
(_msg_id, _owner_key),
"SELECT tags FROM email_tags WHERE message_id=? AND owner=? AND account_id=?",
(_msg_id, _owner_key, _acc_id),
).fetchone()
if _row:
try:
@@ -1842,23 +2010,42 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
for _tag in _new_tags:
if _tag not in _existing:
_existing.append(_tag)
if _new_tags or _spam:
tag_write_details.append({
"uid": _uid_only,
"subject": _v.get("subject", ""),
"from": _v.get("from", ""),
"tags": list(_new_tags),
"spam": _spam,
"reason": _v.get("reason", ""),
"updated": True,
})
_conn.execute(
"UPDATE email_tags SET tags=?, spam_verdict=?, spam_reason=?, uid=?, folder=?, subject=?, sender=? "
"WHERE message_id=? AND owner=?",
"WHERE message_id=? AND owner=? AND account_id=?",
(_json.dumps(_existing), _spam, _v.get("reason", ""), _uid_only, "INBOX",
_v.get("subject", ""), _v.get("from", ""), _msg_id, _owner_key),
_v.get("subject", ""), _v.get("from", ""), _msg_id, _owner_key, _acc_id),
)
else:
if not _new_tags and not _spam:
continue
_conn.execute(
"INSERT INTO email_tags "
"(message_id, owner, uid, folder, subject, sender, tags, spam_verdict, spam_reason, created_at) "
"VALUES (?, ?, ?, 'INBOX', ?, ?, ?, ?, ?, ?)",
(_msg_id, _owner_key, _uid_only, _v.get("subject", ""),
"(message_id, owner, account_id, uid, folder, subject, sender, tags, spam_verdict, spam_reason, created_at) "
"VALUES (?, ?, ?, ?, 'INBOX', ?, ?, ?, ?, ?, ?)",
(_msg_id, _owner_key, _acc_id, _uid_only, _v.get("subject", ""),
_v.get("from", ""), _json.dumps(_new_tags), _spam, _v.get("reason", ""),
_dt2.utcnow().isoformat()),
)
tag_write_details.append({
"uid": _uid_only,
"subject": _v.get("subject", ""),
"from": _v.get("from", ""),
"tags": list(_new_tags),
"spam": _spam,
"reason": _v.get("reason", ""),
"updated": False,
})
_conn.commit()
finally:
_conn.close()
@@ -1866,7 +2053,7 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
logger.warning(f"urgency: bulk tag write failed: {_te}")
# ── 4. Aggregate state. urgent = score ≥ 2.
urgent_keys = [k for k, v in per_uid_scores.items() if v.get("score", 0) >= 2]
urgent_keys = [k for k, v in per_uid_scores.items() if v.get("score", 0) >= 2 and v.get("unread")]
max_score = max((v.get("score", 0) for v in per_uid_scores.values()), default=0)
total_urgent = len(urgent_keys)
@@ -1975,13 +2162,28 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
f"reply-soon {tier_counts[2]} · info {tier_counts[1]} · trivial {tier_counts[0]} · "
f"{saved_classifications} saved classifications"
)
if llm_attempts != saved_classifications:
head += f" · {llm_attempts - saved_classifications} failed"
if failed_classifications:
head += f" · {len(failed_classifications)} failed"
if newly_notified:
head += f" · notified {len(newly_notified)}"
if notify_failed:
head += f" · notify failed {len(notify_failed)}"
def _fmt_tag_write(v):
subj = (v.get("subject") or "(no subject)")[:80]
frm = v.get("from") or ""
tags = list(v.get("tags") or [])
if v.get("spam"):
tags.append("spam")
tag_txt = ", ".join(tags) if tags else "cleared managed tags"
why = v.get("reason") or ""
op = "updated" if v.get("updated") else "created"
line = f"- **{subj}**" + (f" — _{frm}_" if frm else "")
line += f" — `{tag_txt}` ({op})"
if why:
line += f" · {why}"
return line
def _fmt_one(v, newly_notified_set, failed_set, key):
subj = (v.get("subject") or "(no subject)")[:80]
frm = v.get("from") or ""
@@ -1997,6 +2199,13 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
for k, v in per_uid_scores.items():
by_tier.setdefault(v.get("score", 0), []).append((k, v))
lines = [head]
if tag_write_details:
lines.append("")
lines.append(f"**Applied tags ({len(tag_write_details)}):**")
for v in tag_write_details[:16]:
lines.append(_fmt_tag_write(v))
if len(tag_write_details) > 16:
lines.append(f"…and {len(tag_write_details) - 16} more")
tier_labels = {3: "Urgent", 2: "Reply soon", 1: "Informational", 0: "Trivial"}
for tier in (3, 2, 1, 0):
items_t = by_tier.get(tier, [])
@@ -2068,6 +2277,7 @@ async def action_cookbook_serve(
end_after_min = int(cfg.get("end_after_min") or 0)
except Exception:
end_after_min = 0
set_default = bool(cfg.get("set_default", True))
state_path = Path(COOKBOOK_STATE_FILE)
try:
@@ -2154,6 +2364,51 @@ async def action_cookbook_serve(
return f"Launch rejected: {data.get('error') or data.get('detail') or 'unknown'}", False
sid = data.get("session_id") or ""
endpoint_id = data.get("endpoint_id") or ""
# Scheduled serves are usually meant to become the active local model for
# chat/tools while their time window is open. Persist both endpoint and
# model so task/utility/default resolution does not keep routing to a stale
# API fallback. Allow explicit opt-out with {"set_default": false}.
if endpoint_id and set_default:
try:
selected_model = repo_id
try:
from core.database import SessionLocal as _SL, ModelEndpoint as _ME
_db = _SL()
try:
_ep = _db.query(_ME).filter(_ME.id == endpoint_id).first()
if _ep and _ep.cached_models:
_models = json.loads(_ep.cached_models or "[]")
if isinstance(_models, list) and _models:
selected_model = str(_models[0])
finally:
_db.close()
except Exception:
pass
from src.settings import load_settings as _load_settings, save_settings as _save_settings
_settings = _load_settings()
_settings["default_endpoint_id"] = endpoint_id
_settings["default_model"] = selected_model
# Keep background tasks aligned unless the user explicitly chose a
# separate task model.
if not (_settings.get("task_endpoint_id") or "").strip():
_settings["task_endpoint_id"] = endpoint_id
_settings["task_model"] = selected_model
if not (_settings.get("utility_endpoint_id") or "").strip():
_settings["utility_endpoint_id"] = endpoint_id
_settings["utility_model"] = selected_model
_save_settings(_settings)
if owner:
from routes.prefs_routes import _load_for_user, _save_for_user
_prefs = _load_for_user(owner)
_prefs["default_endpoint_id"] = endpoint_id
_prefs["default_model"] = selected_model
if not (_prefs.get("utility_endpoint_id") or "").strip():
_prefs["utility_endpoint_id"] = endpoint_id
_prefs["utility_model"] = selected_model
_save_for_user(owner, _prefs)
except Exception as e:
logger.warning(f"cookbook_serve: default endpoint update failed: {e}")
# Register the new task in cookbook_state.json + stamp it with our
# scheduler-owner markers. /api/model/serve spawns the tmux session
# but leaves the state-write to the UI — when a scheduled action
@@ -2195,12 +2450,16 @@ async def action_cookbook_serve(
"sshPort": "",
"platform": "linux",
"_serveReady": False,
"_endpointAdded": False,
"_endpointAdded": bool(endpoint_id),
}
tasks.append(existing)
# Stamp ownership + end-at on the task entry.
existing["_scheduledByTask"] = task_name or ""
existing["_scheduledByOwner"] = owner or ""
if endpoint_id:
existing["_endpointId"] = endpoint_id
existing["endpointId"] = endpoint_id
existing["_endpointAdded"] = True
if end_after_min > 0:
existing["_scheduledStopAtMs"] = int(_time.time() * 1000) + end_after_min * 60 * 1000
fresh["tasks"] = tasks
+11 -7
View File
@@ -37,6 +37,13 @@ async def _delete_endpoint_for_task(task: dict) -> None:
the picker (probe goes offline; chats still try to route there) and
the user has to delete it by hand in Settings -> Endpoints.
"""
endpoint_id = (task.get("_endpointId") or task.get("endpointId") or "").strip()
if not endpoint_id:
logger.info(
"cookbook_serve_lifecycle: task %s has no endpoint id; skipping endpoint deletion",
task.get("sessionId") or task.get("id") or "",
)
return
import re as _re
payload = task.get("payload") or {}
cmd = str(payload.get("_cmd") or "")
@@ -66,13 +73,10 @@ async def _delete_endpoint_for_task(task: dict) -> None:
if r.status_code >= 400:
return
eps = r.json() if r.content else []
# Prefer exact URL match; fall back to host:port substring so we
# still catch the case where 0.0.0.0 vs the registered host
# representation diverged.
ep = next((e for e in eps if e.get("base_url") == base_url), None)
if not ep:
hostport = f"{host}:{port}"
ep = next((e for e in eps if hostport in (e.get("base_url") or "")), None)
# Delete only the endpoint created by this scheduled serve. URL
# matching is unsafe because a later scheduled serve can reuse the
# same host:port after an older task has gone stale.
ep = next((e for e in eps if e.get("id") == endpoint_id), None)
if ep:
await client.delete(
f"{internal_api_base()}/api/model-endpoints/{ep['id']}",
+10
View File
@@ -77,10 +77,20 @@ async def run_document_tidy(owner: str) -> str:
deleted = 0
kept = 0
survivors = [] # docs that pass the junk rules, considered for dedup
now = datetime.utcnow()
for doc in docs:
content = (doc.current_content or "").strip()
title = (doc.title or "").strip().lower()
created = doc.created_at
is_fresh_empty = (
not content
and created is not None
and (now - created).total_seconds() < 1800
)
if is_fresh_empty:
survivors.append(doc)
continue
# Strip markdown noise to get "real" character count
stripped = re.sub(r"^#{1,6}\s+", "", content, flags=re.MULTILINE) # headers
+2
View File
@@ -207,6 +207,7 @@ def _search_like(
)
if not include_archived:
q = q.filter(DBSession.archived == False)
q = q.filter(~DBSession.name.like("SFT trace batch%"))
if restrict_owner:
q = _owner_filter(q, owner, include_legacy_owner)
rows = q.order_by(DBChatMessage.timestamp.desc()).limit(limit).all()
@@ -270,6 +271,7 @@ def _search_fts(
WHERE chat_messages_fts MATCH :fts_query
{archived_clause}
{owner_clause}
AND s.name NOT LIKE 'SFT trace batch%'
AND m.role IN ('user', 'assistant')
ORDER BY bm25(chat_messages_fts), m.timestamp DESC
LIMIT :limit
+2
View File
@@ -1083,6 +1083,8 @@ class TaskScheduler:
self._set_run_progress(run_id, message)
kwargs = {"owner": task.owner, "task_name": task.name, "progress_cb": _progress}
if task.prompt:
kwargs["prompt"] = task.prompt
if task.action in ("run_script", "run_local", "ssh_command") and task.prompt:
kwargs["script" if task.action in ("run_script", "run_local") else "command"] = task.prompt
# cookbook_serve carries its JSON config in task.prompt — feed it
+106 -10
View File
@@ -116,7 +116,7 @@ async def do_search_chats(query: str, limit: int = 20, owner: str | None = None)
try:
from src.session_search import search_session_messages
results = search_session_messages(query, limit=limit, owner=owner)
results = search_session_messages(query, limit=limit, owner=owner, context_messages=3)
if not results:
return {"results": f"No chats found matching \"{query}\"."}
@@ -130,13 +130,14 @@ async def do_search_chats(query: str, limit: int = 20, owner: str | None = None)
for sid, result in seen_sessions.items():
lines.append(f"- **{result.session_name}** (#{sid})")
lines.append(f" Link: [Open chat](#{sid})")
lines.append(f" Match ({result.role}): {result.content_snippet}")
match_text = (result.content or result.content_snippet or "")[:600]
lines.append(f" Match ({result.role}): {match_text}")
if result.context_before:
before = result.context_before[-1]
lines.append(f" Before ({before['role']}): {before['content'][:180]}")
for before in result.context_before:
lines.append(f" Before ({before['role']}): {before['content'][:500]}")
if result.context_after:
after = result.context_after[0]
lines.append(f" After ({after['role']}): {after['content'][:180]}")
for after in result.context_after:
lines.append(f" After ({after['role']}): {after['content'][:500]}")
lines.append("")
return {"results": "\n".join(lines)}
@@ -1399,6 +1400,9 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
"new": "add",
"save": "add",
"remind": "add",
"get": "view",
"read": "view",
"open": "view",
"remove": "delete",
"remove_item": "toggle_item",
}
@@ -1458,6 +1462,32 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
lines.append(f" {snippet}")
return {"results": "\n".join(lines)}
elif action == "view":
note_id = args.get("id", "")
note = _note_by_prefix(note_id)
if not note:
return {"error": f"Note '{note_id}' not found", "exit_code": 1}
if not _note_visible_to_owner(note, owner):
return {"error": "Note not found", "exit_code": 1}
lines = [f"Note: {note.title or '(untitled)'}", f"id: {note.id}"]
if note.label:
lines.append(f"label: {note.label}")
if note.due_date:
lines.append(f"due_date: {note.due_date}")
if note.note_type == "checklist":
lines.append("items:")
try:
items = json.loads(note.items or "[]")
except (json.JSONDecodeError, TypeError):
items = []
for i, item in enumerate(items):
mark = "x" if item.get("done") else " "
lines.append(f"- [{mark}] {i}: {item.get('text', '')}")
elif note.content:
lines.append("content:")
lines.append(note.content)
return {"response": "\n".join(lines), "note_id": note.id, "exit_code": 0}
elif action == "add":
# Accept the various field names models emit: `text` is the most
# common stand-in for "title or body content" when the model
@@ -1612,7 +1642,7 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
return {"response": f"Item '{items[index].get('text', '')}' marked {mark}", "exit_code": 0}
else:
return {"error": f"Unknown action: {action}. Use list/add/update/delete/toggle_item", "exit_code": 1}
return {"error": f"Unknown action: {action}. Use list/view/add/update/delete/toggle_item", "exit_code": 1}
except Exception as e:
logger.error(f"manage_notes error: {e}")
return {"error": str(e), "exit_code": 1}
@@ -3772,6 +3802,63 @@ async def do_list_cached_models(content: str, owner: Optional[str] = None) -> Di
seen.add(key)
models.append(m)
if not models:
endpoint_models = []
try:
from core.database import SessionLocal, ModelEndpoint
db = SessionLocal()
try:
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
if owner:
query = query.filter((ModelEndpoint.owner == owner) | (ModelEndpoint.owner == None)) # noqa: E711
else:
query = query.filter(ModelEndpoint.owner == None) # noqa: E711
for ep in query.all():
raw_models = json.loads(ep.cached_models or "[]") if ep.cached_models else []
for model_id in raw_models or []:
if not model_id:
continue
model_name = str(model_id)
lower_name = model_name.lower()
quantization = "GGUF" if "gguf" in lower_name else "unknown"
endpoint_models.append(
{
"repo_id": model_name,
"host": ep.name or "endpoint",
"source": "model_endpoint_cache",
"file_size": "unknown",
"quantization": quantization,
}
)
finally:
db.close()
except Exception as e:
logger.debug(f"list_cached_models endpoint fallback failed: {e}")
endpoint_models = []
if endpoint_models:
seen_endpoint_models = set()
deduped_endpoint_models = []
for model in endpoint_models:
key = (model.get("host"), model.get("repo_id"))
if key in seen_endpoint_models:
continue
seen_endpoint_models.add(key)
deduped_endpoint_models.append(model)
lines = [
f"{len(deduped_endpoint_models)} cached model(s) from configured endpoint cache:"
]
for model in deduped_endpoint_models:
lines.append(
f"- {model['repo_id']}{model.get('host', 'endpoint')}; "
f"size: {model.get('file_size', 'unknown')}; "
f"quantization: {model.get('quantization', 'unknown')}"
)
lines.append(
"Endpoint-cache rows are authoritative for configured models; "
"file sizes may be unknown unless the model directory is scanned."
)
return {"output": "\n".join(lines), "models": deduped_endpoint_models, "exit_code": 0}
# Cache scans can miss models downloaded into the HF default cache
# when the server has no explicit model_dir configured. Surface
# completed Cookbook download tasks so the agent doesn't conclude
@@ -4020,7 +4107,11 @@ async def do_resolve_contact(content: str, owner: Optional[str] = None) -> Dict:
for email in (c.get("emails") or []):
email = (email or "").strip().lower()
if email and "@" in email:
contacts[email] = {"name": c.get("name") or email, "source": "contacts"}
contacts[email] = {
"name": c.get("name") or email,
"source": "contacts",
"phones": [p.strip() for p in (c.get("phones") or []) if p and p.strip()],
}
has_email = True
# Fall back to phone numbers when the contact has no email address
if not has_email:
@@ -4051,7 +4142,10 @@ async def do_resolve_contact(content: str, owner: Optional[str] = None) -> Dict:
if info.get("phone"):
lines.append(f"- {info['name']} — phone: {info['phone']} ({info['source']})")
else:
lines.append(f"- {info['name']} <{key}> ({info['source']})")
phone_text = ""
if info.get("phones"):
phone_text = f" — phone: {', '.join(info['phones'])}"
lines.append(f"- {info['name']} <{key}>{phone_text} ({info['source']})")
return {"output": "\n".join(lines), "exit_code": 0}
@@ -4081,7 +4175,9 @@ async def do_manage_contact(content: str, owner: Optional[str] = None) -> Dict:
lines = [f"{len(rows)} contacts:"]
for c in rows:
em = ", ".join(c.get("emails") or [])
lines.append(f"- {c.get('name') or '(no name)'} <{em}> [uid={c.get('uid','')}]")
phones = ", ".join(c.get("phones") or [])
phone_text = f" phones: {phones}" if phones else ""
lines.append(f"- {c.get('name') or '(no name)'} <{em}>{phone_text} [uid={c.get('uid','')}]")
return {"output": "\n".join(lines), "exit_code": 0}
if action == "add":
+2 -2
View File
@@ -105,12 +105,12 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
"search_chats": "Search past session transcripts across chats.",
"ask_user": "Ask the user a multiple-choice question to get a decision or clarification. Use this when the task is genuinely ambiguous and the answer changes what you do next — pick between approaches, confirm an assumption, choose among options — instead of guessing. Provide a clear `question` and 2-6 `options` (each with a short `label`, optional `description`). Calling this ENDS your turn: the user sees clickable buttons and their choice arrives as your next message. Don't use it for things you can decide from context or sensible defaults, or for irreversible-action confirmation if a dedicated flow exists.",
"update_plan": "Write back to the ACTIVE PLAN while executing an approved plan: mark steps done or revise them. After finishing a step call this with the full checklist and that step marked done; when the user asks to change the plan call it with the revised checklist. Always pass the COMPLETE markdown checklist (`- [ ]` / `- [x]`), not a diff. The user's docked plan window updates live. No effect when there is no active plan.",
"ui_control": "Control the UI and toggle tools on/off. Use this to turn off / turn on / disable / enable individual tools and features: shell (bash), search (web), research, browser, documents, incognito. Open panels (documents library, gallery, email inbox, sessions, notes, memories/brain, skills, settings, cookbook) via `open_panel <name>`. Use `open_email_reply <uid> <folder> reply` to open an email reply draft document without sending. To pre-fill the reply body in one shot (USE THIS whenever the user told you what to say — opening an empty draft when they asked you to write is wrong), append the body after the mode: `open_email_reply <uid> <folder> reply <body text>`. Body can continue on subsequent lines for multi-line replies. Also switches between chat/agent modes, changes the current model, and applies/creates themes.",
"ui_control": "Control the UI and toggle tools on/off. Use this to turn off / turn on / disable / enable individual tools and features: shell (bash), search (web), research, browser, documents, incognito. Open panels (documents library, gallery, email inbox, sessions, notes, memories/brain, skills, settings, cookbook) via `open_panel <name>`. Use `open_email_reply <uid> <folder> reply <body text>` (or structured body) to open an email reply draft document without sending. USE THIS whenever the user says to write/draft a reply or tells you what to say — opening an empty draft or sending immediately is wrong. Body can continue on subsequent lines for multi-line replies. Also switches between chat/agent modes, changes the current model, and applies/creates themes.",
"list_email_accounts": "List configured email accounts and default status. Use before reading or sending mail when the user mentions Gmail, work mail, custom domain mail, another mailbox, or asks to compare/check multiple inboxes.",
"list_emails": "List emails for a folder/account, newest first, including read messages by default. Shows subject, sender, date, UID, account, and AI summary. Check inbox, find emails needing replies. Supports account from list_email_accounts for Gmail/work/custom mailboxes. For last/latest/newest email, use max_results=1 and unread_only=false.",
"read_email": "Read the full content of a specific email by UID or Message-ID. View email body, check details. Supports account from list_email_accounts when the UID belongs to a non-default mailbox.",
"send_email": "Send a new email via SMTP. Provide recipient, subject, body, and optional account from list_email_accounts. For replying to a thread use reply_to_email instead.",
"reply_to_email": "SEND a reply email immediately by UID. Do not use for open/start reply draft requests; use ui_control open_email_reply for those. For follow-up 'reply ...' send requests, use the exact UID and account from latest read_email/list_emails output; never invent UID 1. Threads automatically with In-Reply-To/References, prefixes Re:, marks original as Answered.",
"reply_to_email": "SEND a reply email immediately by UID. Do not use for write/draft/open/start reply requests; use ui_control open_email_reply with body so the user can review. Only use when the user explicitly says to send now. For send requests, use the exact UID and account from latest read_email/list_emails output; never invent UID 1. Threads automatically with In-Reply-To/References, prefixes Re:, marks original as Answered.",
"archive_email": "Move an email out of the inbox into the Archive folder. Use after handling messages you want to keep but get out of the way.",
"delete_email": "Delete an email — moves to Trash by default, or expunges permanently with permanent=true.",
"mark_email_read": "Mark an email as read or unread by toggling the \\Seen flag.",
+9 -5
View File
@@ -325,7 +325,7 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "send_to_session",
"description": "Send a message to an existing chat and get the model's response. The chat keeps its conversation history.",
"description": "Send a new message to an existing live chat and get that chat model's response. Do not use this to retrieve, read, summarize, or inspect old chats; use search_chats or list_sessions for past chat evidence.",
"parameters": {
"type": "object",
"properties": {
@@ -415,7 +415,7 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "ui_control",
"description": "Control the user interface. Actions: toggle (turn tools on/off), open_panel (open a modal: documents/library, gallery, email, sessions, notes, memories/brain, skills, settings, cookbook), open_email_reply (open an email reply draft document; does NOT send), set_mode, switch_model, set_theme (built-in presets: dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute), create_theme (CREATE any custom theme with a name + colors object — pick distinctive, evocative hex colors that match the requested aesthetic, NOT generic defaults. The theme auto-applies after creation). When a user asks for ANY theme not in the built-in preset list, ALWAYS use create_theme.",
"description": "Control the user interface. Actions: toggle (turn tools on/off), open_panel (open a modal: documents/library, gallery, email, sessions, notes, memories/brain, skills, settings, cookbook), open_email_reply (open an email reply draft document; DOES NOT send. For 'write/draft a reply saying X', include body with the drafted reply), set_mode, switch_model, set_theme (built-in presets: dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute), create_theme (CREATE any custom theme with a name + colors object — pick distinctive, evocative hex colors that match the requested aesthetic, NOT generic defaults. The theme auto-applies after creation). When a user asks for ANY theme not in the built-in preset list, ALWAYS use create_theme.",
"parameters": {
"type": "object",
"properties": {
@@ -426,6 +426,7 @@ FUNCTION_TOOL_SCHEMAS = [
"uid": {"type": "string", "description": "Email UID for open_email_reply"},
"folder": {"type": "string", "description": "Email folder for open_email_reply (default INBOX)"},
"mode": {"type": "string", "description": "Reply draft mode for open_email_reply: reply, reply-all, or ai-reply"},
"body": {"type": "string", "description": "For open_email_reply: reply body to pre-fill. Required whenever the user told you what the reply should say. Opens a draft, does not send."},
"colors": {"type": "object", "description": "For create_theme: the theme colors",
"properties": {
"bg": {"type": "string", "description": "Background color (hex, e.g. #1a1a2e)"},
@@ -569,12 +570,12 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "manage_notes",
"description": "Manage notes and checklists (Google Keep-style): list, add, update, delete, toggle_item. IMPORTANT: For to-do lists / checklists, set note_type='checklist' and pass the items as the `checklist_items` array — do NOT serialize them into `content` as plain text. For freeform notes, use note_type='note' and put the body in `content`. `due_date` accepts natural language like 'tomorrow at 9am' (parsed in the user's timezone) and fires a notification — do not also create a calendar event for the same reminder.",
"description": "Manage notes and checklists (Google Keep-style): list, view, add, update, delete, toggle_item. Use list/search to find candidate notes, then view with the note id when you need the full body. IMPORTANT: For to-do lists / checklists, set note_type='checklist' and pass the items as the `checklist_items` array — do NOT serialize them into `content` as plain text. For freeform notes, use note_type='note' and put the body in `content`. `due_date` accepts natural language like 'tomorrow at 9am' (parsed in the user's timezone) and fires a notification — do not also create a calendar event for the same reminder.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string",
"enum": ["list", "add", "update", "delete", "toggle_item"],
"enum": ["list", "view", "add", "update", "delete", "toggle_item"],
"description": "The action to perform"},
"id": {"type": "string", "description": "Note id (for update/delete/toggle_item); 8-char prefix is fine"},
"title": {"type": "string", "description": "Note title (for add/update)"},
@@ -1106,7 +1107,7 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "reply_to_email",
"description": "SEND a reply email immediately by UID. Do not use this when the user asks to open/start a reply window or draft; use ui_control action=open_email_reply instead. For follow-up 'reply ...' requests where the user clearly wants to send now, use the exact UID from the latest read_email/list_emails result; never invent UID 1. Automatically threads with In-Reply-To/References headers.",
"description": "SEND a reply email immediately by UID. Do not use this when the user asks to write/draft/open/start a reply; use ui_control action=open_email_reply with body instead so the user can review. Only use when the user explicitly says to send now. Use the exact UID from the latest read_email/list_emails result; never invent UID 1. Automatically threads with In-Reply-To/References headers.",
"parameters": {
"type": "object",
"properties": {
@@ -1373,6 +1374,9 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
folder = args.get("folder") or value or "INBOX"
mode = args.get("mode") or "reply"
content = f"open_email_reply {uid} {folder} {mode}"
body = args.get("body") or args.get("extra") or args.get("content") or ""
if body:
content += f" {body}"
elif action == "set_mode":
content = f"set_mode {value or name}"
elif action == "switch_model":
+48 -6
View File
@@ -1617,6 +1617,7 @@ function initializeEventListeners() {
// Delay tool glow-up for a staggered effect
setTimeout(() => applyModeToToggles(mode), 500);
}
window.__odysseusSetChatMode = setMode;
agentBtn.addEventListener('click', () => {
// Agent mode turns off research if active
const resChk = el('research-toggle');
@@ -1692,10 +1693,29 @@ function initializeEventListeners() {
try { workspaceModule.initWorkspace(); } catch (_) {}
// Document editor toggle (special: uses module panel, not a checkbox)
function bringOpenDocumentToFrontOnMobile() {
if (window.innerWidth > 768) return false;
if (!documentModule || !documentModule.isPanelOpen || !documentModule.isPanelOpen()) return false;
if (!document.body.classList.contains('email-front')) return false;
document.body.classList.remove('email-front', 'email-doc-split-active');
document.documentElement.style.removeProperty('--email-doc-split-left-x');
document.documentElement.style.removeProperty('--email-doc-split-email-w');
document.documentElement.style.removeProperty('--email-doc-split-right-x');
const docPane = document.getElementById('doc-editor-pane');
if (docPane) docPane.style.setProperty('z-index', '10010', 'important');
const overflow = el('overflow-doc-btn');
if (overflow) overflow.classList.add('active');
const indicator = el('doc-indicator-btn');
if (indicator) indicator.classList.add('active');
const st = loadToggleState(); st.doc = true; saveToggleState(st);
return true;
}
const overflowDocBtn = el('overflow-doc-btn');
if (overflowDocBtn) {
overflowDocBtn.addEventListener('click', async () => {
if (!documentModule) return;
if (bringOpenDocumentToFrontOnMobile()) return;
if (documentModule.isPanelOpen()) {
documentModule.closePanel();
overflowDocBtn.classList.remove('active');
@@ -2304,14 +2324,32 @@ function initializeEventListeners() {
// IMPORTANT: don't overwrite the user's persisted per-mode tool prefs
// (`web_agent`, `bash_agent`, `web_chat`, `bash_chat`). Nobody mode is
// ephemeral — their agent-mode defaults must come back on toggle-off.
const beforeNobody = Storage.getJSON(Storage.KEYS.TOGGLES, {}) || {};
if (!beforeNobody.nobody_prev_mode) beforeNobody.nobody_prev_mode = beforeNobody.mode || 'agent';
Storage.setJSON(Storage.KEYS.TOGGLES, beforeNobody);
const _offIds = ['web-toggle', 'bash-toggle', 'research-toggle'];
_offIds.forEach(id => { const c = el(id); if (c) c.checked = false; });
['web-toggle-btn', 'bash-toggle-btn'].forEach(id => { const b = el(id); if (b) b.classList.remove('active'); });
if (typeof window.__odysseusSetChatMode === 'function') {
window.__odysseusSetChatMode('chat');
} else {
const _ab = el('mode-agent-btn'), _cb = el('mode-chat-btn');
if (_ab) _ab.classList.remove('active');
if (_cb) _cb.classList.add('active');
if (_ab) {
_ab.classList.remove('active');
_ab.setAttribute('aria-pressed', 'false');
}
if (_cb) {
_cb.classList.add('active');
_cb.setAttribute('aria-pressed', 'true');
}
const _toggle = _ab?.closest('.mode-toggle') || _cb?.closest('.mode-toggle');
if (_toggle) _toggle.classList.add('mode-chat');
const ts = Storage.getJSON(Storage.KEYS.TOGGLES, {});
ts.research = false; ts.mode = 'chat';
ts.mode = 'chat';
Storage.setJSON(Storage.KEYS.TOGGLES, ts);
}
const ts = Storage.getJSON(Storage.KEYS.TOGGLES, {});
ts.research = false;
Storage.setJSON(Storage.KEYS.TOGGLES, ts);
} else {
incognitoBtn.innerHTML = INCOGNITO_EYE_OPEN + '<span class="incognito-label">Nobody</span>';
@@ -2335,11 +2373,15 @@ function initializeEventListeners() {
// Heal any previously-persisted false values from the old Nobody bug
// so agent-mode defaults (web/bash ON) come back.
const _ts = Storage.getJSON(Storage.KEYS.TOGGLES, {});
let _dirty = false;
const _restoreMode = _ts.nobody_prev_mode || 'agent';
delete _ts.nobody_prev_mode;
['web_agent', 'bash_agent', 'web_chat', 'bash_chat'].forEach(k => {
if (_ts[k] === false) { delete _ts[k]; _dirty = true; }
if (_ts[k] === false) delete _ts[k];
});
if (_dirty) Storage.setJSON(Storage.KEYS.TOGGLES, _ts);
Storage.setJSON(Storage.KEYS.TOGGLES, _ts);
if (typeof window.__odysseusSetChatMode === 'function') {
window.__odysseusSetChatMode(_restoreMode === 'chat' ? 'chat' : 'agent');
}
// Reapply the current mode's real defaults to the visible toggles
const _curMode = (Storage.getJSON(Storage.KEYS.TOGGLES, {}) || {}).mode || 'chat';
try { applyModeToToggles(_curMode); } catch (_) {}
+28
View File
@@ -1952,6 +1952,34 @@
</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="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg>Translation</h2>
<div class="settings-row" style="align-items:center;">
<div style="flex:1;min-width:0;">
<div class="settings-label" style="margin-bottom:3px;">Auto translate</div>
<div class="admin-toggle-sub" style="margin:0;">When an opened email appears to be in another language, prepare a translated view.</div>
</div>
<label class="admin-switch"><input type="checkbox" id="set-email-auto-translate"><span class="admin-slider"></span></label>
</div>
<div class="settings-row" style="align-items:center;margin-top:8px;">
<label class="settings-label" for="set-email-translate-language">Translate to</label>
<input id="set-email-translate-language" class="settings-select" list="set-email-translate-language-list" placeholder="English, Swedish, Japanese..." style="max-width:220px;">
<datalist id="set-email-translate-language-list">
<option value="English"></option>
<option value="Swedish"></option>
<option value="Norwegian"></option>
<option value="Danish"></option>
<option value="Japanese"></option>
<option value="Spanish"></option>
<option value="French"></option>
<option value="German"></option>
<option value="British English"></option>
<option value="Plain English"></option>
</datalist>
<span id="set-email-translate-msg" style="font-size:11px;margin-left:auto;"></span>
</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>
+6 -4
View File
@@ -821,11 +821,14 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// Web toggle: pre-search in Chat mode, tool permission in Agent mode
const toggleState = Storage.loadToggleState();
let isAgentMode = (toggleState.mode || 'chat') === 'agent';
const incognitoChk = el('incognito-toggle');
const isIncognito = !!(incognitoChk && incognitoChk.checked);
// Auto-escalate to agent mode when a document is open — the user expects
// the AI to see the document and have tools to edit it
if (!isAgentMode && documentModule && documentModule.isPanelOpen() && documentModule.getCurrentDocId()) {
if (!isIncognito && !isAgentMode && documentModule && documentModule.isPanelOpen() && documentModule.getCurrentDocId()) {
isAgentMode = true;
}
if (isIncognito) isAgentMode = false;
fd.append('mode', isAgentMode ? 'agent' : 'chat');
if (el('web-toggle').checked) {
if (isAgentMode) {
@@ -846,8 +849,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (ragChk && !ragChk.checked) {
fd.append('use_rag', 'false');
}
const incognitoChk = el('incognito-toggle');
if (incognitoChk && incognitoChk.checked) {
if (isIncognito) {
fd.append('incognito', 'true');
}
const _ws = (Storage.KEYS && Storage.get(Storage.KEYS.WORKSPACE, '')) || '';
@@ -864,7 +866,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
currentAbort = abortCtrl;
const _tState = Storage.loadToggleState();
const _isAgent = (_tState.mode || 'chat') === 'agent';
const _isAgent = !isIncognito && (_tState.mode || 'chat') === 'agent';
// Timeout: 6 min for research and agent mode, 3 min otherwise
const timeoutMs = el('research-toggle').checked || _isAgent ? RESEARCH_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
+15 -1
View File
@@ -1068,6 +1068,17 @@ document.addEventListener('click', function(e) {
}
}, true);
function resolveDocumentPlaceholderLinks(text, metadata) {
if (!text || !metadata || !Array.isArray(metadata.tool_events)) return text;
const docEvents = metadata.tool_events.filter(ev => ev && ev.doc_id);
if (!docEvents.length) return text;
return String(text).replace(/#document-(\d+)\b/g, (match, num) => {
const idx = Number(num) - 1;
const ev = Number.isInteger(idx) && idx >= 0 ? docEvents[idx] : null;
return ev && ev.doc_id ? `#document-${ev.doc_id}` : match;
});
}
// Jump-to-entity anchors — the agent emits links like
// [New Chat](#session-89effa28)
// [Notes](#document-abc123)
@@ -2005,7 +2016,7 @@ export function addMessage(role, content, modelName, metadata) {
for (let r = 0; r < maxRound; r++) {
const roundNum = r + 1;
const txt = (roundTexts[r] || '').trim();
const txt = resolveDocumentPlaceholderLinks((roundTexts[r] || '').trim(), metadata);
if (txt) {
const wrap = document.createElement('div');
@@ -2186,6 +2197,9 @@ export function addMessage(role, content, modelName, metadata) {
b.className = 'body';
let text = markdownModule.squashOutsideCode(stripToolBlocks(textRaw || ''));
if (role === 'assistant') {
text = resolveDocumentPlaceholderLinks(text, metadata);
}
// For user messages, pull out vision-model image descriptions ([Image: name]\n
// <multi-line desc>) into a collapsible "image description" section. Done for
+11 -5
View File
@@ -577,6 +577,7 @@ export async function _hwfitFetch(fresh = false) {
const _sig = _scanSig();
const _cached = fresh ? null : _readScanCache(_sig);
const wp = spinnerModule.createWhirlpool(18);
const _paintedFromCache = !!_cached;
if (_cached) {
// Tag the restored cache with its host too (scan-sig keys cache per
// host, so a hit here is always for the current remoteHost).
@@ -635,6 +636,10 @@ export async function _hwfitFetch(fresh = false) {
});
}).catch(() => {});
}
if (_paintedFromCache) {
try { wp.destroy(); } catch {}
return;
}
try {
const sortBy = document.getElementById('hwfit-sort')?.value || 'newest';
const quantPref = document.getElementById('hwfit-quant')?.value || '';
@@ -1958,16 +1963,17 @@ export function _hwfitInit() {
dot.className = 'cookbook-srv-status testing';
dot.title = 'Testing SSH…';
setMsg('Testing SSH...');
const pf = port && port !== '22' ? `-p ${port} ` : '';
const cmd = `ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new ${pf}${host} "echo ok"`;
const t0 = Date.now();
try {
const res = await fetch('/api/shell/exec', {
const res = await fetch('/api/cookbook/test-ssh', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: cmd, timeout: 8 }),
body: JSON.stringify({ host, ssh_port: port || undefined }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || data.error || `HTTP ${res.status}`);
}
const ms = Date.now() - t0;
const out = (data.stdout || '').trim();
if (data.exit_code === 0 && out.startsWith('ok')) {
@@ -1976,7 +1982,7 @@ export function _hwfitInit() {
setMsg(`Connected · ${ms} ms`, 'var(--green,#50fa7b)');
} else {
dot.className = 'cookbook-srv-status fail';
const err = (data.stderr || data.stdout || `exit ${data.exit_code}`).toString().trim().slice(0, 240);
const err = (data.stderr || data.stdout || (data.exit_code == null ? 'no exit code' : `exit ${data.exit_code}`)).toString().trim().slice(0, 240);
dot.title = `SSH failed: ${err}`;
setMsg(`Failed · ${err}`, 'var(--red,#e06c75)');
}
+7 -5
View File
@@ -873,8 +873,9 @@ async function _fetchDependencies() {
let _spin = null;
try {
const sp = (await import('./spinner.js')).default;
_spin = sp.createWhirlpool(28);
_spin.element.style.cssText = 'margin:24px auto 0;display:block;';
_spin = sp.createWhirlpool(22);
_spin.element.classList.add('cookbook-section-loading-wp');
_spin.element.style.cssText = 'margin:24px auto 0;display:block;width:22px;height:22px;';
list.appendChild(_spin.element);
const label = document.createElement('div');
label.className = 'hwfit-loading';
@@ -1788,7 +1789,7 @@ function _wireTabEvents(body) {
const scanBtn = document.getElementById('hwfit-cache-scan');
if (scanBtn) {
scanBtn.addEventListener('click', () => _fetchCachedModels());
scanBtn.addEventListener('click', () => _fetchCachedModels(true));
}
const editDirsLink = document.querySelector('.cookbook-serve-dir-edit');
@@ -2286,8 +2287,9 @@ function _wireTabEvents(body) {
hfList.innerHTML = '';
try {
const sp = (await import('./spinner.js')).default;
const _spin = sp.createWhirlpool(28);
_spin.element.style.cssText = 'margin:24px auto 0;display:block;';
const _spin = sp.createWhirlpool(22);
_spin.element.classList.add('cookbook-section-loading-wp');
_spin.element.style.cssText = 'margin:24px auto 0;display:block;width:22px;height:22px;';
hfList.appendChild(_spin.element);
const lbl = document.createElement('div');
lbl.className = 'hwfit-loading';
+114 -75
View File
@@ -45,6 +45,30 @@ const SERVE_STATE_KEY = 'cookbook-serve-state';
const SERVE_FAVORITES_KEY = 'cookbook-serve-favorite-models';
let _cachedAllModels = [];
const _CACHED_MODELS_SCAN_KEY = 'cookbook_cached_models_scan_v1';
const _CACHED_MODELS_SCAN_TTL = 6 * 3600 * 1000;
function _readCachedModelScan(sig) {
try {
const all = JSON.parse(localStorage.getItem(_CACHED_MODELS_SCAN_KEY) || '{}');
const entry = all[sig];
if (entry && Date.now() - (entry.ts || 0) < _CACHED_MODELS_SCAN_TTL) return entry.data || null;
} catch {}
return null;
}
function _writeCachedModelScan(sig, data) {
try {
const all = JSON.parse(localStorage.getItem(_CACHED_MODELS_SCAN_KEY) || '{}');
all[sig] = { ts: Date.now(), data };
const keys = Object.keys(all);
if (keys.length > 12) {
keys.sort((a, b) => (all[a].ts || 0) - (all[b].ts || 0));
for (const k of keys.slice(0, keys.length - 12)) delete all[k];
}
localStorage.setItem(_CACHED_MODELS_SCAN_KEY, JSON.stringify(all));
} catch {}
}
function _loadServeFavorites() {
try {
@@ -3563,12 +3587,91 @@ export async function openServePanelForRepo(repo, fields) {
// ── Fetch cached models from server ──
export async function _fetchCachedModels() {
function _renderCachedModelsData(list, data, host) {
// CHANGELOG: 'ready' already excludes partial downloads;
// show every complete model regardless of size/backend.
const ready = (data.models || []).filter(m => m.status === 'ready');
const downloading = (data.models || []).filter(m => m.status === 'downloading');
const allModels = [...ready, ...downloading];
_cachedAllModels = allModels;
if (!allModels.length) {
if (!host) {
list.innerHTML = '<div class="hwfit-loading" style="flex-direction:column;gap:6px;text-align:center;"><div>No cached models found</div><div style="font-size:11px;opacity:0.55;max-width:420px;line-height:1.4;">Docker Local uses Odysseuss cache in <code>data/huggingface</code>. Download a model here, or copy an existing host HuggingFace cache into that folder once.</div></div>';
} else {
list.innerHTML = '<div class="hwfit-loading">No cached models found</div>';
}
const tagContainer = document.getElementById('serve-tags');
if (tagContainer) tagContainer.innerHTML = '';
return;
}
// Auto-detect type + family tags
const _tagMap = {};
const _familyMap = {};
const _families = [
[/qwen/i, 'qwen'], [/llama/i, 'llama'], [/mistral|mixtral/i, 'mistral'],
[/deepseek/i, 'deepseek'], [/gemma/i, 'gemma'], [/phi/i, 'phi'],
[/minimax/i, 'minimax'], [/glm/i, 'glm'], [/flux/i, 'flux'],
[/stable.?diffusion|sdxl/i, 'sd'], [/z-image/i, 'z-image'],
[/whisper/i, 'whisper'], [/command|cohere/i, 'cohere'],
[/yi-/i, 'yi'], [/intern/i, 'intern'], [/falcon/i, 'falcon'],
];
for (const m of allModels) {
const n = (m.repo_id || '').toLowerCase();
let tag = 'other';
if (m.backend === 'ollama' || m.is_ollama) tag = 'llm';
else if (m.is_diffusion || /flux|sdxl|stable-diffusion|z-image|qwen-image|diffusion|dreamshar/i.test(n)) tag = 'image';
else if (/whisper|stt|asr/i.test(n)) tag = 'stt';
else if (/tts|cosyvoice|parler/i.test(n)) tag = 'tts';
else if (/embed|bge|minilm|e5-/i.test(n)) tag = 'embedding';
else if (/lora|adapter/i.test(n)) tag = 'lora';
else tag = 'llm';
m._tag = tag;
_tagMap[tag] = (_tagMap[tag] || 0) + 1;
m._family = '';
for (const [re, fam] of _families) {
if (re.test(n)) { m._family = fam; _familyMap[fam] = (_familyMap[fam] || 0) + 1; break; }
}
if ((m.backend === 'ollama' || m.is_ollama) && !m._family) {
m._family = 'ollama';
_familyMap.ollama = (_familyMap.ollama || 0) + 1;
}
}
// Render tag chips
const tagContainer = document.getElementById('serve-tags');
if (tagContainer) {
const tagOrder = ['llm', 'image', 'lora', 'embedding', 'tts', 'stt', 'other'];
let tagHtml = `<button class="memory-cat-chip active" data-serve-tag="">All (${allModels.length})</button>`;
for (const t of tagOrder) {
if (!_tagMap[t]) continue;
tagHtml += `<button class="memory-cat-chip" data-serve-tag="${t}">${t} (${_tagMap[t]})</button>`;
}
const sortedFamilies = Object.entries(_familyMap).sort((a, b) => b[1] - a[1]);
if (sortedFamilies.length) {
for (const [fam, count] of sortedFamilies) {
const logo = providerLogo(fam);
const logoHtml = logo ? `<span style="width:12px;height:12px;display:inline-flex;align-items:center;vertical-align:-2px;margin-right:2px;opacity:0.6;">${logo}</span>` : '';
tagHtml += `<button class="memory-cat-chip" data-serve-tag="fam:${fam}">${logoHtml}${fam} (${count})</button>`;
}
}
tagContainer.innerHTML = tagHtml;
}
_rerenderCachedModels();
}
export async function _fetchCachedModels(fresh = false) {
const list = document.getElementById('hwfit-cached-list');
if (!list) return;
list.innerHTML = '';
const _dlWp = spinnerModule.createWhirlpool(18);
const _dlWp = spinnerModule.createWhirlpool(22);
_dlWp.element.classList.add('cookbook-section-loading-wp');
_dlWp.element.style.width = '22px';
_dlWp.element.style.height = '22px';
const _dlWrap = document.createElement('div');
_dlWrap.className = 'hwfit-loading';
_dlWrap.style.cssText = 'flex-direction:column;gap:6px;';
@@ -3630,6 +3733,13 @@ export async function _fetchCachedModels() {
if (host) { qp.set('host', host); const _sp4 = _getPort(host); if (_sp4) qp.set('ssh_port', _sp4); const _plat = _getPlatform(host); if (_plat) qp.set('platform', _plat); }
if (modelDirs.length) qp.set('model_dir', modelDirs.join(','));
const params = qp.toString() ? `?${qp}` : '';
const scanSig = params || 'local';
const cached = fresh ? null : _readCachedModelScan(scanSig);
if (cached) {
_dlWp.destroy();
_renderCachedModelsData(list, cached, host);
return;
}
const res = await fetch(`/api/model/cached${params}`);
if (!res.ok) {
const body = await res.text().catch(() => '');
@@ -3644,80 +3754,9 @@ export async function _fetchCachedModels() {
throw new Error(`HTTP ${res.status} ${res.statusText}${msg ? `: ${msg}` : ''}`);
}
const data = await res.json();
_writeCachedModelScan(scanSig, data);
_dlWp.destroy();
// CHANGELOG: 'ready' already excludes partial downloads;
// show every complete model regardless of size/backend.
const ready = data.models.filter(m => m.status === 'ready');
const downloading = data.models.filter(m => m.status === 'downloading');
const allModels = [...ready, ...downloading];
_cachedAllModels = allModels;
if (!allModels.length) {
if (!host) {
list.innerHTML = '<div class="hwfit-loading" style="flex-direction:column;gap:6px;text-align:center;"><div>No cached models found</div><div style="font-size:11px;opacity:0.55;max-width:420px;line-height:1.4;">Docker Local uses Odysseuss cache in <code>data/huggingface</code>. Download a model here, or copy an existing host HuggingFace cache into that folder once.</div></div>';
} else {
list.innerHTML = '<div class="hwfit-loading">No cached models found</div>';
}
document.getElementById('serve-tags').innerHTML = '';
return;
}
// Auto-detect type + family tags
const _tagMap = {};
const _familyMap = {};
const _families = [
[/qwen/i, 'qwen'], [/llama/i, 'llama'], [/mistral|mixtral/i, 'mistral'],
[/deepseek/i, 'deepseek'], [/gemma/i, 'gemma'], [/phi/i, 'phi'],
[/minimax/i, 'minimax'], [/glm/i, 'glm'], [/flux/i, 'flux'],
[/stable.?diffusion|sdxl/i, 'sd'], [/z-image/i, 'z-image'],
[/whisper/i, 'whisper'], [/command|cohere/i, 'cohere'],
[/yi-/i, 'yi'], [/intern/i, 'intern'], [/falcon/i, 'falcon'],
];
for (const m of allModels) {
const n = (m.repo_id || '').toLowerCase();
let tag = 'other';
if (m.backend === 'ollama' || m.is_ollama) tag = 'llm';
else if (m.is_diffusion || /flux|sdxl|stable-diffusion|z-image|qwen-image|diffusion|dreamshar/i.test(n)) tag = 'image';
else if (/whisper|stt|asr/i.test(n)) tag = 'stt';
else if (/tts|cosyvoice|parler/i.test(n)) tag = 'tts';
else if (/embed|bge|minilm|e5-/i.test(n)) tag = 'embedding';
else if (/lora|adapter/i.test(n)) tag = 'lora';
else tag = 'llm';
m._tag = tag;
_tagMap[tag] = (_tagMap[tag] || 0) + 1;
m._family = '';
for (const [re, fam] of _families) {
if (re.test(n)) { m._family = fam; _familyMap[fam] = (_familyMap[fam] || 0) + 1; break; }
}
if ((m.backend === 'ollama' || m.is_ollama) && !m._family) {
m._family = 'ollama';
_familyMap.ollama = (_familyMap.ollama || 0) + 1;
}
}
// Render tag chips
const tagContainer = document.getElementById('serve-tags');
if (tagContainer) {
const tagOrder = ['llm', 'image', 'lora', 'embedding', 'tts', 'stt', 'other'];
let tagHtml = `<button class="memory-cat-chip active" data-serve-tag="">All (${allModels.length})</button>`;
for (const t of tagOrder) {
if (!_tagMap[t]) continue;
tagHtml += `<button class="memory-cat-chip" data-serve-tag="${t}">${t} (${_tagMap[t]})</button>`;
}
const sortedFamilies = Object.entries(_familyMap).sort((a, b) => b[1] - a[1]);
if (sortedFamilies.length) {
for (const [fam, count] of sortedFamilies) {
const logo = providerLogo(fam);
const logoHtml = logo ? `<span style="width:12px;height:12px;display:inline-flex;align-items:center;vertical-align:-2px;margin-right:2px;opacity:0.6;">${logo}</span>` : '';
tagHtml += `<button class="memory-cat-chip" data-serve-tag="fam:${fam}">${logoHtml}${fam} (${count})</button>`;
}
}
tagContainer.innerHTML = tagHtml;
}
_rerenderCachedModels();
_renderCachedModelsData(list, data, host);
} catch (e) {
_dlWp.destroy();
list.innerHTML = `<div class="hwfit-loading">Failed: ${esc(e.message)}</div>`;
+420 -82
View File
@@ -31,6 +31,11 @@ import * as Modals from './modalManager.js';
let _emailAccountsCache = null;
let _emailAccountsCacheAt = 0;
let _emailHeaderManualExpandUntil = 0;
let _emailStreamAnimFrame = null;
let _emailStreamRenderedBody = '';
let _emailStreamTargetBody = '';
let _emailLocalDraftDebounce = null;
const _EMAIL_LOCAL_DRAFT_PREFIX = 'odysseus.email.replyDraft.v1:';
// Diff mode state
let _diffModeActive = false;
@@ -38,6 +43,8 @@ import * as Modals from './modalManager.js';
let _diffNewContent = null;
let _diffChunks = []; // [{id, oldLines, newLines, startLine, resolved, accepted}]
let _diffUnresolvedCount = 0;
let _mdPreviewClickTimes = [];
let _mdPreviewHintLastAt = 0;
// Language auto-detection config
const AUTO_DETECT_DELAY = 500;
@@ -2045,9 +2052,8 @@ import * as Modals from './modalManager.js';
|| '';
const isForm = _isFormBackedDoc(live);
// Footer main button: for a doc opened from an email attachment, morph the
// Copy button into "Reply" (send the filled file back to the sender via the
// signed-reply flow). Otherwise it's the normal Copy action. The click
// handler branches on data-mode.
// Save button into "Attach" (send the filled file back to the sender via
// the signed-reply flow). Otherwise it forces a new saved version.
const _copyBtn = document.getElementById('doc-footer-copy-btn');
if (_copyBtn) {
const _ad = docs.get(activeDocId);
@@ -2056,10 +2062,10 @@ import * as Modals from './modalManager.js';
_copyBtn.dataset.mode = 'reply';
_copyBtn.title = 'Reply to the sender with this filled file attached';
_copyBtn.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>Attach';
} else if (!_replyable && _copyBtn.dataset.mode !== 'copy') {
_copyBtn.dataset.mode = 'copy';
_copyBtn.title = 'Copy document';
_copyBtn.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Copy';
} else if (!_replyable && _copyBtn.dataset.mode !== 'save') {
_copyBtn.dataset.mode = 'save';
_copyBtn.title = 'Save new version';
_copyBtn.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save';
}
}
// Standalone Export PDF / PDF-toggle icon buttons are retired — for a
@@ -2180,6 +2186,7 @@ import * as Modals from './modalManager.js';
if (mdToggle) {
mdToggle.querySelector('[data-mdview="edit"]')?.classList.toggle('active', !_mdActive);
mdToggle.querySelector('[data-mdview="preview"]')?.classList.toggle('active', _mdActive);
mdToggle.classList.toggle('is-preview-active', !!_mdActive);
}
} else if (lang === 'csv') {
show = true;
@@ -2206,6 +2213,16 @@ import * as Modals from './modalManager.js';
// suppress the single morph button to avoid two redundant controls.
if (_hasViewToggle(lang)) show = false;
actionBtn.style.display = show ? '' : 'none';
document.querySelectorAll('.md-toolbar-edit-only').forEach(el => {
el.style.display = (lang === 'markdown' && _mdActive) ? 'none' : '';
});
const mdToolbar = document.getElementById('doc-md-toolbar');
if (mdToolbar) {
mdToolbar.classList.toggle('md-preview-active', lang === 'markdown' && !!_mdActive);
mdToolbar.classList.toggle('md-write-active', lang === 'markdown' && !_mdActive);
}
if (_mdPreview) _mdPreview.classList.toggle('md-preview-active', lang === 'markdown' && !!_mdActive);
if (mdToolbar && mdToolbar._syncOverflow) requestAnimationFrame(mdToolbar._syncOverflow);
// Now that the contextual buttons' visibility is settled, collapse the bar
// if it ended up empty (the common plain-doc-on-mobile case).
@@ -2215,20 +2232,24 @@ import * as Modals from './modalManager.js';
// ── Email document type helpers ──
function _parseEmailHeader(content) {
const empty = { to: '', cc: '', bcc: '', subject: '', inReplyTo: '', references: '', sourceUid: '', sourceFolder: '', attachments: [], body: content || '' };
const empty = { to: '', cc: '', bcc: '', subject: '', inReplyTo: '', references: '', sourceUid: '', sourceFolder: '', forwardAttachments: false, attachments: [], body: content || '' };
if (!content) return empty;
const parts = content.split(/\n---\n/);
if (parts.length < 2) return empty;
const header = parts[0];
const body = parts.slice(1).join('\n---\n');
const fields = { to: '', cc: '', bcc: '', subject: '', inReplyTo: '', references: '', sourceUid: '', sourceFolder: '', attachments: [], body: body };
const fields = { to: '', cc: '', bcc: '', subject: '', inReplyTo: '', references: '', sourceUid: '', sourceFolder: '', forwardAttachments: false, attachments: [], body: body };
for (const line of header.split('\n')) {
const m = line.match(/^(To|Cc|Bcc|Subject|In-Reply-To|References|X-Source-UID|X-Source-Folder|X-Attachments):\s*(.*)$/i);
const m = line.match(/^(To|Cc|Bcc|Subject|In-Reply-To|References|X-Source-UID|X-Source-Folder|X-Forward-Attachments|X-Attachments):\s*(.*)$/i);
if (m) {
let key = m[1].toLowerCase();
if (key === 'in-reply-to') key = 'inReplyTo';
else if (key === 'x-source-uid') key = 'sourceUid';
else if (key === 'x-source-folder') key = 'sourceFolder';
else if (key === 'x-forward-attachments') {
fields.forwardAttachments = /^(1|true|yes)$/i.test((m[2] || '').trim());
continue;
}
else if (key === 'x-attachments') {
fields.attachments = m[2].trim().split('|').map(a => {
const [index, filename, size] = a.split(':');
@@ -2254,6 +2275,95 @@ import * as Modals from './modalManager.js';
return header + '\n---\n' + body;
}
function _emailLocalDraftKey(sourceUid, sourceFolder, inReplyTo) {
const uid = String(sourceUid || '').trim();
if (!uid) return '';
const folder = String(sourceFolder || 'INBOX').trim() || 'INBOX';
const msg = String(inReplyTo || '').trim();
return _EMAIL_LOCAL_DRAFT_PREFIX + encodeURIComponent(`${folder}|${uid}|${msg}`);
}
function _loadEmailLocalDraft(fields) {
const key = _emailLocalDraftKey(fields?.sourceUid, fields?.sourceFolder, fields?.inReplyTo);
if (!key) return null;
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
const draft = JSON.parse(raw);
if (!draft || typeof draft !== 'object') return null;
const updatedAt = Number(draft.updatedAt || 0);
if (updatedAt && Date.now() - updatedAt > 45 * 24 * 60 * 60 * 1000) {
localStorage.removeItem(key);
return null;
}
return draft;
} catch (_) {
return null;
}
}
function _emailFieldsWithLocalDraft(fields) {
const draft = _loadEmailLocalDraft(fields);
if (!draft) return fields;
return {
...fields,
to: draft.to ?? fields.to,
cc: draft.cc ?? fields.cc,
bcc: draft.bcc ?? fields.bcc,
subject: draft.subject ?? fields.subject,
inReplyTo: draft.inReplyTo ?? fields.inReplyTo,
references: draft.references ?? fields.references,
sourceUid: draft.sourceUid ?? fields.sourceUid,
sourceFolder: draft.sourceFolder ?? fields.sourceFolder,
body: draft.body ?? fields.body,
};
}
function _persistEmailLocalDraftNow() {
const doc = activeDocId && docs.get(activeDocId);
if (!doc || doc.language !== 'email') return;
const sourceUid = document.getElementById('doc-email-source-uid')?.value || '';
const sourceFolder = document.getElementById('doc-email-source-folder')?.value || 'INBOX';
const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value || '';
const key = _emailLocalDraftKey(sourceUid, sourceFolder, inReplyTo);
if (!key) return;
const rich = document.getElementById('doc-email-richbody');
const textarea = document.getElementById('doc-editor-textarea');
const body = (rich && rich.style.display !== 'none') ? rich.innerHTML : (textarea?.value || '');
const payload = {
to: document.getElementById('doc-email-to')?.value || '',
cc: document.getElementById('doc-email-cc')?.value || '',
bcc: document.getElementById('doc-email-bcc')?.value || '',
subject: document.getElementById('doc-email-subject')?.value || '',
inReplyTo,
references: document.getElementById('doc-email-references')?.value || '',
sourceUid,
sourceFolder,
body,
updatedAt: Date.now(),
};
try { localStorage.setItem(key, JSON.stringify(payload)); } catch (_) {}
}
function _persistEmailLocalDraftSoon() {
clearTimeout(_emailLocalDraftDebounce);
_emailLocalDraftDebounce = setTimeout(_persistEmailLocalDraftNow, 250);
}
function _clearEmailLocalDraft(sourceUid, sourceFolder, inReplyTo) {
const key = _emailLocalDraftKey(sourceUid, sourceFolder, inReplyTo);
if (!key) return;
try { localStorage.removeItem(key); } catch (_) {}
}
function _clearCurrentEmailLocalDraft() {
_clearEmailLocalDraft(
document.getElementById('doc-email-source-uid')?.value || '',
document.getElementById('doc-email-source-folder')?.value || 'INBOX',
document.getElementById('doc-email-in-reply-to')?.value || '',
);
}
// ── WYSIWYG email body helpers ──
function _emailBodyToHtml(text) {
const t = (text || '').trim();
@@ -2282,7 +2392,10 @@ import * as Modals from './modalManager.js';
function _wireEmailRichbody(rich) {
if (rich._wired) { _syncEmailRichbody(rich); return; }
rich._wired = true;
rich.addEventListener('input', () => _syncEmailRichbody(rich));
rich.addEventListener('input', () => {
_syncEmailRichbody(rich);
_persistEmailLocalDraftSoon();
});
// Highlight toolbar buttons (B / I / S, headings, lists) when the caret
// sits inside formatted text. queryCommandState reflects the live
// selection — we just translate that into .is-active classes the CSS
@@ -2374,6 +2487,53 @@ import * as Modals from './modalManager.js';
});
}
function _renderStreamingEmailBody(body, { immediate = false } = {}) {
const rich = document.getElementById('doc-email-richbody');
const textarea = document.getElementById('doc-editor-textarea');
if (!rich) return;
_emailStreamTargetBody = body || '';
if (!_emailStreamRenderedBody && textarea && textarea.value) {
_emailStreamRenderedBody = textarea.value;
}
const applyBody = (value) => {
if (textarea) {
textarea.value = value;
textarea.scrollTop = textarea.scrollHeight;
}
rich.innerHTML = _emailBodyToHtml(value);
rich.scrollTop = rich.scrollHeight;
};
if (immediate) {
if (_emailStreamAnimFrame) cancelAnimationFrame(_emailStreamAnimFrame);
_emailStreamAnimFrame = null;
_emailStreamRenderedBody = _emailStreamTargetBody;
applyBody(_emailStreamRenderedBody);
return;
}
if (_emailStreamTargetBody.length < _emailStreamRenderedBody.length ||
!_emailStreamTargetBody.startsWith(_emailStreamRenderedBody)) {
_emailStreamRenderedBody = '';
}
if (_emailStreamAnimFrame) return;
const tick = () => {
const remaining = _emailStreamTargetBody.length - _emailStreamRenderedBody.length;
if (remaining <= 0) {
_emailStreamAnimFrame = null;
return;
}
const step = Math.max(1, Math.min(8, Math.ceil(remaining / 18)));
_emailStreamRenderedBody = _emailStreamTargetBody.slice(0, _emailStreamRenderedBody.length + step);
applyBody(_emailStreamRenderedBody);
_emailStreamAnimFrame = requestAnimationFrame(tick);
};
_emailStreamAnimFrame = requestAnimationFrame(tick);
}
function _stripEmailReplyQuoteText(text) {
const original = String(text || '');
if (!original) return { body: '', stripped: false };
@@ -2397,6 +2557,7 @@ import * as Modals from './modalManager.js';
syncHighlighting();
const rich = _emailRichbodyActive();
if (rich) rich.innerHTML = _emailBodyToHtml(textarea.value);
_persistEmailLocalDraftSoon();
}
async function _streamEmailBodyText(textarea, value) {
@@ -2411,6 +2572,7 @@ import * as Modals from './modalManager.js';
const next = finalText.slice(0, i + chunk);
textarea.value = next;
if (rich) rich.innerHTML = _emailBodyToHtml(next);
_persistEmailLocalDraftSoon();
await new Promise(resolve => requestAnimationFrame(resolve));
}
_setEmailBodyText(textarea, finalText);
@@ -2509,7 +2671,8 @@ import * as Modals from './modalManager.js';
document.getElementById('doc-editor-textarea')?.classList.add('email-mode');
document.getElementById('doc-editor-code')?.classList.add('email-mode');
document.getElementById('doc-editor-highlight')?.classList.add('email-mode');
const fields = _parseEmailHeader(doc.content || '');
let fields = _parseEmailHeader(doc.content || '');
fields = _emailFieldsWithLocalDraft(fields);
const toInput = document.getElementById('doc-email-to');
const subjectInput = document.getElementById('doc-email-subject');
const inReplyTo = document.getElementById('doc-email-in-reply-to');
@@ -2635,6 +2798,10 @@ import * as Modals from './modalManager.js';
if (_rich && _srcWrap) {
_srcWrap.style.display = 'none';
_rich.style.display = '';
if (_emailStreamAnimFrame) cancelAnimationFrame(_emailStreamAnimFrame);
_emailStreamAnimFrame = null;
_emailStreamRenderedBody = fields.body || '';
_emailStreamTargetBody = fields.body || '';
_rich.innerHTML = _emailBodyToHtml(fields.body);
_wireEmailRichbody(_rich);
setTimeout(() => {
@@ -2660,6 +2827,49 @@ import * as Modals from './modalManager.js';
if (bccRow) bccRow.style.display = hasCcBcc ? '' : 'none';
if (ccToggle) ccToggle.style.display = hasCcBcc ? 'none' : '';
_syncEmailHeaderSummary();
_stageForwardedSourceAttachments(fields).catch(err => console.error('Forward attachment staging failed:', err));
}
function _syncStreamingEmailFields(doc) {
if (!doc) return;
const fields = _parseEmailHeader(doc.content || '');
const rich = document.getElementById('doc-email-richbody');
const srcWrap = document.getElementById('doc-editor-wrap');
const textarea = document.getElementById('doc-editor-textarea');
if (!rich || rich.style.display === 'none') {
_showEmailFields(doc);
return;
}
const setValue = (id, value) => {
const el = document.getElementById(id);
const next = value || '';
if (el && el.value !== next) el.value = next;
};
setValue('doc-email-to', fields.to);
setValue('doc-email-subject', fields.subject);
setValue('doc-email-in-reply-to', fields.inReplyTo);
setValue('doc-email-references', fields.references);
setValue('doc-email-source-uid', fields.sourceUid || '');
setValue('doc-email-source-folder', fields.sourceFolder || '');
setValue('doc-email-cc', fields.cc || '');
setValue('doc-email-bcc', fields.bcc || '');
const unreadBtn = document.getElementById('doc-email-unread-btn');
if (unreadBtn) unreadBtn.style.display = fields.sourceUid ? '' : 'none';
const ccRow = document.getElementById('doc-email-cc-row');
const bccRow = document.getElementById('doc-email-bcc-row');
const ccToggle = document.getElementById('doc-email-show-cc');
const hasCcBcc = !!(fields.cc || fields.bcc);
if (ccRow) ccRow.style.display = hasCcBcc ? '' : 'none';
if (bccRow) bccRow.style.display = hasCcBcc ? '' : 'none';
if (ccToggle) ccToggle.style.display = hasCcBcc ? 'none' : '';
if (srcWrap) srcWrap.style.display = 'none';
rich.style.display = '';
_renderStreamingEmailBody(fields.body || '');
if (doc._originalBody == null) doc._originalBody = fields.body || '';
_syncEmailHeaderSummary();
}
async function _uploadComposeFiles(files) {
@@ -2695,6 +2905,47 @@ import * as Modals from './modalManager.js';
_renderComposeAttachments();
}
async function _stageForwardedSourceAttachments(fields) {
const doc = docs.get(activeDocId);
if (!doc || doc.language !== 'email') return;
if (!fields?.forwardAttachments || !fields.sourceUid || !Array.isArray(fields.attachments) || fields.attachments.length === 0) return;
const sourceKey = `${fields.sourceFolder || 'INBOX'}:${fields.sourceUid}:${fields.attachments.map(a => a.index).join(',')}`;
if (doc._forwardedAttachmentSourceKey === sourceKey) return;
doc._forwardedAttachmentSourceKey = sourceKey;
if (!doc._composeAtts) doc._composeAtts = [];
const existingForwarded = new Set(doc._composeAtts.filter(a => a.forwardedSourceKey === sourceKey).map(a => String(a.sourceIndex)));
let added = 0;
for (const att of fields.attachments) {
const sourceIndex = String(att.index);
if (existingForwarded.has(sourceIndex)) continue;
try {
const folderQs = encodeURIComponent(fields.sourceFolder || 'INBOX');
const res = await fetch(`${API_BASE}/api/email/compose-from-attachment/${encodeURIComponent(fields.sourceUid)}/${encodeURIComponent(att.index)}?folder=${folderQs}`, {
method: 'POST',
credentials: 'same-origin',
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'failed');
doc._composeAtts.push({
token: data.token,
filename: data.filename || att.filename,
size: data.size || att.size || 0,
forwardedSourceKey: sourceKey,
sourceIndex,
});
added += 1;
} catch (err) {
console.error('Failed to stage forwarded attachment:', err);
if (uiModule) uiModule.showError(`Forward attachment failed: ${att.filename || 'attachment'}`);
}
}
if (added) {
_renderComposeAttachments();
clearTimeout(_autoSaveDebounce);
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800);
}
}
async function _handleAttachUpload(e) {
const files = e.target.files;
e.target.value = ''; // reset for next upload
@@ -3117,6 +3368,8 @@ import * as Modals from './modalManager.js';
in_reply_to: inReplyTo || null, references: references || null,
attachments: attachments.length > 0 ? attachments : null,
account_id: activeAccountId,
source_uid: sourceUid || null,
source_folder: sourceFolder || null,
wait_for_delivery: true,
}),
});
@@ -3166,9 +3419,12 @@ import * as Modals from './modalManager.js';
}
// Mark the source email as answered if this was a reply
if (sourceUid) {
fetch(`${API_BASE}/api/email/mark-answered/${sourceUid}?folder=${encodeURIComponent(sourceFolder)}`, { method: 'POST' }).catch(() => {});
_clearEmailLocalDraft(sourceUid, sourceFolder, inReplyTo);
const markParams = new URLSearchParams({ folder: sourceFolder });
if (data.account_id || activeAccountId) markParams.set('account_id', data.account_id || activeAccountId);
fetch(`${API_BASE}/api/email/mark-answered/${encodeURIComponent(sourceUid)}?${markParams.toString()}`, { method: 'POST' }).catch(() => {});
// Tell the inbox to refresh so the answered state shows
window.dispatchEvent(new CustomEvent('email-answered', { detail: { uid: sourceUid } }));
window.dispatchEvent(new CustomEvent('email-answered', { detail: { uid: sourceUid, folder: sourceFolder, account_id: data.account_id || activeAccountId || null } }));
}
// Delete the compose document after successful send. It was usually
// already detached from the visible tabs so sending can finish in the
@@ -3637,6 +3893,7 @@ import * as Modals from './modalManager.js';
const data = await res.json();
if (data.success) {
if (uiModule) uiModule.showToast(`Scheduled for ${new Date(localDt).toLocaleString()}`);
_clearCurrentEmailLocalDraft();
cleanup();
// Close the document
_closeWithoutDeleting(true);
@@ -3800,20 +4057,15 @@ import * as Modals from './modalManager.js';
}
// Detach a doc from its chat session so it stops reappearing in that
// chat: docs with content are unlinked (kept in the library), empty docs
// are deleted. Used by both the tab × and the mobile chip-to-trash close.
// Close a doc tab without breaking its chat association. The chat transcript
// can contain durable document links, so detaching a non-empty doc from the
// session makes it look like the document vanished from that chat.
function _detachDocFromSession(docId, { toast = false } = {}) {
const doc = docs.get(docId);
const hasContent = doc && doc.content && doc.content.trim().length > 0;
if (hasContent) {
fetch(`${API_BASE}/api/document/${docId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: '' }),
}).then(() => {
if (toast && uiModule) uiModule.showToast('Document unlinked from session');
}).catch(() => {});
saveDocument({ silent: true }).catch(() => {});
if (toast && uiModule) uiModule.showToast('Document closed');
} else {
fetch(`${API_BASE}/api/document/${docId}`, { method: 'DELETE' }).catch(() => {});
}
@@ -3921,6 +4173,7 @@ import * as Modals from './modalManager.js';
const _rich = document.getElementById('doc-email-richbody');
const _emailBody = (_rich && _rich.style.display !== 'none') ? _rich.innerHTML : textarea.value;
doc.content = _buildEmailContent(to, subject, inReplyTo, references, _emailBody, sourceUid, sourceFolder, cc, bcc);
_persistEmailLocalDraftSoon();
} else if (textarea) {
// Don't clobber a PDF/form-backed doc's source when the textarea is empty
// (it's hidden behind the rendered PDF view, so its value isn't the source
@@ -4101,8 +4354,8 @@ import * as Modals from './modalManager.js';
<div class="doc-md-toolbar" id="doc-md-toolbar" style="display:none">
<div class="md-toolbar-items" id="md-toolbar-items">
<span class="md-view-toggle" id="doc-md-view-toggle" style="display:none" role="group" aria-label="Edit or preview">
<button type="button" class="md-view-opt" data-mdview="edit" title="Edit source (Ctrl+Alt+M to toggle)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
<button type="button" class="md-view-opt" data-mdview="preview" title="Preview (Ctrl+Alt+M to toggle)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button type="button" class="md-view-opt" data-mdview="edit" title="Edit source (Ctrl+Alt+M to toggle)"><span class="md-view-label">Write</span><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
<button type="button" class="md-view-opt" data-mdview="preview" title="Preview (Ctrl+Alt+M to toggle)"><span class="md-view-label">Preview</span><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
</span>
<span class="md-view-toggle" id="doc-render-view-toggle" style="display:none" role="group" aria-label="Code or run">
<button type="button" class="md-view-opt" data-renderview="code" title="Edit code"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></button>
@@ -4111,20 +4364,19 @@ import * as Modals from './modalManager.js';
<button id="doc-email-ai-reply-btn" class="doc-action-icon-btn md-toolbar-email-only" type="button" title="Draft a reply with AI (Fast / Full + optional context)" style="display:none;align-items:center;gap:4px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="color:var(--accent, var(--red));flex-shrink:0;position:relative;top:-1px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg><span style="font-size:11px;">Reply</span></button>
<button id="doc-fontsize-btn" class="doc-action-icon-btn" title="Font size" style="position:relative;width:28px;height:26px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;"><path d="M4 7V4h16v3"/><path d="M12 4v16"/><path d="M8 20h8"/></svg><span class="doc-fontsize-levels"><i data-sz="s">S</i><i data-sz="m">M</i><i data-sz="l">L</i></span></button>
<button id="doc-diff-toggle-btn" class="doc-action-icon-btn" title="Compare changes" style="opacity:0.7;display:none;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="M5 12H2l5-5 5 5H9"/><path d="M19 12h3l-5 5-5-5h3"/></svg></button>
<span class="md-toolbar-sep"></span>
<button type="button" data-md="bold" title="Bold (Ctrl+B)"><b>B</b></button>
<button type="button" data-md="italic" title="Italic (Ctrl+I)"><i>I</i></button>
<button type="button" data-md="strike" title="Strikethrough"><s>S</s></button>
<span class="md-toolbar-sep"></span>
<button type="button" class="md-dd-toggle" data-dd="heading" title="Heading"><b>H</b><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<button type="button" class="md-dd-toggle" data-dd="list" title="List"><span style="font-variant-numeric:tabular-nums;">1.</span><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<span class="md-toolbar-sep"></span>
<button type="button" data-md="link" title="Link"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button>
<button type="button" id="md-toolbar-attach-btn" class="md-toolbar-attach-btn" title="Insert image"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></button>
<button type="button" class="md-dd-toggle md-toolbar-email-hide" data-dd="code" title="Code">\`<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<button type="button" data-md="hr" title="Horizontal rule"></button>
<span class="md-toolbar-sep"></span>
<span id="md-toolbar-emoji-slot"></span>
<span class="md-toolbar-sep md-toolbar-edit-only"></span>
<button type="button" class="md-toolbar-edit-only" data-md="bold" title="Bold (Ctrl+B)"><b>B</b></button>
<button type="button" class="md-toolbar-edit-only" data-md="italic" title="Italic (Ctrl+I)"><i>I</i></button>
<button type="button" class="md-toolbar-edit-only" data-md="strike" title="Strikethrough"><s>S</s></button>
<span class="md-toolbar-sep md-toolbar-edit-only"></span>
<button type="button" class="md-dd-toggle md-toolbar-edit-only" data-dd="heading" title="Heading"><b>H</b><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<button type="button" class="md-dd-toggle md-toolbar-edit-only" data-dd="list" title="List"><span style="font-variant-numeric:tabular-nums;">1.</span><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<span class="md-toolbar-sep md-toolbar-edit-only"></span>
<button type="button" class="md-toolbar-edit-only" data-md="link" title="Link"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button>
<button type="button" id="md-toolbar-attach-btn" class="md-toolbar-attach-btn md-toolbar-edit-only" title="Insert image"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></button>
<button type="button" class="md-dd-toggle md-toolbar-email-hide md-toolbar-edit-only" data-dd="code" title="Code">\`<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<span class="md-toolbar-sep md-toolbar-edit-only"></span>
<span id="md-toolbar-emoji-slot" class="md-toolbar-edit-only"></span>
<span class="md-toolbar-sep md-toolbar-pdf-only" style="display:none"></span>
<button type="button" id="doc-pdf-add-text-btn" class="md-toolbar-pdf-only" title="Add text box (then click on PDF)" style="display:none"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg></button>
<button type="button" id="doc-pdf-add-check-btn" class="md-toolbar-pdf-only" title="Add checkmark (then click on PDF)" style="display:none"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></button>
@@ -4179,7 +4431,7 @@ import * as Modals from './modalManager.js';
csv / html / pdf) is the one growing to fill. -->
<div id="doc-actions-footer" class="doc-email-actions">
<span class="email-send-split" id="doc-copy-export-split">
<button type="button" id="doc-footer-copy-btn" class="email-send-btn email-send-main" title="Copy document"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Copy</button>
<button type="button" id="doc-footer-copy-btn" class="email-send-btn email-send-main" title="Save new version" data-mode="save"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save</button>
<button type="button" id="doc-footer-export-btn" class="email-send-btn email-send-caret" title="Export as…" aria-label="Export options"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 15 12 9 18 15"/></svg></button>
</span>
</div>
@@ -4378,7 +4630,7 @@ import * as Modals from './modalManager.js';
document.getElementById('doc-import-btn')?.addEventListener('click', () => openLibrary());
document.getElementById('doc-footer-copy-btn')?.addEventListener('click', (e) => {
if (e.currentTarget.dataset.mode === 'reply') { if (activeDocId) _sendSignedReply(activeDocId); }
else copyDocument();
else saveDocument({ silent: false, forceVersion: true });
});
document.getElementById('doc-footer-export-btn')?.addEventListener('click', (e) => showExportMenu(null, e.currentTarget.getBoundingClientRect()));
// Mobile footer: Close the current doc + Copy its content (replaces the
@@ -4674,7 +4926,13 @@ import * as Modals from './modalManager.js';
});
}
['doc-email-to', 'doc-email-cc', 'doc-email-bcc', 'doc-email-subject'].forEach(id => {
document.getElementById(id)?.addEventListener('input', _syncEmailHeaderSummary);
document.getElementById(id)?.addEventListener('input', () => {
_syncEmailHeaderSummary();
saveCurrentToMap();
_persistEmailLocalDraftSoon();
clearTimeout(_autoSaveDebounce);
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800);
});
document.getElementById(id)?.addEventListener('focus', () => _setEmailHeaderCollapsed(false, { manual: false }));
});
document.getElementById('doc-email-richbody')?.addEventListener('focus', _maybeAutoCollapseEmailHeader);
@@ -4755,6 +5013,9 @@ import * as Modals from './modalManager.js';
const ccToggle = document.getElementById('doc-email-show-cc');
if (ccToggle) ccToggle.style.display = '';
_syncEmailHeaderSummary();
saveCurrentToMap();
clearTimeout(_autoSaveDebounce);
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800);
});
});
@@ -4793,6 +5054,7 @@ import * as Modals from './modalManager.js';
if (wantPreview !== isPreview) toggleMarkdownPreview();
_syncHeaderActions();
});
document.getElementById('doc-md-preview')?.addEventListener('click', _handleMarkdownPreviewClickHint);
// Unified Code / Run-or-View two-icon switch — language-aware: CSV flips
// between code and the table view, Python/JS/etc. between code and run
@@ -4856,6 +5118,7 @@ import * as Modals from './modalManager.js';
_fontIdx = (_fontIdx + 1) % 3;
_applyDocFont();
syncHighlighting();
_scheduleSelRerender();
});
// Undo button in header
@@ -4961,6 +5224,8 @@ import * as Modals from './modalManager.js';
_autoTitleDebounce = setTimeout(() => autoTitleFromContent(ta.value), 600);
clearTimeout(_autoSaveDebounce);
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 2000);
const doc = activeDocId && docs.get(activeDocId);
if (doc && doc.language === 'email') _persistEmailLocalDraftSoon();
});
ta.addEventListener('paste', (e) => {
if (_activeDocLanguage() !== 'markdown') return;
@@ -5727,6 +5992,29 @@ import * as Modals from './modalManager.js';
scrollLeftBtn?.addEventListener('click', () => itemsWrap.scrollTo({ left: 0, behavior: 'smooth' }));
scrollRightBtn?.addEventListener('click', () => itemsWrap.scrollTo({ left: itemsWrap.scrollWidth, behavior: 'smooth' }));
itemsWrap?.addEventListener('scroll', updateScrollArrows, { passive: true });
if (itemsWrap) {
let swipeStartX = 0;
let swipeStartY = 0;
let swipeStartScroll = 0;
itemsWrap.addEventListener('touchstart', (e) => {
const t = e.touches && e.touches[0];
if (!t) return;
swipeStartX = t.clientX;
swipeStartY = t.clientY;
swipeStartScroll = itemsWrap.scrollLeft;
}, { passive: true });
itemsWrap.addEventListener('touchend', (e) => {
const t = e.changedTouches && e.changedTouches[0];
if (!t) return;
const dx = t.clientX - swipeStartX;
const dy = t.clientY - swipeStartY;
if (Math.abs(dx) < 42 || Math.abs(dx) < Math.abs(dy) * 1.4) return;
const maxScroll = Math.max(0, itemsWrap.scrollWidth - itemsWrap.clientWidth);
const page = Math.max(90, Math.round(itemsWrap.clientWidth * 0.75));
const nextLeft = Math.max(0, Math.min(maxScroll, swipeStartScroll - Math.sign(dx) * page));
itemsWrap.scrollTo({ left: nextLeft, behavior: 'smooth' });
}, { passive: true });
}
if (window.ResizeObserver && itemsWrap) {
new ResizeObserver(updateScrollArrows).observe(itemsWrap);
}
@@ -6909,39 +7197,14 @@ import * as Modals from './modalManager.js';
_selResizeObserver.observe(ta);
}
// Detect whether the textarea is currently wrapping any line. If
// every logical line fits on one visual row, the overlay positions
// are exact and pinned selections are safe regardless of fullscreen
// state. We compute rendered-row-count from scrollHeight/line-height
// and compare against the number of \n-separated lines.
function _textareaWraps(ta) {
if (!ta) return false;
const style = getComputedStyle(ta);
const lh = parseFloat(style.lineHeight) || (parseFloat(style.fontSize) * 1.45);
if (!lh) return false;
const padTop = parseFloat(style.paddingTop) || 0;
const padBottom = parseFloat(style.paddingBottom) || 0;
const renderedRows = Math.round((ta.scrollHeight - padTop - padBottom) / lh);
const logicalLines = (ta.value || '').split('\n').length;
return renderedRows > logicalLines;
}
/** Update selection tracking, show badge + persistent highlight.
* Each new selection is added (pinned). Click without selecting to clear all. */
function updateSelectionState() {
// Pinned selections are safe whenever the overlay measurement can
// be exact. That holds in two cases: (1) fullscreen — width is
// stable, or (2) no line wrapping — every logical \n-line fits on
// one visual row, so character-precise mirror measurement isn't
// needed. Outside both cases, panel resizes / wrap shifts make
// overlays drift, so we no-op.
const _pane = document.querySelector('.doc-editor-pane');
const _isFs = !!(_pane && _pane.classList.contains('doc-fullscreen'));
const _ta0 = document.getElementById('doc-editor-textarea');
if (!_isFs && _textareaWraps(_ta0)) {
if (_selections.length) clearSelection();
return;
}
// The mirror measurement below uses the textarea's live computed metrics,
// so pinned selections remain valid with wrapped lines, larger font sizes,
// mobile widths, and non-fullscreen panes. Older code disabled selection
// whenever wrapping was detected; increasing the document font made that
// path fire constantly, so selecting text appeared to stop working.
_ensureSelResizeObserver();
const textarea = document.getElementById('doc-editor-textarea');
if (!textarea) return;
@@ -7623,6 +7886,7 @@ import * as Modals from './modalManager.js';
_renderDiffOverlay(entries);
_renderDiffToolbar();
_renderDiffGutter();
requestAnimationFrame(() => _scrollToDiffChunk(_diffChunks[0]?.id));
// Update header button
const diffBtn = document.getElementById('doc-diff-toggle-btn');
@@ -7782,6 +8046,20 @@ import * as Modals from './modalManager.js';
el.textContent = `${resolved} / ${_diffChunks.length} changes resolved`;
}
function _scrollToDiffChunk(chunkId) {
if (chunkId == null) return;
const firstEl = document.querySelector(`[data-chunk-id="${chunkId}"]`);
const highlight = document.getElementById('doc-editor-highlight');
const textarea = document.getElementById('doc-editor-textarea');
if (!firstEl || !highlight) return;
const target = Math.max(0, firstEl.offsetTop - 80);
highlight.scrollTop = target;
if (textarea) textarea.scrollTop = target;
const gutter = document.getElementById('doc-line-numbers');
const lineNums = gutter && _lineNumberContentEl(gutter);
if (lineNums) lineNums.style.transform = `translateY(${-target}px)`;
}
/** Resolve a single chunk */
function _resolveChunk(chunkId, accept) {
const chunk = _diffChunks.find(c => c.id === chunkId);
@@ -7811,6 +8089,9 @@ import * as Modals from './modalManager.js';
if (_diffUnresolvedCount === 0) {
setTimeout(() => exitDiffMode(false), 300);
} else {
const nextChunk = _diffChunks.find(c => !c.resolved);
requestAnimationFrame(() => _scrollToDiffChunk(nextChunk?.id));
}
}
@@ -7862,6 +8143,7 @@ import * as Modals from './modalManager.js';
function exitDiffMode(discard) {
if (!_diffModeActive) return;
_diffModeActive = false;
const acceptedAnyDiffChunk = !discard && _diffChunks.some(chunk => chunk && chunk.resolved && chunk.accepted);
const textarea = document.getElementById('doc-editor-textarea');
const codeEl = document.getElementById('doc-editor-code');
@@ -7925,6 +8207,12 @@ import * as Modals from './modalManager.js';
syncHighlighting();
updateLineNumbers(textarea ? textarea.value : '');
saveDocument({ silent: true });
if (acceptedAnyDiffChunk) {
const lang = ((docs.get(activeDocId)?.language) || document.getElementById('doc-language-select')?.value || '').toLowerCase();
if (lang === 'markdown') {
requestAnimationFrame(() => _setMarkdownPreviewActive(true, { remember: true }));
}
}
}
/** Check if diff mode is active */
@@ -8402,29 +8690,50 @@ import * as Modals from './modalManager.js';
}
/** Save manual edits */
export async function saveDocument({ silent = false } = {}) {
export async function saveDocument({ silent = false, forceVersion = false } = {}) {
if (!activeDocId) return;
const textarea = document.getElementById('doc-editor-textarea');
if (!textarea) return;
const savingDocId = activeDocId;
saveCurrentToMap();
const localDoc = docs.get(savingDocId);
const contentToSave = localDoc?.content ?? textarea.value;
try {
const res = await fetch(`${API_BASE}/api/document/${activeDocId}`, {
const res = await fetch(`${API_BASE}/api/document/${savingDocId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ content: textarea.value }),
body: JSON.stringify({
content: contentToSave,
force_version: !!forceVersion,
summary: forceVersion ? 'Saved version' : undefined,
}),
});
if (res.status === 404) {
// Streaming/empty email drafts can leave a local tab pointing at a temp
// or already-deleted document. Do not keep surfacing autosave errors for
// a document the backend no longer knows about.
if (docs.has(savingDocId)) docs.delete(savingDocId);
if (activeDocId === savingDocId) {
activeDocId = null;
renderTabs();
}
_syncDocIndicator();
if (!silent && uiModule) uiModule.showError('Document no longer exists');
return;
}
if (!res.ok) throw new Error(`Document save failed: HTTP ${res.status}`);
const doc = await res.json();
const badge = document.getElementById('doc-version-badge');
if (badge) { const _v = doc.version_count || 1; badge.textContent = `v${_v}`; badge.style.display = _v > 1 ? '' : 'none'; }
// Update map
if (docs.has(activeDocId)) {
docs.get(activeDocId).version = doc.version_count || 1;
docs.get(activeDocId).content = textarea.value;
if (docs.has(savingDocId)) {
docs.get(savingDocId).version = doc.version_count || 1;
docs.get(savingDocId).content = contentToSave;
}
_syncDocIndicator();
if (!silent && uiModule) uiModule.showToast('Document saved');
if (!silent && uiModule) uiModule.showToast(forceVersion ? 'New version saved' : 'Document saved');
} catch (e) {
console.error('Failed to save document:', e);
const now = Date.now();
@@ -9471,7 +9780,7 @@ import * as Modals from './modalManager.js';
if (_streamDocId === activeDocId) {
if ((doc?.language || '').toLowerCase() === 'email') {
_showEmailFields(doc);
_syncStreamingEmailFields(doc);
return;
}
const textarea = document.getElementById('doc-editor-textarea');
@@ -9502,6 +9811,11 @@ import * as Modals from './modalManager.js';
* Returns the old _streamDocId so handleDocUpdate can migrate tempreal. */
export function streamDocFinalize() {
const oldId = _streamDocId;
const finishingDoc = oldId ? docs.get(oldId) : null;
if (oldId === activeDocId && (finishingDoc?.language || '').toLowerCase() === 'email') {
const fields = _parseEmailHeader(finishingDoc.content || '');
_renderStreamingEmailBody(fields.body || '', { immediate: true });
}
_streamDocId = null;
// Hide streaming indicator + cursor
const indicator = document.getElementById('doc-stream-indicator');
@@ -9520,6 +9834,27 @@ import * as Modals from './modalManager.js';
return !!(preview && preview.style.display !== 'none');
}
function _handleMarkdownPreviewClickHint() {
if (!_isMarkdownPreviewVisible()) return;
const lang = ((docs.get(activeDocId)?.language) || document.getElementById('doc-language-select')?.value || '').toLowerCase();
if (lang !== 'markdown') return;
const now = Date.now();
_mdPreviewClickTimes = _mdPreviewClickTimes.filter(ts => now - ts < 2500);
_mdPreviewClickTimes.push(now);
if (_mdPreviewClickTimes.length < 3 || now - _mdPreviewHintLastAt < 5000) return;
_mdPreviewHintLastAt = now;
_mdPreviewClickTimes = [];
if (uiModule?.showToast) {
uiModule.showToast('Preview is read-only. Click Write to edit the document.', {
duration: 5000,
action: 'Write',
onAction: () => _setMarkdownPreviewActive(false, { remember: true }),
});
}
}
function _refreshMarkdownPreviewIfVisible(docId, content) {
if (!_isMarkdownPreviewVisible()) return false;
const doc = docs.get(docId);
@@ -9712,11 +10047,14 @@ import * as Modals from './modalManager.js';
// Toolbar shown for every doc type — items inside self-gate on language.
if (mdToolbar) mdToolbar.style.display = '';
// Auto-show table view for CSV after streaming
if (finalLang === 'csv') {
const finalLangLower = (finalLang || '').toLowerCase();
if (finalLangLower === 'csv') {
requestAnimationFrame(() => {
const csvPreview = document.getElementById('doc-csv-preview');
if (csvPreview && csvPreview.style.display === 'none') toggleCsvPreview();
});
} else if (streamingId && finalLangLower === 'markdown') {
requestAnimationFrame(() => _setMarkdownPreviewActive(true, { remember: true }));
}
renderTabs();
+130 -24
View File
@@ -9,11 +9,10 @@ import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibO
import * as Modals from './modalManager.js';
import { applyEdgeDock } from './modalSnap.js';
import { buildReplyAllCc } from './emailLibrary/replyRecipients.js';
import { emailApiUrl, emailAccountQuery } from './emailShared.js';
const API_BASE = window.location.origin;
const _acct = () => window.__odysseusActiveEmailAccount
? `&account_id=${encodeURIComponent(window.__odysseusActiveEmailAccount)}`
: '';
const _acct = () => emailAccountQuery('&');
const _emailSetupHint = () => '<div style="margin-top:6px;opacity:0.72;font-size:11px;">Setup: <span style="color:var(--accent,var(--red));">Settings &rsaquo; Integrations</span></div>';
@@ -27,6 +26,59 @@ const _starFilledIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="c
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 ----------';
const _DONE_RESPONSE_TAGS = new Set(['urgent', 'reply-soon', 'action-needed']);
function _openCalendarEventFromEmail(uid) {
const target = String(uid || '').trim();
if (!target) return;
import('./calendar.js').then(mod => {
const open = mod.openCalendarTo || (mod.default && mod.default.openCalendarTo);
if (open) open(target);
}).catch(() => {});
}
function _openEmailTagFilter(tag) {
const normalized = String(tag || '').trim().toLowerCase().replace(/_/g, '-');
if (!normalized || normalized === 'calendar') return;
try { openEmailLibrary(); } catch (_) {}
setTimeout(() => {
document.dispatchEvent(new CustomEvent('odysseus:email-filter-tag', { detail: { tag: normalized } }));
}, 0);
}
function _emailTagPillHtml(tag, em) {
const normalized = String(tag || '').trim().toLowerCase().replace(/_/g, '-');
if (!normalized) return '';
const eventUid = normalized === 'calendar' && Array.isArray(em?.calendar_event_uids)
? String(em.calendar_event_uids[0] || '').trim()
: '';
if (normalized === 'calendar') {
if (!eventUid) return '';
return `<button type="button" class="email-tag email-tag-${_esc(normalized)} email-tag-clickable" data-calendar-event-uid="${_esc(eventUid)}" title="Open calendar event">${_esc(normalized)}</button>`;
}
return `<button type="button" class="email-tag email-tag-${_esc(normalized)} email-tag-clickable" data-email-filter-tag="${_esc(normalized)}" title="Show ${_esc(normalized)} emails">${_esc(normalized)}</button>`;
}
function _emailTagGroupHtml(tags, em) {
const visible = (Array.isArray(tags) ? tags : [])
.map(t => _emailTagPillHtml(t, em))
.filter(Boolean);
if (!visible.length) return '';
if (visible.length === 1) return `<span class="email-tags">${visible[0]}</span>`;
const extra = visible.slice(1).map(html => `<span class="email-tag-extra">${html}</span>`).join('');
return `<span class="email-tags email-tags-collapsed">${visible[0]}${extra}<button type="button" class="email-tags-more" data-email-tags-more aria-expanded="false" title="Show all tags">+${visible.length - 1}<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button></span>`;
}
function _visibleEmailTagsForRender(em) {
const tags = Array.isArray(em?.tags) ? em.tags : [];
if (!em?.is_answered) return tags;
return tags.filter(t => !_DONE_RESPONSE_TAGS.has(String(t || '').trim().toLowerCase().replace(/_/g, '-')));
}
function _clearDoneResponseTagsLocal(em) {
if (!em || !Array.isArray(em.tags)) return;
em.tags = em.tags.filter(t => !_DONE_RESPONSE_TAGS.has(String(t || '').trim().toLowerCase().replace(/_/g, '-')));
}
function _cleanAiReplyText(text) {
if (!text) return '';
@@ -69,9 +121,14 @@ window.addEventListener('email-answered', (e) => {
const uid = e.detail && e.detail.uid;
if (uid == null) return;
const em = _emails.find(x => String(x.uid) === String(uid));
if (em) { em.is_answered = true; em.is_read = true; }
if (em) {
em.is_answered = true;
em.is_read = true;
_clearDoneResponseTagsLocal(em);
}
document.querySelectorAll('.email-item[data-uid="' + CSS.escape(String(uid)) + '"]').forEach(item => {
item.classList.remove('email-unread');
item.querySelectorAll('.email-tag-urgent, .email-tag-reply-soon, .email-tag-action-needed').forEach(n => n.remove());
const check = item.querySelector('.email-done-check');
if (check) check.classList.add('active');
// Auto-mark from sending a reply — flash the row so the user sees the
@@ -87,10 +144,15 @@ let _docModule = null;
let _listSpinner = null;
let _senderFilter = null; // email address (lowercased) to filter by, or null
let _senderFilterLabel = null; // display label for the active filter chip
let _showEmailTags = localStorage.getItem('odysseus.email.showTags') !== '0';
export function init(documentModule) {
_docModule = documentModule;
_bindEvents();
document.addEventListener('odysseus:email-tags-toggle', (e) => {
_showEmailTags = e.detail?.show !== false;
_renderList();
});
// Init the library popup with a callback to open emails
initEmailLibrary({
documentModule,
@@ -136,6 +198,23 @@ export async function openReplyDraft(uid, folder = 'INBOX', mode = 'reply', pref
}
}
function _bringEmailReplyDraftToFrontOnMobile() {
if (window.innerWidth > 768) return;
document.body.classList.remove('email-front', 'email-doc-split-active');
document.documentElement.style.removeProperty('--email-doc-split-left-x');
document.documentElement.style.removeProperty('--email-doc-split-email-w');
document.documentElement.style.removeProperty('--email-doc-split-right-x');
// Keep the email sheet visible behind the reply document on mobile. The
// document panel sits above it via the normal doc-view z-index rules, and
// swiping the document down minimizes it to a chip to reveal the email.
document.querySelectorAll('#email-lib-modal, .modal[id^="email-reader-"]').forEach(modal => {
modal.classList.remove('email-snap-left', 'modal-left-docked', 'modal-right-docked');
modal.style.removeProperty('z-index');
});
const docPane = document.getElementById('doc-editor-pane');
if (docPane) docPane.style.setProperty('z-index', '10010', 'important');
}
// When the document editor pane opens (body.doc-view turns on), make sure the
// email modal is on the LEFT — even if it was previously docked RIGHT or
// floating — so the email and the doc always end up side-by-side. The actual
@@ -230,24 +309,24 @@ async function _refreshUnreadCount() {
const dot = document.getElementById('email-unread-dot');
if (dot && !dot._stickyState) dot.style.display = 'none';
try {
// Parallel: unread list + urgency state.
const [listRes, urgRes] = await Promise.all([
fetch(`${API_BASE}/api/email/list?folder=INBOX&limit=50&filter=unread${_acct()}`),
// Parallel: cheap unread state + urgency state.
const [stateRes, urgRes] = await Promise.all([
fetch(emailApiUrl('/api/email/unread-state', { folder: 'INBOX' })),
fetch(`${API_BASE}/api/email/urgency-state`, { credentials: 'same-origin' }).catch(() => null),
]);
if (!listRes || !listRes.ok) return;
const data = await listRes.json();
if (!stateRes || !stateRes.ok) return;
const data = await stateRes.json();
if (!dot) return;
const emails = data.emails || [];
if (emails.length === 0) {
const unreadCount = Number(data.unread_count || 0);
if (unreadCount <= 0) {
dot.style.display = 'none';
return;
}
// Compare highest unread UID to the last-seen threshold in localStorage
const lastSeen = parseInt(localStorage.getItem('odysseus-email-last-seen-uid') || '0', 10);
const maxUid = Math.max(...emails.map(e => parseInt(e.uid, 10) || 0));
const maxUid = parseInt(data.max_uid || '0', 10) || 0;
// Only show dot if there's a new email above the threshold
dot.style.display = maxUid > lastSeen ? '' : 'none';
@@ -275,12 +354,11 @@ export function markInboxAsSeen() {
// Called when the user opens the inbox popup — clears the notif dot
try {
// Find current max UID so subsequent arrivals trigger the dot
fetch(`${API_BASE}/api/email/list?folder=INBOX&limit=1${_acct()}`)
fetch(emailApiUrl('/api/email/unread-state', { folder: 'INBOX' }))
.then(r => r.json())
.then(data => {
const emails = data.emails || [];
if (emails.length > 0) {
const maxUid = Math.max(...emails.map(e => parseInt(e.uid, 10) || 0));
const maxUid = parseInt(data.max_uid || '0', 10) || 0;
if (maxUid > 0) {
localStorage.setItem('odysseus-email-last-seen-uid', String(maxUid));
}
const dot = document.getElementById('email-unread-dot');
@@ -308,7 +386,7 @@ export async function loadEmails(append = false) {
try {
const fromQS = _senderFilter ? `&from=${encodeURIComponent(_senderFilter)}` : '';
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(_currentFolder)}&limit=50&offset=${_offset}${fromQS}${_acct()}&_=${Date.now()}`);
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(_currentFolder)}&limit=50&offset=${_offset}${fromQS}${_acct()}`);
const data = await res.json();
if (data.error) throw new Error(data.error);
@@ -338,7 +416,8 @@ export async function loadEmails(append = false) {
async function loadFolders() {
try {
const res = await fetch(`${API_BASE}/api/email/folders?_=1${_acct()}`);
const accountQS = _acct().replace(/^&/, '');
const res = await fetch(`${API_BASE}/api/email/folders${accountQS ? `?${accountQS}` : ''}`);
const data = await res.json();
const select = document.getElementById('email-folder-select');
if (!select || !data.folders) return;
@@ -511,12 +590,10 @@ function _createEmailItem(em) {
? `<span class="email-unread-dot-inline" title="${_esc(_unreadTitle)}" style="display:inline-flex;align-items:center;flex-shrink:0;margin-left:4px;color:${_unreadColor}"><svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="6"/></svg></span>`
: '';
const tags = Array.isArray(em.tags) ? em.tags : [];
const tagPills = tags.length
? `<span class="email-tags">${tags.map(t => `<span class="email-tag email-tag-${_esc(t)}">${_esc(t)}</span>`).join('')}</span>`
: '';
const tags = _showEmailTags ? _visibleEmailTagsForRender(em) : [];
const tagPills = _emailTagGroupHtml(tags, em);
const spamTag = em.is_spam_verdict
const spamTag = _showEmailTags && em.is_spam_verdict
? `<span class="email-tag email-tag-spam" title="AI flagged as spam — click ✓ to unflag">spam <button class="email-spam-unflag" data-uid="${em.uid}" title="Not spam">\u2713</button></span>`
: '';
@@ -534,6 +611,30 @@ function _createEmailItem(em) {
// Click sender name → filter list to that sender
const senderEl = item.querySelector('.email-sender-clickable');
item.querySelectorAll('[data-calendar-event-uid]').forEach(btn => {
btn.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
_openCalendarEventFromEmail(btn.dataset.calendarEventUid);
});
});
item.querySelectorAll('[data-email-filter-tag]').forEach(btn => {
btn.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
_openEmailTagFilter(btn.dataset.emailFilterTag);
});
});
item.querySelectorAll('[data-email-tags-more]').forEach(btn => {
btn.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
const wrap = btn.closest('.email-tags');
if (!wrap) return;
const expanded = wrap.classList.toggle('email-tags-expanded');
btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
});
});
if (senderEl) {
senderEl.addEventListener('click', (e) => {
e.stopPropagation();
@@ -658,7 +759,8 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
try {
let data = preloadedData;
if (!data) {
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}`);
const fullQS = mode === 'forward' ? '&full=1' : '';
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}${fullQS}`);
data = await res.json();
}
if (data.error) {
@@ -752,6 +854,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
if (data.attachments && data.attachments.length > 0) {
const attStr = data.attachments.map(a => `${a.index}:${a.filename}:${a.size}`).join('|');
content += `\nX-Attachments: ${attStr}`;
if (mode === 'forward') content += `\nX-Forward-Attachments: 1`;
}
content += '\n---\n';
@@ -895,6 +998,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
} else {
await _docModule.loadDocument(doc.id);
}
_bringEmailReplyDraftToFrontOnMobile();
}
}
}
@@ -1099,9 +1203,11 @@ async function _toggleDone(em, itemEl) {
if (newState) em.is_read = true; // mark-done implies mark-read
if (itemEl) {
if (newState) {
_clearDoneResponseTagsLocal(em);
itemEl.classList.remove('email-unread');
// Also drop any inline unread indicator dots the renderer may have added
itemEl.querySelectorAll('.email-unread-dot, [data-unread-dot]').forEach(n => n.remove());
itemEl.querySelectorAll('.email-tag-urgent, .email-tag-reply-soon, .email-tag-action-needed').forEach(n => n.remove());
}
const check = itemEl.querySelector('.email-done-check');
if (check) check.classList.toggle('active', newState);
+1182 -223
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -25,6 +25,7 @@ export const state = {
_libFilter: 'all', // all, unread, unanswered
_libSort: 'recent', // recent, unread, favorites
_libHasAttachments: false,
_libShowTags: localStorage.getItem('odysseus.email.showTags') !== '0',
_libLoading: false,
_docModule: null,
_onEmailClick: null,
+19
View File
@@ -0,0 +1,19 @@
const API_BASE = window.location.origin;
export function emailAccountQuery(prefix = '&') {
const accountId = window.__odysseusActiveEmailAccount || '';
if (!accountId) return '';
const lead = prefix === '?' ? '?' : '&';
return `${lead}account_id=${encodeURIComponent(accountId)}`;
}
export function emailApiUrl(path, params = {}) {
const url = new URL(`${API_BASE}${path}`);
const accountId = window.__odysseusActiveEmailAccount || '';
if (accountId) url.searchParams.set('account_id', accountId);
Object.entries(params || {}).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return;
url.searchParams.set(key, String(value));
});
return url.toString();
}
+5 -1
View File
@@ -2774,6 +2774,7 @@ function _collectFormDraft(form) {
const d = {
_ts: Date.now(),
note_type: type,
color: form.dataset.noteColor || '',
title: form.querySelector('.note-form-title')?.value || '',
label: form.querySelector('.note-form-label')?.value || '',
due_date: form.querySelector('.note-form-due')?.value || null,
@@ -2819,7 +2820,7 @@ function _applyDraftToNote(note, id) {
const d = _loadDraft(id);
if (_isDraftEmpty(d)) return { note, restored: false };
const merged = { ...(note || {}) };
['note_type', 'title', 'label', 'due_date', 'repeat', 'content', 'items'].forEach(k => {
['note_type', 'color', 'title', 'label', 'due_date', 'repeat', 'content', 'items'].forEach(k => {
if (d[k] !== undefined) merged[k] = d[k];
});
return { note: merged, restored: true };
@@ -2835,6 +2836,7 @@ function _buildForm(note = null) {
const form = document.createElement('div');
form.className = 'note-form';
form.dataset.noteColor = color || '';
if (color && !_isBgImage(color)) form.classList.add('note-color-' + color);
if (_isBgImage(color)) form.setAttribute('style', _customColorStyle(color));
let currentImageUrl = _safeImgSrc(note?.image_url || '');
@@ -3038,6 +3040,7 @@ function _buildForm(note = null) {
// Color dots — apply to entire form immediately
const _applyFormColor = (newColor) => {
currentColor = newColor || '';
form.dataset.noteColor = currentColor;
const isBg = _isBgImage(currentColor);
COLORS.forEach(c => { if (c.value && c.value !== 'custom') form.classList.remove('note-color-' + c.value); });
if (currentColor && !isBg) form.classList.add('note-color-' + currentColor);
@@ -3047,6 +3050,7 @@ function _buildForm(note = null) {
d.classList.toggle('active', _dotIsActive(d.dataset.color, currentColor));
d.style.background = _dotBg(d.dataset.color, currentColor);
});
form.dispatchEvent(new Event('change', { bubbles: true }));
};
form.querySelectorAll('.note-color-dot').forEach(dot => {
dot.addEventListener('click', () => {
+31 -5
View File
@@ -27,16 +27,20 @@ function _markDismissed(ids) {
}
let _activePollInterval = null;
let _activePollInFlight = false;
let _librarySyncInFlight = false;
let _lastLibrarySyncAt = 0;
const _LIBRARY_SYNC_MIN_MS = 120000;
export function init(apiBase) {
_apiBase = apiBase;
_reconnectActive();
_reconnectActive({ includeLibrary: true, forceLibrary: true });
// Poll for active sessions periodically so research started elsewhere
// (e.g. by the agent via trigger_research) gets adopted into the
// sidebar — _reconnectActive only ran once at load before, so
// agent-started jobs never appeared until a page reload.
if (_activePollInterval) clearInterval(_activePollInterval);
_activePollInterval = setInterval(() => { _reconnectActive(); }, 12000);
_activePollInterval = setInterval(() => { _reconnectActive(); }, 20000);
}
// Allow an immediate adopt when the chat stream signals a new research
@@ -46,7 +50,13 @@ export function adoptSession(sessionId) {
_reconnectActive();
}
async function _reconnectActive() {
export function refreshLibrary(options = {}) {
return _syncLibrary(options);
}
async function _reconnectActive(options = {}) {
if (_activePollInFlight) return;
_activePollInFlight = true;
try {
// Reconnect to running tasks
const res = await fetch(`${_apiBase}/api/research/active`, { credentials: 'same-origin' });
@@ -68,7 +78,20 @@ async function _reconnectActive() {
}
}
// Load recent completed research from disk
if (options.includeLibrary) await _syncLibrary({ force: !!options.forceLibrary });
_notify();
} catch {
} finally {
_activePollInFlight = false;
}
}
async function _syncLibrary(options = {}) {
const now = Date.now();
if (_librarySyncInFlight) return;
if (!options.force && now - _lastLibrarySyncAt < _LIBRARY_SYNC_MIN_MS) return;
_librarySyncInFlight = true;
try {
const libRes = await fetch(`${_apiBase}/api/research/library?sort=recent&limit=20`, { credentials: 'same-origin' });
if (libRes.ok) {
const libData = await libRes.json();
@@ -90,9 +113,12 @@ async function _reconnectActive() {
});
}
}
_lastLibrarySyncAt = Date.now();
_notify();
} catch {}
finally {
_librarySyncInFlight = false;
}
}
function _parseDuration(s) {
+5 -4
View File
@@ -297,6 +297,7 @@ export function openPanel(focusJobId) {
_loadEndpoints().then(_restoreSavedSettings);
_clearBadge();
_updateResearchCount();
jobs.refreshLibrary?.({ force: true });
if ('Notification' in window && Notification.permission === 'default') {
try { Notification.requestPermission(); } catch {}
@@ -370,7 +371,7 @@ function _buildPanelHTML() {
</div>
<p class="memory-desc doclib-desc" style="margin-top:2px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
<span>Multi-step web research with an LLM-in-the-loop agent</span>
<span id="research-no-past-hint" style="display:none;font:inherit;opacity:1;position:static;"> past runs in <button type="button" class="research-library-link" style="background:none;border:none;padding:0;font:inherit;color:var(--accent, var(--red));cursor:pointer;text-decoration:underline;">Library, Research</button></span>
<span id="research-no-past-hint" style="display:none;font:inherit;opacity:1;position:static;">All past research found in: <button type="button" class="research-library-link" style="background:none;border:none;padding:0;font:inherit;color:var(--accent, var(--red));cursor:pointer;text-decoration:underline;">Library, Research</button></span>
</p>
<textarea id="research-query" class="research-query" placeholder="${_pickResearchHint()}" rows="4"></textarea>
<button id="research-settings-toggle" class="research-settings-toggle${chevronCls}">
@@ -669,7 +670,7 @@ function _renderJobs() {
const allJobs = jobs.getJobs();
if (!allJobs.length) {
// No empty-state text in the body — the query box above is the call to
// action. But still surface the "All past research found in Library,
// action. But still surface the "All past research found in: Library,
// Research" hint under the main title, since the Past section won't
// render to host it (this is exactly the case the dynamic hint targets).
container.innerHTML = '';
@@ -718,7 +719,7 @@ function _renderJobs() {
}
// Dynamic Past hint: when the Past section won't render (no past items),
// surface the "All past research found in Library, Research" line under
// surface the "All past research found in: Library, Research" line under
// the main Research title instead, so the link is always discoverable.
const noPastHint = document.getElementById('research-no-past-hint');
if (noPastHint) {
@@ -783,7 +784,7 @@ function _renderJobs() {
if (key === 'past') {
const hint = document.createElement('span');
hint.className = 'research-library-hint';
hint.innerHTML = '<span>Multi-step web research with an LLM-in-the-loop agent</span> <button type="button" class="research-library-link">Library, Research</button>';
hint.innerHTML = '<span>All past research found in:</span> <button type="button" class="research-library-link">Library, Research</button>';
hint.querySelector('.research-library-link').addEventListener('click', (e) => {
e.stopPropagation();
// Close the research panel first so the Library opens ABOVE it on mobile
+319 -51
View File
@@ -16,8 +16,11 @@ let sessions = [];
let currentSessionId = null;
let _sessionNavToken = 0;
let _skipAutoSelect = false;
let _suppressNextSessionLoading = false;
const HISTORY_DISPLAY_CHAR_LIMIT = 160000;
const HISTORY_DISPLAY_TAIL_CHARS = 20000;
const HISTORY_PAGE_LIMIT_MOBILE = 8;
const HISTORY_PAGE_LIMIT_DESKTOP = 24;
const SIDEBAR_MAX_VISIBLE = 10;
const FOLDER_MAX_VISIBLE = 5;
@@ -28,41 +31,42 @@ let _autoCreateInProgress = false; // guard against recursive auto-create
const _INCOGNITO_SESSIONS_KEY = 'ody-incognito-sessions'; // sessionStorage key for incognito session IDs
const _isMac = /Mac|iPhone|iPad/.test(navigator.platform);
const _mod = _isMac ? '⌘' : 'Ctrl';
let _historyPager = null;
function _paintSessionLoading(chatHistory, label = 'Loading chat') {
if (!chatHistory) return;
if (chatRenderer.hideWelcomeScreen) chatRenderer.hideWelcomeScreen();
chatHistory.style.transition = '';
chatHistory.style.opacity = '1';
chatHistory.classList.add('no-animate');
chatHistory.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = 'session-loading-state';
wrap.className = 'session-loading-state session-loading-skeleton';
wrap.setAttribute('role', 'status');
wrap.setAttribute('aria-live', 'polite');
wrap.style.cssText = [
'min-height:100%',
'display:flex',
'align-items:center',
'justify-content:center',
'gap:10px',
'color:var(--muted, var(--fg))',
'opacity:0.72',
'font-size:12px'
].join(';');
wrap.setAttribute('aria-label', label);
const spinner = spinnerModule.createWhirlpool(18);
const text = document.createElement('span');
text.className = 'session-loading-state-label';
text.textContent = label;
wrap.appendChild(spinner.element);
wrap.appendChild(text);
const viewportHeight = chatHistory.clientHeight || window.innerHeight || 720;
const bubbleCount = Math.max(8, Math.min(16, Math.ceil(viewportHeight / 86)));
for (let i = 0; i < bubbleCount; i += 1) {
const bubble = document.createElement('div');
bubble.className = `session-skeleton-bubble ${i % 2 ? 'is-user' : 'is-ai'}`;
const lines = i % 4 === 1 ? 2 : (i % 4 === 3 ? 3 : 4);
for (let j = 0; j < lines; j += 1) {
const line = document.createElement('div');
line.className = 'session-skeleton-line';
line.style.width = `${[72, 92, 58, 82][(i + j) % 4]}%`;
bubble.appendChild(line);
}
wrap.appendChild(bubble);
}
chatHistory.appendChild(wrap);
}
function _updateSessionLoading(chatHistory, label) {
const el = chatHistory?.querySelector('.session-loading-state-label');
if (el) el.textContent = label;
const el = chatHistory?.querySelector('.session-loading-state');
if (el) el.setAttribute('aria-label', label);
}
function _nextPaint() {
@@ -88,6 +92,181 @@ function _displayHistoryContent(content) {
].join('\n');
}
function _historyPageLimit() {
return window.innerWidth <= 768 ? HISTORY_PAGE_LIMIT_MOBILE : HISTORY_PAGE_LIMIT_DESKTOP;
}
function _historyUrl(id, { limit = null, offset = null } = {}) {
const url = new URL(`${API_BASE}/api/history/${id}`);
if (limit != null) url.searchParams.set('limit', String(limit));
if (offset != null) url.searchParams.set('offset', String(offset));
return url.toString();
}
function _renderHistoryMessage(msg, modelName) {
const meta = msg.metadata ? { ...msg.metadata, _fromHistory: true } : null;
let displayContent;
if (typeof msg.content === 'string') {
displayContent = _displayHistoryContent(msg.content);
} else if (Array.isArray(msg.content)) {
displayContent = _displayHistoryContent(msg.content.filter(p => p.type === 'text').map(p => p.text).join('\n').trim());
} else {
displayContent = '';
}
if (msg.role === 'user') {
const trimmed = displayContent.trim();
if (
trimmed === 'Continue where you left off' ||
trimmed.startsWith('Your message was cut off.') ||
trimmed.startsWith('Your previous response was interrupted.') ||
displayContent.includes('[Instruction: Rewrite') ||
displayContent.includes('[Instruction: Explain')
) {
return null;
}
const docEditMatch = displayContent.match(/^In the document, edit this specific text \((lines? [\d-]+)\):\n```\n([\s\S]*?)\n```\n\nInstruction: ([\s\S]*)$/);
if (docEditMatch) {
displayContent = `[Doc edit: ${docEditMatch[1]}] ${docEditMatch[3]}`;
}
}
const box = document.getElementById('chat-history');
if (!box) return null;
if (chatRenderer.hideWelcomeScreen) chatRenderer.hideWelcomeScreen();
const wrap = document.createElement('div');
wrap.className = 'msg ' + (msg.role === 'user' ? 'msg-user' : 'msg-ai');
wrap.dataset.raw = displayContent;
if (meta?._db_id) wrap.dataset.dbId = meta._db_id;
const roleEl = document.createElement('div');
roleEl.className = 'role';
if (msg.role === 'user') {
roleEl.textContent = 'You';
} else {
const pair = chatRenderer.replyModelPair ? chatRenderer.replyModelPair(modelName, meta) : {};
const resolved = pair.actualModel || pair.requestedModel || modelName;
roleEl.textContent = chatRenderer.modelRouteLabel
? chatRenderer.modelRouteLabel(pair.requestedModel, resolved)
: (resolved || 'Odysseus');
if (chatRenderer.applyModelColor) chatRenderer.applyModelColor(roleEl, resolved);
}
const timestamp = meta?.timestamp;
if (timestamp) {
const ts = document.createElement('span');
ts.className = 'msg-time';
try {
ts.textContent = new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch {
ts.textContent = '';
}
roleEl.appendChild(ts);
}
const body = document.createElement('div');
body.className = 'body';
body.innerHTML = markdownModule.processWithThinking(
markdownModule.squashOutsideCode(markdownModule.renderContent(displayContent || ''))
);
if (msg.role === 'user' && Array.isArray(meta?.attachments) && meta.attachments.length) {
const cards = document.createElement('div');
cards.className = 'attach-cards history-attach-cards';
for (const att of meta.attachments) {
const card = document.createElement('div');
card.className = 'attach-card history-attach-card';
const icon = document.createElement('span');
icon.className = 'attach-card-icon';
icon.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>';
const name = document.createElement('span');
name.className = 'attach-card-name';
name.textContent = att.name || 'Image attached';
const size = document.createElement('span');
size.className = 'attach-card-size';
size.textContent = 'image';
card.appendChild(icon);
card.appendChild(name);
card.appendChild(size);
cards.appendChild(card);
}
body.appendChild(cards);
}
wrap.appendChild(roleEl);
wrap.appendChild(body);
box.appendChild(wrap);
return wrap;
}
function _clearHistoryPager() {
const box = document.getElementById('chat-history');
if (_historyPager?.handler && box) {
box.removeEventListener('scroll', _historyPager.handler);
}
_historyPager = null;
}
function _installHistoryPager(id, pageInfo, modelName) {
const box = document.getElementById('chat-history');
_clearHistoryPager();
if (!box || !pageInfo || !pageInfo.has_more_before) return;
_historyPager = {
sessionId: id,
offset: Number(pageInfo.offset || 0),
limit: Number(pageInfo.limit || _historyPageLimit()),
loading: false,
done: false,
modelName,
handler: null,
};
const loadOlder = async () => {
if (!_historyPager || _historyPager.loading || _historyPager.done) return;
if (_historyPager.sessionId !== currentSessionId) return;
if (box.scrollTop > 90) return;
const nextOffset = Math.max(0, _historyPager.offset - _historyPager.limit);
const nextLimit = _historyPager.offset - nextOffset;
if (nextLimit <= 0) {
_historyPager.done = true;
return;
}
_historyPager.loading = true;
const anchor = box.querySelector('.msg, .agent-thread, .gallery-bubble');
const beforeHeight = box.scrollHeight;
try {
const res = await fetch(_historyUrl(_historyPager.sessionId, { limit: nextLimit, offset: nextOffset }));
const data = await res.json();
if (!_historyPager || _historyPager.sessionId !== currentSessionId) return;
const newEls = [];
for (const msg of data.history || []) {
if (msg.role !== 'user' && msg.role !== 'assistant') continue;
const el = _renderHistoryMessage(msg, _historyPager.modelName);
if (el) newEls.push(el);
}
for (const el of newEls) {
box.insertBefore(el, anchor || box.firstChild);
}
_historyPager.offset = Number(data.offset || nextOffset);
_historyPager.done = !data.has_more_before;
if (window.hljs) {
newEls.forEach(el => el.querySelectorAll('pre code:not(.hljs)').forEach(block => window.hljs.highlightElement(block)));
}
const heightDelta = box.scrollHeight - beforeHeight;
box.scrollTop += heightDelta;
} catch (e) {
console.warn('Failed to load older chat history:', e);
} finally {
if (_historyPager) _historyPager.loading = false;
}
};
_historyPager.handler = () => {
if (box.scrollTop <= 90) loadOlder();
};
box.addEventListener('scroll', _historyPager.handler, { passive: true });
}
function _getIncognitoIds() {
try { return JSON.parse(sessionStorage.getItem(_INCOGNITO_SESSIONS_KEY) || '[]'); } catch { return []; }
}
@@ -803,6 +982,58 @@ function createSessionItem(s) {
return div;
}
function _dateBucketLabel(value) {
if (!value) return 'Older';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return 'Older';
const dayStart = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
const today = dayStart(new Date());
const day = dayStart(d);
const diff = Math.round((today - day) / 86400000);
if (diff === 0) return 'Today';
if (diff === 1) return 'Yesterday';
if (diff > 1 && diff < 7) return d.toLocaleDateString([], { weekday: 'long' });
if (diff >= 365) {
const years = Math.floor(diff / 365);
return `${years} ${years === 1 ? 'year' : 'years'} ago`;
}
if (diff >= 180) return '6 months ago';
if (diff >= 30) return `${Math.floor(diff / 30) * 30} days ago`;
const sameYear = d.getFullYear() === new Date().getFullYear();
return d.toLocaleDateString([], sameYear ? { month: 'long', day: 'numeric' } : { month: 'long', day: 'numeric', year: 'numeric' });
}
function _sessionBucketDate(s) {
return s.last_message_at || s.updated_at || s.created_at || '';
}
function _createDateSectionHeader(label, kind = 'session') {
const el = document.createElement('div');
el.className = `date-section-header ${kind}-date-section-header`;
el.textContent = label;
return el;
}
function _appendSessionItemsWithDateHeaders(frag, items) {
let lastLabel = null;
for (const s of items) {
const label = _dateBucketLabel(_sessionBucketDate(s));
if (label !== lastLabel) {
frag.appendChild(_createDateSectionHeader(label, 'session'));
lastLabel = label;
}
frag.appendChild(createSessionItem(s));
}
}
function _appendFavoriteSessionItems(frag, items) {
if (!items.length) return;
frag.appendChild(_createDateSectionHeader('Favorites', 'session'));
for (const s of items) {
frag.appendChild(createSessionItem(s));
}
}
let _renderRAF = null;
export function renderSessionList() {
// Debounce rapid re-renders within the same frame
@@ -861,17 +1092,22 @@ function _renderSessionListImpl() {
}
return 0;
});
// Starred still float to top
const starred = orderedSessions.filter(s => s.is_important);
const rest = orderedSessions.filter(s => !s.is_important);
const allFlat = [...starred, ...rest];
// Favorites are a global pinned block above date buckets, not just
// promoted within the day they belong to.
const allFlat = [
...orderedSessions.filter(s => s.is_important),
...orderedSessions.filter(s => !s.is_important),
];
const limit = _showAllSessions ? allFlat.length : SIDEBAR_MAX_VISIBLE;
const visible = allFlat.slice(0, limit);
const activeIdx = allFlat.findIndex(s => s.id === currentSessionId);
if (!_showAllSessions && activeIdx >= limit) visible.push(allFlat[activeIdx]);
visible.forEach(s => _frag.appendChild(createSessionItem(s)));
const visibleFavorites = visible.filter(s => s.is_important);
const visibleRegular = visible.filter(s => !s.is_important);
_appendFavoriteSessionItems(_frag, visibleFavorites);
_appendSessionItemsWithDateHeaders(_frag, visibleRegular);
if (allFlat.length > SIDEBAR_MAX_VISIBLE) {
const remaining = allFlat.length - SIDEBAR_MAX_VISIBLE;
@@ -1510,8 +1746,12 @@ export async function loadSessions() {
}
}
const suppressSessionLoading = _suppressNextSessionLoading;
_suppressNextSessionLoading = false;
if (targetId && targetId !== currentSessionId) {
await selectSession(targetId, { keepSidebar: true });
const showLoading = !suppressSessionLoading && !(_isFirstLoad && !hashId);
await selectSession(targetId, { keepSidebar: true, showLoading });
} else if (targetId && targetId === currentSessionId) {
// Same session — just refresh the header name in case it was auto-generated
const s = sessions.find(x => x.id === targetId);
@@ -1549,7 +1789,7 @@ export async function loadSessions() {
}
}
export async function selectSession(id, { keepSidebar = false } = {}) {
export async function selectSession(id, { keepSidebar = false, showLoading = true } = {}) {
// Exit compare mode cleanly if active
if (window.compareModule && window.compareModule.isActive()) {
window.compareModule.deactivate(true);
@@ -1558,6 +1798,7 @@ export async function selectSession(id, { keepSidebar = false } = {}) {
try {
const navToken = ++_sessionNavToken;
const prevSessionId = currentSessionId;
_clearHistoryPager();
// Re-archive peeked session when navigating away
_checkPeekCleanup(id);
// Clear any leftover document text selection so it doesn't bleed into the new chat
@@ -1625,7 +1866,15 @@ export async function selectSession(id, { keepSidebar = false } = {}) {
if (window._updateSendBtnIcon) window._updateSendBtnIcon();
}
// On mobile, keep sidebar open — user dismisses it by tapping chat area or swiping
// On mobile manual chat switches, move the drawer away before showing the
// loader so the status sits over the chat pane instead of being hidden by
// the sidebar. Startup auto-restore passes keepSidebar + showLoading=false.
if (showLoading && !keepSidebar && window.innerWidth <= 768) {
const sidebar = document.getElementById('sidebar');
const backdrop = document.getElementById('sidebar-backdrop');
if (sidebar) sidebar.classList.add('hidden');
if (backdrop) backdrop.classList.remove('visible');
}
// Highlight active session in sidebar
document.querySelectorAll('.list-item.active-session').forEach(el => el.classList.remove('active-session'));
@@ -1649,20 +1898,38 @@ export async function selectSession(id, { keepSidebar = false } = {}) {
// declaration had been removed while leaving the references in
// place, producing a ReferenceError every selectSession.)
const isOC = meta && (meta.is_openclaw || id === 'openclaw');
let msgHistory = [], modelName = null;
let msgHistory = [], modelName = null, pageInfo = null;
let paintedLoading = false;
let loadingTimer = null;
let loadingPaintReady = Promise.resolve();
if (!isOC) {
if (chatHistory && prevSessionId !== id) {
if (showLoading && chatHistory && prevSessionId !== id) {
const loadingDelayMs = window.innerWidth <= 768 ? 900 : 500;
loadingTimer = setTimeout(() => {
if (navToken !== _sessionNavToken || currentSessionId !== id) return;
_paintSessionLoading(chatHistory, 'Loading chat');
paintedLoading = true;
await _nextPaint();
if (navToken !== _sessionNavToken || currentSessionId !== id) return;
loadingPaintReady = _nextPaint();
}, loadingDelayMs);
}
const res = await fetch(`${API_BASE}/api/history/${id}`);
const res = await fetch(_historyUrl(id, { limit: _historyPageLimit() }));
const data = await res.json();
if (loadingTimer) {
clearTimeout(loadingTimer);
loadingTimer = null;
}
if (paintedLoading) {
await loadingPaintReady;
}
if (navToken !== _sessionNavToken || currentSessionId !== id) return;
msgHistory = data.history || [];
modelName = data.model || null;
pageInfo = {
offset: data.offset,
limit: data.limit,
total: data.total,
has_more_before: !!data.has_more_before,
};
// The model returned by /api/history is the authoritative one the
// backend will use for this session. Write it back into the cached
// session meta and refresh the picker so the displayed model can
@@ -1721,26 +1988,11 @@ export async function selectSession(id, { keepSidebar = false } = {}) {
'OpenClaw');
} else if (msgHistory.length) {
for (const msg of msgHistory) {
const meta = msg.metadata ? { ...msg.metadata, _fromHistory: true } : null;
let displayContent;
if (typeof msg.content === 'string') {
displayContent = _displayHistoryContent(msg.content);
} else if (Array.isArray(msg.content)) {
// Multimodal (image/audio attachments): extract text parts, skip binary
displayContent = _displayHistoryContent(msg.content.filter(p => p.type === 'text').map(p => p.text).join('\n').trim());
} else {
displayContent = '';
try {
_renderHistoryMessage(msg, modelName);
} catch (e) {
console.warn('Failed to render history message:', e, msg);
}
// Clean up doc selection context for display
if (msg.role === 'user') {
// Hide "Continue where you left off" bubbles
if (displayContent.trim() === 'Continue where you left off' || displayContent.trim().startsWith('Your message was cut off.') || displayContent.trim().startsWith('Your previous response was interrupted.') || displayContent.includes('[Instruction: Rewrite') || displayContent.includes('[Instruction: Explain')) continue;
const docEditMatch = displayContent.match(/^In the document, edit this specific text \((lines? [\d-]+)\):\n```\n([\s\S]*?)\n```\n\nInstruction: ([\s\S]*)$/);
if (docEditMatch) {
displayContent = `[Doc edit: ${docEditMatch[1]}] ${docEditMatch[3]}`;
}
}
window.chatModule.addMessage(msg.role, markdownModule.renderContent(displayContent), modelName, meta);
}
} else {
if (window.chatModule && window.chatModule.showWelcomeScreen) window.chatModule.showWelcomeScreen();
@@ -1748,6 +2000,9 @@ export async function selectSession(id, { keepSidebar = false } = {}) {
document.querySelectorAll('.list-item.active-session').forEach(el => el.classList.remove('active-session'));
}
uiModule.scrollHistoryInstant();
if (!isOC && msgHistory.length) {
_installHistoryPager(id, pageInfo, modelName);
}
// Fade in and re-enable message animations
if (chatHistory) {
@@ -1817,6 +2072,16 @@ export async function selectSession(id, { keepSidebar = false } = {}) {
} catch (error) {
console.error('Error in selectSession:', error);
const chatHistory = uiModule.el('chat-history');
if (chatHistory?.querySelector('.session-loading-state')) {
chatHistory.innerHTML = '';
chatHistory.style.opacity = '1';
chatHistory.classList.remove('no-animate');
const msg = document.createElement('div');
msg.className = 'msg msg-ai';
msg.innerHTML = `<div class="body">Failed to load this chat. ${uiModule.esc ? uiModule.esc(error.message || '') : ''}</div>`;
chatHistory.appendChild(msg);
}
uiModule.showError('Failed to load session: ' + error.message);
} finally {
// Ensure memories are loaded after session selection
@@ -1854,6 +2119,7 @@ export function createDirectChat(url, modelId, endpointId) {
// Don't hit the API — just store the model info and prepare the UI
_pendingChat = { url, modelId, endpointId };
_skipAutoSelect = true;
_suppressNextSessionLoading = true;
currentSessionId = null;
Storage.remove('lastSessionId');
history.replaceState(null, '', window.location.pathname);
@@ -1950,6 +2216,7 @@ export async function materializePendingSession() {
// Reload sidebar to show the new session — await it so the session
// is fully registered before the caller proceeds (prevents race conditions)
_suppressNextSessionLoading = true;
await loadSessions().catch(() => {});
return true;
}
@@ -1986,6 +2253,7 @@ export function setCurrentSessionId(id) {
_sessionNavToken++;
currentSessionId = id;
if (!id) {
_suppressNextSessionLoading = true;
Storage.remove('lastSessionId');
history.replaceState(null, '', window.location.pathname);
document.querySelectorAll('.list-item.active-session, .session-item.active').forEach(el => {
+7
View File
@@ -3135,6 +3135,8 @@ async function initEmailSettings() {
if (el('set-email-smtp-user')) el('set-email-smtp-user').value = cfg.smtp_user || '';
if (el('set-email-smtp-pass')) el('set-email-smtp-pass').value = '';
if (el('set-email-from')) el('set-email-from').value = cfg.from_address || '';
if (el('set-email-auto-translate')) el('set-email-auto-translate').checked = !!cfg.email_auto_translate;
if (el('set-email-translate-language')) el('set-email-translate-language').value = cfg.email_translate_language || 'English';
} catch (_) {}
// Load contacts config
@@ -3165,6 +3167,8 @@ async function initEmailSettings() {
smtp_port: parseInt(el('set-email-smtp-port').value) || 0,
smtp_user: el('set-email-smtp-user').value,
email_from: el('set-email-from').value,
email_auto_translate: !!el('set-email-auto-translate')?.checked,
email_translate_language: (el('set-email-translate-language')?.value || 'English').trim() || 'English',
};
const imapPass = el('set-email-imap-pass').value;
const smtpPass = el('set-email-smtp-pass').value;
@@ -3178,7 +3182,10 @@ async function initEmailSettings() {
});
const result = await res.json();
if (msg) msg.textContent = result.success ? '✓ Saved' : (result.error || 'Failed');
const translateMsg = el('set-email-translate-msg');
if (translateMsg) translateMsg.textContent = result.success ? '✓ Saved' : (result.error || 'Failed');
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
setTimeout(() => { const translateMsg = el('set-email-translate-msg'); if (translateMsg) translateMsg.textContent = ''; }, 3000);
} catch (e) {
if (msg) msg.textContent = 'Failed';
}
+1 -1
View File
@@ -991,7 +991,7 @@ async function _cmdSessionNew(args, ctx) {
if (res.ok) {
const data = await res.json();
await sessionModule.loadSessions();
await sessionModule.selectSession(data.id);
await sessionModule.selectSession(data.id, { showLoading: false });
_hideWelcomeScreen();
const shortModel = (model || '').split('/').pop();
await typewriterReply(`New session — ${shortModel || 'ready'}.`);
+121 -25
View File
@@ -212,6 +212,65 @@ async function _saveUrgentEmailSettings(prompt) {
});
}
const _EMAIL_ACCOUNT_ACTIONS = new Set([
'summarize_emails',
'draft_email_replies',
'extract_email_events',
'check_email_urgency',
]);
let _emailAccounts = null;
async function _fetchEmailAccountsForTasks() {
if (_emailAccounts) return _emailAccounts;
try {
const res = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
const data = await res.json();
_emailAccounts = Array.isArray(data.accounts) ? data.accounts : [];
} catch (e) {
_emailAccounts = [];
}
return _emailAccounts;
}
function _taskPromptConfig(prompt) {
const raw = (prompt || '').trim();
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch (_) {
const cfg = {};
for (const line of raw.split(/\r?\n/)) {
const idx = line.indexOf('=');
if (idx <= 0) continue;
const key = line.slice(0, idx).trim();
const val = line.slice(idx + 1).trim();
if (key) cfg[key] = val;
}
return cfg;
}
}
async function _renderEmailActionOptions(action, existing, extra) {
if (!_EMAIL_ACCOUNT_ACTIONS.has(action)) return;
const accounts = (await _fetchEmailAccountsForTasks()).filter(a => a && a.enabled !== false);
const cfg = _taskPromptConfig(existing?.prompt || '');
const current = String(cfg.account_id || cfg.email_account_id || '');
const options = [
`<option value="" ${current ? '' : 'selected'}>All accounts</option>`,
...accounts.map(a => {
const id = String(a.id || '');
const label = a.name || a.from_address || a.imap_user || id.slice(0, 8);
const suffix = a.is_default ? ' (default)' : '';
return `<option value="${_escHtml(id)}" ${id === current ? 'selected' : ''}>${_escHtml(label + suffix)}</option>`;
}),
].join('');
extra.insertAdjacentHTML('afterbegin', `
<label class="task-form-label">Email account</label>
<select id="task-form-email-account" class="task-form-input">${options}</select>
`);
}
let _triggerEvents = null;
async function _fetchEvents() {
if (_triggerEvents) return _triggerEvents;
@@ -688,9 +747,9 @@ function _renderList() {
const titleRow = document.createElement('div');
titleRow.style.cssText = 'display:flex;align-items:center;gap:6px;cursor:pointer;';
const statusBadge = task.status === 'paused'
? `<span class="task-status-badge task-state-badge task-paused-badge" data-task-status-action="resume" title="Paused - click to resume" style="position:relative;top:4px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><polygon points="7 4 19 12 7 20 7 4"/></svg><span class="task-state-label">paused</span></span>`
? `<span class="task-status-badge task-state-badge task-paused-badge" data-task-status-action="resume" title="Paused - click to resume" style="position:relative;top:4px;"><svg width="10" height="10" 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><span class="task-state-label">paused</span></span>`
: task.status === 'active'
? `<span class="task-status-badge task-state-badge task-active-badge" data-task-status-action="pause" title="Active - click to pause" style="position:relative;top:4px;"><svg width="10" height="10" 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><span class="task-state-label">active</span></span>`
? `<span class="task-status-badge task-state-badge task-active-badge" data-task-status-action="pause" title="Active - click to pause" style="position:relative;top:4px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><polygon points="7 4 19 12 7 20 7 4"/></svg><span class="task-state-label">active</span></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>`
@@ -709,8 +768,8 @@ function _renderList() {
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
const items = [];
// Run now stays in the kebab too (alongside the new Run button on the
// card) for users coming from muscle-memory / mobile long-press.
// Run now stays in the kebab too for users coming from muscle-memory /
// mobile long-press. The expanded card also shows it next to Edit.
if (task.status !== 'completed') items.push({ label: 'Run now', icon: '<polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>', action: () => _doRunNow(task.id) });
items.push({ label: 'Edit', icon: '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>', action: () => _showForm(task) });
if (task.status === 'active') items.push({ label: 'Pause', icon: '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>', action: () => _doPause(task.id) });
@@ -726,17 +785,6 @@ function _renderList() {
_showTaskDropdown(menuBtn, items);
});
actionsWrap.appendChild(menuBtn);
// Run now — promoted out of the kebab onto the card itself for one-click
// manual triggering. Hidden for completed tasks (same gate as before).
if (task.status !== 'completed') {
const runBtn = document.createElement('button');
runBtn.className = 'task-status-badge task-run-now-badge task-card-run-btn';
runBtn.title = 'Run now';
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</span>';
runBtn.addEventListener('click', (e) => { e.stopPropagation(); _doRunNow(task.id); });
actionsWrap.insertBefore(runBtn, menuBtn);
}
titleRow.appendChild(actionsWrap);
// Content area
@@ -767,7 +815,29 @@ function _renderList() {
// Expandable detail (revealed on click) — like the library doc/chat cards:
// extra meta + last-run result + description.
const detail = document.createElement('div');
detail.style.cssText = 'display:none;margin-top:7px;padding:8px 0 2px;border-top:1px solid var(--border);';
detail.style.cssText = 'display:none;margin-top:7px;padding:8px 0 2px;border-top:1px solid var(--border);position:relative;';
const detailActions = document.createElement('div');
detailActions.style.cssText = 'display:flex;justify-content:flex-end;gap:6px;margin-top:7px;';
if (task.status !== 'completed') {
const runBtn = document.createElement('button');
runBtn.className = 'memory-toolbar-btn task-detail-run-btn';
runBtn.title = 'Run now';
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" style="vertical-align:-1px;margin-right:4px;"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Run';
runBtn.addEventListener('click', (e) => {
e.stopPropagation();
_doRunNow(task.id);
});
detailActions.appendChild(runBtn);
}
const editBtn = document.createElement('button');
editBtn.className = 'memory-toolbar-btn task-detail-edit-btn';
editBtn.title = 'Edit task';
editBtn.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" style="vertical-align:-1px;margin-right:4px;"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>Edit';
editBtn.addEventListener('click', (e) => {
e.stopPropagation();
_showForm(task);
});
detailActions.appendChild(editBtn);
const extra = [];
if (task.last_run) extra.push('Last: ' + _relativeTime(task.last_run));
if (task.output_target && task.output_target !== 'session') extra.push('→ ' + task.output_target.replace(/^mcp__/, '').replace(/__/g, ' '));
@@ -803,6 +873,7 @@ function _renderList() {
}
detail.appendChild(desc);
}
detail.appendChild(detailActions);
content.appendChild(detail);
// Select-mode checkbox (mirrors the library's .memory-select-cb).
@@ -898,10 +969,19 @@ function _attachTaskLongPress(card, menuBtn) {
}
function _showTaskDropdown(anchor, items) {
// Remove any existing dropdown
document.querySelectorAll('.task-dropdown').forEach(d => d.remove());
const existing = document.querySelector('.task-dropdown');
if (existing && existing._anchor === anchor) {
if (typeof existing._dismiss === 'function') existing._dismiss();
else existing.remove();
return;
}
document.querySelectorAll('.task-dropdown').forEach(d => {
if (typeof d._dismiss === 'function') d._dismiss();
else d.remove();
});
const dd = document.createElement('div');
dd.className = 'task-dropdown';
dd._anchor = anchor;
dd.style.cssText = 'position:fixed;z-index:100000;background:var(--panel);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3);padding:4px;min-width:120px;';
items.forEach(item => {
const btn = document.createElement('button');
@@ -933,6 +1013,10 @@ function _showTaskDropdown(anchor, items) {
if (performance.now() - openedAt < 250) return;
if (!dd.contains(e.target)) { dd.remove(); document.removeEventListener('click', close); }
};
dd._dismiss = () => {
dd.remove();
document.removeEventListener('click', close);
};
// requestAnimationFrame so the listener is registered AFTER the current
// pointer/click event cycle has finished bubbling.
requestAnimationFrame(() => document.addEventListener('click', close));
@@ -1061,10 +1145,13 @@ function _showForm(existing, initTaskType, initTriggerType) {
<option value="">None</option>
</select>
<label class="task-form-label" style="display:flex;align-items:center;gap:8px;cursor:pointer;">
<input type="checkbox" id="task-form-notif" ${existing && existing.notifications_enabled === false ? '' : 'checked'} style="margin:0;cursor:pointer;">
<label class="task-form-notif-toggle">
<input type="checkbox" id="task-form-notif" ${existing && existing.notifications_enabled === false ? '' : 'checked'}>
<span class="task-form-notif-switch" aria-hidden="true"></span>
<span class="task-form-notif-copy">
<span>Notifications</span>
<span style="opacity:0.55;font-weight:normal;font-size:10px;"> uncheck to silence completion notifications for this task (helpful for chatty cron jobs)</span>
<span>Silence completion alerts for chatty cron jobs.</span>
</span>
</label>
<div class="task-form-actions">
@@ -1114,15 +1201,19 @@ function _showForm(existing, initTaskType, initTriggerType) {
const sel = document.getElementById('task-form-action');
const extra = document.getElementById('task-form-action-extra');
if (!sel || !extra) return;
if (sel.value !== 'check_email_urgency') {
const action = sel.value;
if (!_EMAIL_ACCOUNT_ACTIONS.has(action)) {
extra.innerHTML = '';
return;
}
extra.innerHTML = `
extra.innerHTML = '';
await _renderEmailActionOptions(action, existing, extra);
if (action === 'check_email_urgency') {
extra.insertAdjacentHTML('beforeend', `
<label class="task-form-label">Email triage rules</label>
<textarea id="task-form-urgent-email-prompt" class="task-form-input task-form-textarea" rows="4" placeholder="What should count as urgent? e.g. deadlines, blockers, people waiting outside."></textarea>
<div class="memory-desc" style="font-size:11px;margin-top:4px;">Pause/resume and schedule are controlled by this task. It tags urgent, reply-soon, newsletter, marketing, and spam. Urgent/reply-soon emails use your reminder settings.</div>
`;
<div class="memory-desc" style="font-size:11px;margin-top:4px;">Pause/resume and schedule are controlled by this task. It tags work, personal, urgent, action-needed, finance, legal, travel, newsletter, marketing, spam, and related mail categories. Urgent/reply-soon emails use your reminder settings.</div>
`);
const settings = await _fetchUrgentEmailSettings();
const promptEl = document.getElementById('task-form-urgent-email-prompt');
if (promptEl && !promptEl.dataset.loaded) {
@@ -1131,6 +1222,7 @@ function _showForm(existing, initTaskType, initTriggerType) {
}
const notifEl = document.getElementById('task-form-notif');
if (notifEl && !existing?.id) notifEl.checked = false;
}
};
_fetchActions().then(actions => {
const sel = document.getElementById('task-form-action');
@@ -1469,6 +1561,10 @@ function _showForm(existing, initTaskType, initTriggerType) {
return;
}
payload.action = action;
if (_EMAIL_ACCOUNT_ACTIONS.has(action)) {
const accountId = document.getElementById('task-form-email-account')?.value || '';
payload.prompt = accountId ? JSON.stringify({ account_id: accountId }) : '';
}
if (action === 'check_email_urgency') {
const urgentPrompt = document.getElementById('task-form-urgent-email-prompt')?.value || '';
try {
-19
View File
@@ -373,25 +373,6 @@ export function showToast(msg, durationOrOpts) {
toastEl.appendChild(stack);
// Small × to dismiss the toast without taking the action. Useful when
// the user already acted (or just doesn't want the banner sitting there).
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.setAttribute('aria-label', 'Dismiss');
closeBtn.title = 'Dismiss';
closeBtn.textContent = '×';
closeBtn.style.cssText = 'margin-left:8px;padding:0;width:20px;height:20px;line-height:1;border:none;background:none;color:var(--fg);opacity:0.55;cursor:pointer;font-size:18px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;pointer-events:auto;';
closeBtn.addEventListener('mouseenter', () => { closeBtn.style.opacity = '1'; });
closeBtn.addEventListener('mouseleave', () => { closeBtn.style.opacity = '0.55'; });
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
clearTimeout(toastEl._hideTimer);
toastEl.classList.add('exiting');
toastEl.classList.remove('show');
});
toastEl.appendChild(closeBtn);
toastEl.style.pointerEvents = 'auto';
} else {
// No action — restore the default non-blocking behavior.
+1198 -172
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
from pathlib import Path
def test_active_email_reader_blocks_immediate_reply_tools():
source = Path("routes/chat_routes.py").read_text(encoding="utf-8")
guard_start = source.index("if active_email_ctx and active_email_ctx.get(\"uid\"):")
guard_block = source[guard_start:source.index("# Enforce per-user privileges", guard_start)]
assert '"reply_to_email"' in guard_block
assert '"mcp__email__reply_to_email"' in guard_block
assert '"send_email"' in guard_block
assert '"mcp__email__send_email"' in guard_block
assert '"create_document"' in guard_block
+87 -2
View File
@@ -184,6 +184,87 @@ def test_sender_signature_cache_is_owner_scoped_and_migrates_legacy_rows(tmp_pat
conn.close()
def test_email_message_index_is_owner_account_folder_scoped(tmp_path, monkeypatch):
import routes.email_helpers as email_helpers
db_path = tmp_path / "scheduled_emails.db"
monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path)
email_helpers._init_scheduled_db()
conn = sqlite3.connect(db_path)
try:
info = conn.execute("PRAGMA table_info(email_message_index)").fetchall()
pk_cols = [r[1] for r in sorted((r for r in info if r[5]), key=lambda r: r[5])]
assert pk_cols == ["owner", "account_key", "folder", "uid"]
conn.execute(
"""
INSERT INTO email_message_index
(owner, account_key, folder, uid, message_id, subject, updated_at)
VALUES ('alice', 'acct-a', 'INBOX', '7', '<same@example.com>', 'Alice', '2026-01-01')
"""
)
conn.execute(
"""
INSERT INTO email_message_index
(owner, account_key, folder, uid, message_id, subject, updated_at)
VALUES ('bob', 'acct-a', 'INBOX', '7', '<same@example.com>', 'Bob', '2026-01-01')
"""
)
rows = conn.execute(
"SELECT owner, subject FROM email_message_index WHERE account_key='acct-a' AND folder='INBOX' AND uid='7' ORDER BY owner"
).fetchall()
assert rows == [("alice", "Alice"), ("bob", "Bob")]
finally:
conn.close()
def test_email_index_helpers_roundtrip_and_update_flags(tmp_path, monkeypatch):
import routes.email_helpers as email_helpers
import routes.email_routes as email_routes
db_path = tmp_path / "scheduled_emails.db"
monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path)
monkeypatch.setattr(email_routes, "SCHEDULED_DB", db_path)
email_helpers._init_scheduled_db()
email_routes._email_index_upsert(
"alice",
"acct-a",
"INBOX",
[{
"uid": "11",
"message_id": "<m@example.com>",
"subject": "Cached",
"from_name": "Sender",
"from_address": "sender@example.com",
"to": "alice@example.com",
"cc": "",
"date": "2026-01-01T00:00:00+00:00",
"date_display": "Thu, 1 Jan 2026 00:00:00 +0000",
"date_epoch": 1767225600,
"size": 123,
"flags": "\\Seen",
"has_attachments": True,
}],
)
rows = email_routes._email_index_rows("alice", "acct-a", "INBOX", ["11", "12"])
assert rows["11"]["subject"] == "Cached"
assert rows["11"]["is_read"] is True
assert rows["11"]["has_attachments"] is True
email_routes._email_index_update_flags("alice", "acct-a", "INBOX", "11", "\\Seen", False)
email_routes._email_index_update_flags("alice", "acct-a", "INBOX", "11", "\\Flagged", True)
rows = email_routes._email_index_rows("alice", "acct-a", "INBOX", ["11"])
assert rows["11"]["is_read"] is False
assert rows["11"]["is_flagged"] is True
email_routes._email_index_delete("alice", "acct-a", "INBOX", "11")
assert email_routes._email_index_rows("alice", "acct-a", "INBOX", ["11"]) == {}
@pytest.mark.asyncio
async def test_ai_reply_cache_lookup_is_owner_scoped(tmp_path, monkeypatch):
import routes.email_helpers as email_helpers
@@ -280,8 +361,12 @@ async def test_sender_signature_read_lookup_is_owner_scoped(tmp_path, monkeypatc
def uid(self, command, _uid, query):
assert command == "FETCH"
assert query == "(BODY.PEEK[])"
return "OK", [(b"1 (UID 1 BODY[])", raw)]
assert query.startswith("(BODY.PEEK[HEADER] BODY.PEEK[TEXT]<0.")
header, body = raw.split(b"\r\n\r\n", 1)
return "OK", [
(b"1 (UID 1 BODY[HEADER])", header + b"\r\n\r\n"),
(b"1 (UID 1 BODY[TEXT]<0>)", body),
]
@contextmanager
def fake_imap(_account_id=None, owner=""):
@@ -82,3 +82,14 @@ def test_suggest_document_skips_non_object_suggestion_items():
assert block.content == (
"<<<FIND>>>\nold\n<<<SUGGEST>>>\nnew\n<<<REASON>>>\nclearer\n<<<END>>>"
)
def test_ui_control_open_email_reply_preserves_structured_body():
block = function_call_to_tool_block(
"ui_control",
'{"action":"open_email_reply","uid":"3228","folder":"INBOX","mode":"reply","body":"Hi Andy,\\n\\nNo thank you.\\n\\nBest,"}',
)
assert block is not None
assert block.tool_type == "ui_control"
assert block.content == "open_email_reply 3228 INBOX reply Hi Andy,\n\nNo thank you.\n\nBest,"
+22
View File
@@ -0,0 +1,22 @@
from src.agent_tools.document_tools import _strip_pdf_editor_markers
def test_pdf_ai_edit_derivative_strips_pdf_plumbing():
raw = """<!-- pdf_source upload_id="0123456789abcdef0123456789abcdef.pdf" -->
# Contract
- **Name:** Felix <!-- field=Name type=text -->
- Hello world <!-- annotation id=a1 page=1 x=1 y=2 w=3 h=4 kind=text -->
"""
assert _strip_pdf_editor_markers(raw) == "# Contract\n\n- **Name:** Felix\n- Hello world"
def test_pdf_ai_edit_derivative_strips_form_source_marker():
raw = """<!-- pdf_form_source upload_id="0123456789abcdef0123456789abcdef.pdf" fields="12" -->
# Form
"""
assert _strip_pdf_editor_markers(raw) == "# Form"