mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-08 11:56:59 +00:00
Polish mobile UI and editor workflows
This commit is contained in:
@@ -1001,17 +1001,21 @@ async def _startup_event():
|
||||
|
||||
_startup_tasks.append(asyncio.create_task(_warmup_endpoints()))
|
||||
|
||||
# Keep-alive: ping endpoints every 60 seconds to prevent cold starts
|
||||
async def _keepalive_loop():
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(60)
|
||||
await _warmup_endpoints()
|
||||
except Exception as e:
|
||||
logger.warning(f"Keepalive loop error: {e}")
|
||||
await asyncio.sleep(300) # Back off on error
|
||||
# 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:
|
||||
await asyncio.sleep(60)
|
||||
await _warmup_endpoints()
|
||||
except Exception as e:
|
||||
logger.warning(f"Keepalive loop error: {e}")
|
||||
await asyncio.sleep(300) # Back off on error
|
||||
|
||||
_startup_tasks.append(asyncio.create_task(_keepalive_loop()))
|
||||
_startup_tasks.append(asyncio.create_task(_keepalive_loop()))
|
||||
|
||||
async def _ensure_default_tasks():
|
||||
# Create/reconcile default automation tasks + personal assistant for every user.
|
||||
|
||||
+163
-3
@@ -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.")]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
+189
-142
@@ -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,48 +456,30 @@ 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": [
|
||||
{"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
|
||||
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>>>."},
|
||||
],
|
||||
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 ""
|
||||
if summary:
|
||||
_c = _sql3.connect(SCHEDULED_DB)
|
||||
_c.execute("""
|
||||
INSERT OR REPLACE INTO email_summaries
|
||||
(message_id, owner, uid, folder, subject, sender, summary, model_used, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (message_id, account_owner or "", uid.decode() if isinstance(uid, bytes) else str(uid), _folder, subject, sender, summary, model, datetime.utcnow().isoformat()))
|
||||
_c.commit()
|
||||
_c.close()
|
||||
_sum_existing.add(message_id)
|
||||
_summaries_created += 1
|
||||
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||
_detail_lines.append(f"summary · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
|
||||
summary = _extract_reply((summary or "").strip())
|
||||
if summary:
|
||||
_c = _sql3.connect(SCHEDULED_DB)
|
||||
_c.execute("""
|
||||
INSERT OR REPLACE INTO email_summaries
|
||||
(message_id, owner, uid, folder, subject, sender, summary, model_used, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (message_id, account_owner or "", uid.decode() if isinstance(uid, bytes) else str(uid), _folder, subject, sender, summary, model, datetime.utcnow().isoformat()))
|
||||
_c.commit()
|
||||
_c.close()
|
||||
_sum_existing.add(message_id)
|
||||
_summaries_created += 1
|
||||
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||
_detail_lines.append(f"summary · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
|
||||
except Exception as e:
|
||||
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||
_detail_lines.append(f"summary failed · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
|
||||
@@ -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,26 +722,34 @@ 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:
|
||||
_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, account_owner or "", uid.decode() if isinstance(uid, bytes) else str(uid),
|
||||
_cal_run_count, datetime.utcnow().isoformat())
|
||||
)
|
||||
_cc.commit()
|
||||
_cc.close()
|
||||
_cal_existing.add(message_id)
|
||||
if _cal_parse_ok:
|
||||
_cc = _sql3.connect(SCHEDULED_DB)
|
||||
_cc.execute(
|
||||
"INSERT OR REPLACE INTO email_calendar_extractions "
|
||||
"(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),
|
||||
json.dumps(_cal_event_uids), _cal_run_count, datetime.utcnow().isoformat())
|
||||
)
|
||||
_cc.commit()
|
||||
_cc.close()
|
||||
_cal_existing.add(message_id)
|
||||
except Exception as ce:
|
||||
logger.debug(f"Could not cache calendar extraction: {ce}")
|
||||
|
||||
@@ -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,70 +911,55 @@ 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 = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw_out, flags=re.MULTILINE).strip()
|
||||
jm = re.search(r'\{.*\}', raw_out, re.DOTALL)
|
||||
parsed = None
|
||||
if jm:
|
||||
try:
|
||||
parsed = json.loads(jm.group(0))
|
||||
except Exception:
|
||||
parsed = None
|
||||
if parsed is not None:
|
||||
_ALLOWED_TAGS = {"work","personal","finance","bills","receipt","travel",
|
||||
"newsletter","marketing","notification","security","social",
|
||||
"shopping","calendar"}
|
||||
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]
|
||||
is_spam = bool(parsed.get("spam"))
|
||||
spam_reason = str(parsed.get("reason") or "")[:200]
|
||||
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
|
||||
if jm:
|
||||
try:
|
||||
parsed = json.loads(jm.group(0))
|
||||
except Exception:
|
||||
parsed = None
|
||||
if parsed is not None:
|
||||
_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][:3]
|
||||
is_spam = bool(parsed.get("spam"))
|
||||
spam_reason = str(parsed.get("reason") or "")[:200]
|
||||
|
||||
moved_to = ""
|
||||
if is_spam and auto_spam and spam_folder:
|
||||
if _imap_move(uid, spam_folder, account_id=account_id, owner=account_owner):
|
||||
moved_to = spam_folder
|
||||
logger.info(f"Auto-spam moved uid={uid.decode() if isinstance(uid, bytes) else str(uid)} to {spam_folder}: {spam_reason}")
|
||||
moved_to = ""
|
||||
if is_spam and auto_spam and spam_folder:
|
||||
if _imap_move(uid, spam_folder, account_id=account_id, owner=account_owner):
|
||||
moved_to = spam_folder
|
||||
logger.info(f"Auto-spam moved uid={uid.decode() if isinstance(uid, bytes) else str(uid)} to {spam_folder}: {spam_reason}")
|
||||
|
||||
_c = _sql3.connect(SCHEDULED_DB)
|
||||
_c.execute("""
|
||||
INSERT OR REPLACE INTO email_tags
|
||||
(message_id, owner, 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,
|
||||
json.dumps(tags), 1 if is_spam else 0,
|
||||
spam_reason, moved_to, model, datetime.utcnow().isoformat()))
|
||||
_c.commit()
|
||||
_c.close()
|
||||
_tag_existing.add(message_id)
|
||||
_c = _sql3.connect(SCHEDULED_DB)
|
||||
_c.execute("""
|
||||
INSERT OR REPLACE INTO email_tags
|
||||
(message_id, owner, account_id, uid, folder, subject, sender, tags, spam_verdict,
|
||||
spam_reason, moved_to, model_used, created_at)
|
||||
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()
|
||||
_c.close()
|
||||
_tag_existing.add(message_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Auto-classify {uid} failed: {e}")
|
||||
|
||||
|
||||
+1280
-169
File diff suppressed because it is too large
Load Diff
+123
-17
@@ -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
@@ -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
@@ -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: shell→bash, search→web, deepresearch→research, documents→document_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/document→documents, images→gallery, mail/inbox/emails→email, chats/history→sessions, memory/memories→brain, preferences→settings, models/serve/serving→cookbook. 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 (
|
||||
|
||||
@@ -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()),
|
||||
@@ -641,4 +724,4 @@ class ManageDocumentTool:
|
||||
logger.error(f"manage_documents error: {e}")
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
finally:
|
||||
db.close()
|
||||
db.close()
|
||||
|
||||
@@ -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
@@ -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}'."}
|
||||
|
||||
+311
-52
@@ -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
|
||||
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,
|
||||
),
|
||||
timeout=300,
|
||||
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:
|
||||
result = await _aio.wait_for(
|
||||
_run_auto_summarize_once(
|
||||
do_summary=False,
|
||||
do_reply=False,
|
||||
do_calendar=True,
|
||||
days_back=days_back,
|
||||
account_id=account_id,
|
||||
max_process=max_process,
|
||||
),
|
||||
timeout=timeout,
|
||||
)
|
||||
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:
|
||||
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"
|
||||
)
|
||||
if not _result_has_work(result):
|
||||
raise TaskNoop(f"email→calendar: {result or 'no new emails'}")
|
||||
return f"{result} (3d window)", True
|
||||
except _aio.TimeoutError:
|
||||
return "Email→calendar pass exceeded 5 min budget — try fewer emails or a faster model", False
|
||||
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"]
|
||||
all_unread_keys.add(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
|
||||
|
||||
@@ -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']}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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":
|
||||
|
||||
+49
-7
@@ -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'); });
|
||||
const _ab = el('mode-agent-btn'), _cb = el('mode-chat-btn');
|
||||
if (_ab) _ab.classList.remove('active');
|
||||
if (_cb) _cb.classList.add('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');
|
||||
_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.mode = 'chat';
|
||||
Storage.setJSON(Storage.KEYS.TOGGLES, ts);
|
||||
}
|
||||
const ts = Storage.getJSON(Storage.KEYS.TOGGLES, {});
|
||||
ts.research = false; ts.mode = 'chat';
|
||||
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 (_) {}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 Odysseus’s 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 Odysseus’s 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
@@ -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 temp→real. */
|
||||
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
@@ -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 › 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);
|
||||
|
||||
+1186
-227
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
+321
-53
@@ -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) {
|
||||
_paintSessionLoading(chatHistory, 'Loading chat');
|
||||
paintedLoading = true;
|
||||
await _nextPaint();
|
||||
if (navToken !== _sessionNavToken || currentSessionId !== id) return;
|
||||
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;
|
||||
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 => {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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'}.`);
|
||||
|
||||
+131
-35
@@ -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;">
|
||||
<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>
|
||||
<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>Silence completion alerts for chatty cron jobs.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="task-form-actions">
|
||||
@@ -1114,23 +1201,28 @@ 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 = `
|
||||
<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>
|
||||
`;
|
||||
const settings = await _fetchUrgentEmailSettings();
|
||||
const promptEl = document.getElementById('task-form-urgent-email-prompt');
|
||||
if (promptEl && !promptEl.dataset.loaded) {
|
||||
promptEl.value = settings.urgent_email_prompt || '';
|
||||
promptEl.dataset.loaded = '1';
|
||||
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 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) {
|
||||
promptEl.value = settings.urgent_email_prompt || '';
|
||||
promptEl.dataset.loaded = '1';
|
||||
}
|
||||
const notifEl = document.getElementById('task-form-notif');
|
||||
if (notifEl && !existing?.id) notifEl.checked = false;
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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,"
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user