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
+3 -1
View File
@@ -16,7 +16,8 @@ FROM python:3.14-slim
# downloads, and serves from Docker installs.
# git/cmake are required when Cookbook builds llama.cpp on first llama.cpp
# launch inside Docker.
# nodejs/npm provide npx for the optional built-in Browser MCP server.
# nodejs/npm provide npx for the built-in Browser MCP server.
# chromium provides the actual browser binary used by that MCP server.
# gosu lets the entrypoint drop privileges cleanly so signals still reach
# uvicorn directly (no extra shell layer like `su`/`sudo` would add).
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -26,6 +27,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
git \
nodejs \
npm \
chromium \
tmux \
openssh-client \
gosu \
+8
View File
@@ -32,6 +32,14 @@ the codebase, you are probably right to stay away.
before the user request really starts. We need slimmer prompts, better tool
selection, smaller default tool sets, and clearer guidance for models with
4k/8k/16k context windows.
- Local model speculative decoding support. For Odysseus-tuned local models,
plan to ship or recommend a small same-tokenizer draft model when the serving
backend supports it. Early vLLM testing showed a generic `Qwen3-0.6B` draft
beside `Qwen3-8B` can materially reduce wall time, while an unsupported
DSpark conversion performed poorly. Treat this as a supported draft-model lane
first; keep MTP-specific packaging as future work only when the architecture
and runtime support are real. Judge this by time-to-success, tool correctness,
grammar, and unchanged target output, not tokens/sec alone.
- Skill/tool prompt-injection audit. User-editable skills, notes, documents,
fetched pages, and memories should be treated as untrusted data. Keep testing
whether models follow malicious instructions from those surfaces.
+6
View File
@@ -543,6 +543,12 @@ class SessionManager:
"""Permanently delete a session and all its messages."""
db = SessionLocal()
try:
try:
from src.session_image_cleanup import cleanup_session_images
cleanup_session_images(session_id, db=db)
except Exception as e:
logger.warning(f"Image cleanup failed while deleting session {session_id}: {e}")
# Detach documents so they survive as orphans in the library
db.query(DbDocument).filter(DbDocument.session_id == session_id).update(
{DbDocument.session_id: None}, synchronize_session=False
+369 -6
View File
@@ -24,6 +24,7 @@ from pathlib import Path
from datetime import datetime, timedelta
import uuid
from contextvars import ContextVar
from urllib.parse import parse_qs, unquote, urlparse
from mcp.server import Server
from mcp.server.stdio import stdio_server
@@ -129,20 +130,36 @@ def _mcp_owner_required(rows: list[dict] | None = None) -> bool:
return _has_owner_scoped_accounts(rows)
def _load_email_writing_style() -> str:
"""Return the existing Settings > Email > Writing Style value."""
def _load_email_writing_style(account: str | None = None) -> str:
"""Return the saved Settings > Email > Writing Style value.
Prefer the selected account's style when one exists; fall back to the
legacy global style so older installs keep behaving as before.
"""
try:
settings_path = DATA_DIR / "settings.json"
if not settings_path.exists():
return ""
settings = json.loads(settings_path.read_text(encoding="utf-8"))
account_id = ""
if account:
try:
cfg = _load_config(account)
account_id = str(cfg.get("account_id") or account or "").strip()
except Exception:
account_id = str(account or "").strip()
by_account = settings.get("email_writing_styles_by_account") or {}
if account_id and isinstance(by_account, dict):
style = by_account.get(account_id)
if isinstance(style, str) and style.strip():
return style.strip()
return str(settings.get("email_writing_style") or "").strip()
except Exception:
return ""
def _writing_style_guidance() -> str:
style = _load_email_writing_style()
def _writing_style_guidance(account: str | None = None) -> str:
style = _load_email_writing_style(account)
if not style:
return (
"No saved writing style is configured in Settings > Email > Writing Style. "
@@ -509,6 +526,251 @@ def _decode_header(raw):
return "".join(decoded)
def _uid_from_fetch_meta(meta_b: bytes) -> str:
m = re.search(rb"UID\s+(\d+)", meta_b or b"")
return m.group(1).decode("ascii", errors="ignore") if m else ""
def _parse_list_unsubscribe_header(value: str | None) -> list[dict]:
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) -> 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"))
if not methods:
return None
reasons: list[str] = ["has unsubscribe header"]
score = 45
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 re.search(r"\b(unsubscribe|newsletter|sale|discount|offer|promo|limited time)\b", (subject or "").lower()):
score += 10
reasons.append("promotional subject")
executable = [m for m in methods if m.get("executable")]
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],
}
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())
def _scan_unsubscribe_candidates(folder="INBOX", account=None, limit=25, max_scan=150) -> dict:
limit = max(1, min(int(limit or 25), 100))
max_scan = max(limit, min(int(max_scan or 150), 500))
folder = folder or "INBOX"
candidates: list[dict] = []
conn = _imap_connect(account)
try:
status, _ = conn.select(_q(folder), readonly=True)
if status != "OK":
return {"success": False, "error": f"Folder not found: {folder}", "candidates": []}
status, data = conn.uid("SEARCH", None, "ALL")
if status != "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}
status, msg_data = conn.uid("FETCH", _b(",".join(str(u) for u in uids)), "(UID RFC822.HEADER)")
finally:
try:
conn.logout()
except Exception:
pass
if status != "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.message_from_bytes(item[1] or b"")
except Exception:
continue
candidate = _email_unsubscribe_candidate_from_msg(msg, uid, folder)
if candidate:
candidates.append(candidate)
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), int(c.get("uid") or 0)), reverse=True)
return {
"success": True,
"candidates": candidates[:limit],
"total": len(candidates),
"raw_total": raw_total,
"scanned": len(uids),
"folder": folder,
"account": account or "",
}
def _unsubscribe_email(uid, folder="INBOX", account=None, method_index=0, allow_web=False) -> dict:
uid = str(uid or "").strip()
if not uid:
return {"success": False, "error": "uid is required"}
conn = _imap_connect(account)
try:
status, _ = conn.select(_q(folder), readonly=True)
if status != "OK":
return {"success": False, "error": f"Folder not found: {folder}"}
status, msg_data = conn.uid("FETCH", _b(uid), "(UID RFC822.HEADER)")
finally:
try:
conn.logout()
except Exception:
pass
if status != "OK" or not msg_data:
return {"success": False, "error": f"Email not found: {uid}"}
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.message_from_bytes(raw_header)
candidate = _email_unsubscribe_candidate_from_msg(msg, uid, folder)
if not candidate:
return {"success": False, "error": "No List-Unsubscribe header found"}
methods = candidate.get("methods") or []
method_index = int(method_index or 0)
method = methods[method_index] if 0 <= method_index < len(methods) else (candidate.get("recommended_method") or methods[0])
if method.get("kind") == "url":
return {
"success": False,
"requires_browser": True,
"url": method.get("target"),
"candidate": candidate,
"instructions": (
"This unsubscribe is a web link. Ask the user for approval, then use the browser/web tool "
"to open the exact URL and complete the unsubscribe page. Do not fetch unrelated links."
),
}
if method.get("kind") != "mailto" or not method.get("executable"):
return {"success": False, "error": "Unsupported unsubscribe method", "candidate": candidate}
result = _send_email(
to=method.get("target"),
subject=method.get("subject") or "unsubscribe",
body=method.get("body") or "unsubscribe",
account=account,
)
if "error" in result:
return {"success": False, "error": result["error"], "candidate": candidate}
return {
"success": True,
"method": method,
"candidate": candidate,
"send_result": result,
"pending": bool(result.get("pending")),
}
def _extract_text(msg):
"""Extract plain text body from email message."""
if msg.is_multipart():
@@ -1546,8 +1808,7 @@ async def _ai_draft_reply_to_email(uid, folder="INBOX", reply_all=False, account
except Exception as exc:
return {"error": f"AI reply helpers unavailable: {exc}"}
settings = _load_settings()
style = settings.get("email_writing_style", "")
style = _load_email_writing_style(account)
system_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE
if style:
system_prompt += f"\n\nWRITING STYLE TO MATCH:\n{style}"
@@ -1890,6 +2151,45 @@ async def list_tools() -> list[Tool]:
"required": [],
},
),
Tool(
name="scan_email_unsubscribes",
description=(
"Scan recent email headers for likely spam/newsletter unsubscribe candidates. "
"Returns reviewable candidates with UID, sender, subject, score, reasons, and "
"List-Unsubscribe methods. This does not unsubscribe anything. For mailto "
"methods, use unsubscribe_email after user approval. For web URL methods, use "
"browser/web tools after user approval to open the exact URL and complete the page."
),
inputSchema={
"type": "object",
"properties": {
"folder": {"type": "string", "description": "IMAP folder to scan", "default": "INBOX"},
"limit": {"type": "integer", "description": "Maximum candidates to return", "default": 25},
"max_scan": {"type": "integer", "description": "How many newest messages to inspect", "default": 150},
**ACCOUNT_PROP,
},
"required": [],
},
),
Tool(
name="unsubscribe_email",
description=(
"Execute one approved unsubscribe action for an email UID. Supports safe mailto "
"List-Unsubscribe directly. If the selected method is a web URL, this returns "
"requires_browser with the exact URL; use browser/web tools only after user approval."
),
inputSchema={
"type": "object",
"properties": {
"uid": {"type": "string", "description": "Email UID from scan_email_unsubscribes/list_emails"},
"folder": {"type": "string", "description": "IMAP folder", "default": "INBOX"},
"method_index": {"type": "integer", "description": "Unsubscribe method index from scan_email_unsubscribes", "default": 0},
"allow_web": {"type": "boolean", "description": "Return web unsubscribe URL instructions when the method is URL", "default": False},
**ACCOUNT_PROP,
},
"required": ["uid"],
},
),
Tool(
name="download_attachment",
description=(
@@ -2264,6 +2564,69 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
lines.append(line)
return [TextContent(type="text", text="\n\n".join(lines))]
elif name == "scan_email_unsubscribes":
try:
result = _scan_unsubscribe_candidates(
folder=arguments.get("folder", "INBOX"),
account=acct,
limit=arguments.get("limit", 25),
max_scan=arguments.get("max_scan", 150),
)
except Exception as e:
return [TextContent(type="text", text=f"Unsubscribe scan failed: {e}")]
if not result.get("success"):
return [TextContent(type="text", text=f"Unsubscribe scan failed: {result.get('error', 'unknown error')}")]
candidates = result.get("candidates") or []
if not candidates:
return [TextContent(type="text", text=f"No unsubscribe candidates found in {result.get('scanned', 0)} recent emails.")]
lines = [
f"Found {len(candidates)} unsubscribe candidate(s) from {result.get('scanned', 0)} recent emails.",
"Review these with the user before executing. Mailto methods can use unsubscribe_email; URL methods require browser/web tools after approval.\n",
]
for i, cand in enumerate(candidates, 1):
lines.append(
f"{i}. **{cand.get('subject') or '(no subject)'}**\n"
f" From: {cand.get('from_name') or cand.get('from_address') or ''} ({cand.get('from_address') or ''})\n"
f" UID: {cand.get('uid')} Folder: {cand.get('folder')}\n"
f" Score: {cand.get('score')} Matching emails: {cand.get('duplicate_count', 1)} Reasons: {', '.join(cand.get('reasons') or [])}"
)
for j, method in enumerate(cand.get("methods") or []):
if method.get("kind") == "mailto":
lines.append(f" Method {j}: mailto {method.get('target')} (executable via unsubscribe_email)")
elif method.get("kind") == "url":
lines.append(f" Method {j}: web URL {method.get('target')} (use browser/web tools after approval)")
return [TextContent(type="text", text="\n".join(lines))]
elif name == "unsubscribe_email":
result = _unsubscribe_email(
uid=arguments.get("uid"),
folder=arguments.get("folder", "INBOX"),
account=acct,
method_index=arguments.get("method_index", 0),
allow_web=bool(arguments.get("allow_web", False)),
)
if result.get("requires_browser"):
return [TextContent(
type="text",
text=(
"Web unsubscribe requires browser/web navigation.\n"
f"URL: {result.get('url')}\n"
f"{result.get('instructions')}"
),
)]
if not result.get("success"):
return [TextContent(type="text", text=f"Unsubscribe failed: {result.get('error', 'unknown error')}")]
method = result.get("method") or {}
if result.get("pending"):
return [TextContent(
type="text",
text=(
f"Unsubscribe email staged for approval to {method.get('target')}. "
"Nothing has been sent until the user approves the pending email."
),
)]
return [TextContent(type="text", text=f"Unsubscribe email sent to {method.get('target')}.")]
elif name == "download_attachment":
uid = arguments.get("uid")
index = arguments.get("index")
+6
View File
@@ -81,6 +81,12 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if not model_spec:
return [TextContent(type="text", text="Error: No image model found. Configure one in Admin.")]
try:
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec, model_type="image")
except ValueError:
_lower_model_spec = model_spec.lower()
if not any(_name in _lower_model_spec for _name in ("gpt-image", "dall-e")):
raise
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec)
is_gpt_image = "gpt-image" in model_id.lower()
+64 -13
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,11 +472,12 @@ 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)
@@ -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()
# 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)
+309 -23
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.
@@ -382,8 +473,84 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
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,7 +1655,6 @@ def setup_chat_routes(
},
)
sess.add_message(ChatMessage("assistant", _stopped_content, metadata=_stopped_md))
if not incognito:
session_manager.save_sessions()
raise
finally:
@@ -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,7 +1827,6 @@ def setup_chat_routes(
},
)
sess.add_message(ChatMessage("assistant", _stopped_content2, metadata=_stopped_md2))
if not incognito:
session_manager.save_sessions()
except Exception:
logger.exception("Failed to save partial response on disconnect (session %s)", session)
+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",
+184 -25
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",
@@ -1435,6 +1457,8 @@ def setup_cookbook_routes() -> APIRouter:
"status": "downloading" if m["has_incomplete"] else "ready",
"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
@@ -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()
+342 -19
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,6 +409,9 @@ async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = Tru
finally:
s2 = _load_settings()
for k, v in prev.items():
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,11 +632,20 @@ 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")
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
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
@@ -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
+17 -1
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
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}
+96 -10
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)
if refresh:
base = _normalize_base(ep.base_url)
kind = _effective_endpoint_kind(ep, base)
picker_requires_pinning = _picker_requires_pinning(base, kind)
if refresh:
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,10 +2374,27 @@ 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")
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")))
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()
@@ -2468,6 +2548,12 @@ 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"])
_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"))
+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",
+422 -86
View File
@@ -26,13 +26,14 @@ import io
import json
import logging
import time
import uuid
from pathlib import Path
from contextlib import asynccontextmanager
import torch
import uvicorn
from fastapi import FastAPI
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.trustedhost import TrustedHostMiddleware
from pydantic import BaseModel
@@ -44,6 +45,7 @@ _pipe = None
_model_id = ""
DTYPE_MAP = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}
_args = None
_PROGRESS = {}
@asynccontextmanager
@@ -112,6 +114,77 @@ def _configure_security_middleware(application, allowed_hosts, allowed_origins):
_configure_security_middleware(app, _DEFAULT_ALLOWED_HOSTS, _DEFAULT_CORS_ORIGINS)
def _start_progress(request_id: str, total_steps: int, prompt: str, kind: str) -> str:
rid = (request_id or "").strip() or uuid.uuid4().hex
_PROGRESS[rid] = {
"id": rid,
"kind": kind,
"status": "running",
"step": 0,
"total": max(1, int(total_steps or 1)),
"percent": 0,
"prompt": (prompt or "")[:120],
"started_at": time.time(),
"updated_at": time.time(),
}
return rid
def _update_progress(request_id: str, step: int, total_steps: int | None = None, status: str = "running"):
if not request_id:
return
item = _PROGRESS.get(request_id)
if not item:
return
total = max(1, int(total_steps or item.get("total") or 1))
current = max(0, min(int(step or 0), total))
item.update({
"status": status,
"step": current,
"total": total,
"percent": round((current / total) * 100, 1),
"updated_at": time.time(),
})
def _finish_progress(request_id: str, status: str = "done", error: str = ""):
if not request_id:
return
item = _PROGRESS.get(request_id)
if not item:
return
total = max(1, int(item.get("total") or 1))
item.update({
"status": status,
"step": total if status == "done" else item.get("step", 0),
"total": total,
"percent": 100 if status == "done" else item.get("percent", 0),
"error": error,
"updated_at": time.time(),
})
def _run_pipeline_with_progress(pipe, request_id: str, total_steps: int, **kwargs):
def step_end_callback(_pipe, step, timestep, callback_kwargs):
_update_progress(request_id, int(step) + 1, total_steps)
return callback_kwargs
def legacy_callback(step, timestep, latents):
_update_progress(request_id, int(step) + 1, total_steps)
try:
return pipe(callback_on_step_end=step_end_callback, **kwargs)
except TypeError as exc:
if "callback_on_step_end" not in str(exc):
raise
try:
return pipe(callback=legacy_callback, callback_steps=1, **kwargs)
except TypeError as exc:
if "callback" not in str(exc) and "callback_steps" not in str(exc):
raise
return pipe(**kwargs)
class ImageRequest(BaseModel):
model: str = ""
prompt: str
@@ -119,10 +192,71 @@ class ImageRequest(BaseModel):
size: str = "1024x1024"
quality: str = "medium"
response_format: str = "b64_json"
request_id: str = ""
def _parse_size(size: str) -> tuple[int, int]:
try:
w, h = (size or "").split("x")
return int(w), int(h)
except Exception:
return _args.width, _args.height
def _quality_steps(quality: str) -> int:
default_steps = _args.steps or 8
steps_map = {"low": 4, "medium": default_steps, "high": 20, "auto": 12}
return steps_map.get(quality, default_steps)
def _guidance_scale() -> float:
return float(_args.guidance_scale)
def _default_negative_prompt() -> str | None:
value = (_args.negative_prompt or "").strip()
return value or None
def _pipeline_accepts_arg(name: str) -> bool:
try:
import inspect
sig = inspect.signature(_pipe.__call__)
return name in sig.parameters
except Exception:
return True
def _pipeline_call_kwargs(**kwargs) -> dict:
"""Filter kwargs to the active pipeline signature.
Diffusers/community pipelines are not consistent: ordinary img2img
usually accepts `image`, while instruction-edit models such as OmniGen2
accept `input_images` plus model-specific guidance fields. Filtering keeps
the server generic and avoids hardcoding repo IDs.
"""
try:
import inspect
sig = inspect.signature(_pipe.__call__)
params = sig.parameters
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()):
return {k: v for k, v in kwargs.items() if v is not None}
return {k: v for k, v in kwargs.items() if v is not None and k in params}
except Exception:
return {k: v for k, v in kwargs.items() if v is not None}
def _image_response(images) -> dict:
data = []
for img in images:
buf = io.BytesIO()
img.save(buf, format="PNG")
data.append({"b64_json": base64.b64encode(buf.getvalue()).decode()})
return {"created": int(time.time()), "data": data}
def _fix_meta_tensors(pipe, dtype):
"""Replace any meta tensors with real zero tensors on CPU so .to(cuda) works."""
"""Replace any meta tensors with real zero tensors on CPU so .to(device) works."""
for name, component in pipe.components.items():
if not hasattr(component, 'parameters'):
continue
@@ -142,6 +276,69 @@ def _fix_meta_tensors(pipe, dtype):
logger.info(f" Fixed {fixed} meta tensors in {name}")
def _target_device() -> str:
"""Best available torch device for Diffusers on this host."""
try:
if torch.cuda.is_available():
return "cuda"
except Exception:
pass
try:
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "mps"
except Exception:
pass
return "cpu"
def _can_cpu_offload(device: str) -> bool:
# Diffusers CPU offload helpers are CUDA/accelerate-oriented. On Apple
# Silicon they either no-op poorly or fail; MPS should use direct .to("mps").
return device == "cuda"
def _load_omnigen2_pipeline(model_path: str, torch_dtype, target_device: str, use_offload: bool) -> bool:
"""Load OmniGen2 from the official repo package.
OmniGen2 publishes a Diffusers-style model_index.json, but current
public diffusers builds do not expose OmniGen2Pipeline as a stock class.
The official examples import the pipeline from the cloned `omnigen2`
package, so support that path without hardcoding any private model.
"""
global _pipe
try:
from omnigen2.pipelines.omnigen2.pipeline_omnigen2 import OmniGen2Pipeline
from omnigen2.models.transformers.transformer_omnigen2 import OmniGen2Transformer2DModel
except Exception as exc:
logger.warning("OmniGen2 package import failed: %s", exc)
return False
try:
logger.info("Loading OmniGen2 pipeline via official omnigen2 package")
pipe = OmniGen2Pipeline.from_pretrained(
model_path,
torch_dtype=torch_dtype,
trust_remote_code=True,
)
pipe.transformer = OmniGen2Transformer2DModel.from_pretrained(
model_path,
subfolder="transformer",
torch_dtype=torch_dtype,
)
if use_offload and _can_cpu_offload(target_device):
pipe.enable_model_cpu_offload()
logger.info("Loaded OmniGen2 with CPU offload")
else:
pipe = pipe.to(target_device)
logger.info("Loaded OmniGen2 on %s", target_device)
_pipe = pipe
return True
except Exception as exc:
logger.warning("Official OmniGen2 loader failed: %s", exc)
_pipe = None
return False
def load_model():
global _pipe, _model_id
import diffusers
@@ -151,8 +348,12 @@ def load_model():
dtype_map = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}
torch_dtype = dtype_map.get(_args.dtype, torch.bfloat16)
use_offload = _args.cpu_offload
target_device = _target_device()
if target_device != "cuda" and use_offload:
logger.warning("CPU offload requested but %s is the active device; using direct device placement instead", target_device)
use_offload = False
logger.info(f"Loading model from {model_path} (dtype={_args.dtype}, offload={use_offload})...")
logger.info(f"Loading model from {model_path} (dtype={_args.dtype}, offload={use_offload}, device={target_device})...")
# Ensure HF token is available for gated repos
_hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
@@ -207,15 +408,37 @@ def load_model():
except Exception as e:
logger.debug(f"GPU cache clear failed: {e}")
loaded = False
if cls_name_from_index == "OmniGen2Pipeline" or "omnigen2" in str(model_path).lower():
loaded = _load_omnigen2_pipeline(model_path, torch_dtype, target_device, use_offload)
def _load_pipe(cls, name):
"""Try loading pipeline, handling meta tensor issues."""
global _pipe
# First try normal load
try:
_pipe = cls.from_pretrained(model_path, torch_dtype=torch_dtype)
kwargs = {"torch_dtype": torch_dtype}
if name == "DiffusionPipeline" and cls_name_from_index and not hasattr(diffusers, cls_name_from_index):
kwargs["trust_remote_code"] = True
_pipe = cls.from_pretrained(model_path, **kwargs)
except Exception as e:
logger.warning(f"{name} from_pretrained failed: {e}")
if name == "DiffusionPipeline" and cls_name_from_index and not hasattr(diffusers, cls_name_from_index):
try:
logger.info(f"Retrying {name} with custom_pipeline={model_path}")
_pipe = cls.from_pretrained(
model_path,
torch_dtype=torch_dtype,
custom_pipeline=model_path,
trust_remote_code=True,
)
except Exception as e2:
logger.warning(f"{name} custom_pipeline retry failed: {e2}")
_pipe = None
_cleanup()
return False
else:
_pipe = None
_cleanup()
return False
@@ -223,7 +446,7 @@ def load_model():
# Materialize any meta tensors before moving to device
_fix_meta_tensors(_pipe, torch_dtype)
if use_offload:
if use_offload and _can_cpu_offload(target_device):
try:
_pipe.enable_model_cpu_offload()
logger.info(f"Loaded as {name} with CPU offload")
@@ -234,24 +457,27 @@ def load_model():
_cleanup()
return False
# Try full CUDA
# Try direct device placement
try:
_pipe = _pipe.to("cuda")
logger.info(f"Loaded as {name} on CUDA")
_pipe = _pipe.to(target_device)
logger.info(f"Loaded as {name} on {target_device}")
return True
except Exception as e:
logger.warning(f"{name} + .to(cuda) failed: {e}")
logger.warning(f"{name} + .to({target_device}) failed: {e}")
_pipe = None
_cleanup()
if not use_offload:
logger.error(f"{name} doesn't fit in VRAM. Use --cpu-offload to enable offloading.")
logger.error(f"{name} could not be placed on {target_device}. On CUDA, try --cpu-offload; on Apple Silicon try a smaller model or lower resolution.")
return False
# OOM — reload and try with CPU offload
try:
logger.info(f"Reloading {name} with CPU offload...")
_pipe = cls.from_pretrained(model_path, torch_dtype=torch_dtype)
kwargs = {"torch_dtype": torch_dtype}
if name == "DiffusionPipeline" and cls_name_from_index and not hasattr(diffusers, cls_name_from_index):
kwargs["trust_remote_code"] = True
_pipe = cls.from_pretrained(model_path, **kwargs)
_fix_meta_tensors(_pipe, torch_dtype)
_pipe.enable_model_cpu_offload()
logger.info(f"Loaded as {name} with CPU offload")
@@ -264,7 +490,10 @@ def load_model():
# Last resort — sequential offload
try:
logger.info(f"Reloading {name} with sequential CPU offload...")
_pipe = cls.from_pretrained(model_path, torch_dtype=torch_dtype)
kwargs = {"torch_dtype": torch_dtype}
if name == "DiffusionPipeline" and cls_name_from_index and not hasattr(diffusers, cls_name_from_index):
kwargs["trust_remote_code"] = True
_pipe = cls.from_pretrained(model_path, **kwargs)
_fix_meta_tensors(_pipe, torch_dtype)
_pipe.enable_sequential_cpu_offload()
logger.info(f"Loaded as {name} with sequential CPU offload")
@@ -276,7 +505,7 @@ def load_model():
return False
loaded = False
if not loaded:
for cls, name in candidates:
if _load_pipe(cls, name):
loaded = True
@@ -322,36 +551,19 @@ def load_model():
if single_file:
logger.info(f"Trying from_single_file with: {single_file}")
# Detect model family from path/filename to prioritize the right pipeline + config
_path_lower = (model_path + "/" + (single_file or "")).lower()
_SD35_CONFIGS = ["stabilityai/stable-diffusion-3.5-large", "stabilityai/stable-diffusion-3.5-medium"]
_SD3_CONFIGS = ["stabilityai/stable-diffusion-3-medium-diffusers"]
_FLUX2_CONFIGS = ["black-forest-labs/FLUX.2-dev"]
_FLUX_CONFIGS = ["black-forest-labs/FLUX.1-schnell", "black-forest-labs/FLUX.1-dev"]
_SDXL_CONFIGS = ["stabilityai/stable-diffusion-xl-base-1.0"]
# Build ordered pipeline candidates based on model name hints
_pipeline_configs = []
if "sd3.5" in _path_lower or "stable-diffusion-3.5" in _path_lower:
_pipeline_configs.append(("StableDiffusion3Pipeline", _SD35_CONFIGS))
elif "sd3" in _path_lower or "stable-diffusion-3" in _path_lower:
_pipeline_configs.append(("StableDiffusion3Pipeline", _SD3_CONFIGS + _SD35_CONFIGS))
elif "flux.2" in _path_lower or "flux2" in _path_lower:
_pipeline_configs.append(("Flux2Pipeline", _FLUX2_CONFIGS))
_pipeline_configs.append(("FluxPipeline", _FLUX_CONFIGS))
elif "flux" in _path_lower:
_pipeline_configs.append(("FluxPipeline", _FLUX_CONFIGS))
_pipeline_configs.append(("Flux2Pipeline", _FLUX2_CONFIGS))
elif "sdxl" in _path_lower or "xl" in _path_lower:
_pipeline_configs.append(("StableDiffusionXLPipeline", _SDXL_CONFIGS))
# Always add all pipelines as fallbacks
_pipeline_configs.extend([
("Flux2Pipeline", _FLUX2_CONFIGS),
("StableDiffusion3Pipeline", _SD35_CONFIGS + _SD3_CONFIGS),
("FluxPipeline", _FLUX_CONFIGS),
("StableDiffusionXLPipeline", _SDXL_CONFIGS + [None]),
("StableDiffusionPipeline", [None]),
])
explicit_configs = [
c.strip()
for c in str(_args.single_file_config or "").replace("\n", ",").split(",")
if c.strip()
]
config_candidates = explicit_configs or [None]
_pipeline_configs = [
("Flux2Pipeline", config_candidates),
("StableDiffusion3Pipeline", config_candidates),
("FluxPipeline", config_candidates),
("StableDiffusionXLPipeline", config_candidates),
("StableDiffusionPipeline", config_candidates),
]
# Deduplicate while preserving order
_seen = set()
_deduped = []
@@ -409,12 +621,12 @@ def load_model():
logger.info(f"Trying {cls_name}.from_single_file with config={config}")
_pipe = cls.from_single_file(single_file, **kwargs)
_fix_meta_tensors(_pipe, torch_dtype)
if use_offload:
if use_offload and _can_cpu_offload(target_device):
_pipe.enable_model_cpu_offload()
logger.info(f"Loaded as {cls_name} (single file, config={config}) with CPU offload")
else:
_pipe = _pipe.to("cuda")
logger.info(f"Loaded as {cls_name} (single file, config={config}) on CUDA")
_pipe = _pipe.to(target_device)
logger.info(f"Loaded as {cls_name} (single file, config={config}) on {target_device}")
loaded = True
break
except Exception as e:
@@ -480,17 +692,9 @@ def generate_image(req: ImageRequest):
if _pipe is None:
return {"error": "Model not loaded"}
# Parse size
try:
w, h = req.size.split("x")
width, height = int(w), int(h)
except Exception:
width, height = _args.width, _args.height
# Map quality to num_inference_steps
default_steps = _args.steps or 8
steps_map = {"low": 4, "medium": default_steps, "high": 20, "auto": 12}
steps = steps_map.get(req.quality, default_steps)
width, height = _parse_size(req.size)
steps = _quality_steps(req.quality)
request_id = _start_progress(req.request_id, steps * max(1, int(req.n or 1)), req.prompt, "generation")
logger.info(f"Generating: {req.prompt[:80]}... ({width}x{height}, {steps} steps)")
start = time.time()
@@ -499,44 +703,172 @@ def generate_image(req: ImageRequest):
_is_inpaint_pipe = 'inpaint' in type(_pipe).__name__.lower()
images = []
for _ in range(req.n):
try:
for image_index in range(req.n):
progress_offset = image_index * steps
negative_prompt = _default_negative_prompt() if _pipeline_accepts_arg("negative_prompt") else None
if _is_inpaint_pipe:
# Inpaint pipelines need an image + mask — create blank ones for txt2img
from PIL import Image as _PILGen
_blank = _PILGen.new('RGB', (width, height), (128, 128, 128))
_mask = _PILGen.new('L', (width, height), 255) # full white = regenerate everything
result = _pipe(
prompt=req.prompt,
image=_blank,
mask_image=_mask,
width=width,
height=height,
num_inference_steps=steps,
guidance_scale=3.5,
)
kwargs = {
"prompt": req.prompt,
"image": _blank,
"mask_image": _mask,
"width": width,
"height": height,
"num_inference_steps": steps,
"guidance_scale": _guidance_scale(),
}
else:
result = _pipe(
prompt=req.prompt,
width=width,
height=height,
num_inference_steps=steps,
guidance_scale=3.5,
kwargs = {
"prompt": req.prompt,
"width": width,
"height": height,
"num_inference_steps": steps,
"guidance_scale": _guidance_scale(),
}
if negative_prompt:
kwargs["negative_prompt"] = negative_prompt
result = _run_pipeline_with_progress(
_pipe,
request_id,
steps * max(1, int(req.n or 1)),
**kwargs,
)
_update_progress(request_id, progress_offset + steps, steps * max(1, int(req.n or 1)))
img = result.images[0]
# Convert to base64
buf = io.BytesIO()
img.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode()
images.append({"b64_json": b64})
images.append(img)
except Exception as e:
_finish_progress(request_id, "error", str(e))
raise
elapsed = time.time() - start
logger.info(f"Generated {req.n} image(s) in {elapsed:.1f}s")
_finish_progress(request_id)
return {
"created": int(time.time()),
"data": images,
}
return _image_response(images)
@app.get("/v1/images/progress/{request_id}")
def image_progress(request_id: str):
item = _PROGRESS.get(request_id)
if not item:
return {"id": request_id, "status": "unknown", "step": 0, "total": 0, "percent": 0}
return item
@app.post("/v1/images/edits")
async def edit_image(
prompt: str = Form(...),
image: UploadFile = File(...),
model: str = Form(""),
n: int = Form(1),
size: str = Form("1024x1024"),
quality: str = Form("medium"),
response_format: str = Form("b64_json"),
request_id: str = Form(""),
):
if _pipe is None:
return {"error": "Model not loaded"}
accepts_image = _pipeline_accepts_arg("image")
accepts_input_images = _pipeline_accepts_arg("input_images")
if not accepts_image and not accepts_input_images:
raise HTTPException(
status_code=400,
detail=f"{type(_pipe).__name__} does not support image edits. Use /v1/images/generations with this model.",
)
from PIL import Image as PILImage, ImageOps
width, height = _parse_size(size)
steps = _quality_steps(quality)
request_id = _start_progress(request_id, steps * max(1, min(int(n or 1), 4)), prompt, "edit")
raw = await image.read()
init_image = PILImage.open(io.BytesIO(raw)).convert("RGB")
if width > 0 and height > 0:
init_image = ImageOps.fit(init_image, (width, height), method=PILImage.LANCZOS, centering=(0.5, 0.5))
logger.info(f"Editing image: {prompt[:80]}... ({width}x{height}, {steps} steps)")
start = time.time()
images = []
total_images = max(1, min(int(n or 1), 4))
for image_index in range(total_images):
progress_offset = image_index * steps
try:
if accepts_input_images and not accepts_image:
negative_prompt = _default_negative_prompt()
kwargs = _pipeline_call_kwargs(
prompt=prompt,
input_images=[init_image],
width=width,
height=height,
num_inference_steps=steps,
max_sequence_length=1024,
text_guidance_scale=_guidance_scale(),
image_guidance_scale=2.0,
cfg_range=(0.0, 1.0),
negative_prompt=negative_prompt,
num_images_per_prompt=1,
output_type="pil",
max_pixels=width * height if width > 0 and height > 0 else None,
max_input_image_side_length=max(width, height) if width > 0 and height > 0 else None,
)
else:
kwargs = _pipeline_call_kwargs(
image=init_image,
prompt=prompt,
width=width,
height=height,
num_inference_steps=steps,
guidance_scale=3.5,
true_cfg_scale=4.0,
negative_prompt=_default_negative_prompt(),
output_type="pil",
)
result = _run_pipeline_with_progress(
_pipe,
request_id,
steps * total_images,
**kwargs,
)
except TypeError:
if accepts_input_images and not accepts_image:
kwargs = _pipeline_call_kwargs(
prompt=prompt,
input_images=[init_image],
num_inference_steps=steps,
text_guidance_scale=_guidance_scale(),
image_guidance_scale=2.0,
negative_prompt=_default_negative_prompt(),
output_type="pil",
)
else:
kwargs = _pipeline_call_kwargs(
image=init_image,
prompt=prompt,
num_inference_steps=steps,
guidance_scale=3.5,
negative_prompt=_default_negative_prompt(),
output_type="pil",
)
result = _run_pipeline_with_progress(
_pipe,
request_id,
steps * total_images,
**kwargs,
)
except Exception as e:
_finish_progress(request_id, "error", str(e))
raise
_update_progress(request_id, progress_offset + steps, steps * total_images)
images.append(result.images[0])
elapsed = time.time() - start
logger.info(f"Edited {len(images)} image(s) in {elapsed:.1f}s")
_finish_progress(request_id)
return _image_response(images)
class InpaintRequest(BaseModel):
@@ -607,11 +939,12 @@ def _get_inpaint_pipe():
]
torch_dtype = DTYPE_MAP.get(_args.dtype, torch.bfloat16)
harmonize_gpu = _args.harmonize_gpu
target_device = _target_device()
for name in img2img_names:
cls = getattr(diffusers, name, None)
if cls:
try:
if harmonize_gpu is not None:
if harmonize_gpu is not None and target_device == "cuda":
# Load fresh on separate GPU
logger.info(f"Loading {name} on cuda:{harmonize_gpu}...")
_img2img_pipe = cls.from_pretrained(_args.model, torch_dtype=torch_dtype)
@@ -625,10 +958,10 @@ def _get_inpaint_pipe():
try:
# Some pipelines need from_pretrained instead of from_pipe
_img2img_pipe = cls.from_pretrained(_args.model, torch_dtype=torch_dtype)
if _args.cpu_offload:
if _args.cpu_offload and _can_cpu_offload(target_device):
_img2img_pipe.enable_model_cpu_offload()
else:
_img2img_pipe = _img2img_pipe.to("cuda")
_img2img_pipe = _img2img_pipe.to(target_device)
logger.info(f"Loaded img2img pipeline (from_pretrained): {name}")
return _img2img_pipe, 'img2img'
except Exception as e2:
@@ -1140,6 +1473,9 @@ if __name__ == "__main__":
parser.add_argument("--dtype", default="bfloat16", choices=["bfloat16", "float16", "float32"])
parser.add_argument("--device-map", default=None, help="Device map strategy (unused, kept for compat)")
parser.add_argument("--steps", type=int, default=0, help="Default inference steps (0=auto)")
parser.add_argument("--guidance-scale", type=float, default=3.5, help="Default classifier-free guidance scale")
parser.add_argument("--negative-prompt", default="", help="Default negative prompt for pipelines that support it")
parser.add_argument("--single-file-config", default="", help="Base Diffusers repo/path for single-file checkpoints that need missing components. Comma-separated values are tried in order.")
parser.add_argument("--width", type=int, default=1024, help="Default output width")
parser.add_argument("--height", type=int, default=1024, help="Default output height")
parser.add_argument("--cpu-offload", action="store_true", help="Enable model CPU offload")
+465
View File
@@ -0,0 +1,465 @@
#!/usr/bin/env python3
"""OpenAI-compatible image API wrapper for MLX image models.
This is intentionally small: it exposes the same `/v1/images/generations`
shape Odysseus already uses for local image endpoints, then delegates to the
MLX image CLI for the actual generation. Text MLX models still use
`mlx_lm.server`; image MLX models should use this wrapper.
"""
from __future__ import annotations
import argparse
import base64
import os
import shutil
import subprocess
import sys
import tempfile
import logging
from pathlib import Path
import uvicorn
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from pydantic import BaseModel
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("mlx_image_server")
class ImageRequest(BaseModel):
model: str = ""
prompt: str
n: int = 1
size: str = "1024x1024"
quality: str = "medium"
response_format: str = "b64_json"
class HarmonizeRequest(BaseModel):
image: str
prompt: str = ""
mask: str | None = None
body_mask: str | None = None
seam_mask: str | None = None
strength: float = 0.35
app = FastAPI(title="Odysseus MLX Image Server")
_args: argparse.Namespace
def _steps(quality: str) -> int:
if _args.steps:
return int(_args.steps)
return {"low": 8, "medium": 20, "high": 32, "auto": 20}.get((quality or "medium").lower(), 20)
def _size(size: str) -> tuple[int, int]:
try:
w, h = str(size or "").lower().split("x", 1)
return max(64, int(w)), max(64, int(h))
except Exception:
return int(_args.width), int(_args.height)
def _cli_for_model(model: str) -> str:
lower = model.lower()
if "qwen" in lower:
return "mflux-generate-qwen"
if "flux" in lower:
return "mflux-generate"
return "mflux-generate"
def _resolve_cli(name: str) -> str:
found = shutil.which(name)
if found:
return found
local = Path(sys.executable).resolve().parent / name
if local.exists():
return str(local)
prefix_local = Path(sys.prefix).resolve() / "bin" / name
if prefix_local.exists():
return str(prefix_local)
return ""
def _valid_numbers(values: list[str]) -> list[str]:
out: list[str] = []
for value in values or []:
s = str(value).strip()
if not s:
continue
try:
float(s)
except Exception:
continue
out.append(s)
return out
def _is_hidream(model: str) -> bool:
return "hidream" in (model or "").lower()
def _is_boogu(model: str) -> bool:
return "boogu" in (model or "").lower()
def _is_lama_inpaint(model: str) -> bool:
lower = (model or "").lower()
return "mi-gan" in lower or "migan" in lower or "lama" in lower
def _is_ddcolor(model: str) -> bool:
return "ddcolor" in (model or "").lower()
def _unsupported_swift_mlx_runtime(model: str) -> HTTPException:
if _is_ddcolor(model):
return HTTPException(
503,
"DDColor MLX models require an Odysseus-compatible mlx-ddcolor-swift bridge. "
"Build/install a bridge binary named odysseus-mlx-colorize or mlx-ddcolor-serve "
"on the Apple Silicon host PATH. Upstream currently ships Swift libraries and "
"smoke executables, not a stable colorize CLI.",
)
return HTTPException(
503,
"LaMa / MI-GAN MLX inpainting models require an Odysseus-compatible mlx-lama-swift bridge. "
"Build/install a bridge binary named odysseus-mlx-inpaint or mlx-lama-serve "
"on the Apple Silicon host PATH. Upstream currently ships Swift libraries and "
"smoke executables, not a stable image-edit CLI.",
)
def _resolve_bridge(names: list[str]) -> str:
for name in names:
found = _resolve_cli(name)
if found:
return found
return ""
def _snapshot_path(model: str) -> Path:
p = Path(model).expanduser()
if p.exists():
return p
try:
from huggingface_hub import snapshot_download
except Exception as e:
raise HTTPException(
503,
"huggingface_hub is required to download MLX image model snapshots. "
"Install the model requirements in the selected Python environment.",
) from e
return Path(snapshot_download(model))
def _weights_path(model: str) -> Path:
p = Path(model).expanduser()
if p.is_file():
return p
snap = _snapshot_path(model)
if snap.is_file():
return snap
candidates = sorted(snap.rglob("*.safetensors"))
if not candidates:
raise HTTPException(500, f"No safetensors weights found for {model} in {snap}")
return candidates[0]
def _write_bridge_input_image(raw: bytes, out_path: Path) -> None:
try:
from PIL import Image
import io
except Exception as e:
raise HTTPException(503, "Pillow is required for MLX image edit bridge inputs.") from e
try:
img = Image.open(io.BytesIO(raw)).convert("RGBA")
img.save(out_path, format="PNG")
except Exception as e:
raise HTTPException(400, f"Invalid input image: {e}") from e
def _write_bridge_mask(raw: bytes, out_path: Path) -> None:
try:
from PIL import Image
import io
except Exception as e:
raise HTTPException(503, "Pillow is required for MLX image edit bridge masks.") from e
try:
img = Image.open(io.BytesIO(raw))
if img.mode == "RGBA":
# OpenAI edits mask convention: transparent = regenerate.
alpha = img.getchannel("A")
mask = alpha.point(lambda p: 255 if p < 128 else 0)
else:
mask = img.convert("L")
mask.save(out_path, format="PNG")
except Exception as e:
raise HTTPException(400, f"Invalid mask image: {e}") from e
def _run_bridge(cmd: list[str]) -> None:
env = os.environ.copy()
proc = subprocess.run(cmd, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if proc.returncode != 0:
detail = (proc.stderr or proc.stdout or "MLX Swift bridge failed").strip()
logger.error("MLX Swift bridge failed (%s): %s\n%s", proc.returncode, " ".join(cmd), detail[-4000:])
raise HTTPException(500, detail[-4000:])
def _run_ddcolor_bridge(model: str, image_raw: bytes, out_path: Path) -> None:
bridge = _resolve_bridge(["odysseus-mlx-colorize", "mlx-ddcolor-serve"])
if not bridge:
raise _unsupported_swift_mlx_runtime(model)
with tempfile.TemporaryDirectory(prefix="odysseus-ddcolor-") as td:
inp = Path(td) / "input.png"
_write_bridge_input_image(image_raw, inp)
weights = _weights_path(model)
tier = "tiny" if "tiny" in model.lower() else "large"
_run_bridge([
bridge,
"--model", str(weights),
"--image", str(inp),
"--output", str(out_path),
"--tier", tier,
])
def _run_inpaint_bridge(model: str, image_raw: bytes, mask_raw: bytes | None, out_path: Path) -> None:
if not mask_raw:
raise HTTPException(
422,
"LaMa / MI-GAN inpainting requires an image mask. Use the editor inpaint/object-removal tool so Odysseus can send the mask.",
)
bridge = _resolve_bridge(["odysseus-mlx-inpaint", "mlx-lama-serve"])
if not bridge:
raise _unsupported_swift_mlx_runtime(model)
with tempfile.TemporaryDirectory(prefix="odysseus-mlx-inpaint-") as td:
inp = Path(td) / "input.png"
mask = Path(td) / "mask.png"
_write_bridge_input_image(image_raw, inp)
_write_bridge_mask(mask_raw, mask)
weights = _weights_path(model)
mode = "fast" if ("mi-gan" in model.lower() or "migan" in model.lower()) else "best"
_run_bridge([
bridge,
"--model", str(weights),
"--image", str(inp),
"--mask", str(mask),
"--output", str(out_path),
"--mode", mode,
])
def _generate_hidream(model: str, prompt: str, out_path: Path, width: int, height: int, steps: int) -> None:
model_path = _snapshot_path(model)
script = model_path / "scripts" / "hidream_o1" / "generate_hidream_o1_mlx.py"
if not script.exists():
raise HTTPException(500, f"HiDream generator script not found in snapshot: {script}")
cmd = [
sys.executable,
str(script),
"--model-path",
str(model_path),
"--prompt",
prompt,
"--output",
str(out_path),
"--width",
str(width),
"--height",
str(height),
"--num-inference-steps",
str(steps),
"--no-snap-resolution",
]
env = os.environ.copy()
proc = subprocess.run(cmd, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if proc.returncode != 0:
detail = (proc.stderr or proc.stdout or "HiDream generator failed").strip()
raise HTTPException(500, detail[-4000:])
def _generate_boogu(model: str, prompt: str, out_path: Path, width: int, height: int, steps: int) -> None:
try:
from boogu_image_mlx.pipeline_mlx import BooguImagePipeline
from PIL import Image
except Exception as e:
raise HTTPException(
503,
"Boogu MLX serving requires boogu-image-mlx in the launch Python. "
"Install with: python -m pip install -U git+https://github.com/xocialize/boogu-image-mlx.git",
) from e
model_path = _snapshot_path(model)
vlm_model = (_args.vlm_model or os.environ.get("ODYSSEUS_MLX_IMAGE_VLM_MODEL") or "").strip()
if not vlm_model:
raise HTTPException(
422,
"This MLX image pipeline requires a companion vision-language model. "
"Relaunch with --vlm-model <repo_or_path> or set ODYSSEUS_MLX_IMAGE_VLM_MODEL.",
)
try:
pipe = BooguImagePipeline.from_pretrained(
str(model_path),
vlm_model,
)
img = pipe.generate(
prompt,
height=height,
width=width,
steps=steps,
guidance=3.5,
)
Image.fromarray(img).save(out_path)
except Exception as e:
raise HTTPException(500, f"Boogu MLX generation failed: {e}") from e
@app.get("/v1/models")
def list_models():
return {"data": [{"id": _args.model, "object": "model", "owned_by": "local"}]}
@app.post("/v1/images/generations")
def generate(req: ImageRequest):
model = req.model or _args.model
width, height = _size(req.size)
out_images = []
count = max(1, min(int(req.n or 1), 4))
for _ in range(count):
with tempfile.TemporaryDirectory(prefix="odysseus-mlx-image-") as td:
out_path = Path(td) / "image.png"
if _is_hidream(model):
_generate_hidream(model, req.prompt, out_path, width, height, _steps(req.quality))
elif _is_boogu(model):
_generate_boogu(model, req.prompt, out_path, width, height, _steps(req.quality))
elif _is_lama_inpaint(model) or _is_ddcolor(model):
raise _unsupported_swift_mlx_runtime(model)
else:
cli = _cli_for_model(model)
cli_path = _resolve_cli(cli)
if not cli_path:
raise HTTPException(
503,
f"{cli} not found in PATH or next to {sys.executable}. Install the MLX image runtime with: python3 -m pip install -U mflux",
)
cmd = [
cli_path,
"--model",
model,
"--prompt",
req.prompt,
"--steps",
str(_steps(req.quality)),
"--output",
str(out_path),
]
if _args.base_model:
cmd += ["--base-model", _args.base_model]
if _args.lora_style:
cmd += ["--lora-style", _args.lora_style]
if _args.lora_paths:
cmd += ["--lora-paths", *_args.lora_paths]
lora_scales = _valid_numbers(_args.lora_scales)
if lora_scales:
cmd += ["--lora-scales", *lora_scales]
if "qwen" not in model.lower():
cmd += ["--width", str(width), "--height", str(height)]
env = os.environ.copy()
proc = subprocess.run(cmd, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if proc.returncode != 0:
detail = (proc.stderr or proc.stdout or f"{cli} failed").strip()
logger.error("MLX image command failed (%s): %s\n%s", proc.returncode, " ".join(cmd), detail[-4000:])
raise HTTPException(500, detail[-4000:])
if not out_path.exists():
raise HTTPException(500, f"MLX image generator completed but did not write {out_path}")
b64 = base64.b64encode(out_path.read_bytes()).decode("ascii")
out_images.append({"b64_json": b64})
return {"created": 0, "data": out_images}
@app.post("/v1/images/edits")
async def edit_image(
image: UploadFile = File(...),
mask: UploadFile | None = File(None),
prompt: str = Form(""),
model: str = Form(""),
n: int = Form(1),
size: str = Form("1024x1024"),
response_format: str = Form("b64_json"),
):
active_model = model or _args.model
if _is_lama_inpaint(active_model) or _is_ddcolor(active_model):
image_raw = await image.read()
mask_raw = await mask.read() if mask is not None else None
out_images = []
count = max(1, min(int(n or 1), 4))
for _ in range(count):
with tempfile.TemporaryDirectory(prefix="odysseus-mlx-edit-") as td:
out_path = Path(td) / "image.png"
if _is_ddcolor(active_model):
_run_ddcolor_bridge(active_model, image_raw, out_path)
else:
_run_inpaint_bridge(active_model, image_raw, mask_raw, out_path)
if not out_path.exists():
raise HTTPException(500, f"MLX Swift bridge completed but did not write {out_path}")
out_images.append({"b64_json": base64.b64encode(out_path.read_bytes()).decode("ascii")})
return {"created": 0, "data": out_images}
raise HTTPException(
422,
"This MLX image endpoint supports text-to-image generation only. "
"Use /v1/images/generations, or serve an edit/img2img-capable model.",
)
@app.post("/v1/images/harmonize")
def harmonize_image(req: HarmonizeRequest):
active_model = _args.model
if _is_lama_inpaint(active_model) or _is_ddcolor(active_model):
try:
image_raw = base64.b64decode(req.image.split(",", 1)[-1])
mask_b64 = req.body_mask or req.mask
mask_raw = base64.b64decode(mask_b64.split(",", 1)[-1]) if mask_b64 else None
except Exception as e:
raise HTTPException(400, f"Invalid base64 image payload: {e}") from e
with tempfile.TemporaryDirectory(prefix="odysseus-mlx-harmonize-") as td:
out_path = Path(td) / "image.png"
if _is_ddcolor(active_model):
_run_ddcolor_bridge(active_model, image_raw, out_path)
else:
_run_inpaint_bridge(active_model, image_raw, mask_raw, out_path)
if not out_path.exists():
raise HTTPException(500, f"MLX Swift bridge completed but did not write {out_path}")
return {"image": base64.b64encode(out_path.read_bytes()).decode("ascii")}
raise HTTPException(
422,
"This MLX image endpoint supports text-to-image generation only. "
"Use /v1/images/generations, or serve an edit/img2img-capable model.",
)
def main() -> None:
global _args
parser = argparse.ArgumentParser()
parser.add_argument("--model", required=True)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8100)
parser.add_argument("--steps", type=int, default=0)
parser.add_argument("--width", type=int, default=1024)
parser.add_argument("--height", type=int, default=1024)
parser.add_argument("--base-model", default="")
parser.add_argument("--lora-style", default="")
parser.add_argument("--lora-paths", nargs="*", default=[])
parser.add_argument("--lora-scales", nargs="*", default=[])
parser.add_argument("--vlm-model", default="")
_args = parser.parse_args()
uvicorn.run(app, host=_args.host, port=_args.port)
if __name__ == "__main__":
main()
+334 -275
View File
@@ -1,278 +1,328 @@
"""Image generation model registry and VRAM fitting for Cookbook."""
# Curated registry of image generation models supported by diffusers.
# ONLY verified HuggingFace repo IDs.
# VRAM estimates are for inference (single image generation).
IMAGE_MODEL_REGISTRY = [
# ── Z-Image (Alibaba Tongyi) ──
{
"id": "Tongyi-MAI/Z-Image-Turbo",
"name": "Z-Image Turbo",
"provider": "Tongyi",
"params_b": 6.0,
"vram_bf16": 19.0,
"vram_fp8": 10.0,
"vram_q4": 6.0,
"default_quant": "BF16",
"quant_repos": {
"FP8": "drbaph/Z-Image-Turbo-FP8",
},
"capabilities": ["text-to-image"],
"description": "6B distilled, 8-step. Sub-second on H800. Apache 2.0.",
"quality": 92,
"speed": 95,
"released": "2025-12",
},
{
"id": "Tongyi-MAI/Z-Image",
"name": "Z-Image",
"provider": "Tongyi",
"params_b": 6.0,
"vram_bf16": 19.0,
"vram_fp8": 10.0,
"vram_q4": 6.0,
"default_quant": "BF16",
"quant_repos": {
"FP8": "drbaph/Z-Image-fp8",
},
"capabilities": ["text-to-image"],
"description": "Full undistilled model. Highest creative freedom. Apache 2.0.",
"quality": 93,
"speed": 70,
"released": "2025-12",
},
# ── Qwen Image ──
{
"id": "Qwen/Qwen-Image-2512",
"name": "Qwen Image 2512",
"provider": "Qwen",
"params_b": 20.0,
"vram_bf16": 42.0,
"vram_fp8": 22.0,
"vram_q4": 14.0,
"default_quant": "FP8",
"quant_repos": {},
"capabilities": ["text-to-image", "text-rendering"],
"description": "Dec 2025 update. Better humans, finer detail, strong text. Apache 2.0.",
"quality": 95,
"speed": 50,
"released": "2025-12",
},
{
"id": "Qwen/Qwen-Image",
"name": "Qwen Image",
"provider": "Qwen",
"params_b": 20.0,
"vram_bf16": 42.0,
"vram_fp8": 22.0,
"vram_q4": 14.0,
"default_quant": "FP8",
"quant_repos": {},
"capabilities": ["text-to-image", "text-rendering"],
"description": "20B foundation. Best text rendering in images. Apache 2.0.",
"quality": 94,
"speed": 50,
"released": "2025-08",
},
{
"id": "Qwen/Qwen-Image-Edit-2511",
"name": "Qwen Image Edit",
"provider": "Qwen",
"params_b": 20.0,
"vram_bf16": 42.0,
"vram_fp8": 22.0,
"vram_q4": 14.0,
"default_quant": "FP8",
"quant_repos": {},
"capabilities": ["image-editing", "inpainting"],
"description": "Dedicated editing. Style transfer, object removal. Apache 2.0.",
"quality": 92,
"speed": 50,
"released": "2025-11",
},
# ── Stable Diffusion (dedicated inpainting) ──
{
"id": "diffusers/stable-diffusion-xl-1.0-inpainting-0.1",
"name": "SDXL Inpainting",
"provider": "Stability AI",
"params_b": 3.5,
"vram_bf16": 12.0,
"vram_fp8": 8.0,
"vram_q4": 6.0,
"default_quant": "BF16",
"quant_repos": {},
"capabilities": ["inpainting", "image-editing"],
"description": "SDXL fine-tuned for inpainting (9-channel UNet). Best SD-family fill quality; fits a 24GB card comfortably.",
"quality": 86,
"speed": 68,
"released": "2023-11",
},
{
"id": "stable-diffusion-v1-5/stable-diffusion-inpainting",
"name": "SD 1.5 Inpainting",
"provider": "Stability AI",
"params_b": 1.1,
"vram_bf16": 4.0,
"vram_fp8": 3.0,
"vram_q4": 2.5,
"default_quant": "BF16",
"quant_repos": {},
"capabilities": ["inpainting"],
"description": "Classic SD 1.5 inpaint. Very light and fast; lower fidelity than SDXL.",
"quality": 70,
"speed": 92,
"released": "2022-10",
},
# ── FLUX ──
{
"id": "black-forest-labs/FLUX.1-dev",
"name": "FLUX.1 Dev",
"provider": "Black Forest Labs",
"params_b": 12.0,
"vram_bf16": 33.0,
"vram_fp8": 17.0,
"vram_q4": 10.0,
"default_quant": "FP8",
"quant_repos": {
"FP8": "diffusers/FLUX.1-dev-torchao-fp8",
},
"capabilities": ["text-to-image"],
"description": "High quality, detailed. Popular community model. Non-commercial.",
"quality": 92,
"speed": 55,
"released": "2024-08",
},
{
"id": "black-forest-labs/FLUX.1-schnell",
"name": "FLUX.1 Schnell",
"provider": "Black Forest Labs",
"params_b": 12.0,
"vram_bf16": 33.0,
"vram_fp8": 17.0,
"vram_q4": 10.0,
"default_quant": "FP8",
"quant_repos": {
"FP8": "Kijai/flux-fp8",
},
"capabilities": ["text-to-image"],
"description": "Fast 4-step variant. Apache 2.0 license.",
"quality": 85,
"speed": 90,
"released": "2024-08",
},
# ── Stable Diffusion ──
{
"id": "stabilityai/stable-diffusion-3.5-medium",
"name": "SD 3.5 Medium",
"provider": "Stability AI",
"params_b": 2.5,
"vram_bf16": 12.0,
"vram_fp8": 7.0,
"vram_q4": None,
"default_quant": "BF16",
"quant_repos": {
"FP8": "Comfy-Org/stable-diffusion-3.5-fp8",
},
"capabilities": ["text-to-image"],
"description": "2.5B lightweight, fast. Fits almost any GPU.",
"quality": 75,
"speed": 95,
"released": "2024-10",
},
{
"id": "stabilityai/stable-diffusion-3.5-large",
"name": "SD 3.5 Large",
"provider": "Stability AI",
"params_b": 8.1,
"vram_bf16": 22.0,
"vram_fp8": 12.0,
"vram_q4": None,
"default_quant": "BF16",
"quant_repos": {
"FP8": "Comfy-Org/stable-diffusion-3.5-fp8",
},
"capabilities": ["text-to-image"],
"description": "8B high quality. Good balance of speed and quality.",
"quality": 85,
"speed": 70,
"released": "2024-10",
},
{
"id": "stabilityai/stable-diffusion-3.5-large-turbo",
"name": "SD 3.5 Large Turbo",
"provider": "Stability AI",
"params_b": 8.1,
"vram_bf16": 22.0,
"vram_fp8": 12.0,
"vram_q4": None,
"default_quant": "BF16",
"quant_repos": {
"FP8": "Comfy-Org/stable-diffusion-3.5-fp8",
},
"capabilities": ["text-to-image"],
"description": "Distilled for few-step inference. Fastest large SD.",
"quality": 80,
"speed": 92,
"released": "2024-10",
},
{
"id": "stabilityai/stable-diffusion-xl-base-1.0",
"name": "SDXL",
"provider": "Stability AI",
"params_b": 3.5,
"vram_bf16": 10.0,
"vram_fp8": 6.0,
"vram_q4": None,
"default_quant": "BF16",
"quant_repos": {},
"capabilities": ["text-to-image"],
"description": "Classic workhorse. Huge LoRA ecosystem. Fits 8GB+.",
"quality": 72,
"speed": 90,
"released": "2023-07",
},
# ── Hunyuan ──
{
"id": "tencent/HunyuanImage-3.0",
"name": "HunyuanImage 3.0",
"provider": "Tencent",
"params_b": 13.0,
"vram_bf16": 30.0,
"vram_fp8": 16.0,
"vram_q4": 9.0,
"default_quant": "FP8",
"quant_repos": {
"Q4": "wikeeyang/Hunyuan-Image-30-Qint4",
"NF4": "EricRollei/HunyuanImage-3.0-Instruct-NF4",
},
"capabilities": ["text-to-image", "text-rendering"],
"description": "Strong text rendering. Bilingual Chinese/English. 13B activated per token.",
"quality": 88,
"speed": 60,
"released": "2025-09",
},
{
"id": "tencent/HunyuanImage-3.0-Instruct-Distil",
"name": "HunyuanImage 3.0 Distil",
"provider": "Tencent",
"params_b": 13.0,
"vram_bf16": 30.0,
"vram_fp8": 16.0,
"vram_q4": 9.0,
"default_quant": "FP8",
"quant_repos": {},
"capabilities": ["text-to-image", "text-rendering"],
"description": "Distilled variant, fewer steps. Faster with comparable quality.",
"quality": 85,
"speed": 80,
"released": "2026-01",
},
from __future__ import annotations
import json
import re
import time
import urllib.parse
import urllib.request
from typing import Any
# Image models are discovered from HuggingFace collections/search and local cache.
# Keep this empty: source-coded repo IDs become hidden recommendations.
IMAGE_MODEL_REGISTRY: list[dict[str, Any]] = []
HF_IMAGE_COLLECTIONS = [
"stabilityai/image",
"stabilityai/stable-diffusion-35",
"black-forest-labs/flux2",
]
HF_MLX_IMAGE_COLLECTIONS = [
"mlx-community/flux2-klein-mlx",
"mlx-community/inpainting-mlx",
"mlx-community/ddcolor-mlx",
"mlx-community/boogu-image-01-mlx",
]
HF_MLX_IMAGE_REPO_SEEDS: list[str] = []
HF_IMAGE_REPO_SEEDS: list[str] = []
_HF_COLLECTION_CACHE = {"ts": 0.0, "models": []}
_HF_COLLECTION_TTL = 30 * 60
_HF_VARIANT_CACHE: dict[str, dict[str, str]] = {}
_HF_SEARCH_DISABLED_UNTIL = 0.0
def _repo_display_name(repo_id: str) -> str:
name = str(repo_id or "").split("/")[-1]
return name.replace("-", " ").replace("_", " ").strip() or repo_id
def _provider_from_repo(repo_id: str) -> str:
owner = str(repo_id or "").split("/", 1)[0].lower()
return {
"stabilityai": "Stability AI",
"black-forest-labs": "Black Forest Labs",
"tongyi-mai": "Tongyi",
"qwen": "Qwen",
"mlx-community": "mlx-community",
}.get(owner, owner.replace("-", " ").title() if owner else "HuggingFace")
def _infer_capabilities(item: dict[str, Any], repo_id: str) -> list[str]:
tasks = set()
pipeline = str(item.get("pipeline_tag") or "").strip().lower()
if pipeline:
tasks.add(pipeline)
for provider in item.get("availableInferenceProviders") or []:
if isinstance(provider, dict) and provider.get("task"):
tasks.add(str(provider["task"]).strip().lower())
text = f"{repo_id} {' '.join(tasks)}".lower()
caps = []
if "image-to-image" in tasks or "edit" in text or "inpaint" in text:
caps.append("image-editing")
if "inpaint" in text:
caps.append("inpainting")
if "text-to-image" in tasks or not caps:
caps.append("text-to-image")
return caps
def _estimate_image_model(repo_id: str) -> dict[str, Any]:
text = str(repo_id or "").lower()
params_b = 8.0
param_match = re.search(r"(?<![\d.])(\d+(?:\.\d+)?)\s*b(?:\b|[-_])", text)
if param_match:
params_b = max(0.01, float(param_match.group(1)))
if any(k in text for k in ("mi-gan", "big-lama", "lama-")):
return {"params_b": 0.01, "bf16": 1.0, "fp8": 0.7, "q4": 0.5, "quality": 65, "speed": 98, "quant": "BF16"}
quant = "BF16"
if any(k in text for k in ("4bit", "q4", "nf4")):
quant = "Q4"
elif "fp8" in text or "8bit" in text:
quant = "FP8"
bf16 = max(1.0, round(params_b * 2.6 + 3.0, 1))
fp8 = max(0.7, round(params_b * 1.35 + 2.0, 1))
q4 = max(0.5, round(params_b * 0.8 + 1.5, 1))
speed = max(35, min(95, int(98 - params_b * 3)))
quality = max(60, min(88, int(70 + min(params_b, 18) * 0.8)))
return {"params_b": params_b, "bf16": bf16, "fp8": fp8, "q4": q4, "quality": quality, "speed": speed, "quant": quant}
def _params_b_from_item(item: dict[str, Any]) -> float | None:
raw = item.get("numParameters")
if isinstance(raw, (int, float)) and raw > 0:
return max(0.01, round(float(raw) / 1_000_000_000.0, 3))
return None
def _mlx_quantize_estimate(repo_id: str, est: dict[str, Any]) -> dict[str, Any]:
text = str(repo_id or "").lower()
out = dict(est)
if "3bit" in text or "4bit" in text or "q4" in text:
out["quant"] = "Q4"
out["bf16"] = None
out["fp8"] = None
elif "8bit" in text:
out["quant"] = "FP8"
out["bf16"] = None
elif "6bit" in text or "5bit" in text:
out["quant"] = "Q4"
out["bf16"] = None
out["fp8"] = out.get("fp8") or out.get("q4")
elif "bf16" in text or "fp16" in text:
out["quant"] = "BF16"
out["fp8"] = None
out["q4"] = None
return out
def _collection_item_to_model(item: dict[str, Any], collection_title: str = "", mlx_only: bool = False) -> dict[str, Any] | None:
repo_id = str(item.get("id") or "").strip()
if "/" not in repo_id:
return None
typ = str(item.get("type") or item.get("itemType") or "model").lower()
if typ not in {"", "model"}:
return None
est = _estimate_image_model(repo_id)
item_params_b = _params_b_from_item(item)
if item_params_b is not None:
est = {
**est,
"params_b": item_params_b,
"bf16": max(0.5, round(item_params_b * 2.4 + 0.8, 1)),
"fp8": max(0.5, round(item_params_b * 1.3 + 0.5, 1)),
"q4": max(0.4, round(item_params_b * 0.8 + 0.4, 1)),
}
if mlx_only:
est = _mlx_quantize_estimate(repo_id, est)
caps = _infer_capabilities(item, repo_id)
gated = item.get("gated")
desc_bits = []
if collection_title:
desc_bits.append(f"HF collection: {collection_title}.")
if gated:
desc_bits.append("Gated on HuggingFace.")
out = {
"id": repo_id,
"name": _repo_display_name(repo_id),
"provider": _provider_from_repo(repo_id),
"params_b": est["params_b"],
"vram_bf16": est["bf16"],
"vram_fp8": est["fp8"],
"vram_q4": est["q4"],
"default_quant": est["quant"],
"quant_repos": {},
"capabilities": caps,
"description": " ".join(desc_bits).strip() or "Imported from HuggingFace collection.",
"quality": est["quality"],
"speed": est["speed"],
"released": "",
}
if mlx_only:
out["mlx_only"] = True
out["description"] = (out["description"] + " Apple Silicon / MLX only.").strip()
return out
def _fetch_hf_image_collection_models() -> list[dict[str, Any]]:
now = time.time()
if now - float(_HF_COLLECTION_CACHE.get("ts") or 0) < _HF_COLLECTION_TTL:
return list(_HF_COLLECTION_CACHE.get("models") or [])
models: list[dict[str, Any]] = []
for slug, mlx_only in [(slug, False) for slug in HF_IMAGE_COLLECTIONS] + [(slug, True) for slug in HF_MLX_IMAGE_COLLECTIONS]:
url = f"https://huggingface.co/api/collections/{slug}"
try:
req = urllib.request.Request(url, headers={"User-Agent": "Odysseus-Cookbook/1.0"})
with urllib.request.urlopen(req, timeout=2.5) as resp:
data = json.loads(resp.read().decode("utf-8", "replace"))
except Exception:
continue
title = str(data.get("title") or slug)
for item in data.get("items") or []:
if isinstance(item, dict):
model = _collection_item_to_model(item, title, mlx_only=mlx_only)
if model:
models.append(model)
_HF_COLLECTION_CACHE["ts"] = now
_HF_COLLECTION_CACHE["models"] = models
return list(models)
def _hf_model_search(query: str, limit: int = 10) -> list[dict[str, Any]]:
global _HF_SEARCH_DISABLED_UNTIL
now = time.time()
if now < _HF_SEARCH_DISABLED_UNTIL:
return []
url = "https://huggingface.co/api/models?" + urllib.parse.urlencode({
"search": query,
"limit": str(limit),
})
try:
req = urllib.request.Request(url, headers={"User-Agent": "Odysseus-Cookbook/1.0"})
with urllib.request.urlopen(req, timeout=2.5) as resp:
data = json.loads(resp.read().decode("utf-8", "replace"))
return data if isinstance(data, list) else []
except Exception:
_HF_SEARCH_DISABLED_UNTIL = now + 10 * 60
return []
def _variant_score(candidate: dict[str, Any], base_repo: str, want: str) -> float:
rid = str(candidate.get("id") or candidate.get("modelId") or "")
text = " ".join([
rid,
str(candidate.get("library_name") or ""),
str(candidate.get("pipeline_tag") or ""),
" ".join(str(t) for t in candidate.get("tags") or []),
]).lower()
base = base_repo.lower()
base_short = base_repo.rsplit("/", 1)[-1].lower()
if want == "gguf" and "gguf" not in text:
return -1
if want == "fp8" and not any(k in text for k in ("fp8", "nvfp4", "mxfp8", "mxfp4")):
return -1
score = float(candidate.get("downloads") or 0) / 1000.0 + float(candidate.get("likes") or 0)
if f"base_model:{base}" in text or f"base_model:quantized:{base}" in text:
score += 10000
elif base_short and base_short in rid.lower():
score += 1000
else:
score -= 200
if "diffusers" in text:
score += 50
if str(candidate.get("private")).lower() == "true":
score -= 10000
return score
def _best_variant_repo(base_repo: str, want: str) -> str:
base_short = str(base_repo or "").rsplit("/", 1)[-1]
candidates = _hf_model_search(f"{base_short} {want}", limit=12)
scored = []
for item in candidates:
if not isinstance(item, dict):
continue
rid = str(item.get("id") or item.get("modelId") or "").strip()
if "/" not in rid or rid.lower() == base_repo.lower():
continue
score = _variant_score(item, base_repo, want)
if score >= 0:
scored.append((score, rid))
scored.sort(reverse=True)
return scored[0][1] if scored else ""
def _should_discover_variants(repo_id: str) -> bool:
return False
def _discover_quant_repos(repo_id: str, need_fp8: bool = True, need_gguf: bool = True) -> dict[str, str]:
key = str(repo_id or "").strip()
if not key:
return {}
cache_key = f"{key.lower()}|fp8={int(need_fp8)}|gguf={int(need_gguf)}"
if cache_key in _HF_VARIANT_CACHE:
return dict(_HF_VARIANT_CACHE[cache_key])
found: dict[str, str] = {}
if need_fp8:
fp8 = _best_variant_repo(key, "fp8")
if fp8:
found["FP8"] = fp8
if need_gguf:
gguf = _best_variant_repo(key, "gguf")
if gguf:
# The image-model fitter's smallest bucket is Q4; most HF image GGUF
# repos expose Q4/Q5/Q8 files under one repo, so use it as the low-VRAM
# download source while preserving the explicit GGUF label for callers.
found["Q4"] = gguf
found["GGUF"] = gguf
_HF_VARIANT_CACHE[cache_key] = found
return dict(found)
def _merge_quant_repos(model: dict[str, Any]) -> dict[str, Any]:
out = dict(model)
existing = dict(out.get("quant_repos") or {})
repo_id = str(out.get("id") or "")
if _should_discover_variants(repo_id):
discovered = _discover_quant_repos(
repo_id,
need_fp8="FP8" not in existing,
need_gguf="Q4" not in existing and "GGUF" not in existing,
)
for k, v in discovered.items():
existing.setdefault(k, v)
out["quant_repos"] = existing
return out
def get_image_models():
"""Return the image model registry."""
return IMAGE_MODEL_REGISTRY
merged = [_merge_quant_repos(m) for m in IMAGE_MODEL_REGISTRY]
seen = {str(m.get("id") or "").lower() for m in merged if isinstance(m, dict)}
for model in _fetch_hf_image_collection_models():
key = str(model.get("id") or "").lower()
if key and key not in seen:
merged.append(_merge_quant_repos(model))
seen.add(key)
return merged
def _is_apple_image_system(system: dict[str, Any]) -> bool:
backend = str(system.get("backend") or "").lower()
gpu_name = str(system.get("gpu_name") or "").lower()
cpu_name = str(system.get("cpu_name") or "").lower()
platform = str(system.get("platform") or "").lower()
return (
bool(system.get("unified_memory"))
or backend in {"metal", "mps", "apple"}
or "apple" in gpu_name
or "apple" in cpu_name
or platform == "darwin"
)
def rank_image_models(system, search=None, sort="fit"):
@@ -284,9 +334,17 @@ def rank_image_models(system, search=None, sort="fit"):
system = {}
gpu_vram = system.get("gpu_vram_gb", 0) or 0
has_gpu = system.get("has_gpu", False)
ram_gb = system.get("available_ram_gb") or system.get("total_ram_gb") or 0
budget_gb = gpu_vram if has_gpu and gpu_vram > 0 else ram_gb
budget_kind = "gpu" if has_gpu and gpu_vram > 0 else "ram"
apple_system = _is_apple_image_system(system)
results = []
for model in IMAGE_MODEL_REGISTRY:
for model in get_image_models():
if apple_system and not (model.get("mlx_only") or model.get("apple_ok")):
continue
if model.get("mlx_only") and not apple_system:
continue
# Filter by search
if isinstance(search, str) and search:
s = search.lower()
@@ -299,11 +357,11 @@ def rank_image_models(system, search=None, sort="fit"):
fits = False
quant_repo = None
if has_gpu and gpu_vram > 0:
if budget_gb > 0:
# Try BF16 first, then FP8, then Q4
for q, vram_key in [("BF16", "vram_bf16"), ("FP8", "vram_fp8"), ("Q4", "vram_q4")]:
v = model.get(vram_key)
if v is not None and v <= gpu_vram * 0.90: # 10% headroom
if v is not None and v <= budget_gb * 0.90: # 10% headroom
quant = q
vram_needed = v
fits = True
@@ -315,15 +373,15 @@ def rank_image_models(system, search=None, sort="fit"):
vram_needed = model.get("vram_bf16", 0)
# Fit label
if not has_gpu:
if budget_gb <= 0:
fit = "no_gpu"
fit_label = "No GPU"
elif fits:
headroom = gpu_vram - vram_needed
if headroom > gpu_vram * 0.3:
headroom = budget_gb - vram_needed
if headroom > budget_gb * 0.3:
fit = "perfect"
fit_label = "Perfect"
elif headroom > gpu_vram * 0.1:
elif headroom > budget_gb * 0.1:
fit = "good"
fit_label = "Good"
else:
@@ -355,6 +413,7 @@ def rank_image_models(system, search=None, sort="fit"):
"fits": fits,
"fit": fit,
"fit_label": fit_label,
"fit_budget": budget_kind,
"quality": model["quality"],
"speed": model["speed"],
"score": round(score, 1),
+12
View File
@@ -110,6 +110,18 @@ _ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple(
("research", "deep research imperative request", rf"{_PLEASE}(?:research|deep\s+dive|look\s+into|investigate)\s+.+"),
("research", "assistant deep research request", rf"{_ACTION_QUESTION}(?:research|do\s+research|deep\s+dive|look\s+into|investigate)\s+.+"),
# Workspace / coding-agent intent. These should promote to the agent
# workspace with shell/file tools available, not the "light" typed-tool
# path used for notes/calendar/email.
("workspace", "repo implementation request", rf"{_PLEASE}(?:fix|debug|implement|change|update|refactor|patch|review|test)\b.{{0,160}}\b(?:repo|repository|codebase|project|app|server|api|frontend|backend|tests?|bug|issue|pr)\b"),
("workspace", "assistant repo implementation request", rf"{_ACTION_QUESTION}(?:fix|debug|implement|change|update|refactor|patch|review|test)\b.{{0,160}}\b(?:repo|repository|codebase|project|app|server|api|frontend|backend|tests?|bug|issue|pr)\b"),
("workspace", "test/build command request", rf"{_PLEASE}(?:run|execute|start|launch)\b.{{0,80}}\b(?:tests?|pytest|npm\s+test|pnpm\s+test|yarn\s+test|build|lint|typecheck|benchmark|eval|terminal[- ]bench|tbench)\b"),
("workspace", "file/code inspection request", rf"{_PLEASE}(?:find|inspect|look\s+at|open|read|check)\b.{{0,120}}\b(?:file|folder|directory|repo|repository|code|source|logs?|trace|stack|diff)\b"),
("workspace", "server/process debugging request", rf"{_PLEASE}(?:check|debug|fix|restart|start|stop|kill|tail|inspect)\b.{{0,120}}\b(?:server|service|process|port|docker|container|tmux|endpoint|logs?)\b"),
("workspace", "local computer task request", r"\b(?:on|from|in|using|with)\s+(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b|\b(?:local|host)\s+(?:computer|machine|files?|system)\b"),
("workspace", "named computer task request", r"\b(?:on|from)\s+(?!this\b|my\b|the\b|a\b|an\b)(?:[a-z][a-z0-9_.-]{1,31})\b"),
("workspace", "terminal workspace request", r"\b(?:terminal|shell|workspace|tmux|docker|container|git|branch|commit|diff|pytest|stacktrace|traceback|benchmark|terminal[- ]bench|tbench)\b"),
# Shell / remote-host intent.
("shell", "ssh request", r"\bssh\s+(?:in)?to\b"),
("shell", "ssh target request", r"\bssh\s+\w+"),
+765 -46
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -21,7 +21,8 @@ logger = logging.getLogger(__name__)
from .subprocess_tools import BashTool, PythonTool
from .web_tools import WebSearchTool, WebFetchTool
from .filesystem_tools import ReadFileTool, WriteFileTool, EditFileTool, LsTool, GlobTool, GrepTool, GetWorkspaceTool
from .filesystem_tools import ReadFileTool, WriteFileTool, EditFileTool, ApplyPatchTool, LsTool, GlobTool, GrepTool, GetWorkspaceTool
from .coding_tools import TodoWriteTool
from .document_tools import CreateDocumentTool, UpdateDocumentTool, EditDocumentTool, SuggestDocumentTool, ManageDocumentTool
from .interaction_tools import AskUserTool, UpdatePlanTool
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
@@ -41,6 +42,8 @@ TOOL_HANDLERS = {
"read_file": ReadFileTool().execute,
"write_file": WriteFileTool().execute,
"edit_file": EditFileTool().execute,
"apply_patch": ApplyPatchTool().execute,
"todowrite": TodoWriteTool().execute,
"ls": LsTool().execute,
"glob": GlobTool().execute,
"grep": GrepTool().execute,
@@ -74,6 +77,7 @@ PYTHON_TIMEOUT = 30
# Tool types that trigger execution
TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_file", "edit_file",
"apply_patch", "todowrite",
"grep", "glob", "ls", "get_workspace", "manage_bg_jobs",
"create_document", "update_document", "edit_document",
"search_chats",
+67
View File
@@ -0,0 +1,67 @@
import json
import os
import re
from typing import Any, Dict, List
from src.constants import DATA_DIR
_TODO_DIR = os.path.join(DATA_DIR, "agent_todos")
def _safe_session_id(value: str) -> str:
value = value or "current"
return re.sub(r"[^A-Za-z0-9_.-]+", "_", value)[:120] or "current"
class TodoWriteTool:
async def execute(self, content: str, ctx: dict) -> dict:
try:
args = json.loads(content) if (content or "").strip().startswith("{") else {"todos": []}
except (json.JSONDecodeError, TypeError):
return {"error": "todowrite: JSON object required", "exit_code": 1}
todos = args.get("todos")
if not isinstance(todos, list):
return {"error": "todowrite: todos must be a list", "exit_code": 1}
normalized: List[Dict[str, Any]] = []
allowed_statuses = {"pending", "in_progress", "completed"}
allowed_priorities = {"low", "medium", "high"}
active_count = 0
for item in todos:
if not isinstance(item, dict):
return {"error": "todowrite: each todo must be an object", "exit_code": 1}
content_text = str(item.get("content") or item.get("text") or "").strip()
if not content_text:
return {"error": "todowrite: todo content required", "exit_code": 1}
status = str(item.get("status") or "pending").strip()
if status not in allowed_statuses:
return {"error": f"todowrite: invalid status {status!r}", "exit_code": 1}
if status == "in_progress":
active_count += 1
priority = str(item.get("priority") or "medium").strip()
if priority not in allowed_priorities:
priority = "medium"
normalized.append({
"content": content_text,
"status": status,
"priority": priority,
})
if active_count > 1:
return {"error": "todowrite: only one todo can be in_progress", "exit_code": 1}
session_id = _safe_session_id(str(ctx.get("session_id") or args.get("session_id") or "current"))
os.makedirs(_TODO_DIR, exist_ok=True)
path = os.path.join(_TODO_DIR, f"{session_id}.json")
with open(path, "w", encoding="utf-8") as f:
json.dump({"todos": normalized}, f, ensure_ascii=False, indent=2)
lines = []
for item in normalized:
marker = {"pending": " ", "in_progress": ">", "completed": "x"}[item["status"]]
lines.append(f"[{marker}] {item['content']} ({item['priority']})")
return {
"output": "Updated todo list:\n" + ("\n".join(lines) if lines else "(empty)"),
"exit_code": 0,
"todos": normalized,
}
+176 -1
View File
@@ -5,7 +5,7 @@ import re
import difflib
import fnmatch
import shutil
from typing import Optional, Dict, Any, Tuple
from typing import Optional, Dict, Any, Tuple, List
from src.constants import MAX_READ_CHARS, MAX_DIFF_LINES, MAX_OUTPUT_CHARS
@@ -230,6 +230,181 @@ class WriteFileTool:
result["diff"] = diff
return result
class ApplyPatchTool:
async def execute(self, content: str, ctx: dict) -> dict:
"""Apply a small Codex-style patch using exact context matching.
This is deliberately stricter than git-apply: if an update hunk's old
text is not found exactly once, the whole patch is rejected before any
file is changed. That keeps agent edits reviewable and avoids fuzzy
corruption when the model patches stale context.
"""
from src.tool_execution import _resolve_tool_path
patch_text = content or ""
stripped = patch_text.strip()
if stripped.startswith("{"):
try:
args = json.loads(stripped)
if isinstance(args, dict):
patch_text = str(args.get("patch_text") or args.get("patchText") or args.get("patch") or "")
except (json.JSONDecodeError, TypeError):
pass
if not patch_text.strip():
return {"error": "apply_patch: patch_text required", "exit_code": 1}
try:
ops = _parse_agent_patch(patch_text)
if not ops:
return {"error": "apply_patch: no file operations found", "exit_code": 1}
prepared = []
for op in ops:
path = _resolve_tool_path(op["path"])
kind = op["kind"]
if kind == "add":
if os.path.exists(path):
return {"error": f"apply_patch: {op['path']}: already exists", "exit_code": 1}
old = ""
new = op["content"]
elif kind == "delete":
if not os.path.isfile(path):
return {"error": f"apply_patch: {op['path']}: not found", "exit_code": 1}
with open(path, "r", encoding="utf-8") as f:
old = f.read()
new = ""
else:
if not os.path.isfile(path):
return {"error": f"apply_patch: {op['path']}: not found", "exit_code": 1}
with open(path, "r", encoding="utf-8") as f:
old = f.read()
new = _apply_patch_hunks(old, op["hunks"], op["path"])
prepared.append((kind, path, old, new))
diffs = []
for kind, path, old, new in prepared:
if kind == "delete":
os.remove(path)
else:
directory = os.path.dirname(path)
if directory:
os.makedirs(directory, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(new)
diff = _unified_diff(old, new, path)
if diff:
diffs.append(diff)
except (ValueError, UnicodeDecodeError, PermissionError, OSError) as e:
return {"error": f"apply_patch: {e}", "exit_code": 1}
added = sum(int(d.get("added") or 0) for d in diffs)
removed = sum(int(d.get("removed") or 0) for d in diffs)
text_parts = [d.get("text", "") for d in diffs if d.get("text")]
diff_text = "\n".join(text_parts)
if len(diff_text.splitlines()) > MAX_DIFF_LINES:
diff_text = "\n".join(diff_text.splitlines()[:MAX_DIFF_LINES]) + f"\n... diff truncated at {MAX_DIFF_LINES} lines"
result = {
"output": f"Applied patch ({len(prepared)} file{'s' if len(prepared) != 1 else ''}, +{added}/-{removed})",
"exit_code": 0,
}
if diffs:
result["diff"] = {
"text": diff_text,
"added": added,
"removed": removed,
"new_file": any(d.get("new_file") for d in diffs),
"file": "patch",
}
return result
def _parse_agent_patch(patch_text: str) -> List[Dict[str, Any]]:
lines = patch_text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
while lines and not lines[0].strip():
lines.pop(0)
while lines and not lines[-1].strip():
lines.pop()
if not lines or lines[0].strip() != "*** Begin Patch":
raise ValueError("patch must start with *** Begin Patch")
if lines[-1].strip() != "*** End Patch":
raise ValueError("patch must end with *** End Patch")
ops: List[Dict[str, Any]] = []
i = 1
while i < len(lines) - 1:
line = lines[i]
if not line:
i += 1
continue
if line.startswith("*** Add File: "):
path = line[len("*** Add File: "):].strip()
body = []
i += 1
while i < len(lines) - 1 and not lines[i].startswith("*** "):
if not lines[i].startswith("+"):
raise ValueError(f"add file {path}: every content line must start with +")
body.append(lines[i][1:])
i += 1
ops.append({"kind": "add", "path": path, "content": "\n".join(body) + ("\n" if body else "")})
continue
if line.startswith("*** Delete File: "):
path = line[len("*** Delete File: "):].strip()
ops.append({"kind": "delete", "path": path})
i += 1
continue
if line.startswith("*** Update File: "):
path = line[len("*** Update File: "):].strip()
hunks = []
current = []
i += 1
if i < len(lines) - 1 and lines[i].startswith("*** Move to: "):
raise ValueError("move operations are not supported")
while i < len(lines) - 1 and not lines[i].startswith("*** "):
if lines[i].startswith("@@"):
if current:
hunks.append(current)
current = []
elif lines[i].startswith((" ", "-", "+")):
current.append(lines[i])
elif lines[i] == "":
current.append(" ")
else:
raise ValueError(f"update file {path}: invalid patch line {lines[i]!r}")
i += 1
if current:
hunks.append(current)
if not hunks:
raise ValueError(f"update file {path}: no hunks")
ops.append({"kind": "update", "path": path, "hunks": hunks})
continue
raise ValueError(f"unexpected patch line: {line!r}")
return ops
def _apply_patch_hunks(original: str, hunks: List[List[str]], label: str) -> str:
updated = original
for idx, hunk in enumerate(hunks, 1):
old_lines = []
new_lines = []
for line in hunk:
prefix, body = line[:1], line[1:]
if prefix in (" ", "-"):
old_lines.append(body)
if prefix in (" ", "+"):
new_lines.append(body)
old_text = "\n".join(old_lines)
new_text = "\n".join(new_lines)
if old_text and old_text in updated:
occurrences = updated.count(old_text)
if occurrences != 1:
raise ValueError(f"{label}: hunk {idx} context matched {occurrences} times")
updated = updated.replace(old_text, new_text, 1)
elif old_text + "\n" in updated:
occurrences = updated.count(old_text + "\n")
if occurrences != 1:
raise ValueError(f"{label}: hunk {idx} context matched {occurrences} times")
updated = updated.replace(old_text + "\n", new_text + "\n", 1)
else:
raise ValueError(f"{label}: hunk {idx} context not found")
return updated
class LsTool:
async def execute(self, content: str, ctx: dict) -> dict:
from src.tool_execution import _resolve_tool_path, _resolve_search_root, _truncate
+202
View File
@@ -1,4 +1,7 @@
import asyncio
import os
import re
import shutil
import sys
import time
import collections
@@ -10,6 +13,175 @@ DEFAULT_PYTHON_TIMEOUT = 60 * 60
PROGRESS_INTERVAL_S = 2.0
PROGRESS_TAIL_LINES = 12
TMUX_CAPTURE_LINES = 2000
def _tmux_session_name(session_id: Optional[str]) -> str:
raw = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(session_id or "default")).strip("-")
return f"ody-agent-{raw[:80] or 'default'}"
async def _run_exec(*args: str, timeout: float = 10) -> Tuple[str, str, int]:
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
out_b, err_b = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
try:
proc.kill()
except Exception:
pass
return "", "timeout", 124
return (
out_b.decode("utf-8", errors="replace"),
err_b.decode("utf-8", errors="replace"),
proc.returncode or 0,
)
async def _tmux_has_session(name: str) -> bool:
_, _, rc = await _run_exec("tmux", "has-session", "-t", name, timeout=3)
return rc == 0
async def _tmux_capture(name: str) -> str:
out, _, _ = await _run_exec(
"tmux", "capture-pane", "-p", "-J", "-S", f"-{TMUX_CAPTURE_LINES}", "-t", name,
timeout=5,
)
return out
async def _tmux_send_line(name: str, line: str) -> None:
if line:
await _run_exec("tmux", "send-keys", "-t", name, "-l", line, timeout=5)
await _run_exec("tmux", "send-keys", "-t", name, "C-m", timeout=5)
async def _ensure_tmux_session(name: str, cwd: str, env: Optional[dict]) -> None:
if await _tmux_has_session(name):
await _run_exec("tmux", "send-keys", "-t", name, "stty -echo", "C-m", timeout=5)
return
await _run_exec(
"tmux", "new-session", "-d", "-s", name, "-c", cwd,
"env",
f"TERM={env.get('TERM', 'xterm-256color') if env else 'xterm-256color'}",
f"COLUMNS={env.get('COLUMNS', '120') if env else '120'}",
f"LINES={env.get('LINES', '40') if env else '40'}",
"/bin/bash",
"--noprofile",
"--norc",
timeout=10,
)
if not await _tmux_has_session(name):
raise RuntimeError(f"failed to create tmux session {name}")
await _run_exec("tmux", "send-keys", "-t", name, "stty -echo", "C-m", timeout=5)
def _output_after_marker(capture: str, start_marker: str, end_marker: str) -> Tuple[str, bool]:
lines = capture.splitlines()
start_idx = -1
for idx, line in enumerate(lines):
if line.strip() == start_marker:
start_idx = idx
if start_idx < 0:
return capture, False
end_idx = -1
for idx in range(start_idx + 1, len(lines)):
if lines[idx].strip().startswith(end_marker):
end_idx = idx
if end_idx < 0:
return "\n".join(lines[start_idx + 1:]), False
return "\n".join(lines[start_idx + 1:end_idx]), True
def _extract_marker_rc(capture: str, end_marker: str) -> int:
for line in reversed(capture.splitlines()):
stripped = line.strip()
if stripped.startswith(end_marker):
suffix = stripped[len(end_marker):].strip()
if suffix.isdigit():
return int(suffix)
return 0
async def _run_tmux_bash(
content: str,
*,
session_id: str,
cwd: str,
env: Optional[dict],
timeout: float,
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
) -> Tuple[str, str, Optional[int], bool]:
name = _tmux_session_name(session_id)
await _ensure_tmux_session(name, cwd, env)
stamp = f"{int(time.time() * 1000)}-{abs(hash(content)) % 1000000}"
start_marker = f"__ODYSSEUS_CMD_START_{stamp}__"
end_prefix = f"__ODYSSEUS_CMD_END_{stamp}__:"
wrapped = (
f"printf '\\n{start_marker}\\n'\n"
f"{content}\n"
f"__ody_rc=$?\n"
f"printf '\\n{end_prefix}%s\\n' \"$__ody_rc\"\n"
)
for line in wrapped.splitlines():
await _tmux_send_line(name, line)
started = time.time()
last_tail = ""
while True:
capture = await _tmux_capture(name)
body, done = _output_after_marker(capture, start_marker, end_prefix)
tail = "\n".join(body.splitlines()[-PROGRESS_TAIL_LINES:])
if progress_cb and tail != last_tail:
last_tail = tail
try:
await progress_cb({
"elapsed_s": round(time.time() - started, 1),
"tail": tail,
"tmux_session": name,
})
except Exception:
pass
if done:
rc = _extract_marker_rc(capture, end_prefix)
cleaned = _clean_tmux_command_output(body, wrapped)
return cleaned, "", rc, False
if time.time() - started > timeout:
try:
await _run_exec("tmux", "send-keys", "-t", name, "C-c", timeout=3)
except Exception:
pass
cleaned = _clean_tmux_command_output(body, wrapped)
return cleaned, "", 124, True
await asyncio.sleep(0.5)
def _clean_tmux_command_output(text: str, wrapped_command: str) -> str:
lines = text.splitlines()
wrapped_lines = {ln.rstrip() for ln in wrapped_command.splitlines() if ln.strip()}
cleaned = []
for line in lines:
raw = line.rstrip()
stripped = raw.strip()
if not stripped:
cleaned.append(raw)
continue
if stripped in wrapped_lines:
continue
if stripped.startswith("__ody_rc=") or stripped.startswith("printf "):
continue
if re.fullmatch(r"(?:bash|sh)-[\d.]+\$ ?", stripped):
continue
if re.fullmatch(r"[\w.@:/~+-]+[#$] ?", stripped):
continue
cleaned.append(raw)
return "\n".join(cleaned).strip()
async def _run_subprocess_streaming(
proc: asyncio.subprocess.Process,
@@ -103,8 +275,38 @@ async def _run_subprocess_streaming(
class BashTool:
async def execute(self, content: str, ctx: dict) -> dict:
from src.tool_execution import agent_cwd, _truncate
if isinstance(content, dict):
content = str(content.get("command") or content.get("cmd") or content.get("code") or "")
progress_cb = ctx.get("progress_cb")
_subproc_env = ctx.get("subproc_env")
session_id = ctx.get("session_id")
if session_id and shutil.which("tmux"):
stdout, stderr, rc, timed_out = await _run_tmux_bash(
content,
session_id=str(session_id),
cwd=agent_cwd(),
env=_subproc_env,
timeout=DEFAULT_BASH_TIMEOUT,
progress_cb=progress_cb,
)
if timed_out:
return {
"error": f"bash: timed out after {DEFAULT_BASH_TIMEOUT}s — sent Ctrl-C to tmux session",
"exit_code": 124,
"stdout": _truncate(stdout, MAX_OUTPUT_CHARS),
"stderr": _truncate(stderr, MAX_OUTPUT_CHARS),
"tmux_session": _tmux_session_name(str(session_id)),
}
output = stdout.rstrip()
err = stderr.rstrip()
if err:
output = (output + "\nSTDERR: " + err).strip() if output else "STDERR: " + err
return {
"output": _truncate(output, MAX_OUTPUT_CHARS) or "(no output)",
"exit_code": rc or 0,
"tmux_session": _tmux_session_name(str(session_id)),
}
proc = await asyncio.create_subprocess_shell(
content,
stdout=asyncio.subprocess.PIPE,
+326 -4
View File
@@ -19,7 +19,7 @@ import json
import logging
import uuid
import time
from typing import Dict, Optional, Tuple
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
from src.constants import GENERATED_IMAGES_DIR
@@ -71,9 +71,10 @@ def set_rag_manager(rag_mgr, personal_docs_mgr=None):
# ---------------------------------------------------------------------------
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, resolve_endpoint_runtime
from src.image_model_ids import looks_like_image_generation_model, model_id_leaf
def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Dict]:
def _resolve_model(spec: str, owner: Optional[str] = None, model_type: Optional[str] = None) -> Tuple[str, str, Dict]:
"""Resolve a model specifier to (endpoint_url, model_id, headers).
Accepts:
@@ -97,9 +98,29 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
else:
model_name = spec
def _json_list(value) -> list[str]:
try:
data = json.loads(value or "[]")
except Exception:
return []
if not isinstance(data, list):
return []
return [str(x) for x in data if isinstance(x, (str, int, float)) and str(x)]
def _image_like(name: str) -> bool:
n = (name or "").lower()
if looks_like_image_generation_model(n):
return True
return any(k in n for k in (
"qwen-image", "qwen/image", "z-image", "flux", "stable-diffusion",
"sdxl", "hidream", "boogu", "krea-2", "image-edit",
))
db = SessionLocal()
try:
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
if model_type:
query = query.filter(ModelEndpoint.model_type == model_type)
if target_endpoint_name:
query = query.filter(ModelEndpoint.name.ilike(f"%{target_endpoint_name}%"))
if owner:
@@ -129,11 +150,13 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
return build_chat_url(base), matched, headers
else:
# OpenAI-compatible and native Ollama: probe the provider's model list.
endpoint_reachable = False
try:
models_url = build_models_url(base)
if models_url:
r = httpx.get(models_url, headers=headers, timeout=5)
r.raise_for_status()
endpoint_reachable = True
data = r.json()
items = data if isinstance(data, list) else (data.get("data") or [])
model_ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
@@ -144,10 +167,21 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
if m.get("name") or m.get("model")
]
else:
endpoint_reachable = True
model_ids = json.loads(ep.cached_models or "[]")
except Exception:
model_ids = []
# Manual/local image endpoints are often registered with pinned
# model ids, while /models may return a runtime alias or only the
# served internal id. Include pinned/cached ids in the match set
# so chat sessions using the HF repo id still resolve. Do not use
# stale cached aliases when the endpoint itself is unreachable.
if model_type == "image" and endpoint_reachable:
for extra in _json_list(getattr(ep, "pinned_models", None)) + _json_list(getattr(ep, "cached_models", None)):
if extra not in model_ids:
model_ids.append(extra)
# Exact match first
for mid in model_ids:
if mid.lower() == model_name.lower():
@@ -158,6 +192,13 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
if model_name.lower() in mid.lower() or mid.lower() in model_name.lower():
return build_chat_url(base), mid, headers
# Last resort for local image endpoints: if the requested model
# name is clearly an image model, use the endpoint's first known
# image model id. This prevents a harmless alias mismatch from
# blocking image generation.
if model_type == "image" and _image_like(model_name) and model_ids:
return build_chat_url(base), model_ids[0], headers
raise ValueError(f"Model '{spec}' not found on any configured endpoint")
finally:
db.close()
@@ -967,16 +1008,36 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
if not model_spec:
return {"error": "No image model found. Configure one in Admin → Image Generation."}
async def _resolve_image_model(model_name: str):
def _call():
try:
return _resolve_model(model_name, owner=owner, model_type="image")
except TypeError as exc:
if "model_type" not in str(exc):
raise
return _resolve_model(model_name, owner=owner)
return await asyncio.to_thread(_call)
# Resolve the model to find the right endpoint
try:
try:
url, model_id, headers = await _resolve_image_model(model_spec)
except ValueError:
_lower_model_spec = model_spec.lower()
if not (
any(_name in _lower_model_spec for _name in ("gpt-image", "dall-e"))
or looks_like_image_generation_model(_lower_model_spec)
):
raise
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec, owner=owner)
except ValueError:
return {"error": f"No endpoint found with image model '{model_spec}'. "
"Configure an OpenAI-compatible endpoint with image generation support."}
# Detect if this is a GPT image model vs DALL-E vs local diffusion
is_gpt_image = "gpt-image" in model_id.lower()
is_dalle = "dall-e" in model_id.lower()
_model_leaf = model_id_leaf(model_id)
is_gpt_image = _model_leaf.startswith("gpt-image") or (_model_leaf.startswith("gpt-") and "-image" in _model_leaf)
is_dalle = _model_leaf.startswith("dall-e")
is_local_diffusion = not is_gpt_image and not is_dalle
# Build the images endpoint URL from the chat completions URL
@@ -1106,6 +1167,267 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
return {"error": f"Image generation error: {str(e)}"}
async def do_edit_image(
prompt: str,
image_path: str,
model_spec: str = "",
session_id: Optional[str] = None,
owner: Optional[str] = None,
size: str = "1024x1024",
quality: str = "medium",
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
) -> Dict:
"""Edit an uploaded image using the configured image endpoint."""
import base64
import httpx
import mimetypes
import os
from pathlib import Path
from src.url_safety import check_outbound_url
prompt = (prompt or "").strip()
if not prompt:
return {"error": "Image edit prompt is required"}
path = Path(image_path)
if not path.exists() or not path.is_file():
return {"error": "Attached image file was not found"}
try:
from src.settings import load_settings
_settings = load_settings()
except Exception:
_settings = {}
if not model_spec:
model_spec = _settings.get("image_model", "")
if quality == "medium" and _settings.get("image_quality"):
quality = _settings["image_quality"]
if not model_spec:
return {"error": "No image model selected for image editing"}
try:
try:
def _call():
try:
return _resolve_model(model_spec, owner=owner, model_type="image")
except TypeError as exc:
if "model_type" not in str(exc):
raise
return _resolve_model(model_spec, owner=owner)
url, model_id, headers = await asyncio.to_thread(_call)
except ValueError:
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec, owner=owner)
except ValueError:
return {"error": f"No endpoint found with image model '{model_spec}'."}
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
edits_url = base_url + "/images/edits"
mime = mimetypes.guess_type(str(path))[0] or "image/png"
payload = {
"model": model_id,
"prompt": prompt,
"n": "1",
"size": size,
"quality": quality if quality in ("low", "medium", "high", "auto") else "medium",
"response_format": "b64_json",
}
request_id = uuid.uuid4().hex
payload["request_id"] = request_id
logger.info("Image edit: model=%s, size=%s, quality=%s, image=%s, prompt=%s", model_id, size, quality, path.name, prompt[:80])
def _save_edited_image_to_gallery(filename: str) -> str:
try:
from src.database import SessionLocal as _GallerySL, GalleryImage
new_id = str(uuid.uuid4())
_gdb = _GallerySL()
_gdb.add(GalleryImage(
id=new_id,
filename=filename,
prompt=prompt,
model=model_id,
size=size,
quality=payload.get("quality", "medium"),
session_id=session_id,
owner=owner,
))
_gdb.commit()
_gdb.close()
return new_id
except Exception as _ge:
logger.warning("Failed to save edited image gallery record: %s", _ge)
return ""
def _save_image_bytes(image_bytes: bytes, suffix: str = ".png") -> tuple[str, str]:
img_dir = Path(GENERATED_IMAGES_DIR)
img_dir.mkdir(parents=True, exist_ok=True)
filename = f"{uuid.uuid4().hex[:12]}{suffix}"
(img_dir / filename).write_bytes(image_bytes)
return f"/api/generated-image/{filename}", _save_edited_image_to_gallery(filename)
async def _try_local_img2img_fallback(client: httpx.AsyncClient) -> Optional[Dict[str, Any]]:
"""Try Odysseus' local diffusion img2img endpoint.
Some self-hosted SD/SDXL endpoints expose text-to-image plus
`/images/harmonize`/img2img, but not OpenAI's multipart
`/images/edits`. For chat uploads ("image + prompt"), this gives the
expected instruction-edit behavior instead of stopping at a 400.
"""
harmonize_url = base_url + "/images/harmonize"
try:
image_bytes = path.read_bytes()
image_b64 = base64.b64encode(image_bytes).decode()
fallback_payload = {
"image": image_b64,
"prompt": prompt,
"strength": 0.35,
"steps": 0,
"max_side": 1024,
}
if progress_callback:
await progress_callback({
"status": "running",
"message": "Trying image-to-image fallback",
"step": 0,
"total": 0,
})
fallback_resp = await client.post(harmonize_url, json=fallback_payload, headers=headers)
if fallback_resp.status_code == 404:
return None
if fallback_resp.status_code != 200:
error_text = fallback_resp.text[:500]
try:
err_json = fallback_resp.json()
error_text = err_json.get("detail") or err_json.get("error") or error_text
except Exception:
pass
return {"error": f"Image edit fallback failed ({fallback_resp.status_code}): {error_text}"}
fallback_data = fallback_resp.json()
image_b64 = fallback_data.get("image")
if not image_b64:
return {"error": "Image edit fallback returned no image"}
image_url, image_id = _save_image_bytes(base64.b64decode(image_b64))
return {
"results": f"Edited image for: {prompt[:100]}",
"image_url": image_url,
"image_id": image_id,
"image_prompt": prompt,
"image_model": model_id,
"image_size": size,
"image_quality": payload.get("quality", "medium"),
"edit_route": "img2img",
}
except httpx.TimeoutException:
return {"error": "Image edit fallback timed out. The model may still be loading or overloaded."}
except Exception as fallback_error:
logger.warning("Image edit fallback failed: %s", fallback_error)
return {"error": f"Image edit fallback error: {fallback_error}"}
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=600.0, write=60.0, pool=30.0)) as client:
progress_task = None
if progress_callback:
progress_url = base_url + f"/images/progress/{request_id}"
async def _poll_progress():
last_sig = None
while True:
try:
pr = await client.get(progress_url, headers=headers, timeout=5.0)
if pr.status_code == 404:
return
if pr.status_code == 200:
data = pr.json()
sig = (data.get("status"), data.get("step"), data.get("total"), data.get("percent"))
if sig != last_sig:
last_sig = sig
await progress_callback(data)
if data.get("status") in {"done", "error"}:
return
except Exception:
return
await asyncio.sleep(1)
progress_task = asyncio.create_task(_poll_progress())
try:
with path.open("rb") as f:
files = {"image": (path.name, f, mime)}
resp = await client.post(edits_url, data=payload, files=files, headers=headers)
finally:
if progress_task:
progress_task.cancel()
try:
await progress_task
except asyncio.CancelledError:
pass
if resp.status_code != 200:
error_text = resp.text[:500]
try:
err_json = resp.json()
err = err_json.get("error")
error_text = (
err.get("message", error_text)
if isinstance(err, dict)
else str(err or err_json.get("detail") or error_text)
)
except Exception:
pass
if resp.status_code in (400, 404, 405, 422):
fallback = await _try_local_img2img_fallback(client)
if fallback:
return fallback
if resp.status_code == 404:
return {
"error": (
f"Image model '{model_id}' is reachable, but this endpoint does not expose image editing. "
"Use it without an attached image for text-to-image generation, or serve an edit/img2img "
"model for attached-image prompts."
)
}
return {"error": f"Image edit failed ({resp.status_code}): {error_text}"}
data = resp.json()
images = data.get("data", [])
if not images:
return {"error": "No image returned from edit API"}
img = images[0]
image_url = None
image_id = None
if img.get("b64_json"):
image_url, image_id = _save_image_bytes(base64.b64decode(img.get("b64_json")))
elif img.get("url"):
result_url = img["url"]
ok, reason = check_outbound_url(
result_url,
block_private=os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true",
)
if not ok:
return {"error": f"Image edit API returned unsafe image URL: {reason}"}
dl_resp = httpx.get(result_url, timeout=60)
if dl_resp.status_code != 200:
return {"error": f"Could not download edited image ({dl_resp.status_code})"}
image_url, image_id = _save_image_bytes(dl_resp.content)
else:
return {"error": "Image edit API returned unexpected format (no b64_json or url)"}
return {
"results": f"Edited image for: {prompt[:100]}",
"image_url": image_url,
"image_id": image_id,
"image_prompt": prompt,
"image_model": model_id,
"image_size": size,
"image_quality": payload.get("quality", "medium"),
}
except httpx.TimeoutException:
return {"error": "Image edit timed out. The model may still be loading or overloaded."}
except Exception as e:
return {"error": f"Image edit error: {str(e)}"}
# ---------------------------------------------------------------------------
# Dispatcher (called from agent_tools.execute_tool_block)
# ---------------------------------------------------------------------------
+71 -2
View File
@@ -77,6 +77,7 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]:
try:
import json
import re
from difflib import SequenceMatcher
from src.constants import DATA_DIR
from src.llm_core import llm_call_async_with_fallback
from src.memory import MemoryManager
@@ -112,6 +113,64 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]:
ai_reasons = []
ai_used = False
def _normalized_memory_text(mem: dict) -> str:
text = (mem.get("text") or "").lower()
text = re.sub(r"[^a-z0-9@._+-]+", " ", text)
return " ".join(text.split())
def _memory_rank(mem: dict) -> tuple:
text = (mem.get("text") or "").strip()
return (
1 if mem.get("pinned") else 0,
1 if (mem.get("source") or "") == "user" else 0,
int(mem.get("uses") or 0),
-len(text),
int(mem.get("timestamp") or 0),
)
def _same_memory_fact(a: dict, b: dict) -> bool:
a_cat = (a.get("category") or "fact").strip().lower()
b_cat = (b.get("category") or "fact").strip().lower()
if a_cat != b_cat:
return False
a_text = _normalized_memory_text(a)
b_text = _normalized_memory_text(b)
if not a_text or not b_text:
return False
if a_text == b_text:
return True
shorter, longer = sorted((a_text, b_text), key=len)
if len(shorter) >= 24 and shorter in longer:
return True
return SequenceMatcher(None, a_text, b_text).ratio() >= 0.88
def _dedupe_group(group_memories: list) -> tuple[list, int]:
kept = []
removed = 0
for mem in group_memories:
text = (mem.get("text") or "").strip()
if not text:
removed += 1
if len(removed_examples) < 3:
removed_examples.append("(empty)")
continue
duplicate_idx = next(
(idx for idx, kept_mem in enumerate(kept) if _same_memory_fact(mem, kept_mem)),
None,
)
if duplicate_idx is None:
kept.append(mem)
continue
removed += 1
if _memory_rank(mem) > _memory_rank(kept[duplicate_idx]):
if len(removed_examples) < 3:
old_text = (kept[duplicate_idx].get("text") or "").strip()
removed_examples.append(old_text[:60] + ("..." if len(old_text) > 60 else ""))
kept[duplicate_idx] = mem
elif len(removed_examples) < 3:
removed_examples.append(text[:60] + ("..." if len(text) > 60 else ""))
return kept, removed
async def _try_ai_tidy_group(group_owner: str, group_memories: list) -> bool:
nonlocal all_memories, total_removed, total_cleaned, total_scanned, ai_used
if len(group_memories) < 2:
@@ -220,7 +279,6 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]:
kept_all.append(mem)
removed = sum(1 for m in group_memories if m.get("id") in drop_ids)
total_scanned += len(group_memories)
if removed or changed_text:
all_memories = kept_all
total_removed += removed
@@ -237,12 +295,23 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]:
return False
for group_owner, group_memories in memory_groups.items():
total_scanned += len(group_memories)
deduped_group, group_removed = _dedupe_group(group_memories)
if group_removed:
group_ref_ids = {id(m) for m in group_memories}
keep_ref_ids = {id(m) for m in deduped_group}
all_memories = [
m for m in all_memories
if id(m) not in group_ref_ids or id(m) in keep_ref_ids
]
total_removed += group_removed
group_memories = deduped_group
if await _try_ai_tidy_group(group_owner, group_memories):
continue
seen = {}
keep_refs = set()
total_scanned += len(group_memories)
for mem in group_memories:
text = (mem.get("text") or "").strip()
key = " ".join(text.lower().split())
+61 -15
View File
@@ -87,6 +87,7 @@ _BUILTIN_NPX_SERVERS = {
# Global flag to disable MCP if there are compatibility issues
MCP_DISABLED = os.environ.get("ODYSSEUS_DISABLE_MCP", "").lower() in ("1", "true", "yes")
BROWSER_MCP_REQUIRE_CACHE = os.environ.get("ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE", "").lower() in ("1", "true", "yes")
# Strong references to the fire-and-forget startup tasks scheduled below.
@@ -103,6 +104,46 @@ def _spawn_bg(coro) -> asyncio.Task:
task.add_done_callback(_BG_TASKS.discard)
return task
def _find_browser_executable() -> str:
"""Find a browser binary for the built-in Playwright MCP server.
Docker images ship Debian's `chromium`; desktop installs may already have
Chrome/Chromium in a conventional location. If nothing is found, return an
empty string and let Playwright MCP use its own default browser/channel.
"""
configured = os.environ.get("ODYSSEUS_BROWSER_EXECUTABLE", "").strip()
if configured:
return configured
for name in ("google-chrome", "chromium", "chromium-browser"):
path = shutil.which(name)
if path:
return path
for candidate in (
"/opt/google/chrome/chrome",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
):
if os.path.isfile(candidate):
return candidate
return ""
def _browser_mcp_args(args: list[str]) -> list[str]:
"""Return Playwright MCP args with a concrete browser executable when found."""
out = list(args or [])
if "--executable-path" not in out:
browser = _find_browser_executable()
if browser:
out.extend(["--executable-path", browser])
if os.environ.get("ODYSSEUS_BROWSER_ISOLATED", "1").lower() not in ("0", "false", "no"):
if "--isolated" not in out and "--user-data-dir" not in out:
out.append("--isolated")
if os.environ.get("ODYSSEUS_BROWSER_NO_SANDBOX", "1").lower() not in ("0", "false", "no"):
if "--no-sandbox" not in out and "--sandbox" not in out:
out.append("--no-sandbox")
return out
def builtin_python_env(base_dir: str) -> dict[str, str]:
"""Environment for built-in Python MCP subprocesses.
@@ -162,39 +203,44 @@ async def register_builtin_servers(mcp_manager):
async def _start_npx_servers():
await asyncio.sleep(3) # let Python servers finish first
for server_id, cfg in _BUILTIN_NPX_SERVERS.items():
# Skip the server if its npx package isn't cached. Without this
# check, npx would try to download/install the package on first
# use, which can take minutes (or hang) on fresh installs without
# Playwright system deps. Wrapping that in asyncio.wait_for to
# bound the wait sounds reasonable, but mcp.client.stdio uses an
# internal anyio task group that can't survive the resulting
# cross-task cancellation: it raises "Attempted to exit cancel
# scope in a different task than it was entered in" in a sibling
# task, which cascades cancellations into the rest of the event
# loop and downs the app. Detecting installed-state up-front lets
# us bail with a useful warning before we ever touch stdio_client.
args = cfg["args"]
# Browser automation is a shipped built-in, so the default path
# lets `npx -y` install @playwright/mcp on first start. Locked-down
# installs can opt back into the old no-network startup behavior
# with ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE=1.
args = _browser_mcp_args(cfg["args"]) if server_id == "builtin_browser" else list(cfg["args"])
pkg_spec = _npx_package_from_args(args)
if pkg_spec and not await _is_npx_package_cached(npx_path, pkg_spec):
if BROWSER_MCP_REQUIRE_CACHE and pkg_spec and not await _is_npx_package_cached(npx_path, pkg_spec):
logger.warning(
f"{cfg['name']} is not available.\n"
f" Reason: npm package {pkg_spec!r} is not installed in the npx cache.\n"
f" Impact: tools provided by this MCP server will be unavailable.\n"
f" Fix: {os.path.basename(npx_path)} -y {pkg_spec} --version\n"
f" (run once, then restart Odysseus)\n"
f" Notes: this server is optional; see README.md "
f"'Built-in MCP servers' for details."
f" Notes: ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE=1 is set, "
f"so Odysseus will not install browser automation on startup."
)
continue
logger.info(f"Starting NPX server: {cfg['name']} ({npx_path} {' '.join(args)})")
try:
env = None
if server_id == "builtin_browser":
cache_home = os.environ.get(
"ODYSSEUS_BROWSER_MCP_CACHE",
os.path.join(base_dir, "data", "local", "playwright-mcp-cache"),
)
os.makedirs(cache_home, exist_ok=True)
env = {
"XDG_CACHE_HOME": cache_home,
"PLAYWRIGHT_BROWSERS_PATH": os.path.join(cache_home, "browsers"),
}
ok = await mcp_manager.connect_server(
server_id=server_id,
name=cfg["name"],
transport="stdio",
command=npx_path,
args=args,
env=env,
)
if ok:
logger.info(f"Built-in NPX server registered: {cfg['name']}")
+78 -8
View File
@@ -89,6 +89,71 @@ class ChatProcessor:
# Minimum similarity score for RAG results to be injected
RAG_SIMILARITY_THRESHOLD = 0.35
MEMORY_CONTEXT_LIMIT = 5
PINNED_MEMORY_LIMIT = MEMORY_CONTEXT_LIMIT
def _is_core_memory(self, memory: Dict[str, Any]) -> bool:
"""Return whether a pinned memory is safe to keep globally available."""
category = (memory.get("category") or "").lower()
if category in {"identity", "contact"}:
return True
text = (memory.get("text") or "").lower()
return any(marker in text for marker in (
"my name is",
"name is",
"call me",
"i am ",
"i'm ",
"email",
"phone",
"address",
))
def _select_pinned_memories(self, message: str, pinned: list) -> list:
"""Keep pinned memories high-priority without injecting all of them.
Pinned used to mean "always send every pinned memory to the model".
That bloats every request and leaks unrelated personal context into
tasks that do not need it. Now only a small set of core identity/contact
memories is always available; other pinned memories must match the
current request, but are retrieved before ordinary memories.
"""
if not pinned:
return []
def _recent_first(memory: Dict[str, Any]) -> int:
try:
return int(memory.get("timestamp") or 0)
except Exception:
return 0
core = sorted(
[m for m in pinned if self._is_core_memory(m)],
key=_recent_first,
reverse=True,
)[:self.PINNED_MEMORY_LIMIT]
core_ids = {m.get("id") for m in core if m.get("id")}
contextual_candidates = [
m for m in pinned
if not (m.get("id") and m.get("id") in core_ids)
]
remaining_slots = max(self.PINNED_MEMORY_LIMIT - len(core), 0)
contextual = self._hybrid_retrieve(
message,
contextual_candidates,
k=remaining_slots,
) if remaining_slots else []
selected = []
seen = set()
for memory in [*core, *contextual]:
key = memory.get("id") or memory.get("text")
if key in seen:
continue
seen.add(key)
selected.append(memory)
return selected[:self.PINNED_MEMORY_LIMIT]
def _hybrid_retrieve(self, message: str, mem_entries: list, k: int = 5) -> list:
"""Retrieve memories relevant to the message.
@@ -242,7 +307,7 @@ class ChatProcessor:
"content": UNTRUSTED_CONTEXT_POLICY,
})
# Memory: pinned (always included) + extended (RAG-retrieved when relevant)
# Memory: core pinned facts + relevant pinned/extended recall.
self._last_used_memories = [] # track what was injected
if use_memory:
mem_entries = self.memory_manager.load(owner=owner)
@@ -251,19 +316,24 @@ class ChatProcessor:
extended = [m for m in mem_entries if not m.get("pinned")]
_used_ids: list = []
if pinned:
pinned_text = "\n- ".join([m["text"] for m in pinned])
selected_pinned = self._select_pinned_memories(message, pinned)
if selected_pinned:
pinned_text = "\n- ".join([m["text"] for m in selected_pinned])
preface.append(untrusted_context_message(
"saved memory: pinned user facts",
f"Core facts about the user:\n- {pinned_text}",
"saved memory: pinned context",
(
"Pinned memory context. Some pinned memories are only "
f"included when relevant:\n- {pinned_text}"
),
))
for m in pinned:
for m in selected_pinned:
self._last_used_memories.append({"text": m["text"], "category": m.get("category", "fact"), "type": "pinned"})
if m.get("id"):
_used_ids.append(m["id"])
if extended:
relevant = self._hybrid_retrieve(message, extended, k=3)
remaining_memory_slots = max(self.MEMORY_CONTEXT_LIMIT - len(self._last_used_memories), 0)
if extended and remaining_memory_slots:
relevant = self._hybrid_retrieve(message, extended, k=remaining_memory_slots)
if relevant:
ext_text = "\n".join([f"- {m['text']}" for m in relevant])
preface.append(untrusted_context_message(
+11
View File
@@ -7,6 +7,7 @@ Summarizes older messages via the same LLM, preserving key context.
import json
import logging
import re
from typing import Any, Dict, List, Optional
from src.model_context import get_context_length, estimate_tokens
@@ -70,6 +71,14 @@ What is the system/code/task state right now? What was the last thing discussed?
Keep the summary under 1000 tokens. Be dense every token should carry information. Do not include pleasantries or meta-commentary."""
def normalize_compaction_summary(summary: str) -> str:
"""Remove redundant leading title text before adding our wrapper."""
text = (summary or "").strip()
text = re.sub(r"^(?:#{1,3}\s*)?Conversation Summary\s*", "", text, flags=re.IGNORECASE)
text = re.sub(r"^\*\*Conversation Summary\*\*\s*", "", text, flags=re.IGNORECASE)
return text.lstrip()
def _sanitize_tool_messages(msgs: List[Dict]) -> List[Dict]:
"""Drop orphaned `tool` messages and dangling assistant `tool_calls`.
@@ -393,6 +402,7 @@ async def maybe_compact(
# silently dropping the older half. was_compacted=False signals the
# caller nothing was summarized; trim_for_context handles length.
return messages, context_length, False
summary = normalize_compaction_summary(summary)
summary_msg = {
"role": "system",
@@ -439,6 +449,7 @@ def _update_session_history(session, split_point: int, summary: str,
# messages so the system prompt survives compaction.
system_prefix = list(session.history[:system_msg_count])
recent_history = session.history[effective_split:]
summary = normalize_compaction_summary(summary)
summary_msg = ChatMessage(
role="system",
content=f"[Conversation summary]\n{summary}",
+42
View File
@@ -0,0 +1,42 @@
"""Small helpers for recognizing image-generation model IDs."""
from __future__ import annotations
_IMAGE_MODEL_PREFIXES = (
"gpt-image",
"dall-e",
"chatgpt-image",
"hidream",
"qwen-image",
"z-image",
"flux",
"stable-diffusion",
"sdxl",
"boogu",
"krea-2",
)
def model_id_leaf(model_id: str) -> str:
"""Return the provider-stripped model id leaf in lowercase."""
return str(model_id or "").strip().split("/")[-1].lower()
def looks_like_image_generation_model(model_id: str) -> bool:
"""Return True when a model id should use image generation routes.
API providers can namespace image models, e.g. ``openai/gpt-5-image``.
Classify by the leaf so mixed endpoints can expose chat and image models
without marking the whole endpoint as image-only.
"""
mid = str(model_id or "").strip().lower()
leaf = model_id_leaf(mid)
if not leaf:
return False
if any(leaf.startswith(prefix) for prefix in _IMAGE_MODEL_PREFIXES):
return True
# Newer OpenAI image models use names like gpt-5-image instead of
# gpt-image-1. Keep this pattern provider-agnostic.
return leaf.startswith("gpt-") and "-image" in leaf
+26
View File
@@ -1040,6 +1040,31 @@ def _provider_label(url: str) -> str:
return host or "provider"
def _is_openai_hosted_chat_url(url: str) -> bool:
try:
parsed = urlparse(url or "")
except Exception:
return False
path = (parsed.path or "").rstrip("/")
return _host_match(url, "openai.com") and path.endswith("/chat/completions")
def _model_disallows_reasoning_effort_with_chat_tools(model: str) -> bool:
"""OpenAI GPT 5.x variants reject reasoning_effort + tools on chat completions."""
m = (model or "").strip().lower()
return bool(re.match(r"^(?:openai/)?gpt-5(?:[.\-]\d+)?(?:[-_:].*)?$", m))
def _scrub_openai_chat_tool_reasoning(payload: Dict, target_url: str, model: str) -> None:
if not payload.get("tools"):
return
if not _is_openai_hosted_chat_url(target_url):
return
if not _model_disallows_reasoning_effort_with_chat_tools(model):
return
payload["reasoning_effort"] = "none"
def _normalize_chatgpt_subscription_url(url: str) -> str:
base = (url or "").strip().rstrip("/")
if base.endswith("/responses"):
@@ -2205,6 +2230,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
payload["think"] = False
_apply_local_cache_affinity(payload, url, session_id)
_apply_local_generation_stability(payload, target_url, model)
_scrub_openai_chat_tool_reasoning(payload, target_url, model)
h = _provider_headers(provider, headers)
if provider == "copilot":
from src.copilot import apply_request_headers
+130
View File
@@ -0,0 +1,130 @@
"""Cleanup helpers for images attached to chat sessions."""
from __future__ import annotations
import json
import logging
import os
import re
from pathlib import Path
from src.constants import GENERATED_IMAGES_DIR
logger = logging.getLogger(__name__)
def _database_models():
"""Import DB models at call time so early import stubs cannot stick here."""
from core.database import ChatMessage, GalleryImage, SessionLocal
return ChatMessage, GalleryImage, SessionLocal
def _generated_image_path_for_cleanup(filename: str) -> Path | None:
if not isinstance(filename, str) or not filename:
return None
name = Path(filename).name
if name != filename or name in {".", ".."}:
return None
root = Path(GENERATED_IMAGES_DIR).resolve()
path = (root / name).resolve()
try:
if os.path.commonpath([str(root), str(path)]) != str(root):
return None
except Exception:
return None
return path
def _image_filename_from_url(url: str) -> str:
if not isinstance(url, str) or not url:
return ""
match = re.search(r"/api/generated-image/([^?#/]+)", url)
return match.group(1) if match else ""
def session_image_refs(db, session_id: str) -> tuple[set[str], set[str]]:
"""Return gallery image ids and generated-image filenames referenced by a chat."""
ChatMessage, GalleryImage, _ = _database_models()
image_ids: set[str] = set()
filenames: set[str] = set()
rows = db.query(GalleryImage).filter(GalleryImage.session_id == session_id).all()
for img in rows:
if img.id:
image_ids.add(str(img.id))
if img.filename:
filenames.add(str(img.filename))
messages = db.query(ChatMessage.meta_data).filter(ChatMessage.session_id == session_id).all()
for row in messages:
raw = getattr(row, "meta_data", None)
if not raw:
continue
try:
meta = json.loads(raw)
except Exception:
continue
events = meta.get("tool_events") if isinstance(meta, dict) else None
if not isinstance(events, list):
continue
for ev in events:
if not isinstance(ev, dict):
continue
image_id = ev.get("image_id")
if image_id:
image_ids.add(str(image_id))
filename = _image_filename_from_url(ev.get("image_url") or ev.get("url") or "")
if filename:
filenames.add(filename)
return image_ids, filenames
def cleanup_session_images(session_id: str, db=None) -> int:
"""Soft-delete Gallery rows and unlink generated files owned by a chat."""
_, GalleryImage, SessionLocal = _database_models()
owns_db = db is None
db = db or SessionLocal()
try:
image_ids, filenames = session_image_refs(db, session_id)
query = db.query(GalleryImage).filter(GalleryImage.session_id == session_id)
if image_ids or filenames:
from sqlalchemy import or_
clauses = [GalleryImage.session_id == session_id]
if image_ids:
clauses.append(GalleryImage.id.in_(list(image_ids)))
if filenames:
clauses.append(GalleryImage.filename.in_(list(filenames)))
query = db.query(GalleryImage).filter(or_(*clauses))
images = query.all()
removed = 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 for deleted session %s: %s",
img.filename,
session_id,
exc,
)
removed += 1
if owns_db and images:
db.commit()
return removed
except Exception as exc:
if owns_db:
db.rollback()
logger.warning("Failed to clean images for deleted session %s: %s", session_id, exc)
return 0
finally:
if owns_db:
db.close()
+3 -5
View File
@@ -64,11 +64,9 @@ DEFAULT_SETTINGS = {
"search_url": "",
"search_result_count": 5,
# SafeSearch level applied to every provider that exposes one.
# "strict" — block adult / explicit results (default; matches what users
# expect from a research tool and avoids unrelated NSFW URLs
# bleeding in via provider "related" / spam recommendations)
# "moderate" — provider-default behavior (filter explicit but allow
# suggestive content)
# "strict" — apply the provider's strongest filtering level (default;
# keeps unrelated low-quality/spam recommendations out)
# "moderate" — provider-default filtering behavior
# "off" — disable filtering entirely (advanced users only)
#
# Providers that honor this setting (translated to each provider's native
+5
View File
@@ -752,6 +752,11 @@ async def _execute_tool_block_impl(
desc = f"{tool}: {first_line}"
result = await _direct_fallback(tool, content, progress_cb=progress_cb) \
or {"error": f"{tool}: execution failed", "exit_code": 1}
elif tool in ("apply_patch", "todowrite"):
first_line = content.split(chr(10))[0][:80]
desc = f"{tool}: {first_line}" if first_line else tool
result = await _direct_fallback(tool, content, session_id=session_id, owner=owner) \
or {"error": f"{tool}: execution failed", "exit_code": 1}
elif tool == "manage_bg_jobs":
# Inspect/kill detached `bash` jobs; needs session_id to scope to chat.
desc = f"manage_bg_jobs: {content.split(chr(10))[0][:80]}"
+11 -5
View File
@@ -47,7 +47,7 @@ ALWAYS_AVAILABLE = frozenset({
# Tools that the Personal Assistant always has access to during scheduled
# check-ins and proactive tasks, in addition to RAG-selected tools.
ASSISTANT_ALWAYS_AVAILABLE = frozenset({
"list_email_accounts", "list_emails", "read_email", "send_email", "reply_to_email",
"list_email_accounts", "list_emails", "read_email", "scan_email_unsubscribes", "unsubscribe_email", "send_email", "reply_to_email",
"bulk_email", "archive_email", "delete_email", "mark_email_read",
"manage_calendar", "manage_notes", "manage_tasks",
"manage_memory", "web_search", "read_file",
@@ -78,6 +78,8 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
"get_workspace": "Return the absolute path of the active workspace folder the user is working in. File tools are confined to it; the shell starts there but is not sandboxed. Call this first when the user refers to 'the project'/'the code'/'this folder' without giving a path, instead of asking them.",
"write_file": "Write/create or fully rewrite a file ON DISK (source code, configs, project files). Use for new files or full rewrites — NOT create_document (editor panel) and NOT a bash heredoc.",
"edit_file": "Edit an existing file ON DISK by exact string replacement (fix a bug, change a function). Shows a diff. The tool for changing files on disk — NOT edit_document (editor panel) and NOT bash sed/heredoc.",
"apply_patch": "Apply a multi-file patch to source files ON DISK. Use for implementation, refactors, and bug fixes where several edits belong together. Workspace-confined and returns a diff. Prefer over bash redirects/heredocs/sed.",
"todowrite": "Maintain a structured task list for the current coding session. Use for multi-step code work: inspect, edit, test, and mark statuses current.",
"create_document": "Create a new document in the editor panel. For code, articles, text content longer than 15 lines, unless an already-open document/email draft is the obvious target. If an email compose draft is open, edit that draft instead of creating another document.",
"edit_document": "Preferred tool for editing an existing document — targeted find-and-replace. Use for any small change: add a function, fix a bug, tweak a section, rename things.",
"update_document": "Replace the entire active document content. ONLY for full rewrites (>50% changed). Do not use for small edits — use edit_document instead.",
@@ -109,6 +111,8 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
"list_email_accounts": "List configured email accounts and default status. Use before reading or sending mail when the user mentions Gmail, work mail, custom domain mail, another mailbox, or asks to compare/check multiple inboxes.",
"list_emails": "List emails for a folder/account, newest first, including read messages by default. Shows subject, sender, date, UID, account, and AI summary. Check inbox, find emails needing replies. Supports account from list_email_accounts for Gmail/work/custom mailboxes. For last/latest/newest email, use max_results=1 and unread_only=false.",
"read_email": "Read the full content of a specific email by UID or Message-ID. View email body, check details. Supports account from list_email_accounts when the UID belongs to a non-default mailbox.",
"scan_email_unsubscribes": "Scan recent email headers for spam/newsletter unsubscribe candidates. Review-only; returns UIDs, reasons, and mailto/web unsubscribe methods.",
"unsubscribe_email": "Execute an approved unsubscribe action by UID. Mailto methods are sent/staged; web URL methods return exact browser/web instructions.",
"send_email": "Send a new email via SMTP. Provide recipient, subject, body, and optional account from list_email_accounts. For replying to a thread use reply_to_email instead.",
"reply_to_email": "SEND a reply email immediately by UID. Do not use for write/draft/open/start reply requests; use ui_control open_email_reply with body so the user can review. Only use when the user explicitly says to send now. For send requests, use the exact UID and account from latest read_email/list_emails output; never invent UID 1. Threads automatically with In-Reply-To/References, prefixes Re:, marks original as Answered.",
"archive_email": "Move an email out of the inbox into the Archive folder. Use after handling messages you want to keep but get out of the way.",
@@ -127,8 +131,8 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
"list_downloads": "List in-progress HuggingFace model downloads in the Cookbook. Shows model name, phase, percent, session ID. Use for 'what's downloading', 'show my downloads', 'check download progress'.",
"cancel_download": "Cancel an in-progress model download by tmux session ID. Use for 'cancel the download', 'stop downloading X', 'kill the download'. Call list_downloads first to get the session_id.",
"search_hf_models": "Search HuggingFace for models matching a query (e.g. 'qwen 8B', 'flux', 'llama-3 instruct'). Returns ranked repo IDs with sizes and download counts. Use for 'find a model', 'search huggingface for X', 'what models are there for Y'.",
"list_cached_models": "List models already cached on disk locally or on a remote host. Accepts friendly Cookbook server names like ajax. Use for 'what models do I have', 'show cached models', 'is X downloaded', 'list my models'. Avoids re-downloading.",
"list_serve_presets": "List saved Cookbook serve presets (templates with model+host+port+cmd). Always call this BEFORE serve_model when the user asks to launch a known model — they probably have a preset for it from the UI.",
"list_cached_models": "List models already cached on disk locally or on a remote host. Accepts friendly Cookbook server names like workstation. Use for 'what models do I have', 'show cached models', 'is X downloaded', 'list my models'. Avoids re-downloading.",
"list_serve_presets": "List saved Cookbook serve presets (templates with model+host+port+cmd). Call this BEFORE raw serve_model when the user asks to launch a known model manually.",
"serve_preset": "Launch a saved Cookbook serve preset by name. Reuses the exact tmux command + host the user already saved. Use for 'run stable diffusion 3.5', 'serve vllm-qwen', 'start the inpaint model' — preset-name matches the user's UI labels.",
"adopt_served_model": "Register an existing tmux model server (one started manually or outside the cookbook flow) into Cookbook tracking AND add it as a chat endpoint. Use when the user (or a previous turn) launched something via ssh+tmux and now wants it visible in the UI, stoppable via stop_served_model, and usable in the model picker.",
"list_cookbook_servers": "List the cookbook's configured servers (remote GPU boxes + local) and which is the current default. Use this BEFORE download_model/serve_model when the user didn't name a host — to decide where to run, or to ask the user which server when ambiguous. Downloads/serves default to the cookbook's selected server, NOT localhost.",
@@ -347,7 +351,7 @@ class ToolIndex:
# whole email toolset and crowding out the relevant tools — the model then
# believed it had only email tools and refused web/other tasks (#1707).
frozenset({"email", "emails", "mail", "mails", "gmail", "googlemail", "message", "messages", "send", "reply", "replies", "inbox", "unread"}):
{"list_email_accounts", "list_emails", "read_email", "send_email", "reply_to_email", "bulk_email", "delete_email", "archive_email", "mark_email_read", "resolve_contact", "ui_control"},
{"list_email_accounts", "list_emails", "read_email", "scan_email_unsubscribes", "unsubscribe_email", "send_email", "reply_to_email", "bulk_email", "delete_email", "archive_email", "mark_email_read", "resolve_contact", "ui_control"},
frozenset({"calendar", "event", "meeting", "schedule", "appointment"}):
{"manage_calendar"},
# Detached background `bash` jobs (#!bg): check on / read output / kill.
@@ -471,8 +475,10 @@ class ToolIndex:
{"list_served_models", "stop_served_model"},
# Cookbook serve / launch / preset / server selection
frozenset({"serve", "launch", "spin up", "start the model", "run the model",
"debug launch", "launch command", "drivers", "driver",
"preset", "presets", "which server", "what servers",
"gpu box", "cookbook server", "vllm", "on the server", "on the gpu"}):
"gpu box", "cookbook server", "vllm", "sglang", "mlx", "llama.cpp",
"on the server", "on the gpu"}):
{"serve_preset", "serve_model", "list_serve_presets",
"list_cookbook_servers", "list_cached_models"},
# Cookbook downloads
+5
View File
@@ -250,6 +250,11 @@ _TOOL_NAME_MAP = {
"write": "write_file",
"write_file": "write_file",
"save": "write_file",
"apply_patch": "apply_patch",
"patch": "apply_patch",
"todowrite": "todowrite",
"todo_write": "todowrite",
"todo_update": "todowrite",
"document": "update_document",
"update_document": "update_document",
"create_document": "create_document",
+87 -5
View File
@@ -25,6 +25,7 @@ _REQUIRED_NATIVE_TOOL_ARGS = {
"read_file": ("path",),
"write_file": ("path",),
"edit_file": ("path",),
"apply_patch": ("patch_text", "patchText", "patch"),
}
# ---------------------------------------------------------------------------
@@ -192,6 +193,49 @@ FUNCTION_TOOL_SCHEMAS = [
}
}
},
{
"type": "function",
"function": {
"name": "apply_patch",
"description": "Apply a multi-file source-code patch to disk. Use for real project files in the workspace when several edits belong together. Patch must use *** Begin Patch / *** End Patch with Add File, Update File, or Delete File sections. Prefer this over bash redirects/heredocs/sed.",
"parameters": {
"type": "object",
"properties": {
"patch_text": {
"type": "string",
"description": "Patch text beginning with *** Begin Patch and ending with *** End Patch"
}
},
"required": ["patch_text"]
}
}
},
{
"type": "function",
"function": {
"name": "todowrite",
"description": "Create and maintain a structured task list for the current coding session. Use during multi-step implementation/debug/refactor work and keep statuses current.",
"parameters": {
"type": "object",
"properties": {
"todos": {
"type": "array",
"description": "Current task list. Only one item should be in_progress.",
"items": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "Task description"},
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"]},
"priority": {"type": "string", "enum": ["low", "medium", "high"]}
},
"required": ["content", "status"]
}
}
},
"required": ["todos"]
}
}
},
{
"type": "function",
"function": {
@@ -814,12 +858,12 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "serve_model",
"description": "Start serving a model with vLLM, SGLang, llama.cpp, Ollama, or Diffusers. If `host` is omitted, defaults to the cookbook's selected server (not localhost). For image/inpainting/diffusion models use the built-in command `python3 scripts/diffusion_server.py --model <repo> --port 8100` rather than inventing a custom diffusers API server. After launching, call list_served_models to check readiness/errors; if it reports a diagnosis with retry suggestions, retry via serve_model using the suggested adjusted cmd.",
"description": "Start serving a model with vLLM, SGLang, llama.cpp, Ollama, MLX Image, or Diffusers. If `host` is omitted, defaults to the cookbook's selected server (not localhost). For MLX image models on Apple Silicon use `python3 scripts/mlx_image_server.py --model <repo> --port 8100`; for non-MLX image/inpainting/diffusion models use `python3 scripts/diffusion_server.py --model <repo> --port 8100`. Never serve image models with `mlx_lm.server`; that is only for text/chat MLX models. After launching, call list_served_models to check readiness/errors; if it reports a diagnosis with retry suggestions, retry via serve_model using the suggested adjusted cmd.",
"parameters": {
"type": "object",
"properties": {
"repo_id": {"type": "string", "description": "Model repo (e.g. 'Qwen/Qwen3-8B')"},
"cmd": {"type": "string", "description": "Full serve command (e.g. 'vllm serve Qwen/Qwen3-8B --port 8000 --tp 2', 'python3 -m sglang.launch_server --model-path Qwen/Qwen3-8B --port 30000', or for inpainting/image models: 'python3 scripts/diffusion_server.py --model diffusers/stable-diffusion-xl-1.0-inpainting-0.1 --port 8100')"},
"cmd": {"type": "string", "description": "Full serve command (e.g. 'vllm serve <repo> --port 8000 --tp 2', 'python3 -m sglang.launch_server --model-path <repo> --port 30000', for MLX image models: 'python3 scripts/mlx_image_server.py --model <repo> --port 8100', or for non-MLX image models: 'python3 scripts/diffusion_server.py --model <repo> --port 8100')"},
"host": {"type": "string", "description": "Target server — friendly NAME from list_cookbook_servers (e.g. 'gpu-box', 'workstation') or raw user@host. Omit to use the cookbook's selected default."},
"local": {"type": "boolean", "description": "Force serve on THIS machine instead of the default remote server."},
},
@@ -913,7 +957,7 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "list_serve_presets",
"description": "List saved Cookbook serve presets. Each preset is a launch template (name, model, host, port, tmux cmd) the user previously saved from the UI. Call this BEFORE serve_model when the user asks to launch a model by name — there's almost always a working preset for it.",
"description": "List saved Cookbook serve presets. Each preset is a launch template (name, model, host, port, tmux cmd) the user previously saved from the UI. Call this BEFORE raw serve_model when the user asks to launch a model by name manually.",
"parameters": {"type": "object", "properties": {}}
}
},
@@ -954,11 +998,11 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "list_cached_models",
"description": "List models already cached on disk locally or on a remote server. `host` accepts friendly Cookbook server names from list_cookbook_servers (for example ajax) or raw user@host. Also reports completed Cookbook download tasks when the filesystem cache scan cannot locate the HF cache path.",
"description": "List models already cached on disk locally or on a remote server. `host` accepts friendly Cookbook server names from list_cookbook_servers (for example workstation) or raw user@host. Also reports completed Cookbook download tasks when the filesystem cache scan cannot locate the HF cache path.",
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Friendly Cookbook server name (e.g. 'ajax', 'gpu-box') or raw remote host (e.g. 'user@gpu-box'). Omit for local."},
"host": {"type": "string", "description": "Friendly Cookbook server name (e.g. 'workstation', 'gpu-box') or raw remote host (e.g. 'user@gpu-box'). Omit for local."},
"model_dir": {"type": "string", "description": "Comma-separated additional model directories to scan beyond ~/.cache/huggingface/hub"},
"ssh_port": {"type": "string", "description": "SSH port for remote host (default 22)"},
"platform": {"type": "string", "enum": ["linux", "windows"], "description": "Remote platform"}
@@ -1114,6 +1158,40 @@ FUNCTION_TOOL_SCHEMAS = [
}
}
},
{
"type": "function",
"function": {
"name": "scan_email_unsubscribes",
"description": "Scan recent email headers for likely spam/newsletter unsubscribe candidates. Does not unsubscribe anything. Review candidates with the user before acting; mailto methods can be executed with unsubscribe_email, web URL methods require browser/web tools after approval.",
"parameters": {
"type": "object",
"properties": {
"folder": {"type": "string", "description": "IMAP folder to scan (default: INBOX)"},
"limit": {"type": "integer", "description": "Maximum candidates to return (default: 25)"},
"max_scan": {"type": "integer", "description": "How many newest emails to inspect (default: 150)"},
"account": {"type": "string", "description": "Optional account name/email/id from list_email_accounts"},
},
}
}
},
{
"type": "function",
"function": {
"name": "unsubscribe_email",
"description": "Execute one approved unsubscribe action for an email UID. Safe mailto List-Unsubscribe methods are sent/staged. Web URL methods return a requires-browser instruction and exact URL; use browser/web tools only after user approval.",
"parameters": {
"type": "object",
"properties": {
"uid": {"type": "string", "description": "Email UID from scan_email_unsubscribes/list_emails"},
"folder": {"type": "string", "description": "IMAP folder (default: INBOX)"},
"method_index": {"type": "integer", "description": "Method index from scan_email_unsubscribes (default: 0)"},
"allow_web": {"type": "boolean", "description": "Return browser/web instructions when selected method is URL"},
"account": {"type": "string", "description": "Optional account name/email/id from list_email_accounts"},
},
"required": ["uid"]
}
}
},
{
"type": "function",
"function": {
@@ -1367,6 +1445,10 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
content = args.get("path", "") + "\n" + args.get("content", "")
elif tool_type == "edit_file":
content = json.dumps(args)
elif tool_type == "apply_patch":
content = args.get("patch_text") or args.get("patchText") or args.get("patch") or ""
elif tool_type == "todowrite":
content = json.dumps(args)
elif tool_type == "create_document":
parts = [args.get("title", "Untitled")]
if args.get("language"):
+7 -2
View File
@@ -19,6 +19,8 @@ BUILTIN_EMAIL_TOOLS = frozenset({
"list_emails",
"read_email",
"search_emails",
"scan_email_unsubscribes",
"unsubscribe_email",
"send_email",
"reply_to_email",
"draft_email",
@@ -44,6 +46,7 @@ NON_ADMIN_BLOCKED_TOOLS = BUILTIN_EMAIL_TOOLS | {
"read_file",
"write_file",
"edit_file",
"apply_patch",
"grep",
"glob",
"ls",
@@ -110,6 +113,7 @@ PLAN_MODE_READONLY_TOOLS = {
# classified — see the plan-mode partition test in
# tests/test_email_registry_sync.py.
"search_emails",
"scan_email_unsubscribes",
"list_served_models",
"list_downloads",
"list_cached_models",
@@ -136,14 +140,15 @@ PLAN_MODE_READONLY_TOOLS = {
# here — read-only tools are covered by the allowlist. Keep in sync when adding
# new mutating tools.
_PLAN_MODE_KNOWN_MUTATORS = {
"write_file", "create_document", "edit_document", "update_document",
"write_file", "edit_file", "apply_patch", "todowrite",
"create_document", "edit_document", "update_document",
"suggest_document", "manage_documents", "create_session", "manage_session",
"send_to_session", "pipeline", "manage_memory", "manage_skills",
"manage_tasks", "manage_notes", "manage_endpoints", "manage_mcp",
"manage_webhooks", "manage_tokens", "manage_settings", "manage_contact",
"manage_calendar", "api_call", "app_api", "ui_control",
"send_email", "reply_to_email", "bulk_email", "delete_email",
"archive_email", "mark_email_read",
"archive_email", "mark_email_read", "unsubscribe_email",
# The draft tools create documents and download_attachment writes to
# disk — mutating. They have no native schemas (yet), so without these
# static entries plan-mode safety for their bare fence tags would depend
+186 -1
View File
@@ -33,6 +33,57 @@ def _validate_cookbook_ssh_target(remote_host: Any, ssh_port: Any = "") -> tuple
return remote, sport
def _cookbook_label_key(value: Any) -> str:
return re.sub(r"[^a-z0-9]+", "", str(value or "").lower())
def _cookbook_is_exact_repo_id(value: Any) -> bool:
return bool(re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", str(value or "").strip()))
def _cookbook_match_saved_preset(query: str, presets: List[Any], host: str = "") -> Optional[Dict[str, Any]]:
"""Resolve a user-facing model label to a saved serve preset.
The launch agent should be callable directly. If the model says
`repo_id="Qwen3.6-27B-AEON"` because the user used the short UI label, do
not force it through `list_serve_presets`; match the saved preset inside
the autopilot and let `do_serve_preset` reuse the known-good command.
"""
q = _cookbook_label_key(query)
if not q:
return None
host = str(host or "")
exact_repo = _cookbook_is_exact_repo_id(query)
candidates: List[tuple[int, Dict[str, Any]]] = []
for p in presets or []:
if not isinstance(p, dict):
continue
name = str(p.get("name") or "")
model = str(p.get("model") or p.get("modelId") or "")
phost = str(p.get("host") or p.get("remoteHost") or "")
haystacks = [_cookbook_label_key(name), _cookbook_label_key(model)]
if exact_repo:
# If the user gave a real HF repo, only reuse a preset that names
# that exact repo/label. A substring match here is dangerous:
# cyankiwi/Qwen3.5-122B-A10B-AWQ-8bit must not launch the saved
# generic Qwen/Qwen3.5-122B-A10B preset.
if not any(h and q == h for h in haystacks):
continue
else:
if not any(h and (q == h or q in h or h in q) for h in haystacks):
continue
score = 10
if host and phost == host:
score += 20
if q in haystacks:
score += 10
candidates.append((score, p))
if not candidates:
return None
candidates.sort(key=lambda item: item[0], reverse=True)
return candidates[0][1]
async def _cookbook_servers() -> Dict[str, Any]:
"""Return the cookbook's configured servers + the currently-selected
default host. Shape: {default_host, hosts: [{host, platform, env, envPath}]}.
@@ -200,7 +251,7 @@ async def _ensure_served_endpoint(
port = _infer_serve_port(cmd)
base_url = f"http://{endpoint_host}:{port}/v1"
short_name = model.split("/")[-1] if "/" in model else model
is_image = "diffusion_server.py" in (cmd or "")
is_image = "diffusion_server.py" in (cmd or "") or "mlx_image_server.py" in (cmd or "")
payload = {
"name": short_name if not is_image else f"{short_name} (image)",
"base_url": base_url,
@@ -315,6 +366,7 @@ async def _cookbook_register_task(
_MODEL_PROCESS_PATTERNS = [
("vLLM", ["vllm.entrypoints", "vllm serve", "/vllm/", "vllm-openai"]),
("SGLang", ["sglang.launch_server", "sglang/launch_server"]),
("MLX Image", ["mlx_image_server.py", "mflux-generate-qwen", "mflux-generate"]),
("MLX", ["mlx_lm.server", "mlx-lm"]),
("llama.cpp", ["llama-server", "llama_cpp_server", "llamacppserver"]),
("Ollama", ["ollama serve", "ollama runner", "/ollama "]),
@@ -358,6 +410,139 @@ def _cookbook_apply_retry_suggestion(cmd: str, suggestion: Dict[str, Any]) -> st
return cmd
def _cookbook_engine_from_model_info(repo_id: str, info: Optional[Dict[str, Any]], host_meta: Optional[Dict[str, Any]] = None) -> str:
"""Choose a conservative serve engine from repo metadata and target host.
This is intentionally heuristic: the model card / file list tells us the
official repo and likely format, while the actual serve command still goes
through Cookbook and is diagnosed/retried after launch.
"""
rid = (repo_id or "").lower()
info = info or {}
host_meta = host_meta or {}
platform = (host_meta.get("platform") or "").lower()
tags = {str(t).lower() for t in (info.get("tags") or []) if t is not None}
siblings = [str(s).lower() for s in (info.get("siblings") or []) if s is not None]
files = " ".join(siblings)
is_image = (
"diffusers" in tags
or "text-to-image" in tags
or "image-to-image" in tags
or any(k in rid for k in ("qwen-image", "z-image", "flux", "stable-diffusion", "sdxl", "hidream", "boogu", "krea-2"))
)
is_mlx_image = is_image and ("mlx" in tags or "mlx" in rid or "mlx-community/" in rid)
if is_mlx_image:
return "mlx_image"
if is_image:
return "diffusers"
if "mlx" in tags or "mlx" in rid or "mlx-community/" in rid or platform in {"macos", "darwin"}:
return "mlx"
if "gguf" in tags or "gguf" in rid or ".gguf" in files:
return "llama.cpp"
if "sglang" in tags or "sglang" in rid:
return "sglang"
if any(q in rid or q in files or q in tags for q in ("awq", "fp8", "gptq", "bnb", "bitsandbytes")):
return "vllm"
return "vllm"
def _cookbook_default_launch_cmd(repo_id: str, engine: str, *, port: int = 8000, info: Optional[Dict[str, Any]] = None) -> str:
"""Build a simple first-attempt command for a selected engine."""
engine = (engine or "vllm").lower()
port = int(port or 8000)
if engine in {"mlx", "mlx-lm", "mlx_lm"}:
return f"python3 -m mlx_lm.server --model {repo_id} --host 0.0.0.0 --port {port}"
if engine in {"mlx_image", "mlx-image", "mflux"}:
return f"python3 scripts/mlx_image_server.py --model {repo_id} --host 0.0.0.0 --port {port}"
if engine in {"diffusers", "diffusion", "image"}:
return f"python3 scripts/diffusion_server.py --model {repo_id} --host 0.0.0.0 --port {port}"
if engine in {"sglang", "sgl"}:
return f"python3 -m sglang.launch_server --model-path {repo_id} --host 0.0.0.0 --port {port}"
if engine in {"llama.cpp", "llamacpp", "llama"}:
siblings = [str(s) for s in ((info or {}).get("siblings") or []) if str(s).lower().endswith(".gguf")]
if siblings:
# llama-server accepts HF repo + filename separately on recent builds.
return f"llama-server -hf {repo_id} -hfr {siblings[0]} --host 0.0.0.0 --port {port}"
return f"llama-server -hf {repo_id} --host 0.0.0.0 --port {port}"
return f"vllm serve {repo_id} --host 0.0.0.0 --port {port}"
async def _cookbook_hf_model_info(repo_id: str) -> Dict[str, Any]:
"""Fetch lightweight official Hugging Face metadata for launch planning.
Uses the public HF API directly so this works even when huggingface_hub is
not installed in Odysseus. Failures return a structured warning rather than
blocking launch; cached/private/offline models can still be served.
"""
import httpx
from routes.cookbook_helpers import load_stored_hf_token
repo_id = (repo_id or "").strip().strip("/")
if not repo_id:
return {"error": "repo_id is required"}
headers: Dict[str, str] = {"Accept": "application/json"}
token = load_stored_hf_token()
if token:
headers["Authorization"] = f"Bearer {token}"
url = f"https://huggingface.co/api/models/{repo_id}"
try:
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
resp = await client.get(url, headers=headers)
if resp.status_code >= 400:
return {
"repo_id": repo_id,
"url": f"https://huggingface.co/{repo_id}",
"error": f"HF metadata lookup returned HTTP {resp.status_code}",
}
data = resp.json() if resp.content else {}
except Exception as e:
return {
"repo_id": repo_id,
"url": f"https://huggingface.co/{repo_id}",
"error": f"HF metadata lookup failed: {e}",
}
siblings = []
for s in data.get("siblings") or []:
if isinstance(s, dict) and s.get("rfilename"):
siblings.append(s["rfilename"])
return {
"repo_id": repo_id,
"url": f"https://huggingface.co/{repo_id}",
"pipeline_tag": data.get("pipeline_tag") or "",
"library_name": data.get("library_name") or "",
"tags": data.get("tags") or [],
"sha": data.get("sha") or "",
"private": bool(data.get("private")),
"gated": data.get("gated"),
"siblings": siblings[:500],
"cardData": data.get("cardData") or {},
}
def _cookbook_host_meta(host: str, servers: Dict[str, Any]) -> Dict[str, Any]:
for item in servers.get("hosts") or []:
if not isinstance(item, dict):
continue
if (item.get("host") or "") == (host or ""):
return item
return {}
def _cookbook_find_task(tasks: List[Dict[str, Any]], session_id: str) -> Optional[Dict[str, Any]]:
for task in tasks or []:
if not isinstance(task, dict):
continue
if task.get("session_id") == session_id or task.get("sessionId") == session_id or task.get("id") == session_id:
return task
return None
def _cookbook_phase(task: Optional[Dict[str, Any]]) -> str:
if not task:
return "unknown"
return str(task.get("phase") or task.get("status") or "unknown").lower()
def _scan_running_model_processes() -> List[Dict[str, Any]]:
"""Scan /proc for running model server processes. Linux-only; returns
[] on other platforms or if /proc isn't accessible. Each match returns
+29 -2
View File
@@ -32,8 +32,35 @@ async def do_edit_image(content: str, owner: Optional[str] = None) -> Dict:
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.post(f"{_INTERNAL_BASE}/api/gallery/{action}", json=payload)
data = resp.json()
if data.get("success") or data.get("id"):
return {"output": f"Image edited ({action}). New image ID: {data.get('id', '?')}", "exit_code": 0}
new_id = data.get("id") or data.get("image_id")
if data.get("success") or new_id:
result = {
"output": f"Image edited ({action}). New image ID: {new_id or '?'}",
"exit_code": 0,
}
if new_id:
result["image_id"] = new_id
try:
from src.database import GalleryImage, SessionLocal
db = SessionLocal()
try:
q = db.query(GalleryImage).filter(GalleryImage.id == new_id)
if owner:
q = q.filter(GalleryImage.owner == owner)
img = q.first()
if img and img.filename:
result.update({
"image_url": f"/api/generated-image/{img.filename}",
"image_prompt": img.prompt or args.get("prompt") or action,
"image_model": img.model or "edit_image",
"image_size": img.size or "",
"image_quality": img.quality or "",
})
finally:
db.close()
except Exception:
pass
return result
return {"error": data.get("error", f"{action} failed"), "exit_code": 1}
except Exception as e:
return {"error": str(e), "exit_code": 1}
+38 -15
View File
@@ -280,7 +280,30 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
except ValueError:
return {"error": "Invalid JSON arguments", "exit_code": 1}
if not args.get("action") and any(args.get(k) is not None for k in ("task", "description", "schedule", "time", "day_of_week")):
args["action"] = "create"
if args.get("task") and not args.get("name"):
args["name"] = args["task"]
if args.get("task") and not args.get("prompt"):
args["prompt"] = args["task"]
action = args.get("action", "list")
if args.get("description") and not args.get("prompt"):
args["prompt"] = args["description"]
if args.get("time") and not args.get("scheduled_time"):
args["scheduled_time"] = args["time"]
if args.get("day_of_week") is not None and args.get("scheduled_day") is None:
day = str(args.get("day_of_week")).strip().lower()
days = {
"monday": 0, "mon": 0,
"tuesday": 1, "tue": 1, "tues": 1,
"wednesday": 2, "wed": 2,
"thursday": 3, "thu": 3, "thur": 3, "thurs": 3,
"friday": 4, "fri": 4,
"saturday": 5, "sat": 5,
"sunday": 6, "sun": 6,
}
if day in days:
args["scheduled_day"] = days[day]
db = SessionLocal()
try:
if action == "list":
@@ -288,21 +311,21 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
if owner:
q = q.filter(ScheduledTask.owner == owner)
tasks = q.order_by(ScheduledTask.created_at.desc()).all()
task_list = []
for t in tasks:
task_list.append({
"id": t.id, "name": t.name, "status": t.status,
"task_type": t.task_type or "llm",
"action": t.action,
"trigger_type": t.trigger_type or "schedule",
"schedule": t.schedule,
"trigger_event": t.trigger_event,
"trigger_count": t.trigger_count,
"next_run": t.next_run.isoformat() + "Z" if t.next_run else None,
"last_run": t.last_run.isoformat() + "Z" if t.last_run else None,
"run_count": t.run_count or 0,
})
return {"response": f"Found {len(task_list)} tasks", "tasks": task_list, "exit_code": 0}
if not tasks:
return {"response": "No scheduled tasks found.", "exit_code": 0}
lines = [f"Found {len(tasks)} tasks:"]
for idx, t in enumerate(tasks, 1):
bits = [t.status or "unknown"]
if t.schedule:
bits.append(str(t.schedule))
if t.scheduled_time:
bits.append(str(t.scheduled_time))
if t.next_run:
bits.append(f"next {t.next_run.isoformat()}Z")
detail = ", ".join(bits)
lines.append(f"{idx}. {t.name} ({t.id}) — {detail}")
return {"response": "\n".join(lines), "exit_code": 0}
elif action == "create":
task_type = args.get("task_type", "llm")
+364 -72
View File
@@ -6,29 +6,29 @@ import Storage from './js/storage.js';
import uiModule from './js/ui.js';
import workspaceModule from './js/workspace.js';
import fileHandlerModule from './js/fileHandler.js';
import modelsModule from './js/models.js';
import modelsModule from './js/models.js?v=20260715startupcalm2';
import ragModule from './js/rag.js';
import presetsModule from './js/presets.js';
import searchModule from './js/search.js';
import chatModule from './js/chat.js';
import compareModule from './js/compare/index.js';
import documentModule from './js/document.js';
import chatModule from './js/chat.js?v=20260722ctxheader4';
import compareModule from './js/compare/index.js?v=20260723compareicon2';
import documentModule from './js/document.js?v=20260722emailfastindex1';
import searchChatModule from './js/search-chat.js';
import { makeWindowDraggable } from './js/windowDrag.js';
import markdownModule from './js/markdown.js';
import chatRenderer from './js/chatRenderer.js';
import sessionModule from './js/sessions.js';
import memoryModule from './js/memory.js';
import chatRenderer from './js/chatRenderer.js?v=20260722emailfastindex1';
import sessionModule from './js/sessions.js?v=20260722ctxheader4';
import memoryModule from './js/memory.js?v=20260722memoryloading1';
import voiceRecorderModule from './js/voiceRecorder.js';
import censorModule from './js/censor.js';
import galleryModule from './js/gallery.js';
import tasksModule from './js/tasks.js?v=20260630tasksactivity';
import tasksModule from './js/tasks.js?v=20260723tasksbulkfeedback1';
import calendarModule from './js/calendar.js';
import notesModule from './js/notes.js';
import adminModule from './js/admin.js';
import settingsModule from './js/settings.js';
import adminModule from './js/admin.js?v=20260716openrouter3';
import settingsModule from './js/settings.js?v=20260722emailfastindex1';
// Eagerly bind unified minimize/restore behavior across all tool modals.
import './js/modalManager.js';
import './js/modalManager.js?v=20260723compareicon2';
// Desktop window tiling — drag a modal near an edge/corner to snap.
import './js/tileManager.js';
import themeModule from './js/theme.js';
@@ -43,7 +43,7 @@ import * as researchPanelModule from './js/research/panel.js?v=20260630researcht
import ttsModule from './js/tts-ai.js';
import spinnerModule from './js/spinner.js';
import { initKeyboardShortcuts } from './js/keyboard-shortcuts.js';
import { initSidebarLayout, syncRailSide } from './js/sidebar-layout.js';
import { initSidebarLayout, syncRailSide } from './js/sidebar-layout.js?v=20260715startupclean';
import { initSectionCollapse, initSectionDrag } from './js/section-management.js';
const API_BASE = window.location.origin;
@@ -204,12 +204,22 @@ const el = uiModule.el;
// changes take effect immediately (previously cached once at page load and
// went stale when the user changed their default model).
let _defaultChat = null;
try {
const cachedDefaultChat = JSON.parse(localStorage.getItem('odysseus-default-chat-cache') || 'null');
if (cachedDefaultChat && cachedDefaultChat.endpoint_url && cachedDefaultChat.model) {
_defaultChat = cachedDefaultChat;
window.__odysseusDefaultChat = cachedDefaultChat;
}
} catch (_) {}
async function _refreshDefaultChat() {
try {
const d = await (await fetch('/api/default-chat')).json();
if (d && d.endpoint_url && d.model) {
_defaultChat = d;
try { window.__odysseusDefaultChat = d; } catch (_) {}
try {
window.__odysseusDefaultChat = d;
localStorage.setItem('odysseus-default-chat-cache', JSON.stringify(d));
} catch (_) {}
return d;
}
} catch (_) {}
@@ -224,7 +234,7 @@ async function _createDirectChatFromPreferredModel() {
const pending = sessionModule.getPendingChat && sessionModule.getPendingChat();
if (pending && pending.url && pending.modelId && pending.endpointId) {
sessionModule.createDirectChat(pending.url, pending.modelId, pending.endpointId);
sessionModule.createDirectChat(pending.url, pending.modelId, pending.endpointId, { source: pending.source || 'manual' });
return true;
}
@@ -238,7 +248,7 @@ async function _createDirectChatFromPreferredModel() {
const dc = await _refreshDefaultChat();
if (dc) {
sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id);
sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id, { source: 'default' });
return true;
}
@@ -252,50 +262,6 @@ async function _createDirectChatFromPreferredModel() {
return false;
}
async function _hasUsableChatModel() {
try {
const pending = sessionModule?.getPendingChat?.();
if (pending && pending.url && pending.modelId) return true;
} catch (_) {}
try {
const current = sessionModule?.getSessions?.()
?.find(s => s.id === sessionModule?.getCurrentSessionId?.());
if (current && current.endpoint_url && current.model) return true;
} catch (_) {}
const dc = await _refreshDefaultChat();
if (dc && dc.endpoint_url && dc.model) return true;
try {
const items = window.modelsModule?.getCachedItems?.() || [];
if (items.some(item => !item.offline && ((item.models || []).length || (item.models_extra || []).length))) {
return true;
}
} catch (_) {}
try {
const res = await fetch(`${API_BASE}/api/models?background=false`, { credentials: 'same-origin' });
if (!res.ok) return false;
const data = await res.json();
return (data.items || []).some(item => !item.offline && ((item.models || []).length || (item.models_extra || []).length));
} catch (_) {
return false;
}
}
async function _syncWelcomeModelHint() {
const tip = document.getElementById('welcome-tip');
const sub = document.getElementById('welcome-sub');
if (!tip && !sub) return;
const hasModel = await _hasUsableChatModel();
if (hasModel) {
if (sub && !sub.dataset.researchOrigText) sub.textContent = 'New chat ready.';
if (tip) tip.textContent = 'Pick a model if you want, or just type.';
} else {
if (sub && !sub.dataset.researchOrigText) {
sub.innerHTML = 'Welcome, <span class="setup-trigger-link" style="color:var(--accent,var(--red));font-weight:600;cursor:pointer;text-decoration:underline;" title="Click to launch setup">type /setup</span> to get started.';
}
if (tip) tip.textContent = 'Add an AI endpoint from Settings in the sidebar, or paste an endpoint/API key into the chat.';
}
}
// ============================================
// EVENT LISTENERS INITIALIZATION
// ============================================
@@ -522,6 +488,20 @@ function initializeEventListeners() {
});
}
// Export menu: Compact current chat context
const exportCompactBtn = el('export-compact-btn');
if (exportCompactBtn) {
exportCompactBtn.addEventListener('click', async (e) => {
e.stopPropagation();
exportMenu.classList.remove('open');
if (window.compactCurrentChatContext) {
await window.compactCurrentChatContext();
} else {
uiModule.showError('Compact action is not ready yet');
}
});
}
// Export: PDF
const exportPdfBtn = el('export-pdf-btn');
if (exportPdfBtn) {
@@ -574,6 +554,18 @@ function initializeEventListeners() {
});
}
// Export menu: Delete current chat
const exportDeleteBtn = el('export-delete-btn');
if (exportDeleteBtn) {
exportDeleteBtn.addEventListener('click', async (e) => {
e.stopPropagation();
exportMenu.classList.remove('open');
if (sessionModule?.deleteCurrentSessionFromTopMenu) {
await sessionModule.deleteCurrentSessionFromTopMenu();
}
});
}
// Rename session from top bar
const exportRenameBtn = el('export-rename-btn');
if (exportRenameBtn) {
@@ -1247,6 +1239,7 @@ function initializeEventListeners() {
if (libraryNewDocBtn) {
libraryNewDocBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (libraryNewDocBtn.dataset.docNewWired === '1') return;
try {
if (documentModule && documentModule.newDocument) await documentModule.newDocument();
} catch (err) {
@@ -1746,6 +1739,42 @@ function initializeEventListeners() {
uiModule.showToast(`${label} ${active ? 'on' : 'off'}`, 1800);
}
function syncPlanToggle(active) {
const btn = el('plan-toggle-btn');
const chk = el('plan-toggle');
const status = el('plan-mode-status');
const statusToggle = el('plan-mode-status-toggle');
if (chk) chk.checked = !!active;
document.body.classList.toggle('plan-mode-active', !!active);
if (status) status.hidden = !active;
if (statusToggle) {
statusToggle.setAttribute('aria-pressed', String(!!active));
statusToggle.setAttribute('aria-label', active ? 'Turn off plan mode' : 'Turn on plan mode');
}
if (btn) {
btn.classList.toggle('active', !!active);
btn.setAttribute('aria-pressed', String(!!active));
btn.title = active
? 'Plan mode on - next message proposes a plan only'
: 'Plan mode';
}
}
function setPlanMode(active, options = {}) {
const on = !!active;
const st = loadToggleState();
st.plan_mode = on;
saveToggleState(st);
syncPlanToggle(on);
if (on) {
const resChk = el('research-toggle');
if (resChk && resChk.checked) _syncResearchIndicator(false);
}
if (!options.silent && uiModule?.showToast) {
uiModule.showToast(on ? 'Plan mode on' : 'Plan mode off', 1600);
}
}
function applyModeToToggles(mode) {
MODE_TOOLS.forEach(({ btnId, checkboxId, stateKey }) => {
const btn = el(btnId);
@@ -1806,6 +1835,60 @@ function initializeEventListeners() {
setMode(currentMode);
})();
(function initPlanToggle() {
const btn = el('plan-toggle-btn');
const state = loadToggleState();
syncPlanToggle(!!state.plan_mode);
window.__odysseusSetPlanMode = (active) => setPlanMode(active, { silent: true });
const statusToggle = el('plan-mode-status-toggle');
if (btn) {
btn.addEventListener('click', () => {
const st = loadToggleState();
setPlanMode(!st.plan_mode);
});
}
if (statusToggle) {
statusToggle.addEventListener('click', () => setPlanMode(false));
}
const msgInput = el('message');
if (msgInput && !msgInput._odysseusPlanTabToggle) {
msgInput._odysseusPlanTabToggle = true;
msgInput.addEventListener('keydown', (e) => {
if (e.key !== 'Tab' || e.shiftKey || e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return;
e.preventDefault();
e.stopPropagation();
const st = loadToggleState();
setPlanMode(!st.plan_mode);
});
}
const chatBar = document.querySelector('.chat-input-bar');
if (chatBar && !chatBar._odysseusPlanSwipeToggle) {
chatBar._odysseusPlanSwipeToggle = true;
let touchStartX = 0;
let touchStartY = 0;
let touchStartAt = 0;
chatBar.addEventListener('touchstart', (e) => {
if (!e.touches || e.touches.length !== 1) return;
const t = e.touches[0];
touchStartX = t.clientX;
touchStartY = t.clientY;
touchStartAt = Date.now();
}, { passive: true });
chatBar.addEventListener('touchend', (e) => {
if (!touchStartAt || !e.changedTouches || e.changedTouches.length !== 1) return;
if (!window.matchMedia || !window.matchMedia('(max-width: 768px)').matches) return;
const t = e.changedTouches[0];
const dx = t.clientX - touchStartX;
const dy = t.clientY - touchStartY;
const dt = Date.now() - touchStartAt;
touchStartAt = 0;
if (dt > 700 || Math.abs(dx) < 56 || Math.abs(dy) > 28 || Math.abs(dx) < Math.abs(dy) * 2) return;
const st = loadToggleState();
setPlanMode(!st.plan_mode);
}, { passive: true });
}
})();
// ── Tool splash explainer messages (shown first 2 times per tool) ──
const SPLASH_COUNT_KEY = 'odysseus-tool-splash-counts';
const SPLASH_MAX = 2;
@@ -2305,18 +2388,40 @@ function initializeEventListeners() {
const textarea = el('message');
const inputBottom = document.querySelector('.chat-input-bottom');
const _isMobile = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
let _placeholderHintOn = false;
function setComposerPlaceholder(width) {
if (!textarea) return;
if (_isMobile && _placeholderHintOn) {
textarea.setAttribute('placeholder', 'Swipe to toggle plan');
return;
}
textarea.setAttribute('placeholder', width < PLACEHOLDER_COMPACT_WIDTH ? 'Message...' : 'Message Odysseus...');
}
if (_isMobile && textarea && !textarea._odysseusPlanPlaceholderHint) {
textarea._odysseusPlanPlaceholderHint = true;
setInterval(() => {
_placeholderHintOn = !_placeholderHintOn;
setComposerPlaceholder(inputTop.clientWidth || window.innerWidth || 0);
}, 5000);
}
function checkPickerOverflow() {
// Skip responsive collapse on mobile — keyboard open/close causes flicker
if (_isMobile) return;
const w = inputTop.clientWidth;
const w = inputTop.clientWidth || window.innerWidth || 0;
const hasText = !!(textarea && textarea.value && textarea.value.trim());
if (_isMobile) {
// Mobile has much less horizontal room: any typed text should get
// the full composer row, matching plan-mode's collision behavior.
pickerWrap.classList.toggle('picker-auto-hidden', hasText);
setComposerPlaceholder(w);
return;
}
// Hide model picker
pickerWrap.classList.toggle('picker-auto-hidden', w < PICKER_HIDE_WIDTH);
// Keep a prompt inside the composer even when the picker crowds the row.
// A blank placeholder makes the mobile/compact empty state feel broken.
if (textarea) {
textarea.setAttribute('placeholder', w < PLACEHOLDER_COMPACT_WIDTH ? 'Message...' : 'Message Odysseus...');
}
setComposerPlaceholder(w);
// Hide entire bottom toolbar (tools, mode toggle) — only send button remains
if (inputBottom) {
inputBottom.classList.toggle('toolbar-auto-hidden', w < TOOLBAR_HIDE_WIDTH);
@@ -2482,6 +2587,11 @@ function initializeEventListeners() {
incognitoBtn.title = chk.checked ? 'Disable Nobody mode' : 'Enable Nobody mode — no memory, no history saved';
const welcomeName = document.querySelector('.welcome-name');
if (chk.checked) {
try {
if (sessionModule && sessionModule.setCurrentSessionId) sessionModule.setCurrentSessionId(null);
const box = el('chat-history');
if (box) box.innerHTML = '';
} catch (_) {}
incognitoBtn.innerHTML = INCOGNITO_EYE_CLOSED + '<span class="incognito-label">Nobody</span>';
if (welcomeName) {
welcomeName.dataset.originalHtml = welcomeName.innerHTML;
@@ -2607,7 +2717,6 @@ function initializeEventListeners() {
'sidebar-search': '#sidebar-search-btn',
'sessions-section': '#sessions-section',
'email-section': '#email-section',
'models-section': '#models-section',
'tools-section': '#tools-section',
// Per-tool visibility — fine-grained control over which entries show
// inside the Tools section in the sidebar.
@@ -2639,7 +2748,7 @@ function initializeEventListeners() {
};
// Keys hidden by default on first run (no localStorage yet)
const UI_VIS_DEFAULT_OFF = new Set(['models-section', 'rag-toggle-btn', 'text-emojis', 'chat-fullwidth']);
const UI_VIS_DEFAULT_OFF = new Set(['rag-toggle-btn', 'text-emojis', 'chat-fullwidth']);
// Keys that need admin to toggle off (reserved for future use)
const UI_VIS_ADMIN_ONLY = new Set([]);
@@ -3160,7 +3269,7 @@ function initializeEventListeners() {
['.memory-tabs', '.memory-tab'],
['.admin-tabs', '.admin-tab'],
];
const _IGNORE = 'input, textarea, select, [contenteditable="true"], .preset-range, ' +
const _IGNORE = '.chat-input-bar, input, textarea, select, [contenteditable="true"], .preset-range, ' +
'.note-cl-row, .minimized-dock-chip, canvas, .email-card-reader';
let sx = 0, sy = 0, tracking = false;
@@ -3576,6 +3685,7 @@ function initializeEventListeners() {
// INITIALIZATION ON PAGE LOAD
// ============================================
function startOdysseusApp() {
tasksModule?.startNotificationPolling?.();
if (window.__odysseusAppStarted) return;
window.__odysseusAppStarted = true;
const _bumpChatPriority = (ms = 10000) => {
@@ -3787,6 +3897,7 @@ function startOdysseusApp() {
return;
}
chatRenderer.hideWelcomeScreen();
return originalSubmit.call(chatModule, e);
}
@@ -3797,6 +3908,86 @@ function startOdysseusApp() {
const messageInput = el('message');
const modelPickerWrap = document.getElementById('model-picker-wrap');
function _readComposerPromptHistory() {
const chatBox = document.getElementById('chat-history');
if (!chatBox) return [];
return Array.from(chatBox.querySelectorAll('.msg-user'))
.reverse()
.map(msg => {
const body = msg.querySelector('.body');
return msg.dataset?.raw || (body ? body.textContent : '') || '';
})
.filter(Boolean);
}
if (messageInput && !messageInput._odysseusPromptRecallCapture) {
messageInput._odysseusPromptRecallCapture = true;
let recallHistory = [];
let recallIndex = -1;
let lastRecalled = '';
const norm = (v) => String(v || '').replace(/\r\n/g, '\n').trimEnd();
messageInput.addEventListener('input', () => {
if (norm(messageInput.value) === norm(lastRecalled)) return;
recallHistory = [];
recallIndex = -1;
lastRecalled = '';
try { delete messageInput.dataset.odysseusRecallIndex; } catch {}
}, true);
messageInput.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return;
if (window._ghostAutocomplete?.isActive?.()) return;
const fresh = _readComposerPromptHistory();
const history = fresh.length ? fresh : recallHistory;
if (!history.length) return;
const current = norm(messageInput.value);
let currentIndex = current ? history.findIndex(item => norm(item) === current) : -1;
if (current && currentIndex < 0 && current === norm(lastRecalled)) currentIndex = recallIndex;
if (current && currentIndex < 0) {
const markedIndex = Number(messageInput.dataset.odysseusRecallIndex);
if (Number.isInteger(markedIndex) && markedIndex >= 0 && markedIndex < history.length) {
currentIndex = markedIndex;
}
}
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
if (e.key === 'ArrowDown') {
if (currentIndex < 0) return;
const nextIndex = currentIndex - 1;
if (nextIndex < 0) {
recallHistory = history;
recallIndex = -1;
lastRecalled = '';
try { delete messageInput.dataset.odysseusRecallIndex; } catch {}
messageInput.value = '';
try { messageInput.selectionStart = messageInput.selectionEnd = 0; } catch {}
try { uiModule.autoResize(messageInput); } catch {}
return;
}
const recalled = history[nextIndex];
recallHistory = history;
recallIndex = nextIndex;
lastRecalled = recalled;
try { messageInput.dataset.odysseusRecallIndex = String(nextIndex); } catch {}
messageInput.value = recalled;
try { messageInput.selectionStart = messageInput.selectionEnd = recalled.length; } catch {}
try { uiModule.autoResize(messageInput); } catch {}
return;
}
const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, history.length - 1) : 0;
const recalled = history[nextIndex];
if (!recalled) return;
recallHistory = history;
recallIndex = nextIndex;
lastRecalled = recalled;
try { messageInput.dataset.odysseusRecallIndex = String(nextIndex); } catch {}
messageInput.value = recalled;
try { messageInput.selectionStart = messageInput.selectionEnd = recalled.length; } catch {}
try { uiModule.autoResize(messageInput); } catch {}
}, true);
}
const _sendIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>';
const _micIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
const _stopIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
@@ -4006,12 +4197,17 @@ function startOdysseusApp() {
// Toggle mic/send icon on input change + hide model picker after enough text
if (messageInput) {
const _debouncedUpdateIcon = uiModule.debounce(_updateSendBtnIcon, 50);
const _MODEL_PICKER_HIDE_CHARS = 10;
const _MODEL_PICKER_HIDE_CHARS = 23;
const _syncModelPickerAutohide = () => {
const hidePicker = (messageInput.value || '').replace(/\s/g, '').length >= _MODEL_PICKER_HIDE_CHARS;
const compactMobile = _isMobileChatInput() && !!(messageInput.value || '').trim();
const hidePicker = compactMobile || (messageInput.value || '').replace(/\s/g, '').length >= _MODEL_PICKER_HIDE_CHARS;
if (modelPickerWrap) {
modelPickerWrap.classList.toggle('model-picker-autohide', hidePicker);
}
const planStatus = el('plan-mode-status');
if (planStatus) {
planStatus.classList.toggle('plan-mode-status-autohide', hidePicker);
}
};
window._syncModelPickerAutohide = _syncModelPickerAutohide;
_syncModelPickerAutohide();
@@ -4238,11 +4434,9 @@ function startOdysseusApp() {
// Non-critical startup work must not compete with first paint, chat send, or
// chat switching. Panels load their own data when opened; these are only warmups.
_syncWelcomeModelHint().catch(() => {});
runNonCriticalStartup(() => {
modelsModule.refreshModels(false).then(() => {
try { sessionModule.updateModelPicker(); } catch (_) {}
_syncWelcomeModelHint().catch(() => {});
}).catch(() => {});
}, 3500);
runNonCriticalStartup(() => modelsModule.refreshProviders(), 6500);
@@ -4253,6 +4447,104 @@ function startOdysseusApp() {
voiceRecorderModule.init();
if (censorModule) censorModule.init();
// ── Mobile pull-to-refresh for the active chat ──
(function initMobileChatPullRefresh() {
const historyEl = document.getElementById('chat-history');
const container = document.getElementById('chat-container');
if (!historyEl || !container || !('ontouchstart' in window || navigator.maxTouchPoints > 0)) return;
const THRESHOLD = 72;
const MAX_PULL = 104;
let startY = 0;
let pullY = 0;
let tracking = false;
let refreshing = false;
let spinner = null;
const indicator = document.createElement('div');
indicator.className = 'chat-pull-refresh';
indicator.setAttribute('aria-hidden', 'true');
indicator.innerHTML = '<div class="chat-pull-refresh-spinner"></div>';
container.prepend(indicator);
const spinnerMount = indicator.querySelector('.chat-pull-refresh-spinner');
try {
spinner = spinnerModule.createWhirlpool(18);
spinnerMount.replaceChildren(spinner.element);
} catch (_) {}
function setPull(px, active = false) {
pullY = Math.max(0, Math.min(MAX_PULL, px));
const pct = Math.min(1, pullY / THRESHOLD);
indicator.style.setProperty('--pull-refresh-y', `${pullY}px`);
indicator.style.setProperty('--pull-refresh-progress', `${pct}`);
indicator.classList.toggle('is-visible', active || refreshing || pullY > 2);
indicator.classList.toggle('is-ready', pct >= 1 && !refreshing);
indicator.classList.toggle('is-refreshing', refreshing);
}
async function runRefresh() {
if (refreshing) return;
if (_isForegroundChatBusy()) {
setPull(0, false);
return;
}
refreshing = true;
setPull(THRESHOLD, true);
const safetyTimer = setTimeout(() => {
refreshing = false;
setPull(0, false);
}, 8000);
try {
const sid = sessionModule && sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId();
if (sid && sessionModule.selectSession) {
await sessionModule.selectSession(sid, { keepSidebar: true, showLoading: false, immediateLoading: true });
} else if (sessionModule && sessionModule.loadSessions) {
await sessionModule.loadSessions();
}
} catch (err) {
console.warn('pull refresh failed:', err);
} finally {
clearTimeout(safetyTimer);
refreshing = false;
setPull(0, false);
}
}
historyEl.addEventListener('touchstart', (e) => {
if (refreshing || window.innerWidth > 768) return;
if (document.querySelector('.modal:not(.hidden)')) return;
if (historyEl.scrollTop > 0) return;
if (e.target && e.target.closest && e.target.closest('.chat-input-bar, textarea, input, button, select, a')) return;
tracking = true;
startY = e.touches[0].clientY;
setPull(0, false);
}, { passive: true });
historyEl.addEventListener('touchmove', (e) => {
if (!tracking || refreshing) return;
const dy = e.touches[0].clientY - startY;
if (dy <= 0) {
setPull(0, false);
return;
}
if (historyEl.scrollTop <= 0) {
e.preventDefault();
setPull(dy * 0.62, true);
}
}, { passive: false });
historyEl.addEventListener('touchend', () => {
if (!tracking) return;
tracking = false;
if (pullY >= THRESHOLD) runRefresh();
else setPull(0, false);
}, { passive: true });
historyEl.addEventListener('touchcancel', () => {
tracking = false;
if (!refreshing) setPull(0, false);
}, { passive: true });
})();
// Auto-focus message input on load
const msgEl = document.getElementById('message');
if (msgEl) msgEl.focus();
+117 -71
View File
@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content, viewport-fit=cover" />
<title>Odysseus Chat</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M16 4L16 22L6 22Z' fill='%23e06c75'/%3E%3Cpath d='M16 8L16 22L24 22Z' fill='%23e06c75' opacity='0.6'/%3E%3Cpath d='M4 24Q10 20 16 24Q22 28 28 24' stroke='%23e06c75' stroke-width='2.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
<link rel="manifest" href="/static/manifest.json">
@@ -90,6 +91,18 @@
var _us = localStorage.getItem('odysseus-ui-scale');
if (_us && _us !== '100') document.documentElement.classList.add('ui-scale-' + _us);
} catch(e){}
// Restore the sidebar mode before first paint. Otherwise the icon rail
// and full sidebar can both flash visible until sidebar-layout.js runs.
try {
var _mobileStartup = window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
if (_mobileStartup) {
document.documentElement.classList.add('ody-sidebar-off', 'ody-mobile-startup-sidebar-hidden');
} else {
var _sm = localStorage.getItem('odysseus-sidebar-mode') || 'full';
if (_sm === 'mini') document.documentElement.classList.add('ody-sidebar-mini');
else if (_sm === 'off') document.documentElement.classList.add('ody-sidebar-off');
}
} catch(e){}
// Apply background pattern on body once available
if (t && t.bgPattern && t.bgPattern !== 'none') {
document.addEventListener('DOMContentLoaded', function() {
@@ -201,6 +214,22 @@
@font-face { font-family: 'Inter'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-weight: 500; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-Medium.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-SemiBold.woff2') format('woff2'); }
@media (max-width: 768px) {
html.ody-sidebar-off .sidebar,
html.ody-mobile-startup-sidebar-hidden .sidebar {
transform: translateX(-100%) !important;
pointer-events: none !important;
opacity: 0 !important;
}
html.ody-sidebar-off .sidebar.right-side,
html.ody-mobile-startup-sidebar-hidden .sidebar.right-side {
transform: translateX(100%) !important;
}
html.ody-sidebar-off .icon-rail,
html.ody-mobile-startup-sidebar-hidden .icon-rail {
display: none !important;
}
}
</style>
<!-- KaTeX CSS is loaded with media="print" so it doesn't block render,
then flipped to "all" via JS after load. Mermaid init runs once the
@@ -219,29 +248,43 @@
}, { once: true });
})();
</script>
<link rel="stylesheet" href="/static/style.css?v=20260630mdfontsize">
<link rel="modulepreload" href="/static/app.js?v=20260630mdfontsize">
<link rel="modulepreload" href="/static/js/chat.js">
<link rel="stylesheet" href="/static/style.css?v=20260723tasksbulkfeedback1">
<link rel="modulepreload" href="/static/app.js?v=20260723tasksbulkfeedback1">
<link rel="modulepreload" href="/static/js/chat.js?v=20260722ctxheader4">
<link rel="modulepreload" href="/static/js/ui.js">
<link rel="modulepreload" href="/static/js/sessions.js">
<link rel="modulepreload" href="/static/js/sessions.js?v=20260722ctxheader4">
<link rel="modulepreload" href="/static/js/markdown.js">
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content, viewport-fit=cover" />
</head>
<body>
<!-- Loading overlay — hides all flashing until app is ready -->
<div id="app-loader" style="position:fixed;inset:0;z-index:99999;background:var(--bg,#282c34);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:8px;transition:opacity .3s">
<div id="loader-wave" style="color:var(--brand-color,var(--red,#e06c75));font-family:monospace;font-size:11px;opacity:.5">▁▂▃</div>
<div id="loader-wave" style="color:color-mix(in srgb, #9cdef2 62%, var(--brand-color,var(--red,#e06c75)));font-family:monospace;font-size:11px;opacity:.5;position:relative;padding-top:10px;line-height:1;white-space:pre">▁▂▃</div>
</div>
<script nonce="{{CSP_NONCE}}">
(function(){
var el=document.getElementById('loader-wave');
if(!el)return;
var frames=['▁▂▃','▂▃▄','▃▄▅','▄▅▆','▅▆▅','▆▅▄','▅▄▃','▄▃▂','▃▂▁'];
var frames=[
{wave:'▁▂▃',y:9},
{wave:'▂▃▄',y:7},
{wave:'▃▄▅',y:5},
{wave:'▄▅▆',y:3},
{wave:'▅▆▅',y:1},
{wave:'▆▅▄',y:3},
{wave:'▅▄▃',y:5},
{wave:'▄▃▂',y:7},
{wave:'▃▂▁',y:9}
];
var i=0;
function render(){
var f=frames[i%frames.length];
el.innerHTML='<span style="position:absolute;left:50%;top:0;transform:translate(-50%,'+f.y+'px);font-size:1.24em;line-height:1"></span><span>'+f.wave+'</span>';
i++;
}
render();
var iv=setInterval(function(){
if(!document.getElementById('app-loader')){clearInterval(iv);return}
el.textContent=frames[i%frames.length];
i++;
render();
},150);
setTimeout(function(){var l=document.getElementById('app-loader');if(l){l.style.opacity='0';setTimeout(function(){l.remove()},300)}},5000);
})();
@@ -474,11 +517,11 @@
<!-- Tab: Browse themes -->
<div id="theme-tab-browse" class="theme-tab-panel">
<div class="admin-card">
<h2>Default Themes</h2>
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;opacity:0.6"><circle cx="12" cy="12" r="10"/><path d="M12 2a7 7 0 0 0 0 20 4 4 0 0 1 0-8 4 4 0 0 0 0-8"/></svg>Default Themes</h2>
<div class="theme-grid" id="themeGrid"></div>
</div>
<div class="admin-card" id="themeUserCard" style="display:none">
<h2>Your Themes</h2>
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;opacity:0.6"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Your Themes</h2>
<div class="theme-grid" id="themeUserGrid"></div>
</div>
</div>
@@ -486,7 +529,7 @@
<!-- Tab: Customize -->
<div id="theme-tab-customize" class="theme-tab-panel" style="display:none">
<div class="admin-card">
<h2>Colors</h2>
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;opacity:0.6"><path d="M9.06 11.9l-3.5 3.5a2.85 2.85 0 1 0 4.03 4.03l8.49-8.49a4.5 4.5 0 1 0-6.36-6.36L3.18 12.62"/><path d="M14 7l3 3"/></svg>Colors</h2>
<div class="theme-custom" id="themeCustom">
<div class="color-row"><label>Background</label><input type="color" id="clr-bg"><button class="color-reset-btn" data-reset="bg" title="Reset this color" aria-label="Reset color">&#x21BA;</button></div>
<div class="color-row"><label>Text</label><input type="color" id="clr-fg"><button class="color-reset-btn" data-reset="fg" title="Reset this color" aria-label="Reset color">&#x21BA;</button></div>
@@ -579,7 +622,7 @@
<div id="harmony-preview" class="harmony-preview"></div>
</div>
<div class="admin-card">
<h2>Font & Layout</h2>
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;opacity:0.6"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>Font & Layout</h2>
<div class="theme-fd-row">
<div class="theme-fd-group">
<label class="theme-fd-label">Font</label>
@@ -648,7 +691,7 @@
</div>
</div>
<div class="admin-card" style="margin-top:8px;">
<h2>Save / Share</h2>
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;opacity:0.6"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save / Share</h2>
<div class="theme-save-row" id="theme-save-row">
<input type="text" id="theme-save-name" placeholder="Theme name..." maxlength="32">
<button id="theme-save-go">Save</button>
@@ -686,7 +729,7 @@
<button class="icon-rail-btn rail-dynamic" id="rail-documents" title="Documents" style="display:none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg></button>
<!-- Tool launchers — always visible, alphabetical -->
<button class="icon-rail-btn" id="rail-calendar" title="Calendar"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
<button class="icon-rail-btn" id="rail-compare" title="Compare"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></svg></button>
<button class="icon-rail-btn" id="rail-compare" title="Compare"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="16" rx="1.5"/><rect x="14" y="4" width="7" height="16" rx="1.5"/><path d="M10 8h4"/><path d="M10 16h4"/></svg></button>
<button class="icon-rail-btn" id="rail-cookbook" title="Cookbook"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg></button>
<button class="icon-rail-btn" id="rail-research" title="Deep Research"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg></button>
<button class="icon-rail-btn" id="rail-email" title="Email"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg></button>
@@ -803,35 +846,6 @@
</div>
</div>
<div class="section" id="models-section">
<div class="section-header-flex">
<span class="section-title"> Models</span>
<div style="position:relative; display:inline-block;">
<button type="button" class="section-header-btn" id="model-sort-btn" title="Sort models">
<svg class="sort-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" y1="6" x2="20" y2="6"/>
<line x1="4" y1="12" x2="14" y2="12"/>
<line x1="4" y1="18" x2="9" y2="18"/>
</svg>
</button>
<div id="model-sort-dropdown" class="dropdown sort-dropdown" style="display:none;">
<div class="dropdown-item sort-option sort-dropdown-item" data-sort="alpha">A-Z</div>
<div class="dropdown-item sort-option sort-dropdown-item" data-sort="last-used">Last used</div>
<div class="dropdown-item sort-option sort-dropdown-item" data-sort="most-used">Most used</div>
<div class="dropdown-item rearrange-toggle sort-dropdown-item sort-dropdown-sep" id="model-rearrange-toggle">
&#8593;&#8595; Rearrange <span class="rearrange-check" style="float:right; opacity:0;">&#x2022;</span>
</div>
</div>
</div>
</div>
<div id="models">
<div class="models-row">
<select id="model-select" aria-label="Select model" style="flex: 1; padding: 6px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg);"></select>
<button type="button" id="btn-model-chat" class="model-chat-btn" aria-label="Add model chat" style="transition: all 0.2s ease;"><span class="model-chat-btn-label">+ Chat</span></button>
</div>
</div>
</div>
<div class="section" id="tools-section">
<div class="section-header-flex">
<span class="section-title"><svg class="section-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>Tools</span>
@@ -858,7 +872,7 @@
<span class="grow">Calendar</span>
</div>
<div class="list-item" id="tool-compare-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.5;"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></svg>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.5;"><rect x="3" y="4" width="7" height="16" rx="1.5"/><rect x="14" y="4" width="7" height="16" rx="1.5"/><path d="M10 8h4"/><path d="M10 16h4"/></svg>
<span class="grow">Compare</span>
</div>
<div class="list-item" id="tool-cookbook-btn">
@@ -955,10 +969,10 @@
<h1 class="a11y-visually-hidden">Odysseus</h1>
<div class="chat-top-bar">
<button type="button" class="incognito-indicator" id="incognito-indicator" title="Nobody mode active — click to deactivate" style="display:none;"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg></button>
<div class="chat-meta-overlay"><span id="current-meta">Odysseus Chat</span><span id="current-meta-count" class="chat-meta-count" aria-hidden="true"></span><span id="session-cost-display" class="session-cost-display" style="display:none;"></span><span class="export-dropdown-wrap" id="export-dropdown-wrap"><button type="button" class="export-dl-btn" id="export-dl-btn" title="More"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><div class="export-dropdown-menu" id="export-dropdown-menu"><div class="export-dropdown-item" id="export-rename-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></span><span>Rename</span></div><div class="export-dropdown-item" id="export-copy-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></span><span>Copy Chat</span></div><div class="export-dropdown-item" id="export-pdf-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 15v-2h2a1.5 1.5 0 0 1 0 3H9z"/></svg></span><span>PDF</span></div><div class="export-dropdown-item" id="export-doc-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></span><span>Save to Documents</span></div></div></span></div> </div>
<div class="chat-meta-overlay"><span id="current-meta">Odysseus Chat</span><span id="current-meta-count" class="chat-meta-count" aria-hidden="true"></span><span id="session-cost-display" class="session-cost-display" style="display:none;"></span><button type="button" class="chat-context-pill" id="chat-context-pill" title="Chat context" hidden><span class="chat-context-dot"></span><span id="chat-context-pill-label">0%</span></button><span class="export-dropdown-wrap" id="export-dropdown-wrap"><button type="button" class="export-dl-btn" id="export-dl-btn" title="More"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><div class="export-dropdown-menu" id="export-dropdown-menu"><div class="export-dropdown-item" id="export-rename-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></span><span>Rename</span></div><div class="export-dropdown-item" id="export-compact-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"/><path d="M7 12h10"/><path d="M10 17h4"/></svg></span><span class="export-compact-pill">Compact</span></div><div class="export-dropdown-item" id="export-copy-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></span><span>Copy Chat</span></div><div class="export-dropdown-item" id="export-pdf-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 15v-2h2a1.5 1.5 0 0 1 0 3H9z"/></svg></span><span>PDF</span></div><div class="export-dropdown-item" id="export-doc-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></span><span>Save to Documents</span></div><div class="export-dropdown-item dropdown-item-danger" id="export-delete-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg></span><span>Delete Chat</span></div></div></span></div> </div>
<div id="welcome-screen">
<div class="welcome-name"><svg class="welcome-boat" viewBox="0 0 32 32"><path d="M16 4L16 22L6 22Z" fill="currentColor"/><path d="M16 8L16 22L24 22Z" fill="currentColor" opacity="0.6"/><path d="M4 24Q10 20 16 24Q22 28 28 24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg>Odysseus</div>
<div class="welcome-sub" id="welcome-sub">Welcome, <span class="setup-trigger-link" style="color:var(--accent,var(--red));font-weight:600;cursor:pointer;text-decoration:underline;" title="Click to launch setup">type /setup</span> to get started.</div>
<div class="welcome-sub" id="welcome-sub">New chat ready.</div>
<div class="welcome-tip" id="welcome-tip"></div>
<button type="button" class="incognito-btn" id="incognito-btn" title="Enable Nobody mode — no memory, no history saved">
<svg class="eye-open" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -991,7 +1005,7 @@
var tips = mobile ? phone : desktop;
var el = document.getElementById('welcome-tip');
if (el) {
el.textContent = 'Type /setup, then choose Local models or API.';
el.textContent = 'Pick a model if you want, or just type.';
}
fetch('/api/version').then(function(r){return r.json()}).then(function(d){
if (d.version) window._appVersion = d.version;
@@ -1014,6 +1028,12 @@
<div class="chat-input-top">
<div id="message-ghost" class="ghost-text-overlay" aria-hidden="true"></div>
<textarea id="message" placeholder="Message Odysseus..." required autocomplete="off" aria-label="Message input" rows="1" autofocus></textarea>
<div id="plan-mode-status" class="plan-mode-status" hidden>
<span class="plan-mode-status-label">Plan mode</span>
<button type="button" id="plan-mode-status-toggle" class="plan-mode-status-toggle" aria-label="Turn off plan mode" aria-pressed="true">
<span></span>
</button>
</div>
<!-- Model picker (inside chatbox, top-right) -->
<div class="model-picker-wrap" id="model-picker-wrap">
<button type="button" class="model-picker-btn" id="model-picker-btn" title="Switch model"><span id="model-picker-label">Select model</span> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 15 12 9 18 15"/></svg></button>
@@ -1032,6 +1052,30 @@
<div class="model-picker-list" id="model-picker-list"></div>
</div>
</div>
<script nonce="{{CSP_NONCE}}">
(function(){
function applyDefault(dc) {
if (!dc || !dc.model) return false;
window.__odysseusDefaultChat = dc;
var label = document.getElementById('model-picker-label');
if (!label) return false;
label.textContent = String(dc.model).split('/').pop();
label.title = dc.model;
return true;
}
try {
var dc = JSON.parse(localStorage.getItem('odysseus-default-chat-cache') || 'null');
if (applyDefault(dc)) return;
} catch (_) {}
fetch('/api/default-chat', { credentials: 'same-origin' })
.then(function(r){ return r.ok ? r.json() : null; })
.then(function(dc){
if (!applyDefault(dc)) return;
try { localStorage.setItem('odysseus-default-chat-cache', JSON.stringify(dc)); } catch (_) {}
})
.catch(function(){});
})();
</script>
</div>
<div id="pinned-tools-bar"></div>
<div class="chat-input-bottom" style="visibility:hidden">
@@ -1102,7 +1146,7 @@
</button>
<!-- Workspace indicator (hidden until a folder is set) -->
<button type="button" class="input-icon-btn tool-indicator" title="Workspace - click to clear" id="workspace-indicator-btn" aria-label="Clear workspace" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
<svg class="workspace-indicator-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
<span style="font-size:11px;margin-left:2px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" id="workspace-indicator-name"></span>
<svg class="tool-indicator-x" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
</button>
@@ -1135,7 +1179,7 @@
</button>
<!-- Compare toolbar indicator (hidden until active) -->
<button type="button" class="input-icon-btn tool-indicator" title="Compare active — click to deactivate" id="compare-indicator-btn" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="16" rx="1.5"/><rect x="14" y="4" width="7" height="16" rx="1.5"/><path d="M10 8h4"/><path d="M10 16h4"/></svg>
<span style="font-size:11px;margin-left:2px;">Compare</span>
<svg class="tool-indicator-x" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
</button>
@@ -1154,6 +1198,7 @@
<!-- Hidden checkboxes for state -->
<input type="checkbox" id="web-toggle" style="display:none;">
<input type="checkbox" id="bash-toggle" style="display:none;">
<input type="checkbox" id="plan-toggle" style="display:none;">
</div>
<form id="chat-form" autocomplete="off" action="javascript:void(0);" style="display:none;"></form>
@@ -1748,11 +1793,6 @@
<span class="vis-label">Email</span>
<input type="checkbox" checked data-ui-key="email-section"><span class="vis-switch"></span>
</label>
<label class="vis-row">
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></span>
<span class="vis-label">Models <span class="vis-hint">Model selector &amp; quick-chat</span></span>
<input type="checkbox" checked data-ui-key="models-section"><span class="vis-switch"></span>
</label>
<label class="vis-row">
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg></span>
<span class="vis-label">Tools <span class="vis-hint">Whole section (header + all tools)</span></span>
@@ -1769,7 +1809,7 @@
<input type="checkbox" checked data-ui-key="tool-calendar"><span class="vis-switch"></span>
</label>
<label class="vis-row">
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></svg></span>
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="16" rx="1.5"/><rect x="14" y="4" width="7" height="16" rx="1.5"/><path d="M10 8h4"/><path d="M10 16h4"/></svg></span>
<span class="vis-label">Compare</span>
<input type="checkbox" checked data-ui-key="tool-compare"><span class="vis-switch"></span>
</label>
@@ -1963,6 +2003,14 @@
<!-- ═══ EMAIL TAB ═══ -->
<div data-settings-panel="email" class="hidden">
<div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06A1.65 1.65 0 0 0 8.92 4a1.65 1.65 0 0 0 1-1.51V2a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg>Email Settings</h2>
<div class="settings-row" style="align-items:center;">
<div class="admin-toggle-sub" style="margin:0;flex:1;">Auto reply, newsletter unsubscribe, and writing style live in the Email window.</div>
<button class="admin-btn-add" id="set-email-open-library-settings" style="display:inline-flex;align-items:center;gap:6px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.7"><path d="M9 18l6-6-6-6"/></svg>Open Email Settings</button>
</div>
</div>
<div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>Email Accounts</h2>
<div class="settings-row" style="align-items:center;">
@@ -2236,7 +2284,7 @@
<div class="adm-ep-section-head" style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;opacity:0.7;margin-bottom:6px;display:inline-flex;align-items:center;gap:5px;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>Local
</div>
<div id="adm-epList-local"><div class="admin-empty">Loading...</div></div>
<div id="adm-epList-local"><div class="admin-empty">No local endpoints yet.</div></div>
</div>
<div class="adm-ep-section" style="margin-top:18px;">
<div class="adm-ep-section-head" style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;opacity:0.7;margin-bottom:6px;display:inline-flex;align-items:center;gap:5px;">
@@ -2354,7 +2402,7 @@
<div id="adm-backupMsg" style="margin-top:6px;"></div>
</div>
<div class="admin-card admin-danger-card">
<h2 style="color:#e55;">Danger Zone</h2>
<h2 style="color:#e55;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.75"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>Danger Zone</h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">Irreversible. Each wipe targets one category — pick exactly what you want gone.</div>
<div style="display:flex;justify-content:space-between;align-items:center;">
@@ -2456,36 +2504,34 @@
<script type="module" src="/static/js/ui.js"></script>
<script type="module" src="/static/js/markdown.js"></script>
<script type="module" src="/static/js/dragSort.js"></script>
<script type="module" src="/static/js/sessions.js"></script>
<script type="module" src="/static/js/memory.js"></script>
<script type="module" src="/static/js/sessions.js?v=20260722ctxheader4"></script>
<script type="module" src="/static/js/memory.js?v=20260722memoryloading1"></script>
<script type="module" src="/static/js/skills.js"></script>
<script type="module" src="/static/js/tourHints.js"></script>
<script type="module" src="/static/js/tourAutoplay.js"></script>
<script type="module" src="/static/js/fileHandler.js"></script>
<script type="module" src="/static/js/voiceRecorder.js"></script>
<script type="module" src="/static/js/models.js"></script> <!-- This must come BEFORE app.js -->
<script type="module" src="/static/js/models.js?v=20260715startupcalm2"></script> <!-- This must come BEFORE app.js -->
<script type="module" src="/static/js/rag.js"></script>
<script type="module" src="/static/js/presets.js"></script>
<script type="module" src="/static/js/search.js"></script>
<script type="module" src="/static/js/spinner.js"></script>
<script type="module" src="/static/js/tts-ai.js"></script>
<script type="module" src="/static/js/document.js"></script>
<script type="module" src="/static/js/gallery.js"></script>
<script type="module" src="/static/js/chatRenderer.js?v=20260630toolmetrics"></script>
<script type="module" src="/static/js/document.js?v=20260722emailfastindex1"></script>
<script type="module" src="/static/js/gallery.js?v=20260708match1"></script>
<script type="module" src="/static/js/chatRenderer.js?v=20260722emailfastindex1"></script>
<script type="module" src="/static/js/codeRunner.js"></script>
<script type="module" src="/static/js/chatStream.js"></script>
<script type="module" src="/static/js/chat.js?v=20260630toolmetrics"></script>
<script type="module" src="/static/js/chatStream.js?v=20260722emailfastindex1"></script>
<script type="module" src="/static/js/chat.js?v=20260722ctxheader4"></script>
<script type="module" src="/static/js/cookbook.js"></script>
<script src="/static/js/cookbookSchedule.js"></script>
<script type="module" src="/static/js/search-chat.js"></script>
<script type="module" src="/static/js/compare/index.js"></script>
<script type="module" src="/static/js/theme.js"></script>
<script type="module" src="/static/js/censor.js"></script>
<script type="module" src="/static/js/settings.js"></script>
<script type="module" src="/static/js/admin.js"></script>
<script type="module" src="/static/js/settings.js?v=20260723compareicon1"></script>
<script type="module" src="/static/js/assistant.js"></script>
<script type="module" src="/static/app.js?v=20260630mdfontsize"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/js/init.js"></script>
<script type="module" src="/static/app.js?v=20260723tasksbulkfeedback1"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/js/init.js?v=20260715freshroot3"></script>
<script type="module" src="/static/js/a11y.js"></script>
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js').catch(()=>{});}</script>
</body>
+44 -23
View File
@@ -466,21 +466,22 @@ async function _selectAddedModelInChat(endpoint) {
async function loadEndpoints() {
const listLocal = el('adm-epList-local');
const listApi = el('adm-epList-api');
// Fallback to the legacy single list if the split containers don't exist
// (older HTML or third-party embedding).
const listLegacy = el('adm-epList');
// Refresh model picker so new endpoints show up in chat
if (window.modelsModule && window.modelsModule.refreshModels) {
window.modelsModule.refreshModels(true);
// Render endpoint rows first. Do not make Added Models wait on /api/models or
// endpoint probes; explicit Refresh/Probe actions do that work.
const refreshDependentModelUi = (force = false) => {
setTimeout(() => {
if (window.modelsModule && window.modelsModule.refreshModels) {
window.modelsModule.refreshModels(!!force, force ? {} : { cacheOnly: true }).then(() => {
if (window.sessionModule && window.sessionModule.updateModelPicker) {
window.sessionModule.updateModelPicker();
}
}, 1500);
}).catch(() => {});
}
if (settingsModule && typeof settingsModule.refreshAiModelEndpoints === 'function') {
settingsModule.refreshAiModelEndpoints();
}
}, 0);
};
try {
const res = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
// Treat a non-OK response (e.g. 401/403 for non-admins, or backend
@@ -495,13 +496,16 @@ async function loadEndpoints() {
const empty = '<div class="admin-empty">None</div>';
if (listLocal) listLocal.innerHTML = empty;
if (listApi) listApi.innerHTML = '<div class="admin-empty">None</div>';
if (listLegacy) listLegacy.innerHTML = empty;
refreshDependentModelUi();
return;
}
const rowHtml = data.map(ep => {
const epModels = Array.isArray(ep.models) ? ep.models : [];
const visibleCount = epModels.length;
const totalCount = visibleCount + (ep.hidden_count || 0);
const pinnedModels = Array.isArray(ep.pinned_models) ? ep.pinned_models : [];
const visibleCount = ep.picker_requires_pinning ? pinnedModels.length : epModels.length;
const totalCount = Number.isFinite(Number(ep.model_count))
? Number(ep.model_count)
: visibleCount + (ep.hidden_count || 0);
// `ep.models` is the *visible* set — when every model is hidden it's
// empty, but we still need to render the expand panel so the user can
// un-hide them. Gate on the total instead.
@@ -562,17 +566,21 @@ async function loadEndpoints() {
apiIdx.sort(_sortByEnabled);
_renderInto(listLocal, localIdx);
_renderInto(listApi, apiIdx);
if (listLegacy) listLegacy.innerHTML = rowHtml.join('');
// Iterate matching nodes across both containers.
const queryAll = (sel) => {
const out = [];
[listLocal, listApi, listLegacy].forEach(c => {
[listLocal, listApi].forEach(c => {
if (c) c.querySelectorAll(sel).forEach(n => out.push(n));
});
return out;
};
queryAll('[data-adm-toggle-ep]').forEach(btn => {
btn.addEventListener('click', async (e) => { e.stopPropagation(); await fetch(`/api/model-endpoints/${btn.dataset.admToggleEp}`, { method: 'PATCH' }); loadEndpoints(); });
btn.addEventListener('click', async (e) => {
e.stopPropagation();
await fetch(`/api/model-endpoints/${btn.dataset.admToggleEp}`, { method: 'PATCH' });
await _refreshAfterEndpointChange();
loadEndpoints();
});
});
queryAll('[data-adm-copy-url]').forEach(btn => {
btn.addEventListener('click', (e) => {
@@ -663,6 +671,8 @@ async function loadEndpoints() {
const _loadingHtml = (label) => `<span style="opacity:0.55;font-size:11px;display:inline-flex;align-items:center;gap:8px;">${esc(label)}</span>`;
const renderModels = (models, warning = '') => {
const sortedModels = sortModelObjects(models);
const usesPinnedPicker = sortedModels.some(m => !!m.picker_requires_pinning);
panel.dataset.pickerMode = usesPinnedPicker ? 'pinned' : 'hidden';
const warningHtml = warning ? `<div class="admin-error" style="font-size:11px;margin:6px 0;">${esc(warning)}</div>` : '';
const attachRefresh = () => {
panel.querySelector(`[data-ep-refresh-models="${epId}"]`)?.addEventListener('click', async (e) => {
@@ -674,6 +684,7 @@ async function loadEndpoints() {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const refreshedModels = await res.json();
renderModels(refreshedModels, refreshWarning);
_refreshAfterEndpointChange();
if (refreshWarning && uiModule?.showToast) uiModule.showToast(refreshWarning, 6000);
} catch (_) {
renderModels(sortedModels, 'Model refresh failed; kept cached models.');
@@ -684,26 +695,26 @@ async function loadEndpoints() {
panel.innerHTML = `<div class="mcp-tools-header">
<span>Models</span>
<span style="display:flex;gap:8px;align-items:center;">
<span class="mcp-tools-count">0/0 enabled</span>
<a href="#" data-ep-refresh-models="${epId}">Refresh</a>
</span>
</div>${warningHtml}<span style="opacity:0.5;font-size:11px;">No models</span>`;
attachRefresh();
return;
}
const hiddenSet = new Set(sortedModels.filter(m => m.is_hidden).map(m => m.id));
const enabledCount = usesPinnedPicker
? sortedModels.filter(m => m.is_pinned).length
: sortedModels.filter(m => !m.is_hidden).length;
const showSearch = sortedModels.length >= 8;
panel.innerHTML = `<div class="mcp-tools-header">
<span>Models</span>
<span style="display:flex;gap:8px;align-items:center;">
<span class="mcp-tools-count">${sortedModels.length - hiddenSet.size}/${sortedModels.length} enabled</span>
<a href="#" data-ep-refresh-models="${epId}">Refresh</a>
<a href="#" data-ep-select-all="${epId}">All</a>
<a href="#" data-ep-select-none="${epId}">None</a>
</span>
</div>${warningHtml}${showSearch ? `<input type="search" class="mcp-tools-search" placeholder="Search ${sortedModels.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + sortedModels.map(m =>
`<label title="${esc(m.id)}" data-ep-model-row data-search="${esc((m.display + ' ' + m.id).toLowerCase())}" class="adm-model-row">
<input type="checkbox" class="adm-cb-hidden" data-ep-model-id="${esc(m.id)}" ${!m.is_hidden ? 'checked' : ''}>
<input type="checkbox" class="adm-cb-hidden" data-ep-model-id="${esc(m.id)}" ${(usesPinnedPicker ? m.is_pinned : !m.is_hidden) ? 'checked' : ''}>
<span class="adm-check-dot" aria-hidden="true"></span>
<span>${esc(m.display)}</span>
</label>`
@@ -744,35 +755,43 @@ async function loadEndpoints() {
}
});
});
refreshDependentModelUi();
} catch (e) {
const err = '<div class="admin-error">Failed to load</div>';
[listLocal, listApi, listLegacy].forEach(c => { if (c) c.innerHTML = err; });
[listLocal, listApi].forEach(c => { if (c) c.innerHTML = err; });
}
}
async function _saveEpModelState(epId, panel) {
const hidden = [];
const pinned = [];
const usesPinnedPicker = panel && panel.dataset && panel.dataset.pickerMode === 'pinned';
panel.querySelectorAll('input[type=checkbox]').forEach(cb => {
if (!cb.checked) hidden.push(cb.dataset.epModelId);
if (cb.checked) pinned.push(cb.dataset.epModelId);
else hidden.push(cb.dataset.epModelId);
});
const total = panel.querySelectorAll('input[type=checkbox]').length;
const enabled = usesPinnedPicker ? pinned.length : total - hidden.length;
try {
await fetch(`/api/model-endpoints/${epId}/models`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ hidden }),
body: JSON.stringify(usesPinnedPicker ? { pinned_models: pinned } : { hidden }),
});
const countLabel = panel.querySelector('.mcp-tools-count');
if (countLabel) countLabel.textContent = `${total - hidden.length}/${total} enabled`;
const row = panel.closest('[data-adm-ep-id]');
if (row) {
const badge = row.querySelector('.admin-badge');
if (badge && !badge.classList.contains('admin-badge-off')) badge.textContent = `${total - hidden.length}/${total} models enabled`;
if (badge && !badge.classList.contains('admin-badge-off')) {
const match = String(badge.textContent || '').match(/\/(\d+)/);
const canonicalTotal = match ? Number(match[1]) : total;
badge.textContent = `${enabled}/${canonicalTotal} models enabled`;
}
}
if (settingsModule && typeof settingsModule.refreshAiModelEndpoints === 'function') {
settingsModule.refreshAiModelEndpoints();
}
_refreshAfterEndpointChange();
} catch (e) { /* silent */ }
}
@@ -1480,6 +1499,7 @@ function initEndpointForm() {
})());
await Promise.all(workers);
await loadEndpoints();
await _refreshAfterEndpointChange();
_refreshOfflineCount();
if (uiModule && uiModule.showToast) {
const ok = Math.max(0, ids.length - failed);
@@ -1522,6 +1542,7 @@ function initEndpointForm() {
await Promise.all(ids.map(id =>
fetch('/api/model-endpoints/' + id, { method: 'DELETE', credentials: 'same-origin' }).catch(() => {})
));
await _refreshAfterEndpointChange();
try { await loadEndpoints(); } catch (_) {}
_refreshOfflineCount();
if (uiModule && uiModule.showToast) uiModule.showToast(`Removed ${ids.length} offline endpoint${ids.length === 1 ? '' : 's'}`, 1800);
+608 -64
View File
File diff suppressed because it is too large Load Diff
+46 -2
View File
@@ -16,6 +16,7 @@ const REPORT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
const CHAT_ABOUT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const COPY_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
const CHECK_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
const PAPERCLIP_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
/** Sanitize a URL for use in href — only allow http(s) and protocol-relative. */
function _safeHref(url) {
@@ -1197,7 +1198,7 @@ document.addEventListener('click', function(e) {
} catch {}
});
} else if (kind === 'document') {
import('./document.js').then(mod => {
import('./document.js?v=20260722emailfastindex1').then(mod => {
const open = mod.loadDocument
|| mod.openDocument
|| (mod.default && (mod.default.loadDocument || mod.default.openDocument));
@@ -1219,7 +1220,7 @@ document.addEventListener('click', function(e) {
if (open) open(id);
}).catch(() => {});
} else if (kind === 'email') {
import('./emailLibrary.js').then(mod => {
import('./emailLibrary.js?v=20260722emailfastindex1').then(mod => {
const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
if (open) open({ uid: id });
}).catch(() => {});
@@ -1254,6 +1255,9 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId
var esc = uiModule.esc;
const wrap = document.createElement('div');
wrap.className = 'msg msg-ai generated-image-wrap';
wrap.dataset.imageUrl = imageUrl || '';
wrap.dataset.imageKey = String(imageId || imageUrl || '');
if (imageId) wrap.dataset.imageId = imageId;
const role = document.createElement('div');
role.className = 'role';
@@ -1329,6 +1333,42 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId
});
actions.appendChild(dlBtn);
const reuseBtn = document.createElement('button');
reuseBtn.className = 'footer-copy-btn';
reuseBtn.type = 'button';
reuseBtn.title = 'Attach image to new prompt';
reuseBtn.innerHTML = PAPERCLIP_ICON;
reuseBtn.addEventListener('click', async (e) => {
e.stopPropagation();
reuseBtn.disabled = true;
try {
const resp = await fetch(safeImageUrl, { credentials: 'same-origin' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const blob = await resp.blob();
const ext = (blob.type || '').includes('jpeg') ? 'jpg'
: (blob.type || '').includes('webp') ? 'webp'
: (blob.type || '').includes('gif') ? 'gif'
: 'png';
const base = (prompt || 'generated-image').slice(0, 36).replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'generated-image';
const file = new File([blob], `${base}.${ext}`, { type: blob.type || 'image/png', lastModified: Date.now() });
const mod = await import('./fileHandler.js');
const addFiles = mod.addFiles || (mod.default && mod.default.addFiles);
if (!addFiles) throw new Error('attachment handler unavailable');
await addFiles([file], { skipCrop: true });
const input = document.getElementById('message');
if (input) input.focus();
reuseBtn.innerHTML = CHECK_ICON;
if (window.showToast) window.showToast('Image attached');
setTimeout(() => { reuseBtn.innerHTML = PAPERCLIP_ICON; reuseBtn.disabled = false; }, 1400);
} catch (err) {
console.warn('Attach generated image failed', err);
reuseBtn.textContent = '\u2717';
if (window.showToast) window.showToast('Could not attach image');
setTimeout(() => { reuseBtn.innerHTML = PAPERCLIP_ICON; reuseBtn.disabled = false; }, 1600);
}
});
actions.appendChild(reuseBtn);
const editBtn = document.createElement('button');
editBtn.className = 'footer-copy-btn';
editBtn.type = 'button';
@@ -1447,8 +1487,12 @@ export function hideWelcomeScreen() {
export function showWelcomeScreen() {
const ws = document.getElementById('welcome-screen');
const cc = document.getElementById('chat-container');
const alreadyVisible = !!(ws && !ws.classList.contains('hidden'));
if (ws) ws.classList.remove('hidden');
if (cc) cc.classList.add('welcome-active');
if (alreadyVisible) {
return;
}
// Entering the New Chat / welcome state: discard any stale draft left in the
// composer from the previous session so the input starts empty (issue #1343).
// Switching between existing sessions loads them directly and does NOT call
+3 -3
View File
@@ -7,7 +7,7 @@ import Storage from './storage.js';
import themeModule from './theme.js';
import markdownModule from './markdown.js';
import sessionModule from './sessions.js';
import documentModule from './document.js';
import documentModule from './document.js?v=20260722emailfastindex1';
/**
* Handle a ui_control SSE event AI-driven UI manipulation.
@@ -156,7 +156,7 @@ export function handleUIControl(uiData) {
if (fn) fn();
}).catch(function(){});
} else if (panel === 'email') {
import('./emailLibrary.js').then(function(mod) {
import('./emailLibrary.js?v=20260722emailfastindex1').then(function(mod) {
var fn = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
if (fn) fn();
}).catch(function(){});
@@ -205,7 +205,7 @@ export function handleUIControl(uiData) {
} catch (e) {
console.warn('open_email_reply existing draft update failed:', e);
}
import('./emailInbox.js').then(function(mod) {
import('./emailInbox.js?v=20260722emailfastindex1').then(function(mod) {
var fn = mod.openReplyDraft || (mod.default && mod.default.openReplyDraft);
if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply', uiData.body || '');
}).catch(function(e) {
+2 -2
View File
@@ -19,7 +19,7 @@ import { EVAL_PROMPTS, WAVE_FRAMES,
SEND_SVG, VOTES_STORAGE_KEY,
} from './icons.js';
import { fetchModels, _persistSelections, _modelDisplayNames, getExcludedModels, setExcludedModels } from './models.js';
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js';
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js?v=20260723compareicon2';
import { _checkUnprobed, _clearProbeWaves } from './probe.js';
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js';
import {
@@ -359,7 +359,7 @@ async function _buildCompareUI() {
headerLeft.style.cssText = 'display:flex;align-items:center;min-width:0;';
const headerIcon = document.createElement('span');
headerIcon.style.cssText = 'display:inline-flex;flex-shrink:0;margin-right:6px;opacity:0.85;';
headerIcon.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg>';
headerIcon.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="16" rx="1.5"/><rect x="14" y="4" width="7" height="16" rx="1.5"/><path d="M10 8h4"/><path d="M10 16h4"/></svg>';
headerLeft.appendChild(headerIcon);
headerLeft.appendChild(headerLabel);
headerBar.appendChild(headerLeft);
+1 -1
View File
@@ -75,7 +75,7 @@ async function showModelSelector() {
header.className = 'modal-header';
const title = document.createElement('h4');
title.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></svg>Model Comparison';
title.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><rect x="3" y="4" width="7" height="16" rx="1.5"/><rect x="14" y="4" width="7" height="16" rx="1.5"/><path d="M10 8h4"/><path d="M10 16h4"/></svg>Model Comparison';
// Absorb the free space so the injected minimize (_) and close (✕) cluster
// together on the right instead of being spread apart by space-between.
title.style.marginRight = 'auto';
+135 -25
View File
@@ -1,61 +1,171 @@
/**
* ArrowUp on an empty composer recalls the last user message (chat-app convention).
* ArrowUp on the composer recalls previous user messages from this chat.
*/
/**
* Last user bubble in the active chat surface (#chat-history), using dataset.raw
* (same source as resend/regenerate in chat.js).
* User bubbles in the active chat surface (#chat-history), newest first, using
* dataset.raw (same source as resend/regenerate in chat.js).
*
* @param {Document | Element} [root=document]
* @returns {string[]}
*/
export function getUserMessagesFromChatHistory(root = document) {
const chatBox =
root && root.id === 'chat-history' && typeof root.querySelectorAll === 'function'
? root
: (root.getElementById ? root.getElementById('chat-history') : null);
if (!chatBox) return [];
const users = chatBox.querySelectorAll('.msg-user');
const prompts = [];
for (let i = users.length - 1; i >= 0; i--) {
const msg = users[i];
const bodyEl = msg.querySelector('.body');
const text = msg.dataset?.raw || (bodyEl ? bodyEl.textContent : '') || '';
if (text) prompts.push(text);
}
return prompts;
}
/**
* Last user bubble in the active chat surface (#chat-history).
*
* @param {Document | Element} [root=document]
* @returns {string}
*/
export function getLastUserMessageFromChatHistory(root = document) {
const chatBox =
root && root.id === 'chat-history' && typeof root.querySelectorAll === 'function'
? root
: (root.getElementById ? root.getElementById('chat-history') : null);
if (!chatBox) return '';
const users = chatBox.querySelectorAll('.msg-user');
const last = users[users.length - 1];
if (!last) return '';
const bodyEl = last.querySelector('.body');
return last.dataset?.raw || (bodyEl ? bodyEl.textContent : '') || '';
return getUserMessagesFromChatHistory(root)[0] || '';
}
/**
* @param {HTMLTextAreaElement} composer
* @param {() => string} getLastUserMessage
* @param {() => string|string[]} getUserMessages
* @param {{ autoResize?: (el: HTMLTextAreaElement) => void }} [options]
* @returns {boolean} true when wired (or already wired)
*/
export function wireArrowUpRecall(composer, getLastUserMessage, options = {}) {
export function wireArrowUpRecall(composer, getUserMessages, options = {}) {
if (!composer) return false;
if (composer._arrowUpRecallWired) return true;
composer._arrowUpRecallWired = true;
const { autoResize } = options;
let recallIndex = -1;
let applyingRecall = false;
let lastRecalledValue = '';
let recallHistory = [];
const readHistory = () => {
const value = getUserMessages?.();
if (Array.isArray(value)) return value.filter(Boolean);
return value ? [value] : [];
};
const norm = (value) => String(value || '').replace(/\r\n/g, '\n').trimEnd();
const debug = (...args) => {
try {
if (localStorage.getItem('odysseusArrowRecallDebug') === '1') {
console.debug('[arrow-recall]', ...args);
}
} catch (_) {}
};
composer.addEventListener('input', () => {
if (applyingRecall) return;
if (norm(composer.value) === norm(lastRecalledValue)) return;
recallIndex = -1;
lastRecalledValue = '';
recallHistory = [];
try { delete composer.dataset.odysseusRecallIndex; } catch (_) {}
});
composer.addEventListener('keydown', (e) => {
// Only ArrowUp, no modifier keys, no IME composition
if (e.key !== 'ArrowUp') return;
// Prompt history: ArrowUp walks older, ArrowDown walks newer/back to blank.
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) return;
if (e.isComposing) return;
if (typeof window !== 'undefined' && window._ghostAutocomplete?.isActive?.()) return;
// Literal emptiness — intentional whitespace is not empty
if (composer.value !== '') return;
const recalled = getLastUserMessage();
if (!recalled) return;
const freshHistory = readHistory();
const history = freshHistory.length ? freshHistory : recallHistory;
if (!history.length) {
debug('skip:no-history', { value: composer.value });
return;
}
const rawCurrentValue = String(composer.value || '');
const currentValue = norm(rawCurrentValue);
const recalledValue = norm(lastRecalledValue);
let currentIndex = rawCurrentValue === ''
? -1
: history.findIndex((item) => norm(item) === currentValue);
if (currentIndex < 0 && currentValue && currentValue === recalledValue) {
currentIndex = recallIndex;
}
if (currentIndex < 0 && currentValue) {
const markedIndex = Number(composer.dataset?.odysseusRecallIndex);
if (Number.isInteger(markedIndex) && markedIndex >= 0 && markedIndex < history.length) {
currentIndex = markedIndex;
}
}
if (rawCurrentValue !== '' && currentIndex < 0) {
debug('skip:draft-in-progress', { value: composer.value });
return;
}
e.preventDefault();
e.stopPropagation?.();
e.stopImmediatePropagation?.();
if (e.key === 'ArrowDown') {
if (currentIndex < 0) return;
const nextIndex = currentIndex - 1;
if (nextIndex < 0) {
recallIndex = -1;
recallHistory = history;
applyingRecall = true;
lastRecalledValue = '';
try { delete composer.dataset.odysseusRecallIndex; } catch (_) {}
composer.value = '';
try { composer.selectionStart = composer.selectionEnd = 0; } catch (_) {}
if (autoResize) autoResize(composer);
debug('handled-down-clear', { historyLength: history.length });
setTimeout(() => { applyingRecall = false; }, 0);
return;
}
const recalled = history[nextIndex];
recallIndex = nextIndex;
recallHistory = history;
applyingRecall = true;
lastRecalledValue = recalled;
try { composer.dataset.odysseusRecallIndex = String(nextIndex); } catch (_) {}
composer.value = recalled;
try { composer.selectionStart = composer.selectionEnd = recalled.length; } catch (_) {}
if (autoResize) autoResize(composer);
debug('handled-down', { nextIndex, recalled, historyLength: history.length });
setTimeout(() => { applyingRecall = false; }, 0);
return;
}
// ArrowUp owns prompt history in the chat composer. If the current text
// is not already a recalled prompt, start from newest instead of letting
// the browser move the caret inside the textarea.
const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, history.length - 1) : 0;
const recalled = history[nextIndex];
if (!recalled) {
debug('skip:no-recalled', { nextIndex, history });
return;
}
recallIndex = nextIndex;
recallHistory = history;
applyingRecall = true;
lastRecalledValue = recalled;
try { composer.dataset.odysseusRecallIndex = String(nextIndex); } catch (_) {}
composer.value = recalled;
try {
composer.selectionStart = composer.selectionEnd = recalled.length;
} catch (_) {}
if (autoResize) autoResize(composer);
});
debug('handled', { nextIndex, recalled, historyLength: history.length });
setTimeout(() => { applyingRecall = false; }, 0);
}, true);
return true;
}
+85 -3
View File
@@ -6,7 +6,7 @@
// generic fallback for that backend.
// Recipes carry two variants per entry:
// variants.pip → install into the configured venv via uv/pip
// variants.pip → install into the configured venv via pip/uv
// variants.docker → pull the official container image
//
// The renderer prepends a `source <venv>/bin/activate` for the pip variant
@@ -55,7 +55,89 @@ const _RECIPES = [
label: 'Any MLX model',
match: () => true,
variants: {
pip: { commands: ['uv pip install -U mlx-lm'] },
pip: { commands: ['python -m pip install -U mlx-lm'] },
},
},
{
backend: 'mflux',
label: 'mflux-compatible MLX image models',
match: () => true,
variants: {
pip: { commands: ['python -m pip install -U mflux fastapi uvicorn python-multipart'] },
},
},
{
backend: 'boogu_image_mlx',
label: 'MLX image models (Boogu)',
match: () => true,
variants: {
pip: { commands: ['python -m pip install -U git+https://github.com/xocialize/boogu-image-mlx.git fastapi uvicorn python-multipart pillow'] },
},
},
{
backend: 'mlx_vlm',
label: 'MLX image models (HiDream)',
match: () => true,
variants: {
pip: { commands: ['python -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'] },
},
},
{
backend: 'mlx_lama_swift',
label: 'MLX image editing (LaMa / MI-GAN)',
match: () => true,
variants: {
pip: {
commands: [
'python -m pip install -U fastapi uvicorn python-multipart pillow huggingface_hub',
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; test -d "$BRIDGE_DIR" || { echo "Run this from an Odysseus checkout that includes swift/odysseus-mlx-image-bridge, or set ODYSSEUS_ROOT=/path/to/odysseus."; exit 1; }',
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; cd "$BRIDGE_DIR" && swift build -c release --product odysseus-mlx-inpaint',
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; mkdir -p "$HOME/.local/bin" && cp "$BRIDGE_DIR/.build/release/odysseus-mlx-inpaint" "$HOME/.local/bin/odysseus-mlx-inpaint"',
'MLX_METALLIB="$(python - <<\'PY\'\nimport pathlib, sys\ntry:\n import mlx\nexcept Exception as exc:\n raise SystemExit(f"mlx Python package is required for mlx.metallib: {exc}")\nroot = pathlib.Path(mlx.__file__).resolve().parent\nfor name in ("lib/mlx.metallib", "mlx.metallib", "lib/default.metallib", "default.metallib"):\n path = root / name\n if path.exists():\n print(path)\n break\nelse:\n raise SystemExit(f"No MLX metallib found under {root}")\nPY\n)"; mkdir -p "$HOME/.local/bin" && cp "$MLX_METALLIB" "$HOME/.local/bin/mlx.metallib" && cp "$MLX_METALLIB" "$HOME/.local/bin/default.metallib"',
],
},
},
},
{
backend: 'mlx_ddcolor_swift',
label: 'MLX image editing (DDColor)',
match: () => true,
variants: {
pip: {
commands: [
'python -m pip install -U fastapi uvicorn python-multipart pillow huggingface_hub',
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; test -d "$BRIDGE_DIR" || { echo "Run this from an Odysseus checkout that includes swift/odysseus-mlx-image-bridge, or set ODYSSEUS_ROOT=/path/to/odysseus."; exit 1; }',
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; cd "$BRIDGE_DIR" && swift build -c release --product odysseus-mlx-colorize',
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; mkdir -p "$HOME/.local/bin" && cp "$BRIDGE_DIR/.build/release/odysseus-mlx-colorize" "$HOME/.local/bin/odysseus-mlx-colorize"',
'MLX_METALLIB="$(python - <<\'PY\'\nimport pathlib, sys\ntry:\n import mlx\nexcept Exception as exc:\n raise SystemExit(f"mlx Python package is required for mlx.metallib: {exc}")\nroot = pathlib.Path(mlx.__file__).resolve().parent\nfor name in ("lib/mlx.metallib", "mlx.metallib", "lib/default.metallib", "default.metallib"):\n path = root / name\n if path.exists():\n print(path)\n break\nelse:\n raise SystemExit(f"No MLX metallib found under {root}")\nPY\n)"; mkdir -p "$HOME/.local/bin" && cp "$MLX_METALLIB" "$HOME/.local/bin/mlx.metallib" && cp "$MLX_METALLIB" "$HOME/.local/bin/default.metallib"',
],
},
},
},
// ── Diffusers ────────────────────────────────────────────────────────
{
backend: 'diffusers',
label: 'Any Diffusers image model',
match: () => true,
variants: {
pip: { commands: ['python -m pip install -U "diffusers[torch]" torchvision accelerate scipy python-multipart'] },
},
},
{
backend: 'krea_diffusers',
label: 'Latest Diffusers from Git',
match: () => true,
variants: {
pip: { commands: ['python -m pip install -U git+https://github.com/huggingface/diffusers.git torchvision accelerate scipy python-multipart'] },
},
},
{
backend: 'sam_mask',
label: 'SAM object mask tools',
match: () => true,
variants: {
pip: { commands: ['python -m pip install -U torch torchvision transformers accelerate pillow'] },
},
},
@@ -85,7 +167,7 @@ export function recipeCommands(recipe, variant) {
// Backends we surface a recipe panel for. Other rows in the Dependencies
// list keep the existing flat Install/Reinstall button without an expand
// affordance.
export const RECIPE_BACKENDS = new Set(['vllm', 'sglang', 'mlx_lm', 'llama_cpp']);
export const RECIPE_BACKENDS = new Set(['vllm', 'sglang', 'mlx_lm', 'mflux', 'boogu_image_mlx', 'mlx_vlm', 'mlx_lama_swift', 'mlx_ddcolor_swift', 'diffusers', 'krea_diffusers', 'sam_mask', 'llama_cpp']);
// All recipe entries for a given backend, in catalog order. The first one
// is the model-specific match (when present); the last is always the
+23 -18
View File
@@ -261,17 +261,6 @@ async function _clearGpuProcesses(panel) {
await _runQuickCmd(panel, _gpuCleanupCommand());
}
// Infer the gated base repo that single-file checkpoints need configs from
function _inferBaseRepo(text) {
if (!text) return null;
const t = text.toLowerCase();
if (t.includes('sd3.5') || t.includes('stable-diffusion-3.5')) return 'stabilityai/stable-diffusion-3.5-large';
if (t.includes('sd3') || t.includes('stable-diffusion-3')) return 'stabilityai/stable-diffusion-3-medium-diffusers';
if (t.includes('flux')) return 'black-forest-labs/FLUX.1-schnell';
if (t.includes('sdxl') || t.includes('stable-diffusion-xl')) return 'stabilityai/stable-diffusion-xl-base-1.0';
return null;
}
export const ERROR_PATTERNS = [
{
pattern: /tmux is required|tmux.*not found|tmux:\s*command not found|command not found:\s*tmux|No such file or directory:\s*['"]?tmux/i,
@@ -450,11 +439,10 @@ export const ERROR_PATTERNS = [
message: 'Single-file checkpoint needs a base model for missing components (text encoder, VAE). The base model may be gated — accept the license and set your HF token.',
fixes: [
{ label: 'Request access to base model', action: (panel, _text) => {
// Extract gated repo from error, or infer from model name
const gated = _text && _text.match(/Access to model\s+(\S+)\s+is restricted/i);
const base = _text && _text.match(/config=([^\s,)]+)/i);
const model = _text && _text.match(/load model from\s+(\S+)/i);
const repo = (gated && gated[1]) || (base && base[1]) || _inferBaseRepo(_text);
const repo = (gated && gated[1]) || (base && base[1]);
if (repo) window.open('https://huggingface.co/' + repo, '_blank');
else if (model && model[1]) window.open('https://huggingface.co/' + model[1].replace(/[.]$/, ''), '_blank');
}},
@@ -464,13 +452,21 @@ export const ERROR_PATTERNS = [
}},
],
},
{
pattern: /OmniGen2Pipeline|module diffusers has no attribute .*Pipeline|custom_pipeline=.*failed/i,
message: 'This image model uses a custom Diffusers pipeline that your launch environment does not know yet.',
fixes: [
{ label: 'Update image dependencies', action: () => _openCookbookDependencies('diffusers') },
{ label: 'Copy diagnosis', action: (_panel, _text) => navigator.clipboard?.writeText(_text || '') },
],
},
{
pattern: /Entry Not Found.*model_index\.json|Could not load model.*Check diffusers/i,
message: 'Single-file model needs base config from a gated repo. Accept the license and set your HF token.',
message: 'Single-file model may need an explicit base config. Add --single-file-config <repo_or_path> if the checkpoint is missing components.',
fixes: [
{ label: 'Request access to base model', action: (panel, _text) => {
const gated = _text && _text.match(/Access to model\s+(\S+)\s+is restricted/i);
const repo = (gated && gated[1]) || _inferBaseRepo(_text);
const repo = gated && gated[1];
if (repo) window.open('https://huggingface.co/' + repo, '_blank');
else window.open('https://huggingface.co/settings/gated-repos', '_blank');
}},
@@ -560,6 +556,15 @@ export const ERROR_PATTERNS = [
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install -U mlx-lm') },
],
},
{
pattern: /mflux-generate-qwen.*not found|mflux-generate.*not found|MLX image serving requires mflux|No module named ['"]?mflux/i,
message: 'MLX image serving requires mflux on this Apple Silicon server.',
suggestion: 'Suggested action: install mflux in the selected Python environment. This is for MLX image generation, not text MLX-LM.',
fixes: [
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('mflux') },
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install -U mflux fastapi uvicorn') },
],
},
{
pattern: /Unable to quantize model of type <class ['"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['"]>|QuantizedSwitchLinear/i,
message: 'MLX-LM tried to quantize an already-quantized DeepSeek switch layer.',
@@ -725,11 +730,11 @@ export const ERROR_PATTERNS = [
],
},
{
pattern: /No module named ['"]?torch|No module named ['"]?diffusers|diffusers.*command not found/i,
message: 'Diffusion serving needs PyTorch and diffusers. Install diffusers from Cookbook → Dependencies.',
pattern: /No module named ['"]?torch|No module named ['"]?torchvision|No module named ['"]?diffusers|No module named ['"]?scipy|install scipy if you want to use beta sigmas|requires the Torchvision library|diffusers.*command not found/i,
message: 'Diffusion serving needs PyTorch, Torchvision, Diffusers, Accelerate, and SciPy. Install Diffusers image deps from Cookbook → Dependencies.',
fixes: [
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('diffusers') },
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install "diffusers[torch]"') },
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install "diffusers[torch]" torchvision accelerate scipy python-multipart') },
],
},
{
+173 -15
View File
@@ -40,7 +40,13 @@ import { openCookbookDependencies } from './cookbook-diagnosis.js';
// Map a serve-backend code (vllm / sglang / llamacpp / mlx) → the package name
// the Dependencies API reports. Used to look up "is this backend installed
// on the target server" before firing a launch.
const _BACKEND_PKG = { vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', mlx: 'mlx_lm' };
const _BACKEND_PKG = { vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', mlx: 'mlx_lm', mlx_image: 'mflux', diffusers: 'diffusers' };
function _dependencyPkgForModel(runBackend, modelName = '') {
const nm = String(modelName || '').toLowerCase();
if (runBackend === 'mlx_image' && nm.includes('boogu')) return 'boogu_image_mlx';
if (runBackend === 'diffusers' && nm.includes('krea')) return 'krea_diffusers';
return _BACKEND_PKG[runBackend];
}
function _normalizeCookbookModelDir(dir) {
const d = String(dir || '').replaceAll('\u2715', '').replaceAll('\u2716', '').trim();
@@ -95,7 +101,7 @@ function _wireServerColorPicker(entry) {
// the target server. Returns true if it's good to go, false if we should
// block and route the user into Dependencies.
async function _ensureBackendInstalled(runBackend, host, port, envPath, modelName) {
const pkgName = _BACKEND_PKG[runBackend];
const pkgName = _dependencyPkgForModel(runBackend, modelName);
if (!pkgName) return true; // unknown backend — don't block
try {
const params = new URLSearchParams();
@@ -542,9 +548,31 @@ function _hwfitShowError(list, host, detail) {
// needed. Ollama rows are merged into the main list (see _ensureOllamaLib +
// _ollamaToHwfitRows below) so the filter handles all engines uniformly.
function _applyEngineFilter(models) {
let out = Array.isArray(models) ? models : [];
const useCase = document.getElementById('hwfit-usecase')?.value || '';
const srv = _serverByVal(_envState.remoteServerKey || _envState.remoteHost);
const platform = String(srv?.platform || _envState.platform || _hwfitCache?.system?.platform || '').toLowerCase();
const backend = String(_hwfitCache?.system?.backend || '').toLowerCase();
const gpuName = String(_hwfitCache?.system?.gpu_name || '').toLowerCase();
const isAppleTarget = !!(useCase === 'image_gen' && (
platform === 'darwin'
|| platform === 'macos'
|| platform.includes('mac')
|| backend === 'metal'
|| backend === 'mps'
|| backend === 'apple'
|| gpuName.includes('apple')
|| _hwfitCache?.system?.unified_memory
));
if (isAppleTarget) {
out = out.filter(m => {
const text = `${m?.name || ''} ${m?.id || ''} ${m?.provider || ''}`.toLowerCase();
return text.includes('mlx-community/') || text.includes('mlx-community') || m?.mlx_only || m?.apple_ok;
});
}
const want = document.getElementById('hwfit-engine')?.value || '';
if (!want || !Array.isArray(models)) return models || [];
return models.filter(m => {
if (!want) return out;
return out.filter(m => {
try { return _detectBackend(m).backend === want; } catch { return true; }
});
}
@@ -794,7 +822,7 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
if (v !== '') params.set(k, v);
});
if (hasManualOrDismissed) params.set('_hw_override_ts', String(Date.now()));
// Image models use a separate registry/endpoint
// Image models use a separate registry/endpoint.
const isImageMode = useCase === 'image_gen';
if ((fresh || (_paintedFromCache && !search)) && !isImageMode) {
params.set('refresh_catalog', '1'); // update HF-backed dynamic catalogs in the background
@@ -840,7 +868,7 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
}
}
}
// Normalize image model fields to match LLM renderer expectations
// Normalize image model fields to match LLM renderer expectations.
if (isImageMode && data.models) {
data.models = data.models.map(m => ({
...m,
@@ -1263,9 +1291,46 @@ export const _hwfitColumns = [
{ key: null, label: 'Mode', cls: 'hwfit-c-mode' },
];
function _sortHwfitRows(models) {
const rows = Array.isArray(models) ? [...models] : [];
const sortSel = document.getElementById('hwfit-sort');
const sortKey = sortSel?.value || 'newest';
const asc = sortSel?.dataset.reverse === '1';
if (sortKey === 'fit') {
const fitRank = { perfect: 4, good: 3, marginal: 2, too_tight: 1, no_fit: 0 };
rows.sort((a, b) => {
const ar = fitRank[a.fit_level] ?? -1;
const br = fitRank[b.fit_level] ?? -1;
if (ar !== br) return asc ? ar - br : br - ar;
const as = Number(a.score) || 0;
const bs = Number(b.score) || 0;
return asc ? as - bs : bs - as;
});
return rows;
}
if (sortKey === 'newest') {
rows.sort((a, b) => {
const ad = String(a.release_date || '');
const bd = String(b.release_date || '');
if (ad === bd) return 0;
if (!ad) return 1;
if (!bd) return -1;
return asc ? (ad < bd ? -1 : 1) : (ad < bd ? 1 : -1);
});
return rows;
}
const field = { score: 'score', vram: 'required_gb', speed: 'speed_tps', params: 'params_b', context: 'context' }[sortKey] || 'score';
rows.sort((a, b) => {
const av = Number(a[field]) || 0;
const bv = Number(b[field]) || 0;
return asc ? av - bv : bv - av;
});
return rows;
}
export function _hwfitRenderList(el, models) {
if (!el) return;
models = models || [];
models = _sortHwfitRows(models);
if (!models.length) {
// Disambiguate WHY the list is empty so capable servers don't read as "too weak":
// active filters vs. a likely under-reported probe vs. genuinely low hardware.
@@ -1570,7 +1635,9 @@ export function _expandModelRow(row, modelData) {
html += `</div>`;
html += `<div class="hwfit-panel-actions">`;
html += `<button class="cookbook-btn hwfit-dl-btn">Download</button>`;
if (!modelData.is_image_gen) {
if (modelData.is_image_gen) {
html += `<button class="cookbook-btn cookbook-run-btn hwfit-quickrun-btn" title="Download + run as an image endpoint">Run Image</button>`;
} else {
html += `<button class="cookbook-btn cookbook-run-btn hwfit-quickrun-btn" title="Download + launch with smart defaults">Run</button>`;
html += `<button class="cookbook-btn hwfit-serve-expand-btn" title="Configure & serve">Configure</button>`;
}
@@ -1653,11 +1720,14 @@ export function _expandModelRow(row, modelData) {
const _clashing = _allServes.filter(t => _taskPort(t) === _qrPort);
if (_clashing.length) {
const _names = _clashing.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean);
const _ok = await window.styledConfirm?.(
`${_clashing.length} model${_clashing.length === 1 ? '' : 's'} on port ${_qrPort} (${_names.join(', ')}). Stop it and launch this one?`,
{ confirmText: 'Stop & launch', cancelText: 'Cancel' }
const _choice = await window.styledConfirm?.(
`${_clashing.length} model${_clashing.length === 1 ? '' : 's'} on port ${_qrPort} (${_names.join(', ')}). Stop it first, or launch anyway?`,
{ title: `Port ${_qrPort} in use`, confirmText: 'Stop & launch', alternateText: 'Launch anyway', cancelText: 'Cancel' }
);
if (!_ok) return;
if (!_choice) return;
if (_choice === 'alternate') {
uiModule.showToast('Launching anyway. If the port is already occupied, the new serve may fail.', 6000);
} else {
quickRunBtn.disabled = true;
quickRunBtn.textContent = 'Stopping…';
for (const t of _clashing) {
@@ -1678,6 +1748,7 @@ export function _expandModelRow(row, modelData) {
}
await new Promise(r => setTimeout(r, 2500));
}
}
} catch (_e) { /* best-effort */ }
// -- Launch ───────────────────────────────────────────────────
@@ -1808,9 +1879,12 @@ export function _expandModelRow(row, modelData) {
cmd += ` --context-length ${maxCtx}`;
cmd += ` --mem-fraction-static ${gpuUtil}`;
cmd += ' --trust-remote-code';
} else if (runBackend === 'mlx_image') {
const bindHost = host ? '0.0.0.0' : '127.0.0.1';
cmd = `python3 scripts/mlx_image_server.py --model ${_shellQuote(modelData.name)} --host ${bindHost} --port ${port} --steps 20`;
} else if (runBackend === 'mlx') {
const bindHost = host ? '0.0.0.0' : '127.0.0.1';
cmd = `python3 -m mlx_lm.server --model ${_shellQuote(modelData.name)} --host ${bindHost} --port ${port}`;
cmd = `python3 -m mlx_lm.server --model ${_shellQuote(modelData.name)} --host ${bindHost} --port ${port} --max-tokens ${maxCtx}`;
} else if (runBackend === 'llamacpp') {
const dir = `"$HOME/.cache/huggingface/hub/models--${modelData.name.replace(/\//g, '--')}/snapshots"`;
const ggufPath = `$({ find ${dir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${dir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`;
@@ -1852,7 +1926,7 @@ export function _expandModelRow(row, modelData) {
);
if (!_ok) {
quickRunBtn.disabled = false;
quickRunBtn.textContent = 'Run';
quickRunBtn.textContent = modelData.is_image_gen ? 'Run Image' : 'Run';
return;
}
@@ -1889,7 +1963,7 @@ export function _expandModelRow(row, modelData) {
uiModule.showError('Launch failed: ' + e.message);
}
quickRunBtn.disabled = false;
quickRunBtn.textContent = 'Run';
quickRunBtn.textContent = modelData.is_image_gen ? 'Run Image' : 'Run';
});
}
@@ -1938,6 +2012,89 @@ function _hwfitEngineGlyph(value) {
return _HWFIT_ENGINE_GLYPHS[value] || _HWFIT_ENGINE_GLYPHS[''];
}
const _HWFIT_USECASE_GLYPHS = {
general: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>',
multimodal: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>',
image_gen: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><path d="M21 15l-5-5L5 21"></path></svg>',
};
function _hwfitUsecaseGlyph(value) {
return _HWFIT_USECASE_GLYPHS[value] || _HWFIT_USECASE_GLYPHS.general;
}
function _bindHwfitUsecasePicker(usecase) {
const wrap = usecase?.closest('.hwfit-usecase-wrap');
const btn = wrap?.querySelector('[data-hwfit-usecase-btn]');
const menu = wrap?.querySelector('[data-hwfit-usecase-menu]');
const icon = wrap?.querySelector('[data-hwfit-usecase-icon]');
const label = wrap?.querySelector('[data-hwfit-usecase-label]');
if (!usecase || !wrap || !btn || !menu) return;
usecase.querySelectorAll('option[value="video_gen"]').forEach((opt) => opt.remove());
menu.querySelectorAll('[data-hwfit-usecase-value="video_gen"], .hwfit-usecase-item').forEach((item) => {
if (item.dataset.hwfitUsecaseValue === 'video_gen' || item.textContent?.trim() === 'Video') item.remove();
});
if (usecase.value === 'video_gen') {
usecase.value = 'general';
usecase.dispatchEvent(new Event('change', { bubbles: true }));
}
if (wrap.dataset.usecasePickerBound) return;
wrap.dataset.usecasePickerBound = '1';
const setOpen = (open) => {
menu.hidden = !open;
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
};
const currentLabel = () => {
const opt = Array.from(usecase.options).find((o) => o.value === usecase.value);
return opt?.textContent || 'Standard';
};
const syncButton = () => {
if (label) label.textContent = currentLabel();
if (icon) icon.innerHTML = _hwfitUsecaseGlyph(usecase.value);
menu.querySelectorAll('[data-hwfit-usecase-value]').forEach((item) => {
const active = item.dataset.hwfitUsecaseValue === usecase.value;
item.classList.toggle('active', active);
item.setAttribute('aria-selected', active ? 'true' : 'false');
});
};
const renderMenu = () => {
menu.innerHTML = Array.from(usecase.options).filter((opt) => opt.value !== 'video_gen').map((opt) => (
`<button type="button" role="option" class="hwfit-usecase-item" data-hwfit-usecase-value="${opt.value}">`
+ `<span class="hwfit-usecase-item-icon" aria-hidden="true">${_hwfitUsecaseGlyph(opt.value)}</span>`
+ `<span class="hwfit-usecase-item-label">${opt.textContent}</span>`
+ '</button>'
)).join('');
menu.querySelectorAll('[data-hwfit-usecase-value]').forEach((item) => {
item.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
const next = item.dataset.hwfitUsecaseValue || 'general';
if (usecase.value !== next) {
usecase.value = next;
usecase.dispatchEvent(new Event('change', { bubbles: true }));
}
syncButton();
setOpen(false);
});
});
syncButton();
};
btn.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
setOpen(menu.hidden);
});
usecase.addEventListener('change', syncButton);
document.addEventListener('click', (ev) => {
if (!wrap.contains(ev.target)) setOpen(false);
});
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') setOpen(false);
});
renderMenu();
}
function _bindHwfitEnginePicker(engine) {
const wrap = engine?.closest('.hwfit-engine-wrap');
const btn = wrap?.querySelector('[data-hwfit-engine-btn]');
@@ -2011,6 +2168,7 @@ export function _hwfitInit() {
const search = document.getElementById('hwfit-search');
const remote = document.getElementById('hwfit-host');
_syncCtxControl();
if (uc) _bindHwfitUsecasePicker(uc);
if (uc) uc.addEventListener('change', () => _hwfitFetch());
if (sort) sort.addEventListener('change', () => _hwfitFetch());
if (qpref) qpref.addEventListener('change', () => _hwfitFetch());
+261 -90
View File
@@ -504,6 +504,14 @@ export function _detectBackend(model) {
const isRocm = sysBackend === 'rocm';
const isAppleSilicon = ['metal', 'mps', 'apple'].includes(sysBackend);
const _nm = `${model.repo_id || ''} ${model.path || ''} ${model.name || ''}`.toLowerCase();
const isImageModel = !!(model.is_image_gen || model.is_diffusion || model._tag === 'image');
// Image gen models → diffusers
if (isImageModel) {
if (/\bmlx\b|mlx-|_mlx|mlx-community\//i.test(_nm) || q.startsWith('MLX') || model.mlx_only) {
return { backend: 'mlx_image', label: 'MLX Image' };
}
return { backend: 'diffusers', label: 'Diffusers' };
}
if (/\bmlx\b|mlx-|_mlx/i.test(_nm) || q.startsWith('MLX')) {
return { backend: 'mlx', label: 'MLX' };
}
@@ -512,11 +520,6 @@ export function _detectBackend(model) {
&& model.gguf_files.some(f => f && typeof f.rel_path === 'string' && /\.gguf$/i.test(f.rel_path));
const isGgufLike = model.is_gguf || hasGgufFile || /^Q[2-8]/.test(q) || /^IQ/.test(q) || q === 'GGUF' || _nm.includes('gguf');
// Image gen models → diffusers
if (model.is_image_gen || model.is_diffusion || model._tag === 'image') {
return { backend: 'diffusers', label: 'Diffusers' };
}
// AWQ / GPTQ / FP8 are safetensors GPU-serving formats. Never route them
// through llama.cpp/Ollama just because the host is Mac/Windows; those engines
// need GGUF. The UI will warn/block on Metal where vLLM/SGLang aren't viable.
@@ -558,6 +561,18 @@ export function _shellQuote(value) {
return "'" + String(value ?? '').replace(/'/g, "'\\''") + "'";
}
function _listField(value) {
return String(value || '')
.split(/[\n,]+/)
.map(s => s.trim())
.filter(Boolean);
}
function _numField(value) {
const s = String(value || '').trim();
return /^-?\d+(?:\.\d+)?$/.test(s) ? s : '';
}
export function _psQuote(value) {
return "'" + String(value ?? '').replace(/'/g, "''") + "'";
}
@@ -709,6 +724,10 @@ export function _buildServeCmd(f, modelName, backend) {
const _kv = (f.vllm_kv_cache_dtype ?? '').toString().trim();
if (_kv === 'fp8') cmd += ' --kv-cache-dtype fp8';
if (f.max_seqs && f.max_seqs.toString().trim()) cmd += ` --max-num-seqs ${f.max_seqs.toString().trim()}`;
const _vllmLoraModules = _listField(f.vllm_lora_modules);
if (_vllmLoraModules.length) {
cmd += ` --enable-lora --lora-modules ${_vllmLoraModules.map(_shellQuote).join(' ')}`;
}
if (f.enforce_eager) cmd += ' --enforce-eager';
if (f.trust_remote) cmd += ' --trust-remote-code';
if (f.prefix_cache) cmd += ' --enable-prefix-caching';
@@ -917,7 +936,7 @@ export function _buildServeCmd(f, modelName, backend) {
// Trailing GGUF_FILE is optional; helper picks the first match if empty.
cmd = `docker exec ollama-test ollama-import ${modelName} ${_name} ${_ctx}${_file ? ' ' + _file : ''}`;
} else if (!modelName.includes('/') && modelName) {
// Already-pulled Ollama tag (e.g. `qwen2.5:7b`). On kierkegaard the
// Already-pulled Ollama tag (e.g. `qwen2.5:7b`). On remote hosts the
// runtime is the ROCm Ollama sidecar; this quick command verifies the
// tag exists, then the backend auto-registers http://host.docker.internal:11434/v1.
cmd = `docker exec ollama-rocm ollama show ${modelName}`;
@@ -930,22 +949,54 @@ export function _buildServeCmd(f, modelName, backend) {
const gpuStr = f.gpus?.trim();
cmd += _gpuEnvPrefix(gpuStr);
const diffusersPy = _isWindows() ? 'python' : _py3Bin;
cmd += `${diffusersPy} scripts/diffusion_server.py --model ${modelName} --port ${f.port || '8100'}`;
const diffHost = f.host ? '0.0.0.0' : '127.0.0.1';
cmd += `${diffusersPy} scripts/diffusion_server.py --model ${modelName} --host ${diffHost} --port ${f.port || '8100'}`;
if (f.host) {
const allowedHost = String(f.host || '').split('@').pop().split(':')[0].trim();
if (allowedHost) cmd += ` --allowed-host ${allowedHost}`;
}
if (f.diff_dtype && f.diff_dtype !== 'bfloat16') cmd += ` --dtype ${f.diff_dtype}`;
if (f.diff_device_map && f.diff_device_map !== 'balanced') cmd += ` --device-map ${f.diff_device_map}`;
if (f.diff_steps) cmd += ` --steps ${f.diff_steps}`;
if (f.diff_guidance_scale) cmd += ` --guidance-scale ${_numField(f.diff_guidance_scale) || f.diff_guidance_scale}`;
if (String(f.diff_negative_prompt || '').trim()) cmd += ` --negative-prompt ${_shellQuote(String(f.diff_negative_prompt || '').trim())}`;
if (f.diff_width) cmd += ` --width ${f.diff_width}`;
if (f.diff_height) cmd += ` --height ${f.diff_height}`;
const _diffLoras = _listField(f.diff_lora);
if (_diffLoras.length) cmd += ` --lora ${_shellQuote(_diffLoras.join(','))}`;
const _diffLoraScale = _numField(f.diff_lora_scale);
if (_diffLoraScale) cmd += ` --lora-scale ${_diffLoraScale}`;
if (f.diff_offload) cmd += ' --cpu-offload';
if (f.diff_attention_slicing) cmd += ' --attention-slicing';
if (f.diff_vae_slicing) cmd += ' --vae-slicing';
if (f.diff_harmonize_gpu) cmd += ` --harmonize-gpu ${f.diff_harmonize_gpu}`;
} else if (backend === 'mlx_image') {
const mlxPy = _isWindows() ? 'python' : _py3Bin;
const mlxHost = f.host ? '0.0.0.0' : '127.0.0.1';
cmd += `${mlxPy} scripts/mlx_image_server.py --model ${_shellQuote(modelName)} --host ${mlxHost} --port ${f.port || '8100'}`;
if (f.diff_steps) cmd += ` --steps ${f.diff_steps}`;
if (f.diff_width) cmd += ` --width ${f.diff_width}`;
if (f.diff_height) cmd += ` --height ${f.diff_height}`;
const _mlxBaseModel = String(f.mlx_base_model || '').trim();
if (_mlxBaseModel) cmd += ` --base-model ${_shellQuote(_mlxBaseModel)}`;
const _mlxLoraStyle = String(f.mlx_lora_style || '').trim();
if (_mlxLoraStyle) cmd += ` --lora-style ${_shellQuote(_mlxLoraStyle)}`;
const _mlxLoraPaths = _listField(f.mlx_lora_paths);
if (_mlxLoraPaths.length) cmd += ` --lora-paths ${_mlxLoraPaths.map(_shellQuote).join(' ')}`;
const _mlxLoraScales = _listField(f.mlx_lora_scales).filter(s => /^-?\d+(?:\.\d+)?$/.test(s));
if (_mlxLoraScales.length) cmd += ` --lora-scales ${_mlxLoraScales.map(_shellQuote).join(' ')}`;
} else if (backend === 'mlx') {
const mlxPy = _isWindows() ? 'python' : _py3Bin;
const mlxHost = f.host ? '0.0.0.0' : '127.0.0.1';
cmd += `${mlxPy} -m mlx_lm.server --model ${_shellQuote(modelName)} --host ${mlxHost} --port ${f.port || '8080'}`;
const mlxMaxTokens = String(f.ctx || '').trim();
if (/minimax|mini-max/i.test(modelName)) {
cmd += ' --temp 0.7 --top-p 0.9 --max-tokens 2048';
cmd += ` --temp 0.7 --top-p 0.9 --max-tokens ${mlxMaxTokens || '2048'}`;
} else if (/^\d+$/.test(mlxMaxTokens)) {
// MLX-LM server has no vLLM-style --context-length flag. The closest
// server-side request budget it exposes is --max-tokens, so wire the
// Cookbook Context/Auto control there for MLX launches.
cmd += ` --max-tokens ${mlxMaxTokens}`;
}
}
return cmd;
@@ -1041,19 +1092,20 @@ async function _fetchDependencies() {
try {
// Resolve the target server from the deps dropdown so remote-target
// packages are checked on THAT server's venv (not just the local host).
let _depHost = '', _depPort = '', _depVenv = '';
let _depHost = '', _depPort = '', _depVenv = '', _depPlatform = '';
const _dsel = document.getElementById('hwfit-deps-server');
const _depSrv = _dsel && _dsel.value !== 'local' ? _serverByVal(_dsel.value) : null;
if (_depSrv) {
_depHost = _depSrv.host || ''; _depPort = _depSrv.port || ''; _depVenv = _depSrv.envPath || '';
_depHost = _depSrv.host || ''; _depPort = _depSrv.port || ''; _depVenv = _depSrv.envPath || ''; _depPlatform = _depSrv.platform || '';
} else if (_envState.remoteHost) {
_depHost = _envState.remoteHost; _depPort = _getPort(_envState.remoteHost) || ''; _depVenv = _envState.envPath || '';
_depHost = _envState.remoteHost; _depPort = _getPort(_envState.remoteHost) || ''; _depVenv = _envState.envPath || ''; _depPlatform = _envState.platform || '';
}
const _pkgParams = new URLSearchParams();
if (_depHost) {
_pkgParams.set('host', _depHost);
if (_depPort) _pkgParams.set('ssh_port', _depPort);
if (_depVenv) _pkgParams.set('venv', _depVenv);
if (_depPlatform) _pkgParams.set('platform', _depPlatform);
}
// Pass the detected backend so the server can build a single
// OS+backend-aware install command per row (e.g. add nvidia-cuda-toolkit
@@ -1063,6 +1115,13 @@ async function _fetchDependencies() {
if (_depBackend && _hwfitCache?._scannedHost === _depHost) {
_pkgParams.set('backend', _depBackend);
}
if (_cachedModelIds && _cachedModelIds.size) {
const _hint = Array.from(_cachedModelIds)
.filter(id => /krea/i.test(String(id || '')))
.slice(0, 20)
.join(',');
if (_hint) _pkgParams.set('model_hint', _hint);
}
const resp = await fetch('/api/cookbook/packages' + (_pkgParams.toString() ? '?' + _pkgParams.toString() : ''));
const data = await resp.json();
const pkgs = data.packages || [];
@@ -1098,9 +1157,13 @@ async function _fetchDependencies() {
vllm: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>',
sglang: '<span aria-hidden="true" style="display:block;width:13px;height:13px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
mlx_lm: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"/><path d="M16 6v12"/><path d="M20 6v12"/></svg>',
mflux: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>',
boogu_image_mlx: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M7 17c2.5-4 4.5-4 7 0"/><circle cx="9" cy="9" r="1"/><circle cx="15" cy="9" r="1"/></svg>',
llama_cpp: '<svg width="13" height="13" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"/><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"/></svg>',
ollama: '<img src="/static/icons/ollama-mark-crop.png" alt="" aria-hidden="true" width="13" height="13" style="display:block;width:13px;height:13px;object-fit:contain;" />',
diffusers: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
krea_diffusers: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 19V5"/><path d="M4 12h4"/><path d="M12 5l-7 7 7 7"/><path d="M14 19l3-14 3 14"/><path d="M15.3 13h3.4"/></svg>',
sam_mask: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7c3-3 13-3 16 0"/><path d="M4 17c3 3 13 3 16 0"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3"/></svg>',
};
const _depGlyphHtml = (name) => {
const g = _DEP_GLYPHS[name];
@@ -1143,23 +1206,6 @@ async function _fetchDependencies() {
const _buildDepsBtn = _bdm.length
? `<button type="button" class="cookbook-dep-tag cookbook-dep-install cookbook-dep-install-sysdeps" data-dep-sysdeps="${esc(_bdm.join(','))}" data-dep-target="${isLocal ? 'local' : 'remote'}" title="Install ${esc(_bdm.join(', '))} via the OS package manager on this target (requires passwordless sudo or root).">Install build deps</button>`
: '';
// Render the target-specific install command as a compact mono box
// when the server resolved it (target's /etc/os-release was readable
// AND the backend is known). The box doubles as the source of truth
// for the "Install build deps" button's failure toast — both surfaces
// show the same string for the same target.
const _instCmd = (_bdm.length && pkg.install_cmd_for_target) ? String(pkg.install_cmd_for_target) : '';
const _instCmdOs = pkg.install_cmd_os ? String(pkg.install_cmd_os) : '';
const _instCmdBe = pkg.install_cmd_backend ? String(pkg.install_cmd_backend) : '';
const _instLabel = (_instCmdOs && _instCmdBe) ? `${_instCmdOs} + ${_instCmdBe}` : (_instCmdOs || _instCmdBe || 'this target');
const _instCmdBox = _instCmd
? `<div class="cookbook-dep-install-cmd" data-dep-cmd="${esc(_instCmd)}" style="margin-top:6px;font-size:10.5px;opacity:0.85;">`
+ `<div style="opacity:0.65;margin-bottom:2px;">Install on ${esc(_instLabel)}:</div>`
+ `<div style="display:flex;gap:4px;align-items:stretch;">`
+ `<code style="flex:1;padding:4px 6px;background:color-mix(in srgb, var(--fg) 6%, transparent);border:1px solid var(--border);border-radius:4px;font-family:var(--mono, ui-monospace, monospace);font-size:10.5px;white-space:pre-wrap;word-break:break-all;">${esc(_instCmd)}</code>`
+ `<button type="button" class="cookbook-dep-cmd-copy" data-dep-cmd-copy="${esc(_instCmd)}" title="Copy install command" style="padding:2px 8px;font-size:10px;border:1px solid var(--border);border-radius:4px;background:none;cursor:pointer;color:var(--fg-muted);">Copy</button>`
+ `</div></div>`
: '';
// Partial-state row (replaces the cryptic yellow "Partial ▾" tag).
// Renders inline as a yellow banner with two clear actions: one-tap
// Install (runs the reinstall in cookbook) or Copy command (paste
@@ -1179,7 +1225,6 @@ async function _fetchDependencies() {
+ `<div class="memory-item-meta" style="font-size:10px;opacity:0.5;margin-top:2px;">${esc(pkg.desc)}</div>`
+ note
+ updateNote
+ _instCmdBox
+ `</div>`
+ _rebuildBtn
+ _buildDepsBtn
@@ -1194,13 +1239,21 @@ async function _fetchDependencies() {
// the user sees a paste-ready sequence; Run keeps using env_prefix to
// activate the same venv before the pip command. Docker variant skips
// the activate line — `docker pull` doesn't need a venv.
function _recipeRuntimeCommands(commands, variant) {
if (variant === 'docker') return commands;
const envPath = (_envState.envPath || '').replace(/\/+$/, '');
if (_envState.env !== 'venv' || !envPath) return commands;
const py = _shellQuote(`${envPath}/bin/python3`);
return commands.map(cmd => String(cmd || '').replace(/^python(\s+-m\s+pip\b)/, `${py}$1`));
}
function _recipeDisplayText(commands, variant) {
const runtimeCommands = _recipeRuntimeCommands(commands, variant);
if (variant === 'docker') return commands.join('\n');
const envPath = (_envState.envPath || '').replace(/\/+$/, '');
const activate = envPath
? `source ${envPath}${envPath.endsWith('/bin/activate') ? '' : '/bin/activate'}`
: '# (activate your venv first)';
return [activate, ...commands].join('\n');
return [activate, ...runtimeCommands].join('\n');
}
// Per-backend recipe panel (model picker + commands + Copy/Run).
@@ -1224,18 +1277,19 @@ async function _fetchDependencies() {
const initial = pickRecipe(backend, '') || candidates[0];
const initialVariant = RECIPE_DEFAULT_VARIANT;
const initialCmds = recipeCommands(initial, initialVariant);
const initialRuntimeCmds = _recipeRuntimeCommands(initialCmds, initialVariant);
const rightActive = initialVariant === 'docker' ? ' mode-right' : '';
return `<div class="cookbook-dep-recipe-panel" data-dep-recipe-panel="${esc(backend)}" data-dep-recipe-active-variant="${esc(initialVariant)}" style="display:none;margin:-4px 0 8px;padding:8px 12px 10px;background:rgba(0,0,0,0.04);border:1px solid var(--border);border-top:none;border-radius:0 0 6px 6px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<span style="font-size:11px;opacity:0.75;flex-shrink:0;">Serving which model?</span>
<select class="settings-select cookbook-dep-recipe-pick" data-dep-recipe-pick="${esc(backend)}" style="flex:1;font-size:11px;padding:3px 6px;">${opts}</select>
<div class="mode-toggle${rightActive}" data-dep-recipe-variants="${esc(backend)}" style="flex-shrink:0;">
<button type="button" class="mode-toggle-btn${initialVariant === 'pip' ? ' active' : ''}" data-dep-recipe-variant="${esc(backend)}" data-variant="pip" aria-pressed="${initialVariant === 'pip'}">Pip/uv</button>
<button type="button" class="mode-toggle-btn${initialVariant === 'pip' ? ' active' : ''}" data-dep-recipe-variant="${esc(backend)}" data-variant="pip" aria-pressed="${initialVariant === 'pip'}">Pip</button>
<button type="button" class="mode-toggle-btn${initialVariant === 'docker' ? ' active' : ''}" data-dep-recipe-variant="${esc(backend)}" data-variant="docker" aria-pressed="${initialVariant === 'docker'}">Docker</button>
</div>
</div>
<div style="position:relative;">
<pre class="cookbook-dep-recipe-cmds" data-dep-recipe-cmds="${esc(backend)}" data-dep-recipe-install="${esc(initialCmds.join('\n'))}" style="margin:0;padding:8px 36px 8px 10px;background:rgba(0,0,0,0.08);border-radius:4px;font-size:11px;line-height:1.5;overflow-x:auto;white-space:pre;">${esc(_recipeDisplayText(initialCmds, initialVariant))}</pre>
<pre class="cookbook-dep-recipe-cmds" data-dep-recipe-cmds="${esc(backend)}" data-dep-recipe-install="${esc(initialRuntimeCmds.join('\n'))}" style="margin:0;padding:8px 36px 8px 10px;background:rgba(0,0,0,0.08);border-radius:4px;font-size:11px;line-height:1.5;overflow-x:auto;white-space:pre;">${esc(_recipeDisplayText(initialCmds, initialVariant))}</pre>
<button type="button" id="recipe-copy-${esc(backend)}" class="cookbook-dep-recipe-copy" data-dep-recipe-copy="${esc(backend)}" title="Copy" aria-label="Copy" style="position:absolute;top:6px;right:6px;padding:3px 5px;background:none;border:none;color:inherit;opacity:0.7;cursor:pointer;display:inline-flex;align-items:center;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
</div>
<div style="display:flex;gap:6px;justify-content:flex-end;margin-top:6px;">
@@ -1244,18 +1298,99 @@ async function _fetchDependencies() {
</div>`;
}
const _rowsHtml = (items) => items.map(_depRow).join('');
const _sectionHeader = (title, note) =>
`<div class="cookbook-dep-section"><span class="cookbook-dep-section-title">${title}</span><span class="cookbook-dep-section-note">${note}</span></div>`;
const _section = (title, note, items) =>
items.length
? `<div class="cookbook-dep-section"><span class="cookbook-dep-section-title">${title}</span><span class="cookbook-dep-section-note">${note}</span></div>` + items.map(_depRow).join('')
items.length ? _sectionHeader(title, note) + _rowsHtml(items) : '';
const _pkgOrder = {
System: ['tmux', 'docker'],
Tools: ['hf_transfer'],
LLM: ['llama_cpp', 'sglang', 'vllm', 'mlx_lm'],
Image: ['diffusers', 'krea_diffusers', 'transformers', 'sam_mask', 'mflux', 'boogu_image_mlx', 'mlx_vlm'],
};
const _sortDeps = (items, category) => {
const order = _pkgOrder[category] || [];
return [...items].sort((a, b) => {
const ai = order.indexOf(a.name);
const bi = order.indexOf(b.name);
const ar = ai === -1 ? 999 : ai;
const br = bi === -1 ? 999 : bi;
return ar - br || String(a.name || '').localeCompare(String(b.name || ''));
});
};
const _serverDepsHtml = (items) => {
const byCat = new Map();
for (const item of items) {
const cat = item.category || 'Other';
if (!byCat.has(cat)) byCat.set(cat, []);
byCat.get(cat).push(item);
}
const parts = [];
const order = ['System', 'Tools', 'Image', 'LLM', 'Audio', 'Other'];
for (const cat of order) {
const catItems = _sortDeps(byCat.get(cat) || [], cat);
if (!catItems.length) continue;
if (cat === 'Image') {
const mlxNames = new Set(['mflux', 'boogu_image_mlx', 'mlx_vlm']);
const general = catItems.filter(p => !mlxNames.has(p.name));
const mlx = catItems.filter(p => mlxNames.has(p.name));
parts.push(_sectionHeader('Image', 'Diffusers and shared image tooling.'));
if (general.length) parts.push(_rowsHtml(general));
if (mlx.length) {
parts.push(
`<div class="cookbook-dep-subgroup">`
+ `<div class="cookbook-dep-subgroup-title"><span>MLX image runtimes</span><em>Apple Silicon only</em></div>`
+ _rowsHtml(mlx)
+ `</div>`
);
}
continue;
}
const note = cat === 'System'
? 'OS tools needed for background tasks.'
: cat === 'LLM'
? 'Text model serving engines and download helpers.'
: cat === 'Tools'
? 'Browser and assistant utilities.'
: '';
parts.push(_section(cat, note, catItems));
}
return parts.join('');
};
const _appDepsHtml = (items) => {
if (!items.length) return '';
const byCat = new Map();
for (const item of items) {
const cat = item.category || 'Other';
if (!byCat.has(cat)) byCat.set(cat, []);
byCat.get(cat).push(item);
}
const parts = [_sectionHeader('Odysseus app', 'Run inside the Odysseus app itself.')];
const order = ['System', 'Tools', 'Image', 'LLM', 'Audio', 'Other'];
for (const cat of order) {
const catItems = _sortDeps(byCat.get(cat) || [], cat);
if (!catItems.length) continue;
const note = cat === 'LLM'
? 'Local app model helpers.'
: cat === 'Image'
? 'Editor image tools.'
: cat === 'Tools'
? 'Browser and assistant utilities.'
: '';
parts.push(_section(cat, note, catItems));
}
return parts.join('');
};
const _viewingRemote = !!(_dsel && _dsel.value && _dsel.value !== 'local');
const _appDeps = pkgs.filter(p => p.target === 'local');
const _serverDeps = pkgs.filter(p => p.target !== 'local');
const _visibleDep = (p) => p.applicable !== false || p.installed || (p.kind === 'system' && p.name !== 'APFEL');
const _appDeps = pkgs.filter(p => p.target === 'local' && _visibleDep(p));
const _serverDeps = pkgs.filter(p => p.target !== 'local' && _visibleDep(p));
list.innerHTML = [
_viewingRemote ? '' : _section('Odysseus app', 'Run inside the Odysseus app itself.', _appDeps),
_section('Server', 'Run on the server chosen above (Local, or a remote box over SSH).', _serverDeps),
_viewingRemote ? '' : _appDepsHtml(_appDeps),
_serverDepsHtml(_serverDeps),
].join('');
// Shared install/update routine — used by the Install button and the
@@ -1275,8 +1410,11 @@ async function _fetchDependencies() {
}
}
const targetHost = isLocalOnly ? 'this server' : ((targetServer?.host || _envState.remoteHost) || 'local');
const targetEnv = isLocalOnly ? 'none' : (targetServer?.env || _envState.env || 'none');
let targetEnv = isLocalOnly ? 'none' : (targetServer?.env || _envState.env || 'none');
const targetEnvPath = isLocalOnly ? '' : (targetServer?.envPath || _envState.envPath || '');
if (!isLocalOnly && targetEnvPath && (!targetEnv || targetEnv === 'none')) {
targetEnv = /(?:^|\/)(?:\.?venv|env)(?:\/|$)|\/bin\/activate$/i.test(targetEnvPath) ? 'venv' : targetEnv;
}
const targetPlatform = isLocalOnly ? (_envState.hostPlatform || _envState.platform || '') : (targetServer?.platform || _envState.platform || '');
const targetRemoteHost = isLocalOnly ? '' : (targetServer?.host || _envState.remoteHost || '');
// Always go through `python -m pip` so the leading token is `python`
@@ -1300,7 +1438,14 @@ async function _fetchDependencies() {
} else {
_py = 'python3';
}
const cmd = `${_py} -m pip install${upgrade ? ' -U' : ''}${_pipFlags} "${pipName}"`;
const pipArgs = String(pipName || '')
.trim()
.split(/\s+/)
.filter(Boolean)
.map(_shellQuote)
.join(' ');
const depTaskId = String(pkgName || pipName || 'dependency').trim().replace(/\s+/g, '_');
const cmd = `${_py} -m pip install${upgrade ? ' -U' : ''}${_pipFlags} ${pipArgs}`;
let envPrefix = '';
if (_isWindows()) {
if (targetEnv === 'venv' && targetEnvPath) {
@@ -1318,7 +1463,7 @@ async function _fetchDependencies() {
}
try {
const reqBody = {
repo_id: pipName,
repo_id: depTaskId,
cmd: cmd,
remote_host: targetRemoteHost || undefined,
ssh_port: _getPort(targetRemoteHost) || undefined,
@@ -1347,7 +1492,7 @@ async function _fetchDependencies() {
}
// _dep flags this as a pip dependency/driver install (not a servable
// model) so the running-task card doesn't offer a "Serve →" button.
const payload = { repo_id: pipName, _cmd: cmd, remote_host: targetRemoteHost || '', _dep: true, env_path: targetEnvPath || '', platform: targetPlatform || '' };
const payload = { repo_id: depTaskId, _cmd: cmd, remote_host: targetRemoteHost || '', _dep: true, env_path: targetEnvPath || '', platform: targetPlatform || '' };
_addTask(data.session_id, 'pip ' + pkgName, 'download', payload);
if (statusEl) { statusEl.textContent = upgrade ? 'Updating...' : 'Installing...'; statusEl.disabled = true; }
uiModule.showToast(`${upgrade ? 'Updating' : 'Installing'} ${pkgName} on ${targetHost}...`);
@@ -1422,9 +1567,8 @@ async function _fetchDependencies() {
});
});
// Inline command-box "Copy" buttons — one per row that has a
// resolved per-target install command. Same string surfaces here
// and in the toast/diagnosis so the user always sees one answer.
// Inline command "Copy" buttons, currently used by targeted recipe
// repair panels such as the llama.cpp CUDA wheel reinstall.
list.querySelectorAll('.cookbook-dep-cmd-copy').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
@@ -1443,12 +1587,6 @@ async function _fetchDependencies() {
const names = (btn.dataset.depSysdeps || '').split(',').map(s => s.trim()).filter(Boolean);
if (!names.length) return;
const isLocal = btn.dataset.depTarget === 'local';
// Pull the per-target install command from the sibling box on
// the same row, so failure toasts surface the SAME line the
// user already sees inline. No duplicated formatting logic.
const _row = btn.closest('.cookbook-dep-row');
const _cmdBox = _row?.querySelector('.cookbook-dep-install-cmd');
const _resolvedCmd = _cmdBox?.dataset.depCmd || '';
// Mirror _installDep: the Dependencies tab has its own server
// picker that can override _envState. Apply it before reading
// remoteHost, otherwise the install silently runs on the wrong
@@ -1481,18 +1619,10 @@ async function _fetchDependencies() {
try { await _fetchDependencies(); } catch {}
} else {
const reason = data.error || data.detail || `HTTP ${res.status}`;
// Append the per-target install command (if we already know it
// from the row) so the user can copy-paste it without leaving
// the toast. Otherwise just surface the error.
const _suffix = _resolvedCmd ? `\n\nRun on ${targetLabel}: ${_resolvedCmd}` : '';
uiModule.showToast('System dependency install failed: ' + String(reason).slice(0, 300) + _suffix, {
uiModule.showToast('System dependency install failed: ' + String(reason).slice(0, 300), {
duration: 25000,
action: _resolvedCmd ? 'Copy command' : 'OK',
onAction: async () => {
if (_resolvedCmd) {
try { await navigator.clipboard.writeText(_resolvedCmd); } catch {}
}
},
action: 'OK',
onAction: () => {},
});
btn.textContent = origText;
btn.disabled = false;
@@ -1532,17 +1662,18 @@ async function _fetchDependencies() {
const sel = panel.querySelector('[data-dep-recipe-pick]');
const recipe = pickRecipe(backend, (sel && sel.value) || '');
const cmds = recipeCommands(recipe, variant);
const runtimeCmds = _recipeRuntimeCommands(cmds, variant);
const pre = panel.querySelector('[data-dep-recipe-cmds]');
if (pre) {
pre.textContent = _recipeDisplayText(cmds, variant);
pre.dataset.depRecipeInstall = cmds.join('\n');
pre.dataset.depRecipeInstall = runtimeCmds.join('\n');
}
}
// Model select: pickRecipe matches the model id against the catalog.
list.querySelectorAll('[data-dep-recipe-pick]').forEach(sel => {
sel.addEventListener('change', () => _refreshRecipePre(sel.dataset.depRecipePick));
});
// Variant toggle (Pip/uv vs Docker): mirrors the agent/chat mode-toggle
// Variant toggle (Pip vs Docker): mirrors the agent/chat mode-toggle
// pattern — buttons get .active, container gets .mode-right when the
// right slot is selected so the sliding pill animates over.
list.querySelectorAll('[data-dep-recipe-variant]').forEach(btn => {
@@ -1595,16 +1726,26 @@ async function _fetchDependencies() {
// displayed source line is for the user's reading; env_prefix
// handles it for the actual run.
const installRaw = pre.dataset.depRecipeInstall || pre.textContent;
const cmd = installRaw.split('\n').map(s => s.trim()).filter(Boolean).join(' && ');
const depsSel = document.getElementById('hwfit-deps-server');
if (depsSel) _applyServerSelection(depsSel.value);
const targetHost = _envState.remoteHost || 'local';
const inferredVenv = _envState.envPath && (!_envState.env || _envState.env === 'none')
&& /(?:^|\/)(?:\.?venv|env)(?:\/|$)|\/bin\/activate$/i.test(_envState.envPath);
const recipeEnv = inferredVenv ? 'venv' : _envState.env;
const recipePy = (recipeEnv === 'venv' && _envState.envPath)
? `${_envState.envPath.replace(/\/+$/, '').replace(/\/bin\/activate$/i, '')}/bin/python3`
: '';
const cmd = installRaw.split('\n').map(s => {
let line = s.trim();
if (recipePy) line = line.replace(/^python(?:3)?\s+-m\s+pip\b/, `${recipePy} -m pip`);
return line;
}).filter(Boolean).join(' && ');
// Build env_prefix from the configured envPath (matches _installDep).
let envPrefix = '';
if (_envState.env === 'venv' && _envState.envPath) {
if (recipeEnv === 'venv' && _envState.envPath) {
const p = _envState.envPath;
envPrefix = 'source ' + _shellQuote(p.endsWith('/bin/activate') ? p : p + '/bin/activate');
} else if (_envState.env === 'conda' && _envState.envPath) {
} else if (recipeEnv === 'conda' && _envState.envPath) {
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
}
const reqBody = {
@@ -2013,6 +2154,27 @@ function _wireTabEvents(body) {
hwRefreshBtn.addEventListener('click', _refreshScanDownloadTarget);
}
const hwAdvancedBtn = document.getElementById('hwfit-advanced-btn');
const hwAdvancedPanel = document.getElementById('hwfit-advanced-panel');
if (hwAdvancedBtn && hwAdvancedPanel && !hwAdvancedBtn.dataset.bound) {
hwAdvancedBtn.dataset.bound = '1';
const setAdvancedOpen = (open) => {
hwAdvancedPanel.classList.toggle('hidden', !open);
hwAdvancedBtn.classList.toggle('active', open);
hwAdvancedBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
};
hwAdvancedBtn.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
setAdvancedOpen(hwAdvancedPanel.classList.contains('hidden'));
});
hwAdvancedPanel.addEventListener('click', (ev) => ev.stopPropagation());
document.addEventListener('click', () => setAdvancedOpen(false));
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') setAdvancedOpen(false);
});
}
const editDirsLink = document.querySelector('.cookbook-serve-dir-edit');
if (editDirsLink) {
editDirsLink.addEventListener('click', () => {
@@ -2926,15 +3088,39 @@ function _renderRecipes() {
html += '</div>';
html += '<p class="memory-desc doclib-desc" style="margin-top:6px;">Scans your hardware for what models you can run. Hardware is cached; hit the scan button to re-probe after changing GPUs.</p>';
html += '<div class="hwfit-toolbar" style="margin-top:9px;">';
html += '<select class="cookbook-field-input hwfit-usecase" id="hwfit-usecase" style="height:28px;">';
html += '<option value="general" selected>Standard</option>';
// Image tab removed — text→image gen is gone from this build (only inpaint
// remains, which uses its own settings panel). Vision (multimodal) stays.
html += '<option value="multimodal">Vision</option></select>';
// Search moved next to the Type filter so the two primary picks
// (what category + free text) sit together; the more advanced
// levers (Engine / Quant / Context) live to the right.
html += '<select class="cookbook-field-input hwfit-server-select" id="hwfit-server-select" style="height:28px;min-width:88px;position:relative;top:0px;">';
html += _buildServerOpts(false);
html += '</select>';
// Keep the main scan toolbar light: server + free-text search. Advanced
// levers (Engine / Quant / Context) live behind the cog beside Refresh.
html += '<input type="text" class="cookbook-field-input hwfit-search" id="hwfit-search" placeholder="Search models..." style="flex:1;" />';
html += '</div>';
html += '<div class="hwfit-toolbar" style="margin-top:7px;">';
html += '<span class="hwfit-usecase-wrap">';
html += '<select class="cookbook-field-input hwfit-usecase" id="hwfit-usecase" style="display:none;height:28px;">';
html += '<option value="general" selected>Standard</option>';
html += '<option value="multimodal">Vision</option>';
html += '<option value="image_gen">Image</option></select>';
html += '<button type="button" class="cookbook-field-input hwfit-usecase-btn" data-hwfit-usecase-btn aria-haspopup="listbox" aria-expanded="false" title="Model type">';
html += '<span class="hwfit-usecase-btn-icon" data-hwfit-usecase-icon aria-hidden="true"></span>';
html += '<span class="hwfit-usecase-btn-label" data-hwfit-usecase-label>Standard</span>';
html += '<svg class="hwfit-usecase-caret" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>';
html += '</button>';
html += '<div class="hwfit-usecase-menu" data-hwfit-usecase-menu role="listbox" hidden></div>';
html += '</span>';
html += '<div class="hwfit-gpu-toggles" id="hwfit-gpu-toggles"></div>';
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-manual-btn" id="hwfit-hw-manual-btn" title="Set hardware manually" style="flex-shrink:0;position:relative;top:-3px;left:-1px;display:inline-flex;align-items:center;gap:3px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>EDIT</button>';
html += '<button type="button" class="hwfit-gpu-btn hwfit-advanced-btn" id="hwfit-advanced-btn" title="Scan settings" aria-label="Scan settings" aria-expanded="false" style="flex-shrink:0;position:relative;top:-3px;left:-3px;width:26px;height:26px;padding:0;display:inline-flex;align-items:center;justify-content:center;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06A1.65 1.65 0 0 0 8.92 4a1.65 1.65 0 0 0 1-1.51V2a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg></button>';
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-refresh-btn" id="hwfit-hw-refresh-btn" title="Refresh selected server hardware and cached models" aria-label="Refresh selected server hardware and cached models" style="flex-shrink:0;position:relative;top:-3px;left:-5px;width:26px;height:26px;padding:0;display:inline-flex;align-items:center;justify-content:center;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10"/><path d="M3.51 15a9 9 0 0 0 14.85 3.36L23 14"/></svg></button>';
// Sort state — the clickable column headers read/write this (pewds' original
// sort paradigm). Newest is reachable by clicking the Model column header.
html += '<select class="cookbook-field-input hwfit-sort" id="hwfit-sort" style="display:none">';
html += '<option value="newest" selected>Latest</option>';
html += '<option value="fit">Fit</option><option value="score">Score</option><option value="vram">VRAM</option>';
html += '<option value="speed">Speed</option><option value="params">Params</option>';
html += '<option value="context">Context</option></select>';
html += '</div>';
html += '<div class="hwfit-advanced-panel hidden" id="hwfit-advanced-panel" aria-label="Scan settings">';
html += '<span class="hwfit-engine-wrap">';
html += '<select class="cookbook-field-input hwfit-engine" id="hwfit-engine" style="display:none;" title="Filter by serving engine">';
html += '<option value="">Engine</option>';
@@ -2971,21 +3157,6 @@ function _renderRecipes() {
html += '<span>Context</span><span class="hwfit-help-chip hwfit-help-chip-inline" title="Context length. Lower it to find more models that could fit your hardware; raise it when you need longer chats or documents.">?</span><input type="range" id="hwfit-context" min="0" max="5" step="1" value="3" />';
html += '<output id="hwfit-context-label">50k</output></label>';
html += '</div>';
html += '<div class="hwfit-toolbar" style="margin-top:7px;">';
html += '<select class="cookbook-field-input hwfit-server-select" id="hwfit-server-select" style="height:28px;min-width:88px;position:relative;top:0px;">';
html += _buildServerOpts(false);
html += '</select>';
html += '<div class="hwfit-gpu-toggles" id="hwfit-gpu-toggles"></div>';
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-manual-btn" id="hwfit-hw-manual-btn" title="Set hardware manually" style="flex-shrink:0;position:relative;top:-3px;left:-1px;display:inline-flex;align-items:center;gap:3px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>EDIT</button>';
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-refresh-btn" id="hwfit-hw-refresh-btn" title="Refresh selected server hardware and cached models" aria-label="Refresh selected server hardware and cached models" style="flex-shrink:0;position:relative;top:-3px;left:-3px;width:26px;height:26px;padding:0;display:inline-flex;align-items:center;justify-content:center;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10"/><path d="M3.51 15a9 9 0 0 0 14.85 3.36L23 14"/></svg></button>';
// Sort state — the clickable column headers read/write this (pewds' original
// sort paradigm). Newest is reachable by clicking the Model column header.
html += '<select class="cookbook-field-input hwfit-sort" id="hwfit-sort" style="display:none">';
html += '<option value="newest" selected>Latest</option>';
html += '<option value="fit">Fit</option><option value="score">Score</option><option value="vram">VRAM</option>';
html += '<option value="speed">Speed</option><option value="params">Params</option>';
html += '<option value="context">Context</option></select>';
html += '</div>';
html += '<div class="hwfit-manual-panel hidden" id="hwfit-manual-panel">';
html += '<span class="hwfit-manual-note" style="font-size:10px;opacity:0.6;width:100%;margin-bottom:2px;">Simulator — these values REPLACE detected hardware.</span>';
html += '<select class="hwfit-manual-mode"><option value="gpu">GPU</option><option value="ram">RAM</option></select>';
+52 -23
View File
@@ -9,6 +9,7 @@ import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis
import { registerMenuDismiss } from './escMenuStack.js';
import { computeProgressSignal } from './cookbookProgressSignal.js';
import { portOf, nextFreePort } from './cookbookPorts.js';
import { topPortalZ } from './toolWindowZOrder.js';
// Human-friendly badge label for a task's internal status. Avoids surfacing
// the word "error" in the sidebar — a server the user stopped or one that
@@ -20,6 +21,18 @@ function _statusLabel(status, type) {
return status || '';
}
function _downloadBadgeText(progress) {
const raw = String(progress || '').trim();
if (!raw) return 'downloading';
const pct = raw.match(/(\d+)%/);
if (pct) return pct[0];
if (/^(?:Downloading|Fetching|Resuming)\s+'[^']+'\s+to\s+'[^']+/i.test(raw)
|| /^Downloading\s*\(incomplete\b/i.test(raw)) {
return 'downloading';
}
return raw;
}
// Single source of truth for what a task's status badge shows + its style class.
// Crucially, a serve task that's still coming up shows its live phase
// ("loading 45%", "warming up", …) rather than the generic "running" — they're
@@ -29,8 +42,7 @@ function _statusLabel(status, type) {
function _taskBadge(task) {
if (task._unreachable && task.status === 'running') return { text: 'unreachable', cls: 'cookbook-task-error' };
if (task.type === 'download' && task.status === 'running') {
const progress = String(task.progress || '').trim();
return { text: progress || _statusLabel(task.status, task.type), cls: 'cookbook-task-downloading' };
return { text: _downloadBadgeText(task.progress), cls: 'cookbook-task-downloading' };
}
if (task.type === 'serve' && task.status === 'running' && task.progress) {
// Same green "running" pill — just with dynamic phase text, so it doesn't
@@ -61,9 +73,12 @@ function _downloadNameFromPayload(name, payload) {
const rawName = String(name || '').trim();
// Defensive: failed/restarted downloads can inherit the wrapper executable
// name if older state was saved from a command preview. The row title should
// always be the model/repo, never "bash" or "python".
// always be the model/repo, never "bash", "python", or a live HF progress
// line like "Downloading 'vae/...' to '/mnt/...".
const looksLikeLauncher = /^(?:bash|sh|zsh|python|python3|pwsh|powershell|cmd|tmux)$/i.test(rawName);
const base = (!rawName || looksLikeLauncher)
const looksLikeProgressLine = /^(?:Downloading|Fetching|Resuming)\s+'[^']+'\s+to\s+'[^']+/i.test(rawName)
|| /^Downloading\s*\(incomplete\b/i.test(rawName);
const base = (!rawName || looksLikeLauncher || looksLikeProgressLine)
? String(payload?.repo_id || payload?.repo || '').split('/').pop()
: rawName;
const include = payload?.include || '';
@@ -636,6 +651,11 @@ function _appendPinnedServeModel(fd, task) {
if (expected) fd.append('pinned_models', expected);
}
function _isImageServeTask(task) {
const cmd = String(task?.payload?._cmd || '');
return cmd.includes('diffusion_server') || cmd.includes('mlx_image_server');
}
// ── Download queue — runs one at a time per server ──
function _processQueue() {
@@ -1274,7 +1294,7 @@ function _autoSaveWorkingConfig(task) {
if (task._autoSaved) return;
const cmd = task.payload._cmd;
// Diffusion/image servers aren't vLLM presets — skip them.
if (cmd.includes('diffusion_server')) { task._autoSaved = true; return; }
if (cmd.includes('diffusion_server') || cmd.includes('mlx_image_server')) { task._autoSaved = true; return; }
const model = task.payload.repo_id || task.name;
const presets = _loadPresets();
const existing = presets.find(p => p.cmd === cmd);
@@ -1754,13 +1774,14 @@ function _parseServeCmdToFields(cmd) {
const ex = (re) => { const m = cmd.match(re); return m ? m[1] : ''; };
const fields = {
backend: cmd.includes('llama_cpp') || cmd.includes('llama-server') ? 'llamacpp'
: cmd.includes('mlx_image_server') ? 'mlx_image'
: cmd.includes('mlx_lm.server') ? 'mlx'
: cmd.includes('diffusion_server') ? 'diffusers'
: cmd.includes('sglang') ? 'sglang'
: cmd.includes('ollama') ? 'ollama' : 'vllm',
port: ex(/--port\s+(\d+)/) || '8000',
tp: ex(/--tensor-parallel-size\s+(\d+)/) || '1',
ctx: ex(/--max-model-len\s+(\d+)/) || ex(/--n_ctx\s+(\d+)/) || ex(/-c\s+(\d+)/) || '8192',
ctx: ex(/--max-model-len\s+(\d+)/) || ex(/--context-length\s+(\d+)/) || ex(/--max-tokens\s+(\d+)/) || ex(/--n_ctx\s+(\d+)/) || ex(/-c\s+(\d+)/) || '8192',
gpu_mem: ex(/--gpu-memory-utilization\s+([\d.]+)/) || '0.90',
swap: ex(/--swap-space\s+(\d+)/) || '',
dtype: ex(/--dtype\s+(\w+)/) || 'auto',
@@ -1796,7 +1817,7 @@ function _serveCmdNeedsGpuPreflight(cmd, repo) {
const c = String(cmd || '').toLowerCase();
const r = String(repo || '').toLowerCase();
if (!c || /gpu-cleanup|sglang-kernel|mlx-lm|pip\s+install|python\d*\s+-m\s+pip/.test(`${r} ${c}`)) return false;
return /\b(vllm\s+serve|sglang(?:\.launch_server|\s+serve)|mlx_lm\.server|llama-server|llama_cpp\.server|text-generation-launcher|aphrodite|ollama\s+(?:serve|run))\b/.test(c);
return /\b(vllm\s+serve|sglang(?:\.launch_server|\s+serve)|mlx_lm\.server|mlx_image_server\.py|diffusion_server\.py|llama-server|llama_cpp\.server|text-generation-launcher|aphrodite|ollama\s+(?:serve|run))\b/.test(c);
}
function _selectedGpuIndexes(gpus) {
@@ -1900,6 +1921,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
const _serverMetaName = targetMeta?.serverName || _hsrv.name || (_host ? _host : 'Local');
const _hplatform = _host ? (_hsrv.platform || '') : (_envState.hostPlatform || '');
const _replaceTaskId = fields?._replaceTaskId || '';
const _launchAnyway = !!targetMeta?.launchAnyway;
if (_replaceTaskId) {
try {
const _old = _loadTasks().find(t => t.sessionId === _replaceTaskId);
@@ -1917,7 +1939,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
// servers on one port, so re-serving (or retrying) should stop & remove the
// old one instead of leaving a dead duplicate behind. (The retry buttons
// already removed their own task, so this is a no-op for them.)
try {
if (!_launchAnyway) try {
const _pm = cmd.match(/--port[=\s]+(\d+)/) || cmd.match(/(?:^|\s)-p[=\s]+(\d+)/);
const _newPort = _pm ? _pm[1] : '';
if (_newPort) {
@@ -2374,14 +2396,15 @@ export function _renderRunningTab() {
const _bdg = _taskBadge(task);
const _bdgTitle = (task._unreachable && task.status === 'running') ? ' title="Server not responding — it may have crashed"' : '';
const displayName = _taskDisplayName(task);
const logoName = task.type === 'download' ? (task.payload?.repo_id || task.name) : task.name;
el.innerHTML = `
<div class="cookbook-task-header">
<span class="cookbook-task-type${(task.status === 'done' && task.type === 'download') ? ' cookbook-task-type-done' : ''}" data-type="${esc(task.type)}">${esc((task.status === 'done' && task.type === 'download') ? 'finished' : task.type)}</span>
<span class="cookbook-task-name">${modelLogo(task.name)}${esc(displayName)}</span>
<span class="cookbook-task-name">${modelLogo(logoName)}${esc(displayName)}</span>
<span class="cookbook-task-indicator"><span class="cookbook-task-wave" style="display:${task.status === 'running' ? '' : 'none'}"></span>${_canLaunchDownloadedTask(task) ? '<button type="button" class="cookbook-task-serve-btn" title="Open in Launch"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg><span>Launch</span></button>' : ''}<span class="cookbook-task-check" title="Clear" style="display:${_canClearTask(task) ? '' : 'none'}"><svg class="cookbook-task-check-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#50fa7b" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg><svg class="cookbook-task-clear-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span class="cookbook-task-done-label">${esc(_clearPillLabel(task))}</span><span class="cookbook-task-clear-label">clear</span></span></span>
<button type="button" class="cookbook-task-start-now" title="Start this queued download now" style="display:${(task.type === 'download' && task.status === 'queued') ? '' : 'none'}"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><polygon points="8 5 19 12 8 19 8 5"/></svg><span>start now</span></button>
<span class="cookbook-task-status ${_bdg.cls}"${_bdgTitle}>${esc(_bdg.text)}</span>
<button class="cookbook-task-menu-btn" title="Actions">&#8942;</button>
<button type="button" class="cookbook-task-menu-btn" title="Actions">&#8942;</button>
</div>
<div class="cookbook-task-sub"><span class="cookbook-task-session">${esc(task.sessionId)}</span><span class="cookbook-task-uptime" style="display:${((task.type === 'serve' || task.type === 'download') && task.status === 'running') ? '' : 'none'}"></span>${(task.type === 'download') ? `<span class="cookbook-task-dldir" title="Download destination" style="font-size:9px;color:var(--fg-muted);font-family:'Fira Code',monospace;opacity:0.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:40ch;">Dir: ${esc(task.payload?.local_dir || '~/.cache/huggingface/hub')}</span>` : ''}</div>
<div class="cookbook-output-wrap cookbook-task-collapsible${(_mobileCollapseDefault && !_shouldAutoExpandTaskOutput(task)) ? ' cookbook-task-collapsed' : ''}"><pre class="cookbook-output-pre">${esc(task.output || '')}</pre><button type="button" class="copy-code cookbook-output-copy"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div>
@@ -2572,8 +2595,10 @@ export function _renderRunningTab() {
el.addEventListener('touchmove', _lpMove, { passive: true });
el.addEventListener('touchend', _lpCancel, { passive: true });
el.addEventListener('touchcancel', _lpCancel, { passive: true });
menuBtn.addEventListener('click', (e) => {
let _lastTouchMenuOpenAt = 0;
const _openTaskMenu = (e) => {
e.stopPropagation();
if (e.type === 'click' && Date.now() - _lastTouchMenuOpenAt < 550) return;
const existing = document.querySelector('.cookbook-task-dropdown');
if (existing && existing._anchor === menuBtn) {
if (typeof existing._dismiss === 'function') existing._dismiss();
@@ -2646,7 +2671,7 @@ export function _renderRunningTab() {
fd.append('name', task.name);
fd.append('skip_probe', 'true');
_appendCookbookEndpointScope(fd, task.remoteHost || '');
if (task.payload?._cmd?.includes('diffusion_server')) fd.append('model_type', 'image');
if (_isImageServeTask(task)) fd.append('model_type', 'image');
const res = await fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
if (res.ok) {
task._endpointAdded = true;
@@ -2764,6 +2789,7 @@ export function _renderRunningTab() {
const rect = menuBtn.getBoundingClientRect();
dropdown.style.position = 'fixed';
dropdown.style.zIndex = String(topPortalZ());
dropdown.style.top = rect.bottom + 2 + 'px';
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
document.body.appendChild(dropdown);
@@ -2811,7 +2837,13 @@ export function _renderRunningTab() {
window.visualViewport?.addEventListener('scroll', scrollClose);
}, 0);
_unreg = registerMenuDismiss(_cleanup);
});
};
menuBtn.addEventListener('click', _openTaskMenu);
menuBtn.addEventListener('touchend', (e) => {
e.preventDefault();
_lastTouchMenuOpenAt = Date.now();
_openTaskMenu(e);
}, { passive: false });
}
// Hidden action buttons for menu dispatch
@@ -3619,7 +3651,7 @@ async function _reconnectTask(el, task) {
if (_ex && _ex.id && !(_ex.models || []).length) _probeEndpointUntilOnline(_ex.id, host, port);
return null;
}
const _isDiffusion = task.payload?._cmd?.includes('diffusion_server');
const _isDiffusion = _isImageServeTask(task);
const fd = new FormData();
fd.append('base_url', baseUrl);
fd.append('name', task.name);
@@ -4236,7 +4268,6 @@ async function _pollBackgroundStatus() {
for (const t of readyServes) {
const localTasks = _loadTasks();
const localTask = localTasks.find(lt => lt.sessionId === t.session_id);
if (localTask && localTask._endpointAdded) continue;
let host = _connectHostFromRemote(localTask?.remoteHost || t.remote);
const portMatch = localTask?.payload?._cmd?.match(/--port\s+(\d+)/)
@@ -4249,9 +4280,9 @@ async function _pollBackgroundStatus() {
const endpoint = _endpointFromAdvertisedUrl(ollamaUrlMatch[1], host, '11434');
if (endpoint) ({ host, port, baseUrl } = endpoint);
}
const _isDiffusion = localTask?.payload?._cmd?.includes('diffusion_server');
const _isDiffusion = _isImageServeTask(localTask);
_updateTask(t.session_id, { _serveReady: true, _endpointAdded: true });
_updateTask(t.session_id, { _serveReady: true });
if (localTask) _autoSaveWorkingConfig(localTask); // remember working settings (modal may be closed)
// Auto-detect function-calling support from the serve cmd.
@@ -4273,6 +4304,7 @@ async function _pollBackgroundStatus() {
_markServeEndpointMismatch(taskForMatch, existing, host, port);
return null;
}
_updateTask(t.session_id, { _endpointAdded: true });
// Already registered — but it may be showing offline because
// it was added while the server was still warming. Kick a
// re-probe so it flips online without manual toggle.
@@ -4291,6 +4323,7 @@ async function _pollBackgroundStatus() {
})
.then(async (res) => {
if (res && res.ok) {
_updateTask(t.session_id, { _endpointAdded: true });
uiModule.showToast(`Model endpoint added: ${host}:${port}`);
const data = await res.json().catch(() => ({}));
// A just-started server often can't answer the 1s add-time
@@ -4328,12 +4361,8 @@ async function _pollBackgroundStatus() {
statusEl.textContent = 'cooking';
}
} else {
var _dlProgress = '';
if (t.progress) {
var _pctMatch = t.progress.match(/(\d+)%/);
_dlProgress = _pctMatch ? ` ${_pctMatch[0]}` : '';
}
statusEl.textContent = `downloading${_dlProgress}`;
const _dlText = _downloadBadgeText(t.progress);
statusEl.textContent = _dlText === 'downloading' ? 'downloading' : `downloading ${_dlText}`;
}
statusEl.style.display = '';
} else if (errorTasks.length > 0) {
+357 -52
View File
@@ -46,7 +46,7 @@ const SERVE_STATE_KEY = 'cookbook-serve-state';
const SERVE_FAVORITES_KEY = 'cookbook-serve-favorite-models';
let _cachedAllModels = [];
const _CACHED_MODELS_SCAN_KEY = 'cookbook_cached_models_scan_v1';
const _CACHED_MODELS_SCAN_KEY = 'cookbook_cached_models_scan_v3_ltx_video';
const _CACHED_MODELS_SCAN_TTL = 6 * 3600 * 1000;
function _normalizeCookbookModelDir(dir) {
@@ -54,6 +54,47 @@ function _normalizeCookbookModelDir(dir) {
return /^(home|mnt|media|data|opt|srv|var)\//.test(d) ? `/${d}` : d;
}
function _serveCmdPort(cmd) {
const s = String(cmd || '');
const m = s.match(/--port[=\s]+(\d+)/)
|| s.match(/(?:^|\s)-p[=\s]+(\d+)/)
|| s.match(/OLLAMA_HOST=[^:\s]+:(\d+)/);
return m ? m[1] : '';
}
function _replaceServeCmdPort(cmd, port) {
const s = String(cmd || '');
const p = String(port || '').trim();
if (!s || !p) return s;
if (/(^|\s)--port=\d+/.test(s)) return s.replace(/(^|\s)--port=\d+/, `$1--port=${p}`);
if (/(^|\s)--port\s+\d+/.test(s)) return s.replace(/(^|\s)--port\s+\d+/, `$1--port ${p}`);
if (/(^|\s)-p=\d+/.test(s)) return s.replace(/(^|\s)-p=\d+/, `$1-p=${p}`);
if (/(^|\s)-p\s+\d+/.test(s)) return s.replace(/(^|\s)-p\s+\d+/, `$1-p ${p}`);
if (/OLLAMA_HOST=([^:\s]+):\d+/.test(s)) return s.replace(/OLLAMA_HOST=([^:\s]+):\d+/, `OLLAMA_HOST=$1:${p}`);
return `${s} --port ${p}`;
}
function _nextServeLaunchPort(currentPort, runningMod, host, serverKey) {
const used = new Set();
try {
for (const t of (runningMod?._loadTasks ? runningMod._loadTasks() : [])) {
if (!t || t.type !== 'serve') continue;
if (!(t.status === 'queued' || t.status === 'running' || t.status === 'ready' || t._serveReady)) continue;
const sameTarget = ((t.remoteHost || '') === (host || ''))
|| ((t.remoteServerKey || '') === (serverKey || ''));
if (!sameTarget) continue;
const tp = runningMod?._taskPort ? runningMod._taskPort(t) : _serveCmdPort(t.payload?._cmd || t.cmd || '');
const n = parseInt(tp, 10);
if (Number.isFinite(n) && n > 0) used.add(n);
}
} catch {}
const start = parseInt(currentPort || '8000', 10) || 8000;
used.add(start);
let next = Math.max(1, start + 1);
while (used.has(next)) next += 1;
return String(next);
}
function _readCachedModelScan(sig) {
try {
const all = JSON.parse(localStorage.getItem(_CACHED_MODELS_SCAN_KEY) || '{}');
@@ -611,6 +652,56 @@ function _estimateLlamaContextFit(model, fields, modelCtxMax, modelWeightsGb = 0
};
}
function _estimateMlxContextFit(model, fields, modelCtxMax, modelWeightsGb = 0, fitSystem = null) {
const sys = fitSystem || _hwfitCache?.system || {};
const modelMax = Math.max(1024, _modelContextMaxForServe(model, modelCtxMax));
const modelGb = _modelSizeGb(model, modelWeightsGb);
const availableRamGb = Number(sys.available_ram_gb) || 0;
const totalRamGb = Number(sys.total_ram_gb) || 0;
const unifiedPoolGb = Math.max(availableRamGb, totalRamGb > 0 ? totalRamGb * 0.75 : 0);
if (!unifiedPoolGb) {
return {
ctx: Math.min(modelMax, 32768),
needsHardwareScan: true,
reason: 'scan Apple memory first; using model limit fallback',
};
}
if (!modelGb) {
return {
ctx: Math.min(modelMax, 32768),
needsModelSize: true,
reason: 'model weight size unknown; using MLX fallback',
};
}
const usableGb = Math.max(1, unifiedPoolGb - Math.max(4.0, unifiedPoolGb * 0.10));
const freeForKv = usableGb - modelGb;
const name = `${model?.repo_id || ''} ${model?.name || ''} ${model?.quant || ''}`.toLowerCase();
const totalParams = _parseParamsB(name) || Math.max(1, modelGb / 0.58);
const activeMatch = name.match(/\ba(\d+(?:\.\d+)?)b\b/);
const activeParams = activeMatch ? parseFloat(activeMatch[1]) : (/moe|minimax|deepseek|mixtral|kimi-k2/.test(name) ? Math.min(totalParams, 32) : totalParams);
// MLX uses unified memory. This is intentionally conservative because the
// server exposes max generation tokens, not a hard prefill context length.
const kvGbPerToken = Math.max(0.00002, 0.0000065 * activeParams);
if (freeForKv <= 0) {
return {
ctx: Math.min(modelMax, 2048),
modelGb,
kvGbPerToken,
reason: `model ${modelGb.toFixed(1)}G exceeds usable unified memory ${usableGb.toFixed(1)}G before KV`,
};
}
const raw = Math.floor(freeForKv / kvGbPerToken);
const rounded = Math.max(1024, Math.floor(raw / 1024) * 1024);
const ctx = Math.min(modelMax, rounded);
return {
ctx,
modelGb,
kvGbPerToken,
reason: `MLX --max-tokens from unified memory (${freeForKv.toFixed(1)}G free)`,
};
}
function _selectedServeTarget(panel) {
const select = panel?.querySelector?.('#hwfit-server-select')
|| document.getElementById('hwfit-server-select')
@@ -629,11 +720,11 @@ function _selectedServeTarget(panel) {
}
}
const typedVenv = panel?.querySelector('[data-field="venv"]')?.value?.trim() || '';
// For remote targets the server profile is authoritative. Otherwise a stale
// venv typed/loaded for another host can leak into this launch, e.g. a Linux
// /home/... Python path being used on an Apple Silicon MLX server.
// A venv typed in the serve panel is a per-launch/per-model override and must
// win over the server default. _buildServeCmd still drops obviously wrong
// platform paths, so stale Linux/macOS paths do not leak across hosts.
const venv = host
? (server?.envPath || typedVenv || '')
? (typedVenv || server?.envPath || '')
: (typedVenv || server?.envPath || _envState.envPath || '');
const label = host
? (server?.name ? `${server.name} (${host})` : host)
@@ -660,19 +751,79 @@ function _backendChoicesForTarget(target) {
return [['llamacpp','llama.cpp'],['diffusers','Diffusers']];
}
return _isMetal()
? [['mlx','MLX'],['llamacpp','llama.cpp'],['ollama','Ollama']]
: [['vllm','vLLM'],['sglang','SGLang'],['llamacpp','llama.cpp'],['ollama','Ollama'],['mlx','MLX'],['diffusers','Diffusers']];
? [['mlx','MLX'],['mlx_image','MLX Image'],['llamacpp','llama.cpp'],['ollama','Ollama']]
: [['vllm','vLLM'],['sglang','SGLang'],['llamacpp','llama.cpp'],['ollama','Ollama'],['mlx','MLX'],['mlx_image','MLX Image'],['diffusers','Diffusers']];
}
async function _fetchServeRuntimePackage(panel, backend) {
function _dependencyPkgForServeBackend(backend, modelName = '') {
const nm = String(modelName || '').toLowerCase();
if (backend === 'mlx_image' && nm.includes('boogu')) return 'boogu_image_mlx';
if (backend === 'mlx_image' && (nm.includes('mi-gan') || nm.includes('migan') || nm.includes('lama'))) return 'mlx_lama_swift';
if (backend === 'mlx_image' && nm.includes('ddcolor')) return 'mlx_ddcolor_swift';
if (backend === 'diffusers' && nm.includes('krea')) return 'krea_diffusers';
const packageByBackend = {
vllm: 'vllm',
sglang: 'sglang',
llamacpp: 'llama_cpp',
mlx: 'mlx_lm',
mlx_image: 'mflux',
diffusers: 'diffusers',
};
const packageName = packageByBackend[backend];
return packageByBackend[backend];
}
function _looksLikeAdapterModel(m) {
const repo = String(m?.repo_id || '');
const n = repo.toLowerCase();
return !!(
m?.is_adapter
|| /\b(lora|adapter|peft|qlora)\b/i.test(n)
|| /(?:^|[-_/])(lora|adapter|peft|qlora)(?:[-_/]|$)/i.test(repo)
|| /control[-_]?lora|diffusion[-_]?lora/i.test(repo)
);
}
function _cachedAdapterModels(currentRepo = '') {
const current = String(currentRepo || '');
return (_cachedAllModels || [])
.filter(m => m && m.status === 'ready' && m.repo_id && m.repo_id !== current)
.sort((a, b) => String(a.repo_id || '').localeCompare(String(b.repo_id || '')));
}
function _cachedAdapterSelectHtml(kind, currentRepo = '') {
const adapters = _cachedAdapterModels(currentRepo);
const cls = kind === 'vllm_lora_modules'
? 'hwfit-backend-vllm'
: kind === 'diff_lora'
? 'hwfit-backend-diffusers'
: kind === 'mlx_lora_paths'
? 'hwfit-backend-mlx_image'
: '';
if (!adapters.length) {
return `<label class="hwfit-cached-adapter-label ${cls}" style="grid-column:1 / -1;">Cached adapter <select class="hwfit-cached-adapter-select" data-adapter-kind="${esc(kind)}" disabled style="height:30px;width:100%;background:var(--bg);color:var(--fg-muted);border:1px solid var(--border);border-radius:4px;font:inherit;font-size:11px;opacity:0.75;"><option value="">No cached adapters found</option></select></label>`;
}
const opts = adapters.map(m => {
const repo = String(m.repo_id || '');
const value = m.is_local_dir && m.path
? `${String(m.path || '').replace(/\/+$/, '')}/${repo}`
: repo;
const short = repo.split('/').pop() || repo;
return `<option value="${esc(value)}">${esc(short)}</option>`;
}).join('');
return `<label class="hwfit-cached-adapter-label ${cls}" style="grid-column:1 / -1;">Cached adapter <select class="hwfit-cached-adapter-select" data-adapter-kind="${esc(kind)}" style="height:30px;width:100%;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px;font:inherit;font-size:11px;"><option value="">Choose cached adapter…</option>${opts}</select></label>`;
}
async function _fetchServeRuntimePackage(panel, backend) {
const repo = (panel.closest('.doclib-card, .memory-item')?.dataset?.repo) || '';
const packageByBackend = {
vllm: 'vllm',
sglang: 'sglang',
llamacpp: 'llama_cpp',
mlx: 'mlx_lm',
mlx_image: 'mflux',
diffusers: 'diffusers',
};
const packageName = _dependencyPkgForServeBackend(backend, repo) || packageByBackend[backend];
if (!packageName) return null;
const target = _selectedServeTarget(panel);
const params = new URLSearchParams();
@@ -681,6 +832,7 @@ async function _fetchServeRuntimePackage(panel, backend) {
if (target.port) params.set('ssh_port', target.port);
if (target.venv) params.set('venv', target.venv);
}
if (repo) params.set('model_hint', repo);
const res = await fetch('/api/cookbook/packages' + (params.toString() ? '?' + params.toString() : ''), { credentials: 'same-origin' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
@@ -689,7 +841,7 @@ async function _fetchServeRuntimePackage(panel, backend) {
}
function _runtimeNoteText(backend, pkg, target) {
const labels = { vllm: 'vLLM', sglang: 'SGLang', llamacpp: 'llama.cpp', mlx: 'MLX', diffusers: 'Diffusers' };
const labels = { vllm: 'vLLM', sglang: 'SGLang', llamacpp: 'llama.cpp', mlx: 'MLX', mlx_image: 'MLX Image', diffusers: 'Diffusers' };
const label = labels[backend] || backend;
if (!pkg) return `${label} readiness unavailable for ${target.label}.`;
const note = pkg.status_note || pkg.update_note || '';
@@ -750,6 +902,14 @@ function _isActivelyServing(repoId) {
} catch { return false; }
}
function _isIncompleteCachedModel(model) {
return !!model && (
model.status === 'stalled'
|| model.has_incomplete
|| (model.status === 'downloading' && !_isActivelyDownloading(model.repo_id))
);
}
function _formatGgufSize(bytes) {
const n = Number(bytes || 0);
if (!Number.isFinite(n) || n <= 0) return '';
@@ -964,6 +1124,7 @@ function _rerenderCachedModels() {
let html = '';
let visibleCount = 0;
for (const m of allModels) {
if (m.is_adapter && !m.is_diffusion && !m.is_video) continue;
if (activeTag && m._tag !== activeTag) continue;
if (searchVal && !(m.repo_id || '').toLowerCase().includes(searchVal)) continue;
visibleCount++;
@@ -1085,7 +1246,9 @@ function _rerenderCachedModels() {
const items = [];
items.push({ label: _favNow ? 'Unfavorite' : 'Favorite', icon: _favIco, action: 'favorite' });
if (m && m.status === 'ready') items.push({ label: 'Serve', icon: _serveIco, action: 'serve' });
if (m && m.status === 'downloading') items.push({ label: 'Retry', icon: _retryIco, action: 'retry' });
if (m && (m.status === 'downloading' || m.status === 'stalled' || m.has_incomplete)) {
items.push({ label: 'Resume download', icon: _retryIco, action: 'retry' });
}
if (m && m.status === 'ready') items.push({ label: 'Schedule…', icon: _schedIco, action: 'schedule' });
items.push({ label: 'Select', icon: _selectIco, action: 'select' });
items.push({ label: 'Delete', icon: _deleteIco, action: 'delete', danger: true });
@@ -1102,7 +1265,7 @@ function _rerenderCachedModels() {
_rerenderCachedModels();
}
else if (opt.action === 'delete') _deleteCachedModel(repo, item, false, m);
else if (opt.action === 'retry') _retryCachedModel(repo, m);
else if (opt.action === 'retry') _promptResumeIncompleteModel(m, item);
else if (opt.action === 'schedule') {
// Same entry point as the ^ button next to Launch — let
// cookbookSchedule.js handle it. Expand the panel first
@@ -1171,7 +1334,7 @@ function _rerenderCachedModels() {
// Wire click on card to expand serve panel
list.querySelectorAll('.memory-item[data-repo]').forEach(item => {
item.addEventListener('click', (e) => {
item.addEventListener('click', async (e) => {
if (e.target.closest('a, .hwfit-cached-menu-btn, .memory-item-btn, .hwfit-serve-panel')) return;
if (document.getElementById('hwfit-cache-select')?.classList.contains('active')) return;
const repo = item.dataset.repo;
@@ -1179,9 +1342,15 @@ function _rerenderCachedModels() {
const m = allModels.find(x => x.repo_id === repo);
if (!m) return;
if (m.status !== 'ready') {
if (m.status === 'downloading' && !_isActivelyDownloading(m.repo_id)) {
if (m.status === 'downloading' && _isActivelyDownloading(m.repo_id)) {
uiModule.showToast?.(`${(m.name || m.repo_id || 'Model').split('/').pop()} is still downloading.`);
} else if (_isIncompleteCachedModel(m)) {
await _promptResumeIncompleteModel(m, item);
} else if (m.status === 'downloading') {
uiModule.showToast?.('Refreshing cached model status…');
_fetchCachedModels(true);
} else {
uiModule.showToast?.(`${(m.name || m.repo_id || 'Model').split('/').pop()} is not ready yet.`);
}
return;
}
@@ -1245,11 +1414,12 @@ function _rerenderCachedModels() {
const _backendChoices = _backendChoicesForTarget(_serveTarget);
const _allowedBackends = new Set(_backendChoices.map(([v]) => v));
const detectedBackend = _detectBackend(m).backend;
let defaultBackend = (_repoForcedBackend && ss.backend && _allowedBackends.has(ss.backend))
const _imageBackend = detectedBackend === 'mlx_image' || detectedBackend === 'diffusers';
let defaultBackend = (!_imageBackend && _repoForcedBackend && ss.backend && _allowedBackends.has(ss.backend))
? ss.backend
: detectedBackend;
if (!_allowedBackends.has(defaultBackend)) defaultBackend = _backendChoices[0]?.[0] || detectedBackend;
const savedMatchesBackend = _repoForcedBackend || (ss.backend || 'vllm') === detectedBackend;
const savedMatchesBackend = !_imageBackend && (_repoForcedBackend || (ss.backend || 'vllm') === detectedBackend);
const sv = (k, def) => (ss[k] !== undefined && savedMatchesBackend) ? ss[k] : def;
const defaultTp = defaultBackend === 'llamacpp' ? '1' : sv('tp', _isMiniMaxMSeries ? '8' : '1');
const detectedGpuIds = _allGpuIds(_getGpuToggleTotal?.());
@@ -1452,6 +1622,8 @@ function _rerenderCachedModels() {
panelHtml += `<label class="hwfit-backend-vllm">${_l('Attention','vLLM VLLM_ATTENTION_BACKEND. auto = vLLM picks (often FLASHINFER, which JITs and can fail on old nvcc). FLASH_ATTN skips the JIT entirely.')}<select class="hwfit-sf" data-field="vllm_attn_backend" style="height:32px;">${vllmAttnBackendOpts}</select></label>`;
panelHtml += `<label class="hwfit-backend-vllm">${_l('Block Size','vLLM --block-size. Controls KV-cache block granularity. Leave blank for runtime default; some sparse-attention or custom runtimes need a specific value.')}<input type="text" class="hwfit-sf" data-field="vllm_block_size" value="${esc(svm('vllm_block_size', _isMiniMaxM3 ? '128' : ''))}" placeholder="auto" /></label>`;
panelHtml += `<label class="hwfit-backend-vllm">${_l('Swap','vLLM CPU swap space in GB. Blank/off omits the flag; enter a positive number only for older vLLM runtimes that support --swap-space.')}<input type="text" class="hwfit-sf" data-field="swap" value="${esc(sv('swap', ''))}" placeholder="off" /></label>`;
panelHtml += _cachedAdapterSelectHtml('vllm_lora_modules', repo);
panelHtml += `<label class="hwfit-backend-vllm" style="grid-column:1 / -1;">${_l('LoRA Modules','vLLM LoRA modules, one per line or comma-separated, using name=path. Adds --enable-lora --lora-modules.')}<input type="text" class="hwfit-sf" data-field="vllm_lora_modules" value="${esc(sv('vllm_lora_modules', ''))}" placeholder="style=/path/to/lora or style=org/repo" style="width:100%;" /></label>`;
{
const _envPresetDefault = _isMiniMaxM3 ? 'minimax_m3_cuda' : '';
const _envPresetVal = svm('vllm_env_preset', _envPresetDefault);
@@ -1471,15 +1643,36 @@ function _rerenderCachedModels() {
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang hwfit-extra-env-label">${_l('Env','Extra KEY=VALUE env-var pairs prepended to the launch (space-separated). The Env Preset above covers the usual MiniMax M3 values; use this for additional overrides.')}<input type="text" class="hwfit-sf" data-field="extra_env" value="${esc(svm('extra_env', sv('extra_env','')))}" placeholder="NCCL_P2P_DISABLE=1" style="width:100%;" /></label>`;
panelHtml += `</div>`;
// Row 2b: Diffusers settings
const diffDefaultNegative = 'low quality, blurry, out of focus, deformed, distorted, disfigured, unfinished, smudged, watermark, artifacts';
const diffDtypeOpts = ['bfloat16','float16','float32'].map(d => `<option value="${d}"${sv('diff_dtype','bfloat16')===d?' selected':''}>${d}</option>`).join('');
const deviceMapOpts = ['balanced','auto','sequential'].map(d => `<option value="${d}"${sv('diff_device_map','balanced')===d?' selected':''}>${d}</option>`).join('');
panelHtml += `<div class="hwfit-serve-row hwfit-backend-diffusers hwfit-diff-settings-row">`;
panelHtml += `<div class="hwfit-serve-row hwfit-backend-diffusers hwfit-backend-mlx_image hwfit-diff-settings-row">`;
panelHtml += `<label>Dtype${_h('Precision. bfloat16 recommended for Flux, float16 for SD')} <select class="hwfit-sf" data-field="diff_dtype">${diffDtypeOpts}</select></label>`;
panelHtml += `<label>Device Map${_h('How to place model on GPUs. balanced = split evenly')} <select class="hwfit-sf" data-field="diff_device_map">${deviceMapOpts}</select></label>`;
panelHtml += `<label>Steps${_h('Default inference steps. More = better quality, slower')} <input type="text" class="hwfit-sf" data-field="diff_steps" value="${esc(sv('diff_steps', ''))}" placeholder="auto" /></label>`;
panelHtml += `<label>Steps${_h('Default inference steps. More = better quality, slower. Override with the model card recommendation when needed.')} <input type="text" class="hwfit-sf" data-field="diff_steps" value="${esc(sv('diff_steps', '20'))}" placeholder="20" /></label>`;
panelHtml += `<label>Guidance${_h('Classifier-free guidance scale. Override with the model card recommended value when available.')} <input type="text" class="hwfit-sf" data-field="diff_guidance_scale" value="${esc(sv('diff_guidance_scale', '3.5'))}" placeholder="3.5" /></label>`;
panelHtml += `<label>Width${_h('Default output width')} <input type="text" class="hwfit-sf" data-field="diff_width" value="${esc(sv('diff_width', ''))}" placeholder="1024" /></label>`;
panelHtml += `<label>Height${_h('Default output height')} <input type="text" class="hwfit-sf" data-field="diff_height" value="${esc(sv('diff_height', ''))}" placeholder="1024" /></label>`;
panelHtml += `</div>`;
panelHtml += `<div class="hwfit-serve-row hwfit-backend-diffusers hwfit-backend-mlx_image hwfit-diff-adapters-row">`;
panelHtml += _cachedAdapterSelectHtml('diff_lora', repo);
panelHtml += `<label class="hwfit-backend-diffusers" style="grid-column:1 / -1;">Negative${_h('Default negative prompt. Adds --negative-prompt for pipelines that support it. Edit or clear this per model.')} <input type="text" class="hwfit-sf" data-field="diff_negative_prompt" value="${esc(sv('diff_negative_prompt', diffDefaultNegative))}" placeholder="${esc(diffDefaultNegative)}" style="width:100%;" /></label>`;
panelHtml += `<label class="hwfit-backend-diffusers" style="grid-column:1 / -1;">LoRA${_h('Diffusers LoRA file/path(s), comma or newline separated. Adds --lora.')} <input type="text" class="hwfit-sf" data-field="diff_lora" value="${esc(sv('diff_lora', ''))}" placeholder="/path/adapter.safetensors or org/repo" style="width:100%;" /></label>`;
panelHtml += `<label class="hwfit-backend-diffusers">Scale${_h('Diffusers LoRA scale. Adds --lora-scale.')} <input type="text" class="hwfit-sf" data-field="diff_lora_scale" value="${esc(sv('diff_lora_scale', ''))}" placeholder="1.0" /></label>`;
{
const _mlxBase = sv('mlx_base_model', '');
panelHtml += `<label class="hwfit-backend-mlx_image">Base model${_h('Optional runtime base model/family override from the model card. Adds --base-model. This is not a LoRA/adaptor.')} <input type="text" class="hwfit-sf" data-field="mlx_base_model" value="${esc(_mlxBase)}" placeholder="auto" /></label>`;
}
{
const _mlxStyle = sv('mlx_lora_style', '');
const _styleOpts = ['', 'couple', 'font', 'home', 'identity', 'illustration', 'portrait', 'ppt', 'sandstorm', 'sparklers', 'storyboard']
.map(v => `<option value="${v}"${_mlxStyle === v ? ' selected' : ''}>${v || 'none'}</option>`).join('');
panelHtml += `<label class="hwfit-backend-mlx_image">Style${_h('mflux built-in LoRA style. Adds --lora-style.')} <select class="hwfit-sf" data-field="mlx_lora_style">${_styleOpts}</select></label>`;
}
panelHtml += _cachedAdapterSelectHtml('mlx_lora_paths', repo);
panelHtml += `<label class="hwfit-backend-mlx_image" style="grid-column:1 / -1;">LoRA Paths${_h('mflux LoRA paths/repos, comma or newline separated. Adds --lora-paths.')} <input type="text" class="hwfit-sf" data-field="mlx_lora_paths" value="${esc(sv('mlx_lora_paths', ''))}" placeholder="org/lora or repo:file.safetensors" style="width:100%;" /></label>`;
panelHtml += `<label class="hwfit-backend-mlx_image" style="grid-column:1 / -1;">LoRA Scales${_h('mflux LoRA scales matching paths. Space/comma/newline separated. Adds --lora-scales.')} <input type="text" class="hwfit-sf" data-field="mlx_lora_scales" value="${esc(sv('mlx_lora_scales', ''))}" placeholder="0.8, 1.0" style="width:100%;" /></label>`;
panelHtml += `</div>`;
// Row 3: Advanced toggles for vLLM/SGLang. Several concepts overlap,
// but the actual flags differ; keep labels backend-neutral where a
// shared checkbox maps to different runtime flags.
@@ -1581,11 +1774,11 @@ function _rerenderCachedModels() {
panelHtml += `<label class="hwfit-sf-cb hwfit-spec-group"><input type="checkbox" class="hwfit-sf" data-field="llama_speculative_mtp"${sv('llama_speculative_mtp',false)?' checked':''} /> MTP Spec${_h('llama.cpp native MTP speculative decoding: --spec-type draft-mtp. Requires a GGUF with MTP heads.')} <input type="number" class="hwfit-sf hwfit-spec-tokens hwfit-spec-tokens-bare" data-field="llama_spec_tokens" value="${esc(sv('llama_spec_tokens', '3'))}" min="1" max="10" title="--spec-draft-n-max" /></label>`;
panelHtml += `</div>`;
// Row 3b: Checkboxes (diffusers)
panelHtml += `<div class="hwfit-serve-checks hwfit-backend-diffusers hwfit-diff-checks-row">`;
panelHtml += `<div class="hwfit-serve-checks hwfit-backend-diffusers hwfit-backend-mlx_image hwfit-diff-checks-row">`;
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="diff_offload"${sv('diff_offload',false)?' checked':''} /> CPU Offload${_h('Offload parts of model to CPU RAM to save VRAM. Slower but fits larger models')}</label>`;
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="diff_attention_slicing"${sv('diff_attention_slicing',false)?' checked':''} /> Attention Slicing${_h('Slice attention computation to reduce peak VRAM. Slower')}</label>`;
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="diff_vae_slicing"${sv('diff_vae_slicing',false)?' checked':''} /> VAE Slicing${_h('Process VAE in slices. Reduces VRAM for high-res images')}</label>`;
panelHtml += `</div><div class="hwfit-serve-row hwfit-backend-diffusers hwfit-diff-harmonize-row">`;
panelHtml += `</div><div class="hwfit-serve-row hwfit-backend-diffusers hwfit-backend-mlx_image hwfit-diff-harmonize-row">`;
panelHtml += `<label>Harmonize GPU${_h('Separate GPU for img2img/harmonize. Leave empty to use same GPU')}<input type="text" class="hwfit-sf" data-field="diff_harmonize_gpu" value="${esc(sv('diff_harmonize_gpu', ''))}" placeholder="auto" style="width:50px;" /></label>`;
panelHtml += `</div>`;
// Model-specific optimizations. The checks row always renders for the
@@ -1747,6 +1940,8 @@ function _rerenderCachedModels() {
let fit = null;
if (backend === 'vllm' || backend === 'sglang') {
fit = _estimateVllmContextFit(m, f, panel._modelCtxMax, panel._modelWeightsGb, panel._fitSystem);
} else if (backend === 'mlx') {
fit = _estimateMlxContextFit(m, f, panel._modelCtxMax, panel._modelWeightsGb, panel._fitSystem);
} else if (backend === 'llamacpp' || backend === 'ollama') {
const ggufGb = _selectedGgufSizeGb(m, f.gguf_file);
fit = _estimateLlamaContextFit(m, f, panel._modelCtxMax, ggufGb || panel._modelWeightsGb, panel._fitSystem, panel._contextProfileData);
@@ -1772,6 +1967,8 @@ function _rerenderCachedModels() {
: 'selected GPU memory';
_ctxAutoNote.title = backend === 'llamacpp' || backend === 'ollama'
? `Estimated from scanned GGUF/model size, trained context limit, and ${_llamaMemoryLabel} for llama.cpp KV cache.`
: backend === 'mlx'
? `MLX-LM server does not expose a context-length flag; Cookbook maps this estimate to MLX --max-tokens using scanned unified memory and model size.`
: `Estimated from model size, selected GPU VRAM, GPU utilization, TP, and KV dtype.`;
}
if (apply && _ctxEl0.dataset.autoCtx === '1') {
@@ -1951,6 +2148,7 @@ function _rerenderCachedModels() {
vllm: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>',
sglang: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
mlx: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"/><path d="M16 6v12"/><path d="M20 6v12"/></svg>',
mlx_image: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9" r="1.5"/><path d="M21 15l-5-5L5 20"/><path d="M17.5 4v4M15.5 6h4"/></svg>',
llamacpp: '<svg width="14" height="14" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"/><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"/></svg>',
ollama: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;"></span>',
diffusers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
@@ -2064,7 +2262,7 @@ function _rerenderCachedModels() {
const backend = panel.querySelector('[data-field="backend"]')?.value || 'vllm';
const noteText = note.querySelector('.hwfit-serve-runtime-text');
const _writeNote = (s) => { if (noteText) noteText.textContent = s; else note.textContent = s; };
if (!['vllm', 'sglang', 'llamacpp', 'mlx', 'diffusers'].includes(backend)) {
if (!['vllm', 'sglang', 'llamacpp', 'mlx', 'mlx_image', 'diffusers'].includes(backend)) {
note.style.display = 'none';
_writeNote('');
return;
@@ -2104,8 +2302,8 @@ function _rerenderCachedModels() {
// recipe panel for this backend so the user has one click
// to the fix instead of hunting for the right row.
if (noteText) {
const pkgName = pkg?.name || ({ vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', mlx: 'mlx_lm', diffusers: 'diffusers' }[backend]);
const repo = (panel.closest('.doclib-card, .memory-item')?.dataset?.repo) || '';
const pkgName = pkg?.name || _dependencyPkgForServeBackend(backend, repo);
const link = document.createElement('a');
link.href = '#';
link.textContent = ' Install in Dependencies →';
@@ -2159,15 +2357,16 @@ function _rerenderCachedModels() {
});
} else {
const fields = {
backend: cmd.includes('llama_cpp') || cmd.includes('llama-server') ? 'llamacpp' : cmd.includes('mlx_lm.server') ? 'mlx' : cmd.includes('diffusion_server') ? 'diffusers' : cmd.includes('sglang') ? 'sglang' : cmd.includes('ollama') ? 'ollama' : 'vllm',
backend: cmd.includes('llama_cpp') || cmd.includes('llama-server') ? 'llamacpp' : cmd.includes('mlx_image_server') ? 'mlx_image' : cmd.includes('mlx_lm.server') ? 'mlx' : cmd.includes('diffusion_server') ? 'diffusers' : cmd.includes('sglang') ? 'sglang' : cmd.includes('ollama') ? 'ollama' : 'vllm',
port: _ex(/--port\s+(\d+)/) || '8000',
tp: _ex(/--tensor-parallel-size\s+(\d+)/) || '1',
ctx: _ex(/--max-model-len\s+(\d+)/) || _ex(/--n_ctx\s+(\d+)/) || _ex(/-c\s+(\d+)/) || '8192',
ctx: _ex(/--max-model-len\s+(\d+)/) || _ex(/--context-length\s+(\d+)/) || _ex(/--max-tokens\s+(\d+)/) || _ex(/--n_ctx\s+(\d+)/) || _ex(/-c\s+(\d+)/) || '8192',
gpu_mem: _ex(/--gpu-memory-utilization\s+([\d.]+)/) || '0.90',
swap: _ex(/--swap-space\s+(\d+)/) || '',
dtype: _ex(/--dtype\s+(\w+)/) || 'auto',
vllm_kv_cache_dtype: _ex(/--kv-cache-dtype\s+([\w.-]+)/) || 'auto',
max_seqs: _ex(/--max-num-seqs\s+(\d+)/) || '',
vllm_lora_modules: _ex(/--lora-modules\s+(.+?)(?:\s+--|$)/) || '',
cache_type: _ex(/(?:--cache-type-k|-ctk)\s+(\S+)/) || '',
llama_fit: _ex(/(?:--fit|-fit)\s+(on|off)/) || '',
llama_split_mode: _ex(/(?:--split-mode|-sm)\s+(none|layer|row|tensor)/) || '',
@@ -2177,6 +2376,14 @@ function _rerenderCachedModels() {
llama_batch_size: _ex(/(?:--batch-size|-b)\s+(\d+)/) || '',
llama_ubatch_size: _ex(/(?:--ubatch-size|-ub)\s+(\d+)/) || '',
llama_spec_tokens: _ex(/--spec-draft-n-max\s+(\d+)/) || '3',
diff_lora: (_ex(/--lora\s+'([^']*)'/) || _ex(/--lora\s+(\S+)/) || '').replace(/,/g, '\n'),
diff_lora_scale: _ex(/--lora-scale\s+([\d.]+)/) || '',
diff_guidance_scale: _ex(/--guidance-scale\s+([\d.]+)/) || '',
diff_negative_prompt: _ex(/--negative-prompt\s+'([^']*)'/) || _ex(/--negative-prompt\s+(.+?)(?:\s+--|$)/) || '',
mlx_base_model: _ex(/--base-model\s+'?([^'\s]+)'?/) || '',
mlx_lora_style: _ex(/--lora-style\s+'?([^'\s]+)'?/) || '',
mlx_lora_paths: (_ex(/--lora-paths\s+(.+?)(?:\s+--|$)/) || '').replace(/'\s+'/g, '\n').replace(/^'|'$/g, ''),
mlx_lora_scales: (_ex(/--lora-scales\s+(.+?)(?:\s+--|$)/) || '').replace(/'\s+'/g, '\n').replace(/^'|'$/g, ''),
venv: p.envPath || '',
};
const checks = {
@@ -2268,8 +2475,9 @@ function _rerenderCachedModels() {
const presets = _loadPresets();
const modelSlots = _presetsForModel(presets, repo);
// Compute the current launch command first so we can detect a no-op save.
updateCmd();
const cmd = panel._cmd;
if (!_cmdManuallyEdited) updateCmd();
const cmdBox = panel.querySelector('.hwfit-serve-cmd');
const cmd = _normalizeServeCmdForLaunch((_cmdManuallyEdited && cmdBox) ? cmdBox.value : panel._cmd);
// Already saved? If an existing preset for this model has the identical
// launch command, don't make a duplicate — tell the user via a popup.
const _norm = s => String(s || '').replace(/\s+/g, ' ').trim();
@@ -2289,6 +2497,8 @@ function _rerenderCachedModels() {
if (el.type === 'checkbox') fields[el.dataset.field] = el.checked;
else fields[el.dataset.field] = el.value;
});
if (_cmdManuallyEdited) fields._manual_cmd = cmd;
else delete fields._manual_cmd;
presets.push(_redactServeStateForStorage({ name: shortName, model: repo, cmd, remoteHost: host, port: fields.port || '8000', label, fields }));
_savePresets(presets);
uiModule.showToast(`Saved "${label}"`);
@@ -2513,6 +2723,7 @@ function _rerenderCachedModels() {
menu.appendChild(mk('Cancel', 'dropdown-cancel-mobile', () => {}));
const r = _launchMoreBtn.getBoundingClientRect();
menu.style.position = 'fixed';
menu.style.zIndex = String(topPortalZ());
menu.style.right = (window.innerWidth - r.right) + 'px';
document.body.appendChild(menu);
{
@@ -2559,6 +2770,7 @@ function _rerenderCachedModels() {
menu.appendChild(mk('Cancel', 'dropdown-cancel-mobile', () => {}));
const r = _splitArrow.getBoundingClientRect();
menu.style.position = 'fixed';
menu.style.zIndex = String(topPortalZ());
menu.style.right = (window.innerWidth - r.right) + 'px';
document.body.appendChild(menu);
// Default open BELOW, but if there's no room (esp. on mobile where
@@ -2916,6 +3128,31 @@ function _rerenderCachedModels() {
}
});
});
panel.querySelectorAll('.hwfit-cached-adapter-select').forEach(sel => {
sel.addEventListener('change', () => {
const repoId = String(sel.value || '').trim();
if (!repoId) return;
const kind = sel.dataset.adapterKind || '';
const target = panel.querySelector(`[data-field="${kind}"]`);
if (!target) return;
const current = String(target.value || '').trim();
let next = repoId;
if (kind === 'vllm_lora_modules') {
const name = repoId.split('/').pop().replace(/[^A-Za-z0-9_.-]+/g, '_') || 'adapter';
next = `${name}=${repoId}`;
}
if (current) {
const lines = current.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
if (!lines.includes(next)) lines.push(next);
target.value = lines.join('\n');
} else {
target.value = next;
}
target.dispatchEvent(new Event('input', { bubbles: true }));
updateCmd();
});
});
// llama.cpp CPU/GPU/Unified mode-toggle wiring. Clicking a mode
// flips the .active classes + marker class (so the sliding
// pill matches Agent/Chat), updates the hidden data-field input,
@@ -2994,6 +3231,14 @@ function _rerenderCachedModels() {
// Track manual edits
let _cmdManuallyEdited = false;
const _cmdTextarea = panel.querySelector('.hwfit-serve-cmd');
const _savedManualCmd = String(svm('_manual_cmd', '') || '').trim();
if (_cmdTextarea && _savedManualCmd) {
panel._cmd = _savedManualCmd;
_cmdTextarea.value = _formatServeCmdPreview(_savedManualCmd);
_cmdTextarea.style.height = 'auto';
_cmdTextarea.style.height = _cmdTextarea.scrollHeight + 'px';
_cmdManuallyEdited = true;
}
if (_cmdTextarea) _cmdTextarea.addEventListener('input', () => { _cmdManuallyEdited = true; });
// Cancel button — collapses the serve config panel (same effect as
@@ -3078,8 +3323,9 @@ function _rerenderCachedModels() {
// all whitespace to single spaces before launch — same effect as the
// user manually re-flowing the textarea, no behavior change.
const _rawLaunchCmd = (_cmdManuallyEdited && _cmdTextarea) ? _cmdTextarea.value : panel._cmd;
const launchCmd = _normalizeServeCmdForLaunch(_rawLaunchCmd);
let launchCmd = _normalizeServeCmdForLaunch(_rawLaunchCmd);
const serveState = {};
let launchAnyway = false;
panel.querySelectorAll('.hwfit-sf').forEach(el => {
if (el.type === 'checkbox') serveState[el.dataset.field] = el.checked;
else serveState[el.dataset.field] = el.value;
@@ -3091,7 +3337,7 @@ function _rerenderCachedModels() {
uiModule.showToast('Vision is checked, but no mmproj projector is in the launch command. Refresh cached models after downloading mmproj, or add --mmproj manually.', 8000);
return;
}
if (serveState.backend === 'diffusers' && _remoteWindowsDiffusersUnsupported(launchTarget)) {
if ((serveState.backend === 'diffusers' || serveState.backend === 'mlx_image') && _remoteWindowsDiffusersUnsupported(launchTarget)) {
_restoreLaunchBtn();
uiModule.showToast('Diffusers serving is not supported on remote Windows servers yet. Use local Windows or a Linux server.', 9000);
return;
@@ -3113,18 +3359,32 @@ function _rerenderCachedModels() {
// Only block when the new model's port genuinely collides with
// a running serve. Different ports coexist fine (issue #4507).
if (_active.length) {
const _newPort = (launchCmd.match(/--port[=\s]+(\d+)/) || [])[1] || '';
const _newPort = _serveCmdPort(launchCmd);
const _clashing = _newPort
? _active.filter(t => _runningMod._taskPort(t) === _newPort)
: _active;
if (_clashing.length) {
const _names = _clashing.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean);
const _portNote = _newPort ? ` on port ${_newPort}` : '';
const _ok = await window.styledConfirm(
`${_clashing.length} model${_clashing.length === 1 ? '' : 's'} already serving on ${_hostStr || 'local'} (${_names.join(', ')})${_portNote}. Stop it and launch this one?`,
{ title: _newPort ? `Port ${_newPort} in use` : 'Server already running', confirmText: 'Stop & launch', cancelText: 'Cancel' },
const _choice = await window.styledConfirm(
`${_clashing.length} model${_clashing.length === 1 ? '' : 's'} already serving on ${_hostStr || 'local'} (${_names.join(', ')})${_portNote}. Stop it first, or launch anyway?`,
{ title: _newPort ? `Port ${_newPort} in use` : 'Server already running', confirmText: 'Stop & launch', alternateText: 'Launch anyway', cancelText: 'Cancel' },
);
if (!_ok) { _restoreLaunchBtn(); return; }
if (!_choice) { _restoreLaunchBtn(); return; }
if (_choice === 'alternate') {
launchAnyway = true;
const _oldPort = _newPort || _serveCmdPort(launchCmd);
const _nextPort = _nextServeLaunchPort(_oldPort, _runningMod, _hostStr, _serverKeyStr);
if (_oldPort && _nextPort && _nextPort !== _oldPort) {
launchCmd = _replaceServeCmdPort(launchCmd, _nextPort);
serveState.port = _nextPort;
panel._cmd = launchCmd;
if (_cmdTextarea) _cmdTextarea.value = launchCmd;
uiModule.showToast(`Launching anyway on port ${_nextPort}. Existing serve stays on ${_oldPort}.`, 7000);
} else {
uiModule.showToast('Launching anyway. If the port is already occupied, the new serve may fail.', 6000);
}
} else {
// Kill each clashing serve; prefer the rendered Stop button so
// endpoint cleanup + Ollama unload run normally. Fall back to
// a raw tmux kill when the Active tab isn't in the DOM.
@@ -3146,6 +3406,7 @@ function _rerenderCachedModels() {
await new Promise(r => setTimeout(r, 2500));
}
}
}
} catch (_e) { /* best-effort */ }
const backendWarning = _serveBackendWarning(m, repo, serveState.backend, serveState);
@@ -3387,6 +3648,8 @@ function _rerenderCachedModels() {
const byRepo = (cur && cur._byRepo && typeof cur._byRepo === 'object') ? cur._byRepo : {};
const _saved = { ...serveState, _forceBackend: true };
delete _saved._replaceTaskId;
if (_cmdManuallyEdited) _saved._manual_cmd = launchCmd;
else delete _saved._manual_cmd;
byRepo[repo] = _saved;
localStorage.setItem(SERVE_STATE_KEY, JSON.stringify(_redactServeStateForStorage({ _byRepo: byRepo, _lastUsed: _saved })));
} catch {}
@@ -3446,7 +3709,7 @@ function _rerenderCachedModels() {
// Pass the exact form values so the running task can be re-opened
// in the Serve panel pre-filled with these settings (Edit button).
const taskDisplayName = _serveTaskDisplayName(shortName, m, serveState);
await _launchServeTask(taskDisplayName, repo, launchCmd, serveState, serveHost, { serverKey: serveServerKey, serverName: serveServerName });
await _launchServeTask(taskDisplayName, repo, launchCmd, serveState, serveHost, { serverKey: serveServerKey, serverName: serveServerName, launchAnyway });
});
} finally {
_envState.env = origEnv;
@@ -3481,8 +3744,12 @@ function _rerenderCachedModels() {
// Resolve the host the cached list was scanned from, mirroring
// _fetchCachedModels — so a delete targets the SAME machine the model
// actually lives on, not just the globally-selected serve host.
function _resolveCacheHost() {
function _serverFromCacheSelection() {
let host = _envState.remoteHost || '';
let server = host
? (_envState.servers || []).find(s => s.host === host) || null
: ((_envState.servers || []).find(s => !s.host || s.host === 'local') || null);
let key = '';
const cacheSrv = document.getElementById('hwfit-cache-server');
function _serverByCacheValue(val) {
@@ -3496,14 +3763,25 @@ function _resolveCacheHost() {
if (cacheSrv) {
const val = cacheSrv.value;
key = val || '';
if (val === 'local') {
host = '';
server = (_envState.servers || []).find(s => !s.host || s.host === 'local') || null;
} else {
const s = _serverByCacheValue(val);
if (s) host = s.host;
if (s) {
host = s.host || '';
server = s;
key = _serverKey?.(s) || val || '';
}
}
return host;
}
return { host, server, key };
}
function _resolveCacheHost() {
return _serverFromCacheSelection().host || '';
}
async function _deleteCachedModel(repo, itemEl, skipConfirm = false, model = null) {
@@ -3628,27 +3906,55 @@ async function _deleteCachedModel(repo, itemEl, skipConfirm = false, model = nul
}
}
async function _promptResumeIncompleteModel(m, itemEl = null) {
const repo = m?.repo_id || itemEl?.dataset?.repo || '';
if (!repo) return;
const short = (m?.name || repo).split('/').pop();
if (_isActivelyDownloading(repo)) {
uiModule.showToast?.(`${short} is already downloading.`);
return;
}
const ok = await uiModule.styledConfirm(
`${short} is not finished downloading.\n\nResume the download on the selected cache server?`,
{ confirmText: 'Resume download', cancelText: 'Not now' }
);
if (!ok) return;
uiModule.showToast?.(`Resuming ${short}`);
_retryCachedModel(repo, m);
}
function _retryCachedModel(repo, m) {
const payload = { repo_id: repo };
if (_envState.hfToken) payload.hf_token = _envState.hfToken;
const _target = _selectedServeTarget(document.getElementById('cookbook-modal') || document);
const _target = _serverFromCacheSelection();
const srv = _target.server || {};
if (_target.host) {
payload.remote_host = _target.host;
if (_target.port) payload.ssh_port = _target.port;
if (_target.key && _target.key !== 'local') payload.remote_server_key = _target.key;
if (srv.name) payload.remote_server_name = srv.name;
const port = srv.port || _getPort(_target.host);
if (port) payload.ssh_port = port;
}
if (_target.platform) payload.platform = _target.platform;
if (_isWindows()) {
if (_envState.env === 'venv' && _envState.envPath) {
payload.env_prefix = '& ' + _psQuote(_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1');
} else if (_envState.env === 'conda' && _envState.envPath) {
payload.env_prefix = 'conda activate ' + _psQuote(_envState.envPath);
const platform = _target.host ? (srv.platform || _getPlatform(_target.host) || '') : (_envState.hostPlatform || '');
if (platform) payload.platform = platform;
const env = _target.host ? (srv.env || 'none') : (_envState.env || 'none');
const envPath = _target.host ? (srv.envPath || '') : (_envState.envPath || '');
const downloadDir = srv.downloadDir || (m?.is_local_dir && m?.path ? m.path : '');
if (downloadDir) payload.local_dir = _normalizeCookbookModelDir(downloadDir);
payload.disable_hf_transfer = true;
if (platform === 'windows') {
if (env === 'venv' && envPath) {
payload.env_prefix = '& ' + _psQuote(envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1');
} else if (env === 'conda' && envPath) {
payload.env_prefix = 'conda activate ' + _psQuote(envPath);
}
} else {
if (_envState.env === 'venv' && _envState.envPath) {
const p = _envState.envPath;
if (env === 'venv' && envPath) {
const p = envPath;
payload.env_prefix = 'source ' + _shellQuote(p.endsWith('/bin/activate') ? p : p + '/bin/activate');
} else if (_envState.env === 'conda' && _envState.envPath) {
payload.env_prefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
} else if (env === 'conda' && envPath) {
payload.env_prefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(envPath);
}
}
_retryDownload((m?.name || repo).split('/').pop(), payload);
@@ -3753,8 +4059,7 @@ function _renderCachedModelsData(list, data, host) {
const _families = [
[/qwen/i, 'qwen'], [/llama/i, 'llama'], [/mistral|mixtral/i, 'mistral'],
[/deepseek/i, 'deepseek'], [/gemma/i, 'gemma'], [/phi/i, 'phi'],
[/minimax/i, 'minimax'], [/glm/i, 'glm'], [/flux/i, 'flux'],
[/stable.?diffusion|sdxl/i, 'sd'], [/z-image/i, 'z-image'],
[/minimax/i, 'minimax'], [/glm/i, 'glm'],
[/whisper/i, 'whisper'], [/command|cohere/i, 'cohere'],
[/yi-/i, 'yi'], [/intern/i, 'intern'], [/falcon/i, 'falcon'],
];
@@ -3762,7 +4067,7 @@ function _renderCachedModelsData(list, data, host) {
const n = (m.repo_id || '').toLowerCase();
let tag = 'other';
if (m.backend === 'ollama' || m.is_ollama) tag = 'llm';
else if (m.is_diffusion || /flux|sdxl|stable-diffusion|z-image|qwen-image|diffusion|dreamshar/i.test(n)) tag = 'image';
else if (m.is_diffusion || m.is_video || m.is_image_gen || /(?:^|[-_/])(diffusion|image)(?:[-_/]|$)/i.test(n)) tag = 'image';
else if (/whisper|stt|asr/i.test(n)) tag = 'stt';
else if (/tts|cosyvoice|parler/i.test(n)) tag = 'tts';
else if (/embed|bge|minilm|e5-/i.test(n)) tag = 'embedding';
+313 -151
View File
@@ -123,6 +123,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
let activeDocId = null; // currently visible doc
let _lastSessionId = ''; // session context for "+" button
const docs = new Map(); // docId -> { id, title, language, content, version, sessionId }
let _emailSendInFlight = false;
const _docOpenKey = (sessionId) => 'odysseus-doc-open-' + sessionId;
const _docMinimizedKey = (sessionId) => 'odysseus-doc-minimized-' + sessionId;
@@ -158,6 +159,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
getDocs: () => docs,
isOpen: () => isOpen,
createDocument,
newDocument,
loadDocument,
switchToDoc,
openPanel,
@@ -2244,6 +2246,18 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
// ── Email document type helpers ──
function _unfoldEmailHeaderLines(header) {
const lines = [];
for (const rawLine of String(header || '').replace(/\r\n/g, '\n').split('\n')) {
if (/^[ \t]/.test(rawLine) && lines.length) {
lines[lines.length - 1] += ' ' + rawLine.trim();
} else {
lines.push(rawLine);
}
}
return lines;
}
function _parseEmailHeader(content) {
const empty = { to: '', cc: '', bcc: '', subject: '', inReplyTo: '', references: '', sourceUid: '', sourceFolder: '', forwardAttachments: false, attachments: [], body: content || '' };
if (!content) return empty;
@@ -2252,7 +2266,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const header = parts[0];
const body = parts.slice(1).join('\n---\n');
const fields = { to: '', cc: '', bcc: '', subject: '', inReplyTo: '', references: '', sourceUid: '', sourceFolder: '', forwardAttachments: false, attachments: [], body: body };
for (const line of header.split('\n')) {
for (const line of _unfoldEmailHeaderLines(header)) {
const m = line.match(/^(To|Cc|Bcc|Subject|In-Reply-To|References|X-Source-UID|X-Source-Folder|X-Forward-Attachments|X-Attachments):\s*(.*)$/i);
if (m) {
let key = m[1].toLowerCase();
@@ -2373,16 +2387,20 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
function _emailFieldsWithLocalDraft(fields) {
const draft = _loadEmailLocalDraft(fields);
if (!draft) return fields;
const keepRealField = (draftValue, fieldValue) => {
const d = draftValue == null ? '' : String(draftValue);
return d.trim() ? d : (fieldValue || '');
};
return {
...fields,
to: draft.to ?? fields.to,
cc: draft.cc ?? fields.cc,
bcc: draft.bcc ?? fields.bcc,
subject: draft.subject ?? fields.subject,
inReplyTo: draft.inReplyTo ?? fields.inReplyTo,
references: draft.references ?? fields.references,
sourceUid: draft.sourceUid ?? fields.sourceUid,
sourceFolder: draft.sourceFolder ?? fields.sourceFolder,
to: keepRealField(draft.to, fields.to),
cc: keepRealField(draft.cc, fields.cc),
bcc: keepRealField(draft.bcc, fields.bcc),
subject: keepRealField(draft.subject, fields.subject),
inReplyTo: keepRealField(draft.inReplyTo, fields.inReplyTo),
references: keepRealField(draft.references, fields.references),
sourceUid: keepRealField(draft.sourceUid, fields.sourceUid),
sourceFolder: keepRealField(draft.sourceFolder, fields.sourceFolder),
body: _sanitizeOutgoingEmailBody(draft.body ?? fields.body),
};
}
@@ -2439,7 +2457,20 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
return d.innerHTML.replace(/\n/g, '<br>');
}
function _emailBodyToHtml(text) {
function _emailHtmlToPlainText(html) {
if (typeof document === 'undefined') return String(html || '');
const d = document.createElement('div');
d.innerHTML = String(html || '');
return d.innerText || d.textContent || '';
}
function _emailQuoteMarkerMatch(text) {
const raw = String(text || '');
return raw.match(/(?:<p[^>]*>\s*)?-{5,}\s*Previous message\s*-{5,}(?:\s*<\/p>)?/i)
|| raw.match(/-{5,}\s*Previous message\s*-{5,}/i);
}
function _emailBodyFragmentToHtml(text) {
const t = (text || '').trim();
if (!t) return '';
// If it already contains a formatting/structural HTML tag, it's a saved
@@ -2455,6 +2486,36 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
try { return markdownModule.mdToHtml(text, { shortcodes: false }); }
catch (_) { return _emailPlainTextToHtml(text); }
}
function _emailBodyToHtml(text) {
const raw = String(text || '');
const marker = _emailQuoteMarkerMatch(raw);
if (!marker) {
const t = raw.trim();
if (/<\/?(b|i|u|s|strong|em|del|strike|a|p|div|br|ul|ol|li|h[1-3]|blockquote|span|code|pre)\b[^>]*>/i.test(t)) {
return markdownModule.sanitizeAllowedHtml
? markdownModule.sanitizeAllowedHtml(t)
: _emailPlainTextToHtml(t);
}
return _emailBodyFragmentToHtml(raw);
}
const replyPart = raw.slice(0, marker.index);
const quotedPart = raw.slice(marker.index);
const quotedText = _emailHtmlToPlainText(quotedPart)
.replace(/\u00a0/g, ' ')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
const replyHtml = _emailBodyFragmentToHtml(replyPart);
if (!quotedText) return replyHtml;
const firstQuoted = quotedText
.split(/\n\s*-{5,}\s*Previous message\s*-{5,}\s*\n/i)[0]
.trim();
const truncatedQuote = firstQuoted.length > 1800
? `${firstQuoted.slice(0, 1800).replace(/\s+\S*$/, '').trim()}\n\n[Quoted thread truncated]`
: firstQuoted;
return `${replyHtml}<div class="email-quoted-history" contenteditable="false">${_emailPlainTextToHtml(truncatedQuote)}</div>`;
}
// Mirror the rich body's plain text into the hidden textarea so the existing
// send / draft / change-detection plumbing (which reads the textarea) stays
// valid. The rich body's HTML is read separately on send (body_html).
@@ -2756,8 +2817,21 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
target.focus();
if (target.isContentEditable) {
const range = document.createRange();
const quote = target.querySelector('.email-quoted-history');
if (quote) {
let slot = quote.previousElementSibling;
if (!slot || slot.classList.contains('email-quoted-history')) {
slot = document.createElement('div');
slot.className = 'email-reply-edit-slot';
slot.innerHTML = '<br>';
target.insertBefore(slot, quote);
}
range.selectNodeContents(slot);
range.collapse(false);
} else {
range.selectNodeContents(target);
range.collapse(false);
}
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
@@ -2820,7 +2894,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
if (_shouldAutoCollapseEmailHeader()) _setEmailHeaderCollapsed(true, { manual: false });
}
function _showEmailFields(doc, { applyLocalDraft = true } = {}) {
function _showEmailFields(doc, { applyLocalDraft = true, forceHeaderFields = false } = {}) {
const emailHeader = document.getElementById('doc-email-header');
const emailActions = document.getElementById('doc-email-actions');
// Show MD toolbar for email too (B, I, etc.)
@@ -2857,8 +2931,8 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const preserveEmailHeader = !!(fields.sourceUid || fields.inReplyTo || fields.references);
const subjectInput = document.getElementById('doc-email-subject');
const textarea = document.getElementById('doc-editor-textarea');
_setEmailHeaderInputValue('doc-email-to', fields.to, { preserveNonEmpty: preserveEmailHeader });
_setEmailHeaderInputValue('doc-email-subject', fields.subject, { preserveNonEmpty: preserveEmailHeader });
_setEmailHeaderInputValue('doc-email-to', fields.to, { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
_setEmailHeaderInputValue('doc-email-subject', fields.subject, { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
_setEmailHeaderCollapsed(!!(doc && doc._emailHeaderCollapsed), { manual: false });
if (subjectInput && !subjectInput._emailTabBodyBound) {
subjectInput._emailTabBodyBound = true;
@@ -2869,10 +2943,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
}
});
}
_setEmailHeaderInputValue('doc-email-in-reply-to', fields.inReplyTo, { preserveNonEmpty: preserveEmailHeader });
_setEmailHeaderInputValue('doc-email-references', fields.references, { preserveNonEmpty: preserveEmailHeader });
_setEmailHeaderInputValue('doc-email-source-uid', fields.sourceUid || '', { preserveNonEmpty: preserveEmailHeader });
_setEmailHeaderInputValue('doc-email-source-folder', fields.sourceFolder || '', { preserveNonEmpty: preserveEmailHeader });
_setEmailHeaderInputValue('doc-email-in-reply-to', fields.inReplyTo, { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
_setEmailHeaderInputValue('doc-email-references', fields.references, { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
_setEmailHeaderInputValue('doc-email-source-uid', fields.sourceUid || '', { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
_setEmailHeaderInputValue('doc-email-source-folder', fields.sourceFolder || '', { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
// Show/hide unread button only if we have a source UID (came from inbox)
const unreadBtn = document.getElementById('doc-email-unread-btn');
if (unreadBtn) unreadBtn.style.display = fields.sourceUid ? '' : 'none';
@@ -2984,7 +3058,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
setTimeout(() => {
try {
const _isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints || 0) > 0;
if (!_isTouch) _rich.focus();
if (!_isTouch) _focusEmailBodyEnd();
_rich.scrollTop = 0;
} catch (_) {}
}, 50);
@@ -2995,8 +3069,8 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const ccRow = document.getElementById('doc-email-cc-row');
const bccRow = document.getElementById('doc-email-bcc-row');
const ccToggle = document.getElementById('doc-email-show-cc');
_setEmailHeaderInputValue('doc-email-cc', fields.cc || '', { preserveNonEmpty: preserveEmailHeader });
_setEmailHeaderInputValue('doc-email-bcc', fields.bcc || '', { preserveNonEmpty: preserveEmailHeader });
_setEmailHeaderInputValue('doc-email-cc', fields.cc || '', { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
_setEmailHeaderInputValue('doc-email-bcc', fields.bcc || '', { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
const hasCcBcc = !!(
fields.cc ||
fields.bcc ||
@@ -3777,6 +3851,11 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
}
async function _sendEmail() {
if (_emailSendInFlight) {
if (uiModule) uiModule.showToast('Already sending');
return;
}
if (uiModule) uiModule.showToast('Preparing send', { duration: 1200 });
const sendDocId = activeDocId;
const to = document.getElementById('doc-email-to')?.value?.trim();
const cc = document.getElementById('doc-email-cc')?.value?.trim() || '';
@@ -3810,11 +3889,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const proceed = await _confirmMissingAttachment();
if (!proceed) return;
}
const btn = document.getElementById('doc-email-send-btn');
const _sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const btn = Array.from(document.querySelectorAll('#doc-email-send-btn')).find((candidate) => candidate.offsetParent !== null) || document.getElementById('doc-email-send-btn');
let sendSpinner = null;
let origBtnHtml = '';
let detachedEmailDoc = null;
_emailSendInFlight = true;
if (btn) {
btn.disabled = true;
origBtnHtml = btn.innerHTML;
@@ -3825,24 +3903,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
btn.appendChild(document.createTextNode('Sending'));
}
try {
let canceled = false;
if (uiModule) {
uiModule.showToast('Sending', {
duration: 3200,
leadingIcon: 'spinner',
action: 'Cancel',
onAction: () => { canceled = true; },
});
}
await _sleep(3000);
if (!canceled) detachedEmailDoc = _detachActiveEmailForBackground(sendDocId);
await _sleep(200);
if (canceled) {
_restoreDetachedEmailDoc(detachedEmailDoc);
detachedEmailDoc = null;
if (uiModule) uiModule.showToast('Send canceled');
return;
}
if (uiModule) uiModule.showToast('Sending', { duration: 2200, leadingIcon: 'spinner' });
const activeAccountId = await _resolveComposeSendAccountId();
const res = await fetch(`${API_BASE}/api/email/send`, {
@@ -3873,7 +3934,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
leadingIcon: 'check',
action: 'View Message',
onAction: () => {
import('./emailLibrary.js').then(mod => {
import('./emailLibrary.js?v=20260722emailfastindex1').then(mod => {
const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
if (open) open({
account_id: data.account_id || activeAccountId || null,
@@ -3912,9 +3973,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
// Tell the inbox to refresh so the answered state shows
window.dispatchEvent(new CustomEvent('email-answered', { detail: { uid: sourceUid, folder: sourceFolder, account_id: data.account_id || activeAccountId || null } }));
}
// Delete the compose document after successful send. It was usually
// already detached from the visible tabs so sending can finish in the
// background while the user continues in the next tab.
// Delete the compose document after successful send.
if (sendDocId) {
fetch(`${API_BASE}/api/document/${sendDocId}`, { method: 'DELETE' }).catch(() => {});
const wasActiveSentDoc = activeDocId === sendDocId;
@@ -3930,15 +3989,12 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
_syncDocIndicator();
}
} else {
_restoreDetachedEmailDoc(detachedEmailDoc);
detachedEmailDoc = null;
if (uiModule) uiModule.showError(data.error || 'Failed to send');
}
} catch (e) {
_restoreDetachedEmailDoc(detachedEmailDoc);
detachedEmailDoc = null;
if (uiModule) uiModule.showError(e?.message ? `Failed to send email: ${e.message}` : 'Failed to send email');
} finally {
_emailSendInFlight = false;
if (sendSpinner) sendSpinner.destroy();
if (btn) {
btn.disabled = false;
@@ -4013,41 +4069,6 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
return ids;
}
function _detachActiveEmailForBackground(docId) {
if (!docId || !docs.has(docId)) return null;
saveCurrentToMap();
const doc = docs.get(docId);
const snapshot = { id: docId, doc: { ...doc } };
const wasActive = activeDocId === docId;
if (wasActive) saveDocument({ silent: true }).catch(() => {});
const visibleBefore = _visibleDocIdsForCurrentSession();
const idx = visibleBefore.indexOf(docId);
docs.delete(docId);
if (wasActive) activeDocId = null;
if (wasActive) {
const remaining = visibleBefore.filter(id => id !== docId && docs.has(id));
const nextId = remaining[idx] || remaining[idx - 1] || remaining[0] || null;
if (nextId) {
switchToDoc(nextId);
} else {
closePanel();
}
}
renderTabs();
_syncDocIndicator();
return snapshot;
}
function _restoreDetachedEmailDoc(snapshot) {
if (!snapshot || !snapshot.id || !snapshot.doc) return;
if (!docs.has(snapshot.id)) docs.set(snapshot.id, snapshot.doc);
_ensureDocPaneMounted();
switchToDoc(snapshot.id);
_syncDocIndicator();
}
function _closeWithoutDeleting(deleteDoc = false) {
if (!activeDocId) return;
if (deleteDoc) {
@@ -4068,10 +4089,9 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
renderTabs();
}
// Fast/Full + optional context popover for the doc-editor email Reply button.
// Mirrors the email reader's AI reply choice popover so the UX is identical:
// textarea for an optional steering note, then Fast (lightning) or Full
// (concentric dot) buttons; both feed into _aiReply with the chosen mode.
// Fast AI reply + optional context popover for the doc-editor email Reply button.
// Mirrors the email reader's AI reply choice popover: textarea for an
// optional steering note, then one Submit button.
let _docAiReplyChoiceMenu = null;
const _AI_REPLY_CONTEXT_STORE_PREFIX = 'odysseus:email-ai-reply-context:v1:';
function _docAiReplyContextKey() {
@@ -4149,15 +4169,11 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
].join(';');
menu.innerHTML = `
<div style="display:flex;flex-direction:column;gap:6px;min-width:200px;">
<textarea data-note-input rows="2" placeholder="Add context (optional)" style="width:100%;box-sizing:border-box;resize:vertical;min-height:42px;font-family:inherit;font-size:11px;padding:5px 6px;border-radius:5px;border:1px solid var(--border,#333);background:var(--bg-elev,#1a1a1a);color:var(--fg);"></textarea>
<textarea data-note-input rows="2" placeholder="Context (optional)" style="width:100%;box-sizing:border-box;resize:vertical;min-height:42px;font-family:inherit;font-size:11px;padding:5px 6px;border-radius:5px;border:1px solid var(--border,#333);background:var(--bg-elev,#1a1a1a);color:var(--fg);"></textarea>
<div style="display:flex;align-items:center;gap:4px;">
<button class="memory-toolbar-btn" data-mode="ai-reply-fast" title="Shorter, faster draft" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="var(--accent, var(--red))" aria-hidden="true"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
Fast
</button>
<button class="memory-toolbar-btn" data-mode="ai-reply-full" title="Fuller reply with more context" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="color:var(--accent, var(--red));"><circle cx="12" cy="12" r="6"/></svg>
Full
<button class="memory-toolbar-btn" data-mode="ai-reply-fast" title="Draft reply" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="color:var(--accent, var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
Submit
</button>
</div>
</div>
@@ -4203,6 +4219,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim() || '';
const sourceUid = document.getElementById('doc-email-source-uid')?.value?.trim() || '';
const sourceFolder = document.getElementById('doc-email-source-folder')?.value?.trim() || 'INBOX';
const sourceAccountId = docs.get(activeDocId)?.sourceEmailAccountId || window.__odysseusActiveEmailAccount || '';
const cleanAiReplyText = (text) => {
if (!text) return '';
let t = String(text);
@@ -4217,15 +4234,16 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
return t
.replace(/<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/gi, '')
.replace(/<<<\s*END\s*>>+/gi, '')
.replace(/<\/?\|(?:assistant|assistan|user|system|tool)\|>?|<\/\|end\|>?/gi, '')
.trim();
};
const shouldUseFastAiReply = () => {
const text = `${subject}\n${currentBody}`.toLowerCase();
if (/\b(attach(?:ed|ment)?|pdf|document|contract|invoice|receipt|quote|estimate|proposal|question|questions|details|schedule|booking|reservation|meeting|calendar|availability|confirm|confirmation|review|sign|signature)\b/.test(text)) {
return false;
const splitCurrent = _splitEmailReplyQuote(currentBody);
const ownText = String(splitCurrent.body || '').trim();
const isReplaceableDraft = !ownText || /^(\[AI reply draft will appear here\]|Drafting AI reply)/i.test(ownText);
if (!isReplaceableDraft) {
if (uiModule) uiModule.showToast('Reply already has text');
return;
}
return currentBody.length < 2500;
};
// Use the current chat model
let currentModel = '';
@@ -4243,9 +4261,6 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
// so the backend's "no body" guard doesn't fail. The user_hint carries
// the user's compose intent; the model uses To/Subject + that hint.
const bodyForApi = currentBody || (noteHint ? '(no prior email — compose a new message based on the To, Subject, and user instructions)' : currentBody);
const fastFlag = mode === 'ai-reply-fast' ? true
: mode === 'ai-reply-full' ? false
: shouldUseFastAiReply();
const res = await fetch(`${API_BASE}/api/email/ai-reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -4258,7 +4273,8 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
message_id: inReplyTo,
uid: sourceUid,
folder: sourceFolder,
fast: fastFlag,
account_id: sourceAccountId,
fast: true,
user_hint: noteHint || '',
}),
});
@@ -4271,11 +4287,8 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
// currentBody. Without this, AI's invented quote stacked on top
// of the real one and looked like the history had been "edited".
cleanReply = cleanReply.replace(/\n*On\b[\s\S]*?\bwrote:[\s\S]*$/m, '').trim();
// Never overwrite the existing draft (user's typed text + the
// quoted history below it). Always prepend the AI suggestion so
// the user can read it, copy parts, or delete it — but their
// own work and the original quote are untouched.
const newBody = currentBody ? cleanReply + '\n\n' + currentBody : cleanReply;
const quote = splitCurrent.quote || '';
const newBody = cleanReply + (quote ? `\n\n${quote}` : '');
await _streamEmailBodyText(textarea, newBody);
_clearDocAiReplyContext(contextKey || _docAiReplyContextKey());
if (uiModule) uiModule.showToast(`AI draft inserted (${data.model_used || 'AI'})`);
@@ -4568,7 +4581,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const isEmail = doc.language === 'email';
if (isEmail) {
_setMarkdownPreviewActive(false, { remember: false });
_showEmailFields(doc);
const forceHeaderFields = !!doc._skipLocalDraftOnce;
const applyLocalDraft = forceHeaderFields ? false : true;
doc._skipLocalDraftOnce = false;
_showEmailFields(doc, { applyLocalDraft, forceHeaderFields });
} else {
_hideEmailFields();
const wantsMarkdownPreview = (doc.language || 'markdown') === 'markdown' && doc._markdownPreviewActive === true;
@@ -4913,7 +4929,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
<button type="button" class="md-view-opt" data-renderview="code" title="Edit code"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></button>
<button type="button" class="md-view-opt" data-renderview="run" title="Run / Preview"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
</span>
<button id="doc-email-ai-reply-btn" class="doc-action-icon-btn md-toolbar-email-only" type="button" title="Draft a reply with AI (Fast / Full + optional context)" style="display:none;align-items:center;gap:4px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="color:var(--accent, var(--red));flex-shrink:0;position:relative;top:-1px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg><span style="font-size:11px;">Reply</span></button>
<button id="doc-email-ai-reply-btn" class="doc-action-icon-btn md-toolbar-email-only" type="button" title="Draft a reply with AI (fast + optional context)" style="display:none;align-items:center;gap:4px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="color:var(--accent, var(--red));flex-shrink:0;position:relative;top:-1px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg><span style="font-size:11px;">Reply</span></button>
<button id="doc-fontsize-btn" class="doc-action-icon-btn" title="Font size" style="position:relative;width:28px;height:26px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;"><path d="M4 7V4h16v3"/><path d="M12 4v16"/><path d="M8 20h8"/></svg><span class="doc-fontsize-levels"><i data-sz="s">S</i><i data-sz="m">M</i><i data-sz="l">L</i></span></button>
<button id="doc-diff-toggle-btn" class="doc-action-icon-btn" title="Compare changes" style="opacity:0.7;display:none;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="M5 12H2l5-5 5 5H9"/><path d="M19 12h3l-5 5-5-5h3"/></svg></button>
<span class="md-toolbar-sep md-toolbar-edit-only"></span>
@@ -4963,8 +4979,8 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
<button id="doc-email-discard-btn" class="email-discard-btn" title="Close email" style="display:inline-flex;align-items:center;gap:5px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span>Close</span></button>
<span style="flex:1"></span>
<div class="email-send-split">
<button id="doc-email-send-btn" class="email-send-btn email-send-main" title="Send email (Ctrl+Enter)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>Send</button>
<button id="doc-email-send-caret" class="email-send-btn email-send-caret" title="More send options" aria-haspopup="true" aria-expanded="false"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></button>
<button type="button" id="doc-email-send-btn" class="email-send-btn email-send-main" title="Send email (Ctrl+Enter)" onpointerdown="window.odysseusEmailSendIntent&&window.odysseusEmailSendIntent(event)" onmousedown="window.odysseusEmailSendIntent&&window.odysseusEmailSendIntent(event)" onclick="window.odysseusEmailSendIntent&&window.odysseusEmailSendIntent(event)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>Send</button>
<button type="button" id="doc-email-send-caret" class="email-send-btn email-send-caret" title="More send options" aria-haspopup="true" aria-expanded="false" onpointerdown="window.odysseusEmailCaretIntent&&window.odysseusEmailCaretIntent(event)" onmousedown="window.odysseusEmailCaretIntent&&window.odysseusEmailCaretIntent(event)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></button>
<div id="doc-email-more-menu" class="email-more-menu" style="display:none">
<div class="dropdown-item-compact" id="doc-email-draft-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg></span>Save Draft</div>
<div class="dropdown-item-compact" id="doc-email-schedule-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></span>Schedule Send...</div>
@@ -5400,13 +5416,80 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
));
}
document.getElementById('doc-email-send-btn')?.addEventListener('click', () => {
// Pressing Send must never leave the "more options" menu showing.
const _eventInsideElement = (e, el) => {
if (!e || !el || typeof e.clientX !== 'number' || typeof e.clientY !== 'number') return false;
const rect = el.getBoundingClientRect();
return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
};
const handleSendIntent = (e) => {
if (e && e.__odysseusEmailSendHandled) return;
const rawTarget = e && e.target;
const target = rawTarget && rawTarget.nodeType === Node.TEXT_NODE ? rawTarget.parentElement : rawTarget;
const sendButtons = Array.from(document.querySelectorAll('#doc-email-send-btn'));
const targetBtn = target && target.closest ? target.closest('#doc-email-send-btn') : null;
const rectBtn = sendButtons.find((candidate) => _eventInsideElement(e, candidate));
const btn = targetBtn || rectBtn || null;
if (!btn || btn.disabled) return;
if (e) {
e.preventDefault();
e.stopPropagation();
e.__odysseusEmailSendHandled = true;
}
const _m = document.getElementById('doc-email-more-menu');
if (_m) _m.style.display = 'none';
document.getElementById('doc-email-send-caret')?.setAttribute('aria-expanded', 'false');
_sendEmail();
};
window.odysseusEmailSendIntent = handleSendIntent;
if (!window._emailSendDelegatedBoundV3) {
window._emailSendDelegatedBoundV3 = true;
['pointerdown', 'mousedown', 'pointerup', 'click'].forEach((type) => {
window.addEventListener(type, handleSendIntent, true);
document.addEventListener(type, handleSendIntent, true);
});
}
let lastCaretToggleAt = 0;
const toggleSendMenu = (caret) => {
const menu = document.getElementById('doc-email-more-menu');
if (!menu) return;
const opening = menu.style.display === 'none';
menu.style.display = opening ? '' : 'none';
if (caret) caret.setAttribute('aria-expanded', String(opening));
};
const handleCaretIntent = (e) => {
if (e && e.__odysseusEmailCaretHandled) return;
const now = Date.now();
if (e && e.type === 'click' && now - lastCaretToggleAt < 350) {
e.preventDefault();
e.stopPropagation();
e.__odysseusEmailCaretHandled = true;
return;
}
const rawTarget = e && e.target;
const target = rawTarget && rawTarget.nodeType === Node.TEXT_NODE ? rawTarget.parentElement : rawTarget;
const carets = Array.from(document.querySelectorAll('#doc-email-send-caret'));
const targetCaret = target && target.closest ? target.closest('#doc-email-send-caret') : null;
const rectCaret = carets.find((candidate) => _eventInsideElement(e, candidate));
const caret = targetCaret || rectCaret || null;
if (!caret) return;
if (e) {
e.preventDefault();
e.stopPropagation();
e.__odysseusEmailCaretHandled = true;
}
lastCaretToggleAt = now;
toggleSendMenu(caret);
};
window.odysseusEmailCaretIntent = handleCaretIntent;
if (!window._emailCaretDelegatedBoundV1) {
window._emailCaretDelegatedBoundV1 = true;
['pointerdown', 'mousedown', 'click'].forEach((type) => {
window.addEventListener(type, handleCaretIntent, true);
document.addEventListener(type, handleCaretIntent, true);
});
}
// Ctrl+Enter / Cmd+Enter sends the email when an email doc is active
// Bind once at module level via a guard to avoid duplicate listeners on re-open
@@ -5493,16 +5576,8 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
window.visualViewport.addEventListener('resize', _maybeAutoCollapseEmailHeader);
}
// Split-button caret toggles the send-options menu (drops up).
document.getElementById('doc-email-send-caret')?.addEventListener('click', (e) => {
e.stopPropagation();
const menu = document.getElementById('doc-email-more-menu');
const caret = document.getElementById('doc-email-send-caret');
if (!menu) return;
const opening = menu.style.display === 'none';
menu.style.display = opening ? '' : 'none';
if (caret) caret.setAttribute('aria-expanded', String(opening));
});
// Split-button caret toggles the send-options menu.
document.getElementById('doc-email-send-caret')?.addEventListener('click', handleCaretIntent);
document.addEventListener('click', (e) => {
const menu = document.getElementById('doc-email-more-menu');
// Keep the menu open ONLY while interacting with the caret itself or the
@@ -7009,6 +7084,13 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
export function injectFreshDoc(doc) {
if (!doc || !doc.id) return;
const sessionId = doc.session_id || _lastSessionId || null;
if (doc.language === 'email') {
doc._skipLocalDraftOnce = true;
try {
const fields = _parseEmailHeader(doc.current_content || doc.content || '');
_clearEmailLocalDraft(fields.sourceUid, fields.sourceFolder, fields.inReplyTo);
} catch (_) {}
}
addDocToTabs(doc, sessionId);
// Use _ensureDocPaneMounted (not `if (!isOpen) openPanel()`): when a draft
// is composed from the email modal, `isOpen` can be stale-true while the
@@ -7016,10 +7098,13 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
// mounts into a wrong/half-built pane (rendered as a narrow sidebar on
// mobile instead of its own full-screen window). This remounts it cleanly.
_ensureDocPaneMounted();
// Defer to next frame so the panel DOM exists before switchToDoc populates
requestAnimationFrame(() => requestAnimationFrame(() => {
switchToDoc(doc.id);
}));
// Defer to the next frame so the panel DOM exists before switchToDoc
// populates it. Do not call switchToDoc synchronously here: it saves the
// previously active doc and can re-enter the email draft path while a reply
// document is still being injected.
requestAnimationFrame(() => {
if (docs.has(doc.id)) switchToDoc(doc.id);
});
}
export async function replaceEmailReplyBody(docId, replyText, { force = false } = {}) {
@@ -7053,6 +7138,90 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800);
}
function _buildEmailContentFromFields(fields, body) {
const f = fields || {};
let header = `To: ${f.to || ''}`;
if (f.cc) header += `\nCc: ${f.cc}`;
if (f.bcc) header += `\nBcc: ${f.bcc}`;
header += `\nSubject: ${f.subject || ''}`;
if (f.inReplyTo) header += `\nIn-Reply-To: ${f.inReplyTo}`;
if (f.references) header += `\nReferences: ${f.references}`;
if (f.sourceUid) header += `\nX-Source-UID: ${f.sourceUid}`;
if (f.sourceFolder) header += `\nX-Source-Folder: ${f.sourceFolder}`;
if (f.forwardAttachments) header += `\nX-Forward-Attachments: 1`;
if (Array.isArray(f.attachments) && f.attachments.length) {
const attStr = f.attachments
.map(a => `${a.index}:${a.filename}:${a.size}`)
.join('|');
header += `\nX-Attachments: ${attStr}`;
}
return header + '\n---\n' + (body || '');
}
export async function ensureEmailDraftEnvelope(docId, freshContent) {
const doc = docs.get(docId);
if (!doc || doc.language !== 'email') return false;
const current = _parseEmailHeader(doc.content || '');
const fresh = _parseEmailHeader(freshContent || '');
if (!fresh.to && !fresh.subject && !fresh.sourceUid) return false;
const needsEnvelope = (
(!current.to && !!fresh.to) ||
(!current.subject && !!fresh.subject) ||
(!current.inReplyTo && !!fresh.inReplyTo) ||
(!current.references && !!fresh.references) ||
(!current.sourceUid && !!fresh.sourceUid) ||
(!current.sourceFolder && !!fresh.sourceFolder)
);
const currentSplit = _splitEmailReplyQuote(current.body || '');
const freshSplit = _splitEmailReplyQuote(fresh.body || '');
const currentOwnText = String(currentSplit.body || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const freshOwnText = String(freshSplit.body || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const currentBodyText = String(current.body || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const freshBodyText = String(fresh.body || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const needsBodyRepair = (
!!freshBodyText &&
(
!currentBodyText ||
(!currentOwnText && !!freshOwnText) ||
(!currentSplit.quote && !!freshSplit.quote)
)
);
if (!needsEnvelope && !needsBodyRepair) return false;
let body = current.body || '';
if (needsBodyRepair && (!currentBodyText || (!currentOwnText && !!freshOwnText))) {
body = fresh.body || '';
} else if (!String(body).trim()) {
body = fresh.body || '';
} else if (currentSplit.body && freshSplit.quote && !currentSplit.quote) {
body = `${currentSplit.body}\n\n${freshSplit.quote}`;
}
const merged = {
...fresh,
to: current.to || fresh.to || '',
cc: current.cc || fresh.cc || '',
bcc: current.bcc || fresh.bcc || '',
subject: current.subject || fresh.subject || '',
inReplyTo: current.inReplyTo || fresh.inReplyTo || '',
references: current.references || fresh.references || '',
sourceUid: current.sourceUid || fresh.sourceUid || '',
sourceFolder: current.sourceFolder || fresh.sourceFolder || '',
forwardAttachments: current.forwardAttachments || fresh.forwardAttachments || false,
attachments: (current.attachments && current.attachments.length) ? current.attachments : (fresh.attachments || []),
};
doc.content = _buildEmailContentFromFields(merged, body);
if (activeDocId === docId) {
_showEmailFields(doc, { applyLocalDraft: false });
}
clearTimeout(_autoSaveDebounce);
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800);
return true;
}
// Force the panel into a genuinely-open state. `isOpen` can be true while the
// pane was torn down by another full-screen view (e.g. opening a doc from the
// email modal): in that case openPanel() early-returns and nothing mounts, so
@@ -7187,31 +7356,22 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
_syncDocIndicator();
// Switch to the most recently active one (or first)
const target = activeDocs[0];
if (restoreMode && shouldRestoreMinimized && !shouldRestoreOpen) {
activeDocId = null;
if (restoreMode && !shouldRestoreOpen) {
// Coming back to a chat with documents should advertise the doc without
// stealing half the screen. Default to a docked chip; only reopen the
// full editor when this session explicitly persisted an open state.
activeDocId = target.id;
_minimizedDocId = target.id;
_markDocVisibleState(sessionId, 'minimized');
_ensureDocChipRegistered();
if (isOpen) {
try { switchToDoc(target.id); } catch (e) { console.error('Minimize restored doc failed:', e); }
closePanel('down');
} else {
Modals.minimize('doc-panel');
}
return;
}
// Removed: the old "if restoreMode && !shouldRestoreOpen → stay
// closed" branch. Users expect that entering a chat with an
// attached document opens the panel automatically, not just shows
// an indicator. The minimised branch above still respects an
// explicit user choice to dock the panel; everything else falls
// through to the "open panel" path below.
if (false) {
activeDocId = null;
_minimizedDocId = null;
if (Modals.isRegistered('doc-panel')) Modals.unregister('doc-panel');
return;
}
// Always open when there are docs — the minimised branch above
// already returned for users who explicitly docked the panel.
// The previous `if (!restoreMode || shouldRestoreOpen)` gate left
// the panel closed on first entry to a chat with docs, which
// hides the doc unless the user manually opens the panel.
_markDocVisibleState(sessionId, 'open');
if (!isOpen) openPanel();
switchToDoc(target.id);
@@ -7231,11 +7391,12 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
id: doc.id,
title: doc.title || '',
language: doc.language || '',
content: doc.current_content || '',
content: doc.current_content || doc.content || '',
version: doc.version_count || 1,
sessionId: sessionId || doc.session_id,
userSetLanguage: !!doc.language,
_composeAtts: existing?._composeAtts,
_skipLocalDraftOnce: !!doc._skipLocalDraftOnce,
// Provenance for the "Send signed reply" flow
sourceEmailUid: doc.source_email_uid || null,
sourceEmailFolder: doc.source_email_folder || null,
@@ -11010,6 +11171,7 @@ const documentModule = {
loadDocument,
injectFreshDoc,
replaceEmailReplyBody,
ensureEmailDraftEnvelope,
ensurePaneMounted: _ensureDocPaneMounted,
loadSessionDocs,
ensureDocPanel,
+9 -7
View File
@@ -19,6 +19,7 @@ let _esc; // HTML-escape function
let _getDocs; // () => Map of open docs
let _isOpenFn; // () => boolean — is doc panel open
let _createDocument;
let _newDocument;
let _loadDocument;
let _switchToDoc;
let _openPanel;
@@ -31,6 +32,7 @@ export function initLibrary(config) {
_getDocs = config.getDocs;
_isOpenFn = config.isOpen;
_createDocument = config.createDocument;
_newDocument = config.newDocument;
_loadDocument = config.loadDocument;
_switchToDoc = config.switchToDoc;
_openPanel = config.openPanel;
@@ -3224,16 +3226,16 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
const createBtn = document.getElementById('doclib-create-btn');
if (createBtn) {
createBtn.addEventListener('click', async () => {
// Create a new session, then create a blank document in it
try {
const sRes = await fetch('/api/session', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Untitled Document' }) });
const sData = await sRes.json();
const sessionId = sData.session_id;
if (_newDocument) {
await _newDocument();
} else {
const sessionId = sessionModule && sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId();
if (!sessionId) throw new Error('No active session');
await _createDocument(sessionId);
// Close library and open the new session
}
closeLibrary();
if (window.sessionsModule) window.sessionsModule.loadSession(sessionId);
setTimeout(() => _openPanel(), 300);
setTimeout(() => _openPanel(), 50);
} catch (e) {
console.error('Failed to create document:', e);
if (uiModule) uiModule.showError('Failed to create document');
+7 -1
View File
@@ -136,6 +136,10 @@ export function wireInpaintButtons({
const dilatedMask = dilateMask(mergedMask, padPx);
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
const maskB64 = dilatedMask.toDataURL('image/png').split(',')[1];
const baseSnap = document.createElement('canvas');
baseSnap.width = state.imgWidth;
baseSnap.height = state.imgHeight;
baseSnap.getContext('2d').drawImage(flatCanvas, 0, 0);
const res = await fetch('/api/image/inpaint', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
@@ -180,7 +184,7 @@ export function wireInpaintButtons({
maskSnap.width = state.maskCanvas.width;
maskSnap.height = state.maskCanvas.height;
maskSnap.getContext('2d').drawImage(state.maskCanvas, 0, 0);
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, padPx };
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, base: baseSnap, padPx };
// Apply initial alpha = hard mask (no feather, no edge shift).
applyInpaintFeather(resultLayer, 0, 0);
state.layers.push(resultLayer);
@@ -216,7 +220,9 @@ export function wireInpaintButtons({
const eRow = document.getElementById('ge-inpaint-edgestroke-row');
const eSlider = document.getElementById('ge-edgestroke-slider');
const eLabel = document.getElementById('ge-edgestroke-label');
const autoRow = document.getElementById('ge-inpaint-automatch-row');
if (eRow) eRow.style.display = '';
if (autoRow) autoRow.style.display = '';
if (eSlider) {
eSlider.max = String(padPx);
eSlider.min = String(-padPx);
+35
View File
@@ -102,6 +102,35 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
</div>
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Click a region to select similar pixels. Shift+click to add, Alt+click to subtract. Esc to clear.</p>
</div>
<div class="ge-sam-section" id="ge-sam-section" style="display:none;">
<div class="ge-section-title ge-section-title-with-help"><span>SAM</span><span class="ge-section-help" tabindex="0" role="img" aria-label="SAM selection help" title="Click an object for visual SAM selection, or type a neutral object label and use Find. The text is only used to locate a region before SAM creates the mask.">?</span></div>
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next SAM selection combines with the current selection. Shift / Alt held during a click override this for one click.">
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+ Add</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection"> Subtract</button>
</div>
<div class="ge-control-row" style="display:flex;gap:6px;align-items:center;min-width:0;">
<input type="text" class="ge-inpaint-prompt" id="ge-sam-query" placeholder="Object to select..." style="flex:1 1 auto;min-width:0;" />
<button class="ge-btn ge-btn-sm ge-btn-ai" id="ge-sam-find" style="height:28px;display:inline-flex;align-items:center;gap:5px;" title="Find object and create a SAM mask">
<span class="ge-btn-ai-mark" aria-hidden="true"></span>
Find
</button>
</div>
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-sam-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-clear" title="Clear the selection">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
Clear
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-mask" title="Add selection to the inpaint mask">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>
To Mask
</button>
</div>
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Click an object, or type a neutral object label. Shift adds, Alt subtracts.</p>
</div>
<div class="ge-inpaint-section" id="ge-inpaint-section" style="display:none;">
<div class="ge-inpaint-popover-head" data-inpaint-drag>
<div class="ge-section-title ge-section-title-with-help ge-inpaint-popover-title"><span>INPAINT</span><span class="ge-section-help" tabindex="0" role="img" aria-label="How inpaint works" title="Brush the area you want the AI to redraw the red preview marks the mask region. Use Paint to add, Erase to subtract (or hold Ctrl+Alt to flip for one stroke). Generate fills with what your prompt describes; Remove fills with the surrounding background.">?</span></div>
@@ -189,6 +218,12 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<label>Edge stroke <span id="ge-edgestroke-label">0px</span></label>
<input type="range" id="ge-edgestroke-slider" min="-80" max="80" value="0" title="Expand (+) or contract () the inpaint layer's edge before feathering. Uses the AI buffer generated around your brush." />
</div>
<div class="ge-control-row ge-actions" id="ge-inpaint-automatch-row" style="display:none;margin-top:6px;">
<button class="ge-btn ge-btn-sm ge-btn-iconlabel ge-btn-ai" id="ge-inpaint-automatch" style="width:100%;justify-content:center;" title="Match the latest inpaint result to the surrounding colour and lighting using an adjustment layer.">
<span class="ge-btn-ai-mark" aria-hidden="true"></span>
Auto match color
</button>
</div>
</div>
<div class="ge-eraser-section" id="ge-clone-section" style="display:none;">
<div class="ge-section-title ge-section-title-with-help"><span>Clone</span><span class="ge-section-help" tabindex="0" role="img" aria-label="How clone works" title="Alt-click (desktop) or double-tap (mobile) somewhere on the canvas to set the sample source. Then drag elsewhere to clone those pixels onto the active layer. The source point moves with your brush so the offset stays constant. Size / Opacity / Flow / Softness come from the Brush panel.">?</span></div>
+4 -2
View File
@@ -27,6 +27,7 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
{ id: 'clone', label: 'Clone', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="9" r="3"/><path d="M9 12l-3 4h12l-3-4"/><path d="M4 20h16"/></svg>', key: 'K' },
{ id: 'lasso', label: 'Lasso', icon: '⟡', key: 'L' },
{ id: 'wand', label: 'Wand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4V2"/><path d="M15 16v-2"/><path d="M8 9h2"/><path d="M20 9h2"/><path d="M17.8 11.8L19 13"/><path d="M15 9h0"/><path d="M17.8 6.2L19 5"/><path d="M3 21l9-9"/><path d="M12.2 6.2L11 5"/></svg>', key: 'W' },
{ id: 'sam', label: 'SAM', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7c3-3 13-3 16 0"/><path d="M4 17c3 3 13 3 16 0"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3"/></svg>' },
{ sep: true },
{ id: 'inpaint', label: 'Inpaint', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>', key: 'M' },
{ id: 'rembg', ai: true, label: 'Bg Remove', icon: '✄' },
@@ -54,8 +55,9 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
// Selection-clear badge — rendered only for tools that can hold a
// selection (lasso, wand). Inpaint masks are first-class sub-layers
// now so they get their own delete-X in the layer panel.
const clearBadge = (t.id === 'lasso' || t.id === 'wand')
? '<span class="ge-tool-clear" title="Clear selection" data-clear-tool="' + t.id + '">' +
const clearTitle = t.id === 'sam' ? 'Open SAM prompt' : 'Clear selection';
const clearBadge = (t.id === 'lasso' || t.id === 'wand' || t.id === 'sam')
? '<span class="ge-tool-clear" title="' + clearTitle + '" data-clear-tool="' + t.id + '">' +
'<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>' +
'</span>'
: '';
+3 -2
View File
@@ -26,6 +26,7 @@
* openFxPopup: (layer: object, anchor: HTMLElement) => void,
* editAdjLayer: (layer: object, adj: object, anchor: HTMLElement) => void,
* createLayer: (name: string, w: number, h: number) => object,
* renderLayer?: (layer: object) => HTMLCanvasElement,
* lassoToMask: () => void,
* wandToMask: () => void,
* getActiveMaskLayer: () => object | null,
@@ -54,7 +55,7 @@ export function createLayerPanelRenderer(deps) {
const {
composite, saveState, showLayerThumb, hideLayerThumb,
loadLayerAlphaAsSelection, openFxPopup, editAdjLayer,
createLayer, lassoToMask, wandToMask, getActiveMaskLayer,
createLayer, renderLayer, lassoToMask, wandToMask, getActiveMaskLayer,
syncFxPanelToActiveLayerIfPresent,
dragSortModule, uiModule,
} = deps;
@@ -336,7 +337,7 @@ export function createLayerPanelRenderer(deps) {
mergeDownBtn.addEventListener('click', (e) => {
e.stopPropagation();
saveState(`Merge "${layer.name}" down`);
mergeLayerDownAtIndex(i);
mergeLayerDownAtIndex(i, renderLayer);
composite();
render();
uiModule.showToast('Layer merged down');
+5 -1
View File
@@ -26,6 +26,7 @@
* @param {{
* composite: () => void,
* applyInpaintFeather: (layer: object, featherPx: number, edgeShiftPx: number) => void,
* autoMatchInpaint: () => void,
* syncToolClearIndicators: () => void,
* attachColorPicker: (el: HTMLInputElement) => void,
* uiModule: object,
@@ -37,7 +38,7 @@ const EYE_OPEN_SM = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
const EYE_OFF_SM = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>';
export function wireInpaintControls({
composite, applyInpaintFeather, syncToolClearIndicators,
composite, applyInpaintFeather, autoMatchInpaint, syncToolClearIndicators,
attachColorPicker, uiModule,
}) {
// ── Feather + Strength preview swatches ──
@@ -93,6 +94,9 @@ export function wireInpaintControls({
document.getElementById('ge-strength-label').textContent = (e.target.value / 100).toFixed(2);
syncStrengthPreview(parseInt(e.target.value, 10));
});
document.getElementById('ge-inpaint-automatch')?.addEventListener('click', () => {
if (typeof autoMatchInpaint === 'function') autoMatchInpaint();
});
syncFeatherPreview(0);
syncStrengthPreview(75);
+53 -15
View File
@@ -15,32 +15,60 @@
* createLayer: (name, w, h) => object,
* renderLayerPanel: () => void,
* composite: () => void,
* renderLayer?: (layer) => HTMLCanvasElement,
* uiModule: object,
* }} deps
*/
import { state } from './state.js';
export function mergeLayerDownAtIndex(idx) {
function _renderSource(layer, renderLayer) {
if (!layer) return null;
try {
return typeof renderLayer === 'function' ? (renderLayer(layer) || layer.canvas) : layer.canvas;
} catch {
return layer.canvas;
}
}
function _clearBakedAdjustments(layer) {
if (!layer) return;
layer.adjLayers = [];
layer._adjFinal = null;
layer._adjFinalKey = '';
layer._adjCache = null;
layer._adjCacheKey = '';
}
export function mergeLayerDownAtIndex(idx, renderLayer = null) {
if (idx < 1 || idx >= state.layers.length) return null;
const upper = state.layers[idx];
const lower = state.layers[idx - 1];
const upperOff = state.layerOffsets.get(upper.id) || { x: 0, y: 0 };
const lowerOff = state.layerOffsets.get(lower.id) || { x: 0, y: 0 };
lower.ctx.save();
lower.ctx.globalAlpha = upper.opacity;
lower.ctx.drawImage(
upper.canvas,
upperOff.x - lowerOff.x,
upperOff.y - lowerOff.y,
);
lower.ctx.restore();
const lowerSource = _renderSource(lower, renderLayer);
const upperSource = _renderSource(upper, renderLayer);
const merged = document.createElement('canvas');
merged.width = state.imgWidth;
merged.height = state.imgHeight;
const mctx = merged.getContext('2d');
mctx.globalAlpha = lower.opacity;
mctx.drawImage(lowerSource, lowerOff.x, lowerOff.y);
mctx.globalAlpha = upper.opacity;
mctx.drawImage(upperSource, upperOff.x, upperOff.y);
mctx.globalAlpha = 1;
lower.canvas = merged;
lower.ctx = lower.canvas.getContext('2d');
lower.opacity = 1;
lower.visible = true;
state.layerOffsets.set(lower.id, { x: 0, y: 0 });
_clearBakedAdjustments(lower);
state.layers.splice(idx, 1);
state.layerOffsets.delete(upper.id);
state.activeLayerId = lower.id;
return lower;
}
export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, composite, uiModule }) {
export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, composite, renderLayer, uiModule }) {
// Flatten Copy.
document.getElementById('ge-flatten')?.addEventListener('click', () => {
if (state.layers.length < 2) return;
@@ -51,9 +79,10 @@ export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, com
if (!l.visible) continue;
const off = state.layerOffsets.get(l.id) || { x: 0, y: 0 };
ctx.globalAlpha = l.opacity;
ctx.drawImage(l.canvas, off.x, off.y);
ctx.drawImage(_renderSource(l, renderLayer), off.x, off.y);
ctx.globalAlpha = 1;
}
_clearBakedAdjustments(merged);
state.layers.push(merged);
state.activeLayerId = merged.id;
renderLayerPanel();
@@ -70,14 +99,23 @@ export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, com
}
saveState('Merge all');
const base = visibleLayers[0];
const baseCtx = base.ctx;
for (let i = 1; i < visibleLayers.length; i++) {
const merged = document.createElement('canvas');
merged.width = state.imgWidth;
merged.height = state.imgHeight;
const baseCtx = merged.getContext('2d');
for (let i = 0; i < visibleLayers.length; i++) {
const l = visibleLayers[i];
const off = state.layerOffsets.get(l.id) || { x: 0, y: 0 };
baseCtx.globalAlpha = l.opacity;
baseCtx.drawImage(l.canvas, off.x, off.y);
baseCtx.drawImage(_renderSource(l, renderLayer), off.x, off.y);
baseCtx.globalAlpha = 1;
}
base.canvas = merged;
base.ctx = base.canvas.getContext('2d');
base.opacity = 1;
base.visible = true;
state.layerOffsets.set(base.id, { x: 0, y: 0 });
_clearBakedAdjustments(base);
// Free offset entries for the discarded layers; keep base.
for (const l of state.layers) {
if (l === base) continue;
@@ -95,7 +133,7 @@ export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, com
const idx = state.layers.findIndex(l => l.id === state.activeLayerId);
if (idx < 1) return; // can't merge the bottom layer
saveState('Merge down');
mergeLayerDownAtIndex(idx);
mergeLayerDownAtIndex(idx, renderLayer);
renderLayerPanel();
composite();
uiModule.showToast('Layer merged down');
+8 -3
View File
@@ -71,10 +71,15 @@ export function wireTopbar(deps) {
// original IDs so the standalone handlers below wire to them
// unchanged.
{
const saveBtn = document.getElementById('ge-save-menu-btn');
const saveMenu = document.getElementById('ge-save-menu');
const editorRoot = document.getElementById('gallery-editor-container') || document;
const saveBtn = editorRoot.querySelector('#ge-save-menu-btn');
const saveWrap = saveBtn?.closest('.ge-save-wrap');
const saveMenu = saveWrap?.querySelector('#ge-save-menu');
if (saveBtn && saveMenu) {
const saveTopbar = saveBtn.closest('.ge-topbar');
document.querySelectorAll('body > #ge-save-menu').forEach((menu) => {
if (menu !== saveMenu) menu.remove();
});
// Reparent the menu to <body>. Without this, the menu inherits
// the gallery modal's containing block (the modal applies a
// `transform: scale(...)` for its enter animation — and any
@@ -105,7 +110,7 @@ export function wireTopbar(deps) {
saveMenu.addEventListener('click', () => { setSaveMenuOpen(false); });
window.addEventListener('resize', () => { if (!saveMenu.hidden) positionSaveMenu(); });
registerDocClickAway((e) => {
if (!saveMenu.hidden && !saveMenu.contains(e.target) && e.target !== saveBtn) {
if (!saveMenu.hidden && !saveMenu.contains(e.target) && !saveBtn.contains(e.target)) {
setSaveMenuOpen(false);
}
});
+80 -37
View File
@@ -5,7 +5,7 @@
import spinnerModule from './spinner.js';
import sessionModule from './sessions.js';
import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen } from './emailLibrary.js';
import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen, prewarmEmailLibrary, prewarmUnreadEmails } from './emailLibrary.js?v=20260722emailfastindex1';
import * as Modals from './modalManager.js';
import { applyEdgeDock } from './modalSnap.js';
import { buildReplyAllCc, extractEmail } from './emailLibrary/replyRecipients.js';
@@ -112,21 +112,10 @@ function _cleanAiReplyText(text) {
return t
.replace(/<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/gi, '')
.replace(/<<<\s*END\s*>>+/gi, '')
.replace(/<\/?\|(?:assistant|assistan|user|system|tool)\|>?|<\/\|end\|>?/gi, '')
.trim();
}
function _shouldUseFastAiReply(data) {
const body = String(data?.body || data?.body_html || '');
const subject = String(data?.subject || '');
const atts = Array.isArray(data?.attachments) ? data.attachments : [];
if (atts.length > 0) return false;
const text = `${subject}\n${body}`.toLowerCase();
if (/\b(attach(?:ed|ment)?|pdf|document|contract|invoice|receipt|quote|estimate|proposal|question|questions|details|schedule|booking|reservation|meeting|calendar|availability|confirm|confirmation|review|sign|signature)\b/.test(text)) {
return false;
}
return body.length < 2500;
}
let _emails = [];
let _currentFolder = 'INBOX';
let _offset = 0;
@@ -202,6 +191,7 @@ export function init(documentModule) {
}
},
});
prewarmEmailLibrary({ delay: 1800 });
_watchDocOpenToReDockEmail();
}
@@ -348,7 +338,11 @@ async function _refreshUnreadCount() {
const maxUid = parseInt(data.max_uid || '0', 10) || 0;
// Only show dot if there's a new email above the threshold
dot.style.display = maxUid > lastSeen ? '' : 'none';
const hasNewUnread = maxUid > lastSeen;
dot.style.display = hasNewUnread ? '' : 'none';
if (hasNewUnread && !isLibOpen()) {
prewarmUnreadEmails({ limit: Math.min(10, Math.max(1, unreadCount)), maxUid }).catch(() => {});
}
// Color the dot by urgency tier. Cache the per-uid map so the per-row
// renderer can reuse it without a second fetch.
@@ -405,22 +399,29 @@ export async function loadEmails(append = false) {
try {
const fromQS = _senderFilter ? `&from=${encodeURIComponent(_senderFilter)}` : '';
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(_currentFolder)}&limit=50&offset=${_offset}${fromQS}${_acct()}`);
const data = await res.json();
if (data.error) throw new Error(data.error);
const applyListData = (data) => {
if (!append) _emails = [];
_emails.push(...(data.emails || []));
_total = data.total || 0;
// Remove spinner
if (_listSpinner) { _listSpinner.destroy(); _listSpinner = null; }
_renderList();
const unreadCount = _emails.filter(e => !e.is_read).length;
const dot = document.getElementById('email-unread-dot');
if (dot) dot.style.display = unreadCount > 0 ? '' : 'none';
};
if (!append && !_senderFilter) {
try {
const cachedRes = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(_currentFolder)}&limit=50&offset=${_offset}&cached_only=1${_acct()}`);
const cachedData = await cachedRes.json();
if (!cachedData.error && (cachedData.emails || []).length) {
applyListData(cachedData);
}
} catch (_) {}
}
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(_currentFolder)}&limit=50&offset=${_offset}${fromQS}${_acct()}`);
const data = await res.json();
if (data.error) throw new Error(data.error);
applyListData(data);
} catch (e) {
console.error('Failed to load emails:', e);
if (_listSpinner) { _listSpinner.destroy(); _listSpinner = null; }
@@ -751,7 +752,7 @@ function _createEmailItem(em) {
}
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', noteHint = '', prefilledBody = '') {
const aiReplyMode = mode === 'ai-reply-fast' ? 'fast' : (mode === 'ai-reply-full' ? 'full' : '');
const aiReplyMode = mode === 'ai-reply-fast' ? 'fast' : '';
const wantsAiReply = mode === 'ai-reply' || !!aiReplyMode;
// Body pre-fill from the agent's open_email_reply tool call takes the
// same insertion slot as an AI-suggested body — both land just before
@@ -786,8 +787,29 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
console.error('Failed to read email:', data.error);
return;
}
// The list row is already populated from the durable email index. Some
// IMAP/read paths can return a partial object for long Outlook threads;
// never let that create a reply draft with blank To/Subject.
const _fallback = (primary, fallback) => {
const p = primary == null ? '' : String(primary).trim();
if (p) return primary;
return fallback == null ? '' : fallback;
};
data = {
...em,
...data,
uid: data.uid || em.uid,
subject: _fallback(data.subject, em.subject),
from_name: _fallback(data.from_name, em.from_name || em.from_address),
from_address: _fallback(data.from_address, em.from_address),
to: _fallback(data.to, em.to),
cc: _fallback(data.cc, em.cc),
date: _fallback(data.date, em.date),
message_id: _fallback(data.message_id, em.message_id),
};
if (wantsAiReply) {
if (data.cached_ai_reply) {
const activeReplyAccount = data.account_id || em.account_id || window.__odysseusActiveEmailAccount || '';
if (data.cached_ai_reply && !noteHint && !activeReplyAccount) {
aiSuggestedBody = _cleanAiReplyText(data.cached_ai_reply);
} else {
let draftToastTimer = null;
@@ -813,7 +835,8 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
message_id: data.message_id || '',
uid: String(em.uid || ''),
folder: _currentFolder,
fast: aiReplyMode ? aiReplyMode === 'fast' : _shouldUseFastAiReply(data),
account_id: activeReplyAccount,
fast: true,
user_hint: (noteHint || '').trim() || undefined,
}),
});
@@ -880,6 +903,9 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
let _baseSubject = (data.subject || '').trim();
if (subjectPrefix === 'Re: ' && /^re\s*:/i.test(_baseSubject)) subjectPrefix = '';
else if (subjectPrefix === 'Fwd: ' && /^fwd?\s*:/i.test(_baseSubject)) subjectPrefix = '';
if (mode !== 'forward' && !String(toAddress || '').trim()) {
throw new Error('Cannot create reply: sender address is missing from this email.');
}
let content = `To: ${toAddress}\nSubject: ${subjectPrefix}${_baseSubject}`;
if (ccAddresses) content += `\nCc: ${ccAddresses}`;
if (mode !== 'forward' && data.message_id) content += `\nIn-Reply-To: ${data.message_id}`;
@@ -949,9 +975,10 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
if (_docModule) {
// Agent-provided reply text should land in the email draft the user
// already has open. Otherwise mobile users see the source email while the
// agent silently creates a second draft elsewhere.
const reuseExisting = mode !== 'forward';
// already has open. Plain Reply clicks must create a fresh draft: reusing
// old source-UID drafts can reopen stale quote-only/malformed compose docs
// and block Send on long threads.
const reuseExisting = mode !== 'forward' && !!aiSuggestedBody;
const existingDocId = (reuseExisting && _docModule.findEmailDocId)
? _docModule.findEmailDocId(em.uid, _currentFolder)
: null;
@@ -959,28 +986,37 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
if (!_docModule.isPanelOpen()) _docModule.openPanel();
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
await _docModule.loadDocument(existingDocId);
if (typeof _docModule.ensureEmailDraftEnvelope === 'function') {
await _docModule.ensureEmailDraftEnvelope(existingDocId, content);
}
if (aiSuggestedBody && typeof _docModule.replaceEmailReplyBody === 'function') {
await _docModule.replaceEmailReplyBody(existingDocId, aiSuggestedBody, { force: true });
await _docModule.replaceEmailReplyBody(existingDocId, aiSuggestedBody, { force: false });
}
_bringEmailReplyDraftToFrontOnMobile();
} else {
const activeSid = await _createEmailChat(data);
let activeSid = await _createEmailChat(data, { forceNew: true });
if (!activeSid) {
console.error('reply: could not obtain a session_id');
import('./ui.js').then(m => m.showError && m.showError('Could not start a reply chat.')).catch(() => {});
return;
}
const docRes = await fetch(`${API_BASE}/api/document`, {
const createReplyDoc = (sessionId) => fetch(`${API_BASE}/api/document`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: activeSid,
session_id: sessionId,
title: data.subject,
content: content,
language: 'email',
}),
});
let docRes = await createReplyDoc(activeSid);
if (docRes.status === 404) {
console.warn('[reply-debug] draft session rejected; retrying in a fresh email chat', activeSid);
activeSid = await _createEmailChat(data, { forceNew: true });
if (activeSid) docRes = await createReplyDoc(activeSid);
}
if (!docRes.ok) {
const errText = await docRes.text();
console.error('[reply-debug] POST /api/document failed', docRes.status, errText);
@@ -1255,9 +1291,10 @@ async function _toggleDone(em, itemEl) {
}
}
async function _createEmailChat(emailData) {
async function _createEmailChat(emailData, opts = {}) {
const subject = String(emailData?.subject || 'New Email').trim() || 'New Email';
const title = subject === 'New Email' ? 'New Email' : `Email: ${subject.slice(0, 60)}`;
const forceNew = !!opts.forceNew;
try {
const currentSid = sessionModule.getCurrentSessionId?.() || '';
const current = sessionModule.getSessions?.().find(s => s.id === currentSid);
@@ -1268,7 +1305,7 @@ async function _createEmailChat(emailData) {
&& Number(current.message_count || 0) === 0
&& current.folder !== 'Assistant'
&& current.folder !== 'Tasks';
if (currentIsBlank) {
if (!forceNew && currentIsBlank) {
const meta = document.getElementById('current-meta');
if (meta) meta.textContent = title;
return current.id;
@@ -1319,22 +1356,28 @@ async function _composeNew() {
// (doc shows for a frame, then slides up again). Mount once, at injectFreshDoc,
// after the session + doc exist.
try {
const sid = await _createEmailChat({ subject: 'New Email' });
let sid = await _createEmailChat({ subject: 'New Email' });
if (!sid) {
console.error('compose: could not obtain a session_id');
import('./ui.js').then(m => m.showError && m.showError('Could not start a new email (no session).')).catch(() => {});
return;
}
const res = await fetch(`${API_BASE}/api/document`, {
const createComposeDoc = (sessionId) => fetch(`${API_BASE}/api/document`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sid,
session_id: sessionId,
title: 'New Email',
content: 'To: \nSubject: \n---\n',
language: 'email',
}),
});
let res = await createComposeDoc(sid);
if (res.status === 404) {
console.warn('[compose-debug] draft session rejected; retrying in a fresh email chat', sid);
sid = await _createEmailChat({ subject: 'New Email' }, { forceNew: true });
if (sid) res = await createComposeDoc(sid);
}
if (!res.ok) {
console.error('compose POST failed', res.status, await res.text().catch(() => ''));
import('./ui.js').then(m => m.showError && m.showError('Failed to create new email (' + res.status + ')')).catch(() => {});
+1440 -719
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -356,14 +356,14 @@ export async function uploadPending(opts = {}) {
/**
* Add files to pending list (capped at MAX_FILES)
*/
export async function addFiles(files) {
export async function addFiles(files, opts = {}) {
for (const f of files) {
if (pendingFiles.length >= MAX_FILES) {
_showToast(`Max ${MAX_FILES} files allowed`);
break;
}
let nextFile = f;
if (_isMobileViewport() && _isCroppableImage(f)) {
if (!opts.skipCrop && _isMobileViewport() && _isCroppableImage(f)) {
try {
nextFile = await _openMobileCropper(f);
} catch (_) {
+1 -1
View File
@@ -3,7 +3,7 @@
*/
import uiModule from './ui.js';
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js';
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js?v=20260708match1';
import spinnerModule from './spinner.js';
import { makeWindowDraggable } from './windowDrag.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
+471 -13
View File
@@ -54,12 +54,12 @@ import {
buildThumbnail as _buildThumbnailImpl,
buildMergedMaskCanvas as _buildMergedMaskCanvasImpl,
} from './editor/composite-helpers.js';
import { buildToolbar as _buildToolbar } from './editor/build/toolbar.js';
import { buildToolbar as _buildToolbar } from './editor/build/toolbar.js?v=20260708sam3';
import { buildTopbar as _buildTopbar } from './editor/build/topbar.js';
import {
controlsHTML as _controlsHTML,
layerPanelHTML as _layerPanelHTML,
} from './editor/build/controls.js';
} from './editor/build/controls.js?v=20260708match1';
import {
transformPopupHTML as _transformPopupHTML,
attachSpinRepeat as _attachSpinRepeat,
@@ -96,14 +96,14 @@ import { createShortcutsPopover } from './editor/shortcuts-popover.js';
import { wireKeyboardShortcuts } from './editor/keyboard-shortcuts.js';
import { wireClipboardAndDrop } from './editor/clipboard-and-drop.js';
import { wireAIModelSelectors } from './editor/ai-models.js';
import { wireInpaintButtons } from './editor/ai-inpaint.js';
import { wireInpaintButtons } from './editor/ai-inpaint.js?v=20260708match1';
import { wireAIToolsMisc } from './editor/ai-tools-misc.js';
import { wireRembgAndSharpen } from './editor/ai-rembg.js';
import { wireStrokeToolSliders } from './editor/stroke-tool-sliders.js';
import { wireImport } from './editor/wire-import.js';
import { wireMergeButtons } from './editor/wire-merge-buttons.js';
import { wireSelectionControls } from './editor/wire-selection-controls.js';
import { wireInpaintControls } from './editor/wire-inpaint-controls.js';
import { wireInpaintControls } from './editor/wire-inpaint-controls.js?v=20260708match1';
import { wireTopbar, closeOtherTopbarMenus as _closeOtherTopbarMenus } from './editor/wire-topbar.js';
import { wireTopbarOverflow } from './editor/wire-topbar-overflow.js';
import { wireTopbarMenus } from './editor/wire-topbar-menus.js';
@@ -125,6 +125,14 @@ function _syncTransformOverlay() { _syncTransformOverlayImpl(_TRANSFORM_OVERLAY_
// the inpaint tool for the first time in this editor session we bump
// the slider to this value (without touching other tools).
const _INPAINT_DEFAULT_BRUSH = 100;
let _samAbortController = null;
function _cancelSamQuery(showToast = true) {
if (!_samAbortController) return false;
try { _samAbortController.abort(); } catch {}
if (showToast && uiModule) uiModule.showToast('SAM query cancelled');
return true;
}
function _galleryEditMounted() {
return !!document.querySelector('#gallery-editor-container .gallery-editor');
@@ -133,6 +141,15 @@ function _galleryEditMounted() {
if (!window.__galleryEditEscHardGuardInstalled) {
window.__galleryEditEscHardGuardInstalled = true;
window.addEventListener('keydown', (e) => {
const isSamCancel = !!_samAbortController
&& (e.key === 'Escape' || ((e.ctrlKey || e.metaKey) && String(e.key || '').toLowerCase() === 'c'));
if (isSamCancel) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
_cancelSamQuery();
return;
}
if (e.key !== 'Escape') return;
if (window.__galleryEditLive || _galleryEditMounted()) {
e.preventDefault();
@@ -272,6 +289,16 @@ function _setAiCommandStatus(text, kind = '') {
el.dataset.kind = kind || '';
}
function _escapeAiCommandText(value) {
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[ch]));
}
function _clickToolButton(toolId) {
const btn = state.container?.querySelector(`.ge-tool-btn[data-tool="${toolId}"]`);
if (btn) btn.click();
@@ -288,6 +315,21 @@ function _runExistingButton(id, status) {
return true;
}
function _openSamPrompt() {
if (state.tool !== 'sam') {
_clickToolButton('sam');
} else {
const controls = document.getElementById('ge-controls') || document.querySelector('.ge-controls');
controls?.classList.remove('dismissed');
document.getElementById('ge-sam-section')?.style.removeProperty('display');
}
requestAnimationFrame(() => {
const input = document.getElementById('ge-sam-query');
input?.focus();
input?.select?.();
});
}
function _buildAiCommandBox() {
const wrap = document.createElement('div');
wrap.className = 'ge-ai-command ge-ai-command-collapsed';
@@ -295,7 +337,10 @@ function _buildAiCommandBox() {
wrap.innerHTML = `
<button type="button" class="ge-ai-command-toggle" id="ge-ai-command-toggle" aria-expanded="false">
<span class="ge-btn-ai-mark" aria-hidden="true"></span>
<span>AI Edit</span>
<span>Quick Edit</span>
<svg class="ge-ai-command-toggle-caret" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="6 15 12 9 18 15"></polyline>
</svg>
</button>
<form class="ge-ai-command-form" id="ge-ai-command-form">
<input type="text" class="ge-ai-command-input" id="ge-ai-command-input" autocomplete="off" />
@@ -305,18 +350,49 @@ function _buildAiCommandBox() {
<polyline points="5 12 12 5 19 12"></polyline>
</svg>
</button>
<button type="button" class="ge-ai-command-close" id="ge-ai-command-close" title="Close AI edit" aria-label="Close AI edit">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
<button type="button" class="ge-ai-command-close" id="ge-ai-command-close" title="Collapse AI edit" aria-label="Collapse AI edit">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
</form>
<div class="ge-ai-command-suggestions" id="ge-ai-command-suggestions" hidden></div>
<div class="ge-ai-command-status" id="ge-ai-command-status" aria-live="polite"></div>
`;
return wrap;
}
const _AI_COMMAND_SUGGESTIONS = [
{ label: 'Rotate 90', insert: 'rotate 90', hint: 'Turn the image clockwise', aliases: ['ro', 'rotate', 'right', 'clockwise', 'turn'] },
{ label: 'Rotate left', insert: 'rotate left', hint: 'Turn the image counter-clockwise', aliases: ['rotate left', 'left', 'counter clockwise', 'ccw'] },
{ label: 'Rotate 180', insert: 'rotate 180', hint: 'Flip the canvas upside down', aliases: ['rotate 180', 'upside down'] },
{ label: 'Flip horizontal', insert: 'flip horizontal', hint: 'Mirror left to right', aliases: ['flip', 'mirror', 'horizontal'] },
{ label: 'Flip vertical', insert: 'flip vertical', hint: 'Mirror top to bottom', aliases: ['flip vertical', 'vertical'] },
{ label: 'Remove background', insert: 'remove background', hint: 'Make the background transparent', aliases: ['remove bg', 'background', 'transparent', 'cut out'] },
{ label: 'Upscale', insert: 'upscale 2x', hint: 'Increase image resolution', aliases: ['upscale', 'bigger', 'larger', '2x', '4x'] },
{ label: 'Denoise', insert: 'denoise', hint: 'Reduce grain and noise', aliases: ['denoise', 'noise', 'grain', 'clean up'] },
{ label: 'Sharpen', insert: 'sharpen', hint: 'Make details crisper', aliases: ['sharpen', 'sharp', 'clearer', 'crisp', 'enhance'] },
{ label: 'Enhance face', insert: 'enhance face', hint: 'Restore portrait and skin detail', aliases: ['face', 'portrait', 'skin', 'selfie', 'restore'] },
{ label: 'Style edit', insert: 'style: ', hint: 'Run a full-image prompt edit', aliases: ['style', 'paint', 'anime', 'photo', 'prompt'] },
];
function _matchAiCommandSuggestions(query) {
const q = (query || '').trim().toLowerCase();
if (!q) return [];
return _AI_COMMAND_SUGGESTIONS
.map((item) => {
const hay = [item.label, item.insert, ...(item.aliases || [])].map(v => String(v || '').toLowerCase());
const starts = hay.some(v => v.startsWith(q));
const contains = hay.some(v => v.includes(q));
if (!starts && !contains) return null;
return { item, score: starts ? 0 : 1 };
})
.filter(Boolean)
.sort((a, b) => a.score - b.score || a.item.label.localeCompare(b.item.label))
.slice(0, 6)
.map(hit => hit.item);
}
function _wireAiCommandBox() {
const wrap = document.getElementById('ge-ai-command');
const toggle = document.getElementById('ge-ai-command-toggle');
@@ -324,18 +400,91 @@ function _wireAiCommandBox() {
const form = document.getElementById('ge-ai-command-form');
const input = document.getElementById('ge-ai-command-input');
const runBtn = document.getElementById('ge-ai-command-run');
const suggestions = document.getElementById('ge-ai-command-suggestions');
if (!wrap || !form || !input || !runBtn) return;
let suggestionItems = [];
let suggestionIndex = 0;
const hideSuggestions = () => {
suggestionItems = [];
suggestionIndex = 0;
if (suggestions) {
suggestions.hidden = true;
suggestions.innerHTML = '';
}
};
const renderSuggestions = () => {
if (!suggestions || wrap.classList.contains('ge-ai-command-collapsed')) return;
suggestionItems = _matchAiCommandSuggestions(input.value);
suggestionIndex = Math.min(suggestionIndex, Math.max(0, suggestionItems.length - 1));
if (!suggestionItems.length) {
hideSuggestions();
return;
}
suggestions.hidden = false;
suggestions.innerHTML = suggestionItems.map((item, idx) => `
<button type="button" class="ge-ai-command-suggestion${idx === suggestionIndex ? ' active' : ''}" data-ai-command-suggestion="${idx}">
<span class="ge-ai-command-suggestion-main">${_escapeAiCommandText(item.label)}</span>
<span class="ge-ai-command-suggestion-hint">${_escapeAiCommandText(item.hint || item.insert)}</span>
</button>
`).join('');
};
const pickSuggestion = (idx, run = false) => {
const item = suggestionItems[idx];
if (!item) return false;
input.value = item.insert;
hideSuggestions();
input.focus();
if (run) form.requestSubmit();
return true;
};
wrap.addEventListener('pointerdown', (e) => e.stopPropagation());
wrap.addEventListener('click', (e) => e.stopPropagation());
suggestions?.addEventListener('pointerdown', (e) => e.preventDefault());
suggestions?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-ai-command-suggestion]');
if (!btn) return;
pickSuggestion(Number(btn.dataset.aiCommandSuggestion), true);
});
const setOpen = (open) => {
wrap.classList.toggle('ge-ai-command-collapsed', !open);
toggle?.setAttribute('aria-expanded', open ? 'true' : 'false');
if (open) requestAnimationFrame(() => input.focus());
if (open) requestAnimationFrame(() => {
input.focus();
renderSuggestions();
});
else hideSuggestions();
};
toggle?.addEventListener('click', () => setOpen(wrap.classList.contains('ge-ai-command-collapsed')));
closeBtn?.addEventListener('click', () => setOpen(false));
input.addEventListener('input', renderSuggestions);
input.addEventListener('keydown', (e) => {
const open = suggestions && !suggestions.hidden && suggestionItems.length;
if (open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault();
suggestionIndex = e.key === 'ArrowDown'
? (suggestionIndex + 1) % suggestionItems.length
: (suggestionIndex - 1 + suggestionItems.length) % suggestionItems.length;
renderSuggestions();
return;
}
if (open && e.key === 'Enter') {
e.preventDefault();
pickSuggestion(suggestionIndex, true);
return;
}
if (open && e.key === 'Tab') {
e.preventDefault();
pickSuggestion(suggestionIndex, false);
return;
}
if (open && e.key === 'Escape') {
e.preventDefault();
hideSuggestions();
}
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
hideSuggestions();
const prompt = input.value.trim();
if (!prompt) {
_setAiCommandStatus('Type what you want changed.', 'error');
@@ -344,6 +493,36 @@ function _wireAiCommandBox() {
}
const p = prompt.toLowerCase();
try {
if (/\brotate\b.*\b180\b|\bupside\s*down\b/.test(p)) {
_saveState('Rotate 180');
_rotateAllLayers(180);
_setAiCommandStatus('Rotated 180.', 'done');
return;
}
if (/\brotate\b.*\b(left|ccw|counter)\b|\bturn\s+left\b/.test(p)) {
_saveState('Rotate left');
_rotateAllLayers(270);
_setAiCommandStatus('Rotated left.', 'done');
return;
}
if (/\brotate\b|\bturn\s+right\b|\bclockwise\b/.test(p)) {
_saveState('Rotate 90');
_rotateAllLayers(90);
_setAiCommandStatus('Rotated 90.', 'done');
return;
}
if (/\bflip\b.*\b(vertical|v)\b|\bmirror\b.*\b(vertical|v)\b/.test(p)) {
_saveState('Flip vertical');
_flipAllLayers('v');
_setAiCommandStatus('Flipped vertical.', 'done');
return;
}
if (/\bflip\b|\bmirror\b/.test(p)) {
_saveState('Flip horizontal');
_flipAllLayers('h');
_setAiCommandStatus('Flipped horizontal.', 'done');
return;
}
if (/\b(remove|erase|cut\s*out|transparent)\b.*\b(bg|background)\b|\b(bg|background)\b.*\b(remove|erase|transparent)\b/.test(p)) {
_clickToolButton('rembg');
_runExistingButton('ge-rembg-run', 'Removing background...');
@@ -1220,6 +1399,7 @@ function _beginDraw(e) {
// it doesn't mutate the layer until an action (Erase/Copy) is taken.
// Full implementation in editor/tools/wand.js.
if (state.tool === 'wand') return _wandTool.click(e);
if (state.tool === 'sam') return _runSamSelection(e);
// Inpaint can create its own layer + mask on the fly, so skip the
// "no active layer → bail" gate for it specifically.
if (state.tool !== 'inpaint' && (!layer || layer.locked)) return;
@@ -1740,6 +1920,146 @@ function _runMagicWand(cx, cy, mode = 'replace', opts = {}) {
_syncToolClearIndicators();
}
async function _runSamSelection(e) {
const layer = activeLayer();
if (!layer || layer.locked) {
if (uiModule) uiModule.showToast('Select an unlocked layer');
return;
}
const coords = _canvasCoords(e, state.mainCanvas);
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const lx = Math.floor(coords.x - off.x);
const ly = Math.floor(coords.y - off.y);
if (lx < 0 || ly < 0 || lx >= layer.canvas.width || ly >= layer.canvas.height) return;
let mode = state.wandMode || 'replace';
if (e.shiftKey) mode = 'add';
else if (e.altKey) mode = 'subtract';
_cancelSamQuery(false);
const controller = new AbortController();
_samAbortController = controller;
const cleanup = _showWandLoading();
try {
await _requestAndApplySamMask(layer, {
points: [{ x: lx, y: ly, label: 1 }],
}, mode, { x: coords.x, y: coords.y }, { signal: controller.signal });
} catch (err) {
if (err?.name !== 'AbortError' && uiModule) {
uiModule.showToast(err.message || String(err), 7000);
}
} finally {
cleanup();
if (_samAbortController === controller) _samAbortController = null;
}
}
async function _runSamTextSelection() {
const layer = activeLayer();
if (!layer || layer.locked) {
if (uiModule) uiModule.showToast('Select an unlocked layer');
return;
}
const input = document.getElementById('ge-sam-query');
const text = (input?.value || '').trim();
if (!text) {
if (uiModule) uiModule.showToast('Type an object to find');
input?.focus();
return;
}
const btn = document.getElementById('ge-sam-find');
const old = btn?.innerHTML;
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>Finding…';
}
_cancelSamQuery(false);
const controller = new AbortController();
_samAbortController = controller;
const cleanup = _showWandLoading();
try {
await _requestAndApplySamMask(layer, { text }, state.wandMode || 'replace', null, { signal: controller.signal });
} catch (err) {
if (err?.name !== 'AbortError' && uiModule) {
uiModule.showToast(err.message || String(err), 7000);
}
} finally {
cleanup();
if (_samAbortController === controller) _samAbortController = null;
if (btn) {
btn.disabled = false;
btn.innerHTML = old || '<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>Find';
}
}
}
async function _requestAndApplySamMask(layer, payload, mode, seedPoint, opts = {}) {
const res = await fetch('/api/image/mask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: opts.signal,
body: JSON.stringify({
image: layer.canvas.toDataURL('image/png').split(',')[1],
...payload,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.mask) {
throw new Error(data.detail || data.error || `Mask failed (${res.status})`);
}
if (!data.bbox) {
throw new Error(data.grounding ? `Found ${data.grounding.label || 'object'}, but SAM returned an empty mask` : 'SAM returned an empty mask');
}
const img = new Image();
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = () => reject(new Error('Failed to decode mask'));
img.src = 'data:image/png;base64,' + data.mask;
});
const mask = document.createElement('canvas');
mask.width = layer.canvas.width;
mask.height = layer.canvas.height;
const mctx = mask.getContext('2d');
mctx.drawImage(img, 0, 0, mask.width, mask.height);
const maskData = mctx.getImageData(0, 0, mask.width, mask.height);
const md = maskData.data;
for (let i = 0; i < md.length; i += 4) {
const alpha = md[i]; // server mask is white selected / black unselected
md[i] = 255;
md[i + 1] = 255;
md[i + 2] = 255;
md[i + 3] = alpha;
}
mctx.putImageData(maskData, 0, 0);
_saveState();
const compatible = state.wandMask && state.wandLayerId === layer.id &&
state.wandMask.width === mask.width && state.wandMask.height === mask.height;
if (compatible && mode === 'add') {
state.wandMask.getContext('2d').drawImage(mask, 0, 0);
} else if (compatible && mode === 'subtract') {
const ec = state.wandMask.getContext('2d');
ec.save();
ec.globalCompositeOperation = 'destination-out';
ec.drawImage(mask, 0, 0);
ec.restore();
} else {
state.wandMask = mask;
state.wandLayerId = layer.id;
}
state.wandLastSeed = seedPoint
? { x: seedPoint.x, y: seedPoint.y, mode, source: 'sam' }
: { x: 0, y: 0, mode, source: 'sam-text' };
state.wandMaskVisible = true;
composite();
_syncToolClearIndicators();
if (data.grounding && uiModule) {
const pct = Math.round((data.grounding.score || 0) * 100);
uiModule.showToast(`Selected ${data.grounding.label || 'object'}${pct ? ` (${pct}%)` : ''}`, 2500);
}
}
function _showWandLoading() {
const area = state.container?.querySelector('.ge-canvas-area');
if (!area) return () => {};
@@ -1981,12 +2301,108 @@ function _wandToMask() {
state.wandMask = null;
state.wandLayerId = null;
state.wandLastSeed = null;
mask.visible = true;
layer.activeMaskId = mask.id;
state.maskVisible = true;
composite();
_renderLayerPanel();
if (uiModule) uiModule.showToast('Selection added to mask');
}
// Reveal/hide the small "X" badge on the Lasso and Wand tool buttons
function _autoMatchLastInpaintLayer() {
const layer = state.layers.find(l => l.id === state.lastInpaintLayerId);
const src = layer?.inpaintSource;
if (!layer || !src?.base || !src?.mask) {
if (uiModule) uiModule.showToast('Run inpaint first');
return;
}
const w = state.imgWidth;
const h = state.imgHeight;
let baseData, resultData, maskData;
try {
baseData = src.base.getContext('2d').getImageData(0, 0, w, h).data;
resultData = layer.canvas.getContext('2d').getImageData(0, 0, w, h).data;
maskData = src.mask.getContext('2d').getImageData(0, 0, w, h).data;
} catch (err) {
if (uiModule) uiModule.showToast('Auto match failed: cannot read pixels');
return;
}
const inside = { r: 0, g: 0, b: 0, y: 0, n: 0 };
const outside = { r: 0, g: 0, b: 0, y: 0, n: 0 };
const step = Math.max(1, Math.round(Math.max(w, h) / 900));
const radius = Math.max(2, Math.round(Math.min(w, h) * 0.006));
const sample = (bucket, data, idx) => {
const r = data[idx], g = data[idx + 1], b = data[idx + 2];
bucket.r += r; bucket.g += g; bucket.b += b;
bucket.y += 0.2126 * r + 0.7152 * g + 0.0722 * b;
bucket.n++;
};
const isMasked = (x, y) => {
if (x < 0 || y < 0 || x >= w || y >= h) return false;
return maskData[(y * w + x) * 4 + 3] > 24;
};
for (let y = radius; y < h - radius; y += step) {
for (let x = radius; x < w - radius; x += step) {
const idx = (y * w + x) * 4;
const m = maskData[idx + 3] > 24;
let touchesOther = false;
for (let dy = -radius; dy <= radius && !touchesOther; dy += radius) {
for (let dx = -radius; dx <= radius; dx += radius) {
if (!dx && !dy) continue;
if (isMasked(x + dx, y + dy) !== m) {
touchesOther = true;
break;
}
}
}
if (!touchesOther) continue;
if (m && resultData[idx + 3] > 24) sample(inside, resultData, idx);
else if (!m && baseData[idx + 3] > 24) sample(outside, baseData, idx);
}
}
if (inside.n < 20 || outside.n < 20) {
if (uiModule) uiModule.showToast('Auto match needs a larger mask edge');
return;
}
for (const b of [inside, outside]) {
b.r /= b.n; b.g /= b.n; b.b /= b.n; b.y /= b.n;
}
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const dr = clamp((outside.r - inside.r) * 0.55, -55, 55);
const dg = clamp((outside.g - inside.g) * 0.55, -55, 55);
const db = clamp((outside.b - inside.b) * 0.55, -55, 55);
const dy = clamp((outside.y - inside.y) * 0.35, -35, 35);
if (!layer.adjLayers) layer.adjLayers = [];
layer.adjLayers = layer.adjLayers.filter(a => a.id !== 'auto-match-color' && a.id !== 'auto-match-light');
layer.adjLayers.push({
id: 'auto-match-light',
type: 'brightness-contrast',
params: {
brightness: clamp(1 + (dy / 255), 0.75, 1.25),
contrast: 1,
},
opacity: 0.7,
visible: true,
});
layer.adjLayers.push({
id: 'auto-match-color',
type: 'color-balance',
params: {
shadows: { r: dr * 0.45, g: dg * 0.45, b: db * 0.45 },
midtones: { r: dr, g: dg, b: db },
highlights: { r: dr * 0.35, g: dg * 0.35, b: db * 0.35 },
},
opacity: 0.75,
visible: true,
});
_saveState('Auto match inpaint color');
composite();
_renderLayerPanel();
if (uiModule) uiModule.showToast('Auto matched color');
}
// Reveal/hide the small "X" badge on the Lasso, Wand, and SAM tool buttons
// based on whether each tool currently holds a selection. Called from
// anywhere selection state mutates (wand click, lasso close, undo, etc.).
function _syncToolClearIndicators() {
@@ -2026,9 +2442,11 @@ function _syncToolClearIndicators() {
if (!state.container) return;
const lassoBtn = state.container.querySelector('.ge-tool-btn[data-tool="lasso"]');
const wandBtn = state.container.querySelector('.ge-tool-btn[data-tool="wand"]');
const samBtn = state.container.querySelector('.ge-tool-btn[data-tool="sam"]');
const inpaintBtn = state.container.querySelector('.ge-tool-btn[data-tool="inpaint"]');
if (lassoBtn) lassoBtn.classList.toggle('has-selection', state.lassoPoints.length >= 3 && !state.lassoActive);
if (wandBtn) wandBtn.classList.toggle('has-selection', !!state.wandMask);
if (samBtn) samBtn.classList.toggle('has-selection', !!state.wandMask);
// Inpaint no longer carries a clear-X badge; masks live as sub-layers
// in the layer panel and are deleted from there.
if (inpaintBtn) inpaintBtn.classList.remove('has-selection');
@@ -2470,6 +2888,7 @@ function _wireInpaintPopoverWindow() {
// ── Build DOM ──
function _buildEditor(container) {
document.querySelectorAll('body > #ge-save-menu').forEach(el => el.remove());
container.innerHTML = '';
container.className = 'gallery-editor';
@@ -2484,6 +2903,9 @@ function _buildEditor(container) {
composite();
} else if (which === 'wand') {
_wandClear();
} else if (which === 'sam') {
_openSamPrompt();
return;
}
_syncToolClearIndicators();
},
@@ -2505,7 +2927,7 @@ function _buildEditor(container) {
// panel auto-minimises the layers sheet so the controls aren't
// covered. Swiping the layers handle back up restores it.
const isMobile = window.innerWidth <= 820;
const hasToolControls = ['brush', 'eraser', 'clone', 'inpaint'].includes(toolId);
const hasToolControls = ['brush', 'eraser', 'clone', 'inpaint', 'sam'].includes(toolId);
const controlsVisible = controls && !controls.classList.contains('dismissed');
if (isMobile && hasToolControls && controlsVisible) {
const rp = document.querySelector('.ge-right-panel');
@@ -2540,6 +2962,8 @@ function _buildEditor(container) {
if (lassoSection) lassoSection.style.display = state.tool === 'lasso' ? '' : 'none';
const wandSection = document.getElementById('ge-wand-section');
if (wandSection) wandSection.style.display = state.tool === 'wand' ? '' : 'none';
const samSection = document.getElementById('ge-sam-section');
if (samSection) samSection.style.display = state.tool === 'sam' ? '' : 'none';
const inpaintSection = document.getElementById('ge-inpaint-section');
if (inpaintSection) {
if (state.tool === 'inpaint') {
@@ -2561,6 +2985,13 @@ function _buildEditor(container) {
// Generate cleared it, but on re-entry the user expects to see
// their mask again.
if (state.tool === 'inpaint') {
// If the user just made a SAM/Wand selection and then moves to
// Inpaint, do the obvious thing: bake that selection into the
// inpaint mask. Otherwise Generate says "draw the area first"
// even though a red selection is visible on screen.
if (state.wandMask && state.wandLayerId) {
_wandToMask();
}
// First inpaint entry per session: bump the brush size to the
// mask-friendly default (other tools keep their own size).
if (!state.inpaintBrushInitialised) {
@@ -2923,6 +3354,7 @@ function _buildEditor(container) {
wireInpaintControls({
composite,
applyInpaintFeather: _applyInpaintFeather,
autoMatchInpaint: _autoMatchLastInpaintLayer,
syncToolClearIndicators: () => _syncToolClearIndicators(),
attachColorPicker,
uiModule,
@@ -3008,6 +3440,27 @@ function _buildEditor(container) {
applyImageTool: _applyImageTool,
uiModule,
});
document.getElementById('ge-sam-find')?.addEventListener('click', () => _runSamTextSelection());
document.getElementById('ge-sam-query')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
_runSamTextSelection();
}
});
document.getElementById('ge-sam-clear')?.addEventListener('click', () => _wandClear());
document.getElementById('ge-sam-mask')?.addEventListener('click', () => _wandToMask());
document.getElementById('ge-sam-vis')?.addEventListener('click', () => {
state.wandMaskVisible = !state.wandMaskVisible;
const btn = document.getElementById('ge-sam-vis');
if (btn) {
btn.innerHTML = state.wandMaskVisible
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>'
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.94 10.94 0 0 1 12 20C5 20 1 12 1 12a20.29 20.29 0 0 1 5.06-5.94"/><path d="M9.9 4.24A10.45 10.45 0 0 1 12 4c7 0 11 8 11 8a20.65 20.65 0 0 1-2.16 3.19"/><path d="M14.12 14.12A3 3 0 0 1 9.88 9.88"/><path d="M1 1l22 22"/></svg>';
btn.title = state.wandMaskVisible ? 'Hide selection overlay' : 'Show selection overlay';
btn.classList.toggle('visible', state.wandMaskVisible);
}
composite();
});
_wireAiCommandBox();
// Merge / Flatten buttons (layer-panel footer) — full
@@ -3017,6 +3470,7 @@ function _buildEditor(container) {
createLayer,
renderLayerPanel: () => _renderLayerPanel(),
composite,
renderLayer: (layer) => _renderLayerWithAdjLayers(layer),
uiModule,
});
@@ -3107,6 +3561,7 @@ const _layerPanelRenderer = createLayerPanelRenderer({
openFxPopup: (layer, anchor) => _openFxPopup(layer, anchor),
editAdjLayer: (layer, adj, anchor) => _editAdjLayer(layer, adj, anchor),
createLayer,
renderLayer: (layer) => _renderLayerWithAdjLayers(layer),
lassoToMask: () => _lassoToMask(),
wandToMask: () => _wandToMask(),
getActiveMaskLayer: () => _getActiveMaskLayer(),
@@ -3137,7 +3592,7 @@ function flatten() {
if (!layer.visible) continue;
ctx.globalAlpha = layer.opacity;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
ctx.drawImage(layer.canvas, off.x, off.y);
ctx.drawImage(_renderLayerWithAdjLayers(layer), off.x, off.y);
}
ctx.globalAlpha = 1;
return out;
@@ -3881,6 +4336,9 @@ export function closeEditor() {
el.remove();
});
} catch {}
try {
document.querySelectorAll('body > #ge-save-menu').forEach(el => el.remove());
} catch {}
// Belt-and-suspenders: scrub any minimized-dock chip + modalManager
// entry whose id matches our ephemeral popups (in case the DOM node
// was already removed when the user dragged the chip to trash).
+14 -1
View File
@@ -3,22 +3,35 @@
import Storage from './storage.js';
function markComposerUserEdited() {
const msgInput = document.getElementById('message');
if (!msgInput || msgInput.dataset.startupPreserveBound === '1') return;
msgInput.dataset.startupPreserveBound = '1';
msgInput.addEventListener('input', () => {
window.__odysseusComposerUserEdited = !!msgInput.value;
});
}
function clearFreshComposerRestore() {
const msgInput = document.getElementById('message');
if (!msgInput) return;
markComposerUserEdited();
const hash = window.location.hash || '';
const isEntityHash = /^#(?:document|note|image|email|event|task|skill|research)-/.test(hash)
|| /^#open=notes&note=/.test(hash);
const hasSessionTarget = !!((hash && !isEntityHash) || Storage.get('lastSessionId'));
const hasSessionTarget = !!(hash && !isEntityHash);
if (hasSessionTarget) return;
if (window.__odysseusComposerUserEdited || document.activeElement === msgInput) return;
if (msgInput.value) {
msgInput.value = '';
msgInput.dispatchEvent(new Event('input', { bubbles: true }));
}
}
markComposerUserEdited();
clearFreshComposerRestore();
window.addEventListener('pageshow', clearFreshComposerRestore);
document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: true });
// SECURITY: defense-in-depth state wipe on user switch. If the authenticated
// user is different from the one whose state is cached in this browser,
+18
View File
@@ -15,6 +15,7 @@ let activeCategory = 'all';
let sortOrder = 'newest';
let selectMode = false;
let selectedIds = new Set();
let memoriesLoading = false;
const MEMORY_CATEGORIES = ['fact', 'identity', 'preference', 'contact', 'project', 'goal', 'task'];
@@ -370,12 +371,16 @@ async function syncPrefToggle(elementId, prefKey, onMsg, offMsg, dimBelow = true
export async function loadMemories() {
_ensureNewMemoryCategorySelect();
memoriesLoading = true;
renderMemoryList();
updateMemoryCount();
try {
const response = await fetch(`${window.location.origin}/api/memory`);
if (!response.ok) {
console.error('Memory fetch failed with status:', response.status);
memories = [];
memoriesLoading = false;
buildCategoryChips();
renderMemoryList();
updateMemoryCount();
@@ -393,12 +398,14 @@ export async function loadMemories() {
memories = [];
}
memoriesLoading = false;
buildCategoryChips();
renderMemoryList();
updateMemoryCount();
} catch (error) {
console.error('Failed to load memories:', error);
memories = [];
memoriesLoading = false;
buildCategoryChips();
renderMemoryList();
updateMemoryCount();
@@ -689,6 +696,12 @@ export function renderMemoryList() {
const selectBtn = document.getElementById('memory-select-btn');
if (selectBtn) selectBtn.disabled = true;
if (selectMode) exitSelectMode();
if (memoriesLoading) {
const row = spinnerModule.createLoadingRow('Loading memories...', 14);
row.classList.add('memory-empty');
memoryList.replaceChildren(row);
return;
}
const searchTerm = document.getElementById('memory-search')?.value?.trim() || '';
const _smiley = '<span style="vertical-align:-3px;margin-left:6px;">' + uiModule.emptyStateIcon('smiley') + '</span>';
if (searchTerm || activeCategory !== 'all') {
@@ -1065,6 +1078,11 @@ export function updateMemoryCount() {
const h2Count = document.getElementById('memory-count-h2');
const tabCount = document.getElementById('memory-count'); // optional (may be absent)
if (!h2Count && !tabCount) return;
if (memoriesLoading) {
if (h2Count) h2Count.textContent = 'loading...';
if (tabCount) tabCount.textContent = '...';
return;
}
const searchInput = document.getElementById('memory-search');
const searchTerm = searchInput ? searchInput.value.toLowerCase().trim() : '';
+1 -1
View File
@@ -143,7 +143,7 @@ const _LABELS = {
'custom-preset-modal': { label: 'Prompt', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 2 4 4"/><path d="m17 7 3-3"/><path d="M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5"/><path d="m9 11 4 4"/><path d="m5 19-3 3"/><path d="m14 4 6 6"/></svg>' },
'research-overlay': { label: 'Research', icon: 'M3 11a8 8 0 1 0 16 0a8 8 0 1 0-16 0M21 21l-4.35-4.35M11 8L11 14M8 11L14 11' },
'theme-modal': { label: 'Theme', icon: 'M12 2a10 10 0 1 0 10 10c0-1-1-2-2-2h-2a2 2 0 0 1 0-4h1a2 2 0 0 0 0-4 10 10 0 0 0-7-2zM7.5 12a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM12 7.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM16.5 12a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z' },
'compare-model-overlay': { label: 'Compare', icon: 'M8 3v18M16 3v18M3 8h5M16 16h5' },
'compare-model-overlay': { label: 'Compare', icon: 'M4.5 4h5A1.5 1.5 0 0 1 11 5.5v13A1.5 1.5 0 0 1 9.5 20h-5A1.5 1.5 0 0 1 3 18.5v-13A1.5 1.5 0 0 1 4.5 4ZM15.5 4h5A1.5 1.5 0 0 1 22 5.5v13a1.5 1.5 0 0 1-1.5 1.5h-5a1.5 1.5 0 0 1-1.5-1.5v-13A1.5 1.5 0 0 1 15.5 4ZM10 8h4M10 16h4' },
'settings-modal': { label: 'Settings', icon: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9c.4.4.62.94.6 1.51V11a2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z' },
'ge-shortcuts-modal':{ label: 'Shortcuts', icon: 'M2 6h20v12H2zM6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10' },
// Virtual id — the doc editor pane isn't a modal, but it minimizes to a
+201 -40
View File
@@ -5,6 +5,7 @@ import { providerLogo } from './providers.js';
import uiModule from './ui.js';
import settingsModule from './settings.js';
import { sortModelObjects } from './modelSort.js';
import spinnerModule from './spinner.js';
const API_BASE = window.location.origin;
@@ -51,6 +52,11 @@ function _toggleFavorite(mid) {
return i < 0; // true when now favorited
}
function _pickerModelKey(m) {
if (!m) return '';
return `${m.endpointId || m.url || m.epName || 'model'}::${m.mid || ''}`;
}
// ── Shared keyboard nav for model pickers ──
function _handlePickerKeydown(e, listEl, itemSelector, closeFn) {
if (e.key === 'Escape') { closeFn(); return; }
@@ -78,6 +84,7 @@ function _handlePickerKeydown(e, listEl, itemSelector, closeFn) {
let _deps = null;
let _autoSelectingDefault = false;
let _defaultChatPickInFlight = false;
let _defaultPendingSeq = 0;
function _modelExists(modelId, url) {
if (!modelId || !window.modelsModule || !window.modelsModule.getCachedItems) return false;
@@ -121,17 +128,29 @@ async function _ensureDefaultPendingChat() {
if (!_deps || _defaultChatPickInFlight) return;
if (_deps.getCurrentSessionId && _deps.getCurrentSessionId()) return;
const pending = _deps.getPendingChat && _deps.getPendingChat();
if (pending && pending.modelId && pending.source === 'manual') return;
if (pending && pending.modelId) return;
_defaultChatPickInFlight = true;
const seq = ++_defaultPendingSeq;
try {
await _ensureModelCacheForFallback();
let dc = null;
try {
dc = window.__odysseusDefaultChat || null;
} catch (_) {}
if (!dc || !dc.endpoint_url || !dc.model) {
try {
const res = await fetch(`${API_BASE}/api/default-chat`, { credentials: 'same-origin' });
if (res.ok) dc = await res.json();
} catch (_) {}
if (dc && dc.endpoint_url && dc.model && _modelExists(dc.model, dc.endpoint_url)) {
const pendingUrl = String((pending && pending.url) || '').replace(/\/+$/, '');
}
if (dc && dc.endpoint_url && dc.model) {
if (seq !== _defaultPendingSeq) return;
const latest = _deps.getPendingChat && _deps.getPendingChat();
if (latest && latest.modelId && latest.source !== 'default' && latest.source !== 'fallback') return;
try {
window.__odysseusDefaultChat = dc;
localStorage.setItem('odysseus-default-chat-cache', JSON.stringify(dc));
} catch (_) {}
const pendingUrl = String((latest && latest.url) || '').replace(/\/+$/, '');
const defaultUrl = String(dc.endpoint_url || '').replace(/\/+$/, '');
_deps.setPendingChat({
url: dc.endpoint_url,
@@ -139,17 +158,20 @@ async function _ensureDefaultPendingChat() {
endpointId: dc.endpoint_id || '',
source: 'default',
});
try { window.__odysseusDefaultChat = dc; } catch (_) {}
if (!pending || pending.modelId !== dc.model || pendingUrl !== defaultUrl || pending.source !== 'default') {
if (!latest || latest.modelId !== dc.model || pendingUrl !== defaultUrl || latest.source !== 'default') {
updateModelPicker();
}
return;
}
if (pending && pending.modelId) return;
await _ensureModelCacheForFallback();
// No configured default, or the configured default is gone/offline:
// preserve the convenience fallback and keep the picker usable.
const fallback = _firstAvailableModel();
if (fallback) {
if (seq !== _defaultPendingSeq) return;
const latest = _deps.getPendingChat && _deps.getPendingChat();
if (latest && latest.modelId && latest.source !== 'default' && latest.source !== 'fallback') return;
_deps.setPendingChat({ ...fallback, source: 'fallback' });
updateModelPicker();
}
@@ -181,6 +203,8 @@ function _initModelPickerDropdown() {
const searchRow = menu ? menu.querySelector('.model-picker-search-row') : null;
const refreshBtn = document.getElementById('model-picker-refresh-btn');
if (!wrap || !btn || !menu || !search || !listEl) return;
if (wrap.dataset.modelPickerBound === '1') return;
wrap.dataset.modelPickerBound = '1';
function _close() {
if (menu.classList.contains('hidden')) return;
@@ -227,10 +251,13 @@ function _initModelPickerDropdown() {
// Local endpoint health — only probed for LOCAL endpoints, since
// cloud APIs are essentially always up. Cached briefly on the
// server side too (8s TTL). Picker opens trigger a refresh.
// server side too (8s TTL). Picker opens do not probe; the refresh button
// is the explicit network/probe action.
let _localProbe = {}; // {endpoint_id: {alive, latency_ms, error}}
let _localProbeFetchedAt = 0;
const _LOCAL_PROBE_TTL_MS = 5000;
let _pickerLoading = false;
let _pickerLoadSeq = 0;
async function _refreshLocalProbe() {
try {
@@ -263,18 +290,26 @@ function _initModelPickerDropdown() {
// Mark local endpoints whose live probe failed.
const probeResult = item.endpoint_id ? _localProbe[item.endpoint_id] : null;
const isLocalDead = !!(probeResult && probeResult.alive === false);
const isApiEndpoint = item.category && item.category !== 'local';
allModels.forEach((mid, i) => {
// Deduplicate by model ID — prefer ONLINE endpoint entries over
// offline duplicates so the user gets a working endpoint first
// when the same model is exposed by both.
if (seen.has(mid)) return;
seen.add(mid);
// Local/self-hosted servers often expose the same model through several
// stale endpoints, so keep deduping those by model id. Cloud/API
// endpoints are user-selected provider routes; the same model id can be
// intentionally enabled on OpenRouter and OpenAI, so key those by
// endpoint too or the chat picker silently drops one.
const seenKey = isApiEndpoint
? `${item.endpoint_id || item.url || item.endpoint_name || 'api'}::${mid}`
: mid;
if (seen.has(seenKey)) return;
seen.add(seenKey);
result.push({
key: seenKey,
mid,
display: (allDisplay[i] || mid).split('/').pop(),
url: item.url,
endpointId: item.endpoint_id,
epName: item.endpoint_name || '',
category: item.category || '',
providerText: [
item.endpoint_name || '',
item.category || '',
@@ -292,6 +327,48 @@ function _initModelPickerDropdown() {
return sortModelObjects(result);
}
function _hasModelCache() {
try {
return !!(window.modelsModule && window.modelsModule.getCachedItems && (window.modelsModule.getCachedItems() || []).length);
} catch (_) {
return false;
}
}
function _renderLoading(text = 'Loading models…') {
listEl.innerHTML = '';
listEl.classList.remove('is-empty');
listEl.classList.add('is-loading');
menu.classList.remove('no-models');
if (search) search.placeholder = text;
let row = null;
try {
row = spinnerModule.createLoadingRow(text, 15);
} catch (_) {
row = document.createElement('div');
row.className = 'model-switch-empty';
row.textContent = text;
}
row.classList.add('model-picker-loading-row');
listEl.appendChild(row);
}
async function _refreshPickerModels({ force = false, showLoading = false } = {}) {
if (!window.modelsModule || typeof window.modelsModule.refreshModels !== 'function') return;
const seq = ++_pickerLoadSeq;
_pickerLoading = true;
if (showLoading) _renderLoading(force ? 'Refreshing models…' : 'Loading models…');
try {
await window.modelsModule.refreshModels(force);
await _refreshLocalProbe();
} finally {
if (seq === _pickerLoadSeq) {
_pickerLoading = false;
listEl.classList.remove('is-loading');
}
}
}
// ── Provider display names and grouping ──
const _PROVIDER_NAMES = {
'01-ai': 'Yi', 'abacusai': 'Abacus AI', 'adept': 'Adept',
@@ -332,6 +409,16 @@ function _initModelPickerDropdown() {
function _providerDisplayName(slug) {
return _PROVIDER_NAMES[slug] || slug.charAt(0).toUpperCase() + slug.slice(1).replace(/-/g, ' ');
}
function _providerGroupKey(m) {
if (m && m.category && m.category !== 'local' && m.epName) {
return `~endpoint:${m.epName}`;
}
return _providerSlug((m && m.mid) || '');
}
function _providerGroupName(key) {
if (String(key || '').startsWith('~endpoint:')) return String(key).slice('~endpoint:'.length);
return _providerDisplayName(key);
}
function _providerSlug(mid) {
const slash = mid.indexOf('/');
let slug = slash > 0 ? mid.substring(0, slash) : 'other';
@@ -342,6 +429,7 @@ function _initModelPickerDropdown() {
function _populate(filter) {
listEl.innerHTML = '';
listEl.classList.remove('is-loading');
const all = _getAllModels();
const q = (filter || '').trim().toLowerCase();
const hasAnyModel = all.length > 0;
@@ -359,7 +447,12 @@ function _initModelPickerDropdown() {
// Unique lookup so Recent/Favorites (stored as bare model IDs) can be
// resolved back to full model objects; drops anything no longer offered.
const byId = new Map();
all.forEach(m => { if (!byId.has(m.mid)) byId.set(m.mid, m); });
const byKey = new Map();
all.forEach(m => {
const key = _pickerModelKey(m);
if (key && !byKey.has(key)) byKey.set(key, m);
if (!byId.has(m.mid)) byId.set(m.mid, m);
});
const favs = _loadFavorites();
@@ -470,44 +563,44 @@ function _initModelPickerDropdown() {
// list fits below as "All models" and a separate Recent
// section just duplicates rows.
const shown = new Set();
const favModels = favs.map(id => byId.get(id)).filter(Boolean);
const favModels = favs.map(id => byKey.get(id) || byId.get(id)).filter(Boolean);
if (favModels.length) {
_addSection('Favorites');
favModels.forEach(m => { shown.add(m.mid); _addRow(m); });
favModels.forEach(m => { shown.add(_pickerModelKey(m)); _addRow(m); });
}
// Recent: only render when the catalog is big enough that surfacing
// a recency shortlist is actually useful, AND only models that
// aren't already in Favorites (dedupe).
if (all.length > BROWSE_ALL_LIMIT) {
const recentModels = _loadRecent()
.map(id => byId.get(id))
.map(id => byKey.get(id) || byId.get(id))
.filter(Boolean)
.filter(m => !shown.has(m.mid))
.filter(m => !shown.has(_pickerModelKey(m)))
.slice(0, RECENT_MAX);
if (recentModels.length) {
_addSection('Recent');
recentModels.forEach(m => { shown.add(m.mid); _addRow(m); });
recentModels.forEach(m => { shown.add(_pickerModelKey(m)); _addRow(m); });
}
}
// Small catalogs: still list everything so users aren't forced to search.
if (all.length <= BROWSE_ALL_LIMIT) {
const rest = all.filter(m => !shown.has(m.mid));
const rest = all.filter(m => !shown.has(_pickerModelKey(m)));
if (rest.length) {
if (shown.size) _addSection('All models');
rest.forEach(_addRow);
}
} else {
// Large catalog: show provider groups with collapsible sections.
const rest = all.filter(m => !shown.has(m.mid));
const rest = all.filter(m => !shown.has(_pickerModelKey(m)));
const groups = new Map();
rest.forEach(m => {
const slug = _providerSlug(m.mid);
const slug = _providerGroupKey(m);
if (!groups.has(slug)) groups.set(slug, []);
groups.get(slug).push(m);
});
const sorted = [...groups.keys()].sort((a, b) =>
_providerDisplayName(a).localeCompare(_providerDisplayName(b)));
_providerGroupName(a).localeCompare(_providerGroupName(b)));
sorted.forEach(provider => {
const models = groups.get(provider);
@@ -516,7 +609,7 @@ function _initModelPickerDropdown() {
header.className = 'mp-provider-header';
header.innerHTML =
`<svg class="mp-provider-chevron${isCollapsed ? ' collapsed' : ''}" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`
+ `<span class="mp-provider-name">${_providerDisplayName(provider)}</span>`
+ `<span class="mp-provider-name">${_providerGroupName(provider)}</span>`
+ `<span class="mp-provider-count">${models.length}</span>`;
header.addEventListener('click', (e) => {
e.stopPropagation();
@@ -548,13 +641,32 @@ function _initModelPickerDropdown() {
}
}
async function _pick(m) {
async function _pick(m) {
_defaultPendingSeq++;
try {
window.__odysseusLastPickedRoute = {
model: m.mid || '',
endpoint_url: m.url || '',
endpoint_id: m.endpointId || '',
display: m.display || m.mid || '',
picked_at: Date.now(),
};
} catch (_) {}
let switchDone = null;
const switchPromise = new Promise(resolve => { switchDone = resolve; });
try { window.__odysseusModelSwitchPromise = switchPromise; } catch (_) {}
const finishSwitch = () => {
try {
if (switchDone) switchDone();
if (window.__odysseusModelSwitchPromise === switchPromise) delete window.__odysseusModelSwitchPromise;
} catch (_) {}
};
const currentSessionId = _deps.getCurrentSessionId();
const _pendingChat = _deps.getPendingChat();
// Remember this pick so it surfaces under "Recent" next time the picker
// opens — the whole point of quick-switch.
if (m && m.mid) _pushRecent(m.mid);
if (m && m.mid) _pushRecent(_pickerModelKey(m) || m.mid);
// Broadcast immediately so listeners (e.g. the tour) can advance without
// waiting for the async session-create/PATCH that follows.
@@ -574,12 +686,23 @@ function _initModelPickerDropdown() {
// Header stays as session name — model switch only updates picker
updateModelPicker();
uiModule.showToast(`Using ${m.display}`);
finishSwitch();
return;
} else if (!currentSessionId) {
// No session yet — create one with this model
try {
await _deps.createDirectChat(m.url, m.mid, m.endpointId);
} catch (e) {
uiModule.showError('Failed to start chat: ' + e);
finishSwitch();
return;
}
} else {
// Existing session with no model — PATCH it
const sessions = _deps.getSessions();
const s = sessions.find(x => x.id === currentSessionId);
if (s) { s.model = m.mid; s.endpoint_url = m.url; s.endpoint_id = m.endpointId || s.endpoint_id || ''; }
updateModelPicker();
const fd = new FormData();
fd.append('model', m.mid);
fd.append('endpoint_url', m.url);
@@ -588,20 +711,21 @@ function _initModelPickerDropdown() {
const res = await fetch(`${API_BASE}/api/session/${currentSessionId}`, { method: 'PATCH', body: fd });
if (!res.ok) {
uiModule.showError('Failed to set model');
finishSwitch();
return;
}
const sessions = _deps.getSessions();
const s = sessions.find(x => x.id === currentSessionId);
if (s) { s.model = m.mid; s.endpoint_url = m.url; }
// Header stays as session name — model info shown in picker only
} catch (e) {
uiModule.showError('Failed to set model: ' + e);
finishSwitch();
return;
}
}
// Update picker visibility — model is now set
updateModelPicker();
if (window.refreshChatContextHeader) window.refreshChatContextHeader('model-pick');
uiModule.showToast(`Using ${m.display}`);
finishSwitch();
}
document.addEventListener('odysseus:auto-select-model', async (e) => {
@@ -650,14 +774,27 @@ function _initModelPickerDropdown() {
if (match) await _pick(match);
});
btn.addEventListener('pointerdown', (e) => {
e.stopPropagation();
});
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (menu.classList.contains('hidden') || menu.classList.contains('closing')) {
// Force-clear any in-progress close animation
menu.classList.remove('closing', 'hidden');
const hasCache = _hasModelCache();
if (hasCache) {
_populate('');
} else {
_renderLoading('Loading models…');
}
if (window.modelsModule && window.modelsModule.refreshModels) {
window.modelsModule.refreshModels().then(() => {
// Force the cheap /api/models cache refresh when the picker opens.
// This does not wait on provider probes; the backend returns cached
// inventory and starts refresh work separately. Without this, models
// enabled in Added Models can be absent from the chatbox picker until
// the tab's frontend cache ages out.
_refreshPickerModels({ force: hasCache, showLoading: !hasCache }).then(() => {
if (!menu.classList.contains('hidden')) _populate(search.value || '');
updateModelPicker();
}).catch(() => {});
@@ -671,7 +808,10 @@ function _initModelPickerDropdown() {
}
});
search.addEventListener('input', () => _populate(search.value));
search.addEventListener('input', () => {
if (_pickerLoading) return;
_populate(search.value);
});
search.addEventListener('click', (e) => e.stopPropagation());
if (refreshBtn) {
refreshBtn.addEventListener('click', async (e) => {
@@ -679,10 +819,7 @@ function _initModelPickerDropdown() {
refreshBtn.disabled = true;
refreshBtn.classList.add('spinning');
try {
if (window.modelsModule && window.modelsModule.refreshModels) {
await window.modelsModule.refreshModels(true);
}
await _refreshLocalProbe();
await _refreshPickerModels({ force: true, showLoading: true });
if (!menu.classList.contains('hidden')) _populate(search.value || '');
updateModelPicker();
} catch (_) {
@@ -704,7 +841,7 @@ function _initModelPickerDropdown() {
});
}
document.addEventListener('click', (e) => {
if (!menu.classList.contains('hidden') && !menu.contains(e.target) && e.target !== btn) {
if (!menu.classList.contains('hidden') && !wrap.contains(e.target)) {
_close();
}
});
@@ -738,16 +875,33 @@ export function updateModelPicker() {
let modelId = null;
if (s && s.model) {
modelId = s.model;
if (!_modelExists(modelId, s.endpoint_url || '')) {
modelId = null;
}
} else if (_pendingChat && _pendingChat.modelId) {
modelId = _pendingChat.modelId;
if (!_modelExists(modelId, _pendingChat.url || '')) {
if (_pendingChat.source === 'fallback' && !_modelExists(modelId, _pendingChat.url || '')) {
_deps.setPendingChat(null);
modelId = null;
}
}
if (!modelId && !currentSessionId && !_pendingChat && _deps.setPendingChat) {
let cachedDefault = null;
try {
cachedDefault = window.__odysseusDefaultChat || null;
} catch (_) {}
if (!cachedDefault || !cachedDefault.endpoint_url || !cachedDefault.model) {
try {
cachedDefault = JSON.parse(localStorage.getItem('odysseus-default-chat-cache') || 'null');
} catch (_) {}
}
if (cachedDefault && cachedDefault.endpoint_url && cachedDefault.model) {
modelId = cachedDefault.model;
_deps.setPendingChat({
url: cachedDefault.endpoint_url,
modelId,
endpointId: cachedDefault.endpoint_id || '',
source: 'default',
});
}
}
// SECURITY: deliberately NOT auto-injecting `odysseus-model-favorites[0]`
// here. localStorage favorites are per-browser, not per-user, so on a
// shared browser the previous account's first favorited model would
@@ -757,7 +911,14 @@ export function updateModelPicker() {
//
// Check if selected model is still available — fall back ONLY for pending chats with no user selection
// Never override an existing session's model — the user explicitly chose it
if (modelId && !currentSessionId && _pendingChat && window.modelsModule && window.modelsModule.getCachedItems) {
if (
modelId &&
!currentSessionId &&
_pendingChat &&
_pendingChat.source !== 'manual' &&
window.modelsModule &&
window.modelsModule.getCachedItems
) {
const items = window.modelsModule.getCachedItems();
const allAvailable = [];
items.forEach(item => {
+14 -30
View File
@@ -164,21 +164,31 @@ function _buildModelRow(mid, url, displayName, endpointId, offline, modelType) {
return row;
}
export async function refreshModels(force = false) {
export async function refreshModels(force = false, opts = {}) {
const box = document.getElementById('models');
const cacheOnly = !!(opts && opts.cacheOnly);
const hasCache = _cachedItems.length > 0;
// Skip network fetch if cache is fresh and not forced — still re-render UI
// Cache-only is used for cheap picker/settings opens, but it must not turn a
// cold page load into an empty model list. If nothing has been fetched in this
// tab yet, do one normal load.
const now = Date.now();
const needsFetch = force || _cachedItems.length === 0 || (now - _lastFetchTime) >= _FETCH_CACHE_TTL;
const needsFetch = !(cacheOnly && hasCache) && (force || _cachedItems.length === 0 || (now - _lastFetchTime) >= _FETCH_CACHE_TTL);
if (box) box.innerHTML = '';
const hadRenderedRows = !!(box && box.children && box.children.length);
if (box && (!needsFetch || !hadRenderedRows)) box.innerHTML = '';
if (needsFetch) {
let _loadingSpinner = null;
if (box) {
if (hadRenderedRows) {
box.classList.add('models-refreshing');
} else {
_loadingSpinner = spinnerModule.create('', 'right', 'wave');
box.appendChild(_loadingSpinner.createElement());
_loadingSpinner.start();
}
}
try {
if (force) _fetchInflight = null;
if (!_fetchInflight) {
@@ -208,6 +218,7 @@ export async function refreshModels(force = false) {
return;
} finally {
try { _loadingSpinner && _loadingSpinner.stop && _loadingSpinner.stop(); } catch (_) {}
if (box) box.classList.remove('models-refreshing');
if (box) box.innerHTML = '';
}
}
@@ -576,33 +587,6 @@ export async function refreshModels(force = false) {
+ '<span class="muted-sm">Ask an admin to configure model endpoints</span>';
}
box.appendChild(noModels);
// No endpoints yet: keep the welcome screen focused on first setup.
const welcomeSub = document.getElementById('welcome-sub');
if (welcomeSub) welcomeSub.innerHTML = 'Type <span class="setup-trigger-link" style="color:var(--accent,var(--red));font-weight:600;cursor:pointer;text-decoration:underline;" title="Click to launch setup">/setup</span> to get started.';
const welcomeTip = document.getElementById('welcome-tip');
if (welcomeTip) welcomeTip.textContent = 'Type /setup, then choose Local models or API.';
} else {
// Configured installs should feel ready, not stuck in onboarding.
const welcomeSub = document.getElementById('welcome-sub');
if (welcomeSub) welcomeSub.textContent = 'Yours for the voyage.';
const welcomeTip = document.getElementById('welcome-tip');
if (welcomeTip) {
const tips = window.innerWidth <= 768
? [
'Tip: Long-press a session for rename, delete, and memory options.',
'Tip: Tap the eye icon for Nobody mode - no history saved.',
'Tip: Switch to Agent mode when you want tools.',
'Tip: Attach images or files using the + button next to the input.',
]
: [
'Tip: Press Ctrl+K to search across all your conversations.',
'Tip: Press Ctrl+B to quickly toggle the sidebar.',
'Tip: Shift-click the sidebar toggle to swap it to the other side.',
'Tip: Drag and drop files onto the chat to attach them.',
'Tip: Right-click a session for rename, delete, and memory options.',
];
welcomeTip.textContent = tips[Math.floor(Math.random() * tips.length)];
}
}
} catch (e) {
console.error(e);
+34 -42
View File
@@ -392,6 +392,7 @@ let _loading = false;
// Undo stack — most recent action is at the end. We cap it small because the
// only entries that survive a panel reload are in-memory anyway.
const _undoStack = [];
const _NOTE_UNDO_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>';
function _pushUndo(entry) {
_undoStack.push(entry);
if (_undoStack.length > 20) _undoStack.shift();
@@ -416,6 +417,33 @@ function _undoArchive(note, prevIdx) {
});
}
function _archiveNoteById(id, { card = null, celebrate = false } = {}) {
if (!id) return false;
const idx = _notes.findIndex(n => n.id === id);
if (idx < 0) return false;
const note = _notes[idx];
if (celebrate && card && note && _hasItems(note)) {
const undone = (note.items || []).filter(i => !i.done);
if (undone.length === 0) {
const r = card.getBoundingClientRect();
spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 80);
}
}
const removed = _notes.splice(idx, 1)[0];
_editingId = null;
_renderNotes();
const undo = () => _undoArchive(removed, idx);
_pushUndo({ label: 'archive', run: undo });
_patchNote(id, { archived: true }).then(() => {
uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _NOTE_UNDO_ICON, onAction: undo, actionHint: 'Ctrl+Z' });
}).catch(() => {
_notes.splice(idx, 0, removed);
_renderNotes();
uiModule.showError('Failed to archive');
});
return true;
}
async function _fetchNotes() {
_loading = true;
try {
@@ -2396,34 +2424,12 @@ function _bindCardEvents(body) {
e.stopPropagation();
const id = btn.dataset.noteId;
if (!id) return;
const note = _notes.find(n => n.id === id);
const card = btn.closest('.note-card');
// Confetti when archiving a fully-completed checklist (todo or goal).
if (note && _hasItems(note) && card) {
const undone = (note.items || []).filter(i => !i.done);
if (undone.length === 0) {
const r = card.getBoundingClientRect();
spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 80);
}
}
let done = false;
const finishRemove = () => {
if (done) return;
done = true;
const curIdx = _notes.findIndex(n => n.id === id);
if (curIdx < 0) return;
const removed = _notes.splice(curIdx, 1)[0];
_renderNotes();
const undo = () => _undoArchive(removed, curIdx);
_pushUndo({ label: 'archive', run: undo });
const _undoIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>';
_patchNote(id, { archived: true }).then(() => {
uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _undoIcon, onAction: undo, actionHint: 'Ctrl+Z' });
}).catch(() => {
_notes.splice(curIdx, 0, removed);
_renderNotes();
uiModule.showError('Failed to archive');
});
_archiveNoteById(id, { card, celebrate: true });
};
if (card) {
card.classList.add('note-card-sliding-out');
@@ -3615,10 +3621,11 @@ function _buildForm(note = null) {
if (_saveBtn._saving) return;
// Mobile: when an existing note is opened and closed without edits, the
// Update (✓) button morphs into Archive (set up below). Route the click
// to the hidden archive button so the existing archive flow + undo toast
// run unchanged.
// directly through the archive flow. Using a DOM .click() proxy here was
// fragile on mobile because the real archive button can move from the
// footer to the fullscreen header.
if (_saveBtn.classList.contains('archive-mode')) {
form.querySelector('.note-form-archive-btn')?.click();
_archiveNoteById(note?.id);
return;
}
_saveBtn._saving = true; _saveBtn.disabled = true; _saveBtn.style.opacity = '0.5';
@@ -3743,22 +3750,7 @@ function _buildForm(note = null) {
// Archive / Delete — edit-mode-only buttons, mirror the (now-hidden) card actions.
form.querySelector('.note-form-archive-btn')?.addEventListener('click', () => {
if (!isEdit) return;
const id = note.id;
const idx = _notes.findIndex(n => n.id === id);
if (idx < 0) return;
const removed = _notes.splice(idx, 1)[0];
_editingId = null;
_renderNotes();
const undo = () => _undoArchive(removed, idx);
_pushUndo({ label: 'archive', run: undo });
const _undoIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>';
_patchNote(id, { archived: true }).then(() => {
uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _undoIcon, onAction: undo, actionHint: 'Ctrl+Z' });
}).catch(() => {
_notes.splice(idx, 0, removed);
_renderNotes();
uiModule.showError('Failed to archive');
});
_archiveNoteById(note.id);
});
form.querySelector('.note-form-delete-btn')?.addEventListener('click', async () => {
if (!isEdit) return;
+22
View File
@@ -10,7 +10,29 @@ let results = [];
function el(id) { return document.getElementById(id); }
function hideMobileSidebarForSearch() {
if (window.innerWidth >= 768) return;
const sidebar = el('sidebar');
const rail = el('icon-rail');
const backdrop = el('sidebar-backdrop');
let changed = false;
if (sidebar && !sidebar.classList.contains('hidden')) {
sidebar.classList.add('hidden');
changed = true;
}
if (rail && rail.classList.contains('mobile-mini')) {
rail.classList.remove('mobile-mini');
rail.style.cssText = '';
changed = true;
}
if (backdrop) backdrop.classList.remove('visible');
if (changed && typeof window.syncRailSide === 'function') {
try { window.syncRailSide(); } catch (_) {}
}
}
export function openSearch() {
hideMobileSidebarForSearch();
const overlay = el('search-overlay');
if (!overlay) return;
overlay.classList.remove('hidden');
+235 -53
View File
@@ -3,9 +3,9 @@
import Storage from './storage.js';
import uiModule, { autoResize, styledPrompt } from './ui.js';
import chatRenderer from './chatRenderer.js';
import chatRenderer from './chatRenderer.js?v=20260722ctxheader1';
import { providerLogo } from './providers.js';
import { initModelPicker, updateModelPicker } from './modelPicker.js';
import { initModelPicker, updateModelPicker } from './modelPicker.js?v=20260722ctxheader1';
import themeModule from './theme.js';
import spinnerModule from './spinner.js';
@@ -16,6 +16,7 @@ let currentSessionId = null;
let _sessionNavToken = 0;
let _skipAutoSelect = false;
let _suppressNextSessionLoading = false;
let _rootFreshChatApplied = false;
const HISTORY_DISPLAY_CHAR_LIMIT = 160000;
const HISTORY_DISPLAY_TAIL_CHARS = 20000;
const HISTORY_PAGE_LIMIT_MOBILE = 8;
@@ -26,12 +27,29 @@ const FOLDER_MAX_VISIBLE = 5;
let _showAllSessions = false;
let _expandedFolders = {}; // folderName -> true if "show more" clicked
let _sortMode = Storage.get('odysseus-session-sort') || 'active'; // default to last active
const DATE_SECTION_COLLAPSE_KEY = 'ody-session-date-section-collapsed';
let _autoCreateInProgress = false; // guard against recursive auto-create
const _INCOGNITO_SESSIONS_KEY = 'ody-incognito-sessions'; // sessionStorage key for incognito session IDs
const _isMac = /Mac|iPhone|iPad/.test(navigator.platform);
const _mod = _isMac ? '⌘' : 'Ctrl';
let _historyPager = null;
function _shouldPreserveStartupComposer(msgInput) {
if (!msgInput || !msgInput.value) return false;
if (window.__odysseusComposerUserEdited) return true;
return !!document.getElementById('app-loader') && document.activeElement === msgInput;
}
function _clearComposerUnlessStartupTyped(msgInput) {
if (!msgInput) return;
if (_shouldPreserveStartupComposer(msgInput)) {
msgInput.disabled = false;
autoResize(msgInput);
return;
}
msgInput.value = '';
}
function _paintSessionLoading(chatHistory, label = 'Loading chat') {
if (!chatHistory) return;
if (chatRenderer.hideWelcomeScreen) chatRenderer.hideWelcomeScreen();
@@ -973,21 +991,68 @@ function _sessionBucketDate(s) {
return s.last_message_at || s.updated_at || s.created_at || '';
}
function _loadDateSectionCollapseState() {
const raw = Storage.getJSON(DATE_SECTION_COLLAPSE_KEY, {});
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
return raw;
}
function _saveDateSectionCollapseState(state) {
Storage.setJSON(DATE_SECTION_COLLAPSE_KEY, state && typeof state === 'object' ? state : {});
}
function _dateSectionKey(kind, label) {
return `${kind || 'session'}:${label || 'Older'}`;
}
function _isDateSectionCollapsed(kind, label) {
return _loadDateSectionCollapseState()[_dateSectionKey(kind, label)] === true;
}
function _toggleDateSection(kind, label) {
const state = _loadDateSectionCollapseState();
const key = _dateSectionKey(kind, label);
state[key] = state[key] !== true;
_saveDateSectionCollapseState(state);
renderSessionList();
}
function _createDateSectionHeader(label, kind = 'session') {
const el = document.createElement('div');
el.className = `date-section-header ${kind}-date-section-header`;
el.textContent = label;
const collapsed = _isDateSectionCollapsed(kind, label);
if (collapsed) el.classList.add('collapsed');
el.dataset.dateSectionKind = kind;
el.dataset.dateSectionLabel = label;
el.tabIndex = 0;
el.setAttribute('role', 'button');
el.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
el.title = collapsed ? `Show ${label}` : `Hide ${label}`;
el.addEventListener('click', (e) => {
e.stopPropagation();
_toggleDateSection(kind, label);
});
el.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
e.stopPropagation();
_toggleDateSection(kind, label);
});
return el;
}
function _appendSessionItemsWithDateHeaders(frag, items) {
let lastLabel = null;
let collapsed = false;
for (const s of items) {
const label = _dateBucketLabel(_sessionBucketDate(s));
if (label !== lastLabel) {
frag.appendChild(_createDateSectionHeader(label, 'session'));
collapsed = _isDateSectionCollapsed('session', label);
lastLabel = label;
}
if (collapsed) continue;
frag.appendChild(createSessionItem(s));
}
}
@@ -995,6 +1060,7 @@ function _appendSessionItemsWithDateHeaders(frag, items) {
function _appendFavoriteSessionItems(frag, items) {
if (!items.length) return;
frag.appendChild(_createDateSectionHeader('Favorites', 'session'));
if (_isDateSectionCollapsed('session', 'Favorites')) return;
for (const s of items) {
frag.appendChild(createSessionItem(s));
}
@@ -1226,9 +1292,7 @@ function _renderSessionListImpl() {
visibleFolder.push(folderSessions[activeInFolder]);
}
visibleFolder.forEach(s => {
content.appendChild(createSessionItem(s));
});
_appendSessionItemsWithDateHeaders(content, visibleFolder);
if (folderSessions.length > FOLDER_MAX_VISIBLE) {
const rem = folderSessions.length - FOLDER_MAX_VISIBLE;
@@ -1328,9 +1392,7 @@ function _renderSessionListImpl() {
}
if (unfiledTarget) {
visibleUnfiled.forEach(s => {
unfiledTarget.appendChild(createSessionItem(s));
});
_appendSessionItemsWithDateHeaders(unfiledTarget, visibleUnfiled);
}
// "Show more" / "Show less" toggle
@@ -1616,7 +1678,11 @@ export async function loadSessions() {
sessionStorage.removeItem('ody-prefetch-sessions');
fetched = JSON.parse(prefetched);
} else {
const res = await fetch(`${API_BASE}/api/sessions`);
let url = `${API_BASE}/api/sessions`;
if (currentSessionId && _isIncognitoSession(currentSessionId)) {
url += `?active_incognito_id=${encodeURIComponent(currentSessionId)}`;
}
const res = await fetch(url);
fetched = await res.json();
}
sessions = _normalizeSessionsList(fetched);
@@ -1640,7 +1706,13 @@ export async function loadSessions() {
if (/^(document|note|image|email|event|task|skill|research)-/.test(hashId) || /^open=notes&note=/.test(hashId)) {
hashId = '';
}
let savedId = Storage.get('lastSessionId');
const _isFirstLoad = !sessionStorage.getItem('ody-session-active');
const _freshRootLoad = !_rootFreshChatApplied && !hashId && !currentSessionId && !_pendingChat;
if (_freshRootLoad) {
_rootFreshChatApplied = true;
Storage.remove('lastSessionId');
}
let savedId = _freshRootLoad ? null : Storage.get('lastSessionId');
// If the persisted lastSessionId points to a transient session (legacy
// state from before the persistence-guard was added), drop it.
if (savedId) {
@@ -1665,13 +1737,13 @@ export async function loadSessions() {
} else if (currentSessionId) {
// Session was just created but may not be in the list yet — keep it
targetId = currentSessionId;
} else if (savedId && activeSessions.some(s => s.id === savedId)) {
} else if (!_freshRootLoad && savedId && activeSessions.some(s => s.id === savedId)) {
targetId = savedId;
} else if (!_skipAutoSelect && _realSessions.length > 0) {
} else if (!_freshRootLoad && !_skipAutoSelect && _realSessions.length > 0) {
// Most-recent NON-transient session — skip Assistant / Tasks so the
// auto-firing assistant doesn't become the apparent default chat.
targetId = _realSessions[0].id;
} else if (!_skipAutoSelect && activeSessions.length > 0) {
} else if (!_freshRootLoad && !_skipAutoSelect && activeSessions.length > 0) {
// Only transient sessions exist (brand-new account) — fall through to
// the original behaviour so we don't leave the user with nothing.
targetId = activeSessions[0].id;
@@ -1687,32 +1759,16 @@ export async function loadSessions() {
// picker would still show the old model's name from cached state). See
// the targetId resolution above (hash → currentSession → lastSessionId →
// most-recent).
const _isFirstLoad = !sessionStorage.getItem('ody-session-active');
if (_isFirstLoad) {
sessionStorage.setItem('ody-session-active', '1');
if (!targetId) {
try {
const dcRes = await fetch(`${API_BASE}/api/default-chat`);
const dc = await dcRes.json();
if (dc.endpoint_url && dc.model) {
// Check if there's already an empty session with this model we can reuse
const emptyDefault = activeSessions.find(s =>
s.model === dc.model && s.message_count === 0
);
if (emptyDefault) {
targetId = emptyDefault.id;
} else {
await createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id);
// On mobile, hide sidebar so user lands directly in chat
if (_isFirstLoad) sessionStorage.setItem('ody-session-active', '1');
if ((_isFirstLoad || _freshRootLoad) && !targetId) {
// Land on a visually fresh chat without creating hidden pending session
// state. The send path can create the default-backed session when the
// user actually submits. Pre-creating here races with opening an existing
// chat and was causing sends to jump into brand-new chats.
if (window.innerWidth < 768) {
const sb = document.getElementById('sidebar');
if (sb) sb.classList.add('hidden');
}
return; // createDirectChat handles selectSession internally
}
}
} catch (_) { /* no default model configured */ }
}
}
const suppressSessionLoading = _suppressNextSessionLoading;
@@ -1743,10 +1799,9 @@ export async function loadSessions() {
if (activeSessions.length === 0 && !_autoCreateInProgress) {
_autoCreateInProgress = true;
try {
const dcRes = await fetch(`${API_BASE}/api/default-chat`);
const dc = await dcRes.json();
if (dc.endpoint_url && dc.model) {
await createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id);
const dc = await _getPreferredDefaultChat();
if (dc && dc.endpoint_url && dc.model) {
await createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id, { source: 'default' });
}
} catch (_) { /* no default model — that's fine, user can /setup */ }
_autoCreateInProgress = false;
@@ -1767,6 +1822,13 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
try {
const navToken = ++_sessionNavToken;
const prevSessionId = currentSessionId;
// Selecting a real persisted chat cancels any deferred "New Chat" model
// pick. Otherwise the next send can materialize that pending chat instead
// of posting into the session the user just opened.
if (_pendingChat) {
_pendingChat = null;
_pendingMaterializePromise = null;
}
_clearHistoryPager();
// Re-archive peeked session when navigating away
_checkPeekCleanup(id);
@@ -1775,6 +1837,7 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
try { window.documentModule.clearSelection(); } catch {}
}
currentSessionId = id;
try { window.__odysseusLastSelectedSessionId = id; } catch (_) {}
// Identify Assistant / task-output sessions so we don't "trap" the user
// there on return. Skipped from both `lastSessionId` persistence and the
// URL hash — the user complained that coming back to Odysseus kept
@@ -1784,10 +1847,6 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
const _isTransientChat = !!_meta && (_meta.folder === 'Assistant' || _meta.folder === 'Tasks');
if (!_isTransientChat) {
Storage.set('lastSessionId', id);
// Update URL hash without triggering hashchange handler
if (window.location.hash !== '#' + id) {
history.replaceState(null, '', '#' + id);
}
}
// Restore character preset for persistent chats
try {
@@ -1827,7 +1886,7 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
const msgInput = document.getElementById('message');
if (msgInput) {
msgInput.disabled = false;
msgInput.value = '';
_clearComposerUnlessStartupTyped(msgInput);
msgInput.style.height = '';
msgInput.style.overflow = '';
autoResize(msgInput);
@@ -1859,6 +1918,7 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
}
// Update model picker visibility
updateModelPicker();
if (window.refreshChatContextHeader) window.refreshChatContextHeader('select-session');
// Refresh session cost badge for the newly selected session
if (chatRenderer.updateSessionCostUI) chatRenderer.updateSessionCostUI();
@@ -2086,8 +2146,44 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
// Pending session — stored locally until the first message is sent
let _pendingChat = null; // { url, modelId, endpointId }
let _pendingMaterializePromise = null;
export function createDirectChat(url, modelId, endpointId) {
async function _getPreferredDefaultChat() {
let dc = null;
try {
dc = window.__odysseusDefaultChat || null;
} catch (_) {}
if (!dc || !dc.endpoint_url || !dc.model) {
try {
dc = JSON.parse(localStorage.getItem('odysseus-default-chat-cache') || 'null');
} catch (_) {}
}
if (dc && dc.endpoint_url && dc.model) return dc;
try {
const dcRes = await fetch(`${API_BASE}/api/default-chat`);
dc = await dcRes.json();
if (dc && dc.endpoint_url && dc.model) {
try {
window.__odysseusDefaultChat = dc;
localStorage.setItem('odysseus-default-chat-cache', JSON.stringify(dc));
} catch (_) {}
return dc;
}
} catch (_) {}
return null;
}
export function createDirectChat(url, modelId, endpointId, opts = {}) {
const incomingSource = opts.source || 'manual';
if (
_pendingChat &&
_pendingChat.modelId &&
_pendingChat.source === 'manual' &&
incomingSource !== 'manual'
) {
updateModelPicker();
return;
}
_sessionNavToken++;
// Detach any active stream so it doesn't interfere with the new chat
if (window.chatModule && window.chatModule.detachCurrentStream) {
@@ -2101,10 +2197,12 @@ export function createDirectChat(url, modelId, endpointId) {
}
// Don't hit the API — just store the model info and prepare the UI
_pendingChat = { url, modelId, endpointId };
_pendingChat = { url, modelId, endpointId, source: incomingSource };
_pendingMaterializePromise = null;
_skipAutoSelect = true;
_suppressNextSessionLoading = true;
currentSessionId = null;
try { window.__odysseusLastSelectedSessionId = ''; } catch (_) {}
Storage.remove('lastSessionId');
history.replaceState(null, '', window.location.pathname);
document.querySelectorAll('.list-item.active-session, .session-item.active').forEach(el => {
@@ -2132,6 +2230,7 @@ export function createDirectChat(url, modelId, endpointId) {
// Update model picker to show the pending model
updateModelPicker();
if (window.refreshChatContextHeader) window.refreshChatContextHeader('new-chat');
// Update current-meta header
const metaEl = document.getElementById('current-meta');
@@ -2141,14 +2240,20 @@ export function createDirectChat(url, modelId, endpointId) {
// Enable input
const msgInput = document.getElementById('message');
if (msgInput) { msgInput.disabled = false; msgInput.value = ''; msgInput.focus(); }
if (msgInput) {
msgInput.disabled = false;
_clearComposerUnlessStartupTyped(msgInput);
msgInput.focus();
}
}
/** Actually create the session in the DB. Called on first message send. */
export async function materializePendingSession() {
if (_pendingMaterializePromise) return _pendingMaterializePromise;
const pending = _pendingChat;
if (!pending) return false;
_pendingChat = null;
const materializePromise = (async () => {
const incognitoChk = document.getElementById('incognito-toggle');
const isIncognito = incognitoChk && incognitoChk.checked;
@@ -2186,6 +2291,16 @@ export async function materializePendingSession() {
return false;
}
// The user may have opened an existing chat while this deferred default
// session was being created. Do not let a stale response steal
// currentSessionId and make the next send land in a brand-new chat.
if (_pendingChat !== pending) {
if (payload.id) {
fetch(`${API_BASE}/api/session/${encodeURIComponent(payload.id)}`, { method: 'DELETE' }).catch(() => {});
}
return false;
}
if (isIncognito && payload.id) {
_markIncognito(payload.id);
}
@@ -2194,16 +2309,42 @@ export async function materializePendingSession() {
if (window.documentModule?.clearSelection) {
try { window.documentModule.clearSelection(); } catch {}
}
_pendingChat = null;
currentSessionId = payload.id;
if (!isIncognito) {
Storage.set('lastSessionId', payload.id);
history.replaceState(null, '', '#' + payload.id);
}
// Reload the sidebar in the background. Awaiting this used to block the first
// prompt in a new/pending chat behind startup fetches and slow /api/sessions
// calls, so the user's message could sit for 20s+ before streaming began.
_suppressNextSessionLoading = true;
if (window.refreshChatContextHeader) window.refreshChatContextHeader('materialize-session');
loadSessions().catch(() => {});
return true;
})();
_pendingMaterializePromise = materializePromise;
try {
return await materializePromise;
} finally {
if (_pendingMaterializePromise === materializePromise) {
_pendingMaterializePromise = null;
}
}
}
export function preMaterializePendingSession() {
if (!_pendingChat || _pendingMaterializePromise) return;
const incognitoChk = document.getElementById('incognito-toggle');
if (incognitoChk && incognitoChk.checked) return;
setTimeout(() => {
const chk = document.getElementById('incognito-toggle');
if (chk && chk.checked) return;
if (_pendingChat && !_pendingMaterializePromise) {
materializePendingSession().catch(() => {});
}
}, 250);
}
export function hasPendingChat() { return !!_pendingChat; }
@@ -2213,6 +2354,10 @@ export function getCurrentSessionId() {
return currentSessionId;
}
export function isCurrentSessionIncognito() {
return !!(currentSessionId && _isIncognitoSession(currentSessionId));
}
export function getSessions() {
return sessions;
}
@@ -2220,9 +2365,8 @@ export function getSessions() {
export function getCurrentModel() {
const sess = sessions.find(x => x.id === currentSessionId);
if (sess && sess.model) return sess.model;
// Pending session not yet materialized — read from model picker label
const label = document.getElementById('model-picker-label');
return label ? label.textContent.trim() : null;
if (_pendingChat && _pendingChat.modelId) return _pendingChat.modelId;
return null;
}
/** Endpoint URL serving the current (or pending) session's model. Used to
@@ -2237,6 +2381,7 @@ export function getCurrentEndpointUrl() {
export function setCurrentSessionId(id) {
_sessionNavToken++;
currentSessionId = id;
try { window.__odysseusLastSelectedSessionId = id || ''; } catch (_) {}
if (!id) {
_suppressNextSessionLoading = true;
Storage.remove('lastSessionId');
@@ -2247,6 +2392,41 @@ export function setCurrentSessionId(id) {
}
}
export async function deleteCurrentSessionFromTopMenu() {
const sid = currentSessionId;
if (!sid) {
uiModule.showToast('No chat to delete');
return false;
}
const session = sessions.find(s => String(s.id) === String(sid));
if (session?.is_important) {
uiModule.showToast('Unfavorite before deleting');
return false;
}
if (!await uiModule.styledConfirm('Delete this session?', { confirmText: 'Delete', danger: true })) {
return false;
}
if (window.chatModule && window.chatModule.abortCurrentRequest) {
window.chatModule.abortCurrentRequest();
}
_deselectCurrentSession(sid);
_removeSessionFromLocalState(sid);
_skipAutoSelect = true;
try {
const pm = await import('./presets.js');
if (pm.removePersistentChat) pm.removePersistentChat(sid);
} catch (e) {}
try {
const res = await fetch(`${API_BASE}/api/session/${sid}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed');
uiModule.showToast('Session deleted');
} catch (e) {
uiModule.showError('Failed to delete session');
}
await loadSessions();
return true;
}
// Session list keyboard navigation: arrows to move, Delete to delete
async function _onSessionListKeydown(e) {
const item = e.target.closest('.list-item[data-session-id]');
@@ -3454,6 +3634,7 @@ const sessionModule = {
selectSession,
createDirectChat,
materializePendingSession,
preMaterializePendingSession,
hasPendingChat,
getPendingChat,
getCurrentSessionId,
@@ -3475,7 +3656,8 @@ const sessionModule = {
closeArchive,
setSessionHasDocs,
getSortMode,
setSortMode
setSortMode,
deleteCurrentSessionFromTopMenu
};
export { updateModelPicker };
+30 -11
View File
@@ -22,7 +22,7 @@ function safeRasterDataUrl(raw) {
}
/* ── Tab switching ── */
const ADMIN_TABS = new Set(['services', 'integrations', 'tools', 'users', 'system']);
const ADMIN_TABS = new Set(['services', 'added-models', 'integrations', 'tools', 'users', 'system']);
function initTabs() {
modalEl.querySelectorAll('[data-settings-tab]').forEach(btn => {
@@ -792,8 +792,9 @@ async function initImageSettings() {
async function saveSettings() {
try {
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
const res = await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value }) });
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
}
@@ -1860,7 +1861,7 @@ const SHORTCUT_ICONS = {
settings: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
focus_input: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
open_calendar: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
open_compare: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg>',
open_compare: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="16" rx="1.5"/><rect x="14" y="4" width="7" height="16" rx="1.5"/><path d="M10 8h4"/><path d="M10 16h4"/></svg>',
open_cookbook: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
open_research: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>',
open_gallery: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>',
@@ -2821,6 +2822,17 @@ async function initReminderSettings() {
async function initEmailAccountsSettings() {
const root = el('settings-modal');
if (!root || !root.querySelector('[data-settings-panel="email"]')) return;
el('set-email-open-library-settings')?.addEventListener('click', async () => {
try {
const mod = await import('./emailLibrary.js?v=20260722emailfastindex1');
if (typeof mod.openEmailLibrarySettings === 'function') {
await mod.openEmailLibrarySettings();
}
} catch (e) {
console.warn('Failed to open Email settings page', e);
}
});
const manageBtn = el('set-email-open-integrations');
if (manageBtn && manageBtn.dataset.bound !== '1') {
manageBtn.dataset.bound = '1';
@@ -3111,24 +3123,31 @@ async function initEmailSettings() {
const root = el('settings-modal');
if (!root || !root.querySelector('[data-settings-panel="email"]')) return;
const styleKey = 'odysseus-email-writing-style';
const styleKey = () => {
const account = String(window.__odysseusActiveEmailAccount || '').trim();
return account ? `odysseus-email-writing-style:${account}` : 'odysseus-email-writing-style';
};
const styleEl = el('set-email-style');
const emailAccountSuffix = () => {
const account = String(window.__odysseusActiveEmailAccount || '').trim();
return account ? `?account_id=${encodeURIComponent(account)}` : '';
};
// The account/CardDAV config endpoints can be slow when remote mail servers
// are cold. Populate the Writing Style box independently so saved prose does
// not appear seconds after the panel opens.
try {
const cachedStyle = localStorage.getItem(styleKey);
const cachedStyle = localStorage.getItem(styleKey());
if (styleEl && cachedStyle !== null && !styleEl.value) styleEl.value = cachedStyle;
} catch (_) {}
const loadWritingStyle = async () => {
try {
const res = await fetch('/api/email/style');
const res = await fetch(`/api/email/style${emailAccountSuffix()}`);
const data = await res.json();
const style = data.style || '';
if (styleEl) styleEl.value = style;
try { localStorage.setItem(styleKey, style); } catch (_) {}
try { localStorage.setItem(styleKey(), style); } catch (_) {}
} catch (_) {}
};
loadWritingStyle();
@@ -3240,7 +3259,7 @@ async function initEmailSettings() {
}
}
try {
const res = await fetch('/api/email/extract-style', {
const res = await fetch(`/api/email/extract-style${emailAccountSuffix()}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sample_count: 15 }),
@@ -3248,7 +3267,7 @@ async function initEmailSettings() {
const data = await res.json();
if (data.success && data.style) {
if (styleEl) styleEl.value = data.style;
try { localStorage.setItem(styleKey, data.style); } catch (_) {}
try { localStorage.setItem(styleKey(), data.style); } catch (_) {}
if (msg) msg.textContent = '✓ Style extracted';
} else {
if (msg) msg.textContent = data.error || 'Failed';
@@ -3268,14 +3287,14 @@ async function initEmailSettings() {
if (msg) msg.textContent = 'Saving...';
try {
const style = styleEl ? styleEl.value : '';
const res = await fetch('/api/email/style', {
const res = await fetch(`/api/email/style${emailAccountSuffix()}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ style }),
});
const result = await res.json();
if (result.success) {
try { localStorage.setItem(styleKey, style); } catch (_) {}
try { localStorage.setItem(styleKey(), style); } catch (_) {}
}
if (msg) msg.textContent = result.success ? '✓ Saved' : 'Failed';
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
+49 -1
View File
@@ -34,6 +34,46 @@ export function initSidebarLayout(Storage, opts) {
// ── Icon rail + sidebar toggle ──
const iconRail = document.getElementById('icon-rail');
const hamburgerBtn = document.getElementById('hamburger-btn');
const SIDEBAR_MODE_KEY = 'odysseus-sidebar-mode';
function _setSidebarModeClasses(mode) {
document.documentElement.classList.remove('ody-mobile-startup-sidebar-hidden');
document.documentElement.classList.toggle('ody-sidebar-mini', mode === 'mini');
document.documentElement.classList.toggle('ody-sidebar-off', mode === 'off');
}
function _saveSidebarMode(mode) {
try { localStorage.setItem(SIDEBAR_MODE_KEY, mode); } catch (_) {}
_setSidebarModeClasses(mode);
}
function _applyStoredSidebarMode() {
const sidebar = document.getElementById('sidebar');
if (!sidebar) return;
if (window.innerWidth < 768 && document.getElementById('app-loader')) {
sidebar.classList.add('hidden');
if (iconRail) {
iconRail.classList.add('rail-hidden');
iconRail.classList.remove('mobile-mini');
iconRail.style.cssText = '';
}
_setSidebarModeClasses('off');
return;
}
let mode = 'full';
try { mode = localStorage.getItem(SIDEBAR_MODE_KEY) || 'full'; } catch (_) {}
if (mode === 'mini') {
sidebar.classList.add('hidden');
if (iconRail) iconRail.classList.remove('rail-hidden');
} else if (mode === 'off') {
sidebar.classList.add('hidden');
if (iconRail) iconRail.classList.add('rail-hidden');
} else {
sidebar.classList.remove('hidden');
if (iconRail) iconRail.classList.remove('rail-hidden');
}
_setSidebarModeClasses(mode);
}
function _syncRailSideCore() {
const sidebar = document.getElementById('sidebar');
@@ -62,6 +102,7 @@ export function initSidebarLayout(Storage, opts) {
document.body.classList.toggle('hamburger-left', !isRight);
document.body.classList.toggle('hamburger-only', sidebarHidden && railHidden);
document.body.classList.toggle('sidebar-collapsed', sidebarHidden);
_setSidebarModeClasses(!sidebarHidden ? 'full' : (railHidden ? 'off' : 'mini'));
}
// Keep incognito button clear of hamburger
const incogBtn = document.getElementById('incognito-btn');
@@ -82,6 +123,7 @@ export function initSidebarLayout(Storage, opts) {
if (Storage.get(Storage.KEYS.SIDEBAR_SIDE) === 'right') {
document.getElementById('sidebar').classList.add('right-side');
}
_applyStoredSidebarMode();
syncRailSide();
// In-sidebar toggle button — same behavior as hamburger
@@ -154,6 +196,7 @@ export function initSidebarLayout(Storage, opts) {
if (isSidebarVisible) {
// Closing sidebar
sidebar.classList.add('hidden');
_saveSidebarMode('off');
if (backdrop) backdrop.classList.remove('visible');
} else {
// Mobile: the hamburger always opens the sidebar from the RIGHT.
@@ -169,11 +212,13 @@ export function initSidebarLayout(Storage, opts) {
// Wait for keyboard dismiss to settle, then open
setTimeout(() => {
sidebar.classList.remove('hidden');
_saveSidebarMode('full');
if (backdrop) backdrop.classList.add('visible');
syncRailSide();
}, 250);
} else {
sidebar.classList.remove('hidden');
_saveSidebarMode('full');
if (backdrop) backdrop.classList.add('visible');
}
}
@@ -184,10 +229,13 @@ export function initSidebarLayout(Storage, opts) {
// Desktop: full sidebar ↔ mini (icon rail) — simple toggle
if (isSidebarVisible) {
sidebar.classList.add('hidden');
if (iconRail) iconRail.classList.remove('rail-hidden');
_saveSidebarMode('mini');
} else {
_wasAutoCollapsed = false;
iconRail.classList.remove('rail-hidden');
sidebar.classList.remove('hidden');
_saveSidebarMode('full');
}
syncRailSide();
});
@@ -484,7 +532,7 @@ function _initChatSwipeToOpenSidebar() {
// Areas where a horizontal drag means something else (their own scroll/drag).
const EXCLUDE = [
'#sidebar', '#icon-rail', '.modal', '.input-bar', '#message',
'#sidebar', '#icon-rail', '.modal', '.input-bar', '.chat-input-bar', '#message',
'#minimized-dock', '.minimized-dock-chip', '#dock-trash-zone',
'pre', 'table', '.agent-tool-output', '.agent-thread-cmd',
'input', 'textarea', 'select',
+11 -4
View File
@@ -16,7 +16,7 @@ import modelsModule from './models.js';
import chatRenderer from './chatRenderer.js';
import spinnerModule from './spinner.js';
import themeModule from './theme.js';
import documentModule from './document.js';
import documentModule from './document.js?v=20260722emailfastindex1';
import workspaceModule from './workspace.js';
import settingsModule from './settings.js';
import cookbookModule from './cookbook.js';
@@ -287,8 +287,14 @@ function _setupProviderPrompt() {
// -----------------------------------------------------------------------
/** Persist a message to the current session (fire-and-forget) */
function _persistMsg(role, content, metadata) {
const sid = sessionModule.getCurrentSessionId();
async function _persistMsg(role, content, metadata) {
let sid = sessionModule.getCurrentSessionId();
if (!sid && sessionModule.hasPendingChat?.()) {
try {
await sessionModule.materializePendingSession?.();
sid = sessionModule.getCurrentSessionId();
} catch (_) {}
}
if (!sid || !content) return;
const payload = { role, content };
if (metadata) payload.metadata = metadata;
@@ -300,6 +306,7 @@ function _persistMsg(role, content, metadata) {
}
function slashReply(text) {
_hideWelcomeScreen();
const chatBox = document.getElementById('chat-history');
const div = document.createElement('div');
div.className = 'msg msg-ai';
@@ -1262,7 +1269,7 @@ async function _cmdWorkspace(args, ctx) {
// folder, sensitive dir, filesystem root).
workspaceModule.vetAndSetWorkspace(rest).then(({ ok, path }) => {
if (ok) slashReply(`Workspace set: <code>${uiModule.esc(path)}</code>`);
else slashReply(`Not a usable workspace folder: <code>${uiModule.esc(rest)}</code>. It must be an existing directory, not a filesystem root or sensitive path.`);
else slashReply(`Not a usable workspace folder on the Odysseus backend: <code>${uiModule.esc(rest)}</code>. If Odysseus is running in Docker, use the container path, usually <code>/app</code>, or use <code>/workspace pick</code>.`);
});
return true;
}
+24 -40
View File
@@ -152,7 +152,7 @@ class Spinner {
this._wpCanvas = canvas;
this._wpCtx = canvas.getContext('2d');
this._wpFrame = 60;
this._wpStartedAt = null;
this.element = wrapper;
return wrapper;
}
@@ -164,12 +164,12 @@ class Spinner {
const cx = W / 2, cy = H / 2;
const maxR = Math.min(W, H) / 2 - 1;
const lw = W > 30 ? 3 : W > 20 ? 2 : 1.5;
const TOTAL_TURNS = 4;
const TAIL_LEN = 0.45;
const SPIN_SPEED = 0.08;
const LAYERS = 12;
const STEPS = 50;
const t = this._wpFrame;
const TOTAL_TURNS = 2.7;
const STEPS = 84;
const LOOP_MS = 1100;
if (!this._wpStartedAt) this._wpStartedAt = performance.now();
const loop = ((performance.now() - this._wpStartedAt) % LOOP_MS) / LOOP_MS;
const rot = loop * Math.PI * 2;
// Colors from CSS vars — read ONCE and cache. Calling getComputedStyle every
// frame forces a full style recalc per frame, which janks/freezes the canvas
@@ -185,8 +185,9 @@ class Spinner {
const fg = this._wpColors.fg;
const track = this._wpColors.track;
function spiralPoint(frac, rot) {
const r = maxR * (1 - frac);
function spiralPoint(frac) {
const eased = Math.pow(frac, 0.82);
const r = maxR * eased;
const angle = frac * TOTAL_TURNS * Math.PI * 2 + rot;
return { x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r };
}
@@ -202,49 +203,32 @@ class Spinner {
ctx.stroke();
ctx.globalAlpha = 1;
const headPos = (t * 0.008) % 1;
// overlapping sub-paths for smooth fade
// Rotating a single continuous spiral keeps the loop seamless: the start
// and end frames are the same shape, just one full turn apart.
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
for (let layer = LAYERS - 1; layer >= 0; layer--) {
const endFrac = (layer + 1) / LAYERS;
const stepsForLayer = Math.ceil(STEPS * endFrac);
const alpha = Math.pow(1 - endFrac, 2) * 0.7;
for (let i = 1; i <= STEPS; i++) {
const a = (i - 1) / STEPS;
const b = i / STEPS;
const p0 = spiralPoint(a);
const p1 = spiralPoint(b);
ctx.beginPath();
let started = false;
let prevPos = -1;
for (let i = 0; i <= stepsForLayer; i++) {
const frac = i / STEPS;
let pos = headPos - frac * TAIL_LEN;
if (pos < 0) pos += 1;
if (started && prevPos < 0.3 && pos > 0.7) {
ctx.stroke();
ctx.beginPath();
started = false;
}
const pt = spiralPoint(pos, t * SPIN_SPEED);
if (!started) { ctx.moveTo(pt.x, pt.y); started = true; }
else ctx.lineTo(pt.x, pt.y);
prevPos = pos;
}
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(p1.x, p1.y);
ctx.strokeStyle = fg;
ctx.lineWidth = lw * 0.8;
ctx.globalAlpha = alpha;
ctx.lineWidth = lw * (0.52 + b * 0.32);
ctx.globalAlpha = 0.12 + Math.pow(b, 1.8) * 0.72;
ctx.stroke();
}
// bright dot at head
const head = spiralPoint(headPos, t * SPIN_SPEED);
const head = spiralPoint(1);
ctx.beginPath();
ctx.arc(head.x, head.y, Math.max(1, lw * 0.45), 0, Math.PI * 2);
ctx.arc(head.x, head.y, Math.max(1.05, lw * 0.48), 0, Math.PI * 2);
ctx.fillStyle = fg;
ctx.globalAlpha = 0.9;
ctx.fill();
ctx.globalAlpha = 1;
this._wpFrame++;
if (!this.isRunning) return;
// Leak-safe self-terminate: stop once our element WAS in the DOM and then
// got removed (e.g. a loading row replaced by results). But keep spinning
@@ -293,7 +277,7 @@ class Spinner {
}
if (this.animation === 'whirlpool') {
this._wpFrame = 60;
this._wpStartedAt = performance.now();
this._drawWhirlpool();
return;
}
+271 -33
View File
@@ -20,6 +20,8 @@ let _escHandler = null;
let _viewingRuns = null; // task id when viewing run history
let _clockInterval = null;
let _taskFailurePending = false;
let _taskCompletionPending = false;
let _taskBulkDeleting = false;
const DAYS_OF_WEEK = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
@@ -29,6 +31,12 @@ function _setTaskFailurePending(active) {
document.getElementById('rail-tasks')?.classList.toggle('task-failure-pending', _taskFailurePending);
}
function _setTaskCompletionPending(active) {
_taskCompletionPending = !!active;
document.getElementById('tool-tasks-btn')?.classList.toggle('task-completion-pending', _taskCompletionPending);
document.getElementById('rail-tasks')?.classList.toggle('task-completion-pending', _taskCompletionPending);
}
// ---- API ----
async function _fetchTasks() {
@@ -105,6 +113,25 @@ function _animateTaskRemoval(ids) {
return new Promise(resolve => setTimeout(resolve, 520));
}
function _setTaskCardsDeleting(ids, active) {
for (const id of ids) {
const card = _taskCardById(id);
if (!card) continue;
card.classList.toggle('task-card-deleting', !!active);
const existing = card.querySelector('.task-card-delete-busy');
if (!active) {
existing?.remove();
continue;
}
if (!existing) {
const badge = document.createElement('span');
badge.className = 'task-card-delete-busy';
badge.innerHTML = '<span class="task-card-delete-busy-label">Deleting</span><span class="task-card-delete-busy-spin" aria-hidden="true"></span>';
card.appendChild(badge);
}
}
}
async function _pauseTask(id) {
const res = await fetch(`${API_BASE}/api/tasks/${id}/pause`, {
method: 'POST', credentials: 'same-origin',
@@ -669,17 +696,65 @@ function _taskUpdateBulkCount() {
}
async function _taskBulkDelete() {
const ids = [..._taskSelected];
if (!ids.length) return;
if (!ids.length || _taskBulkDeleting) return;
const ok = uiModule?.styledConfirm
? await uiModule.styledConfirm(`Delete ${ids.length} task${ids.length > 1 ? 's' : ''}? This cannot be undone.`, { confirmText: 'Delete', danger: true })
: confirm(`Delete ${ids.length} task(s)?`);
if (!ok) return;
const results = await Promise.allSettled(ids.map(id => _deleteTask(id)));
const deletedIds = ids.filter((_, i) => results[i].status === 'fulfilled');
_taskBulkDeleting = true;
const countEl = document.getElementById('tasks-selected-count');
const deleteBtn = document.getElementById('tasks-bulk-delete');
const cancelBtn = document.getElementById('tasks-bulk-cancel');
const selectAll = document.getElementById('tasks-select-all');
const originalDeleteHtml = deleteBtn?.innerHTML || '';
let busySpinner = null;
if (deleteBtn) {
deleteBtn.disabled = true;
deleteBtn.classList.add('tasks-bulk-loading');
deleteBtn.innerHTML = '<span class="tasks-bulk-loading-label">Deleting</span>';
busySpinner = spinnerModule.create('', 'clean', 'whirlpool');
const spEl = busySpinner.createElement();
spEl.classList.add('tasks-bulk-whirlpool');
deleteBtn.appendChild(spEl);
busySpinner.start();
}
if (cancelBtn) cancelBtn.disabled = true;
if (selectAll) selectAll.disabled = true;
if (countEl) countEl.textContent = `Deleting 0/${ids.length}`;
_setTaskCardsDeleting(ids, true);
const deletedIds = [];
let finished = 0;
try {
const results = await Promise.allSettled(ids.map(async (id) => {
try {
await _deleteTask(id);
deletedIds.push(id);
} finally {
finished += 1;
if (countEl) countEl.textContent = `Deleting ${finished}/${ids.length}`;
}
}));
const failed = results.filter(r => r.status === 'rejected').length;
await _animateTaskRemoval(deletedIds);
if (uiModule) uiModule.showToast(`Deleted ${deletedIds.length} task${deletedIds.length > 1 ? 's' : ''}`);
if (uiModule) {
const msg = failed
? `Deleted ${deletedIds.length}, failed ${failed}`
: `Deleted ${deletedIds.length} task${deletedIds.length > 1 ? 's' : ''}`;
uiModule.showToast(msg);
}
await _fetchTasks();
} finally {
_setTaskCardsDeleting(ids, false);
if (busySpinner) busySpinner.destroy();
if (deleteBtn) {
deleteBtn.classList.remove('tasks-bulk-loading');
deleteBtn.innerHTML = originalDeleteHtml || deleteBtn.innerHTML;
}
if (cancelBtn) cancelBtn.disabled = false;
if (selectAll) selectAll.disabled = false;
_taskBulkDeleting = false;
_taskExitSelect(); // clears selection + re-renders the fresh list
}
}
// Category filter chips (library-style tags) — solo-select: click one to
@@ -1937,10 +2012,93 @@ function _switchTab(tab) {
b.classList.toggle('active', on);
});
if (tab === 'tasks') _renderMainView();
else if (tab === 'completed') _renderCompletedView();
else if (tab === 'activity') _renderActivityView();
else if (tab === 'new') _showPresetPicker();
}
function _runToActivityEntry(r) {
let resultText = r.result || r.error || '';
if (!resultText) {
if (r.status === 'queued') resultText = '_Queued — waiting for a free slot…_';
if (r.status === 'running') resultText = '_Running…_';
}
return {
kind: r.task_type || 'llm',
taskName: r.task_name || (r.task_type === 'action' ? (r.action || 'Action') : 'Task'),
taskId: r.task_id,
action: r.action || '',
result: resultText,
prompt: '',
ts: r.finished_at || r.started_at,
status: r.status,
model: r.model || '',
endpointUrl: r.endpoint_url || '',
sessionId: r.session_id || '',
researchId: r.research_id || '',
output_target: r.output_target || 'session',
};
}
function _isFinishedRun(entry) {
return !['queued', 'running', 'skipped'].includes(entry.status || '');
}
function _isChatResultRun(entry) {
return _isFinishedRun(entry)
&& (entry.kind === 'llm' || entry.kind === 'research')
&& !!(entry.result || '').trim();
}
async function _renderCompletedView() {
_setTaskCompletionPending(false);
const modal = document.getElementById('tasks-modal');
const body = modal?.querySelector('.modal-body');
if (!body) return;
body.innerHTML = `
<div class="admin-card tasks-activity-card tasks-completed-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;min-height:0;">
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">
<h2 style="margin:0;padding:0;line-height:1;">Completed</h2>
<button class="memory-toolbar-btn" id="tasks-completed-refresh" title="Refresh" style="margin-left:auto;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
</div>
<p class="memory-desc">Completed assistant/research outputs you can open in chat.</p>
<div id="tasks-completed-list" class="memory-list tasks-activity-list tasks-completed-list" style="flex:1;overflow:auto;font-size:13px;min-height:0;"></div>
</div>
`;
document.getElementById('tasks-completed-refresh')?.addEventListener('click', _renderCompletedView);
const list = document.getElementById('tasks-completed-list');
list?.appendChild(spinnerModule.createLoadingRow('Loading…'));
try {
const res = await fetch(`${API_BASE}/api/tasks/runs/recent?limit=${_completedLimit}&max_result_chars=10000`, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const finished = (data.runs || []).map(_runToActivityEntry).filter(_isChatResultRun);
_completedHasMore = !!data.has_more && _completedLimit < 200;
_activityEntries = finished;
_syncCompletedTabCount(finished.length);
if (!list) return;
if (finished.length === 0) {
list.innerHTML = '<div class="doclib-empty task-completed-empty">No completed task outputs yet.</div>';
return;
}
list.innerHTML = finished.map(_renderCompletedPreviewEntry).join('');
if (_completedHasMore) {
list.insertAdjacentHTML('beforeend', `
<button type="button" class="memory-toolbar-btn tasks-activity-load-more" id="tasks-completed-load-more" style="width:100%;justify-content:center;margin-top:6px;">
Load more
</button>
`);
list.querySelector('#tasks-completed-load-more')?.addEventListener('click', () => {
_completedLimit = Math.min(200, _completedLimit + 40);
_renderCompletedView();
});
}
_wireCompletedPreviewRows(list);
} catch (e) {
if (list) list.innerHTML = `<div style="opacity:0.5;padding:12px;">Failed to load completed tasks: ${_escHtml(e.message || String(e))}</div>`;
}
}
// ---- Activity view (assistant session log) ----
async function _renderActivityView() {
@@ -2082,32 +2240,8 @@ async function _renderActivityView() {
list.innerHTML = '<div style="opacity:0.5;padding:12px;">No activity yet. Scheduled tasks will log here once they run.</div>';
return;
}
_activityEntries = runs.map(r => {
let resultText = r.result || r.error || '';
if (!resultText) {
if (r.status === 'queued') resultText = '_Queued — waiting for a free slot…_';
if (r.status === 'running') resultText = '_Running…_';
}
return {
// Surface the actual task_type ('llm' | 'research' | 'action') so the
// chat-worthy check in _renderActivityEntry can decide between "Open
// in chat" (llm/research) and "Copy log" (action). Was hardcoded
// 'task', which never matched and made Open-in-chat dead code.
kind: r.task_type || 'llm',
taskName: r.task_name || (r.task_type === 'action' ? (r.action || 'Action') : 'Task'),
taskId: r.task_id,
action: r.action || '',
result: resultText,
prompt: '',
ts: r.finished_at || r.started_at,
status: r.status,
model: r.model || '',
endpointUrl: r.endpoint_url || '',
sessionId: r.session_id || '',
researchId: r.research_id || '',
output_target: r.output_target || 'session',
};
});
_activityEntries = runs.map(_runToActivityEntry);
_syncCompletedTabCount(_activityEntries.filter(_isChatResultRun).length);
_buildChips();
_applyFilter();
} catch (e) {
@@ -2119,6 +2253,13 @@ async function _renderActivityView() {
let _activityEntries = [];
let _activityLimit = 40;
let _activityHasMore = false;
let _completedLimit = 40;
let _completedHasMore = false;
function _syncCompletedTabCount(count) {
const el = document.getElementById('tasks-completed-tab-count');
if (el) el.textContent = String(count || 0);
}
function _stackActivityEntries(entries) {
const out = [];
@@ -2291,6 +2432,86 @@ function _wireActivityRows(list) {
});
}
function _renderCompletedPreviewEntry(entry) {
const entryIdx = _activityEntries.indexOf(entry);
const tsLabel = _relativeTime(entry.ts);
const tsAbs = entry.ts ? new Date(entry.ts).toLocaleString() : '';
const modelTag = entry.model
? `<span class="doclib-chat-msg-model">${_escHtml(entry.model.split('/').pop())}</span>`
: '';
const raw = (entry.result || '').trim();
const truncated = raw.length > 1400 ? raw.slice(0, 1400) + '…' : raw;
const cleaned = truncated
.replace(/<think>[\s\S]*?<\/think>/g, '')
.replace(/<think>[\s\S]*$/, '')
.trim();
let body;
try {
body = markdownModule.mdToHtml(cleaned);
} catch {
body = _escHtml(cleaned);
}
const title = _escHtml(entry.taskName || 'Task');
const time = `<span class="task-log-time" title="${_escHtml(tsAbs)}">${_escHtml(tsLabel)}</span>`;
return `
<div class="memory-item doclib-chat-row task-completed-preview-row" data-entry-idx="${entryIdx}">
<div class="doclib-chat-header task-completed-preview-head">
<span class="task-log-task-icon">${_taskIcon({ action: entry.action, task_type: entry.kind })}</span>
<span class="task-log-name">${title}</span>${_taskAiMark(entry)}
<span style="flex:1"></span>
${time}
</div>
<div class="doclib-chat-preview task-completed-chat-preview" style="display:block;">
<div class="doclib-chat-preview-messages">
<div class="doclib-chat-bubble-row assistant">
<div class="doclib-chat-bubble">
${modelTag}
<div class="doclib-chat-bubble-body">${body}</div>
</div>
</div>
</div>
<div class="doclib-chat-preview-actions">
<button class="doclib-chat-copy-btn task-completed-copy-btn" type="button">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy
</button>
<button class="doclib-chat-open-btn task-completed-open-chat" type="button">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
Open chat
</button>
</div>
</div>
</div>
`;
}
function _wireCompletedPreviewRows(list) {
list.querySelectorAll('.task-completed-open-chat').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const row = btn.closest('.task-completed-preview-row');
const idx = parseInt(row?.dataset.entryIdx || '-1', 10);
const entry = _activityEntries[idx];
if (entry) _openResultInChat(entry);
});
});
list.querySelectorAll('.task-completed-copy-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const row = btn.closest('.task-completed-preview-row');
const idx = parseInt(row?.dataset.entryIdx || '-1', 10);
const entry = _activityEntries[idx];
if (!entry) return;
try {
uiModule.copyToClipboard((entry.result || '').trim());
uiModule.showToast('Output copied');
} catch (_) {
uiModule.showError('Copy failed');
}
});
});
}
// Open a task run's result in a fresh chat session so it's comfortable
// to read full-width and the user can ask follow-ups.
async function _openResultInChat(entry) {
@@ -2422,7 +2643,7 @@ function _categoryLabel(taskName) {
return 'other';
}
function _renderActivityEntry(entry) {
function _renderActivityEntry(entry, opts = {}) {
// Canonical index into _activityEntries (map() passes the FILTERED
// index, which would be wrong) — used by the Open-in-chat handler.
const entryIdx = Number.isInteger(entry.sourceIdx) ? entry.sourceIdx : _activityEntries.indexOf(entry);
@@ -2568,7 +2789,7 @@ function _renderActivityEntry(entry) {
`;
}
return `
<div class="task-log-row${rowStatusClass}${long ? ' is-long' : ''}${_isRunning ? ' is-running' : ''}" data-kind="${_escHtml(entry.kind)}" data-entry-idx="${entryIdx}" style="${styleVars}">
<div class="task-log-row${rowStatusClass}${long ? ' is-long' : ''}${_isRunning ? ' is-running' : ''}${opts.expanded ? ' expanded' : ''}" data-kind="${_escHtml(entry.kind)}" data-entry-idx="${entryIdx}" style="${styleVars}">
<div class="task-log-row-head">
${statusDot}
<span class="task-log-task-icon">${_taskIcon({ action: entry.action, task_type: entry.kind })}</span>
@@ -2715,10 +2936,13 @@ export function openTasks(focusId, opts) {
startNotificationPolling();
const o = opts || {};
const openActivityForFailure = _taskFailurePending && !focusId && o.filter === undefined;
const openCompletedForNotification = _taskCompletionPending && !focusId && o.filter === undefined;
_setTaskFailurePending(false);
_setTaskCompletionPending(false);
if (_open) {
// Already open — just focus the requested task / apply filter.
if (openActivityForFailure) _switchTab('activity');
else if (openCompletedForNotification) _switchTab('completed');
if (o.filter !== undefined) { _taskFilter = o.filter; _renderList(); }
if (focusId) _focusTask(focusId);
return;
@@ -2751,6 +2975,10 @@ export function openTasks(focusId, opts) {
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
Activity
</button>
<button class="memory-tab tasks-tab" data-tab="completed" role="tab" aria-selected="false">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px"><path d="M20 6 9 17l-5-5"/></svg>
Completed <span id="tasks-completed-tab-count" class="memory-count" style="font-size:0.8em;opacity:0.6;font-weight:normal;margin-left:4px">0</span>
</button>
<button class="memory-tab tasks-tab" data-tab="new" role="tab" aria-selected="false">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Add
@@ -2825,7 +3053,7 @@ export function openTasks(focusId, opts) {
// of an empty modal-body that fills in after the fetch resolves — that delay
// was visible as a "flicker" right after opening.
_activeTab = 'tasks';
_switchTab(openActivityForFailure ? 'activity' : 'tasks');
_switchTab(openActivityForFailure ? 'activity' : openCompletedForNotification ? 'completed' : 'tasks');
_fetchTasks().then(() => {
// Re-render so the list swaps the Loading row for real cards.
_renderList();
@@ -2901,6 +3129,15 @@ async function _pollTaskNotifications() {
const notes = data.notifications || [];
for (const n of notes) {
const ok = n.status === 'success';
if (ok) {
const completedOpen = _open && document.querySelector('.tasks-tab.active[data-tab="completed"]');
if (completedOpen) {
_setTaskCompletionPending(false);
_renderCompletedView();
} else {
_setTaskCompletionPending(true);
}
}
// Tasks with output_target='notification' carry the result text in `body`
// — show it as a real browser Notification (richer than a toast). Falls
// back to a toast when permission is denied or unavailable.
@@ -2934,6 +3171,7 @@ async function _pollTaskNotifications() {
function startNotificationPolling() {
if (_notifInterval) return;
setTimeout(_pollTaskNotifications, 1500);
_notifInterval = setInterval(_pollTaskNotifications, 30000);
}
+24 -6
View File
@@ -577,9 +577,11 @@ export function el(id) {
/**
* Styled confirm dialog replaces native browser confirm().
* Returns a Promise<boolean>.
* Returns a Promise<boolean|'alternate'>. Existing two-button callers only
* receive true/false; callers that pass alternateText can detect the third
* action via the string 'alternate'.
*/
export function styledConfirm(message, { confirmText = 'Confirm', cancelText = 'Cancel', danger = false } = {}) {
export function styledConfirm(message, { confirmText = 'Confirm', cancelText = 'Cancel', alternateText = '', title = 'Confirm', danger = false } = {}) {
return new Promise(resolve => {
// Reuse or create the modal
let overlay = document.getElementById('styled-confirm-overlay');
@@ -593,6 +595,7 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
'<div class="modal-body"><p id="styled-confirm-msg"></p></div>' +
'<div class="modal-footer">' +
'<button id="styled-confirm-cancel"></button>' +
'<button id="styled-confirm-alt" style="display:none;"></button>' +
'<button id="styled-confirm-ok"></button>' +
'</div>' +
'</div>';
@@ -600,14 +603,25 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
}
const msgEl = document.getElementById('styled-confirm-msg');
const titleEl = document.getElementById('styled-confirm-title');
const okBtn = document.getElementById('styled-confirm-ok');
const cancelBtn = document.getElementById('styled-confirm-cancel');
let altBtn = document.getElementById('styled-confirm-alt');
if (!altBtn) {
altBtn = document.createElement('button');
altBtn.id = 'styled-confirm-alt';
okBtn.parentNode.insertBefore(altBtn, okBtn);
}
if (titleEl) titleEl.textContent = title || 'Confirm';
msgEl.textContent = message;
okBtn.textContent = confirmText;
cancelBtn.textContent = cancelText;
altBtn.textContent = alternateText || '';
okBtn.className = danger ? 'confirm-btn confirm-btn-danger' : 'confirm-btn confirm-btn-primary';
cancelBtn.className = 'confirm-btn confirm-btn-secondary';
altBtn.className = 'confirm-btn confirm-btn-secondary';
altBtn.style.display = alternateText ? '' : 'none';
// Remember what had focus so we can restore it when the dialog closes.
const _prevFocus = document.activeElement;
@@ -619,20 +633,23 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
overlay.style.display = 'none';
okBtn.removeEventListener('click', onOk);
cancelBtn.removeEventListener('click', onCancel);
altBtn.removeEventListener('click', onAlt);
overlay.removeEventListener('click', onBackdrop);
document.removeEventListener('keydown', onKey);
try { _prevFocus && _prevFocus.focus && _prevFocus.focus(); } catch {}
resolve(result);
}
function onOk() { cleanup(true); }
function onAlt() { cleanup('alternate'); }
function onCancel() { cleanup(false); }
function onBackdrop(e) { if (e.target === overlay) cleanup(false); }
function onKey(e) {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
const active = document.activeElement;
if (active === okBtn) cancelBtn.focus();
else okBtn.focus();
const f = alternateText ? [cancelBtn, altBtn, okBtn] : [cancelBtn, okBtn];
const i = f.indexOf(document.activeElement);
const dir = e.key === 'ArrowRight' ? 1 : -1;
f[(i + dir + f.length) % f.length].focus();
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
@@ -641,7 +658,7 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
} else if (e.key === 'Tab') {
// Trap focus inside the dialog so Tab can't wander to the page behind.
e.preventDefault();
const f = [cancelBtn, okBtn];
const f = alternateText ? [cancelBtn, altBtn, okBtn] : [cancelBtn, okBtn];
const i = f.indexOf(document.activeElement);
const n = e.shiftKey ? (i <= 0 ? f.length - 1 : i - 1) : (i >= f.length - 1 ? 0 : i + 1);
f[n].focus();
@@ -649,6 +666,7 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
}
okBtn.addEventListener('click', onOk);
altBtn.addEventListener('click', onAlt);
cancelBtn.addEventListener('click', onCancel);
overlay.addEventListener('click', onBackdrop);
document.addEventListener('keydown', onKey);
+240
View File
@@ -0,0 +1,240 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Odysseus Modal Controls</title>
<style>
:root {
color-scheme: dark;
--bg: #101114;
--panel: #181a1f;
--panel-2: #20232a;
--fg: #f4f4f5;
--muted: rgba(244,244,245,.58);
--border: rgba(255,255,255,.13);
--accent: #7dd3fc;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at 18% 12%, rgba(125,211,252,.16), transparent 30%),
radial-gradient(circle at 88% 16%, rgba(255,255,255,.08), transparent 26%),
linear-gradient(145deg, #0d0e11, #17191f 55%, #0f1013);
color: var(--fg);
padding: 32px;
}
.wrap {
max-width: 980px;
margin: 0 auto;
display: grid;
gap: 18px;
}
h1 {
margin: 0;
font-size: 18px;
letter-spacing: 0;
font-weight: 720;
}
p {
margin: -8px 0 6px;
color: var(--muted);
font-size: 13px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
gap: 14px;
}
.card {
border: 1px solid var(--border);
border-radius: 14px;
background: color-mix(in srgb, var(--panel) 86%, transparent);
box-shadow: 0 18px 48px rgba(0,0,0,.32);
overflow: hidden;
}
.label {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px 0;
color: var(--muted);
font-size: 11px;
font-weight: 680;
}
.modal {
margin: 10px;
border: 1px solid var(--border);
border-radius: 11px;
background: color-mix(in srgb, var(--panel-2) 82%, transparent);
overflow: hidden;
}
.header {
height: 42px;
display: flex;
align-items: center;
gap: 7px;
padding: 0 10px 0 14px;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, white 4%, transparent);
}
.title {
margin-right: auto;
font-size: 13px;
font-weight: 680;
letter-spacing: 0;
}
.body {
height: 84px;
padding: 12px 14px;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.ctrl {
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: inherit;
cursor: pointer;
position: relative;
flex: 0 0 auto;
}
.ctrl::before,
.ctrl.close::after {
content: "";
display: block;
background: currentColor;
border-radius: 999px;
}
.ctrl.min::before { width: 9px; height: 1.8px; }
.ctrl.close::before,
.ctrl.close::after {
position: absolute;
width: 9px;
height: 1.7px;
left: 50%;
top: 50%;
}
.ctrl.close::before { transform: translate(-50%, -50%) rotate(45deg); }
.ctrl.close::after { transform: translate(-50%, -50%) rotate(-45deg); }
.v1 .ctrl {
background: rgba(255,255,255,.055);
border: 1px solid rgba(255,255,255,.13);
color: rgba(244,244,245,.68);
}
.v1 .ctrl:hover {
background: rgba(255,255,255,.12);
color: var(--fg);
}
.v2 .ctrl {
width: 22px;
height: 22px;
background: rgba(255,255,255,.07);
color: rgba(244,244,245,.48);
}
.v2 .ctrl:hover { color: rgba(244,244,245,.92); }
.v2 .ctrl.close:hover { background: #ff5f57; color: rgba(40,15,14,.72); }
.v2 .ctrl.min:hover { background: #febc2e; color: rgba(67,44,0,.72); }
.v3 .controls {
display: inline-flex;
gap: 2px;
padding: 2px;
border: 1px solid rgba(255,255,255,.10);
border-radius: 999px;
background: rgba(255,255,255,.045);
}
.v3 .ctrl {
width: 22px;
height: 22px;
color: rgba(244,244,245,.62);
}
.v3 .ctrl:hover {
background: rgba(255,255,255,.11);
color: var(--fg);
}
.v4 .ctrl {
width: 23px;
height: 23px;
background: transparent;
color: rgba(244,244,245,.48);
}
.v4 .ctrl:hover {
background: color-mix(in srgb, var(--accent) 16%, transparent);
color: var(--accent);
}
.v5 .ctrl {
width: 22px;
height: 22px;
background: linear-gradient(180deg, rgba(255,255,255,.14), rgba(255,255,255,.055));
box-shadow:
inset 0 0 0 1px rgba(255,255,255,.13),
0 1px 2px rgba(0,0,0,.25);
color: rgba(244,244,245,.62);
}
.v5 .ctrl:hover {
box-shadow:
inset 0 0 0 1px rgba(255,255,255,.22),
0 2px 5px rgba(0,0,0,.24);
color: var(--fg);
transform: translateY(-1px);
}
</style>
</head>
<body>
<main class="wrap">
<h1>Modal control variants</h1>
<p>Pick the one that feels most Odysseus/Apple-sleek, then Ill apply that globally.</p>
<section class="grid">
<div class="card v1">
<div class="label">A · soft bordered circles</div>
<div class="modal">
<div class="header"><div class="title">Theme</div><button class="ctrl min"></button><button class="ctrl close"></button></div>
<div class="body">Closest to current app styling, just cleaner and softer.</div>
</div>
</div>
<div class="card v2">
<div class="label">B · macOS traffic-light hover</div>
<div class="modal">
<div class="header"><div class="title">Theme</div><button class="ctrl min"></button><button class="ctrl close"></button></div>
<div class="body">Neutral until hover, then yellow/red like macOS window controls.</div>
</div>
</div>
<div class="card v3">
<div class="label">C · glass capsule group</div>
<div class="modal">
<div class="header"><div class="title">Theme</div><span class="controls"><button class="ctrl min"></button><button class="ctrl close"></button></span></div>
<div class="body">Buttons read as one small Apple-like control cluster.</div>
</div>
</div>
<div class="card v4">
<div class="label">D · ghost controls</div>
<div class="modal">
<div class="header"><div class="title">Theme</div><button class="ctrl min"></button><button class="ctrl close"></button></div>
<div class="body">Most minimal. Only shows the round hit area on hover.</div>
</div>
</div>
<div class="card v5">
<div class="label">E · subtle liquid glass</div>
<div class="modal">
<div class="header"><div class="title">Theme</div><button class="ctrl min"></button><button class="ctrl close"></button></div>
<div class="body">A little more polished/glassy without becoming colorful.</div>
</div>
</div>
</section>
</main>
</body>
</html>
+1215 -536
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v344';
const CACHE_NAME = 'odysseus-v376-settings-title-icons';
// Core shell precached on install so repeat opens are instant without any
// network wait. Keep this list in sync with the <script type="module"> tags
+226
View File
@@ -0,0 +1,226 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Odysseus Wave Variants</title>
<style>
:root {
color-scheme: dark;
--bg: #282c34;
--panel: #22262e;
--line: rgba(255,255,255,.08);
--text: #d7dce5;
--muted: #8a93a3;
--accent: #e06c75;
--water: #9cdef2;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at 50% 18%, rgba(224,108,117,.10), transparent 34%), var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
display: flex;
align-items: center;
justify-content: center;
padding: 28px;
}
main {
width: min(1040px, 100%);
}
h1 {
margin: 0 0 4px;
font-size: 18px;
font-weight: 650;
letter-spacing: 0;
}
p {
margin: 0 0 22px;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.card {
min-height: 178px;
border: 1px solid var(--line);
border-radius: 10px;
background: color-mix(in srgb, var(--panel) 92%, black);
display: grid;
grid-template-rows: auto 1fr auto;
overflow: hidden;
}
.card header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
font-size: 12px;
color: var(--muted);
}
.tag {
color: var(--accent);
font-weight: 700;
}
.stage {
display: flex;
align-items: center;
justify-content: center;
padding: 18px 10px;
min-height: 108px;
}
.ascii {
margin: 0;
color: color-mix(in srgb, var(--accent) 78%, white);
font: 600 12px/1.05 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
white-space: pre;
text-align: center;
opacity: .86;
transform: translateY(var(--bob, 0));
transition: transform .18s ease;
text-shadow: 0 0 18px rgba(224,108,117,.12);
}
.wave {
color: color-mix(in srgb, var(--water) 62%, var(--accent));
font-weight: 700;
opacity: .72;
}
.ride-wrap {
position: relative;
display: inline-block;
padding-top: 10px;
}
.ride-dot {
position: absolute;
left: 50%;
top: 0;
transform: translate(-50%, var(--ride-y, 0));
color: color-mix(in srgb, var(--water) 62%, var(--accent));
font-size: 1.24em;
line-height: 1;
}
.note {
padding: 9px 12px 11px;
border-top: 1px solid var(--line);
font-size: 11px;
color: var(--muted);
min-height: 42px;
line-height: 1.35;
}
.splash {
margin-top: 18px;
border: 1px solid var(--line);
border-radius: 12px;
background: #282c34;
height: 220px;
display: flex;
align-items: center;
justify-content: center;
}
.splash .ascii {
font-size: 13px;
opacity: .72;
}
</style>
</head>
<body>
<main>
<h1>Odysseus Loader Wave</h1>
<p>Ship-on-wave variants for the app splash. Pick the one that feels most like Odysseus sailing instead of just a generic loading wave.</p>
<section class="grid" aria-label="Wave variants">
<article class="card" data-variant="A">
<header><span><span class="tag">A</span> dot boat</span><span>minimal</span></header>
<div class="stage"><pre class="ascii" data-ride="dot"></pre></div>
<div class="note">Closest to the current loader. The ship is just a small point riding above the wave.</div>
</article>
<article class="card" data-variant="B">
<header><span><span class="tag">B</span> hollow dot</span><span>minimal</span></header>
<div class="stage"><pre class="ascii" data-template="
{wave}"></pre></div>
<div class="note">A little more visible than the dot, but still abstract and calm.</div>
</article>
<article class="card" data-variant="C">
<header><span><span class="tag">C</span> mast dot</span><span>tiny</span></header>
<div class="stage"><pre class="ascii" data-template=" |
{wave}"></pre></div>
<div class="note">Still tiny, but reads a bit more like a sail/ship than a random dot.</div>
</article>
<article class="card" data-variant="D">
<header><span><span class="tag">D</span> tiny sail</span><span>balanced</span></header>
<div class="stage"><pre class="ascii" data-template=" /\
{wave}"></pre></div>
<div class="note">Probably the best compromise: a hint of sail, but only two marks above the wave.</div>
</article>
<article class="card" data-variant="E">
<header><span><span class="tag">E</span> tiny hull</span><span>ascii</span></header>
<div class="stage"><pre class="ascii" data-template=" /\
\_/
{wave}"></pre></div>
<div class="note">The smallest actual boat shape. More readable, less subtle.</div>
</article>
<article class="card" data-variant="F">
<header><span><span class="tag">F</span> no boat</span><span>baseline</span></header>
<div class="stage"><pre class="ascii" data-template="{wave}"></pre></div>
<div class="note">The current direction: pure wave, no Odysseus ship marker.</div>
</article>
</section>
<section class="splash" aria-label="Splash scale preview">
<pre class="ascii" id="hero-preview" data-ride="dot"></pre>
</section>
</main>
<script>
const waves = [
'▁▂▃',
'▂▃▄',
'▃▄▅',
'▄▅▆',
'▅▆▅',
'▆▅▄',
'▅▄▃',
'▄▃▂',
'▃▂▁'
];
const rideFrames = [
{ wave: '▁▂▃', y: 9 },
{ wave: '▂▃▄', y: 7 },
{ wave: '▃▄▅', y: 5 },
{ wave: '▄▅▆', y: 3 },
{ wave: '▅▆▅', y: 1 },
{ wave: '▆▅▄', y: 3 },
{ wave: '▅▄▃', y: 5 },
{ wave: '▄▃▂', y: 7 },
{ wave: '▃▂▁', y: 9 }
];
const render = (el, wave, bob, phase) => {
if (el.dataset.ride === 'dot') {
const ride = rideFrames[phase % rideFrames.length];
el.innerHTML = `<span class="ride-wrap" style="--ride-y:${ride.y}px"><span class="ride-dot"></span><span class="wave">${ride.wave}</span></span>`;
el.style.setProperty('--bob', '0px');
return;
}
const template = el.dataset.template || '{wave}';
el.innerHTML = template
.replace('{wave}', `<span class="wave">${wave}</span>`)
.replaceAll('\n', '<br>');
el.style.setProperty('--bob', `${bob}px`);
};
let i = 0;
setInterval(() => {
const wave = waves[i % waves.length];
const bob = i % 4 === 0 ? -1 : i % 4 === 2 ? 1 : 0;
document.querySelectorAll('.ascii').forEach(el => render(el, wave, bob, i));
i += 1;
}, 170);
document.querySelectorAll('.ascii').forEach(el => render(el, waves[0], 0, 0));
</script>
</body>
</html>
+281
View File
@@ -0,0 +1,281 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Odysseus Whirlpool Variants</title>
<style>
:root {
--bg: #101216;
--panel: #171a20;
--fg: #e8edf2;
--muted: #8c949e;
--accent: #9cdef2;
--border: #303946;
--red: #e78284;
color-scheme: dark;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
letter-spacing: 0;
}
.wrap {
width: min(980px, calc(100vw - 28px));
margin: 0 auto;
padding: 22px 0 34px;
}
header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 14px;
margin-bottom: 18px;
}
h1 {
margin: 0;
font-size: 18px;
font-weight: 650;
}
.hint {
margin: 5px 0 0;
color: var(--muted);
font-size: 12px;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: flex-end;
}
label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--muted);
font-size: 12px;
}
input[type="range"] { width: 96px; accent-color: var(--accent); }
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.card {
border: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
background: color-mix(in srgb, var(--panel) 92%, transparent);
border-radius: 8px;
padding: 14px;
min-height: 172px;
}
.card-head {
display: flex;
justify-content: space-between;
gap: 10px;
margin-bottom: 14px;
font-size: 12px;
}
.name { font-weight: 650; }
.desc { color: var(--muted); }
.stage {
display: grid;
grid-template-columns: 58px 1fr;
align-items: center;
gap: 14px;
min-height: 86px;
}
canvas {
width: var(--size);
height: var(--size);
display: block;
image-rendering: auto;
}
.sample-lines {
display: grid;
gap: 10px;
font-size: 12px;
color: color-mix(in srgb, var(--fg) 80%, transparent);
}
.line {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 24px;
}
.mini {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--muted);
}
.footer {
margin-top: 14px;
color: var(--muted);
font-size: 12px;
}
@media (max-width: 720px) {
.grid { grid-template-columns: 1fr; }
header { align-items: flex-start; flex-direction: column; }
.controls { justify-content: flex-start; }
}
</style>
</head>
<body>
<main class="wrap">
<header>
<div>
<h1>Whirlpool Loop Variants</h1>
<p class="hint">Tune the loading whirlpool before replacing the app spinner.</p>
</div>
<div class="controls">
<label>Size <input id="size" type="range" min="14" max="42" value="24"></label>
<label>Speed <input id="speed" type="range" min="650" max="1700" value="1180"></label>
<label>Turns <input id="turns" type="range" min="18" max="38" value="27"></label>
</div>
</header>
<section class="grid" id="grid"></section>
<div class="footer">Pick a letter. A is current-ish; B/C/D are smoother loop candidates.</div>
</main>
<script>
const variants = [
{ id: 'A', name: 'Current', desc: 'fixed spiral rotation, visible head loop', mode: 'current' },
{ id: 'B', name: 'Soft Tail', desc: 'same spiral, head fades through loop', mode: 'softTail' },
{ id: 'C', name: 'Breathing', desc: 'subtle radius pulse hides reset', mode: 'breathing' },
{ id: 'D', name: 'Continuous Flow', desc: 'moving dash window, no fixed head snap', mode: 'flow' },
];
const grid = document.getElementById('grid');
const sizeInput = document.getElementById('size');
const speedInput = document.getElementById('speed');
const turnsInput = document.getElementById('turns');
const canvases = [];
function card(v) {
const el = document.createElement('article');
el.className = 'card';
el.innerHTML = `
<div class="card-head">
<div><span class="name">${v.id}. ${v.name}</span></div>
<div class="desc">${v.desc}</div>
</div>
<div class="stage">
<canvas width="56" height="56" data-mode="${v.mode}"></canvas>
<div class="sample-lines">
<div class="line"><span class="mini">Loading inbox</span></div>
<div class="line"><span class="mini">Scanning recent INBOX</span></div>
<div class="line"><span class="mini">Unsubscribing 3/8</span></div>
</div>
</div>`;
return el;
}
for (const v of variants) {
const node = card(v);
grid.appendChild(node);
canvases.push(node.querySelector('canvas'));
}
let started = performance.now();
function getCss(name, fallback) {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
}
function spiralPoint(frac, opts) {
const eased = Math.pow(frac, opts.ease);
const r = opts.maxR * eased * opts.pulse;
const angle = frac * opts.turns * Math.PI * 2 + opts.rot;
return { x: opts.cx + Math.cos(angle) * r, y: opts.cy + Math.sin(angle) * r };
}
function draw(canvas, mode, time) {
const cssSize = Number(sizeInput.value);
canvas.style.setProperty('--size', cssSize + 'px');
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
const px = Math.round(cssSize * dpr);
if (canvas.width !== px || canvas.height !== px) {
canvas.width = px;
canvas.height = px;
}
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;
const cx = W / 2;
const cy = H / 2;
const maxR = Math.min(W, H) / 2 - 2 * dpr;
const lw = Math.max(1.35 * dpr, cssSize > 30 ? 2.6 * dpr : 1.8 * dpr);
const loopMs = Number(speedInput.value);
const loop = ((time - started) % loopMs) / loopMs;
const rot = loop * Math.PI * 2;
const turns = Number(turnsInput.value) / 10;
const fg = getCss('--accent', '#9cdef2');
const track = getCss('--border', '#303946');
ctx.clearRect(0, 0, W, H);
ctx.beginPath();
ctx.arc(cx, cy, maxR - lw / 2, 0, Math.PI * 2);
ctx.strokeStyle = track;
ctx.globalAlpha = 0.28;
ctx.lineWidth = lw;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const steps = 110;
const pulse = mode === 'breathing' ? 0.94 + 0.06 * Math.sin(loop * Math.PI * 2) : 1;
const opts = { cx, cy, maxR, turns, rot, pulse, ease: mode === 'flow' ? 0.76 : 0.82 };
for (let i = 1; i <= steps; i++) {
const a = (i - 1) / steps;
const b = i / steps;
let alpha;
let widthMul;
if (mode === 'flow') {
const phase = (b + loop) % 1;
alpha = 0.1 + Math.pow(1 - Math.abs(phase - 0.72) / 0.72, 2.2) * 0.74;
widthMul = 0.58 + alpha * 0.38;
} else if (mode === 'softTail') {
const headFade = 0.62 + 0.38 * Math.sin(loop * Math.PI * 2 - Math.PI / 2) ** 2;
alpha = (0.1 + Math.pow(b, 1.85) * 0.72) * (b > 0.88 ? headFade : 1);
widthMul = 0.52 + b * 0.34;
} else {
alpha = 0.12 + Math.pow(b, 1.8) * 0.72;
widthMul = 0.52 + b * 0.32;
}
const p0 = spiralPoint(a, opts);
const p1 = spiralPoint(b, opts);
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(p1.x, p1.y);
ctx.strokeStyle = fg;
ctx.lineWidth = lw * widthMul;
ctx.globalAlpha = Math.max(0.04, Math.min(0.9, alpha));
ctx.stroke();
}
if (mode !== 'flow') {
const head = spiralPoint(1, opts);
const headAlpha = mode === 'softTail'
? 0.55 + 0.35 * Math.sin(loop * Math.PI * 2 - Math.PI / 2) ** 2
: 0.9;
ctx.beginPath();
ctx.arc(head.x, head.y, Math.max(1.2 * dpr, lw * 0.48), 0, Math.PI * 2);
ctx.fillStyle = fg;
ctx.globalAlpha = headAlpha;
ctx.fill();
}
ctx.globalAlpha = 1;
}
function tick(now) {
for (const canvas of canvases) draw(canvas, canvas.dataset.mode, now);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
</script>
</body>
</html>
@@ -0,0 +1,30 @@
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "odysseus-mlx-image-bridge",
platforms: [.macOS(.v26)],
products: [
.executable(name: "odysseus-mlx-inpaint", targets: ["OdysseusMLXInpaint"]),
.executable(name: "odysseus-mlx-colorize", targets: ["OdysseusMLXColorize"]),
],
dependencies: [
.package(url: "https://github.com/xocialize/mlx-lama-swift", branch: "main"),
.package(url: "https://github.com/xocialize/mlx-ddcolor-swift", branch: "main"),
],
targets: [
.executableTarget(
name: "OdysseusMLXInpaint",
dependencies: [
.product(name: "LaMa", package: "mlx-lama-swift"),
.product(name: "MIGAN", package: "mlx-lama-swift"),
]
),
.executableTarget(
name: "OdysseusMLXColorize",
dependencies: [
.product(name: "DDColor", package: "mlx-ddcolor-swift"),
]
),
]
)
@@ -0,0 +1,80 @@
import Foundation
import CoreGraphics
import ImageIO
import UniformTypeIdentifiers
import MLX
import DDColor
struct Args {
var model = ""
var image = ""
var output = ""
var tier = ""
}
func value(after flag: String, in args: [String]) -> String? {
guard let i = args.firstIndex(of: flag), i + 1 < args.count else { return nil }
return args[i + 1]
}
func parseArgs() throws -> Args {
let argv = Array(CommandLine.arguments.dropFirst())
var out = Args()
out.model = value(after: "--model", in: argv) ?? ""
out.image = value(after: "--image", in: argv) ?? ""
out.output = value(after: "--output", in: argv) ?? ""
out.tier = value(after: "--tier", in: argv) ?? ""
guard !out.model.isEmpty, !out.image.isEmpty, !out.output.isEmpty else {
throw BridgeError.usage("usage: odysseus-mlx-colorize --model weights.safetensors --image input.png --output output.png [--tier tiny|large]")
}
return out
}
func decodeCGImage(_ path: String) throws -> CGImage {
let url = URL(fileURLWithPath: path)
guard let src = CGImageSourceCreateWithURL(url as CFURL, nil),
let cg = CGImageSourceCreateImageAtIndex(src, 0, nil) else {
throw BridgeError.decode(path)
}
return cg
}
func encodePNG(_ image: CGImage, _ path: String) throws {
let url = URL(fileURLWithPath: path)
guard let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil) else {
throw BridgeError.encode(path)
}
CGImageDestinationAddImage(dest, image, nil)
guard CGImageDestinationFinalize(dest) else { throw BridgeError.encode(path) }
}
enum BridgeError: Error, CustomStringConvertible {
case usage(String)
case decode(String)
case encode(String)
var description: String {
switch self {
case .usage(let s): return s
case .decode(let p): return "failed to decode image: \(p)"
case .encode(let p): return "failed to write PNG: \(p)"
}
}
}
do {
let args = try parseArgs()
let image = try decodeCGImage(args.image)
let text = (args.tier + " " + args.model).lowercased()
let tier: DDColorTier = text.contains("tiny") ? .tiny : .large
let colorizer = try DDColorColorizer.fromPretrained(
args.model,
config: DDColorConfig(tier: tier),
dtype: .float16
)
let output = colorizer(image)
try encodePNG(output, args.output)
} catch {
fputs("\(error)\n", stderr)
exit(1)
}
@@ -0,0 +1,88 @@
import Foundation
import CoreGraphics
import ImageIO
import UniformTypeIdentifiers
import MLX
import LaMa
import MIGAN
struct Args {
var model = ""
var image = ""
var mask = ""
var output = ""
var mode = ""
}
func value(after flag: String, in args: [String]) -> String? {
guard let i = args.firstIndex(of: flag), i + 1 < args.count else { return nil }
return args[i + 1]
}
func parseArgs() throws -> Args {
let argv = Array(CommandLine.arguments.dropFirst())
var out = Args()
out.model = value(after: "--model", in: argv) ?? ""
out.image = value(after: "--image", in: argv) ?? ""
out.mask = value(after: "--mask", in: argv) ?? ""
out.output = value(after: "--output", in: argv) ?? ""
out.mode = value(after: "--mode", in: argv) ?? ""
guard !out.model.isEmpty, !out.image.isEmpty, !out.mask.isEmpty, !out.output.isEmpty else {
throw BridgeError.usage("usage: odysseus-mlx-inpaint --model weights.safetensors --image input.png --mask mask.png --output output.png [--mode best|fast]")
}
return out
}
func decodeCGImage(_ path: String) throws -> CGImage {
let url = URL(fileURLWithPath: path)
guard let src = CGImageSourceCreateWithURL(url as CFURL, nil),
let cg = CGImageSourceCreateImageAtIndex(src, 0, nil) else {
throw BridgeError.decode(path)
}
return cg
}
func encodePNG(_ image: CGImage, _ path: String) throws {
let url = URL(fileURLWithPath: path)
guard let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil) else {
throw BridgeError.encode(path)
}
CGImageDestinationAddImage(dest, image, nil)
guard CGImageDestinationFinalize(dest) else { throw BridgeError.encode(path) }
}
enum BridgeError: Error, CustomStringConvertible {
case usage(String)
case decode(String)
case encode(String)
var description: String {
switch self {
case .usage(let s): return s
case .decode(let p): return "failed to decode image: \(p)"
case .encode(let p): return "failed to write PNG: \(p)"
}
}
}
do {
let args = try parseArgs()
let source = try decodeCGImage(args.image)
let mask = try decodeCGImage(args.mask)
let lower = args.model.lowercased()
let mode = args.mode.lowercased()
let output: CGImage
if lower.contains("mi-gan") || lower.contains("migan") || mode == "fast" {
let resolution = lower.contains("512") ? 512 : 256
let inpainter = try MIGANInpainter.fromPretrained(args.model, resolution: resolution, dtype: .float16)
output = inpainter(source, mask: mask)
} else {
let inpainter = try LaMaInpainter.fromPretrained(args.model, dtype: .bfloat16)
output = inpainter(source, mask: mask)
}
try encodePNG(output, args.output)
} catch {
fputs("\(error)\n", stderr)
exit(1)
}
+14
View File
@@ -56,6 +56,20 @@ def test_explicit_web_search_promotes_to_agent():
assert classify_tool_intent("use web search and find a recipe").category == "web"
def test_workspace_agent_requests_promote_to_shell_workspace():
prompts = [
"fix the bug in this repo",
"run the tests for this project",
"debug the server logs",
"run terminal-bench on this task",
"inspect the traceback and patch the code",
]
for prompt in prompts:
intent = classify_tool_intent(prompt)
assert intent.needs_tools
assert intent.category == "workspace"
def test_explanatory_calendar_questions_stay_plain_chat():
assert not message_needs_tools("How do I add an entry to my calendar?")
assert not message_needs_tools("What about the built-in Odysseus calendar, is that linked to email?")
+60
View File
@@ -36,6 +36,66 @@ def test_npx_package_from_args_prefers_package_after_y_flag(monkeypatch):
) == "@playwright/mcp@latest"
def test_browser_mcp_cache_requirement_is_opt_in(monkeypatch):
monkeypatch.delenv("ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE", raising=False)
builtin_mcp = _load_builtin_mcp(monkeypatch)
assert builtin_mcp.BROWSER_MCP_REQUIRE_CACHE is False
def test_browser_mcp_cache_requirement_can_be_enabled(monkeypatch):
monkeypatch.setenv("ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE", "1")
builtin_mcp = _load_builtin_mcp(monkeypatch)
assert builtin_mcp.BROWSER_MCP_REQUIRE_CACHE is True
def test_browser_mcp_args_use_configured_browser_executable(monkeypatch):
monkeypatch.setenv("ODYSSEUS_BROWSER_EXECUTABLE", "/usr/bin/chromium")
builtin_mcp = _load_builtin_mcp(monkeypatch)
args = builtin_mcp._browser_mcp_args(["-y", "@playwright/mcp@latest", "--headless"])
assert "--executable-path" in args
assert "/usr/bin/chromium" in args
assert "--isolated" in args
assert "--no-sandbox" in args
def test_browser_mcp_args_can_use_persistent_profile_when_requested(monkeypatch):
monkeypatch.setenv("ODYSSEUS_BROWSER_EXECUTABLE", "/usr/bin/chromium")
monkeypatch.setenv("ODYSSEUS_BROWSER_ISOLATED", "0")
builtin_mcp = _load_builtin_mcp(monkeypatch)
args = builtin_mcp._browser_mcp_args(["-y", "@playwright/mcp@latest", "--headless"])
assert "--executable-path" in args
assert "--isolated" not in args
def test_browser_mcp_args_respect_explicit_user_data_dir(monkeypatch):
monkeypatch.setenv("ODYSSEUS_BROWSER_EXECUTABLE", "/usr/bin/chromium")
builtin_mcp = _load_builtin_mcp(monkeypatch)
args = builtin_mcp._browser_mcp_args([
"-y", "@playwright/mcp@latest", "--headless", "--user-data-dir", "/tmp/profile",
])
assert "--user-data-dir" in args
assert "--isolated" not in args
def test_browser_mcp_args_can_keep_sandbox(monkeypatch):
monkeypatch.setenv("ODYSSEUS_BROWSER_EXECUTABLE", "/usr/bin/chromium")
monkeypatch.setenv("ODYSSEUS_BROWSER_NO_SANDBOX", "0")
builtin_mcp = _load_builtin_mcp(monkeypatch)
args = builtin_mcp._browser_mcp_args(["-y", "@playwright/mcp@latest", "--headless"])
assert "--executable-path" in args
assert "--no-sandbox" not in args
def test_npx_cache_check_detects_scoped_package_in_npx_cache(monkeypatch, tmp_path):
builtin_mcp = _load_builtin_mcp(monkeypatch)
package_json = (
+44 -1
View File
@@ -80,7 +80,7 @@ async def test_consolidate_memory_empty_owner_treats_each_owner_separately(monke
message, ok = await action_consolidate_memory("")
assert ok is True
assert "removed 1" in message
assert "removed 1" in message.lower()
assert len(prompts) == 2
saved = {m["id"]: m for m in _read_memories(data_dir)}
assert set(saved) == {"alice-long", "alice-short", "bob-keep"}
@@ -114,3 +114,46 @@ async def test_consolidate_memory_specific_owner_does_not_absorb_ownerless_rows(
assert set(saved) == {"alice-1", "legacy", "bob-1"}
assert "owner" not in saved["legacy"]
assert saved["bob-1"]["owner"] == "bob"
@pytest.mark.asyncio
async def test_consolidate_memory_removes_near_duplicates_before_ai(monkeypatch, tmp_path):
from src import constants
from src import llm_core
from src import task_endpoint
action_consolidate_memory = _import_consolidate_action()
data_dir = _write_memories(
tmp_path,
[
{"id": "a", "owner": "alice", "text": "User prefers bullet points when explaining.", "category": "preference"},
{"id": "b", "owner": "alice", "text": "User prefers bulletpoints when explaining", "category": "preference", "pinned": True},
{"id": "c", "owner": "alice", "text": "User likes local models.", "category": "preference"},
],
)
monkeypatch.setattr(constants, "DATA_DIR", str(data_dir))
monkeypatch.setattr(
task_endpoint,
"resolve_task_candidates",
lambda *args, **kwargs: [("http://llm", "model", {})],
)
async def fake_llm_call_async(_candidates, **kwargs):
items = json.loads(kwargs["messages"][0]["content"].split("MEMORIES:\n", 1)[1])
return json.dumps({
"keep": [
{"id": item["id"], "text": item["text"], "category": item["category"]}
for item in items
],
"drop": [],
})
monkeypatch.setattr(llm_core, "llm_call_async_with_fallback", fake_llm_call_async)
message, ok = await action_consolidate_memory("alice")
assert ok is True
assert "removed 1" in message.lower()
saved = {m["id"]: m for m in _read_memories(data_dir)}
assert set(saved) == {"b", "c"}
assert saved["b"]["pinned"] is True
+20 -4
View File
@@ -342,10 +342,10 @@ def test_clean_thinking_for_save_extracts_thought_tag():
assert metadata["thinking"] == "internal reasoning"
def test_save_assistant_response_preserves_actual_and_requested_model():
def test_save_assistant_response_incognito_does_not_mutate_session_history():
sess = _FakeSession("selected-model")
save_assistant_response(
saved_id = save_assistant_response(
sess,
session_manager=None,
session_id="s1",
@@ -354,8 +354,24 @@ def test_save_assistant_response_preserves_actual_and_requested_model():
incognito=True,
)
assert sess.history[-1].metadata["requested_model"] == "selected-model"
assert sess.history[-1].metadata["model"] == "actual-model"
assert saved_id is None
assert sess.history == []
def test_add_user_message_incognito_does_not_mutate_session_history():
sess = _FakeSession("selected-model")
chat_handler = SimpleNamespace(update_session_name_if_needed=lambda *_args, **_kwargs: None)
preprocessed = PreprocessedMessage(
enhanced_message="secret",
user_content="secret",
text_for_context="secret",
youtube_transcripts=[],
attachment_meta=[],
)
chat_helpers.add_user_message(sess, chat_handler, preprocessed, incognito=True)
assert sess.history == []
class _SpinMsg:
+9
View File
@@ -60,6 +60,15 @@ def test_image_model_prefix_routes_to_image_generation_without_endpoint_lookup(m
assert chat_routes._is_image_generation_session(_session(model="dall-e-3"))
def test_namespaced_gpt_image_model_routes_to_image_generation_without_endpoint_lookup(monkeypatch):
def fail_if_called():
raise AssertionError("provider-prefixed image models should not need a DB lookup")
monkeypatch.setattr(chat_routes, "SessionLocal", fail_if_called)
assert chat_routes._is_image_generation_session(_session(model="openai/gpt-5-image"))
def test_image_endpoint_does_not_catch_text_model_on_different_path(monkeypatch):
db = _FakeDb([
_endpoint("http://localhost:11434/v1/images", models=["sdxl-local"]),

Some files were not shown because too many files have changed in this diff Show More