mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-14 12:48:03 +00:00
Polish mobile UI and editor workflows
This commit is contained in:
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user