Merge verified Odysseus fixes

This commit is contained in:
pewdiepie-archdaemon
2026-07-23 14:49:02 +00:00
parent 4c9a8ca115
commit d8a2059df8
117 changed files with 15693 additions and 3191 deletions
+68 -17
View File
@@ -5,6 +5,7 @@ import json
import logging
import os
import re
import time
from dataclasses import dataclass, field
from typing import Any, Optional
@@ -56,6 +57,9 @@ def _is_casual_low_signal(text: str) -> bool:
# the background work (extraction, auto-naming) silently never runs.
# Mirrors WebhookManager._spawn_tracked from src/webhook_manager.py.
_BG_TASKS: set[asyncio.Task] = set()
_INCOGNITO_CONTEXTS: dict[str, dict[str, Any]] = {}
_INCOGNITO_CONTEXT_TTL_SECONDS = 6 * 60 * 60
_INCOGNITO_CONTEXT_MAX_MESSAGES = 80
def _spawn_bg(coro) -> asyncio.Task:
@@ -66,6 +70,40 @@ def _spawn_bg(coro) -> asyncio.Task:
return task
def _prune_incognito_contexts(now: float | None = None):
now = now or time.time()
stale = [
sid for sid, bundle in _INCOGNITO_CONTEXTS.items()
if now - float(bundle.get("updated_at") or 0) > _INCOGNITO_CONTEXT_TTL_SECONDS
]
for sid in stale:
_INCOGNITO_CONTEXTS.pop(sid, None)
def _incognito_messages(session_id: str) -> list[dict[str, Any]]:
_prune_incognito_contexts()
bundle = _INCOGNITO_CONTEXTS.get(str(session_id or ""))
if not bundle:
return []
return [dict(m) for m in bundle.get("messages", []) if isinstance(m, dict)]
def _append_incognito_message(session_id: str, role: str, content: Any, metadata: dict | None = None):
sid = str(session_id or "").strip()
if not sid:
return
_prune_incognito_contexts()
bundle = _INCOGNITO_CONTEXTS.setdefault(sid, {"messages": [], "updated_at": time.time()})
msg: dict[str, Any] = {"role": role, "content": content}
if metadata:
msg["metadata"] = dict(metadata)
messages = bundle.setdefault("messages", [])
messages.append(msg)
if len(messages) > _INCOGNITO_CONTEXT_MAX_MESSAGES:
del messages[:-_INCOGNITO_CONTEXT_MAX_MESSAGES]
bundle["updated_at"] = time.time()
# ── Data containers ────────────────────────────────────────────────────── #
@dataclass
@@ -434,12 +472,13 @@ def build_uploaded_file_manifest(att_ids: list, upload_handler, owner: Optional[
def add_user_message(sess, chat_handler, preprocessed: PreprocessedMessage, incognito: bool = False):
"""Add user message to session history and update session name.
In incognito mode, still add to in-memory history (for conversation context)
but skip session name update (which would persist)."""
Incognito messages must not mutate persistent session history, even in
memory, because a later normal turn can persist the same session object."""
if incognito:
return
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
sess.add_message(ChatMessage("user", preprocessed.user_content, metadata=user_meta))
if not incognito:
chat_handler.update_session_name_if_needed(sess, preprocessed.text_for_context)
chat_handler.update_session_name_if_needed(sess, preprocessed.text_for_context)
def fire_message_event(request, webhook_manager, session_id: str, sess, message: str, compare_mode: bool = False):
@@ -668,8 +707,14 @@ async def build_chat_context(
allow_tool_preprocessing=allow_tool_preprocessing,
)
# Add user message to history
add_user_message(sess, chat_handler, preprocessed, incognito=incognito)
# Add user message to history. Nobody/incognito uses a request-local
# transcript store instead of session history so stale saved chats cannot
# bleed into context and the turn is not persisted.
if incognito:
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
_append_incognito_message(session_id, "user", preprocessed.user_content, user_meta)
else:
add_user_message(sess, chat_handler, preprocessed, incognito=False)
# Fire events
if not incognito:
@@ -760,8 +805,10 @@ async def build_chat_context(
if norm:
sess.model = norm
# Build messages
messages = preface + sess.get_context_messages()
# Build messages. In Nobody/incognito mode, never read saved session
# history: the session id may be a temporary wrapper or, in buggy clients, a
# stale normal session id. Only the ephemeral incognito transcript is safe.
messages = preface + (_incognito_messages(session_id) if incognito else sess.get_context_messages())
# Current date/time — injected as a standalone *user*-role context message
# placed immediately before the latest user turn, NOT folded into the
@@ -1027,7 +1074,12 @@ def save_assistant_response(
tool_events: list = None,
incognito: bool = False,
):
"""Add assistant response to session history. In incognito mode, keeps in-memory context but skips DB persistence."""
"""Add assistant response to session history.
Incognito responses are intentionally not added to the session object. The
session may later be saved by a normal turn, so "in-memory only" is not
private enough.
"""
md = dict(last_metrics) if last_metrics else {}
def _model_value(value) -> str:
if value is None:
@@ -1067,19 +1119,18 @@ def save_assistant_response(
_content = _think_info["reply"]
else:
_content = full_response
if incognito:
_append_incognito_message(session_id, "assistant", _content, md)
return None
sess.add_message(ChatMessage("assistant", _content, metadata=md))
if not incognito:
from core.database import update_session_last_accessed
update_session_last_accessed(session_id)
session_manager.save_sessions()
from core.database import update_session_last_accessed
update_session_last_accessed(session_id)
session_manager.save_sessions()
# Return the persisted message's DB id so the stream can wire it onto the
# freshly-rendered bubble — lets the user edit/delete a just-streamed reply
# without reloading. Incognito returns None: those messages are ephemeral,
# so we don't hand out an edit/delete handle for them.
if incognito:
return None
# without reloading.
try:
_last = sess.history[-1]
_meta = getattr(_last, "metadata", None)
+311 -25
View File
@@ -42,6 +42,7 @@ from routes.chat_helpers import (
_enforce_chat_privileges,
)
from src.action_intents import ToolIntent, classify_tool_intent as _classify_tool_intent
from src.image_model_ids import looks_like_image_generation_model
from src.tool_policy import (
WEB_TOOL_NAMES,
build_effective_tool_policy,
@@ -53,7 +54,6 @@ logger = logging.getLogger(__name__)
# Track active streams for partial-save safety net
_active_streams: Dict[str, dict] = {}
_IMAGE_MODEL_PREFIXES = ("gpt-image", "dall-e", "chatgpt-image")
def _stream_set(session_id: str, **fields) -> None:
@@ -111,7 +111,8 @@ def _ensure_current_request_is_latest_user(messages: List[Dict[str, Any]], curre
_WEB_FOLLOWUP_RE = re.compile(
r"^\s*(?:(?:can|could|would|will)\s+you\s+)?"
r"(?:check|try\s+again|look(?:\s+now|\s+it\s+up)?|search(?:\s+now|\s+online|\s+it)?|"
r"do\s+it|again)\??\s*$",
r"do\s+it|again|approved|approve(?:d)?|yes|ok(?:ay)?|proceed|go\s+ahead|"
r"send(?:\s+it)?|submit(?:\s+it)?|email(?:\s+them|\s+it)?)\??\s*$",
re.I,
)
_RECENT_WEB_CONTEXT_RE = re.compile(
@@ -119,6 +120,26 @@ _RECENT_WEB_CONTEXT_RE = re.compile(
r"price|current|latest|search|look\s+up|online)\b",
re.I,
)
_RECENT_BROWSER_CONTEXT_RE = re.compile(
r"\b(?:browser|browse|open\s+(?:the\s+)?(?:site|page|url|link)|click|"
r"fill(?:\s+out)?|submit|send\s+(?:the\s+)?form|contact\s+form|web\s*form|"
r"form\s+submission|playwright|automation)\b",
re.I,
)
_BROWSER_MCP_TOOLS = {
"mcp__builtin_browser__browser_navigate",
"mcp__builtin_browser__browser_snapshot",
"mcp__builtin_browser__browser_click",
"mcp__builtin_browser__browser_type",
"mcp__builtin_browser__browser_fill_form",
"mcp__builtin_browser__browser_select_option",
"mcp__builtin_browser__browser_press_key",
"mcp__builtin_browser__browser_wait_for",
"mcp__builtin_browser__browser_take_screenshot",
"mcp__builtin_browser__browser_drag",
"mcp__builtin_browser__browser_navigate_back",
"mcp__builtin_browser__browser_close",
}
def _recent_session_text(sess, limit: int = 8, max_chars: int = 2000) -> str:
@@ -141,6 +162,13 @@ def _is_contextual_web_followup(message: str, sess) -> bool:
return bool(_RECENT_WEB_CONTEXT_RE.search(_recent_session_text(sess)))
def _is_contextual_browser_followup(message: str, sess) -> bool:
"""Treat short retry replies as browser tasks when recent context was forms/browser automation."""
if not message or not _WEB_FOLLOWUP_RE.search(message):
return False
return bool(_RECENT_BROWSER_CONTEXT_RE.search(_recent_session_text(sess, limit=12, max_chars=4000)))
def _resolve_request_workspace(request, raw_value) -> tuple:
"""Resolve the posted workspace for this request: (workspace, rejected).
@@ -168,6 +196,46 @@ def _resolve_request_workspace(request, raw_value) -> tuple:
return workspace, (requested if not workspace else "")
_ABS_PATH_RE = re.compile(r"(?<!\S)(~?/[^\"'\s`<>]+)")
_LOCAL_FILE_TASK_RE = re.compile(
r"\b(?:file|folder|directory|path|workspace|repo|project|movie|video|"
r"subtitle|subtitles|srt|vtt|ass|download|save|rename|move|copy|extract|"
r"convert|ffmpeg|run|execute|open|read|inspect|fix|debug|test|build)\b",
re.IGNORECASE,
)
def _resolve_workspace_from_message_path(request, message: str) -> tuple[str, str]:
"""Auto-bind a workspace only when the user names an explicit safe path.
This is intentionally deterministic rather than LLM/RAG-driven: RAG can
choose the tool family, but filesystem binding must not let a prompt infer
or probe arbitrary host paths. For a file path, bind its parent directory.
For a directory path, bind that directory.
"""
text = str(message or "")
if not text or not _LOCAL_FILE_TASK_RE.search(text):
return "", ""
from src.tool_security import owner_is_admin_or_single_user
if not owner_is_admin_or_single_user(get_current_user(request)):
return "", ""
from src.tool_execution import vet_workspace
for match in _ABS_PATH_RE.finditer(text):
raw = match.group(1).rstrip(".,;:)]}")
expanded = os.path.realpath(os.path.expanduser(raw))
candidates = [expanded]
if os.path.isfile(expanded):
candidates.insert(0, os.path.dirname(expanded))
for candidate in candidates:
workspace = vet_workspace(candidate) or ""
if workspace:
return workspace, ""
return "", ""
def _session_url_matches_endpoint(session_url: str, endpoint_base: str) -> bool:
if not session_url or not endpoint_base:
return False
@@ -243,7 +311,7 @@ def _is_image_generation_session(sess, owner: str | None = None) -> bool:
models into the image-generation path.
"""
model = (getattr(sess, "model", "") or "").strip()
if any(model.lower().startswith(prefix) for prefix in _IMAGE_MODEL_PREFIXES):
if looks_like_image_generation_model(model):
return True
endpoint_url = (getattr(sess, "endpoint_url", "") or "").strip()
@@ -271,6 +339,29 @@ def _is_image_generation_session(sess, owner: str | None = None) -> bool:
return False
def _first_image_attachment(chat_handler, att_ids: List[str], owner: str | None = None) -> Optional[Dict[str, Any]]:
"""Return the first attached image file that this owner can read."""
upload_handler = getattr(chat_handler, "upload_handler", None)
if not upload_handler:
return None
for att_id in att_ids or []:
try:
info = upload_handler.resolve_upload(att_id, owner=owner)
except Exception as e:
logger.warning("Failed to resolve image edit upload %s", att_id, exc_info=e)
continue
if not info:
continue
name = info.get("name") or info.get("original_name") or info.get("id") or ""
mime = info.get("mime", "")
try:
if upload_handler.is_image_file(name, mime):
return info
except Exception:
continue
return None
def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool:
"""Re-populate sess.model from the matching endpoint's cached models.
@@ -381,9 +472,85 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
except Exception as e:
db.rollback()
logger.warning("Failed to recover empty session model for %s: %s", session_id, e)
return False
def _reconcile_selected_route_from_request(
request: Request,
sess,
session_id: str,
form_data,
owner: str | None = None,
) -> bool:
"""Apply the model route the browser selected before streaming.
The frontend creates a pending chat first and only materializes it on first
send. Startup/default-model refreshes can race with that UI state, so the
stream request includes the route that was selected at click/send time.
Trust only registered endpoint ids, or the session's existing endpoint URL.
"""
selected_model = str(form_data.get("selected_model") or "").strip()
selected_endpoint_id = str(form_data.get("selected_endpoint_id") or "").strip()
selected_endpoint_url = str(form_data.get("selected_endpoint_url") or "").strip()
if not selected_model:
return False
endpoint_url = ""
headers = None
if selected_endpoint_id or selected_endpoint_url:
try:
from src.auth_helpers import owner_filter
from src.endpoint_resolver import build_headers, normalize_base
db = SessionLocal()
try:
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
if selected_endpoint_id:
q = q.filter(ModelEndpoint.id == selected_endpoint_id)
if owner:
q = owner_filter(q, ModelEndpoint, owner)
candidates = q.all() if selected_endpoint_url and not selected_endpoint_id else [q.first()]
ep = None
for cand in candidates:
if not cand:
continue
if selected_endpoint_id or _session_url_matches_endpoint(selected_endpoint_url, cand.base_url or ""):
ep = cand
break
if not ep:
return False
endpoint_url = build_chat_url(normalize_base(ep.base_url or ""))
headers = build_headers(ep.api_key or "", ep.base_url or "") if ep.api_key else {}
finally:
db.close()
except Exception as e:
logger.warning("Failed to resolve selected endpoint %s/%s for %s: %s", selected_endpoint_id, selected_endpoint_url, session_id, e)
return False
if not endpoint_url:
return False
if (
selected_model == (getattr(sess, "model", "") or "")
and endpoint_url == (getattr(sess, "endpoint_url", "") or "")
):
return False
sess.model = selected_model
sess.endpoint_url = endpoint_url
sess.headers = headers or {}
db = SessionLocal()
try:
db_session = db.query(DBSession).filter(DBSession.id == session_id).first()
if db_session:
db_session.model = selected_model
db_session.endpoint_url = endpoint_url
db_session.headers = sess.headers or {}
db_session.updated_at = datetime.utcnow()
db.commit()
finally:
db.close()
logger.info("Reconciled selected route for %s: model=%r endpoint=%s", session_id, selected_model, redact_url(endpoint_url))
return True
def _set_user_time_from_request(request: Request) -> None:
@@ -565,9 +732,7 @@ def setup_chat_routes(
search_context = form_data.get("search_context") # pre-fetched web search results (compare mode)
compare_mode = str(form_data.get("compare_mode", "")).lower() == "true"
incognito = str(form_data.get("incognito", "")).lower() == "true"
# Plan mode is not part of the merge-ready UI. Ignore stale clients or
# manual form posts that still send plan_mode=true.
plan_mode = False
plan_mode = str(form_data.get("plan_mode") or (body or {}).get("plan_mode") or "").lower() == "true"
chat_mode = str(form_data.get("mode", "")).lower() # 'chat' or 'agent'
# Workspace: confine the agent's file/shell tools to this folder.
workspace, workspace_rejected = _resolve_request_workspace(
@@ -589,6 +754,25 @@ def setup_chat_routes(
# not chats we quietly promoted for a notes/calendar intent.
user_requested_agent = (chat_mode == "agent")
_search_enabled = web_search_enabled_for_turn(allow_web_search, use_web)
_explicit_web_intent = False
_explicit_browser_intent = False
if isinstance(message, str):
_msg_l = message.lower()
_explicit_web_intent = bool(re.search(
r"\b(search|look\s*up|lookup|google|browse|web|online|latest|current|today|news|weather|forecast|rate|exchange\s+rate)\b",
_msg_l,
))
_explicit_browser_intent = bool(re.search(
r"\b(browser|browse|open\s+(?:the\s+)?(?:site|page|url|link)|"
r"click|fill(?:\s+out)?|submit|send\s+(?:the\s+)?form|"
r"contact\s+form|web\s*form|form\s+submission)\b",
_msg_l,
))
_allow_browser_for_web_turn = bool(
_explicit_browser_intent
or _explicit_web_intent
or _search_enabled
)
# Intent auto-escalation: if the user is clearly asking the assistant
# to create a todo, reminder, or calendar event, promote chat → agent
# for this turn so the LLM has access to manage_notes / manage_calendar.
@@ -598,9 +782,13 @@ def setup_chat_routes(
# shell disabled).
auto_escalated = False
_tool_intent = _classify_tool_intent(message) if isinstance(message, str) else None
_workspace_agent_intent = False
if chat_mode == "chat" and _tool_intent and _tool_intent.needs_tools:
chat_mode = "agent"
auto_escalated = True
_workspace_agent_intent = _tool_intent.category in {"shell", "workspace"}
if _workspace_agent_intent:
allow_bash = "true"
logger.info(
"chat→agent auto-escalation: category=%s reason=%s",
_tool_intent.category,
@@ -610,6 +798,10 @@ def setup_chat_routes(
chat_mode = "agent"
auto_escalated = True
logger.info("chat→agent auto-escalation: search enabled")
elif chat_mode == "chat" and _explicit_web_intent:
chat_mode = "agent"
auto_escalated = True
logger.info("chat→agent auto-escalation: explicit web intent")
active_doc_id = form_data.get("active_doc_id", "").strip()
logger.info(f"[doc-inject] chat_mode={chat_mode}, active_doc_id={active_doc_id!r}")
@@ -688,6 +880,7 @@ def setup_chat_routes(
_verify_session_owner(request, session)
sess = session_manager.get_session(session)
owner = effective_user(request)
_reconcile_selected_route_from_request(request, sess, session, form_data, owner=owner)
if _clear_orphaned_session_endpoint(sess, owner=owner):
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
# Issue #587: picker shows a model from the endpoint cache but
@@ -711,11 +904,28 @@ def setup_chat_routes(
_tool_intent = ToolIntent(True, "web", "contextual web lookup follow-up")
chat_mode = "agent"
auto_escalated = True
_workspace_agent_intent = False
logger.info(
"chat→agent auto-escalation: category=%s reason=%s",
_tool_intent.category,
_tool_intent.reason,
)
if isinstance(message, str) and _is_contextual_browser_followup(message, sess):
_explicit_browser_intent = True
if chat_mode == "chat":
chat_mode = "agent"
auto_escalated = True
_workspace_agent_intent = False
logger.info("chat→agent auto-escalation: contextual browser/form follow-up")
if not workspace and isinstance(message, str):
_auto_workspace, _ = _resolve_workspace_from_message_path(request, message)
if _auto_workspace:
workspace = _auto_workspace
chat_mode = "agent"
auto_escalated = True
_workspace_agent_intent = True
allow_bash = "true"
logger.info("chat→agent auto-escalation: explicit path workspace=%s", workspace)
except SessionNotFoundError as e:
raise HTTPException(404, str(e))
except (ValueError, ValidationError):
@@ -750,7 +960,12 @@ def setup_chat_routes(
except Exception as e:
logger.warning("Failed to parse attachments JSON, ignoring attachments", exc_info=e)
image_generation_session = _is_image_generation_session(sess, owner=effective_user(request))
no_memory = str(form_data.get("no_memory", "")).lower() == "true"
if image_generation_session:
no_memory = True
use_rag = "false"
search_context = None
pre_context_tool_policy = build_effective_tool_policy(
last_user_message=message,
)
@@ -879,7 +1094,7 @@ def setup_chat_routes(
# explicitly enable it.
if allow_bash is not None and str(allow_bash).lower() != "true":
disabled_tools.add("bash")
_explicit_web_intent = bool(_tool_intent and _tool_intent.category == "web")
_explicit_web_intent = _explicit_web_intent or bool(_tool_intent and _tool_intent.category == "web")
if is_web_search_explicitly_denied(allow_web_search) or not _search_enabled:
disabled_tools.update(WEB_TOOL_NAMES)
if _explicit_web_intent:
@@ -893,7 +1108,7 @@ def setup_chat_routes(
"create_document", "edit_document", "update_document",
"send_email", "reply_to_email",
"manage_notes", "manage_calendar", "manage_tasks",
"api_call", "builtin_browser",
"api_call",
})
if _search_enabled:
disabled_tools.difference_update(WEB_TOOL_NAMES)
@@ -909,6 +1124,11 @@ def setup_chat_routes(
"manage_memory", # persistent memory store
"search_chats", # past chat history
"manage_skills", # skill presets tied to user
"create_session",
"list_sessions",
"manage_session",
"send_to_session",
"chat_with_model",
})
# Active email reader open → strip the tools that let the agent drift
@@ -935,7 +1155,7 @@ def setup_chat_routes(
if not _privs.get("can_use_bash", True):
disabled_tools.update({"bash", "python", "read_file", "write_file"})
if not _privs.get("can_use_browser", True):
disabled_tools.add("builtin_browser")
disabled_tools.update(_BROWSER_MCP_TOOLS)
if not _privs.get("can_use_documents", True):
disabled_tools.update({"create_document", "edit_document", "update_document", "suggest_document"})
if not _privs.get("can_generate_images", True):
@@ -958,10 +1178,12 @@ def setup_chat_routes(
# the heavy "do things on the computer" tools — otherwise the model
# tries to shell out for a request that never needed it, then fails
# (and looks broken when the shell is disabled).
if auto_escalated:
if auto_escalated and not _workspace_agent_intent:
disabled_tools.update({
"bash", "python", "read_file", "write_file", "builtin_browser",
"bash", "python", "read_file", "write_file",
})
if not _allow_browser_for_web_turn:
disabled_tools.update(_BROWSER_MCP_TOOLS)
# Disable document tools in compare sessions — they break the pane UI
if sess.name and sess.name.startswith("[CMP]"):
@@ -1195,7 +1417,7 @@ def setup_chat_routes(
_model_info["character_name"] = ctx.preset.character_name
yield f'data: {json.dumps(_model_info)}\n\n'
if _is_image_generation_session(sess, owner=_user):
if image_generation_session:
from src.settings import get_setting
if tool_policy.blocks("generate_image"):
_blocked_msg = tool_policy.reason_for("generate_image")
@@ -1208,26 +1430,85 @@ def setup_chat_routes(
yield "data: [DONE]\n\n"
_active_streams.pop(session, None)
return
from src.ai_interaction import do_generate_image
from src.ai_interaction import do_edit_image, do_generate_image
_user_msg = message or ""
yield f'data: {json.dumps({"type": "tool_start", "tool": "generate_image", "command": _user_msg[:100]})}\n\n'
_image_upload = _first_image_attachment(chat_handler, att_ids, owner=_user)
_image_tool_name = "edit_image" if _image_upload else "generate_image"
yield f'data: {json.dumps({"type": "tool_start", "tool": _image_tool_name, "command": _user_msg[:100]})}\n\n'
yield ": heartbeat\n\n"
_img_result = await do_generate_image(f"{_user_msg}\n{sess.model}", session, owner=_user)
_progress_queue: asyncio.Queue = asyncio.Queue()
async def _image_progress_callback(progress: Dict[str, Any]):
try:
_progress_queue.put_nowait(progress)
except Exception:
pass
if _image_upload:
_img_task = asyncio.create_task(do_edit_image(
_user_msg,
_image_upload.get("path", ""),
model_spec=sess.model,
session_id=session,
owner=_user,
size="1024x1024",
progress_callback=_image_progress_callback,
))
else:
_img_task = asyncio.create_task(do_generate_image(f"{_user_msg}\n{sess.model}\n512x512", session, owner=_user))
_img_started = time.time()
_img_tick = 0
while not _img_task.done():
try:
_progress = await asyncio.wait_for(_progress_queue.get(), timeout=2.0)
except asyncio.TimeoutError:
_progress = None
_img_tick += 1
_elapsed = int(time.time() - _img_started)
_label = "Editing image" if _image_upload else "Generating image"
yield ": image generation still running\n\n"
_progress_data = {"type": "tool_progress", "tool": _image_tool_name, "message": f"{_label}{_elapsed}s", "elapsed": _elapsed, "tick": _img_tick}
if isinstance(_progress, dict) and _progress.get("total"):
_step = int(_progress.get("step") or 0)
_total = int(_progress.get("total") or 0)
_percent = _progress.get("percent")
_progress_data.update({
"step": _step,
"total": _total,
"percent": _percent,
"message": f"{_label}{_step}/{_total}",
})
yield f'data: {json.dumps(_progress_data)}\n\n'
_img_result = await _img_task
_img_output = _img_result.get("results", _img_result.get("error", ""))
_img_tool_data = {"type": "tool_output", "tool": "generate_image", "command": _user_msg[:100], "output": _img_output, "exit_code": 0 if "error" not in _img_result else 1}
_img_tool_data = {"type": "tool_output", "tool": _image_tool_name, "command": _user_msg[:100], "output": _img_output, "exit_code": 0 if "error" not in _img_result else 1}
for _k in ("image_url", "image_id", "image_prompt", "image_model", "image_size", "image_quality"):
if _k in _img_result:
_img_tool_data[_k] = _img_result[_k]
if _image_upload:
_img_tool_data["source_image"] = {
"id": _image_upload.get("id"),
"name": _image_upload.get("name") or _image_upload.get("original_name"),
}
yield f'data: {json.dumps(_img_tool_data)}\n\n'
if _img_result.get("image_url"):
_img_event = {"type": "generated_image", "url": _img_result.get("image_url")}
for _k in ("image_url", "image_id", "image_prompt", "image_model", "image_size", "image_quality"):
if _img_result.get(_k):
_img_event[_k] = _img_result[_k]
yield f'data: {json.dumps(_img_event)}\n\n'
_desc = _img_result.get("results", _img_result.get("error", "Image generation complete"))
full_response = _desc
yield f'data: {json.dumps({"delta": _desc})}\n\n'
# Save to session history
if not incognito:
_ev = {"round": 1, "tool": "generate_image", "command": _user_msg[:100], "output": _img_output, "exit_code": 0 if "error" not in _img_result else 1}
_ev = {"round": 1, "tool": _image_tool_name, "command": _user_msg[:100], "output": _img_output, "exit_code": 0 if "error" not in _img_result else 1}
for _ek in ("image_url", "image_id", "image_prompt", "image_model", "image_size", "image_quality"):
if _img_result.get(_ek):
_ev[_ek] = _img_result[_ek]
if _image_upload:
_ev["source_image_id"] = _image_upload.get("id")
_ev["source_image_name"] = _image_upload.get("name") or _image_upload.get("original_name")
sess.add_message(ChatMessage("assistant", full_response, metadata={"tool_events": [_ev], "model": sess.model}))
session_manager.save_sessions()
yield f'data: {json.dumps({"type": "metrics", "data": {"total_time": 0}})}\n\n'
@@ -1292,8 +1573,10 @@ def setup_chat_routes(
last_metrics["context_messages_after_trim"] = ctx.context_messages_after_trim
last_metrics["context_tokens_before_trim"] = ctx.context_tokens_before_trim
last_metrics["context_tokens_after_trim"] = ctx.context_tokens_after_trim
if ctx.context_length and last_metrics.get("input_tokens"):
pct = min(round((last_metrics["input_tokens"] / ctx.context_length) * 100, 1), 100.0)
request_context_tokens = ctx.context_tokens_after_trim or estimate_tokens(messages)
last_metrics["request_context_tokens"] = request_context_tokens
if ctx.context_length and request_context_tokens:
pct = min(round((request_context_tokens / ctx.context_length) * 100, 1), 100.0)
last_metrics["context_percent"] = pct
last_metrics["context_length"] = ctx.context_length
# The frontend reads `tokens_per_second`; the raw usage event
@@ -1326,6 +1609,7 @@ def setup_chat_routes(
"input_tokens": _est_in,
"output_tokens": _est_out,
"tokens_per_second": _tps,
"request_context_tokens": _est_in,
"context_percent": _ctx_pct,
"context_length": ctx.context_length,
"model": _actual_model or _answered_by or _requested_model,
@@ -1360,7 +1644,7 @@ def setup_chat_routes(
_stream_set(session, status="done")
yield chunk
except (asyncio.CancelledError, GeneratorExit):
if full_response:
if full_response and not incognito:
logger.info("Client disconnected mid-stream (chat mode) for session %s, saving partial (%d chars)", session, len(full_response))
_stopped_content, _stopped_md = clean_thinking_for_save(
full_response,
@@ -1371,8 +1655,7 @@ def setup_chat_routes(
},
)
sess.add_message(ChatMessage("assistant", _stopped_content, metadata=_stopped_md))
if not incognito:
session_manager.save_sessions()
session_manager.save_sessions()
raise
finally:
_active_streams.pop(session, None)
@@ -1405,6 +1688,10 @@ def setup_chat_routes(
_forced_tools = None
if _search_enabled:
_forced_tools = set(WEB_TOOL_NAMES)
if _explicit_browser_intent:
_forced_tools |= set(_BROWSER_MCP_TOOLS)
elif _explicit_browser_intent:
_forced_tools = set(_BROWSER_MCP_TOOLS)
async for chunk in stream_agent_loop(
sess.endpoint_url,
@@ -1529,7 +1816,7 @@ def setup_chat_routes(
# outer finally from running and left _active_streams
# with a stale entry).
try:
if full_response:
if full_response and not incognito:
logger.info("Client disconnected mid-stream for session %s, saving partial response (%d chars)", session, len(full_response))
_stopped_content2, _stopped_md2 = clean_thinking_for_save(
full_response,
@@ -1540,8 +1827,7 @@ def setup_chat_routes(
},
)
sess.add_message(ChatMessage("assistant", _stopped_content2, metadata=_stopped_md2))
if not incognito:
session_manager.save_sessions()
session_manager.save_sessions()
except Exception:
logger.exception("Failed to save partial response on disconnect (session %s)", session)
raise
+36 -7
View File
@@ -463,14 +463,22 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
" if sz == 0 and os.path.isdir(snap):",
" sz2, nf2, ic2 = snapshot_size()",
" sz, nf, ic = sz2, nf2, ic or ic2",
" is_diffusion = False; gguf_files = []",
" is_video = bool(re.search(r'(?i)(^|/)Lightricks/LTX-|(^|/)LTX[-_/]|video|text-to-video|image-to-video', rid))",
" is_diffusion = is_video; is_adapter = bool(re.search(r'(?i)(lora|adapter|peft|qlora|control[-_]?lora|diffusion[-_]?lora)', rid)); gguf_files = []",
" if os.path.isdir(snap):",
" for sd in os.listdir(snap):",
" sf = os.path.join(snap, sd)",
" if not os.path.isdir(sf): continue",
" if os.path.exists(os.path.join(sf, 'model_index.json')): is_diffusion = True",
" if os.path.exists(os.path.join(sf, 'adapter_config.json')) or os.path.exists(os.path.join(sf, 'adapter_model.safetensors')): is_adapter = True",
" for _root, _dirs, _fns in safe_walk(sf):",
" for _fn in _fns:",
" _lfn = _fn.lower()",
" if _lfn.endswith('.safetensors') and re.search(r'(?i)(ltx|video|upscaler)', _lfn): is_video = True; is_diffusion = True",
" if _lfn in ('adapter_config.json','adapter_model.safetensors','pytorch_lora_weights.safetensors') or 'lora' in _lfn:",
" is_adapter = True",
" for f in collect_ggufs(sf): f['rel_path'] = sd + '/' + f['rel_path']; gguf_files.append(f)",
" models.append({'repo_id':rid,'size_bytes':sz,'nb_files':nf,'has_incomplete':ic,'path':cache,'is_diffusion':is_diffusion,'is_gguf':bool(gguf_files),'gguf_files':gguf_files})",
" models.append({'repo_id':rid,'size_bytes':sz,'nb_files':nf,'has_incomplete':ic,'path':cache,'is_diffusion':is_diffusion,'is_video':is_video,'is_adapter':is_adapter,'is_gguf':bool(gguf_files),'gguf_files':gguf_files})",
"def hf_cache_paths():",
" candidates = []",
" def add(p):",
@@ -505,11 +513,12 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
" fp = os.path.join(p, d)",
" if not os.path.isdir(fp) or os.path.islink(fp) or not safe_path(fp): continue",
" if d in seen: continue",
" is_model = False; gguf_files = []",
" is_model = False; is_adapter = bool(re.search(r'(?i)(lora|adapter|peft|qlora|control[-_]?lora|diffusion[-_]?lora)', d)); gguf_files = []",
" for root, dirs, fns in safe_walk(fp):",
" for fn in fns:",
" if fn.lower().endswith('.gguf'): is_model = True",
" elif fn == 'config.json' or fn.endswith('.safetensors') or fn.endswith('.bin'): is_model = True",
" if fn in ('adapter_config.json','adapter_model.safetensors','pytorch_lora_weights.safetensors') or 'lora' in fn.lower(): is_adapter = True",
" if is_model: break",
" if not is_model: continue",
" gguf_files = collect_ggufs(fp)",
@@ -520,7 +529,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
" try: nf += 1; sz += os.path.getsize(os.path.join(dp, fn))",
" except Exception: pass",
" is_diff = os.path.exists(os.path.join(fp, 'model_index.json'))",
" models.append({'repo_id':d,'size_bytes':sz,'nb_files':nf,'has_incomplete':False,'path':p,'is_local_dir':True,'is_diffusion':is_diff,'is_gguf':bool(gguf_files),'gguf_files':gguf_files})",
" models.append({'repo_id':d,'size_bytes':sz,'nb_files':nf,'has_incomplete':False,'path':p,'is_local_dir':True,'is_diffusion':is_diff,'is_adapter':is_adapter,'is_gguf':bool(gguf_files),'gguf_files':gguf_files})",
"def parse_size(num, unit):",
" try: n = float(num)",
" except Exception: return 0",
@@ -1320,6 +1329,26 @@ def _diagnose_serve_output(text: str) -> dict | None:
"MLX LM is not installed on this server.",
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
),
(
r"OmniGen2Pipeline|module diffusers has no attribute .*Pipeline|custom_pipeline=.*failed",
"This image model uses a custom Diffusers pipeline that the launch environment does not know yet.",
[{"label": "update Diffusers image dependencies", "op": "dependency", "package": "diffusers transformers accelerate"}],
),
(
r"mflux-generate-qwen.*not found|mflux-generate.*not found|MLX image serving requires mflux|No module named ['\"]?mflux",
"MLX image serving requires mflux on this Apple Silicon server.",
[{"label": "install mflux in Cookbook Dependencies", "op": "dependency", "package": "mflux"}],
),
(
r"mlx-lama-swift|odysseus-mlx-inpaint|mlx-lama-serve|LaMa / MI-GAN MLX inpainting models require",
"LaMa / MI-GAN MLX inpainting requires an Odysseus-compatible mlx-lama-swift bridge on this Apple Silicon server.",
[{"label": "build mlx-lama-swift bridge and put odysseus-mlx-inpaint or mlx-lama-serve on PATH", "op": "dependency", "package": "mlx_lama_swift"}],
),
(
r"mlx-ddcolor-swift|odysseus-mlx-colorize|mlx-ddcolor-serve|DDColor MLX models require",
"DDColor MLX colorization requires an Odysseus-compatible mlx-ddcolor-swift bridge on this Apple Silicon server.",
[{"label": "build mlx-ddcolor-swift bridge and put odysseus-mlx-colorize or mlx-ddcolor-serve on PATH", "op": "dependency", "package": "mlx_ddcolor_swift"}],
),
(
r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear",
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
@@ -1358,9 +1387,9 @@ def _diagnose_serve_output(text: str) -> dict | None:
[{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}],
),
(
r"No module named 'torch'|No module named torch|No module named 'diffusers'|No module named diffusers",
"Diffusion serving requires PyTorch and diffusers.",
[{"label": "install diffusers[torch] in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch]"}],
r"No module named 'torch'|No module named torch|No module named 'torchvision'|No module named torchvision|No module named 'diffusers'|No module named diffusers|No module named 'scipy'|No module named scipy|install scipy if you want to use beta sigmas|requires the Torchvision library",
"Diffusion serving requires PyTorch, Torchvision, Diffusers, Accelerate, and SciPy.",
[{"label": "install Diffusers image deps in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch] torchvision accelerate scipy python-multipart"}],
),
(
r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review",
+187 -28
View File
@@ -73,6 +73,23 @@ _HF_TOKEN_STATUS_SNIPPET = (
)
def _append_mlx_image_server_script(runner_lines: list[str]) -> None:
"""Write the MLX image API helper next to the tmux runner on remote hosts."""
script_path = Path(__file__).resolve().parents[1] / "scripts" / "mlx_image_server.py"
try:
script = script_path.read_text(encoding="utf-8")
except Exception as e:
logger.warning("Failed to read mlx_image_server.py: %s", e)
runner_lines.append('echo "ERROR: Odysseus could not prepare the MLX image server helper."')
runner_lines.append('ODYSSEUS_PREFLIGHT_EXIT=127')
return
runner_lines.append('mkdir -p scripts')
runner_lines.append("cat > scripts/mlx_image_server.py <<'PY'")
runner_lines.extend(script.splitlines())
runner_lines.append("PY")
runner_lines.append('chmod +x scripts/mlx_image_server.py 2>/dev/null || true')
def _venv_root_from_serve_cmd(cmd: str) -> str:
"""Best-effort venv root from an absolute venv python in a serve command."""
try:
@@ -492,6 +509,11 @@ def setup_cookbook_routes() -> APIRouter:
"MLX LM is not installed on this server.",
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
),
(
r"OmniGen2Pipeline|module diffusers has no attribute .*Pipeline|custom_pipeline=.*failed",
"This image model uses a custom Diffusers pipeline that the launch environment does not know yet.",
[{"label": "update Diffusers image dependencies", "op": "dependency", "package": "diffusers transformers accelerate"}],
),
(
r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear",
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
@@ -530,9 +552,9 @@ def setup_cookbook_routes() -> APIRouter:
[{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}],
),
(
r"No module named 'torch'|No module named torch|No module named 'diffusers'|No module named diffusers",
"Diffusion serving requires PyTorch and diffusers.",
[{"label": "install diffusers[torch] in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch]"}],
r"No module named 'torch'|No module named torch|No module named 'torchvision'|No module named torchvision|No module named 'diffusers'|No module named diffusers|No module named 'scipy'|No module named scipy|install scipy if you want to use beta sigmas|requires the Torchvision library",
"Diffusion serving requires PyTorch, Torchvision, Diffusers, Accelerate, and SciPy.",
[{"label": "install Diffusers image deps in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch] torchvision accelerate scipy python-multipart"}],
),
(
r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review",
@@ -1433,9 +1455,11 @@ def setup_cookbook_routes() -> APIRouter:
"nb_files": m["nb_files"],
"has_incomplete": m["has_incomplete"],
"status": "downloading" if m["has_incomplete"] else "ready",
"path": m.get("path", ""),
"is_diffusion": m.get("is_diffusion", False),
}
"path": m.get("path", ""),
"is_diffusion": m.get("is_diffusion", False),
"is_video": m.get("is_video", False),
"is_adapter": m.get("is_adapter", False),
}
if m.get("is_local_dir"):
entry["is_local_dir"] = True
if m.get("is_gguf"):
@@ -1460,6 +1484,7 @@ def setup_cookbook_routes() -> APIRouter:
"""Register a diffusion model as an image endpoint so it appears in the model selector."""
import re
from core.database import SessionLocal, ModelEndpoint
from src.settings import load_settings, save_settings
# Parse port from command (--port NNNN), default 8100 for diffusion_server
port_match = re.search(r'--port\s+(\d+)', req.cmd)
@@ -1477,6 +1502,7 @@ def setup_cookbook_routes() -> APIRouter:
# Friendly display name from repo_id
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
display_name = f"{short_name} (image)"
pinned_models = [req.repo_id] if req.repo_id else []
db = SessionLocal()
try:
@@ -1486,7 +1512,16 @@ def setup_cookbook_routes() -> APIRouter:
existing.is_enabled = True
existing.model_type = "image"
existing.name = display_name
existing.endpoint_kind = "local"
existing.model_refresh_mode = "manual"
if pinned_models:
existing.cached_models = json.dumps(pinned_models)
existing.pinned_models = json.dumps(pinned_models)
db.commit()
settings = load_settings()
if settings.get("image_gen_enabled") is not True:
settings["image_gen_enabled"] = True
save_settings(settings)
logger.info(f"Updated existing image endpoint: {base_url}")
return existing.id
@@ -1498,9 +1533,18 @@ def setup_cookbook_routes() -> APIRouter:
api_key=None,
is_enabled=True,
model_type="image",
endpoint_kind="local",
model_refresh_mode="manual",
cached_models=json.dumps(pinned_models) if pinned_models else None,
pinned_models=json.dumps(pinned_models) if pinned_models else None,
)
db.add(ep)
db.commit()
settings = load_settings()
settings["image_gen_enabled"] = True
if not settings.get("image_model"):
settings["image_model"] = req.repo_id
save_settings(settings)
logger.info(f"Auto-registered image endpoint: {display_name} @ {base_url}")
return ep_id
except Exception as e:
@@ -2356,24 +2400,8 @@ def setup_cookbook_routes() -> APIRouter:
runner_lines.append('fi')
elif "sglang.launch_server" in req.cmd:
runner_lines.append('export PATH="$HOME/.local/bin:$PATH"')
runner_lines.append('if ! command -v sglang &>/dev/null; then')
runner_lines.append(' echo "ERROR: SGLang is not installed."')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append('elif ! ODYSSEUS_SGLANG_IMPORT_ERROR="$(python3 -c "import sglang" 2>&1)"; then')
runner_lines.append(' echo "ERROR: SGLang is installed but failed to import."')
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_SGLANG_IMPORT_ERROR"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append('fi')
elif "mlx_lm.server" in req.cmd:
runner_lines.append('export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
runner_lines.append('if ! ODYSSEUS_MLX_IMPORT_ERROR="$(python3 -c "import mlx_lm" 2>&1)"; then')
runner_lines.append(' echo "ERROR: MLX LM is not installed."')
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_MLX_IMPORT_ERROR"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append('fi')
runner_lines.append(f"ODYSSEUS_SERVE_CMD='{_bash_squote(req.cmd)}'")
runner_lines.append('if [ -z "$ODYSSEUS_PREFLIGHT_EXIT" ]; then')
runner_lines.append(' ODYSSEUS_MLX_CMD_PY="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
runner_lines.append('ODYSSEUS_SGLANG_CMD_PY="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
runner_lines.append('import shlex, sys')
runner_lines.append('parts = shlex.split(sys.argv[1])')
runner_lines.append('py = "python3"')
@@ -2384,6 +2412,36 @@ def setup_cookbook_routes() -> APIRouter:
runner_lines.append('print(py)')
runner_lines.append('PY')
runner_lines.append(')"')
runner_lines.append('if ! "$ODYSSEUS_SGLANG_CMD_PY" -c "import sglang" &>/dev/null; then')
runner_lines.append(' if ! command -v sglang &>/dev/null; then')
runner_lines.append(' echo "ERROR: SGLang is not installed."')
runner_lines.append(' else')
runner_lines.append(' echo "ERROR: SGLang is installed but failed to import in the launch Python."')
runner_lines.append(' fi')
runner_lines.append(' ODYSSEUS_SGLANG_IMPORT_ERROR="$("$ODYSSEUS_SGLANG_CMD_PY" -c "import sglang" 2>&1)"')
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_SGLANG_IMPORT_ERROR"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append('fi')
elif "mlx_lm.server" in req.cmd:
runner_lines.append('export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
runner_lines.append(f"ODYSSEUS_SERVE_CMD='{_bash_squote(req.cmd)}'")
runner_lines.append('ODYSSEUS_MLX_CMD_PY="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
runner_lines.append('import shlex, sys')
runner_lines.append('parts = shlex.split(sys.argv[1])')
runner_lines.append('py = "python3"')
runner_lines.append('for i, part in enumerate(parts):')
runner_lines.append(' if part.endswith("/bin/python") or part.endswith("/bin/python3") or "/bin/python3." in part:')
runner_lines.append(' py = part')
runner_lines.append(' break')
runner_lines.append('print(py)')
runner_lines.append('PY')
runner_lines.append(')"')
runner_lines.append('if ! ODYSSEUS_MLX_IMPORT_ERROR="$("$ODYSSEUS_MLX_CMD_PY" -c "import mlx_lm" 2>&1)"; then')
runner_lines.append(' echo "ERROR: MLX LM is not installed in the launch Python: $ODYSSEUS_MLX_CMD_PY"')
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_MLX_IMPORT_ERROR"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append('fi')
runner_lines.append('if [ -z "$ODYSSEUS_PREFLIGHT_EXIT" ]; then')
runner_lines.append(' ODYSSEUS_SERVE_CMD="$("$ODYSSEUS_MLX_CMD_PY" - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
runner_lines.append('import json, os, shlex, sys')
runner_lines.append('from pathlib import Path')
@@ -2474,10 +2532,111 @@ def setup_cookbook_routes() -> APIRouter:
runner_lines.append('PY')
runner_lines.append(')"')
runner_lines.append('fi')
elif "scripts/mlx_image_server.py" in req.cmd or ".mlx_image_server.py" in req.cmd:
_append_mlx_image_server_script(runner_lines)
runner_lines.append('export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
runner_lines.append(f"ODYSSEUS_SERVE_CMD='{_bash_squote(req.cmd)}'")
runner_lines.append('ODYSSEUS_MLX_IMAGE_CMD_PY="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
runner_lines.append('import shlex, sys')
runner_lines.append('parts = shlex.split(sys.argv[1])')
runner_lines.append('py = "python3"')
runner_lines.append('for part in parts:')
runner_lines.append(' if part.endswith("/bin/python") or part.endswith("/bin/python3") or "/bin/python3." in part:')
runner_lines.append(' py = part')
runner_lines.append(' break')
runner_lines.append('print(py)')
runner_lines.append('PY')
runner_lines.append(')"')
runner_lines.append('ODYSSEUS_MLX_IMAGE_BIN_DIR="$(dirname "$ODYSSEUS_MLX_IMAGE_CMD_PY" 2>/dev/null || true)"')
runner_lines.append('if [ -n "$ODYSSEUS_MLX_IMAGE_BIN_DIR" ]; then export PATH="$ODYSSEUS_MLX_IMAGE_BIN_DIR:$PATH"; fi')
runner_lines.append('if ! "$ODYSSEUS_MLX_IMAGE_CMD_PY" -c "import fastapi, uvicorn, multipart" >/dev/null 2>&1; then')
runner_lines.append(' echo "ERROR: MLX image serving requires FastAPI + uvicorn + python-multipart in the launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY. Install the MLX image dependencies in Cookbook Dependencies."')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append('fi')
runner_lines.append('ODYSSEUS_MLX_IMAGE_MODEL="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
runner_lines.append('import shlex, sys')
runner_lines.append('parts = shlex.split(sys.argv[1])')
runner_lines.append('model = ""')
runner_lines.append('for i, part in enumerate(parts):')
runner_lines.append(' if part == "--model" and i + 1 < len(parts):')
runner_lines.append(' model = parts[i + 1]')
runner_lines.append(' break')
runner_lines.append('print(model)')
runner_lines.append('PY')
runner_lines.append(')"')
runner_lines.append('if printf "%s" "$ODYSSEUS_MLX_IMAGE_MODEL" | grep -qi hidream; then')
runner_lines.append(' if ! "$ODYSSEUS_MLX_IMAGE_CMD_PY" -c "import mlx, mlx_vlm, transformers, huggingface_hub, safetensors, numpy, PIL" >/dev/null 2>&1; then')
runner_lines.append(' echo "ERROR: HiDream MLX serving needs the model requirements in the launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY."')
runner_lines.append(' echo "Install with: $ODYSSEUS_MLX_IMAGE_CMD_PY -m pip install -U fastapi uvicorn python-multipart mlx mlx-vlm \'transformers>=4.57.0,<6.0\' huggingface_hub safetensors numpy pillow tqdm sentencepiece hf_transfer"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append(' fi')
runner_lines.append('elif printf "%s" "$ODYSSEUS_MLX_IMAGE_MODEL" | grep -qi boogu; then')
runner_lines.append(' if ! "$ODYSSEUS_MLX_IMAGE_CMD_PY" -c "import boogu_image_mlx, mlx, huggingface_hub, safetensors, numpy, PIL" >/dev/null 2>&1; then')
runner_lines.append(' echo "ERROR: Boogu MLX serving needs boogu-image-mlx in the launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY."')
runner_lines.append(' echo "Install with: $ODYSSEUS_MLX_IMAGE_CMD_PY -m pip install -U git+https://github.com/xocialize/boogu-image-mlx.git fastapi uvicorn python-multipart pillow"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append(' fi')
runner_lines.append('elif printf "%s" "$ODYSSEUS_MLX_IMAGE_MODEL" | grep -Eqi "ddcolor"; then')
runner_lines.append(' if ! "$ODYSSEUS_MLX_IMAGE_CMD_PY" -c "import PIL" >/dev/null 2>&1; then')
runner_lines.append(' echo "ERROR: DDColor MLX serving needs Pillow in the launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY."')
runner_lines.append(' echo "Install with: $ODYSSEUS_MLX_IMAGE_CMD_PY -m pip install -U fastapi uvicorn python-multipart pillow huggingface_hub"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append(' fi')
runner_lines.append(' if ! command -v odysseus-mlx-colorize >/dev/null 2>&1 && ! command -v mlx-ddcolor-serve >/dev/null 2>&1; then')
runner_lines.append(' echo "ERROR: DDColor MLX serving requires the Odysseus mlx-ddcolor-swift bridge on PATH: odysseus-mlx-colorize or mlx-ddcolor-serve."')
runner_lines.append(' echo "Build it from swift/odysseus-mlx-image-bridge in Cookbook Dependencies."')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append(' fi')
runner_lines.append(' ODYSSEUS_DDCOLOR_BIN="$(command -v odysseus-mlx-colorize 2>/dev/null || command -v mlx-ddcolor-serve 2>/dev/null || true)"')
runner_lines.append(' if [ -n "$ODYSSEUS_DDCOLOR_BIN" ]; then')
runner_lines.append(' ODYSSEUS_DDCOLOR_DIR="$(dirname "$ODYSSEUS_DDCOLOR_BIN")"')
runner_lines.append(' if [ ! -f "$ODYSSEUS_DDCOLOR_DIR/mlx.metallib" ] && [ ! -f "$ODYSSEUS_DDCOLOR_DIR/default.metallib" ]; then')
runner_lines.append(' echo "ERROR: DDColor MLX serving found the Swift runner, but mlx.metallib/default.metallib is missing next to it."')
runner_lines.append(' echo "Run the DDColor MLX image editing dependency install again; it copies mlx.metallib from the launch Python MLX package."')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append(' fi')
runner_lines.append(' fi')
runner_lines.append('elif printf "%s" "$ODYSSEUS_MLX_IMAGE_MODEL" | grep -Eqi "mi-gan|migan|lama"; then')
runner_lines.append(' if ! "$ODYSSEUS_MLX_IMAGE_CMD_PY" -c "import PIL" >/dev/null 2>&1; then')
runner_lines.append(' echo "ERROR: LaMa / MI-GAN MLX serving needs Pillow in the launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY."')
runner_lines.append(' echo "Install with: $ODYSSEUS_MLX_IMAGE_CMD_PY -m pip install -U fastapi uvicorn python-multipart pillow huggingface_hub"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append(' fi')
runner_lines.append(' if ! command -v odysseus-mlx-inpaint >/dev/null 2>&1 && ! command -v mlx-lama-serve >/dev/null 2>&1; then')
runner_lines.append(' echo "ERROR: LaMa / MI-GAN MLX serving requires the Odysseus mlx-lama-swift bridge on PATH: odysseus-mlx-inpaint or mlx-lama-serve."')
runner_lines.append(' echo "Build it from swift/odysseus-mlx-image-bridge in Cookbook Dependencies."')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append(' fi')
runner_lines.append(' ODYSSEUS_INPAINT_BIN="$(command -v odysseus-mlx-inpaint 2>/dev/null || command -v mlx-lama-serve 2>/dev/null || true)"')
runner_lines.append(' if [ -n "$ODYSSEUS_INPAINT_BIN" ]; then')
runner_lines.append(' ODYSSEUS_INPAINT_DIR="$(dirname "$ODYSSEUS_INPAINT_BIN")"')
runner_lines.append(' if [ ! -f "$ODYSSEUS_INPAINT_DIR/mlx.metallib" ] && [ ! -f "$ODYSSEUS_INPAINT_DIR/default.metallib" ]; then')
runner_lines.append(' echo "ERROR: LaMa / MI-GAN MLX serving found the Swift runner, but mlx.metallib/default.metallib is missing next to it."')
runner_lines.append(' echo "Run the LaMa / MI-GAN MLX image editing dependency install again; it copies mlx.metallib from the launch Python MLX package."')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append(' fi')
runner_lines.append(' fi')
runner_lines.append('elif ! command -v mflux-generate >/dev/null 2>&1 && ! command -v mflux-generate-qwen >/dev/null 2>&1; then')
runner_lines.append(' echo "ERROR: mflux-compatible MLX image serving requires mflux-generate or mflux-generate-qwen in PATH for launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY."')
runner_lines.append(' echo "Install with: $ODYSSEUS_MLX_IMAGE_CMD_PY -m pip install -U mflux fastapi uvicorn python-multipart"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append('fi')
elif "scripts/diffusion_server.py" in req.cmd or ".diffusion_server.py" in req.cmd:
runner_lines.append('export PATH="$HOME/.local/bin:$PATH"')
runner_lines.append('if ! ODYSSEUS_DIFFUSION_IMPORT_ERROR="$(python3 -c "import torch, diffusers" 2>&1)"; then')
runner_lines.append(' echo "ERROR: Diffusion serving requires PyTorch + diffusers."')
runner_lines.append(f"ODYSSEUS_SERVE_CMD='{_bash_squote(req.cmd)}'")
runner_lines.append('ODYSSEUS_DIFFUSION_CMD_PY="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
runner_lines.append('import shlex, sys')
runner_lines.append('parts = shlex.split(sys.argv[1])')
runner_lines.append('py = "python3"')
runner_lines.append('for part in parts:')
runner_lines.append(' if part.endswith("/bin/python") or part.endswith("/bin/python3") or "/bin/python3." in part:')
runner_lines.append(' py = part')
runner_lines.append(' break')
runner_lines.append('print(py)')
runner_lines.append('PY')
runner_lines.append(')"')
runner_lines.append('if ! ODYSSEUS_DIFFUSION_IMPORT_ERROR="$("$ODYSSEUS_DIFFUSION_CMD_PY" -c "import torch, torchvision, diffusers" 2>&1)"; then')
runner_lines.append(' echo "ERROR: Diffusion serving requires PyTorch + Torchvision + diffusers in the launch Python: $ODYSSEUS_DIFFUSION_CMD_PY."')
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_DIFFUSION_IMPORT_ERROR"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append('fi')
@@ -2588,8 +2747,8 @@ def setup_cookbook_routes() -> APIRouter:
# endpoint; any other real model serve (i.e. not a pip-install task) gets
# a local LLM endpoint pointed at its /v1.
endpoint_id = None
is_diffusion = "diffusion_server.py" in req.cmd
if is_diffusion:
is_image_endpoint = "diffusion_server.py" in req.cmd or "mlx_image_server.py" in req.cmd
if is_image_endpoint:
endpoint_id = _auto_register_image_endpoint(req, remote)
elif not is_pip_install:
endpoint_id = _auto_register_llm_endpoint(req, remote)
@@ -2605,7 +2764,7 @@ def setup_cookbook_routes() -> APIRouter:
# if N != 0 within the watch window, delete the endpoint we just
# created. Skipped for diffusion (different image-endpoint cleanup
# path) and pip-install tasks (no endpoint to drop).
if endpoint_id and not is_diffusion and not is_pip_install:
if endpoint_id and not is_image_endpoint and not is_pip_install:
asyncio.create_task(_serve_crash_watchdog(
endpoint_id=endpoint_id,
session_id=session_id,
+2
View File
@@ -246,6 +246,7 @@ import re as _re_reply
# serves replies and summaries (any fenced final-output block).
_REPLY_OPEN_RE = _re_reply.compile(r"<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+", _re_reply.I)
_REPLY_CLOSE_RE = _re_reply.compile(r"<<<\s*END\s*>>+", _re_reply.I)
_REPLY_ROLE_MARKER_RE = _re_reply.compile(r"</?\|(?:assistant|assistan|user|system|tool)\|>?|</\|end\|>?", _re_reply.I)
def _extract_reply(text: str) -> str:
@@ -272,6 +273,7 @@ def _extract_reply(text: str) -> str:
# Drop any stray/duplicate marker tokens, then strip think markup.
t = _REPLY_OPEN_RE.sub("", t)
t = _REPLY_CLOSE_RE.sub("", t)
t = _REPLY_ROLE_MARKER_RE.sub("", t)
return _strip_think(t).strip()
+348 -25
View File
@@ -100,6 +100,272 @@ def _owner_for_email_account(account_id: str | None) -> str:
return ""
def _email_date_only(value: str | None):
value = (value or "").strip()
if not value:
return None
try:
return datetime.strptime(value[:10], "%Y-%m-%d").date()
except Exception:
return None
_AUTO_REPLY_KEYS = {
"email_auto_reply",
"email_auto_reply_start",
"email_auto_reply_end",
"email_auto_reply_subject",
"email_auto_reply_message",
"email_auto_reply_cooldown",
"email_auto_reply_scope",
"email_auto_reply_account_id",
"email_auto_reply_exclude_automated",
"email_auto_reply_pause_notifications",
"email_auto_reply_enabled_at",
}
def _effective_settings_for_email_account(settings: dict, account_id: str | None) -> dict:
"""Overlay per-account auto-reply settings onto global settings.
Other automation toggles remain global. This lets each mailbox have its own
away reply while preserving existing installs that only have global keys.
"""
effective = dict(settings or {})
key = str(account_id or "").strip()
by_account = effective.get("email_auto_reply_by_account") or {}
account_cfg = by_account.get(key) if key and isinstance(by_account, dict) else None
if isinstance(account_cfg, dict):
for k in _AUTO_REPLY_KEYS:
if k in account_cfg:
effective[k] = account_cfg[k]
return effective
def _away_reply_active(settings: dict, account_id: str | None) -> bool:
if not settings.get("email_auto_reply", False):
return False
scope = str(settings.get("email_auto_reply_scope") or "all").strip().lower()
if scope == "account":
selected = str(settings.get("email_auto_reply_account_id") or "").strip()
if selected and selected != str(account_id or ""):
return False
today = datetime.utcnow().date()
start = _email_date_only(settings.get("email_auto_reply_start"))
end = _email_date_only(settings.get("email_auto_reply_end"))
if start and today < start:
return False
if end and today > end:
return False
return True
def _message_after_away_enabled(settings: dict, msg) -> bool:
enabled_at = (settings.get("email_auto_reply_enabled_at") or "").strip()
if not enabled_at:
# Existing installs may already have the toggle on before this feature
# existed. Do not back-reply old mail until the user saves/toggles it.
return False
try:
enabled_dt = datetime.fromisoformat(enabled_at.replace("Z", "+00:00"))
except Exception:
return False
try:
msg_dt = email.utils.parsedate_to_datetime(msg.get("Date", ""))
except Exception:
return False
try:
if enabled_dt.tzinfo and not msg_dt.tzinfo:
msg_dt = msg_dt.replace(tzinfo=enabled_dt.tzinfo)
elif msg_dt.tzinfo and not enabled_dt.tzinfo:
enabled_dt = enabled_dt.replace(tzinfo=msg_dt.tzinfo)
except Exception:
pass
return msg_dt >= enabled_dt
def _away_reply_period_key(settings: dict) -> str:
start = (settings.get("email_auto_reply_start") or "").strip()
end = (settings.get("email_auto_reply_end") or "").strip()
return f"{start or '*'}..{end or '*'}"
def _away_reply_cooldown_seconds(settings: dict) -> int | None:
raw = str(settings.get("email_auto_reply_cooldown") or "period").strip().lower()
if raw == "1d":
return 24 * 60 * 60
if raw == "3d":
return 3 * 24 * 60 * 60
if raw == "7d":
return 7 * 24 * 60 * 60
return None
def _ensure_away_reply_table():
import sqlite3 as _sql3
conn = _sql3.connect(SCHEDULED_DB)
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS email_away_replies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner TEXT DEFAULT '',
account_id TEXT DEFAULT '',
message_id TEXT DEFAULT '',
sender_addr TEXT DEFAULT '',
subject TEXT DEFAULT '',
period_key TEXT DEFAULT '',
sent_at TEXT DEFAULT ''
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_email_away_msg ON email_away_replies(owner, account_id, message_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_email_away_sender ON email_away_replies(owner, account_id, sender_addr, sent_at)")
conn.commit()
finally:
conn.close()
def _sender_is_automated(msg, sender_addr: str) -> bool:
auto_submitted = (msg.get("Auto-Submitted") or "").strip().lower()
if auto_submitted and auto_submitted != "no":
return True
precedence = (msg.get("Precedence") or "").strip().lower()
if precedence in {"bulk", "junk", "list"}:
return True
if msg.get("List-Id") or msg.get("List-Unsubscribe"):
return True
local = (sender_addr or "").split("@", 1)[0].lower()
return local in {
"no-reply", "noreply", "do-not-reply", "donotreply",
"notification", "notifications", "automated", "mailer-daemon",
"postmaster",
}
def _away_reply_already_sent(settings: dict, account_owner: str, account_id: str | None,
message_id: str, sender_addr: str) -> bool:
import sqlite3 as _sql3
_ensure_away_reply_table()
owner = account_owner or ""
aid = account_id or ""
sender = (sender_addr or "").strip().lower()
conn = _sql3.connect(SCHEDULED_DB)
try:
row = conn.execute(
"SELECT 1 FROM email_away_replies WHERE owner=? AND account_id=? AND message_id=? LIMIT 1",
(owner, aid, message_id),
).fetchone()
if row:
return True
cooldown = _away_reply_cooldown_seconds(settings)
if cooldown is None:
period_key = _away_reply_period_key(settings)
row = conn.execute(
"SELECT 1 FROM email_away_replies WHERE owner=? AND account_id=? AND sender_addr=? AND period_key=? LIMIT 1",
(owner, aid, sender, period_key),
).fetchone()
return bool(row)
since = datetime.utcnow().timestamp() - cooldown
rows = conn.execute(
"SELECT sent_at FROM email_away_replies WHERE owner=? AND account_id=? AND sender_addr=? ORDER BY sent_at DESC LIMIT 5",
(owner, aid, sender),
).fetchall()
for (sent_at,) in rows:
try:
if datetime.fromisoformat(sent_at).timestamp() >= since:
return True
except Exception:
continue
return False
finally:
conn.close()
def _record_away_reply(settings: dict, account_owner: str, account_id: str | None,
message_id: str, sender_addr: str, subject: str):
import sqlite3 as _sql3
_ensure_away_reply_table()
conn = _sql3.connect(SCHEDULED_DB)
try:
conn.execute(
"""
INSERT INTO email_away_replies
(owner, account_id, message_id, sender_addr, subject, period_key, sent_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
account_owner or "",
account_id or "",
message_id,
(sender_addr or "").strip().lower(),
subject or "",
_away_reply_period_key(settings),
datetime.utcnow().isoformat(),
),
)
conn.commit()
finally:
conn.close()
def _send_away_reply(settings: dict, account_owner: str, account_id: str | None,
msg, message_id: str, sender: str, subject: str):
sender_name, sender_addr = email.utils.parseaddr(sender or "")
sender_addr = (sender_addr or "").strip()
if not sender_addr:
return False, "missing sender"
cfg = _get_email_config(account_id, owner=account_owner)
from_addr = (cfg.get("from_address") or cfg.get("smtp_user") or "").strip()
if not from_addr:
return False, "missing from address"
if sender_addr.lower() == from_addr.lower():
return False, "self mail"
if settings.get("email_auto_reply_exclude_automated", True) and _sender_is_automated(msg, sender_addr):
return False, "automated sender"
if _away_reply_already_sent(settings, account_owner, account_id, message_id, sender_addr):
return False, "already sent"
body = (settings.get("email_auto_reply_message") or "").strip()
if not body:
body = "Thanks for your email. I'm away and may be slower to reply."
subject_template = (settings.get("email_auto_reply_subject") or "(Away) {subject}").strip()
if subject_template:
original_subject = subject or ""
reply_subject = (
subject_template
.replace("{subject}", original_subject)
.replace("{original_subject}", original_subject)
).strip() or "Re:"
else:
reply_subject = subject or ""
if not reply_subject.lower().lstrip().startswith("re:"):
reply_subject = f"Re: {reply_subject}" if reply_subject else "Re:"
outer = MIMEMultipart("alternative")
display = cfg.get("display_name") or ""
outer["From"] = email.utils.formataddr((display, from_addr)) if display else from_addr
outer["To"] = email.utils.formataddr((sender_name, sender_addr)) if sender_name else sender_addr
outer["Subject"] = reply_subject
outer["Date"] = email.utils.formatdate(localtime=False)
outer["Message-ID"] = email.utils.make_msgid()
outer["Auto-Submitted"] = "auto-replied"
outer["X-Auto-Response-Suppress"] = "All"
if message_id:
outer["In-Reply-To"] = message_id
refs = (msg.get("References") or "").strip()
outer["References"] = f"{refs} {message_id}".strip()
outer.attach(MIMEText(body, "plain", "utf-8"))
_send_smtp_message(cfg, from_addr, [sender_addr], outer.as_string())
_record_away_reply(settings, account_owner, account_id, message_id, sender_addr, subject)
return True, sender_addr
# ── Routes ──
async def _emit_progress(progress_cb, message: str):
@@ -125,9 +391,10 @@ async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = Tru
settings = _load_settings()
prev = {k: settings.get(k, False) for k in
("email_auto_summarize", "email_auto_reply", "email_auto_tag",
"email_auto_spam", "email_auto_calendar")}
"email_auto_spam", "email_auto_calendar", "_email_auto_reply_draft_only")}
settings["email_auto_summarize"] = bool(do_summary)
settings["email_auto_reply"] = bool(do_reply)
settings["_email_auto_reply_draft_only"] = bool(do_reply)
settings["email_auto_tag"] = bool(do_tag)
settings["email_auto_spam"] = bool(do_spam)
settings["email_auto_calendar"] = bool(do_calendar)
@@ -142,7 +409,10 @@ async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = Tru
finally:
s2 = _load_settings()
for k, v in prev.items():
s2[k] = v
if v is None and k.startswith("_"):
s2.pop(k, None)
else:
s2[k] = v
_save_settings(s2)
@@ -176,7 +446,7 @@ def _latest_inbox_fallback_uids(conn, reconnect):
return [], reconnect()
async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None, max_process: int | 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, away_only: bool = False) -> str:
"""Single pass of the auto-summarize/reply scan.
When account_id is None, iterates over every enabled account in
@@ -208,6 +478,7 @@ async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None
account_id=(ids[0] if ids else None),
max_process=max_process,
progress_cb=progress_cb,
away_only=away_only,
)
outs = []
for idx, aid in enumerate(ids, start=1):
@@ -218,6 +489,7 @@ async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None
account_id=aid,
max_process=max_process,
progress_cb=progress_cb,
away_only=away_only,
)
outs.append(f"[{names.get(aid, aid[:8])}] {result}")
except Exception as e:
@@ -229,23 +501,32 @@ async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None
account_id=account_id,
max_process=max_process,
progress_cb=progress_cb,
away_only=away_only,
)
async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None = None, max_process: int | 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, away_only: bool = False) -> str:
"""Single pass of the auto-summarize/reply scan for ONE account.
Reads current settings flags."""
import asyncio
import sqlite3 as _sql3
from src.llm_core import _uses_max_completion_tokens
settings = _load_settings()
settings = _effective_settings_for_email_account(_load_settings(), account_id)
auto_sum = settings.get("email_auto_summarize", False)
auto_reply = settings.get("email_auto_reply", False)
auto_reply_draft = bool(auto_reply and settings.get("_email_auto_reply_draft_only", False))
auto_reply_away = bool(auto_reply and not auto_reply_draft and _away_reply_active(settings, account_id))
auto_tag = settings.get("email_auto_tag", False)
auto_spam = settings.get("email_auto_spam", False)
auto_cal = settings.get("email_auto_calendar", False)
if not auto_sum and not auto_reply and not auto_tag and not auto_spam and not auto_cal:
if away_only:
auto_sum = False
auto_reply_draft = False
auto_tag = False
auto_spam = False
auto_cal = False
if not auto_sum and not auto_reply_draft and not auto_reply_away and not auto_tag and not auto_spam and not auto_cal:
return "Nothing to do"
# Owner of the account being processed. All calendar + mailbox reads/writes
@@ -304,11 +585,11 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
_c = _sql3.connect(SCHEDULED_DB)
_cache_owner_clause, _cache_owner_params = _email_cache_owner_clause(account_owner)
_sum_existing = {r[0] for r in _c.execute(
_sum_existing = set() if away_only else {r[0] for r in _c.execute(
f"SELECT message_id FROM email_summaries WHERE {_cache_owner_clause}",
_cache_owner_params,
).fetchall()}
_reply_existing = {r[0] for r in _c.execute(
_reply_existing = set() if away_only else {r[0] for r in _c.execute(
f"SELECT message_id FROM email_ai_replies WHERE {_cache_owner_clause}",
_cache_owner_params,
).fetchall()}
@@ -325,7 +606,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
).fetchall()}
else:
_tag_existing = set()
_cal_existing = {r[0] for r in _c.execute(
_cal_existing = set() if away_only else {r[0] for r in _c.execute(
f"SELECT message_id FROM email_calendar_extractions WHERE {_cache_owner_clause}",
_cache_owner_params,
).fetchall()} if auto_cal else set()
@@ -351,12 +632,21 @@ 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")
task_candidates = resolve_task_candidates(owner=account_owner)
if not task_candidates:
return "No model configured"
url, model, headers = task_candidates[0]
needs_llm = bool(auto_sum or auto_reply_draft or auto_tag or auto_spam or auto_cal)
if needs_llm:
task_candidates = resolve_task_candidates(owner=account_owner)
if not task_candidates:
return "No model configured"
url, model, headers = task_candidates[0]
else:
url, model, headers = None, "", None
writing_style = settings.get("email_writing_style", "")
by_account_styles = settings.get("email_writing_styles_by_account") or {}
writing_style = ""
if account_id and isinstance(by_account_styles, dict):
writing_style = str(by_account_styles.get(str(account_id)) or "")
if not writing_style:
writing_style = settings.get("email_writing_style", "")
processed = 0
already_cached = 0
too_short = 0
@@ -366,12 +656,15 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
_events_created = 0
_replies_drafted = 0
_reply_failed = 0
_away_replies_sent = 0
_away_replies_skipped = 0
_away_replies_failed = 0
_detail_lines = []
_current_folder = "INBOX"
# 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
_default_max_process = 3 if (auto_cal and not auto_sum and not auto_reply_draft and not auto_reply_away 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:
@@ -402,10 +695,6 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
seed = f"{_folder}|{uid_str}|{msg.get('From','')}|{msg.get('Date','')}|{msg.get('Subject','')}"
message_id = f"<synth-{_hl.sha256(seed.encode()).hexdigest()[:16]}@local>"
no_msgid += 1
need_sum = auto_sum and message_id not in _sum_existing
need_reply = auto_reply and message_id not in _reply_existing
need_class = (auto_tag or auto_spam) and message_id not in _tag_existing
need_cal = bool(settings.get("email_auto_calendar", False)) and message_id not in _cal_existing
# Only check urgency on INBOX (received mail), not Sent
# Skip messages that are themselves urgency alerts, or that
# we sent to ourselves — otherwise the alert loop re-flags
@@ -422,17 +711,45 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
except Exception:
_from_addr_only = ""
_is_self_mail = bool(_self_self_addr) and _from_addr_only.lower() == _self_self_addr
need_sum = auto_sum and message_id not in _sum_existing
need_reply = auto_reply_draft and message_id not in _reply_existing
need_away_reply = bool(
auto_reply_away
and _folder.upper() == "INBOX"
and not _is_self_mail
and (away_only or _message_after_away_enabled(settings, msg))
and not _away_reply_already_sent(settings, account_owner, account_id, message_id, _from_addr_only)
)
need_class = (auto_tag or auto_spam) and message_id not in _tag_existing
need_cal = bool(settings.get("email_auto_calendar", False)) and message_id not in _cal_existing
need_urgent = (auto_urgent and message_id not in _urgent_existing
and not _folder.lower().startswith("sent")
and "sent" not in _folder.lower()
and not _is_alert_echo
and not _is_self_mail)
if not need_sum and not need_reply and not need_class and not need_cal and not need_urgent:
if not need_sum and not need_reply and not need_away_reply and not need_class and not need_cal and not need_urgent:
already_cached += 1
await _emit_progress(progress_cb, f"Checked {examined}/{len(uid_list)} · {already_cached} already cached")
continue
subject = _decode_header(msg.get("Subject", ""))
sender = _decode_header(msg.get("From", ""))
if need_away_reply:
try:
sent_away, away_detail = _send_away_reply(
settings, account_owner, account_id, msg, message_id, sender, subject
)
if sent_away:
_away_replies_sent += 1
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
_detail_lines.append(f"away reply · {_folder}#{_uid_text} · {subject or '(no subject)'}{away_detail}")
else:
_away_replies_skipped += 1
logger.info(f"Away reply skipped for uid={uid}: {away_detail}")
except Exception as e:
_away_replies_failed += 1
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
_detail_lines.append(f"away reply failed · {_folder}#{_uid_text} · {subject or '(no subject)'}")
logger.warning(f"Away reply {uid} failed: {e}")
body = _extract_text(msg)
# Pull text out of any PDFs / text attachments and append to
# the body so summaries / replies can actually reason about
@@ -454,7 +771,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
elif need_reply:
if not body:
body = subject
elif (not body or len(body) < 100) and not att_text:
elif not need_away_reply and (not body or len(body) < 100) and not att_text:
too_short += 1
continue
# Augmented body sent to the LLM: original body + attachment text.
@@ -993,7 +1310,8 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
# Build a clear status message
ops = []
if auto_sum: ops.append("summary")
if auto_reply: ops.append("reply")
if auto_reply_draft: ops.append("reply")
if auto_reply_away: ops.append("away")
if auto_tag: ops.append("tag")
if auto_spam: ops.append("spam")
ops_label = "/".join(ops) or "none"
@@ -1002,10 +1320,14 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
parts.append(f"processed {processed} new")
if auto_sum:
parts.append(f"summarized {_summaries_created}")
if auto_reply:
if auto_reply_draft:
parts.append(f"drafted {_replies_drafted} repl" + ("y" if _replies_drafted == 1 else "ies"))
if _reply_failed:
parts.append(f"{_reply_failed} reply failed")
if auto_reply_away:
parts.append(f"sent {_away_replies_sent} away repl" + ("y" if _away_replies_sent == 1 else "ies"))
if _away_replies_failed:
parts.append(f"{_away_replies_failed} away failed")
if already_cached:
parts.append(f"{already_cached} already cached")
if too_short:
@@ -1032,12 +1354,13 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
async def _auto_summarize_poller():
"""Background loop kept for backward compatibility — calls _auto_summarize_pass every 60s.
"""Background loop kept for backward compatibility — calls _auto_summarize_pass periodically.
Newer setups should use scheduled tasks instead (summarize_emails, draft_email_replies)."""
import asyncio as _asyncio
while True:
try:
await _asyncio.sleep(1800)
settings = _load_settings()
await _asyncio.sleep(60 if settings.get("email_auto_reply", False) else 1800)
await _auto_summarize_pass()
except Exception as e:
logger.error(f"Auto-summarize poller crash: {e}")
+695 -30
View File
@@ -26,6 +26,7 @@ import re
import html
import io
import zipfile
from urllib.parse import parse_qs, unquote, urlparse
from html.parser import HTMLParser as _HTMLParser
import logging
import uuid
@@ -89,6 +90,86 @@ def _google_oauth_imap_transport_allowed(port: int, starttls: bool) -> bool:
def _google_oauth_smtp_transport_allowed(port: int, security: str) -> bool:
return (port == 465 and security == "ssl") or (port == 587 and security == "starttls")
def _email_style_key(account_id: str | None) -> str:
return str(account_id or "").strip()
def _get_email_writing_style_for_account(settings: dict, account_id: str | None = None) -> str:
key = _email_style_key(account_id)
by_account = settings.get("email_writing_styles_by_account") or {}
if key and isinstance(by_account, dict):
val = by_account.get(key)
if isinstance(val, str) and val.strip():
return val
return str(settings.get("email_writing_style") or "")
def _set_email_writing_style_for_account(settings: dict, style: str, account_id: str | None = None) -> None:
key = _email_style_key(account_id)
style = str(style or "")
if key:
by_account = settings.get("email_writing_styles_by_account")
if not isinstance(by_account, dict):
by_account = {}
by_account[key] = style
settings["email_writing_styles_by_account"] = by_account
return
settings["email_writing_style"] = style
_AUTO_REPLY_BOOL_KEYS = {
"email_auto_reply",
"email_auto_reply_exclude_automated",
"email_auto_reply_pause_notifications",
}
_AUTO_REPLY_TEXT_KEYS = {
"email_auto_reply_start",
"email_auto_reply_end",
"email_auto_reply_subject",
"email_auto_reply_message",
"email_auto_reply_cooldown",
"email_auto_reply_scope",
"email_auto_reply_account_id",
"email_auto_reply_enabled_at",
}
_AUTO_REPLY_KEYS = _AUTO_REPLY_BOOL_KEYS | _AUTO_REPLY_TEXT_KEYS
def _get_auto_reply_settings_for_account(settings: dict, account_id: str | None = None) -> dict:
key = _email_style_key(account_id)
out = {k: settings.get(k) for k in _AUTO_REPLY_KEYS if k in settings}
by_account = settings.get("email_auto_reply_by_account") or {}
if key and isinstance(by_account, dict) and isinstance(by_account.get(key), dict):
out.update({k: v for k, v in by_account[key].items() if k in _AUTO_REPLY_KEYS})
return out
def _set_auto_reply_settings_for_account(settings: dict, data: dict, account_id: str | None = None) -> tuple[bool, bool]:
key = _email_style_key(account_id)
target = _get_auto_reply_settings_for_account(settings, account_id) if key else settings
prev_auto_reply = bool(target.get("email_auto_reply", False))
for name in _AUTO_REPLY_BOOL_KEYS:
if name in data:
target[name] = bool(data[name])
for name in _AUTO_REPLY_TEXT_KEYS - {"email_auto_reply_enabled_at"}:
if name in data:
target[name] = str(data.get(name) or "").strip()
if "email_auto_reply" in data:
next_auto_reply = bool(target.get("email_auto_reply", False))
if next_auto_reply and (not prev_auto_reply or not str(target.get("email_auto_reply_enabled_at") or "").strip()):
target["email_auto_reply_enabled_at"] = datetime.utcnow().isoformat()
elif not next_auto_reply:
target.pop("email_auto_reply_enabled_at", None)
if key:
by_account = settings.get("email_auto_reply_by_account")
if not isinstance(by_account, dict):
by_account = {}
by_account[key] = {k: target.get(k) for k in _AUTO_REPLY_KEYS if k in target}
by_account[key]["email_auto_reply_account_id"] = key
by_account[key]["email_auto_reply_scope"] = "account"
settings["email_auto_reply_by_account"] = by_account
return prev_auto_reply, bool(target.get("email_auto_reply", False))
def _safe_attachment_zip_name(name: str, fallback: str) -> str:
"""Return a zip entry filename without path traversal or empty names."""
@@ -273,6 +354,25 @@ def _record_email_received_events(owner: str, account_id: str | None, folder: st
for _ in new_keys[:50]:
fire_event("email_received", owner)
logger.info("Fired email_received for %d new message(s)", min(len(new_keys), 50))
try:
loop = asyncio.get_running_loop()
async def _run_away_reply_check():
try:
from routes.email_pollers import _auto_summarize_pass
result = await _auto_summarize_pass(
days_back=1,
account_id=account_id,
max_process=min(max(len(new_keys), 1), 5),
away_only=True,
)
logger.info("Auto away-reply pass after email_received account=%s: %s", account_id, result)
except Exception:
logger.warning("Auto away-reply pass after email_received failed", exc_info=True)
loop.create_task(_run_away_reply_check())
except RuntimeError:
logger.debug("No running event loop for immediate away-reply check")
except Exception:
logger.debug("email_received event detection skipped", exc_info=True)
@@ -383,6 +483,148 @@ def _uid_from_fetch_meta(meta_b: bytes) -> str:
return m.group(1).decode() if m else ""
def _parse_list_unsubscribe_header(value: str | None) -> list[dict]:
"""Parse RFC List-Unsubscribe entries into safe reviewable actions.
We return mailto/http entries but only the mailto kind is executable by the
first-pass Odysseus flow. HTTP unsubscribe links are useful evidence but
often contain tracking tokens and should be opened manually unless/until we
add a browser-confirmed flow.
"""
raw = str(value or "").strip()
if not raw:
return []
pieces = re.findall(r"<([^>]+)>", raw)
if not pieces:
pieces = [p.strip() for p in raw.split(",") if p.strip()]
out: list[dict] = []
seen = set()
for piece in pieces:
target = piece.strip().strip("<>").strip()
if not target:
continue
parsed = urlparse(target)
scheme = parsed.scheme.lower()
key = target.lower()
if key in seen:
continue
seen.add(key)
if scheme == "mailto":
addr = unquote(parsed.path or "").strip()
if not addr or "\r" in addr or "\n" in addr:
continue
query = parse_qs(parsed.query or "", keep_blank_values=True)
subject = unquote((query.get("subject") or ["unsubscribe"])[0] or "unsubscribe")
body = unquote((query.get("body") or ["unsubscribe"])[0] or "unsubscribe")
subject = re.sub(r"[\r\n]+", " ", subject).strip() or "unsubscribe"
body = re.sub(r"[\r\n]+", "\n", body).strip() or "unsubscribe"
out.append({
"kind": "mailto",
"target": addr,
"subject": subject[:200],
"body": body[:1000],
"executable": True,
})
elif scheme in {"http", "https"}:
out.append({
"kind": "url",
"target": target,
"executable": False,
})
return out
def _email_unsubscribe_candidate_from_msg(msg, uid: str, folder: str, *, spam_cached: dict | None = None) -> dict | None:
sender = _decode_header(msg.get("From", ""))
sender_name, sender_addr = email.utils.parseaddr(sender)
subject = _decode_header(msg.get("Subject", "(no subject)"))
list_id = _decode_header(msg.get("List-Id", ""))
precedence = (msg.get("Precedence") or "").strip().lower()
auto_submitted = (msg.get("Auto-Submitted") or "").strip().lower()
methods = _parse_list_unsubscribe_header(msg.get("List-Unsubscribe"))
has_unsub = bool(methods)
reasons: list[str] = []
score = 0
if has_unsub:
score += 45
reasons.append("has unsubscribe header")
if list_id:
score += 20
reasons.append("mailing-list header")
if precedence in {"bulk", "junk", "list"}:
score += 20
reasons.append(f"precedence={precedence}")
if auto_submitted and auto_submitted != "no":
score += 10
reasons.append(f"auto-submitted={auto_submitted}")
if spam_cached and spam_cached.get("spam"):
score += 35
if spam_cached.get("reason"):
reasons.append(str(spam_cached.get("reason")))
else:
reasons.append("previously classified as spam")
subj_l = (subject or "").lower()
if re.search(r"\b(unsubscribe|newsletter|sale|discount|offer|promo|limited time)\b", subj_l):
score += 10
reasons.append("promotional subject")
executable = [m for m in methods if m.get("executable")]
if score < 45 or not has_unsub:
return None
return {
"uid": str(uid),
"folder": folder,
"message_id": (msg.get("Message-ID") or "").strip(),
"subject": subject,
"from_name": sender_name or sender_addr,
"from_address": sender_addr,
"list_id": list_id,
"score": min(score, 100),
"reasons": reasons[:5],
"methods": methods,
"can_execute": bool(executable),
"recommended_method": executable[0] if executable else (methods[0] if methods else None),
"spam_reason": (spam_cached or {}).get("reason") or "",
}
def _unsubscribe_candidate_dedupe_key(candidate: dict) -> tuple[str, str, str]:
list_id = str(candidate.get("list_id") or "").strip().lower()
method = candidate.get("recommended_method") or {}
method_kind = str(method.get("kind") or "").strip().lower()
method_target = str(method.get("target") or "").strip().lower()
sender = str(candidate.get("from_address") or "").strip().lower()
if list_id:
return ("list", list_id, method_target or sender)
if method_target:
return ("method", method_kind, method_target)
return ("sender", sender, str(candidate.get("subject") or "").strip().lower())
def _dedupe_unsubscribe_candidates(candidates: list[dict]) -> list[dict]:
deduped: dict[tuple[str, str, str], dict] = {}
for candidate in candidates or []:
key = _unsubscribe_candidate_dedupe_key(candidate)
existing = deduped.get(key)
if not existing:
copy = dict(candidate)
copy["duplicate_count"] = 1
copy["duplicate_uids"] = [str(candidate.get("uid") or "")]
deduped[key] = copy
continue
existing["duplicate_count"] = int(existing.get("duplicate_count") or 1) + 1
uid = str(candidate.get("uid") or "")
if uid:
existing.setdefault("duplicate_uids", []).append(uid)
if int(candidate.get("score") or 0) > int(existing.get("score") or 0):
keep_count = existing.get("duplicate_count")
keep_uids = existing.get("duplicate_uids")
replacement = dict(candidate)
replacement["duplicate_count"] = keep_count
replacement["duplicate_uids"] = keep_uids
deduped[key] = replacement
return list(deduped.values())
_FETCH_SEQ_RE = re.compile(rb"^(\d+)\s+\(")
@@ -517,6 +759,82 @@ def _email_index_rows(owner: str, account_id: str | None, folder: str, uids: lis
return out
def _email_index_list(owner: str, account_id: str | None, folder: str, filter_: str, limit: int, offset: int, has_attachments: bool = False) -> tuple[list[dict], int, str | None]:
"""Return a newest-first page from the durable local email index.
This is intentionally a paint-fast cache path for the UI, not the source of
truth. The normal IMAP list still runs after this in the browser to refresh
flags/new mail.
"""
limit = max(1, min(int(limit or 50), 200))
offset = max(0, int(offset or 0))
account_key = _account_cache_key(account_id, owner)
clauses = ["owner=?", "account_key=?", "folder=?"]
params: list = [owner or "", account_key, folder]
if filter_ == "unread":
clauses.append("(flags IS NULL OR instr(flags, '\\Seen') = 0)")
elif filter_ in {"unanswered", "undone"}:
clauses.append("(flags IS NULL OR instr(flags, '\\Answered') = 0)")
elif filter_ == "favorites":
clauses.append("instr(COALESCE(flags, ''), '\\Flagged') > 0")
elif filter_ not in {"all", "", None}:
return [], 0, None
if has_attachments:
clauses.append("has_attachments=1")
where = " AND ".join(clauses)
try:
conn = _sql3.connect(SCHEDULED_DB)
try:
total_row = conn.execute(
f"SELECT COUNT(*), MAX(updated_at) FROM email_message_index WHERE {where}",
params,
).fetchone()
total = int((total_row or [0])[0] or 0)
if not total:
return [], 0, (total_row or [None, None])[1]
rows = conn.execute(
f"""
SELECT uid, message_id, subject, from_name, from_address, to_text, cc_text,
date_iso, date_display, date_epoch, size, flags, has_attachments
FROM email_message_index
WHERE {where}
ORDER BY date_epoch DESC
LIMIT ? OFFSET ?
""",
[*params, limit, offset],
).fetchall()
finally:
conn.close()
except Exception:
logger.debug("email index list skipped", exc_info=True)
return [], 0, None
emails: list[dict] = []
for row in rows:
uid, message_id, subject, from_name, from_address, to_text, cc_text, date_iso, date_display, date_epoch, size, flags, has_attachments_raw = row
flags = flags or ""
emails.append({
"uid": str(uid),
"message_id": (message_id or "").strip(),
"subject": subject or "(no subject)",
"from_name": from_name or from_address or "",
"from_address": from_address or "",
"to": to_text or "",
"cc": cc_text or "",
"date": date_iso or "",
"date_display": date_display or "",
"date_epoch": float(date_epoch or 0),
"size": int(size or 0),
"is_read": "\\Seen" in flags,
"is_answered": "\\Answered" in flags,
"is_flagged": "\\Flagged" in flags,
"flags": flags,
"has_attachments": bool(has_attachments_raw),
"folder": folder,
})
return emails, total, (total_row or [None, None])[1]
def _email_index_search(owner: str, account_id: str | None, folder: str, query: str, limit: int, global_search: bool = True) -> tuple[list[dict], int, str | None]:
q = (query or "").strip()
if not q:
@@ -990,8 +1308,7 @@ def _normalize_addr_field(field: str) -> str:
"""Strip the malformed-but-common trailing/leading commas and stray
whitespace from a To/Cc/Bcc string before it lands in the MIME header
or the SMTP envelope. Users often paste a single address with a
trailing comma (e.g. `user@example.com,`) and most MTAs reject the
resulting `To: user@example.com,` line as a syntax error. Collapse
trailing comma, which most MTAs reject as a syntax error. Collapse
any run of separator junk between addresses too."""
if not field:
return field
@@ -1162,7 +1479,7 @@ def setup_email_routes():
_IMAP_POOL = {} # account_id → (conn, last_used_at)
_IMAP_IDLE_MAX = 60.0
_WARMING_READS = set()
_WARM_READ_LIMIT = 2
_WARM_READ_LIMIT = 6
_WARM_MAX_BYTES = 192 * 1024
_WARM_RECENT_SECONDS = 7 * 24 * 60 * 60
_pool_lock = _threading.Lock()
@@ -1245,6 +1562,10 @@ def setup_email_routes():
return None
return v[1]
def _folder_cache_get_stale(account_id, owner):
v = _FOLDER_CACHE.get((account_id or "", owner or ""))
return v[1] if v else None
def _folder_cache_put(account_id, owner, value):
_FOLDER_CACHE[(account_id or "", owner or "")] = (_time.monotonic() + _FOLDER_TTL, value)
if len(_FOLDER_CACHE) > 32:
@@ -1951,18 +2272,44 @@ def setup_email_routes():
from_addr: str | None = Query(None, alias="from"),
account_id: str | None = Query(None),
has_attachments: int = Query(0),
cached_only: int = Query(0),
cache_bust: str | None = Query(None, alias="_"),
owner: str = Depends(require_owner),
):
"""List emails. Uses an 8s in-memory cache + offloads blocking IMAP
calls to a worker thread so the event loop never stalls."""
started_at = _time.monotonic()
_deferred = getattr(_start_poller, '_deferred', None)
if _deferred:
await _deferred()
fixture_result = _fixture_email_list(folder, limit, offset, filter, from_addr, owner)
if fixture_result is not None:
return fixture_result
if cached_only and not from_addr:
indexed_emails, indexed_total, indexed_at = _email_index_list(
owner, account_id, folder, filter, limit, offset, bool(has_attachments),
)
if indexed_total:
_hide_unlinked_calendar_tags(indexed_emails)
return {
"emails": indexed_emails,
"total": indexed_total,
"folder": folder,
"offset": offset,
"sync": {
"source": "index",
"indexed": indexed_total,
"updated_at": indexed_at,
"cached_only": True,
},
}
return {
"emails": [],
"total": 0,
"folder": folder,
"offset": offset,
"sync": {"source": "index", "cached_only": True},
}
_deferred = getattr(_start_poller, '_deferred', None)
if _deferred:
await _deferred()
# SECURITY: include `owner` in the cache key so two users with
# different account scopes don't share a cached list.
ck = _list_cache_key(account_id, folder, filter, limit, offset, from_addr or "") + (int(bool(has_attachments)), owner)
@@ -2090,6 +2437,230 @@ def setup_email_routes():
logger.error(f"unflag-spam failed: {e}")
return {"ok": False, "error": "Mail operation failed"}
def _unsubscribe_spam_cache(owner: str, account_id: str | None, folder: str) -> dict[str, dict]:
out: dict[str, dict] = {}
try:
conn = _sql3.connect(SCHEDULED_DB)
try:
owner_clause, owner_params = _email_tag_owner_clause(account_id, owner)
account_clause, account_params = _email_tag_account_clause(account_id)
rows = conn.execute(
"SELECT uid, message_id, spam_verdict, spam_reason FROM email_tags "
"WHERE folder=? AND "
f"{owner_clause} AND {account_clause}",
(folder, *owner_params, *account_params),
).fetchall()
finally:
conn.close()
for uid, message_id, spam_verdict, spam_reason in rows:
payload = {"spam": bool(spam_verdict), "reason": spam_reason or ""}
if uid:
out[str(uid)] = payload
if message_id:
out[str(message_id).strip()] = payload
except Exception:
logger.debug("unsubscribe spam cache lookup skipped", exc_info=True)
return out
def _scan_unsubscribe_candidates_sync(folder: str, account_id: str | None, owner: str, limit: int, max_scan: int) -> dict:
folder = folder or "INBOX"
limit = max(1, min(int(limit or 25), 100))
max_scan = max(limit, min(int(max_scan or 150), 500))
spam_cache = _unsubscribe_spam_cache(owner, account_id, folder)
candidates: list[dict] = []
with _imap(account_id, owner=owner) as conn:
st, _ = conn.select(_q(folder), readonly=True)
if st != "OK":
return {"success": False, "error": f"Folder not found: {folder}", "candidates": []}
st, data = _imap_uid_search(conn, "ALL")
if st != "OK" or not data or not data[0]:
return {"success": True, "candidates": [], "total": 0, "scanned": 0, "folder": folder}
uids = []
for raw_uid in data[0].split():
try:
uids.append(int(raw_uid))
except Exception:
continue
uids = sorted(uids, reverse=True)[:max_scan]
if not uids:
return {"success": True, "candidates": [], "total": 0, "scanned": 0, "folder": folder}
fetch_set = ",".join(str(u) for u in uids)
st, msg_data = _imap_uid_fetch(conn, fetch_set, "(UID RFC822.HEADER)")
if st != "OK":
return {"success": False, "error": "Failed to fetch email headers", "candidates": []}
for item in msg_data or []:
if not isinstance(item, tuple) or len(item) < 2:
continue
meta_b = item[0] if isinstance(item[0], bytes) else str(item[0]).encode()
uid = _uid_from_fetch_meta(meta_b)
if not uid:
continue
try:
msg = email_mod.message_from_bytes(item[1] or b"")
except Exception:
continue
mid = (msg.get("Message-ID") or "").strip()
spam_info = spam_cache.get(uid) or (spam_cache.get(mid) if mid else None) or {}
candidate = _email_unsubscribe_candidate_from_msg(msg, uid, folder, spam_cached=spam_info)
if candidate:
candidates.append(candidate)
def _uid_sort_value(candidate: dict) -> int:
try:
return int(candidate.get("uid") or 0)
except Exception:
return 0
raw_total = len(candidates)
candidates = _dedupe_unsubscribe_candidates(candidates)
candidates.sort(key=lambda c: (int(c.get("score") or 0), int(c.get("duplicate_count") or 1), _uid_sort_value(c)), reverse=True)
return {
"success": True,
"candidates": candidates[:limit],
"total": len(candidates),
"raw_total": raw_total,
"scanned": len(uids),
"folder": folder,
"account_id": account_id or "",
}
@router.get("/unsubscribe/scan")
async def scan_unsubscribe_candidates(
folder: str = Query("INBOX"),
account_id: str | None = Query(None),
limit: int = Query(25),
max_scan: int = Query(150),
owner: str = Depends(require_owner),
):
"""Review-only scan for spam/newsletter unsubscribe candidates."""
if account_id:
_assert_owns_account(account_id, owner)
if _fixture_email_enabled():
return {"success": True, "candidates": [], "total": 0, "scanned": 0, "folder": folder, "sync": {"source": "fixture"}}
try:
return await _asyncio.to_thread(_scan_unsubscribe_candidates_sync, folder, account_id, owner, limit, max_scan)
except Exception as e:
logger.error(f"unsubscribe scan failed: {e}")
return {"success": False, "error": "Mail operation failed", "candidates": []}
@router.post("/unsubscribe/execute")
def execute_unsubscribe(data: dict, owner: str = Depends(require_owner)):
"""Execute an approved unsubscribe action.
First implementation supports mailto List-Unsubscribe only. The message
header is re-read server-side; client-supplied addresses are not trusted.
"""
uid = str((data or {}).get("uid") or "").strip()
folder = str((data or {}).get("folder") or "INBOX").strip() or "INBOX"
account_id = (data or {}).get("account_id") or None
method_index = int((data or {}).get("method_index") or 0)
move_to_spam = bool((data or {}).get("move_to_spam"))
if not uid:
raise HTTPException(400, "Missing uid")
if account_id:
_assert_owns_account(account_id, owner)
try:
with _imap(account_id, owner=owner) as conn:
st, _ = conn.select(_q(folder), readonly=True)
if st != "OK":
return {"success": False, "error": f"Folder not found: {folder}"}
st, msg_data = _imap_uid_fetch(conn, uid, "(UID RFC822.HEADER)")
if st != "OK" or not msg_data:
return {"success": False, "error": "Email not found"}
raw_header = b""
for item in msg_data or []:
if isinstance(item, tuple) and len(item) >= 2:
raw_header = item[1] or b""
break
msg = email_mod.message_from_bytes(raw_header)
candidate = _email_unsubscribe_candidate_from_msg(msg, uid, folder)
if not candidate:
return {"success": False, "error": "No unsubscribe header found for this email"}
methods = [m for m in (candidate.get("methods") or []) if m.get("kind") == "mailto" and m.get("executable")]
if not methods:
return {"success": False, "error": "This email only has web unsubscribe links; open manually for now", "candidate": candidate}
method = methods[method_index] if 0 <= method_index < len(methods) else methods[0]
target = str(method.get("target") or "").strip()
if not target or "\r" in target or "\n" in target:
return {"success": False, "error": "Invalid unsubscribe address"}
cfg = _resolve_send_config(account_id, owner=owner)
subject = str(method.get("subject") or "unsubscribe")[:200]
body = str(method.get("body") or "unsubscribe")[:1000]
msg_out = MIMEText(body or "unsubscribe", "plain", "utf-8")
msg_out["From"] = email.utils.formataddr((cfg.get("display_name") or "", cfg["from_address"]))
msg_out["To"] = target
msg_out["Subject"] = subject
msg_out["Message-ID"] = email.utils.make_msgid(domain="odysseus.local")
_apply_odysseus_headers(msg_out, "unsubscribe", uid)
_send_smtp_message(cfg, cfg["from_address"], [target], msg_out.as_string())
moved = False
if move_to_spam:
try:
with _imap(account_id, owner=owner) as conn:
conn.select(_q(folder))
moved = _move_email_message(conn, uid, "Junk", role="junk")
if moved:
_email_index_delete(owner, account_id, folder, uid)
_invalidate_list_cache(account_id)
except Exception:
logger.debug("unsubscribe move-to-spam skipped", exc_info=True)
return {
"success": True,
"method": method,
"candidate": candidate,
"moved_to_spam": moved,
}
except ValueError as e:
return {"success": False, "error": str(e)}
except Exception as e:
logger.error(f"unsubscribe execute failed uid={uid}: {e}")
return {"success": False, "error": "Mail operation failed"}
@router.post("/unsubscribe/cleanup")
def cleanup_unsubscribe_candidates(data: dict, owner: str = Depends(require_owner)):
"""Move reviewed unsubscribe candidate messages to Junk or Trash."""
folder = str((data or {}).get("folder") or "INBOX").strip() or "INBOX"
account_id = (data or {}).get("account_id") or None
action = str((data or {}).get("action") or "").strip().lower()
raw_uids = (data or {}).get("uids") or []
if action not in {"junk", "delete"}:
raise HTTPException(400, "Unsupported cleanup action")
if account_id:
_assert_owns_account(account_id, owner)
uids: list[str] = []
seen = set()
for raw_uid in raw_uids:
uid = str(raw_uid or "").strip()
if not uid or uid in seen:
continue
if not uid.isdigit():
continue
seen.add(uid)
uids.append(uid)
if not uids:
return {"success": False, "error": "No email UIDs provided", "changed": 0, "failed": 0}
role = "junk" if action == "junk" else "trash"
target = "Junk" if action == "junk" else "Trash"
changed = 0
failed = 0
try:
with _imap(account_id, owner=owner) as conn:
conn.select(_q(folder))
for uid in uids:
try:
if _move_email_message(conn, uid, target, role=role):
changed += 1
_email_index_delete(owner, account_id, folder, uid)
else:
failed += 1
except Exception:
failed += 1
logger.debug("unsubscribe cleanup failed for uid=%s", uid, exc_info=True)
if changed:
_invalidate_list_cache(account_id)
return {"success": True, "action": action, "changed": changed, "failed": failed}
except Exception as e:
logger.error(f"unsubscribe cleanup failed: {e}")
return {"success": False, "error": "Mail operation failed", "changed": changed, "failed": failed}
@router.get("/contacts")
async def list_contacts(
q: str = Query(""),
@@ -2566,7 +3137,7 @@ def setup_email_routes():
return
async def _warm():
await _asyncio.sleep(3.0)
await _asyncio.sleep(0.25)
for uid, ck in selected:
if _read_cache_get(ck) is not None:
_WARMING_READS.discard(ck)
@@ -2739,7 +3310,7 @@ def setup_email_routes():
return {"error": "Mail operation failed"}
@router.post("/attachment-as-doc/{uid}/{index}")
async def attachment_as_doc(uid: str, index: int, request: Request, folder: str = Query("INBOX"), account_id: str | None = Query(None), owner: str = Depends(require_owner)):
def attachment_as_doc(uid: str, index: int, request: Request, folder: str = Query("INBOX"), account_id: str | None = Query(None), owner: str = Depends(require_owner)):
"""Extract an email attachment and open it in the document editor.
Supported extensions:
@@ -3225,7 +3796,11 @@ def setup_email_routes():
return {"success": False, "error": "Mail operation failed"}
@router.get("/folders")
async def list_folders(account_id: str | None = Query(None), owner: str = Depends(require_owner)):
async def list_folders(
account_id: str | None = Query(None),
cached_only: int = Query(0),
owner: str = Depends(require_owner),
):
"""List IMAP folders."""
if _fixture_email_enabled():
return {"folders": ["INBOX", "Archive", "Sent"], "sync": {"source": "fixture"}}
@@ -3236,19 +3811,57 @@ def setup_email_routes():
sync_meta["source"] = "folder_cache"
payload["sync"] = sync_meta
return payload
try:
if cached_only:
stale = _folder_cache_get_stale(account_id, owner)
if stale:
payload = dict(stale)
sync_meta = dict(payload.get("sync") or {})
sync_meta["source"] = "folder_cache_stale"
payload["sync"] = sync_meta
return payload
return {
"folders": ["INBOX", "Sent", "Archive"],
"sync": {"source": "folder_cached_only_fallback"},
}
def _list_folders_sync():
with _imap(account_id, owner=owner) as conn:
status, folders = conn.list()
result = []
for f in folders:
for f in folders or []:
decoded = f.decode() if isinstance(f, bytes) else f
match = re.search(r'"([^"]*)"$|(\S+)$', decoded)
if match:
name = match.group(1) or match.group(2)
result.append(name)
payload = {"folders": result, "sync": {"source": "imap", "updated_at": datetime.utcnow().isoformat() + "Z"}}
return {
"folders": result,
"sync": {
"source": "imap",
"updated_at": datetime.utcnow().isoformat() + "Z",
"status": status.decode() if isinstance(status, bytes) else status,
},
}
try:
payload = await _asyncio.wait_for(_asyncio.to_thread(_list_folders_sync), timeout=8.0)
_folder_cache_put(account_id, owner, payload)
return payload
except _asyncio.TimeoutError:
logger.warning(f"list_folders timed out for account={account_id or 'default'} owner={owner}")
stale = _folder_cache_get_stale(account_id, owner)
if stale:
payload = dict(stale)
sync_meta = dict(payload.get("sync") or {})
sync_meta["source"] = "folder_cache_stale"
sync_meta["warning"] = "Folder list timed out"
payload["sync"] = sync_meta
return payload
return {
"folders": ["INBOX", "Sent", "Archive"],
"error": "Folder list timed out",
"sync": {"source": "folder_timeout_fallback"},
}
except Exception as e:
logger.error(f"list_folders failed: {e}")
return {"folders": [], "error": "Mail operation failed"}
@@ -4040,17 +4653,23 @@ def setup_email_routes():
return {"success": True, "message": "Draft saved"}
@router.post("/extract-style")
async def extract_writing_style(req: ExtractStyleRequest, owner: str = Depends(require_owner)):
async def extract_writing_style(
req: ExtractStyleRequest,
account_id: str | None = Query(None),
owner: str = Depends(require_owner),
):
"""Extract writing style from sent emails using LLM.
IMAP fetch is offloaded to a worker thread; the LLM call uses the
async client. Otherwise this handler froze the event loop for ~5s
on the IMAP step alone with a remote server.
"""
if account_id:
_assert_owns_account(account_id, owner)
def _gather_samples() -> tuple[list[str], str | None]:
try:
with _imap(owner=owner) as imap:
with _imap(account_id, owner=owner) as imap:
imap.select(_q(_detect_sent_folder(imap)), readonly=True)
status, data = imap.search(None, "ALL")
if status != "OK" or not data[0]:
@@ -4132,7 +4751,7 @@ def setup_email_routes():
# Save to settings
settings = _load_settings()
settings["email_writing_style"] = style
_set_email_writing_style_for_account(settings, style, account_id)
_save_settings(settings)
logger.info("Writing style extracted and saved")
@@ -4424,8 +5043,11 @@ def setup_email_routes():
message_id = (data.get("message_id") or "").strip()
source_uid = (data.get("uid") or "").strip()
source_folder = (data.get("folder") or "INBOX").strip()
account_id = (data.get("account_id") or "").strip() or None
fast_reply = bool(data.get("fast", False))
user_hint = (data.get("user_hint") or "").strip()
if account_id:
_assert_owns_account(account_id, owner)
if not original_body:
return {"success": False, "error": "No email body provided"}
@@ -4433,7 +5055,7 @@ def setup_email_routes():
# Skip cache lookup when the caller supplied a user_hint — the
# cached generic reply doesn't reflect the instructions and
# would silently override them.
if message_id and not user_hint:
if message_id and not user_hint and not account_id:
try:
_c = _sql3.connect(SCHEDULED_DB)
owner_clause, owner_params = _email_cache_owner_clause(owner)
@@ -4455,7 +5077,7 @@ def setup_email_routes():
logger.warning(f"AI reply cache lookup failed: {e}")
settings = _load_settings()
style = settings.get("email_writing_style", "")
style = _get_email_writing_style_for_account(settings, account_id)
# Try session's endpoint first if session_id provided
url = None
@@ -4576,8 +5198,11 @@ def setup_email_routes():
)
if user_hint:
user_msg += (
f"User's instructions for THIS reply (follow these — they override "
f"defaults like length/tone):\n{user_hint[:2000]}\n\n"
"User guidance for THIS reply. Treat this as intent/context to fold "
"into a normal polished email reply in the user's writing style. "
"Do not answer with only this guidance unless the user explicitly "
"asked for a one-word reply:\n"
f"{user_hint[:2000]}\n\n"
)
user_msg += "Draft a reply. Return only the reply body text."
@@ -4703,23 +5328,41 @@ def setup_email_routes():
return {"success": False, "error": "Mail operation failed"}
@router.get("/style")
async def get_writing_style(owner: str = Depends(require_user)):
async def get_writing_style(
account_id: str | None = Query(None),
owner: str = Depends(require_user),
):
"""Get the current writing style prompt."""
if account_id:
_assert_owns_account(account_id, owner)
settings = _load_settings()
return {"style": settings.get("email_writing_style", "")}
return {"style": _get_email_writing_style_for_account(settings, account_id)}
@router.put("/style")
async def update_writing_style(data: dict, owner: str = Depends(require_user)):
async def update_writing_style(
data: dict,
account_id: str | None = Query(None),
owner: str = Depends(require_user),
):
"""Manually update the writing style prompt."""
if account_id:
_assert_owns_account(account_id, owner)
settings = _load_settings()
settings["email_writing_style"] = data.get("style", "")
_set_email_writing_style_for_account(settings, data.get("style", ""), account_id)
_save_settings(settings)
return {"success": True}
@router.get("/config")
async def get_email_config(owner: str = Depends(require_user)):
async def get_email_config(
account_id: str | None = Query(None),
owner: str = Depends(require_user),
):
"""Get email configuration (passwords masked)."""
cfg = _get_email_config(owner=owner)
if account_id is not None and not isinstance(account_id, str):
account_id = None
if account_id:
_assert_owns_account(account_id, owner)
cfg = _get_email_config(account_id, owner=owner)
cfg["smtp_password"] = "***" if cfg["smtp_password"] else ""
cfg["imap_password"] = "***" if cfg["imap_password"] else ""
# `_get_email_config` includes encrypted OAuth fields for the server's
@@ -4731,11 +5374,21 @@ def setup_email_routes():
cfg.pop("oauth_token_expiry", None)
# Include preferences from settings.json
settings = _load_settings()
auto_reply_settings = _get_auto_reply_settings_for_account(settings, account_id)
cfg["email_auto_summarize"] = bool(settings.get("email_auto_summarize", False))
cfg["email_auto_reply"] = bool(settings.get("email_auto_reply", False))
cfg["email_auto_reply"] = bool(auto_reply_settings.get("email_auto_reply", False))
cfg["email_auto_tag"] = bool(settings.get("email_auto_tag", False))
cfg["email_auto_spam"] = bool(settings.get("email_auto_spam", False))
cfg["email_auto_calendar"] = bool(settings.get("email_auto_calendar", False))
cfg["email_auto_reply_start"] = auto_reply_settings.get("email_auto_reply_start", "")
cfg["email_auto_reply_end"] = auto_reply_settings.get("email_auto_reply_end", "")
cfg["email_auto_reply_subject"] = auto_reply_settings.get("email_auto_reply_subject", "(Away) {subject}")
cfg["email_auto_reply_message"] = auto_reply_settings.get("email_auto_reply_message", "")
cfg["email_auto_reply_cooldown"] = auto_reply_settings.get("email_auto_reply_cooldown", "period")
cfg["email_auto_reply_scope"] = auto_reply_settings.get("email_auto_reply_scope", "account" if account_id else "all")
cfg["email_auto_reply_account_id"] = auto_reply_settings.get("email_auto_reply_account_id", account_id or "")
cfg["email_auto_reply_exclude_automated"] = bool(auto_reply_settings.get("email_auto_reply_exclude_automated", True))
cfg["email_auto_reply_pause_notifications"] = bool(auto_reply_settings.get("email_auto_reply_pause_notifications", False))
# Email translation is owned by the background task now; opening an email
# should not trigger reader-side auto-translation from Settings.
cfg["email_auto_translate"] = False
@@ -4743,7 +5396,11 @@ def setup_email_routes():
return cfg
@router.put("/config")
async def update_email_config(data: dict, owner: str = Depends(require_owner)):
async def update_email_config(
data: dict,
account_id: str | None = Query(None),
owner: str = Depends(require_owner),
):
"""Update email configuration.
Automation flags (email_auto_*) still live in settings.json. Credentials
@@ -4751,11 +5408,19 @@ def setup_email_routes():
overwritten when a non-empty value is provided, so saving the form
without retyping the password no longer wipes it.
"""
# Automation flags stay in settings.json (they're global, not per-account)
if account_id:
_assert_owns_account(account_id, owner)
# Non-reply automation flags stay global. Away/auto-reply settings are
# account-scoped when an account_id is supplied by the Email Settings UI.
settings = _load_settings()
for key in ["email_auto_summarize", "email_auto_reply", "email_auto_tag", "email_auto_spam", "email_auto_calendar"]:
bool_keys = [
"email_auto_summarize", "email_auto_tag", "email_auto_spam", "email_auto_calendar",
]
for key in bool_keys:
if key in data:
settings[key] = data[key]
settings[key] = bool(data[key])
_set_auto_reply_settings_for_account(settings, data, account_id)
_save_settings(settings)
# Credentials go into the default account row
+362 -3
View File
@@ -1,7 +1,9 @@
"""Gallery routes — browsable library for photos and AI-generated images."""
import os
import base64
import hashlib
import io
import logging
import re
import uuid
@@ -27,6 +29,165 @@ from routes.gallery.gallery_helpers import (
logger = logging.getLogger(__name__)
_SAM_STATE: Dict[str, Any] = {}
_GROUNDING_STATE: Dict[str, Any] = {}
def _b64_to_pil_image(image_b64: str, *, mode: str = "RGBA"):
if not image_b64:
raise HTTPException(400, "Missing image")
if "," in image_b64 and image_b64.split(",", 1)[0].startswith("data:"):
image_b64 = image_b64.split(",", 1)[1]
try:
from PIL import Image
raw = base64.b64decode(image_b64)
return Image.open(io.BytesIO(raw)).convert(mode)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(400, "Invalid image") from exc
def _pil_image_to_b64(img, *, fmt: str = "PNG") -> str:
buf = io.BytesIO()
img.save(buf, format=fmt)
return base64.b64encode(buf.getvalue()).decode("ascii")
def _load_sam_backend():
model_id = os.getenv("ODYSSEUS_SAM_MODEL", "facebook/sam-vit-base")
cached = _SAM_STATE.get(model_id)
if cached:
return cached
try:
import torch
from transformers import SamModel, SamProcessor
except Exception as exc:
raise HTTPException(
501,
"SAM mask tools are not installed. Install Cookbook Dependencies -> SAM mask tools.",
) from exc
device = "cpu"
try:
if torch.cuda.is_available():
device = "cuda"
elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
device = "mps"
except Exception:
device = "cpu"
try:
processor = SamProcessor.from_pretrained(model_id)
model = SamModel.from_pretrained(model_id)
model.to(device)
model.eval()
except Exception as exc:
raise HTTPException(500, f"Failed to load SAM model {model_id}: {exc}") from exc
cached = {"torch": torch, "processor": processor, "model": model, "device": device, "model_id": model_id}
_SAM_STATE[model_id] = cached
return cached
def _load_grounding_backend():
model_id = os.getenv("ODYSSEUS_GROUNDING_MODEL", "google/owlvit-base-patch32")
cached = _GROUNDING_STATE.get(model_id)
if cached:
return cached
try:
import torch
from transformers import OwlViTForObjectDetection, OwlViTProcessor
except Exception as exc:
raise HTTPException(
501,
"Object mask tools are not installed. Install Cookbook Dependencies -> SAM mask tools.",
) from exc
device = "cpu"
try:
if torch.cuda.is_available():
device = "cuda"
elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
device = "mps"
except Exception:
device = "cpu"
try:
processor = OwlViTProcessor.from_pretrained(model_id)
model = OwlViTForObjectDetection.from_pretrained(model_id)
model.to(device)
model.eval()
except Exception as exc:
raise HTTPException(500, f"Failed to load object mask model {model_id}: {exc}") from exc
cached = {"torch": torch, "processor": processor, "model": model, "device": device, "model_id": model_id}
_GROUNDING_STATE[model_id] = cached
return cached
def _ground_text_to_box(image, text: str, *, threshold: float = 0.05):
query = (text or "").strip()
if not query:
raise HTTPException(400, "Missing object text")
backend = _load_grounding_backend()
torch = backend["torch"]
processor = backend["processor"]
model = backend["model"]
device = backend["device"]
labels = [query]
if not query.lower().startswith(("a ", "an ", "the ")):
labels.append(f"a photo of {query}")
try:
inputs = processor(text=[labels], images=image, return_tensors="pt")
model_inputs = {
k: (v.to(device) if hasattr(v, "to") else v)
for k, v in inputs.items()
}
with torch.no_grad():
outputs = model(**model_inputs)
target_sizes = torch.tensor([[image.height, image.width]])
if hasattr(processor, "post_process_object_detection"):
results = processor.post_process_object_detection(
outputs=outputs,
target_sizes=target_sizes,
threshold=float(threshold),
)
elif hasattr(processor, "post_process_grounded_object_detection"):
results = processor.post_process_grounded_object_detection(
outputs=outputs,
target_sizes=target_sizes,
threshold=float(threshold),
text_labels=[labels],
)
else:
raise HTTPException(500, "Installed Transformers does not expose OWL-ViT object detection post-processing")
boxes = results[0].get("boxes")
scores = results[0].get("scores")
labels_idx = results[0].get("labels")
text_labels = results[0].get("text_labels") or results[0].get("labels_text")
if boxes is None or scores is None or len(boxes) == 0:
raise HTTPException(404, f"No visible object matched '{query}'")
idx = int(torch.argmax(scores).item())
box = [float(v) for v in boxes[idx].detach().cpu().tolist()]
label_idx = int(labels_idx[idx].detach().cpu().item()) if labels_idx is not None and len(labels_idx) else 0
label = labels[min(label_idx, len(labels) - 1)]
if text_labels and len(text_labels) > idx:
label = str(text_labels[idx])
return {
"box": box,
"score": float(scores[idx].detach().cpu().item()),
"label": label,
"model": backend["model_id"],
}
except HTTPException:
raise
except Exception as exc:
logger.exception("ground_text_to_box failed")
raise HTTPException(500, f"Object mask failed: {exc}") from exc
def _current_user_is_admin(request: Request, user: str | None) -> bool:
if not user:
@@ -1240,20 +1401,89 @@ def setup_gallery_routes() -> APIRouter:
except httpx.TimeoutException:
raise HTTPException(504, "OpenAI inpaint timed out (120s)")
# Self-hosted diffusion server path
# Self-hosted diffusion server path. Newer Odysseus image
# wrappers expose the OpenAI-compatible /v1/images/edits
# multipart route even when they are local/self-hosted. Older
# diffusion_server.py exposes /v1/images/inpaint as JSON. Try the
# OpenAI-compatible local route first, then fall back.
try:
# Forward chosen_model so the diffusion server can route if it ever
# supports multiple models per process. Harmless if ignored.
if chosen_model:
body["model"] = chosen_model
async with httpx.AsyncClient(timeout=120) as client:
async with httpx.AsyncClient(timeout=240) as client:
try:
import base64, io
from PIL import Image
img_bytes = base64.b64decode(body["image"])
mask_bytes = base64.b64decode(body["mask"])
# Normalize both inputs to PNG bytes. Local MLX and
# Diffusers wrappers expect white mask pixels to mean
# "edit this region", which matches the editor's mask.
source_png = Image.open(io.BytesIO(img_bytes)).convert("RGBA")
mask_png = Image.open(io.BytesIO(mask_bytes)).convert("L")
src_buf = io.BytesIO()
source_png.save(src_buf, format="PNG")
mask_buf = io.BytesIO()
mask_png.save(mask_buf, format="PNG")
files = {
"image": ("source.png", src_buf.getvalue(), "image/png"),
"mask": ("mask.png", mask_buf.getvalue(), "image/png"),
}
data = {
"model": chosen_model or body.get("model") or "",
"prompt": body.get("prompt", ""),
"size": f"{int(body.get('width') or source_png.width)}x{int(body.get('height') or source_png.height)}",
"n": "1",
}
r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), data=data, files=files)
if r.status_code == 200:
result = r.json()
if isinstance(result, dict) and result.get("data"):
item = result["data"][0]
if item.get("b64_json"):
return {"image": item["b64_json"]}
if item.get("url"):
raw_b64 = await _fetch_result_image_b64(item["url"])
if raw_b64:
return {"image": raw_b64}
if isinstance(result, dict) and result.get("image"):
return {"image": result["image"]}
raise HTTPException(502, "Image edit endpoint returned no image")
if r.status_code not in (404, 405):
logger.warning("inpaint_proxy self-hosted edits: status %s", r.status_code)
detail = "Image edit request failed"
try:
err = r.json()
detail = err.get("detail") or err.get("error") or detail
except Exception:
pass
# A plain SD/SDXL checkpoint often exposes
# generation only at /images/edits.
# That does not mean the endpoint cannot inpaint:
# Odysseus diffusion_server.py has a dedicated
# /images/inpaint route that can derive/fallback to
# inpaint, img2img crop+composite, or txt2img
# crop+composite. Fall through to that route instead
# of surfacing "does not support image edits".
if r.status_code == 400 and "does not support image edits" in str(detail).lower():
logger.info("inpaint_proxy self-hosted edits unsupported; falling back to /images/inpaint")
else:
raise HTTPException(r.status_code, detail)
except HTTPException:
raise
except Exception:
logger.exception("inpaint_proxy: failed to prepare self-hosted edit request")
raise HTTPException(400, "Failed to prepare inpaint request")
r = await client.post(_join_checked_gallery_endpoint(base, "/images/inpaint"), json=body)
if r.status_code != 200:
logger.error("inpaint_proxy diffusion: status %s", r.status_code)
raise HTTPException(r.status_code, "Inpaint request failed")
return r.json()
except httpx.TimeoutException:
raise HTTPException(504, "Inpaint request timed out (120s)")
raise HTTPException(504, "Inpaint request timed out (240s)")
except HTTPException:
raise
except Exception:
@@ -1588,6 +1818,135 @@ def setup_gallery_routes() -> APIRouter:
return {"error": "AI upscale failed"}
# ---- POST /api/image/remove-bg ----
@router.post("/api/image/mask")
async def smart_mask(request: Request):
"""Create a neutral segmentation mask from user-provided points or a box.
This endpoint intentionally does not inspect edit prompts. It only
turns explicit visual selection hints into a binary mask that the
editor can reuse for wand/layer-mask/inpaint workflows.
"""
require_privilege(request, "can_generate_images")
body = await request.json()
image = _b64_to_pil_image(body.get("image") or "", mode="RGB")
points = body.get("points") or []
box = body.get("box")
text = (body.get("text") or body.get("query") or "").strip()
grounded = None
if not points and not box and text:
grounded = _ground_text_to_box(image, text)
box = grounded["box"]
if not points and not box:
raise HTTPException(400, "Provide at least one point, box, or object text")
backend = _load_sam_backend()
torch = backend["torch"]
processor = backend["processor"]
model = backend["model"]
device = backend["device"]
kwargs: Dict[str, Any] = {"return_tensors": "pt"}
input_points = []
if points:
input_labels = []
for p in points:
try:
input_points.append([float(p["x"]), float(p["y"])])
input_labels.append(int(p.get("label", 1)))
except Exception as exc:
raise HTTPException(400, "Invalid point format") from exc
kwargs["input_points"] = [input_points]
kwargs["input_labels"] = [input_labels]
if box:
if not isinstance(box, list) or len(box) != 4:
raise HTTPException(400, "Box must be [x1, y1, x2, y2]")
try:
kwargs["input_boxes"] = [[[float(v) for v in box]]]
except Exception as exc:
raise HTTPException(400, "Invalid box format") from exc
try:
inputs = processor(image, **kwargs)
model_inputs = {
k: (v.to(device) if hasattr(v, "to") else v)
for k, v in inputs.items()
}
with torch.no_grad():
outputs = model(**model_inputs)
masks = processor.image_processor.post_process_masks(
outputs.pred_masks.detach().cpu(),
inputs["original_sizes"].detach().cpu(),
inputs["reshaped_input_sizes"].detach().cpu(),
)
mask_tensor = masks[0]
while getattr(mask_tensor, "ndim", 0) > 3:
mask_tensor = mask_tensor[0]
if getattr(mask_tensor, "ndim", 0) == 3:
scores = outputs.iou_scores.detach().cpu()[0]
while getattr(scores, "ndim", 0) > 1:
scores = scores[0]
# SAM commonly returns multiple candidates for a click. The
# highest-IoU candidate can be the entire image, which is
# useless as an editor selection. Prefer a candidate that
# contains the clicked point while keeping area reasonable.
point_xy = None
if input_points:
try:
point_xy = (
int(round(float(input_points[0][0]))),
int(round(float(input_points[0][1]))),
)
except Exception:
point_xy = None
best_idx = 0
best_rank = None
total_px = max(1, int(mask_tensor.shape[-1]) * int(mask_tensor.shape[-2]))
for i in range(int(mask_tensor.shape[0])):
candidate = mask_tensor[i]
area_ratio = float(candidate.sum().item()) / float(total_px)
if area_ratio >= 0.985:
continue
contains_click = True
if point_xy:
px = max(0, min(int(candidate.shape[-1]) - 1, point_xy[0]))
py = max(0, min(int(candidate.shape[-2]) - 1, point_xy[1]))
contains_click = bool(candidate[py, px].item())
if not contains_click:
continue
score = float(scores[min(i, len(scores) - 1)].item()) if len(scores) else 0.0
# Strongly penalize broad masks; a click-selection should
# usually be local unless the user gives a box.
rank = score - (area_ratio * 0.35)
if best_rank is None or rank > best_rank:
best_rank = rank
best_idx = i
if best_rank is None and len(scores):
best_idx = int(torch.argmax(scores).item())
mask_tensor = mask_tensor[min(best_idx, mask_tensor.shape[0] - 1)]
mask_array = (mask_tensor.numpy() > 0).astype("uint8") * 255
from PIL import Image
mask_img = Image.fromarray(mask_array, mode="L")
if mask_img.size != image.size:
mask_img = mask_img.resize(image.size, Image.NEAREST)
bbox = mask_img.getbbox()
result = {
"mask": _pil_image_to_b64(mask_img),
"bbox": list(bbox) if bbox else None,
"model": backend["model_id"],
"device": device,
}
if grounded:
result["grounding"] = grounded
return result
except HTTPException:
raise
except Exception as exc:
logger.exception("smart_mask failed")
raise HTTPException(500, f"SAM mask failed: {exc}") from exc
@router.post("/api/image/remove-bg")
async def remove_background(request: Request):
"""Remove background from an image. If the client passes a `hint_mask`
+98 -5
View File
@@ -137,6 +137,44 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
entry["metadata"] = meta
return entry
def _db_message_metadata(m: DbChatMessage) -> Dict[str, Any]:
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"
return meta
def _hydrate_session_history_from_db(session_id: str, rows: list[DbChatMessage]) -> None:
"""Rebuild in-memory context from raw DB rows after a history load.
The browser history endpoint can return paged/display-trimmed messages,
but the next model call reads ``session.history``. After a restart or a
stale in-memory session, selecting an old chat through the paged endpoint
used to show the transcript while the model only saw fresh context.
"""
if not rows:
return
try:
session = session_manager.get_session(session_id)
except KeyError:
return
session.history = [
ChatMessage(role=m.role, content=m.content, metadata=_db_message_metadata(m) or None)
for m in rows
]
session.message_count = len(session.history)
def _session_needs_db_history_hydration(session_id: str, total: int) -> bool:
try:
session = session_manager.get_session(session_id)
except KeyError:
return False
return len(session.history or []) < int(total or 0)
@router.get("/api/history/{session_id}")
async def get_session_history(
request: Request,
@@ -168,6 +206,14 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
.limit(page_limit)
.all()
)
if _session_needs_db_history_hydration(session_id, total):
full_rows = (
db.query(DbChatMessage)
.filter(DbChatMessage.session_id == session_id)
.order_by(DbChatMessage.timestamp)
.all()
)
_hydrate_session_history_from_db(session_id, full_rows)
history_dict = [
entry for entry in (_db_history_entry(m) for m in rows)
if not (entry.get("metadata") or {}).get("hidden")
@@ -228,10 +274,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
if db_history:
# Rebuild in-memory history from the full set so hidden
# messages (e.g. compaction summaries) are kept for AI context.
session.history = [
ChatMessage(role=m["role"], content=m["content"], metadata=m.get("metadata"))
for m in db_history
]
_hydrate_session_history_from_db(session_id, db_messages)
# Response excludes hidden messages, matching the in-memory path.
history_dict = [
m for m in db_history
@@ -656,6 +699,55 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
except Exception as e:
raise HTTPException(500, f"Topic analysis failed: {e}")
@router.get("/api/session/{session_id}/context")
async def get_session_context_usage(request: Request, session_id: str) -> Dict[str, Any]:
"""Return an estimated whole-chat context usage for the session's model.
Streaming footers report the prompt size for the last request. This
endpoint estimates the persisted session context so the header can show
when the whole chat is approaching compaction.
"""
_verify_session_owner(request, session_id)
try:
session = session_manager.get_session(session_id)
except KeyError:
raise HTTPException(404, "Session not found")
try:
from src.model_context import estimate_tokens, get_context_length
messages = session.get_context_messages()
used = int(estimate_tokens(messages))
ctx_len = int(get_context_length(session.endpoint_url, session.model) or 0)
pct = round((used / ctx_len) * 100, 1) if ctx_len else 0.0
pct = max(0.0, min(100.0, pct))
visible_messages = sum(
1 for m in session.history
if not (getattr(m, "metadata", None) or {}).get("hidden")
)
compacted_messages = sum(
1 for m in session.history
if (getattr(m, "metadata", None) or {}).get("compacted")
)
can_compact = used > 0
return {
"session_id": session_id,
"model": session.model,
"endpoint_url": session.endpoint_url,
"used_tokens": used,
"context_length": ctx_len,
"context_percent": pct,
"messages": visible_messages,
"context_messages": len(messages),
"compacted_messages": compacted_messages,
"can_compact": can_compact,
"should_compact": pct >= 70,
"auto_compact_threshold": 85,
}
except Exception as e:
logger.error(f"Context usage error {session_id}: {e}")
raise HTTPException(500, str(e))
@router.post("/api/session/{session_id}/compact")
async def compact_session(request: Request, session_id: str):
"""Manually trigger context compaction for a session."""
@@ -700,7 +792,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
compact_model = util_model or session.model
compact_headers = util_headers if util_url else session.headers
from src.context_compactor import SELF_SUMMARY_SYSTEM_PROMPT
from src.context_compactor import SELF_SUMMARY_SYSTEM_PROMPT, normalize_compaction_summary
compaction_count = sum(1 for m in session.history if isinstance(m, ChatMessage) and "[Conversation summary" in (m.content or ""))
sys_prompt = SELF_SUMMARY_SYSTEM_PROMPT.replace("{count}", str(len(older))).replace("{n}", str(compaction_count + 1))
summary = await llm_call_async(
@@ -712,6 +804,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
temperature=0.2, max_tokens=1024,
headers=compact_headers, timeout=30,
)
summary = normalize_compaction_summary(summary)
# Replace session history: summary as system message + recent messages
# System message holds the full summary for AI context
+21 -5
View File
@@ -429,11 +429,27 @@ def setup_hwfit_routes():
system["available_ram_gb"] = 0
system["total_ram_gb"] = 0
system = _apply_manual_hardware(system, manual_mode, manual_gpu_count, manual_vram_gb, manual_ram_gb, manual_backend)
# Image models use a single GPU — always use per-GPU VRAM
gpu_vrams = [float(g.get("vram_gb") or 0) for g in (system.get("gpus") or []) if isinstance(g, dict)]
single_vram = max(gpu_vrams) if gpu_vrams else ((system.get("gpu_vram_gb") or 0) / max(system.get("gpu_count") or 1, 1))
system["gpu_vram_gb"] = single_vram
system["gpu_count"] = 1 if single_vram > 0 else 0
try:
requested_gpu_count = int(gpu_count) if gpu_count != "" else None
except ValueError:
requested_gpu_count = None
if requested_gpu_count == 0:
# Respect the UI's RAM toggle. Before this route always rewrote the
# system to best-single-GPU VRAM, so image rows never changed when
# switching RAM/GPU.
system["has_gpu"] = False
system["gpu_vram_gb"] = 0
system["gpu_count"] = 0
system["gpu_only"] = False
else:
# Image diffusion backends generally use one device per pipeline,
# so rank GPU mode against the best single GPU rather than total
# multi-GPU VRAM.
gpu_vrams = [float(g.get("vram_gb") or 0) for g in (system.get("gpus") or []) if isinstance(g, dict)]
single_vram = max(gpu_vrams) if gpu_vrams else ((system.get("gpu_vram_gb") or 0) / max(system.get("gpu_count") or 1, 1))
system["gpu_vram_gb"] = single_vram
system["gpu_count"] = 1 if single_vram > 0 else 0
system["gpu_only"] = True if single_vram > 0 else False
results = rank_image_models(system, search=search or None, sort=sort)
return {"system": system, "models": results}
+100 -14
View File
@@ -1310,6 +1310,56 @@ def _visible_models(cached_models, hidden_models, pinned_models=None):
return [m for m in merged if m not in hidden]
def _picker_requires_pinning(base_url: str, kind: str) -> bool:
return _classify_endpoint(base_url, kind) == "api"
def _has_explicit_pinned_models(ep) -> bool:
"""Whether pinned_models was deliberately written for this endpoint.
API endpoints use pinned_models as an allow-list. An explicit empty JSON
list means "show no models"; it must not fall back to the old hidden-list
migration behavior.
"""
raw = getattr(ep, "pinned_models", None)
return raw is not None and str(raw).strip() != ""
def _legacy_visible_api_models(ep) -> List[str]:
"""Return API models selected under the old hidden-list picker.
Before API endpoints switched to an explicit allow-list, selected models
were represented as cached_models minus hidden_models. Existing OpenRouter
rows can therefore have many checked models and an empty pinned_models
field. Treat that old state as the initial pinned list so settings and chat
agree after upgrade.
"""
return _visible_models(
_cached_model_ids(ep),
getattr(ep, "hidden_models", None),
None,
)
def _picker_models_for_endpoint(ep, base_url: str, kind: str):
"""Return model IDs that should appear in the picker for an endpoint.
API providers expose remote inventory from /v1/models. Treat that cache as
inventory, not approval: only manually pinned API models should appear in
the picker. Local/self-hosted endpoints keep the older hide-list behavior.
"""
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
if _picker_requires_pinning(base_url, kind):
if not _has_explicit_pinned_models(ep):
pinned = _legacy_visible_api_models(ep) if _hidden_model_ids(ep) else []
return pinned, pinned
return _visible_models(
_cached_model_ids(ep),
getattr(ep, "hidden_models", None),
pinned,
), pinned
def _api_key_fingerprint(api_key: Optional[str]) -> str:
"""Stable, non-secret label for distinguishing same-URL credentials."""
key = (api_key or "").strip()
@@ -1508,24 +1558,18 @@ def setup_model_routes(model_discovery):
for ep in endpoints:
base = _normalize_base(ep.base_url)
provider = _safe_detect_provider(base)
# Merge cached + pinned models, then filter out hidden ones
ep_model_type = getattr(ep, "model_type", None) or "llm"
model_ids = _visible_models(
_cached_model_ids(ep),
ep.hidden_models,
getattr(ep, "pinned_models", None),
)
# Build correct URL based on provider
chat_url = build_chat_url(base)
kind = _effective_endpoint_kind(ep, base)
category = _classify_endpoint(base, kind)
model_ids, pinned = _picker_models_for_endpoint(ep, base, kind)
if model_ids:
curated_key = _match_provider_curated(base, None)
curated, extra = _curate_models(model_ids, curated_key)
# Pinned models are admin-selected — they always belong in the
# primary curated list, not buried in extras.
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
for m in pinned:
if m not in curated:
curated.append(m)
@@ -1887,18 +1931,24 @@ def setup_model_routes(model_discovery):
_invalidate_models_cache()
rows = db.query(ModelEndpoint).order_by(ModelEndpoint.created_at).all()
results = []
upgraded_legacy_pins = False
for r in rows:
all_models = _cached_model_ids(r)
hidden = _hidden_model_ids(r)
pinned = _normalize_model_ids(getattr(r, "pinned_models", None))
visible = _visible_models(all_models, r.hidden_models, pinned)
# Keep the list route cache-only. It feeds Settings →
# Added Models and must render immediately; explicit
# Refresh/Probe endpoints do the network work.
status = "online" if (all_models or pinned) else ("empty" if r.is_enabled else "offline")
ping = None
base = _normalize_base(r.base_url)
kind = _effective_endpoint_kind(r, base)
visible, pinned = _picker_models_for_endpoint(r, base, kind)
if _picker_requires_pinning(base, kind) and pinned and not _has_explicit_pinned_models(r):
r.pinned_models = json.dumps(pinned)
upgraded_legacy_pins = True
model_inventory_count = len(_merge_model_ids(all_models, pinned))
picker_requires_pinning = _picker_requires_pinning(base, kind)
status = "online" if (all_models or visible or pinned) else ("empty" if r.is_enabled else "offline")
results.append({
"id": r.id,
"name": r.name,
@@ -1907,6 +1957,8 @@ def setup_model_routes(model_discovery):
"api_key_fingerprint": _api_key_fingerprint(r.api_key),
"is_enabled": r.is_enabled,
"models": visible,
"model_count": model_inventory_count,
"picker_requires_pinning": picker_requires_pinning,
"pinned_models": pinned,
"hidden_count": len(hidden),
"online": status != "offline",
@@ -1920,6 +1972,9 @@ def setup_model_routes(model_discovery):
"model_refresh_interval": getattr(r, "model_refresh_interval", None),
"model_refresh_timeout": getattr(r, "model_refresh_timeout", None),
})
if upgraded_legacy_pins:
db.commit()
_invalidate_models_cache()
return results
finally:
db.close()
@@ -2023,6 +2078,10 @@ def setup_model_routes(model_discovery):
if refresh_timeout is not None:
existing.model_refresh_timeout = refresh_timeout
changed = True
incoming_model_type = (model_type or "").strip() or "llm"
if incoming_model_type and (getattr(existing, "model_type", None) or "llm") != incoming_model_type:
existing.model_type = incoming_model_type
changed = True
if api_key.strip() and not existing.api_key:
existing.api_key = api_key.strip()
changed = True
@@ -2255,9 +2314,10 @@ def setup_model_routes(model_discovery):
raise HTTPException(404, "Endpoint not found")
hidden = _hidden_model_ids(ep)
all_models = _cached_model_ids(ep)
base = _normalize_base(ep.base_url)
kind = _effective_endpoint_kind(ep, base)
picker_requires_pinning = _picker_requires_pinning(base, kind)
if refresh:
base = _normalize_base(ep.base_url)
kind = _effective_endpoint_kind(ep, base)
category = _classify_endpoint(base, kind)
timeout = _manual_refresh_timeout(ep, category, refresh_timeout)
try:
@@ -2276,6 +2336,8 @@ def setup_model_routes(model_discovery):
response.headers["X-Model-Refresh-Status"] = "failed"
response.headers["X-Model-Refresh-Warning"] = "Model refresh failed or returned no models; kept cached models."
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
if picker_requires_pinning and not _has_explicit_pinned_models(ep):
pinned = _legacy_visible_api_models(ep)
pinned_set = set(pinned)
return [
{
@@ -2283,6 +2345,7 @@ def setup_model_routes(model_discovery):
"display": m.split("/")[-1],
"is_hidden": m in hidden,
"is_pinned": m in pinned_set,
"picker_requires_pinning": picker_requires_pinning,
}
for m in _merge_model_ids(all_models, pinned)
]
@@ -2311,11 +2374,28 @@ def setup_model_routes(model_discovery):
hidden = body.get("hidden")
if not isinstance(hidden, list):
raise HTTPException(400, "hidden must be a list of model IDs")
ep.hidden_models = json.dumps(hidden) if hidden else None
base = _normalize_base(ep.base_url)
kind = _effective_endpoint_kind(ep, base)
if _picker_requires_pinning(base, kind):
# Compatibility for older/admin UI paths that still submit
# the previous hide-list shape. API pickers are allow-lists:
# convert "unchecked models" into an explicit pinned list so
# Settings summary, /api/models, and chat agree.
selected = _visible_models(_cached_model_ids(ep), hidden, None)
ep.pinned_models = json.dumps(selected)
ep.hidden_models = None
else:
ep.hidden_models = json.dumps(hidden) if hidden else None
# Accept either "pinned" or "pinned_models" for the manual IDs list.
if "pinned_models" in body or "pinned" in body:
pinned = _normalize_model_ids(body.get("pinned_models", body.get("pinned")))
ep.pinned_models = json.dumps(pinned) if pinned else None
base = _normalize_base(ep.base_url)
kind = _effective_endpoint_kind(ep, base)
if _picker_requires_pinning(base, kind):
ep.pinned_models = json.dumps(pinned)
ep.hidden_models = None
else:
ep.pinned_models = json.dumps(pinned) if pinned else None
db.commit()
_invalidate_models_cache()
hidden_count = len(json.loads(ep.hidden_models)) if ep.hidden_models else 0
@@ -2468,7 +2548,13 @@ def setup_model_routes(model_discovery):
ep.model_type = body["model_type"].strip() or ep.model_type
if "pinned_models" in body:
_pinned = _normalize_model_ids(body["pinned_models"])
ep.pinned_models = json.dumps(_pinned) if _pinned else None
_base_for_pins = _normalize_base(ep.base_url)
_kind_for_pins = _effective_endpoint_kind(ep, _base_for_pins)
if _picker_requires_pinning(_base_for_pins, _kind_for_pins):
ep.pinned_models = json.dumps(_pinned)
ep.hidden_models = None
else:
ep.pinned_models = json.dumps(_pinned) if _pinned else None
if "endpoint_kind" in body:
ep.endpoint_kind = _normalize_endpoint_kind(body.get("endpoint_kind"))
if "model_refresh_mode" in body:
+36 -2
View File
@@ -12,6 +12,7 @@ from core.models import ChatMessage
from src.request_models import SessionResponse
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive
from src.auth_helpers import effective_user, _auth_disabled, owner_filter
from src.session_image_cleanup import _generated_image_path_for_cleanup, session_image_refs
from src.session_actions import is_session_recently_active
from src.upload_handler import reserve_message_upload_references
@@ -220,6 +221,7 @@ def setup_session_routes(
@router.get("/sessions")
def list_sessions(request: Request):
user = effective_user(request)
active_incognito_id = str(request.query_params.get("active_incognito_id") or "").strip()
# Lazy purge: incognito sessions are ephemeral by design — wipe leftovers
# from the DB and session_manager so they vanish on the next page refresh.
# BUT: skip sessions that were created within the last 10 minutes.
@@ -240,6 +242,8 @@ def setup_session_routes(
DbSession.created_at < _cutoff,
).all()
for _g in _ghosts:
if active_incognito_id and _g.id == active_incognito_id:
continue
_purge_db.query(_DbMsg).filter(_DbMsg.session_id == _g.id).delete()
_purge_db.delete(_g)
if hasattr(session_manager, "delete_session"):
@@ -641,13 +645,43 @@ def setup_session_routes(
db = SessionLocal()
try:
from core.database import ChatMessage as DbChatMessage
session_ids = [row[0] for row in db.query(DbSession.id).all()]
count = db.query(DbSession).count()
image_ids: set[str] = set()
filenames: set[str] = set()
for sid in session_ids:
ids, names = session_image_refs(db, sid)
image_ids.update(ids)
filenames.update(names)
image_query = db.query(GalleryImage).filter(GalleryImage.session_id.in_(session_ids)) if session_ids else db.query(GalleryImage).filter(False)
if image_ids or filenames:
from sqlalchemy import or_
clauses = []
if session_ids:
clauses.append(GalleryImage.session_id.in_(session_ids))
if image_ids:
clauses.append(GalleryImage.id.in_(list(image_ids)))
if filenames:
clauses.append(GalleryImage.filename.in_(list(filenames)))
image_query = db.query(GalleryImage).filter(or_(*clauses))
images = image_query.all()
removed_images = 0
for img in images:
img.is_active = False
if img.filename:
path = _generated_image_path_for_cleanup(img.filename)
if path and path.exists():
try:
path.unlink()
except Exception as exc:
logger.warning("Could not remove generated image %s during all-session delete: %s", img.filename, exc)
removed_images += 1
db.query(DbChatMessage).delete()
db.query(DbSession).delete()
db.commit()
session_manager.sessions.clear()
logger.info(f"Admin deleted all {count} sessions")
return {"status": "deleted", "count": count}
logger.info(f"Admin deleted all {count} sessions and {removed_images} linked images")
return {"status": "deleted", "count": count, "images_deleted": removed_images}
except Exception as e:
db.rollback()
logger.error(f"Error deleting all sessions: {e}")
+197 -4
View File
@@ -157,6 +157,7 @@ def _package_installed_from_probe(name: str, probe: dict) -> bool:
binaries = probe.get("binaries") if isinstance(probe.get("binaries"), dict) else {}
dists = probe.get("dists") if isinstance(probe.get("dists"), dict) else {}
modules = probe.get("modules") if isinstance(probe.get("modules"), dict) else {}
files = probe.get("files") if isinstance(probe.get("files"), dict) else {}
if name == "vllm":
return bool(binaries.get("vllm"))
@@ -166,11 +167,43 @@ def _package_installed_from_probe(name: str, probe: dict) -> bool:
return bool(dists.get("sglang") or modules.get("sglang", {}).get("real_module"))
if name == "mlx_lm":
return bool(dists.get("mlx-lm") or modules.get("mlx_lm", {}).get("real_module"))
if name == "mflux":
return bool(
dists.get("mflux")
or modules.get("mflux", {}).get("real_module")
or binaries.get("mflux-generate-qwen")
or binaries.get("mflux-generate")
)
if name == "boogu_image_mlx":
return bool(
dists.get("boogu-image-mlx")
or modules.get("boogu_image_mlx", {}).get("real_module")
)
if name == "mlx_lama_swift":
return bool(
(binaries.get("odysseus-mlx-inpaint") or binaries.get("mlx-lama-serve"))
and (files.get("mlx.metallib") or files.get("default.metallib"))
)
if name == "mlx_ddcolor_swift":
return bool(
(binaries.get("odysseus-mlx-colorize") or binaries.get("mlx-ddcolor-serve"))
and (files.get("mlx.metallib") or files.get("default.metallib"))
)
if name == "diffusers":
return bool(
(dists.get("diffusers") or modules.get("diffusers", {}).get("real_module"))
and (dists.get("torch") or modules.get("torch", {}).get("real_module"))
)
if name == "krea_diffusers":
return bool(
(dists.get("diffusers") or modules.get("diffusers", {}).get("real_module"))
and (dists.get("torch") or modules.get("torch", {}).get("real_module"))
)
if name == "sam_mask":
return bool(
(dists.get("transformers") or modules.get("transformers", {}).get("real_module"))
and (dists.get("torch") or modules.get("torch", {}).get("real_module"))
)
if name == "hf_transfer":
return bool(
dists.get("hf-transfer")
@@ -183,6 +216,7 @@ def _package_status_note(name: str, probe: dict) -> str:
binaries = probe.get("binaries") if isinstance(probe.get("binaries"), dict) else {}
modules = probe.get("modules") if isinstance(probe.get("modules"), dict) else {}
dists = probe.get("dists") if isinstance(probe.get("dists"), dict) else {}
files = probe.get("files") if isinstance(probe.get("files"), dict) else {}
module = modules.get(name) if isinstance(modules.get(name), dict) else {}
locations = module.get("locations") or []
if name == "vllm":
@@ -212,10 +246,53 @@ def _package_status_note(name: str, probe: dict) -> str:
if _package_installed_from_probe(name, probe):
return f"diffusers {dists.get('diffusers', 'available')} with torch {dists.get('torch', 'available')}"
return "Diffusers serving needs both diffusers and torch."
if name == "krea_diffusers":
if _package_installed_from_probe(name, probe):
return f"Latest Diffusers runtime: diffusers {dists.get('diffusers', 'available')} with torch {dists.get('torch', 'available')}. Use Update/Reinstall to pull latest Diffusers from Git."
return "Some newer image models need torch plus latest Diffusers from Git."
if name == "sam_mask":
if _package_installed_from_probe(name, probe):
return f"SAM object masks: transformers {dists.get('transformers', 'available')} with torch {dists.get('torch', 'available')}"
return "SAM click/object mask selection needs transformers and torch."
if name == "mlx_lm":
if _package_installed_from_probe(name, probe):
return f"MLX LM {dists.get('mlx-lm', 'available')}"
return "MLX serving needs mlx-lm on an Apple Silicon Mac."
if name == "mflux":
if _package_installed_from_probe(name, probe):
parts = []
if dists.get("mflux"):
parts.append(f"mflux {dists['mflux']}")
if binaries.get("mflux-generate-qwen"):
parts.append(f"Qwen CLI: {binaries['mflux-generate-qwen']}")
if binaries.get("mflux-generate"):
parts.append(f"Flux CLI: {binaries['mflux-generate']}")
return "; ".join(parts) if parts else "mflux available"
return "MLX image serving needs mflux on an Apple Silicon Mac."
if name == "boogu_image_mlx":
if _package_installed_from_probe(name, probe):
return f"Boogu MLX pipeline {dists.get('boogu-image-mlx', 'available')}"
return "Boogu image models need boogu-image-mlx on an Apple Silicon Mac."
if name == "mlx_lama_swift":
if _package_installed_from_probe(name, probe):
found = [
binaries.get("odysseus-mlx-inpaint"),
binaries.get("mlx-lama-serve"),
]
return f"LaMa/MI-GAN Swift MLX runner: {next((p for p in found if p), 'available')}"
if binaries.get("odysseus-mlx-inpaint") or binaries.get("mlx-lama-serve"):
return "LaMa/MI-GAN Swift runner is installed, but mlx.metallib is missing next to the runner."
return "LaMa/MI-GAN inpainting models need an Odysseus-compatible mlx-lama-swift bridge on an Apple Silicon Mac."
if name == "mlx_ddcolor_swift":
if _package_installed_from_probe(name, probe):
found = [
binaries.get("odysseus-mlx-colorize"),
binaries.get("mlx-ddcolor-serve"),
]
return f"DDColor Swift MLX runner: {next((p for p in found if p), 'available')}"
if binaries.get("odysseus-mlx-colorize") or binaries.get("mlx-ddcolor-serve"):
return "DDColor Swift runner is installed, but mlx.metallib is missing next to the runner."
return "DDColor colorization models need an Odysseus-compatible mlx-ddcolor-swift bridge on an Apple Silicon Mac."
if name in dists:
return f"{name} {dists[name]}"
return ""
@@ -314,12 +391,22 @@ dist_names={{
'llama_cpp':['llama-cpp-python'],
'sglang':['sglang'],
'mlx_lm':['mlx-lm'],
'mlx_vlm':['mlx-vlm'],
'mflux':['mflux'],
'boogu_image_mlx':['boogu-image-mlx'],
'mlx_lama_swift':[],
'mlx_ddcolor_swift':[],
'diffusers':['diffusers','torch'],
'krea_diffusers':['diffusers','torch'],
'sam_mask':['transformers','torch'],
'hf_transfer':['hf-transfer','hf_transfer'],
}}
bin_names={{
'vllm':['vllm'],
'llama_cpp':['llama-server'],
'mflux':['mflux-generate-qwen', 'mflux-generate'],
'mlx_lama_swift':['odysseus-mlx-inpaint', 'mlx-lama-serve'],
'mlx_ddcolor_swift':['odysseus-mlx-colorize', 'mlx-ddcolor-serve'],
'tmux':['tmux'],
}}
@@ -372,7 +459,19 @@ def probe(n):
mods['torch'] = mod_status('torch')
dists = dist_status(dist_names.get(n, [n]))
bins = {{b: shutil.which(b) for b in bin_names.get(n, [])}}
return {{'modules': mods, 'dists': dists, 'binaries': bins}}
files = {{}}
if n in ('mlx_lama_swift', 'mlx_ddcolor_swift'):
for key in ('mlx.metallib', 'default.metallib'):
found = None
for b in bins.values():
if not b:
continue
p = os.path.join(os.path.dirname(b), key)
if os.path.exists(p):
found = p
break
files[key] = found
return {{'modules': mods, 'dists': dists, 'binaries': bins, 'files': files}}
print(json.dumps({{n: probe(n) for n in names}}))
"""
@@ -1088,6 +1187,8 @@ def setup_shell_routes() -> APIRouter:
ssh_port: str | None = None,
venv: str | None = None,
backend: str | None = None,
platform: str | None = None,
model_hint: str | None = None,
):
"""Check which optional packages are installed.
@@ -1104,6 +1205,14 @@ def setup_shell_routes() -> APIRouter:
import site
import sys
platform_l = (platform or "").strip().lower()
model_hint_l = (model_hint or "").strip().lower()
has_krea_model = "krea" in model_hint_l
has_lama_mlx_model = any(
key in model_hint_l
for key in ("lama", "mi-gan", "migan", "inpainting-mlx")
)
has_ddcolor_mlx_model = "ddcolor" in model_hint_l
_prepend_user_install_bins_to_path()
importlib.invalidate_caches()
try:
@@ -1158,7 +1267,7 @@ def setup_shell_routes() -> APIRouter:
"name": "hf_transfer",
"pip": "hf_transfer",
"desc": "Fast model downloads from HuggingFace",
"category": "LLM",
"category": "Tools",
"target": "remote",
},
{
@@ -1210,8 +1319,52 @@ def setup_shell_routes() -> APIRouter:
# ── Image ── editor + diffusion model serving
{
"name": "diffusers",
"pip": "diffusers[torch]",
"desc": "Image generation/editing pipelines (SD, Flux) with PyTorch",
"pip": "diffusers[torch] torchvision accelerate scipy python-multipart",
"desc": "Image generation/editing pipelines with PyTorch and Diffusers",
"category": "Image",
"target": "remote",
},
{
"name": "krea_diffusers",
"pip": "git+https://github.com/huggingface/diffusers.git torchvision accelerate scipy python-multipart",
"desc": "Latest Diffusers from Git for newly released image pipelines",
"category": "Image",
"target": "remote",
},
{
"name": "mflux",
"pip": "mflux",
"desc": "MLX image generation runtime for Apple Silicon models like Qwen Image",
"category": "Image",
"target": "remote",
},
{
"name": "boogu_image_mlx",
"pip": "git+https://github.com/xocialize/boogu-image-mlx.git",
"desc": "MLX image generation pipeline for Boogu Image models on Apple Silicon",
"category": "Image",
"target": "remote",
},
{
"name": "mlx_lama_swift",
"pip": "",
"desc": "Swift MLX runtime for LaMa / MI-GAN inpainting and object removal",
"category": "Image",
"target": "remote",
"install_hint": "Build an Odysseus-compatible mlx-lama-swift bridge on the selected Apple Silicon Mac and put odysseus-mlx-inpaint or mlx-lama-serve on PATH. Upstream currently ships Swift libraries plus smoke executables, not a stable image-edit CLI.",
},
{
"name": "mlx_ddcolor_swift",
"pip": "",
"desc": "Swift MLX runtime for DDColor automatic image colorization",
"category": "Image",
"target": "remote",
"install_hint": "Build an Odysseus-compatible mlx-ddcolor-swift bridge on the selected Apple Silicon Mac and put odysseus-mlx-colorize or mlx-ddcolor-serve on PATH. Upstream currently ships Swift libraries plus smoke executables, not a stable colorize CLI.",
},
{
"name": "mlx_vlm",
"pip": "mlx-vlm",
"desc": "MLX-VLM backbone used by HiDream image models on Apple Silicon",
"category": "Image",
"target": "remote",
},
@@ -1222,6 +1375,13 @@ def setup_shell_routes() -> APIRouter:
"category": "Image",
"target": "remote",
},
{
"name": "sam_mask",
"pip": "torch torchvision transformers accelerate pillow",
"desc": "Neutral click/box/object segmentation masks for the image editor",
"category": "Image",
"target": "local",
},
{
"name": "rembg",
"pip": "rembg[gpu]",
@@ -1251,6 +1411,21 @@ def setup_shell_routes() -> APIRouter:
for pkg in packages:
pkg.setdefault("install_cmd", None)
pkg.setdefault("update_cmd", None)
if not has_krea_model:
packages = [
p for p in packages
if p.get("name") not in {"krea_diffusers", "transformers"}
]
if not has_lama_mlx_model:
packages = [
p for p in packages
if p.get("name") != "mlx_lama_swift"
]
if not has_ddcolor_mlx_model:
packages = [
p for p in packages
if p.get("name") != "mlx_ddcolor_swift"
]
# Remote check: for remote-target packages, probe the selected server's
# venv over SSH so a remote `pip install` actually reflects here.
remote_status: dict = {}
@@ -1381,8 +1556,22 @@ def setup_shell_routes() -> APIRouter:
target_os_id = ""
if sys.platform == "darwin":
target_os_id = "macos"
if not target_os_id and platform_l in {"darwin", "macos", "mac"}:
target_os_id = "macos"
for pkg in packages:
if pkg.get("name") in {"mflux", "boogu_image_mlx", "mlx_vlm", "mlx_lama_swift", "mlx_ddcolor_swift"}:
is_apple_target = target_os_id == "macos" or (
not host and IS_APPLE_SILICON
)
known_non_apple_target = bool(target_os_id and target_os_id != "macos") or (
not host and not IS_APPLE_SILICON
)
pkg["applicable"] = is_apple_target
if known_non_apple_target:
pkg["installed"] = None
pkg["status_note"] = "Only relevant for Apple Silicon / MLX image serving."
continue
on_remote = bool(host and pkg.get("target") == "remote")
probe = None
if on_remote:
@@ -1588,6 +1777,10 @@ def setup_shell_routes() -> APIRouter:
"sglang[all]",
"diffusers",
"diffusers[torch]",
"git+https://github.com/huggingface/diffusers.git",
"mflux",
"git+https://github.com/xocialize/boogu-image-mlx.git",
"mlx-vlm",
"transformers",
"TTS",
"bark",